@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,221 @@
|
|
|
1
|
+
// ─── useKonvaLayerTransition ──────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Manages layer navigation animations (zoom into child layer, zoom back to
|
|
4
|
+
// parent). Uses the same callback-driven pattern as the visualli.ai reference:
|
|
5
|
+
// - onViewportChange: imperatively updates the Konva stage each rAF frame
|
|
6
|
+
// - onOpacityChange: writes directly to canvasWrapperRef DOM (no React state)
|
|
7
|
+
// - onBackgroundColorChange: writes directly to bgRef DOM
|
|
8
|
+
// - onPhaseChange('swap'|'zoom-back'): triggers React layer content swap
|
|
9
|
+
// - onComplete: syncs final viewport to Zustand and ends transition
|
|
10
|
+
|
|
11
|
+
import { useCallback, useRef, useEffect } from 'react';
|
|
12
|
+
import type Konva from 'konva';
|
|
13
|
+
import type { VisualliDocument, FlatNode } from '@visualli/core';
|
|
14
|
+
import { useViewportStore } from '../stores/useViewportStore';
|
|
15
|
+
import { konvaLayerTransitionAnimator } from '../animations/konvaLayerTransition';
|
|
16
|
+
import type { AnimatorViewport } from '../animations/konvaLayerTransition';
|
|
17
|
+
import { calculateFitView } from '../utils/layerNavigation';
|
|
18
|
+
|
|
19
|
+
export interface LayerTransitionOptions {
|
|
20
|
+
/** Ref to the Konva Stage for imperative position/scale updates each frame. */
|
|
21
|
+
stageRef: React.RefObject<Konva.Stage | null>;
|
|
22
|
+
/** Ref to the wrapping div whose CSS `opacity` is driven during transitions. */
|
|
23
|
+
canvasWrapperRef: React.RefObject<HTMLDivElement | null>;
|
|
24
|
+
/** Ref to the background color div. */
|
|
25
|
+
bgRef?: React.RefObject<HTMLDivElement | null>;
|
|
26
|
+
/** Called at the content-swap point — use to switch the displayed layer. */
|
|
27
|
+
onSwapLayer: (newLayerId: string) => void;
|
|
28
|
+
/** Called when a zoom-out swap happens — use to update the nav stack. */
|
|
29
|
+
onSwapBack: () => void;
|
|
30
|
+
/** Called after the transition fully completes. */
|
|
31
|
+
onComplete: () => void;
|
|
32
|
+
/**
|
|
33
|
+
* GPU CSS-transform mode: called each rAF frame instead of applyViewport.
|
|
34
|
+
* When provided, the caller is responsible for moving the canvas content.
|
|
35
|
+
*/
|
|
36
|
+
onViewportFrame?: (vp: AnimatorViewport) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Called once when the animation reaches its final viewport.
|
|
39
|
+
* When provided, replaces the default applyViewport call at completion.
|
|
40
|
+
*/
|
|
41
|
+
onViewportFinal?: (vp: AnimatorViewport) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Called at phase-change points ('swap', 'zoom-back') in addition to the
|
|
44
|
+
* internal content-swap logic. Use to reset CSS transforms before Konva redraws.
|
|
45
|
+
*/
|
|
46
|
+
onPhaseChange?: (phase: string) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Called with 'start' before the animator fires its first frame and with
|
|
49
|
+
* 'end' after onComplete. Use to set up / tear down GPU hints (willChange, etc.).
|
|
50
|
+
*/
|
|
51
|
+
onTransitionLifecycle?: (phase: 'start' | 'end') => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LayerTransitionHandlers {
|
|
55
|
+
zoomIntoLayer: (
|
|
56
|
+
doc: VisualliDocument,
|
|
57
|
+
targetNode: FlatNode,
|
|
58
|
+
childLayerId: string,
|
|
59
|
+
childNodes: FlatNode[],
|
|
60
|
+
fromColor: string,
|
|
61
|
+
toColor: string,
|
|
62
|
+
) => void;
|
|
63
|
+
zoomOutToParent: (
|
|
64
|
+
parentViewport: AnimatorViewport,
|
|
65
|
+
fromColor: string,
|
|
66
|
+
toColor: string,
|
|
67
|
+
) => void;
|
|
68
|
+
isTransitioning: () => boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useKonvaLayerTransition(opts: LayerTransitionOptions): LayerTransitionHandlers {
|
|
72
|
+
const {
|
|
73
|
+
stageRef, canvasWrapperRef, bgRef,
|
|
74
|
+
onSwapLayer, onSwapBack, onComplete,
|
|
75
|
+
onViewportFrame, onViewportFinal, onPhaseChange: onPhaseChangeExt,
|
|
76
|
+
onTransitionLifecycle,
|
|
77
|
+
} = opts;
|
|
78
|
+
const setZoom = useViewportStore(s => s.setZoom);
|
|
79
|
+
const setCenter = useViewportStore(s => s.setCenter);
|
|
80
|
+
const getState = useViewportStore.getState;
|
|
81
|
+
|
|
82
|
+
const swappedRef = useRef(false);
|
|
83
|
+
const canvasSizeRef = useRef({ width: 800, height: 600 });
|
|
84
|
+
|
|
85
|
+
// Keep canvas size ref fresh from viewport store (updated by ResizeObserver in useKonvaRenderer)
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const update = () => {
|
|
88
|
+
const vp = useViewportStore.getState();
|
|
89
|
+
canvasSizeRef.current = { width: vp.canvasWidth || 800, height: vp.canvasHeight || 600 };
|
|
90
|
+
};
|
|
91
|
+
update();
|
|
92
|
+
// Subscribe to viewport store changes instead of window resize
|
|
93
|
+
const unsubscribe = useViewportStore.subscribe(update);
|
|
94
|
+
return unsubscribe;
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
// Apply viewport to Konva stage imperatively (no React re-render per frame)
|
|
98
|
+
const applyViewport = useCallback((vp: AnimatorViewport) => {
|
|
99
|
+
const stage = stageRef.current;
|
|
100
|
+
if (!stage) return;
|
|
101
|
+
const { width, height } = canvasSizeRef.current;
|
|
102
|
+
stage.x(width / 2 - vp.centerX * vp.zoomLevel);
|
|
103
|
+
stage.y(height / 2 - vp.centerY * vp.zoomLevel);
|
|
104
|
+
stage.scaleX(vp.zoomLevel);
|
|
105
|
+
stage.scaleY(vp.zoomLevel);
|
|
106
|
+
stage.getLayers().forEach(l => l.drawScene());
|
|
107
|
+
// Sync to Zustand so viewport store reflects reality
|
|
108
|
+
setCenter(vp.centerX, vp.centerY);
|
|
109
|
+
setZoom(vp.zoomLevel);
|
|
110
|
+
}, [stageRef, setCenter, setZoom]);
|
|
111
|
+
|
|
112
|
+
const applyOpacity = useCallback((opacity: number) => {
|
|
113
|
+
if (canvasWrapperRef.current) {
|
|
114
|
+
canvasWrapperRef.current.style.opacity = String(opacity);
|
|
115
|
+
}
|
|
116
|
+
}, [canvasWrapperRef]);
|
|
117
|
+
|
|
118
|
+
const applyBgColor = useCallback((color: string) => {
|
|
119
|
+
if (bgRef?.current) {
|
|
120
|
+
bgRef.current.style.backgroundColor = color;
|
|
121
|
+
}
|
|
122
|
+
}, [bgRef]);
|
|
123
|
+
|
|
124
|
+
const zoomIntoLayer = useCallback((
|
|
125
|
+
_doc: VisualliDocument,
|
|
126
|
+
targetNode: FlatNode,
|
|
127
|
+
childLayerId: string,
|
|
128
|
+
childNodes: FlatNode[],
|
|
129
|
+
fromColor: string,
|
|
130
|
+
toColor: string,
|
|
131
|
+
): void => {
|
|
132
|
+
if (konvaLayerTransitionAnimator.active) return;
|
|
133
|
+
|
|
134
|
+
const vp = getState();
|
|
135
|
+
const stage = stageRef.current;
|
|
136
|
+
const w = stage?.width() ?? 800;
|
|
137
|
+
const h = stage?.height() ?? 600;
|
|
138
|
+
const fit = calculateFitView(childNodes, w, h);
|
|
139
|
+
|
|
140
|
+
swappedRef.current = false;
|
|
141
|
+
|
|
142
|
+
onTransitionLifecycle?.('start');
|
|
143
|
+
konvaLayerTransitionAnimator.animateZoomIntoLayer(
|
|
144
|
+
{ centerX: vp.centerX, centerY: vp.centerY, zoomLevel: vp.zoomLevel },
|
|
145
|
+
{ x: targetNode.x, y: targetNode.y, width: targetNode.width, height: targetNode.height },
|
|
146
|
+
{ x: fit.centerX, y: fit.centerY },
|
|
147
|
+
fromColor,
|
|
148
|
+
toColor,
|
|
149
|
+
{
|
|
150
|
+
onUpdate: ({ viewport, opacity, backgroundColor }) => {
|
|
151
|
+
if (onViewportFrame) { onViewportFrame(viewport); } else { applyViewport(viewport); }
|
|
152
|
+
applyOpacity(opacity);
|
|
153
|
+
applyBgColor(backgroundColor);
|
|
154
|
+
},
|
|
155
|
+
onComplete: () => {
|
|
156
|
+
const finalVp = { centerX: fit.centerX, centerY: fit.centerY, zoomLevel: fit.zoomLevel };
|
|
157
|
+
if (onViewportFinal) { onViewportFinal(finalVp); } else { applyViewport(finalVp); }
|
|
158
|
+
applyOpacity(1);
|
|
159
|
+
onComplete();
|
|
160
|
+
onTransitionLifecycle?.('end');
|
|
161
|
+
},
|
|
162
|
+
onPhaseChange: (phase) => {
|
|
163
|
+
onPhaseChangeExt?.(phase);
|
|
164
|
+
if (phase === 'swap' && !swappedRef.current) {
|
|
165
|
+
swappedRef.current = true;
|
|
166
|
+
onSwapLayer(childLayerId);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
fit.zoomLevel,
|
|
171
|
+
w,
|
|
172
|
+
h,
|
|
173
|
+
);
|
|
174
|
+
}, [stageRef, getState, applyViewport, applyOpacity, applyBgColor, onSwapLayer, onComplete, onViewportFrame, onViewportFinal, onPhaseChangeExt, onTransitionLifecycle]);
|
|
175
|
+
|
|
176
|
+
const zoomOutToParent = useCallback((
|
|
177
|
+
parentViewport: AnimatorViewport,
|
|
178
|
+
fromColor: string,
|
|
179
|
+
toColor: string,
|
|
180
|
+
): void => {
|
|
181
|
+
if (konvaLayerTransitionAnimator.active) return;
|
|
182
|
+
|
|
183
|
+
const vp = getState();
|
|
184
|
+
swappedRef.current = false;
|
|
185
|
+
|
|
186
|
+
onTransitionLifecycle?.('start');
|
|
187
|
+
konvaLayerTransitionAnimator.animateZoomOutToParent(
|
|
188
|
+
{ centerX: vp.centerX, centerY: vp.centerY, zoomLevel: vp.zoomLevel },
|
|
189
|
+
parentViewport,
|
|
190
|
+
fromColor,
|
|
191
|
+
toColor,
|
|
192
|
+
{
|
|
193
|
+
onUpdate: ({ viewport, opacity, backgroundColor }) => {
|
|
194
|
+
if (onViewportFrame) { onViewportFrame(viewport); } else { applyViewport(viewport); }
|
|
195
|
+
applyOpacity(opacity);
|
|
196
|
+
applyBgColor(backgroundColor);
|
|
197
|
+
},
|
|
198
|
+
onComplete: () => {
|
|
199
|
+
if (onViewportFinal) { onViewportFinal(parentViewport); } else { applyViewport(parentViewport); }
|
|
200
|
+
applyOpacity(1);
|
|
201
|
+
onComplete();
|
|
202
|
+
onTransitionLifecycle?.('end');
|
|
203
|
+
},
|
|
204
|
+
onPhaseChange: (phase) => {
|
|
205
|
+
onPhaseChangeExt?.(phase);
|
|
206
|
+
if (phase === 'zoom-back' && !swappedRef.current) {
|
|
207
|
+
swappedRef.current = true;
|
|
208
|
+
onSwapBack();
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
}, [getState, applyViewport, applyOpacity, applyBgColor, onSwapBack, onComplete, onViewportFrame, onViewportFinal, onPhaseChangeExt, onTransitionLifecycle]);
|
|
214
|
+
|
|
215
|
+
const isTransitioning = useCallback(() => konvaLayerTransitionAnimator.active, []);
|
|
216
|
+
|
|
217
|
+
// Cancel on unmount
|
|
218
|
+
useEffect(() => () => { konvaLayerTransitionAnimator.cancel(); }, []);
|
|
219
|
+
|
|
220
|
+
return { zoomIntoLayer, zoomOutToParent, isTransitioning };
|
|
221
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// ─── useKonvaRenderer ─────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Manages Konva rendering lifecycle: resize, pan, zoom, and FPS monitoring.
|
|
4
|
+
// Returns Konva-level event handlers to be wired onto KonvaStage.
|
|
5
|
+
//
|
|
6
|
+
// Design matches visualli.ai's useKonvaRenderer:
|
|
7
|
+
// - handleWheel / handleMouseDown / handleMouseMove / handleMouseUp are
|
|
8
|
+
// Konva.KonvaEventObject handlers (not DOM-level)
|
|
9
|
+
// - Pan is applied directly to the stage during drag for 60 fps, flushed to
|
|
10
|
+
// Zustand store on mouseUp (no per-frame state updates)
|
|
11
|
+
// - Zoom targets the cursor position (same math as visualli.ai)
|
|
12
|
+
|
|
13
|
+
import { useEffect, useCallback, useRef, useState } from 'react';
|
|
14
|
+
import type Konva from 'konva';
|
|
15
|
+
import { ZOOM_MIN, ZOOM_MAX } from '@visualli/core';
|
|
16
|
+
import { useViewportStore } from '../stores/useViewportStore';
|
|
17
|
+
import { useRenderConfigStore } from '../stores/stores';
|
|
18
|
+
|
|
19
|
+
export interface UseKonvaRendererOptions {
|
|
20
|
+
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
21
|
+
stageRef?: React.RefObject<Konva.Stage | null>;
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UseKonvaRendererReturn {
|
|
26
|
+
handleWheel: (e: Konva.KonvaEventObject<WheelEvent>) => void;
|
|
27
|
+
handleMouseDown:(e: Konva.KonvaEventObject<MouseEvent>) => void;
|
|
28
|
+
handleMouseMove:(e: Konva.KonvaEventObject<MouseEvent>) => void;
|
|
29
|
+
handleMouseUp: (e: Konva.KonvaEventObject<MouseEvent>) => void;
|
|
30
|
+
canvasWidth: number;
|
|
31
|
+
canvasHeight: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useKonvaRenderer({
|
|
35
|
+
containerRef,
|
|
36
|
+
stageRef,
|
|
37
|
+
enabled = true,
|
|
38
|
+
}: UseKonvaRendererOptions): UseKonvaRendererReturn {
|
|
39
|
+
const pan = useViewportStore(s => s.pan);
|
|
40
|
+
const zoom = useViewportStore(s => s.zoom);
|
|
41
|
+
const setZoom = useViewportStore(s => s.setZoom);
|
|
42
|
+
const setCenter = useViewportStore(s => s.setCenter);
|
|
43
|
+
const updateCanvasSize = useViewportStore(s => s.updateCanvasSize);
|
|
44
|
+
const autoAdjust = useRenderConfigStore(s => s.autoAdjustQuality);
|
|
45
|
+
|
|
46
|
+
const [canvasWidth, setCanvasWidth] = useState(0);
|
|
47
|
+
const [canvasHeight, setCanvasHeight] = useState(0);
|
|
48
|
+
const cachedSizeRef = useRef({ width: 0, height: 0 });
|
|
49
|
+
|
|
50
|
+
const isPanningRef = useRef(false);
|
|
51
|
+
const lastPanPointRef = useRef({ x: 0, y: 0 });
|
|
52
|
+
const panAccumRef = useRef({ x: 0, y: 0 });
|
|
53
|
+
|
|
54
|
+
// ── FPS monitor ──────────────────────────────────────────────────────────────
|
|
55
|
+
const fpsRef = useRef({ frames: 0, last: performance.now() });
|
|
56
|
+
const fpsRafId = useRef<number | null>(null);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!enabled) return;
|
|
60
|
+
const tick = (now: number): void => {
|
|
61
|
+
fpsRef.current.frames++;
|
|
62
|
+
if (now - fpsRef.current.last >= 2000) {
|
|
63
|
+
const fps = (fpsRef.current.frames / ((now - fpsRef.current.last) / 1000)) | 0;
|
|
64
|
+
autoAdjust(fps);
|
|
65
|
+
fpsRef.current = { frames: 0, last: now };
|
|
66
|
+
}
|
|
67
|
+
fpsRafId.current = requestAnimationFrame(tick);
|
|
68
|
+
};
|
|
69
|
+
fpsRafId.current = requestAnimationFrame(tick);
|
|
70
|
+
return () => { if (fpsRafId.current !== null) cancelAnimationFrame(fpsRafId.current); };
|
|
71
|
+
}, [enabled, autoAdjust]);
|
|
72
|
+
|
|
73
|
+
// ── Resize ────────────────────────────────────────────────────────────────────
|
|
74
|
+
// Observes the container element (not window) so the canvas always fills
|
|
75
|
+
// its parent, regardless of how the renderer is sized by the consuming app.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!enabled) return;
|
|
78
|
+
|
|
79
|
+
let rafId: number | null = null;
|
|
80
|
+
|
|
81
|
+
const measure = () => {
|
|
82
|
+
const el = containerRef.current;
|
|
83
|
+
const w = el ? Math.round(el.getBoundingClientRect().width) : 800;
|
|
84
|
+
const h = el ? Math.round(el.getBoundingClientRect().height) : 600;
|
|
85
|
+
return { w, h };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const updateSize = () => {
|
|
89
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
90
|
+
rafId = requestAnimationFrame(() => {
|
|
91
|
+
rafId = null;
|
|
92
|
+
const { w, h } = measure();
|
|
93
|
+
if (w > 0 && h > 0 &&
|
|
94
|
+
(cachedSizeRef.current.width !== w || cachedSizeRef.current.height !== h)) {
|
|
95
|
+
cachedSizeRef.current = { width: w, height: h };
|
|
96
|
+
setCanvasWidth(w);
|
|
97
|
+
setCanvasHeight(h);
|
|
98
|
+
updateCanvasSize(w, h);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Initial measurement
|
|
104
|
+
updateSize();
|
|
105
|
+
|
|
106
|
+
// ResizeObserver tracks the container itself (handles % / flex / grid sizing)
|
|
107
|
+
let ro: ResizeObserver | null = null;
|
|
108
|
+
if (typeof ResizeObserver !== 'undefined' && containerRef.current) {
|
|
109
|
+
ro = new ResizeObserver(updateSize);
|
|
110
|
+
ro.observe(containerRef.current);
|
|
111
|
+
} else {
|
|
112
|
+
// Fallback: window resize only
|
|
113
|
+
window.addEventListener('resize', updateSize);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
ro ? ro.disconnect() : window.removeEventListener('resize', updateSize);
|
|
118
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
119
|
+
};
|
|
120
|
+
}, [enabled, containerRef, updateCanvasSize]);
|
|
121
|
+
|
|
122
|
+
// ── Wheel zoom ────────────────────────────────────────────────────────────────
|
|
123
|
+
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>): void => {
|
|
124
|
+
e.evt.preventDefault();
|
|
125
|
+
const stage = e.target.getStage();
|
|
126
|
+
if (!stage) return;
|
|
127
|
+
const pointer = stage.getPointerPosition();
|
|
128
|
+
if (!pointer) return;
|
|
129
|
+
const delta = -e.evt.deltaY / 1000;
|
|
130
|
+
zoom(delta, pointer.x, pointer.y);
|
|
131
|
+
}, [zoom]);
|
|
132
|
+
|
|
133
|
+
// ── Mouse pan ─────────────────────────────────────────────────────────────────
|
|
134
|
+
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>): void => {
|
|
135
|
+
const clickedOnEmpty = e.target === e.target.getStage();
|
|
136
|
+
if (clickedOnEmpty && e.evt.button === 0) {
|
|
137
|
+
e.evt.preventDefault();
|
|
138
|
+
isPanningRef.current = true;
|
|
139
|
+
lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY };
|
|
140
|
+
panAccumRef.current = { x: 0, y: 0 };
|
|
141
|
+
const stage = e.target.getStage();
|
|
142
|
+
if (stage) stage.container().style.cursor = 'grabbing';
|
|
143
|
+
}
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>): void => {
|
|
147
|
+
if (!isPanningRef.current) {
|
|
148
|
+
// Update cursor on empty canvas
|
|
149
|
+
const isOverEmpty = e.target === e.target.getStage();
|
|
150
|
+
const stage = e.target.getStage();
|
|
151
|
+
if (stage) stage.container().style.cursor = isOverEmpty ? 'grab' : 'default';
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
e.evt.preventDefault();
|
|
155
|
+
const deltaX = e.evt.clientX - lastPanPointRef.current.x;
|
|
156
|
+
const deltaY = e.evt.clientY - lastPanPointRef.current.y;
|
|
157
|
+
|
|
158
|
+
// Apply pan directly to stage (no Zustand overhead per-frame)
|
|
159
|
+
const stage = e.target.getStage();
|
|
160
|
+
if (stage) {
|
|
161
|
+
stage.x(stage.x() + deltaX);
|
|
162
|
+
stage.y(stage.y() + deltaY);
|
|
163
|
+
stage.batchDraw();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
panAccumRef.current.x += deltaX;
|
|
167
|
+
panAccumRef.current.y += deltaY;
|
|
168
|
+
lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY };
|
|
169
|
+
}, []); // No Zustand dep — pan flushed on mouseUp
|
|
170
|
+
|
|
171
|
+
const handleMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>): void => {
|
|
172
|
+
if (isPanningRef.current) {
|
|
173
|
+
isPanningRef.current = false;
|
|
174
|
+
const { x, y } = panAccumRef.current;
|
|
175
|
+
if (x !== 0 || y !== 0) {
|
|
176
|
+
pan(x, y);
|
|
177
|
+
panAccumRef.current = { x: 0, y: 0 };
|
|
178
|
+
}
|
|
179
|
+
const stage = e.target.getStage();
|
|
180
|
+
if (stage) stage.container().style.cursor = 'grab';
|
|
181
|
+
}
|
|
182
|
+
}, [pan]);
|
|
183
|
+
|
|
184
|
+
return { handleWheel, handleMouseDown, handleMouseMove, handleMouseUp, canvasWidth, canvasHeight };
|
|
185
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ─── useViewportNodes ─────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Returns the subset of nodes currently visible in the viewport.
|
|
4
|
+
// Signature: useViewportNodes(nodes, levelFilter?, skipCulling?)
|
|
5
|
+
//
|
|
6
|
+
// When skipCulling=true (e.g. during layer transitions) all nodes are returned
|
|
7
|
+
// so the full layer is rendered when opacity ramps in.
|
|
8
|
+
|
|
9
|
+
import { useMemo, useRef } from 'react';
|
|
10
|
+
import type { FlatNode } from '@visualli/core';
|
|
11
|
+
import { RBushSpatialIndex, getViewportVisibleNodes } from '@visualli/core';
|
|
12
|
+
import { useViewportStore } from '../stores/useViewportStore';
|
|
13
|
+
|
|
14
|
+
const SPATIAL_INDEX_THRESHOLD = 200;
|
|
15
|
+
|
|
16
|
+
export function useViewportNodes(
|
|
17
|
+
nodes: FlatNode[],
|
|
18
|
+
levelFilter?: number,
|
|
19
|
+
skipCulling = false,
|
|
20
|
+
/** When true, skip culling so dragged nodes are always visible and the
|
|
21
|
+
* spatial index (keyed on layout positions) is not consulted. */
|
|
22
|
+
isDragging = false,
|
|
23
|
+
): FlatNode[] {
|
|
24
|
+
const { centerX, centerY, zoomLevel, canvasWidth, canvasHeight } = useViewportStore();
|
|
25
|
+
|
|
26
|
+
const indexRef = useRef<RBushSpatialIndex | null>(null);
|
|
27
|
+
const nodesHashRef = useRef<number>(0);
|
|
28
|
+
const nodesHash = nodes.length;
|
|
29
|
+
|
|
30
|
+
if (nodesHash !== nodesHashRef.current) {
|
|
31
|
+
nodesHashRef.current = nodesHash;
|
|
32
|
+
indexRef.current = null; // rebuild below
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return useMemo(() => {
|
|
36
|
+
if (nodes.length === 0) return [];
|
|
37
|
+
|
|
38
|
+
if (!indexRef.current && nodes.length >= SPATIAL_INDEX_THRESHOLD && !skipCulling && !isDragging) {
|
|
39
|
+
indexRef.current = new RBushSpatialIndex();
|
|
40
|
+
indexRef.current.bulkLoad(nodes.map(n => ({
|
|
41
|
+
nodeId: n.id,
|
|
42
|
+
bounds: { minX: n.x, minY: n.y, maxX: n.x + n.width, maxY: n.y + n.height },
|
|
43
|
+
})));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return getViewportVisibleNodes({
|
|
47
|
+
nodes,
|
|
48
|
+
viewport: { centerX, centerY, zoomLevel, rotation: 0, visibleBounds: { minX: 0, minY: 0, maxX: 0, maxY: 0 } },
|
|
49
|
+
canvasWidth,
|
|
50
|
+
canvasHeight,
|
|
51
|
+
levelFilter,
|
|
52
|
+
skipCulling,
|
|
53
|
+
isDragging,
|
|
54
|
+
spatialIndex: indexRef.current,
|
|
55
|
+
spatialIndexThreshold: SPATIAL_INDEX_THRESHOLD,
|
|
56
|
+
});
|
|
57
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
58
|
+
}, [nodes, levelFilter, skipCulling, isDragging, centerX, centerY, zoomLevel, canvasWidth, canvasHeight]);
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// ─── @visualli/react ─────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
// ── Primary component (recommended for public use) ────────────────────────────────────────
|
|
4
|
+
export { default as VisualliRenderer } from './VisualliRenderer';
|
|
5
|
+
export type { VisualliRendererProps, VisualliTheme } from './VisualliRenderer';
|
|
6
|
+
|
|
7
|
+
// ── Canvas component (for advanced integrations) ───────────────────────────────────────────
|
|
8
|
+
export { default as VisualliCanvas } from './VisualliCanvas';
|
|
9
|
+
export type { VisualliCanvasProps } from './VisualliCanvas';
|
|
10
|
+
|
|
11
|
+
// ── Context and Provider ──────────────────────────────────────────────────────
|
|
12
|
+
export { VisualliProvider, useVisualli } from './context/VisualliContext';
|
|
13
|
+
export type {
|
|
14
|
+
VisualliProviderProps,
|
|
15
|
+
VisualliContextValue
|
|
16
|
+
} from './context/VisualliContext';
|
|
17
|
+
|
|
18
|
+
// Stores
|
|
19
|
+
export { useNodeStore } from './stores/useNodeStore';
|
|
20
|
+
export { useViewportStore } from './stores/useViewportStore';
|
|
21
|
+
export { useSelectionStore, useRenderConfigStore } from './stores/stores';
|
|
22
|
+
export type { INodeStore } from './stores/useNodeStore';
|
|
23
|
+
export type { IViewportStore } from './stores/useViewportStore';
|
|
24
|
+
|
|
25
|
+
// Hooks
|
|
26
|
+
export { useKonvaRenderer } from './hooks/useKonvaRenderer';
|
|
27
|
+
export { useKonvaLayerTransition } from './hooks/useKonvaLayerTransition';
|
|
28
|
+
export { useViewportNodes } from './hooks/useViewportNodes';
|
|
29
|
+
export type { UseKonvaRendererOptions, UseKonvaRendererReturn } from './hooks/useKonvaRenderer';
|
|
30
|
+
export type { LayerTransitionHandlers } from './hooks/useKonvaLayerTransition';
|
|
31
|
+
|
|
32
|
+
// Animations
|
|
33
|
+
export { KonvaLayerTransitionAnimator, konvaLayerTransitionAnimator } from './animations/konvaLayerTransition';
|
|
34
|
+
export type { AnimatorViewport, TransitionCallbacks } from './animations/konvaLayerTransition';
|
|
35
|
+
|
|
36
|
+
// Config helpers
|
|
37
|
+
export {
|
|
38
|
+
ALL_BLOB_SHAPES,
|
|
39
|
+
ACTIVE_BLOB_TYPES,
|
|
40
|
+
BLOB_LAYER_CONFIG,
|
|
41
|
+
NODE_LAYER_CONFIG,
|
|
42
|
+
getBlobTypeForLayer,
|
|
43
|
+
BLOB_TEXT_OFFSETS,
|
|
44
|
+
drawBlobPath,
|
|
45
|
+
buildBlobPathData,
|
|
46
|
+
NODE_TEXT_BASE_FONT_PX,
|
|
47
|
+
DESCRIPTION_TEXT_BASE_FONT_PX,
|
|
48
|
+
computeNodeTextWorldScale,
|
|
49
|
+
computeNodeTextScreenScale,
|
|
50
|
+
computeOverlayScale,
|
|
51
|
+
computeEdgeLabelScale,
|
|
52
|
+
} from './config';
|
|
53
|
+
|
|
54
|
+
// Utils
|
|
55
|
+
export {
|
|
56
|
+
getConnectionsForLayer,
|
|
57
|
+
getChildLayerForNode,
|
|
58
|
+
getLayerForNavigation,
|
|
59
|
+
calculateFitZoom,
|
|
60
|
+
calculateFitCenter,
|
|
61
|
+
calculateFitView,
|
|
62
|
+
} from './utils/layerNavigation';
|
|
63
|
+
export type { FitResult } from './utils/layerNavigation';
|
|
64
|
+
|
|
65
|
+
// Sub-components (for advanced usage / composition)
|
|
66
|
+
export { default as KonvaStage } from './components/KonvaStage';
|
|
67
|
+
export { default as KonvaNode } from './components/KonvaNode';
|
|
68
|
+
export { default as KonvaEdge } from './components/KonvaEdge';
|
|
69
|
+
export { default as KonvaNodeLayer } from './components/KonvaNodeLayer';
|
|
70
|
+
export { default as KonvaEdgeLayer } from './components/KonvaEdgeLayer';
|
|
71
|
+
export { default as KonvaContainer } from './components/KonvaContainer';
|
|
72
|
+
export { default as KonvaContainerLayer } from './components/KonvaContainerLayer';
|
|
73
|
+
export { default as NavigationStack } from './components/NavigationStack';
|
|
74
|
+
export { default as ZoomControls } from './components/ZoomControls';
|
|
75
|
+
export { default as SketchyBoxKonva } from './components/SketchyBoxKonva';
|
|
76
|
+
export type { NavStackEntry } from './components/NavigationStack';
|
|
77
|
+
export type { KonvaNodeProps } from './components/KonvaNode';
|
|
78
|
+
export type { KonvaEdgeProps } from './components/KonvaEdge';
|
|
79
|
+
export type { KonvaStageProps } from './components/KonvaStage';
|
|
80
|
+
export type { ContainerGroup } from './components/KonvaContainerLayer';
|
|
81
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ─── konvaCompat ──────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// react-konva@18.x defines KonvaNodeComponent with return type
|
|
4
|
+
// `ReactNode | Promise<ReactNode>`. @types/react v19 tightened the JSX checker
|
|
5
|
+
// so that signature no longer satisfies `(props: any) => ReactNode`, causing
|
|
6
|
+
// TS2786 errors everywhere a Konva primitive is used as JSX.
|
|
7
|
+
//
|
|
8
|
+
// The fix: re-export every Konva primitive cast to React.ComponentType<any>.
|
|
9
|
+
// This is purely a type-level shim — no runtime behaviour changes.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
Stage, Layer, Group, Shape, Path, Rect, Text, Arrow, Line, Circle, TextPath,
|
|
13
|
+
} from 'react-konva';
|
|
14
|
+
import type React from 'react';
|
|
15
|
+
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
|
+
type C = React.ComponentType<any>;
|
|
18
|
+
|
|
19
|
+
export const KStage = Stage as unknown as C;
|
|
20
|
+
export const KLayer = Layer as unknown as C;
|
|
21
|
+
export const KGroup = Group as unknown as C;
|
|
22
|
+
export const KShape = Shape as unknown as C;
|
|
23
|
+
export const KPath = Path as unknown as C;
|
|
24
|
+
export const KRect = Rect as unknown as C;
|
|
25
|
+
export const KText = Text as unknown as C;
|
|
26
|
+
export const KArrow = Arrow as unknown as C;
|
|
27
|
+
export const KLine = Line as unknown as C;
|
|
28
|
+
export const KCircle = Circle as unknown as C;
|
|
29
|
+
export const KTextPath = TextPath as unknown as C;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// ─── Selection Store ──────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
import { selectionStore, renderConfigStore } from '@visualli/core';
|
|
5
|
+
import type { ISelectionStore, IRenderConfigStore } from '@visualli/core';
|
|
6
|
+
|
|
7
|
+
export const useSelectionStore = create<ISelectionStore>((set, get, store) => {
|
|
8
|
+
selectionStore.subscribe((state) => set(state));
|
|
9
|
+
return selectionStore.getState();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// ─── Render Config Store ──────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const useRenderConfigStore = create<IRenderConfigStore>((set, get, store) => {
|
|
15
|
+
renderConfigStore.subscribe((state) => set(state));
|
|
16
|
+
return renderConfigStore.getState();
|
|
17
|
+
});
|