@tldiagram/core-ui 1.91.0 → 1.93.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 +9 -0
  3. package/dist/config/runtime-vscode.d.ts +1 -0
  4. package/dist/config/runtime.d.ts +1 -0
  5. package/dist/index.d.ts +0 -1
  6. package/dist/index.js +10063 -9512
  7. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +2 -1
  8. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +20 -21
  9. package/dist/shims/empty-node-module.d.ts +2 -0
  10. package/dist/store/useStore.d.ts +78 -0
  11. package/dist/store/useStore.test.d.ts +1 -0
  12. package/package.json +7 -4
  13. package/src/App.tsx +0 -4
  14. package/src/api/client.ts +39 -1
  15. package/src/components/ElementNode.tsx +11 -58
  16. package/src/components/ElementPanel.tsx +2 -2
  17. package/src/components/LayoutSection.tsx +68 -93
  18. package/src/components/ViewGridNode.tsx +1 -4
  19. package/src/components/ZUI/renderer.ts +166 -66
  20. package/src/components/ZUI/useZUIInteraction.ts +235 -81
  21. package/src/config/runtime-vscode.ts +6 -0
  22. package/src/config/runtime.ts +4 -0
  23. package/src/index.ts +0 -1
  24. package/src/main.tsx +26 -14
  25. package/src/pages/ViewEditor/context.tsx +12 -4
  26. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +172 -121
  27. package/src/pages/ViewEditor/hooks/useViewData.ts +455 -253
  28. package/src/pages/ViewEditor/index.tsx +45 -32
  29. package/src/shims/empty-node-module.ts +1 -0
  30. package/src/store/useStore.test.ts +272 -0
  31. package/src/store/useStore.ts +285 -0
  32. package/dist/demo/DemoPage.d.ts +0 -9
  33. package/dist/demo/seed.d.ts +0 -9
  34. package/dist/demo/store.d.ts +0 -137
  35. package/src/demo/DemoPage.tsx +0 -184
  36. package/src/demo/seed.ts +0 -67
  37. package/src/demo/store.ts +0 -536
@@ -34,8 +34,100 @@ 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
+ }
39
131
 
40
132
  interface CanvasInteractionOptions {
41
133
  viewId: number | null
@@ -127,8 +219,8 @@ export function useCanvasInteractions({
127
219
  interactionSourceIdRef,
128
220
  hoveredZoomRef,
129
221
  hoverPanLockedUntilRef,
130
- setViewElements,
131
- setConnectors,
222
+ setViewElements: _setViewElements,
223
+ setConnectors: _setConnectors,
132
224
  setRfNodes,
133
225
  setRfEdges,
134
226
  setLinksMap,
@@ -163,6 +255,10 @@ export function useCanvasInteractions({
163
255
  onMoveStateChange,
164
256
  }: CanvasInteractionOptions) {
165
257
  const { screenToFlowPosition, setViewport, getViewport, zoomIn, zoomOut } = useReactFlow()
258
+ const updateElementPosition = useStore((state) => state.updateElementPosition)
259
+ const removeElementPlacement = useStore((state) => state.removeElementPlacement)
260
+ const upsertConnector = useStore((state) => state.upsertConnector)
261
+ const replaceConnector = useStore((state) => state.replaceConnector)
166
262
  const screenToFlowPositionRef = useRef(screenToFlowPosition)
167
263
  screenToFlowPositionRef.current = screenToFlowPosition
168
264
 
@@ -176,6 +272,7 @@ export function useCanvasInteractions({
176
272
  const [reconnectPicking, setReconnectPicking] = useState<{ edgeId: number; endpoint: 'source' | 'target' } | null>(null)
177
273
  const [handleReconnectDrag, setHandleReconnectDrag] = useState<HandleReconnectDragState | null>(null)
178
274
  const [connectorLongPressMenu, setConnectorLongPressMenu] = useState<{ edgeId: number; x: number; y: number } | null>(null)
275
+ const isMovingRef = useRef(false)
179
276
 
180
277
  interactionSourceIdRef.current = interactionSourceId
181
278
 
@@ -185,6 +282,7 @@ export function useCanvasInteractions({
185
282
  const connectingSourceRef = useRef<string | null>(null)
186
283
  const connectWasValidRef = useRef(false)
187
284
  const connectGhostListenerRef = useRef<((e: MouseEvent) => void) | null>(null)
285
+ const connectorDragLastUpdateRef = useRef(0)
188
286
  const isReconnectingRef = useRef(false)
189
287
  const suppressNextConnectorClickRef = useRef(false)
190
288
  const suppressNextPaneClickRef = useRef(false)
@@ -225,36 +323,6 @@ export function useCanvasInteractions({
225
323
  isReconnectingRef.current = false
226
324
  }, [clearHandleReconnectListeners])
227
325
 
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
- }, [])
257
-
258
326
  // ── Ref-forwarded callbacks ────────────────────────────────────────────────
259
327
  const openConnectorPanelRef = useRef(openConnectorPanel)
260
328
  openConnectorPanelRef.current = openConnectorPanel
@@ -321,11 +389,12 @@ export function useCanvasInteractions({
321
389
  source_element_id: sourceId, target_element_id: obj.id,
322
390
  source_handle: sourceHandle, target_handle: targetHandle, direction: 'forward',
323
391
  })
324
- upsertConnectorGraphSnapshot(newConnector)
325
- setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
392
+ const connector = connectorToConnector(newConnector)
393
+ upsertConnectorGraphSnapshot(connector)
394
+ upsertConnector(connector)
326
395
  }
327
396
  } catch { /* intentionally empty */ }
328
- }, [canEdit, viewId, addingElementAt, refreshElements, rfNodesRef, setConnectors, viewElementsRef])
397
+ }, [canEdit, viewId, addingElementAt, refreshElements, rfNodesRef, upsertConnector, viewElementsRef])
329
398
 
