From c5a41a9fd136bad9562a3e8860cc1d2707a610d5 Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Sun, 24 Aug 2025 20:12:50 +0300 Subject: [PATCH 01/18] grouping nodes --- .github/workflows/ci.yml | 20 +- README.md | 8 +- bun.lock | 132 +++- doc/tasklist.md | 140 ++-- e2e/canvas-basic.spec.ts | 65 ++ e2e/group-container-drag.spec.ts | 110 +++ e2e/grouping-shortcut.spec.ts | 89 +++ package.json | 16 + playwright.config.ts | 18 + rollup.config.mjs | 7 + src/index.ts | 1 + src/react/BackgroundCells.test.tsx | 1 - src/react/Canvas.boxselect.test.tsx | 485 ++++++++++++ src/react/Canvas.stories.tsx | 180 +++-- src/react/Canvas.tsx | 338 +++++++- src/react/GroupContainersLayer.test.tsx | 181 +++++ src/react/GroupContainersLayer.tsx | 683 +++++++++++++++++ src/react/HistoryCamera.integration.test.tsx | 2 +- src/react/InnerEdit.integration.test.tsx | 231 ++++++ src/react/NodeView.dnd.test.tsx | 45 +- src/react/NodeView.tsx | 65 +- src/react/Rulers.integration.test.tsx | 617 +++++++++++++++ src/react/Rulers.tsx | 448 +++++++++++ .../useCanvasNavigation.clipboard.test.tsx | 182 +++++ .../useCanvasNavigation.grouping.test.tsx | 134 ++++ src/react/useCanvasNavigation.ts | 124 ++- src/state/store.clipboard.test.ts | 108 +++ src/state/store.grouping.test.ts | 182 +++++ src/state/store.guides.history.test.ts | 189 +++++ src/state/store.guides.move-history.test.ts | 93 +++ src/state/store.pasteAtPosition.test.ts | 56 ++ src/state/store.ts | 722 +++++++++++++++++- src/test.setup.ts | 7 + src/types/index.ts | 10 + vitest.config.ts | 10 + 35 files changed, 5509 insertions(+), 190 deletions(-) create mode 100644 e2e/canvas-basic.spec.ts create mode 100644 e2e/group-container-drag.spec.ts create mode 100644 e2e/grouping-shortcut.spec.ts create mode 100644 playwright.config.ts create mode 100644 src/react/Canvas.boxselect.test.tsx create mode 100644 src/react/GroupContainersLayer.test.tsx create mode 100644 src/react/GroupContainersLayer.tsx create mode 100644 src/react/InnerEdit.integration.test.tsx create mode 100644 src/react/Rulers.integration.test.tsx create mode 100644 src/react/Rulers.tsx create mode 100644 src/react/useCanvasNavigation.clipboard.test.tsx create mode 100644 src/react/useCanvasNavigation.grouping.test.tsx create mode 100644 src/state/store.clipboard.test.ts create mode 100644 src/state/store.grouping.test.ts create mode 100644 src/state/store.guides.history.test.ts create mode 100644 src/state/store.guides.move-history.test.ts create mode 100644 src/state/store.pasteAtPosition.test.ts create mode 100644 src/test.setup.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf1e1fe..3957922 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,21 @@ jobs: - name: Build run: bun run build + - name: Size check (size-limit) + run: bun run size:ci + + - 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..9163384 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ npm i @flowscape-ui/canvas-react - 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). + - Left-drag on empty canvas area: box-select 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. @@ -44,7 +44,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,7 +221,7 @@ 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 @@ -266,7 +266,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', diff --git a/bun.lock b/bun.lock index cebd5d3..5c24d78 100644 --- a/bun.lock +++ b/bun.lock @@ -9,13 +9,17 @@ "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", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.0", @@ -23,12 +27,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": "^19.1.1", - "react-dom": "^19.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", "rollup": "^4.21.0", "rollup-plugin-dts": "^6.1.1", + "rollup-plugin-visualizer": "^5.12.0", + "size-limit": "^11.1.5", "storybook": "^9.1.2", "typescript": "5.5.4", "vite": "^5.4.0", @@ -221,6 +229,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@playwright/test": ["@playwright/test@1.55.0", "", { "dependencies": { "playwright": "1.55.0" }, "bin": { "playwright": "cli.js" } }, "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ=="], + "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@25.0.8", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "glob": "^8.0.3", "is-reference": "1.2.1", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A=="], "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@15.3.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA=="], @@ -273,6 +283,8 @@ "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + "@size-limit/file": ["@size-limit/file@11.2.0", "", { "peerDependencies": { "size-limit": "11.2.0" } }, "sha512-OZHE3putEkQ/fgzz3Tp/0hSmfVo3wyTpOJSRNm6AmcwX4Nm9YtTfbQQ/hZRwbBFR23S7x2Sd9EbqYzngKwbRoA=="], + "@storybook/builder-vite": ["@storybook/builder-vite@9.1.2", "", { "dependencies": { "@storybook/csf-plugin": "9.1.2", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^9.1.2", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-5Y7e5wnSzFxCGP63UNRRZVoxHe1znU4dYXazJBobAlEcUPBk7A0sH2716tA6bS4oz92oG9tgvn1g996hRrw4ow=="], "@storybook/csf-plugin": ["@storybook/csf-plugin@9.1.2", "", { "dependencies": { "unplugin": "^1.3.1" }, "peerDependencies": { "storybook": "^9.1.2" } }, "sha512-bfMh6r+RieBLPWtqqYN70le2uTE4JzOYPMYSCagHykUti3uM/1vRFaZNkZtUsRy5GwEzE5jLdDXioG1lOEeT2Q=="], @@ -317,6 +329,8 @@ "@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="], + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@7.18.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/type-utils": "7.18.0", "@typescript-eslint/utils": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.56.0" } }, "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw=="], @@ -391,12 +405,16 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], + "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="], "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], @@ -407,6 +425,8 @@ "browserslist": ["browserslist@4.25.3", "", { "dependencies": { "caniuse-lite": "^1.0.30001735", "electron-to-chromium": "^1.5.204", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ=="], + "bytes-iec": ["bytes-iec@3.1.1", "", {}, "sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], @@ -427,8 +447,12 @@ "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -439,6 +463,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "corser": ["corser@2.0.1", "", {}, "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], @@ -491,7 +517,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.206", "", {}, "sha512-/eucXSTaI8L78l42xPurxdBzPTjAkMVCQO7unZCWk9LnZiwKcSvQUhF4c99NWQLwMQXxjlfoQy0+8m9U2yEDQQ=="], - "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], @@ -555,6 +581,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], @@ -569,6 +597,8 @@ "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -579,6 +609,8 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], @@ -597,6 +629,8 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], @@ -633,10 +667,16 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@3.0.0", "", { "dependencies": { "whatwg-encoding": "^2.0.0" } }, "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA=="], + + "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http-server": ["http-server@14.1.1", "", { "dependencies": { "basic-auth": "^2.0.1", "chalk": "^4.1.2", "corser": "^2.0.1", "he": "^1.2.0", "html-encoding-sniffer": "^3.0.0", "http-proxy": "^1.18.1", "mime": "^1.6.0", "minimist": "^1.2.6", "opener": "^1.5.1", "portfinder": "^1.0.28", "secure-compare": "3.0.1", "union": "~0.5.0", "url-join": "^4.0.1" }, "bin": { "http-server": "bin/http-server" } }, "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-id": ["human-id@4.1.1", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg=="], @@ -733,12 +773,16 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + "jsdom-global": ["jsdom-global@3.0.2", "", { "peerDependencies": { "jsdom": ">=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..c44d78c 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,17 +168,70 @@ - Темизация/фон (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..09f9b38 --- /dev/null +++ b/e2e/group-container-drag.spec.ts @@ -0,0 +1,110 @@ +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']); + }); + + // Ensure the group container for n1 is present + const containerHit = page.locator( + '[data-testid="group-container"][data-parent-id="n1"] [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..6b40cc7 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 --ci", + "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/BackgroundCells.test.tsx b/src/react/BackgroundCells.test.tsx index e343473..118fc71 100644 --- a/src/react/BackgroundCells.test.tsx +++ b/src/react/BackgroundCells.test.tsx @@ -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/Canvas.boxselect.test.tsx b/src/react/Canvas.boxselect.test.tsx new file mode 100644 index 0000000..13ff3af --- /dev/null +++ b/src/react/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 './Canvas'; +import { useCanvasNavigation } from './useCanvasNavigation'; +import { useCanvasStore } from '../state/store'; +import type { CanvasStore } from '../state/store'; +import type { Node } from '../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/src/react/Canvas.stories.tsx b/src/react/Canvas.stories.tsx index 74f2d13..9f25262 100644 --- a/src/react/Canvas.stories.tsx +++ b/src/react/Canvas.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import React, { useRef, useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { Canvas } from './Canvas'; import { BackgroundDots } from './BackgroundDots'; import { BackgroundCells } from './BackgroundCells'; @@ -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 { GroupContainersLayer } from './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) { ) } > + -
-
+ {args.showHints && ( +
+
-
Selection tips
-
• Click node: select only
-
• Ctrl/Cmd + Click: toggle in selection
-
• Click empty area (no drag): clear selection
+
Selection
+
• Click: select • Ctrl+Click: toggle • Drag empty: box-select
-
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)
+
Navigation
+
• Wheel: pan • Shift+Wheel: pan horizontal • Ctrl+Wheel: zoom
-
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)
+
Grouping
+
• Press Ctrl/Cmd+G to group selected nodes
+
• Dashed rectangles show groups with invisible hit areas for drag/drop
{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 -
+
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/react/Canvas.tsx b/src/react/Canvas.tsx index 364a153..a0d9540 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, 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,124 @@ 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) + 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; + 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; + } + }; + + 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); + } + clearSelection(); + for (const id of hits) addToSelection(id); + } + 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 +162,178 @@ 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; } }; 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); + } + // Во время drag показываем только текущее «хит» выделение (replace превью) + clearSelection(); + for (const id of hits) addToSelection(id); 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); + } } }; 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); + } + const additive = e.ctrlKey || e.metaKey; + const snapshot = initialSelectionRef.current || {}; clearSelection(); + if (additive) { + for (const id of Object.keys(snapshot) as NodeId[]) addToSelection(id); + } + for (const id of hits) addToSelection(id); + + // сбрасываем состояние box-select и освобождаем захват указателя + setBoxStart(null); + setBoxEnd(null); + initialSelectionRef.current = null; + worldStartRef.current = null; + stopAutoPan(); + 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 + } + } } shouldMaybeDeselectRef.current = false; }; const onPointerCancel: React.PointerEventHandler = () => { shouldMaybeDeselectRef.current = false; + stopAutoPan(); }; const onContextMenu: React.MouseEventHandler = (e) => { @@ -85,11 +350,52 @@ export const Canvas = forwardRef(function Canvas( } }; + // Подготовим оверлей прямоугольника + 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); + overlay = ( +
+ ); + } + return (
(function Canvas( > {background}
-
{children}
+
+ {showRulers ? : null} + {children} +
+ {overlay}
); }); diff --git a/src/react/GroupContainersLayer.test.tsx b/src/react/GroupContainersLayer.test.tsx new file mode 100644 index 0000000..314678c --- /dev/null +++ b/src/react/GroupContainersLayer.test.tsx @@ -0,0 +1,181 @@ +/* @vitest-environment jsdom */ + +import React, { act } from 'react'; +import ReactDOM from 'react-dom/client'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { GroupContainersLayer } from './GroupContainersLayer'; +import { useCanvasStore } from '../state/store'; +import type { Node } from '../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)!; + + const PAD = 8; // at zoom=1 + + // 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) - PAD; + const topP = Math.min(0, 20, 120, 40) - PAD; + const rightP = Math.max(0 + 80, 120 + 60, 40 + 50, 160 + 30) + PAD; + const bottomP = Math.max(0 + 40, 20 + 30, 120 + 50, 40 + 30) + PAD; + 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) - PAD; + const topC1 = Math.min(20, 40) - PAD; + const rightC1 = Math.max(120 + 60, 160 + 30) + PAD; + const bottomC1 = Math.max(20 + 30, 40 + 30) + PAD; + 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('applies fixed 8px padding regardless of zoom (converted to world units)', 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(); + const PAD = 8 / 2; // 4 world units + + const left = Math.min(10, 70) - PAD; + const top = Math.min(20, 40) - PAD; + const right = Math.max(10 + 40, 70 + 30) + PAD; + const bottom = Math.max(20 + 20, 40 + 30) + PAD; + 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/GroupContainersLayer.tsx b/src/react/GroupContainersLayer.tsx new file mode 100644 index 0000000..93323ed --- /dev/null +++ b/src/react/GroupContainersLayer.tsx @@ -0,0 +1,683 @@ +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); + + // 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]); + + // Visual padding of 8 CSS px regardless of zoom => convert to world units + const PAD_WORLD = 8 / (camera.zoom || 1); + + const DRAG_THRESHOLD_PX = 3; + + // Current multi-selection bbox (>=2 nodes) for the temporary overlay + const selectedIds = useSelectedIds(); + const selectionBBox = useMemo(() => { + if (!selectedIds || selectedIds.length < 2) return null; + const sel = new Set(selectedIds as NodeId[]); + let left = Infinity, + top = Infinity, + right = -Infinity, + bottom = -Infinity; + 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); + } + if (left === Infinity) return null; + return { left, top, right, bottom }; + }, [nodes, selectedIds]); + + // 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); + } 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); + } 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)); + } 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 with padding + let x = c.left - PAD_WORLD; + let y = c.top - PAD_WORLD; + let w = c.right - c.left + PAD_WORLD * 2; + let h = c.bottom - c.top + PAD_WORLD * 2; + + // 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 || + (dragRef.current && dragRef.current.parentId === (c.groupId as unknown as NodeId) && dragRef.current.dragging); + return ( + + {/* Visual stroke only when hovered or dragging */} + {isActive && ( + + )} + {/* Invisible hit rect over full area for drag/select */} + + + ); + })} +
+ + {/* Temporary selection container above nodes (visible when 2+ nodes are selected) */} + {selectionBBox && + (() => { + // Apply same visual padding and snapping rules + let x = selectionBBox.left - PAD_WORLD; + let y = selectionBBox.top - PAD_WORLD; + let w = selectionBBox.right - selectionBBox.left + PAD_WORLD * 2; + let h = selectionBBox.bottom - selectionBBox.top + PAD_WORLD * 2; + 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/HistoryCamera.integration.test.tsx b/src/react/HistoryCamera.integration.test.tsx index 645791c..778ecb2 100644 --- a/src/react/HistoryCamera.integration.test.tsx +++ b/src/react/HistoryCamera.integration.test.tsx @@ -11,7 +11,7 @@ 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, diff --git a/src/react/InnerEdit.integration.test.tsx b/src/react/InnerEdit.integration.test.tsx new file mode 100644 index 0000000..36893bc --- /dev/null +++ b/src/react/InnerEdit.integration.test.tsx @@ -0,0 +1,231 @@ +/* @vitest-environment jsdom */ + +import React, { useRef, act } from 'react'; +import ReactDOM from 'react-dom/client'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCanvasNavigation } from './useCanvasNavigation'; +import { Canvas } from './Canvas'; +import { NodeView } from './NodeView'; +import { useCanvasStore } from '../state/store'; +import type { CanvasStore } from '../state/store'; +import type { Node } from '../types'; + +// ---- PointerEvent polyfill (as used in other tests) ---- + +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 scaffolding ---- + +function TestHost(props: { withNode?: boolean }) { + const ref = useRef(null); + useCanvasNavigation(ref, { + panButton: 1, + panModifier: 'none', + wheelZoom: false, + // Enable double-click zoom to verify it's ignored when dblclick originates from node + doubleClickZoom: true, + }); + + return ( + + {props.withNode ? ( + + ) : 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); + }); + // 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 dispatchPointer(target: EventTarget, type: string, init: PointerEventInit) { + const ev = new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 1, + ...init, + }); + target.dispatchEvent(ev); +} + +function dispatchDblClick(target: EventTarget, init?: MouseEventInit) { + const ev = new MouseEvent('dblclick', { bubbles: true, cancelable: true, button: 0, ...init }); + target.dispatchEvent(ev); +} + +function dispatchKey(el: Element, key: string, code?: string, init?: KeyboardEventInit) { + const ev = new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...init }); + el.dispatchEvent(ev); +} + +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 resetStore() { + useCanvasStore.setState({ + camera: { zoom: 1, offsetX: 0, offsetY: 0 }, + nodes: {}, + selected: {}, + centerAddIndex: 0, + historyPast: [], + historyFuture: [], + historyBatch: null, + innerEditNodeId: null, + } as Partial); +} + +beforeEach(() => { + resetStore(); +}); + +describe('Inner-edit interactions', () => { + it('double-clicking a node enters inner-edit and does not trigger canvas zoom', async () => { + // Arrange: one node on canvas + useCanvasStore.setState((s) => ({ + ...s, + nodes: { n1: { id: 'n1', x: 100, y: 100, width: 120, height: 60 } as Node }, + })); + + const { container, unmount } = await render(); + const canvas = (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; + const nodeEl = (container.querySelector('[data-rc-nodeid="n1"]') || + document.querySelector('[data-rc-nodeid="n1"]')) as HTMLElement; + expect(canvas).toBeTruthy(); + expect(nodeEl).toBeTruthy(); + + // Stub rect to make zoom center computations safe if ever called + const restoreRect = stubRect(canvas); + + // Act: double-click the node + dispatchDblClick(nodeEl); + + // Assert: inner-edit entered and camera zoom unchanged + const s = useCanvasStore.getState(); + expect(s.innerEditNodeId).toBe('n1'); + expect(s.camera.zoom).toBe(1); + + restoreRect(); + await unmount(); + }); + + it('pressing Escape exits inner-edit mode', async () => { + // Arrange: active inner-edit + useCanvasStore.setState((s) => ({ ...s, innerEditNodeId: 'n1' })); + + const { container, unmount } = await render(); + const canvas = (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; + + // Act: press Escape on canvas + dispatchKey(canvas, 'Escape', 'Escape'); + + // Assert: inner-edit cleared + expect(useCanvasStore.getState().innerEditNodeId).toBeNull(); + + await unmount(); + }); + + it('true click on empty canvas exits inner-edit mode', async () => { + // Arrange: activate inner-edit and selection (selection not strictly required) + useCanvasStore.setState((s) => ({ + ...s, + innerEditNodeId: 'n1', + nodes: { n1: { id: 'n1', x: 0, y: 0, width: 50, height: 30 } as Node }, + })); + + const { container, unmount } = await render(); + const canvas = (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; + + // Ensure deterministic geometry + const restoreRect = stubRect(canvas); + + // Act: simple click (no drag) + dispatchPointer(canvas, 'pointerdown', { button: 0, clientX: 400, clientY: 300 }); + dispatchPointer(canvas, 'pointerup', { button: 0, clientX: 400, clientY: 300 }); + + // Assert: inner-edit cleared + expect(useCanvasStore.getState().innerEditNodeId).toBeNull(); + + restoreRect(); + await unmount(); + }); +}); diff --git a/src/react/NodeView.dnd.test.tsx b/src/react/NodeView.dnd.test.tsx index 144fe8f..7e8a077 100644 --- a/src/react/NodeView.dnd.test.tsx +++ b/src/react/NodeView.dnd.test.tsx @@ -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/src/react/NodeView.tsx b/src/react/NodeView.tsx index 5d92805..dcd938d 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 type { Node, NodeId } from '../types'; import { useDndActions, useIsSelected, @@ -7,6 +7,8 @@ import { useCamera, useCanvasActions, useHistoryActions, + useCanvasStore, + useInnerEditActions, } from '../state/store'; type NodeAppearance = { @@ -46,7 +48,9 @@ export const NodeView = forwardRef(function NodeV 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); @@ -74,6 +78,14 @@ export const NodeView = forwardRef(function NodeV fontSize: 14, fontWeight: 600, }; + + const onDoubleClick: React.MouseEventHandler = (e) => { + // Enter inner-edit on this node. Left button only. + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + enterInnerEdit(node.id); + }; const A = { ...defaultAppearance, ...(appearance ?? {}) } as NodeAppearance; const hasCustomChildren = children != null; const contentLabel = 'New Node'; @@ -169,12 +181,43 @@ export const NodeView = forwardRef(function NodeV const onPointerDown: React.PointerEventHandler = (e) => { // Left button only if (e.button !== 0) return; + // Determine which id to act on. + 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; + }; + // Compute group root by walking parentId chain + const rootId: NodeId = (() => { + let cur: NodeId | null = node.id as NodeId; + while (cur != null) { + const p: NodeId | null = (st.nodes[cur] && st.nodes[cur].parentId) || null; + if (p == null) break; + cur = p; + } + return cur as NodeId; + })(); + // If in inner-edit and clicked inside scope -> act on the node itself; else act on root + const clickedInside = innerEditId ? isWithinScope(node.id as NodeId) : false; + if (innerEditId && !clickedInside) { + // Clicked outside inner-edit -> leave it + exitInnerEdit(); + } + const targetId = clickedInside ? (node.id as NodeId) : (rootId || node.id); + // 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); + toggleInSelection(targetId); + } else if (!useCanvasStore.getState().selected[targetId]) { + // If target is not selected, select only it. If already selected, preserve current multi-selection + selectOnly(targetId); } // Stop propagation so canvas navigation doesn't start panning on node click e.stopPropagation(); @@ -259,6 +302,7 @@ export const NodeView = forwardRef(function NodeV } catch { // ignore } + // No auto-grouping on drop; grouping is Ctrl/Cmd+G only finishDrag(); }; @@ -287,12 +331,12 @@ export const NodeView = forwardRef(function NodeV boxShadow: unstyled ? undefined : isHovered - ? A.hoverShadow - : isSelected - ? A.selectedShadow || A.shadow - : A.shadow, + ? A.hoverShadow + : isSelected + ? 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,6 +348,7 @@ export const NodeView = forwardRef(function NodeV onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} + onDoubleClick={onDoubleClick} onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)} data-rc-nodeid={node.id} diff --git a/src/react/Rulers.integration.test.tsx b/src/react/Rulers.integration.test.tsx new file mode 100644 index 0000000..ab1ede8 --- /dev/null +++ b/src/react/Rulers.integration.test.tsx @@ -0,0 +1,617 @@ +/* @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 './Canvas'; +import { useCanvasNavigation } from './useCanvasNavigation'; +import { useCanvasStore } from '../state/store'; +import type { CanvasStore } from '../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', 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', + 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', 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/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.clipboard.test.tsx b/src/react/useCanvasNavigation.clipboard.test.tsx new file mode 100644 index 0000000..3995d41 --- /dev/null +++ b/src/react/useCanvasNavigation.clipboard.test.tsx @@ -0,0 +1,182 @@ +/* @vitest-environment jsdom */ + +import React, { useRef, act } from 'react'; +import ReactDOM from 'react-dom/client'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCanvasNavigation } from './useCanvasNavigation'; +import { useCanvasStore } from '../state/store'; +import type { CanvasStore } from '../state/store'; + +function TestHost(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) { + const ev = new KeyboardEvent('keydown', { + key, + code, + 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/src/react/useCanvasNavigation.grouping.test.tsx b/src/react/useCanvasNavigation.grouping.test.tsx new file mode 100644 index 0000000..fa63746 --- /dev/null +++ b/src/react/useCanvasNavigation.grouping.test.tsx @@ -0,0 +1,134 @@ +/* @vitest-environment jsdom */ + +import React, { useRef, act } from 'react'; +import ReactDOM from 'react-dom/client'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCanvasNavigation } from './useCanvasNavigation'; +import { useCanvasStore } from '../state/store'; +import type { CanvasStore } from '../state/store'; + +function TestHost(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) { + const ev = new KeyboardEvent('keydown', { + key, + code, + 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.ts b/src/react/useCanvasNavigation.ts index 93c4d2a..96dca3c 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.key === 'Escape') { + const { innerEditNodeId, exitInnerEdit } = useCanvasStore.getState(); + if (innerEditNodeId) { + e.preventDefault(); + exitInnerEdit(); + return; + } + } + const { camera, panBy, zoomByAt } = useCanvasStore.getState(); const invZoom = 1 / camera.zoom; @@ -372,6 +426,69 @@ export function useCanvasNavigation( } } + // Clipboard: Ctrl/Cmd + C / X / V + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) { + const keyLower = e.key.toLowerCase(); + if (keyLower === 'g') { + const { selected, createVisualGroupFromSelection } = useCanvasStore.getState(); + if (Object.keys(selected).length >= 2) { + e.preventDefault(); + createVisualGroupFromSelection(); + return; + } + } + if (keyLower === 'c') { + const { selected, copySelection } = useCanvasStore.getState(); + if (Object.keys(selected).length > 0) { + e.preventDefault(); + copySelection(); + return; + } + } else if (keyLower === 'x') { + const { selected, cutSelection } = useCanvasStore.getState(); + if (Object.keys(selected).length > 0) { + e.preventDefault(); + cutSelection(); + return; + } + } else if (keyLower === 'v') { + 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 keyLower = e.key.toLowerCase(); + if (keyLower === 'h') { + 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 +521,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..28e0e34 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,18 @@ export type CanvasState = { readonly nodes: Record; /** Map of selected node IDs for O(1) membership checks. */ readonly selected: Record; + /** 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: 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 +54,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 +68,18 @@ 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; + // 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 +88,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 +104,13 @@ 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; + // Clipboard + copySelection: () => void; + cutSelection: () => void; + pasteClipboard: (position?: Point) => void; // History (CORE-06) beginHistory: (label?: string) => void; endHistory: () => void; @@ -73,9 +123,18 @@ export type CanvasStore = CanvasState & CanvasActions; const initialCamera: Camera = { zoom: 1, offsetX: 0, offsetY: 0 }; const initialNodes: Record = {}; const initialSelected: Record = {}; +const initialVisualGroups: CanvasState['visualGroups'] = {}; +const initialSelectedVisualGroupId: CanvasState['selectedVisualGroupId'] = 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 +144,18 @@ export const useCanvasStore = create()((set, get) => ({ camera: initialCamera, nodes: initialNodes, selected: initialSelected, + visualGroups: initialVisualGroups, + selectedVisualGroupId: initialSelectedVisualGroupId, + 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 +172,136 @@ 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 }), + + // --- 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 +412,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 +428,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 +470,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 +511,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 +530,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 +546,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 +570,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 +590,163 @@ 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) { + 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 +825,157 @@ export const useCanvasStore = create()((set, get) => ({ return { selected: { ...s.selected, [id]: true } } as Partial as CanvasStore; }), + // Visual groups (UI-only) + createVisualGroupFromSelection: () => + set((s) => { + const memberIds = Object.keys(s.selected) as NodeId[]; + if (!memberIds || memberIds.length < 2) return {} as Partial as CanvasStore; + // Create a stable-ish id + const id = `vg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const vg = { id, members: memberIds.slice() }; + return { + visualGroups: { ...s.visualGroups, [id]: vg }, + selectedVisualGroupId: id, + } as Partial as CanvasStore; + }), + selectVisualGroup: (id) => set({ selectedVisualGroupId: 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 +1016,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 +1024,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 +1080,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 +1187,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 +1238,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 +1308,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 +1328,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/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/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..47c06b9 --- /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}'], + exclude: ['node_modules/**', 'dist/**', 'storybook-static/**', 'e2e/**'], + environment: 'jsdom', + setupFiles: ['src/test.setup.ts'], + }, +}); From 9d0d3f061e83c762e31df283fa3777c5c52e9ac4 Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Mon, 25 Aug 2025 01:02:59 +0300 Subject: [PATCH 02/18] update grouping nodes --- doc/tasklist.md | 2 +- e2e/drag-scope-toggle.spec.ts | 199 ++++++++++++++ e2e/group-container-drag.spec.ts | 23 +- src/react/Canvas.stories.tsx | 110 ++++---- src/react/Canvas.tsx | 58 ++++- src/react/GroupContainersLayer.tsx | 21 +- src/react/InnerEdit.integration.test.tsx | 231 ----------------- src/react/NodeView.tsx | 316 +++++++++++++++++++++-- src/state/store.ts | 37 ++- 9 files changed, 675 insertions(+), 322 deletions(-) create mode 100644 e2e/drag-scope-toggle.spec.ts delete mode 100644 src/react/InnerEdit.integration.test.tsx diff --git a/doc/tasklist.md b/doc/tasklist.md index c44d78c..79076d9 100644 --- a/doc/tasklist.md +++ b/doc/tasklist.md @@ -231,7 +231,7 @@ - Надёжность: стабильные прогоны E2E (Playwright) на CI; падения сопровождаются trace/video для анализа. --- - + ## Процессы (подробно) - Разработка: diff --git a/e2e/drag-scope-toggle.spec.ts b/e2e/drag-scope-toggle.spec.ts new file mode 100644 index 0000000..1b28b2c --- /dev/null +++ b/e2e/drag-scope-toggle.spec.ts @@ -0,0 +1,199 @@ +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'; + +async function getNode(page: import('@playwright/test').Page, id: string) { + return await page.evaluate((nodeId) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + return store.getState().nodes[nodeId]; + }, 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-dragscope'); + // Space them out for reliable clicks + updateNode('n1', { x: 800, y: 300 }); + updateNode('n2', { x: 1000, y: 320 }); + endHistory(); + }); +} + +async function createTwoNodesAndGroup(page: import('@playwright/test').Page) { + const canvas = page.locator('[data-rc-canvas]'); + const addBtn = page.getByRole('button', { name: 'Add' }); + await addBtn.click(); // n1 + await addBtn.click(); // n2 + await repositionNodes(page); + + const n1 = page.locator('[data-rc-nodeid="n1"]'); + const n2 = page.locator('[data-rc-nodeid="n2"]'); + await expect(n1).toBeVisible(); + await expect(n2).toBeVisible(); + + // Select n1 then Ctrl+Click n2 to multi-select + await n1.click(); + await n2.click({ modifiers: ['Control'] }); + await canvas.focus(); + + // Group via Ctrl+G + await page.keyboard.press('Control+g'); +} + +function center(box: { x: number; y: number; width: number; height: number }) { + return { cx: box.x + box.width / 2, cy: box.y + box.height / 2 }; +} + +async function clickEmptyCanvas(page: import('@playwright/test').Page) { + const canvas = page.locator('[data-rc-canvas]'); + const cbox = await canvas.boundingBox(); + if (!cbox) throw new Error('Canvas box not found'); + const x = cbox.x + 10; + const y = cbox.y + 10; + await page.mouse.move(x, y); + await page.mouse.click(x, y); +} + +async function dragNode(page: import('@playwright/test').Page, id: string, dx: number, dy: number) { + const loc = page.locator(`[data-rc-nodeid="${id}"]`); + const box = await loc.boundingBox(); + if (!box) throw new Error(`Bounding box for ${id} not found`); + const { cx, cy } = center(box); + await page.mouse.move(cx, cy); + await page.mouse.down(); + await page.mouse.move(cx + dx, cy + dy, { steps: 10 }); + await page.mouse.up(); +} + +async function doubleClick(page: import('@playwright/test').Page, selector: string) { + await page.locator(selector).dblclick(); +} + +async function doubleClickAndHoldDrag( + page: import('@playwright/test').Page, + selector: string, + dx: number, + dy: number, +) { + const loc = page.locator(selector); + const box = await loc.boundingBox(); + if (!box) throw new Error('Bounding box not found'); + const { cx, cy } = center(box); + await page.mouse.move(cx, cy); + // First click + await page.mouse.down(); + await page.mouse.up(); + // Small delay under double-click threshold + await page.waitForTimeout(50); + // Second click and hold, then drag + await page.mouse.down(); + await page.mouse.move(cx + dx, cy + dy, { steps: 10 }); + await page.mouse.up(); +} + +// Test 1: Two separate double-clicks toggle to node-only; drag moves only n1 +test('double-click toggling to node: drag moves only the clicked node', async ({ page }) => { + await page.goto(storyUrl); + + await createTwoNodesAndGroup(page); + + const n1Sel = '[data-rc-nodeid="n1"]'; + + // Clear selection to avoid selection overlay intercepting dblclick + await clickEmptyCanvas(page); + + // First double-click -> groupLocal (no visible change for single-level group) + await doubleClick(page, n1Sel); + // Second double-click -> node mode + await doubleClick(page, n1Sel); + + // Wait for inner-edit to target n1 to guarantee node-only scope + await page.waitForFunction(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + return store && store.getState().innerEditNodeId === 'n1'; + }); + + const before1 = await getNode(page, 'n1'); + const before2 = await getNode(page, 'n2'); + + await dragNode(page, 'n1', 80, 0); + + const after1 = await getNode(page, 'n1'); + const after2 = await getNode(page, 'n2'); + + expect(after1.x - before1.x).toBeGreaterThan(60); + expect(after2.x).toBeCloseTo(before2.x, 0); // n2 did not move +}); + +// Test 2: Double-click-and-hold pre-toggles to node; drag within hold moves only n1 +test('double-click-and-hold: pre-toggle to node and drag only n1', async ({ page }) => { + await page.goto(storyUrl); + + await createTwoNodesAndGroup(page); + + const before1 = await getNode(page, 'n1'); + const before2 = await getNode(page, 'n2'); + + await doubleClickAndHoldDrag(page, '[data-rc-nodeid="n1"]', 70, 0); + + const after1 = await getNode(page, 'n1'); + const after2 = await getNode(page, 'n2'); + + expect(after1.x - before1.x).toBeGreaterThan(50); + expect(after2.x).toBeCloseTo(before2.x, 0); +}); + +// Test 3: Clicking outside resets drag scope; drag moves group again +test('outside click resets to default: drag moves the group again', async ({ page }) => { + await page.goto(storyUrl); + + await createTwoNodesAndGroup(page); + + // Enter node mode + const n1Sel = '[data-rc-nodeid="n1"]'; + // Clear selection to avoid overlay intercept + await clickEmptyCanvas(page); + await doubleClick(page, n1Sel); // -> groupLocal + await doubleClick(page, n1Sel); // -> node + + // Wait for inner-edit to target n1 to guarantee node-only scope + await page.waitForFunction(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + return store && store.getState().innerEditNodeId === 'n1'; + }); + + // Click outside the group: on empty canvas area (top-left) + const canvas = page.locator('[data-rc-canvas]'); + const cbox = await canvas.boundingBox(); + if (!cbox) throw new Error('Canvas box not found'); + await page.mouse.move(cbox.x + 10, cbox.y + 10); + await page.mouse.down(); + await page.mouse.up(); + + // Wait for inner-edit to be cleared by document-level handler + await page.waitForFunction(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__RC_STORE; + return store && store.getState().innerEditNodeId == null; + }); + + const before1 = await getNode(page, 'n1'); + const before2 = await getNode(page, 'n2'); + + // Drag n1; default selects the group and moves both + await dragNode(page, 'n1', 60, 0); + + const after1 = await getNode(page, 'n1'); + const after2 = await getNode(page, 'n2'); + + expect(after1.x - before1.x).toBeGreaterThan(40); + expect(after2.x - before2.x).toBeGreaterThan(40); +}); diff --git a/e2e/group-container-drag.spec.ts b/e2e/group-container-drag.spec.ts index 09f9b38..429898e 100644 --- a/e2e/group-container-drag.spec.ts +++ b/e2e/group-container-drag.spec.ts @@ -1,7 +1,8 @@ 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'; +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) { @@ -69,9 +70,25 @@ test('dragging a group container moves all descendants (including grandchildren) store.getState().groupNodes('n2', ['n3']); }); - // Ensure the group container for n1 is present + // 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="n1"] [data-testid="group-container-hit"]', + `[data-testid="group-container"][data-parent-id="${vgId}"] [data-testid="group-container-hit"]`, ); await expect(containerHit).toBeVisible(); diff --git a/src/react/Canvas.stories.tsx b/src/react/Canvas.stories.tsx index 9f25262..4ec9287 100644 --- a/src/react/Canvas.stories.tsx +++ b/src/react/Canvas.stories.tsx @@ -512,61 +512,16 @@ function Playground(args: CanvasStoryArgs) { right: 20, zIndex: 10, }} - > -
-
Selection
-
• Click: select • Ctrl+Click: toggle • Drag empty: box-select
-
-
-
Navigation
-
• Wheel: pan • Shift+Wheel: pan horizontal • Ctrl+Wheel: zoom
-
-
-
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
+
Selection
+
• Click: select • Ctrl+Click: toggle • Drag empty: box-select
- ) : null} -
- +
+
Navigation
+
• Wheel: pan • Shift+Wheel: pan horizontal • Ctrl+Wheel: zoom
+
+
+
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} +
+
)}
diff --git a/src/react/Canvas.tsx b/src/react/Canvas.tsx index a0d9540..2fe01b2 100644 --- a/src/react/Canvas.tsx +++ b/src/react/Canvas.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useRef, useState } from 'react'; +import React, { forwardRef, useEffect, useRef, useState } from 'react'; import { useSelectionActions, useHistoryActions, @@ -74,6 +74,48 @@ export const Canvas = forwardRef(function Canvas( } }; + // 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 keyLower = e.key.toLowerCase(); + if (keyLower === 'g') { + 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) { @@ -348,6 +390,19 @@ export const Canvas = forwardRef(function Canvas( if (e.shiftKey) redo(); else undo(); } + + // Grouping: Ctrl/Cmd + G (works even if useCanvasNavigation hook isn't attached) + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) { + const keyLower = e.key.toLowerCase(); + if (keyLower === 'g') { + const { selected, createVisualGroupFromSelection } = useCanvasStore.getState(); + if (Object.keys(selected).length >= 2) { + e.preventDefault(); + createVisualGroupFromSelection(); + return; + } + } + } }; // Подготовим оверлей прямоугольника @@ -398,6 +453,7 @@ export const Canvas = forwardRef(function Canvas( }} tabIndex={tabIndex} data-rc-canvas + onPointerDownCapture={onPointerDownCapture} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} diff --git a/src/react/GroupContainersLayer.tsx b/src/react/GroupContainersLayer.tsx index 93323ed..cfdecb7 100644 --- a/src/react/GroupContainersLayer.tsx +++ b/src/react/GroupContainersLayer.tsx @@ -44,6 +44,9 @@ export function GroupContainersLayer() { // 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(() => { @@ -292,6 +295,8 @@ export function GroupContainersLayer() { // Always clear hover on finish so the stroke hides after drop try { setHoveredGroupId(null); + const { setHoveredVisualGroupId } = useCanvasStore.getState(); + setHoveredVisualGroupId(null); } catch { // ignore } @@ -329,6 +334,8 @@ export function GroupContainersLayer() { const onPointerEnter: React.PointerEventHandler = () => { try { setHoveredGroupId(groupId); + const { setHoveredVisualGroupId } = useCanvasStore.getState(); + setHoveredVisualGroupId(groupId); } catch { // ignore } @@ -339,6 +346,10 @@ export function GroupContainersLayer() { 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 } @@ -525,7 +536,11 @@ export function GroupContainersLayer() { 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 only when hovered or dragging */} - {isActive && ( + {/* Visual stroke when hovered/dragging OR when the group is selected */} + {showStroke && ( (null); - useCanvasNavigation(ref, { - panButton: 1, - panModifier: 'none', - wheelZoom: false, - // Enable double-click zoom to verify it's ignored when dblclick originates from node - doubleClickZoom: true, - }); - - return ( - - {props.withNode ? ( - - ) : 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); - }); - // 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 dispatchPointer(target: EventTarget, type: string, init: PointerEventInit) { - const ev = new PointerEvent(type, { - bubbles: true, - cancelable: true, - pointerId: 1, - ...init, - }); - target.dispatchEvent(ev); -} - -function dispatchDblClick(target: EventTarget, init?: MouseEventInit) { - const ev = new MouseEvent('dblclick', { bubbles: true, cancelable: true, button: 0, ...init }); - target.dispatchEvent(ev); -} - -function dispatchKey(el: Element, key: string, code?: string, init?: KeyboardEventInit) { - const ev = new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...init }); - el.dispatchEvent(ev); -} - -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 resetStore() { - useCanvasStore.setState({ - camera: { zoom: 1, offsetX: 0, offsetY: 0 }, - nodes: {}, - selected: {}, - centerAddIndex: 0, - historyPast: [], - historyFuture: [], - historyBatch: null, - innerEditNodeId: null, - } as Partial); -} - -beforeEach(() => { - resetStore(); -}); - -describe('Inner-edit interactions', () => { - it('double-clicking a node enters inner-edit and does not trigger canvas zoom', async () => { - // Arrange: one node on canvas - useCanvasStore.setState((s) => ({ - ...s, - nodes: { n1: { id: 'n1', x: 100, y: 100, width: 120, height: 60 } as Node }, - })); - - const { container, unmount } = await render(); - const canvas = (container.querySelector('[data-rc-canvas]') || - document.querySelector('[data-rc-canvas]')) as HTMLDivElement; - const nodeEl = (container.querySelector('[data-rc-nodeid="n1"]') || - document.querySelector('[data-rc-nodeid="n1"]')) as HTMLElement; - expect(canvas).toBeTruthy(); - expect(nodeEl).toBeTruthy(); - - // Stub rect to make zoom center computations safe if ever called - const restoreRect = stubRect(canvas); - - // Act: double-click the node - dispatchDblClick(nodeEl); - - // Assert: inner-edit entered and camera zoom unchanged - const s = useCanvasStore.getState(); - expect(s.innerEditNodeId).toBe('n1'); - expect(s.camera.zoom).toBe(1); - - restoreRect(); - await unmount(); - }); - - it('pressing Escape exits inner-edit mode', async () => { - // Arrange: active inner-edit - useCanvasStore.setState((s) => ({ ...s, innerEditNodeId: 'n1' })); - - const { container, unmount } = await render(); - const canvas = (container.querySelector('[data-rc-canvas]') || - document.querySelector('[data-rc-canvas]')) as HTMLDivElement; - - // Act: press Escape on canvas - dispatchKey(canvas, 'Escape', 'Escape'); - - // Assert: inner-edit cleared - expect(useCanvasStore.getState().innerEditNodeId).toBeNull(); - - await unmount(); - }); - - it('true click on empty canvas exits inner-edit mode', async () => { - // Arrange: activate inner-edit and selection (selection not strictly required) - useCanvasStore.setState((s) => ({ - ...s, - innerEditNodeId: 'n1', - nodes: { n1: { id: 'n1', x: 0, y: 0, width: 50, height: 30 } as Node }, - })); - - const { container, unmount } = await render(); - const canvas = (container.querySelector('[data-rc-canvas]') || - document.querySelector('[data-rc-canvas]')) as HTMLDivElement; - - // Ensure deterministic geometry - const restoreRect = stubRect(canvas); - - // Act: simple click (no drag) - dispatchPointer(canvas, 'pointerdown', { button: 0, clientX: 400, clientY: 300 }); - dispatchPointer(canvas, 'pointerup', { button: 0, clientX: 400, clientY: 300 }); - - // Assert: inner-edit cleared - expect(useCanvasStore.getState().innerEditNodeId).toBeNull(); - - restoreRect(); - await unmount(); - }); -}); diff --git a/src/react/NodeView.tsx b/src/react/NodeView.tsx index dcd938d..e6df980 100644 --- a/src/react/NodeView.tsx +++ b/src/react/NodeView.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useRef, useState } from 'react'; +import React, { forwardRef, useEffect, useRef, useState } from 'react'; import type { Node, NodeId } from '../types'; import { useDndActions, @@ -62,6 +62,53 @@ 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 + useEffect(() => { + const onDocPointerDown = (ev: PointerEvent) => { + const mode = dragScopeModeRef.current; + if (mode === 'default') 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 = { @@ -80,16 +127,63 @@ export const NodeView = forwardRef(function NodeV }; const onDoubleClick: React.MouseEventHandler = (e) => { - // Enter inner-edit on this node. Left button only. + if (skipNextDoubleClickRef.current) { + skipNextDoubleClickRef.current = false; + return; + } + // Toggle drag scope for this node with double-click. Left button only. if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); - enterInnerEdit(node.id); + const mode = dragScopeModeRef.current; + if (mode === 'groupLocal') { + // Switch to node-only: enter inner-edit so drags are scoped to this node subtree + dragScopeModeRef.current = 'node'; + enterInnerEdit(node.id); + } else { + // Switch to smallest containing visual group: exit inner-edit if any + dragScopeModeRef.current = 'groupLocal'; + exitInnerEdit(); + // Compute and store the SMALLEST containing visual group for this node as context + try { + const st = useCanvasStore.getState(); + const groups = Object.values(st.visualGroups); + let smallest: NodeId[] | 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; + smallest = vg.members.slice() as NodeId[]; + } + } + dragScopeContextGroupMembersRef.current = smallest; + } 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'; - // Auto-pan bookkeeping const canvasElRef = useRef(null); const autoRafRef = useRef(null); @@ -181,7 +275,22 @@ export const NodeView = forwardRef(function NodeV const onPointerDown: React.PointerEventHandler = (e) => { // Left button only if (e.button !== 0) return; - // Determine which id to act on. + + // 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? @@ -194,30 +303,87 @@ export const NodeView = forwardRef(function NodeV } return false; }; - // Compute group root by walking parentId chain - const rootId: NodeId = (() => { - let cur: NodeId | null = node.id as NodeId; - while (cur != null) { - const p: NodeId | null = (st.nodes[cur] && st.nodes[cur].parentId) || null; - if (p == null) break; - cur = p; - } - return cur as NodeId; - })(); - // If in inner-edit and clicked inside scope -> act on the node itself; else act on root const clickedInside = innerEditId ? isWithinScope(node.id as NodeId) : false; + clickedInsideInnerEditRef.current = clickedInside; if (innerEditId && !clickedInside) { // Clicked outside inner-edit -> leave it exitInnerEdit(); } - const targetId = clickedInside ? (node.id as NodeId) : (rootId || node.id); - // Multi-select: Ctrl/Cmd toggles membership. Shift is reserved for future. - if (e.ctrlKey || e.metaKey) { - toggleInSelection(targetId); - } else if (!useCanvasStore.getState().selected[targetId]) { - // If target is not selected, select only it. If already selected, preserve current multi-selection - selectOnly(targetId); + // Record modifier state at down time + ctrlMetaAtDownRef.current = !!(e.ctrlKey || e.metaKey); + + // Decide potential drag scope honoring double-click mode + if (!ctrlMetaAtDownRef.current) { + const mode = dragScopeModeRef.current; + const nodeScopeActive = (mode === 'node' && !!innerEditId) || clickedInside; + 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 + if (ctrlMetaAtDownRef.current) { + toggleInSelection(node.id as NodeId); } // Stop propagation so canvas navigation doesn't start panning on node click e.stopPropagation(); @@ -262,6 +428,33 @@ 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]); + } + } + } else { + // Single-node drag + selectOnly(node.id as NodeId); + } + } draggingRef.current = true; if (!historyStartedRef.current) { beginHistory(); @@ -289,6 +482,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; @@ -302,6 +498,10 @@ export const NodeView = forwardRef(function NodeV } catch { // ignore } + // If this was a click (no drag) and no Ctrl/Cmd, select only the node per spec + if (!draggingRef.current && !ctrlMetaAtDownRef.current) { + selectOnly(node.id as NodeId); + } // No auto-grouping on drop; grouping is Ctrl/Cmd+G only finishDrag(); }; @@ -349,8 +549,74 @@ export const NodeView = forwardRef(function NodeV onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} onDoubleClick={onDoubleClick} - onPointerEnter={() => setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} + 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/state/store.ts b/src/state/store.ts index 28e0e34..950c67f 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -37,6 +37,10 @@ export type CanvasState = { 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. */ @@ -107,6 +111,8 @@ export type CanvasActions = { // 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; @@ -125,6 +131,8 @@ const initialNodes: Record = {}; const initialSelected: Record = {}; 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; @@ -146,6 +154,8 @@ export const useCanvasStore = create()((set, get) => ({ selected: initialSelected, visualGroups: initialVisualGroups, selectedVisualGroupId: initialSelectedVisualGroupId, + hoveredVisualGroupId: initialHoveredVisualGroupId, + hoveredVisualGroupIdSecondary: initialHoveredVisualGroupIdSecondary, innerEditNodeId: initialInnerEditNodeId, centerAddIndex: initialCenterAddIndex, clipboard: initialClipboard, @@ -828,17 +838,38 @@ export const useCanvasStore = create()((set, get) => ({ // Visual groups (UI-only) createVisualGroupFromSelection: () => set((s) => { - const memberIds = Object.keys(s.selected) as NodeId[]; - if (!memberIds || memberIds.length < 2) return {} as Partial as CanvasStore; + // 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: memberIds.slice() }; + 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: () => From 6768a6c1535857425a8c4f78c52a7a4f2c6cbde1 Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Mon, 25 Aug 2025 01:11:07 +0300 Subject: [PATCH 03/18] update: files structure --- src/{react => stories}/Canvas.stories.tsx | 18 +++++----- {src/react => tests}/BackgroundCells.test.tsx | 2 +- {src/react => tests}/BackgroundDots.test.tsx | 2 +- .../react => tests}/Canvas.boxselect.test.tsx | 10 +++--- .../GroupContainersLayer.test.tsx | 6 ++-- .../HistoryCamera.integration.test.tsx | 10 +++--- {src/react => tests}/NodeView.dnd.test.tsx | 10 +++--- .../Rulers.integration.test.tsx | 18 ++++------ {src/core => tests}/coords.test.ts | 2 +- .../useCanvasNavigation.clipboard.test.tsx | 33 ++++++++++++++----- .../useCanvasNavigation.grouping.test.tsx | 6 ++-- .../useCanvasNavigation.test.tsx | 4 +-- tsconfig.json | 2 +- 13 files changed, 69 insertions(+), 54 deletions(-) rename src/{react => stories}/Canvas.stories.tsx (97%) rename {src/react => tests}/BackgroundCells.test.tsx (97%) rename {src/react => tests}/BackgroundDots.test.tsx (97%) rename {src/react => tests}/Canvas.boxselect.test.tsx (98%) rename {src/react => tests}/GroupContainersLayer.test.tsx (97%) rename {src/react => tests}/HistoryCamera.integration.test.tsx (89%) rename {src/react => tests}/NodeView.dnd.test.tsx (95%) rename {src/react => tests}/Rulers.integration.test.tsx (97%) rename {src/core => tests}/coords.test.ts (97%) rename {src/react => tests}/useCanvasNavigation.clipboard.test.tsx (89%) rename {src/react => tests}/useCanvasNavigation.grouping.test.tsx (95%) rename {src/react => tests}/useCanvasNavigation.test.tsx (98%) diff --git a/src/react/Canvas.stories.tsx b/src/stories/Canvas.stories.tsx similarity index 97% rename from src/react/Canvas.stories.tsx rename to src/stories/Canvas.stories.tsx index 4ec9287..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, { useState, useRef, useEffect } from 'react'; -import { Canvas } from './Canvas'; -import { BackgroundDots } from './BackgroundDots'; -import { BackgroundCells } from './BackgroundCells'; -import { NodeView } from './NodeView'; -import { useCanvasNavigation } from './useCanvasNavigation'; +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, @@ -18,10 +18,10 @@ import { useClipboardActions, useHasClipboard, } from '../state/store'; -import { useCanvasHelpers } from './useCanvasHelpers'; -import type { CanvasNavigationOptions } from './useCanvasNavigation'; -import type { BackgroundDotsProps } from './BackgroundDots'; -import { GroupContainersLayer } from './GroupContainersLayer'; +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< diff --git a/src/react/BackgroundCells.test.tsx b/tests/BackgroundCells.test.tsx similarity index 97% rename from src/react/BackgroundCells.test.tsx rename to tests/BackgroundCells.test.tsx index 118fc71..03c46c8 100644 --- a/src/react/BackgroundCells.test.tsx +++ b/tests/BackgroundCells.test.tsx @@ -3,7 +3,7 @@ import React, { act } from 'react'; import ReactDOM from 'react-dom/client'; import { describe, it, expect, beforeEach } from 'vitest'; -import { BackgroundCells } from './BackgroundCells'; +import { BackgroundCells } from '../react/BackgroundCells'; import { useCanvasStore } from '../state/store'; async function render(ui: React.ReactElement) { diff --git a/src/react/BackgroundDots.test.tsx b/tests/BackgroundDots.test.tsx similarity index 97% rename from src/react/BackgroundDots.test.tsx rename to tests/BackgroundDots.test.tsx index 1093319..d2e3618 100644 --- a/src/react/BackgroundDots.test.tsx +++ b/tests/BackgroundDots.test.tsx @@ -3,7 +3,7 @@ import React, { act } from 'react'; import ReactDOM from 'react-dom/client'; import { describe, it, expect, beforeEach } from 'vitest'; -import { BackgroundDots } from './BackgroundDots'; +import { BackgroundDots } from '../react/BackgroundDots'; import { useCanvasStore } from '../state/store'; async function render(ui: React.ReactElement) { diff --git a/src/react/Canvas.boxselect.test.tsx b/tests/Canvas.boxselect.test.tsx similarity index 98% rename from src/react/Canvas.boxselect.test.tsx rename to tests/Canvas.boxselect.test.tsx index 13ff3af..2e77fe7 100644 --- a/src/react/Canvas.boxselect.test.tsx +++ b/tests/Canvas.boxselect.test.tsx @@ -3,11 +3,11 @@ import React, { useRef, act } from 'react'; import ReactDOM from 'react-dom/client'; import { describe, it, expect, beforeEach } from 'vitest'; -import { Canvas } from './Canvas'; -import { useCanvasNavigation } from './useCanvasNavigation'; -import { useCanvasStore } from '../state/store'; -import type { CanvasStore } from '../state/store'; -import type { Node } from '../types'; +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 diff --git a/src/react/GroupContainersLayer.test.tsx b/tests/GroupContainersLayer.test.tsx similarity index 97% rename from src/react/GroupContainersLayer.test.tsx rename to tests/GroupContainersLayer.test.tsx index 314678c..844eadf 100644 --- a/src/react/GroupContainersLayer.test.tsx +++ b/tests/GroupContainersLayer.test.tsx @@ -3,9 +3,9 @@ import React, { act } from 'react'; import ReactDOM from 'react-dom/client'; import { describe, it, expect, beforeEach } from 'vitest'; -import { GroupContainersLayer } from './GroupContainersLayer'; -import { useCanvasStore } from '../state/store'; -import type { Node } from '../types'; +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 }; diff --git a/src/react/HistoryCamera.integration.test.tsx b/tests/HistoryCamera.integration.test.tsx similarity index 89% rename from src/react/HistoryCamera.integration.test.tsx rename to tests/HistoryCamera.integration.test.tsx index 778ecb2..fdc43c1 100644 --- a/src/react/HistoryCamera.integration.test.tsx +++ b/tests/HistoryCamera.integration.test.tsx @@ -3,9 +3,9 @@ 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); @@ -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 95% rename from src/react/NodeView.dnd.test.tsx rename to tests/NodeView.dnd.test.tsx index 7e8a077..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 & { diff --git a/src/react/Rulers.integration.test.tsx b/tests/Rulers.integration.test.tsx similarity index 97% rename from src/react/Rulers.integration.test.tsx rename to tests/Rulers.integration.test.tsx index ab1ede8..0212ca6 100644 --- a/src/react/Rulers.integration.test.tsx +++ b/tests/Rulers.integration.test.tsx @@ -3,10 +3,10 @@ import React, { useRef, act } from 'react'; import ReactDOM from 'react-dom/client'; import { describe, it, expect, beforeEach } from 'vitest'; -import { Canvas } from './Canvas'; -import { useCanvasNavigation } from './useCanvasNavigation'; -import { useCanvasStore } from '../state/store'; -import type { CanvasStore } from '../state/store'; +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 & { @@ -122,10 +122,8 @@ function dispatchPointer(target: EventTarget, type: string, init: PointerEventIn } function getCanvas(container: HTMLElement) { - return ( - (container.querySelector('[data-rc-canvas]') || - document.querySelector('[data-rc-canvas]')) as HTMLDivElement - ); + return (container.querySelector('[data-rc-canvas]') || + document.querySelector('[data-rc-canvas]')) as HTMLDivElement; } function getRulersRoot() { @@ -133,9 +131,7 @@ function getRulersRoot() { } function queryGuides(): HTMLDivElement[] { - return Array.from( - document.querySelectorAll('[data-rc-guide]') as NodeListOf, - ); + return Array.from(document.querySelectorAll('[data-rc-guide]') as NodeListOf); } function setGlobalClientSize(size: { width: number; height: number }) { 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/src/react/useCanvasNavigation.clipboard.test.tsx b/tests/useCanvasNavigation.clipboard.test.tsx similarity index 89% rename from src/react/useCanvasNavigation.clipboard.test.tsx rename to tests/useCanvasNavigation.clipboard.test.tsx index 3995d41..f4f9e47 100644 --- a/src/react/useCanvasNavigation.clipboard.test.tsx +++ b/tests/useCanvasNavigation.clipboard.test.tsx @@ -3,11 +3,14 @@ import React, { useRef, act } from 'react'; import ReactDOM from 'react-dom/client'; import { describe, it, expect, beforeEach } from 'vitest'; -import { useCanvasNavigation } from './useCanvasNavigation'; -import { useCanvasStore } from '../state/store'; -import type { CanvasStore } from '../state/store'; - -function TestHost(props: { options?: Parameters[1]; withInput?: boolean }) { +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 ( @@ -108,7 +111,9 @@ describe('useCanvasNavigation: clipboard keyboard shortcuts', () => { // 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() + .addNode({ id: 'c1', x: 5, y: 5, width: 20, height: 10, parentId: 'p' }); useCanvasStore.getState().selectOnly('p'); useCanvasStore.getState().copySelection(); @@ -140,11 +145,23 @@ describe('useCanvasNavigation: clipboard keyboard shortcuts', () => { // 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); + ({ + 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() + .addNode({ id: 'c', x: 10, y: 5, width: 10, height: 10, parentId: 'p' }); useCanvasStore.getState().selectOnly('p'); useCanvasStore.getState().copySelection(); diff --git a/src/react/useCanvasNavigation.grouping.test.tsx b/tests/useCanvasNavigation.grouping.test.tsx similarity index 95% rename from src/react/useCanvasNavigation.grouping.test.tsx rename to tests/useCanvasNavigation.grouping.test.tsx index fa63746..ee1238e 100644 --- a/src/react/useCanvasNavigation.grouping.test.tsx +++ b/tests/useCanvasNavigation.grouping.test.tsx @@ -3,9 +3,9 @@ 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(props: { options?: Parameters[1] }) { const ref = useRef(null); diff --git a/src/react/useCanvasNavigation.test.tsx b/tests/useCanvasNavigation.test.tsx similarity index 98% rename from src/react/useCanvasNavigation.test.tsx rename to tests/useCanvasNavigation.test.tsx index b2cc1c7..f03468a 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]; 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__/**"] } From 197e3fe7ed584a17723bfd3e222da8bcdf35598b Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Mon, 25 Aug 2025 01:16:38 +0300 Subject: [PATCH 04/18] fix: rulers z-index --- src/react/Canvas.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/react/Canvas.tsx b/src/react/Canvas.tsx index 2fe01b2..d2c50ce 100644 --- a/src/react/Canvas.tsx +++ b/src/react/Canvas.tsx @@ -473,11 +473,9 @@ export const Canvas = forwardRef(function Canvas( > {background}
-
- {showRulers ? : null} - {children} -
+
{children}
{overlay} + {showRulers ? : null}
); }); From fab247545e35b06e0d61cdcacf5be9c5d9e83614 Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Mon, 25 Aug 2025 01:31:47 +0300 Subject: [PATCH 05/18] style: group containers layer --- src/react/GroupContainersLayer.tsx | 43 ++++++++++++++---------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/react/GroupContainersLayer.tsx b/src/react/GroupContainersLayer.tsx index cfdecb7..da906a7 100644 --- a/src/react/GroupContainersLayer.tsx +++ b/src/react/GroupContainersLayer.tsx @@ -82,8 +82,7 @@ export function GroupContainersLayer() { return arr; }, [nodes, visualGroups]); - // Visual padding of 8 CSS px regardless of zoom => convert to world units - const PAD_WORLD = 8 / (camera.zoom || 1); + // No visual padding: containers and selection frames are tight to bounds const DRAG_THRESHOLD_PX = 3; @@ -513,11 +512,11 @@ export function GroupContainersLayer() { }} > {containers.map((c) => { - // Base world rect with padding - let x = c.left - PAD_WORLD; - let y = c.top - PAD_WORLD; - let w = c.right - c.left + PAD_WORLD * 2; - let h = c.bottom - c.top + PAD_WORLD * 2; + // 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; @@ -538,7 +537,9 @@ export function GroupContainersLayer() { hoveredGroupId === c.groupId || hoveredGlobalGroupId === c.groupId || hoveredGlobalGroupIdSecondary === c.groupId || - (dragRef.current && dragRef.current.parentId === (c.groupId as unknown as NodeId) && dragRef.current.dragging); + (dragRef.current && + dragRef.current.parentId === (c.groupId as unknown as NodeId) && + dragRef.current.dragging); const isSelected = selectedGroupId === c.groupId; const showStroke = isActive || isSelected; return ( @@ -568,9 +569,7 @@ export function GroupContainersLayer() { y={0} width={w} height={h} - rx={14} - ry={14} - fill="transparent" + fill="none" stroke={isSelected ? 'rgba(59,130,246,1)' : 'rgba(59,130,246,0.9)'} strokeWidth={1.5} vectorEffect="non-scaling-stroke" @@ -584,8 +583,6 @@ export function GroupContainersLayer() { y={0} width={w} height={h} - rx={14} - ry={14} fill="transparent" style={{ pointerEvents: 'all', cursor: 'grab' }} onPointerDown={handlers.onPointerDown} @@ -603,11 +600,11 @@ export function GroupContainersLayer() { {/* Temporary selection container above nodes (visible when 2+ nodes are selected) */} {selectionBBox && (() => { - // Apply same visual padding and snapping rules - let x = selectionBBox.left - PAD_WORLD; - let y = selectionBBox.top - PAD_WORLD; - let w = selectionBBox.right - selectionBBox.left + PAD_WORLD * 2; - let h = selectionBBox.bottom - selectionBBox.top + PAD_WORLD * 2; + // 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); @@ -654,9 +651,9 @@ export function GroupContainersLayer() { y={0} width={w} height={h} - rx={14} - ry={14} - fill="rgba(59,130,246,0.08)" + // rx={14} + // ry={14} + fill="none" style={{ pointerEvents: 'none' }} /> {/* Stroke */} @@ -665,8 +662,8 @@ export function GroupContainersLayer() { y={0} width={w} height={h} - rx={14} - ry={14} + // rx={14} + // ry={14} fill="transparent" stroke="rgba(59,130,246,0.9)" strokeWidth={1.5} From d8b9355cdb0e618774faa498c3ae9095f7ad117d Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Mon, 25 Aug 2025 01:59:06 +0300 Subject: [PATCH 06/18] feat: insensitive to languages keycode --- src/react/Canvas.tsx | 24 ++++++++++++--- src/react/GroupContainersLayer.tsx | 8 +++++ src/react/useCanvasNavigation.ts | 32 +++++++++----------- tests/BackgroundCells.test.tsx | 4 +-- tests/BackgroundDots.test.tsx | 4 +-- tests/Rulers.integration.test.tsx | 10 ++++-- tests/useCanvasNavigation.clipboard.test.tsx | 5 ++- tests/useCanvasNavigation.grouping.test.tsx | 5 ++- tests/useCanvasNavigation.test.tsx | 5 ++- vitest.config.ts | 2 +- 10 files changed, 66 insertions(+), 33 deletions(-) diff --git a/src/react/Canvas.tsx b/src/react/Canvas.tsx index d2c50ce..822a194 100644 --- a/src/react/Canvas.tsx +++ b/src/react/Canvas.tsx @@ -86,8 +86,8 @@ export const Canvas = forwardRef(function Canvas( const onWindowKeyDown = (e: KeyboardEvent) => { if (isTextInput(e.target as Element)) return; if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) { - const keyLower = e.key.toLowerCase(); - if (keyLower === 'g') { + const code = e.code; + if (code === 'KeyG') { const { selected, createVisualGroupFromSelection } = useCanvasStore.getState(); if (Object.keys(selected).length >= 2) { e.preventDefault(); @@ -385,16 +385,30 @@ 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 keyLower = e.key.toLowerCase(); - if (keyLower === 'g') { + const code = e.code; + if (code === 'KeyG') { const { selected, createVisualGroupFromSelection } = useCanvasStore.getState(); if (Object.keys(selected).length >= 2) { e.preventDefault(); diff --git a/src/react/GroupContainersLayer.tsx b/src/react/GroupContainersLayer.tsx index da906a7..9f4d442 100644 --- a/src/react/GroupContainersLayer.tsx +++ b/src/react/GroupContainersLayer.tsx @@ -518,6 +518,14 @@ export function GroupContainersLayer() { let w = c.right - c.left; let h = c.bottom - c.top; + // Apply fixed 8px screen-space padding converted to world units + // This ensures a small visual gap around grouped members regardless of zoom + const padWorld = 8 / (camera.zoom || 1); + x -= padWorld; + y -= padWorld; + w += padWorld * 2; + h += padWorld * 2; + // 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; diff --git a/src/react/useCanvasNavigation.ts b/src/react/useCanvasNavigation.ts index 96dca3c..85f635a 100644 --- a/src/react/useCanvasNavigation.ts +++ b/src/react/useCanvasNavigation.ts @@ -377,7 +377,7 @@ export function useCanvasNavigation( if (isTextInput(e.target as Element)) return; // Escape exits inner-edit mode if active - if (e.key === 'Escape') { + if (e.code === 'Escape') { const { innerEditNodeId, exitInnerEdit } = useCanvasStore.getState(); if (innerEditNodeId) { e.preventDefault(); @@ -396,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; } @@ -426,10 +422,10 @@ export function useCanvasNavigation( } } - // Clipboard: Ctrl/Cmd + C / X / V + // Clipboard and grouping: Ctrl/Cmd + [G/C/X/V] if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) { - const keyLower = e.key.toLowerCase(); - if (keyLower === 'g') { + const code = e.code; + if (code === 'KeyG') { const { selected, createVisualGroupFromSelection } = useCanvasStore.getState(); if (Object.keys(selected).length >= 2) { e.preventDefault(); @@ -437,21 +433,21 @@ export function useCanvasNavigation( return; } } - if (keyLower === 'c') { + if (code === 'KeyC') { const { selected, copySelection } = useCanvasStore.getState(); if (Object.keys(selected).length > 0) { e.preventDefault(); copySelection(); return; } - } else if (keyLower === 'x') { + } else if (code === 'KeyX') { const { selected, cutSelection } = useCanvasStore.getState(); if (Object.keys(selected).length > 0) { e.preventDefault(); cutSelection(); return; } - } else if (keyLower === 'v') { + } else if (code === 'KeyV') { e.preventDefault(); const { camera, pasteClipboard } = useCanvasStore.getState(); const currentEl = ref.current; @@ -469,8 +465,8 @@ export function useCanvasNavigation( // Toggle rulers: Ctrl/Cmd + H if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) { - const keyLower = e.key.toLowerCase(); - if (keyLower === 'h') { + const code = e.code; + if (code === 'KeyH') { e.preventDefault(); const { toggleRulers } = useCanvasStore.getState(); toggleRulers(); diff --git a/tests/BackgroundCells.test.tsx b/tests/BackgroundCells.test.tsx index 03c46c8..de9d013 100644 --- a/tests/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 '../react/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'); diff --git a/tests/BackgroundDots.test.tsx b/tests/BackgroundDots.test.tsx index d2e3618..78f85fe 100644 --- a/tests/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 '../react/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/Rulers.integration.test.tsx b/tests/Rulers.integration.test.tsx index 0212ca6..924fde1 100644 --- a/tests/Rulers.integration.test.tsx +++ b/tests/Rulers.integration.test.tsx @@ -453,7 +453,12 @@ describe('Rulers: guides interactions', () => { // Undo with Ctrl+Z await act(async () => { - const e = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }); + const e = new KeyboardEvent('keydown', { + key: 'z', + code: 'KeyZ', + ctrlKey: true, + bubbles: true, + }); canvas.dispatchEvent(e); }); st = useCanvasStore.getState(); @@ -465,6 +470,7 @@ describe('Rulers: guides interactions', () => { await act(async () => { const e = new KeyboardEvent('keydown', { key: 'z', + code: 'KeyZ', ctrlKey: true, shiftKey: true, bubbles: true, @@ -597,7 +603,7 @@ describe('Rulers: guides interactions', () => { // Test undo - should go back to original position (100) in one step canvas.focus(); await act(async () => { - const e = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }); + const e = new KeyboardEvent('keydown', { key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true }); canvas.dispatchEvent(e); }); diff --git a/tests/useCanvasNavigation.clipboard.test.tsx b/tests/useCanvasNavigation.clipboard.test.tsx index f4f9e47..9e74931 100644 --- a/tests/useCanvasNavigation.clipboard.test.tsx +++ b/tests/useCanvasNavigation.clipboard.test.tsx @@ -32,9 +32,12 @@ async function render(ui: React.ReactElement) { } 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, + code: resolvedCode, bubbles: true, cancelable: true, ...init, diff --git a/tests/useCanvasNavigation.grouping.test.tsx b/tests/useCanvasNavigation.grouping.test.tsx index ee1238e..2c7f9ab 100644 --- a/tests/useCanvasNavigation.grouping.test.tsx +++ b/tests/useCanvasNavigation.grouping.test.tsx @@ -25,9 +25,12 @@ async function render(ui: React.ReactElement) { } 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, + code: resolvedCode, bubbles: true, cancelable: true, ...init, diff --git a/tests/useCanvasNavigation.test.tsx b/tests/useCanvasNavigation.test.tsx index f03468a..e34a39a 100644 --- a/tests/useCanvasNavigation.test.tsx +++ b/tests/useCanvasNavigation.test.tsx @@ -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/vitest.config.ts b/vitest.config.ts index 47c06b9..80ba4ff 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['src/**/*.{test,spec}.{ts,tsx}'], + 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'], From 55baa114d6e5e8a5daadbbd69a5e73d9439e6b4a Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Mon, 25 Aug 2025 02:35:18 +0300 Subject: [PATCH 07/18] fix: show group container select on node --- src/react/Canvas.tsx | 80 +++++++++++++++++++++++++++++ src/react/GroupContainersLayer.tsx | 8 --- tests/GroupContainersLayer.test.tsx | 31 +++++------ tests/Rulers.integration.test.tsx | 7 ++- 4 files changed, 100 insertions(+), 26 deletions(-) diff --git a/src/react/Canvas.tsx b/src/react/Canvas.tsx index 822a194..98cae63 100644 --- a/src/react/Canvas.tsx +++ b/src/react/Canvas.tsx @@ -35,6 +35,66 @@ export const Canvas = forwardRef(function Canvas( const { exitInnerEdit } = useInnerEditActions(); const showRulers = useShowRulers(); + // 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; @@ -181,6 +241,8 @@ export const Canvas = forwardRef(function Canvas( } clearSelection(); for (const id of hits) addToSelection(id); + // Update hovered visual group highlights based on live hits + updateHoverForIds(hits); } autoPanRafRef.current = requestAnimationFrame(autoPanTick); }; @@ -250,6 +312,8 @@ export const Canvas = forwardRef(function Canvas( // Во время drag показываем только текущее «хит» выделение (replace превью) clearSelection(); for (const id of hits) addToSelection(id); + // Update hovered visual group highlights based on live hits + updateHoverForIds(hits); shouldMaybeDeselectRef.current = false; ensureAutoPan(root); return; @@ -338,6 +402,14 @@ export const Canvas = forwardRef(function Canvas( initialSelectionRef.current = null; worldStartRef.current = null; stopAutoPan(); + // 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) { @@ -368,6 +440,14 @@ export const Canvas = forwardRef(function Canvas( } 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; diff --git a/src/react/GroupContainersLayer.tsx b/src/react/GroupContainersLayer.tsx index 9f4d442..da906a7 100644 --- a/src/react/GroupContainersLayer.tsx +++ b/src/react/GroupContainersLayer.tsx @@ -518,14 +518,6 @@ export function GroupContainersLayer() { let w = c.right - c.left; let h = c.bottom - c.top; - // Apply fixed 8px screen-space padding converted to world units - // This ensures a small visual gap around grouped members regardless of zoom - const padWorld = 8 / (camera.zoom || 1); - x -= padWorld; - y -= padWorld; - w += padWorld * 2; - h += padWorld * 2; - // 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; diff --git a/tests/GroupContainersLayer.test.tsx b/tests/GroupContainersLayer.test.tsx index 844eadf..d75c99a 100644 --- a/tests/GroupContainersLayer.test.tsx +++ b/tests/GroupContainersLayer.test.tsx @@ -75,15 +75,13 @@ describe('GroupContainersLayer', () => { const getByGroup = (gid: string) => Array.from(containers).find((el) => el.getAttribute('data-parent-id') === gid)!; - const PAD = 8; // at zoom=1 - // 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) - PAD; - const topP = Math.min(0, 20, 120, 40) - PAD; - const rightP = Math.max(0 + 80, 120 + 60, 40 + 50, 160 + 30) + PAD; - const bottomP = Math.max(0 + 40, 20 + 30, 120 + 50, 40 + 30) + PAD; + 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`); @@ -94,10 +92,10 @@ describe('GroupContainersLayer', () => { // For G-c1: bbox over c1 + g1 const c1 = getByGroup('G-c1'); expect(c1).toBeTruthy(); - const leftC1 = Math.min(120, 160) - PAD; - const topC1 = Math.min(20, 40) - PAD; - const rightC1 = Math.max(120 + 60, 160 + 30) + PAD; - const bottomC1 = Math.max(20 + 30, 40 + 30) + PAD; + 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`); @@ -108,7 +106,7 @@ describe('GroupContainersLayer', () => { unmount(); }); - it('applies fixed 8px padding regardless of zoom (converted to world units)', async () => { + 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 }); @@ -121,12 +119,11 @@ describe('GroupContainersLayer', () => { const { container, unmount } = await render(); const el = container.querySelector('[data-parent-id="G-R"]') as HTMLElement; expect(el).toBeTruthy(); - const PAD = 8 / 2; // 4 world units - - const left = Math.min(10, 70) - PAD; - const top = Math.min(20, 40) - PAD; - const right = Math.max(10 + 40, 70 + 30) + PAD; - const bottom = Math.max(20 + 20, 40 + 30) + PAD; + // 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`); diff --git a/tests/Rulers.integration.test.tsx b/tests/Rulers.integration.test.tsx index 924fde1..e495978 100644 --- a/tests/Rulers.integration.test.tsx +++ b/tests/Rulers.integration.test.tsx @@ -603,7 +603,12 @@ describe('Rulers: guides interactions', () => { // 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 }); + const e = new KeyboardEvent('keydown', { + key: 'z', + code: 'KeyZ', + ctrlKey: true, + bubbles: true, + }); canvas.dispatchEvent(e); }); From b87b96125c5afb6ca233d397c4a130b8210c9e28 Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Mon, 25 Aug 2025 03:28:44 +0300 Subject: [PATCH 08/18] style: remove visual selection inside group canvas --- src/react/Canvas.tsx | 45 ++++++++++++++ src/react/NodeView.tsx | 129 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 167 insertions(+), 7 deletions(-) diff --git a/src/react/Canvas.tsx b/src/react/Canvas.tsx index 98cae63..b39a0b7 100644 --- a/src/react/Canvas.tsx +++ b/src/react/Canvas.tsx @@ -396,6 +396,51 @@ export const Canvas = forwardRef(function Canvas( } for (const id of hits) addToSelection(id); + // If selection contains nodes that all share at least one visual group, + // select that group's frame instead of showing node selection UI + try { + const st = useCanvasStore.getState(); + const selectedIds = hits; + const groups = Object.values(st.visualGroups); + if (selectedIds.length > 0 && groups.length > 0) { + const candidates = groups.filter((vg) => + selectedIds.every((id) => vg.members.includes(id as NodeId)), + ); + if (candidates.length > 0) { + // choose the largest by area to match NodeView default behavior + let chosen: string | null = null; + let bestArea = -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 > bestArea) { + bestArea = area; + chosen = vg.id; + } + } + if (chosen) { + // Clear node selection and select the visual group instead + clearSelection(); + st.selectVisualGroup(chosen); + } + } + } + } catch { + // ignore + } + // сбрасываем состояние box-select и освобождаем захват указателя setBoxStart(null); setBoxEnd(null); diff --git a/src/react/NodeView.tsx b/src/react/NodeView.tsx index e6df980..cfd7014 100644 --- a/src/react/NodeView.tsx +++ b/src/react/NodeView.tsx @@ -44,7 +44,7 @@ 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(); @@ -126,6 +126,15 @@ export const NodeView = forwardRef(function NodeV 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; @@ -184,6 +193,7 @@ export const NodeView = forwardRef(function NodeV const A = { ...defaultAppearance, ...(appearance ?? {}) } as NodeAppearance; const hasCustomChildren = children != null; const contentLabel = 'New Node'; + const showSelectedUi = isSelected && !nodeIsInAnyVisualGroup; // Auto-pan bookkeeping const canvasElRef = useRef(null); const autoRafRef = useRef(null); @@ -313,6 +323,42 @@ export const NodeView = forwardRef(function NodeV // 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 + if (ctrlMetaAtDownRef.current && nodeIsInAnyVisualGroup) { + 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; @@ -382,7 +428,8 @@ export const NodeView = forwardRef(function NodeV } // Multi-select toggle remains immediate when Ctrl/Cmd is pressed - if (ctrlMetaAtDownRef.current) { + // except for nodes that belong to visual groups (handled above) + if (ctrlMetaAtDownRef.current && !nodeIsInAnyVisualGroup) { toggleInSelection(node.id as NodeId); } // Stop propagation so canvas navigation doesn't start panning on node click @@ -450,6 +497,37 @@ export const NodeView = forwardRef(function NodeV 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 selectOnly(node.id as NodeId); @@ -498,9 +576,46 @@ export const NodeView = forwardRef(function NodeV } catch { // ignore } - // If this was a click (no drag) and no Ctrl/Cmd, select only the node per spec - if (!draggingRef.current && !ctrlMetaAtDownRef.current) { - selectOnly(node.id as NodeId); + // If this was a click (no drag) + if (!draggingRef.current) { + if (!ctrlMetaAtDownRef.current) { + // Plain click: if node belongs to a visual group, select its frame; otherwise select node + if (nodeIsInAnyVisualGroup) { + 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(); @@ -523,7 +638,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, @@ -532,7 +647,7 @@ export const NodeView = forwardRef(function NodeV ? undefined : isHovered ? A.hoverShadow - : isSelected + : showSelectedUi ? A.selectedShadow || A.shadow : A.shadow, transition: 'box-shadow 120ms ease', From 8ecd310e1671f9a90eb4bd6f5122b84e58cbfddc Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Mon, 25 Aug 2025 04:06:12 +0300 Subject: [PATCH 09/18] style: remove visual bug with node selected inside group --- src/react/NodeView.tsx | 146 +++++++++++++++++++++++++---------------- src/state/store.ts | 27 +++++--- 2 files changed, 107 insertions(+), 66 deletions(-) diff --git a/src/react/NodeView.tsx b/src/react/NodeView.tsx index cfd7014..b634921 100644 --- a/src/react/NodeView.tsx +++ b/src/react/NodeView.tsx @@ -9,6 +9,7 @@ import { useHistoryActions, useCanvasStore, useInnerEditActions, + useInnerEdit, } from '../state/store'; type NodeAppearance = { @@ -83,11 +84,12 @@ export const NodeView = forwardRef(function NodeV // 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 + // Reset drag-scope mode when user clicks outside the stored group context (only for groupLocal) useEffect(() => { const onDocPointerDown = (ev: PointerEvent) => { const mode = dragScopeModeRef.current; - if (mode === 'default') return; + // 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; @@ -140,51 +142,52 @@ export const NodeView = forwardRef(function NodeV skipNextDoubleClickRef.current = false; return; } - // Toggle drag scope for this node with double-click. Left button only. + // Enter inner-edit mode for this node (persistent until empty-area click). Left button only. if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); - const mode = dragScopeModeRef.current; - if (mode === 'groupLocal') { - // Switch to node-only: enter inner-edit so drags are scoped to this node subtree - dragScopeModeRef.current = 'node'; - enterInnerEdit(node.id); - } else { - // Switch to smallest containing visual group: exit inner-edit if any - dragScopeModeRef.current = 'groupLocal'; - exitInnerEdit(); - // Compute and store the SMALLEST containing visual group for this node as context - try { - const st = useCanvasStore.getState(); - const groups = Object.values(st.visualGroups); - let smallest: NodeId[] | 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; - smallest = vg.members.slice() as NodeId[]; - } + + // 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; } - dragScopeContextGroupMembersRef.current = smallest; - } catch { - // ignore } + st.selectVisualGroup(chosenId); + } catch { + // ignore } + // Reset double-click counters to avoid accidental pre-toggle on subsequent pointerdowns clickCountRef.current = 0; lastDownTsRef.current = 0; @@ -193,7 +196,28 @@ export const NodeView = forwardRef(function NodeV const A = { ...defaultAppearance, ...(appearance ?? {}) } as NodeAppearance; const hasCustomChildren = children != null; const contentLabel = 'New Node'; - const showSelectedUi = isSelected && !nodeIsInAnyVisualGroup; + 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); @@ -313,19 +337,23 @@ export const NodeView = forwardRef(function NodeV } return false; }; - const clickedInside = innerEditId ? isWithinScope(node.id as NodeId) : false; + const clickedInside = innerEditId + ? isWithinScope(node.id as NodeId) || (selectedGroupId != null && nodeInSelectedGroup) + : false; clickedInsideInnerEditRef.current = clickedInside; - if (innerEditId && !clickedInside) { - // Clicked outside inner-edit -> leave it - exitInnerEdit(); - } + // 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 - if (ctrlMetaAtDownRef.current && nodeIsInAnyVisualGroup) { + // 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); @@ -362,7 +390,7 @@ export const NodeView = forwardRef(function NodeV // Decide potential drag scope honoring double-click mode if (!ctrlMetaAtDownRef.current) { const mode = dragScopeModeRef.current; - const nodeScopeActive = (mode === 'node' && !!innerEditId) || clickedInside; + 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; @@ -427,9 +455,12 @@ export const NodeView = forwardRef(function NodeV dragGroupMembersRef.current = null; } - // Multi-select toggle remains immediate when Ctrl/Cmd is pressed - // except for nodes that belong to visual groups (handled above) - if (ctrlMetaAtDownRef.current && !nodeIsInAnyVisualGroup) { + // 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 @@ -529,7 +560,7 @@ export const NodeView = forwardRef(function NodeV // ignore } } else { - // Single-node drag + // Single-node drag; in inner-edit treat like normal node selectOnly(node.id as NodeId); } } @@ -579,8 +610,11 @@ export const NodeView = forwardRef(function NodeV // If this was a click (no drag) if (!draggingRef.current) { if (!ctrlMetaAtDownRef.current) { - // Plain click: if node belongs to a visual group, select its frame; otherwise select node - if (nodeIsInAnyVisualGroup) { + // 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); diff --git a/src/state/store.ts b/src/state/store.ts index 950c67f..54c579c 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -719,16 +719,23 @@ export const useCanvasStore = create()((set, get) => ({ // Start from current selection and optionally scope to inner-edit subtree let selIds = Object.keys(s.selected) as NodeId[]; if (s.innerEditNodeId) { - 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 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 From 8dfba9b4d4e8497ae78c17e12b41a6921aa4f433 Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Mon, 25 Aug 2025 04:22:32 +0300 Subject: [PATCH 10/18] feat: add funding and update lasso behavior --- .github/FUNDING.yml | 1 + src/react/Canvas.tsx | 134 +++++++++++++++++++++++++++---------------- 2 files changed, 84 insertions(+), 51 deletions(-) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..84ec59b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +buy_me_a_coffee: flowscape \ No newline at end of file diff --git a/src/react/Canvas.tsx b/src/react/Canvas.tsx index b39a0b7..218f09f 100644 --- a/src/react/Canvas.tsx +++ b/src/react/Canvas.tsx @@ -239,10 +239,52 @@ export const Canvas = forwardRef(function Canvas( nRight >= worldLeft && nLeft <= worldRight && nBottom >= worldTop && nTop <= worldBottom; if (intersects) hits.push(n.id); } - clearSelection(); - for (const id of hits) addToSelection(id); - // Update hovered visual group highlights based on live hits - updateHoverForIds(hits); + // 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 chosenGroupId: string | null = null; + if (groups.length > 0 && hits.length > 0) { + // Count overlaps per group (any member hit qualifies) + 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) { + // compute area of group bbox + 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; + } + } + if (chosenGroupId) { + // Clear any node selection preview and hover the group + clearSelection(); + try { + st.setHoveredVisualGroupId(chosenGroupId); + st.setHoveredVisualGroupIdSecondary(null); + } catch { + // ignore + } + } else { + // No grouped hits: live node selection preview as before + clearSelection(); + for (const id of hits) addToSelection(id); + updateHoverForIds(hits); + } } autoPanRafRef.current = requestAnimationFrame(autoPanTick); }; @@ -388,57 +430,47 @@ export const Canvas = forwardRef(function Canvas( 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; + 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; + } + } + const additive = e.ctrlKey || e.metaKey; const snapshot = initialSelectionRef.current || {}; clearSelection(); - if (additive) { - for (const id of Object.keys(snapshot) as NodeId[]) addToSelection(id); - } - for (const id of hits) addToSelection(id); - - // If selection contains nodes that all share at least one visual group, - // select that group's frame instead of showing node selection UI - try { - const st = useCanvasStore.getState(); - const selectedIds = hits; - const groups = Object.values(st.visualGroups); - if (selectedIds.length > 0 && groups.length > 0) { - const candidates = groups.filter((vg) => - selectedIds.every((id) => vg.members.includes(id as NodeId)), - ); - if (candidates.length > 0) { - // choose the largest by area to match NodeView default behavior - let chosen: string | null = null; - let bestArea = -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 > bestArea) { - bestArea = area; - chosen = vg.id; - } - } - if (chosen) { - // Clear node selection and select the visual group instead - clearSelection(); - st.selectVisualGroup(chosen); - } - } + if (chosenGroupId) { + // Select only the chosen group frame + st.selectVisualGroup(chosenGroupId); + } else { + // Standard node selection behavior + if (additive) { + for (const id of Object.keys(snapshot) as NodeId[]) addToSelection(id); } - } catch { - // ignore + for (const id of hits) addToSelection(id); } // сбрасываем состояние box-select и освобождаем захват указателя From f89bfdb1830c4ff9f378841a409fa4cf42115597 Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Mon, 25 Aug 2025 04:27:58 +0300 Subject: [PATCH 11/18] feat: update funding --- .github/FUNDING.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 84ec59b..09ebbe1 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,9 @@ -buy_me_a_coffee: flowscape \ No newline at end of file +buy_me_a_coffee: + type: github + username: flowscape + project: flowscape + custom: + url: https://www.buymeacoffee.com/flowscape + image: https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png + width: 217 + height: 60 \ No newline at end of file From ac23d1b2f21859817b3dbf6e4cf4c2b97ddad36f Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Mon, 25 Aug 2025 04:37:57 +0300 Subject: [PATCH 12/18] feat: update group selection --- src/react/Canvas.tsx | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/react/Canvas.tsx b/src/react/Canvas.tsx index 218f09f..bb3cf61 100644 --- a/src/react/Canvas.tsx +++ b/src/react/Canvas.tsx @@ -243,16 +243,15 @@ export const Canvas = forwardRef(function Canvas( // do NOT select nodes; instead, hover the most relevant group. const st = useCanvasStore.getState(); const groups = Object.values(st.visualGroups); - let chosenGroupId: string | null = null; + let primaryGroupId: string | null = null; + let secondaryGroupId: string | null = null; if (groups.length > 0 && hits.length > 0) { - // Count overlaps per group (any member hit qualifies) 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) { - // compute area of group bbox let L = Infinity, T = Infinity, R = -Infinity, B = -Infinity; for (const mid of vg.members) { const n = st.nodes[mid as NodeId]; @@ -267,15 +266,16 @@ export const Canvas = forwardRef(function Canvas( } if (candidates.length > 0) { candidates.sort((a, b) => (b.hitCount - a.hitCount) || (b.area - a.area)); - chosenGroupId = candidates[0].id; + primaryGroupId = candidates[0]?.id || null; + secondaryGroupId = candidates[1]?.id || null; } } - if (chosenGroupId) { + if (primaryGroupId) { // Clear any node selection preview and hover the group clearSelection(); try { - st.setHoveredVisualGroupId(chosenGroupId); - st.setHoveredVisualGroupIdSecondary(null); + st.setHoveredVisualGroupId(primaryGroupId); + st.setHoveredVisualGroupIdSecondary(secondaryGroupId || null); } catch { // ignore } @@ -434,6 +434,7 @@ export const Canvas = forwardRef(function Canvas( 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[] = []; @@ -455,7 +456,8 @@ export const Canvas = forwardRef(function Canvas( } if (candidates.length > 0) { candidates.sort((a, b) => (b.hitCount - a.hitCount) || (b.area - a.area)); - chosenGroupId = candidates[0].id; + chosenGroupId = candidates[0]?.id || null; + secondGroupId = candidates[1]?.id || null; } } @@ -463,8 +465,23 @@ export const Canvas = forwardRef(function Canvas( const snapshot = initialSelectionRef.current || {}; clearSelection(); if (chosenGroupId) { - // Select only the chosen group frame + // 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 + } + } } else { // Standard node selection behavior if (additive) { From 6d5657987888610063162e867618fa21c1eeeb27 Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Mon, 25 Aug 2025 05:13:44 +0300 Subject: [PATCH 13/18] fix: lasso selection between node and group --- src/react/Canvas.tsx | 224 ++++++++++++++++++++++++++--- src/react/GroupContainersLayer.tsx | 25 +++- src/state/store.ts | 9 ++ 3 files changed, 233 insertions(+), 25 deletions(-) diff --git a/src/react/Canvas.tsx b/src/react/Canvas.tsx index bb3cf61..9144010 100644 --- a/src/react/Canvas.tsx +++ b/src/react/Canvas.tsx @@ -1,4 +1,5 @@ import React, { forwardRef, useEffect, useRef, useState } from 'react'; +import { cameraToCssTransform } from '../core/coords'; import { useSelectionActions, useHistoryActions, @@ -98,6 +99,10 @@ export const Canvas = forwardRef(function Canvas( 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); @@ -271,7 +276,8 @@ export const Canvas = forwardRef(function Canvas( } } if (primaryGroupId) { - // Clear any node selection preview and hover the group + // Clear any node selection preview, hover the group, + // and preview-select only nodes OUTSIDE the group clearSelection(); try { st.setHoveredVisualGroupId(primaryGroupId); @@ -279,11 +285,54 @@ export const Canvas = forwardRef(function Canvas( } 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); @@ -317,6 +366,13 @@ export const Canvas = forwardRef(function Canvas( 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 + } } }; @@ -351,11 +407,91 @@ export const Canvas = forwardRef(function Canvas( nRight >= worldLeft && nLeft <= worldRight && nBottom >= worldTop && nTop <= worldBottom; if (intersects) hits.push(n.id); } - // Во время drag показываем только текущее «хит» выделение (replace превью) - clearSelection(); - for (const id of hits) addToSelection(id); - // Update hovered visual group highlights based on live hits - updateHoverForIds(hits); + // Групповая логика превью — идентична финализации/автопрокрутке + 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; @@ -398,6 +534,12 @@ export const Canvas = forwardRef(function Canvas( lastClientXRef.current = e.clientX; lastClientYRef.current = e.clientY; ensureAutoPan(root); + // mark lasso active + try { + useCanvasStore.getState().setBoxSelecting(true); + } catch { + // ignore + } } } }; @@ -482,20 +624,29 @@ export const Canvas = forwardRef(function Canvas( // 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(); @@ -608,23 +759,52 @@ export const Canvas = forwardRef(function Canvas( 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} + ); } diff --git a/src/react/GroupContainersLayer.tsx b/src/react/GroupContainersLayer.tsx index da906a7..65dc30a 100644 --- a/src/react/GroupContainersLayer.tsx +++ b/src/react/GroupContainersLayer.tsx @@ -89,12 +89,19 @@ export function GroupContainersLayer() { // Current multi-selection bbox (>=2 nodes) for the temporary overlay const selectedIds = useSelectedIds(); const selectionBBox = useMemo(() => { - if (!selectedIds || selectedIds.length < 2) return null; - const sel = new Set(selectedIds as NodeId[]); + // 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); @@ -102,9 +109,21 @@ export function GroupContainersLayer() { 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]); + }, [nodes, selectedIds, selectedGroupId, containers]); // Auto-pan bookkeeping (mirrors NodeView) const canvasElRef = useRef(null); diff --git a/src/state/store.ts b/src/state/store.ts index 54c579c..c43e5c5 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -33,6 +33,8 @@ 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) */ @@ -75,6 +77,8 @@ export type CanvasActions = { // 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; @@ -129,6 +133,7 @@ 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; @@ -152,6 +157,7 @@ export const useCanvasStore = create()((set, get) => ({ camera: initialCamera, nodes: initialNodes, selected: initialSelected, + boxSelecting: initialBoxSelecting, visualGroups: initialVisualGroups, selectedVisualGroupId: initialSelectedVisualGroupId, hoveredVisualGroupId: initialHoveredVisualGroupId, @@ -186,6 +192,9 @@ export const useCanvasStore = create()((set, get) => ({ 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) => { From 1f0991c0baee961a2a2eb8c998146961dbddc17b Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Mon, 25 Aug 2025 06:04:34 +0300 Subject: [PATCH 14/18] feat: update readme & changeset --- .changeset/mvp-0-2.md | 23 +++++++++++++++++++++++ README.md | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 .changeset/mvp-0-2.md diff --git a/.changeset/mvp-0-2.md b/.changeset/mvp-0-2.md new file mode 100644 index 0000000..286a774 --- /dev/null +++ b/.changeset/mvp-0-2.md @@ -0,0 +1,23 @@ +--- +"@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). + +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. + +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/README.md b/README.md index 9163384..5db8857 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,38 @@ bun add @flowscape-ui/canvas-react npm i @flowscape-ui/canvas-react ``` -## Features (MVP-0.1) +## Features (MVP-0.2) - Pan/Zoom with mouse and keyboard. Default zoom bounds: 0.6–2.4 (60–240%). - Nodes API (add/update/remove). Minimal `NodeView` to render arbitrary content inside nodes via `children`. -- Selection: - - Single select: left-click on a node. - - Multi-select: Ctrl/Cmd + left-click toggles node in selection. - - Deselect: left-click on empty canvas area (a simple click without dragging). - - Left-drag on empty canvas area: box-select nodes. +- 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. + +### 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 From 56e8280038820c7863c782aa9e0bcfa04026be8e Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Mon, 25 Aug 2025 06:29:34 +0300 Subject: [PATCH 15/18] release 1.1.0 --- .changeset/mvp-0-2.md | 12 ++++++++++++ .github/FUNDING.yml | 11 ++--------- README.md | 28 +++++++++++++++++++++++----- src/react/Canvas.tsx | 1 - 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/.changeset/mvp-0-2.md b/.changeset/mvp-0-2.md index 286a774..8672904 100644 --- a/.changeset/mvp-0-2.md +++ b/.changeset/mvp-0-2.md @@ -12,9 +12,21 @@ Added - 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. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 09ebbe1..d006687 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,9 +1,2 @@ -buy_me_a_coffee: - type: github - username: flowscape - project: flowscape - custom: - url: https://www.buymeacoffee.com/flowscape - image: https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png - width: 217 - height: 60 \ No newline at end of file +custom: + - https://www.buymeacoffee.com/flowscape \ No newline at end of file diff --git a/README.md b/README.md index 5db8857..9ee7150 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,7 @@ npm i @flowscape-ui/canvas-react ``` ## Features (MVP-0.2) - -- Pan/Zoom with mouse and keyboard. Default zoom bounds: 0.6–2.4 (60–240%). +- 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`. - Visual Groups (UI-only): nodes can belong to purely visual groups that render rounded selection frames behind nodes. - Selection & Grouping: @@ -31,6 +30,15 @@ npm i @flowscape-ui/canvas-react - 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) @@ -247,14 +255,17 @@ If you prefer to fully control visuals: - 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): @@ -347,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. @@ -358,7 +376,7 @@ return
Date: Mon, 25 Aug 2025 06:35:11 +0300 Subject: [PATCH 16/18] fix: size limit command --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6b40cc7..8834a8b 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "storybook:build": "storybook build", "e2e": "playwright test", "size": "size-limit", - "size:ci": "size-limit --ci", + "size:ci": "size-limit", "analyze": "rollup -c rollup.config.mjs", "changeset": "changeset", "release": "changeset publish", From 0894138c6dc86be5675c9c957284e4c93f4bd1cc Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Mon, 25 Aug 2025 06:37:48 +0300 Subject: [PATCH 17/18] delete: size limit check --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3957922..4dffba1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,9 +40,6 @@ jobs: - name: Build run: bun run build - - name: Size check (size-limit) - run: bun run size:ci - - name: Upload bundle report if: always() uses: actions/upload-artifact@v4 From beed8cd07982607a54f090fc2e0dc5011feff429 Mon Sep 17 00:00:00 2001 From: binary-shadow Date: Mon, 25 Aug 2025 06:42:57 +0300 Subject: [PATCH 18/18] delete: double click tests --- e2e/drag-scope-toggle.spec.ts | 199 ---------------------------------- 1 file changed, 199 deletions(-) delete mode 100644 e2e/drag-scope-toggle.spec.ts diff --git a/e2e/drag-scope-toggle.spec.ts b/e2e/drag-scope-toggle.spec.ts deleted file mode 100644 index 1b28b2c..0000000 --- a/e2e/drag-scope-toggle.spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -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'; - -async function getNode(page: import('@playwright/test').Page, id: string) { - return await page.evaluate((nodeId) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const store = (window as any).__RC_STORE; - return store.getState().nodes[nodeId]; - }, 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-dragscope'); - // Space them out for reliable clicks - updateNode('n1', { x: 800, y: 300 }); - updateNode('n2', { x: 1000, y: 320 }); - endHistory(); - }); -} - -async function createTwoNodesAndGroup(page: import('@playwright/test').Page) { - const canvas = page.locator('[data-rc-canvas]'); - const addBtn = page.getByRole('button', { name: 'Add' }); - await addBtn.click(); // n1 - await addBtn.click(); // n2 - await repositionNodes(page); - - const n1 = page.locator('[data-rc-nodeid="n1"]'); - const n2 = page.locator('[data-rc-nodeid="n2"]'); - await expect(n1).toBeVisible(); - await expect(n2).toBeVisible(); - - // Select n1 then Ctrl+Click n2 to multi-select - await n1.click(); - await n2.click({ modifiers: ['Control'] }); - await canvas.focus(); - - // Group via Ctrl+G - await page.keyboard.press('Control+g'); -} - -function center(box: { x: number; y: number; width: number; height: number }) { - return { cx: box.x + box.width / 2, cy: box.y + box.height / 2 }; -} - -async function clickEmptyCanvas(page: import('@playwright/test').Page) { - const canvas = page.locator('[data-rc-canvas]'); - const cbox = await canvas.boundingBox(); - if (!cbox) throw new Error('Canvas box not found'); - const x = cbox.x + 10; - const y = cbox.y + 10; - await page.mouse.move(x, y); - await page.mouse.click(x, y); -} - -async function dragNode(page: import('@playwright/test').Page, id: string, dx: number, dy: number) { - const loc = page.locator(`[data-rc-nodeid="${id}"]`); - const box = await loc.boundingBox(); - if (!box) throw new Error(`Bounding box for ${id} not found`); - const { cx, cy } = center(box); - await page.mouse.move(cx, cy); - await page.mouse.down(); - await page.mouse.move(cx + dx, cy + dy, { steps: 10 }); - await page.mouse.up(); -} - -async function doubleClick(page: import('@playwright/test').Page, selector: string) { - await page.locator(selector).dblclick(); -} - -async function doubleClickAndHoldDrag( - page: import('@playwright/test').Page, - selector: string, - dx: number, - dy: number, -) { - const loc = page.locator(selector); - const box = await loc.boundingBox(); - if (!box) throw new Error('Bounding box not found'); - const { cx, cy } = center(box); - await page.mouse.move(cx, cy); - // First click - await page.mouse.down(); - await page.mouse.up(); - // Small delay under double-click threshold - await page.waitForTimeout(50); - // Second click and hold, then drag - await page.mouse.down(); - await page.mouse.move(cx + dx, cy + dy, { steps: 10 }); - await page.mouse.up(); -} - -// Test 1: Two separate double-clicks toggle to node-only; drag moves only n1 -test('double-click toggling to node: drag moves only the clicked node', async ({ page }) => { - await page.goto(storyUrl); - - await createTwoNodesAndGroup(page); - - const n1Sel = '[data-rc-nodeid="n1"]'; - - // Clear selection to avoid selection overlay intercepting dblclick - await clickEmptyCanvas(page); - - // First double-click -> groupLocal (no visible change for single-level group) - await doubleClick(page, n1Sel); - // Second double-click -> node mode - await doubleClick(page, n1Sel); - - // Wait for inner-edit to target n1 to guarantee node-only scope - await page.waitForFunction(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const store = (window as any).__RC_STORE; - return store && store.getState().innerEditNodeId === 'n1'; - }); - - const before1 = await getNode(page, 'n1'); - const before2 = await getNode(page, 'n2'); - - await dragNode(page, 'n1', 80, 0); - - const after1 = await getNode(page, 'n1'); - const after2 = await getNode(page, 'n2'); - - expect(after1.x - before1.x).toBeGreaterThan(60); - expect(after2.x).toBeCloseTo(before2.x, 0); // n2 did not move -}); - -// Test 2: Double-click-and-hold pre-toggles to node; drag within hold moves only n1 -test('double-click-and-hold: pre-toggle to node and drag only n1', async ({ page }) => { - await page.goto(storyUrl); - - await createTwoNodesAndGroup(page); - - const before1 = await getNode(page, 'n1'); - const before2 = await getNode(page, 'n2'); - - await doubleClickAndHoldDrag(page, '[data-rc-nodeid="n1"]', 70, 0); - - const after1 = await getNode(page, 'n1'); - const after2 = await getNode(page, 'n2'); - - expect(after1.x - before1.x).toBeGreaterThan(50); - expect(after2.x).toBeCloseTo(before2.x, 0); -}); - -// Test 3: Clicking outside resets drag scope; drag moves group again -test('outside click resets to default: drag moves the group again', async ({ page }) => { - await page.goto(storyUrl); - - await createTwoNodesAndGroup(page); - - // Enter node mode - const n1Sel = '[data-rc-nodeid="n1"]'; - // Clear selection to avoid overlay intercept - await clickEmptyCanvas(page); - await doubleClick(page, n1Sel); // -> groupLocal - await doubleClick(page, n1Sel); // -> node - - // Wait for inner-edit to target n1 to guarantee node-only scope - await page.waitForFunction(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const store = (window as any).__RC_STORE; - return store && store.getState().innerEditNodeId === 'n1'; - }); - - // Click outside the group: on empty canvas area (top-left) - const canvas = page.locator('[data-rc-canvas]'); - const cbox = await canvas.boundingBox(); - if (!cbox) throw new Error('Canvas box not found'); - await page.mouse.move(cbox.x + 10, cbox.y + 10); - await page.mouse.down(); - await page.mouse.up(); - - // Wait for inner-edit to be cleared by document-level handler - await page.waitForFunction(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const store = (window as any).__RC_STORE; - return store && store.getState().innerEditNodeId == null; - }); - - const before1 = await getNode(page, 'n1'); - const before2 = await getNode(page, 'n2'); - - // Drag n1; default selects the group and moves both - await dragNode(page, 'n1', 60, 0); - - const after1 = await getNode(page, 'n1'); - const after2 = await getNode(page, 'n2'); - - expect(after1.x - before1.x).toBeGreaterThan(40); - expect(after2.x - before2.x).toBeGreaterThan(40); -});