@tldiagram/core-ui 1.95.1 → 2.0.1

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 (100) 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/NodeContainer.d.ts +2 -0
  9. package/dist/components/ProxyConnectorPanel.d.ts +4 -1
  10. package/dist/components/ViewExplorer/index.d.ts +1 -1
  11. package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
  12. package/dist/components/ViewFloatingMenu.d.ts +8 -1
  13. package/dist/components/ViewGridNode.d.ts +3 -0
  14. package/dist/components/ViewPanel.d.ts +2 -1
  15. package/dist/components/WorkspacePanel.d.ts +2 -0
  16. package/dist/components/ZUI/ZUICanvas.d.ts +4 -0
  17. package/dist/components/ZUI/focus.d.ts +32 -0
  18. package/dist/components/ZUI/focus.test.d.ts +1 -0
  19. package/dist/components/ZUI/layout.d.ts +2 -2
  20. package/dist/components/ZUI/proxy.d.ts +20 -4
  21. package/dist/components/ZUI/renderer.d.ts +35 -1
  22. package/dist/components/ZUI/types.d.ts +6 -0
  23. package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
  24. package/dist/context/WorkspaceVersionContext.d.ts +49 -0
  25. package/dist/crossBranch/resolve.d.ts +39 -2
  26. package/dist/crossBranch/resolve.test.d.ts +1 -0
  27. package/dist/crossBranch/settings.d.ts +6 -1
  28. package/dist/crossBranch/types.d.ts +8 -0
  29. package/dist/hooks/useElementSearch.d.ts +8 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +16529 -14030
  32. package/dist/pages/InfiniteZoom.d.ts +1 -0
  33. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
  34. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
  35. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
  36. package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
  37. package/dist/pages/viewsJumpSearch.d.ts +22 -0
  38. package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
  39. package/dist/store/useStore.d.ts +3 -0
  40. package/dist/types/index.d.ts +9 -0
  41. package/dist/utils/elementIcon.d.ts +2 -0
  42. package/dist/utils/elementIcon.test.d.ts +1 -0
  43. package/dist/utils/sourceEditor.d.ts +7 -0
  44. package/dist/utils/watchDiffSummary.d.ts +34 -0
  45. package/package.json +2 -2
  46. package/src/App.tsx +12 -8
  47. package/src/api/client.ts +488 -26
  48. package/src/components/CodePreviewPanel.tsx +90 -16
  49. package/src/components/ConnectorPanel.tsx +34 -3
  50. package/src/components/ContextNeighborElement.tsx +2 -5
  51. package/src/components/CrossBranchControls.tsx +46 -17
  52. package/src/components/ElementNode.tsx +98 -47
  53. package/src/components/ElementPanel.tsx +62 -25
  54. package/src/components/InlineElementAdder.tsx +8 -3
  55. package/src/components/LayoutSection.tsx +4 -1
  56. package/src/components/MergeDialog.tsx +269 -0
  57. package/src/components/NodeContainer.tsx +55 -17
  58. package/src/components/ProxyConnectorPanel.tsx +58 -16
  59. package/src/components/ViewBezierConnector.tsx +116 -21
  60. package/src/components/ViewExplorer/index.tsx +1 -1
  61. package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
  62. package/src/components/ViewFloatingMenu.tsx +110 -1
  63. package/src/components/ViewGridNode.tsx +59 -8
  64. package/src/components/ViewPanel.tsx +3 -2
  65. package/src/components/WorkspacePanel.tsx +938 -0
  66. package/src/components/ZUI/ZUICanvas.tsx +216 -122
  67. package/src/components/ZUI/focus.test.ts +534 -0
  68. package/src/components/ZUI/focus.ts +293 -0
  69. package/src/components/ZUI/layout.ts +7 -11
  70. package/src/components/ZUI/proxy.ts +470 -114
  71. package/src/components/ZUI/renderer.ts +510 -134
  72. package/src/components/ZUI/types.ts +6 -0
  73. package/src/components/ZUI/useZUIInteraction.ts +66 -29
  74. package/src/context/WorkspaceVersionContext.tsx +126 -0
  75. package/src/crossBranch/resolve.test.ts +342 -0
  76. package/src/crossBranch/resolve.ts +368 -68
  77. package/src/crossBranch/settings.ts +49 -3
  78. package/src/crossBranch/types.ts +9 -0
  79. package/src/hooks/useElementSearch.ts +45 -0
  80. package/src/index.css +11 -0
  81. package/src/index.ts +7 -0
  82. package/src/pages/AppearanceSettings.tsx +24 -1
  83. package/src/pages/Dependencies.tsx +231 -65
  84. package/src/pages/InfiniteZoom.tsx +41 -19
  85. package/src/pages/Settings.tsx +1 -1
  86. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
  87. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
  88. package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
  89. package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
  90. package/src/pages/ViewEditor/index.tsx +549 -59
  91. package/src/pages/Views.tsx +112 -41
  92. package/src/pages/ViewsGrid.tsx +332 -113
  93. package/src/pages/viewsJumpSearch.test.ts +193 -0
  94. package/src/pages/viewsJumpSearch.ts +111 -0
  95. package/src/store/useStore.ts +58 -0
  96. package/src/types/index.ts +10 -0
  97. package/src/utils/elementIcon.test.ts +28 -0
  98. package/src/utils/elementIcon.ts +20 -0
  99. package/src/utils/sourceEditor.ts +46 -0
  100. package/src/utils/watchDiffSummary.ts +159 -0
