@tldiagram/core-ui 1.93.0 → 1.94.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.
@@ -2,6 +2,9 @@ interface Props {
2
2
  sharedToken?: string;
3
3
  shareSlot?: React.ReactNode;
4
4
  }
5
- export default function InfiniteZoom(props: Props): import("react/jsx-runtime").JSX.Element;
5
+ export interface InfiniteZoomHandle {
6
+ focusDiagram(viewId: number): boolean;
7
+ }
8
+ declare const InfiniteZoom: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<InfiniteZoomHandle>>;
9
+ export default InfiniteZoom;
6
10
  export declare function SharedInfiniteZoom(props: Props): import("react/jsx-runtime").JSX.Element;
7
- export {};
@@ -2,6 +2,7 @@ import type { DrawingCanvasHandle } from '../../../components/DrawingCanvas';
2
2
  import { type Connection, type Edge as RFEdge, type EdgeChange, type Node as RFNode, type NodeChange, type NodeDragHandler, type OnConnect, type OnConnectStartParams } from 'reactflow';
3
3
  import type { Connector, PlacedElement, ViewTreeNode, LibraryElement, ViewLayer, ViewConnector, IncomingViewConnector } from '../../../types';
4
4
  export declare function applyNodeChangesWithStructuralSharing(changes: NodeChange[], nodes: RFNode[]): RFNode[];