330
399
  const handleConfirmExistingElement = useCallback(async (obj: LibraryElement) => {
331
400
  if (!canEdit || viewId === null || !addingElementAt || addingElementAt.mode !== 'add') return
@@ -352,11 +421,12 @@ export function useCanvasInteractions({
352
421
  source_element_id: sourceId, target_element_id: obj.id,
353
422
  source_handle: sourceHandle, target_handle: targetHandle, direction: 'forward',
354
423
  })
355
- upsertConnectorGraphSnapshot(newConnector)
356
- setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
424
+ const connector = connectorToConnector(newConnector)
425
+ upsertConnectorGraphSnapshot(connector)
426
+ upsertConnector(connector)
357
427
  }
358
428
  } catch { /* intentionally empty */ }
359
- }, [canEdit, viewId, addingElementAt, existingElementIds, refreshElements, rfNodesRef, setConnectors, viewElementsRef])
429
+ }, [canEdit, viewId, addingElementAt, existingElementIds, refreshElements, rfNodesRef, upsertConnector, viewElementsRef])
360
430
 
361
431
  const handleConfirmConnectExistingElement = useCallback(async (obj: LibraryElement) => {
362
432
  if (!canEdit || viewId === null || !addingElementAt || addingElementAt.mode !== 'connect') return
@@ -377,10 +447,11 @@ export function useCanvasInteractions({
377
447
  target_handle: targetHandle,
378
448
  direction: 'forward',
379
449
  })
380
- upsertConnectorGraphSnapshot(newConnector)
381
- setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
450
+ const connector = connectorToConnector(newConnector)
451
+ upsertConnectorGraphSnapshot(connector)
452
+ upsertConnector(connector)
382
453
  } catch { /* intentionally empty */ }
383
- }, [addingElementAt, canEdit, rfNodesRef, setConnectors, viewId])
454
+ }, [addingElementAt, canEdit, rfNodesRef, upsertConnector, viewId])
384
455
 
385
456
  // ── Zoom-in / zoom-out stable callbacks ───────────────────────────────────
