@visualli/react 0.1.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/README.md +335 -0
- package/dist/index.cjs +3262 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +375 -0
- package/dist/index.d.ts +375 -0
- package/dist/index.js +3289 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/VisualliCanvas.tsx +1087 -0
- package/src/VisualliRenderer.tsx +427 -0
- package/src/animations/index.ts +2 -0
- package/src/animations/konvaLayerTransition.ts +223 -0
- package/src/components/KonvaContainer.tsx +255 -0
- package/src/components/KonvaContainerLayer.tsx +63 -0
- package/src/components/KonvaEdge.tsx +254 -0
- package/src/components/KonvaEdgeLayer.tsx +72 -0
- package/src/components/KonvaNode.tsx +177 -0
- package/src/components/KonvaNodeLayer.tsx +61 -0
- package/src/components/KonvaStage.tsx +80 -0
- package/src/components/NavigationStack.tsx +184 -0
- package/src/components/SketchyBoxKonva.tsx +121 -0
- package/src/components/ZoomControls.tsx +110 -0
- package/src/config/blobShapes.ts +204 -0
- package/src/config/index.ts +21 -0
- package/src/config/textScaling.ts +69 -0
- package/src/context/VisualliContext.tsx +50 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/useKonvaLayerTransition.ts +221 -0
- package/src/hooks/useKonvaRenderer.ts +185 -0
- package/src/hooks/useViewportNodes.ts +59 -0
- package/src/index.ts +81 -0
- package/src/konvaCompat.ts +29 -0
- package/src/stores/index.ts +3 -0
- package/src/stores/stores.ts +17 -0
- package/src/stores/useNodeStore.ts +101 -0
- package/src/stores/useViewportStore.ts +95 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/layerNavigation.ts +15 -0
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
// ─── VisualliCanvas ───────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Main canvas component for rendering a VisualliDocument.
|
|
4
|
+
// Matches the visualli.ai enhanced canvas implementation:
|
|
5
|
+
// • Stage-level hit detection via RBush spatial index (no Konva hit-canvas)
|
|
6
|
+
// • Node description tooltip (sketchy-box style)
|
|
7
|
+
// • Chromatic immersion background (color from current node)
|
|
8
|
+
// • Auto-zoom navigation (zoom in → child layer, zoom out → parent layer)
|
|
9
|
+
// • Professional layer transitions (zoom in / zoom out animations)
|
|
10
|
+
// • Vertical left-side navigation stack
|
|
11
|
+
// • Zoom controls (icon-based, top-right)
|
|
12
|
+
// • Context menu (right-click to go back)
|
|
13
|
+
//
|
|
14
|
+
// NOT included (SDK-only, no generation UI):
|
|
15
|
+
// • Sidebar, search, generation
|
|
16
|
+
// • Semantic anchor tooltips (special word meanings)
|
|
17
|
+
// • Branding logo
|
|
18
|
+
// • Help overlay / view-source panel
|
|
19
|
+
|
|
20
|
+
import React, { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
|
|
21
|
+
import type Konva from 'konva';
|
|
22
|
+
import type { VisualliDocument, VisualliLayer, FlatNode, MindMapConnection } from '@visualli/core';
|
|
23
|
+
import {
|
|
24
|
+
parseVisualliFile,
|
|
25
|
+
getNodesForLayer,
|
|
26
|
+
getThemeBackground,
|
|
27
|
+
darkenHexColor,
|
|
28
|
+
RBushSpatialIndex,
|
|
29
|
+
NODE_HEIGHT,
|
|
30
|
+
TEXT_LABEL_HIDE_BELOW_ZOOM,
|
|
31
|
+
ZOOM_NAV_IN_THRESHOLD,
|
|
32
|
+
ZOOM_NAV_OUT_THRESHOLD,
|
|
33
|
+
ZOOM_MIN,
|
|
34
|
+
} from '@visualli/core';
|
|
35
|
+
|
|
36
|
+
import { useNodeStore } from './stores/useNodeStore';
|
|
37
|
+
import { useViewportStore } from './stores/useViewportStore';
|
|
38
|
+
import { useSelectionStore } from './stores/stores';
|
|
39
|
+
|
|
40
|
+
import { useKonvaRenderer } from './hooks/useKonvaRenderer';
|
|
41
|
+
import { useKonvaLayerTransition } from './hooks/useKonvaLayerTransition';
|
|
42
|
+
|
|
43
|
+
import KonvaStage from './components/KonvaStage';
|
|
44
|
+
import KonvaNodeLayer from './components/KonvaNodeLayer';
|
|
45
|
+
import KonvaEdgeLayer from './components/KonvaEdgeLayer';
|
|
46
|
+
import KonvaContainerLayer from './components/KonvaContainerLayer';
|
|
47
|
+
import NavigationStack, { type NavStackEntry } from './components/NavigationStack';
|
|
48
|
+
import ZoomControls from './components/ZoomControls';
|
|
49
|
+
import SketchyBoxKonva from './components/SketchyBoxKonva';
|
|
50
|
+
|
|
51
|
+
import { getChildLayerForNode, calculateFitView, getConnectionsForLayer, getContainersForLayer } from './utils/layerNavigation';
|
|
52
|
+
import type { ContainerGroup } from './components/KonvaContainerLayer';
|
|
53
|
+
import type { AnimatorViewport } from './animations/konvaLayerTransition';
|
|
54
|
+
import { DESCRIPTION_TEXT_BASE_FONT_PX } from './config/textScaling';
|
|
55
|
+
import { useVisualli } from './context/VisualliContext';
|
|
56
|
+
|
|
57
|
+
// Helper to convert hex color to rgba with transparency
|
|
58
|
+
function hexToRgba(hex: string, alpha: number): string {
|
|
59
|
+
// Remove # if present
|
|
60
|
+
hex = hex.replace(/^#/, '');
|
|
61
|
+
|
|
62
|
+
// Parse hex values
|
|
63
|
+
let r: number, g: number, b: number;
|
|
64
|
+
if (hex.length === 3) {
|
|
65
|
+
r = parseInt(hex[0] + hex[0], 16);
|
|
66
|
+
g = parseInt(hex[1] + hex[1], 16);
|
|
67
|
+
b = parseInt(hex[2] + hex[2], 16);
|
|
68
|
+
} else if (hex.length === 6) {
|
|
69
|
+
r = parseInt(hex.substring(0, 2), 16);
|
|
70
|
+
g = parseInt(hex.substring(2, 4), 16);
|
|
71
|
+
b = parseInt(hex.substring(4, 6), 16);
|
|
72
|
+
} else {
|
|
73
|
+
return `rgba(240, 237, 230, ${alpha})`; // fallback
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function resolveDocument(props: VisualliCanvasProps, fileText?: string): VisualliDocument | null {
|
|
82
|
+
if (props.preParsedVisualli) return props.preParsedVisualli;
|
|
83
|
+
const rawContent = fileText ?? props.visualliString;
|
|
84
|
+
if (rawContent) {
|
|
85
|
+
try { return parseVisualliFile(rawContent); } catch { return null; }
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function layerLabel(doc: VisualliDocument, layer: VisualliLayer, layerId: string): string {
|
|
91
|
+
if (layer.parentNodeId) {
|
|
92
|
+
// Find the parent node in the parent layer to get its title
|
|
93
|
+
const parentLayer = layer.parentLayerId ? doc.layers.get(layer.parentLayerId) : null;
|
|
94
|
+
if (parentLayer) {
|
|
95
|
+
const parentNode = parentLayer.nodes.find(n => n.id === layer.parentNodeId);
|
|
96
|
+
if (parentNode) {
|
|
97
|
+
return Array.isArray(parentNode.data.label)
|
|
98
|
+
? (parentNode.data.label as string[]).join(' ')
|
|
99
|
+
: (parentNode.data.label || 'Untitled');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return layer.description ?? layerId;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getRootLayerId(doc: VisualliDocument): string | null {
|
|
107
|
+
for (const [id, layer] of doc.layers) {
|
|
108
|
+
if (!layer.parentLayerId) return id;
|
|
109
|
+
}
|
|
110
|
+
return doc.layers.keys().next().value ?? null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
export interface VisualliCanvasProps {
|
|
116
|
+
preParsedVisualli?: VisualliDocument;
|
|
117
|
+
/**
|
|
118
|
+
* Raw JSONL string — just pass the string data directly.
|
|
119
|
+
* No preprocessing needed by user.
|
|
120
|
+
*/
|
|
121
|
+
visualliString?: string;
|
|
122
|
+
/**
|
|
123
|
+
* A .visualli file — can be:
|
|
124
|
+
* - File object (from file input)
|
|
125
|
+
* - String path to file (e.g. '/data/file.visualli')
|
|
126
|
+
* Component handles all fetching and parsing automatically.
|
|
127
|
+
*/
|
|
128
|
+
visualliFile?: File | string;
|
|
129
|
+
isDark?: boolean;
|
|
130
|
+
chromaticImmersion?: boolean;
|
|
131
|
+
onNodeClick?: (node: FlatNode) => void;
|
|
132
|
+
onLayerChange?: (layerId: string, layer: VisualliLayer) => void;
|
|
133
|
+
|
|
134
|
+
// Extension points for private features (implement in consuming app)
|
|
135
|
+
renderOverlay?: (params: { isDark: boolean; containerWidth: number; containerHeight: number }) => React.ReactNode;
|
|
136
|
+
renderNodeContent?: (params: {
|
|
137
|
+
summary: string;
|
|
138
|
+
nodeId: string;
|
|
139
|
+
nodeColor?: string;
|
|
140
|
+
zoom: number;
|
|
141
|
+
}) => React.ReactNode;
|
|
142
|
+
navigationStackTop?: string;
|
|
143
|
+
navigationStackLeft?: string;
|
|
144
|
+
|
|
145
|
+
className?: string;
|
|
146
|
+
style?: React.CSSProperties;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
152
|
+
const { isDark = false, chromaticImmersion = false, onNodeClick, onLayerChange, renderOverlay, renderNodeContent, navigationStackTop = '16px', navigationStackLeft = '16px', className = '', style } = props;
|
|
153
|
+
|
|
154
|
+
// Read file if provided (handles both File objects and string paths)
|
|
155
|
+
const [fileText, setFileText] = useState<string | undefined>(undefined);
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (!props.visualliFile) { setFileText(undefined); return; }
|
|
158
|
+
let cancelled = false;
|
|
159
|
+
|
|
160
|
+
// Handle string path - fetch the file
|
|
161
|
+
if (typeof props.visualliFile === 'string') {
|
|
162
|
+
fetch(props.visualliFile)
|
|
163
|
+
.then(response => {
|
|
164
|
+
if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
|
|
165
|
+
return response.text();
|
|
166
|
+
})
|
|
167
|
+
.then(t => {
|
|
168
|
+
if (!cancelled) setFileText(t);
|
|
169
|
+
})
|
|
170
|
+
.catch(() => {
|
|
171
|
+
if (!cancelled) setFileText(undefined);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// Handle File object - read as text
|
|
175
|
+
else {
|
|
176
|
+
props.visualliFile.text().then(t => {
|
|
177
|
+
if (!cancelled) setFileText(t);
|
|
178
|
+
}).catch(() => {
|
|
179
|
+
if (!cancelled) setFileText(undefined);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return () => { cancelled = true; };
|
|
184
|
+
}, [props.visualliFile]);
|
|
185
|
+
|
|
186
|
+
const doc = useMemo(() => resolveDocument(props, fileText), [props.preParsedVisualli, props.visualliString, props.visualliFile, fileText]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
187
|
+
|
|
188
|
+
// ── Navigation ────────────────────────────────────────────────────────────
|
|
189
|
+
const [navStack, setNavStack] = useState<NavStackEntry[]>([]);
|
|
190
|
+
const [currentLayerId, setCurrentLayerId] = useState<string | null>(null);
|
|
191
|
+
const parentViewports = useRef<AnimatorViewport[]>([]);
|
|
192
|
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
193
|
+
const isTransitioningRef = useRef(false);
|
|
194
|
+
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!doc) return;
|
|
197
|
+
const rootId = getRootLayerId(doc);
|
|
198
|
+
if (!rootId) return;
|
|
199
|
+
const rootLayer = doc.layers.get(rootId)!;
|
|
200
|
+
setCurrentLayerId(rootId);
|
|
201
|
+
setNavStack([{ layerId: rootId, layer: rootLayer, label: 'Home' }]);
|
|
202
|
+
parentViewports.current = [];
|
|
203
|
+
}, [doc]);
|
|
204
|
+
|
|
205
|
+
// ── Active layer → FlatNodes ──────────────────────────────────────────────
|
|
206
|
+
const { flatNodes, connections, containers } = useMemo(() => {
|
|
207
|
+
if (!doc || !currentLayerId) return { flatNodes: [] as FlatNode[], connections: [] as MindMapConnection[], containers: [] as ContainerGroup[] };
|
|
208
|
+
try {
|
|
209
|
+
return {
|
|
210
|
+
flatNodes: getNodesForLayer(doc, currentLayerId),
|
|
211
|
+
connections: getConnectionsForLayer(doc, currentLayerId),
|
|
212
|
+
containers: getContainersForLayer(doc, currentLayerId) as ContainerGroup[],
|
|
213
|
+
};
|
|
214
|
+
} catch { return { flatNodes: [] as FlatNode[], connections: [] as MindMapConnection[], containers: [] as ContainerGroup[] }; }
|
|
215
|
+
}, [doc, currentLayerId]);
|
|
216
|
+
|
|
217
|
+
// ── Stores ────────────────────────────────────────────────────────────────
|
|
218
|
+
const setNodes = useNodeStore(s => s.setNodes);
|
|
219
|
+
const nodes = useNodeStore(s => s.nodes);
|
|
220
|
+
const updateNodePosition = useNodeStore(s => s.updateNodePosition);
|
|
221
|
+
const setCenter = useViewportStore(s => s.setCenter);
|
|
222
|
+
const setZoom = useViewportStore(s => s.setZoom);
|
|
223
|
+
const viewport = useViewportStore(s => ({
|
|
224
|
+
centerX: s.centerX, centerY: s.centerY, zoomLevel: s.zoomLevel,
|
|
225
|
+
canvasWidth: s.canvasWidth, canvasHeight: s.canvasHeight,
|
|
226
|
+
}));
|
|
227
|
+
const clearSel = useSelectionStore(s => s.clear);
|
|
228
|
+
const select = useSelectionStore(s => s.select);
|
|
229
|
+
|
|
230
|
+
// Sync nodes → store
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
const map = new Map(flatNodes.map(n => [n.id, n]));
|
|
233
|
+
setNodes(map);
|
|
234
|
+
clearSel();
|
|
235
|
+
}, [flatNodes, setNodes, clearSel]);
|
|
236
|
+
|
|
237
|
+
// ── Canvas refs ───────────────────────────────────────────────────────────
|
|
238
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
239
|
+
const stageRef = useRef<Konva.Stage | null>(null);
|
|
240
|
+
const canvasWrapperRef = useRef<HTMLDivElement>(null);
|
|
241
|
+
|
|
242
|
+
// ── GPU CSS-transform transition refs (matches visualli.ai) ───────────────
|
|
243
|
+
// During animations we express viewport deltas as CSS transforms on Konva's
|
|
244
|
+
// container div — zero canvas redraws, handled entirely by the GPU compositor.
|
|
245
|
+
const baselineTransformRef = useRef<{ x: number; y: number; scaleX: number; scaleY: number } | null>(null);
|
|
246
|
+
const konvaContentDivRef = useRef<HTMLDivElement | null>(null);
|
|
247
|
+
const lastAnimViewportRef = useRef<{ centerX: number; centerY: number; zoomLevel: number } | null>(null);
|
|
248
|
+
const zoomOutTargetRef = useRef<{ centerX: number; centerY: number; zoomLevel: number } | null>(null);
|
|
249
|
+
// canvasSizeRef is kept in sync with the measured container size (not window).
|
|
250
|
+
// Initialise to 0 — it will be updated synchronously before the first frame.
|
|
251
|
+
const canvasSizeRef = useRef({ width: 0, height: 0 });
|
|
252
|
+
|
|
253
|
+
// ── Node drag refs (RAF-throttled, matches visualli.ai) ───────────────────
|
|
254
|
+
const dragMoveRafRef = useRef<number | null>(null);
|
|
255
|
+
const dragMoveLatestRef = useRef<{ nodeId: string; x: number; y: number } | null>(null);
|
|
256
|
+
const dragStartPosRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
|
257
|
+
|
|
258
|
+
// ── Background color (with chromatic immersion support) ──────────────────
|
|
259
|
+
const baseBgColor = isDark ? '#141412' : '#F0EDE6';
|
|
260
|
+
|
|
261
|
+
// For chromatic immersion: get the parent node color for child layers
|
|
262
|
+
const chromaticBgColor = useMemo(() => {
|
|
263
|
+
if (!chromaticImmersion || !doc || !currentLayerId) return baseBgColor;
|
|
264
|
+
|
|
265
|
+
const currentLayer = doc.layers.get(currentLayerId);
|
|
266
|
+
if (!currentLayer) return baseBgColor;
|
|
267
|
+
|
|
268
|
+
// Root layer always uses base background
|
|
269
|
+
if (currentLayer.level === 0 || !currentLayer.parentNodeId) return baseBgColor;
|
|
270
|
+
|
|
271
|
+
// For child layers, find the parent node's color
|
|
272
|
+
const parentLayer = currentLayer.parentLayerId ? doc.layers.get(currentLayer.parentLayerId) : null;
|
|
273
|
+
if (parentLayer) {
|
|
274
|
+
const parentNode = parentLayer.nodes.find(n => n.id === currentLayer.parentNodeId);
|
|
275
|
+
if (parentNode) {
|
|
276
|
+
const nodeColor = parentNode.data.color || baseBgColor;
|
|
277
|
+
// Add transparency to the parent node color for subtle effect
|
|
278
|
+
return hexToRgba(nodeColor, 0.15);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return baseBgColor;
|
|
283
|
+
}, [chromaticImmersion, doc, currentLayerId, baseBgColor]);
|
|
284
|
+
|
|
285
|
+
const bgColor = chromaticImmersion ? chromaticBgColor : baseBgColor;
|
|
286
|
+
|
|
287
|
+
// ── Spatial index (for stage-level hit detection) ─────────────────────────
|
|
288
|
+
const spatialIndexRef = useRef<RBushSpatialIndex | null>(null);
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
if (flatNodes.length === 0) { spatialIndexRef.current?.clear(); return; }
|
|
291
|
+
const idx = new RBushSpatialIndex();
|
|
292
|
+
idx.bulkLoad(flatNodes.map(n => {
|
|
293
|
+
const blobHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (n.width / 2) * 0.74);
|
|
294
|
+
return {
|
|
295
|
+
nodeId: n.id,
|
|
296
|
+
bounds: {
|
|
297
|
+
minX: n.x - n.width / 2, minY: n.y - blobHalfH,
|
|
298
|
+
maxX: n.x + n.width / 2, maxY: n.y + blobHalfH,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}));
|
|
302
|
+
spatialIndexRef.current = idx;
|
|
303
|
+
return () => { spatialIndexRef.current?.clear(); };
|
|
304
|
+
}, [flatNodes]);
|
|
305
|
+
|
|
306
|
+
// ── Konva renderer (resize + FPS) ─────────────────────────────────────────
|
|
307
|
+
const {
|
|
308
|
+
handleWheel: rendererHandleWheel,
|
|
309
|
+
handleMouseDown: rendererHandleMouseDown,
|
|
310
|
+
handleMouseMove: rendererHandleMouseMove,
|
|
311
|
+
handleMouseUp: rendererHandleMouseUp,
|
|
312
|
+
canvasWidth,
|
|
313
|
+
canvasHeight,
|
|
314
|
+
} = useKonvaRenderer({ containerRef, stageRef });
|
|
315
|
+
|
|
316
|
+
// Keep canvasSizeRef in sync with the measured container size.
|
|
317
|
+
// The fallback to containerRef.getBoundingClientRect covers the period before
|
|
318
|
+
// the first ResizeObserver callback fires.
|
|
319
|
+
const _cw = canvasWidth || containerRef.current?.getBoundingClientRect().width || 800;
|
|
320
|
+
const _ch = canvasHeight || containerRef.current?.getBoundingClientRect().height || 600;
|
|
321
|
+
canvasSizeRef.current = { width: _cw, height: _ch };
|
|
322
|
+
|
|
323
|
+
// ── Individual node drag (RAF-throttled, matches visualli.ai) ────────────
|
|
324
|
+
const handleDragStart = useCallback((nodeId: string) => {
|
|
325
|
+
isDraggingRef.current = true;
|
|
326
|
+
setIsDraggingState(true);
|
|
327
|
+
setHoveredNode(null);
|
|
328
|
+
const node = nodes.get(nodeId);
|
|
329
|
+
if (node) dragStartPosRef.current.set(nodeId, { x: node.x, y: node.y });
|
|
330
|
+
}, [nodes]);
|
|
331
|
+
|
|
332
|
+
const handleDragMove = useCallback((nodeId: string, x: number, y: number) => {
|
|
333
|
+
dragMoveLatestRef.current = { nodeId, x, y };
|
|
334
|
+
if (dragMoveRafRef.current !== null) return;
|
|
335
|
+
dragMoveRafRef.current = requestAnimationFrame(() => {
|
|
336
|
+
dragMoveRafRef.current = null;
|
|
337
|
+
const latest = dragMoveLatestRef.current;
|
|
338
|
+
if (latest) { dragMoveLatestRef.current = null; updateNodePosition(latest.nodeId, latest.x, latest.y); }
|
|
339
|
+
});
|
|
340
|
+
}, [updateNodePosition]);
|
|
341
|
+
|
|
342
|
+
const handleDragEnd = useCallback((nodeId: string, x: number, y: number) => {
|
|
343
|
+
if (dragMoveRafRef.current !== null) { cancelAnimationFrame(dragMoveRafRef.current); dragMoveRafRef.current = null; }
|
|
344
|
+
dragMoveLatestRef.current = null;
|
|
345
|
+
isDraggingRef.current = false;
|
|
346
|
+
setIsDraggingState(false);
|
|
347
|
+
updateNodePosition(nodeId, x, y);
|
|
348
|
+
// Update spatial index entry
|
|
349
|
+
if (spatialIndexRef.current) {
|
|
350
|
+
const node = nodes.get(nodeId);
|
|
351
|
+
if (node) {
|
|
352
|
+
const oldPos = dragStartPosRef.current.get(nodeId);
|
|
353
|
+
if (oldPos) {
|
|
354
|
+
const oldHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (node.width / 2) * 0.74);
|
|
355
|
+
spatialIndexRef.current.remove(nodeId, {
|
|
356
|
+
minX: oldPos.x - node.width / 2, minY: oldPos.y - oldHalfH,
|
|
357
|
+
maxX: oldPos.x + node.width / 2, maxY: oldPos.y + oldHalfH,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const newHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (node.width / 2) * 0.74);
|
|
361
|
+
spatialIndexRef.current.insert(nodeId, {
|
|
362
|
+
minX: x - node.width / 2, minY: y - newHalfH,
|
|
363
|
+
maxX: x + node.width / 2, maxY: y + newHalfH,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
dragStartPosRef.current.delete(nodeId);
|
|
368
|
+
}, [updateNodePosition, nodes]);
|
|
369
|
+
// Keep a ref to the latest fitToScreen so async callbacks (onComplete) always
|
|
370
|
+
// call the most current version without re-creating the animation pipeline.
|
|
371
|
+
const fitToScreenRef = useRef<() => void>(() => {});
|
|
372
|
+
|
|
373
|
+
const fitToScreen = useCallback(() => {
|
|
374
|
+
if (flatNodes.length === 0) return;
|
|
375
|
+
if (isTransitioningRef.current) return; // animation is controlling viewport
|
|
376
|
+
// Use the container's measured size (not window) so the fit is always
|
|
377
|
+
// relative to the renderer's actual bounding box.
|
|
378
|
+
const cw = canvasSizeRef.current.width || containerRef.current?.getBoundingClientRect().width || 800;
|
|
379
|
+
const ch = canvasSizeRef.current.height || containerRef.current?.getBoundingClientRect().height || 600;
|
|
380
|
+
const { centerX, centerY } = calculateFitView(flatNodes, cw, ch);
|
|
381
|
+
// Root layer: always use 1.0 zoom to match visualli.ai reference behaviour.
|
|
382
|
+
// calculateFitZoom for a single node or dense layout on a 1920-default canvas
|
|
383
|
+
// produces zoom > 2 which triggers the auto-zoom-in threshold immediately.
|
|
384
|
+
const isRootLayer = navStack.length <= 1;
|
|
385
|
+
// Root layer: use 1.0 zoom to match visualli.ai reference behaviour.
|
|
386
|
+
// Child layers use the bounding-box fit zoom (already clamped to [0.4, 5]).
|
|
387
|
+
const zoomLevel = isRootLayer ? 1.0 : calculateFitView(flatNodes, cw, ch).zoomLevel;
|
|
388
|
+
setCenter(centerX, centerY);
|
|
389
|
+
setZoom(zoomLevel);
|
|
390
|
+
}, [flatNodes, navStack.length, setCenter, setZoom]);
|
|
391
|
+
|
|
392
|
+
// Keep ref in sync after every render so async callbacks always use latest closure
|
|
393
|
+
fitToScreenRef.current = fitToScreen;
|
|
394
|
+
|
|
395
|
+
useEffect(() => { fitToScreen(); }, [currentLayerId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
396
|
+
|
|
397
|
+
// Re-fit when the canvas is properly measured for the first time (the RAF in
|
|
398
|
+
// useKonvaRenderer updates canvasWidth from 0 → container width asynchronously).
|
|
399
|
+
const didFitAfterMeasureRef = useRef(false);
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
if (!canvasWidth || didFitAfterMeasureRef.current) return;
|
|
402
|
+
didFitAfterMeasureRef.current = true;
|
|
403
|
+
fitToScreen();
|
|
404
|
+
}, [canvasWidth, fitToScreen]);
|
|
405
|
+
|
|
406
|
+
// ── Layer transitions ─────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
// Stores the target nav-back index so onSwapBack can read it
|
|
409
|
+
const navBackTargetRef = useRef<number>(-1);
|
|
410
|
+
|
|
411
|
+
const { zoomIntoLayer, zoomOutToParent, isTransitioning: isAnimating } = useKonvaLayerTransition({
|
|
412
|
+
stageRef,
|
|
413
|
+
canvasWrapperRef,
|
|
414
|
+
onSwapLayer: (childLayerId) => {
|
|
415
|
+
const childLayer = doc?.layers.get(childLayerId);
|
|
416
|
+
if (!childLayer || !doc) return;
|
|
417
|
+
setCurrentLayerId(childLayerId);
|
|
418
|
+
setNavStack(prev => [...prev, { layerId: childLayerId, layer: childLayer, label: layerLabel(doc, childLayer, childLayerId) }]);
|
|
419
|
+
onLayerChange?.(childLayerId, childLayer);
|
|
420
|
+
},
|
|
421
|
+
onSwapBack: () => {
|
|
422
|
+
const targetIndex = navBackTargetRef.current;
|
|
423
|
+
setNavStack(prev => {
|
|
424
|
+
const newStack = prev.slice(0, targetIndex + 1);
|
|
425
|
+
parentViewports.current = parentViewports.current.slice(0, targetIndex);
|
|
426
|
+
const newLayerId = newStack[newStack.length - 1].layerId;
|
|
427
|
+
setCurrentLayerId(newLayerId);
|
|
428
|
+
onLayerChange?.(newLayerId, newStack[newStack.length - 1].layer);
|
|
429
|
+
return newStack;
|
|
430
|
+
});
|
|
431
|
+
},
|
|
432
|
+
onComplete: () => {
|
|
433
|
+
setIsTransitioning(false);
|
|
434
|
+
isTransitioningRef.current = false;
|
|
435
|
+
// After every layer switch, snap to the fit view so zoom is always clean.
|
|
436
|
+
// rAF ensures React has committed new node data before we compute the fit.
|
|
437
|
+
requestAnimationFrame(() => { fitToScreenRef.current(); });
|
|
438
|
+
},
|
|
439
|
+
// GPU CSS-transform callbacks (matching visualli.ai exactly):
|
|
440
|
+
onViewportFrame: (vp) => {
|
|
441
|
+
lastAnimViewportRef.current = vp;
|
|
442
|
+
const baseline = baselineTransformRef.current;
|
|
443
|
+
const contentDiv = konvaContentDivRef.current;
|
|
444
|
+
const { width, height } = canvasSizeRef.current;
|
|
445
|
+
if (baseline && contentDiv) {
|
|
446
|
+
const targetX = width / 2 - vp.centerX * vp.zoomLevel;
|
|
447
|
+
const targetY = height / 2 - vp.centerY * vp.zoomLevel;
|
|
448
|
+
const ratio = vp.zoomLevel / baseline.scaleX;
|
|
449
|
+
const tx = targetX - baseline.x * ratio;
|
|
450
|
+
const ty = targetY - baseline.y * ratio;
|
|
451
|
+
contentDiv.style.transform = `translate(${tx}px, ${ty}px) scale(${ratio})`;
|
|
452
|
+
} else {
|
|
453
|
+
const stage = stageRef.current;
|
|
454
|
+
if (stage) {
|
|
455
|
+
stage.x(width / 2 - vp.centerX * vp.zoomLevel);
|
|
456
|
+
stage.y(height / 2 - vp.centerY * vp.zoomLevel);
|
|
457
|
+
stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
|
|
458
|
+
stage.batchDraw();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
onViewportFinal: (vp) => {
|
|
463
|
+
if (konvaContentDivRef.current) konvaContentDivRef.current.style.transform = '';
|
|
464
|
+
const stage = stageRef.current;
|
|
465
|
+
if (stage) {
|
|
466
|
+
const { width, height } = canvasSizeRef.current;
|
|
467
|
+
stage.x(width / 2 - vp.centerX * vp.zoomLevel);
|
|
468
|
+
stage.y(height / 2 - vp.centerY * vp.zoomLevel);
|
|
469
|
+
stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
|
|
470
|
+
stage.getLayers().forEach(l => l.drawScene());
|
|
471
|
+
}
|
|
472
|
+
setCenter(vp.centerX, vp.centerY);
|
|
473
|
+
setZoom(vp.zoomLevel);
|
|
474
|
+
},
|
|
475
|
+
onPhaseChange: (phase) => {
|
|
476
|
+
const stage = stageRef.current;
|
|
477
|
+
const contentDiv = konvaContentDivRef.current;
|
|
478
|
+
if (stage && contentDiv && (phase === 'swap' || phase === 'zoom-back')) {
|
|
479
|
+
contentDiv.style.transform = '';
|
|
480
|
+
const vp = phase === 'zoom-back' ? zoomOutTargetRef.current : lastAnimViewportRef.current;
|
|
481
|
+
if (vp) {
|
|
482
|
+
const { width, height } = canvasSizeRef.current;
|
|
483
|
+
const nx = width / 2 - vp.centerX * vp.zoomLevel;
|
|
484
|
+
const ny = height / 2 - vp.centerY * vp.zoomLevel;
|
|
485
|
+
stage.x(nx); stage.y(ny);
|
|
486
|
+
stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
|
|
487
|
+
stage.getLayers().forEach(l => l.drawScene());
|
|
488
|
+
baselineTransformRef.current = { x: nx, y: ny, scaleX: vp.zoomLevel, scaleY: vp.zoomLevel };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
onTransitionLifecycle: (phase) => {
|
|
493
|
+
const stage = stageRef.current;
|
|
494
|
+
if (phase === 'start') {
|
|
495
|
+
if (canvasWrapperRef.current) canvasWrapperRef.current.style.willChange = 'opacity, transform';
|
|
496
|
+
if (stage) {
|
|
497
|
+
baselineTransformRef.current = { x: stage.x(), y: stage.y(), scaleX: stage.scaleX(), scaleY: stage.scaleY() };
|
|
498
|
+
const container = stage.container();
|
|
499
|
+
const cd = container?.querySelector('.konvajs-content') as HTMLDivElement | null;
|
|
500
|
+
if (cd) {
|
|
501
|
+
konvaContentDivRef.current = cd;
|
|
502
|
+
cd.style.transformOrigin = '0 0';
|
|
503
|
+
cd.style.willChange = 'transform';
|
|
504
|
+
}
|
|
505
|
+
stage.getLayers().forEach(l => { l.listening(false); });
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
if (canvasWrapperRef.current) canvasWrapperRef.current.style.willChange = 'auto';
|
|
509
|
+
if (konvaContentDivRef.current) { konvaContentDivRef.current.style.willChange = 'auto'; konvaContentDivRef.current = null; }
|
|
510
|
+
baselineTransformRef.current = null;
|
|
511
|
+
zoomOutTargetRef.current = null;
|
|
512
|
+
if (stage) {
|
|
513
|
+
stage.getLayers().forEach(l => { l.listening(true); });
|
|
514
|
+
stage.batchDraw();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const handleNavigate = useCallback((nodeId: string) => {
|
|
521
|
+
if (!doc || !currentLayerId) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (isTransitioningRef.current || isAnimating()) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const childLayer = getChildLayerForNode(doc, nodeId, currentLayerId);
|
|
529
|
+
if (!childLayer) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const childLayerId = [...doc.layers.entries()].find(([, l]) => l === childLayer)?.[0];
|
|
533
|
+
if (!childLayerId) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const childNodes = (() => { try { return getNodesForLayer(doc, childLayerId); } catch { return []; } })();
|
|
537
|
+
if (childNodes.length === 0) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const node = nodes.get(nodeId);
|
|
542
|
+
if (!node) return;
|
|
543
|
+
|
|
544
|
+
// Save the FIT viewport for this layer (not the current scroll position).
|
|
545
|
+
// This ensures navigating back always returns to a clean, properly-zoomed view
|
|
546
|
+
// rather than the arbitrary zoom that happened to trigger the navigation.
|
|
547
|
+
const cw = canvasSizeRef.current.width || 800;
|
|
548
|
+
const ch = canvasSizeRef.current.height || 600;
|
|
549
|
+
const fitVp = calculateFitView(flatNodes, cw, ch);
|
|
550
|
+
const isRootNow = navStack.length <= 1;
|
|
551
|
+
parentViewports.current.push({ centerX: fitVp.centerX, centerY: fitVp.centerY, zoomLevel: isRootNow ? 0.85 : fitVp.zoomLevel });
|
|
552
|
+
|
|
553
|
+
setIsTransitioning(true);
|
|
554
|
+
isTransitioningRef.current = true;
|
|
555
|
+
|
|
556
|
+
zoomIntoLayer(
|
|
557
|
+
doc, node, childLayerId, childNodes,
|
|
558
|
+
node.color ?? getThemeBackground(isDark, 'secondary'),
|
|
559
|
+
node.color ?? getThemeBackground(isDark, 'primary'),
|
|
560
|
+
);
|
|
561
|
+
}, [doc, currentLayerId, nodes, isDark, isAnimating, zoomIntoLayer]);
|
|
562
|
+
|
|
563
|
+
const handleNavigateBack = useCallback((targetIndex: number) => {
|
|
564
|
+
if (targetIndex >= navStack.length - 1) return;
|
|
565
|
+
if (isTransitioningRef.current || isAnimating()) return;
|
|
566
|
+
const savedVp = parentViewports.current[targetIndex];
|
|
567
|
+
if (!savedVp) return;
|
|
568
|
+
navBackTargetRef.current = targetIndex;
|
|
569
|
+
setIsTransitioning(true);
|
|
570
|
+
isTransitioningRef.current = true;
|
|
571
|
+
zoomOutToParent(savedVp, getThemeBackground(isDark, 'primary'), getThemeBackground(isDark, 'secondary'));
|
|
572
|
+
}, [navStack.length, isDark, isAnimating, zoomOutToParent]);
|
|
573
|
+
|
|
574
|
+
// ── Auto-zoom navigation (Google Maps style) ──────────────────────────────
|
|
575
|
+
const baseZoomRef = useRef(1.0);
|
|
576
|
+
const lastZoomTransitionRef = useRef(0);
|
|
577
|
+
const ZOOM_COOLDOWN = 1000;
|
|
578
|
+
|
|
579
|
+
useEffect(() => { baseZoomRef.current = 1.0; }, [currentLayerId]);
|
|
580
|
+
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
if (isTransitioningRef.current || isAnimating()) return;
|
|
583
|
+
const now = Date.now();
|
|
584
|
+
if (now - lastZoomTransitionRef.current < ZOOM_COOLDOWN) return;
|
|
585
|
+
const relative = viewport.zoomLevel / baseZoomRef.current;
|
|
586
|
+
|
|
587
|
+
if (relative >= ZOOM_NAV_IN_THRESHOLD) {
|
|
588
|
+
// Zoom in threshold — drill into closest node
|
|
589
|
+
let closest: FlatNode | null = null;
|
|
590
|
+
let minDist = Infinity;
|
|
591
|
+
for (const node of nodes.values()) {
|
|
592
|
+
const dx = node.x - viewport.centerX;
|
|
593
|
+
const dy = node.y - viewport.centerY;
|
|
594
|
+
const d = dx * dx + dy * dy;
|
|
595
|
+
if (d < minDist) { minDist = d; closest = node; }
|
|
596
|
+
}
|
|
597
|
+
if (closest) {
|
|
598
|
+
const childLayer = doc ? getChildLayerForNode(doc, closest.id, currentLayerId ?? '') : null;
|
|
599
|
+
if (childLayer) {
|
|
600
|
+
lastZoomTransitionRef.current = now;
|
|
601
|
+
handleNavigate(closest.id);
|
|
602
|
+
baseZoomRef.current = 1.0;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
} else if (relative < ZOOM_NAV_OUT_THRESHOLD && navStack.length > 1) {
|
|
606
|
+
// Zoom out threshold — go back to parent
|
|
607
|
+
lastZoomTransitionRef.current = now;
|
|
608
|
+
handleNavigateBack(navStack.length - 2);
|
|
609
|
+
baseZoomRef.current = 1.0;
|
|
610
|
+
}
|
|
611
|
+
}, [viewport.zoomLevel, viewport.centerX, viewport.centerY, nodes, doc, currentLayerId, navStack, isAnimating, handleNavigate, handleNavigateBack]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
612
|
+
|
|
613
|
+
// ── Stage-level hit detection ─────────────────────────────────────────────
|
|
614
|
+
const toWorldCoords = useCallback((stage: Konva.Stage, pointer: { x: number; y: number }) => {
|
|
615
|
+
const sp = stage.position();
|
|
616
|
+
const sc = stage.scaleX();
|
|
617
|
+
return { worldX: (pointer.x - sp.x) / sc, worldY: (pointer.y - sp.y) / sc };
|
|
618
|
+
}, []);
|
|
619
|
+
|
|
620
|
+
const hitTestNode = useCallback((worldX: number, worldY: number): string | null => {
|
|
621
|
+
if (!spatialIndexRef.current) return null;
|
|
622
|
+
const hits = spatialIndexRef.current.query({ minX: worldX, minY: worldY, maxX: worldX, maxY: worldY });
|
|
623
|
+
if (!hits.length) return null;
|
|
624
|
+
let targetId: string | null = null, minDist = Infinity;
|
|
625
|
+
for (const nodeId of hits) {
|
|
626
|
+
const node = nodes.get(nodeId);
|
|
627
|
+
if (!node) continue;
|
|
628
|
+
const d = (worldX - node.x) ** 2 + (worldY - node.y) ** 2;
|
|
629
|
+
if (d < minDist) { minDist = d; targetId = nodeId; }
|
|
630
|
+
}
|
|
631
|
+
return targetId;
|
|
632
|
+
}, [nodes]);
|
|
633
|
+
|
|
634
|
+
// Stage pointer interaction state
|
|
635
|
+
const stageActiveNodeIdRef = useRef<string | null>(null);
|
|
636
|
+
const stageDragNodeOffsetRef = useRef({ x: 0, y: 0 });
|
|
637
|
+
const stageMouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
|
|
638
|
+
const stageDragCommittedRef = useRef(false);
|
|
639
|
+
const stageLastPointerCheckRef = useRef<number>(0);
|
|
640
|
+
const pointerNodeIdRef = useRef<string | null>(null);
|
|
641
|
+
const isCanvasPanningRef = useRef(false);
|
|
642
|
+
const isDraggingRef = useRef(false);
|
|
643
|
+
|
|
644
|
+
const [pointerNodeId, setPointerNodeId] = useState<string | null>(null);
|
|
645
|
+
const [pressedNodeId, setPressedNodeId] = useState<string | null>(null);
|
|
646
|
+
const [isDraggingState, setIsDraggingState] = useState(false);
|
|
647
|
+
|
|
648
|
+
// ── Node hover tooltip ────────────────────────────────────────────────────
|
|
649
|
+
const [hoveredNode, setHoveredNode] = useState<{
|
|
650
|
+
nodeId: string; summary: string; screenX: number; screenY: number;
|
|
651
|
+
} | null>(null);
|
|
652
|
+
|
|
653
|
+
const tooltipHoverRef = useRef(false);
|
|
654
|
+
const keepTooltipOpenRef = useRef(false); // External request to keep tooltip open (e.g., from extensions)
|
|
655
|
+
const clearTooltipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
656
|
+
|
|
657
|
+
const hoveredNodePosition = useMemo(() => {
|
|
658
|
+
if (!hoveredNode) return null;
|
|
659
|
+
const node = nodes.get(hoveredNode.nodeId);
|
|
660
|
+
if (!node) return null;
|
|
661
|
+
const cw = viewport.canvasWidth || canvasSizeRef.current.width;
|
|
662
|
+
const ch = viewport.canvasHeight || canvasSizeRef.current.height;
|
|
663
|
+
return {
|
|
664
|
+
screenX: (node.x - viewport.centerX) * viewport.zoomLevel + cw / 2,
|
|
665
|
+
screenY: (node.y - viewport.centerY) * viewport.zoomLevel + ch / 2,
|
|
666
|
+
zoom: viewport.zoomLevel,
|
|
667
|
+
};
|
|
668
|
+
}, [hoveredNode, nodes, viewport]);
|
|
669
|
+
|
|
670
|
+
// ── Context menu ──────────────────────────────────────────────────────────
|
|
671
|
+
const [canvasContextMenu, setCanvasContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
672
|
+
|
|
673
|
+
useEffect(() => {
|
|
674
|
+
if (!canvasContextMenu) return;
|
|
675
|
+
const close = () => setCanvasContextMenu(null);
|
|
676
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
|
|
677
|
+
window.addEventListener('pointerdown', close);
|
|
678
|
+
window.addEventListener('keydown', onKey);
|
|
679
|
+
return () => { window.removeEventListener('pointerdown', close); window.removeEventListener('keydown', onKey); };
|
|
680
|
+
}, [canvasContextMenu]);
|
|
681
|
+
|
|
682
|
+
// ── Stage event handlers ──────────────────────────────────────────────────
|
|
683
|
+
const handleStageMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
|
684
|
+
if (e.evt.button !== 0) { rendererHandleMouseDown(e); return; }
|
|
685
|
+
const stage = e.target.getStage();
|
|
686
|
+
if (!stage) { rendererHandleMouseDown(e); return; }
|
|
687
|
+
const pointer = stage.getPointerPosition();
|
|
688
|
+
if (!pointer) { rendererHandleMouseDown(e); return; }
|
|
689
|
+
const { worldX, worldY } = toWorldCoords(stage, pointer);
|
|
690
|
+
const nodeId = hitTestNode(worldX, worldY);
|
|
691
|
+
if (nodeId) {
|
|
692
|
+
const node = nodes.get(nodeId);
|
|
693
|
+
if (node) {
|
|
694
|
+
select(nodeId);
|
|
695
|
+
stageActiveNodeIdRef.current = nodeId;
|
|
696
|
+
stageDragNodeOffsetRef.current = { x: worldX - node.x, y: worldY - node.y };
|
|
697
|
+
stageMouseDownPosRef.current = { x: e.evt.clientX, y: e.evt.clientY };
|
|
698
|
+
stageDragCommittedRef.current = false;
|
|
699
|
+
setPressedNodeId(nodeId);
|
|
700
|
+
setHoveredNode(null);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
setHoveredNode(null);
|
|
705
|
+
isCanvasPanningRef.current = true;
|
|
706
|
+
rendererHandleMouseDown(e);
|
|
707
|
+
}, [hitTestNode, toWorldCoords, nodes, select, rendererHandleMouseDown]);
|
|
708
|
+
|
|
709
|
+
const handleStageMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
|
710
|
+
const stage = e.target.getStage();
|
|
711
|
+
if (!stage) { rendererHandleMouseMove(e); return; }
|
|
712
|
+
const pointer = stage.getPointerPosition();
|
|
713
|
+
if (!pointer) { rendererHandleMouseMove(e); return; }
|
|
714
|
+
const { worldX, worldY } = toWorldCoords(stage, pointer);
|
|
715
|
+
|
|
716
|
+
if (stageActiveNodeIdRef.current && stageMouseDownPosRef.current) {
|
|
717
|
+
const moved = Math.hypot(e.evt.clientX - stageMouseDownPosRef.current.x, e.evt.clientY - stageMouseDownPosRef.current.y);
|
|
718
|
+
if (!stageDragCommittedRef.current && moved >= 5) {
|
|
719
|
+
stageDragCommittedRef.current = true;
|
|
720
|
+
isDraggingRef.current = true;
|
|
721
|
+
setIsDraggingState(true);
|
|
722
|
+
pointerNodeIdRef.current = null;
|
|
723
|
+
setPointerNodeId(null);
|
|
724
|
+
handleDragStart(stageActiveNodeIdRef.current);
|
|
725
|
+
}
|
|
726
|
+
if (stageDragCommittedRef.current) {
|
|
727
|
+
const offset = stageDragNodeOffsetRef.current;
|
|
728
|
+
handleDragMove(stageActiveNodeIdRef.current, worldX - offset.x, worldY - offset.y);
|
|
729
|
+
stage.container().style.cursor = 'grabbing';
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// During canvas pan — just apply the pan, skip hover detection to avoid re-renders
|
|
736
|
+
if (isCanvasPanningRef.current) {
|
|
737
|
+
rendererHandleMouseMove(e);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Throttled hover detection (only when not panning or dragging)
|
|
742
|
+
const now = performance.now();
|
|
743
|
+
if (now - stageLastPointerCheckRef.current >= 50) {
|
|
744
|
+
stageLastPointerCheckRef.current = now;
|
|
745
|
+
const hovered = hitTestNode(worldX, worldY);
|
|
746
|
+
if (hovered !== pointerNodeIdRef.current) {
|
|
747
|
+
pointerNodeIdRef.current = hovered;
|
|
748
|
+
setPointerNodeId(hovered);
|
|
749
|
+
}
|
|
750
|
+
if (hovered) {
|
|
751
|
+
stage.container().style.cursor = 'pointer';
|
|
752
|
+
} else {
|
|
753
|
+
stage.container().style.cursor = 'grab';
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
rendererHandleMouseMove(e);
|
|
758
|
+
}, [toWorldCoords, hitTestNode, handleDragStart, handleDragMove, rendererHandleMouseMove]);
|
|
759
|
+
|
|
760
|
+
const handleStageMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
|
761
|
+
setPressedNodeId(null);
|
|
762
|
+
if (stageActiveNodeIdRef.current) {
|
|
763
|
+
const nodeId = stageActiveNodeIdRef.current;
|
|
764
|
+
if (stageDragCommittedRef.current) {
|
|
765
|
+
// End drag — flush final world position
|
|
766
|
+
const stage = e.target.getStage();
|
|
767
|
+
if (stage) {
|
|
768
|
+
const pointer = stage.getPointerPosition();
|
|
769
|
+
if (pointer) {
|
|
770
|
+
const { worldX, worldY } = toWorldCoords(stage, pointer);
|
|
771
|
+
const offset = stageDragNodeOffsetRef.current;
|
|
772
|
+
handleDragEnd(nodeId, worldX - offset.x, worldY - offset.y);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
} else {
|
|
776
|
+
// Click — check if node has child layer (regardless of branchCount)
|
|
777
|
+
const node = nodes.get(nodeId);
|
|
778
|
+
if (node) {
|
|
779
|
+
const childLayer = doc ? getChildLayerForNode(doc, nodeId, currentLayerId ?? '') : null;
|
|
780
|
+
if (childLayer) {
|
|
781
|
+
handleNavigate(nodeId);
|
|
782
|
+
}
|
|
783
|
+
onNodeClick?.(node);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
isDraggingRef.current = false;
|
|
787
|
+
setIsDraggingState(false);
|
|
788
|
+
stageActiveNodeIdRef.current = null;
|
|
789
|
+
stageMouseDownPosRef.current = null;
|
|
790
|
+
stageDragCommittedRef.current = false;
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
isCanvasPanningRef.current = false;
|
|
794
|
+
rendererHandleMouseUp(e);
|
|
795
|
+
}, [toWorldCoords, handleDragEnd, nodes, handleNavigate, onNodeClick, rendererHandleMouseUp]);
|
|
796
|
+
|
|
797
|
+
const handleStageContextMenu = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
|
798
|
+
e.evt.preventDefault();
|
|
799
|
+
if (navStack.length <= 1) { setCanvasContextMenu(null); return; }
|
|
800
|
+
setCanvasContextMenu({ x: e.evt.clientX, y: e.evt.clientY });
|
|
801
|
+
}, [navStack.length]);
|
|
802
|
+
|
|
803
|
+
// Touch passthrough
|
|
804
|
+
const handleStageTouchStart = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
|
|
805
|
+
handleStageMouseDown(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseDown]);
|
|
806
|
+
const handleStageTouchMove = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
|
|
807
|
+
handleStageMouseMove(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseMove]);
|
|
808
|
+
const handleStageTouchEnd = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
|
|
809
|
+
handleStageMouseUp(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseUp]);
|
|
810
|
+
|
|
811
|
+
// Wheel with auto-nav guard on non-root layers
|
|
812
|
+
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
|
|
813
|
+
const isZoomOut = e.evt.deltaY > 0;
|
|
814
|
+
if (!isZoomOut) { rendererHandleWheel(e); return; }
|
|
815
|
+
|
|
816
|
+
const liveZoom = useViewportStore.getState().zoomLevel;
|
|
817
|
+
const projected = Math.max(ZOOM_MIN, liveZoom * (1 - e.evt.deltaY / 1000));
|
|
818
|
+
|
|
819
|
+
// Root layer: no parent to navigate to — let the user zoom freely down to ZOOM_MIN
|
|
820
|
+
// (do NOT clamp at ZOOM_NAV_OUT_THRESHOLD; content may require lower zoom to fit)
|
|
821
|
+
|
|
822
|
+
if (navStack.length > 1 && projected <= ZOOM_NAV_OUT_THRESHOLD && !isTransitioningRef.current && !isAnimating()) {
|
|
823
|
+
e.evt.preventDefault();
|
|
824
|
+
const now = Date.now();
|
|
825
|
+
if (now - lastZoomTransitionRef.current >= ZOOM_COOLDOWN) {
|
|
826
|
+
lastZoomTransitionRef.current = now;
|
|
827
|
+
handleNavigateBack(navStack.length - 2);
|
|
828
|
+
baseZoomRef.current = 1.0;
|
|
829
|
+
}
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
rendererHandleWheel(e);
|
|
833
|
+
}, [rendererHandleWheel, navStack, setZoom, isAnimating, handleNavigateBack]);
|
|
834
|
+
|
|
835
|
+
// Canvas mouse move for description tooltip
|
|
836
|
+
const lastHoverCheckRef = useRef(0);
|
|
837
|
+
const handleCanvasMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
838
|
+
if (!containerRef.current || isDraggingRef.current || isCanvasPanningRef.current) {
|
|
839
|
+
setHoveredNode(null);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const now = Date.now();
|
|
843
|
+
if (now - lastHoverCheckRef.current < 50) return;
|
|
844
|
+
lastHoverCheckRef.current = now;
|
|
845
|
+
|
|
846
|
+
// Check if mouse is over a tooltip - if so, don't change hovered node
|
|
847
|
+
const target = e.target as HTMLElement;
|
|
848
|
+
const isOverTooltip = target.closest('[data-node-tooltip="true"]') || target.closest('[data-semantic-tooltip="true"]');
|
|
849
|
+
|
|
850
|
+
// If tooltip is open and we're hovering it or keeping it open, don't detect new nodes
|
|
851
|
+
if (tooltipHoverRef.current || keepTooltipOpenRef.current || isOverTooltip) {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const cw = viewport.canvasWidth || canvasSizeRef.current.width;
|
|
856
|
+
const ch = viewport.canvasHeight || canvasSizeRef.current.height;
|
|
857
|
+
// e.clientX is viewport-relative; subtract the container's left/top offset
|
|
858
|
+
// so world-coordinate conversion is correct when the renderer isn't full-screen.
|
|
859
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
860
|
+
const localX = rect ? e.clientX - rect.left : e.clientX;
|
|
861
|
+
const localY = rect ? e.clientY - rect.top : e.clientY;
|
|
862
|
+
const worldX = (localX - cw / 2) / viewport.zoomLevel + viewport.centerX;
|
|
863
|
+
const worldY = (localY - ch / 2) / viewport.zoomLevel + viewport.centerY;
|
|
864
|
+
|
|
865
|
+
// Use RBush spatial index for O(log n) hit detection
|
|
866
|
+
let found = false;
|
|
867
|
+
if (spatialIndexRef.current) {
|
|
868
|
+
const hits = spatialIndexRef.current.query({ minX: worldX, minY: worldY, maxX: worldX, maxY: worldY });
|
|
869
|
+
for (const nodeId of hits) {
|
|
870
|
+
const node = nodes.get(nodeId);
|
|
871
|
+
if (node?.description) {
|
|
872
|
+
setHoveredNode({ nodeId: node.id, summary: node.description, screenX: e.clientX, screenY: e.clientY });
|
|
873
|
+
found = true;
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (!found) {
|
|
879
|
+
if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
|
|
880
|
+
clearTooltipTimerRef.current = setTimeout(() => {
|
|
881
|
+
if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) setHoveredNode(null);
|
|
882
|
+
}, 150);
|
|
883
|
+
} else {
|
|
884
|
+
if (clearTooltipTimerRef.current) { clearTimeout(clearTooltipTimerRef.current); clearTooltipTimerRef.current = null; }
|
|
885
|
+
}
|
|
886
|
+
}, [nodes, viewport, spatialIndexRef]);
|
|
887
|
+
|
|
888
|
+
// Listen for external requests to keep tooltip open (e.g., from extensions)
|
|
889
|
+
useEffect(() => {
|
|
890
|
+
const handleKeepTooltipOpen = (event: CustomEvent) => {
|
|
891
|
+
keepTooltipOpenRef.current = event.detail.keep;
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
// @ts-ignore - custom event
|
|
895
|
+
window.addEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen);
|
|
896
|
+
|
|
897
|
+
return () => {
|
|
898
|
+
// @ts-ignore - custom event
|
|
899
|
+
window.removeEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen);
|
|
900
|
+
};
|
|
901
|
+
}, []);
|
|
902
|
+
|
|
903
|
+
// Cleanup on unmount
|
|
904
|
+
useEffect(() => {
|
|
905
|
+
return () => {
|
|
906
|
+
if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
|
|
907
|
+
spatialIndexRef.current?.clear();
|
|
908
|
+
if (dragMoveRafRef.current !== null) cancelAnimationFrame(dragMoveRafRef.current);
|
|
909
|
+
};
|
|
910
|
+
}, []);
|
|
911
|
+
|
|
912
|
+
// ── Zoom controls ─────────────────────────────────────────────────────────
|
|
913
|
+
const handleZoomIn = useCallback(() => setZoom(Math.min(viewport.zoomLevel * 1.2, 5)), [viewport.zoomLevel, setZoom]);
|
|
914
|
+
const handleZoomOut = useCallback(() => {
|
|
915
|
+
// Non-root: allow zoom to ZOOM_MIN so the wheel-driven ZOOM_NAV_OUT_THRESHOLD
|
|
916
|
+
// transition can still trigger via scroll; button just navigates zoom value.
|
|
917
|
+
// Root: also ZOOM_MIN (no parent layer to navigate to, allow full zoom-out).
|
|
918
|
+
setZoom(Math.max(viewport.zoomLevel / 1.2, ZOOM_MIN));
|
|
919
|
+
}, [viewport.zoomLevel, setZoom]);
|
|
920
|
+
|
|
921
|
+
// Extension rendering removed - not part of open spec
|
|
922
|
+
|
|
923
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
924
|
+
if (!doc) {
|
|
925
|
+
return (
|
|
926
|
+
<div className={className} style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', background: bgColor, ...style }}>
|
|
927
|
+
<span style={{ color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.3)', fontSize: 14 }}>No document</span>
|
|
928
|
+
</div>
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const hasBackOption = navStack.length > 1;
|
|
933
|
+
|
|
934
|
+
return (
|
|
935
|
+
<div
|
|
936
|
+
ref={containerRef}
|
|
937
|
+
className={className}
|
|
938
|
+
style={{
|
|
939
|
+
position: 'relative',
|
|
940
|
+
overflow: 'hidden',
|
|
941
|
+
userSelect: 'none',
|
|
942
|
+
background: bgColor,
|
|
943
|
+
width: '100%',
|
|
944
|
+
height: '100%',
|
|
945
|
+
...style
|
|
946
|
+
}}
|
|
947
|
+
onMouseMove={handleCanvasMouseMove}
|
|
948
|
+
onMouseLeave={() => {
|
|
949
|
+
// Only close tooltip on mouse leave if not keeping it open
|
|
950
|
+
if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) {
|
|
951
|
+
setHoveredNode(null);
|
|
952
|
+
}
|
|
953
|
+
}}
|
|
954
|
+
>
|
|
955
|
+
{/* Konva canvas — opacity driven imperatively during transitions */}
|
|
956
|
+
<div ref={canvasWrapperRef} style={{ position: 'relative', zIndex: 1, pointerEvents: 'auto' }}>
|
|
957
|
+
<KonvaStage
|
|
958
|
+
ref={stageRef}
|
|
959
|
+
onWheel={handleWheel}
|
|
960
|
+
onMouseDown={handleStageMouseDown}
|
|
961
|
+
onMouseMove={handleStageMouseMove}
|
|
962
|
+
onMouseUp={handleStageMouseUp}
|
|
963
|
+
onContextMenu={handleStageContextMenu}
|
|
964
|
+
onTouchStart={handleStageTouchStart}
|
|
965
|
+
onTouchMove={handleStageTouchMove}
|
|
966
|
+
onTouchEnd={handleStageTouchEnd}
|
|
967
|
+
>
|
|
968
|
+
<KonvaContainerLayer nodes={flatNodes} containers={containers} isDark={isDark} />
|
|
969
|
+
<KonvaEdgeLayer
|
|
970
|
+
nodes={flatNodes}
|
|
971
|
+
connections={connections}
|
|
972
|
+
isDark={isDark}
|
|
973
|
+
isDragging={isDraggingState}
|
|
974
|
+
/>
|
|
975
|
+
<KonvaNodeLayer
|
|
976
|
+
isTransitioning={isTransitioning}
|
|
977
|
+
isDragging={isDraggingState}
|
|
978
|
+
hoveredNodeId={pointerNodeId}
|
|
979
|
+
pressedNodeId={pressedNodeId}
|
|
980
|
+
/>
|
|
981
|
+
</KonvaStage>
|
|
982
|
+
</div>
|
|
983
|
+
|
|
984
|
+
{/* Context menu (right-click to go back) */}
|
|
985
|
+
{canvasContextMenu && hasBackOption && (
|
|
986
|
+
<div
|
|
987
|
+
role="menu"
|
|
988
|
+
aria-label="Canvas options"
|
|
989
|
+
style={{
|
|
990
|
+
position: 'fixed',
|
|
991
|
+
zIndex: 60,
|
|
992
|
+
pointerEvents: 'auto',
|
|
993
|
+
left: `${Math.min(canvasContextMenu.x, Math.max(8, (canvasSizeRef.current.width || 800) - 152))}px`,
|
|
994
|
+
top: `${Math.min(canvasContextMenu.y, Math.max(8, (canvasSizeRef.current.height || 600) - 72))}px`,
|
|
995
|
+
}}
|
|
996
|
+
onContextMenu={e => e.preventDefault()}
|
|
997
|
+
onPointerDown={e => e.stopPropagation()}
|
|
998
|
+
>
|
|
999
|
+
<button
|
|
1000
|
+
type="button"
|
|
1001
|
+
style={{
|
|
1002
|
+
padding: '6px 16px',
|
|
1003
|
+
borderRadius: '10px',
|
|
1004
|
+
border: `1px solid ${isDark ? '#333330' : '#DDD9D0'}`,
|
|
1005
|
+
background: isDark ? '#1C1C1A' : '#FAF8F4',
|
|
1006
|
+
color: isDark ? '#F0EDE6' : '#1A1A18',
|
|
1007
|
+
cursor: 'pointer',
|
|
1008
|
+
fontSize: 13,
|
|
1009
|
+
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
|
1010
|
+
}}
|
|
1011
|
+
onClick={() => { setCanvasContextMenu(null); handleNavigateBack(navStack.length - 2); }}
|
|
1012
|
+
>
|
|
1013
|
+
Back
|
|
1014
|
+
</button>
|
|
1015
|
+
</div>
|
|
1016
|
+
)}
|
|
1017
|
+
|
|
1018
|
+
{/* Navigation stack (left, matches reference) */}
|
|
1019
|
+
<NavigationStack stack={navStack} onNavigateBack={handleNavigateBack} isDark={isDark} top={navigationStackTop} left={navigationStackLeft} />
|
|
1020
|
+
|
|
1021
|
+
{/* Zoom controls (top-right) */}
|
|
1022
|
+
<div data-help="zoom-controls" style={{ position: 'absolute', top: '24px', right: '16px', zIndex: 50, pointerEvents: 'auto' }}>
|
|
1023
|
+
<ZoomControls isDark={isDark} />
|
|
1024
|
+
</div>
|
|
1025
|
+
|
|
1026
|
+
{/* Custom overlay from consuming app (for private features like help button, chromatic bg, etc.) */}
|
|
1027
|
+
{renderOverlay?.({ isDark, containerWidth: canvasSizeRef.current.width || 0, containerHeight: canvasSizeRef.current.height || 0 })}
|
|
1028
|
+
|
|
1029
|
+
{/* Node description tooltip (sketchy-box, above node) - rendered before extensions so extensions appear on top */}
|
|
1030
|
+
{hoveredNode && hoveredNodePosition && viewport.zoomLevel < 3 && viewport.zoomLevel >= TEXT_LABEL_HIDE_BELOW_ZOOM && (() => {
|
|
1031
|
+
const nodeData = nodes.get(hoveredNode.nodeId);
|
|
1032
|
+
const borderColor = nodeData?.color ? darkenHexColor(nodeData.color, 0.13) : '#5BA8D4';
|
|
1033
|
+
const VP_PAD = 8;
|
|
1034
|
+
const HALF_W = 160;
|
|
1035
|
+
const APPROX_H = 90;
|
|
1036
|
+
const containerW = canvasSizeRef.current.width || 800;
|
|
1037
|
+
const safeLeft = Math.max(HALF_W + VP_PAD, Math.min(hoveredNodePosition.screenX, containerW - HALF_W - VP_PAD));
|
|
1038
|
+
const rawTop = hoveredNodePosition.screenY - 60 * hoveredNodePosition.zoom;
|
|
1039
|
+
const safeTop = Math.max(VP_PAD + APPROX_H, rawTop);
|
|
1040
|
+
return (
|
|
1041
|
+
<div
|
|
1042
|
+
data-node-tooltip="true"
|
|
1043
|
+
style={{
|
|
1044
|
+
position: 'fixed',
|
|
1045
|
+
zIndex: 50,
|
|
1046
|
+
left: safeLeft,
|
|
1047
|
+
top: safeTop,
|
|
1048
|
+
transform: 'translate(-50%, -100%)',
|
|
1049
|
+
transformOrigin: 'bottom center',
|
|
1050
|
+
pointerEvents: 'auto',
|
|
1051
|
+
}}
|
|
1052
|
+
onMouseEnter={() => { tooltipHoverRef.current = true; if (clearTooltipTimerRef.current) { clearTimeout(clearTooltipTimerRef.current); clearTooltipTimerRef.current = null; } }}
|
|
1053
|
+
onMouseLeave={() => {
|
|
1054
|
+
tooltipHoverRef.current = false;
|
|
1055
|
+
// Delay clearing to allow external extensions to request keeping tooltip open
|
|
1056
|
+
if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
|
|
1057
|
+
clearTooltipTimerRef.current = setTimeout(() => {
|
|
1058
|
+
if (!keepTooltipOpenRef.current) setHoveredNode(null);
|
|
1059
|
+
}, 100);
|
|
1060
|
+
}}
|
|
1061
|
+
>
|
|
1062
|
+
<div style={{ maxWidth: '20rem', minWidth: '12rem', maxHeight: '15rem', overflowY: 'auto' }}>
|
|
1063
|
+
<SketchyBoxKonva fill="#ffffff" stroke={borderColor} backStroke={borderColor} padding="1rem 1.25rem">
|
|
1064
|
+
<p style={{
|
|
1065
|
+
fontFamily: "'Playpen Sans', cursive",
|
|
1066
|
+
fontSize: `${DESCRIPTION_TEXT_BASE_FONT_PX}px`,
|
|
1067
|
+
fontWeight: 300,
|
|
1068
|
+
color: '#000000',
|
|
1069
|
+
textAlign: 'center',
|
|
1070
|
+
lineHeight: 1.6,
|
|
1071
|
+
margin: 0,
|
|
1072
|
+
}}>
|
|
1073
|
+
{renderNodeContent ? renderNodeContent({
|
|
1074
|
+
summary: hoveredNode.summary,
|
|
1075
|
+
nodeId: hoveredNode.nodeId,
|
|
1076
|
+
nodeColor: nodeData?.color,
|
|
1077
|
+
zoom: viewport.zoomLevel,
|
|
1078
|
+
}) : hoveredNode.summary}
|
|
1079
|
+
</p>
|
|
1080
|
+
</SketchyBoxKonva>
|
|
1081
|
+
</div>
|
|
1082
|
+
</div>
|
|
1083
|
+
);
|
|
1084
|
+
})()}
|
|
1085
|
+
</div>
|
|
1086
|
+
);
|
|
1087
|
+
}
|