rojo/plugin/src/App/Components/PatchVisualizer/DomLabel.lua
Joe 201b971101 Refactor animation goal settings to use instant transitions
- Replaced `Flipper.Spring` with `Flipper.Instant` for setting animation goals across multiple components, including `Page`, `Checkbox`, `Dropdown`, `IconButton`, `TextButton`, `TextInput`, `TouchRipple`, and `Notification`.
- This change simplifies the animation logic, providing immediate transitions without spring dynamics, enhancing performance and responsiveness.

These updates improve the user experience by ensuring quicker visual feedback during state changes.
2026-02-18 02:20:23 -06:00

767 lines
23 KiB
Lua

local SelectionService = game:GetService("Selection")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local bindingUtil = require(Plugin.App.bindingUtil)
local e = Roact.createElement
local ChangeList = require(script.Parent.ChangeList)
local ClassIcon = require(Plugin.App.Components.ClassIcon)
local ChangeTag = Roact.Component:extend("ChangeTag")
function ChangeTag:init()
self:setState({
isHovered = false,
})
end
function ChangeTag:render()
local props = self.props
local isHovered = self.state.isHovered
return Theme.with(function(theme)
local tagColor = props.color or theme.SubTextColor
return e("Frame", {
Size = UDim2.new(0, 0, 0, 16),
AutomaticSize = Enum.AutomaticSize.X,
BackgroundColor3 = tagColor,
BackgroundTransparency = props.transparency:map(function(t)
return 0.85 + (0.15 * t)
end),
LayoutOrder = props.layoutOrder or 1,
Active = true,
[Roact.Event.MouseEnter] = function()
self:setState({ isHovered = true })
end,
[Roact.Event.MouseLeave] = function()
self:setState({ isHovered = false })
end,
}, {
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 3),
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}),
Label = e("TextLabel", {
Text = props.text,
BackgroundTransparency = 1,
FontFace = theme.Font.Thin,
TextSize = theme.TextSize.Small,
TextColor3 = tagColor,
TextXAlignment = Enum.TextXAlignment.Center,
TextTransparency = props.transparency,
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
}),
Tooltip = if props.tooltipText and isHovered
then e("TextLabel", {
Text = props.tooltipText,
BackgroundColor3 = theme.BorderedContainer.BackgroundColor,
BackgroundTransparency = 0.05,
TextColor3 = theme.TextColor,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Small,
Size = UDim2.new(0, 0, 0, 20),
AutomaticSize = Enum.AutomaticSize.X,
Position = UDim2.new(0.5, 0, 0, -22),
AnchorPoint = Vector2.new(0.5, 0),
ZIndex = 100,
}, {
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 3),
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 6),
PaddingRight = UDim.new(0, 6),
}),
})
else nil,
})
end)
end
-- Proportional diff bar showing the ratio of removals/additions/property changes
local function DiffBar(props)
local removed = props.removed or 0
local added = props.added or 0
local propChanges = props.propChanges or 0
local total = removed + added + propChanges
if total == 0 then
return nil
end
return Theme.with(function(theme)
local children = {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
}),
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 2),
}),
}
if propChanges > 0 then
children.SegProp = e("Frame", {
Size = UDim2.fromScale(propChanges / total, 1),
BackgroundColor3 = theme.Diff.Property,
BackgroundTransparency = props.transparency,
BorderSizePixel = 0,
LayoutOrder = 1,
})
end
if added > 0 then
local color = if props.isWhitespaceOnly then theme.Diff.WhitespaceOnly else theme.Diff.Add
children.SegAdd = e("Frame", {
Size = UDim2.fromScale(added / total, 1),
BackgroundColor3 = color,
BackgroundTransparency = props.transparency,
BorderSizePixel = 0,
LayoutOrder = 2,
})
end
if removed > 0 then
local color = if props.isWhitespaceOnly then theme.Diff.WhitespaceOnly else theme.Diff.Remove
children.SegRemove = e("Frame", {
Size = UDim2.fromScale(removed / total, 1),
BackgroundColor3 = color,
BackgroundTransparency = props.transparency,
BorderSizePixel = 0,
LayoutOrder = 3,
})
end
return e("Frame", {
Size = UDim2.new(0, 40, 0, 8),
BackgroundTransparency = 1,
ClipsDescendants = true,
LayoutOrder = props.layoutOrder or 10,
}, children)
end)
end
-- Radio button option for selection (as a component to track hover state)
local SelectionOption = Roact.Component:extend("SelectionOption")
function SelectionOption:init()
self:setState({
isHovered = false,
})
end
function SelectionOption:render()
local props = self.props
local isHovered = self.state.isHovered
return Theme.with(function(theme)
local isSelected = props.isSelected
local isParentOnly = props.isParentOnly
local hasChildren = props.hasChildren
local bgColor = if isSelected
then (if props.optionType == "push"
then Color3.fromHex("335FFF")
elseif props.optionType == "pull" then Color3.fromHex("E74C3C")
else Color3.fromHex("7F8C8D"))
else theme.BorderedContainer.BackgroundColor
local textColor = if isSelected then Color3.new(1, 1, 1) else theme.TextColor
local displayText = props.text
-- Use wider button for "Studio" text (6 chars vs 4 for others)
local buttonWidth = if props.text == "Studio" then 44 else 36
-- Transparency logic with hover effect
local bgTransparency, txtTransparency
if isParentOnly then
-- Parent-only: partially transparent, less so when hovering the button
bgTransparency = props.transparency:map(function(t)
return if isHovered then 0.2 + (0.8 * t) else 0.5 + (0.5 * t)
end)
txtTransparency = props.transparency:map(function(t)
return if isHovered then 0 + t else 0.3 + (0.7 * t)
end)
else
-- Normal buttons: show hover effect by reducing transparency
bgTransparency = props.transparency:map(function(t)
if isSelected then
return if isHovered then 0 + (1 * t) else 0.1 + (0.9 * t)
else
return if isHovered then 0.5 + (0.5 * t) else 0.7 + (0.3 * t)
end
end)
txtTransparency = props.transparency
end
return e("TextButton", {
Size = UDim2.new(0, buttonWidth, 0, 18),
BackgroundColor3 = bgColor,
BackgroundTransparency = bgTransparency,
BorderSizePixel = 0,
Text = displayText,
FontFace = if isSelected then theme.Font.Bold else theme.Font.Main,
TextSize = theme.TextSize.Small,
TextColor3 = textColor,
TextTransparency = txtTransparency,
LayoutOrder = props.layoutOrder,
ZIndex = 10,
[Roact.Event.MouseEnter] = function()
self:setState({ isHovered = true })
-- Trigger subtree highlight when this button would apply to children
if (isParentOnly or (isSelected and hasChildren)) and props.onSubtreeHoverStart then
props.onSubtreeHoverStart()
end
end,
[Roact.Event.MouseLeave] = function()
self:setState({ isHovered = false })
if props.onSubtreeHoverEnd then
props.onSubtreeHoverEnd()
end
end,
[Roact.Event.Activated] = function()
if isParentOnly or (isSelected and hasChildren) then
-- Parent-only or already selected with children: click applies to subtree
if props.onSubtreeClick then
props.onSubtreeClick()
end
else
-- Normal click: select this node only
if props.onClick then
props.onClick()
end
end
end,
}, {
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 3),
}),
})
end)
end
-- Selection radio group component
local function SelectionRadio(props)
if not props.visible then
return nil
end
local hasChildren = props.hasChildren
local isParentOnly = props.isParentOnly
return e("Frame", {
Size = UDim2.new(0, 0, 0, 18),
AutomaticSize = Enum.AutomaticSize.X,
BackgroundTransparency = 1,
Position = UDim2.new(1, -5, 0, 3),
AnchorPoint = Vector2.new(1, 0),
ZIndex = 10,
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 4),
}),
Studio = e(SelectionOption, {
text = "Studio",
optionType = "pull",
isSelected = props.selection == "pull",
transparency = props.transparency,
layoutOrder = 1,
hasChildren = hasChildren,
isParentOnly = isParentOnly,
onSubtreeHoverStart = props.onSubtreeHoverStart,
onSubtreeHoverEnd = props.onSubtreeHoverEnd,
onClick = function()
if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "pull")
end
end,
onSubtreeClick = function()
if props.onSubtreeSelectionChange then
props.onSubtreeSelectionChange(props.nodeId, "pull")
end
end,
}),
Skip = e(SelectionOption, {
text = "Skip",
optionType = "ignore",
isSelected = props.selection == "ignore",
transparency = props.transparency,
layoutOrder = 2,
hasChildren = hasChildren,
isParentOnly = isParentOnly,
onSubtreeHoverStart = props.onSubtreeHoverStart,
onSubtreeHoverEnd = props.onSubtreeHoverEnd,
onClick = function()
if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "ignore")
end
end,
onSubtreeClick = function()
if props.onSubtreeSelectionChange then
props.onSubtreeSelectionChange(props.nodeId, "ignore")
end
end,
}),
Atlas = e(SelectionOption, {
text = "Atlas",
optionType = "push",
isSelected = props.selection == "push",
transparency = props.transparency,
layoutOrder = 3,
hasChildren = hasChildren,
isParentOnly = isParentOnly,
onSubtreeHoverStart = props.onSubtreeHoverStart,
onSubtreeHoverEnd = props.onSubtreeHoverEnd,
onClick = function()
if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "push")
end
end,
onSubtreeClick = function()
if props.onSubtreeSelectionChange then
props.onSubtreeSelectionChange(props.nodeId, "push")
end
end,
}),
})
end
local Expansion = Roact.Component:extend("Expansion")
function Expansion:render()
local props = self.props
if not props.rendered then
return nil
end
return e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(1, -props.indent, 1, -24),
Position = UDim2.new(0, props.indent, 0, 24),
}, {
ChangeList = e(ChangeList, {
changes = props.changeList,
transparency = props.transparency,
showStringDiff = if props.showStringDiff
then function(current: string, incoming: string)
props.showStringDiff(current, incoming, props.instancePath)
end
else nil,
showTableDiff = props.showTableDiff,
}),
})
end
local DomLabel = Roact.Component:extend("DomLabel")
function DomLabel:init()
local initHeight = self.props.elementHeight:getValue()
self.expanded = initHeight > 24
self.isMounted = true
self.motor = Flipper.SingleMotor.new(initHeight)
self.binding = bindingUtil.fromMotor(self.motor)
self:setState({
renderExpansion = self.expanded,
isHovered = false,
})
self.motorStepConnection = self.motor:onStep(function(value)
if not self.isMounted then
return
end
local renderExpansion = value > 24
self.props.setElementHeight(value)
if self.props.updateEvent then
self.props.updateEvent:Fire()
end
self:setState(function(state)
if state.renderExpansion == renderExpansion then
return nil
end
return {
renderExpansion = renderExpansion,
}
end)
end)
end
function DomLabel:willUnmount()
self.isMounted = false
-- Stop the motor to prevent onStep callbacks after unmount
self.motor:stop()
if self.motorStepConnection then
self.motorStepConnection:disconnect()
self.motorStepConnection = nil
end
end
function DomLabel:didUpdate(prevProps)
-- When parent re-renders it creates new bindings starting at 24, but our motor
-- may be at expanded height. Sync immediately to prevent VirtualScroller from
-- positioning elements incorrectly.
if prevProps.setElementHeight ~= self.props.setElementHeight then
local currentHeight = self.binding:getValue()
self.props.setElementHeight(currentHeight)
end
if
prevProps.instance ~= self.props.instance
or prevProps.patchType ~= self.props.patchType
or prevProps.name ~= self.props.name
or prevProps.changeList ~= self.props.changeList
then
-- Close the expansion when the domlabel is changed to a different thing
self.expanded = false
self.motor:setGoal(Flipper.Instant.new(24))
end
end
function DomLabel:render()
local props = self.props
local depth = props.depth or 1
-- Derive subtree highlight state from binding (no re-renders needed)
local ancestorIds = props.ancestorIds
local isSubtreeHighlighted = if props.subtreeHighlightNodeId
then props.subtreeHighlightNodeId:map(function(highlightId)
if highlightId == nil then
return false
end
return ancestorIds[highlightId] == true
end)
else nil
return Theme.with(function(theme)
local color = if props.isWarning
then theme.Diff.Warning
elseif props.patchType then theme.Diff.Background[props.patchType]
else theme.TextColor
local indent = (depth - 1) * 12 + 15
-- Line guides help indent depth remain readable
local lineGuides = {}
for i = 2, depth do
if props.depthsComplete[i] then
continue
end
if props.isFinalChild and i == depth then
-- This line stops halfway down to merge with our connector for the right angle
lineGuides["Line_" .. i] = e("Frame", {
Size = UDim2.new(0, 2, 0, 15),
Position = UDim2.new(0, (12 * (i - 1)) + 6, 0, -1),
BorderSizePixel = 0,
BackgroundTransparency = props.transparency,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
})
else
-- All other lines go all the way
-- with the exception of the final element, which stops halfway down
lineGuides["Line_" .. i] = e("Frame", {
Size = UDim2.new(0, 2, 1, if props.isFinalElement then -9 else 2),
Position = UDim2.new(0, (12 * (i - 1)) + 6, 0, -1),
BorderSizePixel = 0,
BackgroundTransparency = props.transparency,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
})
end
end
if depth > 1 then
lineGuides["Connector"] = e("Frame", {
Size = UDim2.new(0, 8, 0, 2),
Position = UDim2.new(0, 2 + (12 * props.depth), 0, 12),
AnchorPoint = Vector2.xAxis,
BorderSizePixel = 0,
BackgroundTransparency = props.transparency,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
})
end
return e("Frame", {
ClipsDescendants = true,
BackgroundTransparency = if props.elementIndex % 2 == 0 then 0.985 else 1,
BackgroundColor3 = theme.Diff.Row,
Size = self.binding:map(function(expand)
return UDim2.new(1, 0, 0, expand)
end),
}, {
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 10),
PaddingRight = UDim.new(0, 10),
}),
Button = e("Frame", {
Active = true, -- Required for InputBegan to work on Frames
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, 0),
[Roact.Event.MouseEnter] = function()
self:setState({ isHovered = true })
end,
[Roact.Event.MouseLeave] = function()
self:setState({ isHovered = false })
end,
[Roact.Event.InputBegan] = function(_rbx: Instance, input: InputObject)
if input.UserInputType ~= Enum.UserInputType.MouseButton1 then
return
end
-- Check for double click
local now = os.clock()
local lastClickTime = self.lastClickTime or 0
self.lastClickTime = now
if now - lastClickTime < 0.3 then
-- Double click opens the instance in explorer
self.lastDoubleClickTime = now
if props.instance then
SelectionService:Set({ props.instance })
end
else
-- Removed script: show diff of old source vs empty
if
props.patchType == "Remove"
and props.instance
and props.changeInfo
and props.changeInfo.linesRemoved
and props.showStringDiff
then
local ok, source = pcall(function()
return (props.instance :: any).Source
end)
if ok and type(source) == "string" then
props.showStringDiff(source, "", props.instancePath)
return
end
end
-- Added script: find Source in changeList and show diff of empty vs new source
if
props.patchType == "Add"
and props.changeList
and props.changeInfo
and props.changeInfo.linesAdded
and props.showStringDiff
then
for _, entry in props.changeList do
if entry[1] == "Source" and type(entry[3]) == "string" then
props.showStringDiff("", tostring(entry[3]), props.instancePath)
return
end
end
end
if props.changeList then
local cl = props.changeList
if #cl == 2 and cl[2][1] == "Source" and props.showStringDiff then
props.showStringDiff(tostring(cl[2][2]), tostring(cl[2][3]), props.instancePath)
else
self.expanded = not self.expanded
local goalHeight = 24
+ (if self.expanded then math.clamp(#props.changeList * 24, 24, 24 * 6) else 0)
self.motor:setGoal(Flipper.Instant.new(goalHeight))
end
end
end
end,
}),
Expansion = if props.changeList
then e(Expansion, {
rendered = self.state.renderExpansion,
indent = indent,
transparency = props.transparency,
changeList = props.changeList,
showStringDiff = props.showStringDiff,
showTableDiff = props.showTableDiff,
})
else nil,
DiffIcon = if props.patchType
then e("ImageLabel", {
Image = Assets.Images.Diff[props.patchType],
ImageColor3 = color,
ImageTransparency = props.transparency,
BackgroundTransparency = 1,
Size = UDim2.new(0, 14, 0, 14),
Position = UDim2.new(0, 0, 0, 12),
AnchorPoint = Vector2.new(0, 0.5),
})
else nil,
ClassIcon = e(ClassIcon, {
className = props.className,
color = color,
transparency = props.transparency,
size = UDim2.new(0, 16, 0, 16),
position = UDim2.new(0, indent + 2, 0, 12),
anchorPoint = Vector2.new(0, 0.5),
}),
InstanceName = e("TextLabel", {
Text = (if props.isWarning then "⚠ " else "") .. props.name,
RichText = true,
BackgroundTransparency = 1,
FontFace = if props.patchType then theme.Font.Bold else theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = color,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(1, -indent - 50, 0, 24),
Position = UDim2.new(0, indent + 22, 0, 0),
}),
ChangeInfo = e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(1, -indent - (if props.patchType and props.onSelectionChange then 210 else 80), 0, 24),
Position = UDim2.new(1, if props.patchType and props.onSelectionChange then -130 else -2, 0, 0),
AnchorPoint = Vector2.new(1, 0),
ClipsDescendants = true,
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 4),
}),
-- Git-style line change tags: -M (red) then +N (green)
-- Always show both when any line change data exists (even -0 or +0).
-- Grayed when whitespace-only. Always exact counts.
RemovedTag = if props.changeInfo
and (props.changeInfo.linesRemoved ~= nil or props.changeInfo.linesAdded ~= nil)
then e(ChangeTag, {
text = "-" .. (props.changeInfo.linesRemoved or 0),
color = if props.changeInfo.isWhitespaceOnly
or (props.changeInfo.linesRemoved or 0) == 0
then theme.Diff.WhitespaceOnly
else theme.Diff.Remove,
transparency = props.transparency,
layoutOrder = 3,
tooltipText = (props.changeInfo.linesRemoved or 0)
.. (if (props.changeInfo.linesRemoved or 0) == 1 then " line removed" else " lines removed")
.. (if props.changeInfo.isWhitespaceOnly then " (whitespace only)" else ""),
})
else nil,
AddedTag = if props.changeInfo
and (props.changeInfo.linesRemoved ~= nil or props.changeInfo.linesAdded ~= nil)
then e(ChangeTag, {
text = "+" .. (props.changeInfo.linesAdded or 0),
color = if props.changeInfo.isWhitespaceOnly
or (props.changeInfo.linesAdded or 0) == 0
then theme.Diff.WhitespaceOnly
else theme.Diff.Add,
transparency = props.transparency,
layoutOrder = 2,
tooltipText = (props.changeInfo.linesAdded or 0)
.. (if (props.changeInfo.linesAdded or 0) == 1 then " line added" else " lines added")
.. (if props.changeInfo.isWhitespaceOnly then " (whitespace only)" else ""),
})
else nil,
-- Property changes: compact "NP" format in yellow
PropsTag = if props.changeInfo and props.changeInfo.propChanges
then e(ChangeTag, {
text = props.changeInfo.propChanges .. "P",
color = theme.Diff.Property,
transparency = props.transparency,
layoutOrder = 1,
tooltipText = props.changeInfo.propChanges .. (if props.changeInfo.propChanges == 1
then " property change"
else " property changes") .. " (excluding Source)",
})
else nil,
-- Proportional diff bar
Bar = if props.changeInfo
and (
(props.changeInfo.linesRemoved and props.changeInfo.linesRemoved > 0)
or (props.changeInfo.linesAdded and props.changeInfo.linesAdded > 0)
or (props.changeInfo.propChanges and props.changeInfo.propChanges > 0)
)
then e(DiffBar, {
removed = props.changeInfo.linesRemoved or 0,
added = props.changeInfo.linesAdded or 0,
propChanges = props.changeInfo.propChanges or 0,
isWhitespaceOnly = props.changeInfo.isWhitespaceOnly,
transparency = props.transparency,
layoutOrder = 4,
})
else nil,
-- Failed changes
Failed = if props.changeInfo and props.changeInfo.failed
then e(ChangeTag, {
text = "Failed " .. props.changeInfo.failed,
color = theme.Diff.Warning,
transparency = props.transparency,
layoutOrder = 5,
tooltipText = props.changeInfo.failed .. (if props.changeInfo.failed == 1
then " change failed to apply"
else " changes failed to apply"),
})
else nil,
}),
SubtreeHighlight = if isSubtreeHighlighted
then e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundColor3 = theme.Diff.SubtreeHighlight,
BackgroundTransparency = isSubtreeHighlighted:map(function(highlighted)
return if highlighted then 0.92 else 1
end),
BorderSizePixel = 0,
ZIndex = 0,
})
else nil,
SelectionRadio = e(SelectionRadio, {
-- Visible for nodes with patchType, or for parent-only nodes on hover
visible = (props.patchType ~= nil and props.onSelectionChange ~= nil)
or (
props.patchType == nil
and props.hasChildren
and self.state.isHovered
and props.onSubtreeSelectionChange ~= nil
),
nodeId = props.nodeId,
selection = props.selection,
transparency = props.transparency,
hasChildren = props.hasChildren,
-- For parent-only nodes (no patchType), only subtree selection makes sense
isParentOnly = props.patchType == nil,
onSelectionChange = props.onSelectionChange,
onSubtreeSelectionChange = props.onSubtreeSelectionChange,
onSubtreeHoverStart = function()
if props.setSubtreeHighlightNodeId then
props.setSubtreeHighlightNodeId(props.nodeId)
end
end,
onSubtreeHoverEnd = function()
if props.setSubtreeHighlightNodeId then
props.setSubtreeHighlightNodeId(nil)
end
end,
}),
LineGuides = e("Folder", nil, lineGuides),
})
end)
end
return DomLabel