386
457
  const stableOnZoomIn = useCallback(async (elementId: number) => {
@@ -467,20 +538,21 @@ export function useCanvasInteractions({
467
538
  try {
468
539
  await api.workspace.views.placements.remove(viewId, elementId)
469
540
  removePlacementGraphSnapshot(viewId, elementId)
541
+ removeElementPlacement(elementId)
470
542
  handleElementDeleted(elementId)
471
543
  setInteractionSourceId(null)
472
544
  } catch { /* intentionally empty */ }
473
- }, [canEdit, viewId, handleElementDeleted])
545
+ }, [canEdit, viewId, removeElementPlacement, handleElementDeleted])
474
546
 
475
547
  // ── Node/connector changes ─────────────────────────────────────────────────────
476
548
  const onNodesChange = useCallback((changes: NodeChange[]) => {
477
549
  if (!canEdit) {
478
550
  const nonMutating = changes.filter((c) => c.type !== 'position')
479
551
  if (nonMutating.length === 0) return
480
- setRfNodes((nds) => applyNodeChanges(nonMutating, nds))
552
+ setRfNodes((nds) => applyNodeChangesWithStructuralSharing(nonMutating, nds))
481
553
  return
482
554
  }
483
- setRfNodes((nds) => applyNodeChanges(changes, nds))
555
+ setRfNodes((nds) => applyNodeChangesWithStructuralSharing(changes, nds))
484
556
  }, [canEdit, setRfNodes])
485
557
 
486
558
  const onEdgesChange = useCallback((changes: EdgeChange[]) => {
@@ -494,18 +566,11 @@ export function useCanvasInteractions({
494
566
  dragStartPositionsRef.current[node.id] = { x: node.position.x, y: node.position.y }
495
567
  }, [canEdit, viewId])
496
568
 
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])
569
+ const onNodeDrag: NodeDragHandler = useCallback(() => {
570
+ // React Flow already updates rfNodes via onNodesChange while dragging.
571
+ // Mirroring into viewElements here forces every derived edge/node to rebuild
572
+ // on each pointer frame, so persist to app state only on drag stop.
573
+ }, [])
509
574
 
510
575
  const positionTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
511
576
  const dragStartPositionsRef = useRef<Record<string, { x: number; y: number }>>({})
@@ -522,13 +587,7 @@ export function useCanvasInteractions({
522
587
  return
523
588
  }
524
589
 
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
- )
590
+ updateElementPosition(elementId, node.position.x, node.position.y)
532
591
  clearTimeout(positionTimers.current[node.id])
533
592
  positionTimers.current[node.id] = setTimeout(() => {
534
593
  api.workspace.views.placements
@@ -536,7 +595,7 @@ export function useCanvasInteractions({
536
595
  .catch(() => { /* intentionally empty */ })
537
596
  }, 400)
538
597
  delete dragStartPositionsRef.current[node.id]
539
- }, [canEdit, setViewElements, viewId, viewElementsRef])
598
+ }, [canEdit, updateElementPosition, viewId, viewElementsRef])
540
599
 
541
600
  // ── Connections ────────────────────────────────────────────────────────────
542
601
  const onConnect: OnConnect = useCallback(async (params: Connection) => {
@@ -554,25 +613,24 @@ export function useCanvasInteractions({
554
613
  source_handle: sourceHandle, target_handle: targetHandle,
555
614
  direction: 'forward', style: 'bezier',
556
615
  })
557
- upsertConnectorGraphSnapshot(newConnector)
558
- setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
616
+ const connector = connectorToConnector(newConnector)
617
+ upsertConnectorGraphSnapshot(connector)
618
+ upsertConnector(connector)
559
619
  } catch { /* intentionally empty */ }
560
- }, [canEdit, setConnectors, viewId])
620
+ }, [canEdit, upsertConnector, viewId])
561
621
 
562
622
  const onConnectStart = useCallback((_: React.MouseEvent | React.TouchEvent, { nodeId }: OnConnectStartParams) => {
563
623
  if (!canEdit || isReconnectingRef.current) return
564
624
  connectingSourceRef.current = nodeId
565
625
  connectWasValidRef.current = false
626
+ const handleTargets = collectHandleTargets(nodeId ?? undefined)
627
+ connectorDragLastUpdateRef.current = 0
566
628
  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 })
