@tldiagram/core-ui 1.92.0 → 1.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/api/client.d.ts +13 -1
  2. package/dist/components/ElementNode.d.ts +14 -1
  3. package/dist/components/ZUI/ZUICanvas.d.ts +1 -0
  4. package/dist/config/runtime-vscode.d.ts +1 -0
  5. package/dist/config/runtime.d.ts +1 -0
  6. package/dist/index.js +10875 -9550
  7. package/dist/pages/InfiniteZoom.d.ts +5 -2
  8. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +10 -3
  9. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.test.d.ts +1 -0
  10. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +27 -24
  11. package/dist/pages/ViewsGrid.d.ts +9 -1
  12. package/dist/shims/empty-node-module.d.ts +2 -0
  13. package/dist/store/useStore.d.ts +80 -0
  14. package/dist/store/useStore.test.d.ts +1 -0
  15. package/package.json +10 -7
  16. package/src/api/client.ts +39 -1
  17. package/src/components/ElementNode.tsx +21 -59
  18. package/src/components/ElementPanel.tsx +2 -3
  19. package/src/components/LayoutSection.tsx +95 -104
  20. package/src/components/ViewGridNode.tsx +1 -4
  21. package/src/components/ZUI/ZUICanvas.tsx +138 -1
  22. package/src/components/ZUI/renderer.ts +166 -66
  23. package/src/components/ZUI/useZUIInteraction.ts +235 -81
  24. package/src/config/runtime-vscode.ts +6 -0
  25. package/src/config/runtime.ts +4 -0
  26. package/src/main.tsx +26 -14
  27. package/src/pages/InfiniteZoom.tsx +14 -5
  28. package/src/pages/ViewEditor/context.tsx +14 -3
  29. package/src/pages/ViewEditor/hooks/useCanvasInteractions.test.ts +30 -0
  30. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +294 -146
  31. package/src/pages/ViewEditor/hooks/useViewData.ts +459 -256
  32. package/src/pages/ViewEditor/index.tsx +67 -70
  33. package/src/pages/Views.tsx +552 -83
  34. package/src/pages/ViewsGrid.tsx +26 -337
  35. package/src/shims/empty-node-module.ts +1 -0
  36. package/src/store/useStore.test.ts +285 -0
  37. package/src/store/useStore.ts +327 -0
@@ -34,8 +34,106 @@ import {
34
34
  ensureVisualHandleId,
35
35
  getLogicalHandleId,
36
36
  } from '../../../utils/edgeDistribution'
37
+ import { useStore } from '../../../store/useStore'
37
38
 
38
39
  const SNAP_RADIUS = 75
40
+ const CONNECTOR_DRAG_UPDATE_INTERVAL_MS = 25
41
+
42
+ type HandleTarget = {
43
+ nodeId?: string
44
+ handleId: string
45
+ x: number
46
+ y: number
47
+ }
48
+
49
+ function collectHandleTargets(excludeNodeId?: string): HandleTarget[] {
50
+ const handles = document.querySelectorAll('.react-flow__handle')
51
+ const targets: HandleTarget[] = []
52
+
53
+ for (const handle of handles) {
54
+ const nodeId = handle.closest('.react-flow__node')?.getAttribute('data-id') || undefined
55
+ if (excludeNodeId && nodeId === excludeNodeId) continue
56
+
57
+ const rect = handle.getBoundingClientRect()
58
+ targets.push({
59
+ nodeId,
60
+ handleId: handle.getAttribute('data-handleid') || handle.id,
61
+ x: rect.left + rect.width / 2,
62
+ y: rect.top + rect.height / 2,
63
+ })
64
+ }
65
+
66
+ return targets
67
+ }
68
+
69
+ function findNearestHandleTargetInCache(targets: HandleTarget[], clientX: number, clientY: number) {
70
+ let hoveredHandleId: string | undefined
71
+ let hoveredNodeId: string | undefined
72
+ let snapPos = { x: clientX, y: clientY }
73
+ let nearestDistance = Infinity
74
+
75
+ for (const target of targets) {
76
+ const dist = Math.hypot(clientX - target.x, clientY - target.y)
77
+ if (dist < 36 && dist < nearestDistance) {
78
+ nearestDistance = dist
79
+ snapPos = { x: target.x, y: target.y }
80
+ hoveredHandleId = target.handleId
81
+ hoveredNodeId = target.nodeId
82
+ }
83
+ }
84
+
85
+ return {
86
+ nearHandle: hoveredHandleId !== undefined,
87
+ snapPos,
88
+ hoveredHandleId,
89
+ hoveredNodeId,
90
+ }
91
+ }
92
+
93
+ export function applyNodeChangesWithStructuralSharing(changes: NodeChange[], nodes: RFNode[]) {
94
+ if (changes.length === 0) return nodes
95
+
96
+ const canFastPath = changes.every((change) => change.type === 'position' && 'id' in change)
97
+ if (!canFastPath) return applyNodeChanges(changes, nodes)
98
+
99
+ const changesById = new Map(changes.map((change) => [change.id, change]))
100
+ let didChange = false
101
+
102
+ const nextNodes = nodes.map((node) => {
103
+ const change = changesById.get(node.id)
104
+ if (!change || change.type !== 'position') return node
105
+
106
+ const position = change.position ?? node.position
107
+ const positionAbsolute = change.positionAbsolute ?? node.positionAbsolute
108
+ const dragging = change.dragging ?? node.dragging
109
+
110
+ if (
111
+ node.position.x === position.x &&
112
+ node.position.y === position.y &&
113
+ node.positionAbsolute?.x === positionAbsolute?.x &&
114
+ node.positionAbsolute?.y === positionAbsolute?.y &&
115
+ node.dragging === dragging
116
+ ) {
117
+ return node
118
+ }
119
+
120
+ didChange = true
121
+ return {
122
+ ...node,
123
+ position,
124
+ positionAbsolute,
125
+ dragging,
126
+ }
127
+ })
128
+
129
+ return didChange ? nextNodes : nodes
130
+ }
131
+
132
+ export function getConnectorDeletionTarget(
133
+ selectedConnector: Connector | null,
134
+ ) {
135
+ return selectedConnector?.id ?? null
136
+ }
39
137
 
