@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,1033 @@
1
+ import { memo, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Handle, Position, useStore } from 'reactflow'
3
+ import { Box, Flex, Text, Tooltip, HStack, Button, Divider, VStack } from '@chakra-ui/react'
4
+ import { LinkIcon } from '@chakra-ui/icons'
5
+ import { useAccentColor } from '../context/ThemeContext'
6
+
7
+ import type { PlacedElement, ViewConnector, Tag } from '../types'
8
+ import { ElementContainer } from './NodeContainer'
9
+ import { ElementBody } from './NodeBody'
10
+ import { resolveIconPath } from '../utils/url'
11
+ import { ZoomInIcon, ZoomOutIcon, TrashIcon as TrashSvg, EditIcon as EditSvg } from './Icons'
12
+ import { vscodeBridge } from '../lib/vscodeBridge'
13
+ import type { ExtensionToWebviewMessage } from '../types/vscode-messages'
14
+ import {
15
+ DEFAULT_SOURCE_HANDLE_SIDE,
16
+ DEFAULT_TARGET_HANDLE_SIDE,
17
+ ensureVisualHandleId,
18
+ getVisualHandleId,
19
+ getVisualHandleStyle,
20
+ HANDLE_SLOT_CENTER_INDEX,
21
+ HANDLE_SLOT_COUNT,
22
+ } from '../utils/edgeDistribution'
23
+
24
+ function VscodeCodePreview({ filePath, isCanvasMoving }: { filePath: string; isCanvasMoving?: boolean }) {
25
+ const [content, setContent] = useState<string | null>(null)
26
+ const [startLineOffset, setStartLineOffset] = useState(0)
27
+ const [isOpen, setIsOpen] = useState(false)
28
+ const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
29
+
30
+ const [relPath, anchorStr] = filePath.split('#')
31
+ const anchor = useMemo(() => {
32
+ try {
33
+ return anchorStr ? JSON.parse(decodeURIComponent(anchorStr)) : null
34
+ } catch {
35
+ return null
36
+ }
37
+ }, [anchorStr])
38
+ const startLine = typeof anchor?.startLine === 'number' ? anchor.startLine : undefined
39
+ const symbolName = typeof anchor?.name === 'string' ? anchor.name : undefined
40
+ const symbolKind = typeof anchor?.type === 'string' ? anchor.type : undefined
41
+
42
+ useEffect(() => {
43
+ if (!isOpen) return
44
+ const requestId = `file-${Math.random().toString(36).slice(2)}`
45
+
46
+ const unsub = vscodeBridge.onMessage((msg: ExtensionToWebviewMessage) => {
47
+ if (msg.type === 'file-content' && msg.requestId === requestId) {
48
+ setContent(msg.content)
49
+ setStartLineOffset(msg.startLineOffset)
50
+ }
51
+ })
52
+
53
+ vscodeBridge.postMessage({
54
+ type: 'request-file-content',
55
+ requestId,
56
+ filePath: relPath,
57
+ startLine: startLine ?? 0
58
+ })
59
+
60
+ return unsub
61
+ }, [isOpen, relPath, startLine])
62
+
63
+ const handleMouseEnter = () => {
64
+ if (isCanvasMoving) return
65
+ if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current)
66
+ hoverTimeoutRef.current = setTimeout(() => setIsOpen(true), 300)
67
+ }
68
+
69
+ const handleMouseLeave = () => {
70
+ if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current)
71
+ setIsOpen(false)
72
+ }
73
+
74
+ const handleClick = (e: React.MouseEvent) => {
75
+ e.stopPropagation()
76
+ vscodeBridge.postMessage({
77
+ type: 'open-file',
78
+ filePath: relPath,
79
+ startLine,
80
+ symbolName,
81
+ symbolKind,
82
+ })
83
+ }
84
+
85
+ return (
86
+ <Tooltip
87
+ label={
88
+ content ? (
89
+ <Box p={1} maxW="300px" fontFamily="mono" fontSize="xs" whiteSpace="pre" overflowX="auto">
90
+ {content.split('\n').map((line, i) => {
91
+ const actualLine = startLineOffset + i
92
+ const isTargetLine = typeof startLine === 'number' && actualLine === startLine
93
+ return (
94
+ <Text key={i} color={isTargetLine ? 'blue.300' : 'whiteAlpha.800'} bg={isTargetLine ? 'whiteAlpha.200' : 'transparent'} px={1}>
95
+ {line}
96
+ </Text>
97
+ )
98
+ })}
99
+ </Box>
100
+ ) : (
101
+ <Text>Loading preview...</Text>
102
+ )
103
+ }
104
+ placement="top"
105
+ isOpen={isOpen}
106
+ hasArrow
107
+ bg="gray.800"
108
+ color="white"
109
+ boxShadow="lg"
110
+ borderRadius="md"
111
+ px={0}
112
+ py={0}
113
+ >
114
+ <Box
115
+ as="button"
116
+ display="flex"
117
+ alignItems="center"
118
+ justifyContent="center"
119
+ w="18px"
120
+ h="18px"
121
+ rounded="md"
122
+ color="whiteAlpha.900"
123
+ _hover={{ color: 'teal.300', bg: 'whiteAlpha.200', transform: 'scale(1.1)' }}
124
+ transition="all 0.15s"
125
+ onClick={handleClick}
126
+ onMouseEnter={handleMouseEnter}
127
+ onMouseLeave={handleMouseLeave}
128
+ onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}
129
+ >
130
+ <LinkIcon w={2.5} h={2.5} />
131
+ </Box>
132
+ </Tooltip>
133
+ )
134
+ }
135
+
136
+ interface NodeData extends PlacedElement {
137
+ links: ViewConnector[] // child (zoom-in) connectors
138
+ parentLinks: ViewConnector[] // parent (zoom-out) connectors
139
+ parentViewId?: number | null
140
+ onZoomIn: (elementId: number) => void
141
+ onZoomOut: (elementId: number) => void
142
+ onNavigateToDiagram: (viewId: number) => void
143
+ onSelect: (obj: PlacedElement) => void
144
+ onInteractionStart: (elementId: number) => void
145
+ onConnectTo: (elementId: number) => void
146
+ onStartHandleReconnect?: (args: { edgeId: string; endpoint: 'source' | 'target'; handleId: string; clientX: number; clientY: number }) => void
147
+ onRemove: (elementId: number) => void
148
+ onHoverZoom: (elementId: number, type: 'in' | 'out' | null) => void
149
+ isZoomHovered: 'in' | 'out' | null
150
+ interactionSourceId: number | null
151
+ onOpenCodePreview?: (elementId: number) => void
152
+ isClickConnectMode?: boolean
153
+ tagColors: Record<string, Tag>
154
+ layerHighlightColor?: string
155
+ forceShowTagPopup?: boolean
156
+ isCanvasMoving?: boolean
157
+ }
158
+
159
+ interface Props {
160
+ data: NodeData
161
+ selected: boolean
162
+ }
163
+
164
+ // Small icon button used for zoom-in / zoom-out
165
+ // variant='out' → extruded (raised clay bump)
166
+ // variant='in' → sunken (pressed clay indentation)
167
+ function LayerButton({
168
+ label,
169
+ active,
170
+ variant,
171
+ onClick,
172
+ onMouseEnter,
173
+ onMouseLeave,
174
+ isDisabled,
175
+ count = 0,
176
+ children,
177
+ }: {
178
+ label: string
179
+ active: boolean
180
+ variant: 'out' | 'in'
181
+ onClick: (e: React.MouseEvent) => void
182
+ onMouseEnter?: () => void
183
+ onMouseLeave?: () => void
184
+ isDisabled?: boolean
185
+ count?: number
186
+ children: React.ReactNode
187
+ }) {
188
+ const isOut = variant === 'out'
189
+
190
+ const shadow = active
191
+ ? isOut
192
+ ? 'clay-out'
193
+ : 'clay-in'
194
+ : 'none'
195
+
196
+
197
+
198
+ return (
199
+ <Tooltip label={label} placement="top" openDelay={300} isDisabled={isDisabled}>
200
+ <Box
201
+ as="button"
202
+ w="22px"
203
+ h="22px"
204
+ display="flex"
205
+ alignItems="center"
206
+ justifyContent="center"
207
+ rounded="lg"
208
+ fontSize="xs"
209
+ lineHeight={1}
210
+ color={active ? (isOut ? 'blue.400' : 'teal.400') : 'whiteAlpha.300'}
211
+ border="none"
212
+ opacity={active ? 1 : 0.5}
213
+ boxShadow={shadow}
214
+ position="relative"
215
+ _hover={{
216
+ opacity: 1,
217
+ }}
218
+ onClick={onClick}
219
+ onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}
220
+ onMouseEnter={onMouseEnter}
221
+ onMouseLeave={onMouseLeave}
222
+ flexShrink={0}
223
+ pointerEvents="auto"
224
+ >
225
+ <Box
226
+ transform={!isOut && active ? 'translateY(0.5px)' : 'none'}
227
+ transition="transform 0.15s"
228
+ display="flex"
229
+ alignItems="center"
230
+ justifyContent="center"
231
+ >
232
+ {children}
233
+ </Box>
234
+ {count > 1 && (
235
+ <Box
236
+ position="absolute"
237
+ top="-5px"
238
+ right="-5px"
239
+ color={isOut ? 'blue.400' : 'teal.400'}
240
+ fontSize="8px"
241
+ fontWeight="bold"
242
+ w="11px"
243
+ h="11px"
244
+ rounded="full"
245
+ display="flex"
246
+ alignItems="center"
247
+ justifyContent="center"
248
+ boxShadow="0 1px 3px rgba(0,0,0,0.3)"
249
+ >
250
+ {count}
251
+ </Box>
252
+ )}
253
+ </Box>
254
+ </Tooltip>
255
+ )
256
+ }
257
+
258
+ const zoomSelector = (s: { transform: [number, number, number] }) =>
259
+ Math.round(s.transform[2] * 50) / 50
260
+
261
+ const HANDLE_SLOTS = Array.from({ length: HANDLE_SLOT_COUNT }, (_, index) => index)
262
+ const HANDLE_CONFIGS = [
263
+ { side: 'top', position: Position.Top },
264
+ { side: 'left', position: Position.Left },
265
+ { side: 'right', position: Position.Right },
266
+ { side: 'bottom', position: Position.Bottom },
267
+ ] as const
268
+
269
+ function getReconnectZoneStyle(position: Position, slot: number): React.CSSProperties {
270
+ const baseStyle = getVisualHandleStyle(position, slot)
271
+
272
+ switch (position) {
273
+ case Position.Top:
274
+ return { ...baseStyle, top: '-18px', width: '28px', height: '18px', transform: 'translateX(-50%)' }
275
+ case Position.Bottom:
276
+ return { ...baseStyle, bottom: '-18px', width: '28px', height: '18px', transform: 'translateX(-50%)' }
277
+ case Position.Left:
278
+ return { ...baseStyle, left: '-18px', width: '18px', height: '28px', transform: 'translateY(-50%)' }
279
+ case Position.Right:
280
+ return { ...baseStyle, right: '-18px', width: '18px', height: '28px', transform: 'translateY(-50%)' }
281
+ }
282
+ }
283
+
284
+ function ElementNode({ data, selected }: Props) {
285
+ const zoom = useStore(zoomSelector)
286
+ useAccentColor()
287
+
288
+ const nodeId = String(data.element_id)
289
+ const isConnectorHighlighted = useStore((s) =>
290
+ s.edges.some(e => e.selected && (e.source === nodeId || e.target === nodeId))
291
+ )
292
+ const { connectedHandleKey, selectedHandleKey } = useStore((s) => {
293
+ const connectedHandles = new Set<string>()
294
+ const selectedHandles = new Set<string>()
295
+ for (const connector of s.edges) {
296
+ if (connector.source === nodeId) {
297
+ const targetNode = s.nodeInternals.get(connector.target)
298
+ if (targetNode && targetNode.type !== 'ContextBoundaryElement' && targetNode.type !== 'contextNeighborNode') {
299
+ const handleId = ensureVisualHandleId(connector.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE)
300
+ if (handleId) {
301
+ connectedHandles.add(handleId)
302
+ if (connector.selected) selectedHandles.add(handleId)
303
+ }
304
+ }
305
+ }
306
+ if (connector.target === nodeId) {
307
+ const sourceNode = s.nodeInternals.get(connector.source)
308
+ if (sourceNode && sourceNode.type !== 'ContextBoundaryElement' && sourceNode.type !== 'contextNeighborNode') {
309
+ const handleId = ensureVisualHandleId(connector.targetHandle, DEFAULT_TARGET_HANDLE_SIDE)
310
+ if (handleId) {
311
+ connectedHandles.add(handleId)
312
+ if (connector.selected) selectedHandles.add(handleId)
313
+ }
314
+ }
315
+ }
316
+ }
317
+ return {
318
+ connectedHandleKey: Array.from(connectedHandles).sort().join('|'),
319
+ selectedHandleKey: Array.from(selectedHandles).sort().join('|'),
320
+ }
321
+ })
322
+ const handleReconnectCandidates = useStore((s) => {
323
+ const candidates: Array<{ handleId: string; edgeId: string; endpoint: 'source' | 'target'; selected: boolean }> = []
324
+ for (const connector of s.edges) {
325
+ if (connector.source === nodeId) {
326
+ const handleId = ensureVisualHandleId(connector.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE)
327
+ if (handleId) candidates.push({ handleId, edgeId: connector.id, endpoint: 'source', selected: !!connector.selected })
328
+ }
329
+ if (connector.target === nodeId) {
330
+ const handleId = ensureVisualHandleId(connector.targetHandle, DEFAULT_TARGET_HANDLE_SIDE)
331
+ if (handleId) candidates.push({ handleId, edgeId: connector.id, endpoint: 'target', selected: !!connector.selected })
332
+ }
333
+ }
334
+ return candidates
335
+ })
336
+ const connectedHandleIds = useMemo(
337
+ () => new Set(connectedHandleKey ? connectedHandleKey.split('|') : []),
338
+ [connectedHandleKey],
339
+ )
340
+ const selectedHandleIds = useMemo(
341
+ () => new Set(selectedHandleKey ? selectedHandleKey.split('|') : []),
342
+ [selectedHandleKey],
343
+ )
344
+ const activeSides = useMemo(() => {
345
+ const sides = new Set<string>()
346
+ for (const handleId of connectedHandleIds) {
347
+ const side = handleId.split('-', 1)[0]
348
+ if (side) sides.add(side)
349
+ }
350
+ return sides
351
+ }, [connectedHandleIds])
352
+ const reconnectCandidateByHandle = useMemo(() => {
353
+ const next = new Map<string, { edgeId: string; endpoint: 'source' | 'target' }>()
354
+ const sortedCandidates = [...handleReconnectCandidates].sort((left, right) => {
355
+ if (left.selected !== right.selected) return left.selected ? -1 : 1
356
+ return left.edgeId.localeCompare(right.edgeId)
357
+ })
358
+ for (const candidate of sortedCandidates) {
359
+ if (!next.has(candidate.handleId)) {
360
+ next.set(candidate.handleId, { edgeId: candidate.edgeId, endpoint: candidate.endpoint })
361
+ }
362
+ }
363
+ return next
364
+ }, [handleReconnectCandidates])
365
+
366
+ const derivedPrimaryIconPath = (() => {
367
+ const selected = data.technology_connectors?.find((link) => link.type === 'catalog' && !!link.is_primary_icon && !!link.slug)
368
+ if (!selected?.slug) return undefined
369
+ return resolveIconPath(`/icons/${selected.slug}.png`)
370
+ })()
371
+
372
+ const nodeLogoUrl = data.logo_url ? resolveIconPath(data.logo_url) : derivedPrimaryIconPath
373
+
374
+ const technologyLinkCount = (data.technology_connectors || []).filter((l) => !!l.label).length
375
+ const technologyParts = (data.technology || '')
376
+ .split(',')
377
+ .map((t) => t.trim())
378
+ .filter(Boolean)
379
+ const technologyCount = technologyLinkCount > 0 ? technologyLinkCount : technologyParts.length
380
+
381
+ const showTechnologyText = !nodeLogoUrl || technologyCount > 1
382
+ const technologyText =
383
+ showTechnologyText
384
+ ? (data.technology || (technologyLinkCount > 1 ? data.technology_connectors.map((l) => l.label).join(', ') : undefined))
385
+ : undefined
386
+
387
+ const [menuVisible, setMenuVisible] = useState(false)
388
+ const [isDraggedOver, setIsDraggedOver] = useState(false)
389
+ const menuRef = useRef<{ type: 'in' | 'out', links: ViewConnector[] } | null>(null)
390
+
391
+ useEffect(() => {
392
+ if (data.isCanvasMoving) {
393
+ setMenuVisible(false)
394
+ }
395
+ }, [data.isCanvasMoving])
396
+
397
+ const handleDragOver = (e: React.DragEvent) => {
398
+ if (e.dataTransfer.types.includes('application/diag-tag') || e.dataTransfer.types.includes('application/diag-layer')) {
399
+ e.preventDefault()
400
+ setIsDraggedOver(true)
401
+ }
402
+ }
403
+
404
+ const handleDragLeave = () => {
405
+ setIsDraggedOver(false)
406
+ }
407
+
408
+ const handleDrop = () => {
409
+ setIsDraggedOver(false)
410
+ }
411
+
412
+ const hasParent = data.parentLinks.length > 0 || !!data.parentViewId
413
+ const hasChild = data.links.length > 0 || data.has_view
414
+
415
+ const handleZoomOutClick = (e: React.MouseEvent) => {
416
+ e.stopPropagation()
417
+ if (data.parentLinks.length > 1) {
418
+ menuRef.current = { type: 'out', links: data.parentLinks }
419
+ setMenuVisible(true)
420
+ } else {
421
+ data.onZoomOut(data.element_id)
422
+ }
423
+ }
424
+
425
+ const handleZoomInClick = (e: React.MouseEvent) => {
426
+ e.stopPropagation()
427
+ if (data.links.length > 1) {
428
+ menuRef.current = { type: 'in', links: data.links }
429
+ setMenuVisible(true)
430
+ } else {
431
+ data.onZoomIn(data.element_id)
432
+ }
433
+ }
434
+
435
+ const parentLabel = data.parentLinks.length > 1
436
+ ? `${data.parentLinks.length} views`
437
+ : hasParent
438
+ ? data.parentLinks.length > 0
439
+ ? `Zoom out → ${data.parentLinks[0].to_view_name}`
440
+ : 'Zoom out to parent'
441
+ : ''
442
+
443
+ const childLabel = data.links.length > 1
444
+ ? `${data.links.length} sub-views`
445
+ : hasChild
446
+ ? data.links.length > 0
447
+ ? `Zoom in → ${data.links[0].to_view_name}`
448
+ : `Zoom in → ${data.view_label || 'sub-view'}`
449
+ : 'Create child view'
450
+
451
+ // ── Long-press-to-connect ──────────────────────────────────────────────────
452
+ const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
453
+ const longPressActivated = useRef(false)
454
+ const pointerStart = useRef<{ x: number; y: number } | null>(null)
455
+
456
+ const onPointerDown = (e: React.PointerEvent) => {
457
+ if ((e.target as Element).closest('.react-flow__handle')) return
458
+ pointerStart.current = { x: e.clientX, y: e.clientY }
459
+ longPressActivated.current = false
460
+ longPressTimer.current = setTimeout(() => {
461
+ longPressActivated.current = true
462
+ data.onInteractionStart(data.element_id)
463
+ }, 500)
464
+ }
465
+
466
+ const onPointerMove = (e: React.PointerEvent) => {
467
+ if (!pointerStart.current || !longPressTimer.current) return
468
+ const dx = e.clientX - pointerStart.current.x
469
+ const dy = e.clientY - pointerStart.current.y
470
+ if (Math.hypot(dx, dy) > 8) {
471
+ clearTimeout(longPressTimer.current)
472
+ longPressTimer.current = null
473
+ }
474
+ }
475
+
476
+ const onPointerUp = () => {
477
+ if (longPressTimer.current) {
478
+ clearTimeout(longPressTimer.current)
479
+ longPressTimer.current = null
480
+ }
481
+ }
482
+
483
+ const handleBodyClick = (e: React.MouseEvent) => {
484
+ if (longPressActivated.current) {
485
+ longPressActivated.current = false
486
+ return
487
+ }
488
+ if (data.interactionSourceId) {
489
+ e.stopPropagation()
490
+ if (data.interactionSourceId === data.element_id) {
491
+ // tap source again → cancel interaction mode
492
+ data.onInteractionStart(data.element_id)
493
+ } else {
494
+ // tap a different element → create connector
495
+ data.onConnectTo(data.element_id)
496
+ }
497
+ } else {
498
+ data.onSelect(data)
499
+ }
500
+ }
501
+
502
+ const isSource = data.interactionSourceId === data.element_id
503
+ const isTarget = !!data.interactionSourceId && !isSource
504
+
505
+ const bodyCursor = isSource ? 'crosshair' : isTarget ? 'cell' : 'pointer'
506
+
507
+ return (
508
+ <ElementContainer
509
+ isSelected={selected}
510
+ isSource={isSource}
511
+ isTarget={isTarget}
512
+ isConnectorHighlighted={isConnectorHighlighted}
513
+ minW="180px"
514
+ maxW="230px"
515
+ cursor={bodyCursor}
516
+ outline={isDraggedOver ? '2px solid' : undefined}
517
+ outlineColor={isDraggedOver ? 'var(--accent)' : undefined}
518
+ outlineOffset={isDraggedOver ? '2px' : undefined}
519
+ borderTopWidth={data.layerHighlightColor ? '2px' : undefined}
520
+ borderTopColor={data.layerHighlightColor ?? undefined}
521
+ onClick={handleBodyClick}
522
+ onPointerDown={onPointerDown}
523
+ onPointerMove={onPointerMove}
524
+ onPointerUp={onPointerUp}
525
+ onPointerCancel={onPointerUp}
526
+ onContextMenu={(e) => e.preventDefault()}
527
+ onDragOver={handleDragOver}
528
+ onDragLeave={handleDragLeave}
529
+ onDrop={handleDrop}
530
+ style={{
531
+ userSelect: 'none',
532
+ WebkitUserSelect: 'none',
533
+ transition: 'outline 0.15s, outline-color 0.15s',
534
+ } as React.CSSProperties}
535
+ >
536
+ {HANDLE_CONFIGS.flatMap(({ side, position }) =>
537
+ HANDLE_SLOTS.map((slot) => {
538
+ const handleId = getVisualHandleId(side, slot)
539
+ const isFallbackSlot = slot === HANDLE_SLOT_CENTER_INDEX && !activeSides.has(side)
540
+ const className = [
541
+ 'element-node-handle',
542
+ connectedHandleIds.has(handleId) ? 'handle-active-slot' : '',
543
+ isFallbackSlot ? 'handle-fallback-slot' : '',
544
+ connectedHandleIds.has(handleId) ? 'handle-connected' : '',
545
+ selectedHandleIds.has(handleId) ? 'handle-selected-edge' : '',
546
+ ].filter(Boolean).join(' ') || undefined
547
+
548
+ return (
549
+ <Box key={handleId} position="absolute" inset={0} pointerEvents="none">
550
+ <Handle
551
+ type="source"
552
+ position={position}
553
+ id={handleId}
554
+ className={className}
555
+ style={{
556
+ ...getVisualHandleStyle(position, slot),
557
+ background: 'var(--accent)',
558
+ }}
559
+ />
560
+ {data.onStartHandleReconnect && reconnectCandidateByHandle.has(handleId) && (
561
+ <Box
562
+ position="absolute"
563
+ className="element-node-reconnect-zone"
564
+ style={getReconnectZoneStyle(position, slot)}
565
+ pointerEvents="auto"
566
+ cursor="grab"
567
+ zIndex={4}
568
+ onPointerDown={(e: React.PointerEvent) => {
569
+ if (e.button !== 0) return
570
+ e.preventDefault()
571
+ e.stopPropagation()
572
+ const candidate = reconnectCandidateByHandle.get(handleId)
573
+ if (!candidate) return
574
+ data.onStartHandleReconnect?.({
575
+ edgeId: candidate.edgeId,
576
+ endpoint: candidate.endpoint,
577
+ handleId,
578
+ clientX: e.clientX,
579
+ clientY: e.clientY,
580
+ })
581
+ }}
582
+ />
583
+ )}
584
+ </Box>
585
+ )
586
+ }),
587
+ )}
588
+
589
+ {/* ── Header: zoom-out | zoom-in (absolute overlay, consumes no space) ── */}
590
+ <Flex
591
+ position="absolute"
592
+ top={1.5}
593
+ left={2}
594
+ right={2}
595
+ align="center"
596
+ justify="space-between"
597
+ gap={1}
598
+ zIndex={1}
599
+ pointerEvents="none"
600
+ >
601
+ <LayerButton
602
+ label={parentLabel}
603
+ active={hasParent}
604
+ variant="out"
605
+ count={data.parentLinks.length}
606
+ onClick={handleZoomOutClick}
607
+ onMouseEnter={() => data.onHoverZoom(data.element_id, 'out')}
608
+ onMouseLeave={() => data.onHoverZoom(data.element_id, null)}
609
+ isDisabled={data.isCanvasMoving}
610
+ >
611
+ <ZoomOutIcon size={12} strokeWidth={2.5} />
612
+ </LayerButton>
613
+
614
+ <Flex flex={1} justify="center" align="center" minW={0} pointerEvents="none">
615
+ {nodeLogoUrl && (
616
+ <Box
617
+ as="img"
618
+ src={nodeLogoUrl}
619
+ alt="technology icon"
620
+ maxW="28px"
621
+ maxH="28px"
622
+ objectFit="contain"
623
+ opacity={0.95}
624
+ />
625
+ )}
626
+ </Flex>
627
+
628
+ <LayerButton
629
+ label={childLabel}
630
+ active={hasChild}
631
+ variant="in"
632
+ count={data.links.length}
633
+ onClick={handleZoomInClick}
634
+ onMouseEnter={() => data.onHoverZoom(data.element_id, 'in')}
635
+ onMouseLeave={() => data.onHoverZoom(data.element_id, null)}
636
+ isDisabled={data.isCanvasMoving}
637
+ >
638
+ <ZoomInIcon size={12} strokeWidth={2.5} />
639
+ </LayerButton>
640
+ </Flex>
641
+
642
+ {/* ── Zoom hover effect ── */}
643
+ {data.isZoomHovered && (
644
+ <Box
645
+ position="absolute"
646
+ inset="-3px"
647
+ rounded="xl"
648
+ border="1.5px solid"
649
+ borderColor={data.isZoomHovered === 'in' ? 'teal.500' : 'blue.500'}
650
+ boxShadow={`0 0 12px ${data.isZoomHovered === 'in' ? 'rgba(20, 184, 166, 0.2)' : 'rgba(59, 130, 246, 0.2)'}`}
651
+ pointerEvents="none"
652
+ zIndex={-1}
653
+ animation="zoom-quiet-breath 3s ease-in-out infinite"
654
+ sx={{
655
+ '@keyframes zoom-quiet-breath': {
656
+ '0%': { opacity: 0.3, transform: 'scale(1)' },
657
+ '50%': { opacity: 0.6, transform: 'scale(1.01)' },
658
+ '100%': { opacity: 0.3, transform: 'scale(1)' },
659
+ }
660
+ }}
661
+ />
662
+ )}
663
+
664
+ {/* ── Body: name vertically centred, long-press to connect, click to edit ── */}
665
+ <ElementBody
666
+ name={data.name}
667
+ type={data.kind ?? ''}
668
+ technology={technologyText}
669
+ logoUrl={undefined}
670
+ nameSize="xl"
671
+ minH="85px"
672
+ pt={nodeLogoUrl ? 9 : 2}
673
+ pb={2}
674
+ />
675
+
676
+ {/* Tags Dots & Hover Overlay */}
677
+ {data.tags && data.tags.length > 0 && (
678
+ <Box
679
+ position="absolute"
680
+ bottom="8px"
681
+ left="8px"
682
+ zIndex={10}
683
+ role="group"
684
+ >
685
+ {/* Tag Dots (up to 5) */}
686
+ <HStack spacing={1} _groupHover={{ opacity: 0 }}>
687
+ {data.tags.slice(0, 5).map((tag, i) => (
688
+ <Box
689
+ key={i}
690
+ w="6px"
691
+ h="6px"
692
+ rounded="full"
693
+ bg={data.tagColors[tag]?.color || 'var(--accent)'}
694
+ boxShadow="0 0 3px rgba(0,0,0,0.3)"
695
+ transition="all 0.2s"
696
+ />
697
+ ))}
698
+ {data.tags.length > 5 && (
699
+ <Text fontSize="8px" fontWeight="bold" color="whiteAlpha.600" lineHeight={1}>
700
+ +{data.tags.length - 5}
701
+ </Text>
702
+ )}
703
+ </HStack>
704
+
705
+ {/* Hover Menu: Expanding tags at -45 degree angle */}
706
+ <VStack
707
+ position="absolute"
708
+ bottom="-4px"
709
+ left="-4px"
710
+ align="start"
711
+ spacing={1}
712
+ opacity={data.forceShowTagPopup && !data.isCanvasMoving ? 1 : 0}
713
+ visibility={data.forceShowTagPopup && !data.isCanvasMoving ? 'visible' : 'hidden'}
714
+ transform={data.forceShowTagPopup && !data.isCanvasMoving ? 'scale(1) translate(0px, 0px)' : 'scale(0.2) translate(10px, 10px)'}
715
+ transformOrigin="bottom left"
716
+ transition="all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275)"
717
+ _groupHover={{ opacity: 1, visibility: 'visible', transform: 'scale(1) translate(0px, 0px)' }}
718
+ pointerEvents="none"
719
+ >
720
+ {data.tags.map((tag) => (
721
+ <Box
722
+ key={tag}
723
+ bg="var(--bg-panel)"
724
+ border="1px solid"
725
+ borderColor="whiteAlpha.300"
726
+ rounded="full"
727
+ px={2}
728
+ py="1px"
729
+ boxShadow="0 4px 12px rgba(0,0,0,0.5)"
730
+ whiteSpace="nowrap"
731
+ >
732
+ <HStack spacing={1.5}>
733
+ <Box w="6px" h="6px" rounded="full" bg={data.tagColors[tag]?.color || 'var(--accent)'} />
734
+ <Text fontSize="10px" fontWeight="700" color="white">{tag}</Text>
735
+ </HStack>
736
+ </Box>
737
+ ))}
738
+ </VStack>
739
+ </Box>
740
+ )}
741
+
742
+ {/* Code Preview Icon/Link in Bottom Right Corner */}
743
+ {((data.repo || data.url) && !window.__TLD_VSCODE__) && (
744
+ <Box
745
+ position="absolute"
746
+ bottom="8px"
747
+ right="8px"
748
+ zIndex={10}
749
+ >
750
+ <Tooltip
751
+ label={
752
+ data.repo
753
+ ? `View source: ${data.file_path?.includes('#') ? (() => { try { return JSON.parse(data.file_path.split('#')[1]).name } catch { return 'Link' } })() : 'Link'}${data.url ? ' / URL' : ''}`
754
+ : 'Open Link'
755
+ }
756
+ placement="top"
757
+ isDisabled={data.isCanvasMoving}
758
+ >
759
+ <Box
760
+ as="button"
761
+ display="flex"
762
+ alignItems="center"
763
+ justifyContent="center"
764
+ w="18px"
765
+ h="18px"
766
+ rounded="md"
767
+ color="whiteAlpha.900"
768
+ _hover={{ color: 'blue.300', bg: 'whiteAlpha.200', transform: 'scale(1.1)' }}
769
+ transition="all 0.15s"
770
+ onClick={(e: React.MouseEvent) => {
771
+ e.stopPropagation()
772
+ if (data.repo) {
773
+ data.onOpenCodePreview?.(data.element_id)
774
+ } else if (data.url) {
775
+ window.open(data.url, '_blank', 'noopener,noreferrer')
776
+ }
777
+ }}
778
+ onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}
779
+ >
780
+ <LinkIcon w={2.5} h={2.5} />
781
+ </Box>
782
+ </Tooltip>
783
+ </Box>
784
+ )}
785
+
786
+ {/* VSCode specific file link with hover preview */}
787
+ {window.__TLD_VSCODE__ && data.file_path && (
788
+ <Box
789
+ position="absolute"
790
+ bottom="8px"
791
+ right="8px"
792
+ zIndex={10}
793
+ >
794
+ <VscodeCodePreview
795
+ filePath={data.file_path}
796
+ isCanvasMoving={data.isCanvasMoving}
797
+ />
798
+ </Box>
799
+ )}
800
+
801
+ {selected && !isSource && (
802
+ <HStack
803
+ position="absolute"
804
+ top="-20px"
805
+ left="0"
806
+ right="0"
807
+ spacing={0}
808
+ justify="space-evenly"
809
+ pointerEvents="none"
810
+ zIndex={10}
811
+ opacity={data.isCanvasMoving ? 0 : 0.6}
812
+ transition="opacity 0.2s"
813
+ >
814
+ <HStack spacing={1.5}>
815
+ <Text color="whiteAlpha.600" fontSize="8px" fontWeight="bold">E</Text>
816
+ <Text color="whiteAlpha.400" fontSize="8px">Connect</Text>
817
+ </HStack>
818
+ <HStack spacing={1.5}>
819
+ <Text color="whiteAlpha.600" fontSize="8px" fontWeight="bold">R</Text>
820
+ <Text color="whiteAlpha.400" fontSize="8px">Remove</Text>
821
+ </HStack>
822
+ <HStack spacing={1.5}>
823
+ <Text color="whiteAlpha.600" fontSize="8px" fontWeight="bold">⇧R</Text>
824
+ <Text color="whiteAlpha.400" fontSize="8px">Delete</Text>
825
+ </HStack>
826
+ <HStack spacing={1.5}>
827
+ <Text color="whiteAlpha.600" fontSize="8px" fontWeight="bold">T</Text>
828
+ <Text color="whiteAlpha.400" fontSize="8px">Tech</Text>
829
+ </HStack>
830
+ </HStack>
831
+ )}
832
+
833
+ {/* Interaction-mode menu + badge on the source element */}
834
+ {isSource && !data.isCanvasMoving && (
835
+ <>
836
+ {!data.isClickConnectMode && (
837
+ <Box
838
+ position="absolute"
839
+ bottom="calc(100% + 12px)"
840
+ left="50%"
841
+ transform={`translateX(-50%) scale(${1 / zoom})`}
842
+ transformOrigin="bottom center"
843
+ bg="clay.bg"
844
+ border="1px solid"
845
+ borderColor="rgba(255,255,255,0.08)"
846
+ rounded="xl"
847
+ boxShadow="clay-out"
848
+ p={1}
849
+ zIndex={100}
850
+ onClick={(e) => e.stopPropagation()}
851
+ onPointerDown={(e) => e.stopPropagation()}
852
+ >
853
+ <VStack spacing={0} align="stretch">
854
+ <Button
855
+ size="sm"
856
+ variant="ghost"
857
+ h="30px"
858
+ px={2.5}
859
+ justifyContent="flex-start"
860
+ color="clay.text"
861
+ _hover={{ bg: 'whiteAlpha.100', color: 'gray.100' }}
862
+ _active={{ bg: 'whiteAlpha.200' }}
863
+ onClick={(e) => {
864
+ e.stopPropagation()
865
+ data.onSelect(data)
866
+ }}
867
+ >
868
+ <HStack spacing={2} w="full">
869
+ <EditSvg />
870
+ <Text fontSize="xs" fontWeight="normal" flex={1}>Edit</Text>
871
+ </HStack>
872
+ </Button>
873
+ <Divider borderColor="whiteAlpha.100" my={1} />
874
+ <Button
875
+ size="sm"
876
+ variant="ghost"
877
+ h="30px"
878
+ px={2.5}
879
+ justifyContent="flex-start"
880
+ color="red.400"
881
+ _hover={{ bg: 'rgba(254,178,178,0.08)', color: 'red.300' }}
882
+ _active={{ bg: 'rgba(254,178,178,0.12)' }}
883
+ onClick={(e) => {
884
+ e.stopPropagation()
885
+ data.onRemove(data.element_id)
886
+ }}
887
+ >
888
+ <HStack spacing={2} w="full">
889
+ <TrashSvg />
890
+ <Text fontSize="xs" fontWeight="normal" flex={1}>Delete</Text>
891
+ </HStack>
892
+ </Button>
893
+ </VStack>
894
+ </Box>
895
+ )}
896
+
897
+ {/* interaction mode */}
898
+ <Box
899
+ position="absolute"
900
+ bottom="-22px"
901
+ left="50%"
902
+ transform={`translateX(-50%) scale(${1 / zoom})`}
903
+ transformOrigin="top center"
904
+ bg="clay.in"
905
+ color="clay.dim"
906
+ border="1px solid"
907
+ borderColor="rgba(255,255,255,0.06)"
908
+ fontSize="9px"
909
+ px={2}
910
+ py="2px"
911
+ rounded="full"
912
+ whiteSpace="nowrap"
913
+ pointerEvents="none"
914
+ zIndex={10}
915
+ >
916
+ tap element to connect · tap canvas to add
917
+ </Box>
918
+ </>
919
+ )}
920
+
921
+ {/* Drill-down menu for multiple connectors */}
922
+ {menuVisible && menuRef.current && !data.isCanvasMoving && (
923
+ <Box
924
+ position="absolute"
925
+ top={menuRef.current.type === 'in' ? '30px' : '-2px'}
926
+ left={menuRef.current.type === 'in' ? 'auto' : '2px'}
927
+ right={menuRef.current.type === 'in' ? '2px' : 'auto'}
928
+ transform={`scale(${1 / zoom})`}
929
+ transformOrigin={menuRef.current.type === 'in' ? 'top right' : 'top left'}
930
+ bg="clay.bg"
931
+ border="1px solid"
932
+ borderColor="rgba(255,255,255,0.08)"
933
+ rounded="xl"
934
+ boxShadow="clay-out"
935
+ p={1}
936
+ zIndex={1100}
937
+ onClick={(e) => e.stopPropagation()}
938
+ onPointerDown={(e) => e.stopPropagation()}
939
+ >
940
+ <VStack spacing={0} align="stretch" minW="140px">
941
+ <Box px={2.5} py={1.5}>
942
+ <Text fontSize="10px" fontWeight="bold" color="gray.500" textTransform="uppercase">
943
+ {menuRef.current.type === 'in' ? 'Sub-views' : 'Parent Views'}
944
+ </Text>
945
+ </Box>
946
+ <Divider borderColor="whiteAlpha.100" mb={1} />
947
+ {menuRef.current.links.map((link) => (
948
+ <Button
949
+ key={link.id}
950
+ size="sm"
951
+ variant="ghost"
952
+ h="32px"
953
+ px={2.5}
954
+ justifyContent="flex-start"
955
+ color="clay.text"
956
+ _hover={{ bg: 'whiteAlpha.100', color: 'gray.100' }}
957
+ _active={{ bg: 'whiteAlpha.200' }}
958
+ onClick={(e) => {
959
+ e.stopPropagation()
960
+ setMenuVisible(false)
961
+ if (menuRef.current?.type === 'in') {
962
+ data.onNavigateToDiagram(link.to_view_id)
963
+ } else {
964
+ data.onNavigateToDiagram(link.from_view_id)
965
+ }
966
+ }}
967
+ >
968
+ <HStack spacing={2} w="full">
969
+ <Box color={menuRef.current?.type === 'in' ? 'teal.400' : 'blue.400'}>
970
+ {menuRef.current?.type === 'in' ? <ZoomInIcon size={11} strokeWidth={3} /> : <ZoomOutIcon size={11} strokeWidth={3} />}
971
+ </Box>
972
+ <Text fontSize="xs" fontWeight="normal" flex={1} isTruncated>
973
+ {link.to_view_name}
974
+ </Text>
975
+ </HStack>
976
+ </Button>
977
+ ))}
978
+ <Divider borderColor="whiteAlpha.100" my={1} />
979
+ <Button
980
+ size="xs"
981
+ variant="ghost"
982
+ onClick={(e) => {
983
+ e.stopPropagation()
984
+ setMenuVisible(false)
985
+ }}
986
+ >
987
+ Cancel
988
+ </Button>
989
+ </VStack>
990
+ </Box>
991
+ )}
992
+ </ElementContainer>
993
+ )
994
+ }
995
+
996
+ function arePropsEqual(prev: Props, next: Props) {
997
+ if (prev.selected !== next.selected) return false
998
+ const p = prev.data
999
+ const n = next.data
1000
+ return (
1001
+ p.element_id === n.element_id &&
1002
+ p.name === n.name &&
1003
+ p.description === n.description &&
1004
+ p.kind === n.kind &&
1005
+ p.technology === n.technology &&
1006
+ p.logo_url === n.logo_url &&
1007
+ p.links === n.links &&
1008
+ p.parentLinks === n.parentLinks &&
1009
+ p.isZoomHovered === n.isZoomHovered &&
1010
+ p.interactionSourceId === n.interactionSourceId &&
1011
+ p.onZoomIn === n.onZoomIn &&
1012
+ p.onZoomOut === n.onZoomOut &&
1013
+ p.onSelect === n.onSelect &&
1014
+ p.onHoverZoom === n.onHoverZoom &&
1015
+ p.isCanvasMoving === n.isCanvasMoving &&
1016
+ p.onRemove === n.onRemove &&
1017
+ p.onInteractionStart === n.onInteractionStart &&
1018
+ p.onConnectTo === n.onConnectTo &&
1019
+ p.onStartHandleReconnect === n.onStartHandleReconnect &&
1020
+ p.onNavigateToDiagram === n.onNavigateToDiagram &&
1021
+ p.technology_connectors === n.technology_connectors &&
1022
+ p.repo === n.repo &&
1023
+ p.branch === n.branch &&
1024
+ p.file_path === n.file_path &&
1025
+ p.language === n.language &&
1026
+ p.isClickConnectMode === n.isClickConnectMode &&
1027
+ p.tagColors === n.tagColors &&
1028
+ p.layerHighlightColor === n.layerHighlightColor &&
1029
+ p.forceShowTagPopup === n.forceShowTagPopup
1030
+ )
1031
+ }
1032
+
1033
+ export default memo(ElementNode, arePropsEqual)