@tldiagram/core-ui 1.95.0 → 2.0.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 (102) hide show
  1. package/dist/api/client.d.ts +184 -3
  2. package/dist/components/ConnectorPanel.d.ts +5 -1
  3. package/dist/components/CrossBranchControls.d.ts +4 -3
  4. package/dist/components/ElementNode.d.ts +5 -0
  5. package/dist/components/ElementPanel.d.ts +6 -1
  6. package/dist/components/LayoutSection.d.ts +2 -1
  7. package/dist/components/MergeDialog.d.ts +16 -0
  8. package/dist/components/MiniZoomOnboarding.d.ts +2 -1
  9. package/dist/components/NodeContainer.d.ts +2 -0
  10. package/dist/components/ProxyConnectorPanel.d.ts +4 -1
  11. package/dist/components/ViewExplorer/index.d.ts +1 -1
  12. package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
  13. package/dist/components/ViewFloatingMenu.d.ts +8 -1
  14. package/dist/components/ViewGridNode.d.ts +3 -0
  15. package/dist/components/ViewPanel.d.ts +2 -1
  16. package/dist/components/WorkspacePanel.d.ts +2 -0
  17. package/dist/components/ZUI/ZUICanvas.d.ts +5 -0
  18. package/dist/components/ZUI/focus.d.ts +32 -0
  19. package/dist/components/ZUI/focus.test.d.ts +1 -0
  20. package/dist/components/ZUI/layout.d.ts +2 -2
  21. package/dist/components/ZUI/proxy.d.ts +20 -4
  22. package/dist/components/ZUI/renderer.d.ts +35 -1
  23. package/dist/components/ZUI/types.d.ts +6 -0
  24. package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
  25. package/dist/context/WorkspaceVersionContext.d.ts +49 -0
  26. package/dist/crossBranch/resolve.d.ts +39 -2
  27. package/dist/crossBranch/resolve.test.d.ts +1 -0
  28. package/dist/crossBranch/settings.d.ts +6 -1
  29. package/dist/crossBranch/types.d.ts +8 -0
  30. package/dist/hooks/useElementSearch.d.ts +8 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +14597 -12083
  33. package/dist/pages/InfiniteZoom.d.ts +1 -0
  34. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
  35. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
  36. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
  37. package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
  38. package/dist/pages/viewsJumpSearch.d.ts +22 -0
  39. package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
  40. package/dist/store/useStore.d.ts +3 -0
  41. package/dist/types/index.d.ts +9 -0
  42. package/dist/utils/elementIcon.d.ts +2 -0
  43. package/dist/utils/elementIcon.test.d.ts +1 -0
  44. package/dist/utils/sourceEditor.d.ts +7 -0
  45. package/dist/utils/watchDiffSummary.d.ts +34 -0
  46. package/package.json +2 -2
  47. package/src/App.tsx +12 -8
  48. package/src/api/client.ts +488 -26
  49. package/src/components/CodePreviewPanel.tsx +90 -16
  50. package/src/components/ConnectorPanel.tsx +34 -3
  51. package/src/components/ContextNeighborElement.tsx +2 -5
  52. package/src/components/CrossBranchControls.tsx +46 -17
  53. package/src/components/ElementNode.tsx +98 -47
  54. package/src/components/ElementPanel.tsx +62 -25
  55. package/src/components/InlineElementAdder.tsx +8 -3
  56. package/src/components/LayoutSection.tsx +4 -1
  57. package/src/components/MergeDialog.tsx +269 -0
  58. package/src/components/MiniZoomOnboarding.tsx +29 -22
  59. package/src/components/NodeContainer.tsx +55 -17
  60. package/src/components/ProxyConnectorPanel.tsx +58 -16
  61. package/src/components/ViewBezierConnector.tsx +116 -21
  62. package/src/components/ViewExplorer/index.tsx +1 -1
  63. package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
  64. package/src/components/ViewFloatingMenu.tsx +110 -1
  65. package/src/components/ViewGridNode.tsx +59 -8
  66. package/src/components/ViewPanel.tsx +3 -2
  67. package/src/components/WorkspacePanel.tsx +938 -0
  68. package/src/components/ZUI/ZUICanvas.tsx +226 -127
  69. package/src/components/ZUI/focus.test.ts +534 -0
  70. package/src/components/ZUI/focus.ts +293 -0
  71. package/src/components/ZUI/layout.ts +7 -11
  72. package/src/components/ZUI/proxy.ts +470 -114
  73. package/src/components/ZUI/renderer.ts +510 -134
  74. package/src/components/ZUI/types.ts +6 -0
  75. package/src/components/ZUI/useZUIInteraction.ts +66 -29
  76. package/src/context/WorkspaceVersionContext.tsx +126 -0
  77. package/src/crossBranch/resolve.test.ts +342 -0
  78. package/src/crossBranch/resolve.ts +368 -68
  79. package/src/crossBranch/settings.ts +49 -3
  80. package/src/crossBranch/types.ts +9 -0
  81. package/src/hooks/useElementSearch.ts +45 -0
  82. package/src/index.css +11 -0
  83. package/src/index.ts +7 -0
  84. package/src/pages/AppearanceSettings.tsx +24 -1
  85. package/src/pages/Dependencies.tsx +231 -65
  86. package/src/pages/InfiniteZoom.tsx +76 -27
  87. package/src/pages/Settings.tsx +1 -1
  88. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
  89. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
  90. package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
  91. package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
  92. package/src/pages/ViewEditor/index.tsx +549 -59
  93. package/src/pages/Views.tsx +112 -41
  94. package/src/pages/ViewsGrid.tsx +332 -113
  95. package/src/pages/viewsJumpSearch.test.ts +193 -0
  96. package/src/pages/viewsJumpSearch.ts +111 -0
  97. package/src/store/useStore.ts +58 -0
  98. package/src/types/index.ts +10 -0
  99. package/src/utils/elementIcon.test.ts +28 -0
  100. package/src/utils/elementIcon.ts +20 -0
  101. package/src/utils/sourceEditor.ts +46 -0
  102. package/src/utils/watchDiffSummary.ts +159 -0