@@ -27,17 +27,29 @@ import { Link as RouterLink } from 'react-router-dom'
27
27
  import { ExternalLinkIcon } from '@chakra-ui/icons'
28
28
  import type { ExploreData } from '../../types'
29
29
  import { computeLayout } from './layout'
30
- import { renderFrame, getExpandThresholds, setOnImageLoadCallback, setHighlightedTags as setRendererHighlightedTags, setHiddenTags as setRendererHiddenTags, setHighlightColor as setRendererHighlightColor } from './renderer'
30
+ import { renderFrame, getExpandThresholds, getCameraRebase, rawCameraView, screenToWorldX, screenToWorldY, worldToScreenX, worldToScreenY, setOnImageLoadCallback, setHighlightedTags as setRendererHighlightedTags, setHiddenTags as setRendererHiddenTags, setHighlightColor as setRendererHighlightColor, setVersionDiff as setRendererVersionDiff } from './renderer'
31
31
  import { useZUIInteraction } from './useZUIInteraction'
32
32
  import type { DiagramGroupLayout, ZUIViewState } from './types'
33
+ import { findDiagramFocusTarget, findElementFocusTarget, viewportForDiagramFocusTarget, viewportForElementFocusTarget } from './focus'
33
34
  import { buildWorkspaceGraphSnapshot } from '../../crossBranch/graph'
34
35
  import type { CrossBranchContextSettings } from '../../crossBranch/types'
35
- import type { ZUIResolvedConnector } from '../../crossBranch/resolve'
36
- import { buildVisibleProxyConnectors, collectVisibleNodeAnchors, drawVisibleProxyConnectors, findHoveredProxyConnector } from './proxy'
36
+ import { DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA } from '../../crossBranch/settings'
37
+ import type { WorkspaceVersionFollowTarget, WorkspaceVersionPreview } from '../../context/WorkspaceVersionContext'
38
+ import {
39
+ buildProxyConnectorSpatialIndex,
40
+ buildVisibleProxyConnectors,
41
+ collectVisibleNodeAnchors,
42
+ drawVisibleDirectProxyBadges,
43
+ drawVisibleProxyConnectors,
44
+ findHoveredProxyConnector,
45
+ type ProxyConnectorSpatialIndex,
46
+ type VisibleNodeAnchor,
47
+ } from './proxy'
37
48
 