629
+ const now = performance.now()
630
+ if (now - connectorDragLastUpdateRef.current < CONNECTOR_DRAG_UPDATE_INTERVAL_MS) return
631
+ connectorDragLastUpdateRef.current = now
632
+ const hit = findNearestHandleTargetInCache(handleTargets, e.clientX, e.clientY)
633
+ setConnectGhostPos(hit.nearHandle ? null : { x: e.clientX, y: e.clientY })
576
634
  }
577
635
  connectGhostListenerRef.current = listener
578
636
  document.addEventListener('mousemove', listener)
@@ -618,15 +676,16 @@ export function useCanvasInteractions({
618
676
  source_element_id: sourceElementId, target_element_id: targetElementId,
619
677
  source_handle: sourceHandle, target_handle: targetHandle, direction: 'forward',
620
678
  }).then((connector) => {
621
- upsertConnectorGraphSnapshot(connector)
622
- setConnectors((prev) => [...prev, connectorToConnector(connector)])
679
+ const next = connectorToConnector(connector)
680
+ upsertConnectorGraphSnapshot(next)
681
+ upsertConnector(next)
623
682
  }).catch(() => { /* intentionally empty */ })
624
683
  } else {
625
684
  setPendingConnectionSource(sourceElementId)
626
685
  suppressNextPaneClickRef.current = true
627
686
  showAddingElementAt(clientX, clientY, true, 'connect', 'shiftKey' in event && event.shiftKey)
628
687
  }
629
- }, [canEdit, setConnectors, showAddingElementAt, rfNodesRef, viewIdRef])
688
+ }, [canEdit, upsertConnector, showAddingElementAt, rfNodesRef, viewIdRef])
630
689
 
631
690
  // ── Reconnect ──────────────────────────────────────────────────────────────
632
691
  const performReconnect = useCallback(async (oldConnector: RFEdge, newConnection: Connection) => {
@@ -650,12 +709,11 @@ export function useCanvasInteractions({
650
709
  style: existingData?.style === 'default' ? 'bezier' : (existingData?.style ?? 'bezier'),
651
710
  url: existingData?.url ?? undefined, relationship: existingData?.relationship ?? undefined,
652
711
  })
653
- upsertConnectorGraphSnapshot(updated)
654
- setConnectors((prev) =>
655
- prev.map((connector) => (connector.id === edgeId ? connectorToConnector(updated) : connector)),
656
- )
712
+ const connector = connectorToConnector(updated)
713
+ upsertConnectorGraphSnapshot(connector)
714
+ replaceConnector(connector)
657
715
  } catch { /* intentionally empty */ }
658
- }, [canEdit, setConnectors, viewId, setRfEdges, setSelectedEdgeId])
716
+ }, [canEdit, replaceConnector, viewId, setRfEdges, setSelectedEdgeId])
659
717
  const onReconnect = useCallback(async (oldConnector: RFEdge, newConnection: Connection) => {
660
718
  await performReconnect(oldConnector, newConnection)
661
719
  }, [performReconnect])