@@ -1,6 +1,6 @@
1
1
  // src/pages/InfiniteZoom.tsx Explore page holds the ZUI feature
2
2
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
3
- import { useNavigate, useParams } from 'react-router-dom'
3
+ import { useLocation, useNavigate, useParams } from 'react-router-dom'
4
4
  import {
5
5
  Box,
6
6
  Button,
@@ -20,13 +20,16 @@ import {
20
20
  } from '@chakra-ui/react'
21
21
  import { api } from '../api/client'
22
22
  import type { ExploreData, ViewLayer } from '../types'
23
- import { FitViewIcon as FitViewSvg, TagsIcon, EyeIcon, EyeOffIcon, FocusIcon as FocusSvg } from '../components/Icons'
23
+ import { FitViewIcon as FitViewSvg, TagsIcon, EyeIcon, EyeOffIcon } from '../components/Icons'
24
24
  import ExploreOnboarding from '../components/ExploreOnboarding'
25
25
  import ExplorePageOnboarding from '../components/ExplorePageOnboarding'
26
26
  import MiniZoomOnboarding from '../components/MiniZoomOnboarding'
27
27
  import { ZUICanvas, type ZUICameraFrame, type ZUICanvasHandle } from '../components/ZUI'
28
28
  import { useCrossBranchContextSettings } from '../crossBranch/settings'
29
+ import CrossBranchControls from '../components/CrossBranchControls'
29
30
  import { primeWorkspaceGraphSnapshot } from '../crossBranch/store'
31
+ import { WATCH_REPRESENTATION_UPDATED_EVENT } from '../components/WorkspacePanel'
32
+ import { useWorkspaceVersionPreview } from '../context/WorkspaceVersionContext'
30
33
 
31
34
  // ── Types ──────────────────────────────────────────────────────────
32
35
  interface Props {
@@ -36,6 +39,7 @@ interface Props {
36
39
 
37
40
  export interface InfiniteZoomHandle {
38
41
  focusDiagram(viewId: number): boolean
42
+ focusElement(viewId: number, elementId: number): boolean
39
43
  setCameraFrame(frame: ZUICameraFrame): boolean
40
44
  }
41
45
 
@@ -44,11 +48,13 @@ const MINI_ONBOARDING_KEY = 'shared_zoom_onboarding_dismissed'
44
48
  // ── Inner component ────────────────────────────────────────────────
45
49
  function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<InfiniteZoomHandle>) {
46
50
  const navigate = useNavigate()
51
+ const location = useLocation()
47
52
 
48
53
  const [data, setData] = useState<ExploreData | null>(null)
49
54
  const [loading, setLoading] = useState(true)
50
55
  const [canvasReady, setCanvasReady] = useState(false)
51
56
  const [showMiniOnboarding, setShowMiniOnboarding] = useState(false)
57
+ const [miniOnboardingInteractionSeen, setMiniOnboardingInteractionSeen] = useState(false)
52
58
  const [tagColors] = useState<Record<string, import('../types').Tag>>({})
53
59
  const [layers, setLayers] = useState<ViewLayer[]>([])
54
60
  const [highlightedTags, setHighlightedTags] = useState<string[]>([])
@@ -57,12 +63,30 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
57
63
  const { isOpen: isTagsOpen, onClose: onTagsClose, onToggle: onTagsToggle } = useDisclosure()
58
64
  const zuiRef = useRef<ZUICanvasHandle>(null)
59
65
  const crossBranchSurface = sharedToken ? 'zui-shared' : 'zui'
60
- const { settings: crossBranchSettings, setEnabled: setCrossBranchEnabled } = useCrossBranchContextSettings(crossBranchSurface)
66
+ const {
67
+ settings: crossBranchSettings,
68
+ setEnabled: setCrossBranchEnabled,
69
+ setConnectorBudget: setCrossBranchConnectorBudget,
70
+ setConnectorPriority: setCrossBranchConnectorPriority,
71
+ } = useCrossBranchContextSettings(crossBranchSurface)
72
+ const { preview: versionPreview, followTarget: versionFollowTarget } = useWorkspaceVersionPreview()
73
+
74
+ const cameraProfile = useMemo(() => new URLSearchParams(location.search).get('profile'), [location.search])
75
+ const isDetailToOverviewProfile = sharedToken && cameraProfile === 'detail-to-overview'
76
+
77
+ const initialCameraFrame = useMemo<ZUICameraFrame | undefined>(() => {
78
+ return isDetailToOverviewProfile
79
+ ? { profile: 'detail-to-overview', progress: 0 }
80
+ : undefined
81
+ }, [isDetailToOverviewProfile])
61
82
 
62
83
  useImperativeHandle(ref, () => ({
63
84
  focusDiagram(viewId: number) {
64
85
  return zuiRef.current?.focusDiagram(viewId) ?? false
65
86
  },
87
+ focusElement(viewId: number, elementId: number) {
88
+ return zuiRef.current?.focusElement(viewId, elementId) ?? false
89
+ },
66
90
  setCameraFrame(frame: ZUICameraFrame) {
67
91
  return zuiRef.current?.setCameraFrame(frame) ?? false
68
92
  },
@@ -124,20 +148,35 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
124
148
  }, [])
125
149
 
126
150
  useEffect(() => {
151
+ if (isDetailToOverviewProfile) return
127
152
  if (sharedToken && canvasReady && !localStorage.getItem(MINI_ONBOARDING_KEY)) {
128
153
  setShowMiniOnboarding(true)
129
154
  }
130
- }, [sharedToken, canvasReady])
155
+ }, [sharedToken, canvasReady, isDetailToOverviewProfile])
131
156
 
132
- const handleInteraction = useCallback(() => {
157
+ const dismissMiniOnboarding = useCallback(() => {
133
158
  if (showMiniOnboarding) {
134
159
  setShowMiniOnboarding(false)
135
- localStorage.setItem(MINI_ONBOARDING_KEY, 'true')
160
+ if (!isDetailToOverviewProfile) {
161
+ localStorage.setItem(MINI_ONBOARDING_KEY, 'true')
162
+ }
136
163
  }
137
- }, [showMiniOnboarding])
138
- useEffect(() => {
164
+ }, [isDetailToOverviewProfile, showMiniOnboarding])
165
+
166
+ const showMiniOnboardingAfterCanvasInteraction = useCallback(() => {
167
+ if (!isDetailToOverviewProfile || miniOnboardingInteractionSeen) return
168
+ setMiniOnboardingInteractionSeen(true)
169
+ setShowMiniOnboarding(true)
170
+ }, [isDetailToOverviewProfile, miniOnboardingInteractionSeen])
171
+
172
+ const handleCanvasZoom = useCallback(() => {
173
+ setMiniOnboardingInteractionSeen(true)
174
+ dismissMiniOnboarding()
175
+ }, [dismissMiniOnboarding])
176
+
177
+ const loadExploreData = useCallback(() => {
139
178
  const loader = sharedToken ? api.explore.loadShared(sharedToken) : api.explore.load()
140
- loader.then((d) => {
179
+ return loader.then((d) => {
141
180
  if (d.password_required) {
142
181
  setLoading(false)
143
182
  } else {
@@ -148,6 +187,20 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
148
187
  }).catch(() => setLoading(false))
149
188
  }, [sharedToken])
150
189
 
190
+ useEffect(() => {
191
+ void loadExploreData()
192
+ }, [loadExploreData])
193
+
194
+ useEffect(() => {
195
+ if (sharedToken) return
196
+ const refresh = () => {
197
+ setLoading(true)
198
+ void loadExploreData()
199
+ }
200
+ window.addEventListener(WATCH_REPRESENTATION_UPDATED_EVENT, refresh)
201
+ return () => window.removeEventListener(WATCH_REPRESENTATION_UPDATED_EVENT, refresh)
202
+ }, [loadExploreData, sharedToken])
203
+
151
204
  // Fetch tag colors and layers once data is loaded (authenticated users only).
152
205
  // Only fetch from root tree nodes child/nested diagrams would duplicate the same layers.
153
206
  useEffect(() => {
@@ -243,18 +296,21 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
243
296
  ref={zuiRef}
244
297
  data={data}
245
298
  onReady={handleCanvasReady}
246
- onZoom={handleInteraction}
247
- onPan={handleInteraction}
299
+ onZoom={handleCanvasZoom}
300
+ onPan={showMiniOnboardingAfterCanvasInteraction}
301
+ initialCameraFrame={initialCameraFrame}
248
302
  highlightedTags={highlightedTags}
249
303
  highlightColor={highlightColor}
250
304
  hiddenTags={hiddenTags}
305
+ versionPreview={versionPreview}
306
+ versionFollowTarget={versionFollowTarget}
251
307
  crossBranchSettings={crossBranchSettings}
252
308
  hoverLocked={isTagsOpen}
253
309
  />
254
310
 
255
311
  {/* Onboarding overlay */}
256
312
  {data && !sharedToken && <ExploreOnboarding hasLinkedNodes={!!(data.navigations?.length > 0)} />}
257
- <MiniZoomOnboarding isVisible={showMiniOnboarding} />
313
+ <MiniZoomOnboarding isVisible={showMiniOnboarding} onClose={dismissMiniOnboarding} />
258
314
 
259
315
  {/* Bottom toolbar */}
260
316
  <Box
@@ -288,21 +344,13 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
288
344
  {shareSlot}
289
345
 
290
346
  <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
291
- <Tooltip label={!crossBranchSettings.enabled ? 'Show branches' : 'Focus on this view'} placement="top" openDelay={200}>
292
- <Button
293
- variant="ghost" h="28px" px={2.5}
294
- color={!crossBranchSettings.enabled ? 'var(--accent)' : 'gray.300'}
295
- bg={!crossBranchSettings.enabled ? 'rgba(var(--accent-rgb), 0.12)' : 'transparent'}
296
- _hover={{ bg: 'rgba(var(--accent-rgb), 0.12)', color: 'var(--accent)' }}
297
- onClick={() => setCrossBranchEnabled(!crossBranchSettings.enabled)}
298
- >
299
- <HStack spacing={1.5}>
300
- <FocusSvg />
301
- <Text fontSize="11px" fontWeight="normal">Focus View</Text>
302
- <Box w="6px" h="6px" rounded="full" bg={!crossBranchSettings.enabled ? 'var(--accent)' : 'gray.500'} />
303
- </HStack>
304
- </Button>
305
- </Tooltip>
347
+ <CrossBranchControls
348
+ settings={crossBranchSettings}
349
+ onEnabledChange={setCrossBranchEnabled}
350
+ onBudgetChange={setCrossBranchConnectorBudget}
351
+ onPriorityChange={setCrossBranchConnectorPriority}
352
+ label="Branches"
353
+ />
306
354
 
307
355
  {(allTags.length > 0 || layers.length > 0) && (
308
356
  <>
@@ -329,6 +377,7 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
329
377
  </PopoverTrigger>
330
378
  <Portal>
331
379
  <PopoverContent
380
+ data-zui-native-wheel="true"
332
381
  bg="glass.bg"
333
382
  backdropFilter="blur(16px)"
334
383
  borderColor="glass.border"
@@ -28,7 +28,7 @@ export default function Settings({ extraNavItems = [] }: SettingsProps) {
28
28
 
29
29
 
30
30
  return (
31
- <Flex direction="column" h="100vh">
31
+ <Flex direction="column" h="100%">
32
32
  <Flex flex={1} overflow="hidden" direction={{ base: 'column', md: 'row' }}>
33
33
  {/* Sidebar (hidden on small screens) */}
34
34
  <VStack
@@ -90,6 +90,18 @@ function findNearestHandleTargetInCache(targets: HandleTarget[], clientX: number
90
90
  }
91
91
  }
92
92
 
93
+ function flattenViewTree(nodes: ViewTreeNode[]): ViewTreeNode[] {
94
+ const out: ViewTreeNode[] = []
95
+ const walk = (items: ViewTreeNode[]) => {
96
+ items.forEach((item) => {
97
+ out.push(item)
98
+ walk(item.children ?? [])
99
+ })
100
+ }
101
+ walk(nodes)
102
+ return out
103
+ }
104
+
93
105
  export function applyNodeChangesWithStructuralSharing(changes: NodeChange[], nodes: RFNode[]) {
94
106
  if (changes.length === 0) return nodes
95
107
 
@@ -181,6 +193,11 @@ interface CanvasInteractionOptions {
181
193
  handleElementDeleted: (id: number) => void
182
194
  handleElementPermanentlyDeleted: (id: number) => void
183
195
  handleConnectorDeleted: (id: number) => void
196
+ onPlacementMoved?: (before: PlacedElement, after: PlacedElement) => void
197
+ onPlacementRemoved?: (placement: PlacedElement) => void
198
+ onConnectorUpdated?: (before: Connector, after: Connector) => void
199
+ onConnectorDeleted?: (connector: Connector) => void
200
+ onUnsupportedMutation?: () => void
184
201
  handleUpdateTags: (elementId: number, tags: string[]) => Promise<void>
185
202
  drawingCanvasRef: React.MutableRefObject<DrawingCanvasHandle | null>
186
203
  snapToGrid?: boolean
@@ -259,6 +276,11 @@ export function useCanvasInteractions({
259
276
  handleElementDeleted,
260
277
  handleElementPermanentlyDeleted,
261
278
  handleConnectorDeleted,
279
+ onPlacementMoved,
280
+ onPlacementRemoved,
281
+ onConnectorUpdated,
282
+ onConnectorDeleted,
283
+ onUnsupportedMutation,
262
284
  handleUpdateTags,
263
285
  drawingCanvasRef,
264
286
  snapToGrid,
@@ -327,6 +349,13 @@ export function useCanvasInteractions({
327
349
  handleReconnectListenersRef.current = null
328
350
  }, [])
329
351
 
352
+ const clearConnectGhostListener = useCallback(() => {
353
+ const listener = connectGhostListenerRef.current
354
+ if (!listener) return
355
+ document.removeEventListener('mousemove', listener)
356
+ connectGhostListenerRef.current = null
357
+ }, [])
358
+
330
359
  const stopHandleReconnectDrag = useCallback(() => {
331
360
  clearHandleReconnectListeners()
332
361
  handleReconnectDragRef.current = null
@@ -396,6 +425,7 @@ export function useCanvasInteractions({
396
425
  try {
397
426
  const obj = await api.elements.create({ name, kind: '' })
398
427
  await api.workspace.views.placements.add(viewId, obj.id, flowX - 100, flowY - 40)
428
+ onUnsupportedMutation?.()
399
429
  await refreshElements()
400
430
  const placed = viewElementsRef.current.find((element) => element.element_id === obj.id)
401
431
  if (placed) upsertPlacementGraphSnapshot(viewId, placed)
@@ -410,9 +440,10 @@ export function useCanvasInteractions({
410
440
  })
411
441
  const connector = connectorToConnector(newConnector)
412
442
  await finalizeConnectorCreate(connector)
443
+ onUnsupportedMutation?.()
413
444
  }
414
445
  } catch { /* intentionally empty */ }
415
- }, [addingElementAt, canEdit, finalizeConnectorCreate, refreshElements, rfNodesRef, viewId, viewElementsRef])
446
+ }, [addingElementAt, canEdit, finalizeConnectorCreate, onUnsupportedMutation, refreshElements, rfNodesRef, viewId, viewElementsRef])
416
447
 
417
448
  const handleConfirmExistingElement = useCallback(async (obj: LibraryElement) => {
418
449
  if (!canEdit || viewId === null || !addingElementAt || addingElementAt.mode !== 'add') return
@@ -425,6 +456,7 @@ export function useCanvasInteractions({
425
456
  try {
426
457
  if (!existingElementIds.has(obj.id)) {
427
458
  await api.workspace.views.placements.add(viewId, obj.id, flowX - 100, flowY - 40)
459
+ onUnsupportedMutation?.()
428
460
  await refreshElements()
429
461
  const placed = viewElementsRef.current.find((element) => element.element_id === obj.id)
430
462
  if (placed) upsertPlacementGraphSnapshot(viewId, placed)
@@ -443,9 +475,10 @@ export function useCanvasInteractions({
443
475
  })
444
476
  const connector = connectorToConnector(newConnector)
445
477
  await finalizeConnectorCreate(connector)
478
+ onUnsupportedMutation?.()
446
479
  }
447
480
  } catch { /* intentionally empty */ }
448
- }, [addingElementAt, canEdit, existingElementIds, finalizeConnectorCreate, refreshElements, rfNodesRef, viewId, viewElementsRef])
481
+ }, [addingElementAt, canEdit, existingElementIds, finalizeConnectorCreate, onUnsupportedMutation, refreshElements, rfNodesRef, viewId, viewElementsRef])
449
482
 
450
483
  const handleConfirmConnectExistingElement = useCallback(async (obj: LibraryElement) => {
451
484
  if (!canEdit || viewId === null || !addingElementAt || addingElementAt.mode !== 'connect') return
@@ -470,13 +503,21 @@ export function useCanvasInteractions({
470
503
  })
471
504
  const connector = connectorToConnector(newConnector)
472
505
  await finalizeConnectorCreate(connector)
506
+ onUnsupportedMutation?.()
473
507
  } catch { /* intentionally empty */ }
474
- }, [addingElementAt, canEdit, finalizeConnectorCreate, rfNodesRef, viewId])
508
+ }, [addingElementAt, canEdit, finalizeConnectorCreate, onUnsupportedMutation, rfNodesRef, viewId])
475
509
 
476
510
  // ── Zoom-in / zoom-out stable callbacks ───────────────────────────────────
477
511
  const stableOnZoomIn = useCallback(async (elementId: number) => {
478
512
  const childLinks = linksMapRef.current[elementId] || []
479
- if (childLinks.length > 0) { navigateRef.current(`/views/${childLinks[0].to_view_id}`); return }
513
+ if (childLinks.length > 0) {
514
+ setSelectedElement(null)
515
+ setSelectedEdge(null)
516
+ closeElementPanel()
517
+ closeConnectorPanel()
518
+ navigateRef.current(`/views/${childLinks[0].to_view_id}`)
519
+ return
520
+ }
480
521
 
481
522
  const obj = viewElementsRef.current.find((o) => o.element_id === elementId)
482
523
  if (obj?.has_view) {
@@ -491,6 +532,10 @@ export function useCanvasInteractions({
491
532
  }
492
533
  const existingView = findInTree(treeDataRef.current)
493
534
  if (existingView) {
535
+ setSelectedElement(null)
536
+ setSelectedEdge(null)
537
+ closeElementPanel()
538
+ closeConnectorPanel()
494
539
  navigateRef.current(`/views/${existingView.id}`)
495
540
  return
496
541
  }
@@ -506,9 +551,13 @@ export function useCanvasInteractions({
506
551
  [elementId]: [...(prev[elementId] || []),
507
552
  { id: 0, element_id: elementId, from_view_id: cid, to_view_id: newView.id, to_view_name: newView.name, relation_type: 'child' as const }],
508
553
  }))
554
+ setSelectedElement(null)
555
+ setSelectedEdge(null)
556
+ closeElementPanel()
557
+ closeConnectorPanel()
509
558
  navigateRef.current(`/views/${newView.id}`)
510
559
  } catch { /* intentionally empty */ }
511
- }, [canEdit, linksMapRef, viewIdRef, viewElementsRef, navigateRef, setLinksMap, treeDataRef])
560
+ }, [canEdit, linksMapRef, viewIdRef, viewElementsRef, navigateRef, setLinksMap, treeDataRef, setSelectedElement, setSelectedEdge, closeElementPanel, closeConnectorPanel])
512
561
 
513
562
  const stableOnZoomOut = useCallback(async (elementId: number) => {
514
563
  const parentLinks = parentLinksMapRef.current[elementId] || []
@@ -517,7 +566,14 @@ export function useCanvasInteractions({
517
566
  // from the clicked element's ID for elements like functions/classes that
518
567
  // don't own a view themselves).
519
568
  const anyParentLink = parentLinks[0] ?? Object.values(parentLinksMapRef.current).flat()[0]
520
- if (anyParentLink) { navigateRef.current(`/views/${anyParentLink.from_view_id}`); return }
569
+ if (anyParentLink) {
570
+ setSelectedElement(null)
571
+ setSelectedEdge(null)
572
+ closeElementPanel()
573
+ closeConnectorPanel()
574
+ navigateRef.current(`/views/${anyParentLink.from_view_id}`)
575
+ return
576
+ }
521
577
 
522
578
  // Final fallback: use current view's parent_view_id if available
523
579
  const findInTreeById = (nodes: ViewTreeNode[], id: number): ViewTreeNode | null => {
@@ -530,13 +586,21 @@ export function useCanvasInteractions({
530
586
  }
531
587
  const currentView = findInTreeById(treeDataRef.current, viewIdRef.current || -1)
532
588
  if (currentView?.parent_view_id) {
589
+ setSelectedElement(null)
590
+ setSelectedEdge(null)
591
+ closeElementPanel()
592
+ closeConnectorPanel()
533
593
  navigateRef.current(`/views/${currentView.parent_view_id}`)
534
594
  }
535
- }, [parentLinksMapRef, navigateRef, treeDataRef, viewIdRef])
595
+ }, [parentLinksMapRef, navigateRef, treeDataRef, viewIdRef, setSelectedElement, setSelectedEdge, closeElementPanel, closeConnectorPanel])
536
596
 
537
597
  const stableOnNavigateToView = useCallback((id: number) => {
598
+ setSelectedElement(null)
599
+ setSelectedEdge(null)
600
+ closeElementPanel()
601
+ closeConnectorPanel()
538
602
  navigateRef.current(`/views/${id}`)
539
- }, [navigateRef])
603
+ }, [navigateRef, setSelectedElement, setSelectedEdge, closeElementPanel, closeConnectorPanel])
540
604
 
541
605
  const stableOnHoverZoom = useCallback((elementId: number, type: 'in' | 'out' | null) => {
542
606
  const prev = hoveredZoomRef.current
@@ -555,15 +619,17 @@ export function useCanvasInteractions({
555
619
 
556
620
  const stableOnRemoveElement = useCallback(async (elementId: number) => {
557
621
  if (!canEdit || viewId === null) return
622
+ const removedPlacement = viewElementsRef.current.find((element) => element.element_id === elementId) ?? null
558
623
  try {
559
624
  await api.workspace.views.placements.remove(viewId, elementId)
560
625
  removePlacementGraphSnapshot(viewId, elementId)
561
626
  removeElementPlacement(elementId)
627
+ if (removedPlacement) onPlacementRemoved?.(removedPlacement)
562
628
  handleElementDeleted(elementId)
563
629
  setInteractionSourceId(null)
564
630
  pendingConnectionSourceHandleRef.current = null
565
631
  } catch { /* intentionally empty */ }
566
- }, [canEdit, viewId, removeElementPlacement, handleElementDeleted])
632
+ }, [canEdit, viewId, viewElementsRef, removeElementPlacement, onPlacementRemoved, handleElementDeleted])
567
633
 
568
634
  const connectClickModeToHandle = useCallback(async (targetElementId: number, targetHandle: string) => {
569
635
  if (!canEdit) return
@@ -594,8 +660,9 @@ export function useCanvasInteractions({
594
660
  })
595
661
  const connector = connectorToConnector(newConnector)
596
662
  await finalizeConnectorCreate(connector)
663
+ onUnsupportedMutation?.()
597
664
  } catch { /* intentionally empty */ }
598
- }, [canEdit, finalizeConnectorCreate, interactionSourceIdRef, viewIdRef])
665
+ }, [canEdit, finalizeConnectorCreate, interactionSourceIdRef, onUnsupportedMutation, viewIdRef])
599
666
 
600
667
  const stableOnInteractionStart = useCallback((elementId: number, options?: InteractionStartOptions) => {
601
668
  if (!canEdit) return
@@ -684,15 +751,20 @@ export function useCanvasInteractions({
684
751
  return
685
752
  }
686
753
 
754
+ const beforePlacement = currentObj ? { ...currentObj, position_x: startPos?.x ?? currentObj.position_x, position_y: startPos?.y ?? currentObj.position_y } : null
755
+ const afterPlacement = currentObj ? { ...currentObj, position_x: node.position.x, position_y: node.position.y } : null
687
756
  updateElementPosition(elementId, node.position.x, node.position.y)
688
757
  clearTimeout(positionTimers.current[node.id])
689
758
  positionTimers.current[node.id] = setTimeout(() => {
690
759
  api.workspace.views.placements
691
760
  .updatePosition(viewId, elementId, node.position.x, node.position.y)
761
+ .then(() => {
762
+ if (beforePlacement && afterPlacement) onPlacementMoved?.(beforePlacement, afterPlacement)
763
+ })
692
764
  .catch(() => { /* intentionally empty */ })
693
765
  }, 400)
694
766
  delete dragStartPositionsRef.current[node.id]
695
- }, [canEdit, updateElementPosition, viewId, viewElementsRef])
767
+ }, [canEdit, updateElementPosition, viewId, viewElementsRef, onPlacementMoved])
696
768
 
697
769
  // ── Connections ────────────────────────────────────────────────────────────
698
770
  const onConnect: OnConnect = useCallback(async (params: Connection) => {
@@ -712,11 +784,13 @@ export function useCanvasInteractions({
712
784
  })
713
785
  const connector = connectorToConnector(newConnector)
714
786
  await finalizeConnectorCreate(connector)
787
+ onUnsupportedMutation?.()
715
788
  } catch { /* intentionally empty */ }
716
- }, [canEdit, finalizeConnectorCreate, viewId])
789
+ }, [canEdit, finalizeConnectorCreate, onUnsupportedMutation, viewId])
717
790
 
718
791
  const onConnectStart = useCallback((_: React.MouseEvent | React.TouchEvent, { nodeId }: OnConnectStartParams) => {
719
792
  if (!canEdit || isReconnectingRef.current) return
793
+ clearConnectGhostListener()
720
794
  connectingSourceRef.current = nodeId
721
795
  connectWasValidRef.current = false
722
796
  const handleTargets = collectHandleTargets(nodeId ?? undefined)
@@ -730,13 +804,10 @@ export function useCanvasInteractions({
730
804
  }
731
805
  connectGhostListenerRef.current = listener
732
806
  document.addEventListener('mousemove', listener)
733
- }, [canEdit])
807
+ }, [canEdit, clearConnectGhostListener])
734
808
 
735
809
  const onConnectEnd = useCallback((event: MouseEvent | TouchEvent) => {
736
- if (connectGhostListenerRef.current) {
737
- document.removeEventListener('mousemove', connectGhostListenerRef.current)
738
- connectGhostListenerRef.current = null
739
- }
810
+ clearConnectGhostListener()
740
811
  setConnectGhostPos(null)
741
812
  if (!canEdit || isReconnectingRef.current) return
742
813
  const sourceId = connectingSourceRef.current
@@ -774,13 +845,14 @@ export function useCanvasInteractions({
774
845
  }).then((connector) => {
775
846
  const next = connectorToConnector(connector)
776
847
  void finalizeConnectorCreate(next)
848
+ onUnsupportedMutation?.()
777
849
  }).catch(() => { /* intentionally empty */ })
778
850
  } else {
779
851
  setPendingConnectionSource(sourceElementId)
780
852
  suppressNextPaneClickRef.current = true
781
853
  showAddingElementAt(clientX, clientY, true, 'connect', 'shiftKey' in event && event.shiftKey)
782
854
  }
783
- }, [canEdit, finalizeConnectorCreate, showAddingElementAt, rfNodesRef, viewIdRef])
855
+ }, [canEdit, clearConnectGhostListener, finalizeConnectorCreate, onUnsupportedMutation, showAddingElementAt, rfNodesRef, viewIdRef])
784
856
 
785
857
  // ── Reconnect ──────────────────────────────────────────────────────────────
786
858
  const performReconnect = useCallback(async (oldConnector: RFEdge, newConnection: Connection) => {
@@ -806,8 +878,9 @@ export function useCanvasInteractions({
806
878
  const connector = connectorToConnector(updated)
807
879
  upsertConnectorGraphSnapshot(connector)
808
880
  replaceConnector(connector)
881
+ if (existingData) onConnectorUpdated?.(existingData, connector)
809
882
  } catch { /* intentionally empty */ }
810
- }, [canEdit, replaceConnector, viewId, setRfEdges])
883
+ }, [canEdit, replaceConnector, viewId, setRfEdges, onConnectorUpdated])
811
884
  const onReconnect = useCallback(async (oldConnector: RFEdge, newConnection: Connection) => {
812
885
  await performReconnect(oldConnector, newConnection)
813
886
  }, [performReconnect])
