@tldiagram/core-ui 1.87.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 (272) hide show
  1. package/dist/App.d.ts +1 -0
  2. package/dist/api/client.d.ts +143 -0
  3. package/dist/api/transport-vscode.d.ts +8 -0
  4. package/dist/api/transport.d.ts +1 -0
  5. package/dist/components/CodePreviewPanel-vscode.d.ts +7 -0
  6. package/dist/components/CodePreviewPanel.d.ts +9 -0
  7. package/dist/components/ConfirmDialog.d.ts +12 -0
  8. package/dist/components/ConnectorPanel.d.ts +21 -0
  9. package/dist/components/ContextBoundaryElement.d.ts +11 -0
  10. package/dist/components/ContextNeighborElement.d.ts +29 -0
  11. package/dist/components/ContextStraightConnector.d.ts +4 -0
  12. package/dist/components/CrossBranchControls.d.ts +9 -0
  13. package/dist/components/DependenciesOnboarding.d.ts +5 -0
  14. package/dist/components/DrawingCanvas.d.ts +39 -0
  15. package/dist/components/ElementLibrary-vscode.d.ts +7 -0
  16. package/dist/components/ElementLibrary.d.ts +22 -0
  17. package/dist/components/ElementNode.d.ts +36 -0
  18. package/dist/components/ElementPanel.d.ts +25 -0
  19. package/dist/components/ExploreOnboarding.d.ts +5 -0
  20. package/dist/components/ExplorePageOnboarding.d.ts +5 -0
  21. package/dist/components/ExportModal.d.ts +16 -0
  22. package/dist/components/FloatingEdge.d.ts +9 -0
  23. package/dist/components/GitSourceLinker.d.ts +8 -0
  24. package/dist/components/HeaderContext.d.ts +16 -0
  25. package/dist/components/Icons.d.ts +95 -0
  26. package/dist/components/ImportModal.d.ts +10 -0
  27. package/dist/components/InlineElementAdder.d.ts +17 -0
  28. package/dist/components/LayoutSection.d.ts +7 -0
  29. package/dist/components/LocalSourceLinker.d.ts +8 -0
  30. package/dist/components/MiniZoomOnboarding.d.ts +5 -0
  31. package/dist/components/NavBreadcrumb.d.ts +6 -0
  32. package/dist/components/NodeBody.d.ts +12 -0
  33. package/dist/components/NodeContainer.d.ts +8 -0
  34. package/dist/components/NodeHoverCard.d.ts +10 -0
  35. package/dist/components/PanelHeader.d.ts +8 -0
  36. package/dist/components/PanelUI.d.ts +3 -0
  37. package/dist/components/ProxyConnectorEdge.d.ts +4 -0
  38. package/dist/components/ProxyConnectorPanel.d.ts +9 -0
  39. package/dist/components/SafeBackground.d.ts +13 -0
  40. package/dist/components/ScrollIndicatorWrapper.d.ts +8 -0
  41. package/dist/components/SetChildModal.d.ts +10 -0
  42. package/dist/components/SetParentModal.d.ts +10 -0
  43. package/dist/components/SlidingPanel.d.ts +16 -0
  44. package/dist/components/TagUpsert.d.ts +8 -0
  45. package/dist/components/TopMenuBar.d.ts +8 -0
  46. package/dist/components/ViewBezierConnector.d.ts +4 -0
  47. package/dist/components/ViewDrawMenu.d.ts +22 -0
  48. package/dist/components/ViewEditorEdgeLabelLayout.d.ts +16 -0
  49. package/dist/components/ViewEditorOnboarding.d.ts +5 -0
  50. package/dist/components/ViewExplorer/TagManager/ColorPicker.d.ts +7 -0
  51. package/dist/components/ViewExplorer/TagManager/GroupNamingPopover.d.ts +10 -0
  52. package/dist/components/ViewExplorer/TagManager/LayerItem.d.ts +27 -0
  53. package/dist/components/ViewExplorer/TagManager/TagItem.d.ts +25 -0
  54. package/dist/components/ViewExplorer/TagManager/index.d.ts +21 -0
  55. package/dist/components/ViewExplorer/ViewNavigator.d.ts +11 -0
  56. package/dist/components/ViewExplorer/ViewSearch.d.ts +8 -0
  57. package/dist/components/ViewExplorer/ViewTree.d.ts +18 -0
  58. package/dist/components/ViewExplorer/index.d.ts +31 -0
  59. package/dist/components/ViewExplorer/types.d.ts +11 -0
  60. package/dist/components/ViewExplorer/utils.d.ts +6 -0
  61. package/dist/components/ViewExplorer-vscode.d.ts +6 -0
  62. package/dist/components/ViewFloatingMenu-vscode.d.ts +27 -0
  63. package/dist/components/ViewFloatingMenu.d.ts +39 -0
  64. package/dist/components/ViewGridNode.d.ts +29 -0
  65. package/dist/components/ViewHeaderButton.d.ts +11 -0
  66. package/dist/components/ViewPanel.d.ts +18 -0
  67. package/dist/components/ViewsGridOnboarding.d.ts +5 -0
  68. package/dist/components/ZUI/ZUICanvas.d.ts +18 -0
  69. package/dist/components/ZUI/index.d.ts +2 -0
  70. package/dist/components/ZUI/layout.d.ts +18 -0
  71. package/dist/components/ZUI/proxy.d.ts +25 -0
  72. package/dist/components/ZUI/renderer.d.ts +30 -0
  73. package/dist/components/ZUI/types.d.ts +140 -0
  74. package/dist/components/ZUI/useZUIInteraction.d.ts +21 -0
  75. package/dist/config/runtime-vscode.d.ts +22 -0
  76. package/dist/config/runtime.d.ts +5 -0
  77. package/dist/constants/colors.d.ts +27 -0
  78. package/dist/constants/diagramColors.d.ts +1 -0
  79. package/dist/context/ThemeContext.d.ts +27 -0
  80. package/dist/crossBranch/graph.d.ts +13 -0
  81. package/dist/crossBranch/resolve.d.ts +22 -0
  82. package/dist/crossBranch/settings.d.ts +6 -0
  83. package/dist/crossBranch/store.d.ts +11 -0
  84. package/dist/crossBranch/types.d.ts +96 -0
  85. package/dist/demo/DemoPage.d.ts +9 -0
  86. package/dist/demo/seed.d.ts +9 -0
  87. package/dist/demo/store.d.ts +137 -0
  88. package/dist/demo/viewEditor.d.ts +26 -0
  89. package/dist/favicon.svg +35 -0
  90. package/dist/hooks/useSafeFitView.d.ts +16 -0
  91. package/dist/index.css +1 -0
  92. package/dist/index.d.ts +115 -0
  93. package/dist/index.js +19966 -0
  94. package/dist/lib/vscodeBridge-vscode.d.ts +13 -0
  95. package/dist/lib/vscodeBridge.d.ts +5 -0
  96. package/dist/logo-120.png +0 -0
  97. package/dist/logo-bw.png +0 -0
  98. package/dist/logo-bw.svg +15 -0
  99. package/dist/logo-text.svg +51 -0
  100. package/dist/logo.svg +35 -0
  101. package/dist/pages/AppearanceSettings.d.ts +3 -0
  102. package/dist/pages/Dependencies.d.ts +1 -0
  103. package/dist/pages/InfiniteZoom.d.ts +7 -0
  104. package/dist/pages/Settings.d.ts +7 -0
  105. package/dist/pages/ViewEditor/components/EditorMenus.d.ts +24 -0
  106. package/dist/pages/ViewEditor/components/EditorOverlays.d.ts +30 -0
  107. package/dist/pages/ViewEditor/components/EmptyCanvasState.d.ts +7 -0
  108. package/dist/pages/ViewEditor/context.d.ts +13 -0
  109. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +201 -0
  110. package/dist/pages/ViewEditor/hooks/useDrawingEngine.d.ts +40 -0
  111. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +20 -0
  112. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +74 -0
  113. package/dist/pages/ViewEditor/index.d.ts +8 -0
  114. package/dist/pages/ViewEditor/utils.d.ts +14 -0
  115. package/dist/pages/Views.d.ts +6 -0
  116. package/dist/pages/ViewsGrid.d.ts +6 -0
  117. package/dist/pkg/importer/mermaid.d.ts +7 -0
  118. package/dist/pkg/importer/mermaid.test.d.ts +1 -0
  119. package/dist/platform/PlatformContext.d.ts +6 -0
  120. package/dist/platform/context.d.ts +3 -0
  121. package/dist/platform/local.d.ts +2 -0
  122. package/dist/platform/types.d.ts +17 -0
  123. package/dist/slots.d.ts +67 -0
  124. package/dist/theme.d.ts +2 -0
  125. package/dist/types/index.d.ts +193 -0
  126. package/dist/types/vscode-messages.d.ts +60 -0
  127. package/dist/utils/edgeDistribution.d.ts +34 -0
  128. package/dist/utils/githubApi.d.ts +4 -0
  129. package/dist/utils/githubCache.d.ts +17 -0
  130. package/dist/utils/ids.d.ts +2 -0
  131. package/dist/utils/technologyCatalog.d.ts +15 -0
  132. package/dist/utils/toast.d.ts +15 -0
  133. package/dist/utils/treesitter.d.ts +13 -0
  134. package/dist/utils/url.d.ts +12 -0
  135. package/package.json +159 -0
  136. package/src/App.tsx +141 -0
  137. package/src/api/client.ts +618 -0
  138. package/src/api/transport-vscode.ts +28 -0
  139. package/src/api/transport.ts +7 -0
  140. package/src/assets/logo-mark.svg +31 -0
  141. package/src/assets/logo-wordmark.svg +22 -0
  142. package/src/assets/logo.svg +35 -0
  143. package/src/components/CodePreviewPanel-vscode.tsx +85 -0
  144. package/src/components/CodePreviewPanel.tsx +384 -0
  145. package/src/components/ConfirmDialog.tsx +66 -0
  146. package/src/components/ConnectorPanel.tsx +403 -0
  147. package/src/components/ContextBoundaryElement.tsx +35 -0
  148. package/src/components/ContextNeighborElement.tsx +282 -0
  149. package/src/components/ContextStraightConnector.tsx +144 -0
  150. package/src/components/CrossBranchControls.tsx +105 -0
  151. package/src/components/DependenciesOnboarding.tsx +427 -0
  152. package/src/components/DrawingCanvas.tsx +391 -0
  153. package/src/components/ElementLibrary-vscode.tsx +9 -0
  154. package/src/components/ElementLibrary.tsx +512 -0
  155. package/src/components/ElementNode.tsx +1033 -0
  156. package/src/components/ElementPanel.tsx +928 -0
  157. package/src/components/ExploreOnboarding.tsx +347 -0
  158. package/src/components/ExplorePageOnboarding.tsx +383 -0
  159. package/src/components/ExportModal.tsx +132 -0
  160. package/src/components/FloatingEdge.tsx +115 -0
  161. package/src/components/GitSourceLinker.tsx +1053 -0
  162. package/src/components/HeaderContext.tsx +30 -0
  163. package/src/components/Icons.tsx +245 -0
  164. package/src/components/ImportModal.tsx +219 -0
  165. package/src/components/InlineElementAdder.tsx +216 -0
  166. package/src/components/LayoutSection.tsx +624 -0
  167. package/src/components/LocalSourceLinker.tsx +330 -0
  168. package/src/components/MiniZoomOnboarding.tsx +78 -0
  169. package/src/components/NavBreadcrumb.tsx +24 -0
  170. package/src/components/NodeBody.tsx +89 -0
  171. package/src/components/NodeContainer.tsx +58 -0
  172. package/src/components/NodeHoverCard.tsx +135 -0
  173. package/src/components/PanelHeader.tsx +36 -0
  174. package/src/components/PanelUI.tsx +24 -0
  175. package/src/components/ProxyConnectorEdge.tsx +169 -0
  176. package/src/components/ProxyConnectorPanel.tsx +130 -0
  177. package/src/components/SafeBackground.tsx +19 -0
  178. package/src/components/ScrollIndicatorWrapper.tsx +117 -0
  179. package/src/components/SetChildModal.tsx +191 -0
  180. package/src/components/SetParentModal.tsx +187 -0
  181. package/src/components/SlidingPanel.tsx +114 -0
  182. package/src/components/TagUpsert.tsx +142 -0
  183. package/src/components/TopMenuBar.tsx +380 -0
  184. package/src/components/ViewBezierConnector.tsx +143 -0
  185. package/src/components/ViewDrawMenu.tsx +270 -0
  186. package/src/components/ViewEditorEdgeLabelLayout.ts +189 -0
  187. package/src/components/ViewEditorOnboarding.tsx +445 -0
  188. package/src/components/ViewExplorer/TagManager/ColorPicker.tsx +49 -0
  189. package/src/components/ViewExplorer/TagManager/GroupNamingPopover.tsx +96 -0
  190. package/src/components/ViewExplorer/TagManager/LayerItem.tsx +228 -0
  191. package/src/components/ViewExplorer/TagManager/TagItem.tsx +242 -0
  192. package/src/components/ViewExplorer/TagManager/index.tsx +418 -0
  193. package/src/components/ViewExplorer/ViewNavigator.tsx +121 -0
  194. package/src/components/ViewExplorer/ViewSearch.tsx +33 -0
  195. package/src/components/ViewExplorer/ViewTree.tsx +98 -0
  196. package/src/components/ViewExplorer/index.tsx +384 -0
  197. package/src/components/ViewExplorer/types.ts +13 -0
  198. package/src/components/ViewExplorer/utils.ts +56 -0
  199. package/src/components/ViewExplorer-vscode.tsx +8 -0
  200. package/src/components/ViewFloatingMenu-vscode.tsx +248 -0
  201. package/src/components/ViewFloatingMenu.tsx +379 -0
  202. package/src/components/ViewGridNode.tsx +451 -0
  203. package/src/components/ViewHeaderButton.tsx +60 -0
  204. package/src/components/ViewPanel.tsx +162 -0
  205. package/src/components/ViewsGridOnboarding.tsx +400 -0
  206. package/src/components/ZUI/ZUICanvas.tsx +853 -0
  207. package/src/components/ZUI/index.ts +3 -0
  208. package/src/components/ZUI/layout.ts +323 -0
  209. package/src/components/ZUI/proxy.ts +278 -0
  210. package/src/components/ZUI/renderer.ts +1189 -0
  211. package/src/components/ZUI/types.ts +150 -0
  212. package/src/components/ZUI/useZUIInteraction.ts +720 -0
  213. package/src/config/runtime-vscode.ts +46 -0
  214. package/src/config/runtime.ts +30 -0
  215. package/src/constants/colors.ts +80 -0
  216. package/src/constants/diagramColors.ts +9 -0
  217. package/src/context/ThemeContext.tsx +158 -0
  218. package/src/crossBranch/graph.ts +207 -0
  219. package/src/crossBranch/resolve.ts +643 -0
  220. package/src/crossBranch/settings.ts +59 -0
  221. package/src/crossBranch/store.ts +71 -0
  222. package/src/crossBranch/types.ts +102 -0
  223. package/src/demo/DemoPage.tsx +184 -0
  224. package/src/demo/seed.ts +67 -0
  225. package/src/demo/store.ts +536 -0
  226. package/src/demo/viewEditor.ts +110 -0
  227. package/src/hooks/useSafeFitView.ts +60 -0
  228. package/src/index.css +309 -0
  229. package/src/index.ts +184 -0
  230. package/src/kafka-ss.png +0 -0
  231. package/src/lib/vscodeBridge-vscode.ts +27 -0
  232. package/src/lib/vscodeBridge.ts +7 -0
  233. package/src/main.tsx +46 -0
  234. package/src/pages/AppearanceSettings.tsx +135 -0
  235. package/src/pages/Dependencies.tsx +926 -0
  236. package/src/pages/InfiniteZoom.tsx +404 -0
  237. package/src/pages/Settings.tsx +91 -0
  238. package/src/pages/ViewEditor/EDGE_DISTRIBUTION.md +64 -0
  239. package/src/pages/ViewEditor/components/EditorMenus.tsx +112 -0
  240. package/src/pages/ViewEditor/components/EditorOverlays.tsx +172 -0
  241. package/src/pages/ViewEditor/components/EmptyCanvasState.tsx +42 -0
  242. package/src/pages/ViewEditor/context.tsx +21 -0
  243. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +1349 -0
  244. package/src/pages/ViewEditor/hooks/useDrawingEngine.ts +127 -0
  245. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +501 -0
  246. package/src/pages/ViewEditor/hooks/useViewData.ts +491 -0
  247. package/src/pages/ViewEditor/index.tsx +1366 -0
  248. package/src/pages/ViewEditor/utils.ts +88 -0
  249. package/src/pages/Views.tsx +171 -0
  250. package/src/pages/ViewsGrid.tsx +1310 -0
  251. package/src/pkg/importer/mermaid.test.ts +141 -0
  252. package/src/pkg/importer/mermaid.ts +76 -0
  253. package/src/platform/PlatformContext.tsx +17 -0
  254. package/src/platform/context.ts +9 -0
  255. package/src/platform/local.tsx +15 -0
  256. package/src/platform/types.ts +19 -0
  257. package/src/slots.ts +92 -0
  258. package/src/styles/editor-panels.css +66 -0
  259. package/src/styles/theme.css +56 -0
  260. package/src/theme.ts +336 -0
  261. package/src/types/index.ts +234 -0
  262. package/src/types/offline-ambient.d.ts +14 -0
  263. package/src/types/vscode-messages.ts +32 -0
  264. package/src/utils/edgeDistribution.ts +103 -0
  265. package/src/utils/githubApi.ts +121 -0
  266. package/src/utils/githubCache.ts +108 -0
  267. package/src/utils/ids.ts +9 -0
  268. package/src/utils/technologyCatalog.ts +143 -0
  269. package/src/utils/toast.ts +100 -0
  270. package/src/utils/treesitter.ts +147 -0
  271. package/src/utils/url.ts +72 -0
  272. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,1349 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { DrawingCanvasHandle } from '../../../components/DrawingCanvas'
