From 582b25f2c38b016a5d5497d6eca44ffdc5b68a2f Mon Sep 17 00:00:00 2001 From: Pablo Galvez Date: Thu, 8 Oct 2026 17:54:50 +0200 Subject: [PATCH] feat: add shift and alt/option modifiers for shape drawing (#1177) --- AGENTS.md | 8 ++ code/CHANGELOG.md | 4 + code/package-lock.json | 104 +++++++++----- .../src/actions/ellipse-tool/ellipse-tool.ts | 72 ++++++---- .../__tests__/polygon-tool.test.ts | 57 +++++++- .../sdk/src/actions/polygon-tool/constants.ts | 1 + .../src/actions/polygon-tool/polygon-tool.ts | 130 +++++++++++++++++- .../__tests__/rectangle-tool.test.ts | 7 +- .../actions/rectangle-tool/rectangle-tool.ts | 56 ++++++-- .../regular-polygon-tool.ts | 61 +++++--- .../shared/__tests__/shape-geometry.test.ts | 67 +++++++++ .../sdk/src/actions/shared/shape-geometry.ts | 62 +++++++++ .../pages/build/actions/ellipse-tool.adoc | 5 +- .../pages/build/actions/polygon-tool.adoc | 6 +- .../pages/build/actions/rectangle-tool.adoc | 3 + .../build/actions/regular-polygon-tool.adoc | 1 + .../api-reference/actions/ellipse-tool.adoc | 2 + .../api-reference/actions/polygon-tool.adoc | 3 + .../api-reference/actions/rectangle-tool.adoc | 2 + .../actions/regular-polygon-tool.adoc | 2 + 20 files changed, 545 insertions(+), 108 deletions(-) create mode 100644 code/packages/sdk/src/actions/shared/__tests__/shape-geometry.test.ts create mode 100644 code/packages/sdk/src/actions/shared/shape-geometry.ts diff --git a/AGENTS.md b/AGENTS.md index 6de117001..80ff2c510 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,5 +101,13 @@ npx skills@latest add InditexTech/docouture --all | doc page | derived from | status | | ----------------- | --------------------- | ------ | | index.adoc (home) | manual (hand-written) | — | +| main:build/actions/rectangle-tool.adoc | `code/packages/sdk/src/actions/rectangle-tool/rectangle-tool.ts`, `code/packages/sdk/src/actions/shared/shape-geometry.ts` | updated for unreleased changes | +| main:build/actions/ellipse-tool.adoc | `code/packages/sdk/src/actions/ellipse-tool/ellipse-tool.ts`, `code/packages/sdk/src/actions/shared/shape-geometry.ts` | updated for unreleased changes | +| main:build/actions/polygon-tool.adoc | `code/packages/sdk/src/actions/polygon-tool/polygon-tool.ts`, `code/packages/sdk/src/nodes/polygon/presets.ts`, `code/packages/sdk/src/actions/shared/shape-geometry.ts` | updated for unreleased changes | +| main:build/actions/regular-polygon-tool.adoc | `code/packages/sdk/src/actions/regular-polygon-tool/regular-polygon-tool.ts`, `code/packages/sdk/src/actions/shared/shape-geometry.ts` | updated for unreleased changes | +| sdk:api-reference/actions/rectangle-tool.adoc | `code/packages/sdk/src/actions/rectangle-tool/rectangle-tool.ts`, `code/packages/sdk/src/actions/shared/shape-geometry.ts` | updated for unreleased changes | +| sdk:api-reference/actions/ellipse-tool.adoc | `code/packages/sdk/src/actions/ellipse-tool/ellipse-tool.ts`, `code/packages/sdk/src/actions/shared/shape-geometry.ts` | updated for unreleased changes | +| sdk:api-reference/actions/polygon-tool.adoc | `code/packages/sdk/src/actions/polygon-tool/polygon-tool.ts`, `code/packages/sdk/src/nodes/polygon/presets.ts`, `code/packages/sdk/src/actions/shared/shape-geometry.ts` | updated for unreleased changes | +| sdk:api-reference/actions/regular-polygon-tool.adoc | `code/packages/sdk/src/actions/regular-polygon-tool/regular-polygon-tool.ts`, `code/packages/sdk/src/actions/shared/shape-geometry.ts` | updated for unreleased changes | | sdk:api-reference/nodes/stroke.adoc | `code/packages/sdk/src/nodes/stroke/{types,constants}.ts` | updated for unreleased changes | | sdk:api-reference/actions/brush-tool.adoc | `code/packages/sdk/src/actions/brush-tool/{types,constants}.ts` | updated for unreleased changes | diff --git a/code/CHANGELOG.md b/code/CHANGELOG.md index fc2ae9f14..b969d0816 100644 --- a/code/CHANGELOG.md +++ b/code/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- [#1177](https://github.com/InditexTech/weavejs/issues/1177) Shift and Alt/Option modifiers when drawing shapes (proportions and center-based drawing) + ## [5.5.0] - 2026-10-02 ## [5.4.1] - 2026-09-16 diff --git a/code/package-lock.json b/code/package-lock.json index 6afd91832..caa5d3a05 100644 --- a/code/package-lock.json +++ b/code/package-lock.json @@ -302,7 +302,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2423,7 +2422,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2447,7 +2445,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3842,6 +3839,29 @@ "node": ">=14.0.0" } }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -4603,7 +4623,6 @@ "integrity": "sha512-VQ0hJ5jX31TVv/fhZx4xJRzd8pwn6VvzYd2tGOHHr2TfXGCBixZoqdPDXTiEoJLCTS2MmvBf6zyQZZ0M8aGQCQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@swc-node/core": "^1.14.1", "@swc-node/sourcemap-support": "^0.6.1", @@ -4661,7 +4680,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -4877,7 +4895,6 @@ "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -4888,7 +4905,6 @@ "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -4994,7 +5010,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5257,7 +5274,6 @@ "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5280,7 +5296,8 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/qs": { "version": "6.15.1", @@ -5411,7 +5428,6 @@ "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.26.0", "@typescript-eslint/types": "8.26.0", @@ -6192,7 +6208,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6897,7 +6912,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", @@ -7130,7 +7144,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ci-info": { "version": "4.4.0", @@ -7474,7 +7489,6 @@ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -7568,7 +7582,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dargs": { "version": "8.1.0", @@ -7689,6 +7704,7 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", + "peer": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -7711,6 +7727,7 @@ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", + "peer": true, "engines": { "node": ">=4.0.0" } @@ -7881,6 +7898,7 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8" } @@ -7920,7 +7938,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dot-prop": { "version": "5.3.0", @@ -8449,7 +8468,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9175,6 +9193,7 @@ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", + "peer": true, "engines": { "node": ">=6" } @@ -9839,7 +9858,8 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/glob": { "version": "11.1.0", @@ -11514,8 +11534,7 @@ "url": "https://github.com/sponsors/lavrton" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lcov-result-merger": { "version": "6.0.0", @@ -11932,6 +11951,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -12110,6 +12130,7 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -12166,7 +12187,8 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/moo-color": { "version": "1.0.3", @@ -12217,7 +12239,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/natural-compare": { "version": "1.4.0", @@ -12269,6 +12292,7 @@ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", "license": "MIT", + "peer": true, "dependencies": { "semver": "^7.3.5" }, @@ -12280,7 +12304,8 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/node-emoji": { "version": "2.2.0", @@ -12408,7 +12433,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@emnapi/core": "1.4.5", "@emnapi/runtime": "1.4.5", @@ -13095,7 +13119,6 @@ "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" }, @@ -13197,7 +13220,8 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/parse-imports": { "version": "2.2.1", @@ -13497,6 +13521,7 @@ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "license": "MIT", + "peer": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -13550,6 +13575,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -13565,6 +13591,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -13751,6 +13778,7 @@ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "peer": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -13765,13 +13793,15 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13808,7 +13838,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-reconciler": { "version": "0.28.0", @@ -14330,7 +14361,6 @@ "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" @@ -14423,7 +14453,6 @@ "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -14929,6 +14958,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -15360,7 +15390,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/simple-get": { "version": "4.0.1", @@ -15381,6 +15412,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -15626,6 +15658,7 @@ "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", "license": "MIT", + "peer": true, "dependencies": { "parenthesis": "^3.1.5" } @@ -15912,6 +15945,7 @@ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", "license": "MIT", + "peer": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -16291,6 +16325,7 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -16441,7 +16476,6 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16509,7 +16543,6 @@ "integrity": "sha512-MqtmbdNEdoNxTPzpWiWnqNac54h8JDAmkWtJExBVVnSrSmi9z+sZUt0LfKqk9rjqmKOIeRhO4fHHJ1nQIjduIQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.22.0", "@typescript-eslint/types": "8.22.0", @@ -16938,7 +16971,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -17014,7 +17046,6 @@ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", @@ -17553,7 +17584,6 @@ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.99" }, diff --git a/code/packages/sdk/src/actions/ellipse-tool/ellipse-tool.ts b/code/packages/sdk/src/actions/ellipse-tool/ellipse-tool.ts index c297492c2..64b2c7108 100644 --- a/code/packages/sdk/src/actions/ellipse-tool/ellipse-tool.ts +++ b/code/packages/sdk/src/actions/ellipse-tool/ellipse-tool.ts @@ -15,6 +15,10 @@ import { ELLIPSE_TOOL_ACTION_NAME, ELLIPSE_TOOL_STATE } from './constants'; import { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; import { SELECTION_TOOL_ACTION_NAME } from '../selection-tool/constants'; import type { WeaveEllipseNode } from '@/nodes/ellipse/ellipse'; +import { + getShapeDragBounds, + type ShapeDragModifiers, +} from '../shared/shape-geometry'; export class WeaveEllipseToolAction extends WeaveAction { protected initialized: boolean = false; @@ -83,6 +87,26 @@ export class WeaveEllipseToolAction extends WeaveAction { this.cancelAction(); return; } + if ( + (e.key === 'Shift' || e.key === 'Alt') && + this.state === ELLIPSE_TOOL_STATE.DEFINING_SIZE && + this.moved + ) { + this.handleMovement(e); + } + }, + { signal: this.instance.getEventsController().signal } + ); + window.addEventListener( + 'keyup', + (e) => { + if ( + (e.key === 'Shift' || e.key === 'Alt') && + this.state === ELLIPSE_TOOL_STATE.DEFINING_SIZE && + this.moved + ) { + this.handleMovement(e); + } }, { signal: this.instance.getEventsController().signal } ); @@ -130,7 +154,7 @@ export class WeaveEllipseToolAction extends WeaveAction { if (this.state === ELLIPSE_TOOL_STATE.DEFINING_SIZE) { this.moved = true; - this.handleMovement(); + this.handleMovement(e.evt); } }); @@ -146,7 +170,7 @@ export class WeaveEllipseToolAction extends WeaveAction { if (this.state === ELLIPSE_TOOL_STATE.DEFINING_SIZE) { this.creating = false; - this.handleSettingSize(); + this.handleSettingSize(e.evt); } }); @@ -183,8 +207,8 @@ export class WeaveEllipseToolAction extends WeaveAction { if (nodeHandler) { const node = nodeHandler.create(this.ellipseId, { ...this.props, - x: this.clickPoint?.x ?? 0 + this.props.radiusX, - y: this.clickPoint?.y ?? 0 + this.props.radiusY, + x: this.clickPoint?.x ?? 0, + y: this.clickPoint?.y ?? 0, radiusX: 1, radiusY: 1, }); @@ -195,7 +219,7 @@ export class WeaveEllipseToolAction extends WeaveAction { this.setState(ELLIPSE_TOOL_STATE.DEFINING_SIZE); } - private handleSettingSize() { + private handleSettingSize(modifiers: ShapeDragModifiers = {}) { const ellipse = this.instance.getStage().findOne(`#${this.ellipseId}`); if (this.ellipseId && this.clickPoint && this.container && ellipse) { @@ -206,17 +230,20 @@ export class WeaveEllipseToolAction extends WeaveAction { const nodeHandler = this.instance.getNodeHandler('ellipse'); - const ellipsePos: Konva.Vector2d = { + let ellipsePos: Konva.Vector2d = { x: this.clickPoint.x, y: this.clickPoint.y, }; - let ellipseRadiusX = this.props.radiusY; + let ellipseRadiusX = this.props.radiusX; let ellipseRadiusY = this.props.radiusY; if (this.moved) { - ellipsePos.x = Math.min(this.clickPoint.x, mousePoint.x); - ellipsePos.y = Math.min(this.clickPoint.y, mousePoint.y); - ellipseRadiusX = Math.abs(this.clickPoint.x - mousePoint.x); - ellipseRadiusY = Math.abs(this.clickPoint.y - mousePoint.y); + const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { + centered: modifiers.altKey, + constrained: modifiers.shiftKey, + }); + ellipsePos = { x: bounds.x, y: bounds.y }; + ellipseRadiusX = bounds.width; + ellipseRadiusY = bounds.height; } ellipse.setAttrs({ @@ -241,7 +268,7 @@ export class WeaveEllipseToolAction extends WeaveAction { this.cancelAction(); } - private handleMovement() { + private handleMovement(modifiers: ShapeDragModifiers = {}) { if (this.state !== ELLIPSE_TOOL_STATE.DEFINING_SIZE) { return; } @@ -253,17 +280,10 @@ export class WeaveEllipseToolAction extends WeaveAction { this.container ); - const deltaX = Math.abs(mousePoint.x - this.clickPoint?.x); - const deltaY = Math.abs(mousePoint.y - this.clickPoint?.y); - - const ellipsePos: Konva.Vector2d = { - x: this.clickPoint.x, - y: this.clickPoint.y, - }; - if (this.moved) { - ellipsePos.x = Math.min(this.clickPoint.x, mousePoint.x); - ellipsePos.y = Math.min(this.clickPoint.y, mousePoint.y); - } + const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { + centered: modifiers.altKey, + constrained: modifiers.shiftKey, + }); const nodeHandler = this.instance.getNodeHandler('ellipse'); @@ -272,8 +292,10 @@ export class WeaveEllipseToolAction extends WeaveAction { nodeHandler.onUpdate(ellipse as WeaveElementInstance, { ...this.props, id: this.ellipseId, - radiusX: deltaX / 2, - radiusY: deltaY / 2, + x: bounds.x, + y: bounds.y, + radiusX: bounds.width / 2, + radiusY: bounds.height / 2, }); } } diff --git a/code/packages/sdk/src/actions/polygon-tool/__tests__/polygon-tool.test.ts b/code/packages/sdk/src/actions/polygon-tool/__tests__/polygon-tool.test.ts index d28de28d8..6917a32a6 100644 --- a/code/packages/sdk/src/actions/polygon-tool/__tests__/polygon-tool.test.ts +++ b/code/packages/sdk/src/actions/polygon-tool/__tests__/polygon-tool.test.ts @@ -47,7 +47,13 @@ function makeMockWeave() { const stage = { container: vi.fn().mockReturnValue(stageContainer), on: vi.fn((event: string, handler: (e?: unknown) => void) => { - stageHandlers[event] = handler; + const previous = stageHandlers[event]; + stageHandlers[event] = previous + ? (e) => { + previous(e); + handler(e); + } + : handler; }), findOne: vi.fn().mockReturnValue(mockNode), }; @@ -60,6 +66,7 @@ function makeMockWeave() { const nodeHandlerMock = { create: vi.fn().mockReturnValue(mockNode), + onUpdate: vi.fn(), serialize: vi.fn().mockReturnValue({ id: 'test-uuid', type: 'polygon', props: {} }), }; @@ -70,6 +77,9 @@ function makeMockWeave() { mousePoint: { x: 50, y: 75 }, container: defaultContainer, }), + getMousePointerRelativeToContainer: vi.fn().mockReturnValue({ + mousePoint: { x: 120, y: 140 }, + }), getNodeHandler: vi.fn().mockReturnValue(nodeHandlerMock), getEventsController: vi.fn().mockReturnValue(new AbortController()), getActiveAction: vi.fn().mockReturnValue(POLYGON_TOOL_ACTION_NAME), @@ -194,7 +204,7 @@ describe('WeavePolygonToolAction', () => { }); }); - // ── Suite 5: pointerdown creates node ───────────────────────────────────── + // ── Suite 5: pointer interaction creates and sizes node ─────────────────── describe('pointerdown creates polygon', () => { it('5.1 calls addNode with the created node', () => { @@ -211,19 +221,54 @@ describe('WeavePolygonToolAction', () => { expect(mockWeave.addNode).toHaveBeenCalled(); }); - it('5.2 emits onAddingPolygon and onAddedPolygon', () => { + it('5.2 emits onAddingPolygon on pointerdown and onAddedPolygon on pointerup', () => { action.trigger(vi.fn()); const handlers = mockWeave._stageHandlers; handlers['pointerdown']?.({ - evt: { pointerId: 1, clientX: 50, clientY: 75, buttons: 0 }, + evt: { pointerId: 1, clientX: 50, clientY: 75, buttons: 1 }, }); expect(mockWeave.emitEvent).toHaveBeenCalledWith('onAddingPolygon'); + expect(mockWeave.emitEvent).not.toHaveBeenCalledWith('onAddedPolygon'); + handlers['pointerup']?.({ + evt: { pointerId: 1, clientX: 50, clientY: 75, buttons: 0 }, + }); expect(mockWeave.emitEvent).toHaveBeenCalledWith('onAddedPolygon'); }); - it('5.3 uses scaleFactor from updateProps when set', () => { + it('5.3 combines Shift and Alt while dragging from the initial center', () => { + action.trigger(vi.fn()); + const handlers = mockWeave._stageHandlers; + handlers['pointerdown']?.({ + evt: { pointerId: 1, clientX: 50, clientY: 75, buttons: 1 }, + }); + handlers['pointermove']?.({ + evt: { + pointerId: 1, + clientX: 80, + clientY: 100, + buttons: 1, + shiftKey: true, + altKey: true, + }, + }); + + const updateCall = mockWeave._nodeHandler.onUpdate.mock.calls[0]; + const updatedPolygon = updateCall?.[1] as Record; + const defaultGeometry = instantiatePreset( + WEAVE_POLYGON_PRESETS.pentagon, + WEAVE_POLYGON_PRESETS.pentagon.defaultWidth, + WEAVE_POLYGON_PRESETS.pentagon.defaultHeight + ); + expect(updatedPolygon.x + updatedPolygon.width / 2).toBeCloseTo(50); + expect(updatedPolygon.y + updatedPolygon.height / 2).toBeCloseTo(75); + expect(updatedPolygon.width / updatedPolygon.height).toBeCloseTo( + defaultGeometry.width / defaultGeometry.height + ); + }); + + it('5.4 uses scaleFactor from updateProps when set', () => { action.trigger(vi.fn()); action.updateProps({ scaleFactor: 2 }); @@ -242,7 +287,7 @@ describe('WeavePolygonToolAction', () => { expect(createCall?.height).toBe(expected.height); }); - it('5.4 uses preset defaults (scaleFactor=1) when not set', () => { + it('5.5 uses preset defaults (scaleFactor=1) when not set', () => { action.trigger(vi.fn()); const handlers = mockWeave._stageHandlers; diff --git a/code/packages/sdk/src/actions/polygon-tool/constants.ts b/code/packages/sdk/src/actions/polygon-tool/constants.ts index 72653542f..04a506ccf 100644 --- a/code/packages/sdk/src/actions/polygon-tool/constants.ts +++ b/code/packages/sdk/src/actions/polygon-tool/constants.ts @@ -7,5 +7,6 @@ export const POLYGON_TOOL_ACTION_NAME = 'polygonTool'; export const POLYGON_TOOL_STATE = { ['IDLE']: 'idle', ['ADDING']: 'adding', + ['DEFINING_SIZE']: 'defining-size', ['ADDED']: 'added', } as const; diff --git a/code/packages/sdk/src/actions/polygon-tool/polygon-tool.ts b/code/packages/sdk/src/actions/polygon-tool/polygon-tool.ts index c45fe69db..e2c6e622e 100644 --- a/code/packages/sdk/src/actions/polygon-tool/polygon-tool.ts +++ b/code/packages/sdk/src/actions/polygon-tool/polygon-tool.ts @@ -3,8 +3,9 @@ // SPDX-License-Identifier: Apache-2.0 import { v4 as uuidv4 } from 'uuid'; - +import Konva from 'konva'; import { WeaveAction } from '@/actions/action'; +import { type WeaveElementInstance } from '@inditextech/weave-types'; import { type WeavePolygonToolActionState, type WeavePolygonToolActionTriggerParams, @@ -19,11 +20,19 @@ import { type WeavePolygonPresetDef, } from '@/nodes/polygon/presets'; import { WEAVE_POLYGON_NODE_TYPE } from '@/nodes/polygon/constants'; +import { + getShapeDragBounds, + type ShapeDragModifiers, +} from '../shared/shape-geometry'; export class WeavePolygonToolAction extends WeaveAction { protected initialized: boolean = false; protected state!: WeavePolygonToolActionState; protected polygonId!: string | null; + protected pointers!: Map; + protected clickPoint!: Konva.Vector2d | null; + protected container!: Konva.Layer | Konva.Node | undefined; + protected moved!: boolean; protected cancelAction!: () => void; protected preset: string; onPropsChange = undefined; @@ -39,6 +48,10 @@ export class WeavePolygonToolAction extends WeaveAction { this.initialized = false; this.state = POLYGON_TOOL_STATE.IDLE; this.polygonId = null; + this.pointers = new Map(); + this.clickPoint = null; + this.container = undefined; + this.moved = false; this.props = this.initProps(); } @@ -78,6 +91,27 @@ export class WeavePolygonToolAction extends WeaveAction { this.instance.getActiveAction() === POLYGON_TOOL_ACTION_NAME ) { this.cancelAction(); + return; + } + if ( + (e.key === 'Shift' || e.key === 'Alt') && + this.state === POLYGON_TOOL_STATE.DEFINING_SIZE && + this.moved + ) { + this.handleMovement(e); + } + }, + { signal: this.instance.getEventsController().signal } + ); + window.addEventListener( + 'keyup', + (e) => { + if ( + (e.key === 'Shift' || e.key === 'Alt') && + this.state === POLYGON_TOOL_STATE.DEFINING_SIZE && + this.moved + ) { + this.handleMovement(e); } }, { signal: this.instance.getEventsController().signal } @@ -91,12 +125,35 @@ export class WeavePolygonToolAction extends WeaveAction { stage.on('pointerdown', (e) => { this.setTapStart(e); + this.pointers.set(e.evt.pointerId, { + x: e.evt.clientX, + y: e.evt.clientY, + }); if (this.state !== POLYGON_TOOL_STATE.ADDING) return; this.handleAdding(); }); + stage.on('pointermove', (e) => { + if (this.state === POLYGON_TOOL_STATE.IDLE) return; + this.setCursor(); + if (!this.isPressed(e) || !this.pointers.has(e.evt.pointerId)) return; + if (this.state === POLYGON_TOOL_STATE.DEFINING_SIZE) { + this.moved = true; + this.handleMovement(e.evt); + } + }); + + stage.on('pointerup', (e) => { + this.pointers.delete(e.evt.pointerId); + const isTap = this.isTap(e); + if (isTap) this.moved = false; + if (this.state === POLYGON_TOOL_STATE.DEFINING_SIZE) { + this.handleSettingSize(e.evt); + } + }); + this.initialized = true; } @@ -108,9 +165,7 @@ export class WeavePolygonToolAction extends WeaveAction { this.setCursor(); this.setFocusStage(); - this.instance.emitEvent( - 'onAddingPolygon' - ); + this.instance.emitEvent('onAddingPolygon'); this.setState(POLYGON_TOOL_STATE.ADDING); } @@ -118,6 +173,9 @@ export class WeavePolygonToolAction extends WeaveAction { private handleAdding() { const { mousePoint, container } = this.instance.getMousePointer(); + this.clickPoint = mousePoint; + this.container = container; + this.polygonId = uuidv4(); const presetDef = WEAVE_POLYGON_PRESETS[this.preset]; @@ -146,9 +204,65 @@ export class WeavePolygonToolAction extends WeaveAction { this.instance.addNode(node, container?.getAttrs().id); } - this.instance.emitEvent( - 'onAddedPolygon' + this.setState(POLYGON_TOOL_STATE.DEFINING_SIZE); + } + + private handleMovement(modifiers: ShapeDragModifiers = {}) { + if ( + this.state !== POLYGON_TOOL_STATE.DEFINING_SIZE || + !this.polygonId || + !this.clickPoint || + !this.container + ) { + return; + } + const polygon = this.instance.getStage().findOne(`#${this.polygonId}`); + const nodeHandler = this.instance.getNodeHandler( + WEAVE_POLYGON_NODE_TYPE + ); + const presetDef = WEAVE_POLYGON_PRESETS[this.preset]; + if (!polygon || !nodeHandler) return; + + const { mousePoint } = this.instance.getMousePointerRelativeToContainer( + this.container + ); + const defaultGeometry = instantiatePreset( + presetDef, + presetDef.defaultWidth, + presetDef.defaultHeight + ); + const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { + centered: modifiers.altKey, + constrained: modifiers.shiftKey, + aspectRatio: defaultGeometry.width / defaultGeometry.height, + }); + const geometry = instantiatePreset( + presetDef, + (bounds.width * presetDef.defaultWidth) / defaultGeometry.width, + (bounds.height * presetDef.defaultHeight) / defaultGeometry.height + ); + nodeHandler.onUpdate(polygon as WeaveElementInstance, { + ...this.props, + id: this.polygonId, + x: bounds.x, + y: bounds.y, + ...geometry, + }); + } + + private handleSettingSize(modifiers: ShapeDragModifiers = {}) { + if (this.moved) this.handleMovement(modifiers); + const polygon = + this.polygonId && this.instance.getStage().findOne(`#${this.polygonId}`); + const nodeHandler = this.instance.getNodeHandler( + WEAVE_POLYGON_NODE_TYPE ); + if (polygon && nodeHandler) { + this.instance.updateNode( + nodeHandler.serialize(polygon as WeaveElementInstance) + ); + } + this.instance.emitEvent('onAddedPolygon'); this.cancelAction(); } @@ -198,6 +312,10 @@ export class WeavePolygonToolAction extends WeaveAction { } this.polygonId = null; + this.pointers.clear(); + this.clickPoint = null; + this.container = undefined; + this.moved = false; this.setState(POLYGON_TOOL_STATE.IDLE); } diff --git a/code/packages/sdk/src/actions/rectangle-tool/__tests__/rectangle-tool.test.ts b/code/packages/sdk/src/actions/rectangle-tool/__tests__/rectangle-tool.test.ts index a6c3d899a..a0e588814 100644 --- a/code/packages/sdk/src/actions/rectangle-tool/__tests__/rectangle-tool.test.ts +++ b/code/packages/sdk/src/actions/rectangle-tool/__tests__/rectangle-tool.test.ts @@ -786,7 +786,12 @@ describe('WeaveRectangleToolAction', () => { // mousePoint={x:120,y:140} → deltaX=70, deltaY=65 (action as unknown as R)['handleMovement'](); expect((action as unknown as R)['moved']).toBe(true); - expect(tempRect.setAttrs).toHaveBeenCalledWith({ width: 70, height: 65 }); + expect(tempRect.setAttrs).toHaveBeenCalledWith({ + x: 50, + y: 75, + width: 70, + height: 65, + }); }); it('17.3 rectId=null → no setAttrs', () => { diff --git a/code/packages/sdk/src/actions/rectangle-tool/rectangle-tool.ts b/code/packages/sdk/src/actions/rectangle-tool/rectangle-tool.ts index f7f1d90a5..4ab19c5d7 100644 --- a/code/packages/sdk/src/actions/rectangle-tool/rectangle-tool.ts +++ b/code/packages/sdk/src/actions/rectangle-tool/rectangle-tool.ts @@ -14,6 +14,10 @@ import { RECTANGLE_TOOL_ACTION_NAME, RECTANGLE_TOOL_STATE } from './constants'; import { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; import { SELECTION_TOOL_ACTION_NAME } from '../selection-tool/constants'; import type { WeaveRectangleNode } from '@/nodes/rectangle/rectangle'; +import { + getShapeDragBounds, + type ShapeDragModifiers, +} from '../shared/shape-geometry'; export class WeaveRectangleToolAction extends WeaveAction { protected initialized: boolean = false; @@ -83,6 +87,26 @@ export class WeaveRectangleToolAction extends WeaveAction { this.cancelAction(); return; } + if ( + (e.key === 'Shift' || e.key === 'Alt') && + this.state === RECTANGLE_TOOL_STATE.DEFINING_SIZE && + this.moved + ) { + this.handleMovement(e); + } + }, + { signal: this.instance.getEventsController().signal } + ); + window.addEventListener( + 'keyup', + (e) => { + if ( + (e.key === 'Shift' || e.key === 'Alt') && + this.state === RECTANGLE_TOOL_STATE.DEFINING_SIZE && + this.moved + ) { + this.handleMovement(e); + } }, { signal: this.instance.getEventsController().signal } ); @@ -132,7 +156,7 @@ export class WeaveRectangleToolAction extends WeaveAction { } if (this.state === RECTANGLE_TOOL_STATE.DEFINING_SIZE) { - this.handleMovement(); + this.handleMovement(e.evt); } }); @@ -146,7 +170,7 @@ export class WeaveRectangleToolAction extends WeaveAction { } if (this.state === RECTANGLE_TOOL_STATE.DEFINING_SIZE) { - this.handleSettingSize(); + this.handleSettingSize(e.evt); } }); @@ -195,7 +219,7 @@ export class WeaveRectangleToolAction extends WeaveAction { this.setState(RECTANGLE_TOOL_STATE.DEFINING_SIZE); } - private handleSettingSize() { + private handleSettingSize(modifiers: ShapeDragModifiers = {}) { if (this.rectId && this.tempRectNode && this.clickPoint && this.container) { const { mousePoint } = this.instance.getMousePointerRelativeToContainer( this.container @@ -208,10 +232,14 @@ export class WeaveRectangleToolAction extends WeaveAction { let rectWidth = this.props.width; let rectHeight = this.props.height; if (this.moved) { - rectPos.x = Math.min(this.clickPoint.x, mousePoint.x); - rectPos.y = Math.min(this.clickPoint.y, mousePoint.y); - rectWidth = Math.abs(this.clickPoint.x - mousePoint.x); - rectHeight = Math.abs(this.clickPoint.y - mousePoint.y); + const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { + centered: modifiers.altKey, + constrained: modifiers.shiftKey, + }); + rectPos.x = bounds.x; + rectPos.y = bounds.y; + rectWidth = bounds.width; + rectHeight = bounds.height; } this.tempRectNode.setAttrs({ @@ -260,7 +288,7 @@ export class WeaveRectangleToolAction extends WeaveAction { this.cancelAction(); } - private handleMovement() { + private handleMovement(modifiers: ShapeDragModifiers = {}) { if (this.state !== RECTANGLE_TOOL_STATE.DEFINING_SIZE) { return; } @@ -277,12 +305,16 @@ export class WeaveRectangleToolAction extends WeaveAction { this.measureContainer ); - const deltaX = mousePoint.x - this.clickPoint?.x; - const deltaY = mousePoint.y - this.clickPoint?.y; + const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { + centered: modifiers.altKey, + constrained: modifiers.shiftKey, + }); this.tempRectNode.setAttrs({ - width: deltaX, - height: deltaY, + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, }); } } diff --git a/code/packages/sdk/src/actions/regular-polygon-tool/regular-polygon-tool.ts b/code/packages/sdk/src/actions/regular-polygon-tool/regular-polygon-tool.ts index 28d79c973..135ecbb42 100644 --- a/code/packages/sdk/src/actions/regular-polygon-tool/regular-polygon-tool.ts +++ b/code/packages/sdk/src/actions/regular-polygon-tool/regular-polygon-tool.ts @@ -18,6 +18,10 @@ import { import { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; import { SELECTION_TOOL_ACTION_NAME } from '../selection-tool/constants'; import type { WeaveRegularPolygonNode } from '@/nodes/regular-polygon/regular-polygon'; +import { + getShapeDragBounds, + type ShapeDragModifiers, +} from '../shared/shape-geometry'; export class WeaveRegularPolygonToolAction extends WeaveAction { protected initialized: boolean = false; @@ -85,6 +89,26 @@ export class WeaveRegularPolygonToolAction extends WeaveAction { this.cancelAction(); return; } + if ( + (e.key === 'Shift' || e.key === 'Alt') && + this.state === REGULAR_POLYGON_TOOL_STATE.DEFINING_SIZE && + this.moved + ) { + this.handleMovement(e); + } + }, + { signal: this.instance.getEventsController().signal } + ); + window.addEventListener( + 'keyup', + (e) => { + if ( + (e.key === 'Shift' || e.key === 'Alt') && + this.state === REGULAR_POLYGON_TOOL_STATE.DEFINING_SIZE && + this.moved + ) { + this.handleMovement(e); + } }, { signal: this.instance.getEventsController().signal } ); @@ -132,7 +156,7 @@ export class WeaveRegularPolygonToolAction extends WeaveAction { if (this.state === REGULAR_POLYGON_TOOL_STATE.DEFINING_SIZE) { this.moved = true; - this.handleMovement(); + this.handleMovement(e.evt); } }); @@ -148,7 +172,7 @@ export class WeaveRegularPolygonToolAction extends WeaveAction { if (this.state === REGULAR_POLYGON_TOOL_STATE.DEFINING_SIZE) { this.creating = false; - this.handleSettingSize(); + this.handleSettingSize(e.evt); } }); @@ -195,7 +219,7 @@ export class WeaveRegularPolygonToolAction extends WeaveAction { this.setState(REGULAR_POLYGON_TOOL_STATE.DEFINING_SIZE); } - private handleSettingSize() { + private handleSettingSize(modifiers: ShapeDragModifiers = {}) { const regularPolygon = this.instance .getStage() .findOne(`#${this.regularPolygonId}`); @@ -215,15 +239,18 @@ export class WeaveRegularPolygonToolAction extends WeaveAction { 'regular-polygon' ); - const starPos: Konva.Vector2d = { + let starPos: Konva.Vector2d = { x: this.clickPoint.x, y: this.clickPoint.y, }; let newRadius = this.props.radius; if (this.moved) { - starPos.x = Math.min(this.clickPoint.x, mousePoint.x); - starPos.y = Math.min(this.clickPoint.y, mousePoint.y); - newRadius = Math.abs(this.clickPoint.x - mousePoint.x); + const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { + centered: modifiers.altKey, + constrained: true, + }); + starPos = { x: bounds.x, y: bounds.y }; + newRadius = bounds.width; } regularPolygon.setAttrs({ @@ -247,7 +274,7 @@ export class WeaveRegularPolygonToolAction extends WeaveAction { this.cancelAction(); } - private handleMovement() { + private handleMovement(modifiers: ShapeDragModifiers = {}) { if (this.state !== REGULAR_POLYGON_TOOL_STATE.DEFINING_SIZE) { return; } @@ -266,16 +293,10 @@ export class WeaveRegularPolygonToolAction extends WeaveAction { this.container ); - const deltaX = Math.abs(mousePoint.x - this.clickPoint?.x); - - const starPos: Konva.Vector2d = { - x: this.clickPoint.x, - y: this.clickPoint.y, - }; - if (this.moved) { - starPos.x = Math.min(this.clickPoint.x, mousePoint.x); - starPos.y = Math.min(this.clickPoint.y, mousePoint.y); - } + const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { + centered: modifiers.altKey, + constrained: true, + }); const nodeHandler = this.instance.getNodeHandler( @@ -286,7 +307,9 @@ export class WeaveRegularPolygonToolAction extends WeaveAction { nodeHandler.onUpdate(regularPolygon as WeaveElementInstance, { ...this.props, id: this.regularPolygonId, - radius: deltaX / 2, + x: bounds.x, + y: bounds.y, + radius: bounds.width / 2, }); } } diff --git a/code/packages/sdk/src/actions/shared/__tests__/shape-geometry.test.ts b/code/packages/sdk/src/actions/shared/__tests__/shape-geometry.test.ts new file mode 100644 index 000000000..facb7df65 --- /dev/null +++ b/code/packages/sdk/src/actions/shared/__tests__/shape-geometry.test.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { getShapeDragBounds } from '../shape-geometry'; + +describe('getShapeDragBounds', () => { + it('normalizes a drag from either direction', () => { + expect(getShapeDragBounds({ x: 100, y: 100 }, { x: 40, y: 70 })).toEqual({ + x: 40, + y: 70, + width: 60, + height: 30, + }); + }); + + it('constrains a drag to a square', () => { + expect( + getShapeDragBounds( + { x: 10, y: 20 }, + { x: 70, y: 60 }, + { constrained: true } + ) + ).toEqual({ x: 10, y: 20, width: 60, height: 60 }); + }); + + it('keeps the drag direction when constraining a reverse drag', () => { + expect( + getShapeDragBounds( + { x: 100, y: 100 }, + { x: 40, y: 70 }, + { constrained: true } + ) + ).toEqual({ x: 40, y: 40, width: 60, height: 60 }); + }); + + it('constrains a drag to a preset aspect ratio', () => { + expect( + getShapeDragBounds( + { x: 10, y: 20 }, + { x: 70, y: 60 }, + { constrained: true, aspectRatio: 2 } + ) + ).toEqual({ x: 10, y: 20, width: 80, height: 40 }); + }); + + it('draws outward from the anchor when centered', () => { + expect( + getShapeDragBounds( + { x: 50, y: 50 }, + { x: 80, y: 70 }, + { centered: true } + ) + ).toEqual({ x: 20, y: 30, width: 60, height: 40 }); + }); + + it('combines centering and proportion constraints', () => { + expect( + getShapeDragBounds( + { x: 50, y: 50 }, + { x: 80, y: 70 }, + { centered: true, constrained: true } + ) + ).toEqual({ x: 20, y: 20, width: 60, height: 60 }); + }); +}); diff --git a/code/packages/sdk/src/actions/shared/shape-geometry.ts b/code/packages/sdk/src/actions/shared/shape-geometry.ts new file mode 100644 index 000000000..65596639b --- /dev/null +++ b/code/packages/sdk/src/actions/shared/shape-geometry.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import type Konva from 'konva'; + +export type ShapeDragBounds = { + x: number; + y: number; + width: number; + height: number; +}; + +export type ShapeDragModifiers = { + shiftKey?: boolean; + altKey?: boolean; +}; + +/** Returns the normalized bounds described by a drag, including its modifiers. */ +export function getShapeDragBounds( + anchor: Konva.Vector2d, + pointer: Konva.Vector2d, + options: { + centered?: boolean; + constrained?: boolean; + aspectRatio?: number; + } = {} +): ShapeDragBounds { + const { centered = false, constrained = false, aspectRatio = 1 } = options; + const deltaX = Math.abs(pointer.x - anchor.x); + const deltaY = Math.abs(pointer.y - anchor.y); + let width = deltaX; + let height = deltaY; + + if (constrained && aspectRatio > 0) { + if (width / (height || Number.EPSILON) > aspectRatio) { + height = width / aspectRatio; + } else { + width = height * aspectRatio; + } + } + + if (centered) { + width *= 2; + height *= 2; + return { + x: anchor.x - width / 2, + y: anchor.y - height / 2, + width, + height, + }; + } + + const signedWidth = Math.sign(pointer.x - anchor.x || 1) * width; + const signedHeight = Math.sign(pointer.y - anchor.y || 1) * height; + return { + x: Math.min(anchor.x, anchor.x + signedWidth), + y: Math.min(anchor.y, anchor.y + signedHeight), + width, + height, + }; +} diff --git a/docs/src/modules/main/pages/build/actions/ellipse-tool.adoc b/docs/src/modules/main/pages/build/actions/ellipse-tool.adoc index 19c28a52b..979f5f710 100644 --- a/docs/src/modules/main/pages/build/actions/ellipse-tool.adoc +++ b/docs/src/modules/main/pages/build/actions/ellipse-tool.adoc @@ -74,7 +74,10 @@ When active the user can: * *Click* on the canvas, it will create a ellipse with a default size. * *Touch* on the canvas, it will create a ellipse with a default size. * *Click & drag* to create a ellipse and define its radius on both axis (X axis control -the x-radius and y axis controls the y-radius, if both radius are the same you have a circle). + the x-radius and y axis controls the y-radius, if both radius are the same you have a circle). +* Hold *Shift* while dragging to constrain it to a circle. +* Hold *Alt/Option* while dragging to draw outward from the initial click, which stays at the ellipse's center. +* Hold *Shift + Alt/Option* to draw a centered circle. Once clicked or click-drag finishes, the ellipse is added to the canvas as a fully functional node—ready. This action integrates seamlessly with Weave.js's real-time state system, diff --git a/docs/src/modules/main/pages/build/actions/polygon-tool.adoc b/docs/src/modules/main/pages/build/actions/polygon-tool.adoc index 8f0736cef..bc6713474 100644 --- a/docs/src/modules/main/pages/build/actions/polygon-tool.adoc +++ b/docs/src/modules/main/pages/build/actions/polygon-tool.adoc @@ -3,7 +3,7 @@ == Introduction -This action enables users to create new polygon nodes on the canvas by clicking or touching it. +This action enables users to create new polygon nodes on the canvas by clicking, touching, or dragging. The shape drawn is determined by a *preset* — one of the six built-in polygon shapes. == Dependencies @@ -88,6 +88,10 @@ When active the user can: * *Click* on the canvas, it will place the selected preset polygon at the cursor position with its default size. * *Touch* on the canvas, it will place the selected preset polygon at the touch position with its default size. +* *Click & drag* to set the polygon's size. +* Hold *Shift* while dragging to preserve the preset's default proportions. +* Hold *Alt/Option* while dragging to draw outward from the initial click, which stays at the polygon's center. +* Hold *Shift + Alt/Option* to combine centered drawing with the preset's proportions. Once placed, the polygon is added to the canvas as a fully functional node. This action integrates seamlessly with Weave.js's real-time state system, ensuring the new element appears instantly for diff --git a/docs/src/modules/main/pages/build/actions/rectangle-tool.adoc b/docs/src/modules/main/pages/build/actions/rectangle-tool.adoc index 39ccddc21..92319fd97 100644 --- a/docs/src/modules/main/pages/build/actions/rectangle-tool.adoc +++ b/docs/src/modules/main/pages/build/actions/rectangle-tool.adoc @@ -74,6 +74,9 @@ When active the user can: * *Click* on the canvas, it will create a rectangle with a default size. * *Touch* on the canvas, it will create a rectangle with a default size. * *Click & drag* to create a rectangle and define its size. +* Hold *Shift* while dragging to constrain it to a square. +* Hold *Alt/Option* while dragging to draw outward from the initial click, which stays at the rectangle's center. +* Hold *Shift + Alt/Option* to draw a centered square. Once clicked or click-drag finishes, the rectangle is added to the canvas as a fully functional node—ready. This action integrates seamlessly with Weave.js's real-time state system, diff --git a/docs/src/modules/main/pages/build/actions/regular-polygon-tool.adoc b/docs/src/modules/main/pages/build/actions/regular-polygon-tool.adoc index abdaa81fd..4331cfb2b 100644 --- a/docs/src/modules/main/pages/build/actions/regular-polygon-tool.adoc +++ b/docs/src/modules/main/pages/build/actions/regular-polygon-tool.adoc @@ -80,6 +80,7 @@ When active the user can: * *Click* on the canvas, it will create a regular polygon with a default size. * *Touch* on the canvas, it will create a regular polygon with a default size. * *Click & drag* to create a regular polygon and define its radius. +* Hold *Alt/Option* while dragging to draw outward from the initial click, which stays at the polygon's center. Regular polygons keep their equal proportions by default. Once clicked or click-drag finishes, the regular polygon is added to the canvas as a fully functional node—ready. This action integrates seamlessly with Weave.js's real-time state system, diff --git a/docs/src/modules/sdk/pages/api-reference/actions/ellipse-tool.adoc b/docs/src/modules/sdk/pages/api-reference/actions/ellipse-tool.adoc index 83f17025f..a83b20ec0 100644 --- a/docs/src/modules/sdk/pages/api-reference/actions/ellipse-tool.adoc +++ b/docs/src/modules/sdk/pages/api-reference/actions/ellipse-tool.adoc @@ -12,6 +12,8 @@ mockups, and more. Each interaction results in the creation of a xref:sdk:api-reference/nodes/ellipse.adoc[WeaveEllipseNode], added to the shared state and synchronized across all users. +While dragging, hold `Shift` to draw a circle, `Alt`/`Option` to draw from the initial point as the center, or both to combine the behaviors. + The class extends the xref:sdk:api-reference/actions/action.adoc[WeaveAction] class. == Name diff --git a/docs/src/modules/sdk/pages/api-reference/actions/polygon-tool.adoc b/docs/src/modules/sdk/pages/api-reference/actions/polygon-tool.adoc index 037312549..2866121db 100644 --- a/docs/src/modules/sdk/pages/api-reference/actions/polygon-tool.adoc +++ b/docs/src/modules/sdk/pages/api-reference/actions/polygon-tool.adoc @@ -5,6 +5,7 @@ The https://github.com/InditexTech/weavejs/blob/main/code/packages/sdk/src/actions/polygon-tool/polygon-tool.ts[WeavePolygonToolAction] class allows users to place a polygon node on the canvas by clicking or touching it. +Users can also drag to set its size. The shape is selected via a *preset* — one of the six built-in polygon definitions shipped with the SDK. The preset can be set at construction time, changed programmatically, or @@ -13,6 +14,8 @@ overridden per-trigger via the `presetId` trigger parameter. Each interaction results in the creation of a xref:sdk:api-reference/nodes/polygon.adoc[WeavePolygonNode], added to the shared state and synchronized across all users. +While dragging, hold `Shift` to preserve the preset's default proportions, `Alt`/`Option` to draw from the initial point as the center, or both to combine the behaviors. + The class extends the xref:sdk:api-reference/actions/action.adoc[WeaveAction] class. == Name diff --git a/docs/src/modules/sdk/pages/api-reference/actions/rectangle-tool.adoc b/docs/src/modules/sdk/pages/api-reference/actions/rectangle-tool.adoc index ef76dcc7f..f93336576 100644 --- a/docs/src/modules/sdk/pages/api-reference/actions/rectangle-tool.adoc +++ b/docs/src/modules/sdk/pages/api-reference/actions/rectangle-tool.adoc @@ -12,6 +12,8 @@ mockups, and more. Each interaction results in the creation of a xref:sdk:api-reference/nodes/rectangle.adoc[WeaveRectangleNode], added to the shared state and synchronized across all users. +While dragging, hold `Shift` to draw a square, `Alt`/`Option` to draw from the initial point as the center, or both to combine the behaviors. + The class extends the xref:sdk:api-reference/actions/action.adoc[WeaveAction] class. == Name diff --git a/docs/src/modules/sdk/pages/api-reference/actions/regular-polygon-tool.adoc b/docs/src/modules/sdk/pages/api-reference/actions/regular-polygon-tool.adoc index c25ec4a26..4e0e4a5b3 100644 --- a/docs/src/modules/sdk/pages/api-reference/actions/regular-polygon-tool.adoc +++ b/docs/src/modules/sdk/pages/api-reference/actions/regular-polygon-tool.adoc @@ -17,6 +17,8 @@ star elements . Each interaction results in the creation of a xref:sdk:api-reference/nodes/star.adoc[WeaveRegularPolygonNode], added to the shared state and synchronized across all users. +While dragging, hold `Alt`/`Option` to draw from the initial point as the center. Regular polygons keep their equal proportions by default. + The class extends the xref:sdk:api-reference/actions/action.adoc[WeaveAction] class. == Name