diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index a872349d51e..bc7082adb33 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -757,6 +757,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ edge.source, edge.target ), + isEdgeSelected: (edge.data as { isSelected?: boolean } | undefined)?.isSelected, }) if (!isHighlighted) continue if (edge.source === id) keys.push(edge.sourceHandle || 'source') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index bc2194ae700..f23c1ab42f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -64,9 +64,11 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => { source, target ) + const isEdgeSelected = Boolean((data as { isSelected?: boolean } | undefined)?.isSelected) const shouldHighlightEdge = isEdgeHighlighted({ isEndpointSelected: isConnectedToSelection, isConnectedToEditor, + isEdgeSelected, }) const previewExecutionStatus = ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock.test.tsx new file mode 100644 index 00000000000..78a4b8570c9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock.test.tsx @@ -0,0 +1,61 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' +import { useShiftSelectionLock } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock' + +function renderShiftSelectionLock() { + let api: ReturnType | null = null + const host = document.createElement('div') + const root: Root = createRoot(host) + + function Probe() { + api = useShiftSelectionLock({ isHandMode: false }) + return null + } + + act(() => root.render()) + if (!api) throw new Error('hook did not render') + + return { api, unmount: () => act(() => root.unmount()) } +} + +describe('useShiftSelectionLock', () => { + it('does not swallow Shift clicks on selectable elements inside the pane', () => { + const { api, unmount } = renderShiftSelectionLock() + const pane = document.createElement('div') + pane.className = 'react-flow__pane' + const edge = document.createElement('path') + edge.classList.add('react-flow__edge-interaction') + pane.appendChild(edge) + const preventDefault = vi.fn() + + api.handleCanvasMouseDown({ + shiftKey: true, + target: edge, + preventDefault, + } as unknown as React.MouseEvent) + + expect(preventDefault).not.toHaveBeenCalled() + unmount() + }) + + it('still prevents native selection when Shift-drag starts on the pane background', () => { + const { api, unmount } = renderShiftSelectionLock() + const pane = document.createElement('div') + pane.className = 'react-flow__pane' + const preventDefault = vi.fn() + + api.handleCanvasMouseDown({ + shiftKey: true, + target: pane, + preventDefault, + } as unknown as React.MouseEvent) + + expect(preventDefault).toHaveBeenCalledOnce() + unmount() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock.ts index 9a953cbede7..622f86792e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock.ts @@ -31,7 +31,7 @@ export function useShiftSelectionLock({ if (!event.shiftKey) return const target = event.target as HTMLElement | null - const isPaneTarget = Boolean(target?.closest('.react-flow__pane, .react-flow__selectionpane')) + const isPaneTarget = Boolean(target?.matches('.react-flow__pane, .react-flow__selectionpane')) if (isPaneTarget && isHandMode) { setIsShiftSelecting(true) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts index 5c45ab5c1cb..2c95b0d0975 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts @@ -3,13 +3,60 @@ */ import { describe, expect, it } from 'vitest' import { + applyEdgeSelectionChanges, getArrowNavigationDirection, + getEdgeSelectionMapKey, isPositionalTriggerBlock, reconcileCanvasEdges, reconcileCanvasNodes, shouldHighlightContainerDropTarget, } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers' +describe('edge selection helpers', () => { + it('keeps modifier selections and removes deselected edges', () => { + const selected = new Map([['edge-1-loop-1', 'edge-1']]) + const selectionKeys = new Map([ + ['edge-1', 'edge-1-loop-1'], + ['edge-2', 'edge-2-loop-1'], + ]) + + const withSecondEdge = applyEdgeSelectionChanges( + selected, + [{ id: 'edge-2', type: 'select', selected: true }], + (edgeId) => selectionKeys.get(edgeId) ?? null + ) + expect([...withSecondEdge]).toEqual([ + ['edge-1-loop-1', 'edge-1'], + ['edge-2-loop-1', 'edge-2'], + ]) + + const withoutFirstEdge = applyEdgeSelectionChanges( + withSecondEdge, + [{ id: 'edge-1', type: 'select', selected: false }], + (edgeId) => selectionKeys.get(edgeId) ?? null + ) + expect([...withoutFirstEdge]).toEqual([['edge-2-loop-1', 'edge-2']]) + }) + + it('uses nested context keys and ignores temporary edges', () => { + const key = getEdgeSelectionMapKey( + { id: 'edge-1', source: 'source', target: 'target' }, + [{ id: 'source', parentId: 'loop-1' }, { id: 'target' }], + {} + ) + expect(key).toBe('edge-1-loop-1') + + const selected = new Map() + expect( + applyEdgeSelectionChanges( + selected, + [{ id: 'connection-block-selector-edge', type: 'select', selected: true }], + () => null + ) + ).toBe(selected) + }) +}) + describe('getArrowNavigationDirection', () => { it('moves once for a fresh horizontal arrow press', () => { expect( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index f740c4baab3..e0b4cabe03a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -1,6 +1,6 @@ import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS, getNoteBlockHeight } from '@sim/workflow-renderer' import { isEqual } from 'es-toolkit' -import type { Edge, Node } from 'reactflow' +import type { Edge, EdgeChange, Node } from 'reactflow' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -313,6 +313,44 @@ export function getEdgeSelectionContextId( return null } +/** Stable key for transient edge selection, including a nested subflow context when present. */ +export function getEdgeSelectionMapKey( + edge: Pick, + nodes: Array>, + blocks: Record +): string { + const contextId = getEdgeSelectionContextId(edge, nodes, blocks) + return contextId ? `${edge.id}-${contextId}` : edge.id +} + +type EdgeSelectChange = Extract + +/** Applies React Flow selection changes to the canvas' transient edge-selection map. */ +export function applyEdgeSelectionChanges( + current: Map, + changes: EdgeSelectChange[], + getSelectionKey: (edgeId: string) => string | null +): Map { + let next: Map | null = null + const writable = () => (next ??= new Map(current)) + + for (const change of changes) { + if (change.selected) { + const selectionKey = getSelectionKey(change.id) + if (selectionKey && (next ?? current).get(selectionKey) !== change.id) { + writable().set(selectionKey, change.id) + } + continue + } + + for (const [selectionKey, edgeId] of next ?? current) { + if (edgeId === change.id) writable().delete(selectionKey) + } + } + + return next ?? current +} + export function resolveSelectionContextConflicts( nodes: Node[], blocks: Record, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants.ts index 3f03082c045..d1e6e198e55 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants.ts @@ -44,6 +44,7 @@ export const reactFlowStyles = [ '[&_.react-flow__selectionpane]:select-none', String.raw`[&_.react-flow\_\_selection]:!border-[var(--text-secondary)]`, String.raw`[&_.react-flow\_\_selection]:!bg-[color-mix(in_oklch,var(--text-secondary)_8%,transparent)]`, + String.raw`[&_.react-flow\_\_edge:focus-visible_.react-flow\_\_edge-path]:drop-shadow-[0_0_2px_var(--text-secondary)]`, '[&_.react-flow__background]:hidden', '[&_.react-flow__node-subflowNode.selected]:!shadow-none', ].join(' ') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 6c6edf0517c..0a0e948022f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -6,6 +6,7 @@ import ReactFlow, { applyNodeChanges, ConnectionLineType, type Edge, + type EdgeChange, type Node, type NodeChange, type OnConnectStart, @@ -80,6 +81,7 @@ import { useWorkflowExecution, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { + applyEdgeSelectionChanges, calculateContainerDimensions, clampPositionToContainer, clearDragHighlights, @@ -89,7 +91,7 @@ import { getArrowNavigationDirection, getClampedPositionForNode, getDescendantBlockIds, - getEdgeSelectionContextId, + getEdgeSelectionMapKey, getNodeSelectionContextId, getRunFromBlockDependencyState, getWorkflowLockToggleIds, @@ -3366,12 +3368,39 @@ const WorkflowContent = React.memo( } }, [blocks, batchUpdateBlocksWithParent, getNodeAbsolutePosition, isWorkflowReady]) - /** Handles edge removal changes. */ + /** Synchronizes transient edge selection and handles edge removal changes. */ const onEdgesChange = useCallback( - (changes: any) => { + (changes: EdgeChange[]) => { + const selectionChanges = changes.filter( + (change): change is Extract => change.type === 'select' + ) + if (selectionChanges.length > 0) { + const focusedEdgeTestId = document.activeElement?.getAttribute('data-testid') + const shouldRestoreEdgeFocus = selectionChanges.some( + (change) => change.selected && focusedEdgeTestId === `rf__edge-${change.id}` + ) + const nodes = getNodes() + setSelectedEdges((current) => + applyEdgeSelectionChanges(current, selectionChanges, (edgeId) => { + const edge = edgesForDisplay.find((candidate) => candidate.id === edgeId) + return edge ? getEdgeSelectionMapKey(edge, nodes, blocks) : null + }) + ) + if (shouldRestoreEdgeFocus) { + requestAnimationFrame(() => { + const focusedEdge = Array.from( + document.querySelectorAll('.react-flow__edge') + ).find((edge) => edge.getAttribute('data-testid') === focusedEdgeTestId) + focusedEdge?.focus({ preventScroll: true }) + }) + } + } + const edgeIdsToRemove = changes - .filter((change: any) => change.type === 'remove') - .map((change: any) => change.id) + .filter( + (change): change is Extract => change.type === 'remove' + ) + .map((change) => change.id) .filter((edgeId: string) => { // Prevent removing edges targeting protected blocks const edge = edges.find((e) => e.id === edgeId) @@ -3383,7 +3412,7 @@ const WorkflowContent = React.memo( collaborativeBatchRemoveEdges(edgeIdsToRemove) } }, - [collaborativeBatchRemoveEdges, edges, blocks] + [blocks, collaborativeBatchRemoveEdges, edges, edgesForDisplay, getNodes] ) /** @@ -4733,36 +4762,6 @@ const WorkflowContent = React.memo( workflowIdParam, ]) - /** Handles edge selection with container context tracking and Shift-click multi-selection. */ - const onEdgeClick = useCallback( - (event: React.MouseEvent, edge: any) => { - event.stopPropagation() // Prevent bubbling - if (edge.id === `${CONNECTION_BLOCK_SELECTOR_NODE_ID}-edge`) return - - const contextId = `${edge.id}${(() => { - const selectionContextId = getEdgeSelectionContextId(edge, getNodes(), blocks) - return selectionContextId ? `-${selectionContextId}` : '' - })()}` - - if (event.shiftKey) { - // Shift-click: toggle edge in selection - setSelectedEdges((prev) => { - const next = new Map(prev) - if (next.has(contextId)) { - next.delete(contextId) - } else { - next.set(contextId, edge.id) - } - return next - }) - } else { - // Normal click: replace selection with this edge - setSelectedEdges(new Map([[contextId, edge.id]])) - } - }, - [blocks, getNodes] - ) - const latestEdgesRef = useRef(edges) latestEdgesRef.current = edges const latestBlocksRef = useRef(blocks) @@ -4770,6 +4769,8 @@ const WorkflowContent = React.memo( /** Stable delete handler to avoid creating new function references per edge. */ const handleEdgeDelete = useCallback( (edgeId: string) => { + if (!effectivePermissions.canEdit) return + // Prevent removing edges targeting protected blocks const edge = latestEdgesRef.current.find((candidate) => candidate.id === edgeId) if (edge && isEdgeProtected(edge, latestBlocksRef.current)) { @@ -4788,7 +4789,7 @@ const WorkflowContent = React.memo( return next }) }, - [removeEdge] + [effectivePermissions.canEdit, removeEdge] ) /* @@ -4856,7 +4857,7 @@ const WorkflowContent = React.memo( const sourceNode = nodeMap.get(edge.source) const targetNode = nodeMap.get(edge.target) const parentLoopId = sourceNode?.parentId || targetNode?.parentId - const edgeContextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}` + const edgeContextId = getEdgeSelectionMapKey(edge, displayNodes, blocks) // Ordered within the edge band by its container's depth, so an edge is // always above the container body it crosses (which is opaque, and takes @@ -4897,6 +4898,7 @@ const WorkflowContent = React.memo( return { ...edge, + selected: isSelected, zIndex, data: { ...edge.data, @@ -4905,7 +4907,7 @@ const WorkflowContent = React.memo( isInsideLoop: Boolean(parentLoopId), parentLoopId, sourceHandle: edge.sourceHandle, - onDelete: handleEdgeDelete, + ...(effectivePermissions.canEdit ? { onDelete: handleEdgeDelete } : {}), ...(targetContainerZIndex !== undefined ? { labelZIndex: zIndex } : {}), }, } @@ -4925,6 +4927,7 @@ const WorkflowContent = React.memo( displayNodes, selectedNodeIds, selectedEdges, + effectivePermissions.canEdit, handleEdgeDelete, editorOpenBlockId, panelActiveTab, @@ -4978,6 +4981,9 @@ const WorkflowContent = React.memo( // Handle edge deletion first (edges take priority if selected) if (selectedEdges.size > 0) { + event.preventDefault() + if (!effectivePermissions.canEdit) return + // Get all selected edge IDs and filter out edges targeting protected blocks const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => { const edge = edges.find((e) => e.id === edgeId) @@ -5157,7 +5163,6 @@ const WorkflowContent = React.memo( connectionLineContainerStyle={CONNECTION_LINE_CONTAINER_STYLE} connectionLineType={ConnectionLineType.SmoothStep} onPaneClick={onPaneClick} - onEdgeClick={embedded ? undefined : onEdgeClick} onNodeClick={handleNodeClick} onPaneContextMenu={handlePaneContextMenu} onNodeContextMenu={handleNodeContextMenu} diff --git a/packages/workflow-renderer/src/edge/workflow-edge-view-mount.test.tsx b/packages/workflow-renderer/src/edge/workflow-edge-view-mount.test.tsx index f1a782c634d..9b1ff0f896f 100644 --- a/packages/workflow-renderer/src/edge/workflow-edge-view-mount.test.tsx +++ b/packages/workflow-renderer/src/edge/workflow-edge-view-mount.test.tsx @@ -219,9 +219,39 @@ describe('WorkflowEdgeView', () => { expect(path?.style.stroke).toBe('var(--text-error)') }) + it('shows a selected idle edge as a full-opacity neutral highlight', () => { + const onDelete = vi.fn() + const { host, path } = renderEdge({ + data: { isSelected: true, onDelete }, + isConnectedToSelection: true, + }) + + expect(path?.style.stroke).toBe('var(--text-secondary)') + expect(path?.style.strokeWidth).toBe('1.5') + expect(path?.style.opacity).toBe('1') + expect(host.querySelector('button')).toHaveAttribute('aria-label', 'Delete connection') + }) + + it('preserves semantic edge color while selected', () => { + const { path } = renderEdge({ + data: { isSelected: true, onDelete: vi.fn() }, + isConnectedToSelection: true, + sourceHandle: 'error', + }) + + expect(path?.style.stroke).toBe('var(--text-error)') + expect(path?.style.opacity).toBe('1') + }) + + it('hides the delete control when deletion is unavailable', () => { + const { host } = renderEdge({ data: { isSelected: true } }) + + expect(host.querySelector('button')).toBeNull() + }) + it('keeps the selected-edge control on a container target occlusion layer', () => { const { host } = renderEdge({ - data: { isSelected: true, labelZIndex: 1 }, + data: { isSelected: true, labelZIndex: 1, onDelete: vi.fn() }, }) expect(host.querySelector('button')).toHaveStyle({ zIndex: 1 }) diff --git a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx index de8b7656581..a74741202e4 100644 --- a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx +++ b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx @@ -57,8 +57,8 @@ export interface WorkflowEdgeViewProps extends EdgeProps { /** Whether the edge's target block is currently executing. */ isTargetActive?: boolean /** - * Whether either endpoint block is selected on the canvas — brightens the - * edge alongside the selected node. Diff and error colors take priority. + * Whether the edge should receive the neutral selection highlight because it + * or either endpoint is selected. Diff and error colors take priority. */ isConnectedToSelection?: boolean } @@ -177,10 +177,6 @@ export function WorkflowEdgeView({ } } - if (isSelected && !isWorkflowRunning) { - opacity = 0.5 - } - return { strokeWidth: diffStatus ? 2.5 : hasRunStatus ? 2 : 1.5, strokeDasharray: diffStatus === 'deleted' ? '10,5' : undefined, @@ -262,10 +258,11 @@ export function WorkflowEdgeView({ )} - {isSelected && ( + {isSelected && data?.onDelete && (