Ring light

This commit is contained in:
Siddharth Ganesan
2026-01-13 18:53:19 -08:00
parent f04cd7c355
commit 8ec067d280
6 changed files with 126 additions and 19 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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,
},
}
})
}
}