38
49
  export interface ZUICanvasHandle {
39
50
  fitView(): void
40
51
  focusDiagram(viewId: number): boolean
52
+ focusElement(viewId: number, elementId: number): boolean
41
53
  setCameraFrame(frame: ZUICameraFrame): boolean
42
54
  }
43
55
 
@@ -55,6 +67,8 @@ interface Props {
55
67
  highlightedTags?: string[]
56
68
  highlightColor?: string
57
69
  hiddenTags?: string[]
70
+ versionPreview?: WorkspaceVersionPreview | null
71
+ versionFollowTarget?: WorkspaceVersionFollowTarget | null
58
72
  crossBranchSettings: CrossBranchContextSettings
59
73
  hoverLocked?: boolean
60
74
  }
@@ -71,6 +85,26 @@ interface PathItem {
71
85
  absH: number
72
86
  }
73
87
 
88
+ function rebaseVisibleNodeAnchors(
89
+ anchors: Map<string, VisibleNodeAnchor>,
90
+ originX: number,
91
+ originY: number,
92
+ ): Map<string, VisibleNodeAnchor> {
93
+ const rebased = new Map<string, VisibleNodeAnchor>()
94
+ for (const [nodeId, anchor] of anchors) {
95
+ rebased.set(nodeId, {
96
+ ...anchor,
97
+ worldX: anchor.worldX - originX,
98
+ worldY: anchor.worldY - originY,
99
+ })
100
+ }
101
+ return rebased
102
+ }
103
+
104
+ function anchorViewForZoom(zoom: number): ZUIViewState {
105
+ return { x: 0, y: 0, zoom: Math.max(0.0001, zoom) }
106
+ }
107
+
74
108
  function getPathAt(
75
109
  view: ZUIViewState,
76
110
  groups: DiagramGroupLayout[],
@@ -80,8 +114,8 @@ function getPathAt(
80
114
  if (canvasW === 0 || canvasH === 0) return []
81
115
 
82
116
  // World center of the screen
83
- const worldCenterX = (canvasW / 2 - view.x) / view.zoom
84
- const worldCenterY = (canvasH / 2 - view.y) / view.zoom
117
+ const worldCenterX = screenToWorldX(canvasW / 2, view)
118
+ const worldCenterY = screenToWorldY(canvasH / 2, view)
85
119
  const thresholds = getExpandThresholds(canvasW)
86
120
 
87
121
  for (const group of groups) {
@@ -177,70 +211,6 @@ function getPathAt(
177
211
  return []
178
212
  }
179
213
 
180
- function findDiagramFocusTarget(groups: DiagramGroupLayout[], viewId: number): PathItem | null {
181
- for (const group of groups) {
182
- if (group.diagramId === viewId) {
183
- return {
184
- id: `g-${group.diagramId}`,
185
- label: group.label,
186
- type: 'group',
187
- absX: group.worldX,
188
- absY: group.worldY,
189
- absW: group.worldW,
190
- absH: group.worldH,
191
- }
192
- }
193
-
194
- const found = findLinkedDiagramInNodes(viewId, group.nodes, 0, 0, 1, 0, 0)
195
- if (found) return found
196
- }
197
- return null
198
- }
199
-
200
- function findLinkedDiagramInNodes(
201
- viewId: number,
202
- nodes: DiagramGroupLayout['nodes'],
203
- parentAbsX: number,
204
- parentAbsY: number,
205
- parentAbsScale: number,
206
- parentChildOffsetX: number,
207
- parentChildOffsetY: number,
208
- ): PathItem | null {
209
- for (const node of nodes) {
210
- const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
211
- const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
212
- const absW = node.worldW * parentAbsScale
213
- const absH = node.worldH * parentAbsScale
214
-
215
- if (node.linkedDiagramId === viewId) {
216
- return {
217
- id: node.id,
218
- label: node.linkedDiagramLabel || node.label,
219
- type: 'node',
220
- isCircular: node.isCircular,
221
- absX,
222
- absY,
223
- absW,
224
- absH,
225
- }
226
- }
227
-
228
- if (node.children.length > 0) {
229
- const found = findLinkedDiagramInNodes(
230
- viewId,
231
- node.children,
232
- absX,
233
- absY,
234
- parentAbsScale * node.childScale,
235
- node.childOffsetX,
236
- node.childOffsetY,
237
- )
238
- if (found) return found
239
- }
240
- }
241
- return null
242
- }
243
-
244
214
  function easeOutQuart(t: number): number {
245
215
  return 1 - Math.pow(1 - t, 4)
246
216
  }
@@ -320,25 +290,27 @@ function findFirstExpandableNodeInTree(
320
290
  return null
321
291
  }
322
292
 
323
- export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({ data, onReady, onZoom, onPan, initialCameraFrame, highlightedTags, highlightColor, hiddenTags, crossBranchSettings, hoverLocked = false }, ref) {
293
+ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({ data, onReady, onZoom, onPan, initialCameraFrame, highlightedTags, highlightColor, hiddenTags, versionPreview, versionFollowTarget, crossBranchSettings, hoverLocked = false }, ref) {
324
294
  const canvasRef = useRef<HTMLCanvasElement>(null)
325
295
  const containerRef = useRef<HTMLDivElement>(null)
326
296
  const cameraTransitionRef = useRef<number | null>(null)
327
297
  const [initialized, setInitialized] = useState(false)
328
298
  const [containerSize, setContainerSize] = useState({ w: 0, h: 0 })
329
299
  const isMobileLayout = useBreakpointValue({ base: true, md: false }) ?? false
300
+ const debugViewport = useMemo(() => typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugZuiCamera'), [])
330
301
 
331
302
  // ── Layout ──────────────────────────────────────────────────────
332
303
  const layout = useMemo(() => computeLayout(data), [data])
333
304
  const workspaceSnapshot = useMemo(() => buildWorkspaceGraphSnapshot(data), [data])
334
- // Holds the most-recently resolved connector topology so hover detection can
335
- // use it without re-running the expensive O(connectors) resolution on every mousemove.
336
- const proxyConnectorsRef = useRef<ZUIResolvedConnector[]>([])
337
-
338
- const resolveHoveredProxyItem = useCallback((worldX: number, worldY: number, view: ZUIViewState, canvasW: number) => {
339
- const freshAnchors = collectVisibleNodeAnchors(layout.groups, view, canvasW, hiddenTags)
340
- return findHoveredProxyConnector(worldX, worldY, proxyConnectorsRef.current, freshAnchors.byNodeId, view)
341
- }, [hiddenTags, layout.groups])
305
+ // Holds the latest proxy hover index so mousemove can query it without
306
+ // rebuilding anchors or connector geometry.
307
+ const proxyHoverIndexRef = useRef<ProxyConnectorSpatialIndex | null>(null)
308
+
309
+ const resolveHoveredProxyItem = useCallback((worldX: number, worldY: number, view: ZUIViewState) => {
310
+ const index = proxyHoverIndexRef.current
311
+ if (!index) return null
312
+ return findHoveredProxyConnector(worldX, worldY, index, view)
313
+ }, [])
342
314
 
343
315
  // ── Interaction ─────────────────────────────────────────────────
344
316
  const { viewState, viewStateRef, setViewState, fitView, maxZoom, hoveredItem, setHoveredItem, setHoverLocked } = useZUIInteraction(
@@ -352,42 +324,100 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
352
324
  resolveHoveredProxyItem,
353
325
  )
354
326
 
355
- // Anchor positions recompute every render (fast tree traversal, view-dependent).
327
+ // Anchor positions are zoom-dependent, but not pan-dependent. Keeping pan out
328
+ // of this memo avoids re-walking the ZUI tree during drag/trackpad movement.
356
329
  const anchors = useMemo(() =>
357
- collectVisibleNodeAnchors(layout.groups, viewState, containerSize.w || 1, hiddenTags),
358
- [layout.groups, viewState, containerSize.w, hiddenTags],
330
+ collectVisibleNodeAnchors(layout.groups, anchorViewForZoom(viewState.zoom), containerSize.w || 1, hiddenTags),
331
+ [layout.groups, viewState.zoom, containerSize.w, hiddenTags],
359
332
  )
360
333
 
334
+ const viewportBounds = useMemo(() => {
335
+ const zoom = Math.max(0.0001, viewState.zoom)
336
+ const stableView = { ...viewState, zoom }
337
+ const minX = screenToWorldX(0, stableView)
338
+ const minY = screenToWorldY(0, stableView)
339
+ const maxX = screenToWorldX(containerSize.w, stableView)
340
+ const maxY = screenToWorldY(containerSize.h, stableView)
341
+ return {
342
+ minX,
343
+ minY,
344
+ maxX,
345
+ maxY,
346
+ centerX: (minX + maxX) / 2,
347
+ centerY: (minY + maxY) / 2,
348
+ }
349
+ }, [containerSize.h, containerSize.w, viewState])
350
+
351
+ useEffect(() => {
352
+ if (!debugViewport) return
353
+ const cameraRebase = getCameraRebase(viewState, containerSize.w, containerSize.h)
354
+ console.debug('[ZUICanvas] viewport', {
355
+ x: viewState.x,
356
+ y: viewState.y,
357
+ zoom: viewState.zoom,
358
+ width: containerSize.w,
359
+ height: containerSize.h,
360
+ minX: viewportBounds.minX,
361
+ minY: viewportBounds.minY,
362
+ maxX: viewportBounds.maxX,
363
+ maxY: viewportBounds.maxY,
364
+ centerX: viewportBounds.centerX,
365
+ centerY: viewportBounds.centerY,
366
+ renderX: cameraRebase.view.x,
367
+ renderY: cameraRebase.view.y,
368
+ renderOriginX: cameraRebase.originX,
369
+ renderOriginY: cameraRebase.originY,
370
+ })
371
+ }, [containerSize.h, containerSize.w, debugViewport, viewState, viewportBounds])
372
+
361
373
  // A stable string key encoding which element→nodeId pairs are currently visible.
362
374
  // This only changes when nodes cross zoom-expansion thresholds not on every pan pixel.
363
375
  const visibleElementSig = useMemo(() =>
364
376
  Array.from(anchors.visibleAnchors.entries())
365
377
  .sort(([a], [b]) => a - b)
366
- .map(([id, anchor]) => `${id}:${anchor.nodeId}`)
378
+ .map(([id, anchor]) => `${id}:${anchor.nodeId}:${anchor.renderAlpha >= (crossBranchSettings.minConnectorAnchorAlpha ?? DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA) ? 1 : 0}`)
367
379
  .join(','),
368
- [anchors.visibleAnchors],
380
+ [anchors.visibleAnchors, crossBranchSettings.minConnectorAnchorAlpha],
369
381
  )
370
-
371
- // Connector topology: expensive O(connectors) resolution only when visibility set changes.
382
+ const proxySettingsSig = [
383
+ crossBranchSettings.enabled,
384
+ crossBranchSettings.depth,
385
+ crossBranchSettings.connectorBudget,
386
+ crossBranchSettings.connectorPriority,
387
+ crossBranchSettings.minConnectorAnchorAlpha ?? '',
388
+ crossBranchSettings.maxProxyConnectorGroups ?? '',
389
+ ].join(':')
390
+
391
+ // Connector topology follows visible anchor identity, not camera position.
392
+ // Continuous pan/zoom renders reuse the previous topology until zoom changes
393
+ // which elements have visible/eligible anchors.
372
394
  const proxyConnectors = useMemo(() => {
373
395
  const resolved = buildVisibleProxyConnectors(workspaceSnapshot, anchors.visibleAnchors, crossBranchSettings)
374
- proxyConnectorsRef.current = resolved
375
396
  return resolved
376
397
  // eslint-disable-next-line react-hooks/exhaustive-deps
377
- }, [workspaceSnapshot, visibleElementSig, crossBranchSettings])
398
+ }, [workspaceSnapshot, visibleElementSig, proxySettingsSig])
399
+
400
+ const proxyHoverIndex = useMemo(() => (
401
+ buildProxyConnectorSpatialIndex(proxyConnectors.connectors, anchors.byNodeId)
402
+ ), [proxyConnectors.connectors, anchors.byNodeId])
403
+ proxyHoverIndexRef.current = proxyHoverIndex
378
404
 
379
405
  const visibleProxyState = useMemo(() => ({
380
406
  ...anchors,
381
- proxyConnectors,
407
+ proxyConnectors: proxyConnectors.connectors,
408
+ hiddenProxyBadges: proxyConnectors.hiddenBadges,
382
409
  }), [anchors, proxyConnectors])
383
410
 
384
411
  const visibleProxyStateRef = useRef(visibleProxyState)
385
412
  visibleProxyStateRef.current = visibleProxyState
386
413
 
387
414
  const labelBgRef = useRef('#171923')
415
+ const accentRef = useRef('#63b3ed')
388
416
  useEffect(() => {
389
417
  const update = () => {
390
- labelBgRef.current = getComputedStyle(document.documentElement).getPropertyValue('--chakra-colors-gray-900').trim() || '#171923'
418
+ const styles = getComputedStyle(document.documentElement)
419
+ labelBgRef.current = styles.getPropertyValue('--chakra-colors-gray-900').trim() || '#171923'
420
+ accentRef.current = styles.getPropertyValue('--accent').trim() || '#63b3ed'
391
421
  needsRedrawRef.current = true
392
422
  }
393
423
  update()
@@ -415,8 +445,8 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
415
445
  absY = g.worldY + g.diagramY - absH
416
446
  }
417
447
 
418
- const sx = absX * viewState.zoom + viewState.x
419
- const sy = absY * viewState.zoom + viewState.y
448
+ const sx = worldToScreenX(absX, viewState)
449
+ const sy = worldToScreenY(absY, viewState)
420
450
  const sw = absW * viewState.zoom
421
451
  const sh = absH * viewState.zoom
422
452
 
@@ -473,36 +503,13 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
473
503
  setViewState({ x, y, zoom })
474
504
  }, [containerSize, maxZoom, setViewState, setHoveredItem])
475
505
 
476
- const focusDiagram = useCallback((viewId: number) => {
477
- const el = containerRef.current
478
- const target = findDiagramFocusTarget(layout.groups, viewId)
479
- if (!el || !target) return false
480
-
481
- const canvasW = el.offsetWidth
482
- const canvasH = el.offsetHeight
483
- if (canvasW === 0 || canvasH === 0) return false
484
-
485
- setHoveredItem(null, true)
486
-
487
- const padding = isMobileLayout ? 0.18 : 0.16
488
- const bboxW = Math.max(1, target.absW)
489
- const bboxH = Math.max(1, target.absH)
490
- const zoom = Math.min(
491
- (canvasW * (1 - padding * 2)) / bboxW,
492
- (canvasH * (1 - padding * 2)) / bboxH,
493
- maxZoom,
494
- )
495
-
496
- const x = (canvasW - bboxW * zoom) / 2 - target.absX * zoom
497
- const y = (canvasH - bboxH * zoom) / 2 - target.absY * zoom
498
-
506
+ const animateToViewport = useCallback((to: ZUIViewState) => {
499
507
  if (cameraTransitionRef.current !== null) {
500
508
  cancelAnimationFrame(cameraTransitionRef.current)
501
509
  cameraTransitionRef.current = null
502
510
  }
503
511
 
504
- const from = viewStateRef.current
505
- const to = { x, y, zoom }
512
+ const from = rawCameraView(viewStateRef.current)
506
513
  const duration = 520
507
514
  const startedAt = performance.now()
508
515
 
@@ -524,8 +531,41 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
524
531
  }
525
532
 
526
533
  cameraTransitionRef.current = requestAnimationFrame(step)
534
+ }, [setViewState, viewStateRef])
535
+
536
+ const focusDiagram = useCallback((viewId: number) => {
537
+ const el = containerRef.current
538
+ const target = findDiagramFocusTarget(layout.groups, viewId)
539
+ if (!el || !target) return false
540
+
541
+ const canvasW = el.offsetWidth
542
+ const canvasH = el.offsetHeight
543
+ if (canvasW === 0 || canvasH === 0) return false
544
+
545
+ const to = viewportForDiagramFocusTarget(target, canvasW, canvasH, maxZoom, isMobileLayout)
546
+ if (!to) return false
547
+
548
+ setHoveredItem(null, true)
549
+ animateToViewport(to)
550
+ return true
551
+ }, [animateToViewport, isMobileLayout, layout.groups, maxZoom, setHoveredItem])
552
+
553
+ const focusElement = useCallback((viewId: number, elementId: number) => {
554
+ const el = containerRef.current
555
+ const target = findElementFocusTarget(layout.groups, viewId, elementId)
556
+ if (!el || !target) return false
557
+
558
+ const canvasW = el.offsetWidth
559
+ const canvasH = el.offsetHeight
560
+ if (canvasW === 0 || canvasH === 0) return false
561
+
562
+ const to = viewportForElementFocusTarget(target, canvasW, canvasH, maxZoom, isMobileLayout)
563
+ if (!to) return false
564
+
565
+ setHoveredItem(null, true)
566
+ animateToViewport(to)
527
567
  return true
528
- }, [isMobileLayout, layout.groups, maxZoom, setHoveredItem, setViewState, viewStateRef])
568
+ }, [animateToViewport, isMobileLayout, layout.groups, maxZoom, setHoveredItem])
529
569
 
530
570
  const setCameraFrame = useCallback((frame: ZUICameraFrame) => {
531
571
  if (frame.profile !== 'detail-to-overview') return false
@@ -625,9 +665,10 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
625
665
  fitView(el.offsetWidth, el.offsetHeight, layout.bbox)
626
666
  },
627
667
  focusDiagram,
668
+ focusElement,
628
669
  setCameraFrame,
629
670
  }),
630
- [fitView, focusDiagram, layout.bbox, setCameraFrame, setHoveredItem],
671
+ [fitView, focusDiagram, focusElement, layout.bbox, setCameraFrame, setHoveredItem],
631
672
  )
