Skip to content

Commit 7740d9f

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(workflow): improve selected edge visibility
1 parent 2795922 commit 7740d9f

11 files changed

Lines changed: 244 additions & 50 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
757757
edge.source,
758758
edge.target
759759
),
760+
isEdgeSelected: (edge.data as { isSelected?: boolean } | undefined)?.isSelected,
760761
})
761762
if (!isHighlighted) continue
762763
if (edge.source === id) keys.push(edge.sourceHandle || 'source')

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,11 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => {
6464
source,
6565
target
6666
)
67+
const isEdgeSelected = Boolean((data as { isSelected?: boolean } | undefined)?.isSelected)
6768
const shouldHighlightEdge = isEdgeHighlighted({
6869
isEndpointSelected: isConnectedToSelection,
6970
isConnectedToEditor,
71+
isEdgeSelected,
7072
})
7173

7274
const previewExecutionStatus = (
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { act } from 'react'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { describe, expect, it, vi } from 'vitest'
8+
import { useShiftSelectionLock } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock'
9+
10+
function renderShiftSelectionLock() {
11+
let api: ReturnType<typeof useShiftSelectionLock> | null = null
12+
const host = document.createElement('div')
13+
const root: Root = createRoot(host)
14+
15+
function Probe() {
16+
api = useShiftSelectionLock({ isHandMode: false })
17+
return null
18+
}
19+
20+
act(() => root.render(<Probe />))
21+
if (!api) throw new Error('hook did not render')
22+
23+
return { api, unmount: () => act(() => root.unmount()) }
24+
}
25+
26+
describe('useShiftSelectionLock', () => {
27+
it('does not swallow Shift clicks on selectable elements inside the pane', () => {
28+
const { api, unmount } = renderShiftSelectionLock()
29+
const pane = document.createElement('div')
30+
pane.className = 'react-flow__pane'
31+
const edge = document.createElement('path')
32+
edge.classList.add('react-flow__edge-interaction')
33+
pane.appendChild(edge)
34+
const preventDefault = vi.fn()
35+
36+
api.handleCanvasMouseDown({
37+
shiftKey: true,
38+
target: edge,
39+
preventDefault,
40+
} as unknown as React.MouseEvent)
41+
42+
expect(preventDefault).not.toHaveBeenCalled()
43+
unmount()
44+
})
45+
46+
it('still prevents native selection when Shift-drag starts on the pane background', () => {
47+
const { api, unmount } = renderShiftSelectionLock()
48+
const pane = document.createElement('div')
49+
pane.className = 'react-flow__pane'
50+
const preventDefault = vi.fn()
51+
52+
api.handleCanvasMouseDown({
53+
shiftKey: true,
54+
target: pane,
55+
preventDefault,
56+
} as unknown as React.MouseEvent)
57+
58+
expect(preventDefault).toHaveBeenCalledOnce()
59+
unmount()
60+
})
61+
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function useShiftSelectionLock({
3131
if (!event.shiftKey) return
3232

3333
const target = event.target as HTMLElement | null
34-
const isPaneTarget = Boolean(target?.closest('.react-flow__pane, .react-flow__selectionpane'))
34+
const isPaneTarget = Boolean(target?.matches('.react-flow__pane, .react-flow__selectionpane'))
3535

3636
if (isPaneTarget && isHandMode) {
3737
setIsShiftSelecting(true)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { isEdgeHighlighted } from './edge-highlight'
3+
4+
describe('isEdgeHighlighted', () => {
5+
it('highlights a directly selected edge', () => {
6+
expect(isEdgeHighlighted({ isEdgeSelected: true })).toBe(true)
7+
})
8+
9+
it('leaves an unrelated edge idle', () => {
10+
expect(isEdgeHighlighted({})).toBe(false)
11+
})
12+
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,60 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import {
6+
applyEdgeSelectionChanges,
67
getArrowNavigationDirection,
8+
getEdgeSelectionMapKey,
79
isPositionalTriggerBlock,
810
reconcileCanvasEdges,
911
reconcileCanvasNodes,
1012
shouldHighlightContainerDropTarget,
1113
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
1214

15+
describe('edge selection helpers', () => {
16+
it('keeps modifier selections and removes deselected edges', () => {
17+
const selected = new Map([['edge-1-loop-1', 'edge-1']])
18+
const selectionKeys = new Map([
19+
['edge-1', 'edge-1-loop-1'],
20+
['edge-2', 'edge-2-loop-1'],
21+
])
22+
23+
const withSecondEdge = applyEdgeSelectionChanges(
24+
selected,
25+
[{ id: 'edge-2', type: 'select', selected: true }],
26+
(edgeId) => selectionKeys.get(edgeId) ?? null
27+
)
28+
expect([...withSecondEdge]).toEqual([
29+
['edge-1-loop-1', 'edge-1'],
30+
['edge-2-loop-1', 'edge-2'],
31+
])
32+
33+
const withoutFirstEdge = applyEdgeSelectionChanges(
34+
withSecondEdge,
35+
[{ id: 'edge-1', type: 'select', selected: false }],
36+
(edgeId) => selectionKeys.get(edgeId) ?? null
37+
)
38+
expect([...withoutFirstEdge]).toEqual([['edge-2-loop-1', 'edge-2']])
39+
})
40+
41+
it('uses nested context keys and ignores temporary edges', () => {
42+
const key = getEdgeSelectionMapKey(
43+
{ id: 'edge-1', source: 'source', target: 'target' },
44+
[{ id: 'source', parentId: 'loop-1' }, { id: 'target' }],
45+
{}
46+
)
47+
expect(key).toBe('edge-1-loop-1')
48+
49+
const selected = new Map<string, string>()
50+
expect(
51+
applyEdgeSelectionChanges(
52+
selected,
53+
[{ id: 'connection-block-selector-edge', type: 'select', selected: true }],
54+
() => null
55+
)
56+
).toBe(selected)
57+
})
58+
})
59+
1360
describe('getArrowNavigationDirection', () => {
1461
it('moves once for a fresh horizontal arrow press', () => {
1562
expect(

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS, getNoteBlockHeight } from '@sim/workflow-renderer'
22
import { isEqual } from 'es-toolkit'
3-
import type { Edge, Node } from 'reactflow'
3+
import type { Edge, EdgeChange, Node } from 'reactflow'
44
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
55
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils'
66
import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -313,6 +313,44 @@ export function getEdgeSelectionContextId(
313313
return null
314314
}
315315

316+
/** Stable key for transient edge selection, including a nested subflow context when present. */
317+
export function getEdgeSelectionMapKey(
318+
edge: Pick<Edge, 'id' | 'source' | 'target'>,
319+
nodes: Array<Pick<Node, 'id' | 'parentId'>>,
320+
blocks: Record<string, { data?: { parentId?: string } }>
321+
): string {
322+
const contextId = getEdgeSelectionContextId(edge, nodes, blocks)
323+
return contextId ? `${edge.id}-${contextId}` : edge.id
324+
}
325+
326+
type EdgeSelectChange = Extract<EdgeChange, { type: 'select' }>
327+
328+
/** Applies React Flow selection changes to the canvas' transient edge-selection map. */
329+
export function applyEdgeSelectionChanges(
330+
current: Map<string, string>,
331+
changes: EdgeSelectChange[],
332+
getSelectionKey: (edgeId: string) => string | null
333+
): Map<string, string> {
334+
let next: Map<string, string> | null = null
335+
const writable = () => (next ??= new Map(current))
336+
337+
for (const change of changes) {
338+
if (change.selected) {
339+
const selectionKey = getSelectionKey(change.id)
340+
if (selectionKey && (next ?? current).get(selectionKey) !== change.id) {
341+
writable().set(selectionKey, change.id)
342+
}
343+
continue
344+
}
345+
346+
for (const [selectionKey, edgeId] of next ?? current) {
347+
if (edgeId === change.id) writable().delete(selectionKey)
348+
}
349+
}
350+
351+
return next ?? current
352+
}
353+
316354
export function resolveSelectionContextConflicts(
317355
nodes: Node[],
318356
blocks: Record<string, { data?: { parentId?: string } }>,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const reactFlowStyles = [
4444
'[&_.react-flow__selectionpane]:select-none',
4545
String.raw`[&_.react-flow\_\_selection]:!border-[var(--text-secondary)]`,
4646
String.raw`[&_.react-flow\_\_selection]:!bg-[color-mix(in_oklch,var(--text-secondary)_8%,transparent)]`,
47+
String.raw`[&_.react-flow\_\_edge:focus-visible_.react-flow\_\_edge-path]:drop-shadow-[0_0_2px_var(--text-secondary)]`,
4748
'[&_.react-flow__background]:hidden',
4849
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
4950
].join(' ')

0 commit comments

Comments
 (0)