@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.
- package/dist/api/client.d.ts +13 -1
- package/dist/components/ElementNode.d.ts +14 -1
- package/dist/components/ZUI/ZUICanvas.d.ts +1 -0
- package/dist/config/runtime-vscode.d.ts +1 -0
- package/dist/config/runtime.d.ts +1 -0
- package/dist/index.js +10875 -9550
- package/dist/pages/InfiniteZoom.d.ts +5 -2
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +10 -3
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.test.d.ts +1 -0
- package/dist/pages/ViewEditor/hooks/useViewData.d.ts +27 -24
- package/dist/pages/ViewsGrid.d.ts +9 -1
- package/dist/shims/empty-node-module.d.ts +2 -0
- package/dist/store/useStore.d.ts +80 -0
- package/dist/store/useStore.test.d.ts +1 -0
- package/package.json +10 -7
- package/src/api/client.ts +39 -1
- package/src/components/ElementNode.tsx +21 -59
- package/src/components/ElementPanel.tsx +2 -3
- package/src/components/LayoutSection.tsx +95 -104
- package/src/components/ViewGridNode.tsx +1 -4
- package/src/components/ZUI/ZUICanvas.tsx +138 -1
- package/src/components/ZUI/renderer.ts +166 -66
- package/src/components/ZUI/useZUIInteraction.ts +235 -81
- package/src/config/runtime-vscode.ts +6 -0
- package/src/config/runtime.ts +4 -0
- package/src/main.tsx +26 -14
- package/src/pages/InfiniteZoom.tsx +14 -5
- package/src/pages/ViewEditor/context.tsx +14 -3
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.test.ts +30 -0
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +294 -146
- package/src/pages/ViewEditor/hooks/useViewData.ts +459 -256
- package/src/pages/ViewEditor/index.tsx +67 -70
- package/src/pages/Views.tsx +552 -83
- package/src/pages/ViewsGrid.tsx +26 -337
- package/src/shims/empty-node-module.ts +1 -0
- package/src/store/useStore.test.ts +285 -0
- 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 = '
|
|
26
|
+
type Algorithm = 'dagre' | 'force'
|
|
25
27
|
|
|
26
|
-
interface
|
|
27
|
-
|
|
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
|
-
|
|
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>('
|
|
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 [
|
|
60
|
-
|
|
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 === '
|
|
203
|
-
positions = await
|
|
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
|
|
275
|
+
const runDagre = async (objs: any[], edgeList: any[]) => {
|
|
275
276
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
276
|
-
const
|
|
277
|
-
const
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
311
|
-
const id = Number(
|
|
306
|
+
graph.nodes().forEach((nodeId: string) => {
|
|
307
|
+
const id = Number(nodeId)
|
|
312
308
|
if (!Number.isFinite(id)) return
|
|
313
|
-
|
|
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 === '
|
|
431
|
+
{algo === 'dagre' ? (
|
|
432
432
|
<Grid templateColumns="1fr 1fr" gap={4}>
|
|
433
433
|
<FormControl gridColumn="span 2">
|
|
434
|
-
<FormLabel {...LabelStyle}>
|
|
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={
|
|
442
|
-
onChange={e =>
|
|
441
|
+
value={dagreConfig.direction}
|
|
442
|
+
onChange={e => setDagreConfig(c => ({ ...c, direction: e.target.value as DagreConfig['direction'] }))}
|
|
443
443
|
>
|
|
444
|
-
<option value="
|
|
445
|
-
<option value="
|
|
446
|
-
<option value="
|
|
447
|
-
<option value="
|
|
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={
|
|
456
|
+
value={dagreConfig.nodeSpacing}
|
|
477
457
|
min={10} max={400} step={10}
|
|
478
|
-
onChange={(_, v) => !isNaN(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
|
-
|
|
489
|
-
<
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
<
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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 ──────────────────────────────────────────────
|