40
138
  interface CanvasInteractionOptions {
41
139
  viewId: number | null
@@ -72,12 +170,11 @@ interface CanvasInteractionOptions {
72
170
  openConnectorPanel: () => void
73
171
  closeConnectorPanel: () => void
74
172
  selectedElement: LibraryElement | null
75
- selectedEdgeId: number | null
173
+ selectedConnector: Connector | null
76
174
  connectors: Connector[]
77
175
  layers: ViewLayer[]
78
176
  setSelectedElement: React.Dispatch<React.SetStateAction<LibraryElement | null>>
79
177
  setSelectedEdge: (e: Connector | null) => void
80
- setSelectedEdgeId: (id: number | null) => void
81
178
  setSelectedProxyConnectorDetails: React.Dispatch<React.SetStateAction<import('../../../crossBranch/types').ProxyConnectorDetails | null>>
82
179
  openProxyConnectorPanel: () => void
83
180
  closeProxyConnectorPanel: () => void
@@ -110,6 +207,12 @@ type HandleReconnectDragState = {
110
207
  hoveredHandleId?: string
111
208
  }
112
209
 
210
+ type InteractionStartOptions = {
211
+ sourceHandle?: string
212
+ clientX?: number
213
+ clientY?: number
214
+ }
215
+
113
216
  export function useCanvasInteractions({
114
217
  viewId,
115
218
  canEdit,
@@ -127,8 +230,8 @@ export function useCanvasInteractions({
127
230
  interactionSourceIdRef,
128
231
  hoveredZoomRef,
129
232
  hoverPanLockedUntilRef,
130
- setViewElements,
131
- setConnectors,
233
+ setViewElements: _setViewElements,
234
+ setConnectors: _setConnectors,
132
235
  setRfNodes,
133
236
  setRfEdges,
134
237
  setLinksMap,
@@ -145,12 +248,11 @@ export function useCanvasInteractions({
145
248
  openConnectorPanel: openConnectorPanel,
146
249
  closeConnectorPanel: closeConnectorPanel,
147
250
  selectedElement,
148
- selectedEdgeId,
251
+ selectedConnector,
149
252
  connectors,
150
253
  layers,
151
254
  setSelectedElement,
152
255
  setSelectedEdge,
153
- setSelectedEdgeId,
154
256
  setSelectedProxyConnectorDetails,
155
257
  openProxyConnectorPanel,
156
258
  closeProxyConnectorPanel,
@@ -163,6 +265,10 @@ export function useCanvasInteractions({
163
265
  onMoveStateChange,
164
266
  }: CanvasInteractionOptions) {
165
267
  const { screenToFlowPosition, setViewport, getViewport, zoomIn, zoomOut } = useReactFlow()
268
+ const updateElementPosition = useStore((state) => state.updateElementPosition)
269
+ const removeElementPlacement = useStore((state) => state.removeElementPlacement)
270
+ const upsertConnector = useStore((state) => state.upsertConnector)
271
+ const replaceConnector = useStore((state) => state.replaceConnector)
166
272
  const screenToFlowPositionRef = useRef(screenToFlowPosition)
167
273
  screenToFlowPositionRef.current = screenToFlowPosition
168
274
 
@@ -176,6 +282,7 @@ export function useCanvasInteractions({
176
282
  const [reconnectPicking, setReconnectPicking] = useState<{ edgeId: number; endpoint: 'source' | 'target' } | null>(null)
177
283
  const [handleReconnectDrag, setHandleReconnectDrag] = useState<HandleReconnectDragState | null>(null)
178
284
  const [connectorLongPressMenu, setConnectorLongPressMenu] = useState<{ edgeId: number; x: number; y: number } | null>(null)
285
+ const isMovingRef = useRef(false)
179
286
 
180
287
  interactionSourceIdRef.current = interactionSourceId
181
288
 
@@ -185,11 +292,13 @@ export function useCanvasInteractions({
185
292
  const connectingSourceRef = useRef<string | null>(null)
186
293
  const connectWasValidRef = useRef(false)
187
294
  const connectGhostListenerRef = useRef<((e: MouseEvent) => void) | null>(null)
295
+ const connectorDragLastUpdateRef = useRef(0)
188
296
  const isReconnectingRef = useRef(false)
189
297
  const suppressNextConnectorClickRef = useRef(false)
190
298
  const suppressNextPaneClickRef = useRef(false)
191
299
  const longPressCanvasRef = useRef<{ timer: ReturnType<typeof setTimeout>; clientX: number; clientY: number } | null>(null)
192
300
  const pendingConnectionSourceRef = useRef(pendingConnectionSource)
301
+ const pendingConnectionSourceHandleRef = useRef<string | null>(null)
193
302
  pendingConnectionSourceRef.current = pendingConnectionSource
194
303
  const clickConnectModeRef = useRef(clickConnectMode)
195
304
  clickConnectModeRef.current = clickConnectMode
@@ -225,35 +334,11 @@ export function useCanvasInteractions({
225
334
  isReconnectingRef.current = false
226
335
  }, [clearHandleReconnectListeners])
227
336
 
228
- const findNearestHandleTarget = useCallback((clientX: number, clientY: number, excludeNodeId?: string) => {
229
- const handles = document.querySelectorAll('.react-flow__handle')
230
- let hoveredHandleId: string | undefined
231
- let hoveredNodeId: string | undefined
232
- let snapPos = { x: clientX, y: clientY }
233
- let nearestDistance = Infinity
234
-
235
- for (const handle of handles) {
236
- const nodeId = handle.closest('.react-flow__node')?.getAttribute('data-id') || undefined
237
- if (excludeNodeId && nodeId === excludeNodeId) continue
238
- const rect = handle.getBoundingClientRect()
239
- const cx = rect.left + rect.width / 2
240
- const cy = rect.top + rect.height / 2
241
- const dist = Math.hypot(clientX - cx, clientY - cy)
242
- if (dist < 36 && dist < nearestDistance) {
243
- nearestDistance = dist
244
- snapPos = { x: cx, y: cy }
245
- hoveredHandleId = handle.getAttribute('data-handleid') || handle.id
246
- hoveredNodeId = nodeId
247
- }
248
- }
249
-
250
- return {
251
- nearHandle: hoveredHandleId !== undefined,
252
- snapPos,
253
- hoveredHandleId,
254
- hoveredNodeId,
255
- }
256
- }, [])
337
+ const finalizeConnectorCreate = useCallback(async (connector: Connector) => {
338
+ upsertConnectorGraphSnapshot(connector)
339
+ upsertConnector(connector)
340
+ await refreshElements()
341
+ }, [refreshElements, upsertConnector])
257
342
 
258
343
  // ── Ref-forwarded callbacks ────────────────────────────────────────────────
259
344
  const openConnectorPanelRef = useRef(openConnectorPanel)
@@ -304,8 +389,10 @@ export function useCanvasInteractions({
304
389
  if (!canEdit || viewId === null || !addingElementAt || addingElementAt.mode !== 'add') return
305
390
  const { flowX, flowY } = addingElementAt
306
391
  const sourceId = pendingConnectionSourceRef.current
392
+ const pendingSourceHandle = pendingConnectionSourceHandleRef.current
307
393
  setAddingElementAt(null)
308
394
  setPendingConnectionSource(null)
395
+ pendingConnectionSourceHandleRef.current = null
309
396
  try {
310
397
  const obj = await api.elements.create({ name, kind: '' })
311
398
  await api.workspace.views.placements.add(viewId, obj.id, flowX - 100, flowY - 40)
@@ -319,20 +406,22 @@ export function useCanvasInteractions({
319
406
  : { sourceHandle: 'right', targetHandle: 'left' }
320
407
  const newConnector = await api.workspace.connectors.create(viewId, {
321
408
  source_element_id: sourceId, target_element_id: obj.id,
322
- source_handle: sourceHandle, target_handle: targetHandle, direction: 'forward',
409
+ source_handle: pendingSourceHandle ?? sourceHandle, target_handle: targetHandle, direction: 'forward',
323
410
  })
324
- upsertConnectorGraphSnapshot(newConnector)
325
- setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
411
+ const connector = connectorToConnector(newConnector)
412
+ await finalizeConnectorCreate(connector)
326
413
  }
327
414
  } catch { /* intentionally empty */ }
328
- }, [canEdit, viewId, addingElementAt, refreshElements, rfNodesRef, setConnectors, viewElementsRef])
415
+ }, [addingElementAt, canEdit, finalizeConnectorCreate, refreshElements, rfNodesRef, viewId, viewElementsRef])
329
416
 
330
417
  const handleConfirmExistingElement = useCallback(async (obj: LibraryElement) => {
331
418
  if (!canEdit || viewId === null || !addingElementAt || addingElementAt.mode !== 'add') return
332
419
  const { flowX, flowY } = addingElementAt
333
420
  const sourceId = pendingConnectionSourceRef.current
421
+ const pendingSourceHandle = pendingConnectionSourceHandleRef.current
334
422
  setAddingElementAt(null)
335
423
  setPendingConnectionSource(null)
424
+ pendingConnectionSourceHandleRef.current = null
336
425
  try {
337
426
  if (!existingElementIds.has(obj.id)) {
338
427
  await api.workspace.views.placements.add(viewId, obj.id, flowX - 100, flowY - 40)
@@ -350,20 +439,22 @@ export function useCanvasInteractions({
350
439
  : { sourceHandle: 'right', targetHandle: 'left' }
351
440
  const newConnector = await api.workspace.connectors.create(viewId, {
352
441
  source_element_id: sourceId, target_element_id: obj.id,
353
- source_handle: sourceHandle, target_handle: targetHandle, direction: 'forward',
442
+ source_handle: pendingSourceHandle ?? sourceHandle, target_handle: targetHandle, direction: 'forward',
354
443
  })
355
- upsertConnectorGraphSnapshot(newConnector)
356
- setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
444
+ const connector = connectorToConnector(newConnector)
445
+ await finalizeConnectorCreate(connector)
357
446
  }
358
447
  } catch { /* intentionally empty */ }
359
- }, [canEdit, viewId, addingElementAt, existingElementIds, refreshElements, rfNodesRef, setConnectors, viewElementsRef])
448
+ }, [addingElementAt, canEdit, existingElementIds, finalizeConnectorCreate, refreshElements, rfNodesRef, viewId, viewElementsRef])
360
449
 
361
450
  const handleConfirmConnectExistingElement = useCallback(async (obj: LibraryElement) => {
362
451
  if (!canEdit || viewId === null || !addingElementAt || addingElementAt.mode !== 'connect') return
363
452
  const { flowX, flowY } = addingElementAt
364
453
  const sourceId = pendingConnectionSourceRef.current
454
+ const pendingSourceHandle = pendingConnectionSourceHandleRef.current
365
455
  setAddingElementAt(null)
366
456
  setPendingConnectionSource(null)
457
+ pendingConnectionSourceHandleRef.current = null
367
458
  if (sourceId == null || sourceId === obj.id) return
368
459
  try {
369
460
  const sourceNode = rfNodesRef.current.find((n) => n.id === String(sourceId))
@@ -373,14 +464,14 @@ export function useCanvasInteractions({
373
464
  const newConnector = await api.workspace.connectors.create(viewId, {
374
465
  source_element_id: sourceId,
375
466
  target_element_id: obj.id,
376
- source_handle: sourceHandle,
467
+ source_handle: pendingSourceHandle ?? sourceHandle,
377
468
  target_handle: targetHandle,
378
469
  direction: 'forward',
379
470
  })
380
- upsertConnectorGraphSnapshot(newConnector)
381
- setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
471
+ const connector = connectorToConnector(newConnector)
472
+ await finalizeConnectorCreate(connector)
382
473
  } catch { /* intentionally empty */ }
383
- }, [addingElementAt, canEdit, rfNodesRef, setConnectors, viewId])
474
+ }, [addingElementAt, canEdit, finalizeConnectorCreate, rfNodesRef, viewId])
384
475
 
385
476
  // ── Zoom-in / zoom-out stable callbacks ───────────────────────────────────
386
477
  const stableOnZoomIn = useCallback(async (elementId: number) => {
@@ -467,20 +558,98 @@ export function useCanvasInteractions({
467
558
  try {
468
559
  await api.workspace.views.placements.remove(viewId, elementId)
469
560
  removePlacementGraphSnapshot(viewId, elementId)
561
+ removeElementPlacement(elementId)
470
562
  handleElementDeleted(elementId)
471
563
  setInteractionSourceId(null)
564
+ pendingConnectionSourceHandleRef.current = null
472
565
  } catch { /* intentionally empty */ }
473
- }, [canEdit, viewId, handleElementDeleted])
566
+ }, [canEdit, viewId, removeElementPlacement, handleElementDeleted])
567
+
568
+ const connectClickModeToHandle = useCallback(async (targetElementId: number, targetHandle: string) => {
569
+ if (!canEdit) return
570
+ const cid = viewIdRef.current
571
+ const sourceElementId = interactionSourceIdRef.current
572
+ if (cid === null || sourceElementId === null || sourceElementId === targetElementId) return
573
+
574
+ const sourceHandle = pendingConnectionSourceHandleRef.current ??
575
+ (clickConnectModeRef.current?.sourceHandle
576
+ ? getLogicalHandleId(clickConnectModeRef.current.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
577
+ : DEFAULT_SOURCE_HANDLE_SIDE)
578
+ const logicalTargetHandle = getLogicalHandleId(targetHandle, DEFAULT_TARGET_HANDLE_SIDE) ?? DEFAULT_TARGET_HANDLE_SIDE
579
+
580
+ setInteractionSourceId(null)
581
+ setPendingConnectionSource(null)
582
+ pendingConnectionSourceHandleRef.current = null
583
+ setClickConnectMode(null)
584
+ setClickConnectCursorPos(null)
585
+ setConnectGhostPos(null)
586
+
587
+ try {
588
+ const newConnector = await api.workspace.connectors.create(cid, {
589
+ source_element_id: sourceElementId,
590
+ target_element_id: targetElementId,
591
+ source_handle: sourceHandle,
592
+ target_handle: logicalTargetHandle,
593
+ direction: 'forward',
594
+ })
595
+ const connector = connectorToConnector(newConnector)
596
+ await finalizeConnectorCreate(connector)
597
+ } catch { /* intentionally empty */ }
598
+ }, [canEdit, finalizeConnectorCreate, interactionSourceIdRef, viewIdRef])
599
+
600
+ const stableOnInteractionStart = useCallback((elementId: number, options?: InteractionStartOptions) => {
601
+ if (!canEdit) return
602
+ const sourceHandle = options?.sourceHandle
603
+
604
+ if (sourceHandle) {
605
+ const cursorPos = options?.clientX !== undefined && options.clientY !== undefined
606
+ ? { x: options.clientX, y: options.clientY }
607
+ : lastMousePosRef.current
608
+ ? { x: lastMousePosRef.current.clientX, y: lastMousePosRef.current.clientY }
609
+ : null
610
+ if (!cursorPos) return
611
+
612
+ const activeSourceId = interactionSourceIdRef.current
613
+ if (activeSourceId !== null && activeSourceId !== elementId) {
614
+ void connectClickModeToHandle(elementId, sourceHandle)
615
+ return
616
+ }
617
+
618
+ pendingConnectionSourceHandleRef.current = getLogicalHandleId(sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
619
+ setInteractionSourceId(elementId)
620
+ setPendingConnectionSource(null)
621
+ setAddingElementAt(null)
622
+ setClickConnectMode({
623
+ sourceNodeId: String(elementId),
624
+ sourceHandle: ensureVisualHandleId(sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? sourceHandle,
625
+ })
626
+ setClickConnectCursorPos(cursorPos)
627
+ setConnectGhostPos(cursorPos)
628
+ return
629
+ }
630
+
631
+ const isCancelling = interactionSourceIdRef.current === elementId
632
+ const nextSourceId = isCancelling ? null : elementId
633
+
634
+ if (isCancelling) pendingConnectionSourceHandleRef.current = null
635
+ setInteractionSourceId(nextSourceId)
636
+
637
+ if (isCancelling) {
638
+ setClickConnectMode(null)
639
+ setClickConnectCursorPos(null)
640
+ setConnectGhostPos(null)
641
+ }
642
+ }, [canEdit, connectClickModeToHandle, interactionSourceIdRef])
474
643
 
475
644
  // ── Node/connector changes ─────────────────────────────────────────────────────
476
645
  const onNodesChange = useCallback((changes: NodeChange[]) => {
477
646
  if (!canEdit) {
478
647
  const nonMutating = changes.filter((c) => c.type !== 'position')
479
648
  if (nonMutating.length === 0) return
480
- setRfNodes((nds) => applyNodeChanges(nonMutating, nds))
649
+ setRfNodes((nds) => applyNodeChangesWithStructuralSharing(nonMutating, nds))
481
650
  return
482
651
  }
483
- setRfNodes((nds) => applyNodeChanges(changes, nds))
652
+ setRfNodes((nds) => applyNodeChangesWithStructuralSharing(changes, nds))
484
653
  }, [canEdit, setRfNodes])
485
654
 
486
655
  const onEdgesChange = useCallback((changes: EdgeChange[]) => {
@@ -494,18 +663,11 @@ export function useCanvasInteractions({
494
663
  dragStartPositionsRef.current[node.id] = { x: node.position.x, y: node.position.y }
495
664
  }, [canEdit, viewId])
496
665
 
497
- const onNodeDrag: NodeDragHandler = useCallback((_e, node) => {
498
- if (!canEdit || viewId === null) return
499
- const elementId = parseNumericId(node.id)
500
- if (elementId === null) return
501
- setViewElements((prev) =>
502
- prev.map((element) =>
503
- element.element_id === elementId
504
- ? { ...element, position_x: node.position.x, position_y: node.position.y }
505
- : element,
506
- ),
507
- )
508
- }, [canEdit, setViewElements, viewId])
666
+ const onNodeDrag: NodeDragHandler = useCallback(() => {
667
+ // React Flow already updates rfNodes via onNodesChange while dragging.
668
+ // Mirroring into viewElements here forces every derived edge/node to rebuild
669
+ // on each pointer frame, so persist to app state only on drag stop.
670
+ }, [])
509
671
 
510
672
  const positionTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
511
673
  const dragStartPositionsRef = useRef<Record<string, { x: number; y: number }>>({})
@@ -522,13 +684,7 @@ export function useCanvasInteractions({
522
684
  return
523
685
  }
524
686
 
525
- setViewElements((prev) =>
526
- prev.map((element) =>
527
- element.element_id === elementId
528
- ? { ...element, position_x: node.position.x, position_y: node.position.y }
529
- : element,
530
- ),
531
- )
687
+ updateElementPosition(elementId, node.position.x, node.position.y)
532
688
  clearTimeout(positionTimers.current[node.id])
533
689
  positionTimers.current[node.id] = setTimeout(() => {
534
690
  api.workspace.views.placements
@@ -536,7 +692,7 @@ export function useCanvasInteractions({
536
692
  .catch(() => { /* intentionally empty */ })
537
693
  }, 400)
538
694
  delete dragStartPositionsRef.current[node.id]
539
- }, [canEdit, setViewElements, viewId, viewElementsRef])
695
+ }, [canEdit, updateElementPosition, viewId, viewElementsRef])
540
696
 
541
697
  // ── Connections ────────────────────────────────────────────────────────────
542
698
  const onConnect: OnConnect = useCallback(async (params: Connection) => {
@@ -554,25 +710,23 @@ export function useCanvasInteractions({
554
710
  source_handle: sourceHandle, target_handle: targetHandle,
555
711
  direction: 'forward', style: 'bezier',
556
712
  })
557
- upsertConnectorGraphSnapshot(newConnector)
558
- setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
713
+ const connector = connectorToConnector(newConnector)
714
+ await finalizeConnectorCreate(connector)
559
715
  } catch { /* intentionally empty */ }
560
- }, [canEdit, setConnectors, viewId])
716
+ }, [canEdit, finalizeConnectorCreate, viewId])
561
717
 
562
718
  const onConnectStart = useCallback((_: React.MouseEvent | React.TouchEvent, { nodeId }: OnConnectStartParams) => {
563
719
  if (!canEdit || isReconnectingRef.current) return
564
720
  connectingSourceRef.current = nodeId
565
721
  connectWasValidRef.current = false
722
+ const handleTargets = collectHandleTargets(nodeId ?? undefined)
723
+ connectorDragLastUpdateRef.current = 0
566
724
  const listener = (e: MouseEvent) => {
567
- const handles = document.querySelectorAll('.react-flow__handle')
568
- let nearHandle = false
569
- for (const handle of handles) {
570
- const rect = handle.getBoundingClientRect()
571
- if (Math.hypot(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2)) < 36) {
572
- nearHandle = true; break
573
- }
574
- }
575
- setConnectGhostPos(nearHandle ? null : { x: e.clientX, y: e.clientY })
725
+ const now = performance.now()
726
+ if (now - connectorDragLastUpdateRef.current < CONNECTOR_DRAG_UPDATE_INTERVAL_MS) return
727
+ connectorDragLastUpdateRef.current = now
728
+ const hit = findNearestHandleTargetInCache(handleTargets, e.clientX, e.clientY)
729
+ setConnectGhostPos(hit.nearHandle ? null : { x: e.clientX, y: e.clientY })
576
730
  }
577
731
  connectGhostListenerRef.current = listener
578
732
  document.addEventListener('mousemove', listener)
@@ -618,15 +772,15 @@ export function useCanvasInteractions({
618
772
  source_element_id: sourceElementId, target_element_id: targetElementId,
619
773
  source_handle: sourceHandle, target_handle: targetHandle, direction: 'forward',
620
774
  }).then((connector) => {
621
- upsertConnectorGraphSnapshot(connector)
622
- setConnectors((prev) => [...prev, connectorToConnector(connector)])
775
+ const next = connectorToConnector(connector)
776
+ void finalizeConnectorCreate(next)
623
777
  }).catch(() => { /* intentionally empty */ })
624
778
  } else {
625
779
  setPendingConnectionSource(sourceElementId)
626
780
  suppressNextPaneClickRef.current = true
627
781
  showAddingElementAt(clientX, clientY, true, 'connect', 'shiftKey' in event && event.shiftKey)
628
782
  }
629
- }, [canEdit, setConnectors, showAddingElementAt, rfNodesRef, viewIdRef])
783
+ }, [canEdit, finalizeConnectorCreate, showAddingElementAt, rfNodesRef, viewIdRef])
630
784
 
631
785
  // ── Reconnect ──────────────────────────────────────────────────────────────
632
786
  const performReconnect = useCallback(async (oldConnector: RFEdge, newConnection: Connection) => {
@@ -636,7 +790,6 @@ export function useCanvasInteractions({
636
790
  const targetId = parseNumericId(newConnection.target)
637
791
  if (edgeId === null || sourceId === null || targetId === null) return
638
792
  setRfEdges((eds) => reconnectEdge(oldConnector, newConnection, eds))
639
- setSelectedEdgeId(null)
640
793
  try {
641
794
  const existingData = oldConnector.data as Connector
642
795
  const sourceHandle = getLogicalHandleId(newConnection.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE)
@@ -650,12 +803,11 @@ export function useCanvasInteractions({
650
803
  style: existingData?.style === 'default' ? 'bezier' : (existingData?.style ?? 'bezier'),
651
804
  url: existingData?.url ?? undefined, relationship: existingData?.relationship ?? undefined,
652
805
  })
653
- upsertConnectorGraphSnapshot(updated)
654
- setConnectors((prev) =>
655
- prev.map((connector) => (connector.id === edgeId ? connectorToConnector(updated) : connector)),
656
- )
806
+ const connector = connectorToConnector(updated)
807
+ upsertConnectorGraphSnapshot(connector)
808
+ replaceConnector(connector)
657
809
  } catch { /* intentionally empty */ }
658
- }, [canEdit, setConnectors, viewId, setRfEdges, setSelectedEdgeId])
810
+ }, [canEdit, replaceConnector, viewId, setRfEdges])
659
811
  const onReconnect = useCallback(async (oldConnector: RFEdge, newConnection: Connection) => {
660
812
  await performReconnect(oldConnector, newConnection)
661
813
  }, [performReconnect])
@@ -685,6 +837,8 @@ export function useCanvasInteractions({
685
837
  setConnectGhostPos(null)
686
838
  clearHandleReconnectListeners()
687
839
  isReconnectingRef.current = true
840
+ const handleTargets = collectHandleTargets(fixedNodeId)
841
+ connectorDragLastUpdateRef.current = 0
688
842
  syncHandleReconnectDrag({
689
843
  edgeId: args.edgeId,
690
844
  endpoint: args.endpoint,
@@ -695,7 +849,10 @@ export function useCanvasInteractions({
695
849
  })
696
850
 
697
851
  const move = (event: PointerEvent) => {
698
- const hit = findNearestHandleTarget(event.clientX, event.clientY)
852
+ const now = performance.now()
853
+ if (now - connectorDragLastUpdateRef.current < CONNECTOR_DRAG_UPDATE_INTERVAL_MS) return
854
+ connectorDragLastUpdateRef.current = now
855
+ const hit = findNearestHandleTargetInCache(handleTargets, event.clientX, event.clientY)
699
856
  const current = handleReconnectDragRef.current
700
857
  if (!current) return
701
858
  syncHandleReconnectDrag({
@@ -776,7 +933,7 @@ export function useCanvasInteractions({
776
933
  document.addEventListener('pointermove', move)
777
934
  document.addEventListener('pointerup', up)
778
935
  document.addEventListener('pointercancel', up)
779
- }, [canEdit, clearHandleReconnectListeners, findNearestHandleTarget, performReconnect, rfNodesRef, _rfEdgesRef, syncHandleReconnectDrag])
936
+ }, [canEdit, clearHandleReconnectListeners, performReconnect, rfNodesRef, _rfEdgesRef, syncHandleReconnectDrag])
780
937
 
781
938
  // ── Click-connect ghost cursor tracking ────────────────────────────────────
782
939
  useEffect(() => {
@@ -785,43 +942,29 @@ export function useCanvasInteractions({
785
942
  setConnectGhostPos(null)
786
943
  return
787
944
  }
945
+ const handleTargets = collectHandleTargets(clickConnectMode.sourceNodeId)
946
+ const sourceHandleTargets = collectHandleTargets().filter((target) => target.nodeId === clickConnectMode.sourceNodeId)
947
+ connectorDragLastUpdateRef.current = 0
788
948
  const listener = (e: MouseEvent) => {
789
- const handles = document.querySelectorAll('.react-flow__handle')
790
- let nearHandle = false
791
- let snapPos = { x: e.clientX, y: e.clientY }
792
- let hoveredTargetHandleId: string | undefined = undefined
793
- let nearestTargetHandleDistance = Infinity
794
- for (const handle of handles) {
795
- if (handle.closest(`[data-id="${clickConnectMode.sourceNodeId}"]`)) continue
796
- const rect = handle.getBoundingClientRect()
797
- const cx = rect.left + rect.width / 2
798
- const cy = rect.top + rect.height / 2
799
- const dist = Math.hypot(e.clientX - cx, e.clientY - cy)
800
- if (dist < 36 && dist < nearestTargetHandleDistance) {
801
- nearestTargetHandleDistance = dist
802
- nearHandle = true
803
- snapPos = { x: cx, y: cy }
804
- hoveredTargetHandleId = handle.getAttribute('data-handleid') || handle.id
805
- }
806
- }
807
- setClickConnectCursorPos(snapPos)
808
- setConnectGhostPos(nearHandle ? null : { x: e.clientX, y: e.clientY })
949
+ const now = performance.now()
950
+ if (now - connectorDragLastUpdateRef.current < CONNECTOR_DRAG_UPDATE_INTERVAL_MS) return
951
+ connectorDragLastUpdateRef.current = now
952
+ const hit = findNearestHandleTargetInCache(handleTargets, e.clientX, e.clientY)
953
+ setClickConnectCursorPos(hit.snapPos)
954
+ setConnectGhostPos(hit.nearHandle ? null : { x: e.clientX, y: e.clientY })
809
955
  setClickConnectMode((prev) => {
810
956
  if (!prev) return null
811
957
  let bestHandle = prev.sourceHandle
812
- const sourceNodeEl = document.querySelector(`.react-flow__node[data-id="${prev.sourceNodeId}"]`)
813
- if (sourceNodeEl) {
814
- const sourceHandles = sourceNodeEl.querySelectorAll('.react-flow__handle')
815
- let minDist = Infinity
816
- for (const h of sourceHandles) {
817
- const rect = h.getBoundingClientRect()
818
- const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2
819
- const dist = Math.hypot(e.clientX - cx, e.clientY - cy)
820
- if (dist < minDist) { minDist = dist; bestHandle = h.getAttribute('data-handleid') || h.id }
958
+ let minDist = Infinity
959
+ for (const target of sourceHandleTargets) {
960
+ const dist = Math.hypot(e.clientX - target.x, e.clientY - target.y)
961
+ if (dist < minDist) {
962
+ minDist = dist
963
+ bestHandle = target.handleId
821
964
  }
822
965
  }
823
- if (prev.sourceHandle !== bestHandle || prev.targetHandle !== hoveredTargetHandleId) {
824
- return { ...prev, sourceHandle: bestHandle, targetHandle: hoveredTargetHandleId }
966
+ if (prev.sourceHandle !== bestHandle || prev.targetHandle !== hit.hoveredHandleId) {
967
+ return { ...prev, sourceHandle: bestHandle, targetHandle: hit.hoveredHandleId }
825
968
  }
826
969
  return prev
827
970
  })
@@ -855,7 +998,6 @@ export function useCanvasInteractions({
855
998
  setSelectedElement(null)
856
999
  closeElementPanel()
857
1000
  setSelectedEdge(null)
858
- setSelectedEdgeId(null)
859
1001
  closeConnectorPanel()
860
1002
  setSelectedProxyConnectorDetails((rfConnector.data as { details?: import('../../../crossBranch/types').ProxyConnectorDetails }).details ?? null)
861
1003
  openProxyConnectorPanel()
@@ -863,16 +1005,14 @@ export function useCanvasInteractions({
863
1005
  }
864
1006
  const clickedId = parseNumericId(rfConnector.id)
865
1007
  if (clickedId === null) return
866
- if (selectedEdgeId === clickedId) {
867
- const connector = connectors.find((e) => e.id === clickedId)
868
- if (connector) { setSelectedEdge(connector); openConnectorPanelRef.current() }
869
- setSelectedEdgeId(null)
870
- } else {
871
- setSelectedElement(null)
872
- closeElementPanel()
873
- setSelectedEdgeId(clickedId)
874
- }
875
- }, [closeConnectorPanel, closeElementPanel, connectors, openProxyConnectorPanel, selectedEdgeId, setSelectedEdge, setSelectedEdgeId, setSelectedElement, setSelectedProxyConnectorDetails])
1008
+ const connector = connectors.find((e) => e.id === clickedId)
1009
+ if (!connector) return
1010
+ setSelectedElement(null)
1011
+ closeElementPanel()
1012
+ setSelectedProxyConnectorDetails(null)
1013
+ setSelectedEdge(connector)
1014
+ openConnectorPanelRef.current()
1015
+ }, [closeElementPanel, connectors, openProxyConnectorPanel, setSelectedEdge, setSelectedElement, setSelectedProxyConnectorDetails])
876
1016
 
877
1017
  // ── Pane interactions ─────────────────────────────────────────────────────
878
1018
  const onPaneClick = useCallback((e: React.MouseEvent) => {
@@ -881,7 +1021,6 @@ export function useCanvasInteractions({
881
1021
  setReconnectPicking(null)
882
1022
  setSelectedElement(null)
883
1023
  setSelectedEdge(null)
884
- setSelectedEdgeId(null)
885
1024
  setSelectedProxyConnectorDetails(null)
886
1025
  setConnectorLongPressMenu(null)
887
1026
  setCanvasMenu(null)
@@ -904,14 +1043,18 @@ export function useCanvasInteractions({
904
1043
  } else {
905
1044
  setInteractionSourceId(null)
906
1045
  setPendingConnectionSource(sourceId)
1046
+ pendingConnectionSourceHandleRef.current = clickConnectModeRef.current?.sourceHandle
1047
+ ? getLogicalHandleId(clickConnectModeRef.current.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
1048
+ : pendingConnectionSourceHandleRef.current
907
1049
  showAddingElementAt(e.clientX, e.clientY, true, 'connect', e.shiftKey)
908
1050
  }
909
1051
  return
910
1052
  }
911
1053
  setInteractionSourceId(null)
912
1054
  setPendingConnectionSource(null)
1055
+ pendingConnectionSourceHandleRef.current = null
913
1056
  setAddingElementAt(null)
914
- }, [stableOnConnectTo, showAddingElementAt, closeElementPanel, closeConnectorPanel, closeProxyConnectorPanel, rfNodesRef, interactionSourceIdRef, setSelectedElement, setSelectedEdge, setSelectedEdgeId, setSelectedProxyConnectorDetails])
1057
+ }, [stableOnConnectTo, showAddingElementAt, closeElementPanel, closeConnectorPanel, closeProxyConnectorPanel, rfNodesRef, interactionSourceIdRef, setSelectedElement, setSelectedEdge, setSelectedProxyConnectorDetails])
915
1058
 
916
1059
  const onPaneContextMenu = useCallback((e: React.MouseEvent) => {
917
1060
  e.preventDefault()
@@ -930,21 +1073,23 @@ export function useCanvasInteractions({
930
1073
  }, [])
931
1074
 
932
1075
  const onMoveStart = useCallback(() => {
1076
+ if (isMovingRef.current) return
1077
+ isMovingRef.current = true
933
1078
  setCanvasMenu(null)
934
1079
  setConnectorLongPressMenu(null)
935
1080
  setAddingElementAt(null)
936
- setRfNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data, isCanvasMoving: true } })))
937
1081
  onMoveStateChange?.(true)
938
- }, [setRfNodes, onMoveStateChange])
1082
+ }, [onMoveStateChange])
939
1083
 
940
1084
  const onMove = useCallback((_: unknown, viewport: { x: number; y: number; zoom: number }) => {
941
1085
  drawingCanvasRef.current?.notifyViewportChange(viewport)
942
1086
  }, [drawingCanvasRef])
943
1087
 
944
1088
  const onMoveEnd = useCallback(() => {
945
- setRfNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data, isCanvasMoving: false } })))
1089
+ if (!isMovingRef.current) return
1090
+ isMovingRef.current = false
946
1091
  onMoveStateChange?.(false)
947
- }, [setRfNodes, onMoveStateChange])
1092
+ }, [onMoveStateChange])
948
1093
 
949
1094
  // ── Touch & long-press ────────────────────────────────────────────────────
950
1095
  function getTouchDistance(touches: Map<number, { x: number; y: number }>): number {
@@ -1073,10 +1218,12 @@ export function useCanvasInteractions({
1073
1218
  setSelectedElement(null)
1074
1219
  closeElementPanel()
1075
1220
  }
1076
- } else if (selectedEdgeId) {
1077
- api.workspace.connectors.delete('', selectedEdgeId).then(() => {
1078
- handleConnectorDeleted(selectedEdgeId)
1079
- setSelectedEdgeId(null)
1221
+ } else {
1222
+ const connectorId = getConnectorDeletionTarget(selectedConnector)
1223
+ if (connectorId === null) return
1224
+ api.workspace.connectors.delete('', connectorId).then(() => {
1225
+ handleConnectorDeleted(connectorId)
1226
+ setSelectedEdge(null)
1080
1227
  closeConnectorPanel()
1081
1228
  }).catch(() => { /* intentionally empty */ })
1082
1229
  }
@@ -1185,7 +1332,7 @@ export function useCanvasInteractions({
1185
1332
  }
1186
1333
  window.addEventListener('keydown', handler)
1187
1334
  return () => window.removeEventListener('keydown', handler)
1188
- }, [canEdit, refreshGrid, selectedElement, selectedEdgeId, viewId, stableOnRemoveElement, handleConnectorDeleted, handleElementPermanentlyDeleted, closeElementPanel, closeConnectorPanel, viewIdRef, incomingLinksRef, treeDataRef, navigateRef, rfNodesRef, viewElementsRef, setLinksMap, showAddingElementAt, setSelectedElement, setSelectedEdge, setSelectedEdgeId, containerRef, linksMapRef])
1335
+ }, [canEdit, refreshGrid, selectedElement, selectedConnector, viewId, stableOnRemoveElement, handleConnectorDeleted, handleElementPermanentlyDeleted, closeElementPanel, closeConnectorPanel, viewIdRef, incomingLinksRef, treeDataRef, navigateRef, rfNodesRef, viewElementsRef, setLinksMap, showAddingElementAt, setSelectedElement, setSelectedEdge, containerRef, linksMapRef])
1189
1336
 
1190
1337
  // ── DnD handlers ──────────────────────────────────────────────────────────
1191
1338
  const onDragOver = useCallback((e: React.DragEvent) => {
@@ -1300,6 +1447,7 @@ export function useCanvasInteractions({
1300
1447
  stableOnHoverZoom,
1301
1448
  stableOnRemoveElement,
1302
1449
  stableOnConnectTo,
1450
+ stableOnInteractionStart,
1303
1451
  stableOnStartHandleReconnect,
1304
1452
  showAddingElementAt,
1305
1453
  // RF event handlers