3
+ import {
4
+ applyEdgeChanges,
5
+ applyNodeChanges,
6
+ reconnectEdge,
7
+ type Connection,
8
+ type Edge as RFEdge,
9
+ type EdgeChange,
10
+ type Node as RFNode,
11
+ type NodeChange,
12
+ type NodeDragHandler,
13
+ type OnConnect,
14
+ type OnConnectStartParams,
15
+ useReactFlow,
16
+ } from 'reactflow'
17
+ import { api } from '../../../api/client'
18
+ import type {
19
+ Connector,
20
+ PlacedElement,
21
+ ViewTreeNode,
22
+ LibraryElement,
23
+ ViewLayer,
24
+ ViewConnector,
25
+ IncomingViewConnector,
26
+ } from '../../../types'
27
+ import { parseNumericId } from '../../../utils/ids'
28
+ import { connectorToConnector, findClosestHandles, findClosestHandleToPoint } from '../utils'
29
+ import { removePlacementGraphSnapshot, upsertConnectorGraphSnapshot, upsertPlacementGraphSnapshot } from '../../../crossBranch/store'
30
+ import {
31
+ DEFAULT_SOURCE_HANDLE_SIDE,
32
+ DEFAULT_TARGET_HANDLE_SIDE,
33
+ HANDLE_SLOT_CENTER_INDEX,
34
+ ensureVisualHandleId,
35
+ getLogicalHandleId,
36
+ } from '../../../utils/edgeDistribution'
37
+
38
+ const SNAP_RADIUS = 75
39
+ const CONTEXT_BOUNDARY_INSET = 36
40
+
41
+ interface CanvasInteractionOptions {
42
+ viewId: number | null
43
+ canEdit: boolean
44
+
45
+ drawingMode: boolean
46
+ isMobileLayout: boolean
47
+ rfNodesRef: React.MutableRefObject<RFNode[]>
48
+ rfEdgesRef: React.MutableRefObject<RFEdge[]>
49
+ viewElementsRef: React.MutableRefObject<PlacedElement[]>
50
+ viewIdRef: React.MutableRefObject<number | null>
51
+ incomingLinksRef: React.MutableRefObject<IncomingViewConnector[]>
52
+ treeDataRef: React.MutableRefObject<ViewTreeNode[]>
53
+ navigateRef: React.MutableRefObject<(path: string) => void>
54
+ containerRef: React.MutableRefObject<HTMLDivElement | null>
55
+ interactionSourceIdRef: React.MutableRefObject<number | null>
56
+ hoveredZoomRef: React.MutableRefObject<{ elementId: number | null; type: 'in' | 'out' | null } | null>
57
+ hoverPanLockedUntilRef: React.MutableRefObject<number>
58
+ setViewElements: React.Dispatch<React.SetStateAction<PlacedElement[]>>
59
+ setConnectors: React.Dispatch<React.SetStateAction<Connector[]>>
60
+ setRfNodes: React.Dispatch<React.SetStateAction<RFNode[]>>
61
+ setRfEdges: React.Dispatch<React.SetStateAction<RFEdge[]>>
62
+ setLinksMap: React.Dispatch<React.SetStateAction<Record<number, ViewConnector[]>>>
63
+ setParentLinksMap: React.Dispatch<React.SetStateAction<Record<number, ViewConnector[]>>>
64
+ setHoveredZoom: (val: { elementId: number | null; type: 'in' | 'out' | null } | null) => void
65
+ refreshGrid: () => Promise<void>
66
+ refreshElements: () => Promise<void>
67
+ stableOnConnectTo: (targetElementId: number) => Promise<void>
68
+ existingElementIds: Set<number>
69
+ linksMapRef: React.MutableRefObject<Record<number, ViewConnector[]>>
70
+ parentLinksMapRef: React.MutableRefObject<Record<number, ViewConnector[]>>
71
+ openElementPanel: () => void
72
+ closeElementPanel: () => void
73
+ openConnectorPanel: () => void
74
+ closeConnectorPanel: () => void
75
+ selectedElement: LibraryElement | null
76
+ selectedEdgeId: number | null
77
+ connectors: Connector[]
78
+ layers: ViewLayer[]
79
+ setSelectedElement: React.Dispatch<React.SetStateAction<LibraryElement | null>>
80
+ setSelectedEdge: (e: Connector | null) => void
81
+ setSelectedEdgeId: (id: number | null) => void
82
+ setSelectedProxyConnectorDetails: React.Dispatch<React.SetStateAction<import('../../../crossBranch/types').ProxyConnectorDetails | null>>
83
+ openProxyConnectorPanel: () => void
84
+ closeProxyConnectorPanel: () => void
85
+ handleElementDeleted: (id: number) => void
86
+ handleElementPermanentlyDeleted: (id: number) => void
87
+ handleConnectorDeleted: (id: number) => void
88
+ handleUpdateTags: (elementId: number, tags: string[]) => Promise<void>
89
+ drawingCanvasRef: React.MutableRefObject<DrawingCanvasHandle | null>
90
+ snapToGrid?: boolean
91
+ onMoveStateChange?: (isMoving: boolean) => void
92
+ }
93
+
94
+ type PickerState = {
95
+ x: number
96
+ y: number
97
+ flowX: number
98
+ flowY: number
99
+ expandResults?: boolean
100
+ mode: 'add' | 'connect'
101
+ }
102
+
103
+ type HandleReconnectDragState = {
104
+ edgeId: string
105
+ endpoint: 'source' | 'target'
106
+ fixedNodeId: string
107
+ fixedHandle: string
108
+ movingHandle: string
109
+ cursorPos: { x: number; y: number }
110
+ hoveredNodeId?: string
111
+ hoveredHandleId?: string
112
+ }
113
+
114
+ export function useCanvasInteractions({
115
+ viewId,
116
+ canEdit,
117
+
118
+ drawingMode: _drawingMode,
119
+ isMobileLayout: _isMobileLayout,
120
+ rfNodesRef,
121
+ rfEdgesRef: _rfEdgesRef,
122
+ viewElementsRef,
123
+ viewIdRef,
124
+ incomingLinksRef,
125
+ treeDataRef,
126
+ navigateRef,
127
+ containerRef,
128
+ interactionSourceIdRef,
129
+ hoveredZoomRef,
130
+ hoverPanLockedUntilRef,
131
+ setViewElements,
132
+ setConnectors,
133
+ setRfNodes,
134
+ setRfEdges,
135
+ setLinksMap,
136
+ setParentLinksMap: _setParentLinksMap,
137
+ setHoveredZoom,
138
+ refreshGrid,
139
+ refreshElements,
140
+ stableOnConnectTo,
141
+ existingElementIds,
142
+ linksMapRef,
143
+ parentLinksMapRef,
144
+ openElementPanel: _openElementPanel,
145
+ closeElementPanel: closeElementPanel,
146
+ openConnectorPanel: openConnectorPanel,
147
+ closeConnectorPanel: closeConnectorPanel,
148
+ selectedElement,
149
+ selectedEdgeId,
150
+ connectors,
151
+ layers,
152
+ setSelectedElement,
153
+ setSelectedEdge,
154
+ setSelectedEdgeId,
155
+ setSelectedProxyConnectorDetails,
156
+ openProxyConnectorPanel,
157
+ closeProxyConnectorPanel,
158
+ handleElementDeleted,
159
+ handleElementPermanentlyDeleted,
160
+ handleConnectorDeleted,
161
+ handleUpdateTags,
162
+ drawingCanvasRef,
163
+ snapToGrid,
164
+ onMoveStateChange,
165
+ }: CanvasInteractionOptions) {
166
+ const { screenToFlowPosition, setViewport, getViewport, zoomIn, zoomOut } = useReactFlow()
167
+ const screenToFlowPositionRef = useRef(screenToFlowPosition)
168
+ screenToFlowPositionRef.current = screenToFlowPosition
169
+
170
+ const [canvasMenu, setCanvasMenu] = useState<{ x: number; y: number; flowX: number; flowY: number } | null>(null)
171
+ const [addingElementAt, setAddingElementAt] = useState<PickerState | null>(null)
172
+ const [connectGhostPos, setConnectGhostPos] = useState<{ x: number; y: number } | null>(null)
173
+ const [clickConnectMode, setClickConnectMode] = useState<{ sourceNodeId: string; sourceHandle: string; targetHandle?: string } | null>(null)
174
+ const [clickConnectCursorPos, setClickConnectCursorPos] = useState<{ x: number; y: number } | null>(null)
175
+ const [interactionSourceId, setInteractionSourceId] = useState<number | null>(null)
176
+ const [pendingConnectionSource, setPendingConnectionSource] = useState<number | null>(null)
177
+ const [reconnectPicking, setReconnectPicking] = useState<{ edgeId: number; endpoint: 'source' | 'target' } | null>(null)
178
+ const [handleReconnectDrag, setHandleReconnectDrag] = useState<HandleReconnectDragState | null>(null)
179
+ const [connectorLongPressMenu, setConnectorLongPressMenu] = useState<{ edgeId: number; x: number; y: number } | null>(null)
180
+
181
+ interactionSourceIdRef.current = interactionSourceId
182
+
183
+ const reconnectPickingRef = useRef<{ edgeId: number; endpoint: 'source' | 'target' } | null>(null)
184
+ const handleReconnectDragRef = useRef<HandleReconnectDragState | null>(null)
185
+ const handleReconnectListenersRef = useRef<{ move: (event: PointerEvent) => void; up: (event: PointerEvent) => void } | null>(null)
186
+ const connectingSourceRef = useRef<string | null>(null)
187
+ const connectWasValidRef = useRef(false)
188
+ const connectGhostListenerRef = useRef<((e: MouseEvent) => void) | null>(null)
189
+ const isReconnectingRef = useRef(false)
190
+ const suppressNextConnectorClickRef = useRef(false)
191
+ const suppressNextPaneClickRef = useRef(false)
192
+ const longPressCanvasRef = useRef<{ timer: ReturnType<typeof setTimeout>; clientX: number; clientY: number } | null>(null)
193
+ const pendingConnectionSourceRef = useRef(pendingConnectionSource)
194
+ pendingConnectionSourceRef.current = pendingConnectionSource
195
+ const clickConnectModeRef = useRef(clickConnectMode)
196
+ clickConnectModeRef.current = clickConnectMode
197
+ const lastMousePosRef = useRef<{ clientX: number; clientY: number } | null>(null)
198
+
199
+ const touchStateRef = useRef<{
200
+ touches: Map<number, { x: number; y: number }>
201
+ initialDistance: number
202
+ isPinching: boolean
203
+ lastMultiTouchWheelTime: number
204
+ }>({ touches: new Map(), initialDistance: 0, isPinching: false, lastMultiTouchWheelTime: 0 })
205
+
206
+ const hoverPanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
207
+
208
+ const syncHandleReconnectDrag = useCallback((next: HandleReconnectDragState | null) => {
209
+ handleReconnectDragRef.current = next
210
+ setHandleReconnectDrag(next)
211
+ }, [])
212
+
213
+ const clearHandleReconnectListeners = useCallback(() => {
214
+ const listeners = handleReconnectListenersRef.current
215
+ if (!listeners) return
216
+ document.removeEventListener('pointermove', listeners.move)
217
+ document.removeEventListener('pointerup', listeners.up)
218
+ document.removeEventListener('pointercancel', listeners.up)
219
+ handleReconnectListenersRef.current = null
220
+ }, [])
221
+
222
+ const stopHandleReconnectDrag = useCallback(() => {
223
+ clearHandleReconnectListeners()
224
+ handleReconnectDragRef.current = null
225
+ setHandleReconnectDrag(null)
226
+ isReconnectingRef.current = false
227
+ }, [clearHandleReconnectListeners])
228
+
229
+ const findNearestHandleTarget = useCallback((clientX: number, clientY: number, excludeNodeId?: string) => {
230
+ const handles = document.querySelectorAll('.react-flow__handle')
231
+ let hoveredHandleId: string | undefined
232
+ let hoveredNodeId: string | undefined
233
+ let snapPos = { x: clientX, y: clientY }
234
+ let nearestDistance = Infinity
235
+
236
+ for (const handle of handles) {
237
+ const nodeId = handle.closest('.react-flow__node')?.getAttribute('data-id') || undefined
238
+ if (excludeNodeId && nodeId === excludeNodeId) continue
239
+ const rect = handle.getBoundingClientRect()
240
+ const cx = rect.left + rect.width / 2
241
+ const cy = rect.top + rect.height / 2
242
+ const dist = Math.hypot(clientX - cx, clientY - cy)
243
+ if (dist < 36 && dist < nearestDistance) {
244
+ nearestDistance = dist
245
+ snapPos = { x: cx, y: cy }
246
+ hoveredHandleId = handle.getAttribute('data-handleid') || handle.id
247
+ hoveredNodeId = nodeId
248
+ }
249
+ }
250
+
251
+ return {
252
+ nearHandle: hoveredHandleId !== undefined,
253
+ snapPos,
254
+ hoveredHandleId,
255
+ hoveredNodeId,
256
+ }
257
+ }, [])
258
+
259
+ // ── Ref-forwarded callbacks ────────────────────────────────────────────────
260
+ const openConnectorPanelRef = useRef(openConnectorPanel)
261
+ openConnectorPanelRef.current = openConnectorPanel
262
+
263
+ const resolvePickerMode = useCallback((flowX: number, flowY: number, preferredMode: 'add' | 'connect') => {
264
+ if (preferredMode !== 'connect') return preferredMode
265
+
266
+ const mainNodes = rfNodesRef.current.filter((node) => node.type === 'elementNode')
267
+ if (mainNodes.length === 0) return preferredMode
268
+
269
+ let minX = Infinity
270
+ let minY = Infinity
271
+ let maxX = -Infinity
272
+ let maxY = -Infinity
273
+
274
+ for (const node of mainNodes) {
275
+ const width = node.width ?? 200
276
+ const height = node.height ?? 90
277
+ minX = Math.min(minX, node.position.x)
278
+ minY = Math.min(minY, node.position.y)
279
+ maxX = Math.max(maxX, node.position.x + width)
280
+ maxY = Math.max(maxY, node.position.y + height)
281
+ }
282
+
283
+ const withinBoundary =
284
+ flowX >= minX - CONTEXT_BOUNDARY_INSET &&
285
+ flowX <= maxX + CONTEXT_BOUNDARY_INSET &&
286
+ flowY >= minY - CONTEXT_BOUNDARY_INSET &&
287
+ flowY <= maxY + CONTEXT_BOUNDARY_INSET
288
+
289
+ return withinBoundary ? 'add' : 'connect'
290
+ }, [rfNodesRef])
291
+
292
+ // ── showAddingElementAt ─────────────────────────────────────────────────────
293
+ const showAddingElementAt = useCallback((clientX: number, clientY: number, expandResults = false, mode: 'add' | 'connect' = 'add') => {
294
+ const rect = containerRef.current?.getBoundingClientRect()
295
+ if (!rect) return
296
+ const flowPos = screenToFlowPositionRef.current({ x: clientX, y: clientY })
297
+ let { x: flowX, y: flowY } = flowPos
298
+ if (snapToGrid) {
299
+ flowX = Math.round(flowX / 10) * 10
300
+ flowY = Math.round(flowY / 10) * 10
301
+ }
302
+ const px = clientX - rect.left
303
+ const py = clientY - rect.top
304
+ const x = expandResults
305
+ ? Math.max(100, Math.min(px, rect.width - 450))
306
+ : Math.max(120, Math.min(px, rect.width - 120))
307
+ const y = Math.max(40, Math.min(py, rect.height - 250))
308
+ setAddingElementAt({ x, y, flowX, flowY, expandResults, mode: resolvePickerMode(flowX, flowY, mode) })
309
+ }, [containerRef, snapToGrid, resolvePickerMode])
310
+
311
+ // ── Inline element adder handlers ───────────────────────────────────────────
312
+ const handleConfirmNewElement = useCallback(async (name: string) => {
313
+ if (!canEdit || viewId === null || !addingElementAt || addingElementAt.mode !== 'add') return
314
+ const { flowX, flowY } = addingElementAt
315
+ const sourceId = pendingConnectionSourceRef.current
316
+ setAddingElementAt(null)
317
+ setPendingConnectionSource(null)
318
+ try {
319
+ const obj = await api.elements.create({ name, kind: '' })
320
+ await api.workspace.views.placements.add(viewId, obj.id, flowX - 100, flowY - 40)
321
+ await refreshElements()
322
+ const placed = viewElementsRef.current.find((element) => element.element_id === obj.id)
323
+ if (placed) upsertPlacementGraphSnapshot(viewId, placed)
324
+ if (sourceId !== null && sourceId !== obj.id) {
325
+ const sourceNode = rfNodesRef.current.find((n) => n.id === String(sourceId))
326
+ const { sourceHandle, targetHandle } = sourceNode
327
+ ? findClosestHandleToPoint(sourceNode, flowX, flowY)
328
+ : { sourceHandle: 'right', targetHandle: 'left' }
329
+ const newConnector = await api.workspace.connectors.create(viewId, {
330
+ source_element_id: sourceId, target_element_id: obj.id,
331
+ source_handle: sourceHandle, target_handle: targetHandle, direction: 'forward',
332
+ })
333
+ upsertConnectorGraphSnapshot(newConnector)
334
+ setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
335
+ }
336
+ } catch { /* intentionally empty */ }
337
+ }, [canEdit, viewId, addingElementAt, refreshElements, rfNodesRef, setConnectors, viewElementsRef])
338
+
339
+ const handleConfirmExistingElement = useCallback(async (obj: LibraryElement) => {
340
+ if (!canEdit || viewId === null || !addingElementAt || addingElementAt.mode !== 'add') return
341
+ const { flowX, flowY } = addingElementAt
342
+ const sourceId = pendingConnectionSourceRef.current
343
+ setAddingElementAt(null)
344
+ setPendingConnectionSource(null)
345
+ try {
346
+ if (!existingElementIds.has(obj.id)) {
347
+ await api.workspace.views.placements.add(viewId, obj.id, flowX - 100, flowY - 40)
348
+ await refreshElements()
349
+ const placed = viewElementsRef.current.find((element) => element.element_id === obj.id)
350
+ if (placed) upsertPlacementGraphSnapshot(viewId, placed)
351
+ }
352
+ if (sourceId !== null && sourceId !== obj.id) {
353
+ const sourceNode = rfNodesRef.current.find((n) => n.id === String(sourceId))
354
+ const targetNode = rfNodesRef.current.find((n) => n.id === String(obj.id))
355
+ const { sourceHandle, targetHandle } = sourceNode && targetNode
356
+ ? findClosestHandles(sourceNode, targetNode)
357
+ : sourceNode
358
+ ? findClosestHandleToPoint(sourceNode, flowX, flowY)
359
+ : { sourceHandle: 'right', targetHandle: 'left' }
360
+ const newConnector = await api.workspace.connectors.create(viewId, {
361
+ source_element_id: sourceId, target_element_id: obj.id,
362
+ source_handle: sourceHandle, target_handle: targetHandle, direction: 'forward',
363
+ })
364
+ upsertConnectorGraphSnapshot(newConnector)
365
+ setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
366
+ }
367
+ } catch { /* intentionally empty */ }
368
+ }, [canEdit, viewId, addingElementAt, existingElementIds, refreshElements, rfNodesRef, setConnectors, viewElementsRef])
369
+
370
+ const handleConfirmConnectExistingElement = useCallback(async (obj: LibraryElement) => {
371
+ if (!canEdit || viewId === null || !addingElementAt || addingElementAt.mode !== 'connect') return
372
+ const { flowX, flowY } = addingElementAt
373
+ const sourceId = pendingConnectionSourceRef.current
374
+ setAddingElementAt(null)
375
+ setPendingConnectionSource(null)
376
+ if (sourceId == null || sourceId === obj.id) return
377
+ try {
378
+ const sourceNode = rfNodesRef.current.find((n) => n.id === String(sourceId))
379
+ const { sourceHandle, targetHandle } = sourceNode
380
+ ? findClosestHandleToPoint(sourceNode, flowX, flowY)
381
+ : { sourceHandle: 'right', targetHandle: 'left' }
382
+ const newConnector = await api.workspace.connectors.create(viewId, {
383
+ source_element_id: sourceId,
384
+ target_element_id: obj.id,
385
+ source_handle: sourceHandle,
386
+ target_handle: targetHandle,
387
+ direction: 'forward',
388
+ })
389
+ upsertConnectorGraphSnapshot(newConnector)
390
+ setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
391
+ } catch { /* intentionally empty */ }
392
+ }, [addingElementAt, canEdit, rfNodesRef, setConnectors, viewId])
393
+
394
+ // ── Zoom-in / zoom-out stable callbacks ───────────────────────────────────
395
+ const stableOnZoomIn = useCallback(async (elementId: number) => {
396
+ const childLinks = linksMapRef.current[elementId] || []
397
+ if (childLinks.length > 0) { navigateRef.current(`/views/${childLinks[0].to_view_id}`); return }
398
+
399
+ const obj = viewElementsRef.current.find((o) => o.element_id === elementId)
400
+ if (obj?.has_view) {
401
+ // Find the existing view in the tree
402
+ const findInTree = (nodes: ViewTreeNode[]): ViewTreeNode | null => {
403
+ for (const node of nodes) {
404
+ if (node.owner_element_id !== null && Number(node.owner_element_id) === Number(elementId)) return node
405
+ const found = findInTree(node.children)
406
+ if (found) return found
407
+ }
408
+ return null
409
+ }
410
+ const existingView = findInTree(treeDataRef.current)
411
+ if (existingView) {
412
+ navigateRef.current(`/views/${existingView.id}`)
413
+ return
414
+ }
415
+ }
416
+
417
+ if (!canEdit) return
418
+ const cid = viewIdRef.current
419
+ if (cid === null) return
420
+ try {
421
+ const newView = await api.workspace.views.create({ name: `${obj?.name ?? 'Element'}`, parent_view_id: elementId })
422
+ setLinksMap((prev) => ({
423
+ ...prev,
424
+ [elementId]: [...(prev[elementId] || []),
425
+ { id: 0, element_id: elementId, from_view_id: cid, to_view_id: newView.id, to_view_name: newView.name, relation_type: 'child' as const }],
426
+ }))
427
+ navigateRef.current(`/views/${newView.id}`)
428
+ } catch { /* intentionally empty */ }
429
+ }, [canEdit, linksMapRef, viewIdRef, viewElementsRef, navigateRef, setLinksMap, treeDataRef])
430
+
431
+ const stableOnZoomOut = useCallback(async (elementId: number) => {
432
+ const parentLinks = parentLinksMapRef.current[elementId] || []
433
+ // If the clicked element has no direct parent link, fall back to the current
434
+ // view's parent stored under the view's owner element ID (which may differ
435
+ // from the clicked element's ID for elements like functions/classes that
436
+ // don't own a view themselves).
437
+ const anyParentLink = parentLinks[0] ?? Object.values(parentLinksMapRef.current).flat()[0]
438
+ if (anyParentLink) { navigateRef.current(`/views/${anyParentLink.from_view_id}`); return }
439
+
440
+ // Final fallback: use current view's parent_view_id if available
441
+ const findInTreeById = (nodes: ViewTreeNode[], id: number): ViewTreeNode | null => {
442
+ for (const node of nodes) {
443
+ if (node.id === id) return node
444
+ const found = findInTreeById(node.children, id)
445
+ if (found) return found
446
+ }
447
+ return null
448
+ }
449
+ const currentView = findInTreeById(treeDataRef.current, viewIdRef.current || -1)
450
+ if (currentView?.parent_view_id) {
451
+ navigateRef.current(`/views/${currentView.parent_view_id}`)
452
+ }
453
+ }, [parentLinksMapRef, navigateRef, treeDataRef, viewIdRef])
454
+
455
+ const stableOnNavigateToView = useCallback((id: number) => {
456
+ navigateRef.current(`/views/${id}`)
457
+ }, [navigateRef])
458
+
459
+ const stableOnHoverZoom = useCallback((elementId: number, type: 'in' | 'out' | null) => {
460
+ const prev = hoveredZoomRef.current
461
+ const next = type ? { elementId, type } : null
462
+ hoveredZoomRef.current = next
463
+ setHoveredZoom(next)
464
+ setRfNodes((nodes) =>
465
+ nodes.map((n) => {
466
+ const wasHovered = prev && prev.elementId !== null && n.id === String(prev.elementId) ? prev.type : null
467
+ const isHovered = n.id === String(elementId) ? type : null
468
+ if (wasHovered === isHovered) return n
469
+ return { ...n, data: { ...n.data, isZoomHovered: isHovered } }
470
+ }),
471
+ )
472
+ }, [hoveredZoomRef, setHoveredZoom, setRfNodes])
473
+
474
+ const stableOnRemoveElement = useCallback(async (elementId: number) => {
475
+ if (!canEdit || viewId === null) return
476
+ try {
477
+ await api.workspace.views.placements.remove(viewId, elementId)
478
+ removePlacementGraphSnapshot(viewId, elementId)
479
+ handleElementDeleted(elementId)
480
+ setInteractionSourceId(null)
481
+ } catch { /* intentionally empty */ }
482
+ }, [canEdit, viewId, handleElementDeleted])
483
+
484
+ // ── Node/connector changes ─────────────────────────────────────────────────────
485
+ const onNodesChange = useCallback((changes: NodeChange[]) => {
486
+ if (!canEdit) {
487
+ const nonMutating = changes.filter((c) => c.type !== 'position')
488
+ if (nonMutating.length === 0) return
489
+ setRfNodes((nds) => applyNodeChanges(nonMutating, nds))
490
+ return
491
+ }
492
+ setRfNodes((nds) => applyNodeChanges(changes, nds))
493
+ }, [canEdit, setRfNodes])
494
+
495
+ const onEdgesChange = useCallback((changes: EdgeChange[]) => {
496
+ setRfEdges((eds) => applyEdgeChanges(changes, eds))
497
+ }, [setRfEdges])
498
+
499
+ const onNodeDragStart: NodeDragHandler = useCallback((_e, node) => {
500
+ if (!canEdit || viewId === null) return
501
+ const elementId = parseNumericId(node.id)
502
+ if (elementId === null) return
503
+ dragStartPositionsRef.current[node.id] = { x: node.position.x, y: node.position.y }
504
+ }, [canEdit, viewId])
505
+
506
+ const onNodeDrag: NodeDragHandler = useCallback((_e, node) => {
507
+ if (!canEdit || viewId === null) return
508
+ const elementId = parseNumericId(node.id)
509
+ if (elementId === null) return
510
+ setViewElements((prev) =>
511
+ prev.map((element) =>
512
+ element.element_id === elementId
513
+ ? { ...element, position_x: node.position.x, position_y: node.position.y }
514
+ : element,
515
+ ),
516
+ )
517
+ }, [canEdit, setViewElements, viewId])
518
+
519
+ const positionTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
520
+ const dragStartPositionsRef = useRef<Record<string, { x: number; y: number }>>({})
521
+ const onNodeDragStop: NodeDragHandler = useCallback((_e, node) => {
522
+ if (!canEdit || viewId === null) return
523
+ const elementId = parseNumericId(node.id)
524
+ if (elementId === null) return
525
+
526
+ // Skip update if position hasn't changed (prevents redundant calls on simple clicks)
527
+ const currentObj = viewElementsRef.current.find((o) => o.element_id === elementId)
528
+ const startPos = dragStartPositionsRef.current[node.id] ?? (currentObj ? { x: currentObj.position_x, y: currentObj.position_y } : null)
529
+ if (startPos && Math.abs(startPos.x - node.position.x) < 2 && Math.abs(startPos.y - node.position.y) < 2) {
530
+ delete dragStartPositionsRef.current[node.id]
531
+ return
532
+ }
533
+
534
+ setViewElements((prev) =>
535
+ prev.map((element) =>
536
+ element.element_id === elementId
537
+ ? { ...element, position_x: node.position.x, position_y: node.position.y }
538
+ : element,
539
+ ),
540
+ )
541
+ clearTimeout(positionTimers.current[node.id])
542
+ positionTimers.current[node.id] = setTimeout(() => {
543
+ api.workspace.views.placements
544
+ .updatePosition(viewId, elementId, node.position.x, node.position.y)
545
+ .catch(() => { /* intentionally empty */ })
546
+ }, 400)
547
+ delete dragStartPositionsRef.current[node.id]
548
+ }, [canEdit, setViewElements, viewId, viewElementsRef])
549
+
550
+ // ── Connections ────────────────────────────────────────────────────────────
551
+ const onConnect: OnConnect = useCallback(async (params: Connection) => {
552
+ if (!canEdit || isReconnectingRef.current) return
553
+ connectWasValidRef.current = true
554
+ if (viewId === null || !params.source || !params.target) return
555
+ const sourceId = parseNumericId(params.source)
556
+ const targetId = parseNumericId(params.target)
557
+ if (sourceId === null || targetId === null) return
558
+ try {
559
+ const sourceHandle = getLogicalHandleId(params.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE)
560
+ const targetHandle = getLogicalHandleId(params.targetHandle, DEFAULT_TARGET_HANDLE_SIDE)
561
+ const newConnector = await api.workspace.connectors.create(viewId, {
562
+ source_element_id: sourceId, target_element_id: targetId,
563
+ source_handle: sourceHandle, target_handle: targetHandle,
564
+ direction: 'forward', style: 'bezier',
565
+ })
566
+ upsertConnectorGraphSnapshot(newConnector)
567
+ setConnectors((prev) => [...prev, connectorToConnector(newConnector)])
568
+ } catch { /* intentionally empty */ }
569
+ }, [canEdit, setConnectors, viewId])
570
+
571
+ const onConnectStart = useCallback((_: React.MouseEvent | React.TouchEvent, { nodeId }: OnConnectStartParams) => {
572
+ if (!canEdit || isReconnectingRef.current) return
573
+ connectingSourceRef.current = nodeId
574
+ connectWasValidRef.current = false
575
+ const listener = (e: MouseEvent) => {
576
+ const handles = document.querySelectorAll('.react-flow__handle')
577
+ let nearHandle = false
578
+ for (const handle of handles) {
579
+ const rect = handle.getBoundingClientRect()
580
+ if (Math.hypot(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2)) < 36) {
581
+ nearHandle = true; break
582
+ }
583
+ }
584
+ setConnectGhostPos(nearHandle ? null : { x: e.clientX, y: e.clientY })
585
+ }
586
+ connectGhostListenerRef.current = listener
587
+ document.addEventListener('mousemove', listener)
588
+ }, [canEdit])
589
+
590
+ const onConnectEnd = useCallback((event: MouseEvent | TouchEvent) => {
591
+ if (connectGhostListenerRef.current) {
592
+ document.removeEventListener('mousemove', connectGhostListenerRef.current)
593
+ connectGhostListenerRef.current = null
594
+ }
595
+ setConnectGhostPos(null)
596
+ if (!canEdit || isReconnectingRef.current) return
597
+ const sourceId = connectingSourceRef.current
598
+ connectingSourceRef.current = null
599
+ if (!sourceId || connectWasValidRef.current) { connectWasValidRef.current = false; return }
600
+ connectWasValidRef.current = false
601
+ const target = event.target
602
+ if (target instanceof globalThis.Element) {
603
+ if (target.closest('.react-flow__handle') || target.closest('.react-flow__node')) return
604
+ }
605
+ const { clientX, clientY } = 'changedTouches' in event
606
+ ? (event as TouchEvent).changedTouches[0]
607
+ : (event as MouseEvent)
608
+ const flowPos = screenToFlowPositionRef.current({ x: clientX, y: clientY })
609
+ const nearNode = rfNodesRef.current.find((node) => {
610
+ if (node.id === sourceId) return false
611
+ const cx = node.position.x + (node.width ?? 180) / 2
612
+ const cy = node.position.y + (node.height ?? 80) / 2
613
+ return Math.hypot(flowPos.x - cx, flowPos.y - cy) < SNAP_RADIUS
614
+ })
615
+ const cid = viewIdRef.current
616
+ if (cid === null) return
617
+ const sourceElementId = parseNumericId(sourceId)
618
+ if (sourceElementId === null) return
619
+ if (nearNode) {
620
+ const targetElementId = parseNumericId(nearNode.id)
621
+ if (targetElementId === null) return
622
+ const sourceNode = rfNodesRef.current.find((n) => n.id === sourceId)
623
+ const { sourceHandle, targetHandle } = sourceNode
624
+ ? findClosestHandles(sourceNode, nearNode)
625
+ : { sourceHandle: 'right', targetHandle: 'left' }
626
+ api.workspace.connectors.create(cid, {
627
+ source_element_id: sourceElementId, target_element_id: targetElementId,
628
+ source_handle: sourceHandle, target_handle: targetHandle, direction: 'forward',
629
+ }).then((connector) => {
630
+ upsertConnectorGraphSnapshot(connector)
631
+ setConnectors((prev) => [...prev, connectorToConnector(connector)])
632
+ }).catch(() => { /* intentionally empty */ })
633
+ } else {
634
+ setPendingConnectionSource(sourceElementId)
635
+ suppressNextPaneClickRef.current = true
636
+ showAddingElementAt(clientX, clientY, true, 'connect')
637
+ }
638
+ }, [canEdit, setConnectors, showAddingElementAt, rfNodesRef, viewIdRef])
639
+
640
+ // ── Reconnect ──────────────────────────────────────────────────────────────
641
+ const performReconnect = useCallback(async (oldConnector: RFEdge, newConnection: Connection) => {
642
+ if (!canEdit || viewId === null || !newConnection.source || !newConnection.target) return
643
+ const edgeId = parseNumericId(oldConnector.id)
644
+ const sourceId = parseNumericId(newConnection.source)
645
+ const targetId = parseNumericId(newConnection.target)
646
+ if (edgeId === null || sourceId === null || targetId === null) return
647
+ setRfEdges((eds) => reconnectEdge(oldConnector, newConnection, eds))
648
+ setSelectedEdgeId(null)
649
+ try {
650
+ const existingData = oldConnector.data as Connector
651
+ const sourceHandle = getLogicalHandleId(newConnection.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE)
652
+ const targetHandle = getLogicalHandleId(newConnection.targetHandle, DEFAULT_TARGET_HANDLE_SIDE)
653
+ const updated = await api.workspace.connectors.update(viewId, edgeId, {
654
+ source_element_id: sourceId, target_element_id: targetId,
655
+ source_handle: sourceHandle ?? undefined,
656
+ target_handle: targetHandle ?? undefined,
657
+ label: existingData?.label ?? undefined, description: existingData?.description ?? undefined,
658
+ direction: existingData?.direction ?? undefined,
659
+ style: existingData?.style === 'default' ? 'bezier' : (existingData?.style ?? 'bezier'),
660
+ url: existingData?.url ?? undefined, relationship: existingData?.relationship ?? undefined,
661
+ })
662
+ upsertConnectorGraphSnapshot(updated)
663
+ setConnectors((prev) =>
664
+ prev.map((connector) => (connector.id === edgeId ? connectorToConnector(updated) : connector)),
665
+ )
666
+ } catch { /* intentionally empty */ }
667
+ }, [canEdit, setConnectors, viewId, setRfEdges, setSelectedEdgeId])
668
+ const onReconnect = useCallback(async (oldConnector: RFEdge, newConnection: Connection) => {
669
+ await performReconnect(oldConnector, newConnection)
670
+ }, [performReconnect])
671
+ const onReconnectStart = useCallback(() => { isReconnectingRef.current = true }, [])
672
+ const onReconnectEnd = useCallback(() => { isReconnectingRef.current = false }, [])
673
+
674
+ const stableOnStartHandleReconnect = useCallback((args: { edgeId: string; endpoint: 'source' | 'target'; handleId: string; clientX: number; clientY: number }) => {
675
+ if (!canEdit) return
676
+ const edge = _rfEdgesRef.current.find((candidate) => candidate.id === args.edgeId)
677
+ if (!edge) return
678
+
679
+ const fixedNodeId = args.endpoint === 'source' ? edge.target : edge.source
680
+ const fixedHandle = ensureVisualHandleId(
681
+ args.endpoint === 'source' ? edge.targetHandle : edge.sourceHandle,
682
+ args.endpoint === 'source' ? DEFAULT_TARGET_HANDLE_SIDE : DEFAULT_SOURCE_HANDLE_SIDE,
683
+ ) ?? (args.endpoint === 'source'
684
+ ? `${DEFAULT_TARGET_HANDLE_SIDE}-${HANDLE_SLOT_CENTER_INDEX}`
685
+ : `${DEFAULT_SOURCE_HANDLE_SIDE}-${HANDLE_SLOT_CENTER_INDEX}`)
686
+ const movingHandle = ensureVisualHandleId(
687
+ args.handleId,
688
+ args.endpoint === 'source' ? DEFAULT_SOURCE_HANDLE_SIDE : DEFAULT_TARGET_HANDLE_SIDE,
689
+ ) ?? args.handleId
690
+
691
+ setInteractionSourceId(null)
692
+ setClickConnectMode(null)
693
+ setClickConnectCursorPos(null)
694
+ setConnectGhostPos(null)
695
+ clearHandleReconnectListeners()
696
+ isReconnectingRef.current = true
697
+ syncHandleReconnectDrag({
698
+ edgeId: args.edgeId,
699
+ endpoint: args.endpoint,
700
+ fixedNodeId,
701
+ fixedHandle,
702
+ movingHandle,
703
+ cursorPos: { x: args.clientX, y: args.clientY },
704
+ })
705
+
706
+ const move = (event: PointerEvent) => {
707
+ const hit = findNearestHandleTarget(event.clientX, event.clientY)
708
+ const current = handleReconnectDragRef.current
709
+ if (!current) return
710
+ syncHandleReconnectDrag({
711
+ ...current,
712
+ cursorPos: hit.snapPos,
713
+ hoveredNodeId: hit.hoveredNodeId,
714
+ hoveredHandleId: hit.hoveredHandleId,
715
+ movingHandle: hit.hoveredHandleId ?? current.movingHandle,
716
+ })
717
+ }
718
+
719
+ const up = async (event: PointerEvent) => {
720
+ const current = handleReconnectDragRef.current
721
+ clearHandleReconnectListeners()
722
+ handleReconnectDragRef.current = null
723
+ setHandleReconnectDrag(null)
724
+ isReconnectingRef.current = false
725
+ suppressNextPaneClickRef.current = true
726
+ if (!current) return
727
+
728
+ const oldConnector = _rfEdgesRef.current.find((candidate) => candidate.id === current.edgeId)
729
+ if (!oldConnector) return
730
+
731
+ let newConnection: Connection | null = null
732
+
733
+ if (current.hoveredNodeId && current.hoveredHandleId) {
734
+ newConnection = current.endpoint === 'source'
735
+ ? {
736
+ source: current.hoveredNodeId,
737
+ sourceHandle: current.hoveredHandleId,
738
+ target: current.fixedNodeId,
739
+ targetHandle: current.fixedHandle,
740
+ }
741
+ : {
742
+ source: current.fixedNodeId,
743
+ sourceHandle: current.fixedHandle,
744
+ target: current.hoveredNodeId,
745
+ targetHandle: current.hoveredHandleId,
746
+ }
747
+ } else {
748
+ const flowPos = screenToFlowPositionRef.current({ x: event.clientX, y: event.clientY })
749
+ const nearNode = rfNodesRef.current.find((node) => {
750
+ if (node.id === current.fixedNodeId) return false
751
+ const cx = node.position.x + (node.width ?? 180) / 2
752
+ const cy = node.position.y + (node.height ?? 80) / 2
753
+ return Math.hypot(flowPos.x - cx, flowPos.y - cy) < SNAP_RADIUS
754
+ })
755
+
756
+ if (nearNode) {
757
+ const fixedNode = rfNodesRef.current.find((node) => node.id === current.fixedNodeId)
758
+ if (!fixedNode) return
759
+
760
+ if (current.endpoint === 'source') {
761
+ const { sourceHandle, targetHandle } = findClosestHandles(nearNode, fixedNode)
762
+ newConnection = {
763
+ source: nearNode.id,
764
+ sourceHandle: ensureVisualHandleId(sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? sourceHandle,
765
+ target: fixedNode.id,
766
+ targetHandle: ensureVisualHandleId(targetHandle, DEFAULT_TARGET_HANDLE_SIDE) ?? targetHandle,
767
+ }
768
+ } else {
769
+ const { sourceHandle, targetHandle } = findClosestHandles(fixedNode, nearNode)
770
+ newConnection = {
771
+ source: fixedNode.id,
772
+ sourceHandle: ensureVisualHandleId(sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? sourceHandle,
773
+ target: nearNode.id,
774
+ targetHandle: ensureVisualHandleId(targetHandle, DEFAULT_TARGET_HANDLE_SIDE) ?? targetHandle,
775
+ }
776
+ }
777
+ }
778
+ }
779
+
780
+ if (!newConnection) return
781
+ await performReconnect(oldConnector, newConnection)
782
+ }
783
+
784
+ handleReconnectListenersRef.current = { move, up }
785
+ document.addEventListener('pointermove', move)
786
+ document.addEventListener('pointerup', up)
787
+ document.addEventListener('pointercancel', up)
788
+ }, [canEdit, clearHandleReconnectListeners, findNearestHandleTarget, performReconnect, rfNodesRef, _rfEdgesRef, syncHandleReconnectDrag])
789
+
790
+ // ── Click-connect ghost cursor tracking ────────────────────────────────────
791
+ useEffect(() => {
792
+ if (!clickConnectMode) {
793
+ setClickConnectCursorPos(null)
794
+ setConnectGhostPos(null)
795
+ return
796
+ }
797
+ const listener = (e: MouseEvent) => {
798
+ const handles = document.querySelectorAll('.react-flow__handle')
799
+ let nearHandle = false
800
+ let snapPos = { x: e.clientX, y: e.clientY }
801
+ let hoveredTargetHandleId: string | undefined = undefined
802
+ let nearestTargetHandleDistance = Infinity
803
+ for (const handle of handles) {
804
+ if (handle.closest(`[data-id="${clickConnectMode.sourceNodeId}"]`)) continue
805
+ const rect = handle.getBoundingClientRect()
806
+ const cx = rect.left + rect.width / 2
807
+ const cy = rect.top + rect.height / 2
808
+ const dist = Math.hypot(e.clientX - cx, e.clientY - cy)
809
+ if (dist < 36 && dist < nearestTargetHandleDistance) {
810
+ nearestTargetHandleDistance = dist
811
+ nearHandle = true
812
+ snapPos = { x: cx, y: cy }
813
+ hoveredTargetHandleId = handle.getAttribute('data-handleid') || handle.id
814
+ }
815
+ }
816
+ setClickConnectCursorPos(snapPos)
817
+ setConnectGhostPos(nearHandle ? null : { x: e.clientX, y: e.clientY })
818
+ setClickConnectMode((prev) => {
819
+ if (!prev) return null
820
+ let bestHandle = prev.sourceHandle
821
+ const sourceNodeEl = document.querySelector(`.react-flow__node[data-id="${prev.sourceNodeId}"]`)
822
+ if (sourceNodeEl) {
823
+ const sourceHandles = sourceNodeEl.querySelectorAll('.react-flow__handle')
824
+ let minDist = Infinity
825
+ for (const h of sourceHandles) {
826
+ const rect = h.getBoundingClientRect()
827
+ const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2
828
+ const dist = Math.hypot(e.clientX - cx, e.clientY - cy)
829
+ if (dist < minDist) { minDist = dist; bestHandle = h.getAttribute('data-handleid') || h.id }
830
+ }
831
+ }
832
+ if (prev.sourceHandle !== bestHandle || prev.targetHandle !== hoveredTargetHandleId) {
833
+ return { ...prev, sourceHandle: bestHandle, targetHandle: hoveredTargetHandleId }
834
+ }
835
+ return prev
836
+ })
837
+ }
838
+ document.addEventListener('mousemove', listener)
839
+ return () => { document.removeEventListener('mousemove', listener); setConnectGhostPos(null) }
840
+ }, [clickConnectMode])
841
+
842
+ useEffect(() => {
843
+ if (interactionSourceId === null) setClickConnectMode(null)
844
+ }, [interactionSourceId])
845
+
846
+ useEffect(() => () => {
847
+ stopHandleReconnectDrag()
848
+ }, [stopHandleReconnectDrag])
849
+
850
+ // ── Connector interactions ─────────────────────────────────────────────────────
851
+ const onEdgeContextMenu = useCallback((e: React.MouseEvent, rfConnector: RFEdge) => {
852
+ if ((rfConnector.data as { isProxy?: boolean } | undefined)?.isProxy) return
853
+ e.preventDefault()
854
+ suppressNextConnectorClickRef.current = true
855
+ const edgeId = parseNumericId(rfConnector.id)
856
+ if (edgeId === null) return
857
+ const rect = containerRef.current?.getBoundingClientRect()
858
+ setConnectorLongPressMenu({ edgeId, x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) })
859
+ }, [containerRef])
860
+
861
+ const onEdgeClick = useCallback((_: React.MouseEvent, rfConnector: RFEdge) => {
862
+ if (suppressNextConnectorClickRef.current) { suppressNextConnectorClickRef.current = false; return }
863
+ if ((rfConnector.data as { isProxy?: boolean; details?: import('../../../crossBranch/types').ProxyConnectorDetails } | undefined)?.isProxy) {
864
+ setSelectedElement(null)
865
+ closeElementPanel()
866
+ setSelectedEdge(null)
867
+ setSelectedEdgeId(null)
868
+ closeConnectorPanel()
869
+ setSelectedProxyConnectorDetails((rfConnector.data as { details?: import('../../../crossBranch/types').ProxyConnectorDetails }).details ?? null)
870
+ openProxyConnectorPanel()
871
+ return
872
+ }
873
+ const clickedId = parseNumericId(rfConnector.id)
874
+ if (clickedId === null) return
875
+ if (selectedEdgeId === clickedId) {
876
+ const connector = connectors.find((e) => e.id === clickedId)
877
+ if (connector) { setSelectedEdge(connector); openConnectorPanelRef.current() }
878
+ setSelectedEdgeId(null)
879
+ } else {
880
+ setSelectedElement(null)
881
+ closeElementPanel()
882
+ setSelectedEdgeId(clickedId)
883
+ }
884
+ }, [closeConnectorPanel, closeElementPanel, connectors, openProxyConnectorPanel, selectedEdgeId, setSelectedEdge, setSelectedEdgeId, setSelectedElement, setSelectedProxyConnectorDetails])
885
+
886
+ // ── Pane interactions ─────────────────────────────────────────────────────
887
+ const onPaneClick = useCallback((e: React.MouseEvent) => {
888
+ if (suppressNextPaneClickRef.current) { suppressNextPaneClickRef.current = false; return }
889
+ reconnectPickingRef.current = null
890
+ setReconnectPicking(null)
891
+ setSelectedElement(null)
892
+ setSelectedEdge(null)
893
+ setSelectedEdgeId(null)
894
+ setSelectedProxyConnectorDetails(null)
895
+ setConnectorLongPressMenu(null)
896
+ setCanvasMenu(null)
897
+ closeElementPanel()
898
+ closeConnectorPanel()
899
+ closeProxyConnectorPanel()
900
+ const sourceId = interactionSourceIdRef.current
901
+ if (sourceId !== null) {
902
+ const flowPos = screenToFlowPositionRef.current({ x: e.clientX, y: e.clientY })
903
+ const nearNode = rfNodesRef.current.find((node) => {
904
+ if (node.id === String(sourceId)) return false
905
+ const cx = node.position.x + (node.width ?? 180) / 2
906
+ const cy = node.position.y + (node.height ?? 80) / 2
907
+ return Math.hypot(flowPos.x - cx, flowPos.y - cy) < SNAP_RADIUS
908
+ })
909
+ if (nearNode) {
910
+ const targetId = parseNumericId(nearNode.id)
911
+ if (targetId === null) return
912
+ stableOnConnectTo(targetId)
913
+ } else {
914
+ setInteractionSourceId(null)
915
+ setPendingConnectionSource(sourceId)
916
+ showAddingElementAt(e.clientX, e.clientY, true, 'connect')
917
+ }
918
+ return
919
+ }
920
+ setInteractionSourceId(null)
921
+ setPendingConnectionSource(null)
922
+ setAddingElementAt(null)
923
+ }, [stableOnConnectTo, showAddingElementAt, closeElementPanel, closeConnectorPanel, closeProxyConnectorPanel, rfNodesRef, interactionSourceIdRef, setSelectedElement, setSelectedEdge, setSelectedEdgeId, setSelectedProxyConnectorDetails])
924
+
925
+ const onPaneContextMenu = useCallback((e: React.MouseEvent) => {
926
+ e.preventDefault()
927
+ setConnectorLongPressMenu(null)
928
+ const rect = containerRef.current?.getBoundingClientRect()
929
+ if (!rect) return
930
+ const flowPos = screenToFlowPositionRef.current({ x: e.clientX, y: e.clientY })
931
+ const px = e.clientX - rect.left; const py = e.clientY - rect.top
932
+ const x = Math.max(75, Math.min(px, rect.width - 75))
933
+ const y = Math.max(190, Math.min(py, rect.height - 10))
934
+ setCanvasMenu({ x, y, flowX: flowPos.x, flowY: flowPos.y })
935
+ }, [containerRef])
936
+
937
+ const onPaneMouseMove = useCallback((e: React.MouseEvent) => {
938
+ lastMousePosRef.current = { clientX: e.clientX, clientY: e.clientY }
939
+ }, [])
940
+
941
+ const onMoveStart = useCallback(() => {
942
+ setCanvasMenu(null)
943
+ setConnectorLongPressMenu(null)
944
+ setAddingElementAt(null)
945
+ setRfNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data, isCanvasMoving: true } })))
946
+ onMoveStateChange?.(true)
947
+ }, [setRfNodes, onMoveStateChange])
948
+
949
+ const onMove = useCallback((_: unknown, viewport: { x: number; y: number; zoom: number }) => {
950
+ drawingCanvasRef.current?.notifyViewportChange(viewport)
951
+ }, [drawingCanvasRef])
952
+
953
+ const onMoveEnd = useCallback(() => {
954
+ setRfNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data, isCanvasMoving: false } })))
955
+ onMoveStateChange?.(false)
956
+ }, [setRfNodes, onMoveStateChange])
957
+
958
+ // ── Touch & long-press ────────────────────────────────────────────────────
959
+ function getTouchDistance(touches: Map<number, { x: number; y: number }>): number {
960
+ const points = Array.from(touches.values())
961
+ if (points.length < 2) return 0
962
+ const [p1, p2] = points
963
+ return Math.hypot(p2.x - p1.x, p2.y - p1.y)
964
+ }
965
+
966
+ const onTouchStart = useCallback((e: React.TouchEvent) => {
967
+ if (e.touches.length !== 2) { touchStateRef.current.touches.clear(); touchStateRef.current.isPinching = false; return }
968
+ touchStateRef.current.touches.clear()
969
+ for (let i = 0; i < 2; i++) {
970
+ const t = e.touches[i]
971
+ touchStateRef.current.touches.set(t.identifier, { x: t.clientX, y: t.clientY })
972
+ }
973
+ touchStateRef.current.initialDistance = getTouchDistance(touchStateRef.current.touches)
974
+ touchStateRef.current.isPinching = false
975
+ }, [])
976
+
977
+ const onTouchMove = useCallback((e: React.TouchEvent) => {
978
+ if (e.touches.length !== 2) return
979
+ touchStateRef.current.touches.clear()
980
+ for (let i = 0; i < 2; i++) {
981
+ const t = e.touches[i]
982
+ touchStateRef.current.touches.set(t.identifier, { x: t.clientX, y: t.clientY })
983
+ }
984
+ if (Math.abs(getTouchDistance(touchStateRef.current.touches) - touchStateRef.current.initialDistance) > 8) {
985
+ touchStateRef.current.isPinching = true
986
+ }
987
+ }, [])
988
+
989
+ const onTouchEnd = useCallback((e: React.TouchEvent) => {
990
+ if (e.touches.length < 2) { touchStateRef.current.touches.clear(); touchStateRef.current.isPinching = false }
991
+ }, [])
992
+
993
+ const onContainerPointerDown = useCallback((e: React.PointerEvent) => {
994
+ if (e.pointerType !== 'touch') return
995
+ const target = e.target
996
+ if (target instanceof globalThis.Element) {
997
+ if (target.closest('.react-flow__node') || target.closest('.react-flow__connector')) return
998
+ }
999
+ const { clientX, clientY } = e
1000
+ longPressCanvasRef.current = {
1001
+ clientX, clientY,
1002
+ timer: setTimeout(() => {
1003
+ longPressCanvasRef.current = null
1004
+ const rect = containerRef.current?.getBoundingClientRect()
1005
+ if (!rect) return
1006
+ const flowPos = screenToFlowPositionRef.current({ x: clientX, y: clientY })
1007
+ const px = clientX - rect.left; const py = clientY - rect.top
1008
+ setCanvasMenu({
1009
+ x: Math.max(75, Math.min(px, rect.width - 75)),
1010
+ y: Math.max(190, Math.min(py, rect.height - 10)),
1011
+ flowX: flowPos.x, flowY: flowPos.y,
1012
+ })
1013
+ }, 600),
1014
+ }
1015
+ }, [containerRef])
1016
+
1017
+ const onContainerPointerMove = useCallback((e: React.PointerEvent) => {
1018
+ lastMousePosRef.current = { clientX: e.clientX, clientY: e.clientY }
1019
+ if (!longPressCanvasRef.current) return
1020
+ const dx = e.clientX - longPressCanvasRef.current.clientX
1021
+ const dy = e.clientY - longPressCanvasRef.current.clientY
1022
+ if (Math.hypot(dx, dy) > 10) { clearTimeout(longPressCanvasRef.current.timer); longPressCanvasRef.current = null }
1023
+ }, [])
1024
+
1025
+ const onContainerPointerUp = useCallback(() => {
1026
+ if (longPressCanvasRef.current) { clearTimeout(longPressCanvasRef.current.timer); longPressCanvasRef.current = null }
1027
+ }, [])
1028
+
1029
+ // ── Hover pan ─────────────────────────────────────────────────────────────
1030
+ useEffect(() => {
1031
+ if (hoverPanTimeoutRef.current) { clearTimeout(hoverPanTimeoutRef.current); hoverPanTimeoutRef.current = null }
1032
+ const elementId = hoveredZoomRef.current?.elementId
1033
+ if (!elementId) return
1034
+ hoverPanTimeoutRef.current = setTimeout(() => {
1035
+ hoverPanTimeoutRef.current = null
1036
+ if (Date.now() < hoverPanLockedUntilRef.current) return
1037
+ const node = rfNodesRef.current.find((n) => n.id === String(elementId))
1038
+ if (!node) return
1039
+ const container = containerRef.current
1040
+ if (!container) return
1041
+ const { width: cw, height: ch } = container.getBoundingClientRect()
1042
+ const { x: vx, y: vy, zoom } = getViewport()
1043
+ const nodeW = (node.width ?? 200) * zoom; const nodeH = (node.height ?? 90) * zoom
1044
+ const sx = node.position.x * zoom + vx; const sy = node.position.y * zoom + vy
1045
+ const pad = 80
1046
+ let dx = 0; let dy = 0
1047
+ if (sx < pad) dx = pad - sx
1048
+ else if (sx + nodeW > cw - pad) dx = (cw - pad) - (sx + nodeW)
1049
+ if (sy < pad) dy = pad - sy
1050
+ else if (sy + nodeH > ch - pad) dy = (ch - pad) - (sy + nodeH)
1051
+ if (dx === 0 && dy === 0) return
1052
+ hoverPanLockedUntilRef.current = Date.now() + 900
1053
+ setViewport({ x: vx + dx, y: vy + dy, zoom }, { duration: 450 })
1054
+ }, 320)
1055
+ }, [hoveredZoomRef, hoverPanLockedUntilRef, rfNodesRef, containerRef, getViewport, setViewport])
1056
+
1057
+ // ── Keyboard navigation (WASD, e, c, delete) ──────────────────────────────
1058
+ useEffect(() => {
1059
+ const handler = async (e: KeyboardEvent) => {
1060
+ const target = e.target as HTMLElement | null
1061
+ const isInput = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' ||
1062
+ target?.tagName === 'SELECT' || target?.isContentEditable
1063
+ if (isInput) return
1064
+ if (e.key === 'Escape') { setInteractionSourceId(null); return }
1065
+ const key = e.key.toLowerCase()
1066
+ if (!['w', 'a', 's', 'd', 'c', 'e', 'backspace', 'delete', 'r'].includes(key)) return
1067
+ if (e.ctrlKey || e.altKey || e.metaKey) return
1068
+ if (key === 'c' && e.shiftKey) return
1069
+
1070
+ if (key === 'backspace' || key === 'delete' || key === 'r') {
1071
+ if (!canEdit) return
1072
+ e.preventDefault()
1073
+ if (selectedElement) {
1074
+ if (key === 'r' && e.shiftKey) {
1075
+ api.elements.delete('', selectedElement.id).then(() => {
1076
+ handleElementPermanentlyDeleted(selectedElement.id)
1077
+ setSelectedElement(null)
1078
+ closeElementPanel()
1079
+ }).catch(() => { /* intentionally empty */ })
1080
+ } else {
1081
+ stableOnRemoveElement(selectedElement.id)
1082
+ setSelectedElement(null)
1083
+ closeElementPanel()
1084
+ }
1085
+ } else if (selectedEdgeId) {
1086
+ api.workspace.connectors.delete('', selectedEdgeId).then(() => {
1087
+ handleConnectorDeleted(selectedEdgeId)
1088
+ setSelectedEdgeId(null)
1089
+ closeConnectorPanel()
1090
+ }).catch(() => { /* intentionally empty */ })
1091
+ }
1092
+ return
1093
+ }
1094
+ e.preventDefault()
1095
+ if (key === 'c') {
1096
+ if (!canEdit) return
1097
+ const rect = containerRef.current?.getBoundingClientRect()
1098
+ if (!rect) return
1099
+ let cx = rect.left + rect.width / 2
1100
+ let cy = rect.top + rect.height * 0.4
1101
+ if (lastMousePosRef.current) {
1102
+ const { clientX, clientY } = lastMousePosRef.current
1103
+ if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {
1104
+ cx = clientX; cy = clientY
1105
+ }
1106
+ }
1107
+ showAddingElementAt(cx, cy, true)
1108
+ return
1109
+ }
1110
+ if (key === 'e') {
1111
+ if (!canEdit || !selectedElement) return
1112
+ const node = rfNodesRef.current.find((n) => n.id === String(selectedElement.id))
1113
+ if (!node) return
1114
+ const cursor = lastMousePosRef.current
1115
+ const flowCursor = cursor
1116
+ ? screenToFlowPositionRef.current({ x: cursor.clientX, y: cursor.clientY })
1117
+ : { x: node.position.x + (node.width ?? 180), y: node.position.y + (node.height ?? 80) / 2 }
1118
+ const { sourceHandle } = findClosestHandleToPoint(node, flowCursor.x, flowCursor.y)
1119
+ setClickConnectMode({
1120
+ sourceNodeId: node.id,
1121
+ sourceHandle: ensureVisualHandleId(sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? sourceHandle,
1122
+ })
1123
+ setInteractionSourceId(selectedElement.id)
1124
+ if (cursor) setClickConnectCursorPos({ x: cursor.clientX, y: cursor.clientY })
1125
+ return
1126
+ }
1127
+ const cid = viewIdRef.current
1128
+ if (!cid) return
1129
+ const incoming = incomingLinksRef.current
1130
+ const tree = treeDataRef.current
1131
+ const nav = navigateRef.current
1132
+ const links = linksMapRef.current
1133
+ const treeNode = tree.find((n) => n.id === cid)
1134
+
1135
+ // Parents: parent_view_id + incoming links
1136
+ const parentIds = new Set<number>()
1137
+ if (treeNode?.parent_view_id) parentIds.add(treeNode.parent_view_id)
1138
+ incoming.forEach(l => parentIds.add(l.from_view_id))
1139
+ const allParents = Array.from(parentIds).sort((a, b) => a - b)
1140
+
1141
+ // Children: direct children in tree + linksMap
1142
+ const childIds = new Set<number>()
1143
+ tree.filter(n => n.parent_view_id === cid).forEach(n => childIds.add(n.id))
1144
+ Object.values(links).flat().forEach(l => childIds.add(l.to_view_id))
1145
+ const allChildren = Array.from(childIds).sort((a, b) => a - b)
1146
+
1147
+ // Siblings: views with same parent or at same level
1148
+ const siblings = tree
1149
+ .filter((n) => n.parent_view_id === treeNode?.parent_view_id && n.id !== cid)
1150
+ .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
1151
+ const allSiblings = [treeNode, ...siblings].filter(Boolean) as ViewTreeNode[]
1152
+ allSiblings.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
1153
+
1154
+ if (e.shiftKey) {
1155
+ if (!canEdit) return
1156
+ if (key === 'w') {
1157
+ const primaryObj = incoming[0]; if (!primaryObj) return
1158
+ try {
1159
+ const newDiag = await api.workspace.views.create({ name: `${primaryObj.element_name}` })
1160
+ await api.workspace.views.placements.add(newDiag.id, primaryObj.element_id, 200, 200)
1161
+ await refreshGrid()
1162
+ nav(`/views/${newDiag.id}`)
1163
+ } catch { /* intentionally empty */ }
1164
+ } else if (key === 's') {
1165
+ const firstObj = viewElementsRef.current[0]; if (!firstObj) return
1166
+ try {
1167
+ const newDiag = await api.workspace.views.create({ name: `${firstObj.name}`, parent_view_id: firstObj.element_id })
1168
+ setLinksMap((prev) => ({ ...prev, [firstObj.element_id]: [...(prev[firstObj.element_id] || []), { id: 0, element_id: firstObj.element_id, from_view_id: cid, to_view_id: newDiag.id, to_view_name: newDiag.name, relation_type: 'child' as const }] }))
1169
+ await refreshGrid()
1170
+ nav(`/views/${newDiag.id}`)
1171
+ } catch { /* intentionally empty */ }
1172
+ } else {
1173
+ const primaryObj = incoming[0]; if (!primaryObj) return
1174
+ try {
1175
+ const newDiag = await api.workspace.views.create({ name: `${primaryObj.element_name} - Layer`, parent_view_id: primaryObj.element_id })
1176
+ await api.workspace.views.placements.add(newDiag.id, primaryObj.element_id, 200, 200)
1177
+ await refreshGrid()
1178
+ nav(`/views/${newDiag.id}`)
1179
+ } catch { /* intentionally empty */ }
1180
+ }
1181
+ } else {
1182
+ if (key === 'w') {
1183
+ if (allParents.length > 0) nav(`/views/${allParents[0]}`)
1184
+ } else if (key === 's') {
1185
+ if (allChildren.length > 0) nav(`/views/${allChildren[0]}`)
1186
+ } else {
1187
+ if (allSiblings.length < 2) return
1188
+ const idx = allSiblings.findIndex((n) => n.id === cid)
1189
+ if (idx === -1) return
1190
+ const next = key === 'd' ? (idx + 1) % allSiblings.length : (idx - 1 + allSiblings.length) % allSiblings.length
1191
+ nav(`/views/${allSiblings[next].id}`)
1192
+ }
1193
+ }
1194
+ }
1195
+ window.addEventListener('keydown', handler)
1196
+ return () => window.removeEventListener('keydown', handler)
1197
+ }, [canEdit, refreshGrid, selectedElement, selectedEdgeId, viewId, stableOnRemoveElement, handleConnectorDeleted, handleElementPermanentlyDeleted, closeElementPanel, closeConnectorPanel, viewIdRef, incomingLinksRef, treeDataRef, navigateRef, rfNodesRef, viewElementsRef, setLinksMap, showAddingElementAt, setSelectedElement, setSelectedEdge, setSelectedEdgeId, containerRef, linksMapRef])
1198
+
1199
+ // ── DnD handlers ──────────────────────────────────────────────────────────
1200
+ const onDragOver = useCallback((e: React.DragEvent) => {
1201
+ e.preventDefault(); e.dataTransfer.dropEffect = 'move'
1202
+ }, [])
1203
+
1204
+ const onDrop = useCallback(async (e: React.DragEvent) => {
1205
+ if (!canEdit || !viewId) return
1206
+ e.preventDefault()
1207
+
1208
+ // 1. Check for View Element drop (existing functionality)
1209
+ const rawObj = e.dataTransfer.getData('application/diag-element')
1210
+ if (rawObj) {
1211
+ const obj: { id: number } = JSON.parse(rawObj)
1212
+ if (existingElementIds.has(obj.id)) return
1213
+ const pos = screenToFlowPositionRef.current({ x: e.clientX, y: e.clientY })
1214
+ try {
1215
+ await api.workspace.views.placements.add(viewId, obj.id, pos.x - 100, pos.y - 40)
1216
+ await refreshElements()
1217
+ const placed = viewElementsRef.current.find((element) => element.element_id === obj.id)
1218
+ if (placed) upsertPlacementGraphSnapshot(viewId, placed)
1219
+ } catch { /* ignored */ }
1220
+ return
1221
+ }
1222
+
1223
+ // 2. Check for Tag or Layer drop
1224
+ const tagName = e.dataTransfer.getData('application/diag-tag')
1225
+ const layerIdStr = e.dataTransfer.getData('application/diag-layer')
1226
+
1227
+ if (tagName || layerIdStr) {
1228
+ const flowPos = screenToFlowPositionRef.current({ x: e.clientX, y: e.clientY })
1229
+ // Find node under drop position
1230
+ const nodeUnderDrop = rfNodesRef.current.find((node) => {
1231
+ const x = node.position.x
1232
+ const y = node.position.y
1233
+ const w = node.width ?? 180
1234
+ const h = node.height ?? 80
1235
+ return flowPos.x >= x && flowPos.x <= x + w && flowPos.y >= y && flowPos.y <= y + h
1236
+ })
1237
+
1238
+ if (nodeUnderDrop) {
1239
+ const elementId = parseNumericId(nodeUnderDrop.id)
1240
+ if (elementId === null) return
1241
+
1242
+ // Get existing element tags
1243
+ const element = viewElementsRef.current.find(o => o.element_id === elementId)
1244
+ if (!element) return
1245
+
1246
+ const nextTags = [...(element.tags || [])]
1247
+
1248
+ if (tagName) {
1249
+ if (!nextTags.includes(tagName)) nextTags.push(tagName)
1250
+ } else if (layerIdStr) {
1251
+ const layerId = Number(layerIdStr)
1252
+ const layer = layers.find(l => l.id === layerId)
1253
+ if (layer) {
1254
+ // Merge logic: "if tag A exists and group(A&B) is added remove tag A let group take its place"
1255
+ // Since tags are just flat strings, we just ensure all group tags are present.
1256
+ layer.tags.forEach(t => {
1257
+ if (!nextTags.includes(t)) nextTags.push(t)
1258
+ })
1259
+ }
1260
+ }
1261
+
1262
+ if (nextTags.length !== (element.tags?.length ?? 0)) {
1263
+ await handleUpdateTags(elementId, nextTags)
1264
+ }
1265
+ }
1266
+ }
1267
+ }, [canEdit, viewId, existingElementIds, refreshElements, rfNodesRef, viewElementsRef, layers, handleUpdateTags])
1268
+
1269
+ const onWheelCapture = useCallback((e: React.WheelEvent) => {
1270
+ if (touchStateRef.current.touches.size === 2) return
1271
+ if (e.deltaX !== 0) touchStateRef.current.lastMultiTouchWheelTime = Date.now()
1272
+ const isRecentMultiTouch = Date.now() - touchStateRef.current.lastMultiTouchWheelTime < 1000
1273
+ const isNotchedWheel = !e.ctrlKey && e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 20
1274
+ const isMouseWheel = e.deltaMode !== 0 || isNotchedWheel
1275
+ if (isMouseWheel && !isRecentMultiTouch) {
1276
+ e.stopPropagation()
1277
+ if (e.deltaY > 0) zoomOut()
1278
+ else zoomIn()
1279
+ }
1280
+ }, [zoomIn, zoomOut])
1281
+
1282
+ return {
1283
+ // State
1284
+ canvasMenu,
1285
+ setCanvasMenu,
1286
+ addingElementAt,
1287
+ setAddingElementAt,
1288
+ connectGhostPos,
1289
+ clickConnectMode,
1290
+ clickConnectCursorPos,
1291
+ handleReconnectDrag,
1292
+ interactionSourceId,
1293
+ setInteractionSourceId,
1294
+ pendingConnectionSource,
1295
+ setPendingConnectionSource,
1296
+ reconnectPicking,
1297
+ setReconnectPicking,
1298
+ reconnectPickingRef,
1299
+ connectorLongPressMenu,
1300
+ setConnectorLongPressMenu,
1301
+ // Refs
1302
+ screenToFlowPositionRef,
1303
+ lastMousePosRef,
1304
+ touchStateRef,
1305
+ // Stable callbacks passed to node data
1306
+ stableOnZoomIn,
1307
+ stableOnZoomOut,
1308
+ stableOnNavigateToView,
1309
+ stableOnHoverZoom,
1310
+ stableOnRemoveElement,
1311
+ stableOnConnectTo,
1312
+ stableOnStartHandleReconnect,
1313
+ showAddingElementAt,
1314
+ // RF event handlers
1315
+ onNodesChange,
1316
+ onEdgesChange,
1317
+ onNodeDragStart,
1318
+ onNodeDrag,
1319
+ onNodeDragStop,
1320
+ onConnect,
1321
+ onConnectStart,
1322
+ onConnectEnd,
1323
+ onReconnect,
1324
+ onReconnectStart,
1325
+ onReconnectEnd,
1326
+ onEdgeClick,
1327
+ onEdgeContextMenu,
1328
+ onPaneClick,
1329
+ onPaneContextMenu,
1330
+ onPaneMouseMove,
1331
+ onMoveStart,
1332
+ onMove,
1333
+ onMoveEnd,
1334
+ // Container event handlers
1335
+ onTouchStart,
1336
+ onTouchMove,
1337
+ onTouchEnd,
1338
+ onContainerPointerDown,
1339
+ onContainerPointerMove,
1340
+ onContainerPointerUp,
1341
+ onDragOver,
1342
+ onDrop,
1343
+ onWheelCapture,
1344
+ // Library / adder callbacks
1345
+ handleConfirmNewElement,
1346
+ handleConfirmExistingElement,
1347
+ handleConfirmConnectExistingElement,
1348
+ }
1349
+ }