@tldiagram/core-ui 1.93.0 → 1.94.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.
- package/dist/components/ElementNode.d.ts +5 -1
- package/dist/components/ZUI/ZUICanvas.d.ts +1 -0
- package/dist/index.js +9285 -9742
- package/dist/pages/InfiniteZoom.d.ts +5 -2
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +9 -3
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.test.d.ts +1 -0
- package/dist/pages/ViewEditor/hooks/useViewData.d.ts +7 -3
- package/dist/pages/ViewsGrid.d.ts +9 -1
- package/dist/store/useStore.d.ts +2 -0
- package/package.json +6 -5
- package/src/components/ElementNode.tsx +10 -1
- package/src/components/ElementPanel.tsx +1 -2
- package/src/components/LayoutSection.tsx +27 -11
- package/src/components/ZUI/ZUICanvas.tsx +138 -1
- package/src/pages/InfiniteZoom.tsx +14 -5
- package/src/pages/ViewEditor/context.tsx +4 -1
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.test.ts +30 -0
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +139 -42
- package/src/pages/ViewEditor/hooks/useViewData.ts +4 -3
- package/src/pages/ViewEditor/index.tsx +24 -40
- package/src/pages/Views.tsx +552 -83
- package/src/pages/ViewsGrid.tsx +26 -337
- package/src/store/useStore.test.ts +13 -0
- package/src/store/useStore.ts +42 -0
|
@@ -2,6 +2,9 @@ interface Props {
|
|
|
2
2
|
sharedToken?: string;
|
|
3
3
|
shareSlot?: React.ReactNode;
|
|
4
4
|
}
|
|
5
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -7,7 +7,7 @@ interface ViewDataOptions {
|
|
|
7
7
|
sourceHandle: string;
|
|
8
8
|
targetHandle?: string;
|
|
9
9
|
} | null;
|
|
10
|
-
|
|
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
|
|
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,
|
|
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(
|
|
13
|
+
export default function ViewsGrid(props: Props): import("react/jsx-runtime").JSX.Element;
|
|
6
14
|
export {};
|
package/dist/store/useStore.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.94.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@buf/tldiagramcom_diagram.bufbuild_es": "^2.11.0-20260419172603-8192c519478e.1",
|
|
40
40
|
"@bufbuild/protobuf": "^2.11.0",
|
|
41
|
-
"@tanstack/react-query": "^5.100.1",
|
|
42
41
|
"esbuild": "^0.25.12",
|
|
43
42
|
"zustand": "^5.0.12"
|
|
44
43
|
},
|
|
@@ -59,6 +58,7 @@
|
|
|
59
58
|
"@connectrpc/connect-web": "^2.1.1",
|
|
60
59
|
"@emotion/react": "^11.14.0",
|
|
61
60
|
"@emotion/styled": "^11.14.0",
|
|
61
|
+
"@tanstack/react-query": "^5.100.1",
|
|
62
62
|
"@uiw/react-codemirror": "^4.25.8",
|
|
63
63
|
"d3-force": "^3.0.0",
|
|
64
64
|
"dagre": "^0.8.5",
|
|
@@ -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.
|
|
70
|
+
"react-router-dom": "^6.30.3",
|
|
71
71
|
"reactflow": "^11.11.4",
|
|
72
72
|
"web-tree-sitter": "0.21.0"
|
|
73
73
|
},
|
|
@@ -126,6 +126,7 @@
|
|
|
126
126
|
"@connectrpc/connect-web": "^2.1.1",
|
|
127
127
|
"@emotion/react": "^11.14.0",
|
|
128
128
|
"@emotion/styled": "^11.14.0",
|
|
129
|
+
"@tanstack/react-query": "^5.100.1",
|
|
129
130
|
"@eslint/js": "^9.0.0",
|
|
130
131
|
"@types/dagre": "^0.7.54",
|
|
131
132
|
"@types/react": "^18.3.12",
|
|
@@ -143,11 +144,11 @@
|
|
|
143
144
|
"mermaid-ast": "^0.8.2",
|
|
144
145
|
"react": "^18.3.1",
|
|
145
146
|
"react-dom": "^18.3.1",
|
|
146
|
-
"react-router-dom": "^6.
|
|
147
|
+
"react-router-dom": "^6.30.3",
|
|
147
148
|
"reactflow": "^11.11.4",
|
|
148
149
|
"typescript": "^5.6.3",
|
|
149
150
|
"typescript-eslint": "^8.0.0",
|
|
150
|
-
"vite": "^6.
|
|
151
|
+
"vite": "^6.4.2",
|
|
151
152
|
"vite-tsconfig-paths": "^6.1.1",
|
|
152
153
|
"vitest": "^4.1.2",
|
|
153
154
|
"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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
+
})
|