}) {
deleteSelected();
};
+ const panAway = () => {
+ // Pan a large amount to move current nodes off-screen for demo
+ panBy(2000, 1500);
+ };
+
return (
}) {
Delete Selected
count: {nodes.length}
+ {showHistoryControls ? (
+ <>
+ |
+
+
+
+ >
+ ) : null}
);
}
@@ -310,7 +407,13 @@ function Playground(args: CanvasStoryArgs) {
panModifier: args.panModifier,
wheelZoom: args.wheelZoom,
wheelModifier: args.wheelModifier,
- wheelSensitivity: args.wheelSensitivity,
+ wheelBehavior: args.wheelBehavior,
+ touchpadZoomSensitivityIn: args.touchpadZoomSensitivityIn,
+ touchpadZoomSensitivityOut: args.touchpadZoomSensitivityOut,
+ mouseZoomSensitivityIn: args.mouseZoomSensitivityIn,
+ mouseZoomSensitivityOut: args.mouseZoomSensitivityOut,
+ touchpadPanScale: args.touchpadPanScale,
+ mousePanScale: args.mousePanScale,
doubleClickZoom: args.doubleClickZoom,
doubleClickZoomFactor: args.doubleClickZoomFactor,
doubleClickZoomOut: args.doubleClickZoomOut,
@@ -334,12 +437,23 @@ function Playground(args: CanvasStoryArgs) {
}}
tabIndex={args.tabIndex}
background={
-
+ args.bgVariant === 'cells' ? (
+
+ ) : (
+
+ )
}
>
@@ -387,8 +501,50 @@ function Playground(args: CanvasStoryArgs) {
• Canvas auto-focuses on pointer interactions
• To disable auto-focus, set Canvas tabIndex to -1 (see Controls)
+
+
Wheel & Zoom
+
• Auto mode: wheel pans vertically; Shift+wheel pans horizontally
+
• Ctrl+wheel zooms (mouse & touchpad pinch)
+
• Default zoom bounds: 60–240% (0.6–2.4)
+
+ {args.showHistoryPanel ? (
+
+
History & Camera
+
• Camera pans are not recorded in history
+
• Undo/Redo of camera-only changes are no-ops
+
+ • When nodes are re-added by Undo/Redo and are off-screen, the camera recenters
+
+
+ • Try: add a node, remove it, Pan Away, then Undo — view recenters on the restored
+ node
+
+
+ ) : null}
-
+
);
@@ -397,7 +553,20 @@ function Playground(args: CanvasStoryArgs) {
export const Basic: Story = {
args: {
tabIndex: 10,
+ mouseZoomSensitivityIn: 0.03,
+ mouseZoomSensitivityOut: 0.03,
+ mousePanScale: 15,
+ touchpadZoomSensitivityIn: 0.01,
+ touchpadZoomSensitivityOut: 0.01,
},
render: (args) => ,
};
+
+export const HistoryAndCamera: Story = {
+ name: 'History & Camera',
+ args: {
+ showHistoryPanel: true,
+ },
+ render: (args) => ,
+};
diff --git a/src/react/HistoryCamera.integration.test.tsx b/src/react/HistoryCamera.integration.test.tsx
new file mode 100644
index 0000000..645791c
--- /dev/null
+++ b/src/react/HistoryCamera.integration.test.tsx
@@ -0,0 +1,86 @@
+/* @vitest-environment jsdom */
+
+import React, { useRef, act } from 'react';
+import ReactDOM from 'react-dom/client';
+import { describe, it, expect, beforeEach } from 'vitest';
+import { useCanvasNavigation } from './useCanvasNavigation';
+import { useCanvasStore } from '../state/store';
+import type { CanvasStore } from '../state/store';
+
+function TestHost() {
+ const ref = useRef(null);
+ // Mount navigation to simulate a real canvas host; disable zoom shortcuts to avoid warnings/noise
+ useCanvasNavigation(ref, {
+ panButton: 0,
+ panModifier: 'none',
+ wheelZoom: false,
+ doubleClickZoom: false,
+ });
+ return ;
+}
+
+async function render(ui: React.ReactElement) {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = ReactDOM.createRoot(container);
+ await act(async () => {
+ root.render(ui);
+ });
+ // wait microtask + macrotask to allow effects to mount
+ await Promise.resolve();
+ await new Promise((r) => setTimeout(r, 0));
+ return {
+ container,
+ root,
+ unmount: async () => {
+ await act(async () => {
+ root.unmount();
+ });
+ },
+ };
+}
+
+function resetStore() {
+ useCanvasStore.setState({
+ camera: { zoom: 1, offsetX: 0, offsetY: 0 },
+ nodes: {},
+ selected: {},
+ centerAddIndex: 0,
+ historyPast: [],
+ historyFuture: [],
+ historyBatch: null,
+ } as Partial);
+}
+
+describe('React integration: undo re-add recenters camera when node is off-screen', () => {
+ beforeEach(() => {
+ resetStore();
+ // Ensure viewport size is deterministic for camera centering logic
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 800 });
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 });
+ // Some React DOM codepaths check global Window constructor; provide a fallback in jsdom
+ if (!(globalThis as unknown as { Window?: unknown }).Window) {
+ (globalThis as unknown as { Window: unknown }).Window = (window as unknown as { constructor: unknown }).constructor;
+ }
+ });
+
+ it('recenters viewport to re-added node bbox center on undo remove', async () => {
+ const { unmount } = await render();
+
+ // Arrange: add node at origin, pan far away so it is off-screen, then remove
+ useCanvasStore.getState().addNode({ id: 'ri1', x: 0, y: 0, width: 100, height: 60 });
+ useCanvasStore.getState().panBy(5000, 5000);
+ useCanvasStore.getState().removeNode('ri1');
+
+ // Act: undo -> should re-add and center camera to node bbox
+ useCanvasStore.getState().undo();
+
+ const s = useCanvasStore.getState();
+ expect(s.nodes['ri1']).toBeDefined();
+ // With zoom=1 and window 800x600, center of node (50,30) -> offset should be -350,-270
+ expect(s.camera.offsetX).toBeCloseTo(-350, 6);
+ expect(s.camera.offsetY).toBeCloseTo(-270, 6);
+
+ await unmount();
+ });
+});
diff --git a/src/react/useCanvasNavigation.test.tsx b/src/react/useCanvasNavigation.test.tsx
index fc29670..b2cc1c7 100644
--- a/src/react/useCanvasNavigation.test.tsx
+++ b/src/react/useCanvasNavigation.test.tsx
@@ -48,6 +48,20 @@ function dispatchKey(el: Element, key: string, code?: string, init?: KeyboardEve
el.dispatchEvent(ev);
}
+function dispatchWheel(el: Element, init: WheelEventInit) {
+ const ev = new WheelEvent('wheel', {
+ bubbles: true,
+ cancelable: true,
+ deltaMode: 0,
+ deltaX: 0,
+ deltaY: 0,
+ clientX: 100,
+ clientY: 100,
+ ...init,
+ });
+ el.dispatchEvent(ev);
+}
+
// Reset camera before each test
beforeEach(() => {
useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 });
@@ -172,3 +186,137 @@ describe('useCanvasNavigation keyboard', () => {
unmount();
});
});
+
+describe('useCanvasNavigation wheel / touchpad', () => {
+ beforeEach(() => {
+ useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 });
+ });
+
+ it('pans with two-finger touchpad scroll (deltaMode: pixels, moderate deltas)', async () => {
+ const { container, unmount } = await render();
+ const el = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement;
+
+ // Start fresh
+ useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 });
+
+ // Simulate touchpad two-finger scroll: pixel deltas, moderate magnitude
+ dispatchWheel(el, { deltaMode: 0, deltaX: 30, deltaY: 10, ctrlKey: false });
+
+ const cam = useCanvasStore.getState().camera;
+ expect(cam.offsetX).toBe(30);
+ expect(cam.offsetY).toBe(10);
+
+ unmount();
+ });
+
+ it('touchpad pan ignores wheelModifier (still pans without modifiers)', async () => {
+ const { container, unmount } = await render();
+ const el = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement;
+
+ useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 });
+ // No ctrl pressed, but should still pan because it is touchpad scroll
+ dispatchWheel(el, { deltaMode: 0, deltaX: -20, deltaY: 15, ctrlKey: false });
+
+ const cam = useCanvasStore.getState().camera;
+ expect(cam.offsetX).toBe(-20);
+ expect(cam.offsetY).toBe(15);
+
+ unmount();
+ });
+
+ it('pinch on touchpad (ctrl+wheel) still zooms', async () => {
+ const { container, unmount } = await render();
+ const el = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement;
+
+ useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 });
+
+ // ctrl+wheel with negative deltaY -> zoom in
+ dispatchWheel(el, { ctrlKey: true, deltaY: -100, deltaMode: 0 });
+
+ const cam = useCanvasStore.getState().camera;
+ expect(cam.zoom).toBeCloseTo(Math.exp(0.15), 4); // sensitivity 0.0015 => exp(0.15)
+
+ unmount();
+ });
+
+ it('mouse wheel (deltaMode: lines) pans vertically by default', async () => {
+ const { container, unmount } = await render();
+ const el = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement;
+
+ useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 });
+
+ // Wheel up (deltaY negative) => vertical pan
+ dispatchWheel(el, { deltaMode: 1, deltaY: -3, ctrlKey: false });
+
+ const cam = useCanvasStore.getState().camera;
+ expect(cam.offsetX).toBe(0);
+ expect(cam.offsetY).toBe(3);
+
+ unmount();
+ });
+
+ it('mouse wheel with Shift pans horizontally', async () => {
+ const { container, unmount } = await render();
+ const el = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement;
+
+ useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 });
+
+ // Shift + wheel up (deltaY negative) => horizontal pan to the right
+ dispatchWheel(el, { deltaMode: 1, deltaY: -5, shiftKey: true, ctrlKey: false });
+
+ const cam = useCanvasStore.getState().camera;
+ expect(cam.offsetX).toBe(5);
+ expect(cam.offsetY).toBe(0);
+
+ unmount();
+ });
+
+ it('mouse ctrl+wheel zooms (legacy gesture for mouse)', async () => {
+ const { container, unmount } = await render();
+ const el = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement;
+
+ useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 });
+
+ // Ctrl + wheel up (deltaY negative) => zoom in
+ dispatchWheel(el, { deltaMode: 1, deltaY: -3, ctrlKey: true });
+
+ const cam = useCanvasStore.getState().camera;
+ expect(cam.zoom).toBeCloseTo(Math.exp(0.0015 * 3), 6);
+
+ unmount();
+ });
+
+ it('applies touchpadPanScale to two-finger pan deltas', async () => {
+ const { container, unmount } = await render();
+ const el = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement;
+
+ useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 });
+ // Raw deltas: (30, 10) => scaled by 0.5 => (15, 5)
+ dispatchWheel(el, { deltaMode: 0, deltaX: 30, deltaY: 10, ctrlKey: false });
+
+ const cam = useCanvasStore.getState().camera;
+ expect(cam.offsetX).toBe(15);
+ expect(cam.offsetY).toBe(5);
+
+ unmount();
+ });
+
+ it('applies mousePanScale to mouse wheel pan (vertical and Shift+wheel horizontal)', async () => {
+ const { container, unmount } = await render();
+ const el = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement;
+
+ useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 });
+ // Vertical pan: deltaY -3 => base offsetY +3, scaled x2 => +6
+ dispatchWheel(el, { deltaMode: 1, deltaY: -3, ctrlKey: false });
+ expect(useCanvasStore.getState().camera.offsetX).toBe(0);
+ expect(useCanvasStore.getState().camera.offsetY).toBe(6);
+
+ // Horizontal pan with Shift: deltaY -5 => base offsetX +5, scaled x2 => +10
+ dispatchWheel(el, { deltaMode: 1, deltaY: -5, shiftKey: true, ctrlKey: false });
+ const cam2 = useCanvasStore.getState().camera;
+ expect(cam2.offsetX).toBe(10);
+ expect(cam2.offsetY).toBe(6);
+
+ unmount();
+ });
+});
diff --git a/src/react/useCanvasNavigation.ts b/src/react/useCanvasNavigation.ts
index 7c263c8..93c4d2a 100644
--- a/src/react/useCanvasNavigation.ts
+++ b/src/react/useCanvasNavigation.ts
@@ -7,7 +7,6 @@ export type CanvasNavigationOptions = {
panModifier?: 'none' | 'shift' | 'alt' | 'ctrl';
wheelZoom?: boolean;
wheelModifier?: 'none' | 'shift' | 'alt' | 'ctrl'; // убрать отсюда shift
- wheelSensitivity?: number; // higher = faster zoom
doubleClickZoom?: boolean; // zoom-in on double left click
doubleClickZoomFactor?: number; // factor per double click (e.g., 2)
doubleClickZoomOut?: boolean; // enable zoom-out gesture on double click with modifier
@@ -16,6 +15,18 @@ export type CanvasNavigationOptions = {
keyboardPan?: boolean /** Enable keyboard panning with arrows/WASD */;
keyboardPanStep?: number /** Base step in screen pixels per key press */;
keyboardPanSlowStep?: number /** Slow step in screen pixels when holding Shift */;
+ /** Overall wheel behavior policy. 'auto' enables modern UX: mouse pan (Y/Shift->X), Ctrl+wheel zoom, touchpad pan and pinch zoom. 'zoom' preserves legacy behavior where wheel zooms by default. 'pan' forces wheel to pan, zoom only with Ctrl+wheel/pinch. */
+ wheelBehavior?: 'auto' | 'zoom' | 'pan';
+ /** Touchpad-only zoom sensitivities (pixels-based deltas). If omitted, defaults to 0.0015. */
+ touchpadZoomSensitivityIn?: number;
+ touchpadZoomSensitivityOut?: number;
+ /** Mouse Ctrl+wheel zoom sensitivities. If omitted, defaults to 0.0015. */
+ mouseZoomSensitivityIn?: number;
+ mouseZoomSensitivityOut?: number;
+ /** Scale multiplier for touchpad two-finger wheel panning (screen-space deltas). Default: 1 */
+ touchpadPanScale?: number;
+ /** Scale multiplier for mouse wheel panning (screen-space deltas, incl. Shift+wheel horizontal). Default: 1 */
+ mousePanScale?: number;
};
const defaultOptions: Required = {
@@ -23,7 +34,6 @@ const defaultOptions: Required = {
panModifier: 'none',
wheelZoom: true,
wheelModifier: 'none',
- wheelSensitivity: 0.0015,
doubleClickZoom: true,
doubleClickZoomFactor: 2,
doubleClickZoomOut: true,
@@ -32,6 +42,13 @@ const defaultOptions: Required = {
keyboardPan: true,
keyboardPanStep: 50,
keyboardPanSlowStep: 25,
+ wheelBehavior: 'auto',
+ touchpadZoomSensitivityIn: 0.0015,
+ touchpadZoomSensitivityOut: 0.0015,
+ mouseZoomSensitivityIn: 0.0015,
+ mouseZoomSensitivityOut: 0.0015,
+ touchpadPanScale: 1,
+ mousePanScale: 1,
};
function hasSetPointerCapture(
@@ -176,20 +193,92 @@ export function useCanvasNavigation(
}
function onWheel(e: WheelEvent) {
- if (!opts.wheelZoom) return;
- if (!wheelModifierPressed(e)) return;
- // Allow trackpad zoom on Mac with ctrl+wheel; also regular wheel
- e.preventDefault();
const currentEl = ref.current;
if (!currentEl) return;
+
const rect = currentEl.getBoundingClientRect();
const screenPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
- const sensitivity = opts.wheelSensitivity;
- const factor = Math.exp(-e.deltaY * sensitivity);
+ // Heuristic detection
+ const isPinchZoom = e.ctrlKey === true; // treat Ctrl+wheel as pinch/zoom for both mouse and touchpad
+ const isPixelDelta = e.deltaMode === 0;
+ // Previous implementation gated touchpad by magnitude (<50), which could flip classification
+ // when a trackpad emitted larger deltas, causing apparent direction reversals. Stabilize by
+ // classifying any pixel-delta wheel (without Ctrl) as touchpad scroll.
+ const isLikelyTouchpadScroll = !isPinchZoom && isPixelDelta;
- const { zoomByAt } = useCanvasStore.getState();
- zoomByAt(screenPoint, factor);
+ // Helper: perform zoom using appropriate sensitivity
+ const doZoom = (device: 'touchpad' | 'mouse') => {
+ if (!opts.wheelZoom) return;
+ // In auto/pan modes, ignore wheelModifier for pinch/ctrl zoom to avoid breaking gestures.
+ if (opts.wheelBehavior === 'zoom') {
+ // Honor wheelModifier only in legacy zoom mode
+ if (!wheelModifierPressed(e)) return;
+ }
+ e.preventDefault();
+ const sensIn =
+ device === 'touchpad' ? opts.touchpadZoomSensitivityIn : opts.mouseZoomSensitivityIn;
+ const sensOut =
+ device === 'touchpad' ? opts.touchpadZoomSensitivityOut : opts.mouseZoomSensitivityOut;
+ const sensitivity = e.deltaY < 0 ? sensIn : sensOut;
+ const factor = Math.exp(-e.deltaY * sensitivity);
+ const { zoomByAt } = useCanvasStore.getState();
+ zoomByAt(screenPoint, factor);
+ };
+
+ // Helper: pan by deltas in screen space converted to world space
+ const doPan = (dxScreen: number, dyScreen: number) => {
+ const { camera, panBy } = useCanvasStore.getState();
+ const invZoom = 1 / camera.zoom;
+ const dxWorld = dxScreen * invZoom;
+ const dyWorld = dyScreen * invZoom;
+ if (dxWorld !== 0 || dyWorld !== 0) panBy(dxWorld, dyWorld);
+ };
+
+ const behavior = opts.wheelBehavior;
+
+ // 1) Zoom if Ctrl (pinch/intentional zoom) regardless of device
+ if (isPinchZoom) {
+ doZoom(isPixelDelta ? 'touchpad' : 'mouse');
+ return;
+ }
+
+ // 2) Behavior selection
+ if (behavior === 'zoom') {
+ // Legacy: wheel = zoom by default
+ if (!opts.wheelZoom) return;
+ if (!wheelModifierPressed(e)) return;
+ // Use device-specific sensitivities (same as pinch/Ctrl+wheel):
+ // pixel delta -> touchpad; otherwise -> mouse
+ doZoom(isPixelDelta ? 'touchpad' : 'mouse');
+ return;
+ }
+
+ if (behavior === 'pan') {
+ // Always pan with wheel (zoom only with Ctrl handled above)
+ e.preventDefault();
+ if (isLikelyTouchpadScroll) {
+ doPan(e.deltaX * opts.touchpadPanScale, e.deltaY * opts.touchpadPanScale);
+ } else {
+ // Mouse: Shift => horizontal, else vertical
+ if (e.shiftKey) doPan(-e.deltaY * opts.mousePanScale, 0);
+ else doPan(0, -e.deltaY * opts.mousePanScale);
+ }
+ return;
+ }
+
+ // behavior === 'auto'
+ if (isLikelyTouchpadScroll) {
+ // Touchpad: natural two-finger scroll pans in both axes
+ e.preventDefault();
+ doPan(e.deltaX * opts.touchpadPanScale, e.deltaY * opts.touchpadPanScale);
+ return;
+ }
+
+ // Mouse: pan vertically by default, Shift => horizontal pan
+ e.preventDefault();
+ if (e.shiftKey) doPan(-e.deltaY * opts.mousePanScale, 0);
+ else doPan(0, -e.deltaY * opts.mousePanScale);
}
function onDblClick(e: MouseEvent) {
@@ -328,7 +417,11 @@ export function useCanvasNavigation(
opts.panModifier,
opts.wheelZoom,
opts.wheelModifier,
- opts.wheelSensitivity,
+ opts.wheelBehavior,
+ opts.touchpadZoomSensitivityIn,
+ opts.touchpadZoomSensitivityOut,
+ opts.mouseZoomSensitivityIn,
+ opts.mouseZoomSensitivityOut,
opts.doubleClickZoom,
opts.doubleClickZoomFactor,
opts.doubleClickZoomOut,
@@ -337,5 +430,7 @@ export function useCanvasNavigation(
opts.keyboardPan,
opts.keyboardPanStep,
opts.keyboardPanSlowStep,
+ opts.touchpadPanScale,
+ opts.mousePanScale,
]);
}
diff --git a/src/react/useWorldLockedTile.ts b/src/react/useWorldLockedTile.ts
new file mode 100644
index 0000000..4e1ac39
--- /dev/null
+++ b/src/react/useWorldLockedTile.ts
@@ -0,0 +1,80 @@
+import { useMemo } from 'react';
+import { useCamera } from '../state/store';
+import type { Camera } from '../core/coords';
+
+export type TileSize = number | { x: number; y: number };
+
+export type UseWorldLockedTileOptions = {
+ size: TileSize; // base size in world units (px)
+ dprSnap?: boolean | number; // false = off (default), true = use window.devicePixelRatio, number = explicit DPR
+ userOffset?: { x?: number; y?: number }; // extra world-space phase, optional
+ camera?: Camera; // optional override
+};
+
+export type UseWorldLockedTileResult = {
+ scaledX: number;
+ scaledY: number;
+ offsetX: number;
+ offsetY: number;
+ style: Pick;
+ styleForLayers: (count: number) => Pick;
+};
+
+function mod(a: number, n: number) {
+ return ((a % n) + n) % n;
+}
+
+export function useWorldLockedTile(options: UseWorldLockedTileOptions): UseWorldLockedTileResult {
+ const cameraState = useCamera();
+ const cam = options.camera ?? cameraState;
+
+ return useMemo(() => {
+ const sizeX = typeof options.size === 'number' ? options.size : options.size.x;
+ const sizeY = typeof options.size === 'number' ? options.size : options.size.y;
+ const zoom = cam.zoom;
+
+ // Base scaled size in screen px
+ const rawScaledX = Math.max(1, sizeX * zoom);
+ const rawScaledY = Math.max(1, sizeY * zoom);
+
+ const extraX = options.userOffset?.x ?? 0;
+ const extraY = options.userOffset?.y ?? 0;
+
+ // Optional DPR snapping for subtle seam mitigation
+ const dprEnabled = !!options.dprSnap;
+ const dpr = dprEnabled
+ ? (typeof options.dprSnap === 'number' ? options.dprSnap : (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1))
+ : 1;
+ const roundTo = (v: number) => Math.round(v * dpr) / dpr;
+
+ // Keep tile size continuous to avoid zoom-induced pattern shifts
+ const scaledX = rawScaledX;
+ const scaledY = rawScaledY;
+
+ let offX = mod(-(cam.offsetX + extraX) * zoom, scaledX);
+ let offY = mod(-(cam.offsetY + extraY) * zoom, scaledY);
+ // Snap only offsets to device pixels for crisper lines, without changing tile size
+ if (dprEnabled) {
+ offX = roundTo(offX);
+ offY = roundTo(offY);
+ }
+
+ const makeStyle = (layers: number) => {
+ const sizeStr = Array(layers).fill(`${scaledX}px ${scaledY}px`).join(', ');
+ const posStr = Array(layers).fill(`${offX}px ${offY}px`).join(', ');
+ return {
+ backgroundSize: sizeStr,
+ backgroundPosition: posStr,
+ } as Pick;
+ };
+
+ return {
+ scaledX,
+ scaledY,
+ offsetX: offX,
+ offsetY: offY,
+ style: makeStyle(1),
+ styleForLayers: makeStyle,
+ };
+ }, [cam.offsetX, cam.offsetY, cam.zoom, options.size, options.dprSnap, options.userOffset?.x, options.userOffset?.y]);
+}
diff --git a/src/state/store.history.test.ts b/src/state/store.history.test.ts
index 96fc46a..962fd50 100644
--- a/src/state/store.history.test.ts
+++ b/src/state/store.history.test.ts
@@ -152,37 +152,38 @@ describe('History: camera pan and coalescing', () => {
} as Partial);
});
- it('records single camera pan in history and supports undo/redo', () => {
+ it('does not record single camera pan in history; undo/redo do not affect camera', () => {
const s1 = useCanvasStore.getState();
expect(s1.camera).toMatchObject({ offsetX: 0, offsetY: 0, zoom: 1 });
useCanvasStore.getState().panBy(10, -5);
let s2 = useCanvasStore.getState();
expect(s2.camera).toMatchObject({ offsetX: 10, offsetY: -5 });
- expect(s2.historyPast.length).toBe(1);
+ expect(s2.historyPast.length).toBe(0);
+ // undo should be a no-op for camera when there is no history
s2.undo();
s2 = useCanvasStore.getState();
- expect(s2.camera).toMatchObject({ offsetX: 0, offsetY: 0 });
+ expect(s2.camera).toMatchObject({ offsetX: 10, offsetY: -5 });
+ // redo -> still nothing
s2.redo();
s2 = useCanvasStore.getState();
expect(s2.camera).toMatchObject({ offsetX: 10, offsetY: -5 });
});
- it('coalesces multiple panBy calls into a single cameraMove within a batch', () => {
+ it('panBy inside a batch is not recorded; empty batch is discarded', () => {
useCanvasStore.getState().beginHistory('camera-pan');
useCanvasStore.getState().panBy(5, 0);
useCanvasStore.getState().panBy(5, 0);
useCanvasStore.getState().panBy(-2, 3);
useCanvasStore.getState().endHistory();
let s = useCanvasStore.getState();
- expect(s.historyPast.length).toBe(1);
+ // No node changes in the batch -> batch discarded
+ expect(s.historyPast.length).toBe(0);
expect(s.camera).toMatchObject({ offsetX: 8, offsetY: 3 });
- // only one change inside the batch (coalesced cameraMove)
- expect(s.historyPast[0].changes.length).toBe(1);
- // undo -> back to origin
+ // undo still has nothing to do
s.undo();
s = useCanvasStore.getState();
- expect(s.camera).toMatchObject({ offsetX: 0, offsetY: 0 });
- // redo -> aggregated movement
+ expect(s.camera).toMatchObject({ offsetX: 8, offsetY: 3 });
+ // redo -> still nothing
s.redo();
s = useCanvasStore.getState();
expect(s.camera).toMatchObject({ offsetX: 8, offsetY: 3 });
@@ -196,7 +197,7 @@ describe('History: camera pan and coalescing', () => {
});
});
-describe('History: mixed batch (cameraMove + moveSelectedBy)', () => {
+describe('History: mixed batch (pan + moveSelectedBy)', () => {
beforeEach(() => {
useCanvasStore.setState({
camera: { zoom: 1, offsetX: 0, offsetY: 0 },
@@ -207,9 +208,14 @@ describe('History: mixed batch (cameraMove + moveSelectedBy)', () => {
historyFuture: [],
historyBatch: null,
} as Partial);
+ // Provide a window shim to make visibility checks meaningful
+ ((globalThis as unknown) as { window?: { innerWidth: number; innerHeight: number } }).window = {
+ innerWidth: 800,
+ innerHeight: 600,
+ };
});
- it('coalesces camera moves and keeps per-node updates; undo/redo restores both camera and nodes', () => {
+ it('ignores camera moves in batch and keeps per-node updates; undo/redo restore only nodes', () => {
// Arrange: two nodes selected
useCanvasStore.getState().addNode({ id: 'mx1', x: 0, y: 0, width: 10, height: 10 });
useCanvasStore.getState().addNode({ id: 'mx2', x: 5, y: 5, width: 10, height: 10 });
@@ -231,22 +237,19 @@ describe('History: mixed batch (cameraMove + moveSelectedBy)', () => {
// Nodes moved by (3,4)
expect(s.nodes['mx1']).toMatchObject({ x: 3, y: 4 });
expect(s.nodes['mx2']).toMatchObject({ x: 8, y: 9 });
- // Inside the batch: expect 1 cameraMove + 2 updates
+ // Inside the batch: expect only node updates (no camera changes recorded)
const last = s.historyPast[2];
const kinds = last.changes.map((c) => c.kind);
- const cameraCount = kinds.filter((k) => k === 'cameraMove').length;
- const updateCount = kinds.filter((k) => k === 'update').length;
- expect(cameraCount).toBe(1);
- expect(updateCount).toBe(2);
+ expect(kinds).toEqual(['update', 'update']);
- // Undo -> camera back to (0,0), nodes back to initial
+ // Undo -> nodes back to initial, camera remains unchanged
s.undo();
s = useCanvasStore.getState();
- expect(s.camera).toMatchObject({ offsetX: 0, offsetY: 0 });
+ expect(s.camera).toMatchObject({ offsetX: 8, offsetY: -4 });
expect(s.nodes['mx1']).toMatchObject({ x: 0, y: 0 });
expect(s.nodes['mx2']).toMatchObject({ x: 5, y: 5 });
- // Redo -> camera (8,-4), nodes moved by (3,4) again
+ // Redo -> nodes moved by (3,4) again, camera unchanged
s.redo();
s = useCanvasStore.getState();
expect(s.camera).toMatchObject({ offsetX: 8, offsetY: -4 });
@@ -254,3 +257,30 @@ describe('History: mixed batch (cameraMove + moveSelectedBy)', () => {
expect(s.nodes['mx2']).toMatchObject({ x: 8, y: 9 });
});
});
+
+describe('History: undo re-add centers camera when re-added nodes are off-screen', () => {
+ beforeEach(() => resetStore());
+
+ it('recenters viewport to re-added node bbox center on undo remove if currently off-screen', () => {
+ // Provide a minimal window shim in Node env so undo() centering uses screen size
+ ((globalThis as unknown) as { window?: { innerWidth: number; innerHeight: number } }).window = {
+ innerWidth: 800,
+ innerHeight: 600,
+ };
+
+ // Add a node near origin
+ useCanvasStore.getState().addNode({ id: 'c1', x: 0, y: 0, width: 100, height: 60 });
+ // Pan camera far away so the node is off-screen
+ useCanvasStore.getState().panBy(5000, 5000);
+ // Remove node and then undo -> should re-add and center camera
+ useCanvasStore.getState().removeNode('c1');
+ let s = useCanvasStore.getState();
+ expect(s.nodes['c1']).toBeUndefined();
+ s.undo();
+ s = useCanvasStore.getState();
+ expect(s.nodes['c1']).toBeDefined();
+ // With zoom=1 and window 800x600, center of node (50,30) -> offset should be -350,-270
+ expect(s.camera.offsetX).toBeCloseTo(-350, 6);
+ expect(s.camera.offsetY).toBeCloseTo(-270, 6);
+ });
+});
diff --git a/src/state/store.ts b/src/state/store.ts
index 1631a03..ebef34a 100644
--- a/src/state/store.ts
+++ b/src/state/store.ts
@@ -3,12 +3,11 @@ import type { Camera, Point } from '../core/coords';
import type { Node, NodeId } from '../types';
import { applyPan, clampZoom, zoomAtPoint } from '../core/coords';
-// History types (nodes + camera pan; zoom is intentionally excluded)
+// History types (nodes only; camera and zoom are excluded)
type NodeChange =
| { kind: 'add'; node: Node }
| { kind: 'remove'; node: Node }
- | { kind: 'update'; id: NodeId; before: Node; after: Node }
- | { kind: 'cameraMove'; dx: number; dy: number };
+ | { kind: 'update'; id: NodeId; before: Node; after: Node };
type HistoryEntry = {
label?: string;
@@ -33,8 +32,6 @@ export type CanvasState = {
label?: string;
changes: NodeChange[];
updateIndexById: Record;
- /** Index of coalesced cameraMove within changes, if any */
- cameraMoveIndex?: number;
} | null;
};
@@ -96,35 +93,9 @@ export const useCanvasStore = create()((set, get) => ({
setCamera: (camera) => set({ camera }),
panBy: (dx, dy) =>
- set((s) => {
- const nextCamera = applyPan(s.camera, dx, dy);
- if (__isReplayingHistory) return { camera: nextCamera } as Partial as CanvasStore;
- if (s.historyBatch) {
- const batch = s.historyBatch;
- const idx = batch.cameraMoveIndex;
- if (idx == null) {
- const newChanges = [...batch.changes, { kind: 'cameraMove', dx, dy } as NodeChange];
- return {
- camera: nextCamera,
- historyBatch: { ...batch, changes: newChanges, cameraMoveIndex: newChanges.length - 1 },
- } as Partial as CanvasStore;
- } else {
- const newChanges = batch.changes.slice();
- const prev = newChanges[idx] as Extract;
- newChanges[idx] = { kind: 'cameraMove', dx: prev.dx + dx, dy: prev.dy + dy };
- return {
- camera: nextCamera,
- historyBatch: { ...batch, changes: newChanges },
- } as Partial as CanvasStore;
- }
- }
- const entry: HistoryEntry = { changes: [{ kind: 'cameraMove', dx, dy }] };
- return {
- camera: nextCamera,
- historyPast: [...s.historyPast, entry],
- historyFuture: [],
- } as Partial as CanvasStore;
- }),
+ set((s) => ({
+ camera: applyPan(s.camera, dx, dy),
+ })),
zoomTo: (zoom) =>
set((s) => ({
@@ -205,17 +176,33 @@ export const useCanvasStore = create()((set, get) => ({
const idx = batch.updateIndexById[id];
if (idx == null) {
const newIdx = batch.changes.length;
- const newChanges = [...batch.changes, { kind: 'update', id, before: current, after: updated } as NodeChange];
+ const newChanges = [
+ ...batch.changes,
+ { kind: 'update', id, before: current, after: updated } as NodeChange,
+ ];
const newMap = { ...batch.updateIndexById, [id]: newIdx } as Record;
- return { nodes: nextNodes, historyBatch: { ...batch, changes: newChanges, updateIndexById: newMap } } as Partial as CanvasStore;
+ return {
+ nodes: nextNodes,
+ historyBatch: { ...batch, changes: newChanges, updateIndexById: newMap },
+ } as Partial as CanvasStore;
} else {
const newChanges = batch.changes.slice();
const prev = newChanges[idx] as Extract;
- newChanges[idx] = { kind: 'update', id, before: prev.kind === 'update' ? prev.before : current, after: updated } as NodeChange;
- return { nodes: nextNodes, historyBatch: { ...batch, changes: newChanges } } as Partial as CanvasStore;
+ newChanges[idx] = {
+ kind: 'update',
+ id,
+ before: prev.kind === 'update' ? prev.before : current,
+ after: updated,
+ } as NodeChange;
+ return {
+ nodes: nextNodes,
+ historyBatch: { ...batch, changes: newChanges },
+ } as Partial as CanvasStore;
}
}
- const entry: HistoryEntry = { changes: [{ kind: 'update', id, before: current, after: updated }] };
+ const entry: HistoryEntry = {
+ changes: [{ kind: 'update', id, before: current, after: updated }],
+ };
return {
nodes: nextNodes,
historyPast: [...s.historyPast, entry],
@@ -238,7 +225,10 @@ export const useCanvasStore = create()((set, get) => ({
return {
nodes: next,
selected: sel,
- historyBatch: { ...batch, changes: [...batch.changes, { kind: 'remove', node: removed }] },
+ historyBatch: {
+ ...batch,
+ changes: [...batch.changes, { kind: 'remove', node: removed }],
+ },
} as Partial as CanvasStore;
}
const entry: HistoryEntry = { changes: [{ kind: 'remove', node: removed }] };
@@ -254,10 +244,20 @@ export const useCanvasStore = create()((set, get) => ({
if (!__isReplayingHistory) {
if (s.historyBatch) {
const batch = s.historyBatch;
- return { nodes: next, historyBatch: { ...batch, changes: [...batch.changes, { kind: 'remove', node: removed }] } } as Partial as CanvasStore;
+ return {
+ nodes: next,
+ historyBatch: {
+ ...batch,
+ changes: [...batch.changes, { kind: 'remove', node: removed }],
+ },
+ } as Partial as CanvasStore;
}
const entry: HistoryEntry = { changes: [{ kind: 'remove', node: removed }] };
- return { nodes: next, historyPast: [...s.historyPast, entry], historyFuture: [] } as Partial as CanvasStore;
+ return {
+ nodes: next,
+ historyPast: [...s.historyPast, entry],
+ historyFuture: [],
+ } as Partial as CanvasStore;
}
return { nodes: next } as Partial as CanvasStore;
}),
@@ -291,16 +291,31 @@ export const useCanvasStore = create()((set, get) => ({
}
if (s.historyBatch) {
const batch = s.historyBatch;
- const removeChanges = removedList.map((n) => ({ kind: 'remove', node: n } as NodeChange));
+ const removeChanges = removedList.map((n) => ({ kind: 'remove', node: n }) as NodeChange);
const newBatch = { ...batch, changes: [...batch.changes, ...removeChanges] };
return selChanged
- ? ({ nodes: nextNodes, selected: nextSel, historyBatch: newBatch } as Partial as CanvasStore)
+ ? ({
+ nodes: nextNodes,
+ selected: nextSel,
+ historyBatch: newBatch,
+ } as Partial as CanvasStore)
: ({ nodes: nextNodes, historyBatch: newBatch } as Partial as CanvasStore);
}
- const entry: HistoryEntry = { changes: removedList.map((n) => ({ kind: 'remove', node: n })) };
+ const entry: HistoryEntry = {
+ changes: removedList.map((n) => ({ kind: 'remove', node: n })),
+ };
return selChanged
- ? ({ nodes: nextNodes, selected: nextSel, historyPast: [...s.historyPast, entry], historyFuture: [] } as Partial as CanvasStore)
- : ({ nodes: nextNodes, historyPast: [...s.historyPast, entry], historyFuture: [] } as Partial as CanvasStore);
+ ? ({
+ nodes: nextNodes,
+ selected: nextSel,
+ historyPast: [...s.historyPast, entry],
+ historyFuture: [],
+ } as Partial as CanvasStore)
+ : ({
+ nodes: nextNodes,
+ historyPast: [...s.historyPast, entry],
+ historyFuture: [],
+ } as Partial as CanvasStore);
}),
moveSelectedBy: (dx, dy) =>
set((s) => {
@@ -336,10 +351,24 @@ export const useCanvasStore = create()((set, get) => ({
newChanges[idx] = { kind: 'update', id: u.id, before: prev.before, after: u.after };
}
}
- return { nodes: nextNodes, historyBatch: { ...batch, changes: newChanges, updateIndexById: newMap } } as Partial as CanvasStore;
+ return {
+ nodes: nextNodes,
+ historyBatch: { ...batch, changes: newChanges, updateIndexById: newMap },
+ } as Partial as CanvasStore;
}
- const entry: HistoryEntry = { changes: updates.map((u) => ({ kind: 'update', id: u.id, before: u.before, after: u.after })) };
- return { nodes: nextNodes, historyPast: [...s.historyPast, entry], historyFuture: [] } as Partial as CanvasStore;
+ const entry: HistoryEntry = {
+ changes: updates.map((u) => ({
+ kind: 'update',
+ id: u.id,
+ before: u.before,
+ after: u.after,
+ })),
+ };
+ return {
+ nodes: nextNodes,
+ historyPast: [...s.historyPast, entry],
+ historyFuture: [],
+ } as Partial as CanvasStore;
}),
// Selection
@@ -380,7 +409,7 @@ export const useCanvasStore = create()((set, get) => ({
set((s) => {
if (s.historyBatch) return {} as Partial as CanvasStore;
return {
- historyBatch: { label, changes: [], updateIndexById: {}, cameraMoveIndex: undefined },
+ historyBatch: { label, changes: [], updateIndexById: {} },
} as Partial as CanvasStore;
}),
endHistory: () =>
@@ -399,7 +428,8 @@ export const useCanvasStore = create()((set, get) => ({
}),
undo: () =>
set((s) => {
- if (s.historyBatch || s.historyPast.length === 0) return {} as Partial as CanvasStore;
+ if (s.historyBatch || s.historyPast.length === 0)
+ return {} as Partial as CanvasStore;
const past = s.historyPast.slice();
const entry = past.pop() as HistoryEntry;
const future = s.historyFuture.slice();
@@ -407,9 +437,62 @@ export const useCanvasStore = create()((set, get) => ({
// Apply inverse of entry
__isReplayingHistory = true;
try {
- // Build new nodes map and camera by applying inverse of changes
- const nodes = { ...s.nodes } as Record;
+ // Determine reference bbox for visibility check BEFORE applying inverse
+ let bboxLeft = Infinity,
+ bboxTop = Infinity,
+ bboxRight = -Infinity,
+ bboxBottom = -Infinity;
+ let hasRef = false;
+ for (const ch of entry.changes) {
+ if (ch.kind === 'add') {
+ // Node will be removed on undo; use its geometry as reference
+ const n = ch.node;
+ bboxLeft = Math.min(bboxLeft, n.x);
+ bboxTop = Math.min(bboxTop, n.y);
+ bboxRight = Math.max(bboxRight, n.x + n.width);
+ bboxBottom = Math.max(bboxBottom, n.y + n.height);
+ hasRef = true;
+ } else if (ch.kind === 'remove') {
+ // Node will be re-added; use its geometry
+ const n = ch.node;
+ bboxLeft = Math.min(bboxLeft, n.x);
+ bboxTop = Math.min(bboxTop, n.y);
+ bboxRight = Math.max(bboxRight, n.x + n.width);
+ bboxBottom = Math.max(bboxBottom, n.y + n.height);
+ hasRef = true;
+ } else if (ch.kind === 'update') {
+ // Undo returns to 'before'
+ const n = ch.before;
+ bboxLeft = Math.min(bboxLeft, n.x);
+ bboxTop = Math.min(bboxTop, n.y);
+ bboxRight = Math.max(bboxRight, n.x + n.width);
+ bboxBottom = Math.max(bboxBottom, n.y + n.height);
+ hasRef = true;
+ }
+ }
+ // Check visibility in current camera
let cam = s.camera;
+ if (hasRef) {
+ const w = typeof window !== 'undefined' ? window.innerWidth : 0;
+ const h = typeof window !== 'undefined' ? window.innerHeight : 0;
+ const zoom = cam.zoom || 1;
+ const viewLeft = cam.offsetX;
+ const viewTop = cam.offsetY;
+ const viewRight = viewLeft + w / zoom;
+ const viewBottom = viewTop + h / zoom;
+ const isOutside =
+ bboxRight < viewLeft ||
+ bboxLeft > viewRight ||
+ bboxBottom < viewTop ||
+ bboxTop > viewBottom;
+ if (isOutside) {
+ const cx = (bboxLeft + bboxRight) / 2;
+ const cy = (bboxTop + bboxBottom) / 2;
+ cam = { zoom: cam.zoom, offsetX: cx - w / (2 * zoom), offsetY: cy - h / (2 * zoom) };
+ }
+ }
+ // Build new nodes map by applying inverse of changes
+ const nodes = { ...s.nodes } as Record;
for (let i = entry.changes.length - 1; i >= 0; i--) {
const ch = entry.changes[i];
if (ch.kind === 'add') {
@@ -418,8 +501,6 @@ export const useCanvasStore = create()((set, get) => ({
nodes[ch.node.id] = ch.node;
} else if (ch.kind === 'update') {
nodes[ch.id] = ch.before;
- } else if (ch.kind === 'cameraMove') {
- cam = applyPan(cam, -ch.dx, -ch.dy);
}
}
// Clean selection of non-existing ids
@@ -440,15 +521,66 @@ export const useCanvasStore = create()((set, get) => ({
}),
redo: () =>
set((s) => {
- if (s.historyBatch || s.historyFuture.length === 0) return {} as Partial as CanvasStore;
+ if (s.historyBatch || s.historyFuture.length === 0)
+ return {} as Partial as CanvasStore;
const future = s.historyFuture.slice();
const entry = future.shift() as HistoryEntry;
const past = s.historyPast.slice();
past.push(entry);
__isReplayingHistory = true;
try {
- const nodes = { ...s.nodes } as Record;
+ // Determine reference bbox BEFORE applying redo
+ let bboxLeft = Infinity,
+ bboxTop = Infinity,
+ bboxRight = -Infinity,
+ bboxBottom = -Infinity;
+ let hasRef = false;
+ for (const ch of entry.changes) {
+ if (ch.kind === 'add') {
+ const n = ch.node; // will appear after redo
+ bboxLeft = Math.min(bboxLeft, n.x);
+ bboxTop = Math.min(bboxTop, n.y);
+ bboxRight = Math.max(bboxRight, n.x + n.width);
+ bboxBottom = Math.max(bboxBottom, n.y + n.height);
+ hasRef = true;
+ } else if (ch.kind === 'remove') {
+ const n = ch.node; // will disappear after redo; still use its geometry
+ bboxLeft = Math.min(bboxLeft, n.x);
+ bboxTop = Math.min(bboxTop, n.y);
+ bboxRight = Math.max(bboxRight, n.x + n.width);
+ bboxBottom = Math.max(bboxBottom, n.y + n.height);
+ hasRef = true;
+ } else if (ch.kind === 'update') {
+ const n = ch.after; // redo applies 'after'
+ bboxLeft = Math.min(bboxLeft, n.x);
+ bboxTop = Math.min(bboxTop, n.y);
+ bboxRight = Math.max(bboxRight, n.x + n.width);
+ bboxBottom = Math.max(bboxBottom, n.y + n.height);
+ hasRef = true;
+ }
+ }
let cam = s.camera;
+ if (hasRef) {
+ const w = typeof window !== 'undefined' ? window.innerWidth : 0;
+ const h = typeof window !== 'undefined' ? window.innerHeight : 0;
+ const zoom = cam.zoom || 1;
+ const viewLeft = cam.offsetX;
+ const viewTop = cam.offsetY;
+ const viewRight = viewLeft + w / zoom;
+ const viewBottom = viewTop + h / zoom;
+ const isOutside =
+ bboxRight < viewLeft ||
+ bboxLeft > viewRight ||
+ bboxBottom < viewTop ||
+ bboxTop > viewBottom;
+ if (isOutside) {
+ const cx = (bboxLeft + bboxRight) / 2;
+ const cy = (bboxTop + bboxBottom) / 2;
+ cam = { zoom: cam.zoom, offsetX: cx - w / (2 * zoom), offsetY: cy - h / (2 * zoom) };
+ }
+ }
+ // Apply changes
+ const nodes = { ...s.nodes } as Record;
for (const ch of entry.changes) {
if (ch.kind === 'add') {
nodes[ch.node.id] = ch.node;
@@ -456,8 +588,6 @@ export const useCanvasStore = create()((set, get) => ({
delete nodes[ch.node.id];
} else if (ch.kind === 'update') {
nodes[ch.id] = ch.after;
- } else if (ch.kind === 'cameraMove') {
- cam = applyPan(cam, ch.dx, ch.dy);
}
}
const nextSel: Record = {};
@@ -553,7 +683,10 @@ export function useDndActions(): Pick {
}
// History actions
-export function useHistoryActions(): Pick {
+export function useHistoryActions(): Pick<
+ CanvasActions,
+ 'beginHistory' | 'endHistory' | 'undo' | 'redo'
+> {
return useCanvasStore((s) => ({
beginHistory: s.beginHistory,
endHistory: s.endHistory,