@@ -978,8 +1051,9 @@ export function useCanvasInteractions({
978
1051
  }, [interactionSourceId])
979
1052
 
980
1053
  useEffect(() => () => {
1054
+ clearConnectGhostListener()
981
1055
  stopHandleReconnectDrag()
982
- }, [stopHandleReconnectDrag])
1056
+ }, [clearConnectGhostListener, stopHandleReconnectDrag])
983
1057
 
984
1058
  // ── Connector interactions ─────────────────────────────────────────────────────
985
1059
  const onEdgeContextMenu = useCallback((e: React.MouseEvent, rfConnector: RFEdge) => {
@@ -1012,7 +1086,7 @@ export function useCanvasInteractions({
1012
1086
  setSelectedProxyConnectorDetails(null)
1013
1087
  setSelectedEdge(connector)
1014
1088
  openConnectorPanelRef.current()
1015
- }, [closeElementPanel, connectors, openProxyConnectorPanel, setSelectedEdge, setSelectedElement, setSelectedProxyConnectorDetails])
1089
+ }, [closeConnectorPanel, closeElementPanel, connectors, openProxyConnectorPanel, setSelectedEdge, setSelectedElement, setSelectedProxyConnectorDetails])
1016
1090
 
1017
1091
  // ── Pane interactions ─────────────────────────────────────────────────────
