@tldiagram/core-ui 1.92.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.
Files changed (37) hide show
  1. package/dist/api/client.d.ts +13 -1
  2. package/dist/components/ElementNode.d.ts +14 -1
  3. package/dist/components/ZUI/ZUICanvas.d.ts +1 -0
  4. package/dist/config/runtime-vscode.d.ts +1 -0
  5. package/dist/config/runtime.d.ts +1 -0
  6. package/dist/index.js +10875 -9550
  7. package/dist/pages/InfiniteZoom.d.ts +5 -2
  8. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +10 -3
  9. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.test.d.ts +1 -0
  10. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +27 -24
  11. package/dist/pages/ViewsGrid.d.ts +9 -1
  12. package/dist/shims/empty-node-module.d.ts +2 -0
  13. package/dist/store/useStore.d.ts +80 -0
  14. package/dist/store/useStore.test.d.ts +1 -0
  15. package/package.json +10 -7
  16. package/src/api/client.ts +39 -1
  17. package/src/components/ElementNode.tsx +21 -59
  18. package/src/components/ElementPanel.tsx +2 -3
  19. package/src/components/LayoutSection.tsx +95 -104
  20. package/src/components/ViewGridNode.tsx +1 -4
  21. package/src/components/ZUI/ZUICanvas.tsx +138 -1
  22. package/src/components/ZUI/renderer.ts +166 -66
  23. package/src/components/ZUI/useZUIInteraction.ts +235 -81
  24. package/src/config/runtime-vscode.ts +6 -0
  25. package/src/config/runtime.ts +4 -0
  26. package/src/main.tsx +26 -14
  27. package/src/pages/InfiniteZoom.tsx +14 -5
  28. package/src/pages/ViewEditor/context.tsx +14 -3
  29. package/src/pages/ViewEditor/hooks/useCanvasInteractions.test.ts +30 -0
  30. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +294 -146
  31. package/src/pages/ViewEditor/hooks/useViewData.ts +459 -256
  32. package/src/pages/ViewEditor/index.tsx +67 -70
  33. package/src/pages/Views.tsx +552 -83
  34. package/src/pages/ViewsGrid.tsx +26 -337
  35. package/src/shims/empty-node-module.ts +1 -0
  36. package/src/store/useStore.test.ts +285 -0
  37. package/src/store/useStore.ts +327 -0
@@ -30,6 +30,54 @@ interface DeepestNodeResult {
30
30
  cumulativeScale: number
31
31
  }
32
32
 
