@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,1366 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { CoreUISlots } from '../../slots'
3
+ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
4
+ import { parseNumericId } from '../../utils/ids'
5
+ import { useSafeFitView } from '../../hooks/useSafeFitView'
6
+ import { SafeBackground } from '../../components/SafeBackground'
7
+ import ReactFlow, {
8
+ BackgroundVariant,
9
+ ConnectionMode,
10
+ Controls,
11
+ PanOnScrollMode,
12
+ ReactFlowProvider,
13
+ useReactFlow,
14
+ applyNodeChanges,
15
+ } from 'reactflow'
16
+ import type { EdgeMarker as RFEdgeMarker, Node as RFNode, NodeChange } from 'reactflow'
17
+ import 'reactflow/dist/style.css'
18
+ import { toPng, toSvg } from 'html-to-image'
19
+ import {
20
+ Box,
21
+ Button,
22
+ Flex,
23
+ IconButton,
24
+ Spinner,
25
+ Text,
26
+ Tooltip,
27
+ VStack,
28
+ useBreakpointValue,
29
+ useDisclosure,
30
+ useToast,
31
+ } from '@chakra-ui/react'
32
+ import {
33
+ NavigationIcon,
34
+ LibraryIcon,
35
+ ChevronLeftIcon,
36
+ ChevronRightIcon,
37
+ } from '../../components/Icons'
38
+ import { api } from '../../api/client'
39
+ import type {
40
+ ViewTreeNode,
41
+ PlacedElement,
42
+ LibraryElement as WorkspaceElement,
43
+ Connector,
44
+ ViewConnector,
45
+ Tag,
46
+ } from '../../types'
47
+ import ElementNode from '../../components/ElementNode'
48
+ import ElementPanel from '../../components/ElementPanel'
49
+ import CodePreviewPanel from '../../components/CodePreviewPanel'
50
+ import ConnectorPanel from '../../components/ConnectorPanel'
51
+ import ElementLibrary from '../../components/ElementLibrary'
52
+ import ViewExplorer from '../../components/ViewExplorer'
53
+ import { useSetHeader } from '../../components/HeaderContext'
54
+ import ViewPanel from '../../components/ViewPanel'
55
+ import InlineElementAdder from '../../components/InlineElementAdder'
56
+ import ExportModal, { type ExportOptions } from '../../components/ExportModal'
57
+ import ImportModal from '../../components/ImportModal'
58
+ import ViewEditorOnboarding from '../../components/ViewEditorOnboarding'
59
+ import DrawingCanvas, { type DrawingCanvasHandle } from '../../components/DrawingCanvas'
60
+ import ViewFloatingMenu from '../../components/ViewFloatingMenu'
61
+ import ViewDrawMenu from '../../components/ViewDrawMenu'
62
+ import ViewHeaderButton from '../../components/ViewHeaderButton'
63
+ import ViewBezierConnector from '../../components/ViewBezierConnector'
64
+ import ViewContextNeighborElement from '../../components/ContextNeighborElement'
65
+ import ContextBoundaryElement from '../../components/ContextBoundaryElement'
66
+ import ContextStraightConnector from '../../components/ContextStraightConnector'
67
+ import ProxyConnectorEdge from '../../components/ProxyConnectorEdge'
68
+ import ProxyConnectorPanel from '../../components/ProxyConnectorPanel'
69
+ import { useViewContextNeighbours } from './hooks/useViewContextNeighbours'
70
+ import type { ParsedImport } from '../../pkg/importer/mermaid'
71
+ import { vscodeBridge } from '../../lib/vscodeBridge'
72
+ import type { ExtensionToWebviewMessage } from '../../types/vscode-messages'
73
+
74
+ import { ViewEditorContext } from './context'
75
+ import { useViewData } from './hooks/useViewData'
76
+ import { useDrawingEngine } from './hooks/useDrawingEngine'
77
+ import { useCanvasInteractions } from './hooks/useCanvasInteractions'
78
+ import { sanitizeExportFilename, triggerDownload } from './utils'
79
+ import { pickUnusedColor } from '../../components/ViewExplorer/utils'
80
+
81
+ import { EmptyCanvasState } from './components/EmptyCanvasState'
82
+ import { EditorOverlays } from './components/EditorOverlays'
83
+ import { ConnectorContextMenu, CanvasContextMenu } from './components/EditorMenus'
84
+ import { overrideViewContentInSnapshot } from '../../crossBranch/graph'
85
+ import { useCrossBranchContextSettings } from '../../crossBranch/settings'
86
+ import { removeConnectorGraphSnapshot, upsertConnectorGraphSnapshot, useWorkspaceGraphSnapshot } from '../../crossBranch/store'
87
+ import type { ProxyConnectorDetails } from '../../crossBranch/types'
88
+ import { useDemoRevealViewport, type ViewEditorDemoOptions } from '../../demo/viewEditor'
89
+
90
+ const nodeTypes = {
91
+ elementNode: ElementNode,
92
+ contextNeighborNode: ViewContextNeighborElement,
93
+ ContextBoundaryElement: ContextBoundaryElement,
94
+ }
95
+ const edgeTypes = { default: ViewBezierConnector, contextStraightConnector: ContextStraightConnector, proxyConnectorEdge: ProxyConnectorEdge }
96
+ const EMPTY_LINKS: ViewConnector[] = []
97
+
98
+ function alphaColor(color: string, opacity: number): string {
99
+ if (opacity >= 1) return color
100
+ return `color-mix(in srgb, ${color} ${Math.round(opacity * 100)}%, transparent)`
101
+ }
102
+
103
+ function fadeMarker(marker: string | RFEdgeMarker | undefined, opacity: number) {
104
+ if (!marker || typeof marker === 'string') return marker
105
+ return {
106
+ ...marker,
107
+ color: alphaColor(marker.color ?? 'var(--accent)', opacity),
108
+ }
109
+ }
110
+
111
+ function areTranslateExtentsEqual(
112
+ left: [[number, number], [number, number]] | undefined,
113
+ right: [[number, number], [number, number]] | undefined,
114
+ ) {
115
+ if (left === right) return true
116
+ if (!left || !right) return !left && !right
117
+
118
+ return left[0][0] === right[0][0] &&
119
+ left[0][1] === right[0][1] &&
120
+ left[1][0] === right[1][0] &&
121
+ left[1][1] === right[1][1]
122
+ }
123
+
124
+
125
+
126
+ // ─────────────────────────────────────────────────────────────────────────────
127
+
128
+ interface Props extends CoreUISlots {
129
+ demoOptions?: ViewEditorDemoOptions
130
+ }
131
+
132
+ function ViewEditorInner({
133
+ demoOptions,
134
+ canvasOverlaySlot,
135
+ toolbarSlot,
136
+ shareSlot,
137
+ elementPanelAfterContentSlot,
138
+ connectorPanelAfterContentSlot,
139
+ rightSlot: _rightSlot,
140
+ mobileMenuSlot: _mobileMenuSlot,
141
+ userControlsSlot: _userControlsSlot,
142
+ }: Props) {
143
+ const { id: viewIdParam } = useParams<{ id: string }>()
144
+ const [searchParams, setSearchParams] = useSearchParams()
145
+ const viewId = parseNumericId(viewIdParam)
146
+ const navigate = useNavigate()
147
+ const navigateRef = useRef(navigate)
148
+ navigateRef.current = navigate
149
+
150
+ const toast = useToast()
151
+ const canEdit = true
152
+ const isOwner = true
153
+ const isFreePlan = false
154
+
155
+ const setHeader = useSetHeader()
156
+ const isMobileLayout = useBreakpointValue({ base: true, md: false }) ?? false
157
+
158
+ const elementPanel = useDisclosure()
159
+ const connectorPanel = useDisclosure()
160
+ const proxyConnectorPanel = useDisclosure()
161
+ const viewDetails = useDisclosure()
162
+ const exportModal = useDisclosure()
163
+ const importModal = useDisclosure()
164
+ const codePreview = useDisclosure()
165
+
166
+ // ── Stable disclosure refs ──────────────────────────────────────────────
167
+ const openElementPanelRef = useRef(elementPanel.onOpen)
168
+ openElementPanelRef.current = elementPanel.onOpen
169
+ const closeElementPanelRef = useRef(elementPanel.onClose)
170
+ closeElementPanelRef.current = elementPanel.onClose
171
+
172
+ const openConnectorPanelRef = useRef(connectorPanel.onOpen)
173
+ openConnectorPanelRef.current = connectorPanel.onOpen
174
+ const closeConnectorPanelRef = useRef(connectorPanel.onClose)
175
+ closeConnectorPanelRef.current = connectorPanel.onClose
176
+
177
+ const openProxyConnectorPanelRef = useRef(proxyConnectorPanel.onOpen)
178
+ openProxyConnectorPanelRef.current = proxyConnectorPanel.onOpen
179
+ const closeProxyConnectorPanelRef = useRef(proxyConnectorPanel.onClose)
180
+ closeProxyConnectorPanelRef.current = proxyConnectorPanel.onClose
181
+
182
+ const openViewDetailsRef = useRef(viewDetails.onOpen)
183
+ openViewDetailsRef.current = viewDetails.onOpen
184
+
185
+ const openCodePreviewRef = useRef(codePreview.onOpen)
186
+ openCodePreviewRef.current = codePreview.onOpen
187
+
188
+ const openExportModalRef = useRef(exportModal.onOpen)
189
+ openExportModalRef.current = exportModal.onOpen
190
+ const closeExportModalRef = useRef(exportModal.onClose)
191
+ closeExportModalRef.current = exportModal.onClose
192
+
193
+ const openImportModalRef = useRef(importModal.onOpen)
194
+ openImportModalRef.current = importModal.onOpen
195
+ const closeImportModalRef = useRef(importModal.onClose)
196
+ closeImportModalRef.current = importModal.onClose
197
+
198
+
199
+ const [selectedElement, setSelectedElement] = useState<WorkspaceElement | null>(null)
200
+ const [selectedEdge, setSelectedEdge] = useState<Connector | null>(null)
201
+ const [selectedEdgeId, setSelectedEdgeId] = useState<number | null>(null)
202
+ const [selectedProxyConnectorDetails, setSelectedProxyConnectorDetails] = useState<ProxyConnectorDetails | null>(null)
203
+ const [previewElement, setPreviewElement] = useState<PlacedElement | null>(null)
204
+ const [libraryOpen, setLibraryOpen] = useState(() => {
205
+ if (typeof window === 'undefined') return false
206
+ const stored = localStorage.getItem('diag:libraryOpen')
207
+ return stored !== null ? stored === 'true' : window.innerWidth >= 768
208
+ })
209
+ const [isExplorerOpen, setIsExplorerOpen] = useState(() => {
210
+ if (typeof window === 'undefined') return false
211
+ const stored = localStorage.getItem('diag:explorerOpen')
212
+ return stored !== null ? stored === 'true' : window.innerWidth >= 768
213
+ })
214
+
215
+ useEffect(() => { localStorage.setItem('diag:libraryOpen', String(libraryOpen)) }, [libraryOpen])
216
+ useEffect(() => { localStorage.setItem('diag:explorerOpen', String(isExplorerOpen)) }, [isExplorerOpen])
217
+ const [extrasOpen, setExtrasOpen] = useState(false)
218
+ const [isImporting, setIsImporting] = useState(false)
219
+ const [isExporting, setIsExporting] = useState(false)
220
+ const [snapToGrid, setSnapToGrid] = useState(() => {
221
+ if (typeof window === 'undefined') return false
222
+ const stored = localStorage.getItem('diag:snapToGrid')
223
+ return stored === 'true'
224
+ })
225
+ useEffect(() => { localStorage.setItem('diag:snapToGrid', String(snapToGrid)) }, [snapToGrid])
226
+ const [, setHoveredZoom] = useState<{ elementId: number | null; type: 'in' | 'out' | null } | null>(null)
227
+ const hoveredZoomRef = useRef<{ elementId: number | null; type: 'in' | 'out' | null } | null>(null)
228
+ const hoverPanLockedUntilRef = useRef(0)
229
+
230
+ const [activeTags, setActiveTags] = useState<string[]>([])
231
+ const activeTagsRef = useRef<string[]>([])
232
+ activeTagsRef.current = activeTags
233
+ const [tagColors, setTagColors] = useState<Record<string, Tag>>({})
234
+
235
+ useEffect(() => {
236
+ // api.workspace.orgs.tagColors.list().then(setTagColors).catch(() => { /* skip */ })
237
+ }, [])
238
+
239
+ const [layers, setLayers] = useState<import('../../types').ViewLayer[]>([])
240
+ const [hiddenLayerTags, setHiddenLayerTags] = useState<string[]>([])
241
+ const hiddenLayerTagsRef = useRef<string[]>([])
242
+ hiddenLayerTagsRef.current = hiddenLayerTags
243
+ const [hoveredLayerTags, setHoveredLayerTags] = useState<string[] | null>(null)
244
+ const [hoveredLayerColor, setHoveredLayerColor] = useState<string | null>(null)
245
+ const handleHoverLayer = useCallback((tags: string[] | null, color?: string | null) => {
246
+ setHoveredLayerTags(tags)
247
+ setHoveredLayerColor(tags ? (color ?? null) : null)
248
+ }, [])
249
+
250
+ useEffect(() => {
251
+ if (viewId === null) return
252
+ api.workspace.views.layers.list(viewId).then(setLayers).catch(() => { /* skip */ })
253
+ }, [viewId])
254
+
255
+ const handleCreateLayer = useCallback(async (name: string, tags: string[], color: string) => {
256
+ if (viewId === null) return
257
+ try {
258
+ const layer = await api.workspace.views.layers.create(viewId, { name, tags, color })
259
+ setLayers(prev => [...prev, layer])
260
+ } catch (e) {
261
+ toast({ status: 'error', title: 'Failed to create layer', description: String(e) })
262
+ }
263
+ }, [viewId, toast])
264
+
265
+ const handleCreateTag = useCallback(async (tag: string, color?: string, description?: string) => {
266
+ const name = tag.trim()
267
+ if (!name) return
268
+
269
+ const nextColor = color ?? tagColors[name]?.color ?? pickUnusedColor(Object.values(tagColors).map(t => t.color))
270
+ const nextDescription = description ?? tagColors[name]?.description ?? null
271
+
272
+ setTagColors((prev) => ({ ...prev, [name]: { name, color: nextColor, description: nextDescription } }))
273
+ }, [tagColors])
274
+
275
+ const handleUpdateLayer = useCallback(async (layer: import('../../types').ViewLayer) => {
276
+ if (viewId === null) return
277
+ try {
278
+ const updated = await api.workspace.views.layers.update(viewId, layer.id, layer)
279
+ setLayers(prev => prev.map(l => l.id === updated.id ? updated : l))
280
+ } catch (e) {
281
+ toast({ status: 'error', title: 'Failed to update layer', description: String(e) })
282
+ }
283
+ }, [viewId, toast])
284
+
285
+ const handleDeleteLayer = useCallback(async (layerId: number) => {
286
+ if (viewId === null) return
287
+ try {
288
+ await api.workspace.views.layers.delete(viewId, layerId)
289
+ setLayers(prev => prev.filter(l => l.id !== layerId))
290
+ } catch (e) {
291
+ toast({ status: 'error', title: 'Failed to delete layer', description: String(e) })
292
+ }
293
+ }, [viewId, toast])
294
+
295
+ const containerRef = useRef<HTMLDivElement | null>(null)
296
+ const drawingCanvasRef = useRef<DrawingCanvasHandle | null>(null)
297
+
298
+ const { safeFitView } = useSafeFitView(containerRef)
299
+ const { screenToFlowPosition, fitView, setViewport } = useReactFlow()
300
+ const screenToFlowPositionRef = useRef(screenToFlowPosition)
301
+ screenToFlowPositionRef.current = screenToFlowPosition
302
+ const needsFitView = useRef(true)
303
+ const rfReadyRef = useRef(false)
304
+ const interactionSourceIdRef = useRef<number | null>(null)
305
+
306
+ const nodeTypesMemo = useMemo(() => nodeTypes, [])
307
+ const edgeTypesMemo = useMemo(() => edgeTypes, [])
308
+ const workspaceGraphSnapshot = useWorkspaceGraphSnapshot(true)
309
+ const { settings: crossBranchSettings, setEnabled: setCrossBranchEnabled } = useCrossBranchContextSettings('editor')
310
+
311
+ const previewViewElementsRef = useRef<PlacedElement[]>([])
312
+
313
+ const handleSetActiveTags = useCallback((tags: string[]) => {
314
+ setActiveTags(tags)
315
+ }, [])
316
+
317
+ const handleSetHiddenLayerTags = useCallback((tags: string[]) => {
318
+ setHiddenLayerTags(tags)
319
+ }, [])
320
+
321
+ // stableOnConnectTo is wired after canvasInteractions is declared
322
+ const stableOnConnectToRef = useRef<(targetElementId: number) => Promise<void>>(async () => { })
323
+ const stableOnStartHandleReconnectRef = useRef<(args: { edgeId: string; endpoint: 'source' | 'target'; handleId: string; clientX: number; clientY: number }) => void>(() => { })
324
+
325
+ // ── Drawing engine ────────────────────────────────────────────────────────
326
+ const drawing = useDrawingEngine(viewId)
327
+ const {
328
+ drawingMode, setDrawingMode, drawingVisible, setDrawingVisible,
329
+ drawingPaths, setDrawingPaths: _setDrawingPaths, drawingTool, setDrawingTool,
330
+ drawingColor, setDrawingColor, drawingWidth, setDrawingWidth,
331
+ setTextEditorState, drawingHistoryRef, drawingRedoStackRef,
332
+ handleUndo, handleRedo, onPathComplete, onPathDelete, onPathUpdate,
333
+ } = drawing
334
+
335
+ // ── Data ──────────────────────────────────────────────────────────────────
336
+ const stableOnZoomInRef = useRef<(id: number) => Promise<void>>(async () => { })
337
+ const stableOnZoomOutRef = useRef<(id: number) => Promise<void>>(async () => { })
338
+ const stableOnNavigateToViewRef = useRef<(id: number) => void>(() => { })
339
+ const stableOnRemoveElementRef = useRef<(id: number) => Promise<void>>(async () => { })
340
+
341
+ const data = useViewData({
342
+ viewId,
343
+ interactionSourceId: interactionSourceIdRef.current,
344
+ clickConnectMode: null, // wired after canvasInteractions
345
+ selectedEdgeId,
346
+ activeTags,
347
+ hiddenLayerTags,
348
+ hoveredLayerTags,
349
+ hoveredLayerColor,
350
+ tagColors,
351
+ stableOnZoomIn: useCallback(async (id: number) => { await stableOnZoomInRef.current(id) }, []),
352
+ stableOnZoomOut: useCallback(async (id: number) => { await stableOnZoomOutRef.current(id) }, []),
353
+ stableOnNavigateToView: useCallback((id: number) => { stableOnNavigateToViewRef.current(id) }, []),
354
+ stableOnSelect: useCallback((obj: PlacedElement) => {
355
+ setSelectedEdge(null)
356
+ setSelectedEdgeId(null)
357
+ setSelectedProxyConnectorDetails(null)
358
+ closeProxyConnectorPanelRef.current()
359
+ closeConnectorPanelRef.current()
360
+ setSelectedElement({
361
+ id: obj.element_id, name: obj.name, description: obj.description, kind: obj.kind,
362
+ technology: obj.technology, url: obj.url, logo_url: obj.logo_url,
363
+ technology_connectors: obj.technology_connectors, tags: obj.tags, repo: obj.repo,
364
+ branch: obj.branch, file_path: obj.file_path, language: obj.language,
365
+ created_at: '', updated_at: '', has_view: false, view_label: null,
366
+ })
367
+ openElementPanelRef.current()
368
+ }, []),
369
+ stableOnOpenCodePreview: useCallback((elementId: number) => {
370
+ const obj = previewViewElementsRef.current.find((o) => o.element_id === elementId)
371
+ if (obj) {
372
+ setPreviewElement(obj)
373
+ openCodePreviewRef.current()
374
+ }
375
+ }, []),
376
+ stableOnInteractionStart: useCallback((elementId: number) => {
377
+ if (!canEdit) return
378
+ interactionSourceIdRef.current = interactionSourceIdRef.current === elementId ? null : elementId
379
+ }, [canEdit]),
380
+ stableOnConnectTo: useCallback(async (targetElementId: number) => {
381
+ await stableOnConnectToRef.current(targetElementId)
382
+ }, []),
383
+ stableOnStartHandleReconnect: useCallback((args: { edgeId: string; endpoint: 'source' | 'target'; handleId: string; clientX: number; clientY: number }) => {
384
+ stableOnStartHandleReconnectRef.current(args)
385
+ }, []),
386
+ stableOnRemoveElement: useCallback(async (id: number) => { await stableOnRemoveElementRef.current(id) }, []),
387
+ stableOnHoverZoom: useCallback((elementId: number, type: 'in' | 'out' | null) => {
388
+ const next = type ? { elementId, type } : null
389
+ hoveredZoomRef.current = next
390
+ setHoveredZoom(next)
391
+ }, []),
392
+ hoveredZoomRef,
393
+ })
394
+
395
+ const {
396
+ view, setView, viewElements, setViewElements, connectors, setConnectors,
397
+ rfNodes, setRfNodes, rfEdges, setRfEdges,
398
+ linksMap, setLinksMap, parentLinksMap, setParentLinksMap,
399
+ treeData, allElements, libraryRefresh,
400
+ existingElementIds,
401
+ viewElementsRef, linksMapRef, parentLinksMapRef, incomingLinksRef,
402
+ treeDataRef, rfNodesRef, rfEdgesRef, viewIdRef,
403
+ refreshGrid, refreshElements,
404
+ handleElementDeleted, handleElementPermanentlyDeleted, handleElementSaved,
405
+ setAllElements: _setAllElements,
406
+ } = data
407
+
408
+ const tagCounts = useMemo(() => {
409
+ const counts: Record<string, number> = {}
410
+ viewElements.forEach(p => {
411
+ (p.tags ?? []).forEach(t => { counts[t] = (counts[t] ?? 0) + 1 })
412
+ })
413
+ return counts
414
+ }, [viewElements])
415
+
416
+ const layerElementCounts = useMemo(() => {
417
+ const counts: Record<number, number> = {}
418
+ for (const layer of layers) {
419
+ let count = 0
420
+ viewElements.forEach(p => {
421
+ if ((p.tags ?? []).some(t => layer.tags.includes(t))) count++
422
+ })
423
+ counts[layer.id] = count
424
+ }
425
+ return counts
426
+ }, [viewElements, layers])
427
+
428
+ const toggleLayerVisibility = useCallback((layer: import('../../types').ViewLayer) => {
429
+ if (layer.tags.length === 0) return
430
+ const prev = hiddenLayerTagsRef.current
431
+ const allHidden = layer.tags.every(t => prev.includes(t))
432
+ const next = allHidden
433
+ ? prev.filter(t => !layer.tags.includes(t))
434
+ : Array.from(new Set([...prev, ...layer.tags]))
435
+ handleSetHiddenLayerTags(next)
436
+ }, [handleSetHiddenLayerTags])
437
+
438
+ const toggleTagVisibility = useCallback((tag: string) => {
439
+ const prev = hiddenLayerTagsRef.current
440
+ const next = prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
441
+ handleSetHiddenLayerTags(next)
442
+ }, [handleSetHiddenLayerTags])
443
+
444
+ // ── VS Code Integration ───────────────────────────────────────────────────
445
+ const hasSentLoadedRef = useRef<number | null>(null)
446
+ useEffect(() => {
447
+ if (view && viewElements.length > 0 && hasSentLoadedRef.current !== view.id) {
448
+ hasSentLoadedRef.current = view.id
449
+ vscodeBridge.postMessage({
450
+ type: 'diagram-loaded',
451
+ diagramId: view.id,
452
+ elements: allElements,
453
+ })
454
+ }
455
+ }, [view, viewElements, allElements])
456
+
457
+ useEffect(() => {
458
+ const unsub = vscodeBridge.onMessage(async (msg: ExtensionToWebviewMessage) => {
459
+ if (msg.type === 'focus-element') {
460
+ fitView({ nodes: [{ id: String(msg.elementId) }], duration: 800, padding: 100 })
461
+ } else if (msg.type === 'element-placed') {
462
+ if (viewId === null) return
463
+ try {
464
+ await api.workspace.views.placements.add(viewId, msg.elementId, msg.x, msg.y)
465
+ void refreshElements()
466
+ } catch (e) {
467
+ console.error('Failed to place element from VS Code:', e)
468
+ }
469
+ }
470
+ })
471
+ return unsub
472
+ }, [fitView, viewId, refreshElements])
473
+
474
+ const existingElements = useMemo(() => {
475
+ return viewElements.map(obj => ({
476
+ id: obj.element_id,
477
+ name: obj.name,
478
+ kind: obj.kind,
479
+ description: obj.description,
480
+ technology: obj.technology,
481
+ url: obj.url,
482
+ logo_url: obj.logo_url,
483
+ technology_connectors: obj.technology_connectors,
484
+ tags: obj.tags,
485
+ repo: obj.repo,
486
+ branch: obj.branch,
487
+ file_path: obj.file_path,
488
+ language: obj.language,
489
+ created_at: '',
490
+ updated_at: '',
491
+ has_view: false,
492
+ view_label: null,
493
+ } as WorkspaceElement))
494
+ }, [viewElements])
495
+
496
+ const availableTags = useMemo(() => {
497
+ const tags = new Set<string>()
498
+ allElements.forEach((o) => o.tags?.forEach((t: string) => tags.add(t)))
499
+ Object.keys(tagColors).forEach((t) => tags.add(t))
500
+ return Array.from(tags).sort((a, b) => a.localeCompare(b))
501
+ }, [allElements, tagColors])
502
+
503
+ const effectiveWorkspaceSnapshot = useMemo(() => {
504
+ if (viewId == null) return workspaceGraphSnapshot
505
+ return overrideViewContentInSnapshot(workspaceGraphSnapshot, viewId, viewElements, connectors)
506
+ }, [workspaceGraphSnapshot, viewId, viewElements, connectors])
507
+
508
+ const placementSummaryByElementId = useMemo(() => {
509
+ const summary: Record<number, string> = {}
510
+ if (!effectiveWorkspaceSnapshot) return summary
511
+ for (const [elementId, placements] of Object.entries(effectiveWorkspaceSnapshot.placementsByElementId)) {
512
+ const names = Array.from(new Set(placements.map((placement) => placement.viewName))).slice(0, 2)
513
+ if (names.length > 0) {
514
+ summary[Number(elementId)] = names.length > 1 ? `${names.join(' · ')}…` : names[0]
515
+ }
516
+ }
517
+ return summary
518
+ }, [effectiveWorkspaceSnapshot])
519
+
520
+ useEffect(() => {
521
+ const requestedElementId = parseNumericId(searchParams.get('element'))
522
+ if (requestedElementId == null) return
523
+ const match = viewElements.find((element) => element.element_id === requestedElementId)
524
+ if (!match) return
525
+ setSelectedEdge(null)
526
+ setSelectedEdgeId(null)
527
+ setSelectedProxyConnectorDetails(null)
528
+ closeConnectorPanelRef.current()
529
+ closeProxyConnectorPanelRef.current()
530
+ setSelectedElement({
531
+ id: match.element_id,
532
+ name: match.name,
533
+ description: match.description,
534
+ kind: match.kind,
535
+ technology: match.technology,
536
+ url: match.url,
537
+ logo_url: match.logo_url,
538
+ technology_connectors: match.technology_connectors,
539
+ tags: match.tags,
540
+ repo: match.repo,
541
+ branch: match.branch,
542
+ file_path: match.file_path,
543
+ language: match.language,
544
+ created_at: '',
545
+ updated_at: '',
546
+ has_view: match.has_view,
547
+ view_label: match.view_label,
548
+ })
549
+ openElementPanelRef.current()
550
+ const next = new URLSearchParams(searchParams)
551
+ next.delete('element')
552
+ setSearchParams(next, { replace: true })
553
+ }, [searchParams, setSearchParams, viewElements])
554
+
555
+ previewViewElementsRef.current = viewElements
556
+
557
+ const handleUpdateTags = useCallback(async (elementId: number, tags: string[]) => {
558
+ if (!canEdit) return
559
+ const obj = selectedElement?.id === elementId ? selectedElement : allElements.find(o => o.id === elementId)
560
+ if (!obj) return
561
+ try {
562
+ const saved = await api.elements.update(elementId, {
563
+ name: obj.name,
564
+ description: obj.description ?? '',
565
+ kind: obj.kind ?? '',
566
+ technology: obj.technology ?? '',
567
+ url: obj.url ?? '',
568
+ logo_url: obj.logo_url ?? '',
569
+ technology_connectors: obj.technology_connectors ?? [],
570
+ tags,
571
+ repo: obj.repo,
572
+ branch: obj.branch,
573
+ file_path: obj.file_path,
574
+ language: obj.language,
575
+ })
576
+ handleElementSaved(saved)
577
+ if (selectedElement?.id === elementId) {
578
+ setSelectedElement(saved)
579
+ }
580
+ } catch (err) {
581
+ console.error('Failed to update tags:', err)
582
+ }
583
+ }, [canEdit, selectedElement, allElements, handleElementSaved, setSelectedElement])
584
+
585
+ // ── Canvas interactions ────────────────────────────────────────────────────
586
+ const canvas = useCanvasInteractions({
587
+ viewId, canEdit,
588
+ drawingMode, isMobileLayout,
589
+ rfNodesRef, rfEdgesRef, viewElementsRef, viewIdRef,
590
+ incomingLinksRef,
591
+ treeDataRef,
592
+ navigateRef,
593
+ containerRef,
594
+ interactionSourceIdRef,
595
+ hoveredZoomRef, hoverPanLockedUntilRef,
596
+ setViewElements, setConnectors,
597
+ setRfNodes, setRfEdges,
598
+ setLinksMap, setParentLinksMap,
599
+ setHoveredZoom,
600
+ refreshGrid, refreshElements,
601
+ stableOnConnectTo: async (targetElementId: number) => {
602
+ // Inline this is the real implementation, also stored in stableOnConnectToRef
603
+ const sourceId = interactionSourceIdRef.current
604
+ const cid = viewIdRef.current
605
+ if (sourceId === null || cid === null) return
606
+ interactionSourceIdRef.current = null
607
+ const sourceNode = rfNodesRef.current.find((n) => n.id === String(sourceId))
608
+ const targetNode = rfNodesRef.current.find((n) => n.id === String(targetElementId))
609
+ let finalSourceHandle = 'right'; let finalTargetHandle = 'left'
610
+ if (sourceNode && targetNode) {
611
+ const h = (await import('./utils')).findClosestHandles(sourceNode, targetNode)
612
+ finalSourceHandle = h.sourceHandle; finalTargetHandle = h.targetHandle
613
+ }
614
+ try {
615
+ const newConnector = await api.workspace.connectors.create(cid, {
616
+ source_element_id: sourceId, target_element_id: targetElementId,
617
+ source_handle: finalSourceHandle, target_handle: finalTargetHandle, direction: 'forward',
618
+ })
619
+ const connector = (await import('./utils')).connectorToConnector(newConnector)
620
+ upsertConnectorGraphSnapshot(connector)
621
+ setConnectors((prev) => [...prev, connector])
622
+ } catch { /* intentionally empty */ }
623
+ },
624
+ existingElementIds, linksMapRef, parentLinksMapRef,
625
+ openElementPanel: useCallback(() => openElementPanelRef.current(), []),
626
+ closeElementPanel: useCallback(() => closeElementPanelRef.current(), []),
627
+ openConnectorPanel: useCallback(() => openConnectorPanelRef.current(), []),
628
+ closeConnectorPanel: useCallback(() => closeConnectorPanelRef.current(), []),
629
+ selectedElement, selectedEdgeId, connectors,
630
+ layers,
631
+ setSelectedElement,
632
+ setSelectedEdge, setSelectedEdgeId,
633
+ setSelectedProxyConnectorDetails,
634
+ openProxyConnectorPanel: useCallback(() => openProxyConnectorPanelRef.current(), []),
635
+ closeProxyConnectorPanel: useCallback(() => closeProxyConnectorPanelRef.current(), []),
636
+ handleElementDeleted, handleElementPermanentlyDeleted,
637
+ handleConnectorDeleted: useCallback((edgeId: number) => {
638
+ if (viewId != null) removeConnectorGraphSnapshot(viewId, edgeId)
639
+ setConnectors((prev) => prev.filter((connector) => connector.id !== edgeId))
640
+ }, [setConnectors, viewId]),
641
+ handleUpdateTags,
642
+ drawingCanvasRef,
643
+ snapToGrid,
644
+ onMoveStateChange: useCallback((moving: boolean) => {
645
+ setLiveContextNodes((nds) => {
646
+ let changed = false
647
+ const nextNodes = nds.map((node) => {
648
+ const currentMoving = Boolean((node.data as { isCanvasMoving?: boolean }).isCanvasMoving)
649
+ if (currentMoving === moving) return node
650
+ changed = true
651
+ return { ...node, data: { ...node.data, isCanvasMoving: moving } }
652
+ })
653
+ return changed ? nextNodes : nds
654
+ })
655
+ }, []),
656
+ })
657
+
658
+ // Wire stable placeholders to the real implementations from canvas hook
659
+ useEffect(() => {
660
+ stableOnZoomInRef.current = canvas.stableOnZoomIn
661
+ stableOnZoomOutRef.current = canvas.stableOnZoomOut
662
+ stableOnNavigateToViewRef.current = canvas.stableOnNavigateToView
663
+ stableOnRemoveElementRef.current = canvas.stableOnRemoveElement
664
+ stableOnConnectToRef.current = canvas.stableOnConnectTo
665
+ stableOnStartHandleReconnectRef.current = canvas.stableOnStartHandleReconnect
666
+ }, [canvas.stableOnZoomIn, canvas.stableOnZoomOut, canvas.stableOnNavigateToView, canvas.stableOnRemoveElement, canvas.stableOnConnectTo, canvas.stableOnStartHandleReconnect])
667
+ const viewName = view?.name ?? null
668
+
669
+ const [expandedAncestorGroups, setExpandedAncestorGroups] = useState<Set<string>>(new Set())
670
+ const stableOnToggleAncestorGroup = useCallback((anchorId: string) => {
671
+ setExpandedAncestorGroups((prev) => {
672
+ const next = new Set(prev)
673
+ if (next.has(anchorId)) next.delete(anchorId)
674
+ else next.add(anchorId)
675
+ return next
676
+ })
677
+ }, [])
678
+
679
+ const { contextNodes, contextConnectors } = useViewContextNeighbours({
680
+ snapshot: effectiveWorkspaceSnapshot,
681
+ settings: crossBranchSettings,
682
+ viewId,
683
+ viewElements,
684
+ rfNodes,
685
+ stableOnNavigateToView: canvas.stableOnNavigateToView,
686
+ onSelectProxyDetails: useCallback((details: ProxyConnectorDetails) => {
687
+ setSelectedElement(null)
688
+ setSelectedEdge(null)
689
+ setSelectedEdgeId(null)
690
+ closeConnectorPanelRef.current()
691
+ closeElementPanelRef.current()
692
+ setSelectedProxyConnectorDetails(details)
693
+ openProxyConnectorPanelRef.current()
694
+ }, []),
695
+ expandedAncestorGroups,
696
+ onToggleAncestorGroup: stableOnToggleAncestorGroup,
697
+ })
698
+
699
+ // Keep context nodes in state so React Flow can store measured dimensions.
700
+ // When computed positions change (e.g. main node drag), preserve the previously
701
+ // measured width/height so nodes don't flash hidden while being re-measured.
702
+ const [liveContextNodes, setLiveContextNodes] = useState<RFNode[]>([])
703
+ const contextNodeIdsRef = useRef<Set<string>>(new Set())
704
+ useEffect(() => {
705
+ contextNodeIdsRef.current = new Set(contextNodes.map((n) => n.id))
706
+ setLiveContextNodes((prev) =>
707
+ contextNodes.map((n) => {
708
+ const existing = prev.find((p) => p.id === n.id)
709
+ if (existing?.width != null && existing?.height != null) {
710
+ return { ...n, width: existing.width, height: existing.height }
711
+ }
712
+ return n
713
+ })
714
+ )
715
+ }, [contextNodes])
716
+
717
+ const flowNodes = useMemo(() => {
718
+ const allNodes = [...liveContextNodes, ...rfNodes]
719
+ const selectedNodeIds = new Set(allNodes.filter((n) => n.selected).map((n) => n.id))
720
+
721
+ const allEdges = [...contextConnectors, ...rfEdges]
722
+ const selectedEdgeEndPoints = new Set<string>()
723
+ allEdges.forEach((e) => {
724
+ if (e.selected) {
725
+ selectedEdgeEndPoints.add(e.source)
726
+ selectedEdgeEndPoints.add(e.target)
727
+ }
728
+ })
729
+
730
+ const neighborNodeIds = new Set<string>()
731
+ if (selectedNodeIds.size > 0) {
732
+ allEdges.forEach((e) => {
733
+ if (selectedNodeIds.has(e.source)) neighborNodeIds.add(e.target)
734
+ if (selectedNodeIds.has(e.target)) neighborNodeIds.add(e.source)
735
+ })
736
+ }
737
+
738
+ if (selectedNodeIds.size === 0 && selectedEdgeEndPoints.size === 0) return allNodes
739
+
740
+ return allNodes.map((n) => {
741
+ const isHighlighted = selectedNodeIds.has(n.id) || selectedEdgeEndPoints.has(n.id) || neighborNodeIds.has(n.id)
742
+ if (isHighlighted) return n
743
+ return {
744
+ ...n,
745
+ style: { ...n.style, opacity: (Number(n.style?.opacity ?? 1)) * 0.2 },
746
+ }
747
+ })
748
+ }, [liveContextNodes, rfNodes, contextConnectors, rfEdges])
749
+
750
+ const flowEdges = useMemo(() => {
751
+ const allEdges = [...contextConnectors, ...rfEdges]
752
+ const allNodes = [...liveContextNodes, ...rfNodes]
753
+ const selectedNodeIds = new Set(allNodes.filter((n) => n.selected).map((n) => n.id))
754
+ const hasEdgeSelection = allEdges.some((e) => e.selected)
755
+
756
+ if (selectedNodeIds.size === 0 && !hasEdgeSelection) return allEdges
757
+
758
+ return allEdges.map((e) => {
759
+ const isHighlighted = e.selected || selectedNodeIds.has(e.source) || selectedNodeIds.has(e.target)
760
+ if (isHighlighted) return e
761
+
762
+ const multiplier = 0.2
763
+ return {
764
+ ...e,
765
+ style: { ...e.style, opacity: (Number(e.style?.opacity ?? 0.8)) * multiplier },
766
+ labelStyle: e.labelStyle ? { ...e.labelStyle, opacity: (Number(e.labelStyle.opacity ?? 1)) * multiplier } : undefined,
767
+ labelBgStyle: e.labelBgStyle ? { ...e.labelBgStyle, fillOpacity: (Number(e.labelBgStyle.fillOpacity ?? 0.95)) * multiplier } : undefined,
768
+ markerEnd: fadeMarker(e.markerEnd, multiplier),
769
+ markerStart: fadeMarker(e.markerStart, multiplier),
770
+ }
771
+ })
772
+ }, [contextConnectors, rfEdges, liveContextNodes, rfNodes])
773
+
774
+ // Route onNodesChange: context node changes (dimensions, selection) go to
775
+ // liveContextNodes state; main node changes go to the canvas handler.
776
+ const { onNodesChange: canvasOnNodesChange } = canvas
777
+ const onNodesChange = useCallback((changes: NodeChange[]) => {
778
+ const ctxChanges = changes.filter((c) => 'id' in c && contextNodeIdsRef.current.has((c as { id: string }).id))
779
+ const mainChanges = changes.filter((c) => !('id' in c) || !contextNodeIdsRef.current.has((c as { id: string }).id))
780
+ if (ctxChanges.length > 0) {
781
+ setLiveContextNodes((nds) => applyNodeChanges(ctxChanges, nds))
782
+ }
783
+ if (mainChanges.length > 0) {
784
+ canvasOnNodesChange(mainChanges)
785
+ }
786
+ }, [canvasOnNodesChange])
787
+
788
+ const {
789
+ canvasMenu, setCanvasMenu,
790
+ addingElementAt, setAddingElementAt,
791
+ connectGhostPos, clickConnectMode, clickConnectCursorPos,
792
+ setPendingConnectionSource,
793
+ reconnectPicking, setReconnectPicking, reconnectPickingRef,
794
+ connectorLongPressMenu, setConnectorLongPressMenu,
795
+ lastMousePosRef,
796
+ showAddingElementAt,
797
+ onEdgesChange, onNodeDragStart, onNodeDrag, onNodeDragStop,
798
+ onConnect, onConnectStart, onConnectEnd,
799
+ onReconnect, onReconnectStart, onReconnectEnd,
800
+ onEdgeClick, onEdgeContextMenu, onPaneClick, onPaneContextMenu, onPaneMouseMove,
801
+ onMoveStart, onMove, onMoveEnd,
802
+ onTouchStart, onTouchMove, onTouchEnd,
803
+ onContainerPointerDown, onContainerPointerMove, onContainerPointerUp,
804
+ onDragOver, onDrop, onWheelCapture,
805
+ handleConfirmNewElement, handleConfirmExistingElement, handleConfirmConnectExistingElement,
806
+ } = canvas
807
+
808
+ // ── FitView ────────────────────────────────────────────────────────────────
809
+ const fitViewRef = useRef(safeFitView)
810
+ fitViewRef.current = safeFitView
811
+ const [computedMinZoom, setComputedMinZoom] = useState(0.05)
812
+ const [computedTranslateExtent, setComputedTranslateExtent] = useState<[[number, number], [number, number]] | undefined>(undefined)
813
+ const {
814
+ clampedRevealProgress,
815
+ applyDemoRevealViewport,
816
+ disableImportExport,
817
+ hideFlowControls,
818
+ } = useDemoRevealViewport({
819
+ demoOptions,
820
+ containerRef,
821
+ rfNodesRef,
822
+ rfReadyRef,
823
+ needsFitViewRef: needsFitView,
824
+ computedMinZoom,
825
+ setViewport,
826
+ resetKey: viewId,
827
+ })
828
+
829
+ const maybeFitView = useCallback(() => {
830
+ if (!rfReadyRef.current || !needsFitView.current) return
831
+ const nodes = rfNodesRef.current
832
+ if (nodes.length === 0) return
833
+ if (!nodes.every((n) => typeof n.width === 'number' && n.width > 0 && typeof n.height === 'number' && n.height > 0)) return
834
+
835
+ if (clampedRevealProgress !== null) {
836
+ const ok = applyDemoRevealViewport()
837
+ if (ok && clampedRevealProgress >= 0.999) needsFitView.current = false
838
+ else if (!ok) setTimeout(() => { if (needsFitView.current) maybeFitView() }, 50)
839
+ return
840
+ }
841
+
842
+ const ok = safeFitView({ duration: 0 })
843
+ if (ok) needsFitView.current = false
844
+ else setTimeout(() => { if (needsFitView.current) maybeFitView() }, 50)
845
+ }, [applyDemoRevealViewport, clampedRevealProgress, safeFitView, rfNodesRef])
846
+
847
+ const onRFInit = useCallback(() => { rfReadyRef.current = true; maybeFitView() }, [maybeFitView])
848
+
849
+ useEffect(() => { maybeFitView() }, [rfNodes, maybeFitView])
850
+
851
+ useEffect(() => {
852
+ const el = containerRef.current
853
+ if (!el) return
854
+ const observer = new ResizeObserver(() => { if (needsFitView.current) maybeFitView() })
855
+ observer.observe(el)
856
+ return () => observer.disconnect()
857
+ }, [maybeFitView])
858
+
859
+ useEffect(() => { setRfNodes([]); setRfEdges([]); needsFitView.current = true }, [viewId, setRfEdges, setRfNodes])
860
+
861
+ // ── Dynamic viewport bounds ────────────────────────────────────────────────
862
+ useEffect(() => {
863
+ if (flowNodes.length === 0 && drawingPaths.length === 0) {
864
+ setComputedMinZoom((prev) => prev === 0.05 ? prev : 0.05)
865
+ setComputedTranslateExtent((prev) => prev === undefined ? prev : undefined)
866
+ return
867
+ }
868
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
869
+ for (const n of flowNodes) {
870
+ minX = Math.min(minX, n.position.x); minY = Math.min(minY, n.position.y)
871
+ maxX = Math.max(maxX, n.position.x + (n.width ?? 180)); maxY = Math.max(maxY, n.position.y + (n.height ?? 80))
872
+ }
873
+ for (const p of drawingPaths) {
874
+ for (const pt of p.points) { minX = Math.min(minX, pt.x); minY = Math.min(minY, pt.y); maxX = Math.max(maxX, pt.x); maxY = Math.max(maxY, pt.y) }
875
+ }
876
+ if (!isFinite(minX)) {
877
+ setComputedMinZoom((prev) => prev === 0.05 ? prev : 0.05)
878
+ setComputedTranslateExtent((prev) => prev === undefined ? prev : undefined)
879
+ return
880
+ }
881
+ const vw = window.innerWidth; const vh = window.innerHeight
882
+ const bboxW = maxX - minX; const bboxH = maxY - minY
883
+ let minZoom = Math.sqrt((0.12 * vw * vh) / Math.max(1, bboxW * bboxH))
884
+ if (!isFinite(minZoom) || isNaN(minZoom) || minZoom <= 0) minZoom = 0.05
885
+ const nextMinZoom = Math.max(0.05, Math.min(minZoom, 1))
886
+ setComputedMinZoom((prev) => prev === nextMinZoom ? prev : nextMinZoom)
887
+ const pmX = Math.max(vw * 2, 2000); const pmY = Math.max(vh * 2, 2000)
888
+ const nextTranslateExtent: [[number, number], [number, number]] = [[minX - pmX, minY - pmY], [maxX + pmX, maxY + pmY]]
889
+ setComputedTranslateExtent((prev) => areTranslateExtentsEqual(prev, nextTranslateExtent) ? prev : nextTranslateExtent)
890
+ }, [flowNodes, drawingPaths])
891
+
892
+ // ── Keyboard shortcuts for drawing ────────────────────────────────────────
893
+ useEffect(() => {
894
+ if (!drawingMode) return
895
+ const handleKeyDown = (e: KeyboardEvent) => {
896
+ const target = e.target as HTMLElement | null
897
+ if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) return
898
+
899
+ const key = e.key.toLowerCase()
900
+ const isCmd = e.metaKey || e.ctrlKey
901
+
902
+ if (isCmd && key === 'z') {
903
+ e.preventDefault()
904
+ if (e.shiftKey) handleRedo()
905
+ else handleUndo()
906
+ return
907
+ }
908
+
909
+ if (key === 'p') { setDrawingTool('pencil'); return }
910
+ if (key === 'e') { setDrawingTool('eraser'); return }
911
+ if (key === 't') { setDrawingTool('text'); return }
912
+ if (key === 'v') { setDrawingTool('select'); return }
913
+ }
914
+ window.addEventListener('keydown', handleKeyDown)
915
+ return () => window.removeEventListener('keydown', handleKeyDown)
916
+ }, [drawingMode, handleUndo, handleRedo, setDrawingTool])
917
+
918
+ // ── Overscroll prevention ──────────────────────────────────────────────────
919
+ useEffect(() => {
920
+ const html = document.documentElement
921
+ const prev = html.style.overscrollBehaviorX
922
+ html.style.overscrollBehaviorX = 'none'
923
+ return () => { html.style.overscrollBehaviorX = prev }
924
+ }, [])
925
+
926
+ // ── Header ─────────────────────────────────────────────────────────────────
927
+ useEffect(() => {
928
+ setHeader({
929
+ node: <ViewHeaderButton name={viewName ?? undefined} onOpen={openViewDetailsRef.current} />,
930
+ })
931
+ }, [viewName, setHeader])
932
+
933
+ useEffect(() => () => setHeader(null), [setHeader])
934
+
935
+ // ── Share ──────────────────────────────────────────────────────────────────
936
+ const onShare = useCallback(() => {}, [])
937
+
938
+ // ── Library helpers ────────────────────────────────────────────────────────
939
+ const handleAddElementAtCenter = useCallback((forceCenter = false) => {
940
+ if (!canEdit) return
941
+ const rect = containerRef.current?.getBoundingClientRect()
942
+ if (!rect) return
943
+ let cx = rect.left + rect.width / 2; let cy = rect.top + rect.height * 0.4
944
+ if (!forceCenter && lastMousePosRef.current) {
945
+ const { clientX, clientY } = lastMousePosRef.current
946
+ if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {
947
+ cx = clientX; cy = clientY
948
+ }
949
+ }
950
+ showAddingElementAt(cx, cy, true)
951
+ }, [canEdit, showAddingElementAt, lastMousePosRef])
952
+
953
+ const handleTapAdd = useCallback(async (obj: WorkspaceElement) => {
954
+ if (!canEdit || !viewId || existingElementIds.has(obj.id)) return
955
+ const pos = screenToFlowPositionRef.current({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
956
+ try { await api.workspace.views.placements.add(viewId, obj.id, pos.x - 100, pos.y - 40); await refreshElements() } catch { /* intentionally empty */ }
957
+ }, [canEdit, viewId, existingElementIds, refreshElements])
958
+
959
+ const handleTouchDrop = useCallback(async (obj: WorkspaceElement, clientX: number, clientY: number) => {
960
+ if (!canEdit || !viewId || existingElementIds.has(obj.id)) return
961
+ const container = containerRef.current; if (!container) return
962
+ const bounds = container.getBoundingClientRect()
963
+ if (clientX < bounds.left || clientX > bounds.right || clientY < bounds.top || clientY > bounds.bottom) return
964
+ const pos = screenToFlowPositionRef.current({ x: clientX, y: clientY })
965
+ try { await api.workspace.views.placements.add(viewId, obj.id, pos.x - 100, pos.y - 40); await refreshElements() } catch { /* intentionally empty */ }
966
+ }, [canEdit, viewId, existingElementIds, refreshElements])
967
+
968
+ const handleFindElement = useCallback((elementId: number) => {
969
+ const node = rfNodesRef.current.find((n) => (n.data as PlacedElement).element_id === elementId)
970
+ if (node) {
971
+ fitViewRef.current({ nodes: [node], duration: 800, padding: 0.8 })
972
+ }
973
+ }, [rfNodesRef])
974
+
975
+ // ── Export / Import ────────────────────────────────────────────────────────
976
+ const handleExportView = useCallback(async (options: ExportOptions) => {
977
+ const flowRoot = containerRef.current?.querySelector('.react-flow') as HTMLElement | null
978
+ if (!flowRoot) { toast({ status: 'error', title: 'Export failed', description: 'Could not find the view canvas.' }); return }
979
+ const baseName = sanitizeExportFilename(options.filename || viewName || 'view-export')
980
+ const downloadName = `${baseName}.${options.format}`
981
+ const filterNode = (node: HTMLElement) => {
982
+ const cn = node.className
983
+ if (typeof cn !== 'string') return true
984
+ return !cn.includes('react-flow__controls') && !cn.includes('react-flow__panel')
985
+ }
986
+ try {
987
+ setIsExporting(true)
988
+ if (options.format === 'mermaid') {
989
+ let code = 'architecture-beta\n'
990
+ for (const obj of viewElements) {
991
+ const safeId = `obj_${obj.element_id}`
992
+ const shape = obj.kind === 'database' ? 'database' : obj.kind === 'person' ? 'person' : 'server'
993
+ code += ` service ${safeId}(${shape})[${obj.name}]\n`
994
+ }
995
+ code += '\n'
996
+ for (const connector of connectors) { code += ` obj_${connector.source_element_id}:R -- L:obj_${connector.target_element_id}\n` }
997
+ triggerDownload(URL.createObjectURL(new Blob([code], { type: 'text/plain;charset=utf-8' })), downloadName)
998
+ } else if (options.format === 'svg') {
999
+ triggerDownload(await toSvg(flowRoot, { cacheBust: true, filter: filterNode }), downloadName)
1000
+ } else {
1001
+ triggerDownload(await toPng(flowRoot, { cacheBust: true, pixelRatio: options.scale, filter: filterNode }), downloadName)
1002
+ }
1003
+ closeExportModalRef.current()
1004
+ toast({ status: 'success', title: 'Export complete', description: `Saved ${downloadName}` })
1005
+ } catch {
1006
+ toast({ status: 'error', title: 'Export failed', description: 'Please try again.' })
1007
+ } finally { setIsExporting(false) }
1008
+ }, [viewName, viewElements, connectors, toast])
1009
+
1010
+ const handleImportView = useCallback(async (parsed: ParsedImport) => {
1011
+ const currentViewId = viewIdRef.current
1012
+ if (!currentViewId) return
1013
+ setIsImporting(true)
1014
+ try {
1015
+ const res = await api.import.resources('', { elements: parsed.elements, connectors: parsed.connectors })
1016
+ closeImportModalRef.current()
1017
+ toast({ status: 'success', title: 'Import complete', description: `Created ${parsed.elements.length} elements and ${parsed.connectors.length} connectors.`, duration: 5000, isClosable: true })
1018
+ if (res.view_id && res.view_id !== currentViewId) navigate(`/views/${res.view_id}`)
1019
+ else window.location.reload()
1020
+ } catch (e) {
1021
+ toast({ status: 'error', title: 'Import failed', description: e instanceof Error ? e.message : 'Unknown error' })
1022
+ } finally { setIsImporting(false) }
1023
+ }, [navigate, toast, viewIdRef])
1024
+
1025
+ // ─────────────────────────────────────────────────────────────────────────────
1026
+ // Render states
1027
+ // ─────────────────────────────────────────────────────────────────────────────
1028
+ if (view === undefined) {
1029
+ return <Flex h={{ base: '100vh', sm: 'calc(100vh - var(--editor-top-offset, 48px))' }} align="center" justify="center"><Spinner size="xl" /></Flex>
1030
+ }
1031
+ if (view === null) {
1032
+ return <Flex h={{ base: '100vh', sm: 'calc(100vh - var(--editor-top-offset, 48px))' }} align="center" justify="center"><Text>View not found.</Text></Flex>
1033
+ }
1034
+
1035
+ return (
1036
+ <ViewEditorContext.Provider value={{
1037
+ viewId, canEdit, isOwner, isFreePlan, snapToGrid, setSnapToGrid,
1038
+ selectedElement, selectedConnector: selectedEdge
1039
+ }}>
1040
+ <Box h={{ base: '100vh', sm: 'calc(100vh - var(--editor-top-offset, 48px))' }} display="flex" flexDir="column">
1041
+ <Flex flex={1} overflow="hidden">
1042
+ <Box
1043
+ ref={containerRef}
1044
+ flex={1}
1045
+ position="relative"
1046
+ onDrop={onDrop}
1047
+ onDragOver={onDragOver}
1048
+ onPointerDown={onContainerPointerDown}
1049
+ onPointerMove={onContainerPointerMove}
1050
+ onPointerUp={onContainerPointerUp}
1051
+ onPointerCancel={onContainerPointerUp}
1052
+ sx={{ overscrollBehaviorX: 'none' }}
1053
+ >
1054
+ {/* Library toggle */}
1055
+ {!isMobileLayout && (
1056
+ <Tooltip label={libraryOpen ? 'Close element library' : 'Open element library'} placement="right" openDelay={300}>
1057
+ <IconButton
1058
+ aria-label={libraryOpen ? 'Close element library' : 'Open element library'}
1059
+ icon={libraryOpen ? <ChevronLeftIcon size={16} strokeWidth={3.5} /> : <ChevronRightIcon size={16} strokeWidth={3.5} />}
1060
+ size="md" position="absolute" top="50%"
1061
+ left={libraryOpen ? '328px' : 3}
1062
+ transition="left 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94), transform 0.15s ease"
1063
+ zIndex={1200} border='1px solid rgba(255, 255, 255, 0.08)'
1064
+ variant="clay" colorScheme="gray" bg="var(--bg-panel)"
1065
+ color={libraryOpen ? 'white' : 'gray.300'}
1066
+ _hover={{ bg: 'var(--bg-card-solid)', transform: 'translateY(-50%) scale(1.1)', color: 'white' }}
1067
+ onClick={() => setLibraryOpen((v) => !v)}
1068
+ transform="translateY(-50%)"
1069
+ />
1070
+ </Tooltip>
1071
+ )}
1072
+
1073
+ {/* Explorer toggle */}
1074
+ {!isMobileLayout && !elementPanel.isOpen && !connectorPanel.isOpen && !viewDetails.isOpen && (
1075
+ <Tooltip label={isExplorerOpen ? 'Close view explorer' : 'Open view explorer'} placement="left" openDelay={300}>
1076
+ <IconButton
1077
+ aria-label={isExplorerOpen ? 'Close view explorer' : 'Open view explorer'}
1078
+ icon={isExplorerOpen ? <ChevronRightIcon size={16} strokeWidth={3.5} /> : <ChevronLeftIcon size={16} strokeWidth={3.5} />}
1079
+ size="md" position="absolute" top="50%"
1080
+ right={isExplorerOpen ? '328px' : 3}
1081
+ transition="right 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94), transform 0.15s ease"
1082
+ zIndex={5} border="1px solid rgba(255, 255, 255, 0.08)"
1083
+ variant="clay" colorScheme="gray" bg="var(--bg-panel)"
1084
+ color={isExplorerOpen ? 'white' : 'gray.300'}
1085
+ _hover={{ bg: 'var(--bg-card-solid)', transform: 'translateY(-50%) scale(1.1)', color: 'white' }}
1086
+ onClick={() => setIsExplorerOpen((v) => !v)}
1087
+ transform="translateY(-50%)"
1088
+ />
1089
+ </Tooltip>
1090
+ )}
1091
+
1092
+ {/* Mobile toggles */}
1093
+ {isMobileLayout && !isExplorerOpen && !libraryOpen && !elementPanel.isOpen && !connectorPanel.isOpen && !viewDetails.isOpen && (
1094
+ <VStack position="absolute" left={3} top="50%" transform="translateY(-50%)" spacing={2} zIndex={5}>
1095
+ <IconButton aria-label="Open view navigation" icon={<NavigationIcon />}
1096
+ size="md" variant="clay" colorScheme="gray" bg="var(--bg-panel)" color="gray.300"
1097
+ border="1px solid rgba(255,255,255,0.08)"
1098
+ _hover={{ bg: 'var(--bg-card-solid)', transform: 'scale(1.1)', color: 'white' }}
1099
+ transition="all 0.15s ease"
1100
+ onClick={() => { setIsExplorerOpen(true); setLibraryOpen(false) }}
1101
+ />
1102
+ <IconButton aria-label="Open element library" icon={<LibraryIcon />}
1103
+ size="md" variant="clay" colorScheme="gray" bg="var(--bg-panel)" color="gray.300"
1104
+ border="1px solid rgba(255,255,255,0.08)"
1105
+ _hover={{ bg: 'var(--bg-card-solid)', transform: 'scale(1.1)', color: 'white' }}
1106
+ transition="all 0.15s ease"
1107
+ onClick={() => { setLibraryOpen(true); setIsExplorerOpen(false) }}
1108
+ />
1109
+ </VStack>
1110
+ )}
1111
+
1112
+ <Box
1113
+ position="relative"
1114
+ w="full"
1115
+ h="full"
1116
+ onWheelCapture={onWheelCapture}
1117
+ onTouchStart={onTouchStart}
1118
+ onTouchMove={onTouchMove}
1119
+ onTouchEnd={onTouchEnd}
1120
+ sx={{
1121
+ '.react-flow__edgelabel-renderer': {
1122
+ zIndex: 1002,
1123
+ },
1124
+ }}
1125
+ >
1126
+ <ReactFlow
1127
+ nodes={flowNodes} edges={flowEdges}
1128
+ onInit={onRFInit}
1129
+ onNodesChange={onNodesChange} onEdgesChange={onEdgesChange}
1130
+ onConnect={onConnect} onConnectStart={onConnectStart} onConnectEnd={onConnectEnd}
1131
+ onNodeDragStart={onNodeDragStart} onNodeDrag={onNodeDrag} onNodeDragStop={onNodeDragStop}
1132
+ onEdgeClick={onEdgeClick} onEdgeContextMenu={onEdgeContextMenu}
1133
+ onPaneContextMenu={onPaneContextMenu} onPaneClick={onPaneClick}
1134
+ onPaneMouseMove={onPaneMouseMove}
1135
+ onMoveStart={onMoveStart} onMove={onMove} onMoveEnd={onMoveEnd}
1136
+ translateExtent={computedTranslateExtent} minZoom={computedMinZoom}
1137
+ onReconnect={onReconnect} onReconnectStart={onReconnectStart} onReconnectEnd={onReconnectEnd}
1138
+ nodeTypes={nodeTypesMemo} edgeTypes={edgeTypesMemo}
1139
+ nodesDraggable={canEdit} connectionMode={ConnectionMode.Loose} connectionRadius={25}
1140
+ edgesUpdatable={canEdit} reconnectRadius={0}
1141
+ snapToGrid={snapToGrid}
1142
+ snapGrid={[30, 30]}
1143
+ deleteKeyCode={null}
1144
+ panOnDrag={!drawingMode}
1145
+ panOnScroll={!isMobileLayout} panOnScrollSpeed={1.2} panOnScrollMode={PanOnScrollMode.Free}
1146
+ zoomOnScroll={false} zoomOnPinch
1147
+ >
1148
+ <SafeBackground variant={BackgroundVariant.Dots} gap={16} color="#2D3748" size={1} />
1149
+ {!hideFlowControls && (
1150
+ <Controls position="bottom-right" className="glass" style={{ overflow: 'hidden', margin: '1rem' }} />
1151
+ )}
1152
+ </ReactFlow>
1153
+ {canvasOverlaySlot && (
1154
+ <Box position="absolute" inset={0} pointerEvents="none" zIndex={10}>
1155
+ {canvasOverlaySlot}
1156
+ </Box>
1157
+ )}
1158
+ </Box>
1159
+
1160
+ <ViewExplorer
1161
+ treeNodes={treeData}
1162
+ linksMap={linksMap} viewElements={viewElements}
1163
+ onNavigate={canvas.stableOnNavigateToView}
1164
+ onHoverZoom={(elementId, type) => setHoveredZoom(type && elementId ? { elementId, type } : null)}
1165
+ isOpen={isExplorerOpen} onToggle={() => setIsExplorerOpen((v) => !v)}
1166
+ isMobile={isMobileLayout}
1167
+ activeTags={activeTags}
1168
+ setActiveTags={handleSetActiveTags}
1169
+ hiddenLayerTags={hiddenLayerTags}
1170
+ setHiddenLayerTags={handleSetHiddenLayerTags}
1171
+ availableTags={availableTags}
1172
+ layers={layers}
1173
+ onHoverLayer={handleHoverLayer}
1174
+ onCreateLayer={handleCreateLayer}
1175
+ onUpdateLayer={handleUpdateLayer}
1176
+ onDeleteLayer={handleDeleteLayer}
1177
+ tagColors={tagColors}
1178
+ selectedElement={selectedElement}
1179
+ onUpdateTags={handleUpdateTags}
1180
+ onCreateTag={handleCreateTag}
1181
+ suppressed={elementPanel.isOpen || connectorPanel.isOpen || viewDetails.isOpen}
1182
+ />
1183
+
1184
+ <EditorOverlays
1185
+ connectGhostPos={connectGhostPos}
1186
+ clickConnectMode={clickConnectMode}
1187
+ clickConnectCursorPos={clickConnectCursorPos}
1188
+ handleReconnectDrag={canvas.handleReconnectDrag}
1189
+ rfNodes={flowNodes}
1190
+ />
1191
+
1192
+ <ViewDrawMenu
1193
+ drawingMode={drawingMode} drawingTool={drawingTool} setDrawingTool={setDrawingTool}
1194
+ drawingColor={drawingColor} setDrawingColor={setDrawingColor}
1195
+ drawingWidth={drawingWidth} setDrawingWidth={setDrawingWidth}
1196
+ onUndo={handleUndo} onRedo={handleRedo}
1197
+ canUndo={drawingHistoryRef.current.length > 0} canRedo={drawingRedoStackRef.current.length > 0}
1198
+ setDrawingMode={setDrawingMode}
1199
+ />
1200
+
1201
+ {/* Inline text editor ... */}
1202
+ {/* ... */}
1203
+
1204
+ <DrawingCanvas
1205
+ ref={drawingCanvasRef}
1206
+ paths={drawingPaths}
1207
+ isDrawing={drawingMode} isVisible={drawingVisible}
1208
+ strokeColor={drawingColor} strokeWidth={drawingWidth} mode={drawingTool}
1209
+ onPathComplete={onPathComplete} onPathDelete={onPathDelete} onPathUpdate={onPathUpdate}
1210
+ onTextPositionSelected={(canvasX, canvasY, flowX, flowY) => setTextEditorState({ canvasX, canvasY, flowX, flowY })}
1211
+ />
1212
+
1213
+
1214
+
1215
+ <ConnectorContextMenu
1216
+ menu={connectorLongPressMenu}
1217
+ onEdit={(edgeId) => { const connector = connectors.find((e) => e.id === edgeId); if (connector) { setSelectedEdge(connector); connectorPanel.onOpen() }; setConnectorLongPressMenu(null) }}
1218
+ onMoveSource={(edgeId) => { const picking = { edgeId, endpoint: 'source' as const }; reconnectPickingRef.current = picking; setReconnectPicking(picking); setConnectorLongPressMenu(null) }}
1219
+ onMoveTarget={(edgeId) => { const picking = { edgeId, endpoint: 'target' as const }; reconnectPickingRef.current = picking; setConnectorLongPressMenu(null) }}
1220
+ onDelete={async (edgeId) => {
1221
+ setConnectorLongPressMenu(null)
1222
+ if (!viewId) return
1223
+ try {
1224
+ await api.workspace.connectors.delete('', edgeId)
1225
+ removeConnectorGraphSnapshot(viewId, edgeId)
1226
+ setConnectors((prev) => prev.filter((connector) => connector.id !== edgeId))
1227
+ } catch { /* intentionally empty */ }
1228
+ }}
1229
+ />
1230
+
1231
+ {/* Reconnect picking banner */}
1232
+ {reconnectPicking && (
1233
+ <Box position="absolute" top="14px" left="50%" transform="translateX(-50%)" zIndex={2000}
1234
+ bg="var(--bg-dots)" border="1px" borderColor="gray.900" px={4} py={2} rounded="xl" shadow="xl"
1235
+ display="flex" alignItems="center" gap={3} onClick={(e) => e.stopPropagation()}>
1236
+ <Text fontSize="sm" fontWeight="semibold">Tap a node to set as new {reconnectPicking.endpoint}</Text>
1237
+ <Button size="xs" variant="clay" onClick={() => { reconnectPickingRef.current = null; setReconnectPicking(null) }}>Cancel</Button>
1238
+ </Box>
1239
+ )}
1240
+
1241
+ <CanvasContextMenu
1242
+ menu={canvasMenu}
1243
+ onAddElement={(x, y) => {
1244
+ const rect = containerRef.current?.getBoundingClientRect()
1245
+ if (rect) showAddingElementAt(x + rect.left, y + rect.top)
1246
+ setCanvasMenu(null)
1247
+ }}
1248
+ />
1249
+
1250
+ {/* Inline element adder */}
1251
+ {addingElementAt && (
1252
+ <InlineElementAdder
1253
+ x={addingElementAt.x} y={addingElementAt.y} expandResults={addingElementAt.expandResults}
1254
+ allElements={allElements} existingElementIds={existingElementIds}
1255
+ allowCreate={addingElementAt.mode === 'add'}
1256
+ title={addingElementAt.mode === 'connect' ? 'Connect To Off-View Element' : undefined}
1257
+ placeholder={addingElementAt.mode === 'connect' ? 'Search workspace elements...' : undefined}
1258
+ getSecondaryLabel={addingElementAt.mode === 'connect'
1259
+ ? (obj) => placementSummaryByElementId[obj.id] ?? obj.technology ?? null
1260
+ : undefined}
1261
+ onConfirmNew={handleConfirmNewElement}
1262
+ onConfirmExisting={addingElementAt.mode === 'connect' ? handleConfirmConnectExistingElement : handleConfirmExistingElement}
1263
+ onCancel={() => { setAddingElementAt(null); setPendingConnectionSource(null) }}
1264
+ />
1265
+ )}
1266
+
1267
+ <EmptyCanvasState isMobile={isMobileLayout} hasNodes={rfNodes.length > 0} />
1268
+
1269
+ <ViewFloatingMenu
1270
+ handleAddElementAtCenter={handleAddElementAtCenter}
1271
+ drawingMode={drawingMode} setDrawingMode={setDrawingMode}
1272
+ hasDrawingPaths={drawingPaths.length > 0} drawingVisible={drawingVisible} setDrawingVisible={setDrawingVisible}
1273
+ extrasOpen={extrasOpen} setExtrasOpen={setExtrasOpen}
1274
+ focusMode={!crossBranchSettings.enabled}
1275
+ onFocusModeChange={(v) => setCrossBranchEnabled(!v)}
1276
+ disableImportExport={disableImportExport}
1277
+ onImport={importModal.onOpen} onExport={() => exportModal.onOpen()} onShare={onShare}
1278
+ allTags={availableTags}
1279
+ layers={layers}
1280
+ tagColors={tagColors}
1281
+ hiddenTags={hiddenLayerTags}
1282
+ toggleTagVisibility={toggleTagVisibility}
1283
+ toggleLayerVisibility={toggleLayerVisibility}
1284
+ tagCounts={tagCounts}
1285
+ layerElementCounts={layerElementCounts}
1286
+ setHighlightedTags={setHoveredLayerTags}
1287
+ setHighlightColor={setHoveredLayerColor}
1288
+ shareSlot={shareSlot}
1289
+ toolbarSlot={toolbarSlot}
1290
+ />
1291
+ </Box>
1292
+ </Flex>
1293
+
1294
+ <ElementLibrary
1295
+ existingElementIds={existingElementIds}
1296
+ existingElements={existingElements}
1297
+ onCreateNew={() => handleAddElementAtCenter(true)} refresh={libraryRefresh}
1298
+ isOpen={libraryOpen} onClose={() => setLibraryOpen(false)}
1299
+ onTapAdd={canEdit ? handleTapAdd : undefined}
1300
+ onFindElement={handleFindElement}
1301
+ onTouchDrop={canEdit ? handleTouchDrop : undefined}
1302
+ />
1303
+
1304
+ <ElementPanel
1305
+ isOpen={elementPanel.isOpen} onClose={elementPanel.onClose} element={selectedElement}
1306
+ onSave={handleElementSaved} autoSave
1307
+ onDelete={handleElementDeleted} onPermanentDelete={handleElementPermanentlyDeleted}
1308
+ orgId={''}
1309
+ links={selectedElement ? (linksMap[selectedElement.id] || EMPTY_LINKS) : EMPTY_LINKS}
1310
+ parentLinks={selectedElement ? (parentLinksMap[selectedElement.id] || EMPTY_LINKS) : EMPTY_LINKS}
1311
+ hasBackdrop={isMobileLayout}
1312
+ availableTags={availableTags}
1313
+ elementPanelAfterContentSlot={elementPanelAfterContentSlot}
1314
+ />
1315
+
1316
+ <CodePreviewPanel isOpen={codePreview.isOpen} onClose={codePreview.onClose} element={previewElement} hasBackdrop={isMobileLayout} />
1317
+
1318
+ <ConnectorPanel
1319
+ isOpen={connectorPanel.isOpen} onClose={connectorPanel.onClose} connector={selectedEdge}
1320
+ orgId={''}
1321
+ onSave={(updated: Connector) => {
1322
+ upsertConnectorGraphSnapshot(updated)
1323
+ setConnectors((prev) => prev.map((connector) => (connector.id === updated.id ? updated : connector)))
1324
+ }} autoSave
1325
+ onDelete={(edgeId: number) => {
1326
+ if (viewId != null) removeConnectorGraphSnapshot(viewId, edgeId)
1327
+ setConnectors((prev) => prev.filter((connector) => connector.id !== edgeId))
1328
+ }}
1329
+ hasBackdrop={isMobileLayout}
1330
+ connectorPanelAfterContentSlot={connectorPanelAfterContentSlot}
1331
+ />
1332
+ <ProxyConnectorPanel
1333
+ isOpen={proxyConnectorPanel.isOpen}
1334
+ onClose={proxyConnectorPanel.onClose}
1335
+ details={selectedProxyConnectorDetails}
1336
+ hasBackdrop={isMobileLayout}
1337
+ />
1338
+
1339
+ <ViewPanel
1340
+ isOpen={viewDetails.isOpen} onClose={viewDetails.onClose}
1341
+ view={view as ViewTreeNode}
1342
+ onSave={(updated) => setView(updated)} hasBackdrop={isMobileLayout}
1343
+ />
1344
+
1345
+ <ExportModal
1346
+ isOpen={exportModal.isOpen} onClose={exportModal.onClose}
1347
+ defaultFilename={sanitizeExportFilename((view as ViewTreeNode).name)}
1348
+ onExport={handleExportView} isExporting={isExporting}
1349
+ />
1350
+ <ImportModal
1351
+ isOpen={importModal.isOpen} onClose={importModal.onClose}
1352
+ onImport={handleImportView} isImporting={isImporting}
1353
+ />
1354
+ {!demoOptions?.disableOnboarding && <ViewEditorOnboarding hasElements={rfNodes.length > 0} />}
1355
+ </Box>
1356
+ </ViewEditorContext.Provider>
1357
+ )
1358
+ }
1359
+
1360
+ export default function ViewEditor(props: Props) {
1361
+ return (
1362
+ <ReactFlowProvider>
1363
+ <ViewEditorInner {...props} />
1364
+ </ReactFlowProvider>
1365
+ )
1366
+ }