@@ -685,6 +743,8 @@ export function useCanvasInteractions({
685
743
  setConnectGhostPos(null)
686
744
  clearHandleReconnectListeners()
687
745
  isReconnectingRef.current = true
746
+ const handleTargets = collectHandleTargets(fixedNodeId)
747
+ connectorDragLastUpdateRef.current = 0
688
748
  syncHandleReconnectDrag({
689
749
  edgeId: args.edgeId,
690
750
  endpoint: args.endpoint,
@@ -695,7 +755,10 @@ export function useCanvasInteractions({
695
755
  })
696
756
 
697
757
  const move = (event: PointerEvent) => {
698
- const hit = findNearestHandleTarget(event.clientX, event.clientY)
758
+ const now = performance.now()
759
+ if (now - connectorDragLastUpdateRef.current < CONNECTOR_DRAG_UPDATE_INTERVAL_MS) return
760
+ connectorDragLastUpdateRef.current = now
761
+ const hit = findNearestHandleTargetInCache(handleTargets, event.clientX, event.clientY)
699
762
  const current = handleReconnectDragRef.current
700
763
  if (!current) return
701
764
  syncHandleReconnectDrag({
@@ -776,7 +839,7 @@ export function useCanvasInteractions({
776
839
  document.addEventListener('pointermove', move)
777
840
  document.addEventListener('pointerup', up)
778
841
  document.addEventListener('pointercancel', up)
779
- }, [canEdit, clearHandleReconnectListeners, findNearestHandleTarget, performReconnect, rfNodesRef, _rfEdgesRef, syncHandleReconnectDrag])
842
+ }, [canEdit, clearHandleReconnectListeners, performReconnect, rfNodesRef, _rfEdgesRef, syncHandleReconnectDrag])
780
843
 
781
844
  // ── Click-connect ghost cursor tracking ────────────────────────────────────
782
845
  useEffect(() => {
@@ -785,43 +848,29 @@ export function useCanvasInteractions({
785
848
  setConnectGhostPos(null)
786
849
  return
787
850
  }
851
+ const handleTargets = collectHandleTargets(clickConnectMode.sourceNodeId)
852
+ const sourceHandleTargets = collectHandleTargets().filter((target) => target.nodeId === clickConnectMode.sourceNodeId)
853
+ connectorDragLastUpdateRef.current = 0
788
854
  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 })
855
+ const now = performance.now()
856
+ if (now - connectorDragLastUpdateRef.current < CONNECTOR_DRAG_UPDATE_INTERVAL_MS) return
857
+ connectorDragLastUpdateRef.current = now
858
+ const hit = findNearestHandleTargetInCache(handleTargets, e.clientX, e.clientY)
859
+ setClickConnectCursorPos(hit.snapPos)
860
+ setConnectGhostPos(hit.nearHandle ? null : { x: e.clientX, y: e.clientY })
809
861
  setClickConnectMode((prev) => {
810
862
  if (!prev) return null
811
863
  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 }
864
+ let minDist = Infinity
865
+ for (const target of sourceHandleTargets) {
866
+ const dist = Math.hypot(e.clientX - target.x, e.clientY - target.y)
867
+ if (dist < minDist) {
868
+ minDist = dist
869
+ bestHandle = target.handleId
821
870
  }
822
871
  }
823
- if (prev.sourceHandle !== bestHandle || prev.targetHandle !== hoveredTargetHandleId) {
824
- return { ...prev, sourceHandle: bestHandle, targetHandle: hoveredTargetHandleId }
872
+ if (prev.sourceHandle !== bestHandle || prev.targetHandle !== hit.hoveredHandleId) {
873
+ return { ...prev, sourceHandle: bestHandle, targetHandle: hit.hoveredHandleId }
825
874
  }
826
875
  return prev
827
876
  })
@@ -930,21 +979,23 @@ export function useCanvasInteractions({
930
979
  }, [])
931
980
 
932
981
  const onMoveStart = useCallback(() => {
982
+ if (isMovingRef.current) return
983
+ isMovingRef.current = true
933
984
  setCanvasMenu(null)
934
985
  setConnectorLongPressMenu(null)
935
986
  setAddingElementAt(null)
936
- setRfNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data, isCanvasMoving: true } })))
937
987
  onMoveStateChange?.(true)
938
- }, [setRfNodes, onMoveStateChange])
988
+ }, [onMoveStateChange])
939
989
 
940
990
  const onMove = useCallback((_: unknown, viewport: { x: number; y: number; zoom: number }) => {
941
991
  drawingCanvasRef.current?.notifyViewportChange(viewport)
942
992
  }, [drawingCanvasRef])
943
993
 
944
994
  const onMoveEnd = useCallback(() => {
945
- setRfNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data, isCanvasMoving: false } })))
995
+ if (!isMovingRef.current) return
996
+ isMovingRef.current = false
946
997
  onMoveStateChange?.(false)
947
- }, [setRfNodes, onMoveStateChange])
998
+ }, [onMoveStateChange])
948
999
 
949
1000
  // ── Touch & long-press ────────────────────────────────────────────────────
950
1001
  function getTouchDistance(touches: Map<number, { x: number; y: number }>): number {