632
673
 
633
674
  // ── RAF render loop ──────────────────────────────────────────────
@@ -719,14 +760,29 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
719
760
  ctx.save()
720
761
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
721
762
  const occupiedLabelRects = renderFrame(ctx, layout.groups, currentView, w, h)
763
+ const cameraRebase = getCameraRebase(currentView, w, h)
764
+ const rebasedProxyAnchors = rebaseVisibleNodeAnchors(
765
+ visibleProxyStateRef.current.byNodeId,
766
+ cameraRebase.originX,
767
+ cameraRebase.originY,
768
+ )
722
769
  ctx.save()
723
- ctx.translate(currentView.x, currentView.y)
724
- ctx.scale(currentView.zoom, currentView.zoom)
770
+ ctx.translate(cameraRebase.view.x, cameraRebase.view.y)
771
+ ctx.scale(cameraRebase.view.zoom, cameraRebase.view.zoom)
725
772
  drawVisibleProxyConnectors(
726
773
  ctx,
727
774
  visibleProxyStateRef.current.proxyConnectors,
728
- visibleProxyStateRef.current.byNodeId,
729
- currentView.zoom,
775
+ rebasedProxyAnchors,
776
+ cameraRebase.view.zoom,
777
+ labelBgRef.current,
778
+ accentRef.current,
779
+ occupiedLabelRects,
780
+ )
781
+ drawVisibleDirectProxyBadges(
782
+ ctx,
783
+ visibleProxyStateRef.current.hiddenProxyBadges,
784
+ rebasedProxyAnchors,
785
+ cameraRebase.view.zoom,
730
786
  labelBgRef.current,
731
787
  occupiedLabelRects,
732
788
  )
