diff --git a/.changeset/mvp-0-2.md b/.changeset/mvp-0-2.md new file mode 100644 index 0000000..8672904 --- /dev/null +++ b/.changeset/mvp-0-2.md @@ -0,0 +1,35 @@ +--- +"@flowscape-ui/canvas-react": minor +--- + +MVP 0.2 + +Added +- Inner‑edit mode: double‑click a node inside a visual group to enter a persistent inner‑edit. While active, nodes in the selected group behave like ordinary nodes (their own selection/hover UI), can be selected/toggled/dragged individually. Mode persists until clicking on empty canvas. +- Box selection (lasso) preview parity with drop: + - Touching a group highlights the group frame (no inner node preview). + - Group + outside node(s): preview shows the group frame, highlights outside nodes only, and renders a single combined overlay covering the union. Drop selects exactly that. + - Multiple groups: preview highlights all intersected group frames and renders one combined overlay covering all selected groups. +- Edge auto‑pan during lasso (parity with node drag). + +- Clipboard (Copy/Cut/Paste): + - Ctrl/Cmd + C/X/V keyboard shortcuts. + - First paste offsets nodes to avoid exact overlap; hierarchy is preserved. + - Store actions support programmatic copy/cut/paste. + +- Rulers & Guides: + - Drag from top/left ruler to create horizontal/vertical guides. + - Hover highlights and larger hit area for easier grabbing. + - Delete/Backspace removes the active guide. + - Guide drags commit as a single undoable step (clean undo/redo). + +Changed +- Clicking a node that belongs to a visual group (outside inner‑edit) selects the group frame; inner node selection visuals are suppressed. +- While inner‑edit is active, node selection/hover visuals inside the selected group are enabled and behave like ordinary nodes. +- Default zoom bounds changed to 0.5–2.4 (50–240%). + +Fixed +- Incorrect lasso preview that appeared to select inner nodes of a group instead of the group frame. +- Lasso preview mismatch between drag and drop when selecting between a group and nodes outside it. +- Preview when intersecting two groups: now shows frames of all intersected groups plus a single combined overlay. + diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..d006687 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +custom: + - https://www.buymeacoffee.com/flowscape \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf1e1fe..4dffba1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -40,5 +40,18 @@ jobs: - name: Build run: bun run build + - name: Upload bundle report + if: always() + uses: actions/upload-artifact@v4 + with: + name: bundle-report + path: dist/bundle-report.html + - name: Storybook build run: bun run storybook:build + + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + - name: E2E (Playwright) + run: bun run e2e diff --git a/README.md b/README.md index a2b9c63..9ee7150 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,46 @@ 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%). +## Features (MVP-0.2) +- Pan/Zoom with mouse and keyboard. Default zoom bounds: 0.5–2.4 (50–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). +- Visual Groups (UI-only): nodes can belong to purely visual groups that render rounded selection frames behind nodes. +- Selection & Grouping: + - Click node: selects the node (unless group selection rules apply, see below). + - Ctrl/Cmd + Click: toggles a node in the selection set. + - Empty click: clears selection. + - Ctrl/Cmd + G: create a visual group from the current selection (2+ nodes). - Drag & History: - - Drag nodes (single or multi-select) without panning the canvas thanks to hit-testing. - - Dragging batches updates into a single history entry; Undo/Redo reverts/applies the whole drag as one action. + - Drag nodes (single or multi-select) with coalesced history; Undo/Redo reverts/applies the whole drag as one action. + - Edge auto‑pan when dragging or performing a box selection. + +- Clipboard: + - Ctrl/Cmd + C/X/V to Copy/Cut/Paste selection. + - First paste offsets nodes to avoid exact overlap; hierarchy is preserved. + +- Rulers & Guides: + - Horizontal/vertical rulers; drag from a ruler to create a guide. + - Hover highlights and larger hit area for easier grabbing. + - Delete/Backspace removes the active guide. Guide drags commit as a single undoable step. + +### MVP‑0.2 UX improvements + +#### Inner‑edit mode (double‑click) +- Double‑click on a node inside a group enters a persistent inner‑edit mode for that node. +- While inner‑edit is active: + - Nodes inside the selected group behave like ordinary nodes (their own selection/hover UI is shown). + - You can select and move individual nodes inside the group; the mode persists until you click on empty canvas. + +#### Group selection vs node selection +- Outside inner‑edit: clicking a node that belongs to a visual group selects the group frame (nodes do not show individual selected UI). +- Inside inner‑edit: nodes in the selected group show regular selection/hover/drag behavior. + +#### Box selection (lasso) preview and drop +- When the box touches a group, the preview does not highlight inner nodes; instead it highlights the group frame. +- Group + node: preview shows the group frame plus any outside nodes hit by the lasso, and renders one combined overlay frame covering the union of the group and those nodes. Drop selects exactly that. +- Multiple groups: preview highlights all intersected groups and renders one combined overlay frame covering all of them. Drop selects the primary group frame (secondary remains visually highlighted). Node‑level preview inside groups is suppressed. + +These rules make the preview during drag match the final selection after mouse up. ### Example: Basic Canvas with Navigation and Node Views @@ -44,7 +72,7 @@ import { useRef, useEffect } from 'react'; export default function Example() { const ref = useRef(null); - useCanvasNavigation(ref, { panButton: 0 }); + useCanvasNavigation(ref, { panButton: 1 }); // pan with middle button (or 2 for right) const nodes = useNodes(); const { addNode } = useNodeActions(); @@ -221,20 +249,23 @@ If you prefer to fully control visuals: - 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. +- Left-drag on empty space performs box selection. Panning uses the middle (button 1) or right (button 2) mouse button. ### Keyboard & Shortcuts - WASD/Arrow keys to pan (Shift reduces step). - Mouse wheel zoom (configurable), double-click zoom. -- Zoom bounds: 0.6–2.4. +- Zoom bounds: 0.5–2.4. - Delete/Backspace: deletes all currently selected nodes. +- Ctrl/Cmd + C: copy selection. +- Ctrl/Cmd + X: cut selection. +- Ctrl/Cmd + V: paste clipboard (first paste offsets; hierarchy preserved). - Focus behavior: the canvas automatically focuses itself on pointer down (both on nodes and empty area) so shortcuts work immediately. The root is focusable via `tabIndex` (default `0`); you can override with `` to disable focus, or another value to suit your app. - Shortcuts are ignored when the event originates from text inputs or contenteditable elements. ## Navigation Options (Wheel & Touchpad) -The hook `useCanvasNavigation(ref, options)` supports modern and legacy wheel behaviors. Default zoom bounds are 0.6–2.4 (60–240%). +The hook `useCanvasNavigation(ref, options)` supports modern and legacy wheel behaviors. Default zoom bounds are 0.5–2.4 (50–240%). - **wheelBehavior**: `'auto' | 'zoom' | 'pan'` - `'auto'` (default): @@ -266,7 +297,7 @@ import { useRef } from 'react'; export default function Example() { const ref = useRef(null); useCanvasNavigation(ref, { - panButton: 0, + panButton: 1, panModifier: 'none', wheelZoom: true, wheelModifier: 'ctrl', @@ -327,6 +358,13 @@ const { style } = useWorldLockedTile({ size: 32, dprSnap: true }); return
; ``` +## Rulers & Guides + +- Drag from the top ruler to create a horizontal guide; drag from the left ruler to create a vertical guide. +- Guides have hover highlights and a larger hit area to make selection/grab easier. +- Press Delete/Backspace to remove the currently active guide. +- Dragging a guide commits as a single history entry (undo/redo moves it back/forth in one step). + ### Drag & History Behavior - Dragging a node starts a coalesced history batch; intermediate updates are merged. @@ -338,7 +376,7 @@ return
=10.0.0" } }, "sha512-t1KMcBkz/pT5JrvcJbpUR2u/w1kO9jXctaaGJ0vZDzwFnIvGWw9IDSRciT83kIs8Bnw4qpOl8bQK08V01YgMPg=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -757,6 +801,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + "locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -779,6 +825,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], @@ -793,6 +841,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanospinner": ["nanospinner@1.2.2", "", { "dependencies": { "picocolors": "^1.1.1" } }, "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -821,6 +871,8 @@ "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "opener": ["opener@1.5.2", "", { "bin": { "opener": "bin/opener-bin.js" } }, "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], @@ -867,6 +919,12 @@ "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + "playwright": ["playwright@1.55.0", "", { "dependencies": { "playwright-core": "1.55.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA=="], + + "playwright-core": ["playwright-core@1.55.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg=="], + + "portfinder": ["portfinder@1.0.37", "", { "dependencies": { "async": "^3.2.6", "debug": "^4.3.6" } }, "sha512-yuGIEjDAYnnOex9ddMnKZEMFE0CcGo6zbfzDklkmT1m5z734ss6JMzN9rNB3+RR7iS+F10D4/BVIaXOyh8PQKw=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -881,6 +939,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -897,6 +957,8 @@ "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], @@ -905,6 +967,10 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], @@ -917,12 +983,16 @@ "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=="], + "rollup-plugin-visualizer": ["rollup-plugin-visualizer@5.14.0", "", { "dependencies": { "open": "^8.4.0", "picomatch": "^4.0.2", "source-map": "^0.7.4", "yargs": "^17.5.1" }, "peerDependencies": { "rolldown": "1.x", "rollup": "2.x || 3.x || 4.x" }, "optionalPeers": ["rolldown", "rollup"], "bin": { "rollup-plugin-visualizer": "dist/bin/cli.js" } }, "sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA=="], + "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=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], @@ -933,6 +1003,8 @@ "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "secure-compare": ["secure-compare@3.0.1", "", {}, "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw=="], + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -957,9 +1029,11 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "size-limit": ["size-limit@11.2.0", "", { "dependencies": { "bytes-iec": "^3.1.1", "chokidar": "^4.0.3", "jiti": "^2.4.2", "lilconfig": "^3.1.3", "nanospinner": "^1.2.2", "picocolors": "^1.1.1", "tinyglobby": "^0.2.11" }, "bin": { "size-limit": "bin.js" } }, "sha512-2kpQq2DD/pRpx3Tal/qRW1SYwcIeQ0iq8li5CJHQgOC+FtPn2BVmuDtzUCgNnpCrbgtfEHqh+iWzxK+Tq6C+RQ=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -975,7 +1049,7 @@ "storybook": ["storybook@9.1.2", "", { "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/spy": "3.2.4", "better-opn": "^3.0.2", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", "esbuild-register": "^3.5.0", "recast": "^0.23.5", "semver": "^7.6.2", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./bin/index.cjs" }, "sha512-TYcq7WmgfVCAQge/KueGkVlM/+g33sQcmbATlC3X6y/g2FEeSSLGrb6E6d3iemht8oio+aY6ld3YOdAnMwx45Q=="], - "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1015,6 +1089,8 @@ "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], @@ -1059,6 +1135,8 @@ "unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], + "union": ["union@0.5.0", "", { "dependencies": { "qs": "^6.4.0" } }, "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA=="], + "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "unplugin": ["unplugin@1.16.1", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w=="], @@ -1067,6 +1145,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="], + "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], "vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="], @@ -1101,7 +1181,7 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -1113,8 +1193,14 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="], "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], @@ -1131,8 +1217,12 @@ "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@joshwooding/vite-plugin-react-docgen-typescript/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], @@ -1181,28 +1271,32 @@ "glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "html-encoding-sniffer/whatwg-encoding": ["whatwg-encoding@2.0.0", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "jsdom/html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + "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=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "read-yaml-file/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], - - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "vitest/@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], @@ -1211,16 +1305,14 @@ "vitest/@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], - - "wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], - - "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@changesets/parse/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="], + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + "@joshwooding/vite-plugin-react-docgen-typescript/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -1245,8 +1337,6 @@ "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=="], - "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], @@ -1297,10 +1387,6 @@ "vitest/@vitest/spy/tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="], - "@joshwooding/vite-plugin-react-docgen-typescript/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], diff --git a/doc/tasklist.md b/doc/tasklist.md index 7f26fea..79076d9 100644 --- a/doc/tasklist.md +++ b/doc/tasklist.md @@ -83,46 +83,46 @@ ### Core -| 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 ✅ | +| 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 ✅ | +| 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 | ID | Раздел | Задача | Описание | Старт | Дедлайн | Статус | | ------ | ------ | --------------- | -------------------------------------------- | ---------- | ---------- | ------- | -| UI-01 | UI | Canvas/NodeView | Базовый рендер узлов, overlay-хендлеры | 2025-08-22 | 2025-08-27 | pending | -| UI-02 | UI | Background | Лёгкий фон: grid/dots (один вариант для MVP) | 2025-08-24 | 2025-08-27 | pending | -| DOC-01 | Docs | Storybook | Конфиг 8.x (Vite), примеры, аддоны | 2025-08-21 | 2025-08-26 | pending | +| UI-01 | UI | Canvas/NodeView | Базовый рендер узлов, overlay-хендлеры | 2025-08-22 | 2025-08-27 | done ✅ | +| UI-02 | UI | Background | Лёгкий фон: grid/dots (один вариант для MVP) | 2025-08-24 | 2025-08-27 | done ✅ | +| DOC-01 | Docs | Storybook | Конфиг 8.x (Vite), примеры, аддоны | 2025-08-21 | 2025-08-26 | done ✅ | ### QA -| 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 | +| ID | Задача | Описание | Старт | Дедлайн | Статус | +| ----- | ---------------- | --------------------------------------- | ---------- | ---------- | ------- | +| QA-01 | Unit/Integration | Vitest + RTL: ядро/редьюсеры/компоненты | 2025-08-21 | 2025-08-28 | done ✅ | +| QA-02 | E2E (минимум) | Playwright: smoke (навигация/зум) | 2025-08-26 | 2025-08-29 | done ✅ | ### 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 | Мониторинг | 2025-08-30 | 2025-09-01 | pending | Size-limit/Bundle analyzer, отчёт | +| ID | Задача | Описание | Старт | Дедлайн | Статус | Ссылки | +| ------- | --------------- | --------------------------------------------- | ---------- | ---------- | ------- | --------------------------------------------------------------------------------- | +| REL-01 | Changesets init | Инициализация Changesets, первый changeset | 2025-08-21 | 2025-08-21 | done ✅ | `/.changeset/config.json`, `/.changeset/initial-release.md` | +| REL-02 | v1.0.0 (MVP) | Срез функционала, релиз в npm, Pages доступен | 2025-08-22 | 2025-08-23 | done ✅ | | +| POST-01 | Post | Мониторинг | 2025-08-30 | 2025-09-01 | done ✅ | `/.github/workflows/ci.yml`, `dist/bundle-report.html`, `package.json#size-limit` | > Примечание: даты ориентировочные, можно корректировать релиз-план по мере прогресса. @@ -148,16 +148,15 @@ ## План MVP-0.2 (Usability) -| ID | Этап | Задача | Описание | Ответственный | Старт | Дедлайн | Статус | -| ------ | ------------- | ------------------- | ------------------------------------------- | ------------- | ---------- | ---------- | ------- | -| SEL-01 | Select | Маркировка рамкой | Прямоугольное выделение области | - | 2025-09-01 | 2025-09-03 | pending | -| GRP-01 | Group | Группировка | Структура parentId + совместное перемещение | - | 2025-09-01 | 2025-09-04 | pending | -| CLP-01 | Clipboard | Copy/Paste/Cut | Копирование/вырезка/вставка узлов | - | 2025-09-02 | 2025-09-05 | pending | -| RUL-01 | Rulers | Rulers/Helper lines | Включение Ctrl+H, добавление/удаление линий | - | 2025-09-03 | 2025-09-06 | pending | -| SER-01 | Serialization | JSON I/O | `exportJSON`/`importJSON` (минимум) | - | 2025-09-04 | 2025-09-07 | pending | -| REL-03 | Release | v0.2.0 | Публичный релиз | - | 2025-09-07 | 2025-09-08 | pending | +| ID | Этап | Задача | Описание | Ответственный | Старт | Дедлайн | Статус | +| ------ | --------- | ------------------- | ------------------------------------------- | ------------- | ---------- | ---------- | ------- | +| SEL-01 | Select | Маркировка рамкой | Прямоугольное выделение области | - | 2025-09-01 | 2025-09-03 | done ✅ | +| GRP-01 | Group | Группировка | Структура parentId + совместное перемещение | - | 2025-09-01 | 2025-09-04 | done ✅ | +| CLP-01 | Clipboard | Copy/Paste/Cut | Копирование/вырезка/вставка узлов | - | 2025-09-02 | 2025-09-05 | done ✅ | +| RUL-01 | Rulers | Rulers/Helper lines | Включение Ctrl+H, добавление/удаление линий | - | 2025-09-03 | 2025-09-06 | done ✅ | +| REL-03 | Release | v1.1.0 | Публичный релиз | - | 2025-09-07 | 2025-09-08 | pending | -Критерии: стабильно работает рамочное выделение/группы/буфер/линейки, JSON экспорт/импорт. +Критерии: стабильно работает рамочное выделение/группы/буфер/линейки. --- @@ -169,14 +168,67 @@ - Темизация/фон (ThemeProvider, BackgroundSpec), адаптеры MUI/AntD/shadcn. - Полировка API и документации. -| ID | Этап | Задача | Описание | Ответственный | Старт | Дедлайн | Статус | -| -------- | ------- | ------------- | ------------------------------------- | ------------- | ---------- | ---------- | ------- | -| PERF-01 | Perf | Виртуализация | Рендер только видимых узлов | - | 2025-09-09 | 2025-09-12 | pending | -| PERF-02 | Perf | Spatial index | Quadtree/RBush интеграция | - | 2025-09-09 | 2025-09-13 | pending | -| EXT-01 | Ext | Плагин-API | Контракты плагинов и базовые плагины | - | 2025-09-10 | 2025-09-15 | pending | -| THEME-01 | Theme | Темизация | CSS vars + ThemeProvider, адаптер MUI | - | 2025-09-11 | 2025-09-16 | pending | -| DOC-02 | Docs | Документация | Примеры/гайды/README бейджи | - | 2025-09-15 | 2025-09-17 | pending | -| REL-04 | Release | v0.3.0 | Публичный релиз | - | 2025-09-17 | 2025-09-18 | pending | +| ID | Этап | Задача | Описание | Ответственный | Старт | Дедлайн | Статус | +| -------- | ------------- | ------------- | ------------------------------------- | ------------- | ---------- | ---------- | ------- | +| SER-01 | Serialization | JSON I/O | `exportJSON`/`importJSON` (минимум) | - | 2025-09-04 | 2025-09-11 | pending | +| PERF-01 | Perf | Виртуализация | Рендер только видимых узлов | - | 2025-09-09 | 2025-09-12 | pending | +| PERF-02 | Perf | Spatial index | Quadtree/RBush интеграция | - | 2025-09-09 | 2025-09-13 | pending | +| EXT-01 | Ext | Плагин-API | Контракты плагинов и базовые плагины | - | 2025-09-10 | 2025-09-15 | pending | +| THEME-01 | Theme | Темизация | CSS vars + ThemeProvider, адаптер MUI | - | 2025-09-11 | 2025-09-16 | pending | +| DOC-02 | Docs | Документация | Примеры/гайды/README бейджи | - | 2025-09-15 | 2025-09-17 | pending | +| REL-04 | Release | v0.3.0 | Публичный релиз | - | 2025-09-17 | 2025-09-18 | pending | + +### Критерии готовности Beta-0.3 (Definition of Done) + +- Производительность: + - Виртуализация активна по умолчанию; рендерятся только узлы внутри viewport + padding. + - Цель: стабильные 60 FPS при ≥1000 узлах в области видимости на средних машинах. + - Вводная задержка (pointer down→move) < 16 ms при типовом сценарии DnD. +- Spatial index: + - Выбор/hover и hit-testing работают через индекс (quadtree/RBush). + - Корректная ресинхронизация индекса при перемещениях/удалениях/изменениях размеров. +- Плагин-API: + - Задокументирован минимальный контракт (`Plugin`, `PluginContext`, жизненный цикл). + - Реализовано не менее 2 базовых плагинов (например: rulers/helper-lines, clipboard) поверх API. +- Темизация/фон: + - Введён `ThemeProvider` + CSS custom properties; фон конфигурируется через `BackgroundSpec`. + - Есть демонстрационный адаптер (напр. MUI) и пример интеграции в Storybook. +- JSON I/O: + - `exportJSON` / `importJSON` (минимальный формат), совместимость с текущей моделью `Node`/`Group`. +- Документация: + - Обновлён README (бейджи), примеры в Storybook, краткий гайд по плагинам и теме. +- Качество: + - Покрытие core >70% (цель) и E2E сценарии для виртуализации/выделения/плагинов проходят стабильно. +- Инфраструктура: + - CI зелёный, Pages обновлён. Release `v0.3.0` готов к публикации с provenance. + +### API Impact и миграция + +- Внутренние оптимизации (виртуализация, spatial index) не ломают публичный API — считаются прозрачными. +- Плагин-API: + - Новые публичные типы/хуки для подключения плагинов. Экспериментальные элементы помечены `@experimental`. + - Миграция встроенных возможностей: часть функционала (rulers/helper-lines/clipboard/backgrounds) доступна как плагины. +- Темизация/фон: + - Вводится `ThemeProvider` и `BackgroundSpec`. Возможны лёгкие изменения пропсов фона — описать в changelog и MIGRATION.md (если потребуется). +- JSON I/O: + - Базовый формат считается черновым и может измениться до стабильной 1.x — пометка в документации. + +### Риски и зависимости + +- Виртуализация: регрессии в фокусе/навигации/выборе для «offscreen» элементов. + - Митигации: фича-флаг для отключения, интеграционные тесты, ручные проверки в Storybook. +- Spatial index: рассинхрон при частых мутациях размеров/позиции. + - Митигации: централизованные события мутаций узлов, батч-обновления, инварианты в тестах. +- Плагин-API: риск churn до стабилизации контракта. + - Митигации: ограниченный публичный surface, чёткие «extension points», пометки `@experimental`. +- Темизация: конфликт css-переменных с хост-приложением. + - Митигации: префиксы переменных (`--flowscape-*`), нейминг-требования в contributing. + +### Метрики и валидация + +- Производительность: ручной профилинг (DevTools Performance) и сценарии в Storybook с большим количеством узлов. +- Bundle size: контроль через size-limit (отчёт в CI) — без роста по сравнению с 0.2 более чем на оговорённый порог. +- Надёжность: стабильные прогоны E2E (Playwright) на CI; падения сопровождаются trace/video для анализа. --- diff --git a/e2e/canvas-basic.spec.ts b/e2e/canvas-basic.spec.ts new file mode 100644 index 0000000..7ea79fd --- /dev/null +++ b/e2e/canvas-basic.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; + +// Target Storybook story: title 'Core/Canvas', story export 'Basic' => id 'core-canvas--basic' +const storyUrl = '/iframe.html?id=core-canvas--basic&args-showHints:false&args-showHistoryPanel:false'; + +// Helper to extract transform components from an element +async function readTransform(page: import('@playwright/test').Page, selector: string) { + const t = await page.$eval(selector, (el) => { + const s = getComputedStyle(el as HTMLElement).transform; + return s || 'none'; + }); + if (!t || t === 'none') return { a: 1, d: 1, tx: 0, ty: 0 }; + // matrix(a, b, c, d, tx, ty) + const m = t.match(/matrix\(([^)]+)\)/); + if (!m) return { a: 1, d: 1, tx: 0, ty: 0 }; + const [a, , , d, tx, ty] = m[1].split(',').map((v) => parseFloat(v.trim())); + return { a, d, tx, ty }; +} + +// Dispatch a ctrl+wheel event at the canvas to trigger zoom +async function zoomWithCtrl(page: import('@playwright/test').Page, canvasSel: string, deltaY: number) { + await page.$eval(canvasSel, (el, dy) => { + el.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: dy as number, + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + ctrlKey: true, + }), + ); + }, deltaY); +} + +test('pan and zoom work in Core/Canvas: Basic story', async ({ page }) => { + await page.goto(storyUrl); + + const canvas = page.locator('[data-rc-canvas]'); + await expect(canvas).toBeVisible(); + + // World layer is the element inside canvas that has transform applied + const worldSel = '[data-rc-canvas] div[style*="transform:"]'; + + const before = await readTransform(page, worldSel); + + // Pan: drag on empty canvas area + const box = await canvas.boundingBox(); + if (!box) throw new Error('Canvas bounding box not found'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down({ button: 'middle' }); + await page.mouse.move(box.x + box.width / 2 + 200, box.y + box.height / 2 + 150, { steps: 10 }); + await page.mouse.up({ button: 'middle' }); + + const afterPan = await readTransform(page, worldSel); + expect(Math.abs(afterPan.tx - before.tx)).toBeGreaterThan(10); + expect(Math.abs(afterPan.ty - before.ty)).toBeGreaterThan(10); + + // Zoom in using ctrl+wheel + const beforeZoom = afterPan; + await zoomWithCtrl(page, '[data-rc-canvas]', -300); + await page.waitForTimeout(200); + const afterZoomIn = await readTransform(page, worldSel); + expect(afterZoomIn.a).toBeGreaterThan(beforeZoom.a); + expect(afterZoomIn.d).toBeGreaterThan(beforeZoom.d); +}); diff --git a/e2e/group-container-drag.spec.ts b/e2e/group-container-drag.spec.ts new file mode 100644 index 0000000..429898e --- /dev/null +++ b/e2e/group-container-drag.spec.ts @@ -0,0 +1,127 @@ +import { test, expect } from '@playwright/test'; + +// Storybook story: 'Core/Canvas: Basic' +const storyUrl = + '/iframe.html?id=core-canvas--basic&args-showHints:false&args-showHistoryPanel:false'; + +// Helpers to interact with the exposed Zustand store +async function getNodeXY(page: import('@playwright/test').Page, id: string) { + return await page.evaluate((nid) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + if (!store) throw new Error('__RC_STORE not found on window'); + const n = store.getState().nodes[nid]; + return n ? { x: n.x, y: n.y } : null; + }, id); +} + +async function bumpHistory(page: import('@playwright/test').Page) { + return await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + return store.getState().historyPast.length as number; + }); +} + +test('dragging a group container moves all descendants (including grandchildren)', async ({ + page, +}) => { + await page.goto(storyUrl); + + const canvas = page.locator('[data-rc-canvas]'); + await expect(canvas).toBeVisible(); + + // Add three nodes using the Controls panel + const addBtn = page.getByRole('button', { name: 'Add' }); + await addBtn.click(); // n1 + await addBtn.click(); // n2 + await addBtn.click(); // n3 + + const n1 = page.locator('[data-rc-nodeid="n1"]'); + const n2 = page.locator('[data-rc-nodeid="n2"]'); + const n3 = page.locator('[data-rc-nodeid="n3"]'); + await expect(n1).toBeVisible(); + await expect(n2).toBeVisible(); + await expect(n3).toBeVisible(); + + // Reposition nodes to avoid overlap + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + const { beginHistory, endHistory, updateNode } = store.getState(); + beginHistory('e2e:setup-group-drag'); + updateNode('n1', { x: 800, y: 300 }); + updateNode('n2', { x: 1000, y: 320 }); + updateNode('n3', { x: 1180, y: 340 }); + endHistory(); + }); + + // Create hierarchy programmatically: n1 is parent of n2 + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + store.getState().groupNodes('n1', ['n2']); + }); + + // Nest n3 under n2 (grandchild of n1) + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + store.getState().groupNodes('n2', ['n3']); + }); + + // Create a visual group from selection (UI-only group container) + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + const { selectOnly, addToSelection, createVisualGroupFromSelection } = store.getState(); + selectOnly('n1'); + addToSelection('n2'); + createVisualGroupFromSelection(); + }); + + // Resolve the visual group id and ensure its container is present + const vgId = await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + return store.getState().selectedVisualGroupId as string; + }); + expect(vgId).toBeTruthy(); + const containerHit = page.locator( + `[data-testid="group-container"][data-parent-id="${vgId}"] [data-testid="group-container-hit"]`, + ); + await expect(containerHit).toBeVisible(); + + // Record positions before + const before1 = await getNodeXY(page, 'n1'); + const before2 = await getNodeXY(page, 'n2'); + const before3 = await getNodeXY(page, 'n3'); + + const historyBefore = await bumpHistory(page); + + // Drag the container by +50, +40 screen px + const box = await containerHit.boundingBox(); + if (!box) throw new Error('container bounding box not found'); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + await page.mouse.move(cx, cy); + await page.mouse.down(); + await page.mouse.move(cx + 50, cy + 40, { steps: 8 }); + await page.mouse.up(); + + // Check all moved by same delta (zoom=1 in story) + const after1 = await getNodeXY(page, 'n1'); + const after2 = await getNodeXY(page, 'n2'); + const after3 = await getNodeXY(page, 'n3'); + + expect(after1 && before1 && Math.round(after1.x - before1.x)).toBe(50); + expect(after1 && before1 && Math.round(after1.y - before1.y)).toBe(40); + expect(after2 && before2 && Math.round(after2.x - before2.x)).toBe(50); + expect(after2 && before2 && Math.round(after2.y - before2.y)).toBe(40); + expect(after3 && before3 && Math.round(after3.x - before3.x)).toBe(50); + expect(after3 && before3 && Math.round(after3.y - before3.y)).toBe(40); + + // One new history entry should be added + const historyAfter = await bumpHistory(page); + expect(historyAfter).toBe(historyBefore + 1); +}); diff --git a/e2e/grouping-shortcut.spec.ts b/e2e/grouping-shortcut.spec.ts new file mode 100644 index 0000000..bf2f230 --- /dev/null +++ b/e2e/grouping-shortcut.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; + +// Storybook story: 'Core/Canvas: Basic' +const storyUrl = '/iframe.html?id=core-canvas--basic&args-showHints:false&args-showHistoryPanel:false'; + +// Helpers to interact with the exposed Zustand store +async function getSelectedVisualGroupId(page: import('@playwright/test').Page) { + return await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + if (!store) throw new Error('__RC_STORE not found on window'); + return store.getState().selectedVisualGroupId as string | null; + }); +} + +async function getVisualGroupMembers(page: import('@playwright/test').Page, id: string) { + return await page.evaluate((vgId) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + const vg = store.getState().visualGroups[vgId]; + return vg ? vg.members.slice() : null; + }, id); +} + +async function repositionNodes(page: import('@playwright/test').Page) { + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + const { beginHistory, endHistory, updateNode } = store.getState(); + beginHistory('e2e:setup-grouping'); + // Space them out for reliable clicks + updateNode('n1', { x: 800, y: 300 }); + updateNode('n2', { x: 1000, y: 320 }); + endHistory(); + }); +} + +// Core test: Ctrl/Cmd+G groups selected nodes into a visual group +// CI runs on Linux (Control). If running locally on macOS, the handler also supports Meta key. +// We use Control+g which works on CI; macOS locally can also work since the code listens to both. +test('Ctrl/Cmd+G creates a visual group from multi-selection and selects it', async ({ page }) => { + await page.goto(storyUrl); + + const canvas = page.locator('[data-rc-canvas]'); + await expect(canvas).toBeVisible(); + + // Add two nodes using the Controls panel + const addBtn = page.getByRole('button', { name: 'Add' }); + await addBtn.click(); // n1 + await addBtn.click(); // n2 + + const n1 = page.locator('[data-rc-nodeid="n1"]'); + const n2 = page.locator('[data-rc-nodeid="n2"]'); + await expect(n1).toBeVisible(); + await expect(n2).toBeVisible(); + + // Reposition nodes for ease of selection + await repositionNodes(page); + + // Select two nodes: click first, then Ctrl-click second to add to selection + await n1.click(); + await n2.click({ modifiers: ['Control'] }); + + // Ensure canvas has focus for keyboard shortcuts + await canvas.focus(); + + // Trigger Ctrl+G (handled by useCanvasNavigation to create a visual group from selection) + await page.keyboard.press('Control+g'); + + // Wait for store to reflect a selected visual group id + await page.waitForFunction(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + return Boolean(store && store.getState().selectedVisualGroupId); + }); + + const vgId = await getSelectedVisualGroupId(page); + expect(vgId).toBeTruthy(); + + // Assert the visual group container appears in the DOM with the id + const container = page.locator(`[data-testid="group-container"][data-parent-id="${vgId}"]`); + await expect(container).toBeVisible(); + await expect(container.locator('[data-testid="group-container-hit"]')).toBeVisible(); + + // Store-level assertion: members include both n1 and n2 + const members = await getVisualGroupMembers(page, vgId!); + expect(members).toBeTruthy(); + expect(members).toEqual(expect.arrayContaining(['n1', 'n2'])); +}); diff --git a/package.json b/package.json index a324eae..8834a8b 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,12 @@ "devDependencies": { "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.27.8", + "@playwright/test": "^1.55.0", "@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", + "@size-limit/file": "^11.1.5", "@storybook/react": "^9.1.2", "@storybook/react-vite": "^9.1.2", "@types/node": "^24.3.0", @@ -65,12 +67,16 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-react": "^7.34.2", "eslint-plugin-react-hooks": "^5.2.0", + "http-server": "^14.1.1", "jsdom": "^26.1.0", + "jsdom-global": "^3.0.2", "prettier": "^3.3.3", "react": "^18.2.0", "react-dom": "^18.2.0", "rollup": "^4.21.0", "rollup-plugin-dts": "^6.1.1", + "rollup-plugin-visualizer": "^5.12.0", + "size-limit": "^11.1.5", "storybook": "^9.1.2", "typescript": "5.5.4", "vite": "^5.4.0", @@ -88,10 +94,20 @@ "test:watch": "vitest", "storybook": "storybook dev -p 6006", "storybook:build": "storybook build", + "e2e": "playwright test", + "size": "size-limit", + "size:ci": "size-limit", + "analyze": "rollup -c rollup.config.mjs", "changeset": "changeset", "release": "changeset publish", "prepack": "bun run build" }, + "size-limit": [ + { + "path": "dist/index.js", + "limit": "25 kB" + } + ], "publishConfig": { "access": "public" }, diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..47506ba --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: 'e2e', + timeout: 30_000, + retries: process.env.CI ? 1 : 0, + use: { + baseURL: 'http://localhost:6007', + headless: true, + }, + webServer: { + command: 'bunx http-server storybook-static -p 6007 -s', + url: 'http://localhost:6007', + reuseExistingServer: !process.env.CI, + }, + reporter: process.env.CI ? [['github'], ['list']] : [['list']], + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/rollup.config.mjs b/rollup.config.mjs index 8002fe6..7bfc088 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -2,6 +2,7 @@ import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import replace from '@rollup/plugin-replace'; import typescript from '@rollup/plugin-typescript'; +import { visualizer } from 'rollup-plugin-visualizer'; /** @type {import('rollup').RollupOptions} */ const config = { @@ -22,6 +23,12 @@ const config = { commonjs(), replace({ preventAssignment: true, 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production') }), typescript({ tsconfig: './tsconfig.json' }), + visualizer({ + filename: 'dist/bundle-report.html', + template: 'treemap', + gzipSize: true, + brotliSize: true, + }), ], }; diff --git a/src/index.ts b/src/index.ts index 190b3be..9d934af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,3 +8,4 @@ export * from './react/BackgroundCells'; export * from './react/useWorldLockedTile'; export * from './react/NodeView'; export * from './react/useCanvasHelpers'; +export * from './react/Rulers'; diff --git a/src/react/Canvas.tsx b/src/react/Canvas.tsx index 364a153..00e6676 100644 --- a/src/react/Canvas.tsx +++ b/src/react/Canvas.tsx @@ -1,5 +1,14 @@ -import React, { forwardRef, useRef } from 'react'; -import { useSelectionActions, useHistoryActions } from '../state/store'; +import React, { forwardRef, useEffect, useRef, useState } from 'react'; +import { + useSelectionActions, + useHistoryActions, + useCanvasStore, + useShowRulers, + useRulersActions, + useInnerEditActions, +} from '../state/store'; +import { Rulers } from './Rulers'; +import type { NodeId, Node } from '../types'; export type CanvasProps = { className?: string; @@ -22,12 +31,318 @@ export const Canvas = forwardRef(function Canvas( ) { const { clearSelection } = useSelectionActions(); const { undo, redo } = useHistoryActions(); + const { setActiveGuide } = useRulersActions(); + const { exitInnerEdit } = useInnerEditActions(); + const showRulers = useShowRulers(); - // Track whether this interaction should clear selection (i.e., true click without drag) + // Helper: update hovered visual group highlights for given node ids + const updateHoverForIds = (ids: NodeId[]) => { + try { + const st = useCanvasStore.getState(); + const setPrimary = st.setHoveredVisualGroupId; + const setSecondary = st.setHoveredVisualGroupIdSecondary; + if (!ids || ids.length === 0) { + setPrimary(null); + setSecondary(null); + return; + } + const groups = Object.values(st.visualGroups); + if (!groups || groups.length === 0) { + setPrimary(null); + setSecondary(null); + return; + } + // Candidates that contain ALL ids + const candidates = groups.filter((vg) => ids.every((id) => vg.members.includes(id))); + if (candidates.length === 0) { + setPrimary(null); + setSecondary(null); + return; + } + let largestId: string | null = null; + let largestArea = -Infinity; + let smallestId: string | null = null; + let smallestArea = Infinity; + for (const vg of candidates) { + let left = Infinity, + top = Infinity, + right = -Infinity, + bottom = -Infinity; + for (const mid of vg.members) { + const n = st.nodes[mid as NodeId]; + if (!n) continue; + left = Math.min(left, n.x); + top = Math.min(top, n.y); + right = Math.max(right, n.x + n.width); + bottom = Math.max(bottom, n.y + n.height); + } + if (left === Infinity) continue; + const area = Math.max(0, right - left) * Math.max(0, bottom - top); + if (area > largestArea) { + largestArea = area; + largestId = vg.id; + } + if (area < smallestArea) { + smallestArea = area; + smallestId = vg.id; + } + } + setPrimary(largestId); + const secondaryId = smallestId && smallestId !== largestId ? smallestId : null; + setSecondary(secondaryId); + } catch { + // ignore + } + }; + + const [boxStart, setBoxStart] = useState<{ x: number; y: number } | null>(null); + const [boxEnd, setBoxEnd] = useState<{ x: number; y: number } | null>(null); + const isBoxSelecting = boxStart != null && boxEnd != null; + // Live combined preview bbox (world coords) for [group ∪ outside nodes] + const [liveCombinedBBox, setLiveCombinedBBox] = useState< + { left: number; top: number; right: number; bottom: number } | null + >(null); + const activePointerIdRef = useRef(null); + // Снимок выделения на момент старта прямоугольного выделения (для additive на отпускании) + const initialSelectionRef = useRef | null>(null); + const DRAG_THRESHOLD_PX = 3; // movement beyond this cancels deselect const shouldMaybeDeselectRef = useRef(false); const startXRef = useRef(0); const startYRef = useRef(0); - const DRAG_THRESHOLD_PX = 3; // movement beyond this cancels deselect + // --- Автопрокрутка при рамочном выделении --- + const rootElRef = useRef(null); + const lastClientXRef = useRef(0); + const lastClientYRef = useRef(0); + const autoPanRafRef = useRef(null); + const EDGE_PX = 32; + const AUTO_PAN_MIN_PX = 4; + const AUTO_PAN_MAX_PX = 24; + // refs со свежими значениями состояния для rAF-цикла + const isBoxSelectingRef = useRef(false); + const boxStartRef = useRef<{ x: number; y: number } | null>(null); + const boxEndRef = useRef<{ x: number; y: number } | null>(null); + // Точка начала рамки в МИРОВЫХ координатах (фиксируем на старте, не двигаем при панораме) + const worldStartRef = useRef<{ x: number; y: number } | null>(null); + if (isBoxSelectingRef.current !== isBoxSelecting) isBoxSelectingRef.current = isBoxSelecting; + if (boxStartRef.current !== boxStart) boxStartRef.current = boxStart; + if (boxEndRef.current !== boxEnd) boxEndRef.current = boxEnd; + + const stopAutoPan = () => { + if (autoPanRafRef.current != null) { + try { + cancelAnimationFrame(autoPanRafRef.current); + } catch { + // ignore + } + autoPanRafRef.current = null; + } + }; + + // Global Ctrl/Cmd+G so grouping works even if Canvas isn't focused + useEffect(() => { + 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; + } + const onWindowKeyDown = (e: KeyboardEvent) => { + if (isTextInput(e.target as Element)) return; + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) { + const code = e.code; + if (code === 'KeyG') { + const { selected, createVisualGroupFromSelection } = useCanvasStore.getState(); + if (Object.keys(selected).length >= 2) { + e.preventDefault(); + createVisualGroupFromSelection(); + } + } + } + }; + window.addEventListener('keydown', onWindowKeyDown); + return () => window.removeEventListener('keydown', onWindowKeyDown); + }, []); + + // Capture-phase focus: ensure the canvas gets focus even when child components + // stop propagation of pointer events (e.g., NodeView). This guarantees our + // onKeyDown will receive keyboard shortcuts after any click inside the canvas. + const onPointerDownCapture: React.PointerEventHandler = (e) => { + 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 + } + } + }; + + const autoPanTick = () => { + const el = rootElRef.current; + if (!el || !isBoxSelectingRef.current) { + autoPanRafRef.current = null; + return; + } + const rect = el.getBoundingClientRect(); + const x = lastClientXRef.current; + const y = lastClientYRef.current; + let vxPx = 0; + let vyPx = 0; + const leftZone = rect.left + EDGE_PX; + const rightZone = rect.right - EDGE_PX; + const topZone = rect.top + EDGE_PX; + const bottomZone = rect.bottom - EDGE_PX; + if (x < leftZone) vxPx = -(leftZone - x); + else if (x > rightZone) vxPx = x - rightZone; + if (y < topZone) vyPx = -(topZone - y); + else if (y > bottomZone) vyPx = y - bottomZone; + // Преобразуем «насколько зашли в зону» в скорость, с минимумом/максимумом + const scale = (d: number) => { + const mag = Math.min(Math.max(Math.abs(d), 0), EDGE_PX); + if (mag <= 0) return 0; + const v = (mag / EDGE_PX) * AUTO_PAN_MAX_PX; + return Math.max(v, AUTO_PAN_MIN_PX) * Math.sign(d); + }; + const sx = scale(vxPx); + const sy = scale(vyPx); + if (sx === 0 && sy === 0) { + // Нет автопрокрутки — прекратим цикл до следующего движения указателя + autoPanRafRef.current = null; + return; + } + const st = useCanvasStore.getState(); + const { panBy } = st; + const zoom = st.camera.zoom || 1; + const dxWorld = sx / zoom; + const dyWorld = sy / zoom; + panBy(dxWorld, dyWorld); + // Форсируем перерисовку, чтобы оверлей пересчитался относительно новой камеры + setBoxEnd((prev) => (prev ? { x: prev.x, y: prev.y } : prev)); + // Обновим «живое» выделение с УЧЁТОМ новой камеры после panBy, якорь — worldStartRef + const { camera, nodes, clearSelection, addToSelection } = useCanvasStore.getState(); + const startWorld = worldStartRef.current!; + const endPt = boxEndRef.current!; + if (startWorld && endPt) { + const invZoom = 1 / (camera.zoom || 1); + const endWorldX = endPt.x * invZoom + camera.offsetX; + const endWorldY = endPt.y * invZoom + camera.offsetY; + const worldLeft = Math.min(startWorld.x, endWorldX); + const worldTop = Math.min(startWorld.y, endWorldY); + const worldRight = Math.max(startWorld.x, endWorldX); + const worldBottom = Math.max(startWorld.y, endWorldY); + const hits: NodeId[] = []; + for (const n of Object.values(nodes) as Node[]) { + const nLeft = n.x; + const nTop = n.y; + const nRight = n.x + n.width; + const nBottom = n.y + n.height; + const intersects = + nRight >= worldLeft && nLeft <= worldRight && nBottom >= worldTop && nTop <= worldBottom; + if (intersects) hits.push(n.id); + } + // Lasso behavior: if any hit nodes belong to a visual group, + // do NOT select nodes; instead, hover the most relevant group. + const st = useCanvasStore.getState(); + const groups = Object.values(st.visualGroups); + let primaryGroupId: string | null = null; + let secondaryGroupId: string | null = null; + if (groups.length > 0 && hits.length > 0) { + type Candidate = { id: string; hitCount: number; area: number }; + const candidates: Candidate[] = []; + for (const vg of groups) { + let count = 0; + for (const hid of hits) if (vg.members.includes(hid as NodeId)) count++; + if (count > 0) { + let L = Infinity, T = Infinity, R = -Infinity, B = -Infinity; + for (const mid of vg.members) { + const n = st.nodes[mid as NodeId]; + if (!n) continue; + L = Math.min(L, n.x); + T = Math.min(T, n.y); + R = Math.max(R, n.x + n.width); + B = Math.max(B, n.y + n.height); + } + if (L !== Infinity) candidates.push({ id: vg.id, hitCount: count, area: Math.max(0, R - L) * Math.max(0, B - T) }); + } + } + if (candidates.length > 0) { + candidates.sort((a, b) => (b.hitCount - a.hitCount) || (b.area - a.area)); + primaryGroupId = candidates[0]?.id || null; + secondaryGroupId = candidates[1]?.id || null; + } + } + if (primaryGroupId) { + // Clear any node selection preview, hover the group, + // and preview-select only nodes OUTSIDE the group + clearSelection(); + try { + st.setHoveredVisualGroupId(primaryGroupId); + st.setHoveredVisualGroupIdSecondary(secondaryGroupId || null); + } catch { + // ignore + } + try { + const vg = st.visualGroups[primaryGroupId]; + if (vg) { + const memberSet = new Set(vg.members as NodeId[]); + for (const hid of hits) { + if (!memberSet.has(hid as NodeId)) addToSelection(hid as NodeId); + } + } + } catch { + // ignore + } + // Compute live combined bbox: [primary group bbox ∪ any outside nodes] + try { + const vg = st.visualGroups[primaryGroupId]; + if (vg) { + let L = Infinity, T = Infinity, R = -Infinity, B = -Infinity; + for (const mid of vg.members) { + const n = st.nodes[mid as NodeId]; + if (!n) continue; + L = Math.min(L, n.x); + T = Math.min(T, n.y); + R = Math.max(R, n.x + n.width); + B = Math.max(B, n.y + n.height); + } + const memberSet = new Set(vg.members as NodeId[]); + for (const hid of hits) { + if (memberSet.has(hid as NodeId)) continue; + const n = st.nodes[hid as NodeId]; + if (!n) continue; + L = Math.min(L, n.x); + T = Math.min(T, n.y); + R = Math.max(R, n.x + n.width); + B = Math.max(B, n.y + n.height); + } + if (L !== Infinity) setLiveCombinedBBox({ left: L, top: T, right: R, bottom: B }); + else setLiveCombinedBBox(null); + } else { + setLiveCombinedBBox(null); + } + } catch { + setLiveCombinedBBox(null); + } + } else { + // No grouped hits: live node selection preview as before + clearSelection(); + for (const id of hits) addToSelection(id); + updateHoverForIds(hits); + setLiveCombinedBBox(null); + } + } + autoPanRafRef.current = requestAnimationFrame(autoPanTick); + }; + + const ensureAutoPan = (el: HTMLElement) => { + rootElRef.current = el; + if (autoPanRafRef.current == null) { + autoPanRafRef.current = requestAnimationFrame(autoPanTick); + } + }; const onPointerDown: React.PointerEventHandler = (e) => { // Focus canvas so it receives keyboard events (Delete/Backspace, arrows, etc.) @@ -41,34 +356,350 @@ export const Canvas = forwardRef(function Canvas( // ignore } } - // Only consider left button. NodeView stops propagation, so this only - // fires for empty canvas area. Do not stop propagation to keep pan working. + // Только левая кнопка. NodeView останавливает всплытие, значит здесь — пустая область. + // Не останавливаем всплытие — панорамирование средней кнопкой останется рабочим. if (e.button === 0) { shouldMaybeDeselectRef.current = true; startXRef.current = e.clientX; startYRef.current = e.clientY; + lastClientXRef.current = e.clientX; + lastClientYRef.current = e.clientY; + rootElRef.current = root; + // lasso not yet active + try { + const st = useCanvasStore.getState(); + if (st.boxSelecting) st.setBoxSelecting(false); + } catch { + // ignore + } } }; 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 + const root = e.currentTarget as HTMLElement; + const rect = root.getBoundingClientRect(); + // Обновляем прямоугольник выделения, если он активен + if (isBoxSelecting) { + lastClientXRef.current = e.clientX; + lastClientYRef.current = e.clientY; + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + setBoxEnd({ x, y }); + // «живое» выделение: пересчитываем пересечения и отражаем границы сразу + const end = { x, y }; + const { camera, nodes, clearSelection, addToSelection } = useCanvasStore.getState(); + const invZoom = 1 / (camera.zoom || 1); + const startWorld = worldStartRef.current!; + const endWorldX = end.x * invZoom + camera.offsetX; + const endWorldY = end.y * invZoom + camera.offsetY; + const worldLeft = Math.min(startWorld.x, endWorldX); + const worldTop = Math.min(startWorld.y, endWorldY); + const worldRight = Math.max(startWorld.x, endWorldX); + const worldBottom = Math.max(startWorld.y, endWorldY); + const hits: NodeId[] = []; + for (const n of Object.values(nodes) as Node[]) { + const nLeft = n.x; + const nTop = n.y; + const nRight = n.x + n.width; + const nBottom = n.y + n.height; + const intersects = + nRight >= worldLeft && nLeft <= worldRight && nBottom >= worldTop && nTop <= worldBottom; + if (intersects) hits.push(n.id); + } + // Групповая логика превью — идентична финализации/автопрокрутке + const st = useCanvasStore.getState(); + const groups = Object.values(st.visualGroups); + let primaryGroupId: string | null = null; + let secondaryGroupId: string | null = null; + if (groups.length > 0 && hits.length > 0) { + type Candidate = { id: string; hitCount: number; area: number }; + const candidates: Candidate[] = []; + for (const vg of groups) { + let count = 0; + for (const hid of hits) if (vg.members.includes(hid as NodeId)) count++; + if (count > 0) { + let L = Infinity, T = Infinity, R = -Infinity, B = -Infinity; + for (const mid of vg.members) { + const n = st.nodes[mid as NodeId]; + if (!n) continue; + L = Math.min(L, n.x); + T = Math.min(T, n.y); + R = Math.max(R, n.x + n.width); + B = Math.max(B, n.y + n.height); + } + if (L !== Infinity) + candidates.push({ id: vg.id, hitCount: count, area: Math.max(0, R - L) * Math.max(0, B - T) }); + } + } + if (candidates.length > 0) { + candidates.sort((a, b) => (b.hitCount - a.hitCount) || (b.area - a.area)); + primaryGroupId = candidates[0]?.id || null; + secondaryGroupId = candidates[1]?.id || null; + } + } + if (primaryGroupId) { + // preview: очистить внутренние ноды и выделить только внешние, подсветить группы + clearSelection(); + try { + st.setHoveredVisualGroupId(primaryGroupId); + st.setHoveredVisualGroupIdSecondary(secondaryGroupId || null); + } catch { + // ignore + } + try { + const vg = st.visualGroups[primaryGroupId]; + if (vg) { + const memberSet = new Set(vg.members as NodeId[]); + for (const hid of hits) if (!memberSet.has(hid as NodeId)) addToSelection(hid as NodeId); + } + } catch { + // ignore + } + // live combined bbox + try { + const vg = st.visualGroups[primaryGroupId]; + if (vg) { + let L = Infinity, T = Infinity, R = -Infinity, B = -Infinity; + for (const mid of vg.members) { + const n = st.nodes[mid as NodeId]; + if (!n) continue; + L = Math.min(L, n.x); + T = Math.min(T, n.y); + R = Math.max(R, n.x + n.width); + B = Math.max(B, n.y + n.height); + } + const memberSet = new Set(vg.members as NodeId[]); + for (const hid of hits) { + if (memberSet.has(hid as NodeId)) continue; + const n = st.nodes[hid as NodeId]; + if (!n) continue; + L = Math.min(L, n.x); + T = Math.min(T, n.y); + R = Math.max(R, n.x + n.width); + B = Math.max(B, n.y + n.height); + } + if (L !== Infinity) setLiveCombinedBBox({ left: L, top: T, right: R, bottom: B }); + else setLiveCombinedBBox(null); + } else setLiveCombinedBBox(null); + } catch { + setLiveCombinedBBox(null); + } + } else { + // default node-only preview + clearSelection(); + for (const id of hits) addToSelection(id); + updateHoverForIds(hits); + setLiveCombinedBBox(null); + } shouldMaybeDeselectRef.current = false; + ensureAutoPan(root); + return; + } + // Если ещё не активировали box-select — проверим порог и стартуем + if (shouldMaybeDeselectRef.current) { + 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) { + // Начинаем прямоугольное выделение от первоначальной точки + const startX = startXRef.current - rect.left; + const startY = startYRef.current - rect.top; + setBoxStart({ x: startX, y: startY }); + const currX = e.clientX - rect.left; + const currY = e.clientY - rect.top; + setBoxEnd({ x: currX, y: currY }); + activePointerIdRef.current = e.pointerId; + // Снимок исходного выделения для additive на отпускании + initialSelectionRef.current = { ...useCanvasStore.getState().selected }; + // Зафиксируем мировую точку старта + { + const cam = useCanvasStore.getState().camera; + const invZ = 1 / (cam.zoom || 1); + worldStartRef.current = { + x: startX * invZ + cam.offsetX, + y: startY * invZ + cam.offsetY, + }; + } + try { + const el = e.currentTarget as Element; + if ('setPointerCapture' in el) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore — DOM API присутствует в браузере + el.setPointerCapture(e.pointerId); + } + } catch { + // ignore + } + shouldMaybeDeselectRef.current = false; + lastClientXRef.current = e.clientX; + lastClientYRef.current = e.clientY; + ensureAutoPan(root); + // mark lasso active + try { + useCanvasStore.getState().setBoxSelecting(true); + } catch { + // ignore + } + } } }; const onPointerUp: React.PointerEventHandler = (e) => { - if (e.button === 0 && shouldMaybeDeselectRef.current) { + // Завершаем прямоугольное выделение, если активно. + // Используем признак захвата/старта (activePointerIdRef + worldStartRef), + // так как React мог ещё не перерендерить isBoxSelecting к этому событию. + if (activePointerIdRef.current != null && worldStartRef.current) { + // вычисляем прямоугольник в мировых координатах из экранных + const root = e.currentTarget as HTMLElement; + const rect = root.getBoundingClientRect(); + const endPt = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + const { camera, nodes, clearSelection, addToSelection } = useCanvasStore.getState(); + const invZoom = 1 / (camera.zoom || 1); + const startWorld = worldStartRef.current!; + const endWorldX = endPt.x * invZoom + camera.offsetX; + const endWorldY = endPt.y * invZoom + camera.offsetY; + const worldLeft = Math.min(startWorld.x, endWorldX); + const worldTop = Math.min(startWorld.y, endWorldY); + const worldRight = Math.max(startWorld.x, endWorldX); + const worldBottom = Math.max(startWorld.y, endWorldY); + const hits: NodeId[] = []; + for (const n of Object.values(nodes) as Node[]) { + const nLeft = n.x; + const nTop = n.y; + const nRight = n.x + n.width; + const nBottom = n.y + n.height; + const intersects = + nRight >= worldLeft && nLeft <= worldRight && nBottom >= worldTop && nTop <= worldBottom; + if (intersects) hits.push(n.id); + } + // Lasso finalization: if any hit node belongs to a visual group, select that group instead of nodes + const st = useCanvasStore.getState(); + const groups = Object.values(st.visualGroups); + let chosenGroupId: string | null = null; + let secondGroupId: string | null = null; + if (groups.length > 0 && hits.length > 0) { + type Candidate = { id: string; hitCount: number; area: number }; + const candidates: Candidate[] = []; + for (const vg of groups) { + let count = 0; + for (const hid of hits) if (vg.members.includes(hid as NodeId)) count++; + if (count > 0) { + let L = Infinity, T = Infinity, R = -Infinity, B = -Infinity; + for (const mid of vg.members) { + const n = st.nodes[mid as NodeId]; + if (!n) continue; + L = Math.min(L, n.x); + T = Math.min(T, n.y); + R = Math.max(R, n.x + n.width); + B = Math.max(B, n.y + n.height); + } + if (L !== Infinity) candidates.push({ id: vg.id, hitCount: count, area: Math.max(0, R - L) * Math.max(0, B - T) }); + } + } + if (candidates.length > 0) { + candidates.sort((a, b) => (b.hitCount - a.hitCount) || (b.area - a.area)); + chosenGroupId = candidates[0]?.id || null; + secondGroupId = candidates[1]?.id || null; + } + } + + const additive = e.ctrlKey || e.metaKey; + const snapshot = initialSelectionRef.current || {}; clearSelection(); + if (chosenGroupId) { + // Select the chosen group frame AND any nodes outside that group hit by the lasso + const vg = st.visualGroups[chosenGroupId]; + const memberSet = new Set(vg ? (vg.members as NodeId[]) : []); + const outside = hits.filter((id) => !memberSet.has(id as NodeId)); + if (additive) { + for (const id of Object.keys(snapshot) as NodeId[]) addToSelection(id); + } + for (const id of outside) addToSelection(id); + st.selectVisualGroup(chosenGroupId); + // If a second group also intersects, provide secondary hover highlight for UX feedback + if (secondGroupId && secondGroupId !== chosenGroupId) { + try { + st.setHoveredVisualGroupIdSecondary(secondGroupId); + } catch { + // ignore + } + } + setLiveCombinedBBox(null); + } else { + // Standard node selection behavior + if (additive) { + for (const id of Object.keys(snapshot) as NodeId[]) addToSelection(id); + } + for (const id of hits) addToSelection(id); + setLiveCombinedBBox(null); + } + + // сбрасываем состояние box-select и освобождаем захват указателя + setBoxStart(null); + setBoxEnd(null); + setLiveCombinedBBox(null); + initialSelectionRef.current = null; + worldStartRef.current = null; + stopAutoPan(); + // mark lasso inactive + try { + useCanvasStore.getState().setBoxSelecting(false); + } catch { + // ignore + } + // Clear hover highlights when box selection ends + try { + const st2 = useCanvasStore.getState(); + st2.setHoveredVisualGroupId(null); + st2.setHoveredVisualGroupIdSecondary(null); + } catch { + // ignore + } + const target = e.currentTarget as Element; + const pid = activePointerIdRef.current; + if (pid != null && target && 'releasePointerCapture' in target) { + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore — DOM API присутствует в браузере + target.releasePointerCapture(pid); + } catch { + // ignore + } + } + activePointerIdRef.current = null; + } else { + // «Чистый клик» по пустому месту — снять выделение и активную guide + if (e.button === 0 && shouldMaybeDeselectRef.current) { + clearSelection(); + setActiveGuide(null); + // Exit inner-edit mode if active + try { + const { innerEditNodeId } = useCanvasStore.getState(); + if (innerEditNodeId) exitInnerEdit(); + } catch { + // ignore + } + try { + const { selectVisualGroup } = useCanvasStore.getState(); + selectVisualGroup(null); + } catch { + // ignore + } + // Also clear any hovered visual group highlights + try { + const st3 = useCanvasStore.getState(); + st3.setHoveredVisualGroupId(null); + st3.setHoveredVisualGroupIdSecondary(null); + } catch { + // ignore + } + } } shouldMaybeDeselectRef.current = false; }; const onPointerCancel: React.PointerEventHandler = () => { shouldMaybeDeselectRef.current = false; + stopAutoPan(); }; const onContextMenu: React.MouseEventHandler = (e) => { @@ -78,20 +709,118 @@ export const Canvas = forwardRef(function Canvas( 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')) { + if ((e.ctrlKey || e.metaKey) && e.code === 'KeyZ') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); } + + // Escape: exit inner-edit if active; otherwise clear node and visual group selections + if (e.code === 'Escape') { + const { innerEditNodeId, selectVisualGroup } = useCanvasStore.getState(); + if (innerEditNodeId) { + e.preventDefault(); + exitInnerEdit(); + return; + } + e.preventDefault(); + clearSelection(); + selectVisualGroup(null); + return; + } + + // Grouping: Ctrl/Cmd + G (works even if useCanvasNavigation hook isn't attached) + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) { + const code = e.code; + if (code === 'KeyG') { + const { selected, createVisualGroupFromSelection } = useCanvasStore.getState(); + if (Object.keys(selected).length >= 2) { + e.preventDefault(); + createVisualGroupFromSelection(); + return; + } + } + } }; + // Подготовим оверлей прямоугольника + let overlay: React.ReactNode = null; + if (isBoxSelecting) { + // Визуализируем рамку относительно МИРОВОГО старта, чтобы она не «ехала» со сценой при панораме + const startWorld = worldStartRef.current; + const { camera } = useCanvasStore.getState(); + const z = camera.zoom || 1; + const sx = startWorld ? (startWorld.x - camera.offsetX) * z : boxStart!.x; + const sy = startWorld ? (startWorld.y - camera.offsetY) * z : boxStart!.y; + const ex = boxEnd!.x; + const ey = boxEnd!.y; + const x1 = Math.min(sx, ex); + const y1 = Math.min(sy, ey); + const x2 = Math.max(sx, ex); + const y2 = Math.max(sy, ey); + + // Combined preview bbox for [group ∪ outside nodes] if available + let unionFrame: React.ReactNode = null; + if (liveCombinedBBox) { + const ux1 = (liveCombinedBBox.left - camera.offsetX) * z; + const uy1 = (liveCombinedBBox.top - camera.offsetY) * z; + const ux2 = (liveCombinedBBox.right - camera.offsetX) * z; + const uy2 = (liveCombinedBBox.bottom - camera.offsetY) * z; + unionFrame = ( +
+ ); + } + + overlay = ( + <> +
+ {unionFrame} + + ); + } + return (
(function Canvas( > {background}
-
{children}
+
{children}
+ {overlay} + {showRulers ? : null}
); }); diff --git a/src/react/GroupContainersLayer.tsx b/src/react/GroupContainersLayer.tsx new file mode 100644 index 0000000..65dc30a --- /dev/null +++ b/src/react/GroupContainersLayer.tsx @@ -0,0 +1,714 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { cameraToCssTransform } from '../core/coords'; +import { + useCamera, + useNodes, + useSelectedIds, + useSelectionActions, + useHistoryActions, + useNodeActions, + useCanvasStore, + useCanvasActions, +} from '../state/store'; +import type { Node, NodeId } from '../types'; + +/** + * Renders visual containers for logical groups (nodes with descendants). + * Containers are purely decorative: dashed rounded rectangles behind nodes. + * They use world coordinates and inherit camera transform. + */ +export function GroupContainersLayer() { + const camera = useCamera(); + const nodes = useNodes(); + const { selectOnly, toggleInSelection } = useSelectionActions(); + const { beginHistory, endHistory } = useHistoryActions(); + const { updateNode } = useNodeActions(); + const { panBy } = useCanvasActions(); + // Hovered group id for showing stroke on hover + const [hoveredGroupId, setHoveredGroupId] = useState(null); + + // Active drag state shared across container rects + const dragRef = useRef<{ + parentId: NodeId; + memberIds: NodeId[]; + pointerId: number; + startX: number; + startY: number; + lastX: number; + lastY: number; + dragging: boolean; + ctrlMetaAtDown: boolean; + /** true when dragging the temporary selection container */ + selectionDrag?: boolean; + } | null>(null); + + // Visual groups from store (purely visual, independent of parentId) + const visualGroups = useCanvasStore((s) => s.visualGroups); + const selectedGroupId = useCanvasStore((s) => s.selectedVisualGroupId); + const hoveredGlobalGroupId = useCanvasStore((s) => s.hoveredVisualGroupId); + const hoveredGlobalGroupIdSecondary = useCanvasStore((s) => s.hoveredVisualGroupIdSecondary); + + // Compute bbox for each visual group based on member nodes + const containers = useMemo(() => { + const nsById = new Map(nodes.map((n) => [n.id, n])); + const arr: Array<{ + groupId: string; + left: number; + top: number; + right: number; + bottom: number; + members: NodeId[]; + area: number; + }> = []; + for (const vg of Object.values(visualGroups)) { + const members = vg.members.filter((id) => nsById.has(id)); + if (members.length < 1) continue; + let left = Infinity, + top = Infinity, + right = -Infinity, + bottom = -Infinity; + for (const mid of members) { + const c = nsById.get(mid)!; + left = Math.min(left, c.x); + top = Math.min(top, c.y); + right = Math.max(right, c.x + c.width); + bottom = Math.max(bottom, c.y + c.height); + } + if (left === Infinity) continue; + const area = Math.max(0, right - left) * Math.max(0, bottom - top); + arr.push({ groupId: vg.id, left, top, right, bottom, members, area }); + } + arr.sort((a, b) => b.area - a.area); + return arr; + }, [nodes, visualGroups]); + + // No visual padding: containers and selection frames are tight to bounds + + const DRAG_THRESHOLD_PX = 3; + + // Current multi-selection bbox (>=2 nodes) for the temporary overlay + const selectedIds = useSelectedIds(); + const selectionBBox = useMemo(() => { + // Build combined selection of: selected nodes + selected group (as one entity) + const nodesCount = selectedIds ? selectedIds.length : 0; + const hasGroup = Boolean(selectedGroupId); + // Show combined selection frame when at least two entities are selected + if (nodesCount + (hasGroup ? 1 : 0) < 2) return null; + + const sel = new Set((selectedIds || []) as NodeId[]); + let left = Infinity, + top = Infinity, + right = -Infinity, + bottom = -Infinity; + + // Include selected nodes + for (const n of nodes) { + if (!sel.has(n.id)) continue; + left = Math.min(left, n.x); + top = Math.min(top, n.y); + right = Math.max(right, n.x + n.width); + bottom = Math.max(bottom, n.y + n.height); + } + + // Include selected visual group bbox if present + if (selectedGroupId) { + const cg = containers.find((c) => c.groupId === selectedGroupId); + if (cg) { + left = Math.min(left, cg.left); + top = Math.min(top, cg.top); + right = Math.max(right, cg.right); + bottom = Math.max(bottom, cg.bottom); + } + } + + if (left === Infinity) return null; + return { left, top, right, bottom }; + }, [nodes, selectedIds, selectedGroupId, containers]); + + // Auto-pan bookkeeping (mirrors NodeView) + 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; + const MAX_SPEED_PX_PER_SEC = 800; + + 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) => { + const st = dragRef.current; + if (!st || !st.dragging) { + 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) { + stopAutoPan(); + return; + } + const dxScreen = sx * dt; + const dyScreen = sy * dt; + const cam = useCanvasStore.getState().camera; + const dz = cam.zoom || 1; + const dxWorld = dxScreen / dz; + const dyWorld = dyScreen / dz; + + // Pan the camera and move by same world delta + panBy(dxWorld, dyWorld); + if (st.selectionDrag) { + // Move all currently selected nodes (and descendants) together + const { moveSelectedBy } = useCanvasStore.getState(); + moveSelectedBy(dxWorld, dyWorld); + } else { + // Group container drag: move explicit members + const stateNodes = useCanvasStore.getState().nodes; + for (const id of st.memberIds) { + const cur = stateNodes[id]; + if (!cur) continue; + updateNode(id, { x: cur.x + dxWorld, y: cur.y + 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); + + 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(); + }; + + // Handlers factory (bound to specific group) + function makePointerHandlers(groupId: string, memberIds: NodeId[]) { + const onPointerDown: React.PointerEventHandler = (e) => { + if (e.button !== 0) return; // LMB only + // focus canvas for hotkeys + const closestEl = (e.currentTarget as Element).closest('[data-rc-canvas]'); + if (closestEl && closestEl instanceof HTMLElement) { + try { + closestEl.focus({ preventScroll: true } as FocusOptions); + } catch { + // noop + } + } + // cache canvas root for edge auto-pan + canvasElRef.current = closestEl as HTMLElement | null; + // UX: indicate dragging + try { + (e.currentTarget as SVGRectElement).style.cursor = 'grabbing'; + } catch { + // ignore + } + dragRef.current = { + parentId: groupId as unknown as NodeId, + memberIds, + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + lastX: e.clientX, + lastY: e.clientY, + dragging: false, + ctrlMetaAtDown: Boolean(e.ctrlKey || e.metaKey), + selectionDrag: false, + }; + try { + (e.currentTarget as Element).setPointerCapture(e.pointerId); + } catch { + // ignore for JSDOM + } + e.stopPropagation(); + e.preventDefault(); + }; + + const onPointerMove: React.PointerEventHandler = (e) => { + const st = dragRef.current; + if (!st || st.pointerId !== e.pointerId) return; + const totalDx = Math.abs(e.clientX - st.startX); + const totalDy = Math.abs(e.clientY - st.startY); + if (!st.dragging) { + if (totalDx > DRAG_THRESHOLD_PX || totalDy > DRAG_THRESHOLD_PX) { + st.dragging = true; + beginHistory('group-drag'); + } else { + return; + } + } + const dxScreen = e.clientX - st.lastX; + const dyScreen = e.clientY - st.lastY; + st.lastX = e.clientX; + st.lastY = e.clientY; + if (dxScreen === 0 && dyScreen === 0) return; + const dz = camera.zoom || 1; + const dxWorld = dxScreen / dz; + const dyWorld = dyScreen / dz; + // Move root + descendants explicitly (do not rely on selection) + for (const id of st.memberIds) { + const cur = useCanvasStore.getState().nodes[id]; + if (!cur) continue; + updateNode(id, { x: cur.x + dxWorld, y: cur.y + dyWorld }); + } + // Edge auto-pan + updateEdgeSpeeds(e.clientX, e.clientY); + }; + + const finish = (asCancel = false) => { + const st = dragRef.current; + if (!st) return; + stopAutoPan(); + if (st.dragging) { + endHistory(); + } else if (!asCancel) { + // Click without drag: selection per spec + const { selectVisualGroup } = useCanvasStore.getState(); + selectVisualGroup(groupId); + } + // Always clear hover on finish so the stroke hides after drop + try { + setHoveredGroupId(null); + const { setHoveredVisualGroupId } = useCanvasStore.getState(); + setHoveredVisualGroupId(null); + } catch { + // ignore + } + dragRef.current = null; + }; + + const onPointerUp: React.PointerEventHandler = (e) => { + const st = dragRef.current; + if (!st || st.pointerId !== e.pointerId) return; + try { + (e.currentTarget as Element).releasePointerCapture(e.pointerId); + } catch { + // ignore + } + try { + (e.currentTarget as SVGRectElement).style.cursor = 'grab'; + } catch { + // ignore + } + e.stopPropagation(); + e.preventDefault(); + finish(false); + }; + + const onPointerCancel: React.PointerEventHandler = (e) => { + try { + (e.currentTarget as SVGRectElement).style.cursor = 'grab'; + } catch { + // ignore + } + finish(true); + }; + + // Hover tracking for visual-only stroke + const onPointerEnter: React.PointerEventHandler = () => { + try { + setHoveredGroupId(groupId); + const { setHoveredVisualGroupId } = useCanvasStore.getState(); + setHoveredVisualGroupId(groupId); + } catch { + // ignore + } + }; + const onPointerLeave: React.PointerEventHandler = () => { + try { + // If leaving while dragging, keep active state handled by dragRef + const st = dragRef.current; + if (st && st.parentId === (groupId as unknown as NodeId) && st.dragging) return; + setHoveredGroupId((prev: string | null) => (prev === groupId ? null : prev)); + const { setHoveredVisualGroupId } = useCanvasStore.getState(); + if (useCanvasStore.getState().hoveredVisualGroupId === groupId) { + setHoveredVisualGroupId(null); + } + } catch { + // ignore + } + }; + + return { + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onPointerEnter, + onPointerLeave, + }; + } + + // Handlers for temporary multi-selection container (drag all selected nodes) + function makeSelectionPointerHandlers() { + const onPointerDown: React.PointerEventHandler = (e) => { + if (e.button !== 0) return; // LMB only + const closestEl = (e.currentTarget as Element).closest('[data-rc-canvas]'); + if (closestEl && closestEl instanceof HTMLElement) { + try { + closestEl.focus({ preventScroll: true } as FocusOptions); + } catch { + // noop + } + } + canvasElRef.current = closestEl as HTMLElement | null; + try { + (e.currentTarget as SVGRectElement).style.cursor = 'grabbing'; + } catch { + // ignore + } + dragRef.current = { + parentId: '__selection__' as NodeId, + memberIds: [], + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + lastX: e.clientX, + lastY: e.clientY, + dragging: false, + ctrlMetaAtDown: Boolean(e.ctrlKey || e.metaKey), + selectionDrag: true, + }; + try { + (e.currentTarget as Element).setPointerCapture(e.pointerId); + } catch { + // ignore for JSDOM + } + e.stopPropagation(); + e.preventDefault(); + }; + + const onPointerMove: React.PointerEventHandler = (e) => { + const st = dragRef.current; + if (!st || st.pointerId !== e.pointerId) return; + const totalDx = Math.abs(e.clientX - st.startX); + const totalDy = Math.abs(e.clientY - st.startY); + if (!st.dragging) { + if (totalDx > DRAG_THRESHOLD_PX || totalDy > DRAG_THRESHOLD_PX) { + st.dragging = true; + beginHistory('selection-drag'); + } else { + return; + } + } + const dxScreen = e.clientX - st.lastX; + const dyScreen = e.clientY - st.lastY; + st.lastX = e.clientX; + st.lastY = e.clientY; + if (dxScreen === 0 && dyScreen === 0) return; + const dz = camera.zoom || 1; + const dxWorld = dxScreen / dz; + const dyWorld = dyScreen / dz; + const { moveSelectedBy } = useCanvasStore.getState(); + moveSelectedBy(dxWorld, dyWorld); + updateEdgeSpeeds(e.clientX, e.clientY); + }; + + const finish = (asCancel = false, upEvent?: React.PointerEvent) => { + const st = dragRef.current; + if (!st) return; + stopAutoPan(); + if (st.dragging) { + endHistory(); + } else if (!asCancel && upEvent) { + // Treat click-through behavior: if clicking a selected node, mirror NodeView selection logic + try { + const cam = useCanvasStore.getState().camera; + const dz = cam.zoom || 1; + const worldX = upEvent.clientX / dz + cam.offsetX; + const worldY = upEvent.clientY / dz + cam.offsetY; + const selected = new Set(Object.keys(useCanvasStore.getState().selected)); + let hitId: string | null = null; + const ns = Object.values(useCanvasStore.getState().nodes) as Node[]; + for (let i = 0; i < ns.length; i++) { + const n = ns[i]; + if (!selected.has(n.id)) continue; + const L = n.x, + T = n.y, + R = n.x + n.width, + B = n.y + n.height; + if (worldX >= L && worldX <= R && worldY >= T && worldY <= B) { + hitId = n.id; + break; + } + } + if (hitId) { + if (st.ctrlMetaAtDown) toggleInSelection(hitId as NodeId); + else selectOnly(hitId as NodeId); + } + } catch { + // ignore + } + } + dragRef.current = null; + }; + + const onPointerUp: React.PointerEventHandler = (e) => { + const st = dragRef.current; + if (!st || st.pointerId !== e.pointerId) return; + try { + (e.currentTarget as Element).releasePointerCapture(e.pointerId); + } catch { + // ignore + } + try { + (e.currentTarget as SVGRectElement).style.cursor = 'grab'; + } catch { + // ignore + } + e.stopPropagation(); + e.preventDefault(); + finish(false, e); + }; + + const onPointerCancel: React.PointerEventHandler = (e) => { + try { + (e.currentTarget as SVGRectElement).style.cursor = 'grab'; + } catch { + // ignore + } + finish(true); + }; + + return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }; + } + + return ( + <> + {/* Group containers behind nodes */} +
+ {containers.map((c) => { + // Base world rect (tight to members) + let x = c.left; + let y = c.top; + let w = c.right - c.left; + let h = c.bottom - c.top; + + // Snap rect to 0.5px grid in SCREEN space to reduce antialiasing/tearing + const dz = camera.zoom || 1; + const round05 = (v: number) => Math.round(v * 2) / 2; + const sx = round05(x * dz); + const sy = round05(y * dz); + let sw = round05(w * dz); + let sh = round05(h * dz); + // Prevent degenerate sizes which may clip fill/stroke + sw = Math.max(sw, 1); + sh = Math.max(sh, 1); + x = sx / dz; + y = sy / dz; + w = sw / dz; + h = sh / dz; + const handlers = makePointerHandlers(c.groupId, c.members); + const isActive = + hoveredGroupId === c.groupId || + hoveredGlobalGroupId === c.groupId || + hoveredGlobalGroupIdSecondary === c.groupId || + (dragRef.current && + dragRef.current.parentId === (c.groupId as unknown as NodeId) && + dragRef.current.dragging); + const isSelected = selectedGroupId === c.groupId; + const showStroke = isActive || isSelected; + return ( + + {/* Visual stroke when hovered/dragging OR when the group is selected */} + {showStroke && ( + + )} + {/* Invisible hit rect over full area for drag/select */} + + + ); + })} +
+ + {/* Temporary selection container above nodes (visible when 2+ nodes are selected) */} + {selectionBBox && + (() => { + // Snap to 0.5px grid with no visual padding for the selection container + let x = selectionBBox.left; + let y = selectionBBox.top; + let w = selectionBBox.right - selectionBBox.left; + let h = selectionBBox.bottom - selectionBBox.top; + const dz = camera.zoom || 1; + const round05 = (v: number) => Math.round(v * 2) / 2; + const sx = round05(x * dz); + const sy = round05(y * dz); + let sw = round05(w * dz); + let sh = round05(h * dz); + sw = Math.max(sw, 1); + sh = Math.max(sh, 1); + x = sx / dz; + y = sy / dz; + w = sw / dz; + h = sh / dz; + const handlers = makeSelectionPointerHandlers(); + return ( +
+ + {/* Fill */} + + {/* Stroke */} + + {/* Hit rect on top for drag/click behavior */} + + +
+ ); + })()} + + ); +} diff --git a/src/react/NodeView.tsx b/src/react/NodeView.tsx index 5d92805..b634921 100644 --- a/src/react/NodeView.tsx +++ b/src/react/NodeView.tsx @@ -1,5 +1,5 @@ -import React, { forwardRef, useRef, useState } from 'react'; -import type { Node } from '../types'; +import React, { forwardRef, useEffect, useRef, useState } from 'react'; +import type { Node, NodeId } from '../types'; import { useDndActions, useIsSelected, @@ -7,6 +7,9 @@ import { useCamera, useCanvasActions, useHistoryActions, + useCanvasStore, + useInnerEditActions, + useInnerEdit, } from '../state/store'; type NodeAppearance = { @@ -42,11 +45,13 @@ export const NodeView = forwardRef(function NodeV ref, ) { const isSelected = useIsSelected(node.id); - const { selectOnly, toggleInSelection } = useSelectionActions(); + const { selectOnly, toggleInSelection, clearSelection } = useSelectionActions(); const { moveSelectedBy } = useDndActions(); const camera = useCamera(); const { panBy } = useCanvasActions(); + const { enterInnerEdit, exitInnerEdit } = useInnerEditActions(); const { beginHistory, endHistory } = useHistoryActions(); + // Grouping is triggered exclusively via Ctrl/Cmd+G keyboard shortcut handled in useCanvasNavigation. // Drag bookkeeping const startXRef = useRef(0); @@ -58,6 +63,54 @@ export const NodeView = forwardRef(function NodeV const historyStartedRef = useRef(false); const DRAG_THRESHOLD_PX = 3; const [isHovered, setIsHovered] = useState(false); + const lastHoverVgIdRef = useRef(null); + const lastHoverVgIdSecondaryRef = useRef(null); + // Drag modality bookkeeping + const ctrlMetaAtDownRef = useRef(false); + const dragGroupMembersRef = useRef(null); + const clickedInsideInnerEditRef = useRef(false); + // Double-click drag scope mode for this node: default -> largest group, groupLocal -> smallest containing group, node -> single node + const dragScopeModeRef = useRef<'default' | 'groupLocal' | 'node'>('default'); + // Context of the currently toggled group (members), used to detect outside clicks for reset + const dragScopeContextGroupMembersRef = useRef(null); + // When we pre-handle double-click in onPointerDown (for double-click-and-hold), skip the upcoming onDoubleClick + const skipNextDoubleClickRef = useRef(false); + // Double-click detection for pointerdown (React PointerEvent.detail can be unreliable) + const DOUBLE_CLICK_MS = 350; + const lastDownTsRef = useRef(0); + const clickCountRef = useRef(0); + // Track a possible double-click-and-hold sequence; finalized on drag start + const doubleClickHoldCandidateRef = useRef(false); + // When true, this gesture should drag only this node regardless of persistent mode + const forceNodeDragGestureRef = useRef(false); + + // Reset drag-scope mode when user clicks outside the stored group context (only for groupLocal) + useEffect(() => { + const onDocPointerDown = (ev: PointerEvent) => { + const mode = dragScopeModeRef.current; + // Only manage outside-click reset for the temporary groupLocal highlight mode + if (mode !== 'groupLocal') return; + // Determine clicked node id, if any + const target = ev.target as Element | null; + const nodeEl = target?.closest?.('[data-rc-nodeid]') as HTMLElement | null; + const clickedNodeId = nodeEl?.getAttribute('data-rc-nodeid') as NodeId | undefined; + const members = dragScopeContextGroupMembersRef.current; + const insideGroup = !!(clickedNodeId && members && members.includes(clickedNodeId)); + if (!insideGroup) { + // Reset mode and context; also exit inner-edit so user must double-click again + dragScopeModeRef.current = 'default'; + dragScopeContextGroupMembersRef.current = null; + try { + exitInnerEdit(); + } catch { + // ignore + } + } + }; + document.addEventListener('pointerdown', onDocPointerDown, { capture: true }); + return () => document.removeEventListener('pointerdown', onDocPointerDown, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Appearance defaults (pill-like as on the reference image) const defaultAppearance: NodeAppearance = { @@ -74,10 +127,97 @@ export const NodeView = forwardRef(function NodeV fontSize: 14, fontWeight: 600, }; + + // Whether this node belongs to any visual group + const nodeIsInAnyVisualGroup = useCanvasStore((s) => { + const groups = Object.values(s.visualGroups); + for (let i = 0; i < groups.length; i++) { + if (groups[i].members.includes(node.id as NodeId)) return true; + } + return false; + }); + + const onDoubleClick: React.MouseEventHandler = (e) => { + if (skipNextDoubleClickRef.current) { + skipNextDoubleClickRef.current = false; + return; + } + // Enter inner-edit mode for this node (persistent until empty-area click). Left button only. + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + + // Persistently scope drags to this node subtree + dragScopeModeRef.current = 'node'; + enterInnerEdit(node.id); + // Select the node so it behaves like an ordinary selected node inside the group + try { + selectOnly(node.id as NodeId); + } catch { + // ignore + } + // Also select the largest containing visual group for frame highlight + try { + const st = useCanvasStore.getState(); + const groups = Object.values(st.visualGroups); + let chosenId: string | null = null; + let bestArea = -Infinity; + for (const vg of groups) { + if (!vg.members.includes(node.id as NodeId)) continue; + let left = Infinity, + top = Infinity, + right = -Infinity, + bottom = -Infinity; + for (const mid of vg.members) { + const n = st.nodes[mid as NodeId]; + if (!n) continue; + left = Math.min(left, n.x); + top = Math.min(top, n.y); + right = Math.max(right, n.x + n.width); + bottom = Math.max(bottom, n.y + n.height); + } + if (left === Infinity) continue; + const area = Math.max(0, right - left) * Math.max(0, bottom - top); + if (area > bestArea) { + bestArea = area; + chosenId = vg.id; + } + } + st.selectVisualGroup(chosenId); + } catch { + // ignore + } + + // Reset double-click counters to avoid accidental pre-toggle on subsequent pointerdowns + clickCountRef.current = 0; + lastDownTsRef.current = 0; + }; + // Merge appearance props and set up content helpers const A = { ...defaultAppearance, ...(appearance ?? {}) } as NodeAppearance; const hasCustomChildren = children != null; const contentLabel = 'New Node'; - + const innerEditId = useInnerEdit(); + const selectedGroupId = useCanvasStore((s) => s.selectedVisualGroupId); + const nodeInSelectedGroup = useCanvasStore((s) => { + const gid = s.selectedVisualGroupId; + if (!gid) return false; + const vg = s.visualGroups[gid]; + return vg ? vg.members.includes(node.id as NodeId) : false; + }); + const innerEditActive = Boolean(innerEditId); + // Visual rules: + // - Default: grouped nodes do NOT show selection UI + // - Inner-edit: nodes in the selected group behave like ordinary nodes (show selection/hover) + // - The double-clicked node is NOT forced to look selected; selection drives visuals + const showSelectedUi = + (innerEditActive && nodeInSelectedGroup && isSelected) || (!nodeIsInAnyVisualGroup && isSelected); + // When inner-edit ends (via empty-area click in Canvas), return to default drag scope + useEffect(() => { + if (!innerEditId) { + dragScopeModeRef.current = 'default'; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [innerEditId]); // Auto-pan bookkeeping const canvasElRef = useRef(null); const autoRafRef = useRef(null); @@ -169,12 +309,159 @@ export const NodeView = forwardRef(function NodeV 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); + + // Time-based double-click detection to support double-click-and-hold + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const last = lastDownTsRef.current || 0; + if (now - last <= DOUBLE_CLICK_MS) { + clickCountRef.current = (clickCountRef.current || 0) + 1; + } else { + clickCountRef.current = 1; + } + lastDownTsRef.current = now; + + if (clickCountRef.current === 2) { + // Mark as potential double-click-and-hold; final decision will be made on drag start + doubleClickHoldCandidateRef.current = true; + } + + const st = useCanvasStore.getState(); + const innerEditId = st.innerEditNodeId; + // Helper: is clicked node within current inner-edit scope? + const isWithinScope = (id: NodeId): boolean => { + if (!innerEditId) return false; + let cur: NodeId | null | undefined = id; + while (cur != null) { + if (cur === innerEditId) return true; + cur = st.nodes[cur]?.parentId ?? null; + } + return false; + }; + const clickedInside = innerEditId + ? isWithinScope(node.id as NodeId) || (selectedGroupId != null && nodeInSelectedGroup) + : false; + clickedInsideInnerEditRef.current = clickedInside; + // Do NOT exit inner-edit on clicking another node; inner-edit persists until empty area click + + // Record modifier state at down time + ctrlMetaAtDownRef.current = !!(e.ctrlKey || e.metaKey); + + // If Ctrl/Cmd at down on a node that belongs to a visual group, + // select that group's frame instead of toggling node selection, + // EXCEPT when inner-edit is active and the node belongs to the selected group + if ( + ctrlMetaAtDownRef.current && + nodeIsInAnyVisualGroup && + !(innerEditActive && nodeInSelectedGroup) + ) { + try { + const st2 = useCanvasStore.getState(); + const groups = Object.values(st2.visualGroups); + let chosenId: string | null = null; + let bestArea = -Infinity; + for (const vg of groups) { + if (!vg.members.includes(node.id as NodeId)) continue; + let left = Infinity, + top = Infinity, + right = -Infinity, + bottom = -Infinity; + for (const mid of vg.members) { + const n = st2.nodes[mid as NodeId]; + if (!n) continue; + left = Math.min(left, n.x); + top = Math.min(top, n.y); + right = Math.max(right, n.x + n.width); + bottom = Math.max(bottom, n.y + n.height); + } + if (left === Infinity) continue; + const area = Math.max(0, right - left) * Math.max(0, bottom - top); + if (area > bestArea) { + bestArea = area; + chosenId = vg.id; + } + } + clearSelection(); + st2.selectVisualGroup(chosenId); + } catch { + // ignore + } + } + + // Decide potential drag scope honoring double-click mode + if (!ctrlMetaAtDownRef.current) { + const mode = dragScopeModeRef.current; + const nodeScopeActive = (mode === 'node' && !!innerEditId) || clickedInside || (innerEditActive && nodeInSelectedGroup); + if (nodeScopeActive) { + // Explicit node scope while inner-edit active, or clicked inside inner-edit -> single-node drag + dragGroupMembersRef.current = null; + } else { + const groups = Object.values(st.visualGroups); + let chosen: { id: string; members: NodeId[] } | null = null; + if (groups.length > 0) { + if (mode === 'groupLocal') { + // Choose SMALLEST containing visual group (local) + let bestArea = Infinity; + for (const vg of groups) { + if (!vg.members.includes(node.id as NodeId)) continue; + let left = Infinity, + top = Infinity, + right = -Infinity, + bottom = -Infinity; + for (const mid of vg.members) { + const n = st.nodes[mid as NodeId]; + if (!n) continue; + left = Math.min(left, n.x); + top = Math.min(top, n.y); + right = Math.max(right, n.x + n.width); + bottom = Math.max(bottom, n.y + n.height); + } + if (left === Infinity) continue; + const area = Math.max(0, right - left) * Math.max(0, bottom - top); + if (area < bestArea) { + bestArea = area; + chosen = { id: vg.id, members: vg.members.slice() as NodeId[] }; + } + } + } else { + // Default behavior: choose LARGEST containing visual group if any + let bestArea = -Infinity; + for (const vg of groups) { + if (!vg.members.includes(node.id as NodeId)) continue; + let left = Infinity, + top = Infinity, + right = -Infinity, + bottom = -Infinity; + for (const mid of vg.members) { + const n = st.nodes[mid as NodeId]; + if (!n) continue; + left = Math.min(left, n.x); + top = Math.min(top, n.y); + right = Math.max(right, n.x + n.width); + bottom = Math.max(bottom, n.y + n.height); + } + if (left === Infinity) continue; + const area = Math.max(0, right - left) * Math.max(0, bottom - top); + if (area > bestArea) { + bestArea = area; + chosen = { id: vg.id, members: vg.members.slice() as NodeId[] }; + } + } + } + } + dragGroupMembersRef.current = chosen ? chosen.members : null; + } + } else { + // Ctrl/Cmd: do not group-drag; selection toggling applies + dragGroupMembersRef.current = null; + } + + // Multi-select toggle remains immediate when Ctrl/Cmd is pressed. + // While inner-edit is active inside a group, allow toggling nodes within that group. + if ( + ctrlMetaAtDownRef.current && + (!nodeIsInAnyVisualGroup || (innerEditActive && nodeInSelectedGroup)) + ) { + toggleInSelection(node.id as NodeId); } // Stop propagation so canvas navigation doesn't start panning on node click e.stopPropagation(); @@ -219,6 +506,64 @@ export const NodeView = forwardRef(function NodeV const totalDy = Math.abs(e.clientY - startYRef.current); if (!draggingRef.current) { if (totalDx > DRAG_THRESHOLD_PX || totalDy > DRAG_THRESHOLD_PX) { + // Transition into dragging: decide selection now + if (doubleClickHoldCandidateRef.current) { + // This is a double-click-and-hold -> force node-only drag for this gesture + forceNodeDragGestureRef.current = true; + skipNextDoubleClickRef.current = true; // suppress the upcoming onDoubleClick + doubleClickHoldCandidateRef.current = false; + } + if (!ctrlMetaAtDownRef.current) { + if ( + !forceNodeDragGestureRef.current && + dragGroupMembersRef.current && + dragGroupMembersRef.current.length > 0 + ) { + const ids = dragGroupMembersRef.current as NodeId[]; + // Replace selection with full group + selectOnly(ids[0]); + for (let i = 1; i < ids.length; i++) { + // Avoid duplicates + if (!useCanvasStore.getState().selected[ids[i]]) { + useCanvasStore.getState().addToSelection(ids[i]); + } + } + // Also select the visual group frame for clarity + try { + const st3 = useCanvasStore.getState(); + const groups = Object.values(st3.visualGroups); + let chosenId: string | null = null; + let bestArea = -Infinity; + for (const vg of groups) { + if (!vg.members.includes(node.id as NodeId)) continue; + let left = Infinity, + top = Infinity, + right = -Infinity, + bottom = -Infinity; + for (const mid of vg.members) { + const n = st3.nodes[mid as NodeId]; + if (!n) continue; + left = Math.min(left, n.x); + top = Math.min(top, n.y); + right = Math.max(right, n.x + n.width); + bottom = Math.max(bottom, n.y + n.height); + } + if (left === Infinity) continue; + const area = Math.max(0, right - left) * Math.max(0, bottom - top); + if (area > bestArea) { + bestArea = area; + chosenId = vg.id; + } + } + st3.selectVisualGroup(chosenId); + } catch { + // ignore + } + } else { + // Single-node drag; in inner-edit treat like normal node + selectOnly(node.id as NodeId); + } + } draggingRef.current = true; if (!historyStartedRef.current) { beginHistory(); @@ -246,6 +591,9 @@ export const NodeView = forwardRef(function NodeV draggingRef.current = false; pointerIdRef.current = null; stopAutoPan(); + // Clear gesture-scoped flags + forceNodeDragGestureRef.current = false; + doubleClickHoldCandidateRef.current = false; if (historyStartedRef.current) { endHistory(); historyStartedRef.current = false; @@ -259,6 +607,51 @@ export const NodeView = forwardRef(function NodeV } catch { // ignore } + // If this was a click (no drag) + if (!draggingRef.current) { + if (!ctrlMetaAtDownRef.current) { + // In inner-edit mode within the selected group, behave like a normal node + if (innerEditActive && nodeInSelectedGroup) { + selectOnly(node.id as NodeId); + } else if (nodeIsInAnyVisualGroup) { + // Outside inner-edit: clicking grouped node selects the group frame + try { + const st2 = useCanvasStore.getState(); + const groups = Object.values(st2.visualGroups); + let chosenId: string | null = null; + let bestArea = -Infinity; + for (const vg of groups) { + if (!vg.members.includes(node.id as NodeId)) continue; + let left = Infinity, + top = Infinity, + right = -Infinity, + bottom = -Infinity; + for (const mid of vg.members) { + const n = st2.nodes[mid as NodeId]; + if (!n) continue; + left = Math.min(left, n.x); + top = Math.min(top, n.y); + right = Math.max(right, n.x + n.width); + bottom = Math.max(bottom, n.y + n.height); + } + if (left === Infinity) continue; + const area = Math.max(0, right - left) * Math.max(0, bottom - top); + if (area > bestArea) { + bestArea = area; + chosenId = vg.id; + } + } + clearSelection(); + st2.selectVisualGroup(chosenId); + } catch { + // ignore + } + } else { + selectOnly(node.id as NodeId); + } + } + } + // No auto-grouping on drop; grouping is Ctrl/Cmd+G only finishDrag(); }; @@ -279,7 +672,7 @@ export const NodeView = forwardRef(function NodeV boxSizing: 'border-box', border: unstyled ? undefined - : `${A.borderWidth}px solid ${isSelected ? A.selectedBorderColor : A.borderColor}`, + : `${A.borderWidth}px solid ${showSelectedUi ? A.selectedBorderColor : A.borderColor}`, borderRadius: unstyled ? undefined : A.borderRadius, background: unstyled ? undefined : A.background, color: unstyled ? undefined : A.textColor, @@ -287,12 +680,12 @@ export const NodeView = forwardRef(function NodeV boxShadow: unstyled ? undefined : isHovered - ? A.hoverShadow - : isSelected - ? A.selectedShadow || A.shadow - : A.shadow, + ? A.hoverShadow + : showSelectedUi + ? A.selectedShadow || A.shadow + : A.shadow, transition: 'box-shadow 120ms ease', - padding: unstyled ? undefined : (hasCustomChildren ? undefined : A.padding), + padding: unstyled ? undefined : hasCustomChildren ? undefined : A.padding, display: hasCustomChildren ? undefined : 'flex', alignItems: hasCustomChildren ? undefined : 'center', justifyContent: hasCustomChildren ? undefined : 'center', @@ -304,8 +697,75 @@ export const NodeView = forwardRef(function NodeV onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} - onPointerEnter={() => setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} + onDoubleClick={onDoubleClick} + onPointerEnter={() => { + setIsHovered(true); + try { + // Determine largest and smallest visual groups containing this node (for dual highlight) + const st = useCanvasStore.getState(); + const groups = Object.values(st.visualGroups); + if (!groups || groups.length === 0) return; + let largestId: string | null = null; + let largestArea = -Infinity; + let smallestId: string | null = null; + let smallestArea = Infinity; + for (const vg of groups) { + if (!vg.members.includes(node.id)) continue; + let left = Infinity, + top = Infinity, + right = -Infinity, + bottom = -Infinity; + for (const mid of vg.members) { + const n = st.nodes[mid]; + if (!n) continue; + left = Math.min(left, n.x); + top = Math.min(top, n.y); + right = Math.max(right, n.x + n.width); + bottom = Math.max(bottom, n.y + n.height); + } + if (left === Infinity) continue; + const area = Math.max(0, right - left) * Math.max(0, bottom - top); + if (area > largestArea) { + largestArea = area; + largestId = vg.id; + } + if (area < smallestArea) { + smallestArea = area; + smallestId = vg.id; + } + } + if (largestId) { + lastHoverVgIdRef.current = largestId; + st.setHoveredVisualGroupId(largestId); + } + const secondaryId = smallestId && smallestId !== largestId ? smallestId : null; + lastHoverVgIdSecondaryRef.current = secondaryId; + st.setHoveredVisualGroupIdSecondary(secondaryId); + } catch { + // ignore + } + }} + onPointerLeave={() => { + setIsHovered(false); + try { + const st = useCanvasStore.getState(); + const last = lastHoverVgIdRef.current; + if (last && st.hoveredVisualGroupId === last) { + st.setHoveredVisualGroupId(null); + } + const lastSec = lastHoverVgIdSecondaryRef.current; + if (lastSec && st.hoveredVisualGroupIdSecondary === lastSec) { + st.setHoveredVisualGroupIdSecondary(null); + } else if (!lastSec && st.hoveredVisualGroupIdSecondary != null) { + // If we previously set null secondary for this node, also clear any residual + st.setHoveredVisualGroupIdSecondary(null); + } + lastHoverVgIdRef.current = null; + lastHoverVgIdSecondaryRef.current = null; + } catch { + // ignore + } + }} data-rc-nodeid={node.id} > {hasCustomChildren ? children : contentLabel} diff --git a/src/react/Rulers.tsx b/src/react/Rulers.tsx new file mode 100644 index 0000000..be49227 --- /dev/null +++ b/src/react/Rulers.tsx @@ -0,0 +1,448 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCamera, useGuides, useRulersActions, useActiveGuideId } from '../state/store'; +import type { GuideId } from '../state/store'; + +function useElementSize() { + const ref = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + useEffect(() => { + const el = ref.current; + if (!el) return; + // Feature-detect ResizeObserver (undefined in jsdom/SSR). If absent, set size once. + const RO: typeof ResizeObserver | undefined = + typeof ResizeObserver === 'function' ? ResizeObserver : undefined; + if (!RO) { + setSize({ width: el.clientWidth, height: el.clientHeight }); + return; + } + const ro = new RO(() => { + setSize({ width: el.clientWidth, height: el.clientHeight }); + }); + setSize({ width: el.clientWidth, height: el.clientHeight }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + return { ref, ...size } as const; +} + +function chooseStep(zoom: number) { + // Aim for 60..140 px between major ticks + const targetMin = 60; + const targetMax = 140; + const bases = [1, 2, 5]; + // Iterate finite scales to satisfy lint (no while(true)) + for (let unit = 1; unit <= 1e6; unit *= 10) { + for (const b of bases) { + const step = unit * b; // world units + const px = step * zoom; + if (px >= targetMin && px <= targetMax) return step; + } + } + return 1e6; +} + +export function Rulers() { + const camera = useCamera(); + const guides = useGuides(); + const activeGuideId = useActiveGuideId(); + const { addGuide, moveGuideTemporary, moveGuideCommit, removeGuide, setActiveGuide } = + useRulersActions(); + + // Track hovered guide for visual feedback + const [hoveredGuideId, setHoveredGuideId] = useState(null); + + const { ref, width, height } = useElementSize(); + + // Keyboard shortcuts for active guide + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (activeGuideId && (e.key === 'Delete' || e.key === 'Backspace')) { + e.preventDefault(); + removeGuide(activeGuideId); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [activeGuideId, removeGuide]); + + const { stepWorld, stepPx, firstX, firstY } = useMemo(() => { + const z = camera.zoom || 1; + const step = chooseStep(z); + const stepPx = step * z; + // world coord at left/top screen edge + const worldAtLeft = camera.offsetX; + const worldAtTop = camera.offsetY; + // first tick <= edge + const firstX = Math.floor(worldAtLeft / step) * step; + const firstY = Math.floor(worldAtTop / step) * step; + return { stepWorld: step, stepPx, firstX, firstY }; + }, [camera.offsetX, camera.offsetY, camera.zoom]); + + // Drag state for creating or moving guides + const dragRef = useRef<{ + mode: 'create' | 'move'; + axis: 'x' | 'y'; + id: GuideId | null; + startValue?: number; // Store initial position for history + } | null>(null); + + const onTopDown: React.PointerEventHandler = (e) => { + // Start creating a horizontal guide (axis 'y') + const el = e.currentTarget as HTMLElement; + el.setPointerCapture?.(e.pointerId); + dragRef.current = { mode: 'create', axis: 'y', id: null }; + e.preventDefault(); + e.stopPropagation(); + }; + + const onLeftDown: React.PointerEventHandler = (e) => { + // Start creating a vertical guide (axis 'x') + const el = e.currentTarget as HTMLElement; + el.setPointerCapture?.(e.pointerId); + dragRef.current = { mode: 'create', axis: 'x', id: null }; + e.preventDefault(); + e.stopPropagation(); + }; + + const rootOnPointerMove: React.PointerEventHandler = (e) => { + const drag = dragRef.current; + if (!drag) return; + const z = camera.zoom || 1; + const host = ref.current; + if (!host) return; + const rect = host.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + if (drag.mode === 'create') { + // Lazily allocate guide on first move; then keep moving + if (!drag.id) { + if (drag.axis === 'x') { + const worldX = x / z + camera.offsetX; + const id = addGuide('x', worldX); + dragRef.current = { ...drag, id }; + setActiveGuide(id); + } else { + const worldY = y / z + camera.offsetY; + const id = addGuide('y', worldY); + dragRef.current = { ...drag, id }; + setActiveGuide(id); + } + } else { + if (drag.axis === 'x') { + const worldX = x / z + camera.offsetX; + moveGuideTemporary(drag.id, worldX); + } else { + const worldY = y / z + camera.offsetY; + moveGuideTemporary(drag.id, worldY); + } + } + } else if (drag.mode === 'move' && drag.id) { + if (drag.axis === 'x') { + const worldX = x / z + camera.offsetX; + moveGuideTemporary(drag.id, worldX); + } else { + const worldY = y / z + camera.offsetY; + moveGuideTemporary(drag.id, worldY); + } + } + e.preventDefault(); + e.stopPropagation(); + }; + + const rootOnPointerUp: React.PointerEventHandler = () => { + const drag = dragRef.current; + if (drag?.mode === 'move' && drag.id && drag.startValue !== undefined) { + // Get current position and save to history if it changed + const currentGuide = guides.find((g) => g.id === drag.id); + if (currentGuide && currentGuide.value !== drag.startValue) { + moveGuideCommit(drag.id, drag.startValue, currentGuide.value); + } + } + dragRef.current = null; + }; + + const onGuideDown = useCallback( + (id: GuideId, axis: 'x' | 'y'): React.PointerEventHandler => { + return (e) => { + setActiveGuide(id); + const guide = guides.find((g) => g.id === id); + const el = e.currentTarget as HTMLElement; + el.setPointerCapture?.(e.pointerId); + dragRef.current = { mode: 'move', axis, id, startValue: guide?.value }; + e.preventDefault(); + e.stopPropagation(); + }; + }, + [setActiveGuide, guides], + ); + + const rulerBg = '#f0f3f7'; + const tickColor = '#a3a9b3'; + const labelColor = '#616975'; + const lineColor = '#1e90ff'; + + // Render ticks and labels for top ruler + const topTicks = useMemo(() => { + const z = camera.zoom || 1; + const items: React.ReactNode[] = []; + if (width <= 0) return items; + const count = Math.ceil(width / stepPx) + 2; + for (let i = 0; i < count; i++) { + const worldX = firstX + i * stepWorld; + const x = (worldX - camera.offsetX) * z; + items.push( +
+
+
+ {Math.round(worldX)} +
+
, + ); + } + return items; + }, [width, stepPx, stepWorld, firstX, camera.offsetX, camera.zoom]); + + // Render ticks and labels for left ruler + const leftTicks = useMemo(() => { + const z = camera.zoom || 1; + const items: React.ReactNode[] = []; + if (height <= 0) return items; + const count = Math.ceil(height / stepPx) + 2; + for (let i = 0; i < count; i++) { + const worldY = firstY + i * stepWorld; + const y = (worldY - camera.offsetY) * z; + items.push( +
+
+
+ {Math.round(worldY)} +
+
, + ); + } + return items; + }, [height, stepPx, stepWorld, firstY, camera.offsetY, camera.zoom]); + + // Render guide lines + const guideLines = useMemo(() => { + const z = camera.zoom || 1; + const lines: React.ReactNode[] = []; + for (const g of guides) { + const isActive = g.id === activeGuideId; + const isHovered = g.id === hoveredGuideId; + + // Enhanced visual states + const getLineColor = () => { + if (isActive) return lineColor; + if (isHovered) return '#60a5fa'; // lighter blue on hover + return '#3b82f6'; // default blue + }; + + const getLineWidth = () => { + if (isActive) return 2; + if (isHovered) return 2; + return 1; + }; + + const getHitAreaSize = () => 12; // consistent hit area to prevent hover jitter + + if (g.axis === 'x') { + const x = (g.value - camera.offsetX) * z; + if (x < -4 || x > width + 4) continue; + const hitWidth = getHitAreaSize(); + lines.push( +
setHoveredGuideId(g.id)} + onPointerOver={() => setHoveredGuideId(g.id)} + onPointerLeave={() => setHoveredGuideId(null)} + data-rc-guide="" + data-rc-guide-axis="x" + data-rc-guide-id={g.id} + > +
+
, + ); + } else { + const y = (g.value - camera.offsetY) * z; + if (y < -4 || y > height + 4) continue; + const hitHeight = getHitAreaSize(); + lines.push( +
setHoveredGuideId(g.id)} + onPointerOver={() => setHoveredGuideId(g.id)} + onPointerLeave={() => setHoveredGuideId(null)} + data-rc-guide="" + data-rc-guide-axis="y" + data-rc-guide-id={g.id} + > +
+
, + ); + } + } + return lines; + }, [ + guides, + activeGuideId, + hoveredGuideId, + camera.offsetX, + camera.offsetY, + camera.zoom, + width, + height, + onGuideDown, + setHoveredGuideId, + ]); + + return ( +
+ {/* Corner square */} +
+ {/* Top ruler */} +
+
{topTicks}
+
+ {/* Left ruler */} +
+
{leftTicks}
+
+ + {/* Guide lines overlay (inside content area) */} +
+ {guideLines} +
+
+ ); +} diff --git a/src/react/useCanvasNavigation.ts b/src/react/useCanvasNavigation.ts index 93c4d2a..85f635a 100644 --- a/src/react/useCanvasNavigation.ts +++ b/src/react/useCanvasNavigation.ts @@ -1,9 +1,10 @@ import { useEffect } from 'react'; import type { RefObject } from 'react'; import { useCanvasStore } from '../state/store'; +import { screenToWorld } from '../core/coords'; export type CanvasNavigationOptions = { - panButton?: 0 | 1 | 2; // left | middle | right + panButton?: 1 | 2; // middle | right (left is reserved for selection) panModifier?: 'none' | 'shift' | 'alt' | 'ctrl'; wheelZoom?: boolean; wheelModifier?: 'none' | 'shift' | 'alt' | 'ctrl'; // убрать отсюда shift @@ -30,7 +31,7 @@ export type CanvasNavigationOptions = { }; const defaultOptions: Required = { - panButton: 0, + panButton: 1, panModifier: 'none', wheelZoom: true, wheelModifier: 'none', @@ -67,7 +68,7 @@ function hasReleasePointerCapture( * Attach interactive navigation (pan/zoom) to a Canvas root element. * Usage: * const ref = useRef(null); - * useCanvasNavigation(ref, { panButton: 0 }); + * useCanvasNavigation(ref, { panButton: 1 }); // pan with middle button (or 2 for right) */ export function useCanvasNavigation( ref: RefObject, @@ -84,6 +85,10 @@ export function useCanvasNavigation( let lastX = 0; let lastY = 0; let prevCursor: string | null = null; + // Track last known pointer position (client coordinates) over the canvas + let hasLastPointer = false; + let lastClientX = 0; + let lastClientY = 0; // ensure pointer events behave for pan/zoom const prevTouchAction = el.style.touchAction; @@ -125,6 +130,8 @@ export function useCanvasNavigation( } function onPointerDown(e: PointerEvent) { + // Do not allow panning with the left button even if misconfigured + if (e.button === 0) return; if (e.button !== opts.panButton) return; if (!modifierPressed(e)) return; // Do not start panning when interacting with a Node element @@ -160,6 +167,10 @@ export function useCanvasNavigation( } function onPointerMove(e: PointerEvent) { + // Always update last hover position + lastClientX = e.clientX; + lastClientY = e.clientY; + hasLastPointer = true; if (!isPanning) return; const dx = e.clientX - lastX; const dy = e.clientY - lastY; @@ -192,12 +203,38 @@ export function useCanvasNavigation( } } + function onPointerCancel(e: PointerEvent) { + if (!isPanning) return; + isPanning = false; + try { + const { endHistory } = useCanvasStore.getState(); + endHistory(); + } catch { + // ignore + } + if (prevCursor !== null) { + rootEl.style.cursor = prevCursor; + } + const target = e.target as Element | null; + if (target && hasReleasePointerCapture(target)) { + try { + target.releasePointerCapture((e as PointerEvent).pointerId); + } catch { + // ignore + } + } + } + function onWheel(e: WheelEvent) { const currentEl = ref.current; if (!currentEl) return; const rect = currentEl.getBoundingClientRect(); const screenPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + // Update last pointer position from wheel events as well + lastClientX = e.clientX; + lastClientY = e.clientY; + hasLastPointer = true; // Heuristic detection const isPinchZoom = e.ctrlKey === true; // treat Ctrl+wheel as pinch/zoom for both mouse and touchpad @@ -285,11 +322,17 @@ export function useCanvasNavigation( if (!opts.doubleClickZoom) return; // Only respond to left-button double click if (e.button !== 0) return; + // Ignore double-clicks that originate on a node (NodeView handles inner-edit) + if (isFromNode(e.target)) 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 }; + // Update last pointer position from dblclick + lastClientX = e.clientX; + lastClientY = e.clientY; + hasLastPointer = true; // If zoom-out is enabled and its modifier is held, perform zoom-out let factor = opts.doubleClickZoomFactor; const outMod = opts.doubleClickZoomOutModifier; @@ -317,6 +360,7 @@ export function useCanvasNavigation( el.addEventListener('pointerdown', onPointerDown); window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointercancel', onPointerCancel); el.addEventListener('wheel', onWheel, { passive: false }); el.addEventListener('dblclick', onDblClick); // keyboard navigation: arrows/WASD to pan (if enabled), +/- to zoom at center @@ -332,6 +376,16 @@ export function useCanvasNavigation( // ignore if typing in inputs/contenteditable if (isTextInput(e.target as Element)) return; + // Escape exits inner-edit mode if active + if (e.code === 'Escape') { + const { innerEditNodeId, exitInnerEdit } = useCanvasStore.getState(); + if (innerEditNodeId) { + e.preventDefault(); + exitInnerEdit(); + return; + } + } + const { camera, panBy, zoomByAt } = useCanvasStore.getState(); const invZoom = 1 / camera.zoom; @@ -342,25 +396,21 @@ export function useCanvasNavigation( let step = opts.keyboardPanStep; if (slow) step = opts.keyboardPanSlowStep; - switch (e.key) { + switch (e.code) { case 'ArrowLeft': - case 'a': - case 'A': + case 'KeyA': dxScreen = step; break; case 'ArrowRight': - case 'd': - case 'D': + case 'KeyD': dxScreen = -step; break; case 'ArrowUp': - case 'w': - case 'W': + case 'KeyW': dyScreen = step; break; case 'ArrowDown': - case 's': - case 'S': + case 'KeyS': dyScreen = -step; break; } @@ -372,6 +422,69 @@ export function useCanvasNavigation( } } + // Clipboard and grouping: Ctrl/Cmd + [G/C/X/V] + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) { + const code = e.code; + if (code === 'KeyG') { + const { selected, createVisualGroupFromSelection } = useCanvasStore.getState(); + if (Object.keys(selected).length >= 2) { + e.preventDefault(); + createVisualGroupFromSelection(); + return; + } + } + if (code === 'KeyC') { + const { selected, copySelection } = useCanvasStore.getState(); + if (Object.keys(selected).length > 0) { + e.preventDefault(); + copySelection(); + return; + } + } else if (code === 'KeyX') { + const { selected, cutSelection } = useCanvasStore.getState(); + if (Object.keys(selected).length > 0) { + e.preventDefault(); + cutSelection(); + return; + } + } else if (code === 'KeyV') { + e.preventDefault(); + const { camera, pasteClipboard } = useCanvasStore.getState(); + const currentEl = ref.current; + if (currentEl && hasLastPointer) { + const rect = currentEl.getBoundingClientRect(); + const screenPoint = { x: lastClientX - rect.left, y: lastClientY - rect.top }; + const worldPoint = screenToWorld(screenPoint, camera); + pasteClipboard(worldPoint); + } else { + pasteClipboard(); + } + return; + } + } + + // Toggle rulers: Ctrl/Cmd + H + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) { + const code = e.code; + if (code === 'KeyH') { + e.preventDefault(); + const { toggleRulers } = useCanvasStore.getState(); + toggleRulers(); + return; + } + } + + // deletion of active guide via Delete / Backspace + if (e.key === 'Delete' || e.key === 'Backspace') { + const { activeGuideId, removeGuide, setActiveGuide } = useCanvasStore.getState(); + if (activeGuideId) { + e.preventDefault(); + removeGuide(activeGuideId); + setActiveGuide(null); + return; + } + } + // deletion: Delete / Backspace removes selected nodes if (e.key === 'Delete' || e.key === 'Backspace') { const { selected, deleteSelected } = useCanvasStore.getState(); @@ -404,6 +517,7 @@ export function useCanvasNavigation( rootEl.removeEventListener('pointerdown', onPointerDown); window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerCancel); rootEl.removeEventListener('wheel', onWheel); rootEl.removeEventListener('dblclick', onDblClick); rootEl.removeEventListener('keydown', onKeyDown); diff --git a/src/state/store.clipboard.test.ts b/src/state/store.clipboard.test.ts new file mode 100644 index 0000000..cef081b --- /dev/null +++ b/src/state/store.clipboard.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCanvasStore } from './store'; +import type { CanvasStore } from './store'; +import type { Node } from '../types'; + +function resetStore() { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + centerAddIndex: 0, + clipboard: null, + pasteIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + } as Partial); +} + +describe('Clipboard actions: copy, cut, paste', () => { + beforeEach(() => resetStore()); + + it('copySelection copies selected and descendants into clipboard', () => { + const p: Node = { id: 'p', x: 0, y: 0, width: 100, height: 60 }; + const c1: Node = { id: 'c1', x: 10, y: 5, width: 40, height: 20, parentId: 'p' }; + const c2: Node = { id: 'c2', x: 20, y: 15, width: 40, height: 20, parentId: 'c1' }; + useCanvasStore.getState().addNode(p); + useCanvasStore.getState().addNode(c1); + useCanvasStore.getState().addNode(c2); + + useCanvasStore.getState().selectOnly('p'); + useCanvasStore.getState().copySelection(); + + const s = useCanvasStore.getState(); + expect(s.clipboard).toBeTruthy(); + const ids = new Set((s.clipboard?.nodes ?? []).map((n) => n.id)); + expect(ids.has('p')).toBe(true); + expect(ids.has('c1')).toBe(true); + expect(ids.has('c2')).toBe(true); + }); + + it('cutSelection copies then deletes selected nodes', () => { + const a: Node = { id: 'a', x: 0, y: 0, width: 10, height: 10 }; + useCanvasStore.getState().addNode(a); + useCanvasStore.getState().selectOnly('a'); + + useCanvasStore.getState().cutSelection(); + + const s = useCanvasStore.getState(); + expect(s.nodes['a']).toBeUndefined(); + expect(Object.keys(s.selected)).toHaveLength(0); + expect(s.clipboard?.nodes.map((n) => n.id)).toEqual(['a']); + }); + + it('pasteClipboard remaps IDs, preserves parent links, offsets positions, selects pasted, and creates one history entry', () => { + // Arrange a parent+child chain, select parent only + const p: Node = { id: 'p', x: 0, y: 0, width: 100, height: 60 }; + const c1: Node = { id: 'c1', x: 10, y: 5, width: 40, height: 20, parentId: 'p' }; + useCanvasStore.getState().addNode(p); + useCanvasStore.getState().addNode(c1); + // Copy selection closure + useCanvasStore.getState().selectOnly('p'); + useCanvasStore.getState().copySelection(); + + // Paste once + const sBefore = useCanvasStore.getState(); + expect(sBefore.pasteIndex).toBe(0); + sBefore.pasteClipboard(); + + let s = useCanvasStore.getState(); + expect(s.pasteIndex).toBe(1); + // First paste uses k = 1 => offset (16,16) + const pCopy = s.nodes['p-copy']; + const c1Copy = s.nodes['c1-copy']; + expect(pCopy).toBeDefined(); + expect(c1Copy).toBeDefined(); + expect(c1Copy?.parentId).toBe('p-copy'); + expect(pCopy).toMatchObject({ x: p.x + 16, y: p.y + 16, width: 100, height: 60 }); + expect(c1Copy).toMatchObject({ x: c1.x + 16, y: c1.y + 16, width: 40, height: 20 }); + // Selection should be exactly pasted IDs + expect(s.selected).toEqual({ 'p-copy': true, 'c1-copy': true }); + + // History: adds are batched into one entry + expect(s.historyPast.length).toBe(3); // add p, add c1, paste batch + const last = s.historyPast[s.historyPast.length - 1]; + expect(last.changes.every((c) => c.kind === 'add')).toBe(true); + + // Undo -> pasted nodes removed + s.undo(); + s = useCanvasStore.getState(); + expect(s.nodes['p-copy']).toBeUndefined(); + expect(s.nodes['c1-copy']).toBeUndefined(); + + // Redo -> re-added with same ids + s.redo(); + s = useCanvasStore.getState(); + expect(s.nodes['p-copy']).toBeDefined(); + expect(s.nodes['c1-copy']).toBeDefined(); + + // Paste again -> ids increment suffix, larger offset (k=2 => 32) + s.pasteClipboard(); + s = useCanvasStore.getState(); + expect(s.nodes['p-copy2']).toBeDefined(); + expect(s.nodes['c1-copy2']).toBeDefined(); + expect(s.nodes['c1-copy2']?.parentId).toBe('p-copy2'); + expect(s.nodes['p-copy2']).toMatchObject({ x: p.x + 32, y: p.y + 32 }); + }); +}); diff --git a/src/state/store.grouping.test.ts b/src/state/store.grouping.test.ts new file mode 100644 index 0000000..f9f6526 --- /dev/null +++ b/src/state/store.grouping.test.ts @@ -0,0 +1,182 @@ +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('Grouping: data and movement semantics', () => { + beforeEach(() => resetStore()); + + it('groupNodes(parent, children) assigns parentId and ignores invalid/self ids', () => { + const add = (n: Node) => useCanvasStore.getState().addNode(n); + add({ id: 'p', x: 0, y: 0, width: 10, height: 10 }); + add({ id: 'c1', x: 5, y: 5, width: 10, height: 10 }); + + // include non-existing id and self id + useCanvasStore.getState().groupNodes('p', ['c1', 'nope', 'p']); + + const s = useCanvasStore.getState(); + expect(s.nodes['c1']).toBeDefined(); + expect(s.nodes['c1']?.parentId).toBe('p'); + // parent must not change + expect(s.nodes['p']?.parentId).toBeUndefined(); + }); + + it('prevents cycles: cannot set a descendant as parent of its ancestor', () => { + 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 }); + + useCanvasStore.getState().groupNodes('a', ['b']); // a -> b + useCanvasStore.getState().groupNodes('b', ['c']); // b -> c + + // attempt to make c parent of a -> would create cycle a->b->c->a, must be ignored + useCanvasStore.getState().groupNodes('c', ['a']); + + const s = useCanvasStore.getState(); + expect(s.nodes['a']?.parentId).toBeUndefined(); + expect(s.nodes['b']?.parentId).toBe('a'); + expect(s.nodes['c']?.parentId).toBe('b'); + }); + + it('moveSelectedBy moves selected parents along with all descendants exactly once', () => { + const add = (n: Node) => useCanvasStore.getState().addNode(n); + add({ id: 'p', x: 0, y: 0, width: 10, height: 10 }); + add({ id: 'c1', x: 5, y: 5, width: 10, height: 10 }); + add({ id: 'c2', x: 7, y: -1, width: 10, height: 10 }); + add({ id: 'g1', x: 8, y: 8, width: 10, height: 10 }); + + useCanvasStore.getState().groupNodes('p', ['c1', 'c2']); + useCanvasStore.getState().groupNodes('c1', ['g1']); // grandchild + + // select both parent and one child; descendants should still move once + useCanvasStore.getState().selectOnly('p'); + useCanvasStore.getState().addToSelection('c1'); + + useCanvasStore.getState().moveSelectedBy(3, 4); + + const s = useCanvasStore.getState(); + expect(s.nodes['p']).toMatchObject({ x: 3, y: 4 }); + expect(s.nodes['c1']).toMatchObject({ x: 8, y: 9 }); + expect(s.nodes['c2']).toMatchObject({ x: 10, y: 3 }); + expect(s.nodes['g1']).toMatchObject({ x: 11, y: 12 }); + }); + + it('ungroup(ids) clears parentId only for provided ids', () => { + const add = (n: Node) => useCanvasStore.getState().addNode(n); + add({ id: 'p', x: 0, y: 0, width: 10, height: 10 }); + add({ id: 'c1', x: 5, y: 5, width: 10, height: 10 }); + add({ id: 'c2', x: 7, y: 7, width: 10, height: 10 }); + + useCanvasStore.getState().groupNodes('p', ['c1', 'c2']); + useCanvasStore.getState().ungroup(['c1']); + + const s = useCanvasStore.getState(); + expect(s.nodes['c1']?.parentId).toBeNull(); + expect(s.nodes['c2']?.parentId).toBe('p'); + }); + + it('removeNode(parent) clears parentId for its immediate children', () => { + const add = (n: Node) => useCanvasStore.getState().addNode(n); + add({ id: 'p', x: 0, y: 0, width: 10, height: 10 }); + add({ id: 'c1', x: 5, y: 5, width: 10, height: 10 }); + + useCanvasStore.getState().groupNodes('p', ['c1']); + useCanvasStore.getState().removeNode('p'); + + const s = useCanvasStore.getState(); + expect(s.nodes['p']).toBeUndefined(); + expect(s.nodes['c1']).toBeDefined(); + expect(s.nodes['c1']?.parentId).toBeNull(); + }); +}); + +describe('Grouping: deleteSelected semantics', () => { + beforeEach(() => resetStore()); + + it('deleting only children leaves root intact', () => { + const add = (n: Node) => useCanvasStore.getState().addNode(n); + add({ id: 'root', x: 0, y: 0, width: 10, height: 10 }); + add({ id: 'c1', x: 10, y: 0, width: 10, height: 10 }); + add({ id: 'c2', x: 20, y: 0, width: 10, height: 10 }); + + useCanvasStore.getState().groupNodes('root', ['c1', 'c2']); + + // select only children + useCanvasStore.getState().selectOnly('c1'); + useCanvasStore.getState().addToSelection('c2'); + useCanvasStore.getState().deleteSelected(); + + const s = useCanvasStore.getState(); + expect(s.nodes['root']).toBeDefined(); + expect(s.nodes['c1']).toBeUndefined(); + expect(s.nodes['c2']).toBeUndefined(); + }); + + it('deleting root + children removes them all', () => { + const add = (n: Node) => useCanvasStore.getState().addNode(n); + add({ id: 'root', x: 0, y: 0, width: 10, height: 10 }); + add({ id: 'c1', x: 10, y: 0, width: 10, height: 10 }); + add({ id: 'c2', x: 20, y: 0, width: 10, height: 10 }); + add({ id: 'g1', x: 30, y: 0, width: 10, height: 10 }); + + useCanvasStore.getState().groupNodes('root', ['c1', 'c2']); + useCanvasStore.getState().groupNodes('c1', ['g1']); + + // explicitly select root and all descendants + useCanvasStore.getState().selectOnly('root'); + useCanvasStore.getState().addToSelection('c1'); + useCanvasStore.getState().addToSelection('c2'); + useCanvasStore.getState().addToSelection('g1'); + useCanvasStore.getState().deleteSelected(); + + const s = useCanvasStore.getState(); + expect(s.nodes['root']).toBeUndefined(); + expect(s.nodes['c1']).toBeUndefined(); + expect(s.nodes['c2']).toBeUndefined(); + expect(s.nodes['g1']).toBeUndefined(); + }); +}); + +describe('Grouping: history integration (undo/redo)', () => { + beforeEach(() => resetStore()); + + it('records group/ungroup as updates and supports undo/redo', () => { + useCanvasStore.getState().addNode({ id: 'p', x: 0, y: 0, width: 10, height: 10 }); + useCanvasStore.getState().addNode({ id: 'c', x: 1, y: 1, width: 10, height: 10 }); + + // group -> expect one history entry with update + useCanvasStore.getState().groupNodes('p', ['c']); + let s = useCanvasStore.getState(); + expect(s.historyPast.length).toBe(3); // add p, add c, group + expect(s.nodes['c']?.parentId).toBe('p'); + + // undo -> parentId cleared + s.undo(); + s = useCanvasStore.getState(); + expect(s.nodes['c']?.parentId ?? null).toBeNull(); + + // redo -> parentId restored + s.redo(); + s = useCanvasStore.getState(); + expect(s.nodes['c']?.parentId).toBe('p'); + + // ungroup -> another history entry + useCanvasStore.getState().ungroup(['c']); + s = useCanvasStore.getState(); + expect(s.historyPast.length).toBe(4); // + ungroup + expect(s.nodes['c']?.parentId).toBeNull(); + }); +}); diff --git a/src/state/store.guides.history.test.ts b/src/state/store.guides.history.test.ts new file mode 100644 index 0000000..33b2783 --- /dev/null +++ b/src/state/store.guides.history.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCanvasStore } from './store'; + +describe('Guide lines history integration', () => { + beforeEach(() => { + // Reset store state + useCanvasStore.setState({ + guides: [], + activeGuideId: null, + historyPast: [], + historyFuture: [], + }); + }); + + it('should save guide addition to history and support undo/redo', () => { + // Add a guide + const guideId = useCanvasStore.getState().addGuide('x', 100); + + // Check guide was added + let state = useCanvasStore.getState(); + expect(state.guides).toHaveLength(1); + expect(state.guides[0]).toEqual({ id: guideId, axis: 'x', value: 100 }); + + // Check history was recorded + expect(state.historyPast).toHaveLength(1); + expect(state.historyPast[0].guideChanges).toHaveLength(1); + expect(state.historyPast[0].guideChanges![0].kind).toBe('add'); + + // Undo + useCanvasStore.getState().undo(); + + // Check guide was removed + state = useCanvasStore.getState(); + expect(state.guides).toHaveLength(0); + expect(state.historyPast).toHaveLength(0); + expect(state.historyFuture).toHaveLength(1); + + // Redo + useCanvasStore.getState().redo(); + + // Check guide was restored + state = useCanvasStore.getState(); + expect(state.guides).toHaveLength(1); + expect(state.guides[0]).toEqual({ id: guideId, axis: 'x', value: 100 }); + expect(state.historyPast).toHaveLength(1); + expect(state.historyFuture).toHaveLength(0); + }); + + it('should save guide removal to history and support undo/redo', () => { + // Add a guide first + const guideId = useCanvasStore.getState().addGuide('y', 200); + + // Clear history to focus on removal + useCanvasStore.setState({ historyPast: [], historyFuture: [] }); + + // Remove the guide + useCanvasStore.getState().removeGuide(guideId); + + // Check guide was removed + let state = useCanvasStore.getState(); + expect(state.guides).toHaveLength(0); + expect(state.activeGuideId).toBe(null); + + // Check history was recorded + expect(state.historyPast).toHaveLength(1); + expect(state.historyPast[0].guideChanges).toHaveLength(1); + expect(state.historyPast[0].guideChanges![0].kind).toBe('remove'); + + // Undo removal + useCanvasStore.getState().undo(); + + // Check guide was restored + state = useCanvasStore.getState(); + expect(state.guides).toHaveLength(1); + expect(state.guides[0]).toEqual({ id: guideId, axis: 'y', value: 200 }); + + // Redo removal + useCanvasStore.getState().redo(); + + // Check guide was removed again + state = useCanvasStore.getState(); + expect(state.guides).toHaveLength(0); + }); + + it('should save guide move to history and support undo/redo', () => { + // Add a guide first + const guideId = useCanvasStore.getState().addGuide('x', 100); + + // Clear history to focus on move + useCanvasStore.setState({ historyPast: [], historyFuture: [] }); + + // Move the guide + useCanvasStore.getState().moveGuide(guideId, 150); + + // Check guide was moved + let state = useCanvasStore.getState(); + expect(state.guides[0].value).toBe(150); + + // Check history was recorded (move is recorded as remove + add) + expect(state.historyPast).toHaveLength(1); + expect(state.historyPast[0].guideChanges).toHaveLength(2); + expect(state.historyPast[0].guideChanges![0].kind).toBe('remove'); + expect(state.historyPast[0].guideChanges![1].kind).toBe('add'); + + // Undo move + useCanvasStore.getState().undo(); + + // Check guide was restored to original position + state = useCanvasStore.getState(); + expect(state.guides[0].value).toBe(100); + + // Redo move + useCanvasStore.getState().redo(); + + // Check guide was moved again + state = useCanvasStore.getState(); + expect(state.guides[0].value).toBe(150); + }); + + it('should save clear guides to history and support undo/redo', () => { + // Add multiple guides + const guide1 = useCanvasStore.getState().addGuide('x', 100); + const guide2 = useCanvasStore.getState().addGuide('y', 200); + + // Clear history to focus on clear operation + useCanvasStore.setState({ historyPast: [], historyFuture: [] }); + + // Clear all guides + useCanvasStore.getState().clearGuides(); + + // Check guides were cleared + let state = useCanvasStore.getState(); + expect(state.guides).toHaveLength(0); + expect(state.activeGuideId).toBe(null); + + // Check history was recorded + expect(state.historyPast).toHaveLength(1); + expect(state.historyPast[0].guideChanges).toHaveLength(1); + expect(state.historyPast[0].guideChanges![0].kind).toBe('clear'); + + // Undo clear + useCanvasStore.getState().undo(); + + // Check guides were restored + state = useCanvasStore.getState(); + expect(state.guides).toHaveLength(2); + expect(state.guides.find((g) => g.id === guide1)).toEqual({ + id: guide1, + axis: 'x', + value: 100, + }); + expect(state.guides.find((g) => g.id === guide2)).toEqual({ + id: guide2, + axis: 'y', + value: 200, + }); + + // Redo clear + useCanvasStore.getState().redo(); + + // Check guides were cleared again + state = useCanvasStore.getState(); + expect(state.guides).toHaveLength(0); + }); + + it('should handle active guide state correctly during undo/redo', () => { + // Add a guide and set it as active + const guideId = useCanvasStore.getState().addGuide('x', 100); + useCanvasStore.getState().setActiveGuide(guideId); + + // Clear history to focus on removal + useCanvasStore.setState({ historyPast: [], historyFuture: [] }); + + // Remove the active guide + useCanvasStore.getState().removeGuide(guideId); + + // Check active guide was cleared + let state = useCanvasStore.getState(); + expect(state.activeGuideId).toBe(null); + + // Undo removal + useCanvasStore.getState().undo(); + + // Check guide was restored but active state was not (this is expected behavior) + state = useCanvasStore.getState(); + expect(state.guides).toHaveLength(1); + expect(state.activeGuideId).toBe(null); // Active state is not part of history + }); +}); diff --git a/src/state/store.guides.move-history.test.ts b/src/state/store.guides.move-history.test.ts new file mode 100644 index 0000000..a755c2d --- /dev/null +++ b/src/state/store.guides.move-history.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCanvasStore } from './store'; + +describe('Guide lines move history optimization', () => { + beforeEach(() => { + useCanvasStore.setState({ + guides: [], + activeGuideId: null, + historyPast: [], + historyFuture: [], + }); + }); + + it('moveGuideTemporary should not create history entries', () => { + const { addGuide, moveGuideTemporary } = useCanvasStore.getState(); + + // Add a guide + const id = addGuide('x', 100); + const initialHistoryLength = useCanvasStore.getState().historyPast.length; + + // Move it temporarily multiple times + moveGuideTemporary(id, 150); + moveGuideTemporary(id, 200); + moveGuideTemporary(id, 250); + + // History should not have grown + expect(useCanvasStore.getState().historyPast.length).toBe(initialHistoryLength); + + // But guide position should be updated + const guide = useCanvasStore.getState().guides.find(g => g.id === id); + expect(guide?.value).toBe(250); + }); + + it('moveGuide should create single history entry after temporary moves', () => { + const { addGuide, moveGuideTemporary, moveGuide } = useCanvasStore.getState(); + + // Add a guide + const id = addGuide('x', 100); + const initialHistoryLength = useCanvasStore.getState().historyPast.length; + + // Move it temporarily (no history) + moveGuideTemporary(id, 150); + moveGuideTemporary(id, 200); + + // Now commit the move to history + moveGuide(id, 200); + + // Should have exactly one more history entry + expect(useCanvasStore.getState().historyPast.length).toBe(initialHistoryLength + 1); + + // Final position should be correct + const guide = useCanvasStore.getState().guides.find(g => g.id === id); + expect(guide?.value).toBe(200); + }); + + it('undo after move should revert to original position, not intermediate steps', () => { + const { addGuide, moveGuideTemporary, moveGuideCommit, undo } = useCanvasStore.getState(); + + // Add a guide at position 100 + const id = addGuide('x', 100); + + // Simulate drag: temporary moves + final commit + moveGuideTemporary(id, 120); + moveGuideTemporary(id, 140); + moveGuideTemporary(id, 160); + moveGuideCommit(id, 100, 160); // Commit final position with original and final values + + // Verify final position + let guide = useCanvasStore.getState().guides.find(g => g.id === id); + expect(guide?.value).toBe(160); + + // Undo should revert to original position (100), not any intermediate step + undo(); + + guide = useCanvasStore.getState().guides.find(g => g.id === id); + expect(guide?.value).toBe(100); + }); + + it('multiple separate moves should create separate history entries', () => { + const { addGuide, moveGuide } = useCanvasStore.getState(); + + // Add a guide + const id = addGuide('x', 100); + const initialHistoryLength = useCanvasStore.getState().historyPast.length; + + // Make two separate committed moves + moveGuide(id, 150); + moveGuide(id, 200); + + // Should have two more history entries + expect(useCanvasStore.getState().historyPast.length).toBe(initialHistoryLength + 2); + }); +}); diff --git a/src/state/store.pasteAtPosition.test.ts b/src/state/store.pasteAtPosition.test.ts new file mode 100644 index 0000000..a6be98e --- /dev/null +++ b/src/state/store.pasteAtPosition.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCanvasStore } from './store'; +import type { CanvasStore } from './store'; +import type { Node } from '../types'; + +function resetStore() { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + centerAddIndex: 0, + clipboard: null, + pasteIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + } as Partial); +} + +describe('Clipboard paste at position', () => { + beforeEach(() => resetStore()); + + it('pastes clipboard aligning bbox top-left to given world position', () => { + const a: Node = { id: 'a', x: 20, y: 30, width: 50, height: 40 }; + const b: Node = { id: 'b', x: 60, y: 80, width: 30, height: 20, parentId: 'a' }; + useCanvasStore.getState().addNode(a); + useCanvasStore.getState().addNode(b); + + useCanvasStore.getState().selectOnly('a'); + useCanvasStore.getState().copySelection(); + + // Paste at world point (200, 100) -> dx = 200 - 20, dy = 100 - 30 + useCanvasStore.getState().pasteClipboard({ x: 200, y: 100 }); + + const s = useCanvasStore.getState(); + const aCopy = s.nodes['a-copy']; + const bCopy = s.nodes['b-copy']; + expect(aCopy).toBeDefined(); + expect(bCopy).toBeDefined(); + expect(bCopy?.parentId).toBe('a-copy'); + expect(aCopy).toMatchObject({ x: 200, y: 100, width: 50, height: 40 }); + // Child must be shifted by same delta + const dx = aCopy!.x - a.x; + const dy = aCopy!.y - a.y; + expect(bCopy).toMatchObject({ x: b.x + dx, y: b.y + dy, width: 30, height: 20 }); + + // Selection equals pasted ids + expect(s.selected).toEqual({ 'a-copy': true, 'b-copy': true }); + + // Undo/redo integrity + s.undo(); + expect(useCanvasStore.getState().nodes['a-copy']).toBeUndefined(); + s.redo(); + expect(useCanvasStore.getState().nodes['a-copy']).toBeDefined(); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts index ebef34a..c43e5c5 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -3,17 +3,28 @@ import type { Camera, Point } from '../core/coords'; import type { Node, NodeId } from '../types'; import { applyPan, clampZoom, zoomAtPoint } from '../core/coords'; -// History types (nodes only; camera and zoom are excluded) +// History types (nodes and guides; camera and zoom are excluded) type NodeChange = | { kind: 'add'; node: Node } | { kind: 'remove'; node: Node } | { kind: 'update'; id: NodeId; before: Node; after: Node }; +type GuideChange = + | { kind: 'add'; guide: Guide } + | { kind: 'remove'; guide: Guide } + | { kind: 'clear'; guides: Guide[] } + | { kind: 'setActive'; before: GuideId | null; after: GuideId | null }; + type HistoryEntry = { label?: string; changes: NodeChange[]; + guideChanges?: GuideChange[]; }; +// Rulers/Guides UI types (not part of history) +export type GuideId = string; +export type Guide = { id: GuideId; axis: 'x' | 'y'; value: number }; + export const MIN_ZOOM = 0.6; export const MAX_ZOOM = 2.4; @@ -22,8 +33,24 @@ export type CanvasState = { readonly nodes: Record; /** Map of selected node IDs for O(1) membership checks. */ readonly selected: Record; + /** UI: box-select (lasso) is active */ + readonly boxSelecting: boolean; + /** UI: purely visual groups, do NOT affect nodes hierarchy */ + readonly visualGroups: Record; + /** UI: currently selected visual group id (single-select for now) */ + readonly selectedVisualGroupId: string | null; + /** UI: currently hovered visual group id (drives container highlight from node hover) */ + readonly hoveredVisualGroupId: string | null; + /** UI: secondary hovered visual group id for dual highlight (e.g., local group alongside parent) */ + readonly hoveredVisualGroupIdSecondary: string | null; + /** UI: inner-edit mode target node; when set, drags affect only this node (and its descendants). */ + readonly innerEditNodeId: NodeId | null; /** Internal counter for add-at-center offset progression. */ readonly centerAddIndex: number; + /** Clipboard buffer storing a snapshot of nodes to paste. */ + readonly clipboard: { nodes: Node[] } | null; + /** Internal counter for paste offset progression. */ + readonly pasteIndex: number; /** History stacks (nodes-only). */ readonly historyPast: HistoryEntry[]; readonly historyFuture: HistoryEntry[]; @@ -33,6 +60,12 @@ export type CanvasState = { changes: NodeChange[]; updateIndexById: Record; } | null; + /** UI: show rulers overlay */ + readonly showRulers: boolean; + /** UI: collection of guide lines (world-locked) */ + readonly guides: Guide[]; + /** UI: currently active guide (for deletion, highlight) */ + readonly activeGuideId: GuideId | null; }; export type CanvasActions = { @@ -41,6 +74,20 @@ export type CanvasActions = { zoomTo: (zoom: number) => void; /** Zoom by factor centered at screenPoint (screen coords in px). */ zoomByAt: (screenPoint: Point, factor: number) => void; + // Inner-edit mode (UI-only) + enterInnerEdit: (id: NodeId) => void; + exitInnerEdit: () => void; + // Lasso state (UI-only) + setBoxSelecting: (active: boolean) => void; + // Rulers/Guides (UI-only, not in history) + toggleRulers: () => void; + addGuide: (axis: 'x' | 'y', value: number) => GuideId; + moveGuideTemporary: (id: GuideId, value: number) => void; + moveGuide: (id: GuideId, value: number) => void; + moveGuideCommit: (id: GuideId, fromValue: number, toValue: number) => void; + removeGuide: (id: GuideId) => void; + clearGuides: () => void; + setActiveGuide: (id: GuideId | null) => void; // Nodes CRUD addNode: (node: Node) => void; /** Add node at the visible center regardless of zoom, with slight diagonal offset per call. */ @@ -49,6 +96,10 @@ export type CanvasActions = { removeNode: (id: NodeId) => void; /** Remove multiple nodes at once. */ removeNodes: (ids: NodeId[]) => void; + /** Group: assign parentId to given children (no cycles). */ + groupNodes: (parentId: NodeId, childIds: NodeId[]) => void; + /** Ungroup: clear parentId for given nodes. */ + ungroup: (ids: NodeId[]) => void; /** Move all currently selected nodes by dx,dy in WORLD units. */ moveSelectedBy: (dx: number, dy: number) => void; // Selection (CORE-05a) @@ -61,6 +112,15 @@ export type CanvasActions = { toggleInSelection: (id: NodeId) => void; /** Delete all currently selected nodes. */ deleteSelected: () => void; + // Visual groups (UI-only) + createVisualGroupFromSelection: () => void; + selectVisualGroup: (id: string | null) => void; + setHoveredVisualGroupId: (id: string | null) => void; + setHoveredVisualGroupIdSecondary: (id: string | null) => void; + // Clipboard + copySelection: () => void; + cutSelection: () => void; + pasteClipboard: (position?: Point) => void; // History (CORE-06) beginHistory: (label?: string) => void; endHistory: () => void; @@ -73,9 +133,21 @@ export type CanvasStore = CanvasState & CanvasActions; const initialCamera: Camera = { zoom: 1, offsetX: 0, offsetY: 0 }; const initialNodes: Record = {}; const initialSelected: Record = {}; +const initialBoxSelecting = false; +const initialVisualGroups: CanvasState['visualGroups'] = {}; +const initialSelectedVisualGroupId: CanvasState['selectedVisualGroupId'] = null; +const initialHoveredVisualGroupId: CanvasState['hoveredVisualGroupId'] = null; +const initialHoveredVisualGroupIdSecondary: CanvasState['hoveredVisualGroupIdSecondary'] = null; +const initialInnerEditNodeId: CanvasState['innerEditNodeId'] = null; const initialCenterAddIndex = 0; +const initialClipboard: CanvasState['clipboard'] = null; +const initialPasteIndex = 0; const initialHistoryPast: HistoryEntry[] = []; const initialHistoryFuture: HistoryEntry[] = []; +const initialShowRulers = true; +const initialGuides: Guide[] = []; +const initialActiveGuideId: GuideId | null = null; +// Guide history is now integrated into main history system // Internal flag: suppress history recording during undo/redo replay let __isReplayingHistory = false; @@ -85,10 +157,21 @@ export const useCanvasStore = create()((set, get) => ({ camera: initialCamera, nodes: initialNodes, selected: initialSelected, + boxSelecting: initialBoxSelecting, + visualGroups: initialVisualGroups, + selectedVisualGroupId: initialSelectedVisualGroupId, + hoveredVisualGroupId: initialHoveredVisualGroupId, + hoveredVisualGroupIdSecondary: initialHoveredVisualGroupIdSecondary, + innerEditNodeId: initialInnerEditNodeId, centerAddIndex: initialCenterAddIndex, + clipboard: initialClipboard, + pasteIndex: initialPasteIndex, historyPast: initialHistoryPast, historyFuture: initialHistoryFuture, historyBatch: null, + showRulers: initialShowRulers, + guides: initialGuides, + activeGuideId: initialActiveGuideId, setCamera: (camera) => set({ camera }), @@ -105,6 +188,139 @@ export const useCanvasStore = create()((set, get) => ({ zoomByAt: (screenPoint, factor) => set((s) => ({ camera: zoomAtPoint(s.camera, screenPoint, factor, MIN_ZOOM, MAX_ZOOM) })), + // --- Inner-edit UI state --- + enterInnerEdit: (id) => set({ innerEditNodeId: id }), + exitInnerEdit: () => set({ innerEditNodeId: null }), + + // --- Lasso UI state --- + setBoxSelecting: (active) => set({ boxSelecting: active }), + + // --- Rulers/Guides UI --- + toggleRulers: () => set((s) => ({ showRulers: !s.showRulers })), + addGuide: (axis, value) => { + const id = `guide-${Date.now()}-${Math.random().toString(36).slice(2)}`; + set((s) => { + const newGuide = { id, axis, value }; + const newGuides = [...s.guides, newGuide]; + if (__isReplayingHistory) { + return { guides: newGuides } as Partial as CanvasStore; + } + // Save to main history system + const entry: HistoryEntry = { + changes: [], + guideChanges: [{ kind: 'add', guide: newGuide }], + }; + return { + guides: newGuides, + historyPast: [...s.historyPast, entry], + historyFuture: [], + } as Partial as CanvasStore; + }); + return id; + }, + moveGuideTemporary: (id: GuideId, value: number) => + set((s) => { + const newGuides = s.guides.map((g) => (g.id === id ? { ...g, value } : g)); + return { guides: newGuides } as Partial as CanvasStore; + }), + moveGuide: (id, value) => + set((s) => { + const oldGuide = s.guides.find((g) => g.id === id); + if (!oldGuide) return {} as Partial as CanvasStore; + const newGuides = s.guides.map((g) => (g.id === id ? { ...g, value } : g)); + if (__isReplayingHistory) { + return { guides: newGuides } as Partial as CanvasStore; + } + // Save to main history system + const entry: HistoryEntry = { + changes: [], + guideChanges: [ + { kind: 'remove', guide: oldGuide }, + { kind: 'add', guide: { ...oldGuide, value } }, + ], + }; + return { + guides: newGuides, + historyPast: [...s.historyPast, entry], + historyFuture: [], + } as Partial as CanvasStore; + }), + moveGuideCommit: (id, fromValue, toValue) => + set((s) => { + const guide = s.guides.find((g) => g.id === id); + if (!guide) return {} as Partial as CanvasStore; + + // Guide should already be at toValue from temporary moves + if (__isReplayingHistory) { + const newGuides = s.guides.map((g) => (g.id === id ? { ...g, value: toValue } : g)); + return { guides: newGuides } as Partial as CanvasStore; + } + + // Only create history entry if position actually changed + if (fromValue === toValue) { + return {} as Partial as CanvasStore; + } + + // Save to main history system with original and final positions + const entry: HistoryEntry = { + changes: [], + guideChanges: [ + { kind: 'remove', guide: { ...guide, value: fromValue } }, + { kind: 'add', guide: { ...guide, value: toValue } }, + ], + }; + return { + historyPast: [...s.historyPast, entry], + historyFuture: [], + } as Partial as CanvasStore; + }), + removeGuide: (id) => + set((s) => { + const removedGuide = s.guides.find((g) => g.id === id); + if (!removedGuide) return {} as Partial as CanvasStore; + const newGuides = s.guides.filter((g) => g.id !== id); + const newActiveGuideId = s.activeGuideId === id ? null : s.activeGuideId; + if (__isReplayingHistory) { + return { + guides: newGuides, + activeGuideId: newActiveGuideId, + } as Partial as CanvasStore; + } + // Save to main history system + const entry: HistoryEntry = { + changes: [], + guideChanges: [{ kind: 'remove', guide: removedGuide }], + }; + return { + guides: newGuides, + activeGuideId: newActiveGuideId, + historyPast: [...s.historyPast, entry], + historyFuture: [], + } as Partial as CanvasStore; + }), + clearGuides: () => + set((s) => { + if (s.guides.length === 0) return {} as Partial as CanvasStore; + if (__isReplayingHistory) { + return { + guides: [], + activeGuideId: null, + } as Partial as CanvasStore; + } + // Save to main history system + const entry: HistoryEntry = { + changes: [], + guideChanges: [{ kind: 'clear', guides: s.guides }], + }; + return { + guides: [], + activeGuideId: null, + historyPast: [...s.historyPast, entry], + historyFuture: [], + } as Partial as CanvasStore; + }), + setActiveGuide: (id) => set({ activeGuideId: id }), + // Nodes CRUD addNode: (node) => set((s) => { @@ -215,6 +431,15 @@ export const useCanvasStore = create()((set, get) => ({ const next = { ...s.nodes } as Record; const removed = next[id]; delete next[id]; + // clear parentId for immediate children of the removed node + const childUpdates: { id: NodeId; before: Node; after: Node }[] = []; + for (const [cid, cn] of Object.entries(next) as [NodeId, Node][]) { + if (cn.parentId === id) { + const updated = { ...cn, parentId: null } as Node; + next[cid] = updated; + childUpdates.push({ id: cid, before: cn, after: updated }); + } + } // also remove from selection if present if (s.selected[id]) { const sel = { ...s.selected }; @@ -222,16 +447,36 @@ export const useCanvasStore = create()((set, get) => ({ if (!__isReplayingHistory) { if (s.historyBatch) { const batch = s.historyBatch; + // merge updates with coalescing + const newChanges = batch.changes.slice(); + const newMap = { ...batch.updateIndexById } as Record; + for (const u of childUpdates) { + 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 }; + } + } + newChanges.push({ kind: 'remove', node: removed }); return { nodes: next, selected: sel, - historyBatch: { - ...batch, - changes: [...batch.changes, { kind: 'remove', node: removed }], - }, + historyBatch: { ...batch, changes: newChanges, updateIndexById: newMap }, } as Partial as CanvasStore; } - const entry: HistoryEntry = { changes: [{ kind: 'remove', node: removed }] }; + const entry: HistoryEntry = { + changes: [ + ...childUpdates.map( + (u) => + ({ kind: 'update', id: u.id, before: u.before, after: u.after }) as NodeChange, + ), + { kind: 'remove', node: removed }, + ], + }; return { nodes: next, selected: sel, @@ -244,15 +489,33 @@ export const useCanvasStore = create()((set, get) => ({ if (!__isReplayingHistory) { if (s.historyBatch) { const batch = s.historyBatch; + const newChanges = batch.changes.slice(); + const newMap = { ...batch.updateIndexById } as Record; + for (const u of childUpdates) { + 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 }; + } + } + newChanges.push({ kind: 'remove', node: removed }); return { nodes: next, - historyBatch: { - ...batch, - changes: [...batch.changes, { kind: 'remove', node: removed }], - }, + historyBatch: { ...batch, changes: newChanges, updateIndexById: newMap }, } as Partial as CanvasStore; } - const entry: HistoryEntry = { changes: [{ kind: 'remove', node: removed }] }; + const entry: HistoryEntry = { + changes: [ + ...childUpdates.map( + (u) => ({ kind: 'update', id: u.id, before: u.before, after: u.after }) as NodeChange, + ), + { kind: 'remove', node: removed }, + ], + }; return { nodes: next, historyPast: [...s.historyPast, entry], @@ -267,10 +530,12 @@ export const useCanvasStore = create()((set, get) => ({ let changed = false; const nextNodes: Record = { ...s.nodes }; const removedList: Node[] = []; + const removedSet = new Set(); for (const id of ids) { if (nextNodes[id]) { removedList.push(nextNodes[id]); delete nextNodes[id]; + removedSet.add(id); changed = true; } } @@ -284,6 +549,15 @@ export const useCanvasStore = create()((set, get) => ({ selChanged = true; } } + // clear parentId for children that pointed to any removed id + const childUpdates: { id: NodeId; before: Node; after: Node }[] = []; + for (const [cid, cn] of Object.entries(nextNodes) as [NodeId, Node][]) { + if (cn.parentId && removedSet.has(cn.parentId)) { + const updated = { ...cn, parentId: null } as Node; + nextNodes[cid] = updated; + childUpdates.push({ id: cid, before: cn, after: updated }); + } + } if (__isReplayingHistory) { return selChanged ? ({ nodes: nextNodes, selected: nextSel } as Partial as CanvasStore) @@ -291,8 +565,21 @@ export const useCanvasStore = create()((set, get) => ({ } if (s.historyBatch) { const batch = s.historyBatch; - const removeChanges = removedList.map((n) => ({ kind: 'remove', node: n }) as NodeChange); - const newBatch = { ...batch, changes: [...batch.changes, ...removeChanges] }; + const newChanges = batch.changes.slice(); + const newMap = { ...batch.updateIndexById } as Record; + for (const u of childUpdates) { + 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 }; + } + } + for (const n of removedList) newChanges.push({ kind: 'remove', node: n }); + const newBatch = { ...batch, changes: newChanges, updateIndexById: newMap }; return selChanged ? ({ nodes: nextNodes, @@ -302,7 +589,12 @@ export const useCanvasStore = create()((set, get) => ({ : ({ nodes: nextNodes, historyBatch: newBatch } as Partial as CanvasStore); } const entry: HistoryEntry = { - changes: removedList.map((n) => ({ kind: 'remove', node: n })), + changes: [ + ...childUpdates.map( + (u) => ({ kind: 'update', id: u.id, before: u.before, after: u.after }) as NodeChange, + ), + ...removedList.map((n) => ({ kind: 'remove', node: n }) as NodeChange), + ], }; return selChanged ? ({ @@ -317,15 +609,170 @@ export const useCanvasStore = create()((set, get) => ({ historyFuture: [], } as Partial as CanvasStore); }), + groupNodes: (parentId, childIds) => + set((s) => { + if (!parentId || !childIds || childIds.length === 0) + return {} as Partial as CanvasStore; + const parent = s.nodes[parentId]; + if (!parent) return {} as Partial as CanvasStore; + const nextNodes: Record = { ...s.nodes }; + const updates: { id: NodeId; before: Node; after: Node }[] = []; + for (const cid of childIds) { + const child = s.nodes[cid]; + if (!child) continue; + if (cid === parentId) continue; // cannot parent self + // cycle check: parent chain of parent must not include child id + let p: NodeId | null | undefined = parentId; + let cycle = false; + while (p != null) { + if (p === cid) { + cycle = true; + break; + } + p = s.nodes[p]?.parentId ?? null; + } + if (cycle) continue; + if (child.parentId === parentId) continue; // already grouped + const updated = { ...child, parentId } as Node; + nextNodes[cid] = updated; + updates.push({ id: cid, before: child, after: updated }); + } + if (updates.length === 0) return {} as Partial as CanvasStore; + if (__isReplayingHistory) return { nodes: nextNodes } as Partial as CanvasStore; + if (s.historyBatch) { + const batch = s.historyBatch; + 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; + }), + ungroup: (ids) => + set((s) => { + if (!ids || ids.length === 0) return {} as Partial as CanvasStore; + const nextNodes: Record = { ...s.nodes }; + const updates: { id: NodeId; before: Node; after: Node }[] = []; + for (const id of ids) { + const n = s.nodes[id]; + if (!n) continue; + if (n.parentId == null) continue; + const updated = { ...n, parentId: null } as Node; + nextNodes[id] = updated; + updates.push({ id, before: n, after: updated }); + } + if (updates.length === 0) return {} as Partial as CanvasStore; + if (__isReplayingHistory) return { nodes: nextNodes } as Partial as CanvasStore; + if (s.historyBatch) { + const batch = s.historyBatch; + 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; + }), moveSelectedBy: (dx, dy) => set((s) => { if (dx === 0 && dy === 0) return {} as Partial as CanvasStore; - const selIds = Object.keys(s.selected) as NodeId[]; + // Start from current selection and optionally scope to inner-edit subtree + let selIds = Object.keys(s.selected) as NodeId[]; + if (s.innerEditNodeId) { + // If a visual group is selected while in inner-edit, scope movement to that group's members + const gid = s.selectedVisualGroupId; + if (gid && s.visualGroups[gid]) { + const members = new Set(s.visualGroups[gid].members as NodeId[]); + selIds = selIds.filter((id) => members.has(id)); + } else { + const scopeRoot = s.innerEditNodeId; + const isWithinScope = (id: NodeId): boolean => { + let cur: NodeId | null | undefined = id; + while (cur != null) { + if (cur === scopeRoot) return true; + cur = s.nodes[cur]?.parentId ?? null; + } + return false; + }; + selIds = selIds.filter(isWithinScope); + } + } if (selIds.length === 0) return {} as Partial as CanvasStore; + // Build children map to collect all descendants + const childrenByParent = new Map(); + for (const [id, n] of Object.entries(s.nodes) as [NodeId, Node][]) { + if (n.parentId) { + const arr = childrenByParent.get(n.parentId) || []; + arr.push(id); + childrenByParent.set(n.parentId, arr); + } + } + const toMove = new Set(selIds); + const queue: NodeId[] = selIds.slice(); + while (queue.length) { + const pid = queue.shift() as NodeId; + const kids = childrenByParent.get(pid); + if (!kids) continue; + for (const cid of kids) { + if (!toMove.has(cid)) { + toMove.add(cid); + queue.push(cid); + } + } + } let changed = false; const nextNodes: Record = { ...s.nodes }; const updates: { id: NodeId; before: Node; after: Node }[] = []; - for (const id of selIds) { + for (const id of toMove) { const n = s.nodes[id]; if (!n) continue; const moved = { ...n, x: n.x + dx, y: n.y + dy } as Node; @@ -404,6 +851,178 @@ export const useCanvasStore = create()((set, get) => ({ return { selected: { ...s.selected, [id]: true } } as Partial as CanvasStore; }), + // Visual groups (UI-only) + createVisualGroupFromSelection: () => + set((s) => { + // Start from explicitly selected node ids + const selectedIds = Object.keys(s.selected) as NodeId[]; + if (!selectedIds || selectedIds.length < 2) return {} as Partial as CanvasStore; + + // If selection touches any existing visual groups, include ALL their members + // so the newly created group visually contains those groups. + const union = new Set(selectedIds); + for (const vg of Object.values(s.visualGroups)) { + // Does this group contain at least one selected node? + let touches = false; + for (const id of selectedIds) { + if (vg.members.includes(id)) { + touches = true; + break; + } + } + if (touches) { + for (const m of vg.members) union.add(m as NodeId); + } + } + + // Create a stable-ish id + const id = `vg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const vg = { id, members: Array.from(union) }; + return { + visualGroups: { ...s.visualGroups, [id]: vg }, + selectedVisualGroupId: id, + } as Partial as CanvasStore; + }), + selectVisualGroup: (id) => set({ selectedVisualGroupId: id }), + setHoveredVisualGroupId: (id) => set({ hoveredVisualGroupId: id }), + setHoveredVisualGroupIdSecondary: (id) => set({ hoveredVisualGroupIdSecondary: id }), + + // Clipboard actions + copySelection: () => + set((s) => { + const selIds = Object.keys(s.selected) as NodeId[]; + if (selIds.length === 0) return { clipboard: null } as Partial as CanvasStore; + + // Build children map from current nodes + const childrenByParent = new Map(); + for (const [id, n] of Object.entries(s.nodes) as [NodeId, Node][]) { + if (n.parentId) { + const arr = childrenByParent.get(n.parentId) || []; + arr.push(id); + childrenByParent.set(n.parentId, arr); + } + } + // Collect closure: selected + all descendants + const toCopy = new Set(selIds); + const queue: NodeId[] = selIds.slice(); + while (queue.length) { + const pid = queue.shift() as NodeId; + const kids = childrenByParent.get(pid); + if (!kids) continue; + for (const cid of kids) { + if (!toCopy.has(cid)) { + toCopy.add(cid); + queue.push(cid); + } + } + } + const nodes: Node[] = []; + for (const id of toCopy) { + const n = s.nodes[id]; + if (n) nodes.push({ ...n }); + } + return { clipboard: { nodes } } as Partial as CanvasStore; + }), + cutSelection: () => { + const { copySelection, deleteSelected } = get(); + copySelection(); + deleteSelected(); + }, + pasteClipboard: (position?: Point) => { + const s = get(); + const clip = s.clipboard; + if (!clip || clip.nodes.length === 0) return; + + // Unique id generator based on existing ids and base id + const existing = new Set(Object.keys(s.nodes)); + const genId = (base: string): string => { + let candidate = `${base}-copy`; + if (!existing.has(candidate)) { + existing.add(candidate); + return candidate; + } + let i = 2; + candidate = `${base}-copy${i}`; + while (existing.has(candidate)) { + i += 1; + candidate = `${base}-copy${i}`; + } + existing.add(candidate); + return candidate; + }; + + // Compute paste delta (world units) + let dx = 0; + let dy = 0; + if (position) { + // Align clipboard bbox top-left to provided world position + let minX = Infinity; + let minY = Infinity; + for (const n of clip.nodes) { + if (n.x < minX) minX = n.x; + if (n.y < minY) minY = n.y; + } + if (Number.isFinite(minX) && Number.isFinite(minY)) { + dx = position.x - minX; + dy = position.y - minY; + } + } else { + // Default diagonal nudge like before + const stepWorld = 16; + const modulo = 12; + const k = (s.pasteIndex % modulo) + 1; // start from 1 so first paste is nudged + dx = k * stepWorld; + dy = k * stepWorld; + } + + // Build id remap and compute depth to add parents before children + const originalById = new Map(); + for (const n of clip.nodes) originalById.set(n.id, n); + const remap = new Map(); + for (const n of clip.nodes) remap.set(n.id, genId(n.id)); + + const depthOf = (id: NodeId): number => { + let d = 0; + let p = originalById.get(id)?.parentId ?? null; + while (p && originalById.has(p)) { + d++; + p = originalById.get(p)?.parentId ?? null; + } + return d; + }; + + const sorted = clip.nodes.slice().sort((a, b) => depthOf(a.id) - depthOf(b.id)); + + // Begin a history batch so paste is a single undoable step + get().beginHistory('paste'); + const newIds: NodeId[] = []; + for (const n of sorted) { + const newId = remap.get(n.id) as NodeId; + const parentId = + n.parentId && remap.has(n.parentId) ? (remap.get(n.parentId) as NodeId) : null; + const cloned: Node = { + id: newId, + x: n.x + dx, + y: n.y + dy, + width: n.width, + height: n.height, + parentId, + }; + get().addNode(cloned); + newIds.push(newId); + } + get().endHistory(); + // Select pasted nodes and increment paste index + set((state) => { + const sel: Record = {}; + for (const id of newIds) sel[id] = true; + return { + selected: sel, + pasteIndex: state.pasteIndex + 1, + } as Partial as CanvasStore; + }); + }, + // --- History actions --- beginHistory: (label) => set((s) => { @@ -444,7 +1063,7 @@ export const useCanvasStore = create()((set, get) => ({ bboxBottom = -Infinity; let hasRef = false; for (const ch of entry.changes) { - if (ch.kind === 'add') { + if (ch.kind === 'add' && 'node' in ch) { // Node will be removed on undo; use its geometry as reference const n = ch.node; bboxLeft = Math.min(bboxLeft, n.x); @@ -452,7 +1071,7 @@ export const useCanvasStore = create()((set, get) => ({ bboxRight = Math.max(bboxRight, n.x + n.width); bboxBottom = Math.max(bboxBottom, n.y + n.height); hasRef = true; - } else if (ch.kind === 'remove') { + } else if (ch.kind === 'remove' && 'node' in ch) { // Node will be re-added; use its geometry const n = ch.node; bboxLeft = Math.min(bboxLeft, n.x); @@ -508,10 +1127,31 @@ export const useCanvasStore = create()((set, get) => ({ for (const id of Object.keys(s.selected) as NodeId[]) { if (nodes[id]) nextSel[id] = true; } + // Apply guide changes (if any) + let guides = s.guides; + let activeGuideId = s.activeGuideId; + if (entry.guideChanges) { + for (let i = entry.guideChanges.length - 1; i >= 0; i--) { + const gc = entry.guideChanges[i]; + if (gc.kind === 'add') { + // Undo add: remove the guide + guides = guides.filter((g) => g.id !== gc.guide.id); + if (activeGuideId === gc.guide.id) activeGuideId = null; + } else if (gc.kind === 'remove') { + // Undo remove: add the guide back + guides = [...guides, gc.guide]; + } else if (gc.kind === 'clear') { + // Undo clear: restore all guides + guides = gc.guides; + } + } + } return { nodes, camera: cam, selected: nextSel, + guides, + activeGuideId, historyPast: past, historyFuture: future, } as Partial as CanvasStore; @@ -594,10 +1234,31 @@ export const useCanvasStore = create()((set, get) => ({ for (const id of Object.keys(s.selected) as NodeId[]) { if (nodes[id]) nextSel[id] = true; } + // Apply guide changes (if any) + let guides = s.guides; + let activeGuideId = s.activeGuideId; + if (entry.guideChanges) { + for (const gc of entry.guideChanges) { + if (gc.kind === 'add') { + // Redo add: add the guide + guides = [...guides, gc.guide]; + } else if (gc.kind === 'remove') { + // Redo remove: remove the guide + guides = guides.filter((g) => g.id !== gc.guide.id); + if (activeGuideId === gc.guide.id) activeGuideId = null; + } else if (gc.kind === 'clear') { + // Redo clear: clear all guides + guides = []; + activeGuideId = null; + } + } + } return { nodes, camera: cam, selected: nextSel, + guides, + activeGuideId, historyPast: past, historyFuture: future, } as Partial as CanvasStore; @@ -624,6 +1285,18 @@ export function useCanvasActions(): Pick< })); } +// Inner-edit selectors/actions +export function useInnerEdit(): NodeId | null { + return useCanvasStore((s) => s.innerEditNodeId); +} + +export function useInnerEditActions(): Pick { + return useCanvasStore((s) => ({ + enterInnerEdit: s.enterInnerEdit, + exitInnerEdit: s.exitInnerEdit, + })); +} + // Nodes selectors/actions export function useNodes(): Node[] { return useCanvasStore((s) => Object.values(s.nodes)); @@ -682,6 +1355,14 @@ export function useDndActions(): Pick { })); } +// Grouping actions +export function useGroupingActions(): Pick { + return useCanvasStore((s) => ({ + groupNodes: s.groupNodes, + ungroup: s.ungroup, + })); +} + // History actions export function useHistoryActions(): Pick< CanvasActions, @@ -694,3 +1375,55 @@ export function useHistoryActions(): Pick< redo: s.redo, })); } + +// Clipboard actions/selectors +export function useClipboardActions(): Pick< + CanvasActions, + 'copySelection' | 'cutSelection' | 'pasteClipboard' +> { + return useCanvasStore((s) => ({ + copySelection: s.copySelection, + cutSelection: s.cutSelection, + pasteClipboard: s.pasteClipboard, + })); +} + +export function useHasClipboard(): boolean { + return useCanvasStore((s) => Boolean(s.clipboard && s.clipboard.nodes.length > 0)); +} + +// Rulers/Guides selectors & actions +export function useShowRulers(): boolean { + return useCanvasStore((s) => s.showRulers); +} + +export function useGuides(): Guide[] { + return useCanvasStore((s) => s.guides); +} + +export function useActiveGuideId(): GuideId | null { + return useCanvasStore((s) => s.activeGuideId); +} + +export function useRulersActions(): Pick< + CanvasActions, + | 'toggleRulers' + | 'addGuide' + | 'moveGuideTemporary' + | 'moveGuide' + | 'moveGuideCommit' + | 'removeGuide' + | 'clearGuides' + | 'setActiveGuide' +> { + return useCanvasStore((s) => ({ + toggleRulers: s.toggleRulers, + addGuide: s.addGuide, + moveGuideTemporary: s.moveGuideTemporary, + moveGuide: s.moveGuide, + moveGuideCommit: s.moveGuideCommit, + removeGuide: s.removeGuide, + clearGuides: s.clearGuides, + setActiveGuide: s.setActiveGuide, + })); +} diff --git a/src/react/Canvas.stories.tsx b/src/stories/Canvas.stories.tsx similarity index 74% rename from src/react/Canvas.stories.tsx rename to src/stories/Canvas.stories.tsx index 74f2d13..871c6ba 100644 --- a/src/react/Canvas.stories.tsx +++ b/src/stories/Canvas.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react'; -import React, { useRef, useState } from 'react'; -import { Canvas } from './Canvas'; -import { BackgroundDots } from './BackgroundDots'; -import { BackgroundCells } from './BackgroundCells'; -import { NodeView } from './NodeView'; -import { useCanvasNavigation } from './useCanvasNavigation'; +import React, { useState, useRef, useEffect } from 'react'; +import { Canvas } from '../react/Canvas'; +import { BackgroundDots } from '../react/BackgroundDots'; +import { BackgroundCells } from '../react/BackgroundCells'; +import { NodeView } from '../react/NodeView'; +import { useCanvasNavigation } from '../react/useCanvasNavigation'; import { cameraToCssTransform } from '../core/coords'; import { useCamera, @@ -13,10 +13,15 @@ import { useDeleteActions, useHistoryActions, useCanvasActions, + useSelectedIds, + useCanvasStore, + useClipboardActions, + useHasClipboard, } from '../state/store'; -import { useCanvasHelpers } from './useCanvasHelpers'; -import type { CanvasNavigationOptions } from './useCanvasNavigation'; -import type { BackgroundDotsProps } from './BackgroundDots'; +import { useCanvasHelpers } from '../react/useCanvasHelpers'; +import type { CanvasNavigationOptions } from '../react/useCanvasNavigation'; +import type { BackgroundDotsProps } from '../react/BackgroundDots'; +import { GroupContainersLayer } from '../react/GroupContainersLayer'; type CanvasStoryArgs = Required< Pick< @@ -54,6 +59,7 @@ type CanvasStoryArgs = Required< tabIndex: number; // Story toggles showHistoryPanel: boolean; + showHints: boolean; // Node appearance controls nodeUnstyled: boolean; nodeBorderColor: string; @@ -85,9 +91,10 @@ const meta: Meta = { }, 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)', + options: [1, 2], + mapping: { 1: 1, 2: 2 }, + description: + 'Mouse button for panning (1=middle, 2=right). Left button is reserved for selection/box-select.', }, panModifier: { control: { type: 'select' }, @@ -178,10 +185,11 @@ const meta: Meta = { canvasHeight: { control: { type: 'text' } }, tabIndex: { control: { type: 'number', min: -1, max: 10, step: 1 } }, showHistoryPanel: { control: 'boolean' }, + showHints: { control: 'boolean' }, }, args: { bgVariant: 'dots', - panButton: 0, + panButton: 1, panModifier: 'none', wheelZoom: true, wheelModifier: 'ctrl', @@ -212,6 +220,7 @@ const meta: Meta = { canvasHeight: '100vh', tabIndex: 0, showHistoryPanel: false, + showHints: true, // Node defaults (match NodeView defaultAppearance) nodeUnstyled: false, @@ -284,6 +293,9 @@ function Controls({ const { updateNode, removeNode } = useNodeActions(); const { deleteSelected } = useDeleteActions(); const nodes = useNodes(); + const selectedIds = useSelectedIds(); + const { copySelection, cutSelection, pasteClipboard } = useClipboardActions(); + const hasClipboard = useHasClipboard(); const counterRef = useRef(1); const [targetId, setTargetId] = useState(''); const { addNodeAtCenter } = useCanvasHelpers(rootRef); @@ -381,6 +393,33 @@ function Controls({ + | + + + count: {nodes.length} {showHistoryControls ? ( <> @@ -402,6 +441,12 @@ function Controls({ function Playground(args: CanvasStoryArgs) { const ref = useRef(null); + // Expose store for E2E assertions + useEffect(() => { + if (typeof window !== 'undefined') { + (window as unknown as { __RC_STORE: typeof useCanvasStore }).__RC_STORE = useCanvasStore; + } + }, []); useCanvasNavigation(ref, { panButton: args.panButton, panModifier: args.panModifier, @@ -456,96 +501,91 @@ function Playground(args: CanvasStoryArgs) { ) } > + -
-
-
-
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)
-
+ {args.showHints && ( +
-
Wheel & Zoom
-
• Auto mode: wheel pans vertically; Shift+wheel pans horizontally
-
• Ctrl+wheel zooms (mouse & touchpad pinch)
-
• Default zoom bounds: 60–240% (0.6–2.4)
-
- {args.showHistoryPanel ? (
-
History & Camera
-
• Camera pans are not recorded in history
-
• Undo/Redo of camera-only changes are no-ops
-
- • When nodes are re-added by Undo/Redo and are off-screen, the camera recenters -
-
- • Try: add a node, remove it, Pan Away, then Undo — view recenters on the restored - node -
+
Selection
+
• Click: select • Ctrl+Click: toggle • Drag empty: box-select
+
+
+
Navigation
+
• Wheel: pan • Shift+Wheel: pan horizontal • Ctrl+Wheel: zoom
- ) : null} +
+
Grouping
+
• Press Ctrl/Cmd+G to group selected nodes
+
• Dashed rectangles show groups with invisible hit areas for drag/drop
+
+ {args.showHistoryPanel ? ( +
+
History
+
• Camera pans not recorded • Undo/Redo recenters off-screen nodes
+
+ ) : null} +
+
- -
+ )}
); } @@ -563,10 +603,14 @@ export const Basic: Story = { render: (args) => , }; -export const HistoryAndCamera: Story = { - name: 'History & Camera', - args: { - showHistoryPanel: true, - }, - render: (args) => , -}; +// export const HistoryAndCamera: Story = { +// name: 'History & Camera', +// args: { +// showHistoryPanel: true, +// mousePanScale: 15, +// mouseZoomSensitivityIn: 0.03, +// mouseZoomSensitivityOut: 0.03, +// panButton: 2, +// }, +// render: (args) => , +// }; diff --git a/src/test.setup.ts b/src/test.setup.ts new file mode 100644 index 0000000..6cf394c --- /dev/null +++ b/src/test.setup.ts @@ -0,0 +1,7 @@ +// Shared test setup for Vitest +// Suppress React act warnings in jsdom environment +// See: https://react.dev/reference/react/StrictMode#turn-off-warning-about-not-wrapping-updates-in-act +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// You can add more shared mocks/polyfills here if needed. diff --git a/src/types/index.ts b/src/types/index.ts index da67717..7588355 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,4 +6,14 @@ export type Node = { y: number; width: number; height: number; + /** + * Optional logical parent for grouping. If set, this node is considered a child of `parentId`. + * Invariants: + * - parent chain must be acyclic (no cycles) + * - `parentId` references an existing node id when non-null + * - `parentId` must not equal this node's id + * + * MVP note: groups are logical only (no container geometry). Moving a parent moves all descendants. + */ + parentId?: NodeId | null; }; diff --git a/src/react/BackgroundCells.test.tsx b/tests/BackgroundCells.test.tsx similarity index 94% rename from src/react/BackgroundCells.test.tsx rename to tests/BackgroundCells.test.tsx index e343473..de9d013 100644 --- a/src/react/BackgroundCells.test.tsx +++ b/tests/BackgroundCells.test.tsx @@ -3,8 +3,8 @@ import React, { act } from 'react'; import ReactDOM from 'react-dom/client'; import { describe, it, expect, beforeEach } from 'vitest'; -import { BackgroundCells } from './BackgroundCells'; -import { useCanvasStore } from '../state/store'; +import { BackgroundCells } from '../src/react/BackgroundCells'; +import { useCanvasStore } from '../src/state/store'; async function render(ui: React.ReactElement) { const container = document.createElement('div'); @@ -43,7 +43,6 @@ describe('BackgroundCells (world-locked, smooth)', () => { const { container, unmount } = await render(); const el = container.firstElementChild as HTMLDivElement; - await act(async () => { useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 20, offsetY: -5 }); }); diff --git a/src/react/BackgroundDots.test.tsx b/tests/BackgroundDots.test.tsx similarity index 95% rename from src/react/BackgroundDots.test.tsx rename to tests/BackgroundDots.test.tsx index 1093319..78f85fe 100644 --- a/src/react/BackgroundDots.test.tsx +++ b/tests/BackgroundDots.test.tsx @@ -3,8 +3,8 @@ import React, { act } from 'react'; import ReactDOM from 'react-dom/client'; import { describe, it, expect, beforeEach } from 'vitest'; -import { BackgroundDots } from './BackgroundDots'; -import { useCanvasStore } from '../state/store'; +import { BackgroundDots } from '../src/react/BackgroundDots'; +import { useCanvasStore } from '../src/state/store'; async function render(ui: React.ReactElement) { const container = document.createElement('div'); diff --git a/tests/Canvas.boxselect.test.tsx b/tests/Canvas.boxselect.test.tsx new file mode 100644 index 0000000..2e77fe7 --- /dev/null +++ b/tests/Canvas.boxselect.test.tsx @@ -0,0 +1,485 @@ +/* @vitest-environment jsdom */ + +import React, { useRef, act } from 'react'; +import ReactDOM from 'react-dom/client'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { Canvas } from '../src/react/Canvas'; +import { useCanvasNavigation } from '../src/react/useCanvasNavigation'; +import { useCanvasStore } from '../src/state/store'; +import type { CanvasStore } from '../src/state/store'; +import type { Node } from '../src/types'; + +// ---- PointerEvent polyfill (copied/adapted from NodeView.dnd.test.tsx) ---- +// 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; +} + +// ---- Test utilities ---- + +function TestHost() { + const ref = useRef(null); + // Enable middle-button panning, disable zoom shortcuts to avoid noise + useCanvasNavigation(ref, { + panButton: 1, + panModifier: 'none', + wheelZoom: false, + doubleClickZoom: false, + }); + return ( + + ); +} + +async function render(ui: React.ReactElement) { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOM.createRoot(container); + await act(async () => { + root.render(ui); + }); + // wait microtask + macrotask to allow effects to mount + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + return { + container, + root, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +function stubRect(el: HTMLElement, rect: Partial = {}) { + const base: DOMRect = { + left: 0, + top: 0, + width: 800, + height: 600, + right: 800, + bottom: 600, + x: 0, + y: 0, + toJSON() {}, + } as DOMRect; + const value = { ...base, ...rect } as DOMRect; + const original = el.getBoundingClientRect.bind(el); + el.getBoundingClientRect = () => value; + return () => { + el.getBoundingClientRect = original; + }; +} + +function dispatchPointer(target: EventTarget, type: string, init: PointerEventInit) { + const ev = new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 1, + clientX: 0, + clientY: 0, + ...init, + }); + target.dispatchEvent(ev); +} + +function resetStore() { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + centerAddIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + } as Partial); +} + +beforeEach(() => { + resetStore(); +}); + +describe('Canvas: rectangle selection and panning interactions', () => { + it('left-drag on empty canvas starts box selection without Shift, overlays and live-highlights', async () => { + // Arrange: create a few nodes in world space + useCanvasStore.setState({ + nodes: { + a: { id: 'a', x: 50, y: 40, width: 100, height: 60 } as Node, + b: { id: 'b', x: 250, y: 40, width: 100, height: 60 } as Node, + c: { id: 'c', x: 450, y: 40, width: 100, height: 60 } as Node, + }, + }); + + const { container, unmount } = await render(); + const canvas = (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; + expect(canvas).toBeTruthy(); + const restoreRect = stubRect(canvas); + + // Act: start drag; then allow a re-render; then move again for live-highlighting + await act(async () => { + dispatchPointer(canvas, 'pointerdown', { button: 0, clientX: 10, clientY: 10 }); + dispatchPointer(canvas, 'pointermove', { clientX: 12, clientY: 12 }); // below threshold, no box yet + dispatchPointer(canvas, 'pointermove', { clientX: 200, clientY: 200 }); // starts box-select + }); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + await act(async () => { + // Now the component has re-rendered with isBoxSelecting=true + dispatchPointer(canvas, 'pointermove', { clientX: 210, clientY: 210 }); + }); + + // Assert: overlay exists and has expected geometry + const overlay = document.querySelector('[data-rc-box]') as HTMLDivElement | null; + expect(overlay).toBeTruthy(); + const style = overlay!.style; + expect(style.left).toBe('10px'); + expect(style.top).toBe('10px'); + expect(style.width).toBe('200px'); + expect(style.height).toBe('200px'); + + // Live selection highlights nodes under the current rect: only 'a' is inside + const sel = useCanvasStore.getState().selected; + expect(Object.keys(sel).sort()).toEqual(['a']); + + // Finish drag + await act(async () => { + dispatchPointer(canvas, 'pointerup', { button: 0, clientX: 210, clientY: 210 }); + }); + + restoreRect(); + await unmount(); + }); + + it('auto-pans while box-select cursor is near the right/bottom edge', async () => { + // Arrange: enable middle-button pan in nav; box-select uses left button here + const { container, unmount } = await render(); + const canvas = (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; + const restoreRect = stubRect(canvas, { + left: 0, + top: 0, + width: 800, + height: 600, + right: 800, + bottom: 600, + }); + + // Mock rAF so we can drive auto-pan frames manually + const gAny = globalThis as unknown as { + requestAnimationFrame: (cb: FrameRequestCallback) => number; + cancelAnimationFrame: (id: number) => void; + }; + const origRAF = gAny.requestAnimationFrame; + const origCAF = gAny.cancelAnimationFrame; + const queue: FrameRequestCallback[] = []; + gAny.requestAnimationFrame = (cb: FrameRequestCallback) => { + queue.push(cb); + return queue.length; + }; + gAny.cancelAnimationFrame = (id: number) => { + // no-op in tests; consume id to avoid unused-var lint + void id; + }; + const flushFrames = (n: number) => { + for (let i = 0; i < n; i++) { + const cb = queue.shift(); + if (cb) cb(performance.now()); + } + }; + + try { + // Ensure camera at origin + useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 }); + + // Start left drag to activate box-select + await act(async () => { + dispatchPointer(canvas, 'pointerdown', { button: 0, clientX: 100, clientY: 100 }); + dispatchPointer(canvas, 'pointermove', { clientX: 160, clientY: 140 }); + }); + // Move cursor into the right-bottom edge zone to trigger auto-pan + await act(async () => { + dispatchPointer(canvas, 'pointermove', { clientX: 795, clientY: 595 }); + }); + + // Drive a few rAF frames; camera should pan positively (right and down in world space) + flushFrames(5); + let cam = useCanvasStore.getState().camera; + expect(cam.offsetX).toBeGreaterThan(0); + expect(cam.offsetY).toBeGreaterThan(0); + + // Release pointer — auto-pan must stop + await act(async () => { + dispatchPointer(canvas, 'pointerup', { button: 0, clientX: 795, clientY: 595 }); + }); + const stopX = useCanvasStore.getState().camera.offsetX; + const stopY = useCanvasStore.getState().camera.offsetY; + // Even if a queued callback runs once, it should early-return and not change camera + flushFrames(3); + cam = useCanvasStore.getState().camera; + expect(cam.offsetX).toBe(stopX); + expect(cam.offsetY).toBe(stopY); + + restoreRect(); + await unmount(); + } finally { + // restore rAF + gAny.requestAnimationFrame = origRAF; + gAny.cancelAnimationFrame = origCAF; + } + }); + + it('additive selection with Ctrl merges initial snapshot on pointerup', async () => { + // Arrange: two nodes and a pre-selection of b + useCanvasStore.setState({ + nodes: { + a: { id: 'a', x: 50, y: 40, width: 100, height: 60 } as Node, + b: { id: 'b', x: 250, y: 40, width: 100, height: 60 } as Node, + }, + selected: { b: true }, + }); + + const { container, unmount } = await render(); + const canvas = (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; + const restoreRect = stubRect(canvas); + + // Drag a rectangle around node 'a' + await act(async () => { + dispatchPointer(canvas, 'pointerdown', { button: 0, clientX: 10, clientY: 10 }); + dispatchPointer(canvas, 'pointermove', { clientX: 160, clientY: 120 }); + }); + // On pointerup hold Ctrl to request additive merge + await act(async () => { + dispatchPointer(canvas, 'pointerup', { + button: 0, + clientX: 160, + clientY: 120, + ctrlKey: true, + }); + }); + + const sel = useCanvasStore.getState().selected; + expect(new Set(Object.keys(sel))).toEqual(new Set(['a', 'b'])); + + restoreRect(); + await unmount(); + }); + + it('click without drag on empty canvas clears selection', async () => { + // Preselect one node + useCanvasStore.setState({ + nodes: { a: { id: 'a', x: 50, y: 40, width: 100, height: 60 } as Node }, + selected: { a: true }, + }); + + const { container, unmount } = await render(); + const canvas = (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; + const restoreRect = stubRect(canvas); + + // Simple click (no movement beyond threshold) + await act(async () => { + dispatchPointer(canvas, 'pointerdown', { button: 0, clientX: 400, clientY: 400 }); + dispatchPointer(canvas, 'pointerup', { button: 0, clientX: 400, clientY: 400 }); + }); + + const sel = useCanvasStore.getState().selected; + expect(Object.keys(sel).length).toBe(0); + + restoreRect(); + await unmount(); + }); + + it('left-drag does not pan the camera, while middle-drag pans', async () => { + const { container, unmount } = await render(); + const canvas = (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; + const restoreRect = stubRect(canvas); + + // Ensure camera at origin + useCanvasStore.getState().setCamera({ zoom: 1, offsetX: 0, offsetY: 0 }); + + // Left drag to start box selection (should not pan) + await act(async () => { + dispatchPointer(canvas, 'pointerdown', { button: 0, clientX: 100, clientY: 100 }); + dispatchPointer(canvas, 'pointermove', { clientX: 140, clientY: 130 }); + dispatchPointer(canvas, 'pointerup', { button: 0, clientX: 140, clientY: 130 }); + }); + + let cam = useCanvasStore.getState().camera; + expect(cam.offsetX).toBe(0); + expect(cam.offsetY).toBe(0); + + // Middle drag should pan (useCanvasNavigation listens on window for move/up) + await act(async () => { + dispatchPointer(canvas, 'pointerdown', { button: 1, clientX: 300, clientY: 300 }); + dispatchPointer(window, 'pointermove', { clientX: 320, clientY: 330 }); // dx=20, dy=30 => panBy(-20,-30) + dispatchPointer(window, 'pointerup', { button: 1, clientX: 320, clientY: 330 }); + }); + + cam = useCanvasStore.getState().camera; + expect(cam.offsetX).toBe(-20); + expect(cam.offsetY).toBe(-30); + + restoreRect(); + await unmount(); + }); + + it('selection works with non-default camera (zoom and offset)', async () => { + // Camera zoom=2, offset=(100,50) + useCanvasStore.getState().setCamera({ zoom: 2, offsetX: 100, offsetY: 50 }); + // Node at world (120,70) size (40x20) -> screen bbox: x [40..120], y [40..80] + useCanvasStore.setState({ + nodes: { k: { id: 'k', x: 120, y: 70, width: 40, height: 20 } as Node }, + }); + + const { container, unmount } = await render(); + const canvas = (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; + const restoreRect = stubRect(canvas); + + // Drag a rect that includes the node's screen bbox + await act(async () => { + dispatchPointer(canvas, 'pointerdown', { button: 0, clientX: 0, clientY: 0 }); + dispatchPointer(canvas, 'pointermove', { clientX: 90, clientY: 90 }); // starts selection + }); + // Extra move to ensure isBoxSelecting branch ran before pointerup + await act(async () => { + dispatchPointer(canvas, 'pointermove', { clientX: 95, clientY: 95 }); + dispatchPointer(canvas, 'pointerup', { button: 0, clientX: 95, clientY: 95 }); + }); + + const sel = useCanvasStore.getState().selected; + expect(Object.keys(sel)).toEqual(['k']); + + restoreRect(); + await unmount(); + }); + + it('overlay rectangle grows during auto-pan and keeps the cursor-side anchored', async () => { + const { container, unmount } = await render(); + const canvas = (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; + const restoreRect = stubRect(canvas, { + left: 0, + top: 0, + width: 800, + height: 600, + right: 800, + bottom: 600, + }); + + // Mock rAF to drive auto-pan frames deterministically + const gAny = globalThis as unknown as { + requestAnimationFrame: (cb: FrameRequestCallback) => number; + cancelAnimationFrame: (id: number) => void; + }; + const origRAF = gAny.requestAnimationFrame; + const origCAF = gAny.cancelAnimationFrame; + const queue: FrameRequestCallback[] = []; + gAny.requestAnimationFrame = (cb: FrameRequestCallback) => { + queue.push(cb); + return queue.length; + }; + gAny.cancelAnimationFrame = (id: number) => { + void id; + }; + const flushFrames = (n: number) => { + for (let i = 0; i < n; i++) { + const cb = queue.shift(); + if (cb) cb(performance.now()); + } + }; + + try { + // Start box selection + await act(async () => { + dispatchPointer(canvas, 'pointerdown', { button: 0, clientX: 100, clientY: 100 }); + dispatchPointer(canvas, 'pointermove', { clientX: 160, clientY: 140 }); + }); + + // Move near the right/bottom edge to trigger auto-pan + await act(async () => { + dispatchPointer(canvas, 'pointermove', { clientX: 795, clientY: 595 }); + }); + + // Capture initial overlay geometry + let overlay = document.querySelector('[data-rc-box]') as HTMLDivElement | null; + expect(overlay).toBeTruthy(); + const width0 = parseInt(overlay!.style.width || '0', 10); + const cursorX = 795; // right edge cursor x + + // Drive a few frames of auto-pan and verify that width increases + await act(async () => { + flushFrames(8); + }); + // allow effects/state to commit + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + overlay = document.querySelector('[data-rc-box]') as HTMLDivElement | null; + expect(overlay).toBeTruthy(); + const left1 = parseInt(overlay!.style.left || '0', 10); + const width1 = parseInt(overlay!.style.width || '0', 10); + + expect(width1).toBeGreaterThan(width0); + // The right edge should remain near the cursor X (allowing small rounding diff) + expect(Math.abs(left1 + width1 - cursorX)).toBeLessThanOrEqual(2); + + // Release pointer to stop auto-pan + await act(async () => { + dispatchPointer(canvas, 'pointerup', { button: 0, clientX: 795, clientY: 595 }); + }); + + restoreRect(); + await unmount(); + } finally { + gAny.requestAnimationFrame = origRAF; + gAny.cancelAnimationFrame = origCAF; + } + }); +}); diff --git a/tests/GroupContainersLayer.test.tsx b/tests/GroupContainersLayer.test.tsx new file mode 100644 index 0000000..d75c99a --- /dev/null +++ b/tests/GroupContainersLayer.test.tsx @@ -0,0 +1,178 @@ +/* @vitest-environment jsdom */ + +import React, { act } from 'react'; +import ReactDOM from 'react-dom/client'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { GroupContainersLayer } from '../src/react/GroupContainersLayer'; +import { useCanvasStore } from '../src/state/store'; +import type { Node } from '../src/types'; + +// Polyfill PointerEvent for jsdom if missing +type PointerEventCtor = { new (type: string, eventInitDict?: PointerEventInit): PointerEvent }; +const g = globalThis as unknown as { PointerEvent?: PointerEventCtor }; +if (typeof g.PointerEvent === 'undefined') { + class FakePointerEvent extends MouseEvent { + pointerId: number; + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId ?? 1; + } + } + g.PointerEvent = FakePointerEvent as unknown as PointerEventCtor; +} + +async function render(ui: React.ReactElement) { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOM.createRoot(container); + await act(async () => { + root.render(ui); + }); + await Promise.resolve(); + return { container, root, unmount: () => root.unmount() }; +} + +function add(n: Node) { + useCanvasStore.getState().addNode(n); +} + +describe('GroupContainersLayer', () => { + beforeEach(() => { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + visualGroups: {}, + selectedVisualGroupId: null, + centerAddIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + }); + }); + + it('renders containers for visual groups with correct bbox (including nested-like cases)', async () => { + // Nodes + add({ id: 'P', x: 0, y: 0, width: 80, height: 40 }); + add({ id: 'c1', x: 120, y: 20, width: 60, height: 30 }); + add({ id: 'c2', x: 40, y: 120, width: 50, height: 50 }); + add({ id: 'g1', x: 160, y: 40, width: 30, height: 30 }); + // Create two visual groups analogous to parentId-based roots: + // G-P: members [P, c1, c2, g1]; G-c1: members [c1, g1] + useCanvasStore.setState((s) => ({ + ...s, + visualGroups: { + 'G-P': { id: 'G-P', members: ['P', 'c1', 'c2', 'g1'] }, + 'G-c1': { id: 'G-c1', members: ['c1', 'g1'] }, + }, + })); + + const { container, unmount } = await render(); + + const containers = container.querySelectorAll('[data-testid="group-container"]'); + expect(containers.length).toBe(2); + + const getByGroup = (gid: string) => + Array.from(containers).find((el) => el.getAttribute('data-parent-id') === gid)!; + + // For G-P: bbox over P, c1, c2, g1 + const p = getByGroup('G-P'); + expect(p).toBeTruthy(); + const leftP = Math.min(0, 120, 40, 160); + const topP = Math.min(0, 20, 120, 40); + const rightP = Math.max(0 + 80, 120 + 60, 40 + 50, 160 + 30); + const bottomP = Math.max(0 + 40, 20 + 30, 120 + 50, 40 + 30); + const widthP = rightP - leftP; + const heightP = bottomP - topP; + expect(p.style.left).toBe(`${leftP}px`); + expect(p.style.top).toBe(`${topP}px`); + expect(p.style.width).toBe(`${widthP}px`); + expect(p.style.height).toBe(`${heightP}px`); + + // For G-c1: bbox over c1 + g1 + const c1 = getByGroup('G-c1'); + expect(c1).toBeTruthy(); + const leftC1 = Math.min(120, 160); + const topC1 = Math.min(20, 40); + const rightC1 = Math.max(120 + 60, 160 + 30); + const bottomC1 = Math.max(20 + 30, 40 + 30); + const widthC1 = rightC1 - leftC1; + const heightC1 = bottomC1 - topC1; + expect(c1.style.left).toBe(`${leftC1}px`); + expect(c1.style.top).toBe(`${topC1}px`); + expect(c1.style.width).toBe(`${widthC1}px`); + expect(c1.style.height).toBe(`${heightC1}px`); + + unmount(); + }); + + it('renders tight bbox regardless of zoom (no extra padding)', async () => { + // Setup: group G-R with members R and C + add({ id: 'R', x: 10, y: 20, width: 40, height: 20 }); + add({ id: 'C', x: 70, y: 40, width: 30, height: 30 }); + useCanvasStore.setState((s) => ({ + ...s, + camera: { ...s.camera, zoom: 2 }, + visualGroups: { 'G-R': { id: 'G-R', members: ['R', 'C'] } }, + })); + + const { container, unmount } = await render(); + const el = container.querySelector('[data-parent-id="G-R"]') as HTMLElement; + expect(el).toBeTruthy(); + // Even at zoom=2, container should be tight to members (no padding) + const left = Math.min(10, 70); + const top = Math.min(20, 40); + const right = Math.max(10 + 40, 70 + 30); + const bottom = Math.max(20 + 20, 40 + 30); + const width = right - left; + const height = bottom - top; + expect(el.style.left).toBe(`${left}px`); + expect(el.style.top).toBe(`${top}px`); + expect(el.style.width).toBe(`${width}px`); + expect(el.style.height).toBe(`${height}px`); + unmount(); + }); + + it('click on frame selects visual group; drag moves whole group members', async () => { + // Layout + add({ id: 'P', x: 0, y: 0, width: 40, height: 40 }); + add({ id: 'c1', x: 60, y: 0, width: 30, height: 30 }); + add({ id: 'c2', x: 0, y: 60, width: 20, height: 20 }); + useCanvasStore.setState((s) => ({ + ...s, + visualGroups: { 'G-P': { id: 'G-P', members: ['P', 'c1', 'c2'] } }, + })); + + const { container, unmount } = await render(); + const hit = container.querySelector( + '[data-parent-id="G-P"] [data-testid="group-container-hit"]', + ) as SVGRectElement; + expect(hit).toBeTruthy(); + + // Plain click -> selects visual group + hit.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0, clientX: 5, clientY: 5 }), + ); + hit.dispatchEvent( + new PointerEvent('pointerup', { bubbles: true, button: 0, clientX: 5, clientY: 5 }), + ); + expect(useCanvasStore.getState().selectedVisualGroupId).toBe('G-P'); + + // Start drag: exceed threshold and move by 10,15 screen px at zoom=1 + hit.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0, clientX: 0, clientY: 0 }), + ); + hit.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: 5, clientY: 5 })); // under threshold + hit.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: 10, clientY: 15 })); // start drag + hit.dispatchEvent( + new PointerEvent('pointerup', { bubbles: true, button: 0, clientX: 10, clientY: 15 }), + ); + + const s = useCanvasStore.getState(); + expect(s.nodes['P']).toMatchObject({ x: 10, y: 15 }); + expect(s.nodes['c1']).toMatchObject({ x: 70, y: 15 }); + expect(s.nodes['c2']).toMatchObject({ x: 10, y: 75 }); + + unmount(); + }); +}); diff --git a/src/react/HistoryCamera.integration.test.tsx b/tests/HistoryCamera.integration.test.tsx similarity index 88% rename from src/react/HistoryCamera.integration.test.tsx rename to tests/HistoryCamera.integration.test.tsx index 645791c..fdc43c1 100644 --- a/src/react/HistoryCamera.integration.test.tsx +++ b/tests/HistoryCamera.integration.test.tsx @@ -3,15 +3,15 @@ import React, { useRef, act } from 'react'; import ReactDOM from 'react-dom/client'; import { describe, it, expect, beforeEach } from 'vitest'; -import { useCanvasNavigation } from './useCanvasNavigation'; -import { useCanvasStore } from '../state/store'; -import type { CanvasStore } from '../state/store'; +import { useCanvasNavigation } from '../src/react/useCanvasNavigation'; +import { useCanvasStore } from '../src/state/store'; +import type { CanvasStore } from '../src/state/store'; function TestHost() { const ref = useRef(null); // Mount navigation to simulate a real canvas host; disable zoom shortcuts to avoid warnings/noise useCanvasNavigation(ref, { - panButton: 0, + panButton: 1, panModifier: 'none', wheelZoom: false, doubleClickZoom: false, @@ -60,7 +60,9 @@ describe('React integration: undo re-add recenters camera when node is off-scree Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 }); // Some React DOM codepaths check global Window constructor; provide a fallback in jsdom if (!(globalThis as unknown as { Window?: unknown }).Window) { - (globalThis as unknown as { Window: unknown }).Window = (window as unknown as { constructor: unknown }).constructor; + (globalThis as unknown as { Window: unknown }).Window = ( + window as unknown as { constructor: unknown } + ).constructor; } }); diff --git a/src/react/NodeView.dnd.test.tsx b/tests/NodeView.dnd.test.tsx similarity index 66% rename from src/react/NodeView.dnd.test.tsx rename to tests/NodeView.dnd.test.tsx index 144fe8f..a490107 100644 --- a/src/react/NodeView.dnd.test.tsx +++ b/tests/NodeView.dnd.test.tsx @@ -3,11 +3,11 @@ 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'; +import { NodeView } from '../src/react/NodeView'; +import { useCanvasNavigation } from '../src/react/useCanvasNavigation'; +import { useCanvasStore } from '../src/state/store'; +import type { CanvasStore } from '../src/state/store'; +import type { Node } from '../src/types'; // Polyfill PointerEvent in jsdom if missing type PointerEventInitLike = MouseEventInit & { @@ -48,12 +48,18 @@ if (typeof g.PointerEvent === 'undefined') { this.isPrimary = params.isPrimary ?? true; } } - (globalThis as unknown as { PointerEvent: typeof Event }).PointerEvent = PointerEventPolyfill as unknown as typeof Event; + (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 }); + useCanvasNavigation(ref, { + panButton: 1, + panModifier: 'none', + wheelZoom: false, + doubleClickZoom: false, + }); const node = useCanvasStore.getState().nodes['d1']; if (!node) return
; @@ -123,4 +129,39 @@ describe('NodeView DnD UI + hit-testing vs canvas pan', () => { unmount(); }); + + it('dropping a node inside a group container does NOT auto-group; only movement is recorded in one batch', async () => { + // Setup: Group root G with descendant g1 -> container exists; draggable d1 is selected + useCanvasStore.setState((s) => ({ + ...s, + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: { + d1: { id: 'd1', x: 20, y: 20, width: 40, height: 20 } as Node, + G: { id: 'G', x: 200, y: 100, width: 100, height: 60 } as Node, + g1: { id: 'g1', x: 340, y: 120, width: 40, height: 30, parentId: 'G' } as Node, + }, + selected: { d1: true }, + historyPast: [], + historyFuture: [], + historyBatch: null, + })); + + 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 d1 and drop inside G's container (which spans roughly [192..388]x[92..168]) + dispatchPointer(nodeEl!, 'pointerdown', { button: 0, clientX: 30, clientY: 30 }); + dispatchPointer(nodeEl!, 'pointermove', { clientX: 45, clientY: 45 }); // exceed threshold + dispatchPointer(nodeEl!, 'pointerup', { button: 0, clientX: 210, clientY: 110 }); // inside G container + + const s = useCanvasStore.getState(); + // Node d1 should NOT have been auto-adopted into G (grouping now only via Ctrl/Cmd+G) + expect(s.nodes['d1'].parentId).toBeUndefined(); + // Batch count: one history entry for the drag movement only + expect(s.historyPast.length).toBe(1); + + unmount(); + }); }); diff --git a/tests/Rulers.integration.test.tsx b/tests/Rulers.integration.test.tsx new file mode 100644 index 0000000..e495978 --- /dev/null +++ b/tests/Rulers.integration.test.tsx @@ -0,0 +1,624 @@ +/* @vitest-environment jsdom */ + +import React, { useRef, act } from 'react'; +import ReactDOM from 'react-dom/client'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { Canvas } from '../src/react/Canvas'; +import { useCanvasNavigation } from '../src/react/useCanvasNavigation'; +import { useCanvasStore } from '../src/state/store'; +import type { CanvasStore } from '../src/state/store'; + +// ---- PointerEvent polyfill (same as in Canvas.boxselect.test.tsx) ---- +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; +} + +// ---- Test utilities (mirrored from Canvas.boxselect.test.tsx) ---- +function TestHost() { + const ref = useRef(null); + useCanvasNavigation(ref, { + panButton: 1, + panModifier: 'none', + wheelZoom: false, + doubleClickZoom: false, + }); + return ( + + ); +} + +async function render(ui: React.ReactElement) { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOM.createRoot(container); + await act(async () => { + root.render(ui); + }); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + return { + container, + root, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +function stubRect(el: HTMLElement, rect: Partial = {}) { + const base: DOMRect = { + left: 0, + top: 0, + width: 800, + height: 600, + right: 800, + bottom: 600, + x: 0, + y: 0, + toJSON() {}, + } as DOMRect; + const value = { ...base, ...rect } as DOMRect; + const original = el.getBoundingClientRect.bind(el); + el.getBoundingClientRect = () => value; + return () => { + el.getBoundingClientRect = original; + }; +} + +function dispatchPointer(target: EventTarget, type: string, init: PointerEventInit) { + const ev = new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 1, + clientX: 0, + clientY: 0, + ...init, + }); + target.dispatchEvent(ev); +} + +function getCanvas(container: HTMLElement) { + return (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; +} + +function getRulersRoot() { + return document.querySelector('[data-rc-rulers]') as HTMLDivElement | null; +} + +function queryGuides(): HTMLDivElement[] { + return Array.from(document.querySelectorAll('[data-rc-guide]') as NodeListOf); +} + +function setGlobalClientSize(size: { width: number; height: number }) { + const descW = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); + const descH = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight'); + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get: () => size.width, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + get: () => size.height, + }); + return () => { + if (descW) Object.defineProperty(HTMLElement.prototype, 'clientWidth', descW); + if (descH) Object.defineProperty(HTMLElement.prototype, 'clientHeight', descH); + }; +} + +// Per-element client size stub. Useful when we want to constrain only rulersRoot dimensions. +function stubClientSize(el: HTMLElement, size: { width: number; height: number }) { + const prevW = Object.getOwnPropertyDescriptor(el, 'clientWidth'); + const prevH = Object.getOwnPropertyDescriptor(el, 'clientHeight'); + Object.defineProperty(el, 'clientWidth', { configurable: true, get: () => size.width }); + Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => size.height }); + return () => { + if (prevW) Object.defineProperty(el, 'clientWidth', prevW); + else delete (el as unknown as { clientWidth?: unknown }).clientWidth; + if (prevH) Object.defineProperty(el, 'clientHeight', prevH); + else delete (el as unknown as { clientHeight?: unknown }).clientHeight; + }; +} + +function resetStore() { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + centerAddIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + guides: [], + activeGuideId: null, + } as Partial); +} + +beforeEach(() => { + resetStore(); +}); + +describe('Rulers: guides interactions', () => { + it('creates a horizontal guide by dragging from the top ruler', async () => { + const restoreGlobalClient = setGlobalClientSize({ width: 800, height: 600 }); + const { container, unmount } = await render(); + const canvas = getCanvas(container); + const rulersRoot = getRulersRoot(); + expect(canvas).toBeTruthy(); + expect(rulersRoot).toBeTruthy(); + + const restoreCanvasRect = stubRect(canvas); + const restoreRulersRect = stubRect(rulersRoot!); + + const top = document.querySelector('[data-rc-ruler-top]') as HTMLDivElement; + expect(top).toBeTruthy(); + + // Start drag on the top ruler and move inside content to y=100 to create a horizontal (axis y) guide + await act(async () => { + dispatchPointer(top, 'pointerdown', { button: 0, clientX: 50, clientY: 10 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 200, clientY: 100 }); + }); + // allow React to commit + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + + const guides = queryGuides(); + expect(guides.length).toBe(1); + const gEl = guides[0]; + expect(gEl.getAttribute('data-rc-guide-axis')).toBe('y'); + + // The guide value is in world coordinates, with zoom=1 and offset=0 equals screen y + const st = useCanvasStore.getState(); + expect(st.guides.length).toBe(1); + const g = st.guides[0]; + expect(g.axis).toBe('y'); + expect(g.value).toBeCloseTo(100, 3); + + // Finish drag + await act(async () => { + dispatchPointer(rulersRoot!, 'pointerup', { button: 0, clientX: 200, clientY: 100 }); + }); + + restoreRulersRect(); + restoreCanvasRect(); + restoreGlobalClient(); + await unmount(); + }); + + it('creates a vertical guide by dragging from the left ruler', async () => { + const restoreGlobalClient = setGlobalClientSize({ width: 800, height: 600 }); + const { container, unmount } = await render(); + const canvas = getCanvas(container); + const rulersRoot = getRulersRoot(); + expect(canvas).toBeTruthy(); + expect(rulersRoot).toBeTruthy(); + + const restoreCanvasRect = stubRect(canvas); + const restoreRulersRect = stubRect(rulersRoot!); + const restoreClient = stubClientSize(rulersRoot!, { width: 800, height: 600 }); + + const left = document.querySelector('[data-rc-ruler-left]') as HTMLDivElement; + expect(left).toBeTruthy(); + + await act(async () => { + dispatchPointer(left, 'pointerdown', { button: 0, clientX: 10, clientY: 50 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 150, clientY: 200 }); + }); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + + const guides = queryGuides(); + expect(guides.length).toBe(1); + const gEl = guides[0]; + expect(gEl.getAttribute('data-rc-guide-axis')).toBe('x'); + + const st = useCanvasStore.getState(); + expect(st.guides.length).toBe(1); + const g = st.guides[0]; + expect(g.axis).toBe('x'); + expect(g.value).toBeCloseTo(150, 3); + + await act(async () => { + dispatchPointer(rulersRoot!, 'pointerup', { button: 0, clientX: 150, clientY: 200 }); + }); + + restoreRulersRect(); + restoreClient(); + restoreCanvasRect(); + restoreGlobalClient(); + await unmount(); + }); + + it('moves a guide with temporary updates and commits on pointerup', async () => { + const restoreGlobalClient = setGlobalClientSize({ width: 800, height: 600 }); + const { container, unmount } = await render(); + const canvas = getCanvas(container); + const rulersRoot = getRulersRoot(); + const restoreCanvasRect = stubRect(canvas); + const restoreRulersRect = stubRect(rulersRoot!); + const restoreClient = stubClientSize(rulersRoot!, { width: 800, height: 600 }); + + // Create a vertical guide at x=150 + const left = document.querySelector('[data-rc-ruler-left]') as HTMLDivElement; + await act(async () => { + dispatchPointer(left, 'pointerdown', { button: 0, clientX: 10, clientY: 10 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 150, clientY: 120 }); + }); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + + let st = useCanvasStore.getState(); + expect(st.guides.length).toBe(1); + const id = st.guides[0].id; + + // Start moving: pointerdown on guide, then move to x=300 (temporary update) + const guideEl = document.querySelector( + '[data-rc-guide][data-rc-guide-axis="x"]', + ) as HTMLDivElement; + expect(guideEl).toBeTruthy(); + + await act(async () => { + dispatchPointer(guideEl, 'pointerdown', { button: 0, clientX: 150, clientY: 120 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 300, clientY: 120 }); + }); + + st = useCanvasStore.getState(); + const temp = st.guides.find((g) => g.id === id)!; + expect(temp.value).toBeCloseTo(300, 3); + + // Commit on pointerup + await act(async () => { + dispatchPointer(rulersRoot!, 'pointerup', { button: 0, clientX: 300, clientY: 120 }); + }); + + // History should now have 2 entries (add + move), and final value is 300 + st = useCanvasStore.getState(); + expect(st.guides.find((g) => g.id === id)!.value).toBeCloseTo(300, 3); + expect(st.historyPast.length).toBeGreaterThanOrEqual(2); + + restoreRulersRect(); + restoreClient(); + restoreCanvasRect(); + restoreGlobalClient(); + await unmount(); + }); + + it('shows hover visual feedback on guide and reverts on leave', async () => { + const restoreGlobalClient = setGlobalClientSize({ width: 800, height: 600 }); + const { container, unmount } = await render(); + const canvas = getCanvas(container); + const rulersRoot = getRulersRoot(); + const restoreCanvasRect = stubRect(canvas); + const restoreRulersRect = stubRect(rulersRoot!); + const restoreClient = stubClientSize(rulersRoot!, { width: 800, height: 600 }); + + // Create a vertical guide at x=120 + const left = document.querySelector('[data-rc-ruler-left]') as HTMLDivElement; + await act(async () => { + dispatchPointer(left, 'pointerdown', { button: 0, clientX: 10, clientY: 10 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 120, clientY: 120 }); + dispatchPointer(rulersRoot!, 'pointerup', { button: 0, clientX: 120, clientY: 120 }); + }); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + + // Clear active state via a clean click on empty canvas area (to not conflate active vs hover styles) + await act(async () => { + dispatchPointer(canvas, 'pointerdown', { button: 0, clientX: 400, clientY: 400 }); + dispatchPointer(canvas, 'pointerup', { button: 0, clientX: 400, clientY: 400 }); + }); + + const guideEl = document.querySelector( + '[data-rc-guide][data-rc-guide-axis="x"]', + ) as HTMLDivElement; + expect(guideEl).toBeTruthy(); + const lineEl = guideEl.querySelector('[data-rc-guide-line]') as HTMLDivElement; + expect(lineEl).toBeTruthy(); + + // Default (not hovered, not active) should be 1px width + expect(lineEl.style.width).toBe('1px'); + + // Hover (use pointerover which React uses under the hood for onPointerEnter) + await act(async () => { + dispatchPointer(guideEl, 'pointerover', { clientX: 120, clientY: 120 }); + }); + expect(lineEl.style.width).toBe('2px'); + + // Leave -> back to 1px + await act(async () => { + dispatchPointer(guideEl, 'pointerout', { clientX: 120, clientY: 120 }); + }); + expect(lineEl.style.width).toBe('1px'); + + restoreRulersRect(); + restoreClient(); + restoreCanvasRect(); + restoreGlobalClient(); + await unmount(); + }); + + it('deletes the active guide with Delete and Backspace keys', async () => { + const { container, unmount } = await render(); + const canvas = getCanvas(container); + const rulersRoot = getRulersRoot(); + const restoreCanvasRect = stubRect(canvas); + const restoreRulersRect = stubRect(rulersRoot!); + + // Create guide (becomes active during creation) + const left = document.querySelector('[data-rc-ruler-left]') as HTMLDivElement; + await act(async () => { + dispatchPointer(left, 'pointerdown', { button: 0, clientX: 10, clientY: 10 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 200, clientY: 120 }); + dispatchPointer(rulersRoot!, 'pointerup', { button: 0, clientX: 200, clientY: 120 }); + }); + + let st = useCanvasStore.getState(); + expect(st.guides.length).toBe(1); + + // Press Delete on window (Rulers listens on window) + await act(async () => { + const e = new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }); + window.dispatchEvent(e); + }); + st = useCanvasStore.getState(); + expect(st.guides.length).toBe(0); + + // Create again, then Backspace + await act(async () => { + dispatchPointer(left, 'pointerdown', { button: 0, clientX: 10, clientY: 10 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 180, clientY: 120 }); + dispatchPointer(rulersRoot!, 'pointerup', { button: 0, clientX: 180, clientY: 120 }); + }); + st = useCanvasStore.getState(); + expect(st.guides.length).toBe(1); + + await act(async () => { + const e = new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }); + window.dispatchEvent(e); + }); + st = useCanvasStore.getState(); + expect(st.guides.length).toBe(0); + + restoreRulersRect(); + restoreCanvasRect(); + await unmount(); + }); + + it('undo/redo guide operations with Canvas keyboard shortcuts', async () => { + const { container, unmount } = await render(); + const canvas = getCanvas(container); + const rulersRoot = getRulersRoot(); + const restoreCanvasRect = stubRect(canvas); + const restoreRulersRect = stubRect(rulersRoot!); + + // Focus canvas to enable keyboard shortcuts + canvas.focus(); + + // Create a guide + const left = document.querySelector('[data-rc-ruler-left]') as HTMLDivElement; + await act(async () => { + dispatchPointer(left, 'pointerdown', { button: 0, clientX: 10, clientY: 10 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 150, clientY: 120 }); + dispatchPointer(rulersRoot!, 'pointerup', { button: 0, clientX: 150, clientY: 120 }); + }); + + let st = useCanvasStore.getState(); + expect(st.guides.length).toBe(1); + expect(st.historyPast.length).toBe(1); + + // Undo with Ctrl+Z + await act(async () => { + const e = new KeyboardEvent('keydown', { + key: 'z', + code: 'KeyZ', + ctrlKey: true, + bubbles: true, + }); + canvas.dispatchEvent(e); + }); + st = useCanvasStore.getState(); + expect(st.guides.length).toBe(0); + expect(st.historyPast.length).toBe(0); + expect(st.historyFuture.length).toBe(1); + + // Redo with Shift+Ctrl+Z + await act(async () => { + const e = new KeyboardEvent('keydown', { + key: 'z', + code: 'KeyZ', + ctrlKey: true, + shiftKey: true, + bubbles: true, + }); + canvas.dispatchEvent(e); + }); + st = useCanvasStore.getState(); + expect(st.guides.length).toBe(1); + expect(st.historyPast.length).toBe(1); + expect(st.historyFuture.length).toBe(0); + + restoreRulersRect(); + restoreCanvasRect(); + await unmount(); + }); + + it('creates guides with proper data attributes for stable testing', async () => { + const restoreGlobalClient = setGlobalClientSize({ width: 800, height: 600 }); + const { container, unmount } = await render(); + const canvas = getCanvas(container); + const rulersRoot = getRulersRoot(); + const restoreCanvasRect = stubRect(canvas); + const restoreRulersRect = stubRect(rulersRoot!); + const restoreClient = stubClientSize(rulersRoot!, { width: 800, height: 600 }); + + // Create vertical guide + const left = document.querySelector('[data-rc-ruler-left]') as HTMLDivElement; + await act(async () => { + dispatchPointer(left, 'pointerdown', { button: 0, clientX: 10, clientY: 10 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 100, clientY: 120 }); + dispatchPointer(rulersRoot!, 'pointerup', { button: 0, clientX: 100, clientY: 120 }); + }); + + // Create horizontal guide + const top = document.querySelector('[data-rc-ruler-top]') as HTMLDivElement; + await act(async () => { + dispatchPointer(top, 'pointerdown', { button: 0, clientX: 50, clientY: 10 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 200, clientY: 80 }); + dispatchPointer(rulersRoot!, 'pointerup', { button: 0, clientX: 200, clientY: 80 }); + }); + + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + + // Verify data attributes + const xGuide = document.querySelector( + '[data-rc-guide][data-rc-guide-axis="x"]', + ) as HTMLDivElement; + const yGuide = document.querySelector( + '[data-rc-guide][data-rc-guide-axis="y"]', + ) as HTMLDivElement; + + expect(xGuide).toBeTruthy(); + expect(yGuide).toBeTruthy(); + + expect(xGuide.getAttribute('data-rc-guide')).toBe(''); + expect(xGuide.getAttribute('data-rc-guide-axis')).toBe('x'); + expect(xGuide.getAttribute('data-rc-guide-id')).toBeTruthy(); + + expect(yGuide.getAttribute('data-rc-guide')).toBe(''); + expect(yGuide.getAttribute('data-rc-guide-axis')).toBe('y'); + expect(yGuide.getAttribute('data-rc-guide-id')).toBeTruthy(); + + // Verify guide lines have data-rc-guide-line + const xLine = xGuide.querySelector('[data-rc-guide-line]') as HTMLDivElement; + const yLine = yGuide.querySelector('[data-rc-guide-line]') as HTMLDivElement; + + expect(xLine).toBeTruthy(); + expect(yLine).toBeTruthy(); + expect(xLine.getAttribute('data-rc-guide-line')).toBe(''); + expect(yLine.getAttribute('data-rc-guide-line')).toBe(''); + + restoreRulersRect(); + restoreClient(); + restoreCanvasRect(); + restoreGlobalClient(); + await unmount(); + }); + + it('handles guide movement history correctly with moveGuideCommit', async () => { + const restoreGlobalClient = setGlobalClientSize({ width: 800, height: 600 }); + const { container, unmount } = await render(); + const canvas = getCanvas(container); + const rulersRoot = getRulersRoot(); + const restoreCanvasRect = stubRect(canvas); + const restoreRulersRect = stubRect(rulersRoot!); + const restoreClient = stubClientSize(rulersRoot!, { width: 800, height: 600 }); + + // Create a vertical guide + const left = document.querySelector('[data-rc-ruler-left]') as HTMLDivElement; + await act(async () => { + dispatchPointer(left, 'pointerdown', { button: 0, clientX: 10, clientY: 10 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 100, clientY: 120 }); + dispatchPointer(rulersRoot!, 'pointerup', { button: 0, clientX: 100, clientY: 120 }); + }); + + let st = useCanvasStore.getState(); + expect(st.guides.length).toBe(1); + const initialHistoryLength = st.historyPast.length; + const guideId = st.guides[0].id; + + // Move the guide from x=100 to x=250 + const guideEl = document.querySelector( + '[data-rc-guide][data-rc-guide-axis="x"]', + ) as HTMLDivElement; + await act(async () => { + dispatchPointer(guideEl, 'pointerdown', { button: 0, clientX: 100, clientY: 120 }); + // Multiple temporary moves (should not create history entries) + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 150, clientY: 120 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 200, clientY: 120 }); + dispatchPointer(rulersRoot!, 'pointermove', { clientX: 250, clientY: 120 }); + }); + + // Verify temporary position + st = useCanvasStore.getState(); + expect(st.guides.find((g) => g.id === guideId)!.value).toBeCloseTo(250, 3); + // History should not have grown during temporary moves + expect(st.historyPast.length).toBe(initialHistoryLength); + + // Commit the move + await act(async () => { + dispatchPointer(rulersRoot!, 'pointerup', { button: 0, clientX: 250, clientY: 120 }); + }); + + // Now history should have one more entry for the move + st = useCanvasStore.getState(); + expect(st.historyPast.length).toBe(initialHistoryLength + 1); + expect(st.guides.find((g) => g.id === guideId)!.value).toBeCloseTo(250, 3); + + // Test undo - should go back to original position (100) in one step + canvas.focus(); + await act(async () => { + const e = new KeyboardEvent('keydown', { + key: 'z', + code: 'KeyZ', + ctrlKey: true, + bubbles: true, + }); + canvas.dispatchEvent(e); + }); + + st = useCanvasStore.getState(); + expect(st.guides.find((g) => g.id === guideId)!.value).toBeCloseTo(100, 3); + + restoreRulersRect(); + restoreClient(); + restoreCanvasRect(); + restoreGlobalClient(); + await unmount(); + }); +}); diff --git a/src/core/coords.test.ts b/tests/coords.test.ts similarity index 97% rename from src/core/coords.test.ts rename to tests/coords.test.ts index a9b0640..69e9e06 100644 --- a/src/core/coords.test.ts +++ b/tests/coords.test.ts @@ -6,7 +6,7 @@ import { zoomAtPoint, type Camera, type Point, -} from './coords'; +} from '../src/core/coords'; describe('coords', () => { it('worldToScreen and screenToWorld roundtrip', () => { diff --git a/tests/useCanvasNavigation.clipboard.test.tsx b/tests/useCanvasNavigation.clipboard.test.tsx new file mode 100644 index 0000000..9e74931 --- /dev/null +++ b/tests/useCanvasNavigation.clipboard.test.tsx @@ -0,0 +1,202 @@ +/* @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 '../src/react/useCanvasNavigation'; +import { useCanvasStore } from '../src/state/store'; +import type { CanvasStore } from '../src/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); + }); + await Promise.resolve(); + return { container, root, unmount: () => root.unmount() }; +} + +function dispatchKey(el: Element, key: string, code?: string, init?: KeyboardEventInit) { + // Provide a sensible default for KeyboardEvent.code when a single letter is used + const resolvedCode = + code ?? (key.length === 1 && /[a-zA-Z]/.test(key) ? `Key${key.toUpperCase()}` : undefined); + const ev = new KeyboardEvent('keydown', { + key, + code: resolvedCode, + bubbles: true, + cancelable: true, + ...init, + }); + el.dispatchEvent(ev); +} + +function resetStore() { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + centerAddIndex: 0, + clipboard: null, + pasteIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + } as Partial); +} + +describe('useCanvasNavigation: clipboard keyboard shortcuts', () => { + beforeEach(() => resetStore()); + + it('Ctrl/Cmd+C copies selected nodes into clipboard', async () => { + const { container, unmount } = await render(); + const canvas = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement; + + // Arrange a node and selection + useCanvasStore.getState().addNode({ id: 'a', x: 0, y: 0, width: 10, height: 10 }); + useCanvasStore.getState().selectOnly('a'); + + // Ctrl+C + dispatchKey(canvas, 'c', undefined, { ctrlKey: true }); + let s = useCanvasStore.getState(); + expect(s.clipboard?.nodes.map((n) => n.id)).toEqual(['a']); + + // Cmd+C (meta) also works + useCanvasStore.getState().selectOnly('a'); + dispatchKey(canvas, 'c', undefined, { metaKey: true }); + s = useCanvasStore.getState(); + expect(s.clipboard?.nodes.map((n) => n.id)).toEqual(['a']); + + unmount(); + }); + + it('Ctrl/Cmd+X cuts selected nodes (copies then deletes)', async () => { + const { container, unmount } = await render(); + const canvas = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement; + + useCanvasStore.getState().addNode({ id: 'b', x: 0, y: 0, width: 10, height: 10 }); + useCanvasStore.getState().selectOnly('b'); + + dispatchKey(canvas, 'x', undefined, { ctrlKey: true }); + let s = useCanvasStore.getState(); + expect(s.clipboard?.nodes.map((n) => n.id)).toEqual(['b']); + expect(s.nodes['b']).toBeUndefined(); + expect(Object.keys(s.selected)).toHaveLength(0); + + // Meta key variant + useCanvasStore.getState().addNode({ id: 'b2', x: 0, y: 0, width: 10, height: 10 }); + useCanvasStore.getState().selectOnly('b2'); + dispatchKey(canvas, 'x', undefined, { metaKey: true }); + s = useCanvasStore.getState(); + expect(s.clipboard?.nodes.map((n) => n.id)).toEqual(['b2']); + expect(s.nodes['b2']).toBeUndefined(); + + unmount(); + }); + + it('Ctrl/Cmd+V pastes clipboard with id remap and selects pasted nodes', async () => { + const { container, unmount } = await render(); + const canvas = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement; + + // Seed clipboard by copying a parent+child + useCanvasStore.getState().addNode({ id: 'p', x: 0, y: 0, width: 50, height: 30 }); + useCanvasStore + .getState() + .addNode({ id: 'c1', x: 5, y: 5, width: 20, height: 10, parentId: 'p' }); + useCanvasStore.getState().selectOnly('p'); + useCanvasStore.getState().copySelection(); + + // Paste (Ctrl+V) + dispatchKey(canvas, 'v', undefined, { ctrlKey: true }); + let s = useCanvasStore.getState(); + expect(s.nodes['p-copy']).toBeDefined(); + expect(s.nodes['c1-copy']).toBeDefined(); + expect(s.nodes['c1-copy']?.parentId).toBe('p-copy'); + expect(s.selected).toEqual({ 'p-copy': true, 'c1-copy': true }); + + // Paste (Cmd+V) + dispatchKey(canvas, 'v', undefined, { metaKey: true }); + s = useCanvasStore.getState(); + expect(s.nodes['p-copy2']).toBeDefined(); + expect(s.nodes['c1-copy2']).toBeDefined(); + expect(s.nodes['c1-copy2']?.parentId).toBe('p-copy2'); + + unmount(); + }); + + it('Ctrl/Cmd+V pastes at the current cursor world position', async () => { + // Render and obtain the canvas element managed by the nav hook + const { container, unmount } = await render( + , + ); + const canvas = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement; + + // Stub bounding rect so screen->world is deterministic + const originalGetRect = canvas.getBoundingClientRect.bind(canvas); + canvas.getBoundingClientRect = () => + ({ + left: 50, + top: 60, + width: 800, + height: 600, + right: 850, + bottom: 660, + x: 50, + y: 60, + toJSON() {}, + }) as DOMRect; + + // Seed clipboard with a parent+child; bbox min is at (0,0) + useCanvasStore.getState().addNode({ id: 'p', x: 0, y: 0, width: 40, height: 30 }); + useCanvasStore + .getState() + .addNode({ id: 'c', x: 10, y: 5, width: 10, height: 10, parentId: 'p' }); + useCanvasStore.getState().selectOnly('p'); + useCanvasStore.getState().copySelection(); + + // Simulate a wheel event to set last pointer position without changing camera + const clientX = 350; // screen + const clientY = 460; // screen + const wheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX, + clientY, + deltaX: 0, + deltaY: 0, + }); + canvas.dispatchEvent(wheel); + + // Now paste (Ctrl+V); expected world point = (client - rect) with zoom=1, offset=0 => (300,400) + const worldX = clientX - 50; + const worldY = clientY - 60; + dispatchKey(canvas, 'v', undefined, { ctrlKey: true }); + + const s = useCanvasStore.getState(); + const pCopy = s.nodes['p-copy']; + const cCopy = s.nodes['c-copy']; + expect(pCopy).toBeDefined(); + expect(cCopy).toBeDefined(); + expect(cCopy?.parentId).toBe('p-copy'); + expect(pCopy).toMatchObject({ x: worldX, y: worldY }); + expect(cCopy).toMatchObject({ x: 10 + worldX, y: 5 + worldY }); + + // Restore + canvas.getBoundingClientRect = originalGetRect; + unmount(); + }); +}); diff --git a/tests/useCanvasNavigation.grouping.test.tsx b/tests/useCanvasNavigation.grouping.test.tsx new file mode 100644 index 0000000..2c7f9ab --- /dev/null +++ b/tests/useCanvasNavigation.grouping.test.tsx @@ -0,0 +1,137 @@ +/* @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 '../src/react/useCanvasNavigation'; +import { useCanvasStore } from '../src/state/store'; +import type { CanvasStore } from '../src/state/store'; + +function TestHost(props: { options?: Parameters[1] }) { + const ref = useRef(null); + useCanvasNavigation(ref, props.options); + return
; +} + +async function render(ui: React.ReactElement) { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOM.createRoot(container); + await act(async () => { + root.render(ui); + }); + await Promise.resolve(); + return { container, root, unmount: () => root.unmount() }; +} + +function dispatchKey(el: Element, key: string, code?: string, init?: KeyboardEventInit) { + // Default code for single-letter keys to layout-independent form (e.g., 'g' -> 'KeyG') + const resolvedCode = + code ?? (key.length === 1 && /[a-zA-Z]/.test(key) ? `Key${key.toUpperCase()}` : undefined); + const ev = new KeyboardEvent('keydown', { + key, + code: resolvedCode, + bubbles: true, + cancelable: true, + ...init, + }); + el.dispatchEvent(ev); +} + +function resetStore() { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + visualGroups: {}, + selectedVisualGroupId: null, + innerEditNodeId: null, + centerAddIndex: 0, + clipboard: null, + pasteIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + showRulers: true, + guides: [], + activeGuideId: null, + } as Partial); +} + +describe('useCanvasNavigation: grouping keyboard shortcut', () => { + beforeEach(() => resetStore()); + + it('Ctrl/Cmd+G creates a visual group from selection (>=2)', async () => { + const { container, unmount } = await render(); + const canvas = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement; + + // Arrange two nodes and multi-select them + await act(async () => { + useCanvasStore.getState().addNode({ id: 'a', x: 0, y: 0, width: 10, height: 10 }); + useCanvasStore.getState().addNode({ id: 'b', x: 20, y: 0, width: 10, height: 10 }); + useCanvasStore.getState().selectOnly('a'); + useCanvasStore.getState().addToSelection('b'); + }); + + // Ctrl+G + await act(async () => { + dispatchKey(canvas, 'g', undefined, { ctrlKey: true }); + }); + let s = useCanvasStore.getState(); + let vgIds = Object.keys(s.visualGroups); + expect(vgIds.length).toBe(1); + let gid = vgIds[0]; + expect(s.selectedVisualGroupId).toBe(gid); + expect(new Set(s.visualGroups[gid].members)).toEqual(new Set(['a', 'b'])); + + // Meta+G variant + await act(async () => { + resetStore(); + useCanvasStore.getState().addNode({ id: 'a', x: 0, y: 0, width: 10, height: 10 }); + useCanvasStore.getState().addNode({ id: 'b', x: 20, y: 0, width: 10, height: 10 }); + useCanvasStore.getState().selectOnly('a'); + useCanvasStore.getState().addToSelection('b'); + dispatchKey(canvas, 'g', undefined, { metaKey: true }); + }); + s = useCanvasStore.getState(); + vgIds = Object.keys(s.visualGroups); + expect(vgIds.length).toBe(1); + gid = vgIds[0]; + expect(s.selectedVisualGroupId).toBe(gid); + expect(new Set(s.visualGroups[gid].members)).toEqual(new Set(['a', 'b'])); + + await act(async () => { + unmount(); + }); + }); + + it('Ctrl/Cmd+G does nothing when selection size < 2', async () => { + const { container, unmount } = await render(); + const canvas = container.querySelector('[data-testid="canvas"]')! as HTMLDivElement; + + await act(async () => { + useCanvasStore.getState().addNode({ id: 'a', x: 0, y: 0, width: 10, height: 10 }); + useCanvasStore.getState().selectOnly('a'); + }); + + // Ctrl+G + await act(async () => { + dispatchKey(canvas, 'g', undefined, { ctrlKey: true }); + }); + let s = useCanvasStore.getState(); + expect(Object.keys(s.visualGroups)).toHaveLength(0); + expect(s.selectedVisualGroupId).toBeNull(); + + // Meta+G + await act(async () => { + dispatchKey(canvas, 'g', undefined, { metaKey: true }); + }); + s = useCanvasStore.getState(); + expect(Object.keys(s.visualGroups)).toHaveLength(0); + expect(s.selectedVisualGroupId).toBeNull(); + + await act(async () => { + unmount(); + }); + }); +}); diff --git a/src/react/useCanvasNavigation.test.tsx b/tests/useCanvasNavigation.test.tsx similarity index 96% rename from src/react/useCanvasNavigation.test.tsx rename to tests/useCanvasNavigation.test.tsx index b2cc1c7..e34a39a 100644 --- a/src/react/useCanvasNavigation.test.tsx +++ b/tests/useCanvasNavigation.test.tsx @@ -3,8 +3,8 @@ import React, { useRef, act } from 'react'; import ReactDOM from 'react-dom/client'; import { describe, it, expect, beforeEach } from 'vitest'; -import { useCanvasNavigation } from './useCanvasNavigation'; -import { useCanvasStore } from '../state/store'; +import { useCanvasNavigation } from '../src/react/useCanvasNavigation'; +import { useCanvasStore } from '../src/state/store'; function TestHost(props: { options?: Parameters[1]; @@ -38,9 +38,12 @@ async function render(ui: React.ReactElement) { } function dispatchKey(el: Element, key: string, code?: string, init?: KeyboardEventInit) { + // Default to layout-independent code for letter keys, e.g. 'a' -> 'KeyA' + const resolvedCode = + code ?? (key.length === 1 && /[a-zA-Z]/.test(key) ? `Key${key.toUpperCase()}` : undefined); const ev = new KeyboardEvent('keydown', { key, - code, + code: resolvedCode, bubbles: true, cancelable: true, ...init, diff --git a/tsconfig.json b/tsconfig.json index 6dd4658..e1a474e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,6 @@ "noEmit": false, "sourceMap": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "tests", "src/stories/Canvas.stories.tsx"], "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx", "**/__tests__/**"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..80ba4ff --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.{test,spec}.{ts,tsx}', 'tests/**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules/**', 'dist/**', 'storybook-static/**', 'e2e/**'], + environment: 'jsdom', + setupFiles: ['src/test.setup.ts'], + }, +});