33
+ interface NodeSpatialIndex {
34
+ cellSize: number
35
+ cells: Map<string, LayoutNode[]>
36
+ }
37
+
38
+ const NODE_INDEX_CELL_SIZE = 320
39
+ const nodeSpatialIndexCache = new WeakMap<LayoutNode[], NodeSpatialIndex>()
40
+
41
+ function getNodeSpatialIndex(nodes: LayoutNode[]): NodeSpatialIndex {
42
+ const cached = nodeSpatialIndexCache.get(nodes)
43
+ if (cached) return cached
44
+
45
+ const index: NodeSpatialIndex = { cellSize: NODE_INDEX_CELL_SIZE, cells: new Map() }
46
+ for (const node of nodes) {
47
+ const startX = Math.floor(node.worldX / index.cellSize)
48
+ const endX = Math.floor((node.worldX + node.worldW) / index.cellSize)
49
+ const startY = Math.floor(node.worldY / index.cellSize)
50
+ const endY = Math.floor((node.worldY + node.worldH) / index.cellSize)
51
+
52
+ for (let cx = startX; cx <= endX; cx++) {
53
+ for (let cy = startY; cy <= endY; cy++) {
54
+ const key = cellKey(cx, cy)
55
+ let bucket = index.cells.get(key)
56
+ if (!bucket) {
57
+ bucket = []
58
+ index.cells.set(key, bucket)
59
+ }
60
+ bucket.push(node)
61
+ }
62
+ }
63
+ }
64
+
65
+ nodeSpatialIndexCache.set(nodes, index)
66
+ return index
67
+ }
68
+
69
+ function getNodesAtPoint(nodes: LayoutNode[], worldX: number, worldY: number): LayoutNode[] {
70
+ const index = getNodeSpatialIndex(nodes)
71
+ return index.cells.get(cellKey(Math.floor(worldX / index.cellSize), Math.floor(worldY / index.cellSize))) ?? []
72
+ }
73
+
74
+ function warmNodeSpatialIndexes(nodes: LayoutNode[]): void {
75
+ getNodeSpatialIndex(nodes)
76
+ for (const node of nodes) {
77
+ if (node.children.length > 0) warmNodeSpatialIndexes(node.children)
78
+ }
79
+ }
80
+
33
81
  function findDeepestAt(worldX: number, worldY: number, groups: DiagramGroupLayout[], view: ZUIViewState, thresholds: { start: number, end: number }): DeepestNodeResult | null {
34
82
  for (const group of groups) {
35
83
  if (worldX >= group.worldX && worldX <= group.worldX + group.worldW &&
@@ -53,7 +101,8 @@ function findDeepestInNodes(
53
101
  view: ZUIViewState,
54
102
  thresholds: { start: number, end: number }
55
103
  ): DeepestNodeResult | null {
56
- for (const node of nodes) {
104
+ const candidates = getNodesAtPoint(nodes, worldX, worldY)
105
+ for (const node of candidates) {
57
106
  if (worldX >= node.worldX && worldX <= node.worldX + node.worldW &&
58
107
  worldY >= node.worldY && worldY <= node.worldY + node.worldH) {
59
108
 
@@ -120,13 +169,72 @@ function findHoveredGroup(worldX: number, worldY: number, groups: DiagramGroupLa
120
169
  return null
121
170
  }
122
171
 
123
- function findHoveredEdge(
124
- worldX: number,
125
- worldY: number,
126
- groups: DiagramGroupLayout[],
127
- view: ZUIViewState
128
- ): HoveredItem | null {
129
- const threshold = 18 / view.zoom // 18 screen pixels converted to world distance
172
+ type IndexedEdge =
173
+ | {
174
+ kind: 'edge'
175
+ x1: number
176
+ y1: number
177
+ x2: number
178
+ y2: number
179
+ midX: number
180
+ midY: number
181
+ sourceLabel: string
182
+ targetLabel: string
183
+ label: string
184
+ diagramId: number
185
+ sourceObjId: number
186
+ targetObjId: number
187
+ }
188
+ | {
189
+ kind: 'portal'
190
+ x1: number
191
+ y1: number
192
+ x2: number
193
+ y2: number
194
+ midX: number
195
+ midY: number
196
+ sourceLabel: string
197
+ targetLabel: string
198
+ diagramId: number
199
+ targetDiagId?: number
200
+ }
201
+
202
+ interface EdgeSpatialIndex {
203
+ cellSize: number
204
+ cells: Map<string, IndexedEdge[]>
205
+ }
206
+
207
+ const EDGE_INDEX_CELL_SIZE = 360
208
+
209
+ function cellKey(cx: number, cy: number): string {
210
+ return `${cx},${cy}`
211
+ }
212
+
213
+ function addEdgeToSpatialIndex(index: EdgeSpatialIndex, edge: IndexedEdge): void {
214
+ const minX = Math.min(edge.x1, edge.x2)
215
+ const maxX = Math.max(edge.x1, edge.x2)
216
+ const minY = Math.min(edge.y1, edge.y2)
217
+ const maxY = Math.max(edge.y1, edge.y2)
218
+ const startX = Math.floor(minX / index.cellSize)
219
+ const endX = Math.floor(maxX / index.cellSize)
220
+ const startY = Math.floor(minY / index.cellSize)
221
+ const endY = Math.floor(maxY / index.cellSize)
222
+
223
+ for (let cx = startX; cx <= endX; cx++) {
224
+ for (let cy = startY; cy <= endY; cy++) {
225
+ const key = cellKey(cx, cy)
226
+ let bucket = index.cells.get(key)
227
+ if (!bucket) {
228
+ bucket = []
229
+ index.cells.set(key, bucket)
230
+ }
231
+ bucket.push(edge)
232
+ }
233
+ }
234
+ }
235
+
236
+ function buildEdgeSpatialIndex(groups: DiagramGroupLayout[]): EdgeSpatialIndex {
237
+ const index: EdgeSpatialIndex = { cellSize: EDGE_INDEX_CELL_SIZE, cells: new Map() }
130
238
 
131
239
  for (const group of groups) {
132
240
  const nodeMap = new Map<string, LayoutNode>()
@@ -139,89 +247,122 @@ function findHoveredEdge(
139
247
  const target = nodeMap.get(edge.targetId)
140
248
  if (!source || !target) continue
141
249
 
142
- // Node centers
143
250
  const x1 = source.worldX + source.worldW / 2
144
251
  const y1 = source.worldY + source.worldH / 2
145
252
  const x2 = target.worldX + target.worldW / 2
146
253
  const y2 = target.worldY + target.worldH / 2
147
-
148
- // Midpoint for popover placement
149
- const midX = (x1 + x2) / 2
150
- const midY = (y1 + y2) / 2
151
-
152
- // Distance from point to line segment
153
- const dx = x2 - x1
154
- const dy = y2 - y1
155
- const l2 = dx * dx + dy * dy
156
- if (l2 === 0) continue
157
-
158
- let t = ((worldX - x1) * dx + (worldY - y1) * dy) / l2
159
- t = Math.max(0, Math.min(1, t))
160
-
161
- const nearestX = x1 + t * dx
162
- const nearestY = y1 + t * dy
163
- const dist = Math.sqrt((worldX - nearestX) ** 2 + (worldY - nearestY) ** 2)
164
-
165
- if (dist < threshold) {
166
- return {
167
- type: 'edge',
168
- data: {
169
- sourceId: source.label,
170
- targetId: target.label,
171
- label: edge.label || 'Connection',
172
- diagramId: group.diagramId,
173
- sourceObjId: source.elementId,
174
- targetObjId: target.elementId
175
- },
176
- absX: midX,
177
- absY: midY
178
- }
179
- }
254
+ addEdgeToSpatialIndex(index, {
255
+ kind: 'edge',
256
+ x1,
257
+ y1,
258
+ x2,
259
+ y2,
260
+ midX: (x1 + x2) / 2,
261
+ midY: (y1 + y2) / 2,
262
+ sourceLabel: source.label,
263
+ targetLabel: target.label,
264
+ label: edge.label || 'Connection',
265
+ diagramId: group.diagramId,
266
+ sourceObjId: source.elementId,
267
+ targetObjId: target.elementId,
268
+ })
180
269
  }
181
270
 
182
- // ── Squiggly lines to portal nodes ──
183
271
  for (const node of group.nodes) {
184
- if (node.isPortal) {
185
- // Line from diagram bottom center to portal top center
186
- const x1 = group.worldX + group.diagramX + group.diagramW / 2
187
- const y1 = group.worldY + group.diagramY + group.diagramH
188
- const x2 = node.worldX + node.worldW / 2
189
- const y2 = node.worldY
190
-
191
- const midX = (x1 + x2) / 2
192
- const midY = (y1 + y2) / 2
193
-
194
- const dx = x2 - x1
195
- const dy = y2 - y1
272
+ if (!node.isPortal) continue
273
+ const x1 = group.worldX + group.diagramX + group.diagramW / 2
274
+ const y1 = group.worldY + group.diagramY + group.diagramH
275
+ const x2 = node.worldX + node.worldW / 2
276
+ const y2 = node.worldY
277
+ addEdgeToSpatialIndex(index, {
278
+ kind: 'portal',
279
+ x1,
280
+ y1,
281
+ x2,
282
+ y2,
283
+ midX: (x1 + x2) / 2,
284
+ midY: (y1 + y2) / 2,
285
+ sourceLabel: group.label,
286
+ targetLabel: node.label,
287
+ diagramId: group.diagramId,
288
+ targetDiagId: node.linkedDiagramId,
289
+ })
290
+ }
291
+ }
292
+
293
+ return index
294
+ }
295
+
296
+ function findHoveredEdge(
297
+ worldX: number,
298
+ worldY: number,
299
+ index: EdgeSpatialIndex,
300
+ view: ZUIViewState
301
+ ): HoveredItem | null {
302
+ const threshold = 18 / view.zoom // 18 screen pixels converted to world distance
303
+ const startX = Math.floor((worldX - threshold) / index.cellSize)
304
+ const endX = Math.floor((worldX + threshold) / index.cellSize)
305
+ const startY = Math.floor((worldY - threshold) / index.cellSize)
306
+ const endY = Math.floor((worldY + threshold) / index.cellSize)
307
+ const thresholdSquared = threshold * threshold
308
+ let bestEdge: IndexedEdge | null = null
309
+ let bestDistSquared = thresholdSquared
310
+
311
+ for (let cx = startX; cx <= endX; cx++) {
312
+ for (let cy = startY; cy <= endY; cy++) {
313
+ const bucket = index.cells.get(cellKey(cx, cy))
314
+ if (!bucket) continue
315
+
316
+ for (const edge of bucket) {
317
+ const dx = edge.x2 - edge.x1
318
+ const dy = edge.y2 - edge.y1
196
319
  const l2 = dx * dx + dy * dy
197
320
  if (l2 === 0) continue
198
321
 
199
- let t = ((worldX - x1) * dx + (worldY - y1) * dy) / l2
322
+ let t = ((worldX - edge.x1) * dx + (worldY - edge.y1) * dy) / l2
200
323
  t = Math.max(0, Math.min(1, t))
201
324
 
202
- const nearestX = x1 + t * dx
203
- const nearestY = y1 + t * dy
204
- const dist = Math.sqrt((worldX - nearestX) ** 2 + (worldY - nearestY) ** 2)
205
-
206
- if (dist < threshold) {
207
- return {
208
- type: 'edge',
209
- data: {
210
- sourceId: group.label,
211
- targetId: node.label,
212
- label: '',
213
- diagramId: group.diagramId,
214
- targetDiagId: node.linkedDiagramId,
215
- isPortalConn: true
216
- },
217
- absX: midX,
218
- absY: midY
219
- }
325
+ const nearestX = edge.x1 + t * dx
326
+ const nearestY = edge.y1 + t * dy
327
+ const distSquared = (worldX - nearestX) ** 2 + (worldY - nearestY) ** 2
328
+ if (distSquared < bestDistSquared) {
329
+ bestDistSquared = distSquared
330
+ bestEdge = edge
220
331
  }
221
332
  }
222
333
  }
223
334
  }
224
- return null
335
+
336
+ if (!bestEdge) return null
337
+ if (bestEdge.kind === 'portal') {
338
+ return {
339
+ type: 'edge',
340
+ data: {
341
+ sourceId: bestEdge.sourceLabel,
342
+ targetId: bestEdge.targetLabel,
343
+ label: '',
344
+ diagramId: bestEdge.diagramId,
345
+ targetDiagId: bestEdge.targetDiagId,
346
+ isPortalConn: true
347
+ },
348
+ absX: bestEdge.midX,
349
+ absY: bestEdge.midY
350
+ }
351
+ }
352
+
353
+ return {
354
+ type: 'edge',
355
+ data: {
356
+ sourceId: bestEdge.sourceLabel,
357
+ targetId: bestEdge.targetLabel,
358
+ label: bestEdge.label,
359
+ diagramId: bestEdge.diagramId,
360
+ sourceObjId: bestEdge.sourceObjId,
361
+ targetObjId: bestEdge.targetObjId
362
+ },
363
+ absX: bestEdge.midX,
364
+ absY: bestEdge.midY
365
+ }
225
366
  }
226
367
 
227
368
  export function calculateMaxZoom(groups: DiagramGroupLayout[], canvasW: number): number {
@@ -350,12 +491,20 @@ export function useZUIInteraction(
350
491
  // ── Refs for stable event handlers ──────────────────────────────
351
492
  const viewStateRef = useRef<ZUIViewState>(initialView)
352
493
  const groupsRef = useRef<DiagramGroupLayout[]>(groups)
494
+ const edgeSpatialIndexRef = useRef<EdgeSpatialIndex | null>(null)
495
+ if (edgeSpatialIndexRef.current === null) {
496
+ edgeSpatialIndexRef.current = buildEdgeSpatialIndex(groups)
497
+ }
353
498
  const bboxRef = useRef<BBox | undefined>(bbox)
354
499
  const onZoomRef = useRef(onZoom)
355
500
  const onPanRef = useRef(onPan)
356
501
 
357
502
  useEffect(() => {
358
503
  groupsRef.current = groups
504
+ edgeSpatialIndexRef.current = buildEdgeSpatialIndex(groups)
505
+ for (const group of groups) {
506
+ warmNodeSpatialIndexes(group.nodes)
507
+ }
359
508
  bboxRef.current = bbox
360
509
  onZoomRef.current = onZoom
361
510
  onPanRef.current = onPan
@@ -503,7 +652,8 @@ export function useZUIInteraction(
503
652
  function onMouseDown(e: MouseEvent) {
504
653
  if (e.button !== 0) return
505
654
  dragging.current = true
506
- lastMouse.current = { x: e.clientX, y: e.clientY }
655
+ lastMouse.current.x = e.clientX
656
+ lastMouse.current.y = e.clientY
507
657
  el!.style.cursor = 'grabbing'
508
658
  setHoveredItem(null, true) // Hide popover immediately while dragging
509
659
  }
@@ -518,7 +668,8 @@ export function useZUIInteraction(
518
668
  if (dragging.current) {
519
669
  const dx = e.clientX - lastMouse.current.x
520
670
  const dy = e.clientY - lastMouse.current.y
521
- lastMouse.current = { x: e.clientX, y: e.clientY }
671
+ lastMouse.current.x = e.clientX
672
+ lastMouse.current.y = e.clientY
522
673
  setViewState((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }))
523
674
  onPanRef.current?.()
524
675
  return
@@ -547,7 +698,7 @@ export function useZUIInteraction(
547
698
  setHoveredItem(proxyEdge)
548
699
  return
549
700
  }
550
- const edge = findHoveredEdge(worldX, worldY, groupsRef.current, view)
701
+ const edge = findHoveredEdge(worldX, worldY, edgeSpatialIndexRef.current!, view)
551
702
  if (edge) {
552
703
  setHoveredItem(edge)
553
704
  } else {
@@ -619,7 +770,8 @@ export function useZUIInteraction(
619
770
  e.preventDefault()
620
771
  if (e.touches.length === 1) {
621
772
  dragging.current = true
622
- lastMouse.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
773
+ lastMouse.current.x = e.touches[0].clientX
774
+ lastMouse.current.y = e.touches[0].clientY
623
775
  lastPinchDist.current = null
624
776
  } else if (e.touches.length >= 2) {
625
777
  dragging.current = false
@@ -635,7 +787,8 @@ export function useZUIInteraction(
635
787
  if (e.touches.length === 1 && dragging.current) {
636
788
  const dx = e.touches[0].clientX - lastMouse.current.x
637
789
  const dy = e.touches[0].clientY - lastMouse.current.y
638
- lastMouse.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
790
+ lastMouse.current.x = e.touches[0].clientX
791
+ lastMouse.current.y = e.touches[0].clientY
639
792
  setViewState((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }))
640
793
  onPanRef.current?.()
641
794
  } else if (e.touches.length >= 2) {
@@ -677,7 +830,8 @@ export function useZUIInteraction(
677
830
  } else if (e.touches.length === 1) {
678
831
  // Transition back to dragging with the single remaining finger
679
832
  dragging.current = true
680
- lastMouse.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
833
+ lastMouse.current.x = e.touches[0].clientX
834
+ lastMouse.current.y = e.touches[0].clientY
681
835
  lastPinchDist.current = null
682
836
  } else {
683
837
  // Still have multiple fingers, reset baseline to avoid jumps
@@ -28,6 +28,12 @@ export function apiUrl(path: string): string {
28
28
  return `${apiBase}${path.startsWith('/') ? path : `/${path}`}`
29
29
  }
30
30
 
31
+ export function fetchApiAsset(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
32
+ const headers = new Headers(init?.headers)
33
+ if (window.__TLD_API_KEY__) headers.set('Authorization', `Bearer ${window.__TLD_API_KEY__}`)
34
+ return fetch(input, { ...init, headers })
35
+ }
36
+
31
37
  export function oauthGoogleStartUrl(): string {
32
38
  return apiUrl('/auth/oauth/google')
33
39
  }
@@ -28,3 +28,7 @@ export const apiBase = trimTrailingSlash(
28
28
  export function apiUrl(path: string): string {
29
29
  return `${apiBase}${path.startsWith("/") ? path : `/${path}`}`
30
30
  }
31
+
32
+ export function fetchApiAsset(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
33
+ return fetch(input, init)
34
+ }
package/src/main.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { StrictMode } from "react"
2
2
  import { createRoot } from "react-dom/client"
3
3
  import { ChakraProvider } from "@chakra-ui/react"
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
4
5
  import { BrowserRouter } from "react-router-dom"
5
6
  import App from "./App"
6
7
  import theme from "./theme"
@@ -10,6 +11,15 @@ import { PlatformProvider } from "./platform/PlatformContext"
10
11
  import { platform as localPlatform } from "./platform/local"
11
12
  import "./index.css"
12
13
 
14
+ const queryClient = new QueryClient({
15
+ defaultOptions: {
16
+ queries: {
17
+ staleTime: 5_000,
18
+ refetchOnWindowFocus: false,
19
+ },
20
+ },
21
+ })
22
+
13
23
  if (typeof window !== "undefined") {
14
24
  document.addEventListener(
15
25
  "wheel",
@@ -28,19 +38,21 @@ if (typeof window !== "undefined") {
28
38
 
29
39
  createRoot(document.getElementById("root")!).render(
30
40
  <StrictMode>
31
- <ChakraProvider theme={theme}>
32
- <PlatformProvider platform={localPlatform}>
33
- <BrowserRouter
34
- basename={routerBasename}
35
- future={{
36
- v7_startTransition: false,
37
- v7_relativeSplatPath: true,
38
- }}
39
- >
40
- <App />
41
- </BrowserRouter>
42
- <ToastContainer />
43
- </PlatformProvider>
44
- </ChakraProvider>
41
+ <QueryClientProvider client={queryClient}>
42
+ <ChakraProvider theme={theme}>
43
+ <PlatformProvider platform={localPlatform}>
44
+ <BrowserRouter
45
+ basename={routerBasename}
46
+ future={{
47
+ v7_startTransition: false,
48
+ v7_relativeSplatPath: true,
49
+ }}
50
+ >
51
+ <App />
52
+ </BrowserRouter>
53
+ <ToastContainer />
54
+ </PlatformProvider>
55
+ </ChakraProvider>
56
+ </QueryClientProvider>
45
57
  </StrictMode>,
46
58
  )
@@ -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,5 +1,6 @@
1
1
  import { createContext, useContext } from 'react'
2
2
  import type { LibraryElement, Connector } from '../../types'
3
+ import { useStore } from '../../store/useStore'
3
4
 
4
5
  export interface ViewEditorContextValue {
5
6
  viewId: number | null
@@ -15,7 +16,17 @@ export interface ViewEditorContextValue {
15
16
  export const ViewEditorContext = createContext<ViewEditorContextValue | null>(null)
16
17
 
17
18
  export function useViewEditorContext(): ViewEditorContextValue {
18
- const ctx = useContext(ViewEditorContext)
19
- if (!ctx) throw new Error('useViewEditorContext must be used inside ViewEditor')
20
- return ctx
19
+ const context = useContext(ViewEditorContext)
20
+ const viewId = useStore((state) => state.viewId)
21
+ const canEdit = useStore((state) => state.canEdit)
22
+ const isOwner = useStore((state) => state.isOwner)
23
+ const isFreePlan = useStore((state) => state.isFreePlan)
24
+ const snapToGrid = useStore((state) => state.snapToGrid)
25
+ const setSnapToGrid = useStore((state) => state.setSnapToGrid)
26
+ const selectedElement = useStore((state) => state.selectedElement)
27
+ const selectedConnector = useStore((state) => state.selectedConnector)
28
+
29
+ if (context) return context
30
+
31
+ return { viewId, canEdit, isOwner, isFreePlan, snapToGrid, setSnapToGrid, selectedElement, selectedConnector }
21
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
+ })