@@ -761,6 +817,30 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
761
817
  needsRedrawRef.current = true
762
818
  }, [hiddenTags])
763
819
 
820
+ useEffect(() => {
821
+ const pulsedElementChanges = new Map<number, string>()
822
+ const pulsedElementLineDeltas = new Map<number, { added: number; removed: number }>()
823
+ if (versionFollowTarget?.resourceType === 'element' && versionFollowTarget.resourceId) {
824
+ const change = versionFollowTarget.changeType ?? versionPreview?.elementChanges.get(versionFollowTarget.resourceId)
825
+ if (change) pulsedElementChanges.set(versionFollowTarget.resourceId, change)
826
+ }
827
+ setRendererVersionDiff(
828
+ pulsedElementChanges,
829
+ versionPreview?.connectorChanges ?? new Map(),
830
+ versionPreview?.elementLineDeltas ?? pulsedElementLineDeltas,
831
+ )
832
+ needsRedrawRef.current = true
833
+ }, [versionPreview, versionFollowTarget])
834
+
835
+ useEffect(() => {
836
+ if (!initialized || !versionFollowTarget?.viewId) return
837
+ if (versionFollowTarget.resourceType === 'element' && versionFollowTarget.resourceId) {
838
+ focusElement(versionFollowTarget.viewId, versionFollowTarget.resourceId)
839
+ return
840
+ }
841
+ focusDiagram(versionFollowTarget.viewId)
842
+ }, [focusDiagram, focusElement, initialized, versionFollowTarget?.resourceId, versionFollowTarget?.resourceType, versionFollowTarget?.token, versionFollowTarget?.viewId])
843
+
764
844
  useEffect(() => {
765
845
  setHoverLocked(hoverLocked)
766
846
  }, [hoverLocked, setHoverLocked])
