diff --git a/.changeset/dnd-tests-docs.md b/.changeset/dnd-tests-docs.md new file mode 100644 index 0000000..ad2c297 --- /dev/null +++ b/.changeset/dnd-tests-docs.md @@ -0,0 +1,8 @@ +--- +"@flowscape-ui/canvas-react": patch +--- + +- test: add NodeView drag-and-drop UI test verifying hit-testing (no canvas pan), node move, and single history batch +- chore: add PointerEvent polyfill for jsdom tests +- docs(README): document DnD behavior, hit-testing, and history batching/undo-redo +- docs(tasklist): mark CORE-05c/CORE-05d/CORE-06 and CORE-05 as done diff --git a/README.md b/README.md index d88ea50..a02c2a3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,193 @@ bun add @flowscape-ui/canvas-react npm i @flowscape-ui/canvas-react ``` +## Features (MVP-0.1) + +- Pan/Zoom with mouse and keyboard. Default zoom bounds: 0.6–2.4 (60–240%). +- Nodes API (add/update/remove). Minimal `NodeView` to render arbitrary content inside nodes via `children`. +- Selection: + - Single select: left-click on a node. + - 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. + +### Example: Basic Canvas with Navigation and Node Views + +```tsx +import { Canvas, NodeView, useNodeActions, useNodes, useCanvasNavigation } from '@flowscape-ui/canvas-react'; +import { useRef, useEffect } from 'react'; + +export default function Example() { + const ref = useRef(null); + useCanvasNavigation(ref, { panButton: 0 }); + const nodes = useNodes(); + const { addNode } = useNodeActions(); + + useEffect(() => { + addNode({ id: 'a', x: 50, y: 50, width: 120, height: 60 }); + addNode({ id: 'b', x: 240, y: 160, width: 120, height: 60 }); + }, [addNode]); + + return ( + + {nodes.map((n) => ( + +
+ Node {n.id} +
+
+ ))} +
+ ); +} +``` + +### Add nodes at the visible center (regardless of zoom) + +You can add nodes at the current visual center using the store action `addNodeAtCenter`: + +```tsx +import { Canvas, useNodeActions } from '@flowscape-ui/canvas-react'; +import { useRef } from 'react'; + +export default function CenterAddExample() { + const { addNodeAtCenter } = useNodeActions(); + return ( + <> + + + + ); +} +``` + +For embedded canvases where the element may not fill the viewport, use the helper hook `useCanvasHelpers(rootRef)` which computes the center of the actual element: + +```tsx +import { Canvas, useCanvasHelpers } from '@flowscape-ui/canvas-react'; +import { useRef } from 'react'; + +export default function EmbeddedCenterAdd() { + const ref = useRef(null); + const { addNodeAtCenter } = useCanvasHelpers(ref); + return ( + <> + + + + ); +} +``` + +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. + +## NodeView appearance (appearance, unstyled) + +`NodeView` supports a minimal built-in look that you can customize or fully opt out of: + +- `appearance?: Partial` — selectively override visual tokens. +- `unstyled?: boolean` — disable built-in look entirely; you render your own HTML/CSS. + +Defaults are a pill-like card: rounded corners, soft border and shadow, white background. If no `children` are provided, `NodeView` renders a centered label “New Node”. When `children` are provided, `NodeView` does not impose padding, centering or typography on your content (container visuals like border/background/shadow still apply unless `unstyled`). + +Shape of `NodeAppearance`: + +```ts +type NodeAppearance = { + borderColor: string; + selectedBorderColor: string; + borderWidth: number; + borderRadius: number; + background: string; + textColor: string; + 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 +}; +``` + +### Example: Customizing NodeView visuals + +```tsx +import { Canvas, NodeView, useNodes } from '@flowscape-ui/canvas-react'; + +export default function StyledNodes() { + const nodes = useNodes(); + return ( + + {nodes.map((n) => ( + +
+ + My Node {n.id} +
+
+ ))} +
+ ); +} +``` + +If you prefer to fully control visuals: + +```tsx + +
Custom content
+ {/* Your CSS decides everything */} + {/* Position/size still come from node.x/y/width/height */} + {/* DnD and selection behavior are preserved */} + {/* Tip: add your own shadows/rounded/borders via CSS classes */} +
+``` + +### Selection Behavior + +- Click on a node: it becomes the only selected node. +- Ctrl/Cmd + Click: toggles the clicked node in the selection set. +- Click on empty space (no mouse movement): clears selection. +- Dragging on empty space pans the canvas and does not change selection. + +### Keyboard & Shortcuts + +- WASD/Arrow keys to pan (Shift reduces step). +- Mouse wheel zoom (configurable), double-click zoom. +- Zoom bounds: 0.6–2.4. +- Delete/Backspace: deletes all currently selected nodes. +- 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. + +### 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`). + ## Usage (very basic) ```tsx diff --git a/bun.lock b/bun.lock index b2acd70..b6ce1b0 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,7 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-react": "^7.34.2", "eslint-plugin-react-hooks": "^5.2.0", + "jsdom": "^26.1.0", "prettier": "^3.3.3", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -47,6 +48,8 @@ "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/compat-data": ["@babel/compat-data@7.28.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="], @@ -119,6 +122,16 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@csstools/color-helpers": ["@csstools/color-helpers@5.0.2", "", {}, "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.0.10", "", { "dependencies": { "@csstools/color-helpers": "^5.0.2", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.9", "", { "os": "aix", "cpu": "ppc64" }, "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.9", "", { "os": "android", "cpu": "arm" }, "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ=="], @@ -347,6 +360,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], @@ -433,8 +448,12 @@ "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], @@ -445,6 +464,8 @@ "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -479,6 +500,8 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -615,6 +638,12 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "human-id": ["human-id@4.1.1", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -673,6 +702,8 @@ "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -711,6 +742,8 @@ "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -771,6 +804,8 @@ "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], + "nwsapi": ["nwsapi@2.2.21", "", {}, "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -813,6 +848,8 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], @@ -885,6 +922,8 @@ "rollup-plugin-dts": ["rollup-plugin-dts@6.2.3", "", { "dependencies": { "magic-string": "^0.30.17" }, "optionalDependencies": { "@babel/code-frame": "^7.27.1" }, "peerDependencies": { "rollup": "^3.29.4 || ^4", "typescript": "^4.5 || ^5.0" } }, "sha512-UgnEsfciXSPpASuOelix7m4DrmyQgiaWBnvI0TM4GxuDh5FkqW8E5hu57bCxXB90VvR1WNfLV80yEDN18UogSA=="], + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], @@ -895,6 +934,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], @@ -967,6 +1008,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], @@ -983,9 +1026,15 @@ "tinyspy": ["tinyspy@4.0.3", "", {}, "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], "ts-api-utils": ["ts-api-utils@1.4.3", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="], @@ -1031,11 +1080,17 @@ "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1059,6 +1114,10 @@ "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="], @@ -1131,6 +1190,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -1183,6 +1244,10 @@ "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="], diff --git a/doc/tasklist.md b/doc/tasklist.md index b9a85e1..7f26fea 100644 --- a/doc/tasklist.md +++ b/doc/tasklist.md @@ -59,38 +59,47 @@ ### Project -| ID | Задача | Описание | Старт | Дедлайн | Статус | Ссылки | -| ---- | ----------------- | ----------------------------------------------------------------------------- | ---------- | ---------- | ------ | ------------------------------------------------------------------------- | -| P-01 | Scaffolding (Bun) | `package.json`, `tsconfig.json`, `rollup.config.ts`, базовая структура `src/` | 2025-08-18 | 2025-08-20 | done | `doc/vision.md` | -| P-02 | Repo policies | `SECURITY.md`, `CONTRIBUTING.md`, `CODEOWNERS` | 2025-08-18 | 2025-08-22 | done | `/.github/SECURITY.md`, `/.github/CONTRIBUTING.md`, `/.github/CODEOWNERS` | +| ID | Задача | Описание | Старт | Дедлайн | Статус | Ссылки | +| ---- | ----------------- | ----------------------------------------------------------------------------- | ---------- | ---------- | ------- | ------------------------------------------------------------------------- | +| P-01 | Scaffolding (Bun) | `package.json`, `tsconfig.json`, `rollup.config.ts`, базовая структура `src/` | 2025-08-18 | 2025-08-20 | done ✅ | `doc/vision.md` | +| P-02 | Repo policies | `SECURITY.md`, `CONTRIBUTING.md`, `CODEOWNERS` | 2025-08-18 | 2025-08-22 | done ✅ | `/.github/SECURITY.md`, `/.github/CONTRIBUTING.md`, `/.github/CODEOWNERS` | ### CI/CD -| ID | Задача | Описание | Старт | Дедлайн | Статус | Ссылки | -| ----- | ---------------- | ---------------------------------------------------------------------- | ---------- | ---------- | ------ | ------------------------------------------------- | -| CI-01 | CI workflow | `.github/workflows/ci.yml` (typecheck/lint/test/build/storybook:build) | 2025-08-19 | 2025-08-20 | done | `doc/vision.md`, `/.github/workflows/ci.yml` | -| CI-02 | Release workflow | `.github/workflows/release.yml` (Changesets + npm publish) | 2025-08-20 | 2025-08-21 | done | `doc/vision.md`, `/.github/workflows/release.yml` | -| CI-03 | Pages workflow | `.github/workflows/pages.yml` (Storybook → Pages) | 2025-08-20 | 2025-08-21 | done | `doc/vision.md`, `/.github/workflows/pages.yml` | +| ID | Задача | Описание | Старт | Дедлайн | Статус | Ссылки | +| ----- | ---------------- | ---------------------------------------------------------------------- | ---------- | ---------- | ------- | ------------------------------------------------- | +| CI-01 | CI workflow | `.github/workflows/ci.yml` (typecheck/lint/test/build/storybook:build) | 2025-08-19 | 2025-08-20 | done ✅ | `doc/vision.md`, `/.github/workflows/ci.yml` | +| CI-02 | Release workflow | `.github/workflows/release.yml` (Changesets + npm publish) | 2025-08-20 | 2025-08-21 | done ✅ | `doc/vision.md`, `/.github/workflows/release.yml` | +| CI-03 | Pages workflow | `.github/workflows/pages.yml` (Storybook → Pages) | 2025-08-20 | 2025-08-21 | done ✅ | `doc/vision.md`, `/.github/workflows/pages.yml` | ### Security -| ID | Задача | Описание | Старт | Дедлайн | Статус | Ссылки | -| ------ | --------------------- | ------------------------------------------------------------ | ---------- | ---------- | ------- | --------------- | -| SEC-01 | .gitignore/.npmignore | Исключения для секретов/артефактов; `files` в `package.json` | 2025-08-19 | 2025-08-19 | done | `doc/vision.md` | -| SEC-02 | CodeQL | Настроить `CodeQL` workflow | 2025-08-22 | 2025-08-22 | done | `/.github/workflows/codeql.yml` | -| SEC-03 | Gitleaks | Настроить `Gitleaks` workflow | 2025-08-22 | 2025-08-22 | done | `/.github/workflows/gitleaks.yml` | -| SEC-04 | Dependabot | `.github/dependabot.yml` (npm + actions) | 2025-08-22 | 2025-08-22 | done | `/.github/dependabot.yml` | +| ID | Задача | Описание | Старт | Дедлайн | Статус | Ссылки | +| ------ | --------------------- | ------------------------------------------------------------ | ---------- | ---------- | ------- | --------------------------------- | +| SEC-01 | .gitignore/.npmignore | Исключения для секретов/артефактов; `files` в `package.json` | 2025-08-19 | 2025-08-19 | done ✅ | `doc/vision.md` | +| SEC-02 | CodeQL | Настроить `CodeQL` workflow | 2025-08-22 | 2025-08-22 | done ✅ | `/.github/workflows/codeql.yml` | +| SEC-03 | Gitleaks | Настроить `Gitleaks` workflow | 2025-08-22 | 2025-08-22 | done ✅ | `/.github/workflows/gitleaks.yml` | +| SEC-04 | Dependabot | `.github/dependabot.yml` (npm + actions) | 2025-08-22 | 2025-08-22 | done ✅ | `/.github/dependabot.yml` | ### Core -| ID | Задача | Описание | Старт | Дедлайн | Статус | -| ------- | ----------------- | ------------------------------------------------------- | ---------- | ---------- | ------- | -| CORE-01 | Координаты/камера | Модель `world/screen`, `zoom`, `offset`, преобразования | 2025-08-19 | 2025-08-23 | pending | -| CORE-02 | Ввод/события | Pointer/Wheel/Keyboard, жесты навигации | 2025-08-20 | 2025-08-24 | pending | -| CORE-03 | Store и селекторы | Лёгкий стор, подписки/селекторы, API-хуки | 2025-08-20 | 2025-08-25 | pending | -| CORE-04 | Узлы (CRUD) | Типы узлов, добавление/удаление/обновление | 2025-08-21 | 2025-08-26 | pending | -| CORE-05 | Select & DnD | Выбор/снятие, перетаскивание узлов | 2025-08-22 | 2025-08-27 | pending | -| CORE-06 | История | Командная модель, undo/redo, coalescing | 2025-08-23 | 2025-08-28 | pending | +| ID | Задача | Описание | Старт | Дедлайн | Статус | +| ------- | ----------------- | ------------------------------------------------------- | ---------- | ---------- | ----------- | +| CORE-01 | Координаты/камера | Модель `world/screen`, `zoom`, `offset`, преобразования | 2025-08-19 | 2025-08-23 | done ✅ | +| CORE-02 | Ввод/события | Pointer/Wheel/Keyboard, жесты навигации | 2025-08-20 | 2025-08-24 | done ✅ | +| CORE-03 | Store и селекторы | Лёгкий стор, подписки/селекторы, API-хуки | 2025-08-20 | 2025-08-25 | done ✅ | +| CORE-04 | Узлы (CRUD) | Типы узлов, добавление/удаление/обновление | 2025-08-21 | 2025-08-26 | done ✅ | +| CORE-05 | Select & DnD | Выбор/снятие, перетаскивание узлов | 2025-08-22 | 2025-08-27 | done ✅ | +| CORE-06 | История | Командная модель, undo/redo, coalescing | 2025-08-23 | 2025-08-28 | done ✅ | + +#### Подзадачи CORE-05 (Select & DnD) + +| ID | Задача | Описание | Старт | Дедлайн | Статус | +| -------- | ----------------------- | -------------------------------------------------------------- | ----- | ------- | ----------- | +| CORE-05a | Click select/deselect | Клик по узлу: выбор; клик по пустому месту: снятие выделения | - | - | done ✅ | +| CORE-05b | Multi-select (modifier) | Ctrl/Cmd: добавление/снятие узлов из текущего выбора | - | - | done ✅ | +| CORE-05c | Drag move (1/мульти) | Перетаскивание одного/нескольких узлов; coalescing для истории | - | - | done ✅ | +| CORE-05d | Hit-testing при DnD | Корректный захват/перемещение; без конфликта с навигацией | - | - | done ✅ | ### UI & Docs @@ -102,25 +111,25 @@ ### QA -| ID | Задача | Описание | Старт | Дедлайн | Статус | -| ----- | ---------------- | --------------------------------------- | ---------- | ---------- | ------- | -| QA-01 | Unit/Integration | Vitest + RTL: ядро/редьюсеры/компоненты | 2025-08-21 | 2025-08-28 | pending | -| QA-02 | E2E (минимум) | Playwright: smoke (навигация/зум) | 2025-08-26 | 2025-08-29 | pending | +| ID | Задача | Описание | Старт | Дедлайн | Статус | +| ----- | ---------------- | --------------------------------------- | ---------- | ---------- | ----------- | +| QA-01 | Unit/Integration | Vitest + RTL: ядро/редьюсеры/компоненты | 2025-08-21 | 2025-08-28 | in_progress | +| QA-02 | E2E (минимум) | Playwright: smoke (навигация/зум) | 2025-08-26 | 2025-08-29 | pending | ### Release -| ID | Задача | Описание | Старт | Дедлайн | Статус | Ссылки | -| ------- | --------------- | --------------------------------------------- | --------------------------------- | ---------- | ---------- | ----------------------------------------------------------- | ------- | --- | -| REL-01 | Changesets init | Инициализация Changesets, первый changeset | 2025-08-21 | 2025-08-21 | done | `/.changeset/config.json`, `/.changeset/initial-release.md` | -| REL-02 | v0.1.0 (MVP) | Срез функционала, релиз в npm, Pages доступен | 2025-08-29 | 2025-08-30 | pending | | -| POST-01 | Post | Мониторинг | Size-limit/Bundle analyzer, отчёт | - | 2025-08-30 | 2025-09-01 | pending | | +| ID | Задача | Описание | Старт | Дедлайн | Статус | Ссылки | +| ------- | --------------- | --------------------------------------------- | ---------- | ---------- | ------- | ----------------------------------------------------------- | +| REL-01 | Changesets init | Инициализация Changesets, первый changeset | 2025-08-21 | 2025-08-21 | done ✅ | `/.changeset/config.json`, `/.changeset/initial-release.md` | +| REL-02 | v0.1.0 (MVP) | Срез функционала, релиз в npm, Pages доступен | 2025-08-29 | 2025-08-30 | pending | | +| POST-01 | Post | Мониторинг | 2025-08-30 | 2025-09-01 | pending | Size-limit/Bundle analyzer, отчёт | > Примечание: даты ориентировочные, можно корректировать релиз-план по мере прогресса. ### Критерии готовности MVP-0.1 (Definition of Done) - Функционал: - - Навигация: pan/zoom (мышь/тачпад), диапазон зума 10–500%. + - Навигация: pan/zoom (мышь/тачпад), диапазон зума 60–240%. - Узлы: создание/удаление/обновление; выбор, перетаскивание. - История: undo/redo, coalescing при drag. - Фон: один из вариантов (grid или dots). diff --git a/package.json b/package.json index 229e3be..dea5c4a 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,14 @@ "zustand": "^4.5.2" }, "devDependencies": { + "@changesets/changelog-github": "^0.5.0", + "@changesets/cli": "^2.27.8", "@rollup/plugin-commonjs": "^25.0.0", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-typescript": "^11.1.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", @@ -47,18 +51,15 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-react": "^7.34.2", "eslint-plugin-react-hooks": "^5.2.0", + "jsdom": "^26.1.0", "prettier": "^3.3.3", + "react": "^19.1.1", + "react-dom": "^19.1.1", "rollup": "^4.21.0", "rollup-plugin-dts": "^6.1.1", + "storybook": "^9.1.2", "tslib": "^2.6.3", "typescript": "5.5.4", - "@changesets/cli": "^2.27.8", - "@changesets/changelog-github": "^0.5.0", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "@storybook/react": "^9.1.2", - "storybook": "^9.1.2", - "@storybook/react-vite": "^9.1.2", "vite": "^5.4.0", "vitest": "^2.0.5" }, diff --git a/src/core/coords.ts b/src/core/coords.ts index 311a7d5..144800b 100644 --- a/src/core/coords.ts +++ b/src/core/coords.ts @@ -55,6 +55,8 @@ export function zoomAtPoint(camera: Camera, screenPoint: Point, factor: number, * Usage example: style={{ transform: cameraToCssTransform(camera) }} */ export function cameraToCssTransform(camera: Camera): string { - // translate in world units, then scale; keeps worldToScreen mapping: (x - offset) * zoom - return `translate(${-camera.offsetX}px, ${-camera.offsetY}px) scale(${camera.zoom})`; + // CSS applies transform functions right-to-left. + // We need translate first, then scale to satisfy: screen = (x - offset) * zoom. + // Therefore write: scale(...) translate(...), so translate runs first. + return `scale(${camera.zoom}) translate(${-camera.offsetX}px, ${-camera.offsetY}px)`; } diff --git a/src/index.ts b/src/index.ts index b5ed6c4..7f16442 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,7 @@ export * from './react/Canvas'; export * from './core/coords'; export * from './types'; export * from './state/store'; +export * from './react/useCanvasNavigation'; +export * from './react/BackgroundDots'; +export * from './react/NodeView'; +export * from './react/useCanvasHelpers'; diff --git a/src/react/BackgroundDots.tsx b/src/react/BackgroundDots.tsx new file mode 100644 index 0000000..a174b7e --- /dev/null +++ b/src/react/BackgroundDots.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { useCamera } from '../state/store'; + +export type BackgroundDotsProps = { + size?: number; // base grid step in world units (px) + dotRadius?: number; // dot radius in screen px + colorMinor?: string; + colorMajor?: string; + baseColor?: string; + 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. + * Place as a direct child of the canvas container (not inside the transformed world). + */ +export function BackgroundDots({ + size = 24, + dotRadius = 1.2, + colorMinor = '#91919a', + baseColor = '#f7f9fb', + style, +}: BackgroundDotsProps) { + const camera = useCamera(); + + // Build dot layers + 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)); + + return ( +
+ ); +} diff --git a/src/react/Camera.stories.tsx b/src/react/Camera.stories.tsx deleted file mode 100644 index 07ded2d..0000000 --- a/src/react/Camera.stories.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import React from 'react'; -import { Canvas } from './Canvas'; -import { useCamera, useCanvasActions } from '../state/store'; -import { cameraToCssTransform } from '../core/coords'; - -const meta: Meta = { - title: 'Core/Camera', - component: Canvas, - tags: ['dev'], -}; - -export default meta; - -type Story = StoryObj; - -function Controls() { - const camera = useCamera(); - const { panBy, zoomTo, zoomByAt } = useCanvasActions(); - - const anchor = { x: 400, y: 300 }; // center of 800x600 viewport - - return ( -
- - - - - - - - - zoom: {camera.zoom.toFixed(2)} | offset: ({camera.offsetX.toFixed(0)}, {camera.offsetY.toFixed(0)}) - -
- ); -} - -function WorldLayer() { - const camera = useCamera(); - return ( -
- {/* Big world area with a light grid */} -
- {/* Markers at known world coords */} -
-
-
-
-
- ); -} - -export const Playground: Story = { - render: () => ( -
- - - - -
- ), -}; diff --git a/src/react/Canvas.stories.tsx b/src/react/Canvas.stories.tsx index 2b372c6..205f278 100644 --- a/src/react/Canvas.stories.tsx +++ b/src/react/Canvas.stories.tsx @@ -1,32 +1,403 @@ import type { Meta, StoryObj } from '@storybook/react'; -import React from 'react'; -import { Canvas, type CanvasProps } from './Canvas'; +import React, { useRef, useState } from 'react'; +import { Canvas } from './Canvas'; +import { BackgroundDots } from './BackgroundDots'; +import { NodeView } from './NodeView'; +import { useCanvasNavigation } from './useCanvasNavigation'; +import { cameraToCssTransform } from '../core/coords'; +import { useCamera, useNodeActions, useNodes, useDeleteActions } from '../state/store'; +import { useCanvasHelpers } from './useCanvasHelpers'; +import type { CanvasNavigationOptions } from './useCanvasNavigation'; +import type { BackgroundDotsProps } from './BackgroundDots'; -const meta: Meta = { +type CanvasStoryArgs = Required< + Pick< + CanvasNavigationOptions, + | 'panButton' + | 'panModifier' + | 'wheelZoom' + | 'wheelModifier' + | 'wheelSensitivity' + | 'doubleClickZoom' + | 'doubleClickZoomFactor' + | 'doubleClickZoomOut' + | 'doubleClickZoomOutModifier' + | 'doubleClickZoomOutFactor' + | 'keyboardPan' + | 'keyboardPanStep' + | 'keyboardPanSlowStep' + > +> & { + bgSize: NonNullable; + bgDotRadius: NonNullable; + bgColorMinor: NonNullable; + bgBaseColor: NonNullable; + canvasWidth: string; + canvasHeight: string; + tabIndex: number; + // Node appearance controls + nodeUnstyled: boolean; + nodeBorderColor: string; + nodeSelectedBorderColor: string; + nodeBorderWidth: number; + nodeBorderRadius: number; + nodeBackground: string; + nodeTextColor: string; + nodeShadow: string; + nodeHoverShadow: string; + nodeSelectedShadow: string; + nodePadding: number; + nodeFontSize: number; + nodeFontWeight: number; +}; + +const meta: Meta = { title: 'Core/Canvas', - component: Canvas, - tags: ['autodocs'], + component: Playground, + tags: ['dev'], + parameters: { + layout: 'fullscreen', + }, argTypes: { - className: { control: 'text' }, - style: { control: 'object' }, - children: { control: false }, + panButton: { + control: { type: 'radio' }, + options: [0, 1, 2], + mapping: { 0: 0, 1: 1, 2: 2 }, + description: 'Mouse button for panning (0=left, 1=middle, 2=right)', + }, + panModifier: { + control: { type: 'select' }, + options: ['none', 'shift', 'alt', 'ctrl'], + }, + wheelZoom: { control: 'boolean' }, + wheelModifier: { + control: { type: 'select' }, + options: ['none', 'shift', 'alt', 'ctrl'], + }, + 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' }, + doubleClickZoomOutModifier: { + control: { type: 'select' }, + options: ['none', 'shift', 'alt', 'ctrl'], + }, + doubleClickZoomOutFactor: { control: { type: 'number', min: 1, max: 4, step: 0.25 } }, + keyboardPan: { control: 'boolean' }, + keyboardPanStep: { control: { type: 'number', min: 1, max: 200, step: 1 } }, + keyboardPanSlowStep: { control: { type: 'number', min: 1, max: 200, step: 1 } }, + + 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' }, + bgColorMinor: { control: 'color', name: 'bg.colorMinor' }, + bgBaseColor: { control: 'color', name: 'bg.baseColor' }, + + nodeUnstyled: { control: 'boolean', name: 'node.unstyled' }, + nodeBorderColor: { control: 'color', name: 'node.borderColor' }, + nodeSelectedBorderColor: { control: 'color', name: 'node.selectedBorderColor' }, + nodeBorderWidth: { + control: { type: 'number', min: 0, max: 8, step: 1 }, + name: 'node.borderWidth', + }, + nodeBorderRadius: { + control: { type: 'number', min: 0, max: 40, step: 1 }, + name: 'node.borderRadius', + }, + nodeBackground: { control: 'color', name: 'node.background' }, + nodeTextColor: { control: 'color', name: 'node.textColor' }, + nodeShadow: { control: { type: 'text' }, name: 'node.shadow' }, + nodeHoverShadow: { control: { type: 'text' }, name: 'node.hoverShadow' }, + nodeSelectedShadow: { control: { type: 'text' }, name: 'node.selectedShadow' }, + nodePadding: { control: { type: 'number', min: 0, max: 40, step: 1 }, name: 'node.padding' }, + nodeFontSize: { control: { type: 'number', min: 8, max: 48, step: 1 }, name: 'node.fontSize' }, + nodeFontWeight: { + control: { type: 'number', min: 100, max: 900, step: 100 }, + name: 'node.fontWeight', + }, + + canvasWidth: { control: { type: 'text' } }, + canvasHeight: { control: { type: 'text' } }, + tabIndex: { control: { type: 'number', min: -1, max: 10, step: 1 } }, + }, + args: { + panButton: 0, + panModifier: 'none', + wheelZoom: true, + wheelModifier: 'ctrl', + wheelSensitivity: 0.0015, + doubleClickZoom: true, + doubleClickZoomFactor: 2, + doubleClickZoomOut: true, + doubleClickZoomOutModifier: 'alt', + doubleClickZoomOutFactor: 2, + keyboardPan: true, + keyboardPanStep: 50, + keyboardPanSlowStep: 10, + + bgSize: 24, + bgDotRadius: 1.2, + bgColorMinor: '#91919a', + bgBaseColor: '#f7f9fb', + + canvasWidth: '100vw', + canvasHeight: '100vh', + tabIndex: 0, + + // Node defaults (match NodeView defaultAppearance) + nodeUnstyled: false, + nodeBorderColor: '#E5E7EB', + nodeSelectedBorderColor: '#ff0073', + nodeBorderWidth: 1, + nodeBorderRadius: 18, + nodeBackground: '#FFFFFF', + nodeTextColor: '#111827', + nodeShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.05)', + nodeHoverShadow: '0 8px 20px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.08)', + nodeSelectedShadow: '0 8px 20px rgba(255,0,115,0.18), 0 2px 6px rgba(17,24,39,0.08)', + nodePadding: 10, + nodeFontSize: 14, + nodeFontWeight: 600, }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; -export const Basic: Story = { - args: { - style: { width: 800, height: 600, border: '1px solid #ddd' }, - children: ( -
- @flowscape-ui/canvas-react -
- Базовый пример холста. Добавляйте свои компоненты как children. +function WorldLayer({ args }: { args: CanvasStoryArgs }) { + const camera = useCamera(); + const nodes = useNodes(); + return ( +
+ {nodes.map((n) => ( + + New node - {n.id} + + ))} +
+ ); +} + +function Controls({ rootRef }: { rootRef: React.RefObject }) { + const { updateNode, removeNode } = useNodeActions(); + const { deleteSelected } = useDeleteActions(); + const nodes = useNodes(); + const counterRef = useRef(1); + const [targetId, setTargetId] = useState(''); + const { addNodeAtCenter } = useCanvasHelpers(rootRef); + + const add = () => { + const id = `n${counterRef.current++}`; + addNodeAtCenter({ id, width: 100, height: 60 }); + }; + + const updateFirstNode = () => { + if (nodes.length === 0) return; + const first = nodes[0]; + updateNode(first.id, { x: first.x + 10, y: first.y + 6 }); + }; + + const updateLastNode = () => { + if (nodes.length === 0) return; + const last = nodes[nodes.length - 1]; + updateNode(last.id, { x: last.x + 10, y: last.y + 6 }); + }; + + const removeFirstNode = () => { + if (nodes.length === 0) return; + const first = nodes[0]; + removeNode(first.id); + }; + + const removeLastNode = () => { + if (nodes.length === 0) return; + const last = nodes[nodes.length - 1]; + removeNode(last.id); + }; + + const updateNodeById = () => { + if (!targetId) return; + const node = nodes.find((n) => n.id === targetId); + if (!node) return; + updateNode(node.id, { x: node.x + 10, y: node.y + 6 }); + }; + + const removeNodeById = () => { + if (!targetId) return; + removeNode(targetId); + }; + + const removeSelectedNodes = () => { + deleteSelected(); + }; + + return ( +
+ + + + + + setTargetId(e.target.value)} + placeholder="id..." + style={{ padding: 4, border: '1px solid #ddd', borderRadius: 4 }} + /> + + + + count: {nodes.length} +
+ ); +} + +function Playground(args: CanvasStoryArgs) { + const ref = useRef(null); + useCanvasNavigation(ref, { + panButton: args.panButton, + panModifier: args.panModifier, + wheelZoom: args.wheelZoom, + wheelModifier: args.wheelModifier, + wheelSensitivity: args.wheelSensitivity, + doubleClickZoom: args.doubleClickZoom, + doubleClickZoomFactor: args.doubleClickZoomFactor, + doubleClickZoomOut: args.doubleClickZoomOut, + doubleClickZoomOutModifier: args.doubleClickZoomOutModifier, + doubleClickZoomOutFactor: args.doubleClickZoomOutFactor, + keyboardPan: args.keyboardPan, + keyboardPanStep: args.keyboardPanStep, + keyboardPanSlowStep: args.keyboardPanSlowStep, + }); + + return ( +
+ + } + > + + +
+
+
+
Selection tips
+
• Click node: select only
+
• Ctrl/Cmd + Click: toggle in selection
+
• Click empty area (no drag): clear selection
+
+
+
Keyboard & focus
+
• Press Delete/Backspace to remove selected nodes
+
• Canvas auto-focuses on pointer interactions
+
• To disable auto-focus, set Canvas tabIndex to -1 (see Controls)
+
+
- ), - } satisfies CanvasProps, +
+ ); +} + +export const Basic: Story = { + args: { + tabIndex: 10, + }, + + render: (args) => , }; diff --git a/src/react/Canvas.tsx b/src/react/Canvas.tsx index d4dc88d..364a153 100644 --- a/src/react/Canvas.tsx +++ b/src/react/Canvas.tsx @@ -1,15 +1,117 @@ -import type React from 'react'; +import React, { forwardRef, useRef } from 'react'; +import { useSelectionActions, useHistoryActions } from '../state/store'; export type CanvasProps = { className?: string; style?: React.CSSProperties; children?: React.ReactNode; + /** Optional background component rendered behind content (e.g., ). */ + background?: React.ReactNode; + /** Optional CSS background styling for the built-in background layer. */ + backgroundStyle?: React.CSSProperties; + /** + * tabIndex applied to the Canvas root to enable keyboard navigation. + * Defaults to 0 (focusable). + */ + tabIndex?: number; }; -export function Canvas({ className, style, children }: CanvasProps) { +export const Canvas = forwardRef(function Canvas( + { className, style, children, background, backgroundStyle, tabIndex = 0 }: CanvasProps, + ref, +) { + const { clearSelection } = useSelectionActions(); + const { undo, redo } = useHistoryActions(); + + // Track whether this interaction should clear selection (i.e., true click without drag) + const shouldMaybeDeselectRef = useRef(false); + const startXRef = useRef(0); + const startYRef = useRef(0); + const DRAG_THRESHOLD_PX = 3; // movement beyond this cancels deselect + + const onPointerDown: React.PointerEventHandler = (e) => { + // Focus canvas so it receives keyboard events (Delete/Backspace, arrows, etc.) + const root = e.currentTarget as HTMLElement; + const tabindexAttr = root.getAttribute('tabindex'); + const isFocusDisabled = tabindexAttr === '-1'; + if (!isFocusDisabled && typeof root.focus === 'function') { + try { + root.focus({ preventScroll: true } as FocusOptions); + } catch { + // ignore + } + } + // Only consider left button. NodeView stops propagation, so this only + // fires for empty canvas area. Do not stop propagation to keep pan working. + if (e.button === 0) { + shouldMaybeDeselectRef.current = true; + startXRef.current = e.clientX; + startYRef.current = e.clientY; + } + }; + + const onPointerMove: React.PointerEventHandler = (e) => { + if (!shouldMaybeDeselectRef.current) return; + const dx = Math.abs(e.clientX - startXRef.current); + const dy = Math.abs(e.clientY - startYRef.current); + if (dx > DRAG_THRESHOLD_PX || dy > DRAG_THRESHOLD_PX) { + // Treat as pan/drag; do not deselect on pointer up + shouldMaybeDeselectRef.current = false; + } + }; + + const onPointerUp: React.PointerEventHandler = (e) => { + if (e.button === 0 && shouldMaybeDeselectRef.current) { + clearSelection(); + } + shouldMaybeDeselectRef.current = false; + }; + + const onPointerCancel: React.PointerEventHandler = () => { + shouldMaybeDeselectRef.current = false; + }; + + const onContextMenu: React.MouseEventHandler = (e) => { + // Disable the browser's context menu when interacting with the canvas + e.preventDefault(); + }; + + const onKeyDown: React.KeyboardEventHandler = (e) => { + // Undo/Redo shortcuts: Ctrl/Cmd+Z, Shift+Ctrl/Cmd+Z + if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { + e.preventDefault(); + if (e.shiftKey) redo(); + else undo(); + } + }; + return ( -
- {children} +
+
+ {background} +
+
{children}
); -} +}); diff --git a/src/react/NodeView.dnd.test.tsx b/src/react/NodeView.dnd.test.tsx new file mode 100644 index 0000000..144fe8f --- /dev/null +++ b/src/react/NodeView.dnd.test.tsx @@ -0,0 +1,126 @@ +/* @vitest-environment jsdom */ + +import React, { useRef } from 'react'; +import ReactDOM from 'react-dom/client'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { NodeView } from './NodeView'; +import { useCanvasNavigation } from './useCanvasNavigation'; +import { useCanvasStore } from '../state/store'; +import type { CanvasStore } from '../state/store'; +import type { Node } from '../types'; + +// Polyfill PointerEvent in jsdom if missing +type PointerEventInitLike = MouseEventInit & { + pointerId?: number; + width?: number; + height?: number; + pressure?: number; + tiltX?: number; + tiltY?: number; + pointerType?: string; + isPrimary?: boolean; +}; + +interface GlobalWithPointer { + PointerEvent?: typeof Event; +} + +const g = globalThis as unknown as GlobalWithPointer; +if (typeof g.PointerEvent === 'undefined') { + class PointerEventPolyfill extends MouseEvent { + public pointerId: number; + public width: number; + public height: number; + public pressure: number; + public tiltX: number; + public tiltY: number; + public pointerType: string; + public isPrimary: boolean; + constructor(type: string, params: PointerEventInitLike = {}) { + super(type, params); + this.pointerId = params.pointerId ?? 1; + this.width = params.width ?? 0; + this.height = params.height ?? 0; + this.pressure = params.pressure ?? 0.5; + this.tiltX = params.tiltX ?? 0; + this.tiltY = params.tiltY ?? 0; + this.pointerType = params.pointerType ?? 'mouse'; + this.isPrimary = params.isPrimary ?? true; + } + } + (globalThis as unknown as { PointerEvent: typeof Event }).PointerEvent = PointerEventPolyfill as unknown as typeof Event; +} + +function TestHost() { + const ref = useRef(null); + useCanvasNavigation(ref, { panButton: 0, panModifier: 'none', wheelZoom: false, doubleClickZoom: false }); + const node = useCanvasStore.getState().nodes['d1']; + if (!node) return
; + + return ( +
+ +
+ ); +} + +async function render(ui: React.ReactElement) { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOM.createRoot(container); + 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: () => root.unmount() }; +} + +function dispatchPointer(el: Element, type: string, init: PointerEventInit) { + const ev = new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, ...init }); + el.dispatchEvent(ev); +} + +describe('NodeView DnD UI + hit-testing vs canvas pan', () => { + beforeEach(() => { + // reset store + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: { d1: { id: 'd1', x: 100, y: 100, width: 80, height: 40 } as Node }, + selected: { d1: true }, + centerAddIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + } as Partial); + }); + + it("dragging a node moves it and doesn't pan the canvas; one history batch is recorded", async () => { + const { container, unmount } = await render(); + let nodeEl = container.querySelector('[data-rc-nodeid="d1"]') as HTMLElement | null; + if (!nodeEl) nodeEl = document.querySelector('[data-rc-nodeid="d1"]') as HTMLElement | null; + expect(nodeEl).toBeTruthy(); + + // Start drag on the node + dispatchPointer(nodeEl!, 'pointerdown', { button: 0, clientX: 300, clientY: 200 }); + // move beyond threshold (DRAG_THRESHOLD_PX = 3) + dispatchPointer(nodeEl!, 'pointermove', { clientX: 312, clientY: 209 }); // dx=12, dy=9 (screen) + // finish + dispatchPointer(nodeEl!, 'pointerup', { button: 0, clientX: 312, clientY: 209 }); + + const s = useCanvasStore.getState(); + // Camera should not have panned because useCanvasNavigation ignores node-origin events + expect(s.camera.offsetX).toBe(0); + expect(s.camera.offsetY).toBe(0); + + // Node should have moved by world delta = screen delta / zoom (zoom=1) + expect(s.nodes['d1']).toMatchObject({ x: 112, y: 109 }); + + // History: exactly one batch for the drag (we prepopulated the node directly) + expect(s.historyPast.length).toBe(1); + const last = s.historyPast[0]; + // Only updates inside the batch + expect(last.changes.every((c) => c.kind === 'update')).toBe(true); + + unmount(); + }); +}); diff --git a/src/react/NodeView.tsx b/src/react/NodeView.tsx new file mode 100644 index 0000000..5d92805 --- /dev/null +++ b/src/react/NodeView.tsx @@ -0,0 +1,314 @@ +import React, { forwardRef, useRef, useState } from 'react'; +import type { Node } from '../types'; +import { + useDndActions, + useIsSelected, + useSelectionActions, + useCamera, + useCanvasActions, + useHistoryActions, +} from '../state/store'; + +type NodeAppearance = { + borderColor: string; + selectedBorderColor: string; + borderWidth: number; + borderRadius: number; + background: string; + textColor: string; + shadow: string; + hoverShadow: string; + selectedShadow?: string; + padding: number; + fontSize: number; + fontWeight: number; +}; + +export type NodeViewProps = { + node: Node; + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; + appearance?: Partial; + unstyled?: boolean; +}; + +/** + * Minimal node view: absolutely positioned box at node's world coords. + * You can pass children to render arbitrary HTML content inside the node. + */ +export const NodeView = forwardRef(function NodeView( + { node, className, style, children, appearance, unstyled }: NodeViewProps, + ref, +) { + const isSelected = useIsSelected(node.id); + const { selectOnly, toggleInSelection } = useSelectionActions(); + const { moveSelectedBy } = useDndActions(); + const camera = useCamera(); + const { panBy } = useCanvasActions(); + const { beginHistory, endHistory } = useHistoryActions(); + + // Drag bookkeeping + const startXRef = useRef(0); + const startYRef = useRef(0); + const lastXRef = useRef(0); + const lastYRef = useRef(0); + const draggingRef = useRef(false); + const pointerIdRef = useRef(null); + const historyStartedRef = useRef(false); + const DRAG_THRESHOLD_PX = 3; + const [isHovered, setIsHovered] = useState(false); + + // Appearance defaults (pill-like as on the reference image) + const defaultAppearance: NodeAppearance = { + borderColor: '#E5E7EB', + selectedBorderColor: '#ff0073', + borderWidth: 1, + borderRadius: 18, + background: '#FFFFFF', + textColor: '#111827', + shadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.05)', + hoverShadow: '0 8px 20px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.08)', + selectedShadow: '0 8px 20px rgba(255,0,115,0.18), 0 2px 6px rgba(17,24,39,0.08)', + padding: 10, + fontSize: 14, + fontWeight: 600, + }; + const A = { ...defaultAppearance, ...(appearance ?? {}) } as NodeAppearance; + const hasCustomChildren = children != null; + const contentLabel = 'New Node'; + + // Auto-pan bookkeeping + const canvasElRef = useRef(null); + const autoRafRef = useRef(null); + const lastTsRef = useRef(null); + const edgeSpeedXRef = useRef(0); // screen px/sec, +right, -left + const edgeSpeedYRef = useRef(0); // screen px/sec, +down, -up + const EDGE_PX = 24; // activation zone size + const MAX_SPEED_PX_PER_SEC = 800; // max auto-pan speed + + const stopAutoPan = () => { + if (autoRafRef.current != null) { + cancelAnimationFrame(autoRafRef.current); + autoRafRef.current = null; + } + lastTsRef.current = null; + edgeSpeedXRef.current = 0; + edgeSpeedYRef.current = 0; + }; + + const autoPanStep = (ts: number) => { + if (!draggingRef.current) { + stopAutoPan(); + return; + } + if (lastTsRef.current == null) { + lastTsRef.current = ts; + autoRafRef.current = requestAnimationFrame(autoPanStep); + return; + } + const dt = (ts - lastTsRef.current) / 1000; + lastTsRef.current = ts; + + const sx = edgeSpeedXRef.current; + const sy = edgeSpeedYRef.current; + if (sx === 0 && sy === 0) { + // No active edge pressure; stop until next pointer move updates speeds + stopAutoPan(); + return; + } + const dxScreen = sx * dt; + const dyScreen = sy * dt; + const dz = camera.zoom || 1; + const dxWorld = dxScreen / dz; + const dyWorld = dyScreen / dz; + // Pan the canvas and move nodes by the SAME world delta to keep node under cursor + panBy(dxWorld, dyWorld); + moveSelectedBy(dxWorld, dyWorld); + + autoRafRef.current = requestAnimationFrame(autoPanStep); + }; + + const ensureAutoPan = () => { + if (autoRafRef.current == null) { + lastTsRef.current = null; + autoRafRef.current = requestAnimationFrame(autoPanStep); + } + }; + + const updateEdgeSpeeds = (clientX: number, clientY: number) => { + const root = canvasElRef.current; + if (!root) { + edgeSpeedXRef.current = 0; + edgeSpeedYRef.current = 0; + stopAutoPan(); + return; + } + const rect = root.getBoundingClientRect(); + const leftDist = clientX - rect.left; + const rightDist = rect.right - clientX; + const topDist = clientY - rect.top; + const bottomDist = rect.bottom - clientY; + + const leftDepth = Math.min(Math.max(EDGE_PX - leftDist, 0), EDGE_PX); + const rightDepth = Math.min(Math.max(EDGE_PX - rightDist, 0), EDGE_PX); + const topDepth = Math.min(Math.max(EDGE_PX - topDist, 0), EDGE_PX); + const bottomDepth = Math.min(Math.max(EDGE_PX - bottomDist, 0), EDGE_PX); + + // screen velocity: right/bottom -> positive, left/top -> negative + const vx = ((rightDepth - leftDepth) / EDGE_PX) * MAX_SPEED_PX_PER_SEC; + const vy = ((bottomDepth - topDepth) / EDGE_PX) * MAX_SPEED_PX_PER_SEC; + + edgeSpeedXRef.current = vx; + edgeSpeedYRef.current = vy; + + if (vx !== 0 || vy !== 0) ensureAutoPan(); + else stopAutoPan(); + }; + + const onPointerDown: React.PointerEventHandler = (e) => { + // Left button only + if (e.button !== 0) return; + // Multi-select: Ctrl/Cmd toggles membership. Shift is reserved for future. + if (e.ctrlKey || e.metaKey) { + toggleInSelection(node.id); + } else if (!isSelected) { + // If node is not selected, select only it. If already selected, preserve current multi-selection + selectOnly(node.id); + } + // Stop propagation so canvas navigation doesn't start panning on node click + e.stopPropagation(); + // Prevent default to avoid text selection side-effects + e.preventDefault(); + + // Prepare for potential drag + startXRef.current = e.clientX; + startYRef.current = e.clientY; + lastXRef.current = e.clientX; + lastYRef.current = e.clientY; + draggingRef.current = false; + pointerIdRef.current = e.pointerId; + // cache canvas root for edge auto-pan + canvasElRef.current = (e.currentTarget as HTMLElement).closest( + '[data-rc-canvas]', + ) as HTMLElement | null; + // Ensure canvas receives keyboard events (Delete/Backspace etc.) + const canvasEl = canvasElRef.current; + if (canvasEl) { + const tabindexAttr = canvasEl.getAttribute('tabindex'); + const isFocusDisabled = tabindexAttr === '-1'; + if (!isFocusDisabled && typeof (canvasEl as HTMLElement).focus === 'function') { + try { + (canvasEl as HTMLElement).focus({ preventScroll: true } as FocusOptions); + } catch { + // ignore + } + } + } + try { + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + } catch { + // ignore if capture is not available + } + }; + + const onPointerMove: React.PointerEventHandler = (e) => { + if (pointerIdRef.current == null) return; + // calculate total movement from start to decide if it's a drag + const totalDx = Math.abs(e.clientX - startXRef.current); + const totalDy = Math.abs(e.clientY - startYRef.current); + if (!draggingRef.current) { + if (totalDx > DRAG_THRESHOLD_PX || totalDy > DRAG_THRESHOLD_PX) { + draggingRef.current = true; + if (!historyStartedRef.current) { + beginHistory(); + historyStartedRef.current = true; + } + } else { + return; + } + } + // Incremental movement since last event (screen px) + const dxScreen = e.clientX - lastXRef.current; + const dyScreen = e.clientY - lastYRef.current; + lastXRef.current = e.clientX; + lastYRef.current = e.clientY; + if (dxScreen === 0 && dyScreen === 0) return; + // Convert to WORLD delta using current zoom + const dz = camera.zoom || 1; + moveSelectedBy(dxScreen / dz, dyScreen / dz); + + // Update edge speeds for auto-pan and start/stop RAF accordingly + updateEdgeSpeeds(e.clientX, e.clientY); + }; + + const finishDrag = () => { + draggingRef.current = false; + pointerIdRef.current = null; + stopAutoPan(); + if (historyStartedRef.current) { + endHistory(); + historyStartedRef.current = false; + } + }; + + const onPointerUp: React.PointerEventHandler = (e) => { + if (pointerIdRef.current == null) return; + try { + (e.currentTarget as HTMLElement).releasePointerCapture(pointerIdRef.current); + } catch { + // ignore + } + finishDrag(); + }; + + const onPointerCancel: React.PointerEventHandler = () => { + finishDrag(); + }; + + return ( +
setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + data-rc-nodeid={node.id} + > + {hasCustomChildren ? children : contentLabel} +
+ ); +}); diff --git a/src/react/useCanvasHelpers.ts b/src/react/useCanvasHelpers.ts new file mode 100644 index 0000000..a299a48 --- /dev/null +++ b/src/react/useCanvasHelpers.ts @@ -0,0 +1,62 @@ +import { useRef } from 'react'; +import type { RefObject } from 'react'; +import { screenToWorld } from '../core/coords'; +import { useCamera, useNodeActions } from '../state/store'; +import type { Node, NodeId } from '../types'; + +export type AddNodeAtCenterInput = { + id: NodeId; + width: number; + height: number; + // Optional patch to extend Node in future + data?: Partial; +}; + +export type AddAtCenterOptions = { + stepPx?: number; // screen px per step + modulo?: number; // wrap steps to avoid drifting too far +}; + +/** + * React helpers bound to a specific Canvas root element. + * Provides addNodeAtCenter() that places nodes at the visual center regardless of zoom, + * with a small diagonal offset for subsequent nodes. + */ +export function useCanvasHelpers(rootRef: RefObject, options?: AddAtCenterOptions) { + const camera = useCamera(); + const { addNode } = useNodeActions(); + const offsetIndexRef = useRef(0); + const stepPx = options?.stepPx ?? 16; + const modulo = options?.modulo ?? 12; + + function getWorldCenter() { + const root = rootRef.current; + if (root) { + const r = root.getBoundingClientRect(); + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + return screenToWorld({ x: cx, y: cy }, camera); + } + // Fallback: viewport center + const cx = typeof window !== 'undefined' ? window.innerWidth / 2 : 0; + const cy = typeof window !== 'undefined' ? window.innerHeight / 2 : 0; + return screenToWorld({ x: cx, y: cy }, camera); + } + + function addNodeAtCenter(input: AddNodeAtCenterInput) { + const centerWorld = getWorldCenter(); + const k = offsetIndexRef.current % modulo; + const dxWorld = (k * stepPx) / camera.zoom; + const dyWorld = (k * stepPx) / camera.zoom; + const x = centerWorld.x - input.width / 2 + dxWorld; + const y = centerWorld.y - input.height / 2 + dyWorld; + addNode({ id: input.id, x, y, width: input.width, height: input.height }); + offsetIndexRef.current += 1; + } + + function resetCenterOffset() { + offsetIndexRef.current = 0; + } + + return { addNodeAtCenter, resetCenterOffset }; +} diff --git a/src/react/useCanvasNavigation.test.tsx b/src/react/useCanvasNavigation.test.tsx new file mode 100644 index 0000000..fc29670 --- /dev/null +++ b/src/react/useCanvasNavigation.test.tsx @@ -0,0 +1,174 @@ +/* @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'; + +function TestHost(props: { + options?: Parameters[1]; + withInput?: boolean; +}) { + const ref = useRef(null); + useCanvasNavigation(ref, props.options); + + return ( +
+ {props.withInput ? : null} +
+ ); +} + +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); + }); + // microtask to ensure useEffect ran + await Promise.resolve(); + return { container, root, unmount: () => root.unmount() }; +} + +function dispatchKey(el: Element, key: string, code?: string, init?: KeyboardEventInit) { + const ev = new KeyboardEvent('keydown', { + key, + code, + bubbles: true, + cancelable: true, + ...init, + }); + el.dispatchEvent(ev); +} + +// Reset camera before each test +beforeEach(() => { + useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 }); +}); + +describe('useCanvasNavigation keyboard', () => { + it('pans with WASD/Arrow keys using base step', async () => { + const { container, unmount } = await render( + , + ); + + const el = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement; + + // Left (A): dxScreen = +step => panBy(-step, 0) => offsetX -= 10 + dispatchKey(el, 'a'); + expect(useCanvasStore.getState().camera.offsetX).toBe(-10); + + // Right (D): dxScreen = -step => panBy(+step, 0) => offsetX += 10 + dispatchKey(el, 'd'); + expect(useCanvasStore.getState().camera.offsetX).toBe(0); + + // Up (W): dyScreen = +step => panBy(0, -step) => offsetY -= 10 + dispatchKey(el, 'w'); + expect(useCanvasStore.getState().camera.offsetY).toBe(-10); + + // Down (S): dyScreen = -step => panBy(0, +step) => offsetY += 10 + dispatchKey(el, 's'); + expect(useCanvasStore.getState().camera.offsetY).toBe(0); + + unmount(); + }); + + it('uses slow step when holding Shift', async () => { + const { container, unmount } = await render( + , + ); + + const el = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement; + // Reset offsets + useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 }); + + // Shift + A => offsetX -= 5 + dispatchKey(el, 'a', undefined, { shiftKey: true }); + expect(useCanvasStore.getState().camera.offsetX).toBe(-5); + + // Shift + W => offsetY -= 5 + dispatchKey(el, 'w', undefined, { shiftKey: true }); + expect(useCanvasStore.getState().camera.offsetY).toBe(-5); + + unmount(); + }); + + it("zooms with '+' key around canvas center", async () => { + const { container, unmount } = await render( + , + ); + + const el = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement; + + // Stub getBoundingClientRect to return non-zero size + const rect = { + left: 0, + top: 0, + width: 800, + height: 600, + right: 800, + bottom: 600, + x: 0, + y: 0, + toJSON() {}, + } as DOMRect; + const originalGetBCR = el.getBoundingClientRect.bind(el); + el.getBoundingClientRect = () => rect; + + // initial zoom + expect(useCanvasStore.getState().camera.zoom).toBe(1); + + // Press '+' (Equal) + dispatchKey(el, '+', 'Equal'); + + expect(useCanvasStore.getState().camera.zoom).toBeCloseTo(1.1, 5); + + // restore + el.getBoundingClientRect = originalGetBCR; + unmount(); + }); + + it('ignores key events originating from inputs/contenteditable', async () => { + const { container, unmount } = await render( + , + ); + + const canvas = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement; + const input = container.querySelector('[data-testid="inner-input"]')! as HTMLInputElement; + + useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 }); + + // Dispatch on input (bubbles to canvas), but handler should early-return + dispatchKey(input, 'a'); + expect(useCanvasStore.getState().camera.offsetX).toBe(0); + + // Dispatch on canvas to verify it still works + dispatchKey(canvas, 'a'); + expect(useCanvasStore.getState().camera.offsetX).toBe(-10); + + unmount(); + }); +}); diff --git a/src/react/useCanvasNavigation.ts b/src/react/useCanvasNavigation.ts new file mode 100644 index 0000000..7c263c8 --- /dev/null +++ b/src/react/useCanvasNavigation.ts @@ -0,0 +1,341 @@ +import { useEffect } from 'react'; +import type { RefObject } from 'react'; +import { useCanvasStore } from '../state/store'; + +export type CanvasNavigationOptions = { + panButton?: 0 | 1 | 2; // left | middle | right + 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 + doubleClickZoomOutModifier?: 'none' | 'shift' | 'alt' | 'ctrl'; + doubleClickZoomOutFactor?: number; // factor for zoom-out (divides zoom) + 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 */; +}; + +const defaultOptions: Required = { + panButton: 0, + panModifier: 'none', + wheelZoom: true, + wheelModifier: 'none', + wheelSensitivity: 0.0015, + doubleClickZoom: true, + doubleClickZoomFactor: 2, + doubleClickZoomOut: true, + doubleClickZoomOutModifier: 'alt', + doubleClickZoomOutFactor: 2, + keyboardPan: true, + keyboardPanStep: 50, + keyboardPanSlowStep: 25, +}; + +function hasSetPointerCapture( + el: Element, +): el is Element & { setPointerCapture(id: number): void } { + return 'setPointerCapture' in el; +} + +function hasReleasePointerCapture( + el: Element, +): el is Element & { releasePointerCapture(id: number): void } { + return 'releasePointerCapture' in el; +} + +/** + * Attach interactive navigation (pan/zoom) to a Canvas root element. + * Usage: + * const ref = useRef(null); + * useCanvasNavigation(ref, { panButton: 0 }); + */ +export function useCanvasNavigation( + ref: RefObject, + options?: CanvasNavigationOptions, +): void { + const opts = { ...defaultOptions, ...(options ?? {}) }; + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const rootEl = el as HTMLElement; + let isPanning = false; + let lastX = 0; + let lastY = 0; + let prevCursor: string | null = null; + + // ensure pointer events behave for pan/zoom + const prevTouchAction = el.style.touchAction; + el.style.touchAction = 'none'; + + function modifierPressed(ev: PointerEvent | WheelEvent): boolean { + switch (opts.panModifier) { + case 'none': + return true; + case 'shift': + return ev.shiftKey === true; + case 'alt': + return ev.altKey === true; + case 'ctrl': + return ev.ctrlKey === true || ev.metaKey === true; + default: + return true; + } + } + + function isFromNode(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return !!target.closest('[data-rc-nodeid]'); + } + + function wheelModifierPressed(ev: WheelEvent): boolean { + switch (opts.wheelModifier) { + case 'none': + return true; + case 'shift': + return ev.shiftKey === true; + case 'alt': + return ev.altKey === true; + case 'ctrl': + return ev.ctrlKey === true || ev.metaKey === true; + default: + return true; + } + } + + function onPointerDown(e: PointerEvent) { + if (e.button !== opts.panButton) return; + if (!modifierPressed(e)) return; + // Do not start panning when interacting with a Node element + if (isFromNode(e.target)) return; + + isPanning = true; + lastX = e.clientX; + lastY = e.clientY; + // start a history batch so the whole drag is a single undoable step + try { + const { beginHistory } = useCanvasStore.getState(); + beginHistory('camera-pan'); + } catch { + // ignore + } + // switch cursor to indicate panning state + if (prevCursor === null) prevCursor = rootEl.style.cursor; + rootEl.style.cursor = 'grabbing'; + const target = e.target as Element | null; + if (target && hasSetPointerCapture(target)) { + target.setPointerCapture(e.pointerId); + } + // focus the canvas so it can receive keyboard events (respect tabindex=-1) + const tabindexAttr = rootEl.getAttribute('tabindex'); + const isFocusDisabled = tabindexAttr === '-1'; + if (!isFocusDisabled && typeof rootEl.focus === 'function') { + try { + rootEl.focus({ preventScroll: true } as FocusOptions); + } catch { + // ignore + } + } + } + + function onPointerMove(e: PointerEvent) { + if (!isPanning) return; + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + lastX = e.clientX; + lastY = e.clientY; + + const { camera, panBy } = useCanvasStore.getState(); + const invZoom = 1 / camera.zoom; + // convert screen delta to world delta so content follows the cursor + panBy(-dx * invZoom, -dy * invZoom); + } + + function onPointerUp(e: PointerEvent) { + if (!isPanning) return; + isPanning = false; + // end history batch started on pointerdown + try { + const { endHistory } = useCanvasStore.getState(); + endHistory(); + } catch { + // ignore + } + // restore previous cursor + if (prevCursor !== null) { + rootEl.style.cursor = prevCursor; + } + const target = e.target as Element | null; + if (target && hasReleasePointerCapture(target)) { + target.releasePointerCapture(e.pointerId); + } + } + + 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); + + const { zoomByAt } = useCanvasStore.getState(); + zoomByAt(screenPoint, factor); + } + + function onDblClick(e: MouseEvent) { + if (!opts.doubleClickZoom) return; + // Only respond to left-button double click + if (e.button !== 0) return; + 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 }; + // If zoom-out is enabled and its modifier is held, perform zoom-out + let factor = opts.doubleClickZoomFactor; + const outMod = opts.doubleClickZoomOutModifier; + const outModActive = (() => { + switch (outMod) { + case 'none': + return true; + case 'shift': + return e.shiftKey === true; + case 'alt': + return e.altKey === true; + case 'ctrl': + return e.ctrlKey === true || e.metaKey === true; + default: + return false; + } + })(); + if (opts.doubleClickZoomOut && outModActive) { + factor = 1 / (opts.doubleClickZoomOutFactor || 2); + } + const { zoomByAt } = useCanvasStore.getState(); + zoomByAt(screenPoint, factor); + } + + el.addEventListener('pointerdown', onPointerDown); + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + el.addEventListener('wheel', onWheel, { passive: false }); + el.addEventListener('dblclick', onDblClick); + // keyboard navigation: arrows/WASD to pan (if enabled), +/- to zoom at center + function isTextInput(element: Element | null): boolean { + if (!element || !(element instanceof HTMLElement)) return false; + const tag = element.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; + if (element.isContentEditable) return true; + return false; + } + + function onKeyDown(e: KeyboardEvent) { + // ignore if typing in inputs/contenteditable + if (isTextInput(e.target as Element)) return; + + const { camera, panBy, zoomByAt } = useCanvasStore.getState(); + const invZoom = 1 / camera.zoom; + + if (opts.keyboardPan) { + let dxScreen = 0; + let dyScreen = 0; + const slow = e.shiftKey; // Shift = slow + let step = opts.keyboardPanStep; + if (slow) step = opts.keyboardPanSlowStep; + + switch (e.key) { + case 'ArrowLeft': + case 'a': + case 'A': + dxScreen = step; + break; + case 'ArrowRight': + case 'd': + case 'D': + dxScreen = -step; + break; + case 'ArrowUp': + case 'w': + case 'W': + dyScreen = step; + break; + case 'ArrowDown': + case 's': + case 'S': + dyScreen = -step; + break; + } + + if (dxScreen !== 0 || dyScreen !== 0) { + e.preventDefault(); // prevent page/scroll keys + panBy(-dxScreen * invZoom, -dyScreen * invZoom); + return; + } + } + + // deletion: Delete / Backspace removes selected nodes + if (e.key === 'Delete' || e.key === 'Backspace') { + const { selected, deleteSelected } = useCanvasStore.getState(); + if (Object.keys(selected).length > 0) { + e.preventDefault(); + deleteSelected(); + } + return; + } + + // zoom with +/- (including numpad and '=' as '+') + const code = e.code; + const key = e.key; + const isPlus = key === '+' || key === '=' || code === 'Equal' || code === 'NumpadAdd'; + const isMinus = key === '-' || code === 'Minus' || code === 'NumpadSubtract'; + if (isPlus || isMinus) { + e.preventDefault(); + const factor = isPlus ? 1.1 : 1 / 1.1; + const currentEl = ref.current; + if (!currentEl) return; + const rect = currentEl.getBoundingClientRect(); + const screenPoint = { x: rect.width / 2, y: rect.height / 2 }; + zoomByAt(screenPoint, factor); + } + } + + el.addEventListener('keydown', onKeyDown); + + return () => { + rootEl.removeEventListener('pointerdown', onPointerDown); + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + rootEl.removeEventListener('wheel', onWheel); + rootEl.removeEventListener('dblclick', onDblClick); + rootEl.removeEventListener('keydown', onKeyDown); + // ensure cursor is restored if effect is torn down mid-pan + if (prevCursor !== null) rootEl.style.cursor = prevCursor; + rootEl.style.touchAction = prevTouchAction; + }; + }, [ + ref, + opts.panButton, + opts.panModifier, + opts.wheelZoom, + opts.wheelModifier, + opts.wheelSensitivity, + opts.doubleClickZoom, + opts.doubleClickZoomFactor, + opts.doubleClickZoomOut, + opts.doubleClickZoomOutModifier, + opts.doubleClickZoomOutFactor, + opts.keyboardPan, + opts.keyboardPanStep, + opts.keyboardPanSlowStep, + ]); +} diff --git a/src/state/store.delete.test.ts b/src/state/store.delete.test.ts new file mode 100644 index 0000000..7b36ec6 --- /dev/null +++ b/src/state/store.delete.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { useCanvasStore } from './store'; +import type { Node } from '../types'; + +function resetStore() { + // Reset only state slices; keep action functions intact + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + }); +} + +describe('bulk deletion in store', () => { + beforeEach(() => { + resetStore(); + }); + + it('removeNodes(ids) removes existing nodes and cleans selection', () => { + const add = (n: Node) => useCanvasStore.getState().addNode(n); + add({ id: 'n1', x: 0, y: 0, width: 10, height: 10 }); + add({ id: 'n2', x: 1, y: 1, width: 10, height: 10 }); + add({ id: 'n3', x: 2, y: 2, width: 10, height: 10 }); + + // select n1 and n3 + useCanvasStore.getState().selectOnly('n1'); + useCanvasStore.getState().addToSelection('n3'); + + // remove n1 and n3; include a non-existing id to ensure idempotency + useCanvasStore.getState().removeNodes(['n1', 'n3', 'nx']); + + const s = useCanvasStore.getState(); + expect(s.nodes['n1']).toBeUndefined(); + expect(s.nodes['n3']).toBeUndefined(); + expect(s.nodes['n2']).toBeDefined(); + + // selection entries for removed ids must be gone + expect(s.selected['n1']).toBeUndefined(); + expect(s.selected['n3']).toBeUndefined(); + + // nothing else remains selected + expect(Object.keys(s.selected)).toHaveLength(0); + }); + + it('deleteSelected() deletes only currently selected nodes and clears selection', () => { + const add = (n: Node) => useCanvasStore.getState().addNode(n); + add({ id: 'a', x: 0, y: 0, width: 10, height: 10 }); + add({ id: 'b', x: 1, y: 1, width: 10, height: 10 }); + add({ id: 'c', x: 2, y: 2, width: 10, height: 10 }); + + // select a and b, leave c unselected + useCanvasStore.getState().selectOnly('a'); + useCanvasStore.getState().addToSelection('b'); + + useCanvasStore.getState().deleteSelected(); + + const s = useCanvasStore.getState(); + expect(s.nodes['a']).toBeUndefined(); + expect(s.nodes['b']).toBeUndefined(); + expect(s.nodes['c']).toBeDefined(); + + // selection should be cleared + expect(Object.keys(s.selected)).toHaveLength(0); + }); +}); diff --git a/src/state/store.dnd.test.ts b/src/state/store.dnd.test.ts new file mode 100644 index 0000000..de7c6d6 --- /dev/null +++ b/src/state/store.dnd.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { useCanvasStore } from './store'; +import type { Node } from '../types'; + +describe('DnD in store: moveSelectedBy', () => { + it('moves a single selected node by dx,dy (world units)', () => { + const n: Node = { id: 'm1', x: 10, y: 20, width: 50, height: 40 }; + useCanvasStore.getState().addNode(n); + useCanvasStore.getState().selectOnly('m1'); + + useCanvasStore.getState().moveSelectedBy(15, -5); + + const s = useCanvasStore.getState(); + expect(s.nodes['m1']).toMatchObject({ x: 25, y: 15 }); + }); + + it('moves multiple selected nodes together', () => { + useCanvasStore.getState().addNode({ id: 'm2', x: 0, y: 0, width: 10, height: 10 }); + useCanvasStore.getState().addNode({ id: 'm3', x: 5, y: 5, width: 10, height: 10 }); + // select two nodes + useCanvasStore.getState().selectOnly('m2'); + useCanvasStore.getState().addToSelection('m3'); + + useCanvasStore.getState().moveSelectedBy(3, 4); + + const s = useCanvasStore.getState(); + expect(s.nodes['m2']).toMatchObject({ x: 3, y: 4 }); + expect(s.nodes['m3']).toMatchObject({ x: 8, y: 9 }); + }); + + it('no-op when nothing selected or zero delta', () => { + useCanvasStore.getState().addNode({ id: 'm4', x: 100, y: 200, width: 10, height: 10 }); + // nothing selected + useCanvasStore.getState().moveSelectedBy(10, 10); + let s = useCanvasStore.getState(); + expect(s.nodes['m4']).toMatchObject({ x: 100, y: 200 }); + + // select and move by zero + useCanvasStore.getState().selectOnly('m4'); + useCanvasStore.getState().moveSelectedBy(0, 0); + s = useCanvasStore.getState(); + expect(s.nodes['m4']).toMatchObject({ x: 100, y: 200 }); + }); +}); diff --git a/src/state/store.history.test.ts b/src/state/store.history.test.ts new file mode 100644 index 0000000..96fc46a --- /dev/null +++ b/src/state/store.history.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCanvasStore } from './store'; +import type { Node } from '../types'; +import type { CanvasStore } from './store'; + +function resetStore() { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + centerAddIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + } as Partial); +} + +describe('History: CRUD and undo/redo', () => { + beforeEach(() => resetStore()); + + it('records add/update/remove into history and supports undo/redo', () => { + const a: Node = { id: 'h1', x: 10, y: 10, width: 20, height: 10 }; + useCanvasStore.getState().addNode(a); + // update + useCanvasStore.getState().updateNode('h1', { x: 15 }); + // remove + useCanvasStore.getState().removeNode('h1'); + + let s = useCanvasStore.getState(); + expect(s.historyPast.length).toBe(3); + expect(s.nodes['h1']).toBeUndefined(); + + // undo remove + s.undo(); + s = useCanvasStore.getState(); + expect(s.nodes['h1']).toBeDefined(); + expect(s.nodes['h1']).toMatchObject({ x: 15, y: 10 }); + + // undo update + s.undo(); + s = useCanvasStore.getState(); + expect(s.nodes['h1']).toMatchObject({ x: 10, y: 10 }); + + // undo add -> node gone + s.undo(); + s = useCanvasStore.getState(); + expect(s.nodes['h1']).toBeUndefined(); + + // redo add + s.redo(); + s = useCanvasStore.getState(); + expect(s.nodes['h1']).toMatchObject({ x: 10, y: 10 }); + + // redo update + s.redo(); + s = useCanvasStore.getState(); + expect(s.nodes['h1']).toMatchObject({ x: 15, y: 10 }); + + // redo remove + s.redo(); + s = useCanvasStore.getState(); + expect(s.nodes['h1']).toBeUndefined(); + }); +}); + +describe('History: drag batching with beginHistory/endHistory', () => { + beforeEach(() => resetStore()); + + it('coalesces multiple moveSelectedBy calls into a single history entry with per-node updates', () => { + const n1: Node = { id: 'hb1', x: 0, y: 0, width: 10, height: 10 }; + const n2: Node = { id: 'hb2', x: 5, y: 0, width: 10, height: 10 }; + useCanvasStore.getState().addNode(n1); + useCanvasStore.getState().addNode(n2); + useCanvasStore.getState().selectOnly('hb1'); + useCanvasStore.getState().addToSelection('hb2'); + + // Start a batch (like NodeView drag) + useCanvasStore.getState().beginHistory(); + useCanvasStore.getState().moveSelectedBy(1, 0); + useCanvasStore.getState().moveSelectedBy(2, 0); + useCanvasStore.getState().moveSelectedBy(-1, 0); + useCanvasStore.getState().endHistory(); + + let s = useCanvasStore.getState(); + expect(s.historyPast.length).toBe(3); // add n1, add n2, batch move + // Final positions: total dx = 1+2-1 = 2 + expect(s.nodes['hb1']).toMatchObject({ x: 2, y: 0 }); + expect(s.nodes['hb2']).toMatchObject({ x: 7, y: 0 }); + + // Undo should revert both nodes back to original positions in one step + s.undo(); + s = useCanvasStore.getState(); + expect(s.nodes['hb1']).toMatchObject({ x: 0, y: 0 }); + expect(s.nodes['hb2']).toMatchObject({ x: 5, y: 0 }); + + // Redo should reapply the final positions + s.redo(); + s = useCanvasStore.getState(); + expect(s.nodes['hb1']).toMatchObject({ x: 2, y: 0 }); + expect(s.nodes['hb2']).toMatchObject({ x: 7, y: 0 }); + }); +}); + +describe('History: chaining and future clearing', () => { + beforeEach(() => resetStore()); + + it('clears future on new action after undo and keeps chaining correctly', () => { + const a: Node = { id: 'hc1', x: 0, y: 0, width: 10, height: 10 }; + const b: Node = { id: 'hc2', x: 0, y: 0, width: 10, height: 10 }; + useCanvasStore.getState().addNode(a); + useCanvasStore.getState().addNode(b); + useCanvasStore.getState().updateNode('hc1', { x: 5 }); + + let s = useCanvasStore.getState(); + expect(s.historyPast.length).toBe(3); + + // Undo last (update) + s.undo(); + s = useCanvasStore.getState(); + expect(s.nodes['hc1']).toMatchObject({ x: 0, y: 0 }); + expect(s.historyFuture.length).toBe(1); + + // Perform a new action -> should clear future + useCanvasStore.getState().updateNode('hc2', { y: 3 }); + s = useCanvasStore.getState(); + expect(s.historyFuture.length).toBe(0); + + // Undo twice -> should undo update hc2 and add b + s.undo(); + s.undo(); + s = useCanvasStore.getState(); + expect(s.nodes['hc2']).toBeUndefined(); + + // Redo twice -> re-add b and re-apply hc2 update + s.redo(); + s.redo(); + s = useCanvasStore.getState(); + expect(s.nodes['hc2']).toMatchObject({ y: 3 }); + }); +}); + +describe('History: camera pan and coalescing', () => { + beforeEach(() => { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + centerAddIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + } as Partial); + }); + + it('records single camera pan in history and supports undo/redo', () => { + 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); + s2.undo(); + s2 = useCanvasStore.getState(); + expect(s2.camera).toMatchObject({ offsetX: 0, offsetY: 0 }); + 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', () => { + 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); + 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 + s.undo(); + s = useCanvasStore.getState(); + expect(s.camera).toMatchObject({ offsetX: 0, offsetY: 0 }); + // redo -> aggregated movement + s.redo(); + s = useCanvasStore.getState(); + expect(s.camera).toMatchObject({ offsetX: 8, offsetY: 3 }); + }); + + it('does not record an empty history batch', () => { + useCanvasStore.getState().beginHistory('empty'); + useCanvasStore.getState().endHistory(); + const s = useCanvasStore.getState(); + expect(s.historyPast.length).toBe(0); + }); +}); + +describe('History: mixed batch (cameraMove + moveSelectedBy)', () => { + beforeEach(() => { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + centerAddIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + } as Partial); + }); + + it('coalesces camera moves and keeps per-node updates; undo/redo restores both camera and 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 }); + useCanvasStore.getState().selectOnly('mx1'); + useCanvasStore.getState().addToSelection('mx2'); + + // Batch: pan, move nodes, pan again + useCanvasStore.getState().beginHistory('mixed'); + useCanvasStore.getState().panBy(10, -5); + useCanvasStore.getState().moveSelectedBy(3, 4); + useCanvasStore.getState().panBy(-2, 1); + useCanvasStore.getState().endHistory(); + + let s = useCanvasStore.getState(); + // Two adds + one batch + expect(s.historyPast.length).toBe(3); + // Camera: (10,-5)+(-2,1) = (8,-4) + expect(s.camera).toMatchObject({ offsetX: 8, offsetY: -4 }); + // 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 + 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); + + // Undo -> camera back to (0,0), nodes back to initial + s.undo(); + s = useCanvasStore.getState(); + expect(s.camera).toMatchObject({ offsetX: 0, offsetY: 0 }); + 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 + s.redo(); + s = useCanvasStore.getState(); + expect(s.camera).toMatchObject({ offsetX: 8, offsetY: -4 }); + expect(s.nodes['mx1']).toMatchObject({ x: 3, y: 4 }); + expect(s.nodes['mx2']).toMatchObject({ x: 8, y: 9 }); + }); +}); diff --git a/src/state/store.nodes.test.ts b/src/state/store.nodes.test.ts new file mode 100644 index 0000000..de81de3 --- /dev/null +++ b/src/state/store.nodes.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { useCanvasStore } from './store'; +import type { Node } from '../types'; + +describe('nodes CRUD in store', () => { + it('adds a node', () => { + const node: Node = { id: 'a', x: 10, y: 20, width: 100, height: 50 }; + useCanvasStore.getState().addNode(node); + const s = useCanvasStore.getState(); + expect(s.nodes['a']).toEqual(node); + }); + + it('updates a node by id with patch', () => { + useCanvasStore.getState().addNode({ id: 'b', x: 0, y: 0, width: 10, height: 10 }); + useCanvasStore.getState().updateNode('b', { x: 5, y: 7 }); + const s = useCanvasStore.getState(); + expect(s.nodes['b']).toMatchObject({ x: 5, y: 7, width: 10, height: 10 }); + }); + + it('removeNode deletes existing node and is idempotent', () => { + useCanvasStore.getState().addNode({ id: 'c', x: 1, y: 2, width: 3, height: 4 }); + useCanvasStore.getState().removeNode('c'); + let s = useCanvasStore.getState(); + expect(s.nodes['c']).toBeUndefined(); + // second removal should not throw or change state + useCanvasStore.getState().removeNode('c'); + s = useCanvasStore.getState(); + expect(s.nodes['c']).toBeUndefined(); + }); +}); diff --git a/src/state/store.selection.test.ts b/src/state/store.selection.test.ts new file mode 100644 index 0000000..857396a --- /dev/null +++ b/src/state/store.selection.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCanvasStore } from './store'; +import type { Node } from '../types'; + +describe('selection state (CORE-05a)', () => { + beforeEach(() => { + // reset store to initial state between tests + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + }); + }); + + it('starts with empty selection', () => { + const s = useCanvasStore.getState(); + expect(Object.keys(s.selected)).toHaveLength(0); + }); + + it('selectOnly selects exactly one id', () => { + const a: Node = { id: 'a', x: 0, y: 0, width: 10, height: 10 }; + const b: Node = { id: 'b', x: 0, y: 0, width: 10, height: 10 }; + useCanvasStore.getState().addNode(a); + useCanvasStore.getState().addNode(b); + + useCanvasStore.getState().selectOnly('a'); + let s = useCanvasStore.getState(); + expect(s.selected).toEqual({ a: true }); + + useCanvasStore.getState().selectOnly('b'); + s = useCanvasStore.getState(); + expect(s.selected).toEqual({ b: true }); + }); + + it('clearSelection empties selection', () => { + useCanvasStore.getState().selectOnly('a'); + useCanvasStore.getState().clearSelection(); + const s = useCanvasStore.getState(); + expect(Object.keys(s.selected)).toHaveLength(0); + }); + + it('removeNode removes id from selection if present', () => { + const a: Node = { id: 'a', x: 0, y: 0, width: 10, height: 10 }; + useCanvasStore.getState().addNode(a); + useCanvasStore.getState().selectOnly('a'); + + useCanvasStore.getState().removeNode('a'); + const s = useCanvasStore.getState(); + expect(s.nodes['a']).toBeUndefined(); + expect(s.selected['a']).toBeUndefined(); + }); +}); + +describe('selection state (CORE-05b multi-select)', () => { + beforeEach(() => { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + }); + }); + + it('toggleInSelection toggles membership', () => { + useCanvasStore.getState().toggleInSelection('a'); + let s = useCanvasStore.getState(); + expect(s.selected).toEqual({ a: true }); + + useCanvasStore.getState().toggleInSelection('a'); + s = useCanvasStore.getState(); + expect(s.selected).toEqual({}); + }); + + it('addToSelection and removeFromSelection modify set without duplicates', () => { + useCanvasStore.getState().addToSelection('a'); + useCanvasStore.getState().addToSelection('a'); // idempotent + useCanvasStore.getState().addToSelection('b'); + let s = useCanvasStore.getState(); + expect(s.selected).toEqual({ a: true, b: true }); + + useCanvasStore.getState().removeFromSelection('a'); + s = useCanvasStore.getState(); + expect(s.selected).toEqual({ b: true }); + }); + + it('selectOnly then addToSelection results in multi selection', () => { + useCanvasStore.getState().selectOnly('a'); + useCanvasStore.getState().addToSelection('b'); + const s = useCanvasStore.getState(); + expect(s.selected).toEqual({ a: true, b: true }); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts index a439ab0..1631a03 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -1,12 +1,41 @@ import { create } from 'zustand'; import type { Camera, Point } from '../core/coords'; +import type { Node, NodeId } from '../types'; import { applyPan, clampZoom, zoomAtPoint } from '../core/coords'; -export const MIN_ZOOM = 0.1; -export const MAX_ZOOM = 5; +// History types (nodes + camera pan; zoom is intentionally 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 }; + +type HistoryEntry = { + label?: string; + changes: NodeChange[]; +}; + +export const MIN_ZOOM = 0.6; +export const MAX_ZOOM = 2.4; export type CanvasState = { readonly camera: Camera; + readonly nodes: Record; + /** Map of selected node IDs for O(1) membership checks. */ + readonly selected: Record; + /** Internal counter for add-at-center offset progression. */ + readonly centerAddIndex: number; + /** History stacks (nodes-only). */ + readonly historyPast: HistoryEntry[]; + readonly historyFuture: HistoryEntry[]; + /** Active batch for coalescing changes (e.g., drag). */ + readonly historyBatch: { + label?: string; + changes: NodeChange[]; + updateIndexById: Record; + /** Index of coalesced cameraMove within changes, if any */ + cameraMoveIndex?: number; + } | null; }; export type CanvasActions = { @@ -15,19 +44,87 @@ export type CanvasActions = { zoomTo: (zoom: number) => void; /** Zoom by factor centered at screenPoint (screen coords in px). */ zoomByAt: (screenPoint: Point, factor: number) => void; + // Nodes CRUD + addNode: (node: Node) => void; + /** Add node at the visible center regardless of zoom, with slight diagonal offset per call. */ + addNodeAtCenter: (node: Pick) => void; + updateNode: (id: NodeId, patch: Partial) => void; + removeNode: (id: NodeId) => void; + /** Remove multiple nodes at once. */ + removeNodes: (ids: NodeId[]) => void; + /** Move all currently selected nodes by dx,dy in WORLD units. */ + moveSelectedBy: (dx: number, dy: number) => void; + // Selection (CORE-05a) + clearSelection: () => void; + /** Select only the given node id (single selection). */ + selectOnly: (id: NodeId) => void; + // Selection (CORE-05b) + addToSelection: (id: NodeId) => void; + removeFromSelection: (id: NodeId) => void; + toggleInSelection: (id: NodeId) => void; + /** Delete all currently selected nodes. */ + deleteSelected: () => void; + // History (CORE-06) + beginHistory: (label?: string) => void; + endHistory: () => void; + undo: () => void; + redo: () => void; }; export type CanvasStore = CanvasState & CanvasActions; const initialCamera: Camera = { zoom: 1, offsetX: 0, offsetY: 0 }; +const initialNodes: Record = {}; +const initialSelected: Record = {}; +const initialCenterAddIndex = 0; +const initialHistoryPast: HistoryEntry[] = []; +const initialHistoryFuture: HistoryEntry[] = []; + +// Internal flag: suppress history recording during undo/redo replay +let __isReplayingHistory = false; // eslint-disable-next-line @typescript-eslint/no-unused-vars export const useCanvasStore = create()((set, get) => ({ camera: initialCamera, + nodes: initialNodes, + selected: initialSelected, + centerAddIndex: initialCenterAddIndex, + historyPast: initialHistoryPast, + historyFuture: initialHistoryFuture, + historyBatch: null, setCamera: (camera) => set({ camera }), - panBy: (dx, dy) => set((s) => ({ camera: applyPan(s.camera, dx, dy) })), + 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; + }), zoomTo: (zoom) => set((s) => ({ @@ -36,6 +133,348 @@ export const useCanvasStore = create()((set, get) => ({ zoomByAt: (screenPoint, factor) => set((s) => ({ camera: zoomAtPoint(s.camera, screenPoint, factor, MIN_ZOOM, MAX_ZOOM) })), + + // Nodes CRUD + addNode: (node) => + set((s) => { + const nextNodes = { ...s.nodes, [node.id]: node } as Record; + if (__isReplayingHistory) return { nodes: nextNodes } as Partial as CanvasStore; + if (s.historyBatch) { + const batch = s.historyBatch; + return { + nodes: nextNodes, + historyBatch: { ...batch, changes: [...batch.changes, { kind: 'add', node }] }, + } as Partial as CanvasStore; + } + const entry: HistoryEntry = { changes: [{ kind: 'add', node }] }; + return { + nodes: nextNodes, + historyPast: [...s.historyPast, entry], + historyFuture: [], + } as Partial as CanvasStore; + }), + addNodeAtCenter: (node) => + set((s) => { + const stepPx = 16; // screen px per step + const modulo = 12; // wrap to avoid drifting too far + const k = s.centerAddIndex % modulo; + // Compute screen center (viewport). For nested layouts, users can use useCanvasHelpers. + const screenCx = typeof window !== 'undefined' ? window.innerWidth / 2 : 0; + const screenCy = typeof window !== 'undefined' ? window.innerHeight / 2 : 0; + // Convert to world and normalize offset by zoom + const zoom = s.camera.zoom; + const cx = screenCx / zoom + s.camera.offsetX; + const cy = screenCy / zoom + s.camera.offsetY; + const dxWorld = (k * stepPx) / zoom; + const dyWorld = (k * stepPx) / zoom; + const x = cx - node.width / 2 + dxWorld; + const y = cy - node.height / 2 + dyWorld; + const placed: Node = { id: node.id, x, y, width: node.width, height: node.height }; + const nextNodes = { ...s.nodes, [node.id]: placed } as Record; + if (__isReplayingHistory) { + return { + nodes: nextNodes, + centerAddIndex: s.centerAddIndex + 1, + } as Partial as CanvasStore; + } + if (s.historyBatch) { + const batch = s.historyBatch; + return { + nodes: nextNodes, + centerAddIndex: s.centerAddIndex + 1, + historyBatch: { ...batch, changes: [...batch.changes, { kind: 'add', node: placed }] }, + } as Partial as CanvasStore; + } + const entry: HistoryEntry = { changes: [{ kind: 'add', node: placed }] }; + return { + nodes: nextNodes, + centerAddIndex: s.centerAddIndex + 1, + historyPast: [...s.historyPast, entry], + historyFuture: [], + } as Partial as CanvasStore; + }), + updateNode: (id, patch) => + set((s) => { + const current = s.nodes[id]; + if (!current) return { nodes: s.nodes } as Partial as CanvasStore; + const updated: Node = { ...current, ...patch } as Node; + const nextNodes = { ...s.nodes, [id]: updated } as Record; + if (__isReplayingHistory) return { nodes: nextNodes } as Partial as CanvasStore; + if (s.historyBatch) { + const batch = s.historyBatch; + 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 newMap = { ...batch.updateIndexById, [id]: newIdx } as Record; + 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; + } + } + const entry: HistoryEntry = { changes: [{ kind: 'update', id, before: current, after: updated }] }; + return { + nodes: nextNodes, + historyPast: [...s.historyPast, entry], + historyFuture: [], + } as Partial as CanvasStore; + }), + removeNode: (id) => + set((s) => { + if (!s.nodes[id]) return { nodes: s.nodes } as Partial as CanvasStore; + const next = { ...s.nodes } as Record; + const removed = next[id]; + delete next[id]; + // also remove from selection if present + if (s.selected[id]) { + const sel = { ...s.selected }; + delete sel[id]; + if (!__isReplayingHistory) { + if (s.historyBatch) { + const batch = s.historyBatch; + return { + nodes: next, + selected: sel, + historyBatch: { ...batch, changes: [...batch.changes, { kind: 'remove', node: removed }] }, + } as Partial as CanvasStore; + } + const entry: HistoryEntry = { changes: [{ kind: 'remove', node: removed }] }; + return { + nodes: next, + selected: sel, + historyPast: [...s.historyPast, entry], + historyFuture: [], + } as Partial as CanvasStore; + } + return { nodes: next, selected: sel } as Partial as CanvasStore; + } + 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; + } + const entry: HistoryEntry = { changes: [{ kind: 'remove', node: removed }] }; + return { nodes: next, historyPast: [...s.historyPast, entry], historyFuture: [] } as Partial as CanvasStore; + } + return { nodes: next } as Partial as CanvasStore; + }), + removeNodes: (ids) => + set((s) => { + if (!ids || ids.length === 0) return {} as Partial as CanvasStore; + let changed = false; + const nextNodes: Record = { ...s.nodes }; + const removedList: Node[] = []; + for (const id of ids) { + if (nextNodes[id]) { + removedList.push(nextNodes[id]); + delete nextNodes[id]; + changed = true; + } + } + if (!changed) return {} as Partial as CanvasStore; + // clean up selection entries + let selChanged = false; + const nextSel = { ...s.selected }; + for (const id of ids) { + if (nextSel[id]) { + delete nextSel[id]; + selChanged = true; + } + } + if (__isReplayingHistory) { + return selChanged + ? ({ nodes: nextNodes, selected: nextSel } as Partial as CanvasStore) + : ({ nodes: nextNodes } as Partial as CanvasStore); + } + if (s.historyBatch) { + const batch = s.historyBatch; + 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, historyBatch: newBatch } as Partial as CanvasStore); + } + 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); + }), + moveSelectedBy: (dx, dy) => + set((s) => { + if (dx === 0 && dy === 0) return {} as Partial as CanvasStore; + const selIds = Object.keys(s.selected) as NodeId[]; + if (selIds.length === 0) return {} as Partial as CanvasStore; + let changed = false; + const nextNodes: Record = { ...s.nodes }; + const updates: { id: NodeId; before: Node; after: Node }[] = []; + for (const id of selIds) { + const n = s.nodes[id]; + if (!n) continue; + const moved = { ...n, x: n.x + dx, y: n.y + dy } as Node; + nextNodes[id] = moved; + updates.push({ id, before: n, after: moved }); + changed = true; + } + if (!changed) return {} as Partial as CanvasStore; + if (__isReplayingHistory) return { nodes: nextNodes } as Partial as CanvasStore; + if (s.historyBatch) { + const batch = s.historyBatch; + // merge each update with per-node coalescing + const newChanges = batch.changes.slice(); + const newMap = { ...batch.updateIndexById } as Record; + for (const u of updates) { + const idx = newMap[u.id]; + if (idx == null) { + const newIdx = newChanges.length; + newChanges.push({ kind: 'update', id: u.id, before: u.before, after: u.after }); + newMap[u.id] = newIdx; + } else { + const prev = newChanges[idx] as Extract; + 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; + } + 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 + deleteSelected: () => + set(() => { + const ids = Object.keys(get().selected) as NodeId[]; + if (ids.length === 0) return {} as Partial as CanvasStore; + // Use removeNodes to record history in bulk + get().removeNodes(ids); + return { selected: {} } as Partial as CanvasStore; + }), + clearSelection: () => set({ selected: {} }), + selectOnly: (id) => set({ selected: { [id]: true } }), + addToSelection: (id) => + set((s) => { + if (s.selected[id]) return { selected: s.selected } as Partial as CanvasStore; + return { selected: { ...s.selected, [id]: true } } as Partial as CanvasStore; + }), + removeFromSelection: (id) => + set((s) => { + if (!s.selected[id]) return { selected: s.selected } as Partial as CanvasStore; + const sel = { ...s.selected }; + delete sel[id]; + return { selected: sel } as Partial as CanvasStore; + }), + toggleInSelection: (id) => + set((s) => { + if (s.selected[id]) { + const sel = { ...s.selected }; + delete sel[id]; + return { selected: sel } as Partial as CanvasStore; + } + return { selected: { ...s.selected, [id]: true } } as Partial as CanvasStore; + }), + + // --- History actions --- + beginHistory: (label) => + set((s) => { + if (s.historyBatch) return {} as Partial as CanvasStore; + return { + historyBatch: { label, changes: [], updateIndexById: {}, cameraMoveIndex: undefined }, + } as Partial as CanvasStore; + }), + endHistory: () => + set((s) => { + const batch = s.historyBatch; + if (!batch) return {} as Partial as CanvasStore; + if (batch.changes.length === 0) { + return { historyBatch: null } as Partial as CanvasStore; + } + const entry: HistoryEntry = { label: batch.label, changes: batch.changes }; + return { + historyPast: [...s.historyPast, entry], + historyFuture: [], + historyBatch: null, + } as Partial as CanvasStore; + }), + undo: () => + set((s) => { + 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(); + future.unshift(entry); + // Apply inverse of entry + __isReplayingHistory = true; + try { + // Build new nodes map and camera by applying inverse of changes + const nodes = { ...s.nodes } as Record; + let cam = s.camera; + for (let i = entry.changes.length - 1; i >= 0; i--) { + const ch = entry.changes[i]; + if (ch.kind === 'add') { + delete nodes[ch.node.id]; + } else if (ch.kind === 'remove') { + 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 + const nextSel: Record = {}; + for (const id of Object.keys(s.selected) as NodeId[]) { + if (nodes[id]) nextSel[id] = true; + } + return { + nodes, + camera: cam, + selected: nextSel, + historyPast: past, + historyFuture: future, + } as Partial as CanvasStore; + } finally { + __isReplayingHistory = false; + } + }), + redo: () => + set((s) => { + 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; + let cam = s.camera; + for (const ch of entry.changes) { + if (ch.kind === 'add') { + nodes[ch.node.id] = ch.node; + } else if (ch.kind === 'remove') { + 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 = {}; + for (const id of Object.keys(s.selected) as NodeId[]) { + if (nodes[id]) nextSel[id] = true; + } + return { + nodes, + camera: cam, + selected: nextSel, + historyPast: past, + historyFuture: future, + } as Partial as CanvasStore; + } finally { + __isReplayingHistory = false; + } + }), })); // Convenience hooks @@ -54,3 +493,71 @@ export function useCanvasActions(): Pick< zoomByAt: s.zoomByAt, })); } + +// Nodes selectors/actions +export function useNodes(): Node[] { + return useCanvasStore((s) => Object.values(s.nodes)); +} + +export function useNode(id: NodeId): Node | undefined { + return useCanvasStore((s) => s.nodes[id]); +} + +export function useNodeActions(): Pick< + CanvasActions, + 'addNode' | 'addNodeAtCenter' | 'updateNode' | 'removeNode' +> { + return useCanvasStore((s) => ({ + addNode: s.addNode, + addNodeAtCenter: s.addNodeAtCenter, + updateNode: s.updateNode, + removeNode: s.removeNode, + })); +} + +// Bulk deletion helpers +export function useDeleteActions(): Pick { + return useCanvasStore((s) => ({ + removeNodes: s.removeNodes, + deleteSelected: s.deleteSelected, + })); +} + +// Selection selectors/actions +export function useSelectedIds(): NodeId[] { + return useCanvasStore((s) => Object.keys(s.selected)); +} + +export function useIsSelected(id: NodeId): boolean { + return useCanvasStore((s) => Boolean(s.selected[id])); +} + +export function useSelectionActions(): Pick< + CanvasActions, + 'clearSelection' | 'selectOnly' | 'addToSelection' | 'removeFromSelection' | 'toggleInSelection' +> { + return useCanvasStore((s) => ({ + clearSelection: s.clearSelection, + selectOnly: s.selectOnly, + addToSelection: s.addToSelection, + removeFromSelection: s.removeFromSelection, + toggleInSelection: s.toggleInSelection, + })); +} + +// DnD actions +export function useDndActions(): Pick { + return useCanvasStore((s) => ({ + moveSelectedBy: s.moveSelectedBy, + })); +} + +// History actions +export function useHistoryActions(): Pick { + return useCanvasStore((s) => ({ + beginHistory: s.beginHistory, + endHistory: s.endHistory, + undo: s.undo, + redo: s.redo, + })); +}