@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
@@ -16,16 +16,17 @@ 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
- type Algorithm = 'elk' | 'force'
26
+ type Algorithm = 'dagre' | 'force'
25
27
 
26
- interface ElkConfig {
27
- algorithm: 'layered' | 'force' | 'mrtree' | 'box'
28
- direction: 'RIGHT' | 'LEFT' | 'DOWN' | 'UP'
28
+ interface DagreConfig {
29
+ direction: 'TB' | 'BT' | 'LR' | 'RL'
29
30
  nodeSpacing: number
30
31
  layerSpacing: number
31
32
  }
@@ -41,7 +42,7 @@ const NODE_W = 200
41
42
  const NODE_H = 120
42
43
 
43
44
  const ALGO_META: Record<Algorithm, { label: string }> = {
44
- elk: { label: 'Layered' },
45
+ dagre: { label: 'Layered' },
45
46
  force: { label: 'Organic' },
46
47
  }
47
48
 
@@ -52,13 +53,13 @@ interface Props {
52
53
 
53
54
  export default function LayoutSection({ view, canEdit }: Props) {
54
55
  const [open, setOpen] = useState(false)
55
- const [algo, setAlgo] = useState<Algorithm>('elk')
56
+ const [algo, setAlgo] = useState<Algorithm>('dagre')
56
57
  const [running, setRunning] = useState(false)
57
58
  const [collisionRunning, setCollisionRunning] = useState(false)
59
+ const adjustConnectorsConfirm = useDisclosure()
58
60
 
59
- const [elkConfig, setElkConfig] = useState<ElkConfig>({
60
- algorithm: 'layered',
61
- direction: 'DOWN',
61
+ const [dagreConfig, setDagreConfig] = useState<DagreConfig>({
62
+ direction: 'TB',
62
63
  nodeSpacing: 75,
63
64
  layerSpacing: 75,
64
65
  })
@@ -199,8 +200,8 @@ export default function LayoutSection({ view, canEdit }: Props) {
199
200
  ])
200
201
 
201
202
  let positions: Map<number, { x: number; y: number }>
202
- if (algo === 'elk') {
203
- positions = await runElk(objs, edgeList)
203
+ if (algo === 'dagre') {
204
+ positions = await runDagre(objs, edgeList)
204
205
  } else {
205
206
  positions = await runForce(objs, edgeList)
206
207
  }
@@ -271,46 +272,45 @@ export default function LayoutSection({ view, canEdit }: Props) {
271
272
  }
272
273
 
273
274
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
274
- const runElk = async (objs: any[], edgeList: any[]) => {
275
+ const runDagre = async (objs: any[], edgeList: any[]) => {
275
276
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
- const ELKModule = await import('elkjs/lib/elk.bundled.js') as any
277
- const elk = new ELKModule.default()
278
-
279
- const layoutOptions: Record<string, string> = {
280
- 'elk.algorithm': elkConfig.algorithm,
281
- 'elk.spacing.nodeNode': String(elkConfig.nodeSpacing),
282
- }
283
- if (elkConfig.algorithm === 'layered') {
284
- layoutOptions['elk.direction'] = elkConfig.direction
285
- layoutOptions['elk.layered.spacing.nodeNodeBetweenLayers'] = String(elkConfig.layerSpacing)
286
- }
277
+ const dagreModule = await import('dagre') as any
278
+ const dagre = dagreModule.default ?? dagreModule
287
279
 
288
280
  const objSet = new Set<number>(objs.map((o: { element_id?: number }) => Number(o.element_id)))
289
- const graph = {
290
- id: 'root',
291
- layoutOptions,
292
- children: objs.map((obj: { element_id: number }) => ({
293
- id: String(obj.element_id),
294
- width: NODE_W,
295
- height: NODE_H,
296
- })),
297
- edges: edgeList
298
- .filter((e: { source_element_id: number; target_element_id: number }) =>
299
- objSet.has(e.source_element_id) && objSet.has(e.target_element_id)
300
- )
301
- .map((e: { id: number; source_element_id: number; target_element_id: number }) => ({
302
- id: String(e.id),
303
- sources: [String(e.source_element_id)],
304
- targets: [String(e.target_element_id)],
305
- })),
306
- }
281
+ const graph = new dagre.graphlib.Graph({ multigraph: true })
282
+ graph.setGraph({
283
+ rankdir: dagreConfig.direction,
284
+ nodesep: dagreConfig.nodeSpacing,
285
+ ranksep: dagreConfig.layerSpacing,
286
+ marginx: 0,
287
+ marginy: 0,
288
+ })
289
+ graph.setDefaultEdgeLabel(() => ({}))
290
+
291
+ objs.forEach((obj: { element_id: number }) => {
292
+ graph.setNode(String(obj.element_id), { width: NODE_W, height: NODE_H })
293
+ })
294
+
295
+ edgeList
296
+ .filter((e: { source_element_id: number; target_element_id: number }) =>
297
+ objSet.has(e.source_element_id) && objSet.has(e.target_element_id)
298
+ )
299
+ .forEach((e: { id: number; source_element_id: number; target_element_id: number }) => {
300
+ graph.setEdge(String(e.source_element_id), String(e.target_element_id), {}, String(e.id))
301
+ })
302
+
303
+ dagre.layout(graph)
307
304
 
308
- const result = await elk.layout(graph)
309
305
  const positions = new Map<number, { x: number; y: number }>()
310
- result.children?.forEach((child: { id: string; x?: number; y?: number }) => {
311
- const id = Number(child.id)
306
+ graph.nodes().forEach((nodeId: string) => {
307
+ const id = Number(nodeId)
312
308
  if (!Number.isFinite(id)) return
313
- positions.set(id, { x: child.x ?? 0, y: child.y ?? 0 })
309
+ const node = graph.node(nodeId) as { x?: number; y?: number }
310
+ positions.set(id, {
311
+ x: (node.x ?? 0) - NODE_W / 2,
312
+ y: (node.y ?? 0) - NODE_H / 2,
313
+ })
314
314
  })
315
315
  return positions
316
316
  }
@@ -428,54 +428,34 @@ export default function LayoutSection({ view, canEdit }: Props) {
428
428
  border="1px solid"
429
429
  borderColor="whiteAlpha.100"
430
430
  >
431
- {algo === 'elk' ? (
431
+ {algo === 'dagre' ? (
432
432
  <Grid templateColumns="1fr 1fr" gap={4}>
433
433
  <FormControl gridColumn="span 2">
434
- <FormLabel {...LabelStyle}>Algorithm</FormLabel>
434
+ <FormLabel {...LabelStyle}>Direction</FormLabel>
435
435
  <Select
436
436
  size="xs"
437
437
  variant="filled"
438
438
  bg="whiteAlpha.100"
439
439
  border="none"
440
440
  _hover={{ bg: 'whiteAlpha.200' }}
441
- value={elkConfig.algorithm}
442
- onChange={e => setElkConfig(c => ({ ...c, algorithm: e.target.value as ElkConfig['algorithm'] }))}
441
+ value={dagreConfig.direction}
442
+ onChange={e => setDagreConfig(c => ({ ...c, direction: e.target.value as DagreConfig['direction'] }))}
443
443
  >
444
- <option value="layered">Layered</option>
445
- <option value="force">Force</option>
446
- <option value="mrtree">Mr. Tree</option>
447
- <option value="box">Box</option>
444
+ <option value="TB">Top → Bottom</option>
445
+ <option value="BT">Bottom → Top</option>
446
+ <option value="LR">Left → Right</option>
447
+ <option value="RL">Right → Left</option>
448
448
  </Select>
449
449
  </FormControl>
450
450
 
451
- {elkConfig.algorithm === 'layered' && (
452
- <FormControl gridColumn="span 2">
453
- <FormLabel {...LabelStyle}>Direction</FormLabel>
454
- <Select
455
- size="xs"
456
- variant="filled"
457
- bg="whiteAlpha.100"
458
- border="none"
459
- _hover={{ bg: 'whiteAlpha.200' }}
460
- value={elkConfig.direction}
461
- onChange={e => setElkConfig(c => ({ ...c, direction: e.target.value as ElkConfig['direction'] }))}
462
- >
463
- <option value="DOWN">Top → Bottom</option>
464
- <option value="UP">Bottom → Top</option>
465
- <option value="RIGHT">Left → Right</option>
466
- <option value="LEFT">Right → Left</option>
467
- </Select>
468
- </FormControl>
469
- )}
470
-
471
451
  <FormControl>
472
452
  <FormLabel {...LabelStyle}>Element Gap</FormLabel>
473
453
  <NumberInput
474
454
  size="xs"
475
455
  variant="filled"
476
- value={elkConfig.nodeSpacing}
456
+ value={dagreConfig.nodeSpacing}
477
457
  min={10} max={400} step={10}
478
- onChange={(_, v) => !isNaN(v) && setElkConfig(c => ({ ...c, nodeSpacing: v }))}
458
+ onChange={(_, v) => !isNaN(v) && setDagreConfig(c => ({ ...c, nodeSpacing: v }))}
479
459
  >
480
460
  <NumberInputField bg="whiteAlpha.100" border="none" />
481
461
  <NumberInputStepper>
@@ -485,24 +465,22 @@ export default function LayoutSection({ view, canEdit }: Props) {
485
465
  </NumberInput>
486
466
  </FormControl>
487
467
 
488
- {elkConfig.algorithm === 'layered' && (
489
- <FormControl>
490
- <FormLabel {...LabelStyle}>Layer Gap</FormLabel>
491
- <NumberInput
492
- size="xs"
493
- variant="filled"
494
- value={elkConfig.layerSpacing}
495
- min={10} max={400} step={10}
496
- onChange={(_, v) => !isNaN(v) && setElkConfig(c => ({ ...c, layerSpacing: v }))}
497
- >
498
- <NumberInputField bg="whiteAlpha.100" border="none" />
499
- <NumberInputStepper>
500
- <NumberIncrementStepper border="none" />
501
- <NumberDecrementStepper border="none" />
502
- </NumberInputStepper>
503
- </NumberInput>
504
- </FormControl>
505
- )}
468
+ <FormControl>
469
+ <FormLabel {...LabelStyle}>Layer Gap</FormLabel>
470
+ <NumberInput
471
+ size="xs"
472
+ variant="filled"
473
+ value={dagreConfig.layerSpacing}
474
+ min={10} max={400} step={10}
475
+ onChange={(_, v) => !isNaN(v) && setDagreConfig(c => ({ ...c, layerSpacing: v }))}
476
+ >
477
+ <NumberInputField bg="whiteAlpha.100" border="none" />
478
+ <NumberInputStepper>
479
+ <NumberIncrementStepper border="none" />
480
+ <NumberDecrementStepper border="none" />
481
+ </NumberInputStepper>
482
+ </NumberInput>
483
+ </FormControl>
506
484
  </Grid>
507
485
  ) : (
508
486
  <Grid templateColumns="1fr 1fr" gap={4}>
@@ -595,17 +573,17 @@ export default function LayoutSection({ view, canEdit }: Props) {
595
573
  </Button>
596
574
  {/* Apply button */}
597
575
  <VStack spacing={2} w="full">
598
- <Button
599
- size="sm"
600
- w="full"
601
- variant="outline"
602
- colorScheme="blue"
603
- onClick={handleCollisionRemoval}
604
- isLoading={collisionRunning}
605
- isDisabled={!canEdit || !view}
606
- loadingText="Removing Connector Collisions..."
607
- fontWeight="bold"
608
- 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"
609
587
  letterSpacing="0.05em"
610
588
  textTransform="uppercase"
611
589
  h="32px"
@@ -619,6 +597,19 @@ export default function LayoutSection({ view, canEdit }: Props) {
619
597
 
620
598
  </VStack>
621
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
+ />
622
613
  </Box>
623
614
  )
624
615
  }
@@ -137,7 +137,7 @@ export default function ViewGridNode({ data }: { data: ViewGridNodeData }) {
137
137
 
138
138
  return () => {
139
139
  active = false
140
- if (url) {
140
+ if (url?.startsWith('blob:')) {
141
141
  URL.revokeObjectURL(url)
142
142
  }
143
143
  }
@@ -224,9 +224,6 @@ export default function ViewGridNode({ data }: { data: ViewGridNodeData }) {
224
224
  display="block"
225
225
  p={2}
226
226
  bg="var(--bg-card-solid)"
227
- style={{
228
- filter: 'brightness(0) saturate(100%) invert(35%) sepia(26%) forum-blue(82%) hue-rotate(180deg) brightness(95%) contrast(90%)',
229
- }}
230
227
  />
231
228
  ) : (
232
229
  <Flex
@@ -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 ──────────────────────────────────────────────