1018
1092
  const onPaneClick = useCallback((e: React.MouseEvent) => {
@@ -1221,7 +1295,11 @@ export function useCanvasInteractions({
1221
1295
  } else {
1222
1296
  const connectorId = getConnectorDeletionTarget(selectedConnector)
1223
1297
  if (connectorId === null) return
1298
+ const deletedConnector = selectedConnector?.id === connectorId
1299
+ ? selectedConnector
1300
+ : connectors.find((connector) => connector.id === connectorId) ?? null
1224
1301
  api.workspace.connectors.delete('', connectorId).then(() => {
1302
+ if (deletedConnector) onConnectorDeleted?.(deletedConnector)
1225
1303
  handleConnectorDeleted(connectorId)
1226
1304
  setSelectedEdge(null)
1227
1305
  closeConnectorPanel()
@@ -1265,7 +1343,7 @@ export function useCanvasInteractions({
1265
1343
  const cid = viewIdRef.current
1266
1344
  if (!cid) return
1267
1345
  const incoming = incomingLinksRef.current
1268
- const tree = treeDataRef.current
1346
+ const tree = flattenViewTree(treeDataRef.current)
1269
1347
  const nav = navigateRef.current
1270
1348
  const links = linksMapRef.current
1271
1349
  const treeNode = tree.find((n) => n.id === cid)
@@ -1332,7 +1410,7 @@ export function useCanvasInteractions({
1332
1410
  }
1333
1411
  window.addEventListener('keydown', handler)
1334
1412
  return () => window.removeEventListener('keydown', handler)
1335
- }, [canEdit, refreshGrid, selectedElement, selectedConnector, viewId, stableOnRemoveElement, handleConnectorDeleted, handleElementPermanentlyDeleted, closeElementPanel, closeConnectorPanel, viewIdRef, incomingLinksRef, treeDataRef, navigateRef, rfNodesRef, viewElementsRef, setLinksMap, showAddingElementAt, setSelectedElement, setSelectedEdge, containerRef, linksMapRef])
1413
+ }, [canEdit, refreshGrid, selectedElement, selectedConnector, connectors, viewId, stableOnRemoveElement, handleConnectorDeleted, handleElementPermanentlyDeleted, onConnectorDeleted, closeElementPanel, closeConnectorPanel, viewIdRef, incomingLinksRef, treeDataRef, navigateRef, rfNodesRef, viewElementsRef, setLinksMap, showAddingElementAt, setSelectedElement, setSelectedEdge, containerRef, linksMapRef])
1336
1414
 
1337
1415
  // ── DnD handlers ──────────────────────────────────────────────────────────
1338
1416
  const onDragOver = useCallback((e: React.DragEvent) => {
@@ -1351,6 +1429,7 @@ export function useCanvasInteractions({
1351
1429
  const pos = screenToFlowPositionRef.current({ x: e.clientX, y: e.clientY })
1352
1430
  try {
1353
1431
  await api.workspace.views.placements.add(viewId, obj.id, pos.x - 100, pos.y - 40)
1432
+ onUnsupportedMutation?.()
1354
1433
  await refreshElements()
1355
1434
  const placed = viewElementsRef.current.find((element) => element.element_id === obj.id)
1356
1435
  if (placed) upsertPlacementGraphSnapshot(viewId, placed)
@@ -1402,7 +1481,7 @@ export function useCanvasInteractions({
1402
1481
  }
1403
1482
  }
1404
1483
  }
1405
- }, [canEdit, viewId, existingElementIds, refreshElements, rfNodesRef, viewElementsRef, layers, handleUpdateTags])
1484
+ }, [canEdit, viewId, existingElementIds, onUnsupportedMutation, refreshElements, rfNodesRef, viewElementsRef, layers, handleUpdateTags])
1406
1485
 
1407
1486
  const onWheelCapture = useCallback((e: React.WheelEvent) => {
1408
1487
  if (touchStateRef.current.touches.size === 2) return