@@ -771,6 +851,7 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
771
851
  setRendererHighlightedTags(new Set())
772
852
  setRendererHighlightColor('')
773
853
  setRendererHiddenTags(new Set())
854
+ setRendererVersionDiff(new Map(), new Map())
774
855
  }
775
856
  }, [])
776
857
 
@@ -957,6 +1038,19 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
957
1038
  </Text>
958
1039
  <Text fontSize="xs" color="gray.400">{hoveredItem.data.details.label}</Text>
959
1040
  </VStack>
1041
+ <VStack align="start" spacing={1} width="full">
1042
+ <Text color="gray.400" fontSize="2xs" fontWeight="600" letterSpacing="wider">UNDERLYING PATHS</Text>
1043
+ {hoveredItem.data.details.connectors.slice(0, 4).map((leaf, index) => (
1044
+ <Text key={`${leaf.connector.id}-${index}`} fontSize="xs" color="gray.200">
1045
+ {leaf.source.actualElementName} &rarr; {leaf.target.actualElementName}
1046
+ </Text>
1047
+ ))}
1048
+ {hoveredItem.data.details.connectors.length > 4 && (
1049
+ <Text fontSize="xs" color="gray.500">
1050
+ +{hoveredItem.data.details.connectors.length - 4} more
1051
+ </Text>
1052
+ )}
1053
+ </VStack>
960
1054
  <Divider borderColor="whiteAlpha.200" />
961
1055
  <VStack align="stretch" spacing={2} width="full">
962
1056
  {hoveredItem.data.details.ownerViewIds.map((ownerViewId, index) => (