Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
152 changes: 145 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -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.

Expand All @@ -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
};
```

Expand Down Expand Up @@ -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 `<Canvas tabIndex={-1} />` 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<HTMLDivElement | null>(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 <Canvas ref={ref} style={{ width: 800, height: 600 }} />;
}
```

## 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
<BackgroundDots size={24} />

// Disable snapping (may blur on some zoom levels)
<BackgroundDots size={24} dprSnap={false} />
```

```tsx
// Cells: crisp 1px grid lines
<BackgroundCells size={24} lineWidth={1} />

// Force a fixed DPR (useful in tests/SSR)
<BackgroundCells size={24} dprSnap={2} />
```

Direct hook usage:

```tsx
const { style } = useWorldLockedTile({ size: 32, dprSnap: true });
return <div style={{ position: 'absolute', inset: 0, backgroundImage: '...', ...style }} />;
```

### 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
Expand Down
9 changes: 2 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 19 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
Expand All @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
61 changes: 61 additions & 0 deletions src/react/BackgroundCells.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BackgroundCells size={24} />);
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(<BackgroundCells size={24} />);
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();
});
});
Loading
Loading