mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Ring light
This commit is contained in:
@@ -76,6 +76,14 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress the default selection ring for grouped selections
|
||||
* These blocks show a more transparent ring via the component's ring overlay
|
||||
*/
|
||||
.react-flow__node.selected > div[data-grouped-selection="true"] > div::after {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color tokens - single source of truth for all colors
|
||||
* Light mode: Warm theme
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface WorkflowBlockProps {
|
||||
isPreview?: boolean
|
||||
/** Whether this block is selected in preview mode */
|
||||
isPreviewSelected?: boolean
|
||||
/** Whether this block is selected as part of a group (not directly clicked) */
|
||||
isGroupedSelection?: boolean
|
||||
subBlockValues?: Record<string, any>
|
||||
blockState?: any
|
||||
}
|
||||
|
||||
@@ -915,8 +915,10 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
|
||||
|
||||
const isGroupedSelection = data.isGroupedSelection ?? false
|
||||
|
||||
return (
|
||||
<div className='group relative'>
|
||||
<div className='group relative' data-grouped-selection={isGroupedSelection ? 'true' : undefined}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface UseBlockVisualProps {
|
||||
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
|
||||
const isPreview = data.isPreview ?? false
|
||||
const isPreviewSelected = data.isPreviewSelected ?? false
|
||||
const isGroupedSelection = data.isGroupedSelection ?? false
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
@@ -64,8 +65,18 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
diffStatus: isPreview ? undefined : diffStatus,
|
||||
runPathStatus,
|
||||
isPreviewSelection: isPreview && isPreviewSelected,
|
||||
isGroupedSelection: !isPreview && isGroupedSelection,
|
||||
}),
|
||||
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
|
||||
[
|
||||
isActive,
|
||||
isPending,
|
||||
isDeletedBlock,
|
||||
diffStatus,
|
||||
runPathStatus,
|
||||
isPreview,
|
||||
isPreviewSelected,
|
||||
isGroupedSelection,
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface BlockRingOptions {
|
||||
diffStatus: BlockDiffStatus
|
||||
runPathStatus: BlockRunPathStatus
|
||||
isPreviewSelection?: boolean
|
||||
isGroupedSelection?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,8 +22,15 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
hasRing: boolean
|
||||
ringClassName: string
|
||||
} {
|
||||
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
|
||||
options
|
||||
const {
|
||||
isActive,
|
||||
isPending,
|
||||
isDeletedBlock,
|
||||
diffStatus,
|
||||
runPathStatus,
|
||||
isPreviewSelection,
|
||||
isGroupedSelection,
|
||||
} = options
|
||||
|
||||
const hasRing =
|
||||
isActive ||
|
||||
@@ -30,17 +38,24 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
isDeletedBlock ||
|
||||
!!runPathStatus
|
||||
!!runPathStatus ||
|
||||
!!isGroupedSelection
|
||||
|
||||
const ringClassName = cn(
|
||||
// Grouped selection: more transparent ring for blocks selected as part of a group
|
||||
// Using rgba with the brand-secondary color (#33b4ff) at 40% opacity
|
||||
isGroupedSelection &&
|
||||
!isActive &&
|
||||
'ring-[2px] ring-[rgba(51,180,255,0.4)]',
|
||||
// Preview selection: static blue ring (standard thickness, no animation)
|
||||
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
|
||||
// Executing block: pulsing success ring with prominent thickness
|
||||
isActive &&
|
||||
!isPreviewSelection &&
|
||||
!isGroupedSelection &&
|
||||
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||
// Non-active states use standard ring utilities
|
||||
!isActive && hasRing && 'ring-[1.75px]',
|
||||
// Non-active states use standard ring utilities (except grouped selection which has its own)
|
||||
!isActive && hasRing && !isGroupedSelection && 'ring-[1.75px]',
|
||||
// Pending state: warning ring
|
||||
!isActive && isPending && 'ring-[var(--warning)]',
|
||||
// Deleted state (highest priority after active/pending)
|
||||
|
||||
@@ -2589,9 +2589,53 @@ const WorkflowContent = React.memo(() => {
|
||||
parentId: currentParentId,
|
||||
})
|
||||
|
||||
// Capture all selected nodes' positions for multi-node undo/redo
|
||||
// Expand selection to include all group members before capturing positions
|
||||
const groups = getGroups()
|
||||
const allNodes = getNodes()
|
||||
const selectedNodes = allNodes.filter((n) => n.selected)
|
||||
|
||||
// Find the group of the dragged node
|
||||
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
|
||||
|
||||
// If the dragged node is in a group, expand selection to include all group members
|
||||
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
|
||||
const group = groups[draggedBlockGroupId]
|
||||
const groupBlockIds = new Set(group.blockIds)
|
||||
|
||||
// Check if we need to expand selection
|
||||
const currentSelectedIds = new Set(allNodes.filter((n) => n.selected).map((n) => n.id))
|
||||
const needsExpansion = [...groupBlockIds].some((id) => !currentSelectedIds.has(id))
|
||||
|
||||
if (needsExpansion) {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => {
|
||||
const isInGroup = groupBlockIds.has(n.id)
|
||||
const isDirectlyDragged = n.id === node.id
|
||||
return {
|
||||
...n,
|
||||
selected: isInGroup ? true : n.selected,
|
||||
data: {
|
||||
...n.data,
|
||||
// Mark as grouped selection if in group but not the directly dragged node
|
||||
isGroupedSelection: isInGroup && !isDirectlyDragged && !n.selected,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Capture all selected nodes' positions for multi-node undo/redo
|
||||
// Re-get nodes after potential selection expansion
|
||||
const updatedNodes = getNodes()
|
||||
const selectedNodes = updatedNodes.filter((n) => {
|
||||
// Include node if it's selected OR if it's in the same group as the dragged node
|
||||
if (n.selected) return true
|
||||
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
|
||||
return groups[draggedBlockGroupId].blockIds.includes(n.id)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
multiNodeDragStartRef.current.clear()
|
||||
selectedNodes.forEach((n) => {
|
||||
const block = blocks[n.id]
|
||||
@@ -2604,7 +2648,7 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
})
|
||||
},
|
||||
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
|
||||
[blocks, setDragStartPosition, getNodes, setNodes, getGroups, potentialParentId, setPotentialParentId]
|
||||
)
|
||||
|
||||
/** Handles node drag stop to establish parent-child relationships. */
|
||||
@@ -3228,6 +3272,7 @@ const WorkflowContent = React.memo(() => {
|
||||
/**
|
||||
* Handles node click to select the node in ReactFlow.
|
||||
* When clicking on a grouped block, also selects all other blocks in the group.
|
||||
* Grouped blocks are marked with isGroupedSelection for different visual styling.
|
||||
* Parent-child conflict resolution happens automatically in onNodesChange.
|
||||
*/
|
||||
const handleNodeClick = useCallback(
|
||||
@@ -3235,12 +3280,29 @@ const WorkflowContent = React.memo(() => {
|
||||
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
|
||||
const groups = getGroups()
|
||||
|
||||
// Track which nodes are directly clicked vs. group-expanded
|
||||
const directlySelectedIds = new Set<string>()
|
||||
|
||||
setNodes((nodes) => {
|
||||
// First, calculate the base selection
|
||||
let updatedNodes = nodes.map((n) => ({
|
||||
...n,
|
||||
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
|
||||
}))
|
||||
let updatedNodes = nodes.map((n) => {
|
||||
const isDirectlySelected = isMultiSelect
|
||||
? n.id === node.id
|
||||
? true
|
||||
: n.selected
|
||||
: n.id === node.id
|
||||
if (isDirectlySelected) {
|
||||
directlySelectedIds.add(n.id)
|
||||
}
|
||||
return {
|
||||
...n,
|
||||
selected: isDirectlySelected,
|
||||
data: {
|
||||
...n.data,
|
||||
isGroupedSelection: false, // Reset grouped selection flag
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Expand selection to include all group members
|
||||
const selectedNodeIds = new Set(updatedNodes.filter((n) => n.selected).map((n) => n.id))
|
||||
@@ -3264,12 +3326,19 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Update nodes with expanded selection
|
||||
// Update nodes with expanded selection, marking group-expanded nodes
|
||||
if (expandedNodeIds.size > selectedNodeIds.size) {
|
||||
updatedNodes = updatedNodes.map((n) => ({
|
||||
...n,
|
||||
selected: expandedNodeIds.has(n.id) ? true : n.selected,
|
||||
}))
|
||||
updatedNodes = updatedNodes.map((n) => {
|
||||
const isGroupExpanded = expandedNodeIds.has(n.id) && !directlySelectedIds.has(n.id)
|
||||
return {
|
||||
...n,
|
||||
selected: expandedNodeIds.has(n.id) ? true : n.selected,
|
||||
data: {
|
||||
...n.data,
|
||||
isGroupedSelection: isGroupExpanded,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user