5
+ export declare function getConnectorDeletionTarget(selectedConnector: Connector | null): number | null;
5
6
  interface CanvasInteractionOptions {
6
7
  viewId: number | null;
7
8
  canEdit: boolean;
@@ -42,12 +43,11 @@ interface CanvasInteractionOptions {
42
43
  openConnectorPanel: () => void;
43
44
  closeConnectorPanel: () => void;
44
45
  selectedElement: LibraryElement | null;
45
- selectedEdgeId: number | null;
46
+ selectedConnector: Connector | null;
46
47
  connectors: Connector[];
47
48
  layers: ViewLayer[];
48
49
  setSelectedElement: React.Dispatch<React.SetStateAction<LibraryElement | null>>;
49
50
  setSelectedEdge: (e: Connector | null) => void;
50
- setSelectedEdgeId: (id: number | null) => void;
51
51
  setSelectedProxyConnectorDetails: React.Dispatch<React.SetStateAction<import('../../../crossBranch/types').ProxyConnectorDetails | null>>;
52
52
  openProxyConnectorPanel: () => void;
53
53
  closeProxyConnectorPanel: () => void;
@@ -80,7 +80,12 @@ type HandleReconnectDragState = {
80
80
  hoveredNodeId?: string;
81
81
  hoveredHandleId?: string;
82
82
  };
83
- export declare function useCanvasInteractions({ viewId, canEdit, drawingMode: _drawingMode, isMobileLayout: _isMobileLayout, rfNodesRef, rfEdgesRef: _rfEdgesRef, viewElementsRef, viewIdRef, incomingLinksRef, treeDataRef, navigateRef, containerRef, interactionSourceIdRef, hoveredZoomRef, hoverPanLockedUntilRef, setViewElements: _setViewElements, setConnectors: _setConnectors, setRfNodes, setRfEdges, setLinksMap, setParentLinksMap: _setParentLinksMap, setHoveredZoom, refreshGrid, refreshElements, stableOnConnectTo, existingElementIds, linksMapRef, parentLinksMapRef, openElementPanel: _openElementPanel, closeElementPanel: closeElementPanel, openConnectorPanel: openConnectorPanel, closeConnectorPanel: closeConnectorPanel, selectedElement, selectedEdgeId, connectors, layers, setSelectedElement, setSelectedEdge, setSelectedEdgeId, setSelectedProxyConnectorDetails, openProxyConnectorPanel, closeProxyConnectorPanel, handleElementDeleted, handleElementPermanentlyDeleted, handleConnectorDeleted, handleUpdateTags, drawingCanvasRef, snapToGrid, onMoveStateChange, }: CanvasInteractionOptions): {
83
+ type InteractionStartOptions = {
84
+ sourceHandle?: string;
85
+ clientX?: number;
86
+ clientY?: number;
87
+ };
88
+ export declare function useCanvasInteractions({ viewId, canEdit, drawingMode: _drawingMode, isMobileLayout: _isMobileLayout, rfNodesRef, rfEdgesRef: _rfEdgesRef, viewElementsRef, viewIdRef, incomingLinksRef, treeDataRef, navigateRef, containerRef, interactionSourceIdRef, hoveredZoomRef, hoverPanLockedUntilRef, setViewElements: _setViewElements, setConnectors: _setConnectors, setRfNodes, setRfEdges, setLinksMap, setParentLinksMap: _setParentLinksMap, setHoveredZoom, refreshGrid, refreshElements, stableOnConnectTo, existingElementIds, linksMapRef, parentLinksMapRef, openElementPanel: _openElementPanel, closeElementPanel: closeElementPanel, openConnectorPanel: openConnectorPanel, closeConnectorPanel: closeConnectorPanel, selectedElement, selectedConnector, connectors, layers, setSelectedElement, setSelectedEdge, setSelectedProxyConnectorDetails, openProxyConnectorPanel, closeProxyConnectorPanel, handleElementDeleted, handleElementPermanentlyDeleted, handleConnectorDeleted, handleUpdateTags, drawingCanvasRef, snapToGrid, onMoveStateChange, }: CanvasInteractionOptions): {
84
89
  canvasMenu: {
85
90
  x: number;
86
91
  y: number;
@@ -155,6 +160,7 @@ export declare function useCanvasInteractions({ viewId, canEdit, drawingMode: _d
155
160
  stableOnHoverZoom: (elementId: number, type: "in" | "out" | null) => void;
156
161
  stableOnRemoveElement: (elementId: number) => Promise<void>;
157
162
  stableOnConnectTo: (targetElementId: number) => Promise<void>;
163
+ stableOnInteractionStart: (elementId: number, options?: InteractionStartOptions) => void;
158
164
  stableOnStartHandleReconnect: (args: {
159
165
  edgeId: string;
160
166
  endpoint: "source" | "target";
@@ -7,7 +7,7 @@ interface ViewDataOptions {
7
7
  sourceHandle: string;
8
8
  targetHandle?: string;
9
9
  } | null;
10
- selectedEdgeId: number | null;
10
+ selectedConnector: Connector | null;
11
11
  activeTags: string[];
12
12
  hiddenLayerTags: string[];
13
13
  hoveredLayerTags: string[] | null;
@@ -18,7 +18,11 @@ interface ViewDataOptions {
18
18
  stableOnNavigateToView: (id: number) => void;
19
19
  stableOnSelect: (obj: PlacedElement) => void;
20
20
  stableOnOpenCodePreview: (elementId: number) => void;
21
- stableOnInteractionStart: (elementId: number) => void;
21
+ stableOnInteractionStart: (elementId: number, options?: {
22
+ sourceHandle?: string;
23
+ clientX?: number;
24
+ clientY?: number;
25
+ }) => void;
22
26
  stableOnConnectTo: (targetElementId: number) => Promise<void>;
23
27
  stableOnStartHandleReconnect: (args: {
24
28
  edgeId: string;
@@ -34,7 +38,7 @@ interface ViewDataOptions {
34
38
  type: 'in' | 'out' | null;
35
39
  } | null>;
36
40
  }
37
- export declare function useViewData({ viewId, interactionSourceId, clickConnectMode, selectedEdgeId, activeTags, hiddenLayerTags, hoveredLayerTags, hoveredLayerColor, tagColors, stableOnZoomIn, stableOnZoomOut, stableOnNavigateToView, stableOnSelect, stableOnOpenCodePreview, stableOnInteractionStart, stableOnConnectTo, stableOnStartHandleReconnect, stableOnRemoveElement, stableOnHoverZoom, hoveredZoomRef, }: ViewDataOptions): {
41
+ export declare function useViewData({ viewId, interactionSourceId, clickConnectMode, selectedConnector, activeTags, hiddenLayerTags, hoveredLayerTags, hoveredLayerColor, tagColors, stableOnZoomIn, stableOnZoomOut, stableOnNavigateToView, stableOnSelect, stableOnOpenCodePreview, stableOnInteractionStart, stableOnConnectTo, stableOnStartHandleReconnect, stableOnRemoveElement, stableOnHoverZoom, hoveredZoomRef, }: ViewDataOptions): {
38
42
  view: ViewTreeNode | null | undefined;
39
43
  setView: (view: ViewTreeNode | null | undefined) => void;
40
44
  viewElements: PlacedElement[];
@@ -1,6 +1,14 @@
1
+ import { type Dispatch, type SetStateAction } from 'react';
1
2
  import 'reactflow/dist/style.css';
3
+ import type { ViewTreeNode } from '../types';
2
4
  interface Props {
3
5
  onShare?: (viewId: number) => void;
6
+ treeData: ViewTreeNode[];
7
+ loading: boolean;
8
+ focusedId: number | null;
9
+ onFocusChange: (viewId: number | null) => void;
10
+ setTreeData: Dispatch<SetStateAction<ViewTreeNode[]>>;
11
+ refreshTree: () => Promise<void>;
4
12
  }
5
- export default function ViewsGrid({ onShare }: Props): import("react/jsx-runtime").JSX.Element;
13
+ export default function ViewsGrid(props: Props): import("react/jsx-runtime").JSX.Element;
6
14
  export {};
@@ -67,6 +67,8 @@ export declare function selectElementById(state: Pick<CanvasStoreState, 'viewEle
67
67
  export declare function selectConnectorById(state: Pick<CanvasStoreState, 'connectors'>, connectorId: number): Connector | undefined;
68
68
  export declare function updatePlacedElementPosition(elements: PlacedElement[], elementId: number, x: number, y: number): PlacedElement[];
69
69
  export declare function removePlacedElement(elements: PlacedElement[], elementId: number): PlacedElement[];
70
+ export declare function placedElementToLibraryElement(element: PlacedElement): LibraryElement;
71
+ export declare function buildElementLibraryItems(allElements: LibraryElement[], viewElements: PlacedElement[]): LibraryElement[];
70
72
  export declare function mergeSavedElementIntoPlacements(elements: PlacedElement[], saved: LibraryElement): PlacedElement[];
71
73
  export declare function upsertConnectorInList(connectors: Connector[], connector: Connector): Connector[];
72
74
  export declare function removeConnectorFromList(connectors: Connector[], connectorId: number): Connector[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tldiagram/core-ui",
3
- "version": "1.93.0",
3
+ "version": "1.94.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -67,7 +67,7 @@
67
67
  "mermaid-ast": "^0.8.2",
68
68
  "react": "^18.3.1",
69
69
  "react-dom": "^18.3.1",
70
- "react-router-dom": "^6.28.1",
70
+ "react-router-dom": "^6.30.3",
71
71
  "reactflow": "^11.11.4",
72
72
  "web-tree-sitter": "0.21.0"
73
73
  },
@@ -143,11 +143,11 @@
143
143
  "mermaid-ast": "^0.8.2",
144
144
  "react": "^18.3.1",
145
145
  "react-dom": "^18.3.1",
146
- "react-router-dom": "^6.28.1",
146
+ "react-router-dom": "^6.30.3",
147
147
  "reactflow": "^11.11.4",
148
148
  "typescript": "^5.6.3",
149
149
  "typescript-eslint": "^8.0.0",
150
- "vite": "^6.0.3",
150
+ "vite": "^6.4.2",
151
151
  "vite-tsconfig-paths": "^6.1.1",
152
152
  "vitest": "^4.1.2",
153
153
  "web-tree-sitter": "^0.21.0"
@@ -138,7 +138,7 @@ interface NodeData extends PlacedElement {
138
138
  onZoomOut: (elementId: number) => void
139
139
  onNavigateToDiagram: (viewId: number) => void
140
140
  onSelect: (obj: PlacedElement) => void
141
- onInteractionStart: (elementId: number) => void
141
+ onInteractionStart: (elementId: number, options?: { sourceHandle?: string; clientX?: number; clientY?: number }) => void
142
142
  onConnectTo: (elementId: number) => void
143
143
  onStartHandleReconnect?: (args: { edgeId: string; endpoint: 'source' | 'target'; handleId: string; clientX: number; clientY: number }) => void
144
144
  onRemove: (elementId: number) => void
@@ -505,6 +505,15 @@ function ElementNode({ data, selected }: Props) {
505
505
  position={position}
506
506
  id={handleId}
507
507
  className={className}
508
+ onClick={(e: React.MouseEvent) => {
509
+ e.preventDefault()
510
+ e.stopPropagation()
511
+ data.onInteractionStart(data.element_id, {
512
+ sourceHandle: handleId,
513
+ clientX: e.clientX,
514
+ clientY: e.clientY,
515
+ })
516
+ }}
508
517
  style={{
509
518
  ...getVisualHandleStyle(position, slot),
510
519
  background: 'var(--accent)',
@@ -491,8 +491,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
491
491
  const handlePermanentDelete = async () => {
492
492
  if (isReadOnly || !element) return
493
493
  try {
494
- if (orgId === undefined) return
495
- await api.elements.delete(orgId, element.id)
494
+ await api.elements.delete(orgId ?? '', element.id)
496
495
  onPermanentDelete?.(element.id)
497
496
  confirmPermanentDelete.onClose()
498
497
  onClose()
@@ -16,10 +16,12 @@ import {
16
16
  Text,
17
17
  VStack,
18
18
  Icon,
19
+ useDisclosure,
19
20
  } from '@chakra-ui/react'
20
21
  import { ChevronDownIcon, ChevronRightIcon } from './Icons'
21
22
  import { api } from '../api/client'
22
23
  import type { ViewTreeNode } from '../types'
24
+ import ConfirmDialog from './ConfirmDialog'
23
25
 
24
26
  type Algorithm = 'dagre' | 'force'
25
27
 
@@ -54,6 +56,7 @@ export default function LayoutSection({ view, canEdit }: Props) {
54
56
  const [algo, setAlgo] = useState<Algorithm>('dagre')
55
57
  const [running, setRunning] = useState(false)
56
58
  const [collisionRunning, setCollisionRunning] = useState(false)
59
+ const adjustConnectorsConfirm = useDisclosure()
57
60
 
58
61
  const [dagreConfig, setDagreConfig] = useState<DagreConfig>({
59
62
  direction: 'TB',
@@ -570,17 +573,17 @@ export default function LayoutSection({ view, canEdit }: Props) {
570
573
  </Button>
571
574
  {/* Apply button */}
572
575
  <VStack spacing={2} w="full">
573
- <Button
574
- size="sm"
575
- w="full"
576
- variant="outline"
577
- colorScheme="blue"
578
- onClick={handleCollisionRemoval}
579
- isLoading={collisionRunning}
580
- isDisabled={!canEdit || !view}
581
- loadingText="Removing Connector Collisions..."
582
- fontWeight="bold"
583
- fontSize="xs"
576
+ <Button
577
+ size="sm"
578
+ w="full"
579
+ variant="outline"
580
+ colorScheme="blue"
581
+ onClick={adjustConnectorsConfirm.onOpen}
582
+ isLoading={collisionRunning}
583
+ isDisabled={!canEdit || !view}
584
+ loadingText="Removing Connector Collisions..."
585
+ fontWeight="bold"
586
+ fontSize="xs"
584
587
  letterSpacing="0.05em"
585
588
  textTransform="uppercase"
586
589
  h="32px"
@@ -594,6 +597,19 @@ export default function LayoutSection({ view, canEdit }: Props) {
594
597
 
595
598
  </VStack>
596
599
  </Collapse>
600
+ <ConfirmDialog
601
+ isOpen={adjustConnectorsConfirm.isOpen}
602
+ onClose={adjustConnectorsConfirm.onClose}
603
+ onConfirm={() => {
604
+ adjustConnectorsConfirm.onClose();
605
+ void handleCollisionRemoval();
606
+ }}
607
+ title="Adjust Connectors"
608
+ body="This action will re-attach existing connectors to form the shortest path between the elements."
609
+ confirmLabel="Confirm"
610
+ confirmColorScheme="blue"
611
+ isLoading={collisionRunning}
612
+ />
597
613
  </Box>
598
614
  )
599
615
  }
@@ -37,6 +37,7 @@ import { buildVisibleProxyConnectors, collectVisibleNodeAnchors, drawVisibleProx
37
37
 
38
38
  export interface ZUICanvasHandle {
39
39
  fitView(): void
40
+ focusDiagram(viewId: number): boolean
40
41
  }
41
42
 
42
43
  interface Props {
@@ -169,9 +170,78 @@ function getPathAt(
169
170
  return []
170
171
  }
171
172
 
173
+ function findDiagramFocusTarget(groups: DiagramGroupLayout[], viewId: number): PathItem | null {
174
+ for (const group of groups) {
175
+ if (group.diagramId === viewId) {
176
+ return {
177
+ id: `g-${group.diagramId}`,
178
+ label: group.label,
179
+ type: 'group',
180
+ absX: group.worldX,
181
+ absY: group.worldY,
182
+ absW: group.worldW,
183
+ absH: group.worldH,
184
+ }
185
+ }
186
+
187
+ const found = findLinkedDiagramInNodes(viewId, group.nodes, 0, 0, 1, 0, 0)
188
+ if (found) return found
189
+ }
190
+ return null
191
+ }
192
+
193
+ function findLinkedDiagramInNodes(
194
+ viewId: number,
195
+ nodes: DiagramGroupLayout['nodes'],
196
+ parentAbsX: number,
197
+ parentAbsY: number,
198
+ parentAbsScale: number,
199
+ parentChildOffsetX: number,
200
+ parentChildOffsetY: number,
201
+ ): PathItem | null {
202
+ for (const node of nodes) {
203
+ const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
204
+ const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
205
+ const absW = node.worldW * parentAbsScale
206
+ const absH = node.worldH * parentAbsScale
207
+
208
+ if (node.linkedDiagramId === viewId) {
209
+ return {
210
+ id: node.id,
211
+ label: node.linkedDiagramLabel || node.label,
212
+ type: 'node',
213
+ isCircular: node.isCircular,
214
+ absX,
215
+ absY,
216
+ absW,
217
+ absH,
218
+ }
219
+ }
220
+
221
+ if (node.children.length > 0) {
222
+ const found = findLinkedDiagramInNodes(
223
+ viewId,
224
+ node.children,
225
+ absX,
226
+ absY,
227
+ parentAbsScale * node.childScale,
228
+ node.childOffsetX,
229
+ node.childOffsetY,
230
+ )
231
+ if (found) return found
232
+ }
233
+ }
234
+ return null
235
+ }
236
+
237
+ function easeOutQuart(t: number): number {
238
+ return 1 - Math.pow(1 - t, 4)
239
+ }
240
+
172
241
  export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({ data, onReady, onZoom, onPan, highlightedTags, highlightColor, hiddenTags, crossBranchSettings, hoverLocked = false }, ref) {
173
242
  const canvasRef = useRef<HTMLCanvasElement>(null)
174
243
  const containerRef = useRef<HTMLDivElement>(null)
244
+ const cameraTransitionRef = useRef<number | null>(null)
175
245
  const [initialized, setInitialized] = useState(false)
176
246
  const [containerSize, setContainerSize] = useState({ w: 0, h: 0 })
177
247
  const isMobileLayout = useBreakpointValue({ base: true, md: false }) ?? false
@@ -297,6 +367,10 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
297
367
 
298
368
  const zoomToPathItem = useCallback((item: PathItem) => {
299
369
  if (containerSize.w === 0 || containerSize.h === 0) return
370
+ if (cameraTransitionRef.current !== null) {
371
+ cancelAnimationFrame(cameraTransitionRef.current)
372
+ cameraTransitionRef.current = null
373
+ }
300
374
  setHoveredItem(null, true) // Clear popover immediately on breadcrumb jump
301
375
 
302
376
  // Use a comfortable padding for the focused item
@@ -317,6 +391,68 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
317
391
  setViewState({ x, y, zoom })
318
392
  }, [containerSize, maxZoom, setViewState, setHoveredItem])
319
393
 
394
+ const focusDiagram = useCallback((viewId: number) => {
395
+ const el = containerRef.current
396
+ const target = findDiagramFocusTarget(layout.groups, viewId)
397
+ if (!el || !target) return false
398
+
399
+ const canvasW = el.offsetWidth
400
+ const canvasH = el.offsetHeight
401
+ if (canvasW === 0 || canvasH === 0) return false
402
+
403
+ setHoveredItem(null, true)
404
+
405
+ const padding = isMobileLayout ? 0.18 : 0.16
406
+ const bboxW = Math.max(1, target.absW)
407
+ const bboxH = Math.max(1, target.absH)
408
+ const zoom = Math.min(
409
+ (canvasW * (1 - padding * 2)) / bboxW,
410
+ (canvasH * (1 - padding * 2)) / bboxH,
411
+ maxZoom,
412
+ )
413
+
414
+ const x = (canvasW - bboxW * zoom) / 2 - target.absX * zoom
415
+ const y = (canvasH - bboxH * zoom) / 2 - target.absY * zoom
416
+
417
+ if (cameraTransitionRef.current !== null) {
418
+ cancelAnimationFrame(cameraTransitionRef.current)
419
+ cameraTransitionRef.current = null
420
+ }
421
+
422
+ const from = viewStateRef.current
423
+ const to = { x, y, zoom }
424
+ const duration = 520
425
+ const startedAt = performance.now()
426
+
427
+ const step = (now: number) => {
428
+ const t = Math.min(1, (now - startedAt) / duration)
429
+ const eased = easeOutQuart(t)
430
+ setViewState({
431
+ x: from.x + (to.x - from.x) * eased,
432
+ y: from.y + (to.y - from.y) * eased,
433
+ zoom: from.zoom + (to.zoom - from.zoom) * eased,
434
+ })
435
+
436
+ if (t < 1) {
437
+ cameraTransitionRef.current = requestAnimationFrame(step)
438
+ } else {
439
+ cameraTransitionRef.current = null
440
+ setViewState(to)
441
+ }
442
+ }
443
+
444
+ cameraTransitionRef.current = requestAnimationFrame(step)
445
+ return true
446
+ }, [isMobileLayout, layout.groups, maxZoom, setHoveredItem, setViewState, viewStateRef])
447
+
448
+ useEffect(() => {
449
+ return () => {
450
+ if (cameraTransitionRef.current !== null) {
451
+ cancelAnimationFrame(cameraTransitionRef.current)
452
+ }
453
+ }
454
+ }, [])
455
+
320
456
  // ── Fit view on mount and when layout changes ────────────────────
321
457
  useEffect(() => {
322
458
  const el = containerRef.current
@@ -345,8 +481,9 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
345
481
  setHoveredItem(null, true) // Clear popover immediately on fitView
346
482
  fitView(el.offsetWidth, el.offsetHeight, layout.bbox)
347
483
  },
484
+ focusDiagram,
348
485
  }),
349
- [fitView, layout.bbox, setHoveredItem],
486
+ [fitView, focusDiagram, layout.bbox, setHoveredItem],
350
487
  )
351
488
 
352
489
  // ── RAF render loop ──────────────────────────────────────────────
@@ -1,5 +1,5 @@
1
1
  // src/pages/InfiniteZoom.tsx Explore page holds the ZUI feature
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
3
3
  import { useNavigate, useParams } from 'react-router-dom'
4
4
  import {
5
5
  Box,
@@ -34,10 +34,14 @@ interface Props {
34
34
  shareSlot?: React.ReactNode
35
35
  }
36
36
 
37
+ export interface InfiniteZoomHandle {
38
+ focusDiagram(viewId: number): boolean
39
+ }
40
+
37
41
  const MINI_ONBOARDING_KEY = 'shared_zoom_onboarding_dismissed'
38
42
 
39
43
  // ── Inner component ────────────────────────────────────────────────
40
- function InfiniteZoomInner({ sharedToken, shareSlot }: Props) {
44
+ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<InfiniteZoomHandle>) {
41
45
  const navigate = useNavigate()
42
46
 
43
47
  const [data, setData] = useState<ExploreData | null>(null)
@@ -54,6 +58,12 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props) {
54
58
  const crossBranchSurface = sharedToken ? 'zui-shared' : 'zui'
55
59
  const { settings: crossBranchSettings, setEnabled: setCrossBranchEnabled } = useCrossBranchContextSettings(crossBranchSurface)
56
60
 
61
+ useImperativeHandle(ref, () => ({
62
+ focusDiagram(viewId: number) {
63
+ return zuiRef.current?.focusDiagram(viewId) ?? false
64
+ },
65
+ }), [])
66
+
57
67
  // ── No data or No content ────────────────────────────────────────
58
68
  const hasPlacements = useMemo(() => {
59
69
  if (!data || !data.views) return false
@@ -387,9 +397,8 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props) {
387
397
 
388
398
  // ── Exports ───────────────────────────────────────────────────────
389
399
 
390
- export default function InfiniteZoom(props: Props) {
391
- return <InfiniteZoomInner {...props} />
392
- }
400
+ const InfiniteZoom = forwardRef<InfiniteZoomHandle, Props>(InfiniteZoomInner)
401
+ export default InfiniteZoom
393
402
 
394
403
  export function SharedInfiniteZoom(props: Props) {
395
404
  const { token } = useParams()
@@ -1,4 +1,4 @@
1
- import { createContext } from 'react'
1
+ import { createContext, useContext } from 'react'
2
2
  import type { LibraryElement, Connector } from '../../types'
3
3
  import { useStore } from '../../store/useStore'
4
4
 
@@ -16,6 +16,7 @@ export interface ViewEditorContextValue {
16
16
  export const ViewEditorContext = createContext<ViewEditorContextValue | null>(null)
17
17
 
18
18
  export function useViewEditorContext(): ViewEditorContextValue {
19
+ const context = useContext(ViewEditorContext)
19
20
  const viewId = useStore((state) => state.viewId)
20
21
  const canEdit = useStore((state) => state.canEdit)
21
22
  const isOwner = useStore((state) => state.isOwner)
@@ -25,5 +26,7 @@ export function useViewEditorContext(): ViewEditorContextValue {
25
26
  const selectedElement = useStore((state) => state.selectedElement)
26
27
  const selectedConnector = useStore((state) => state.selectedConnector)
27
28
 
29
+ if (context) return context
30
+
28
31
  return { viewId, canEdit, isOwner, isFreePlan, snapToGrid, setSnapToGrid, selectedElement, selectedConnector }
29
32
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Connector } from '../../../types'
3
+ import { getConnectorDeletionTarget } from './useCanvasInteractions'
4
+
5
+ const connector = (id: number): Connector => ({
6
+ id,
7
+ view_id: 1,
8
+ source_element_id: 10,
9
+ target_element_id: 20,
10
+ label: null,
11
+ description: null,
12
+ relationship: null,
13
+ direction: 'forward',
14
+ style: 'bezier',
15
+ url: null,
16
+ source_handle: 'right',
17
+ target_handle: 'left',
18
+ created_at: '2024-01-01',
19
+ updated_at: '2024-01-01',
20
+ })
21
+
22
+ describe('getConnectorDeletionTarget', () => {
23
+ it('returns the selected connector id', () => {
24
+ expect(getConnectorDeletionTarget(connector(7))).toBe(7)
25
+ })
26
+
27
+ it('returns null when nothing is selected', () => {
28
+ expect(getConnectorDeletionTarget(null)).toBeNull()
29
+ })
30
+ })