From 52b9852ef0520ad6646076d2d84b67a4f87e7e92 Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Fri, 22 Aug 2025 18:02:34 +0300 Subject: [PATCH] =?UTF-8?q?release:=20v1.0.0=20=E2=80=93=20remove=20wheelS?= =?UTF-8?q?ensitivity,=20add=20dprSnap=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 34 +++ README.md | 152 ++++++++++- bun.lock | 9 +- package.json | 25 +- src/index.ts | 2 + src/react/BackgroundCells.test.tsx | 61 +++++ src/react/BackgroundCells.tsx | 49 ++++ src/react/BackgroundDots.test.tsx | 76 ++++++ src/react/BackgroundDots.tsx | 27 +- src/react/Canvas.stories.tsx | 199 +++++++++++++-- src/react/HistoryCamera.integration.test.tsx | 86 +++++++ src/react/useCanvasNavigation.test.tsx | 148 +++++++++++ src/react/useCanvasNavigation.ts | 117 ++++++++- src/react/useWorldLockedTile.ts | 80 ++++++ src/state/store.history.test.ts | 70 +++-- src/state/store.ts | 255 ++++++++++++++----- 16 files changed, 1247 insertions(+), 143 deletions(-) create mode 100644 src/react/BackgroundCells.test.tsx create mode 100644 src/react/BackgroundCells.tsx create mode 100644 src/react/BackgroundDots.test.tsx create mode 100644 src/react/HistoryCamera.integration.test.tsx create mode 100644 src/react/useWorldLockedTile.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f0860f9..b0c7b3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # @flowscape-ui/canvas-react +## 1.0.0 + +### Major Changes + +- Remove deprecated `wheelSensitivity`; clarify zoom sensitivities; document `dprSnap`. + - BREAKING: removed `wheelSensitivity` from `useCanvasNavigation` and Storybook controls. + - Legacy `wheelBehavior: 'zoom'` now uses device-specific sensitivities consistently. + - Migration: + - Before: + ```ts + useCanvasNavigation(ref, { + wheelBehavior: 'auto', + wheelModifier: 'ctrl', + wheelSensitivity: 0.002, + }); + ``` + - After: + ```ts + useCanvasNavigation(ref, { + wheelBehavior: 'auto', + wheelModifier: 'ctrl', + mouseZoomSensitivityIn: 0.002, + mouseZoomSensitivityOut: 0.002, + // optional touchpad overrides + touchpadZoomSensitivityIn: 0.0015, + touchpadZoomSensitivityOut: 0.0015, + }); + ``` + - Zoom sensitivity defaults: when omitted, each of `mouseZoomSensitivityIn/Out` and `touchpadZoomSensitivityIn/Out` defaults to `0.0015`. + - Docs: added a dedicated `dprSnap` section for world-locked backgrounds (`BackgroundDots`, `BackgroundCells`) with examples and SSR/test guidance (numeric DPR override). + - Docs: updated Navigation Options section and added a migration note with before/after snippets. + - Behavior note: in `'auto'`/`'pan'`, the wheel modifier is ignored for pinch/`Ctrl+wheel` zoom; `Shift` is reserved for horizontal panning. + - Housekeeping: rebuilt `dist/` and Storybook static. + ## 0.1.3 ### Patch Changes diff --git a/README.md b/README.md index a02c2a3..a2b9c63 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,20 @@ npm i @flowscape-ui/canvas-react - Multi-select: Ctrl/Cmd + left-click toggles node in selection. - Deselect: left-click on empty canvas area (a simple click without dragging). - Shift is reserved for future features (e.g., range/box selection). - - Drag & History: - - Drag nodes (single or multi-select) without panning the canvas thanks to hit-testing. - - Dragging batches updates into a single history entry; Undo/Redo reverts/applies the whole drag as one action. +- Drag & History: + - Drag nodes (single or multi-select) without panning the canvas thanks to hit-testing. + - Dragging batches updates into a single history entry; Undo/Redo reverts/applies the whole drag as one action. ### Example: Basic Canvas with Navigation and Node Views ```tsx -import { Canvas, NodeView, useNodeActions, useNodes, useCanvasNavigation } from '@flowscape-ui/canvas-react'; +import { + Canvas, + NodeView, + useNodeActions, + useNodes, + useCanvasNavigation, +} from '@flowscape-ui/canvas-react'; import { useRef, useEffect } from 'react'; export default function Example() { @@ -61,6 +67,33 @@ export default function Example() { } ``` +#### Migration from `wheelSensitivity` (breaking) + +The legacy `wheelSensitivity` option has been removed. Use the device‑specific sensitivity props instead. Defaults are `0.0015`. + +Before: + +```tsx +useCanvasNavigation(ref, { + wheelBehavior: 'auto', + wheelModifier: 'ctrl', + wheelSensitivity: 0.002, +}); +``` + +After: + +```tsx +useCanvasNavigation(ref, { + wheelBehavior: 'auto', + wheelModifier: 'ctrl', + mouseZoomSensitivityIn: 0.002, + mouseZoomSensitivityOut: 0.002, + touchpadZoomSensitivityIn: 0.0015, // optional override + touchpadZoomSensitivityOut: 0.0015, // optional override +}); +``` + ### Add nodes at the visible center (regardless of zoom) You can add nodes at the current visual center using the store action `addNodeAtCenter`: @@ -103,6 +136,7 @@ export default function EmbeddedCenterAdd() { ``` Notes: + - The placement includes a small diagonal offset per subsequent node so that multiple adds do not overlap completely. - The visual offset is stable across zoom levels. @@ -128,9 +162,9 @@ type NodeAppearance = { shadow: string; hoverShadow: string; selectedShadow?: string; - padding: number; // applied only when no children - fontSize: number; // applied only when no children - fontWeight: number; // applied only when no children + padding: number; // applied only when no children + fontSize: number; // applied only when no children + fontWeight: number; // applied only when no children }; ``` @@ -198,12 +232,116 @@ If you prefer to fully control visuals: - Focus behavior: the canvas automatically focuses itself on pointer down (both on nodes and empty area) so shortcuts work immediately. The root is focusable via `tabIndex` (default `0`); you can override with `` to disable focus, or another value to suit your app. - Shortcuts are ignored when the event originates from text inputs or contenteditable elements. +## Navigation Options (Wheel & Touchpad) + +The hook `useCanvasNavigation(ref, options)` supports modern and legacy wheel behaviors. Default zoom bounds are 0.6–2.4 (60–240%). + +- **wheelBehavior**: `'auto' | 'zoom' | 'pan'` + - `'auto'` (default): + - Mouse wheel pans vertically; `Shift+wheel` pans horizontally. + - `Ctrl+wheel` zooms. + - Touchpad: two-finger pan; pinch (`Ctrl+wheel`) zooms. + - `'zoom'`: legacy — wheel zooms by default (respects `wheelModifier`). + - `'pan'`: wheel always pans; zoom only with `Ctrl+wheel`/pinch. + +- **wheelModifier**: `'none' | 'alt' | 'ctrl'` + - In `'auto'`/`'pan'` modes, the modifier is ignored for pinch/`Ctrl+wheel` zoom to avoid breaking native gestures. + - `Shift` is reserved for horizontal panning and therefore not available as a wheel modifier. + + - **Zoom sensitivities**: + - `touchpadZoomSensitivityIn`, `touchpadZoomSensitivityOut` — for pixel-based touchpad pinch. + - `mouseZoomSensitivityIn`, `mouseZoomSensitivityOut` — for mouse `Ctrl+wheel` zoom. + - Defaults: if omitted, each sensitivity defaults to `0.0015`. + + - **Pan multipliers**: + - `touchpadPanScale` — multiplier for two-finger touchpad pan speed. Defaults to `1`. + - `mousePanScale` — multiplier for mouse wheel pan speed (vertical) and `Shift+wheel` (horizontal). Defaults to `1`. + +### Example + +```tsx +import { useCanvasNavigation } from '@flowscape-ui/canvas-react'; +import { useRef } from 'react'; + +export default function Example() { + const ref = useRef(null); + useCanvasNavigation(ref, { + panButton: 0, + panModifier: 'none', + wheelZoom: true, + wheelModifier: 'ctrl', + wheelBehavior: 'auto', // mouse: pan (Y / Shift->X), Ctrl+wheel: zoom; touchpad: two-finger pan + pinch zoom + touchpadZoomSensitivityIn: 0.0015, + touchpadZoomSensitivityOut: 0.0015, + mouseZoomSensitivityIn: 0.0015, + mouseZoomSensitivityOut: 0.0015, + // Faster mouse pan, slightly slower touchpad pan + mousePanScale: 1.5, + touchpadPanScale: 0.8, + doubleClickZoom: true, + doubleClickZoomFactor: 2, + doubleClickZoomOut: true, + doubleClickZoomOutModifier: 'alt', + doubleClickZoomOutFactor: 2, + keyboardPan: true, + keyboardPanStep: 50, + keyboardPanSlowStep: 25, + }); + + return ; +} +``` + +## World‑locked Backgrounds and dprSnap + +- __Purpose__: keep dotted/gridded backgrounds crisp on high‑DPR displays during pan/zoom by snapping background phase to device pixels. +- __How__: only the offsets (backgroundPosition) are DPR‑snapped; the tile size (backgroundSize) stays continuous to avoid jumps while zooming. Implemented in `useWorldLockedTile()`. +- __Defaults__: + - `BackgroundDots`/`BackgroundCells`: `dprSnap = true` (uses `window.devicePixelRatio` when available). + - `useWorldLockedTile`: off by default — pass `true` or a number to enable. +- __When to use__: keep enabled for 1px lines/small dots to prevent blur and seams. Disable only if you need subpixel phase animation and can accept anti‑aliasing. +- __SSR/tests__: pass a number (e.g. `2`) to force DPR when `window` is not available. + +Examples: + +```tsx +// Dots: crisp by default + + +// Disable snapping (may blur on some zoom levels) + +``` + +```tsx +// Cells: crisp 1px grid lines + + +// Force a fixed DPR (useful in tests/SSR) + +``` + +Direct hook usage: + +```tsx +const { style } = useWorldLockedTile({ size: 32, dprSnap: true }); +return
; +``` + ### Drag & History Behavior - Dragging a node starts a coalesced history batch; intermediate updates are merged. - Undo/Redo reverts/applies the entire drag in one step. - Canvas panning is suppressed while dragging over nodes (hit-tested by `data-rc-nodeid`). +#### Camera & History + +- Camera panning and zoom are transient UI state and are not recorded in history. +- Undo/Redo do not change the camera if only camera moves occurred (no node changes). +- When Undo/Redo re-adds or reveals nodes that are currently off-screen, the camera recenters to bring them into view. This happens at the same zoom level. +- Default zoom bounds remain 0.6–2.4 (60–240%). + +Tip for demos/tests: Add a node, remove it, pan the camera away, then Undo — the view recenters on the restored node. + ## Usage (very basic) ```tsx diff --git a/bun.lock b/bun.lock index b6ce1b0..cebd5d3 100644 --- a/bun.lock +++ b/bun.lock @@ -16,8 +16,6 @@ "@storybook/react": "^9.1.2", "@storybook/react-vite": "^9.1.2", "@types/node": "^24.3.0", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.0", @@ -32,14 +30,13 @@ "rollup": "^4.21.0", "rollup-plugin-dts": "^6.1.1", "storybook": "^9.1.2", - "tslib": "^2.6.3", "typescript": "5.5.4", "vite": "^5.4.0", "vitest": "^2.0.5", }, "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0", + "react": "^18 || ^19", + "react-dom": "^18 || ^19", }, }, }, @@ -320,8 +317,6 @@ "@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="], - "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], - "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@7.18.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/type-utils": "7.18.0", "@typescript-eslint/utils": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.56.0" } }, "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw=="], diff --git a/package.json b/package.json index 8833c4a..a324eae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flowscape-ui/canvas-react", - "version": "0.1.3", + "version": "1.0.0", "description": "High-performance Open Source React library for an interactive infinite canvas with nodes, pan/zoom, selection, history, and a plugin-friendly architecture.", "license": "Apache-2.0", "type": "module", @@ -20,14 +20,28 @@ "LICENSE", "NOTICE" ], + "keywords": [ + "react", + "react-component", + "typescript", + "canvas", + "infinite-canvas", + "pan-zoom", + "pinch-zoom", + "drag-and-drop", + "whiteboard", + "flowchart", + "diagram", + "node-editor" + ], "engines": { "node": ">=18", "bun": ">=1.1.0" }, "packageManager": "bun@1.1.9", "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" + "react": "^18 || ^19", + "react-dom": "^18 || ^19" }, "dependencies": { "zustand": "^4.5.2" @@ -53,12 +67,11 @@ "eslint-plugin-react-hooks": "^5.2.0", "jsdom": "^26.1.0", "prettier": "^3.3.3", - "react": "^19.1.1", - "react-dom": "^19.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", "rollup": "^4.21.0", "rollup-plugin-dts": "^6.1.1", "storybook": "^9.1.2", - "tslib": "^2.6.3", "typescript": "5.5.4", "vite": "^5.4.0", "vitest": "^2.0.5" diff --git a/src/index.ts b/src/index.ts index 7f16442..190b3be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,7 @@ export * from './types'; export * from './state/store'; export * from './react/useCanvasNavigation'; export * from './react/BackgroundDots'; +export * from './react/BackgroundCells'; +export * from './react/useWorldLockedTile'; export * from './react/NodeView'; export * from './react/useCanvasHelpers'; diff --git a/src/react/BackgroundCells.test.tsx b/src/react/BackgroundCells.test.tsx new file mode 100644 index 0000000..e343473 --- /dev/null +++ b/src/react/BackgroundCells.test.tsx @@ -0,0 +1,61 @@ +/* @vitest-environment jsdom */ + +import React, { act } from 'react'; +import ReactDOM from 'react-dom/client'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { BackgroundCells } from './BackgroundCells'; +import { useCanvasStore } from '../state/store'; + +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); + }); + await Promise.resolve(); + return { container, root, unmount: () => root.unmount() }; +} + +// Reset camera before each test +beforeEach(() => { + useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 }); +}); + +describe('BackgroundCells (world-locked, smooth)', () => { + it('zero offset: backgroundPosition stays 0, size scales with zoom', async () => { + const { container, unmount } = await render(); + const el = container.firstElementChild as HTMLDivElement; + + // JSDOM may omit background-position even if set for multi-layer, so we only check size and image + expect(el.style.backgroundImage).toContain('linear-gradient'); + expect(el.style.backgroundSize).toBe('24px 24px, 24px 24px'); + + await act(async () => { + useCanvasStore.getState().setCamera({ zoom: 2, offsetX: 0, offsetY: 0 }); + }); + expect(el.style.backgroundSize).toBe('48px 48px, 48px 48px'); + + unmount(); + }); + + it('phase moves with pan and scales smoothly with zoom', async () => { + const { container, unmount } = await render(); + const el = container.firstElementChild as HTMLDivElement; + + + await act(async () => { + useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 20, offsetY: -5 }); + }); + expect(el.style.backgroundSize).toBe('24px 24px, 24px 24px'); + // background-position reflection is flaky in JSDOM for multi-layers; skip strict assertion + + await act(async () => { + useCanvasStore.getState().setCamera({ zoom: 1.5, offsetX: 20, offsetY: -5 }); + }); + expect(el.style.backgroundSize).toBe('36px 36px, 36px 36px'); + // background-position check skipped (covered by BackgroundDots tests) + + unmount(); + }); +}); diff --git a/src/react/BackgroundCells.tsx b/src/react/BackgroundCells.tsx new file mode 100644 index 0000000..50f22dd --- /dev/null +++ b/src/react/BackgroundCells.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { useWorldLockedTile } from './useWorldLockedTile'; + +export type BackgroundCellsProps = { + size?: number; // base cell size in world units (px) + lineWidth?: number; // grid line width in screen px + colorMinor?: string; // line color + baseColor?: string; // background fill color + dprSnap?: boolean | number; // snap tile size/offset to device pixels to avoid anti-aliased thicker lines + style?: React.CSSProperties; +}; + +/** + * Viewport-filling background of cells (grid) locked to WORLD coordinates. + * Uses the same world-locked smooth tiling as BackgroundDots. + */ +export function BackgroundCells({ + size = 24, + lineWidth = 1, + colorMinor = '#91919a', + baseColor = '#f7f9fb', + dprSnap = true, + style, +}: BackgroundCellsProps) { + const { styleForLayers } = useWorldLockedTile({ size, dprSnap }); + const tileStyle = styleForLayers(2); + + // Two orthogonal 1px lines per cell: vertical + horizontal + // The transparent stop at 0 then a thin colored line then transparent again. + const vertical = `linear-gradient(to right, ${colorMinor} ${lineWidth}px, transparent ${lineWidth}px)`; + const horizontal = `linear-gradient(to bottom, ${colorMinor} ${lineWidth}px, transparent ${lineWidth}px)`; + + return ( +
+ ); +} diff --git a/src/react/BackgroundDots.test.tsx b/src/react/BackgroundDots.test.tsx new file mode 100644 index 0000000..1093319 --- /dev/null +++ b/src/react/BackgroundDots.test.tsx @@ -0,0 +1,76 @@ +/* @vitest-environment jsdom */ + +import React, { act } from 'react'; +import ReactDOM from 'react-dom/client'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { BackgroundDots } from './BackgroundDots'; +import { useCanvasStore } from '../state/store'; + +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); + }); + await Promise.resolve(); + return { container, root, unmount: () => root.unmount() }; +} + +// Reset camera before each test +beforeEach(() => { + useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 }); +}); + +describe('BackgroundDots (world-locked, smooth)', () => { + it('keeps backgroundPosition constant while backgroundSize scales with zoom', async () => { + const { container, unmount } = await render(); + const el = container.firstElementChild as HTMLDivElement; + expect(el).toBeTruthy(); + + // Initial state: zoom=1 + expect(el.style.backgroundPosition).toBe('0px 0px'); + expect(el.style.backgroundSize).toBe('24px 24px'); + + // Zoom to 1.5 + await act(async () => { + useCanvasStore.getState().setCamera({ zoom: 1.5, offsetX: 0, offsetY: 0 }); + }); + + // Position remains fixed, size updates (24 * 1.5 = 36) + expect(el.style.backgroundPosition).toBe('0px 0px'); + expect(el.style.backgroundSize).toBe('36px 36px'); + + unmount(); + }); + + it('moves phase with pan and scales phase smoothly with zoom (no jitter)', async () => { + const size = 24; + const { container, unmount } = await render(); + const el = container.firstElementChild as HTMLDivElement; + + const mod = (a: number, n: number) => ((a % n) + n) % n; + + // Apply pan at zoom=1 + await act(async () => { + useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 30, offsetY: 10 }); + }); + // scaled = 24, off = mod(-offset*zoom, scaled) + expect(el.style.backgroundSize).toBe('24px 24px'); + expect(el.style.backgroundPosition).toBe(`${mod(-30, 24)}px ${mod(-10, 24)}px`); + + // Increase zoom to 1.5: scaled = 36, phase recomputed smoothly + await act(async () => { + useCanvasStore.getState().setCamera({ zoom: 1.5, offsetX: 30, offsetY: 10 }); + }); + expect(el.style.backgroundSize).toBe('36px 36px'); + expect(el.style.backgroundPosition).toBe(`${mod(-30 * 1.5, 36)}px ${mod(-10 * 1.5, 36)}px`); + + // The ratio off/zoom remains constant for fixed offsets (sanity check of smoothness) + const off1x = mod(-30, 24); + const off2x = mod(-45, 36); + expect(off2x / 1.5).toBeCloseTo(off1x / 1, 6); + + unmount(); + }); +}); diff --git a/src/react/BackgroundDots.tsx b/src/react/BackgroundDots.tsx index a174b7e..9323d0c 100644 --- a/src/react/BackgroundDots.tsx +++ b/src/react/BackgroundDots.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useCamera } from '../state/store'; +import { useWorldLockedTile } from './useWorldLockedTile'; export type BackgroundDotsProps = { size?: number; // base grid step in world units (px) @@ -7,13 +7,16 @@ export type BackgroundDotsProps = { colorMinor?: string; colorMajor?: string; baseColor?: string; + dprSnap?: boolean | number; // optionally snap tile offsets to device pixels majorEvery?: number; // emphasize each Nth row/column style?: React.CSSProperties; }; /** - * Viewport-filling dotted background that is locked to WORLD coordinates - * (moves with pan, respects zoom via scaled cell size) but is NOT scaled itself. + * Viewport-filling dotted background locked to WORLD coordinates. + * - Moves with pan (phase depends on camera offsets). + * - Respects zoom by changing backgroundSize smoothly (no integer rounding). + * - Avoids jitter during zoom by computing phase from continuous values. * Place as a direct child of the canvas container (not inside the transformed world). */ export function BackgroundDots({ @@ -21,20 +24,12 @@ export function BackgroundDots({ dotRadius = 1.2, colorMinor = '#91919a', baseColor = '#f7f9fb', + dprSnap = true, style, }: BackgroundDotsProps) { - const camera = useCamera(); - - // Build dot layers + // Build dot layer and compute world-locked tiling const minor = `radial-gradient(${colorMinor} ${dotRadius}px, transparent ${dotRadius}px)`; - // Scale cell size by zoom and snap to integer px to prevent tiling seams. - const scaled = Math.max(1, Math.round(size * camera.zoom)); - // Offsets snapped to integers to avoid half-pixel artifacts - const mod = (a: number, n: number) => ((a % n) + n) % n; - const baseOffX = -camera.offsetX * camera.zoom; - const baseOffY = -camera.offsetY * camera.zoom; - const offX = Math.round(mod(baseOffX, scaled)); - const offY = Math.round(mod(baseOffY, scaled)); + const { style: tileStyle } = useWorldLockedTile({ size, dprSnap }); return (
); } + diff --git a/src/react/Canvas.stories.tsx b/src/react/Canvas.stories.tsx index 205f278..74f2d13 100644 --- a/src/react/Canvas.stories.tsx +++ b/src/react/Canvas.stories.tsx @@ -2,10 +2,18 @@ import type { Meta, StoryObj } from '@storybook/react'; import React, { useRef, useState } from 'react'; import { Canvas } from './Canvas'; import { BackgroundDots } from './BackgroundDots'; +import { BackgroundCells } from './BackgroundCells'; import { NodeView } from './NodeView'; import { useCanvasNavigation } from './useCanvasNavigation'; import { cameraToCssTransform } from '../core/coords'; -import { useCamera, useNodeActions, useNodes, useDeleteActions } from '../state/store'; +import { + useCamera, + useNodeActions, + useNodes, + useDeleteActions, + useHistoryActions, + useCanvasActions, +} from '../state/store'; import { useCanvasHelpers } from './useCanvasHelpers'; import type { CanvasNavigationOptions } from './useCanvasNavigation'; import type { BackgroundDotsProps } from './BackgroundDots'; @@ -17,7 +25,13 @@ type CanvasStoryArgs = Required< | 'panModifier' | 'wheelZoom' | 'wheelModifier' - | 'wheelSensitivity' + | 'wheelBehavior' + | 'touchpadZoomSensitivityIn' + | 'touchpadZoomSensitivityOut' + | 'mouseZoomSensitivityIn' + | 'mouseZoomSensitivityOut' + | 'touchpadPanScale' + | 'mousePanScale' | 'doubleClickZoom' | 'doubleClickZoomFactor' | 'doubleClickZoomOut' @@ -28,13 +42,18 @@ type CanvasStoryArgs = Required< | 'keyboardPanSlowStep' > > & { + bgVariant: 'dots' | 'cells'; bgSize: NonNullable; bgDotRadius: NonNullable; + bgLineWidth: number; bgColorMinor: NonNullable; bgBaseColor: NonNullable; + bgDprSnap: boolean; canvasWidth: string; canvasHeight: string; tabIndex: number; + // Story toggles + showHistoryPanel: boolean; // Node appearance controls nodeUnstyled: boolean; nodeBorderColor: string; @@ -59,6 +78,11 @@ const meta: Meta = { layout: 'fullscreen', }, argTypes: { + bgVariant: { + control: { type: 'radio' }, + options: ['dots', 'cells'], + name: 'bg.variant', + }, panButton: { control: { type: 'radio' }, options: [0, 1, 2], @@ -72,9 +96,38 @@ const meta: Meta = { wheelZoom: { control: 'boolean' }, wheelModifier: { control: { type: 'select' }, - options: ['none', 'shift', 'alt', 'ctrl'], + options: ['none', 'alt', 'ctrl'], + }, + wheelBehavior: { + control: { type: 'select' }, + options: ['auto', 'zoom', 'pan'], + description: + "'auto': mouse=pan (Y / Shift->X), Ctrl+wheel=zoom; touchpad: two-finger pan, pinch zoom. 'zoom': legacy zoom by wheel. 'pan': wheel always pans; zoom only with Ctrl.", + }, + touchpadZoomSensitivityIn: { + control: { type: 'number', min: 0.0001, max: 0.01, step: 0.0001 }, + description: 'Sensitivity for touchpad zoom-in (pixels-based deltas)', + }, + touchpadZoomSensitivityOut: { + control: { type: 'number', min: 0.0001, max: 0.01, step: 0.0001 }, + description: 'Sensitivity for touchpad zoom-out (pixels-based deltas)', + }, + mouseZoomSensitivityIn: { + control: { type: 'number', min: 0.0001, max: 0.01, step: 0.0001 }, + description: 'Sensitivity for mouse Ctrl+wheel zoom-in', + }, + mouseZoomSensitivityOut: { + control: { type: 'number', min: 0.0001, max: 0.01, step: 0.0001 }, + description: 'Sensitivity for mouse Ctrl+wheel zoom-out', + }, + touchpadPanScale: { + control: { type: 'number', min: 0.25, max: 8, step: 0.25 }, + description: 'Multiplier for two-finger touchpad pan speed', + }, + mousePanScale: { + control: { type: 'number', min: 0.25, max: 8, step: 0.25 }, + description: 'Multiplier for mouse wheel pan speed (Y, Shift->X)', }, - wheelSensitivity: { control: { type: 'number', min: 0.0001, max: 0.01, step: 0.0001 } }, doubleClickZoom: { control: 'boolean' }, doubleClickZoomFactor: { control: { type: 'number', min: 1, max: 4, step: 0.25 } }, doubleClickZoomOut: { control: 'boolean' }, @@ -89,8 +142,14 @@ const meta: Meta = { bgSize: { control: { type: 'number', min: 4, max: 64, step: 1 }, name: 'bg.size' }, bgDotRadius: { control: { type: 'number', min: 0.5, max: 4, step: 0.1 }, name: 'bg.dotRadius' }, + bgLineWidth: { control: { type: 'number', min: 0.5, max: 8, step: 0.5 }, name: 'bg.lineWidth' }, bgColorMinor: { control: 'color', name: 'bg.colorMinor' }, bgBaseColor: { control: 'color', name: 'bg.baseColor' }, + bgDprSnap: { + control: 'boolean', + name: 'bg.dprSnap', + description: 'Snap background tiling to device pixels for crisp lines/dots', + }, nodeUnstyled: { control: 'boolean', name: 'node.unstyled' }, nodeBorderColor: { control: 'color', name: 'node.borderColor' }, @@ -118,13 +177,21 @@ const meta: Meta = { canvasWidth: { control: { type: 'text' } }, canvasHeight: { control: { type: 'text' } }, tabIndex: { control: { type: 'number', min: -1, max: 10, step: 1 } }, + showHistoryPanel: { control: 'boolean' }, }, args: { + bgVariant: 'dots', panButton: 0, panModifier: 'none', wheelZoom: true, wheelModifier: 'ctrl', - wheelSensitivity: 0.0015, + wheelBehavior: 'auto', + touchpadZoomSensitivityIn: 0.0015, + touchpadZoomSensitivityOut: 0.0015, + mouseZoomSensitivityIn: 0.0015, + mouseZoomSensitivityOut: 0.0015, + touchpadPanScale: 1, + mousePanScale: 4, doubleClickZoom: true, doubleClickZoomFactor: 2, doubleClickZoomOut: true, @@ -132,16 +199,19 @@ const meta: Meta = { doubleClickZoomOutFactor: 2, keyboardPan: true, keyboardPanStep: 50, - keyboardPanSlowStep: 10, + keyboardPanSlowStep: 25, bgSize: 24, bgDotRadius: 1.2, + bgLineWidth: 1, bgColorMinor: '#91919a', bgBaseColor: '#f7f9fb', + bgDprSnap: true, canvasWidth: '100vw', canvasHeight: '100vh', tabIndex: 0, + showHistoryPanel: false, // Node defaults (match NodeView defaultAppearance) nodeUnstyled: false, @@ -204,13 +274,21 @@ function WorldLayer({ args }: { args: CanvasStoryArgs }) { ); } -function Controls({ rootRef }: { rootRef: React.RefObject }) { +function Controls({ + rootRef, + showHistoryControls, +}: { + rootRef: React.RefObject; + showHistoryControls?: boolean; +}) { const { updateNode, removeNode } = useNodeActions(); const { deleteSelected } = useDeleteActions(); const nodes = useNodes(); const counterRef = useRef(1); const [targetId, setTargetId] = useState(''); const { addNodeAtCenter } = useCanvasHelpers(rootRef); + const { undo, redo } = useHistoryActions(); + const { panBy } = useCanvasActions(); const add = () => { const id = `n${counterRef.current++}`; @@ -257,6 +335,11 @@ function Controls({ rootRef }: { rootRef: React.RefObject }) { 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,