diff --git a/.ai/rules/js.md b/.ai/rules/js.md index 0cc0a31d61c..def22444f0f 100644 --- a/.ai/rules/js.md +++ b/.ai/rules/js.md @@ -16,3 +16,8 @@ Check VueUse before writing lifecycle-managed composables. PHP-derived TypeScrip ## Treat custom elements as native Vue treats tags containing a hyphen as native custom elements. Let them pass through instead of registering them as Vue components. + +## Bind Lit boolean properties with `.prop`, not `:attr` +Vue only strips a `false` boolean attribute for the seven names in its `isSpecialBooleanAttr` list (itemscope, allowfullscreen, formnovalidate, ismap, nomodule, novalidate, readonly). `open`, `disabled`, `active` and friends are in the wider `isBooleanAttr` list, which `patchAttr` never consults — so `:open="false"` on a custom element writes `open="false"`, and Lit's `{type: Boolean}` converter reads attribute *presence* and turns it on. + +Use the property binding instead: `.opened="isOpen"` on `craft-dialog`, not `:open="isOpen"`. It also survives the element not being upgraded yet, since Lit re-applies shadowed instance properties. Regression test: `resources/js/modules/image-editor/components/dialogOpenBinding.test.ts`. diff --git a/package-lock.json b/package-lock.json index 90950245c7a..8b23840ad92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@tanstack/vue-table": "^8.21.3", "@vueuse/core": "^14.3.0", "axios": "^1.17.0", + "fabric": "^7.4.0", "laravel-vite-plugin": "^3.1.0", "lit": "^3.3.2", "overtype": "^2.4.0", @@ -234,6 +235,122 @@ "node": ">=14.17" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC", + "optional": true + }, "node_modules/@atlaskit/pragmatic-drag-and-drop": { "version": "1.8.1", "license": "Apache-2.0", @@ -1921,6 +2038,26 @@ "resolved": "packages/craftcms-webpack", "link": true }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/css-calc": { "version": "3.2.0", "dev": true, @@ -8985,6 +9122,29 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-1.0.9.tgz", + "integrity": "sha512-j3/4pkfih8W4NK22gxVSXcEonTpAHOHh0hu5BoZrKcOsW/4oBPxTi4Yk3SAj+FhC1f3+bRTkXdm4019gw1vg9g==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^2.1.0" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-2.7.0.tgz", + "integrity": "sha512-pXK8ez/pVjqFdAgBkF1YPVRacuLQ9EXBKaKWaeh58WNfMkCmZhOZzu+NtKSPD5PHmCCHheQ5cD29qM1K4QTxIg==", + "license": "MIT", + "optional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/acorn-import-phases": { "version": "1.0.4", "license": "MIT", @@ -9037,7 +9197,6 @@ "node_modules/ajv": { "version": "6.15.0", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -9311,11 +9470,31 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assert-never": { "version": "1.4.0", "dev": true, "license": "MIT" }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "license": "MIT", @@ -9439,6 +9618,23 @@ "node": ">=18" } }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT", + "optional": true + }, "node_modules/axe-core": { "version": "4.11.0", "dev": true, @@ -9538,7 +9734,7 @@ }, "node_modules/base64-js": { "version": "1.5.1", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -9569,6 +9765,16 @@ "version": "0.6.1", "license": "MIT" }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/big.js": { "version": "5.2.2", "license": "MIT", @@ -9599,7 +9805,7 @@ }, "node_modules/bl": { "version": "4.1.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -9727,6 +9933,15 @@ "node": ">=8" } }, + "node_modules/browser-request": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/browser-request/-/browser-request-0.3.3.tgz", + "integrity": "sha512-YyNI4qJJ+piQG6MMEuo7J3Bzaqssufx04zpEKYfSrl/1Op59HWali9zMtBpXnkmqMcOuWJPZvudrm9wISmnCbg==", + "engines": [ + "node" + ], + "optional": true + }, "node_modules/browserslist": { "version": "4.28.0", "funding": [ @@ -9760,7 +9975,7 @@ }, "node_modules/buffer": { "version": "5.7.1", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -10074,6 +10289,28 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", + "integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/chai": { "version": "5.3.3", "license": "MIT", @@ -10775,6 +11012,20 @@ "node": ">= 0.6" } }, + "node_modules/contextify": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/contextify/-/contextify-0.1.15.tgz", + "integrity": "sha512-NpM4b6u5Mzig1/ux3ReVv42L/og3WcKKvkmTWxZpIjhZ/S23BViWZD/7hds9LGNzEL3W9ItfoZ+p6eRhCQMH6Q==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "bindings": "^1.2.1", + "nan": "^2.1.0" + }, + "engines": { + "node": ">=0.10.11" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "license": "MIT" @@ -11207,6 +11458,27 @@ "version": "2.0.28", "license": "CC0-1.0" }, + "node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "license": "MIT", + "optional": true + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "license": "MIT" @@ -11565,6 +11837,43 @@ "node": ">=12" } }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "dev": true, @@ -11637,6 +11946,29 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT", + "optional": true + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "license": "MIT", @@ -11644,10 +11976,19 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/default-browser": { "version": "5.5.0", @@ -12034,6 +12375,17 @@ "version": "0.2.0", "license": "MIT" }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ee-first": { "version": "1.1.1", "license": "MIT" @@ -12084,8 +12436,18 @@ "node": ">=0.10.0" } }, - "node_modules/enhanced-resolve": { - "version": "5.20.1", + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -12350,6 +12712,83 @@ "node": ">=0.8.0" } }, + "node_modules/escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=4.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "license": "MIT", + "optional": true, + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "license": "MIT", + "optional": true, + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "optional": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/eslint": { "version": "9.39.4", "license": "MIT", @@ -12834,6 +13273,16 @@ "node": ">= 8" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "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)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expand-tilde": { "version": "2.0.2", "dev": true, @@ -12945,19 +13394,30 @@ }, "node_modules/extend": { "version": "3.0.2", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, "node_modules/fabric": { - "version": "1.7.22", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/fabric/-/fabric-7.4.0.tgz", + "integrity": "sha512-NalYDc3eifTl1C33zryQwpH6+XA/2ClxQrH9vkASkZw3tbkRmorpikhYMmxhUTmi7O3e9ODz0vOT8qfaCh9IVA==", "license": "MIT", "engines": { - "node": ">=0.4.0" + "node": ">=20.0.0" }, "optionalDependencies": { - "canvas": "1.6.x", - "jsdom": "3.x.x", - "xmldom": "0.1.x" + "canvas": "^3.2.0", + "jsdom": "^26.1.0" } }, "node_modules/fast-deep-equal": { @@ -12985,13 +13445,11 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", @@ -13338,6 +13796,16 @@ "node": ">= 8" } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/form-data": { "version": "4.0.5", "license": "MIT", @@ -13402,6 +13870,13 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, "node_modules/fs-minipass": { "version": "2.1.0", "license": "ISC", @@ -13645,6 +14120,23 @@ "node": ">=6.0" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/github-from-package": { + "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", + "optional": true + }, "node_modules/glob": { "version": "10.4.5", "license": "ISC", @@ -13942,6 +14434,31 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "optional": true, + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "dev": true, @@ -14108,6 +14625,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-entities": { "version": "2.6.0", "funding": [ @@ -14138,6 +14668,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "license": "MIT", + "optional": true, + "dependencies": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "optional": true, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/htmlparser2/node_modules/domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "domelementtype": "1" + } + }, + "node_modules/htmlparser2/node_modules/domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "license": "BSD-2-Clause", + "optional": true + }, "node_modules/htmx.org": { "version": "1.9.12", "license": "0BSD" @@ -14218,6 +14832,22 @@ "version": "4.0.7", "license": "MIT" }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "license": "MIT", @@ -14297,7 +14927,7 @@ }, "node_modules/ieee754": { "version": "1.2.1", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -14470,7 +15100,7 @@ }, "node_modules/ini": { "version": "1.3.8", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/inputmask": { @@ -15024,6 +15654,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT", + "optional": true + }, "node_modules/is-primitive": { "version": "3.0.1", "license": "MIT", @@ -15144,6 +15781,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT", + "optional": true + }, "node_modules/is-unc-path": { "version": "1.0.0", "dev": true, @@ -15251,6 +15895,13 @@ "node": ">=0.10.0" } }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT", + "optional": true + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "devOptional": true, @@ -15477,6 +16128,127 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT", + "optional": true + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "license": "MIT", + "optional": true, + "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" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "optional": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "license": "MIT", @@ -15517,16 +16289,29 @@ "version": "2.3.1", "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)", + "optional": true + }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "license": "MIT", "peer": true }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, "node_modules/json5": { "version": "2.2.3", "license": "MIT", @@ -15553,6 +16338,22 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/jstransformer": { "version": "1.0.0", "dev": true, @@ -16603,6 +17404,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "license": "MIT", @@ -16817,6 +17631,13 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "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", + "optional": true + }, "node_modules/mlly": { "version": "1.8.0", "dev": true, @@ -16916,6 +17737,13 @@ "picocolors": "^1.1.1" } }, + "node_modules/napi-build-utils": { + "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", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "license": "MIT", @@ -16937,6 +17765,32 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "license": "MIT", @@ -17181,6 +18035,30 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/nwmatcher": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/nwmatcher/-/nwmatcher-1.4.4.tgz", + "integrity": "sha512-3iuY4N5dhgMpCUrOVnuAdGrgxVqV2cJpM+XNccjR2DKOB1RUP0aA+wGXEiNziG/UKboFyGBIoKOaNlJxx8bciQ==", + "license": "MIT", + "optional": true + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "license": "MIT", + "optional": true + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/object-assign": { "version": "4.1.1", "license": "MIT", @@ -17915,6 +18793,13 @@ "node": ">= 14.16" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -18962,6 +19847,34 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "license": "MIT", @@ -19104,6 +20017,19 @@ "version": "1.0.2", "license": "ISC" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pug": { "version": "3.0.4", "dev": true, @@ -19216,6 +20142,17 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "license": "MIT", @@ -19246,6 +20183,16 @@ "dev": true, "license": "MIT" }, + "node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/quansync": { "version": "0.2.11", "dev": true, @@ -19320,6 +20267,32 @@ "node": ">=0.10.0" } }, + "node_modules/rc": { + "version": "1.2.8", + "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)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "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", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.5", "license": "MIT", @@ -19657,10 +20630,83 @@ "version": "0.13.0", "license": "BSD-2-Clause", "dependencies": { - "jsesc": "~3.1.0" + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "optional": true, "bin": { - "regjsparser": "bin/parser" + "uuid": "bin/uuid" } }, "node_modules/require-directory": { @@ -19830,6 +20876,13 @@ "version": "3.0.3", "license": "Unlicense" }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT", + "optional": true + }, "node_modules/rs-module-lexer": { "version": "2.8.0", "dev": true, @@ -20051,6 +21104,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "optional": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "license": "MIT" @@ -20450,6 +21516,53 @@ "node": ">=6" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/singleton-manager": { "version": "1.8.0", "dev": true, @@ -20637,6 +21750,32 @@ "wbuf": "^1.7.3" } }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ssr-window": { "version": "4.0.2", "license": "MIT" @@ -21510,6 +22649,13 @@ "node": ">= 4.7.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT", + "optional": true + }, "node_modules/tabbable": { "version": "6.4.0", "license": "MIT" @@ -21678,6 +22824,43 @@ "node": ">=10" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tar/node_modules/minipass": { "version": "5.0.0", "license": "ISC", @@ -21871,6 +23054,26 @@ "dev": true, "license": "MIT" }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT", + "optional": true + }, "node_modules/to-regex-range": { "version": "5.0.1", "license": "MIT", @@ -21904,6 +23107,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-dedent": { "version": "2.2.0", "dev": true, @@ -22036,6 +23265,26 @@ "node": ">=14" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "license": "MIT", @@ -22396,7 +23645,6 @@ "node_modules/uri-js": { "version": "4.4.1", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -22521,6 +23769,28 @@ "version": "1.5.2", "license": "MIT" }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT", + "optional": true + }, "node_modules/vite": { "name": "@voidzero-dev/vite-plus-core", "version": "0.2.6", @@ -23620,6 +24890,19 @@ "vue": "^2.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "optional": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.4.4", "license": "MIT", @@ -23646,6 +24929,16 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=12" + } + }, "node_modules/webpack": { "version": "5.102.1", "license": "MIT", @@ -24020,6 +25313,33 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "devOptional": true, @@ -24028,6 +25348,20 @@ "node": ">=12" } }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "1.3.1", "dev": true, @@ -24198,7 +25532,6 @@ "node_modules/word-wrap": { "version": "1.2.5", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -24401,14 +25734,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT", + "optional": true + }, "node_modules/xmldom": { "version": "0.1.31", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz", + "integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==", + "deprecated": "Deprecated due to CVE-2021-21366 resolved in 0.5.0", "license": "(LGPL-2.0 or MIT)", "optional": true, "engines": { "node": ">=0.1" } }, + "node_modules/xmlhttprequest": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz", + "integrity": "sha512-58Im/U0mlVBLM38NdZjHyhuMtCqa61469k2YP/AaPbvCoV9aQGUpbJBj1QRm2ytRiVQBD/fsw7L2bJGDVQswBA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xregexp": { "version": "5.1.2", "license": "MIT", @@ -24814,6 +26177,18 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "packages/craftcms-legacy/node_modules/acorn": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-0.11.0.tgz", + "integrity": "sha512-fvlA412hssF7l+22DIRlhXUKIuUz9Z9IrCMn/gPyWx90R75X/xB4SsqU8Y1vhiq9xd72P6duzQn5kKojqFN6cw==", + "optional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "packages/craftcms-legacy/node_modules/ansi-regex": { "version": "5.0.1", "dev": true, @@ -24827,6 +26202,20 @@ "dev": true, "license": "MIT" }, + "packages/craftcms-legacy/node_modules/canvas": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-1.6.13.tgz", + "integrity": "sha512-XAfzfEOHZ3JIPjEV+WSI6PpISgUta3dgmndWbsajotz+0TQOX/jDpp2kawjRERatOGv9sMMzk5auB3GKEKA6hg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "nan": "^2.10.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, "packages/craftcms-legacy/node_modules/chokidar": { "version": "3.6.0", "dev": true, @@ -24850,6 +26239,30 @@ "fsevents": "~2.3.2" } }, + "packages/craftcms-legacy/node_modules/cssstyle": { + "version": "0.2.37", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-0.2.37.tgz", + "integrity": "sha512-FUpKc+1FNBsHUr9IsfSGCovr8VuGOiiuzlgCyppKBjJi2jYTOFLN3oiiNRMIvYqbFzF38mqKj4BgcevzU5/kIA==", + "license": "MIT", + "optional": true, + "dependencies": { + "cssom": "0.3.x" + } + }, + "packages/craftcms-legacy/node_modules/fabric": { + "version": "1.7.22", + "resolved": "https://registry.npmjs.org/fabric/-/fabric-1.7.22.tgz", + "integrity": "sha512-VRfQKuDQyTqRzWvJSjSbkOb9+988N2d522LDLI3GP8N4tEORNAttMbsh4HncJwY5LFYTKVZPfE2Ei39Npe72og==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + }, + "optionalDependencies": { + "canvas": "1.6.x", + "jsdom": "3.x.x", + "xmldom": "0.1.x" + } + }, "packages/craftcms-legacy/node_modules/globby": { "version": "11.1.0", "dev": true, @@ -24904,6 +26317,28 @@ "jiti": "bin/jiti.js" } }, + "packages/craftcms-legacy/node_modules/jsdom": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-3.1.2.tgz", + "integrity": "sha512-GA+u4MzID/sjkAH2zzzSHDRStSLK2qwSockuy2X/ixqSGNv8OmCbnF49NGXLQUakOhGRm/wf7CLdF3omdAN/0g==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "0.11.0", + "acorn-globals": "^1.0.2", + "browser-request": ">= 0.3.1 < 0.4.0", + "contextify": ">= 0.1.9 < 0.2.0", + "cssom": ">= 0.3.0 < 0.4.0", + "cssstyle": ">= 0.2.21 < 0.3.0", + "escodegen": "^1.6.1", + "htmlparser2": ">= 3.7.3 < 4.0.0", + "nwmatcher": ">= 1.3.4 < 2.0.0", + "parse5": ">= 1.3.1 < 2.0.0", + "request": ">= 2.44.0 < 3.0.0", + "xml-name-validator": "^1.0.0", + "xmlhttprequest": ">= 1.6.0 < 2.0.0" + } + }, "packages/craftcms-legacy/node_modules/mathml-tag-names": { "version": "2.1.3", "dev": true, @@ -24924,6 +26359,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/craftcms-legacy/node_modules/parse5": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-1.5.1.tgz", + "integrity": "sha512-w2jx/0tJzvgKwZa58sj2vAYq/S/K1QJfIB3cWYea/Iu1scFPDQQ3IQiVZTHWtRBwAjv2Yd7S/xeZf3XqLDb3bA==", + "optional": true + }, "packages/craftcms-legacy/node_modules/pkg-dir": { "version": "5.0.0", "dev": true, @@ -25232,6 +26673,13 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "packages/craftcms-legacy/node_modules/xml-name-validator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-1.0.0.tgz", + "integrity": "sha512-XDmXffxxQs/+0VLW9NB2oSIbSoSINj6dQdhegY3kEM81LOoLr6NfsFE9RR59qrwsKEHaZLxa1MOSahFA0CE9Ow==", + "license": "WTFPL", + "optional": true + }, "packages/craftcms-playwright": { "name": "@craftcms/playwright", "version": "1.0.0", diff --git a/package.json b/package.json index 52501724198..cd3ac16442d 100644 --- a/package.json +++ b/package.json @@ -70,9 +70,9 @@ "stylelint-use-logical": "^2.1.3", "typescript": "^6.0.3", "vite": "npm:@voidzero-dev/vite-plus-core@0.2.6", + "vite-plus": "0.2.6", "vitest": "4.1.10", - "vue-tsc": "^3.2.7", - "vite-plus": "0.2.6" + "vue-tsc": "^3.2.7" }, "optionalDependencies": { "@awesome.me/kit-ddaed3f5c5": "^1.0.74" @@ -80,14 +80,15 @@ "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", - "@craftcms/ui": "file:packages/craftcms-ui", "@craftcms/graphiql": "file:packages/craftcms-graphiql", + "@craftcms/ui": "file:packages/craftcms-ui", "@headlessui/vue": "^1.7.23", "@inertiajs/vite": "^3.0.3", "@inertiajs/vue3": "^3.0.3", "@tanstack/vue-table": "^8.21.3", "@vueuse/core": "^14.3.0", "axios": "^1.17.0", + "fabric": "^7.4.0", "laravel-vite-plugin": "^3.1.0", "lit": "^3.3.2", "overtype": "^2.4.0", diff --git a/packages/craftcms-ui/scripts/generate-colors.js b/packages/craftcms-ui/scripts/generate-colors.js index 7627b126b8e..d65c921f8ce 100644 --- a/packages/craftcms-ui/scripts/generate-colors.js +++ b/packages/craftcms-ui/scripts/generate-colors.js @@ -145,7 +145,16 @@ function buildStyleBlock(color) { function generateStyles(paletteColors, semanticColors) { return `/* Auto-generated by scripts/generate-colors.js — do not edit manually */ -:root { +/* + Also on [data-theme], not just :root. These tokens are built out of the raw + palette with var(), and a custom property carrying a var() is substituted + where it is declared -- so resolving them only at :root bakes in the root's + palette, and re-declaring the palette further down can never reach back into + them. Repeating the block on a themed element re-resolves it against that + element's palette, which is what makes a themed subtree possible. +*/ +:root, +[data-theme] { ${buildColorableTokens(paletteColors)} ${buildSemanticTokens(semanticColors)} } diff --git a/packages/craftcms-ui/src/components/button-group/button-group.stories.ts b/packages/craftcms-ui/src/components/button-group/button-group.stories.ts index 7c584576fe5..60b6ac4c3f3 100644 --- a/packages/craftcms-ui/src/components/button-group/button-group.stories.ts +++ b/packages/craftcms-ui/src/components/button-group/button-group.stories.ts @@ -1,4 +1,5 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import {expect} from 'storybook/test'; import {html} from 'lit'; @@ -161,3 +162,31 @@ export const WithActions: Story = { `, }; + +/** + * A group handed its selection in markup keeps it: with no `value` of its own, + * the group adopts the one from whichever child is marked `active`. + * + * Without that it would clear `active` from every child on its first sync — + * and a consumer setting `active` itself would find it stripped back off, + * with nothing to say why. Drive the selection through `value` where you can; + * this is for markup that states its own. + */ +export const SelectionFromMarkup: Story = { + name: 'Selection from markup', + render: () => html` + + Landscape + Portrait + + `, + async play({canvasElement}) { + const group = canvasElement.querySelector('craft-button-group')!; + await group.updateComplete; + + const landscape = canvasElement.querySelector('craft-button')!; + + await expect(group.value).toBe('landscape'); + await expect(landscape.getAttribute('aria-pressed')).toBe('true'); + }, +}; diff --git a/packages/craftcms-ui/src/components/button-group/button-group.test.ts b/packages/craftcms-ui/src/components/button-group/button-group.test.ts index 8aad88d01c5..866ee04f10b 100644 --- a/packages/craftcms-ui/src/components/button-group/button-group.test.ts +++ b/packages/craftcms-ui/src/components/button-group/button-group.test.ts @@ -56,4 +56,84 @@ describe('craft-button-group', () => { expect(values).toEqual(['news']); expect(setFormValue).toHaveBeenCalled(); }); + + it('adopts its value from a child marked active in markup', async () => { + // A group given its selection in markup used to have it stripped: with no + // `value`, the first sync cleared `active` from every child. + document.body.innerHTML = ` + + + + + `; + + const group = document.querySelector('craft-button-group')!; + await group.updateComplete; + + const [landscape, portrait] = + document.querySelectorAll('craft-button'); + + expect(group.value).toBe('landscape'); + expect(landscape.hasAttribute('active')).toBe(true); + expect(landscape.getAttribute('aria-pressed')).toBe('true'); + expect(portrait.hasAttribute('active')).toBe(false); + }); + + it('lets an explicit value win over a child marked active', async () => { + document.body.innerHTML = ` + + + + + `; + + const group = document.querySelector('craft-button-group')!; + await group.updateComplete; + + const [landscape, portrait] = + document.querySelectorAll('craft-button'); + + expect(group.value).toBe('portrait'); + expect(landscape.hasAttribute('active')).toBe(false); + expect(portrait.hasAttribute('active')).toBe(true); + }); + + it('adopts only once, so a later sync cannot resurrect the old value', async () => { + document.body.innerHTML = ` + + + + + `; + + const group = document.querySelector('craft-button-group')!; + await group.updateComplete; + + group.value = 'portrait'; + await group.updateComplete; + + const [landscape, portrait] = + document.querySelectorAll('craft-button'); + + expect(group.value).toBe('portrait'); + expect(landscape.hasAttribute('active')).toBe(false); + expect(portrait.hasAttribute('active')).toBe(true); + }); + + it('leaves multi-select alone, which already reads active off its children', async () => { + document.body.innerHTML = ` + + + + + `; + + const group = document.querySelector('craft-button-group')!; + await group.updateComplete; + + const buttons = document.querySelectorAll('craft-button'); + + expect(group.value).toBeUndefined(); + expect([...buttons].every((b) => b.hasAttribute('active'))).toBe(true); + }); }); diff --git a/packages/craftcms-ui/src/components/button-group/button-group.ts b/packages/craftcms-ui/src/components/button-group/button-group.ts index ac1b99692e9..9beed65aca7 100644 --- a/packages/craftcms-ui/src/components/button-group/button-group.ts +++ b/packages/craftcms-ui/src/components/button-group/button-group.ts @@ -26,7 +26,14 @@ export default class CraftButtonGroup extends LitElement { /** Form field name. When set, enables selection mode. */ @property({reflect: true}) name: string; - /** The currently selected value in single-selection mode. */ + /** + * The currently selected value in single-selection mode. + * + * The group owns the children's `active` state and rewrites it from this, so + * drive the selection here rather than on the buttons. Left unset, it is + * seeded from whichever child is marked `active`, so markup that states its + * own selection keeps it. + */ @property({reflect: true}) value: string; /** Whether multiple buttons can be selected. */ @@ -103,7 +110,39 @@ export default class CraftButtonGroup extends LitElement { ); }; + /** + * Seeds `value` from a child already marked `active`, for a group handed its + * selection in markup rather than through the property. + * + * Without this a single-select group with no `value` clears `active` from + * every child on its first sync, silently destroying the selection instead of + * leaving it alone — and a consumer setting `active` itself gets it stripped + * back off on the next sync, with nothing to say why. + * `craft-radio-group` adopts its `name` from slotted inputs for the same + * reason. + * + * Multi-select needs none of this: it already reads `active` off the + * children rather than writing it. + */ + private _adoptSlottedValue() { + if (this.multiple || this.value !== undefined) { + return; + } + + const selected = this.querySelector('craft-button[active]'); + const value = selected?.getAttribute('value'); + + // No active child yet — leave `value` unset so a later sync can still + // adopt once the children have been parsed. There is nothing to clobber in + // the meantime. + if (value != null) { + this.value = value; + } + } + private _syncChildren() { + this._adoptSlottedValue(); + const buttons = this.querySelectorAll('craft-button'); buttons.forEach((btn) => { if (btn.getAttribute('type') !== 'button') { diff --git a/packages/craftcms-ui/src/components/button/button.styles.ts b/packages/craftcms-ui/src/components/button/button.styles.ts index 6d005f27495..d9f08224ee1 100644 --- a/packages/craftcms-ui/src/components/button/button.styles.ts +++ b/packages/craftcms-ui/src/components/button/button.styles.ts @@ -237,8 +237,11 @@ export default css` } :host([variant~='primary']:active), + :host([variant~='primary'][aria-pressed='true']), :host([variant~='danger']:active), - :host([variant~='solid']:active) { + :host([variant~='danger'][aria-pressed='true']), + :host([variant~='solid']:active), + :host([variant~='solid'][aria-pressed='true']) { --_active-background-color: hsl( from var(--c-color-fill-loud, var(--c-color-neutral-fill-loud)) h s calc(l - 10) @@ -440,6 +443,7 @@ export default css` the (non-positioned) anchor and swallows every pointer click before it can activate the link. Recreate the overlay on the anchor instead, so the full target navigates. */ + &::before { display: none; } @@ -466,6 +470,7 @@ export default css` /* Same minimum click area as Lion's :host::before (WCAG 2.5.5), but as part of the anchor so clicks on it follow the link. */ + &::before { content: ''; position: absolute; diff --git a/packages/craftcms-ui/src/components/button/button.test.ts b/packages/craftcms-ui/src/components/button/button.test.ts index 4b1fc925ce4..60aa969406d 100644 --- a/packages/craftcms-ui/src/components/button/button.test.ts +++ b/packages/craftcms-ui/src/components/button/button.test.ts @@ -262,3 +262,68 @@ describe('craft-button actions', () => { expect(fired).toBe(false); }); }); + +describe('craft-button toggle', () => { + it('derives aria-pressed from active', async () => { + const button = document.createElement('craft-button'); + button.toggle = true; + document.body.append(button); + await button.updateComplete; + + expect(button.getAttribute('aria-pressed')).toBe('false'); + + button.active = true; + await button.updateComplete; + + expect(button.getAttribute('aria-pressed')).toBe('true'); + }); + + it('leaves aria-pressed alone on a button that is not a toggle', async () => { + // craft-button-group sets aria-pressed on its children; overwriting it + // would be worse than leaving it be. + const button = document.createElement('craft-button'); + button.setAttribute('aria-pressed', 'true'); + document.body.append(button); + await button.updateComplete; + + button.active = false; + await button.updateComplete; + + expect(button.getAttribute('aria-pressed')).toBe('true'); + }); + + it('reports the state being asked for without changing active itself', async () => { + const button = document.createElement('craft-button'); + button.toggle = true; + document.body.append(button); + await button.updateComplete; + + let asked: boolean | undefined; + button.addEventListener('craft-toggle', (event) => { + asked = (event as CustomEvent<{active: boolean}>).detail.active; + }); + + button.click(); + await button.updateComplete; + + expect(asked).toBe(true); + // The owner of `active` decides; the button does not move on its own. + expect(button.active).toBe(false); + }); + + it('stays quiet when disabled', async () => { + const button = document.createElement('craft-button'); + button.toggle = true; + button.setAttribute('disabled', ''); + document.body.append(button); + await button.updateComplete; + + let fired = false; + button.addEventListener('craft-toggle', () => { + fired = true; + }); + button.click(); + + expect(fired).toBe(false); + }); +}); diff --git a/packages/craftcms-ui/src/components/button/button.ts b/packages/craftcms-ui/src/components/button/button.ts index bb06657c71d..50d046db50f 100644 --- a/packages/craftcms-ui/src/components/button/button.ts +++ b/packages/craftcms-ui/src/components/button/button.ts @@ -41,6 +41,10 @@ export type ButtonVariant = (typeof ButtonVariant)[keyof typeof ButtonVariant]; * @csspart suffix - The button's suffix slot. * @csspart spinner - Spinner that shows when the button is in a loading state. * @csspart link - The anchor element rendered when the button has an href. + * + * @event craft-toggle - Fired when a `toggle` button is activated. `detail.active` + * is the state being asked for. Cancelable — `active` is owned by whoever set + * it, and the button never changes it itself. */ export default class CraftButton extends LionButtonSubmit { static override get styles() { @@ -71,11 +75,13 @@ export default class CraftButton extends LionButtonSubmit { super.connectedCallback(); this.syncLinkHostState(); this.addEventListener('click', this.#handleActionClick); + this.addEventListener('click', this.#handleToggleClick); } override disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('click', this.#handleActionClick); + this.removeEventListener('click', this.#handleToggleClick); if (this.announcementTimer) { clearTimeout(this.announcementTimer); @@ -103,12 +109,43 @@ export default class CraftButton extends LionButtonSubmit { this.loading = false; } }; + /** + * Reports that a toggle was activated, for the owner of `active` to act on. + * + * Cancelable, so a consumer can refuse the change; `active` is left alone + * either way. + */ + #handleToggleClick = (event: Event) => { + if (!this.toggle || this.disabled || this.loading) { + return; + } + + this.dispatchEvent( + new CustomEvent('craft-toggle', { + bubbles: true, + composed: true, + cancelable: true, + detail: {active: !this.active, sourceEvent: event}, + }) + ); + }; + override updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('href') || changedProperties.has('disabled')) { this.syncLinkHostState(); } + // Only while `toggle` is set: a plain button may carry an `aria-pressed` + // its owner manages (`craft-button-group` sets one on every child), and + // overwriting that would be worse than leaving it alone. + if ( + this.toggle && + (changedProperties.has('active') || changedProperties.has('toggle')) + ) { + this.setAttribute('aria-pressed', String(this.active)); + } + if (changedProperties.has('loading')) { if (this.loading) { this.announceLoading(); @@ -198,6 +235,25 @@ export default class CraftButton extends LionButtonSubmit { /** Whether the button is in a selected/active state (e.g. inside a radio button-group) */ @property({reflect: true, type: Boolean}) override active: boolean = false; + /** + * Makes the button a toggle: something that turns a thing on and off and + * stays that way, rather than firing a one-shot action. + * + * `aria-pressed` is then derived from `active`, so the pressed state a screen + * reader hears and the one people see can't drift apart — setting one and + * forgetting the other is the easy mistake here, and `active` already carries + * the visible state. + * + * Deliberately does *not* flip `active` itself. Selection may be owned + * elsewhere — `craft-button-group` writes `active` on its children, and a + * consumer may reject the change — so the button reports the intent through + * `toggle` and lets the owner decide. + * + * For an on/off setting, reach for `craft-switch` instead; a toggle button is + * for a control that acts on something, like a toolbar. + */ + @property({type: Boolean, reflect: true}) toggle: boolean = false; + /** Show a spinner instead of the label */ @property({reflect: true, type: Boolean}) loading: boolean = false; diff --git a/packages/craftcms-ui/src/components/dialog/dialog.stories.ts b/packages/craftcms-ui/src/components/dialog/dialog.stories.ts index a817c77c951..d52de3d9777 100644 --- a/packages/craftcms-ui/src/components/dialog/dialog.stories.ts +++ b/packages/craftcms-ui/src/components/dialog/dialog.stories.ts @@ -91,8 +91,25 @@ export const NonModal: Story = { }, }; +/** + * Fills the viewport, so the body row can hand a definite height down to + * whatever it slots — a canvas, say, that measures its container to decide how + * big to draw. + */ export const Fullscreen: Story = { - args: {fullscreen: true}, + args: {fullscreen: true, open: true}, + async play({canvasElement}) { + const dialog = canvasElement.querySelector('craft-dialog') as CraftDialog; + await dialog.updateComplete; + + const surface = dialog.shadowRoot!.querySelector('.surface')!; + const {width, height} = surface.getBoundingClientRect(); + + // The height is the half worth pinning: the surface used to carry only a + // `max-block-size`, which left it full-width but as short as its content. + await expect(Math.round(width)).toBe(window.innerWidth); + await expect(Math.round(height)).toBe(window.innerHeight); + }, }; /** Long content scrolls inside the body rather than growing the surface. */ @@ -113,3 +130,36 @@ export const Scrolling: Story = { export const ClosesOnOutsideClick: Story = { args: {'close-on-outside-click': true}, }; + +/** + * A dialog can carry its own theme. `[data-theme]` re-resolves the semantic + * tokens against the palette it names, so everything slotted in follows — + * including each component's shadow root, since custom properties inherit past + * the boundary. + */ +export const Themed: Story = { + args: {label: 'Dark dialog', open: true}, + render: (args) => + template({...args, 'data-theme': 'dark'}, html`${body}${footerClose}`), + async play({canvasElement}) { + const dialog = canvasElement.querySelector('craft-dialog') as CraftDialog; + await dialog.updateComplete; + + const inside = getComputedStyle(dialog); + const outside = getComputedStyle(canvasElement); + + // The raw palette and the semantic tokens built out of it both follow the + // theme. The semantic half is the one that used to stay light: a custom + // property carrying a `var()` is substituted where it's declared, so + // resolving these only at `:root` baked in the root's palette. + await expect(inside.getPropertyValue('--color-base-50').trim()).not.toBe( + outside.getPropertyValue('--color-base-50').trim() + ); + await expect(inside.getPropertyValue('--c-surface-raised').trim()).not.toBe( + outside.getPropertyValue('--c-surface-raised').trim() + ); + await expect( + inside.getPropertyValue('--c-color-neutral-fill-quiet').trim() + ).not.toBe(outside.getPropertyValue('--c-color-neutral-fill-quiet').trim()); + }, +}; diff --git a/packages/craftcms-ui/src/components/dialog/dialog.styles.ts b/packages/craftcms-ui/src/components/dialog/dialog.styles.ts index b99dc6b0710..ae85c2f1fa6 100644 --- a/packages/craftcms-ui/src/components/dialog/dialog.styles.ts +++ b/packages/craftcms-ui/src/components/dialog/dialog.styles.ts @@ -19,6 +19,7 @@ export default css` --c-dialog-max-inline-size, min(90vw, 40rem) ); + --_dialog-block-size: var(--c-dialog-block-size, auto); --_dialog-max-block-size: var(--c-dialog-max-block-size, 85dvh); } @@ -26,6 +27,13 @@ export default css` --_dialog-inline-size: var(--c-dialog-inline-size, 100vw); --_dialog-min-inline-size: var(--c-dialog-min-inline-size, 100vw); --_dialog-max-inline-size: var(--c-dialog-max-inline-size, 100vw); + /* + A real size, not just a cap: with \`max-block-size\` alone the surface is + only as tall as its content, so a fullscreen dialog came out full-width + but short. Its body row can then hand a definite height to whatever it + slots. + */ + --_dialog-block-size: var(--c-dialog-block-size, 100dvh); --_dialog-max-block-size: var(--c-dialog-max-block-size, 100dvh); } @@ -68,6 +76,7 @@ export default css` inline-size: var(--_dialog-inline-size); min-inline-size: var(--_dialog-min-inline-size); max-inline-size: var(--_dialog-max-inline-size); + block-size: var(--_dialog-block-size); max-block-size: var(--_dialog-max-block-size); background-color: var(--c-surface-raised); border-radius: var(--c-radius-md); diff --git a/packages/craftcms-ui/src/components/dialog/dialog.test.ts b/packages/craftcms-ui/src/components/dialog/dialog.test.ts index 1a827333f14..f0839d0f333 100644 --- a/packages/craftcms-ui/src/components/dialog/dialog.test.ts +++ b/packages/craftcms-ui/src/components/dialog/dialog.test.ts @@ -360,3 +360,56 @@ describe('craft-dialog', () => { }); }); }); + +describe('craft-dialog before-hide', () => { + it('stays open when a listener cancels the dismissal', async () => { + const dialog = document.createElement('craft-dialog'); + dialog.opened = true; + document.body.append(dialog); + await dialog.updateComplete; + + dialog.addEventListener('craft-before-hide', (event) => { + event.preventDefault(); + }); + + dialog.shadowRoot!.querySelector('.close')!.click(); + await dialog.updateComplete; + + expect(dialog.opened).toBe(true); + }); + + it('closes when nothing objects', async () => { + const dialog = document.createElement('craft-dialog'); + dialog.opened = true; + document.body.append(dialog); + await dialog.updateComplete; + + let asked = false; + dialog.addEventListener('craft-before-hide', () => { + asked = true; + }); + + dialog.shadowRoot!.querySelector('.close')!.click(); + await dialog.updateComplete; + + expect(asked).toBe(true); + expect(dialog.opened).toBe(false); + }); + + it('does not ask when opened is set to false directly', async () => { + const dialog = document.createElement('craft-dialog'); + dialog.opened = true; + document.body.append(dialog); + await dialog.updateComplete; + + let asked = false; + dialog.addEventListener('craft-before-hide', () => { + asked = true; + }); + + dialog.opened = false; + await dialog.updateComplete; + + expect(asked).toBe(false); + }); +}); diff --git a/packages/craftcms-ui/src/components/dialog/dialog.ts b/packages/craftcms-ui/src/components/dialog/dialog.ts index 0136d002dcf..e6c29f2ff5b 100644 --- a/packages/craftcms-ui/src/components/dialog/dialog.ts +++ b/packages/craftcms-ui/src/components/dialog/dialog.ts @@ -58,6 +58,9 @@ function releasePageScroll(): void { * @slot footer - Footer content, typically buttons. * @csspart dialog - The native `` element. * @csspart surface - The visible panel inside it. + * + * @cssproperty --c-dialog-block-size - The surface's height. Defaults to + * `auto`, and to `100dvh` under `fullscreen`. * @csspart header - The header row. * @csspart title - The heading. * @csspart close - The header close button. @@ -65,6 +68,10 @@ function releasePageScroll(): void { * @csspart footer - The footer row. * * @fires craft-show - The dialog has opened. + * @fires craft-before-hide - A dismissal was asked for, by the close button, + * Escape or the backdrop. Cancelable: call `preventDefault()` to keep the + * dialog open — for confirming away unsaved work, say. Does not fire when + * `opened` is set to false directly, which is a decision already made. * @fires craft-hide - The dialog has closed. * @fires craft-after-show - The dialog has opened and finished updating. * @fires craft-after-hide - The dialog has closed and finished updating. @@ -248,7 +255,7 @@ export default class CraftDialog extends LitElement { protected renderFooter(): TemplateResult { return html` `; } @@ -337,7 +344,20 @@ export default class CraftDialog extends LitElement { * intercepting four listeners and the platform's own Escape handling. */ protected requestClose(): void { - this.opened = false; + // Every dismissal — the close button, Escape, the backdrop — comes through + // here, so asking once covers all of them. Cancelable, for a dialog holding + // work that would be lost. + const allowed = this.dispatchEvent( + new CustomEvent('craft-before-hide', { + bubbles: true, + composed: true, + cancelable: true, + }) + ); + + if (allowed) { + this.opened = false; + } } /** diff --git a/packages/craftcms-ui/src/components/slide-rule/slide-rule.stories.ts b/packages/craftcms-ui/src/components/slide-rule/slide-rule.stories.ts index 8ac024dd600..b54d1522e46 100644 --- a/packages/craftcms-ui/src/components/slide-rule/slide-rule.stories.ts +++ b/packages/craftcms-ui/src/components/slide-rule/slide-rule.stories.ts @@ -1,4 +1,6 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import {html} from 'lit'; +import {expect} from 'storybook/test'; import type CraftSlideRule from './slide-rule.js'; import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; import './slide-rule.js'; @@ -31,3 +33,479 @@ export const StartingAngle: Story = { value: 15, }, }; + +/** + * The cursor is pinned to the middle of the rule, and the strip is centred on + * the width of the window it slides behind — so the two only agree while that + * window is the rule's width rather than the strip's. It used to size to its + * content, leaving the strip centred on a box wider than the visible one and + * the cursor sitting about 20 degrees off zero. + */ +export const CentredOnZero: Story = { + args: {}, + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const graduations = + rule.shadowRoot!.querySelector('.graduations')!; + const strip = + rule.shadowRoot!.querySelector('.graduations ul')!; + + // The window is the rule, not the strip behind it. + await expect(graduations.offsetWidth).toBeLessThanOrEqual(rule.offsetWidth); + await expect(strip.offsetWidth).toBeGreaterThan(graduations.offsetWidth); + + // And zero lands under the cursor, at the middle of the window. + const zero = rule.shadowRoot!.querySelector( + '[data-graduation="0"]' + )!; + const offset = + zero.getBoundingClientRect().left - + graduations.getBoundingClientRect().left; + + await expect(Math.abs(offset - graduations.offsetWidth / 2)).toBeLessThan( + 6 + ); + + // Each graduation is 10px, which is what the positioning maths assumes. + await expect(strip.offsetWidth).toBe(141 * 10); + }, +}; + +/** + * The graduations fade towards the edges by masking their own alpha, not by + * painting a gradient of the page background over them. The overlay this + * replaces was hard-coded to `--gray-900`, so anywhere lighter got a dark band + * smeared across both ends instead of a fade — which this story would show. + */ +export const OnALightBackground: Story = { + args: {}, + decorators: [ + (story) => + html`${story()}`, + ], + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const graduations = + rule.shadowRoot!.querySelector('.graduations')!; + + // Nothing paints over the graduations any more. + await expect(rule.shadowRoot!.querySelector('.overlay')).toBe(null); + + // A real browser resolves the mask, so this checks more than the + // stylesheet text the happy-dom test can see. + const mask = getComputedStyle(graduations).maskImage; + + await expect(mask).not.toBe('none'); + + // Resolved, so `transparent` comes back as zero-alpha black: see-through + // at both ends, fully opaque across the middle where the value is read. + await expect(mask).toBe( + 'linear-gradient(to right, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 15%, ' + + 'rgb(0, 0, 0) 85%, rgba(0, 0, 0, 0) 100%)' + ); + }, +}; + +/** + * The graduations size from `--c-slide-rule-*` tokens. The width is the + * load-bearing one: the strip's positioning is in units of one graduation, so + * the component measures what was rendered rather than trusting the default. + * Assume the default and a wider graduation slides zero out from under the + * cursor — the same failure the whitespace between inline-block graduations + * used to cause. + */ +export const CustomGraduations: Story = { + args: {}, + decorators: [ + (story) => html` + + ${story()} + + `, + ], + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const graduations = + rule.shadowRoot!.querySelector('.graduations')!; + const strip = + rule.shadowRoot!.querySelector('.graduations ul')!; + + // The token reached the graduations, so the strip is wider than default. + await expect(strip.offsetWidth).toBe(141 * 16); + + // The height token is the whole control; the graduations take whatever the + // cursor row and the gap above them leave, down to the bottom of it. + await expect(rule.offsetHeight).toBe(52); + + const cursor = rule.shadowRoot!.querySelector('.cursor')!; + const gap = + graduations.getBoundingClientRect().top - + cursor.getBoundingClientRect().bottom; + + await expect(gap).toBeCloseTo(4, 1); + await expect( + graduations.getBoundingClientRect().bottom - + rule.getBoundingClientRect().bottom + ).toBeCloseTo(0, 1); + + // Every fifth tick is half again as tall, derived from the one token. + const tickHeight = (selector: string) => + getComputedStyle(rule.shadowRoot!.querySelector(selector)!) + .blockSize; + + await expect(tickHeight('[data-graduation="1"]')).toBe('10px'); + await expect(tickHeight('[data-graduation="5"]')).toBe('15px'); + + // And zero still lands under the cursor, which only holds if the maths + // measured 16px rather than assuming 10. + const zero = rule.shadowRoot!.querySelector( + '[data-graduation="0"]' + )!; + const offset = + zero.getBoundingClientRect().left - + graduations.getBoundingClientRect().left; + + await expect(Math.abs(offset - graduations.offsetWidth / 2)).toBeLessThan( + 10 + ); + }, +}; + +/** + * The ticks, labels and accent all come from tokens now, so the rule follows + * the CP's scheme instead of the legacy image editor's hard-coded `#63a6e1` + * and `--white`. + */ +export const CustomColors: Story = { + args: {value: 12}, + decorators: [ + (story) => html` + + ${story()} + + `, + ], + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const cursor = rule.shadowRoot!.querySelector('.cursor')!; + const tick = rule.shadowRoot!.querySelector('.graduation')!; + + await expect(getComputedStyle(cursor).borderBlockStartColor).toBe( + 'rgb(249, 115, 22)' + ); + await expect(getComputedStyle(tick).backgroundColor).toBe( + 'rgb(124, 58, 237)' + ); + + // Labels have no colour of their own — they take whatever text colour + // they land in, so they can never be the one thing that doesn't match. + const label = rule.shadowRoot!.querySelector( + '.main-graduation .label' + )!; + + await expect(getComputedStyle(label).color).toBe('rgb(124, 58, 237)'); + + // And the digits are monospaced, so they don't jitter as the strip slides. + await expect(getComputedStyle(label).fontFamily).toContain('monospace'); + }, +}; + +/** + * The colours are tokens, so they re-resolve per theme rather than staying at + * the legacy image editor's hard-coded `#63a6e1` and `--white` — which only + * ever read correctly on a dark background. + */ +export const AcrossThemes: Story = { + args: {}, + decorators: [ + (story) => html` + + ${story()} + + + ${story()} + + `, + ], + async play({canvasElement}) { + const [light, dark] = [ + ...canvasElement.querySelectorAll('craft-slide-rule'), + ] as CraftSlideRule[]; + + await light.updateComplete; + await dark.updateComplete; + + const tickColor = (rule: CraftSlideRule) => + getComputedStyle( + rule.shadowRoot!.querySelector('.graduation')! + ).backgroundColor; + + // Dark slate on light, light slate on dark — a hard-coded colour would + // come back the same in both. + await expect(tickColor(light)).toBe('rgb(58, 69, 90)'); + await expect(tickColor(dark)).toBe('rgb(203, 213, 224)'); + + const cursorColor = (rule: CraftSlideRule) => + getComputedStyle(rule.shadowRoot!.querySelector('.cursor')!) + .borderBlockStartColor; + + await expect(cursorColor(light)).toBe('rgb(33, 56, 167)'); + await expect(cursorColor(dark)).toBe('rgb(188, 213, 251)'); + }, +}; + +/** + * The cursor sits in its own grid row above the ruler, centred by the layout + * rather than by `left: 50%` and a margin pulling it back by half its width. + * That margin was a magic -4px against a 10px-wide triangle, so the cursor + * landed a pixel off the centre the strip is positioned against — and a tap, + * which measures its delta from the cursor, inherited the error. + */ +export const CursorCentring: Story = { + args: {}, + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const cursor = rule.shadowRoot!.querySelector('.cursor')!; + const graduations = + rule.shadowRoot!.querySelector('.graduations')!; + + const cursorBox = cursor.getBoundingClientRect(); + const windowBox = graduations.getBoundingClientRect(); + + // The triangle is drawn out of the borders of a zero-width box, so its + // centre is half the border box across — the same point the JS uses. + const cursorCentre = cursorBox.left + cursor.offsetWidth / 2; + const windowCentre = windowBox.left + graduations.offsetWidth / 2; + + await expect(Math.abs(cursorCentre - windowCentre)).toBeLessThan(0.5); + + // And the mark it points at is centred on that same point. A graduation is + // a position on the ruler, so the mark straddles it -- begin the mark + // there instead and it sits half its own thickness to the right, which is + // the cursor pointing into the gap beside it. + const zero = rule.shadowRoot!.querySelector( + '[data-graduation="0"]' + )!; + const zeroBox = zero.getBoundingClientRect(); + + await expect( + Math.abs(zeroBox.left + zeroBox.width / 2 - cursorCentre) + ).toBeLessThan(0.5); + + // Its own row, so it sits above the ruler rather than over the ticks, with + // a little air between it and the marks it points at. + await expect(windowBox.top - cursorBox.bottom).toBeCloseTo(4, 1); + + // And that air is taken out of the space above the cursor, not added to + // the total -- the control is still the height the token asks for. + const root = rule.shadowRoot!.querySelector('.slide-rule')!; + + await expect( + windowBox.bottom - root.getBoundingClientRect().bottom + ).toBeCloseTo(0, 1); + + // The focus ring still anchors to the cursor, which is the only reason + // the cursor is positioned at all now. + const ring = getComputedStyle(cursor, '::after'); + + await expect(ring.position).toBe('absolute'); + await expect(parseFloat(ring.width)).toBeGreaterThan(0); + }, +}; + +/** + * Each graduation is the mark, not a box with a pseudo-element drawing one. + * The grid track supplies the spacing the box used to, so the item is free to + * be the tick — which also means the marks are now a couple of pixels wide, + * and the strip behind them has to stay the thing a drag lands on. + */ +export const GraduationIsTheMark: Story = { + args: {}, + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const minor = rule.shadowRoot!.querySelector( + '[data-graduation="1"]' + )!; + const major = rule.shadowRoot!.querySelector( + '[data-graduation="5"]' + )!; + + // Nothing is drawn by a pseudo-element any more. + await expect(getComputedStyle(minor, '::before').content).toBe('none'); + + // The element is the tick: 2px by 6px, twice as thick and half again as + // tall every fifth mark. + await expect(minor.offsetWidth).toBe(2); + await expect(minor.offsetHeight).toBe(6); + await expect(major.offsetWidth).toBe(4); + await expect(major.offsetHeight).toBe(9); + + // Both sit on the same centre line, which is what the strip is positioned + // against — the thinner mark is inset by half the difference. + const centre = (el: HTMLElement) => + el.getBoundingClientRect().left + el.offsetWidth / 2; + + await expect(centre(major) - centre(minor)).toBeCloseTo(40, 1); + + // Exactly one graduation apart, thick mark to thin -- they share a centre + // line because each is centred on its own point, not nudged onto one. + const next = rule.shadowRoot!.querySelector( + '[data-graduation="2"]' + )!; + + await expect(centre(next) - centre(minor)).toBeCloseTo(10, 1); + + // The label clears the mark rather than butting against it, and still + // ends up inside the clip -- the floor on the graduations accounts for + // the gap as well as the label. + const label = rule.shadowRoot!.querySelector( + '.main-graduation .label' + )!; + const window_ = + rule.shadowRoot!.querySelector('.graduations')!; + + await expect( + label.getBoundingClientRect().top - major.getBoundingClientRect().bottom + ).toBeCloseTo(2, 1); + await expect( + window_.getBoundingClientRect().bottom - + label.getBoundingClientRect().bottom + ).toBeGreaterThan(0); + + // A press in the gap between two marks still has something to land on: + // the strip covers the window, and only the window itself is excluded. + const graduations = + rule.shadowRoot!.querySelector('.graduations')!; + const strip = + rule.shadowRoot!.querySelector('.graduations ul')!; + const box = minor.getBoundingClientRect(); + + const inTheGap = rule.shadowRoot!.elementFromPoint( + box.right + 3, + box.top + 2 + )!; + + await expect(inTheGap).not.toBe(minor); + await expect(graduations.contains(inTheGap)).toBe(true); + await expect(inTheGap).not.toBe(graduations); + await expect(strip.contains(inTheGap) || inTheGap === strip).toBe(true); + }, +}; + +/** + * The span between zero and the current value, in the accent fill a selected + * table row or menu item uses. It's a band rather than lit-up graduations + * because the value is continuous: 12.4 degrees falls between two marks, and + * there is nothing there to light up. + */ +export const ValueIndicator: Story = { + args: {value: 12.4}, + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const indicator = + rule.shadowRoot!.querySelector('.indicator')!; + const cursor = rule.shadowRoot!.querySelector('.cursor')!; + const zero = rule.shadowRoot!.querySelector( + '[data-graduation="0"]' + )!; + + const box = indicator.getBoundingClientRect(); + const cursorCentre = + cursor.getBoundingClientRect().left + cursor.offsetWidth / 2; + const zeroCentre = + zero.getBoundingClientRect().left + + zero.getBoundingClientRect().width / 2; + + // It runs from the mark for zero to the value under the cursor, landing + // 4px past the 12th graduation rather than on it. + await expect(box.left).toBeCloseTo(zeroCentre, 0); + await expect(box.right).toBeCloseTo(cursorCentre, 0); + await expect(box.width).toBeCloseTo(124, 0); + + // The same accent a selected row takes, so a selection reads the same + // wherever it is in the CP. + const style = getComputedStyle(indicator); + const accent = getComputedStyle(rule).getPropertyValue( + '--c-color-accent-fill-quiet' + ); + + await expect(accent.trim()).not.toBe(''); + await expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)'); + + // And it sits behind the marks rather than over them. + const atZero = rule.shadowRoot!.elementFromPoint( + zeroCentre, + zero.getBoundingClientRect().top + 2 + ); + + await expect(atZero).toBe(zero); + }, +}; + +/** + * At zero the band has no width, so all that would show is the pair of + * borders standing either side of the cursor. It stays out of the way until + * there is a value to describe. + */ +export const NoValueYet: Story = { + args: {value: 0}, + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const indicator = + rule.shadowRoot!.querySelector('.indicator')!; + + // Nothing in the sheet sets `display` on it, so `hidden` is free to. + await expect(getComputedStyle(indicator).display).toBe('none'); + await expect(indicator.getBoundingClientRect().width).toBe(0); + }, +}; diff --git a/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts b/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts index 88f3ef8d924..1a1a42b12d6 100644 --- a/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts +++ b/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts @@ -1,35 +1,83 @@ import {css} from 'lit'; // Ported from the legacy packages/craftcms-legacy/cp/src/css/_image_editor.scss -// `.slide-rule` block into the component's shadow root. `--focus-ring` / `--white` -// inherit from the page; the accent color is the legacy hard-coded value. +// `.slide-rule` block into the component's shadow root. The legacy hard-coded +// colours (`#63a6e1`, `--white`, `--gray-900`) are gone: everything here comes +// from the `--c-slide-rule-*` tokens, so the rule follows the CP's scheme and +// its themes rather than assuming a dark image editor around it. +// +// Nothing paints the page background any more either -- see the mask on +// `.graduations` -- so it can sit on anything. export default css` :host { display: block; } .slide-rule { - position: relative; - padding-block: 10px; - padding-inline: 0; + /* + Seeded from the public tokens so the rest of the sheet reads off one + name apiece. The graduation width is the load-bearing one: the strip's + positioning maths is in units of it, and the JS measures what was + actually rendered rather than assuming the default. + */ + --_graduation-width: var(--c-slide-rule-graduation-width, 8px); + --_graduation-height: var(--c-slide-rule-graduation-height, 10px); + --_graduation-color: var(--c-slide-rule-graduation-color, currentColor); + --_accent-color: var(--c-slide-rule-accent-color, currentColor); + + /* Every fifth graduation, half again as tall and twice as thick as the + rest. Derived rather than their own knobs, so they can't drift out of + proportion with the graduation they are a louder version of. */ + --_graduation-main-height: calc(var(--_graduation-height) * 1.5); + --_graduation-thickness: 2px; + --_graduation-main-thickness: calc(var(--_graduation-thickness) * 2); + + /* A label sits this far below the tallest mark, and takes about this much + room once it is there -- the floor on the graduations uses both. */ + --_label-gap: var(--c-spacing-xs); + --_label-space: calc(12rem / 16); + + /* + The cursor stacked above the ruler it points at. It used to be absolutely + positioned at 50% with a -4px margin pulling it back by half its width -- + a magic number that landed it a pixel off the centre the strip is + positioned against, which taps then measured their delta from. Letting + the layout centre it is exact, and stacking the two means they can no + longer overlap by accident. + */ + display: flex; + flex-direction: column; + /* Sets the cursor off the marks it points at. Taken out of the space that + used to sit above the cursor rather than added to the total, so the + control stays the height the token asks for. That slack is now spent: + widening this again grows the control. */ + gap: var(--c-spacing-sm); outline: none; + height: var(--c-slide-rule-height, 40px); + + /* Digits that don't change width as the strip slides past. */ + font-family: var(--c-font-mono); } .slide-rule:focus-visible .cursor::after { - box-shadow: var(--focus-ring); + outline: var(--c-focus-outline-width) var(--c-focus-outline-style) + var(--c-color-focus-outline); + outline-offset: var(--c-focus-outline-offset); + border-radius: var(--c-radius-sm); } .cursor { - position: absolute; - margin-inline-start: calc(-4 / 16 * 1rem); - margin-block-start: 4px; - inset-inline-start: 50%; - z-index: 1; + /* The cross axis of a column, so this is the horizontal centring that + replaced the margin. justify-self is the grid spelling and does + nothing here. */ + align-self: center; + /* Not for placement -- the focus ring below anchors to it. */ + position: relative; width: 0; height: 0; border-inline-start: calc(5 / 16 * 1rem) solid transparent; border-inline-end: calc(5 / 16 * 1rem) solid transparent; - border-block-start: calc(5 / 16 * 1rem) solid #63a6e1; + border-block-start: calc(5 / 16 * 1rem) solid var(--_accent-color); } .cursor::after { @@ -42,91 +90,193 @@ export default css` transform: translateX(-50%); } - .overlay { - z-index: 2; - position: absolute; - inset-block: 0 1px; - inset-inline: 0; - pointer-events: none; - background-image: linear-gradient( + .graduations { + /* + Takes the height the cursor row leaves behind. A height has to come from + somewhere: the graduations' only content is an absolutely positioned + label, so there is nothing here to size to. It also has to end up + definite, because the strip sizes to it and the graduations are the + pointer targets -- a zero-height strip is one a drag can never hit. + + The floor keeps a taller tick from pushing its label out of the clip. + */ + flex: 1; + min-block-size: calc( + var(--_graduation-main-height) + var(--_label-gap) + var(--_label-space) + ); + position: relative; + /* + This element is the window the strip slides behind, and the positioning + maths centres the strip on its width — so it has to be the width of the + rule, not of the strip inside it. Without the clip it sizes to its + content, and the strip is centred on a box wider than the one the cursor + sits in the middle of: about 20 degrees out for the default range. + + The legacy markup got this from a wrapper around the component -- + .straightening, with a max-width and its own clip -- so the component + owns it now rather than depending on where it is placed. + */ + overflow: hidden; + + /* + The graduations fade out towards the edges. This masks their own alpha + rather than painting a gradient of the page background over them, so the + rule holds up on any background -- the overlay it replaces was a hard + --gray-900 and smeared a dark band across anything lighter. + + Masking the window rather than the graduations themselves keeps it free + as the strip slides underneath: the fade belongs to the visible box, so + dragging doesn't have to recompute anything per graduation. + */ + mask-image: linear-gradient( to right, - var(--gray-900) 0%, - transparent 15%, - transparent 85%, - var(--gray-900) 100% + transparent 0%, + #000 15%, + #000 85%, + transparent 100% ); } - .graduations { - white-space: nowrap; - height: 40px; - position: relative; + /* + The span between zero and the current value, in the accent fill a selected + row or menu item uses -- so a selection reads the same here as it does + anywhere else in the CP. + + A band rather than lit-up graduations: the value is continuous, so it + usually falls between two marks and there is nothing there to light up. + + Hidden at zero, where the band has no width and would be nothing but the + two borders standing either side of the cursor. + + Behind the strip, which is why both are positioned and this one comes + first. + */ + .indicator { + position: absolute; + /* The width the JS sets is the span from zero to the value, borders and + all -- a content box would make the band two pixels wider than the + value it stands for. */ + box-sizing: border-box; + inset-block: 0; + inset-inline-start: 50%; + inline-size: 0; + background: var(--c-color-accent-fill-quiet); + border-inline: 1px solid var(--c-color-accent-border-quiet); + pointer-events: none; + transition: + 200ms inline-size linear, + 200ms translate linear; + } + + .slide-rule.dragging .indicator { + transition: none; } .graduations ul { position: relative; - float: inline-start; - height: 40px; + /* + One column per graduation, sized by the track. The width is declared once + here rather than on every item, and a fixed track has no reason to shrink + -- which is what the flex: none on each item was for. + + Whatever lays this out has to put the graduations flush against each + other. As inline-blocks the newlines between them in the template each + rendered as a space, making the strip wider than the graduation apiece + the positioning maths works in, which put zero nowhere near the cursor. + */ + display: grid; + grid-auto-flow: column; + grid-auto-columns: var(--_graduation-width); + + /* + Shrink-to-fit around those tracks, which is what the float here used to + buy. As a plain block the strip would size to the window it slides + behind instead, and the JS measures this element to learn how wide a + graduation actually came out. + */ + width: max-content; + + height: 100%; margin: 0; padding: 0; list-style: none; - /* "left" (not a logical property) because that's what the JS sets. */ - transition: 200ms left linear; /* stylelint-disable-line */ + /* + Translated rather than offset: sliding the strip is then a compositor + job with no layout behind it, which is what a drag is doing every frame. + */ + transition: 200ms transform linear; + + /* On the strip, not the marks: a press anywhere in it drags, and the marks + are only a couple of pixels wide to aim at. */ + cursor: pointer; } - .slide-rule.dragging .graduations ul { + .slide-rule.dragging .graduations ul, + .slide-rule.dragging .indicator { transition: none; } - .graduations ul li { - display: inline-block; - font-size: 8px; - position: relative; - width: 10px; + /* Until the first placement has been rendered -- see #markPlaced(). */ + .slide-rule:not(.placed) .graduations ul, + .slide-rule:not(.placed) .indicator { + transition: none; } - .graduations ul li:hover { - cursor: pointer; - } + .graduations ul li { + /* + The graduation is the mark, rather than a box holding a pseudo-element + that draws one. The grid track supplies the spacing the box used to, so + the item is free to be exactly the tick it represents. + */ + justify-self: start; + /* + A graduation is a point on the ruler, and the maths puts that point at + the start of the track -- so the mark has to straddle it rather than + begin at it, or every mark sits half its own thickness to the right of + the value it stands for, and the cursor points between them. - .graduations ul li:hover::before { - border-inline-start-color: #63a6e1; + Centring on the point rather than in the track also means marks of + different thicknesses line up on it for free. + */ + translate: -50%; + inline-size: var(--_graduation-thickness); + block-size: var(--_graduation-height); + background: var(--_graduation-color); + + font-size: 8px; + /* The label is positioned against this. */ + position: relative; } - .graduations ul li:not(.main-graduation) { - inset-inline-start: 1px; + .graduations ul li.main-graduation { + inline-size: var(--_graduation-main-thickness); + block-size: var(--_graduation-main-height); } - .graduations ul li.main-graduation::before { - border-inline-start-width: 4px; - height: 10px; + .graduations ul li:hover { + background: var(--_accent-color); } .graduations ul li .label { width: 20px; position: absolute; - inset-block-start: 10px; - inset-inline-start: -9px; + /* Clear of the tallest mark, so a taller graduation pushes the labels + down with it rather than crowding them. */ + inset-block-start: calc(var(--_graduation-main-height) + var(--_label-gap)); + /* Centred on the mark, whatever it is -- the -9px this replaces was half + of a 20px label against a box that is no longer 10px wide. */ + inset-inline-start: 50%; + transform: translateX(-50%); display: none; text-align: center; + /* Narrower than some labels are long, and a leading minus is a break + opportunity -- so they stay on one line explicitly. */ + white-space: nowrap; } .graduations ul li.main-graduation .label { display: block; cursor: default; } - - .graduations ul li.selected::before { - border-inline-start-color: #63a6e1; - } - - .graduations ul li::before { - content: ''; - position: absolute; - border-inline-start: 2px solid var(--white); - height: 6px; - inset-block-start: 0; - inset-inline-start: 0; - } `; diff --git a/packages/craftcms-ui/src/components/slide-rule/slide-rule.test.ts b/packages/craftcms-ui/src/components/slide-rule/slide-rule.test.ts index dae98b43d2d..f47a421259c 100644 --- a/packages/craftcms-ui/src/components/slide-rule/slide-rule.test.ts +++ b/packages/craftcms-ui/src/components/slide-rule/slide-rule.test.ts @@ -49,13 +49,14 @@ describe('craft-slide-rule', () => { expect(main).toHaveLength(29); }); - it('selects graduations between zero and the current value', async () => { - const element = await createSlideRule({value: 10}); - const selected = graduations(element) - .filter((g) => g.classList.contains('selected')) - .map((g) => Number(g.dataset.graduation)); - - expect(selected).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + it('leaves the graduations themselves unmarked', async () => { + // The span from zero is the indicator's job. Lighting up graduations + // can't say where a continuous value is -- it usually falls between two. + const element = await createSlideRule({value: 10.4}); + + expect( + graduations(element).filter((g) => g.classList.contains('selected')) + ).toHaveLength(0); }); it('moves the value with the keyboard and emits change', async () => { @@ -105,3 +106,147 @@ describe('craft-slide-rule', () => { expect(r.getAttribute('aria-valuetext')).toContain('12'); }); }); + +describe('craft-slide-rule positioning', () => { + it('waits for a measurable window rather than centring against zero', async () => { + // Rendered inside something with no layout — a closed dialog — the window + // measures 0, and centring against that lands the strip half its own + // length out: 20 degrees for the default range. + const host = document.createElement('div'); + host.style.display = 'none'; + document.body.append(host); + + const rule = document.createElement('craft-slide-rule'); + host.append(rule); + await rule.updateComplete; + + const list = + rule.shadowRoot!.querySelector('.graduations ul')!; + + expect(list.style.transform).toBe(''); + }); +}); + +describe('craft-slide-rule edge fade', () => { + it('fades the graduations with a mask rather than the page background', async () => { + // The fade used to be a gradient of --gray-900 painted on top, which only + // reads as a fade over a --gray-900 background and smears a dark band + // across anything lighter. Masking the window fades the graduations + // themselves, so the rule holds up wherever it is put. + const element = await createSlideRule(); + const window_ = + element.shadowRoot!.querySelector('.graduations')!; + + expect(element.shadowRoot!.querySelector('.overlay')).toBe(null); + + const mask = getComputedStyle(window_).getPropertyValue('mask-image'); + + expect(mask).toContain('linear-gradient'); + expect(mask).not.toBe('none'); + }); + + it('leaves the middle unmasked, where the value is read', async () => { + const element = await createSlideRule(); + const mask = getComputedStyle( + element.shadowRoot!.querySelector('.graduations')! + ).getPropertyValue('mask-image'); + + // Transparent only at the two ends; fully opaque across the middle, where + // the cursor sits and the value is read. + expect(mask.replace(/\s+/g, ' ')).toMatch( + /transparent 0%, [^,]+ 15%, [^,]+ 85%, transparent 100%/ + ); + }); +}); + +describe('craft-slide-rule value indicator', () => { + function indicator(element: CraftSlideRule): HTMLElement { + return element.shadowRoot!.querySelector('.indicator') as HTMLElement; + } + + it('spans from the middle of the window out to zero', async () => { + // 10px a degree by default, and the value always sits under the cursor in + // the middle -- so the band is however far zero is from there. + const element = await createSlideRule({value: 12}); + + expect(indicator(element).style.inlineSize).toBe('120px'); + }); + + it('lands on a fractional value, which no graduation could', async () => { + const element = await createSlideRule({value: 12.4}); + + expect(indicator(element).style.inlineSize).toBe('124px'); + }); + + it('grows the other way once the value goes negative', async () => { + // Positive values slide the strip left, putting zero to the left of the + // cursor; negative values put it to the right. + const positive = await createSlideRule({value: 12}); + const negative = await createSlideRule({value: -12}); + + expect(indicator(positive).style.translate).toBe('-100%'); + expect(indicator(negative).style.translate).toBe('0'); + expect(indicator(negative).style.inlineSize).toBe('120px'); + }); + + it('is hidden at zero, where it would be nothing but its own borders', async () => { + const element = await createSlideRule({value: 0}); + + expect(indicator(element).hidden).toBe(true); + }); + + it('comes back as soon as the value leaves zero', async () => { + const element = await createSlideRule({value: 0}); + + pressKey(element, 'ArrowUp'); + await element.updateComplete; + + expect(indicator(element).hidden).toBe(false); + expect(indicator(element).style.inlineSize).toBe('10px'); + }); + + it('is hidden again on the way back to zero', async () => { + const element = await createSlideRule({value: 1}); + + expect(indicator(element).hidden).toBe(false); + + pressKey(element, 'ArrowDown'); + await element.updateComplete; + + expect(indicator(element).hidden).toBe(true); + }); +}); + +describe('craft-slide-rule change events', () => { + it('keeps the value continuous rather than settling on a graduation', async () => { + // The image editor straightens by fractions of a degree where the backend + // can, so the rule must not round on its way through. + const element = await createSlideRule({min: -45.3}); + + pressKey(element, 'Home'); + await element.updateComplete; + + expect(element.value).toBe(-45.3); + }); + + it('stays quiet when a move lands on the value already showing', async () => { + const element = await createSlideRule({value: 45}); + const changes: number[] = []; + + element.addEventListener('change', (event) => { + changes.push((event as CustomEvent<{value: number}>).detail.value); + }); + + pressKey(element, 'ArrowUp'); + pressKey(element, 'End'); + await element.updateComplete; + + // Already at the ceiling, so neither key moved it anywhere new. + expect(changes).toEqual([]); + + pressKey(element, 'ArrowDown'); + await element.updateComplete; + + expect(changes).toEqual([44]); + }); +}); diff --git a/packages/craftcms-ui/src/components/slide-rule/slide-rule.ts b/packages/craftcms-ui/src/components/slide-rule/slide-rule.ts index 8e5718423a1..33385baf2b5 100644 --- a/packages/craftcms-ui/src/components/slide-rule/slide-rule.ts +++ b/packages/craftcms-ui/src/components/slide-rule/slide-rule.ts @@ -6,6 +6,9 @@ import styles from './slide-rule.styles.js'; const SENSITIVITY = 3; +/** Matches `--c-slide-rule-graduation-width`, for before anything is laid out. */ +const DEFAULT_GRADUATION_WIDTH = 10; + /** * @summary Ruler-style slider for fine rotation adjustment — the port of the * legacy `Craft.SlideRuleInput`, used by the image editor's straighten control. @@ -44,12 +47,14 @@ export default class CraftSlideRule extends LitElement { @query('.graduations') private _graduations!: HTMLElement; @query('.graduations ul') private _list!: HTMLElement; @query('.cursor') private _cursor!: HTMLElement; + @query('.indicator') private _indicator!: HTMLElement; #dragging = false; #rotateIntent = false; #startPositionX = 0; #startLeft = 0; #calculatedWidth = 0; + #placed = false; #resizeObserver: ResizeObserver | null = null; #graduations(): number[] { @@ -73,8 +78,10 @@ export default class CraftSlideRule extends LitElement { } override firstUpdated(): void { - // (n - 1) graduations because each border sits on the left of its 10px box. - this.#calculatedWidth = (this.#graduations().length - 1) * 10; + // A starting point for the unmeasurable case below; `#reposition()` + // replaces it with what was actually rendered. + this.#calculatedWidth = + (this.#graduations().length - 1) * DEFAULT_GRADUATION_WIDTH; this.#reposition(); } @@ -92,10 +99,114 @@ export default class CraftSlideRule extends LitElement { /** Slides the ruler so the current value lines up under the cursor. */ #reposition(): void { - if (!this._list) { + // The strip is centred against the width of its window, so an unmeasurable + // window puts it half its own length out — 20 degrees off for the default + // range. That happens whenever the rule first renders inside something not + // yet laid out, a closed dialog being the usual case. The resize observer + // calls back the moment there is a size, so waiting costs nothing. + if (this._graduations?.offsetWidth) { + this.#measureGraduations(); + } + + // Drawn before the bail below: the indicator is a function of the value + // and the graduation width, so unlike the strip it doesn't need the + // window to have been laid out to know where it goes. + this.#drawIndicator(); + + if (!this._list || !this._graduations?.offsetWidth) { + return; + } + + this._list.style.transform = `translateX(${this.#valueToPosition( + this.value + )}px)`; + + this.#markPlaced(); + } + + /** + * Lets the strip and the indicator start animating, once they have been put + * where they belong. + * + * Neither resting place is something to animate into. `left` never did, + * because a transition can't run from `auto` to a length -- but `none` to a + * matrix interpolates fine, so both would slide into place every time the + * rule first appears. The stylesheet holds their transitions off until this + * class lands, a frame later. + */ + #markPlaced(): void { + if (this.#placed) { + return; + } + + this.#placed = true; + requestAnimationFrame(() => this._root?.classList.add('placed')); + } + + /** + * Sizes the band running from zero to the current value. + * + * The graduations can't carry this themselves: the value is continuous, so + * it usually falls between two of them and there is no mark to light up. + * The band is measured in the same pixels-per-unit the strip is positioned + * in, so it lands exactly on a fractional value. + * + * It grows from the middle of the window, because that is where the cursor + * is and so where the current value always sits. Zero is however far away + * the value says it is -- to the left once the value goes positive, since + * that is the direction the strip slides. + */ + #drawIndicator(): void { + if (!this._indicator) { return; } - this._list.style.left = `${this.#valueToPosition(this.value)}px`; + + const scaleMax = (this.graduationMin - this.graduationMax) * -1; + const perUnit = this.#calculatedWidth / scaleMax; + + this._indicator.style.inlineSize = `${Math.abs(this.value) * perUnit}px`; + this._indicator.style.translate = this.value > 0 ? '-100%' : '0'; + } + + /** + * Reads the rendered graduation width instead of assuming the default. + * + * The positioning maths is in units of one graduation, and + * `--c-slide-rule-graduation-width` can be set to anything — so a strip that + * doesn't match what the maths assumes puts zero nowhere near the cursor. + * That is the shape of two bugs already: graduations rendered as + * inline-blocks picked up the template's newlines as whitespace, and an + * unmeasurable window centred the strip against zero. + * + * Measured off the strip rather than one graduation, so a fractional width + * doesn't accumulate a rounding error across the whole range. + */ + #measureGraduations(): void { + const count = this.#graduations().length; + const width = this._list.getBoundingClientRect().width / count; + + if (width > 0) { + // (n - 1) because each border sits at the start of its own box. + this.#calculatedWidth = (count - 1) * width; + } + } + + /** + * Where the strip is sitting right now, read back off the transform. + * + * Not the value `#reposition()` last wrote: a drag can start while the + * transition from a tap or a keypress is still running, and this has to be + * the position on screen rather than the one being animated towards. + */ + #currentOffset(): number { + const {transform} = getComputedStyle(this._list); + + // Before the first reposition, and whenever a stylesheet hasn't applied. + if (!transform || transform === 'none') { + return 0; + } + + return new DOMMatrixReadOnly(transform).m41; } #valueText(value: number): string { @@ -131,6 +242,12 @@ export default class CraftSlideRule extends LitElement { #setValue(rawValue: number, emitChange = true): void { const value = Math.min(Math.max(rawValue, this.min), this.max); + // A move that lands on the value already showing shouldn't ask a consumer + // to redraw for it. + if (value === this.value) { + return; + } + this.value = value; if (emitChange) { @@ -163,7 +280,7 @@ export default class CraftSlideRule extends LitElement { event.preventDefault(); this.#startPositionX = event.clientX; - this.#startLeft = this._list.offsetLeft; + this.#startLeft = this.#currentOffset(); this._root.setPointerCapture(event.pointerId); this.#emit('start'); } @@ -246,13 +363,6 @@ export default class CraftSlideRule extends LitElement { event.preventDefault(); } - #isSelected(graduation: number): boolean { - return ( - graduation >= Math.min(0, this.value) && - graduation <= Math.max(0, this.value) - ); - } - override render() { return html` - + ${this.#graduations().map( (graduation) => html` @@ -279,7 +393,6 @@ export default class CraftSlideRule extends LitElement { class=${classMap({ graduation: true, 'main-graduation': graduation % 5 === 0, - selected: this.#isSelected(graduation), })} data-graduation=${graduation} > diff --git a/packages/craftcms-ui/src/styles/shared/base.css b/packages/craftcms-ui/src/styles/shared/base.css index c576dac8d61..930bca14449 100644 --- a/packages/craftcms-ui/src/styles/shared/base.css +++ b/packages/craftcms-ui/src/styles/shared/base.css @@ -13,6 +13,20 @@ body { -webkit-font-smoothing: subpixel-antialiased; } +/* + A themed subtree has to restate the text colour. `color` is resolved where + it's declared, so the value set on `body` carries the page's palette wherever + it inherits -- leaving dark text on a themed dark background, which is how + this first showed up: a contrast ratio of 1.07. + + Only `color`. Backgrounds are painted deliberately by whatever owns the + surface, and giving every themed element one would paint a lot of boxes + nobody asked for. +*/ +[data-theme] { + color: var(--c-text-default); +} + /* Global keyboard-focus indicator: any focusable element gets a visible ring when focused via the keyboard. Uses :focus-visible so it stays off during diff --git a/packages/craftcms-ui/src/styles/shared/colorable.css b/packages/craftcms-ui/src/styles/shared/colorable.css index c97c886df11..457af17938e 100644 --- a/packages/craftcms-ui/src/styles/shared/colorable.css +++ b/packages/craftcms-ui/src/styles/shared/colorable.css @@ -1,6 +1,15 @@ /* Auto-generated by scripts/generate-colors.js — do not edit manually */ -:root { +/* + Also on [data-theme], not just :root. These tokens are built out of the raw + palette with var(), and a custom property carrying a var() is substituted + where it is declared -- so resolving them only at :root bakes in the root's + palette, and re-declaring the palette further down can never reach back into + them. Repeating the block on a themed element re-resolves it against that + element's palette, which is what makes a themed subtree possible. +*/ +:root, +[data-theme] { /* red */ --c-color-red-fill-quiet: var(--color-red-50); --c-color-red-fill-normal: var(--color-red-100); diff --git a/packages/craftcms-ui/src/styles/shared/tokens.css b/packages/craftcms-ui/src/styles/shared/tokens.css index d918bd7b23d..5db942e9129 100644 --- a/packages/craftcms-ui/src/styles/shared/tokens.css +++ b/packages/craftcms-ui/src/styles/shared/tokens.css @@ -1,5 +1,14 @@ +/* + [data-theme] is here for the same reason as in colorable.css: these tokens are + built out of the raw palette with var(), and a custom property carrying a + var() is substituted where it is declared. Resolved only at :root they carry + the root's palette wherever they inherit, so re-declaring the palette on a + subtree cannot reach them. Repeating the block on a themed element re-resolves + it against that element's palette. +*/ :root, -:host { +:host, +[data-theme] { --c-text-lg: calc(16rem / 16); --c-text-base: calc(14rem / 16); --c-text-sm: calc(12rem / 16); @@ -195,6 +204,13 @@ --c-checkbox-size: var(--c-size-control-xs); --c-checkbox-group-spacing: calc(24px - var(--c-checkbox-size)); + /* Slide rule */ + --c-slide-rule-height: var(--c-size-touch-target); + --c-slide-rule-graduation-width: 10px; + --c-slide-rule-graduation-height: 6px; + --c-slide-rule-graduation-color: var(--c-color-neutral-border-loud); + --c-slide-rule-accent-color: var(--c-color-accent-border-loud); + /** Appearances **/ /* Generic Panes */ --c-pane-fill: var(--c-surface-overlay); diff --git a/resources/js/modules/elements/components/ElementEditScreen.vue b/resources/js/modules/elements/components/ElementEditScreen.vue index a600e4750e2..7e5344579d2 100644 --- a/resources/js/modules/elements/components/ElementEditScreen.vue +++ b/resources/js/modules/elements/components/ElementEditScreen.vue @@ -136,7 +136,10 @@ tab in the details column is that list now. --> - + + @@ -350,7 +353,7 @@ border-block-end: 1px solid var(--color-neutral-border-quiet); position: sticky; top: 0; - z-index: 1000; + z-index: 10; background-color: white; } diff --git a/resources/js/modules/image-editor/components/ImageEditorDialog.vue b/resources/js/modules/image-editor/components/ImageEditorDialog.vue new file mode 100644 index 00000000000..03ea88fb04a --- /dev/null +++ b/resources/js/modules/image-editor/components/ImageEditorDialog.vue @@ -0,0 +1,765 @@ + + + + + + + + + + + + {{ t('Rotate Left') }} + + + {{ t('Rotate Right') }} + + + + + + {{ t('Flip Vertical') }} + + + {{ t('Flip Horizontal') }} + + + + + + {{ t('Focal Point') }} + + + + + {{ t('Crop') }} + + + + + + {{ t('Reset to original') }} + + + + + + + + + + + + + + + + + + + + + + {{ option.label }} + + + + + + + + + x + + + + + + + + + + + + + + {{ t('Edit {type}', {type: t('Cropping Rectangle')}) }} + + + + + + + + + + + {{ t('Cropping Rectangle') }} + + + + + + + {{ button.label }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ t('Save as a new asset') }} + + + {{ t('Save') }} + + + + + + diff --git a/resources/js/modules/image-editor/components/dialogOpenBinding.test.ts b/resources/js/modules/image-editor/components/dialogOpenBinding.test.ts new file mode 100644 index 00000000000..2250ce19277 --- /dev/null +++ b/resources/js/modules/image-editor/components/dialogOpenBinding.test.ts @@ -0,0 +1,45 @@ +import {expect, it} from 'vite-plus/test'; +import {createApp, h, nextTick} from 'vue'; +import '@craftcms/ui/components/dialog/dialog'; +import type CraftDialog from '@craftcms/ui/components/dialog/dialog'; + +/** + * `craft-dialog` takes its open state as the `opened` property, backed by the + * `open` attribute. Vue only removes a false boolean for the seven names in its + * `isSpecialBooleanAttr` list; `open` is in the wider `isBooleanAttr` list that + * `patchAttr` never consults. So an attribute binding writes the string + * `"false"`, Lit's boolean converter sees an attribute that is present, and the + * dialog shows itself with nothing having asked it to. + */ +async function mountDialog(props: Record) { + const container = document.createElement('div'); + document.body.append(container); + createApp({render: () => h('craft-dialog', props)}).mount(container); + await nextTick(); + + const dialog = container.querySelector('craft-dialog')!; + await dialog.updateComplete; + + return dialog; +} + +it('opens a dialog handed `false` through the attribute', async () => { + const dialog = await mountDialog({open: false}); + + // Not what anyone wants — this is the trap the property binding avoids, and + // it fails loudly here if Vue ever starts stripping the attribute. + expect(dialog.opened).toBe(true); +}); + +it('leaves a dialog closed when the property is bound instead', async () => { + const dialog = await mountDialog({'.opened': false}); + + expect(dialog.opened).toBe(false); + expect(dialog.hasAttribute('open')).toBe(false); +}); + +it('still opens on demand through the property', async () => { + const dialog = await mountDialog({'.opened': true}); + + expect(dialog.opened).toBe(true); +}); diff --git a/resources/js/modules/image-editor/constraints.test.ts b/resources/js/modules/image-editor/constraints.test.ts new file mode 100644 index 00000000000..cead9a85848 --- /dev/null +++ b/resources/js/modules/image-editor/constraints.test.ts @@ -0,0 +1,89 @@ +import {expect, it} from 'vite-plus/test'; +import { + constraintOptions, + defaultConstraintKey, + orientConstraint, +} from './constraints'; + +// Mirrors the shipped `imageEditorRatios` config. +const ratios = { + Unconstrained: 'none', + Original: 'original', + Square: 1, + '16:9': 1.78, + '10:8': 1.25, + '3:2': 1.5, +}; + +it('leaves every ratio alone in landscape', () => { + for (const [label, ratio] of Object.entries(ratios)) { + expect(orientConstraint(String(ratio), label, 'landscape')).toEqual({ + value: String(ratio), + label, + }); + } +}); + +it('inverts a numeric ratio and reverses its label in portrait', () => { + expect(orientConstraint('1.78', '16:9', 'portrait')).toEqual({ + value: String(1 / 1.78), + label: '9:16', + }); +}); + +it('leaves the ratios that have no orientation alone in portrait', () => { + expect(orientConstraint('none', 'Unconstrained', 'portrait')).toEqual({ + value: 'none', + label: 'Unconstrained', + }); + expect(orientConstraint('original', 'Original', 'portrait')).toEqual({ + value: 'original', + label: 'Original', + }); +}); + +it('reads a square the same either way', () => { + const portrait = orientConstraint('1', 'Square', 'portrait'); + + expect(parseFloat(portrait.value)).toBe(1); + expect(portrait.label).toBe('Square'); +}); + +it('keeps each option key stable across an orientation change', () => { + // The regression this guards: the selection used to be tracked by value, so + // flipping the orientation lost it and re-applied the previous ratio. + const landscape = constraintOptions(ratios, 'landscape'); + const portrait = constraintOptions(ratios, 'portrait'); + + expect(portrait.map((option) => option.key)).toEqual( + landscape.map((option) => option.key) + ); +}); + +it('turns the selected ratio over when the orientation changes', () => { + const selected = '16:9'; + const landscape = constraintOptions(ratios, 'landscape').find( + (option) => option.key === selected + ); + const portrait = constraintOptions(ratios, 'portrait').find( + (option) => option.key === selected + ); + + expect(parseFloat(landscape!.value)).toBeGreaterThan(1); + expect(parseFloat(portrait!.value)).toBeLessThan(1); + expect( + parseFloat(landscape!.value) * parseFloat(portrait!.value) + ).toBeCloseTo(1, 10); +}); + +it('ends the list with Custom in both orientations', () => { + for (const orientation of ['landscape', 'portrait'] as const) { + const options = constraintOptions(ratios, orientation); + + expect(options.at(-1)?.key).toBe('custom'); + } +}); + +it('starts on the unconstrained option', () => { + expect(defaultConstraintKey(ratios)).toBe('Unconstrained'); +}); diff --git a/resources/js/modules/image-editor/constraints.ts b/resources/js/modules/image-editor/constraints.ts new file mode 100644 index 00000000000..422dd95ef34 --- /dev/null +++ b/resources/js/modules/image-editor/constraints.ts @@ -0,0 +1,62 @@ +import {t} from '@craftcms/ui'; + +export type CropOrientation = 'landscape' | 'portrait'; + +export interface ConstraintOption { + /** + * Stable identity, taken from the `imageEditorRatios` key. + * + * The selection is tracked by this rather than by `value`, which inverts with + * the orientation — keying on the value would drop the selection the moment + * the orientation flipped, and re-apply the ratio just switched away from. + */ + key: string; + value: string; + label: string; +} + +/** + * Turns a ratio to face the given orientation: `16:9` reads `9:16` in portrait + * and its value inverts with it. + * + * Only numeric ratios turn. `none` and `original` have no orientation, and a + * square reads the same either way. + */ +export function orientConstraint( + value: string, + label: string, + orientation: CropOrientation +): {value: string; label: string} { + if (!/^\d*\.?\d+$/.test(value) || orientation === 'landscape') { + return {value, label}; + } + + return { + value: String(1 / parseFloat(value)), + label: label.split(':').reverse().join(':').replace(/\s/g, ''), + }; +} + +/** The constraint list for an orientation, with `Custom` last. */ +export function constraintOptions( + ratios: Record, + orientation: CropOrientation +): ConstraintOption[] { + return [ + ...Object.entries(ratios).map(([key, ratio]) => ({ + key, + ...orientConstraint(String(ratio), t(key), orientation), + })), + {key: 'custom', value: 'custom', label: t('Custom')}, + ]; +} + +/** The key of the unconstrained option, which is where the list starts. */ +export function defaultConstraintKey( + ratios: Record +): string { + return ( + Object.entries(ratios).find(([, ratio]) => String(ratio) === 'none')?.[0] ?? + 'custom' + ); +} diff --git a/resources/js/modules/image-editor/fabric.ts b/resources/js/modules/image-editor/fabric.ts new file mode 100644 index 00000000000..f17d65246d7 --- /dev/null +++ b/resources/js/modules/image-editor/fabric.ts @@ -0,0 +1,69 @@ +/** + * Every fabric API the editor touches is funnelled through this one file, so a + * version bump lands here and, as far as possible, nowhere else. + * + * It earned that on the way from 1.7 to 7. The library stopped being a UMD + * global that `FabricAsset` registered and became an ESM package we import, + * renamed `fabric.Image` to `FabricImage`, dropped the `getWidth()` accessors + * in favour of plain properties, and returned promises where it used to take + * callbacks -- and the composables saw none of it. + * + * The legacy jQuery editor still runs on the 1.7 global. That copy is built + * from `packages/craftcms-legacy`'s own dependency and served by `FabricAsset`, + * so the two versions don't meet. + */ +import { + Circle, + FabricImage, + Group, + Line, + Path, + Rect, + StaticCanvas, + loadSVGFromString, + util, + type FabricObject, +} from 'fabric'; + +export {Circle, FabricImage, Group, Line, Path, Rect, StaticCanvas}; + +export type {FabricObject}; + +/** + * Properties `animate()` will tween. Everything the editor animates is a + * number -- an angle, a size, a position -- and fabric's own signature is + * narrower than the `unknown` these objects used to be typed with. + */ +export type FabricAnimatable = Record; + +/** Named for what the editor uses them as, rather than what fabric calls them. */ +export type FabricGroup = Group; +export type FabricCanvas = StaticCanvas; + +/** + * Loads an image, rejecting rather than resolving null. + * + * fabric resolves to `null` for an image it couldn't fetch, which reads as a + * success everywhere it is awaited. The editor wants the failure. + */ +export async function loadImage(url: string): Promise { + const image = await FabricImage.fromURL(url); + + if (!image) { + throw new Error(`Could not load image: ${url}`); + } + + return image; +} + +/** Parses an SVG into a single object the editor can place on a canvas. */ +export async function loadSvg(svg: string): Promise { + const {objects, options} = await loadSVGFromString(svg); + const parsed = objects.filter((object) => object !== null); + + if (!parsed.length) { + throw new Error('Could not parse SVG.'); + } + + return util.groupSVGElements(parsed, options); +} diff --git a/resources/js/modules/image-editor/geometry.test.ts b/resources/js/modules/image-editor/geometry.test.ts new file mode 100644 index 00000000000..b61263bf7ef --- /dev/null +++ b/resources/js/modules/image-editor/geometry.test.ts @@ -0,0 +1,152 @@ +import {expect, it} from 'vite-plus/test'; +import { + arePointsInsideRectangle, + getFarthestAllowedDeltas, + getHandlePosition, + getRectangleVertices, + hitTestHandle, + resizeRectangle, + transposeRectangle, +} from './geometry'; +import type {Rectangle, VerticeCoords} from './types'; + +/** An axis-aligned image quad, corners clockwise from the top-right. */ +function quad( + left: number, + top: number, + width: number, + height: number +): VerticeCoords { + return { + a: {x: left + width, y: top}, + b: {x: left + width, y: top + height}, + c: {x: left, y: top + height}, + d: {x: left, y: top}, + }; +} + +const image = quad(20, 20, 600, 400); +const full: Rectangle = {left: 20, top: 20, width: 600, height: 400}; + +it('accepts a rectangle whose corners sit exactly on the boundary', () => { + // The crop starts flush with the image, so the common case is corners + // touching the edge rather than strictly inside it. + expect(arePointsInsideRectangle(getRectangleVertices(full), image)).toBe( + true + ); +}); + +it('rejects a rectangle that leaves the image on any side', () => { + for (const [dx, dy] of [ + [-1, 0], + [1, 0], + [0, -1], + [0, 1], + ]) { + expect( + arePointsInsideRectangle(getRectangleVertices(full, dx, dy), image) + ).toBe(false); + } +}); + +it('shrinks from the dragged edge and leaves the others alone', () => { + const smaller = resizeRectangle(full, -50, 0, 'r', false, false); + + expect(smaller).toEqual({left: 20, top: 20, width: 550, height: 400}); + + const fromLeft = resizeRectangle(full, 50, 0, 'l', false, false); + + expect(fromLeft).toEqual({left: 70, top: 20, width: 550, height: 400}); +}); + +it('grows a corner outward on both axes', () => { + // `bl` drags the left edge left and the bottom edge down. + expect(resizeRectangle(full, -10, 10, 'bl', false, false)).toEqual({ + left: 10, + top: 20, + width: 610, + height: 410, + }); +}); + +it('preserves the aspect ratio of a rectangle that already conforms', () => { + // A constrained drag scales the *deltas* by the ratio, so it holds a shape + // that already matches rather than converging on one that doesn't — bringing + // the rectangle to the ratio in the first place is `enforce()`'s job. + const conforming: Rectangle = {left: 20, top: 20, width: 600, height: 300}; + const constrained = resizeRectangle(conforming, -60, 0, 'r', 2, false); + + expect(constrained.width / constrained.height).toBeCloseTo(2, 5); +}); + +it('slides along an edge rather than refusing a blocked move', () => { + // Pushing left is blocked by the image, but downward travel is free. + const {farthest, farthestDeltas} = getFarthestAllowedDeltas( + {left: 20, top: 20, width: 600, height: 300}, + {x: -5, y: 5}, + image + ); + + expect(farthest).toBeGreaterThan(0); + // `toBeCloseTo` rather than `toBe`: the blocked axis comes back as `-0`. + expect(farthestDeltas.x).toBeCloseTo(0, 10); + expect(farthestDeltas.y).toBeGreaterThan(0); +}); + +it('finds each handle from a point on the rectangle border', () => { + const clipper = {left: 320, top: 220, width: 600, height: 400}; + + for (const handle of ['tl', 't', 'tr', 'l', 'r', 'bl', 'b', 'br'] as const) { + const position = getHandlePosition(handle, clipper); + + expect(hitTestHandle(position, clipper)).toBe(handle); + } +}); + +it('finds no handle in the middle of the rectangle', () => { + const clipper = {left: 320, top: 220, width: 600, height: 400}; + + expect(hitTestHandle({x: 320, y: 220}, clipper)).toBeNull(); +}); + +it('swaps width and height about the centre when it already fits', () => { + // A 300x200 crop sitting well inside a 600x400 image. + const clipper = {left: 320, top: 220, width: 300, height: 200}; + const turned = transposeRectangle(clipper, image); + + expect(turned.width).toBe(200); + expect(turned.height).toBe(300); + // Centre is preserved, so the crop stays where the user framed it. + expect(turned.left + turned.width / 2).toBeCloseTo(320, 10); + expect(turned.top + turned.height / 2).toBeCloseTo(220, 10); +}); + +it('lands on exactly the inverted aspect ratio', () => { + const clipper = {left: 320, top: 220, width: 320, height: 180}; + const turned = transposeRectangle(clipper, image); + const before = clipper.width / clipper.height; + const after = turned.width / turned.height; + + expect(before * after).toBeCloseTo(1, 10); +}); + +it('shrinks a turned rectangle that would leave the image', () => { + // 560 wide fits a 600-wide image; turned it would be 560 tall in 400. + const clipper = {left: 320, top: 220, width: 560, height: 300}; + const turned = transposeRectangle(clipper, image); + + expect(arePointsInsideRectangle(getRectangleVertices(turned), image)).toBe( + true + ); + // Shrunk, but still the inverted shape. + expect(turned.width / turned.height).toBeCloseTo(300 / 560, 6); + expect(turned.height).toBeLessThan(560); +}); + +it('keeps a turned rectangle centred where it was', () => { + const clipper = {left: 200, top: 150, width: 500, height: 200}; + const turned = transposeRectangle(clipper, image); + + expect(turned.left + turned.width / 2).toBeCloseTo(200, 10); + expect(turned.top + turned.height / 2).toBeCloseTo(150, 10); +}); diff --git a/resources/js/modules/image-editor/geometry.ts b/resources/js/modules/image-editor/geometry.ts new file mode 100644 index 00000000000..fde5c97c8dd --- /dev/null +++ b/resources/js/modules/image-editor/geometry.ts @@ -0,0 +1,511 @@ +import type { + CropHandle, + Dimensions, + NudgeDirection, + Point, + Rectangle, + VerticeCoords, +} from './types'; + +/** How far one arrow-key press moves the cropper or focal point, in pixels. */ +const NUDGE_STEP = 5; + +export function getVector(a: Point, b: Point): Point { + return {x: b.x - a.x, y: b.y - a.y}; +} + +export function getScalarProduct(a: Point, b: Point): number { + return a.x * b.x + a.y * b.y; +} + +export function getVectorMagnitude(vector: Point): number { + return Math.sqrt(vector.x * vector.x + vector.y * vector.y); +} + +/** The angle between two vectors in degrees, to two decimal places. */ +export function getAngleBetweenVectors(a: Point, b: Point): number { + const cosine = Math.min( + 1, + getScalarProduct(a, b) / (getVectorMagnitude(a) * getVectorMagnitude(b)) + ); + + return Math.round(((Math.acos(cosine) * 180) / Math.PI) * 100) / 100; +} + +/** + * The four corners of a rectangle, clockwise from the top-left, optionally + * displaced by an offset. + * + * @see https://stackoverflow.com/a/2763387 + */ +export function getRectangleVertices( + rectangle: Rectangle, + offsetX = 0, + offsetY = 0 +): Point[] { + const topLeft = { + x: rectangle.left + offsetX, + y: rectangle.top + offsetY, + }; + + return [ + topLeft, + {x: topLeft.x + rectangle.width, y: topLeft.y}, + {x: topLeft.x + rectangle.width, y: topLeft.y + rectangle.height}, + {x: topLeft.x, y: topLeft.y + rectangle.height}, + ]; +} + +/** + * Whether every point falls inside a rectangle given by its corners — which may + * be rotated, so this projects each point onto two adjacent edges rather than + * comparing bounds. + */ +export function arePointsInsideRectangle( + points: Point[], + rectangle: VerticeCoords +): boolean { + const ab = getVector(rectangle.a, rectangle.b); + const bc = getVector(rectangle.b, rectangle.c); + const scalarAbAb = getScalarProduct(ab, ab); + const scalarBcBc = getScalarProduct(bc, bc); + + return points.every((point) => { + const scalarAbAp = getScalarProduct(ab, getVector(rectangle.a, point)); + const scalarBcBp = getScalarProduct(bc, getVector(rectangle.b, point)); + + return ( + scalarAbAp >= 0 && + scalarAbAp <= scalarAbAb && + scalarBcBp >= 0 && + scalarBcBp <= scalarBcBc + ); + }); +} + +/** The axis-aligned box enclosing a set of (possibly rotated) corners. */ +export function getBoundingRectangle(coords: VerticeCoords): Dimensions { + const xs = [coords.a.x, coords.b.x, coords.c.x, coords.d.x]; + const ys = [coords.a.y, coords.b.y, coords.c.y, coords.d.y]; + + return { + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + }; +} + +/** + * Whether one center-origin object's center sits inside another's bounds. Only + * valid for an unrotated container. + */ +export function isCenterInside( + object: {left: number; top: number}, + container: {left: number; top: number; width: number; height: number} +): boolean { + return ( + object.left > container.left - container.width / 2 && + object.top > container.top - container.height / 2 && + object.left < container.left + container.width / 2 && + object.top < container.top + container.height / 2 + ); +} + +/** + * Which edge of `rectangle` an imaginary line from `center` to `vertex` crosses. + * + * Found by angle rather than intersection: for the offending edge, the angle + * from center to vertex equals the sum of the angles each makes with the edge. + * Rounding means that's never exact, so the closest match wins. + */ +export function getEdgeCrossed( + rectangle: VerticeCoords, + vertex: Point, + center: Point +): [Point, Point] | null { + const edges: Array<[Point, Point]> = [ + [rectangle.a, rectangle.b], + [rectangle.b, rectangle.c], + [rectangle.c, rectangle.d], + [rectangle.d, rectangle.a], + ]; + + let smallestDiff = 180; + let edgeCrossed: [Point, Point] | null = null; + + for (const edge of edges) { + const toCenter = getVector(edge[0], center); + const edgeVector = getVector(edge[0], edge[1]); + const toVertex = getVector(edge[0], vertex); + + const diff = Math.abs( + getAngleBetweenVectors(toCenter, toVertex) - + (getAngleBetweenVectors(toCenter, edgeVector) + + getAngleBetweenVectors(edgeVector, toVertex)) + ); + + if (diff < smallestDiff) { + smallestDiff = diff; + edgeCrossed = edge; + } + } + + return edgeCrossed; +} + +/** Perpendicular distance from a point to the line through an edge. */ +function distanceToEdge(edge: [Point, Point], point: Point): number { + return ( + Math.abs( + (edge[1].y - edge[0].y) * point.x - + (edge[1].x - edge[0].x) * point.y + + edge[1].x * edge[0].y - + edge[1].y * edge[0].x + ) / + Math.sqrt( + Math.pow(edge[1].y - edge[0].y, 2) + Math.pow(edge[1].x - edge[0].x, 2) + ) + ); +} + +/** + * How much a rectangle would have to be zoomed for it to fit inside a container + * given by its corners. Returns 1 when it already fits. + * + * @see https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line + */ +export function getZoomRatioToFitRectangle( + rectangle: Rectangle, + containingVertices: VerticeCoords, + center: Point +): number { + const escapee = getRectangleVertices(rectangle).find( + (vertex) => !arePointsInsideRectangle([vertex], containingVertices) + ); + + if (!escapee) { + return 1; + } + + const edge = getEdgeCrossed(containingVertices, escapee, center); + + if (!edge) { + return 1; + } + + const rectangleCenter = { + x: rectangle.left + rectangle.width / 2, + y: rectangle.top + rectangle.height / 2, + }; + + const distanceFromVertex = distanceToEdge(edge, escapee); + const distanceFromCenter = distanceToEdge(edge, rectangleCenter); + + return (distanceFromVertex + distanceFromCenter) / distanceFromCenter; +} + +/** + * The largest fraction of a proposed move that keeps the rectangle inside the + * image, so dragging into an edge slides along it instead of stopping dead. + * Searches at most ten pixels per axis, which is all a single frame or key + * press can produce. + */ +export function getFarthestAllowedDeltas( + rectangle: Rectangle, + deltas: Point, + containingVertices: VerticeCoords +): {farthest: number; farthestDeltas: Point} { + const signX = deltas.x > 0 ? 1 : -1; + const signY = deltas.y > 0 ? 1 : -1; + + const result = {farthest: 0, farthestDeltas: {x: 0, y: 0}}; + + for (let dxi = Math.min(Math.abs(deltas.x), 10); dxi >= 0; dxi--) { + for (let dyi = Math.min(Math.abs(deltas.y), 10); dyi >= 0; dyi--) { + const vertices = getRectangleVertices( + rectangle, + dxi * signX, + dyi * signY + ); + + if ( + arePointsInsideRectangle(vertices, containingVertices) && + dxi + dyi > result.farthest + ) { + result.farthest = dxi + dyi; + result.farthestDeltas = {x: dxi * signX, y: dyi * signY}; + } + } + } + + return result; +} + +/** Where a named handle sits on a center-origin rectangle. */ +export function getHandlePosition( + handle: CropHandle, + clipper: {left: number; top: number; width: number; height: number} +): Point { + const halfWidth = clipper.width / 2; + const halfHeight = clipper.height / 2; + + const x = handle.includes('l') + ? clipper.left - halfWidth + : handle.includes('r') + ? clipper.left + halfWidth + : clipper.left; + + const y = handle.includes('t') + ? clipper.top - halfHeight + : handle.includes('b') + ? clipper.top + halfHeight + : clipper.top; + + return {x, y}; +} + +export function getDeltasFromDirection(direction: NudgeDirection): Point { + switch (direction) { + case 'up': + return {x: 0, y: -NUDGE_STEP}; + case 'down': + return {x: 0, y: NUDGE_STEP}; + case 'left': + return {x: -NUDGE_STEP, y: 0}; + case 'right': + return {x: NUDGE_STEP, y: 0}; + } +} + +/** Rotates an offset around the origin — how a point moves as the image turns. */ +export function rotatePoint(point: Point, degrees: number): Point { + const radians = degrees * (Math.PI / 180); + + return { + x: point.x * Math.cos(radians) - point.y * Math.sin(radians), + y: point.x * Math.sin(radians) + point.y * Math.cos(radians), + }; +} + +/** How close to an edge or corner the pointer must be to grab that handle. */ +const HANDLE_HIT_SLOP = 10; + +/** + * Which cropper handle, if any, sits under a point. The asymmetric tolerances + * come from the handle artwork, which is drawn a few pixels outside the + * rectangle on the right and bottom. + */ +export function hitTestHandle( + point: Point, + clipper: {left: number; top: number; width: number; height: number} +): CropHandle | null { + const left = clipper.left - clipper.width / 2; + const right = left + clipper.width; + const top = clipper.top - clipper.height / 2; + const bottom = top + clipper.height; + + const nearLeft = point.x < left + HANDLE_HIT_SLOP && point.x > left - 3; + const nearRight = point.x > right - 13 && point.x < right + 3; + const nearTop = point.y < top + HANDLE_HIT_SLOP && point.y > top - 3; + const nearBottom = point.y < bottom + 3 && point.y > bottom - HANDLE_HIT_SLOP; + + if (nearLeft && nearTop) return 'tl'; + if (nearLeft && nearBottom) return 'bl'; + if (nearRight && nearTop) return 'tr'; + if (nearRight && nearBottom) return 'br'; + + const betweenVertically = + point.y < bottom - HANDLE_HIT_SLOP && point.y > top + HANDLE_HIT_SLOP; + const betweenHorizontally = + point.x > left + HANDLE_HIT_SLOP && point.x < right - HANDLE_HIT_SLOP; + + if (point.x < left + 3 && point.x > left - 3 && betweenVertically) return 'l'; + if (point.x < right + 1 && point.x > right - 5 && betweenVertically) + return 'r'; + if (point.y < top + 4 && point.y > top - 2 && betweenHorizontally) return 't'; + if (point.y < bottom + 2 && point.y > bottom - 4 && betweenHorizontally) + return 'b'; + + return null; +} + +/** The mouse cursor that signals what dragging a given handle would do. */ +export function getCursorForHandle(handle: CropHandle): string { + if (handle === 't' || handle === 'b') return 'ns-resize'; + if (handle === 'l' || handle === 'r') return 'ew-resize'; + if (handle === 'tl' || handle === 'br') return 'nwse-resize'; + return 'nesw-resize'; +} + +/** + * How far a drag on one handle moves the rectangle's size along each axis, + * before the aspect ratio is applied. A corner takes whichever axis the pointer + * moved further along, so the drag follows the mouse rather than one edge. + */ +function getConstrainedChange( + handle: CropHandle, + deltaX: number, + deltaY: number +): number { + const dominantIsVertical = Math.abs(deltaY) > Math.abs(deltaX); + + switch (handle) { + case 't': + return -deltaY; + case 'b': + return deltaY; + case 'r': + return deltaX; + case 'l': + return -deltaX; + case 'tr': + return dominantIsVertical ? -deltaY : deltaX; + case 'tl': + return dominantIsVertical ? -deltaY : -deltaX; + case 'br': + return dominantIsVertical ? deltaY : deltaX; + case 'bl': + return dominantIsVertical ? deltaY : -deltaX; + } +} + +/** + * The rectangle a resize drag produces. + * + * With a locked aspect ratio the rectangle grows from the dragged edge and + * spreads evenly along the other axis, so it feels anchored where the pointer + * is. Unconstrained, each named edge moves independently — with Shift on a + * corner holding the current ratio. + */ +export function resizeRectangle( + startingRectangle: Rectangle, + deltaX: number, + deltaY: number, + handle: CropHandle, + constraint: number | false, + shiftKeyHeld: boolean +): Rectangle { + const rectangle = {...startingRectangle}; + + if (constraint) { + const change = getConstrainedChange(handle, deltaX, deltaY); + + let dx: number; + let dy: number; + + if (constraint > 1) { + dx = change; + dy = dx / constraint; + } else { + dy = change; + dx = dy * constraint; + } + + rectangle.width += dx; + rectangle.height += dy; + + // Shift the origin so the rectangle expands away from the dragged edge. + if (handle.includes('t')) { + rectangle.top -= dy; + } + if (handle.includes('l')) { + rectangle.left -= dx; + } + if (handle === 't' || handle === 'b') { + rectangle.left -= dx / 2; + } + if (handle === 'l' || handle === 'r') { + rectangle.top -= dy / 2; + } + + return rectangle; + } + + let dx = deltaX; + let dy = deltaY; + + const isCorner = handle.length === 2; + + if (shiftKeyHeld && isCorner) { + const ratio = startingRectangle.width / startingRectangle.height; + const invert = handle === 'tr' || handle === 'bl' ? -1 : 1; + + if (Math.abs(deltaX) > Math.abs(deltaY)) { + dy = (dx / ratio) * invert; + } else { + dx = dy * ratio * invert; + } + } + + if (handle.includes('t')) { + rectangle.top += dy; + rectangle.height -= dy; + } + if (handle.includes('b')) { + rectangle.height += dy; + } + if (handle.includes('r')) { + rectangle.width += dx; + } + if (handle.includes('l')) { + rectangle.left += dx; + rectangle.width -= dx; + } + + return rectangle; +} + +/** Bisection steps used to shrink a turned rectangle back inside its container. */ +const TRANSPOSE_FIT_STEPS = 24; + +/** + * Stands a centre-origin rectangle the other way up: width and height swap + * about its centre. + * + * Swapping the two *is* the inverted aspect ratio, so a constrained crop lands + * on exactly the shape a flipped constraint asks for. A turned rectangle can + * stick out where the original didn't — a wide crop becomes a tall one — so it + * shrinks about its centre until it fits. + * + * Returns the shape it settled on as a top-left-origin rectangle. + */ +export function transposeRectangle( + clipper: {left: number; top: number; width: number; height: number}, + containingVertices: VerticeCoords +): Rectangle { + const turned = {width: clipper.height, height: clipper.width}; + + const at = (scale: number): Rectangle => ({ + left: clipper.left - (turned.width * scale) / 2, + top: clipper.top - (turned.height * scale) / 2, + width: turned.width * scale, + height: turned.height * scale, + }); + + const contained = (candidate: Rectangle): boolean => + arePointsInsideRectangle( + getRectangleVertices(candidate), + containingVertices + ); + + if (contained(at(1))) { + return at(1); + } + + // Shrinking about the centre converges on the centre point, which is inside + // the container, so a fitting scale exists. Bisection finds it to well under + // a pixel in a handful of steps. + let tooSmall = 0; + let tooBig = 1; + + for (let step = 0; step < TRANSPOSE_FIT_STEPS; step++) { + const middle = (tooSmall + tooBig) / 2; + + if (contained(at(middle))) { + tooSmall = middle; + } else { + tooBig = middle; + } + } + + return at(tooSmall); +} diff --git a/resources/js/modules/image-editor/types.ts b/resources/js/modules/image-editor/types.ts new file mode 100644 index 00000000000..e7a3feff550 --- /dev/null +++ b/resources/js/modules/image-editor/types.ts @@ -0,0 +1,74 @@ +/** A point in editor-space (pixels, origin at the editor's top-left). */ +export interface Point { + x: number; + y: number; +} + +/** A rectangle whose `left`/`top` reference its **top-left** corner. */ +export interface Rectangle { + left: number; + top: number; + width: number; + height: number; +} + +export interface Dimensions { + width: number; + height: number; +} + +/** + * The four corners of the (possibly rotated) image, going clockwise from the + * top-right. Named `a`–`d` because the containment maths treats `a`→`b` and + * `b`→`c` as the two edge vectors. + */ +export interface VerticeCoords { + a: Point; + b: Point; + c: Point; + d: Point; +} + +/** + * The cropper's position and size, stored at a zoom ratio of 1 and relative to + * the image center, so it survives zooming, rotation and editor resizes. + */ +export interface CropperState { + offsetX: number; + offsetY: number; + width: number; + height: number; + imageDimensions: Dimensions; +} + +/** The focal point's offset from the image center, stored at a zoom ratio of 1. */ +export interface FocalPointState { + offsetX: number; + offsetY: number; + imageDimensions: Dimensions; +} + +/** Which axes the image has been flipped on, as 0/1 so it posts as ints. */ +export interface FlipData { + x: number; + y: number; +} + +/** A corner or edge handle on the cropping rectangle. */ +export type CropHandle = 'tl' | 't' | 'tr' | 'l' | 'r' | 'bl' | 'b' | 'br'; + +/** + * Anything the keyboard editing layer can pick up: the cropping rectangle + * itself, the focal point, or one of the eight resize handles. + */ +export type FabricElementHandle = CropHandle | 'rectangle' | 'focalpoint'; + +export type NudgeDirection = 'up' | 'down' | 'left' | 'right'; + +export type EditorView = 'rotate' | 'crop'; + +/** The focal point as the server stores it: fractions of the image's size. */ +export interface RelativeFocalPoint { + x: number; + y: number; +} diff --git a/resources/js/modules/image-editor/useCropper.ts b/resources/js/modules/image-editor/useCropper.ts new file mode 100644 index 00000000000..f5b8a58461e --- /dev/null +++ b/resources/js/modules/image-editor/useCropper.ts @@ -0,0 +1,670 @@ +import { + Circle, + Group, + Line, + Path, + Rect, + StaticCanvas, + type FabricGroup, + type FabricObject, +} from './fabric'; +import { + arePointsInsideRectangle, + getFarthestAllowedDeltas, + getHandlePosition, + getRectangleVertices, + resizeRectangle, + transposeRectangle, +} from './geometry'; +import type { + CropHandle, + CropperState, + FabricElementHandle, + Point, + Rectangle, +} from './types'; +import type {EditorAnnouncements} from './useEditorAnnouncements'; +import type {EditorGeometry, EditorState} from './useEditorState'; +import type {ImageCanvas} from './useImageCanvas'; + +/** The cropping rectangle never shrinks below this, in either dimension. */ +const MIN_CROP_SIZE = 30; + +/** Extra breathing room around a straightened image's crop rectangle. */ +const STRAIGHTENED_RECT_PADDING = 1.2; + +export interface CropperFocusContext { + /** The handle whose edit button currently has focus, if any. */ + focusedHandle: () => FabricElementHandle | null; + /** The handle currently picked up for keyboard editing, if any. */ + pickedHandle: () => CropHandle | null; + /** Whether the rectangle itself is picked up. */ + rectanglePickedUp: () => boolean; + /** Whether the last interaction was a drag rather than the keyboard. */ + dragEditMode: () => boolean; +} + +/** + * The cropping layer: a second canvas stacked over the image, holding the + * shade, the cropping rectangle and its handles. + * + * The clipper is drawn with `destination-out`, so it punches a hole in the + * shade rather than being drawn on top of it. Its position, like the focal + * point's, is stored zoom-independently in `cropperState`. + */ +export function useCropper( + state: EditorState, + geometry: EditorGeometry, + canvas: ImageCanvas, + announcements: EditorAnnouncements, + focus: CropperFocusContext +) { + /** + * Captures the clipper's canvas position back into zoom-independent state, + * or stores a state passed in wholesale. With no clipper yet, falls back to + * the whole image. + */ + function storeCropperState(next?: CropperState): void { + if (next) { + state.cropperState.value = next; + return; + } + + const clipper = state.clipper.value; + const image = state.image.value; + + if (clipper && image) { + const zoomFactor = 1 / state.zoomRatio.value; + + state.cropperState.value = { + offsetX: (clipper.left - image.left) * zoomFactor, + offsetY: (clipper.top - image.top) * zoomFactor, + width: clipper.width * zoomFactor, + height: clipper.height * zoomFactor, + imageDimensions: geometry.getScaledImageDimensions(), + }; + + return; + } + + const dimensions = geometry.getScaledImageDimensions(); + + state.cropperState.value = { + offsetX: 0, + offsetY: 0, + width: dimensions.width, + height: dimensions.height, + imageDimensions: dimensions, + }; + } + + /** The clipper as a top-left-origin rectangle, for the containment maths. */ + function getClipperRect(): Rectangle | null { + const clipper = state.clipper.value; + + if (!clipper) { + return null; + } + + return { + left: clipper.left - clipper.width / 2, + top: clipper.top - clipper.height / 2, + width: clipper.width, + height: clipper.height, + }; + } + + /** The white L-shaped brackets at each corner of the rectangle. */ + function buildHandles(clipper: FabricObject): FabricGroup { + // `fill: false` meant "no fill" in fabric 1.x; v7 spells it null. + const lineOptions = { + strokeWidth: 4, + stroke: state.settings.colors.white, + fill: null, + }; + + const {width, height} = clipper; + + const paths = [ + 'M 0,10 L 0,0 L 10,0', + `M ${width - 8},0 L ${width + 4},0 L ${width + 4},10`, + `M ${width + 4},${height - 8} L${width + 4},${height + 4} L ${width - 8},${height + 4}`, + `M 10,${height + 4} L 0,${height + 4} L 0,${height - 8}`, + ].map((path) => new Path(path, lineOptions)); + + return new Group(paths, { + left: clipper.left, + top: clipper.top, + originX: 'center', + originY: 'center', + }); + } + + /** The rule-of-thirds guides inside the rectangle. */ + function buildGrid(clipper: FabricObject): FabricGroup { + const gridOptions = {strokeWidth: 2, stroke: 'rgba(255,255,255,0.5)'}; + const {width, height} = clipper; + + const lines: [number, number, number, number][] = [ + [width * 0.33, 0, width * 0.33, height], + [width * 0.66, 0, width * 0.66, height], + [0, height * 0.33, width, height * 0.33], + [0, height * 0.66, width, height * 0.66], + ]; + + return new Group( + lines.map((points) => new Line(points, gridOptions)), + { + left: clipper.left, + top: clipper.top, + originX: 'center', + originY: 'center', + } + ); + } + + /** + * The rectangle outline. Gains a blue/white double outline while it's + * focused or picked up, and the move icon while it's picked up. + */ + function buildCroppingRectangle(clipper: FabricObject): FabricGroup { + const strokeWidth = 2; + const shared = { + fill: state.settings.colors.transparent, + top: 0, + left: 0, + strokeWidth, + originX: 'center', + originY: 'center', + } as const; + + const outerOutline = new Rect({ + ...shared, + width: clipper.width + strokeWidth * 4, + height: clipper.height + strokeWidth * 4, + stroke: null, + }); + + const innerOutline = new Rect({ + ...shared, + width: clipper.width + strokeWidth * 2, + height: clipper.height + strokeWidth * 2, + stroke: null, + }); + + const outline = new Rect({ + ...shared, + width: clipper.width, + height: clipper.height, + stroke: state.settings.colors.white, + }); + + const group = new Group([outerOutline, innerOutline, outline], { + originX: 'center', + originY: 'center', + left: clipper.left, + top: clipper.top, + }); + + const pickedUp = focus.rectanglePickedUp(); + const focused = focus.focusedHandle() === 'rectangle'; + + if (pickedUp || focused) { + outerOutline.set({stroke: state.settings.colors.white}); + innerOutline.set({stroke: state.settings.colors.accent}); + + if (pickedUp && state.moveIcon.value) { + group.add( + new Circle({ + fill: state.settings.colors.black, + top: 0, + left: 0, + radius: 15, + stroke: state.settings.colors.white, + strokeWidth: 2, + originX: 'center', + originY: 'center', + }) + ); + group.add(state.moveIcon.value); + } + } + + return group; + } + + /** + * The concentric rings marking which handle the keyboard is acting on. Not + * drawn during pointer interaction, where the cursor already says it. + */ + function buildHandleFocusIndicator( + clipper: FabricObject + ): FabricGroup | null { + const picked = focus.pickedHandle(); + const focused = focus.focusedHandle(); + const focusedIsHandle = + focused !== null && focused !== 'rectangle' && focused !== 'focalpoint'; + + if (focus.dragEditMode() || (!focusedIsHandle && !picked)) { + return null; + } + + const handle = (picked ?? focused) as CropHandle; + const position = getHandlePosition(handle, clipper); + + const size = 12; + const width = 3; + const shared = { + fill: null, + strokeWidth: width, + left: 0, + top: 0, + originX: 'center', + originY: 'center', + } as const; + + const rings = [ + new Circle({ + ...shared, + radius: size + width * 2, + stroke: state.settings.colors.accent, + }), + new Circle({ + ...shared, + radius: size + width, + stroke: state.settings.colors.white, + }), + new Circle({ + ...shared, + radius: size, + stroke: state.settings.colors.accent, + }), + ]; + + const focusRing = new Group(rings, { + originX: 'center', + originY: 'center', + left: position.x, + top: position.y, + }); + + if (picked && state.moveIcon.value) { + focusRing.add(state.moveIcon.value); + focusRing.item(0).set({fill: state.settings.colors.transparentBlack}); + } + + return focusRing; + } + + /** Rebuilds everything drawn on top of the clipper. */ + function redrawElements(): void { + const croppingCanvas = state.croppingCanvas.value; + const clipper = state.clipper.value; + + if (!croppingCanvas || !clipper) { + return; + } + + for (const object of [ + state.cropperHandles.value, + state.cropperGrid.value, + state.croppingRectangle.value, + state.handleFocusIndicator.value, + ]) { + if (object) { + croppingCanvas.remove(object); + } + } + + state.cropperHandles.value = buildHandles(clipper); + state.cropperGrid.value = buildGrid(clipper); + state.croppingRectangle.value = buildCroppingRectangle(clipper); + state.handleFocusIndicator.value = buildHandleFocusIndicator(clipper); + + croppingCanvas.add(state.croppingRectangle.value); + croppingCanvas.add(state.cropperHandles.value); + croppingCanvas.add(state.cropperGrid.value); + + if (state.handleFocusIndicator.value) { + croppingCanvas.add(state.handleFocusIndicator.value); + } + } + + /** Builds the cropping canvas, its shade, and the clipper that cuts it. */ + function setupLayer(clipperData?: Partial): void { + const canvasEl = state.croppingCanvasEl.value; + + if (!canvasEl) { + return; + } + + // Replacing the canvas on the same element -- safe without awaiting, for + // the reason given in `hide()`. + void state.croppingCanvas.value?.dispose(); + + const croppingCanvas = new StaticCanvas(canvasEl, { + backgroundColor: state.settings.colors.transparent, + hoverCursor: 'default', + selection: false, + }); + + croppingCanvas.setDimensions({ + width: state.editorWidth.value, + height: state.editorHeight.value, + }); + + state.croppingCanvas.value = croppingCanvas; + + const shade = new Rect({ + left: state.editorWidth.value / 2, + top: state.editorHeight.value / 2, + originX: 'center', + originY: 'center', + width: state.editorWidth.value, + height: state.editorHeight.value, + fill: state.settings.colors.transparentBlack, + }); + + // A straightened image needs the rectangle pulled in, or its corners would + // sit outside the picture. + const dimensions = geometry.getScaledImageDimensions(); + const ratio = + state.imageStraightenAngle.value === 0 + ? 1 + : geometry.getCombinedZoomRatio(dimensions) * STRAIGHTENED_RECT_PADDING; + + let rectWidth = dimensions.width / ratio; + let rectHeight = dimensions.height / ratio; + + if (geometry.hasOrientationChanged()) { + [rectWidth, rectHeight] = [rectHeight, rectWidth]; + } + + const clipper = new Rect({ + left: state.editorWidth.value / 2, + top: state.editorHeight.value / 2, + originX: 'center', + originY: 'center', + width: rectWidth, + height: rectHeight, + stroke: 'black', + fill: 'rgba(128,0,0,1)', + strokeWidth: 0, + }); + + if (clipperData) { + clipper.set(clipperData as Record); + } + + // Cuts the rectangle out of the shade rather than drawing over it. + clipper.globalCompositeOperation = 'destination-out'; + + state.croppingShade.value = shade; + state.clipper.value = clipper; + + croppingCanvas.add(shade); + croppingCanvas.add(clipper); + } + + function show(clipperData?: Partial): void { + setupLayer(clipperData); + redrawElements(); + canvas.renderCropper(); + } + + function hide(): void { + if (!state.clipper.value) { + return; + } + + // Wiped before disposing, because disposing no longer does it. fabric 1.x + // cleared the context on the way out; fabric 7 only resets the element's + // width and height attributes, and a browser is free to skip that when the + // values haven't changed -- leaving the rectangle, its grid and the shade + // painted over the image after crop closes. + state.croppingCanvas.value?.clear(); + + // Not awaited, and it doesn't need to be: the half that matters here -- + // unwrapping the canvas element and cancelling animations -- happens + // synchronously before the promise resolves. Only object teardown is + // deferred, and the environment hook it ends at is a no-op in the browser, + // so the element is safe to hand to a new canvas the next time crop opens. + void state.croppingCanvas.value?.dispose(); + state.croppingCanvas.value = null; + state.clipper.value = null; + state.croppingShade.value = null; + state.cropperHandles.value = null; + state.cropperGrid.value = null; + state.croppingRectangle.value = null; + state.handleFocusIndicator.value = null; + } + + /** + * Turns the rectangle on its side: width and height swap about its centre, + * so the crop the user framed is kept and simply stands the other way up. + * + * Swapping the two *is* the inverted ratio, so a constrained crop lands on + * exactly the shape the flipped constraint asks for — without `enforce()` + * rebuilding it from the ratio and jumping in size. + * + * A turned rectangle can stick out of the image where the original didn't (a + * wide crop becomes a tall one), so it shrinks about its centre until it + * fits. Returns the shape it settled on, or null if it couldn't turn. + */ + function transpose(): Rectangle | null { + const clipper = state.clipper.value; + const coords = state.imageVerticeCoords.value; + + if (state.animationInProgress.value || !clipper || !coords) { + return null; + } + + const target = transposeRectangle(clipper, coords); + + if (target.width < MIN_CROP_SIZE || target.height < MIN_CROP_SIZE) { + return null; + } + + state.animationInProgress.value = true; + + clipper.animate( + {width: target.width, height: target.height}, + { + duration: state.settings.animationDuration, + onChange: () => { + redrawElements(); + state.croppingCanvas.value?.renderAll(); + }, + onComplete: () => { + redrawElements(); + state.animationInProgress.value = false; + canvas.renderCropper(); + storeCropperState(); + }, + } + ); + + return target; + } + + /** + * Re-derives the rectangle from the stored cropper state and the image's + * current position. + * + * `reposition()` translates the rectangle by how much the editor changed, + * which preserves whatever offset it already had — right for a live resize, + * wrong once the rectangle and the image have drifted apart. The stored state + * is held independently of zoom and position, so re-deriving from it puts the + * rectangle back onto the image whatever happened in between. + */ + function restoreFromState(): void { + const clipper = state.clipper.value; + const cropperState = state.cropperState.value; + const image = state.image.value; + + if (!clipper || !cropperState || !image) { + return; + } + + const sizeFactor = + geometry.getScaledImageDimensions().width / + cropperState.imageDimensions.width; + const scale = sizeFactor * state.zoomRatio.value; + + clipper.set({ + left: image.left + cropperState.offsetX * scale, + top: image.top + cropperState.offsetY * scale, + width: cropperState.width * scale, + height: cropperState.height * scale, + }); + + redrawElements(); + } + + /** + * Resizes the cropping layer to the editor and puts the rectangle back where + * it belongs on the image. + * + * The rectangle is re-derived rather than shifted by how much the editor + * changed — the stored state holds it relative to the image at a zoom of 1, + * so it lands on the same part of the picture whatever the editor did. Must + * run after the image has taken its new position and size. + */ + function reposition(): void { + const croppingCanvas = state.croppingCanvas.value; + const shade = state.croppingShade.value; + + if (!croppingCanvas || !shade) { + return; + } + + croppingCanvas.setDimensions({ + width: state.editorWidth.value, + height: state.editorHeight.value, + }); + + shade.set({ + width: state.editorWidth.value, + height: state.editorHeight.value, + left: state.editorWidth.value / 2, + top: state.editorHeight.value / 2, + }); + + restoreFromState(); + canvas.renderCropper(); + } + + /** + * Moves the rectangle, clamping to the image. A move that would leave the + * picture is retried at the farthest distance that stays inside, so dragging + * into an edge slides along it. + */ + function moveByDelta(deltaX: number, deltaY: number, announce = true): void { + const clipper = state.clipper.value; + const rectangle = getClipperRect(); + const coords = state.imageVerticeCoords.value; + + if (!clipper || !rectangle || !coords) { + return; + } + + let dx = deltaX; + let dy = deltaY; + + if ( + !arePointsInsideRectangle(getRectangleVertices(rectangle, dx, dy), coords) + ) { + const {farthest, farthestDeltas} = getFarthestAllowedDeltas( + rectangle, + {x: dx, y: dy}, + coords + ); + + if (farthest === 0) { + return; + } + + dx = farthestDeltas.x; + dy = farthestDeltas.y; + } + + clipper.set({left: clipper.left + dx, top: clipper.top + dy}); + + if (announce) { + announcements.announcePosition(clipper); + } + } + + /** Resizes the rectangle by one handle, refusing moves that leave the image. */ + function resizeByHandle( + handle: CropHandle, + deltas: Point, + announce = true + ): void { + const clipper = state.clipper.value; + const starting = getClipperRect(); + const coords = state.imageVerticeCoords.value; + + if (!clipper || !starting || !coords) { + return; + } + + const attempt = (x: number, y: number): Rectangle => + resizeRectangle( + starting, + x, + y, + handle, + state.croppingConstraint.value, + state.shiftKeyHeld.value + ); + + const fits = (candidate: Rectangle): boolean => + candidate.height >= MIN_CROP_SIZE && + candidate.width >= MIN_CROP_SIZE && + arePointsInsideRectangle(getRectangleVertices(candidate), coords); + + // A corner drag moves on both axes, and the rectangle starts flush with the + // image — so one axis is usually blocked while the other has room. Refusing + // the whole move makes the cropper feel dead; taking whichever axis still + // fits lets it slide along the edge, the way dragging the rectangle does. + const rectangle = + [ + attempt(deltas.x, deltas.y), + attempt(deltas.x, 0), + attempt(0, deltas.y), + ].find(fits) ?? null; + + if (!rectangle) { + return; + } + + clipper.set({ + top: rectangle.top + rectangle.height / 2, + left: rectangle.left + rectangle.width / 2, + width: rectangle.width, + height: rectangle.height, + }); + + redrawElements(); + + if (announce) { + announcements.announceSizeAndPosition(clipper); + } + } + + return { + storeCropperState, + getClipperRect, + redrawElements, + show, + hide, + reposition, + restoreFromState, + transpose, + moveByDelta, + resizeByHandle, + }; +} + +export type Cropper = ReturnType; diff --git a/resources/js/modules/image-editor/useCroppingConstraint.ts b/resources/js/modules/image-editor/useCroppingConstraint.ts new file mode 100644 index 00000000000..8b63a108a8a --- /dev/null +++ b/resources/js/modules/image-editor/useCroppingConstraint.ts @@ -0,0 +1,136 @@ +import {arePointsInsideRectangle, getRectangleVertices} from './geometry'; +import type {Cropper} from './useCropper'; +import type {EditorState} from './useEditorState'; +import type {ImageCanvas} from './useImageCanvas'; + +/** + * A ratio, either as a number or a numeric string, or one of the named values + * `none`, `original` (the asset's own ratio) and `current` (whatever the + * rectangle happens to be). + */ +export type ConstraintValue = string | number; + +/** + * The aspect-ratio lock on the cropping rectangle. + * + * Setting a constraint records the ratio; enforcing it animates the rectangle + * to match, growing along whichever axis keeps it inside the image. + */ +export function useCroppingConstraint( + state: EditorState, + cropper: Cropper, + canvas: ImageCanvas +) { + function setConstraint(constraint: ConstraintValue): void { + const clipper = state.clipper.value; + + switch (constraint) { + case 'none': + state.croppingConstraint.value = false; + break; + + case 'original': + state.croppingConstraint.value = + state.originalWidth.value / state.originalHeight.value; + break; + + case 'current': + state.croppingConstraint.value = clipper + ? clipper.width / clipper.height + : false; + break; + + // Custom keeps whatever the width/height inputs last applied. + case 'custom': + break; + + default: { + const ratio = + typeof constraint === 'number' ? constraint : parseFloat(constraint); + state.croppingConstraint.value = Number.isNaN(ratio) ? false : ratio; + } + } + } + + /** Applies a `w / h` ratio from the custom constraint inputs. */ + function setCustomConstraint(width: number, height: number): void { + if (width > 0 && height > 0) { + state.croppingConstraint.value = width / height; + } + } + + /** + * Reshapes the rectangle to the current ratio. + * + * Grows the short axis first, since that keeps the visible crop as large as + * possible; if that would push a corner off the image, shrinks the long axis + * instead. + */ + function enforce(): void { + const constraint = state.croppingConstraint.value; + const clipper = state.clipper.value; + const rectangle = cropper.getClipperRect(); + const coords = state.imageVerticeCoords.value; + + if ( + state.animationInProgress.value || + !constraint || + !clipper || + !rectangle || + !coords + ) { + return; + } + + state.animationInProgress.value = true; + + if (clipper.width > clipper.height * constraint) { + const previousHeight = rectangle.height; + + rectangle.height = clipper.width / constraint; + rectangle.top -= (rectangle.height - previousHeight) / 2; + + if (!arePointsInsideRectangle(getRectangleVertices(rectangle), coords)) { + rectangle.width = clipper.height * constraint; + rectangle.height = rectangle.width / constraint; + } + } else { + const previousWidth = rectangle.width; + + rectangle.width = clipper.height * constraint; + rectangle.left -= (rectangle.width - previousWidth) / 2; + + if (!arePointsInsideRectangle(getRectangleVertices(rectangle), coords)) { + rectangle.height = clipper.width / constraint; + rectangle.width = rectangle.height * constraint; + } + } + + clipper.animate( + {width: rectangle.width, height: rectangle.height}, + { + duration: state.settings.animationDuration, + onChange: () => { + cropper.redrawElements(); + state.croppingCanvas.value?.renderAll(); + }, + onComplete: () => { + cropper.redrawElements(); + state.animationInProgress.value = false; + canvas.renderCropper(); + cropper.storeCropperState(); + }, + } + ); + } + + /** Sets a constraint and immediately reshapes the rectangle to match. */ + function apply(constraint: ConstraintValue): void { + setConstraint(constraint); + enforce(); + } + + return {setConstraint, setCustomConstraint, enforce, apply}; +} + +export type CroppingConstraint = ReturnType; diff --git a/resources/js/modules/image-editor/useEditorAnnouncements.ts b/resources/js/modules/image-editor/useEditorAnnouncements.ts new file mode 100644 index 00000000000..5858978ab76 --- /dev/null +++ b/resources/js/modules/image-editor/useEditorAnnouncements.ts @@ -0,0 +1,100 @@ +import {t} from '@craftcms/ui'; +import {useAnnouncer} from '@/common/composables/useAnnouncer'; +import type {EditorState} from './useEditorState'; + +/** + * Screen-reader announcements for the keyboard editing flow. + * + * The legacy editor built these position strings as bare template literals, so + * they stayed English in every locale. They go through `t()` here. + */ +export function useEditorAnnouncements(state: EditorState) { + const {announce} = useAnnouncer(); + + /** Where an object sits, as a percentage across and down the image. */ + function positionMessage( + item: {left: number; top: number} | null + ): string | null { + const image = state.image.value; + + if (!item || !image || !item.left || !item.top) { + return null; + } + + const x = ( + ((item.left - image.left + image.getScaledWidth() / 2) / + image.getScaledWidth()) * + 100 + ).toFixed(1); + const y = ( + ((item.top - image.top + image.getScaledHeight() / 2) / + image.getScaledHeight()) * + 100 + ).toFixed(1); + + return t('Centered at X axis: {x}%, Y axis: {y}%.', {x, y}); + } + + function sizeAndPositionMessage( + item: {left: number; top: number; width: number; height: number} | null + ): string | null { + if (!item) { + return null; + } + + const size = t('Crop rectangle width: {width}px, height: {height}px.', { + width: Math.round(item.width), + height: Math.round(item.height), + }); + + return [size, positionMessage(item)].filter(Boolean).join(' '); + } + + function announcePosition(item: {left: number; top: number} | null): void { + announce(positionMessage(item)); + } + + function announceSizeAndPosition( + item: {left: number; top: number; width: number; height: number} | null + ): void { + announce(sizeAndPositionMessage(item)); + } + + function announcePickUp( + itemName: string, + item: {left: number; top: number} | null + ): void { + announce( + [ + t('{item} picked up.', {item: itemName}), + positionMessage(item), + t('Use the arrow keys to change position, Tab or Spacebar to drop.'), + ] + .filter(Boolean) + .join(' ') + ); + } + + function announceDrop( + itemName: string, + item: {left: number; top: number} | null + ): void { + announce( + [t('{item} dropped.', {item: itemName}), positionMessage(item)] + .filter(Boolean) + .join(' ') + ); + } + + return { + announce, + positionMessage, + sizeAndPositionMessage, + announcePosition, + announceSizeAndPosition, + announcePickUp, + announceDrop, + }; +} + +export type EditorAnnouncements = ReturnType; diff --git a/resources/js/modules/image-editor/useEditorInteractions.ts b/resources/js/modules/image-editor/useEditorInteractions.ts new file mode 100644 index 00000000000..1e09f0410ab --- /dev/null +++ b/resources/js/modules/image-editor/useEditorInteractions.ts @@ -0,0 +1,446 @@ +import {ref} from 'vue'; +import { + getCursorForHandle, + getDeltasFromDirection, + hitTestHandle, +} from './geometry'; +import type { + CropHandle, + FabricElementHandle, + NudgeDirection, + Point, +} from './types'; +import type {Cropper, CropperFocusContext} from './useCropper'; +import type {EditorAnnouncements} from './useEditorAnnouncements'; +import type {EditorState} from './useEditorState'; +import type {FocalPoint} from './useFocalPoint'; +import type {ImageCanvas} from './useImageCanvas'; + +const NUDGE_KEYS: Record = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +}; + +/** + * Which element the keyboard is currently acting on. + * + * Split out from the interactions themselves so `useCropper` can read it while + * drawing focus rings without depending on the interaction layer, which in turn + * depends on the cropper. + */ +export function useEditingState() { + /** The handle whose edit button has focus, if any. */ + const focusedHandle = ref(null); + /** The handle picked up for keyboard editing, if any. */ + const pickedHandle = ref(null); + const rectanglePickedUp = ref(false); + const focalPickedUp = ref(false); + /** + * True while the pointer is driving the editor. Suppresses the keyboard focus + * rings, which would otherwise fight the cursor for saying what's happening. + */ + const dragEditMode = ref(true); + + function reset(): void { + rectanglePickedUp.value = false; + pickedHandle.value = null; + focalPickedUp.value = false; + } + + const focusContext: CropperFocusContext = { + focusedHandle: () => focusedHandle.value, + pickedHandle: () => pickedHandle.value, + rectanglePickedUp: () => rectanglePickedUp.value, + dragEditMode: () => dragEditMode.value, + }; + + return { + focusedHandle, + pickedHandle, + rectanglePickedUp, + focalPickedUp, + dragEditMode, + reset, + focusContext, + }; +} + +export type EditingState = ReturnType; + +/** + * Pointer and keyboard editing of the cropper and focal point. + * + * Pointer events are unified through the Pointer Events API, which covers mouse + * and touch in one set of handlers — the legacy editor bound `mouse*` and + * `touch*` pairs separately through jQuery. + */ +export function useEditorInteractions( + state: EditorState, + editing: EditingState, + canvas: ImageCanvas, + cropper: Cropper, + focalPoint: FocalPoint, + announcements: EditorAnnouncements +) { + /** Bound to the editor element, so the cursor stays a template concern. */ + const cursor = ref('default'); + + const previousPointer = ref({x: 0, y: 0}); + const pointerHandle = ref(null); + const focalClicked = ref(false); + const cropperClicked = ref(false); + const draggingFocal = ref(false); + const draggingCropper = ref(false); + const scalingCropper = ref(false); + + /** Pointer position relative to the cropping canvas's top-left. */ + function toCanvasPoint(event: PointerEvent): Point { + const rect = state.croppingCanvasEl.value?.getBoundingClientRect(); + + if (!rect) { + return {x: 0, y: 0}; + } + + return {x: event.clientX - rect.left, y: event.clientY - rect.top}; + } + + /** Whether a point is within a center-origin object's bounds. */ + function isOver( + point: Point, + object: {left: number; top: number; width: number; height: number} | null + ): boolean { + if (!object) { + return false; + } + + return ( + point.x >= object.left - object.width / 2 && + point.x <= object.left + object.width / 2 && + point.y >= object.top - object.height / 2 && + point.y <= object.top + object.height / 2 + ); + } + + function updateCursor(point: Point): void { + const handle = state.clipper.value + ? hitTestHandle(point, state.clipper.value) + : null; + + if (state.focalPoint.value && isOver(point, state.focalPoint.value)) { + cursor.value = 'pointer'; + } else if (handle) { + cursor.value = getCursorForHandle(handle); + } else if (isOver(point, state.clipper.value)) { + cursor.value = 'move'; + } else if (editing.focalPickedUp.value) { + cursor.value = 'grabbing'; + } else { + cursor.value = 'default'; + } + } + + /** Whether a press is currently driving the cropper or focal point. */ + function isDragging(): boolean { + return ( + focalClicked.value || cropperClicked.value || pointerHandle.value !== null + ); + } + + function onPointerDown(event: PointerEvent): void { + editing.dragEditMode.value = true; + + const point = toCanvasPoint(event); + + // Focal point wins over a resize handle, which wins over a drag. + const overFocal = + Boolean(state.focalPoint.value) && isOver(point, state.focalPoint.value); + const handle = state.clipper.value + ? hitTestHandle(point, state.clipper.value) + : null; + const overClipper = isOver(point, state.clipper.value); + + if (!overFocal && !handle && !overClipper) { + return; + } + + previousPointer.value = {x: event.clientX, y: event.clientY}; + + if (overFocal) { + focalClicked.value = true; + } else if (handle) { + pointerHandle.value = handle; + } else { + cropperClicked.value = true; + } + + // Captured *after* the drag state is set: taking capture dispatches + // boundary events, and `onPointerLeave` decides whether to bail by asking + // `isDragging()` — which has to already be true by then. + // + // Capture itself is what lets a drag stray outside the editor and keep + // delivering moves. Without it the gesture dies as the cursor crosses the + // edge, which is most drags: the handles sit on the rectangle's border and + // the rectangle starts at the image's. + (event.currentTarget as Element | null)?.setPointerCapture?.( + event.pointerId + ); + } + + function onPointerMove(event: PointerEvent): void { + const deltaX = event.clientX - previousPointer.value.x; + const deltaY = event.clientY - previousPointer.value.y; + + if (editing.dragEditMode.value) { + if (state.focalPoint.value && focalClicked.value) { + draggingFocal.value = true; + focalPoint.moveByDelta(deltaX, deltaY); + focalPoint.storeFocalPointState(); + canvas.renderImage(); + } else if (cropperClicked.value || pointerHandle.value) { + if (cropperClicked.value) { + draggingCropper.value = true; + // Silent: a drag reports continuously, which would flood the live + // region. The keyboard path announces instead. + cropper.moveByDelta(deltaX, deltaY, false); + } else if (pointerHandle.value) { + scalingCropper.value = true; + + // An edge handle only resizes along its own axis. + const constrainedX = + pointerHandle.value === 'b' || pointerHandle.value === 't' + ? 0 + : deltaX; + const constrainedY = + pointerHandle.value === 'l' || pointerHandle.value === 'r' + ? 0 + : deltaY; + + if (constrainedX !== 0 || constrainedY !== 0) { + cropper.resizeByHandle( + pointerHandle.value, + {x: constrainedX, y: constrainedY}, + false + ); + } + } + + cropper.redrawElements(); + cropper.storeCropperState(); + canvas.renderCropper(); + } + } + + updateCursor(toCanvasPoint(event)); + previousPointer.value = {x: event.clientX, y: event.clientY}; + } + + function onPointerUp(event: PointerEvent): void { + const target = event.currentTarget as Element | null; + + if (target?.hasPointerCapture?.(event.pointerId)) { + target.releasePointerCapture(event.pointerId); + } + + if (focalClicked.value) { + // A click without a drag toggles the focal point's picked-up state. + if (!draggingFocal.value) { + editing.focalPickedUp.value = !editing.focalPickedUp.value; + focalPoint.setPickedUpStyles(editing.focalPickedUp.value); + } + } else if ( + editing.focalPickedUp.value && + !draggingFocal.value && + !draggingCropper.value && + !scalingCropper.value + ) { + // While picked up, clicking anywhere moves the focal point there. + focalPoint.moveTo(toCanvasPoint(event)); + } + + draggingCropper.value = false; + cropperClicked.value = false; + scalingCropper.value = false; + pointerHandle.value = null; + draggingFocal.value = false; + focalClicked.value = false; + } + + function onPointerLeave(event: PointerEvent): void { + // Mid-drag the pointer is captured, so leaving isn't the end of the + // gesture — only a real pointer-up is. Bailing here would cancel exactly + // the drags that need to travel past the edge. + if (isDragging()) { + return; + } + + updateCursor(toCanvasPoint(event)); + } + + /** The name announced for an element, matching its edit button's label. */ + function itemName(handle: FabricElementHandle, label?: string): string { + return label ?? handle; + } + + function pickUp(handle: FabricElementHandle, label?: string): void { + editing.reset(); + + if (handle === 'rectangle') { + editing.rectanglePickedUp.value = true; + announcements.announcePickUp( + itemName(handle, label), + state.clipper.value + ); + } else if (handle === 'focalpoint') { + editing.focalPickedUp.value = true; + announcements.announcePickUp( + itemName(handle, label), + state.focalPoint.value + ); + } else { + editing.pickedHandle.value = handle; + announcements.announcePickUp(itemName(handle, label), null); + } + + if (state.croppingCanvas.value) { + cropper.redrawElements(); + canvas.renderCropper(); + } + } + + function drop(handle: FabricElementHandle, label?: string): void { + const item = + handle === 'rectangle' + ? state.clipper.value + : handle === 'focalpoint' + ? // The marker is already gone by now, so report where it was. + state.previousFocalPoint.value + : null; + + editing.reset(); + announcements.announceDrop(itemName(handle, label), item); + + if (state.croppingCanvas.value) { + cropper.redrawElements(); + canvas.renderCropper(); + } + } + + /** + * Handles a click on one of the edit buttons: toggles the focal point, then + * picks the element up or puts it back down. + */ + function onEditButtonClick( + handle: FabricElementHandle, + pressed: boolean, + label?: string + ): void { + if (handle === 'focalpoint') { + focalPoint.toggle(); + } + + if (pressed) { + drop(handle, label); + } else { + editing.dragEditMode.value = false; + pickUp(handle, label); + } + } + + /** Moves whatever is currently picked up. */ + function nudge(direction: NudgeDirection): void { + const deltas = getDeltasFromDirection(direction); + + if (editing.rectanglePickedUp.value) { + cropper.moveByDelta(deltas.x, deltas.y); + cropper.redrawElements(); + cropper.storeCropperState(); + canvas.renderCropper(); + } else if (editing.pickedHandle.value) { + cropper.resizeByHandle(editing.pickedHandle.value, deltas); + canvas.renderCropper(); + } else if (editing.focalPickedUp.value) { + focalPoint.moveByDelta(deltas.x, deltas.y); + focalPoint.storeFocalPointState(); + canvas.renderImage(); + } + } + + function onEditButtonKeydown(event: KeyboardEvent): void { + const direction = NUDGE_KEYS[event.key]; + + if (!direction) { + return; + } + + const somethingPickedUp = + editing.rectanglePickedUp.value || + editing.pickedHandle.value !== null || + editing.focalPickedUp.value; + + if (!somethingPickedUp) { + return; + } + + editing.dragEditMode.value = false; + event.preventDefault(); + nudge(direction); + } + + /** + * Tracks which edit button has focus so the canvas can draw a matching + * outline, and clears the outlines once focus moves elsewhere. + */ + function onEditButtonFocus(handle: FabricElementHandle): void { + editing.focusedHandle.value = handle; + editing.reset(); + + if (state.croppingCanvas.value) { + cropper.redrawElements(); + canvas.renderCropper(); + } + } + + function onEditButtonBlur(): void { + editing.focusedHandle.value = null; + editing.reset(); + + if (state.croppingCanvas.value) { + cropper.redrawElements(); + canvas.renderCropper(); + } + } + + /** Shift locks the aspect ratio while dragging a corner. */ + function onKeyDown(event: KeyboardEvent): void { + if (event.key === 'Shift') { + state.shiftKeyHeld.value = true; + } + } + + function onKeyUp(event: KeyboardEvent): void { + if (event.key === 'Shift') { + state.shiftKeyHeld.value = false; + } + } + + return { + cursor, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerLeave, + onEditButtonClick, + onEditButtonKeydown, + onEditButtonFocus, + onEditButtonBlur, + onKeyDown, + onKeyUp, + nudge, + pickUp, + drop, + }; +} + +export type EditorInteractions = ReturnType; diff --git a/resources/js/modules/image-editor/useEditorState.test.ts b/resources/js/modules/image-editor/useEditorState.test.ts new file mode 100644 index 00000000000..1f1976aa07a --- /dev/null +++ b/resources/js/modules/image-editor/useEditorState.test.ts @@ -0,0 +1,189 @@ +import {expect, it} from 'vite-plus/test'; +import {arePointsInsideRectangle, getRectangleVertices} from './geometry'; +import {useEditorGeometry, useEditorState} from './useEditorState'; +import type {CropperState, Rectangle} from './types'; + +function makeEditor(width: number, height: number) { + const state = useEditorState( + { + animationDuration: 0, + allowDegreeFractions: false, + colors: { + white: '#fff', + black: '#000', + transparentBlack: 'rgba(0,0,0,0.8)', + transparent: 'rgba(0,0,0,0)', + accent: '#00f', + }, + }, + {imageCanvas: null, croppingCanvas: null, editor: null} + ); + + state.editorWidth.value = width; + state.editorHeight.value = height; + + return {state, geometry: useEditorGeometry(state)}; +} + +/** + * Where the cropping rectangle lands, mirroring `useCropper.restoreFromState()` + * — the image's centre plus the stored offset, scaled to the current zoom. + */ +function clipperFor( + cropperState: CropperState, + imageCentre: {x: number; y: number}, + scaledWidth: number, + zoom: number +): Rectangle { + const scale = (scaledWidth / cropperState.imageDimensions.width) * zoom; + const width = cropperState.width * scale; + const height = cropperState.height * scale; + + return { + left: imageCentre.x + cropperState.offsetX * scale - width / 2, + top: imageCentre.y + cropperState.offsetY * scale - height / 2, + width, + height, + }; +} + +/** + * The invariant the cropper depends on: the rectangle has to sit inside the + * image quad, because every drag is tested against it. Three separate bugs — + * a transition racing a resize, a clobbered measurement baseline, and a + * rectangle translated instead of re-derived — all surfaced as this being + * false, and as the cropper silently refusing to move. + */ +function clipperFitsImage(width: number, height: number, image: number) { + const {state, geometry} = makeEditor(width, height); + + state.originalWidth.value = image; + state.originalHeight.value = image; + + const dimensions = geometry.getScaledImageDimensions(); + state.zoomRatio.value = geometry.getZoomToFitRatio(dimensions); + + const cropperState: CropperState = { + offsetX: 0, + offsetY: 0, + width: dimensions.width, + height: dimensions.height, + imageDimensions: dimensions, + }; + + const quad = geometry.getImageVerticeCoords('fit'); + const clipper = clipperFor( + cropperState, + {x: width / 2, y: height / 2}, + dimensions.width, + state.zoomRatio.value + ); + + return {quad, clipper, geometry, state, dimensions}; +} + +it('keeps the cropping rectangle inside the image at any editor size', () => { + const sizes: Array<[number, number]> = [ + [1168, 574], + [1168, 514], + [1155, 670], + [600, 900], + [900, 600], + ]; + + for (const [w, h] of sizes) { + const {quad, clipper} = clipperFitsImage(w, h, 3000); + + expect(arePointsInsideRectangle(getRectangleVertices(clipper), quad)).toBe( + true + ); + } +}); + +it('keeps it inside after the editor resizes, which is what used to break', () => { + // The dialog opens short and settles taller. The rectangle is re-derived + // from the stored state, so it has to land on the image at the new size — + // translating it instead is what left it 30px above the image. + const before = clipperFitsImage(1168, 514, 3000); + + const cropperState: CropperState = { + offsetX: 0, + offsetY: 0, + width: before.dimensions.width, + height: before.dimensions.height, + imageDimensions: before.dimensions, + }; + + const {state, geometry} = makeEditor(1168, 574); + state.originalWidth.value = 3000; + state.originalHeight.value = 3000; + + const dimensions = geometry.getScaledImageDimensions(); + state.zoomRatio.value = geometry.getZoomToFitRatio(dimensions); + + const quad = geometry.getImageVerticeCoords('fit'); + const clipper = clipperFor( + cropperState, + {x: 1168 / 2, y: 574 / 2}, + dimensions.width, + state.zoomRatio.value + ); + + expect(arePointsInsideRectangle(getRectangleVertices(clipper), quad)).toBe( + true + ); +}); + +it('leaves room around the image for the cropper handles', () => { + // The handles are drawn outside the rectangle; with the image flush to the + // canvas edge they were clipped away and half their grab area sat off-canvas. + const {quad} = clipperFitsImage(1155, 670, 3000); + + expect(quad.d.x).toBeGreaterThanOrEqual(4); + expect(quad.d.y).toBeGreaterThanOrEqual(4); + expect(quad.b.x).toBeLessThanOrEqual(1155 - 4); + expect(quad.b.y).toBeLessThanOrEqual(670 - 4); +}); + +it('never returns a zero or NaN zoom for an unmeasured editor', () => { + // A dialog's container has no size until it opens; `0 / 0` used to poison + // every measurement downstream with NaN. + const {state, geometry} = makeEditor(0, 0); + + state.originalWidth.value = 3000; + state.originalHeight.value = 2000; + + const dimensions = geometry.getScaledImageDimensions(); + + expect(Number.isFinite(geometry.getZoomToCoverRatio(dimensions))).toBe(true); + expect(Number.isFinite(geometry.getZoomToFitRatio(dimensions))).toBe(true); +}); + +it('frames the image the same whether or not the crop controls are open', () => { + // The inset that gives the cropper's handles room used to appear only while + // cropping, so opening the controls reframed the image on top of the zoom + // change — a visible lurch. The content box is the same in both views now. + const {state, geometry} = makeEditor(1168, 574); + + state.originalWidth.value = 4032; + state.originalHeight.value = 3024; + + const dimensions = geometry.getScaledImageDimensions(); + const content = geometry.getContentSize(); + + // Unstraightened, both zooms are 1, so the drawn size is the base size. + expect(geometry.getZoomToCoverRatio(dimensions)).toBeCloseTo(1, 10); + expect(geometry.getZoomToFitRatio(dimensions)).toBeCloseTo(1, 10); + + // And that base already sits inside the content box, handles included. + expect(dimensions.width).toBeLessThanOrEqual(content.width); + expect(dimensions.height).toBeLessThanOrEqual(content.height); +}); + +it('keeps the content box inset from the editor on both axes', () => { + const {geometry} = makeEditor(1168, 574); + const content = geometry.getContentSize(); + + expect(1168 - content.width).toBeGreaterThanOrEqual(8); + expect(574 - content.height).toBeGreaterThanOrEqual(8); +}); diff --git a/resources/js/modules/image-editor/useEditorState.ts b/resources/js/modules/image-editor/useEditorState.ts new file mode 100644 index 00000000000..3d894468310 --- /dev/null +++ b/resources/js/modules/image-editor/useEditorState.ts @@ -0,0 +1,412 @@ +import { + computed, + ref, + shallowRef, + toValue, + type MaybeRefOrGetter, + type Ref, + type ShallowRef, +} from 'vue'; +import type { + FabricCanvas, + FabricGroup, + FabricImage, + FabricObject, +} from './fabric'; +import type { + CropperState, + Dimensions, + EditorView, + FlipData, + FocalPointState, + VerticeCoords, +} from './types'; + +/** + * How much bigger the editor can get before the source image is refetched at a + * higher resolution rather than being upscaled. + */ +const RELOAD_THRESHOLD = 1.5; + +/** + * Breathing room kept around the image, in every view. + * + * The cropper's handles are drawn a few pixels *outside* the rectangle, and + * their keyboard focus rings reach ~18px past a corner. With the image zoomed + * flush to the canvas edge, a full-image crop puts its handles off-canvas — + * clipped from view, with their grab zones half outside the hit area. + * + * Applied everywhere rather than only while cropping: an inset that appears + * with the crop controls reframes the image the moment they open, on top of + * the zoom change, which reads as a lurch. Holding the same content box in + * every view leaves the switch as one zoom and nothing else. + */ +const CROP_HANDLE_MARGIN = 20; + +export interface EditorColors { + white: string; + black: string; + transparentBlack: string; + transparent: string; + accent: string; +} + +export interface EditorSettings { + animationDuration: number; + allowDegreeFractions: boolean; + colors: EditorColors; +} + +/** + * Every piece of mutable editor state, in one object passed to each feature + * composable. + * + * fabric objects live in `shallowRef`s deliberately: Vue's deep reactivity + * would proxy their internals and fabric mutates those on every render, so a + * deep ref both thrashes and misbehaves. Nothing renders off their contents — + * the canvas does — so shallow is also all we need. + */ +export interface EditorState { + settings: EditorSettings; + + // Canvas elements, owned by the component and read-only here. + imageCanvasEl: Readonly>; + croppingCanvasEl: Readonly>; + editorEl: Readonly>; + + // fabric objects. + canvas: ShallowRef; + croppingCanvas: ShallowRef; + image: ShallowRef; + viewport: ShallowRef; + focalPoint: ShallowRef; + previousFocalPoint: ShallowRef; + focalPointPickedIndicator: ShallowRef; + grid: ShallowRef; + clipper: ShallowRef; + croppingShade: ShallowRef; + croppingRectangle: ShallowRef; + cropperHandles: ShallowRef; + cropperGrid: ShallowRef; + handleFocusIndicator: ShallowRef; + moveIcon: ShallowRef; + + // Image state. + originalWidth: Ref; + originalHeight: Ref; + imageStraightenAngle: Ref; + viewportRotation: Ref; + zoomRatio: Ref; + scaleFactor: Ref; + flipData: Ref; + imageVerticeCoords: ShallowRef; + lastLoadedDimensions: ShallowRef; + + // Editor state. + editorWidth: Ref; + editorHeight: Ref; + currentView: Ref; + animationInProgress: Ref; + imageIsLoading: Ref; + cropperState: ShallowRef; + focalPointState: ShallowRef; + croppingConstraint: Ref; + shiftKeyHeld: Ref; +} + +/** + * The elements the editor draws into. The component owns these — it's the one + * with the template — and hands them over, so the editor never reaches into + * the DOM to find them. + */ +export interface EditorElements { + imageCanvas: MaybeRefOrGetter; + croppingCanvas: MaybeRefOrGetter; + editor: MaybeRefOrGetter; +} + +export function useEditorState( + settings: EditorSettings, + elements: EditorElements +): EditorState { + return { + settings, + + imageCanvasEl: computed(() => toValue(elements.imageCanvas) ?? null), + croppingCanvasEl: computed(() => toValue(elements.croppingCanvas) ?? null), + editorEl: computed(() => toValue(elements.editor) ?? null), + + canvas: shallowRef(null), + croppingCanvas: shallowRef(null), + image: shallowRef(null), + viewport: shallowRef(null), + focalPoint: shallowRef(null), + previousFocalPoint: shallowRef(null), + focalPointPickedIndicator: shallowRef(null), + grid: shallowRef(null), + clipper: shallowRef(null), + croppingShade: shallowRef(null), + croppingRectangle: shallowRef(null), + cropperHandles: shallowRef(null), + cropperGrid: shallowRef(null), + handleFocusIndicator: shallowRef(null), + moveIcon: shallowRef(null), + + originalWidth: ref(0), + originalHeight: ref(0), + imageStraightenAngle: ref(0), + viewportRotation: ref(0), + zoomRatio: ref(1), + scaleFactor: ref(1), + flipData: ref({x: 0, y: 0}), + imageVerticeCoords: shallowRef(null), + lastLoadedDimensions: shallowRef(null), + + editorWidth: ref(0), + editorHeight: ref(0), + currentView: ref('rotate'), + animationInProgress: ref(false), + imageIsLoading: ref(false), + cropperState: shallowRef(null), + focalPointState: shallowRef(null), + croppingConstraint: ref(false), + shiftKeyHeld: ref(false), + }; +} + +/** + * The geometry that falls out of the current state. Kept as plain functions + * rather than computeds because most read fabric objects, which aren't + * reactive — a computed would cache against dependencies that never invalidate. + */ +export function useEditorGeometry(state: EditorState) { + /** True once the image has been rotated onto its side. */ + function hasOrientationChanged(): boolean { + return state.viewportRotation.value % 180 !== 0; + } + + /** + * The area the image is laid out within: the editor, less the room the + * cropper's handles need outside the rectangle. + * + * The same in every view, so moving between them changes the zoom and + * nothing else. + */ + function getContentSize(): Dimensions { + return { + width: Math.max(state.editorWidth.value - CROP_HANDLE_MARGIN * 2, 1), + height: Math.max(state.editorHeight.value - CROP_HANDLE_MARGIN * 2, 1), + }; + } + + /** + * The size the image occupies in the editor with no straightening or rotation + * applied — the basis every other measurement is expressed against. + */ + /** + * The scale that draws the image at a given width on screen. + * + * fabric sizes an image by scaling it: `width` and `height` are the natural + * pixel dimensions of the file that was loaded, not what ends up on screen. + * Everything else here works in displayed pixels, so this is the conversion + * between the two. In fabric 1.x the image's width *was* the displayed + * width, and setting it was how the editor zoomed -- do that now and the + * image renders at full resolution inside a box the size of the viewport. + */ + function getImageScaleFor(displayedWidth: number): number { + const natural = state.originalWidth.value; + + return natural > 0 ? displayedWidth / natural : 1; + } + + function getScaledImageDimensions(): Dimensions { + const originalWidth = state.originalWidth.value; + const originalHeight = state.originalHeight.value; + const {width: availableWidth, height: availableHeight} = getContentSize(); + + if (originalHeight / originalWidth > availableHeight / availableWidth) { + const height = Math.min(availableHeight, originalHeight); + + return { + height, + width: Math.round(originalWidth / (originalHeight / height)), + }; + } + + const width = Math.min(availableWidth, originalWidth); + + return { + width, + height: Math.round(originalHeight * (width / originalWidth)), + }; + } + + /** The zoom needed for a straightened image to still cover its viewport. */ + function getZoomToCoverRatio(dimensions: Dimensions): number { + // Guards the divisions below: an unmeasured editor yields 0x0 dimensions, + // and `0 / 0` would hand back NaN for every size derived from this. + if (!dimensions.width || !dimensions.height) { + return 1; + } + + const radians = + Math.abs(state.imageStraightenAngle.value) * (Math.PI / 180); + + const scaledWidth = + Math.sin(radians) * dimensions.height + + Math.cos(radians) * dimensions.width; + const scaledHeight = + Math.sin(radians) * dimensions.width + + Math.cos(radians) * dimensions.height; + + return Math.max( + scaledWidth / dimensions.width, + scaledHeight / dimensions.height + ); + } + + /** The axis-aligned box a straightened image needs, before any zooming. */ + function getImageBoundingBox(dimensions: Dimensions): Dimensions { + const radians = + Math.abs(state.imageStraightenAngle.value) * (Math.PI / 180); + const proportion = dimensions.height / dimensions.width; + + const box = { + height: + dimensions.width * (Math.sin(radians) + Math.cos(radians) * proportion), + width: + dimensions.width * (Math.cos(radians) + Math.sin(radians) * proportion), + }; + + return hasOrientationChanged() + ? {width: box.height, height: box.width} + : box; + } + + /** The zoom needed for the whole straightened image to fit on screen. */ + function getZoomToFitRatio(dimensions: Dimensions): number { + const boundingBox = getImageBoundingBox(dimensions); + + if (!boundingBox.width || !boundingBox.height) { + return 1; + } + + // The inset now lives in `getScaledImageDimensions`, so the base size is + // already within the content box; this only has to answer whether the + // straightened bounding box still fits. + const {width: availableWidth, height: availableHeight} = getContentSize(); + + if ( + boundingBox.height <= availableHeight && + boundingBox.width <= availableWidth + ) { + return 1; + } + + return Math.min( + availableWidth / boundingBox.width, + availableHeight / boundingBox.height + ); + } + + function getCombinedZoomRatio(dimensions: Dimensions): number { + return getZoomToCoverRatio(dimensions) / getZoomToFitRatio(dimensions); + } + + /** + * The image's four corners at a given zoom, accounting for both the + * straightening angle and any 90° rotation. `zoomMode` is 'cover', 'fit', or + * an explicit ratio. + */ + function getImageVerticeCoords( + zoomMode: 'cover' | 'fit' | number + ): VerticeCoords { + const radians = + -1 * + ((hasOrientationChanged() ? 90 : 0) + state.imageStraightenAngle.value) * + (Math.PI / 180); + + const dimensions = getScaledImageDimensions(); + + const ratio = + typeof zoomMode === 'number' + ? zoomMode + : zoomMode === 'cover' + ? getZoomToCoverRatio(dimensions) + : getZoomToFitRatio(dimensions); + + const scaledHeight = dimensions.height * ratio; + const scaledWidth = dimensions.width * ratio; + + // The segments of the box containing the rotated image, projected onto its + // right and bottom edges. + const topVertical = Math.cos(radians) * scaledHeight; + const bottomVertical = Math.sin(radians) * scaledWidth; + const rightHorizontal = Math.cos(radians) * scaledWidth; + const leftHorizontal = Math.sin(radians) * scaledHeight; + + const verticalOffset = + (state.editorHeight.value - (topVertical + bottomVertical)) / 2; + const horizontalOffset = + (state.editorWidth.value - (leftHorizontal + rightHorizontal)) / 2; + + return { + a: {x: horizontalOffset + rightHorizontal, y: verticalOffset}, + b: { + x: state.editorWidth.value - horizontalOffset, + y: verticalOffset + topVertical, + }, + c: { + x: horizontalOffset + leftHorizontal, + y: state.editorHeight.value - verticalOffset, + }, + d: {x: horizontalOffset, y: verticalOffset + bottomVertical}, + }; + } + + /** Caches the corners of the image as zoomed to fit, for containment tests. */ + function setFittedImageVerticeCoordinates(): void { + state.imageVerticeCoords.value = getImageVerticeCoords('fit'); + } + + /** The editor center, in editor-space. */ + function getEditorCenter() { + return { + x: state.editorWidth.value / 2, + y: state.editorHeight.value / 2, + }; + } + + /** Whether the editor has grown enough to warrant refetching a larger image. */ + function needsHigherResolution(): boolean { + const last = state.lastLoadedDimensions.value; + + if (!last) { + return false; + } + + const current = getScaledImageDimensions(); + + return ( + current.width / last.width > RELOAD_THRESHOLD || + current.height / last.height > RELOAD_THRESHOLD + ); + } + + return { + hasOrientationChanged, + getContentSize, + getImageScaleFor, + getScaledImageDimensions, + getZoomToCoverRatio, + getImageBoundingBox, + getZoomToFitRatio, + getCombinedZoomRatio, + getImageVerticeCoords, + setFittedImageVerticeCoordinates, + getEditorCenter, + needsHigherResolution, + }; +} + +export type EditorGeometry = ReturnType; diff --git a/resources/js/modules/image-editor/useFocalPoint.ts b/resources/js/modules/image-editor/useFocalPoint.ts new file mode 100644 index 00000000000..ea8f66bb112 --- /dev/null +++ b/resources/js/modules/image-editor/useFocalPoint.ts @@ -0,0 +1,356 @@ +import {Circle, Group, type FabricGroup} from './fabric'; +import { + arePointsInsideRectangle, + isCenterInside, + rotatePoint, +} from './geometry'; +import type {FocalPointState, Point} from './types'; +import type {EditorAnnouncements} from './useEditorAnnouncements'; +import type {EditorGeometry, EditorState} from './useEditorState'; +import type {ImageCanvas} from './useImageCanvas'; + +/** + * The focal point marker: the spot transforms crop around when Craft generates + * a smaller version of the image. + * + * Its position is stored as an offset from the image center at a zoom ratio of + * 1 (`focalPointState`), so it survives zooming, straightening and rotation; + * the on-canvas circle is derived from that offset whenever anything moves. + */ +export function useFocalPoint( + state: EditorState, + geometry: EditorGeometry, + canvas: ImageCanvas, + announcements: EditorAnnouncements +) { + /** + * Captures the marker's current canvas position back into zoom-independent + * state, or stores a state passed in wholesale. + */ + function storeFocalPointState(next?: FocalPointState): void { + if (next) { + state.focalPointState.value = next; + return; + } + + const focalPoint = state.focalPoint.value; + const image = state.image.value; + + if (!focalPoint || !image) { + return; + } + + const zoomFactor = 1 / state.zoomRatio.value; + + state.focalPointState.value = { + offsetX: + ((focalPoint.left - image.left) * zoomFactor) / state.scaleFactor.value, + offsetY: + ((focalPoint.top - image.top) * zoomFactor) / state.scaleFactor.value, + imageDimensions: geometry.getScaledImageDimensions(), + }; + } + + /** Puts the stored offset back at the image center. */ + function resetPosition(): void { + const focalState = state.focalPointState.value; + + if (focalState) { + storeFocalPointState({...focalState, offsetX: 0, offsetY: 0}); + } + } + + /** Builds the marker: a dark disc, a white ring, and a pick-up halo. */ + function buildMarker(left: number, top: number): FabricGroup { + const pickedIndicator = new Circle({ + radius: 12, + strokeWidth: 0, + stroke: 'rgba(255,255,255,0.8)', + left: 0, + top: 0, + originX: 'center', + originY: 'center', + }); + + const outerCircle = new Circle({ + radius: 8, + fill: 'rgba(0,0,0,0.5)', + strokeWidth: 2, + stroke: 'rgba(255,255,255,0.8)', + left: 0, + top: 0, + originX: 'center', + originY: 'center', + }); + + const innerCircle = new Circle({ + radius: 1, + fill: 'rgba(255,255,255,0)', + strokeWidth: 2, + stroke: 'rgba(255,255,255,0.8)', + left: 0, + top: 0, + originX: 'center', + originY: 'center', + }); + + state.focalPointPickedIndicator.value = pickedIndicator; + + return new Group([pickedIndicator, outerCircle, innerCircle], { + originX: 'center', + originY: 'center', + left, + top, + }); + } + + function create(): void { + const focalState = state.focalPointState.value; + const image = state.image.value; + + if (!focalState || !image) { + return; + } + + const scaled = geometry.getScaledImageDimensions(); + const sizeFactor = scaled.width / focalState.imageDimensions.width; + const perOffsetPixel = + sizeFactor * state.zoomRatio.value * state.scaleFactor.value; + + let focalX = focalState.offsetX * perOffsetPixel + image.left; + let focalY = focalState.offsetY * perOffsetPixel + image.top; + + const next = {...focalState}; + + // A fresh focal point lands in the middle of what the user can actually + // see — the cropper while cropping, the viewport otherwise — rather than + // the middle of an image that may be panned off-screen. + if (next.offsetX === 0 && next.offsetY === 0) { + const anchor = + state.currentView.value === 'crop' + ? state.clipper.value + : state.viewport.value; + + if (anchor) { + const deltaX = anchor.left - image.left; + const deltaY = anchor.top - image.top; + + focalX += deltaX; + focalY += deltaY; + + next.offsetX += deltaX / perOffsetPixel; + next.offsetY += deltaY / perOffsetPixel; + } + } + + state.focalPoint.value = buildMarker(focalX, focalY); + storeFocalPointState(next); + state.canvas.value?.add(state.focalPoint.value); + } + + function toggle(): void { + if (state.focalPoint.value) { + // Held onto so the drop announcement can still report where it was. + state.previousFocalPoint.value = state.focalPoint.value; + state.canvas.value?.remove(state.focalPoint.value); + state.focalPoint.value = null; + } else { + create(); + state.previousFocalPoint.value = null; + } + + canvas.renderImage(); + } + + /** + * Swings the marker around the image center by an angle, so it stays on the + * same part of the picture when the image rotates. + */ + function adjustByAngle(angle: number): void { + const focalState = state.focalPointState.value; + const focalPoint = state.focalPoint.value; + const image = state.image.value; + + if (!focalState || !focalPoint || !image) { + return; + } + + const rotated = rotatePoint( + {x: focalState.offsetX, y: focalState.offsetY}, + angle + ); + + const sizeFactor = + geometry.getScaledImageDimensions().width / + focalState.imageDimensions.width; + + focalPoint.left = + image.left + rotated.x * sizeFactor * state.zoomRatio.value; + focalPoint.top = image.top + rotated.y * sizeFactor * state.zoomRatio.value; + + storeFocalPointState({ + ...focalState, + offsetX: rotated.x, + offsetY: rotated.y, + }); + } + + /** + * Puts the marker back on the part of the image it belongs to. + * + * Derived from the stored offset rather than shifted by how much the editor + * changed: the offset is held relative to the image at a zoom of 1, so this + * lands on the same spot in the picture whatever the editor did. Must run + * after the image has taken its new position and size, since it reads both. + */ + function positionFromState(): void { + const focalState = state.focalPointState.value; + const focalPoint = state.focalPoint.value; + const image = state.image.value; + + if (!focalState || !focalPoint || !image) { + return; + } + + const sizeFactor = + geometry.getScaledImageDimensions().width / + focalState.imageDimensions.width; + + focalPoint.left = + image.left + focalState.offsetX * sizeFactor * state.zoomRatio.value; + focalPoint.top = + image.top + focalState.offsetY * sizeFactor * state.zoomRatio.value; + } + + /** + * Positions the marker and puts it back on the canvas, for a transition that + * lifted it off. Only for that case — `add()` appends unconditionally, so + * calling this on every resize would stack up duplicates. + */ + function restoreFromState(): void { + if (!state.focalPoint.value) { + return; + } + + positionFromState(); + state.canvas.value?.add(state.focalPoint.value); + } + + /** Whether a point falls within the unclipped region. */ + function isPointInsideViewport(point: Point): boolean { + const viewport = state.viewport.value; + + if (!viewport) { + return false; + } + + return ( + viewport.left - viewport.width / 2 - point.x < 0 && + viewport.left + viewport.width / 2 - point.x > 0 && + viewport.top - viewport.height / 2 - point.y < 0 && + viewport.top + viewport.height / 2 - point.y > 0 + ); + } + + /** + * Whether the marker may sit at a point — bounded by the image while + * cropping (where the whole image is visible) and by the viewport otherwise. + */ + function canMoveTo(point: Point): boolean { + if (state.currentView.value === 'crop') { + return state.imageVerticeCoords.value + ? arePointsInsideRectangle([point], state.imageVerticeCoords.value) + : false; + } + + return isPointInsideViewport(point); + } + + function moveByDelta(deltaX: number, deltaY: number): void { + const focalPoint = state.focalPoint.value; + + if (!focalPoint || (deltaX === 0 && deltaY === 0)) { + return; + } + + const target = {x: focalPoint.left + deltaX, y: focalPoint.top + deltaY}; + + if (!canMoveTo(target)) { + return; + } + + focalPoint.set({left: target.x, top: target.y}); + announcements.announcePosition(focalPoint); + } + + /** Jumps the marker to a clicked point, if that point is in bounds. */ + function moveTo(point: Point): void { + const focalPoint = state.focalPoint.value; + + if (!focalPoint || !canMoveTo(point)) { + return; + } + + focalPoint.set({left: point.x, top: point.y}); + storeFocalPointState(); + canvas.renderImage(); + } + + /** Dims the marker once straightening has pushed it out of the viewport. */ + function updateVisibilityForViewport(): void { + const focalPoint = state.focalPoint.value; + const viewport = state.viewport.value; + + if (!focalPoint || !viewport) { + return; + } + + focalPoint.set({opacity: isCenterInside(focalPoint, viewport) ? 1 : 0}); + } + + /** + * Drops a marker that straightening pushed outside the viewport, rather than + * leaving an invisible focal point behind. + */ + function cleanupAfterStraighten(): void { + const focalPoint = state.focalPoint.value; + const viewport = state.viewport.value; + + if (!focalPoint || !viewport || isCenterInside(focalPoint, viewport)) { + return; + } + + focalPoint.set({opacity: 1}); + resetPosition(); + toggle(); + } + + /** Swaps the halo in and out as the marker is picked up and dropped. */ + function setPickedUpStyles(pickedUp: boolean): void { + state.focalPointPickedIndicator.value?.set({ + strokeWidth: pickedUp ? 2 : 0, + fill: pickedUp ? 'rgba(0,0,0,0.5)' : state.settings.colors.transparent, + }); + + state.canvas.value?.renderAll(); + } + + return { + storeFocalPointState, + resetPosition, + create, + toggle, + positionFromState, + adjustByAngle, + restoreFromState, + isPointInsideViewport, + canMoveTo, + moveByDelta, + moveTo, + updateVisibilityForViewport, + cleanupAfterStraighten, + setPickedUpStyles, + }; +} + +export type FocalPoint = ReturnType; diff --git a/resources/js/modules/image-editor/useImageCanvas.ts b/resources/js/modules/image-editor/useImageCanvas.ts new file mode 100644 index 00000000000..ef36bcb428c --- /dev/null +++ b/resources/js/modules/image-editor/useImageCanvas.ts @@ -0,0 +1,266 @@ +import {Rect, StaticCanvas, loadImage, type FabricImage} from './fabric'; +import type {EditorGeometry, EditorState} from './useEditorState'; +import type {Dimensions} from './types'; + +/** + * The canvas layer: creating the fabric canvases, keeping the image and + * viewport sized and centered, and scheduling renders. + * + * The viewport is a filled rectangle drawn with `destination-in`, so it acts as + * a mask — everything outside it is clipped away. That's how the editor shows a + * cropped region without actually cropping the image. + */ +export function useImageCanvas(state: EditorState, geometry: EditorGeometry) { + let imageFrame: number | null = null; + let cropperFrame: number | null = null; + + /** + * Renders are coalesced to one per frame: drags and animations can each ask + * to render several times before the browser paints once. + */ + function renderImage(): void { + if (imageFrame !== null) { + return; + } + + imageFrame = requestAnimationFrame(() => { + imageFrame = null; + state.canvas.value?.renderAll(); + }); + } + + function renderCropper(): void { + if (cropperFrame !== null || !state.croppingCanvas.value) { + return; + } + + cropperFrame = requestAnimationFrame(() => { + cropperFrame = null; + state.croppingCanvas.value?.renderAll(); + }); + } + + function cancelPendingRenders(): void { + if (imageFrame !== null) { + cancelAnimationFrame(imageFrame); + imageFrame = null; + } + + if (cropperFrame !== null) { + cancelAnimationFrame(cropperFrame); + cropperFrame = null; + } + } + + /** The largest image worth requesting for the current viewport. */ + function getMaxImageSize(): number { + const {clientWidth, clientHeight} = document.documentElement; + + return ( + Math.max(clientHeight, clientWidth) * + (window.devicePixelRatio > 1 ? 2 : 1) + ); + } + + function measureEditor(): void { + const el = state.editorEl.value; + + if (el) { + state.editorWidth.value = el.clientWidth; + state.editorHeight.value = el.clientHeight; + } + } + + /** Creates the main canvas and loads the image onto it, centered. */ + async function createCanvas(imageUrl: string): Promise { + const canvasEl = state.imageCanvasEl.value; + + if (!canvasEl) { + throw new Error('The image canvas is not mounted.'); + } + + const canvas = new StaticCanvas(canvasEl); + canvas.enableRetinaScaling = true; + state.canvas.value = canvas; + + const image = await loadImage(imageUrl); + + image.set({ + originX: 'center', + originY: 'center', + left: state.editorWidth.value / 2, + top: state.editorHeight.value / 2, + }); + + canvas.add(image); + + state.image.value = image; + state.originalWidth.value = image.width; + state.originalHeight.value = image.height; + state.zoomRatio.value = 1; + state.lastLoadedDimensions.value = geometry.getScaledImageDimensions(); + + return image; + } + + /** + * Refetches the image at a higher resolution once the editor has grown enough + * that the current one would visibly soften. + */ + function reloadImage(imageUrl: string, onLoaded: () => void): void { + const image = state.image.value; + + if (state.imageIsLoading.value || !image) { + return; + } + + state.imageIsLoading.value = true; + + // fabric 7 resolves rather than calling back, and hands back the same + // object it was called on -- so the new size is read off `image` itself. + void image.setSrc(imageUrl).then(() => { + state.originalWidth.value = image.width; + state.originalHeight.value = image.height; + state.lastLoadedDimensions.value = { + width: state.originalWidth.value, + height: state.originalHeight.value, + }; + state.imageIsLoading.value = false; + onLoaded(); + }); + } + + /** Creates the mask that clips the image down to the cropped region. */ + function createViewport(): void { + const image = state.image.value; + const canvas = state.canvas.value; + + if (!image || !canvas) { + return; + } + + const viewport = new Rect({ + width: image.getScaledWidth(), + height: image.getScaledHeight(), + fill: 'rgba(127,0,0,1)', + originX: 'center', + originY: 'center', + // Clips away everything drawn outside this rectangle. + globalCompositeOperation: 'destination-in', + left: image.left, + top: image.top, + }); + + state.viewport.value = viewport; + canvas.add(viewport); + renderImage(); + } + + /** Sizes the image to the current zoom ratio. */ + function zoomImage(): void { + const dimensions = geometry.getScaledImageDimensions(); + const scale = geometry.getImageScaleFor( + dimensions.width * state.zoomRatio.value + ); + + state.image.value?.set({scaleX: scale, scaleY: scale}); + } + + /** + * Keeps the image's offset from center intact as the editor resizes, so a + * panned image doesn't jump when the window changes. + */ + function repositionImage(previous: Dimensions): void { + const image = state.image.value; + + if (!image) { + return; + } + + image.set({ + left: image.left - (previous.width - state.editorWidth.value) / 2, + top: image.top - (previous.height - state.editorHeight.value) / 2, + }); + } + + /** + * Resizes the viewport mask. While cropping it covers the whole editor (the + * cropper layer draws the shade instead); otherwise it takes the stored + * cropper's size and the image slides so the right region shows through. + */ + function repositionViewport(): void { + const viewport = state.viewport.value; + const image = state.image.value; + + if (!viewport || !image) { + return; + } + + const dimensions: Record = { + left: state.editorWidth.value / 2, + top: state.editorHeight.value / 2, + }; + + if (state.currentView.value === 'crop') { + dimensions.width = state.editorWidth.value; + dimensions.height = state.editorHeight.value; + } else if (state.cropperState.value) { + const cropperState = state.cropperState.value; + const scaled = geometry.getScaledImageDimensions(); + const sizeFactor = scaled.width / cropperState.imageDimensions.width; + + dimensions.width = + cropperState.width * sizeFactor * state.zoomRatio.value; + dimensions.height = + cropperState.height * sizeFactor * state.zoomRatio.value; + + image.set({ + left: state.editorWidth.value / 2 - cropperState.offsetX * sizeFactor, + top: state.editorHeight.value / 2 - cropperState.offsetY * sizeFactor, + }); + } else { + Object.assign(dimensions, geometry.getScaledImageDimensions()); + } + + viewport.set(dimensions); + } + + /** Matches the fabric canvases to the editor element's current size. */ + function resizeCanvases(): void { + const dimensions = { + width: state.editorWidth.value, + height: state.editorHeight.value, + }; + + state.canvas.value?.setDimensions(dimensions); + state.croppingCanvas.value?.setDimensions(dimensions); + } + + function destroy(): void { + cancelPendingRenders(); + // Unmounting, so there is nothing left to wait for the deferred half of + // these to finish tidying -- see the note in `useCropper.hide()`. + void state.croppingCanvas.value?.dispose(); + void state.canvas.value?.dispose(); + state.croppingCanvas.value = null; + state.canvas.value = null; + } + + return { + renderImage, + renderCropper, + cancelPendingRenders, + getMaxImageSize, + measureEditor, + createCanvas, + reloadImage, + createViewport, + zoomImage, + repositionImage, + repositionViewport, + resizeCanvases, + destroy, + }; +} + +export type ImageCanvas = ReturnType; diff --git a/resources/js/modules/image-editor/useImageEditor.ts b/resources/js/modules/image-editor/useImageEditor.ts new file mode 100644 index 00000000000..2722d8b864e --- /dev/null +++ b/resources/js/modules/image-editor/useImageEditor.ts @@ -0,0 +1,852 @@ +import {computed, nextTick, onBeforeUnmount, ref, watch} from 'vue'; +import {useEventListener, useResizeObserver} from '@vueuse/core'; +import {t} from '@craftcms/ui'; +import {useHelpers} from '@/common/composables/useCraftData'; +import {useActionClient} from '@/common/composables/useFetch'; +import {useFlashMessages} from '@/common/composables/useFlashMessages'; +import {loadSvg, type FabricAnimatable} from './fabric'; +import {useCropper} from './useCropper'; +import { + useCroppingConstraint, + type ConstraintValue, +} from './useCroppingConstraint'; +import {useEditorAnnouncements} from './useEditorAnnouncements'; +import {useEditingState, useEditorInteractions} from './useEditorInteractions'; +import { + useEditorGeometry, + useEditorState, + type EditorElements, + type EditorSettings, +} from './useEditorState'; +import {useFocalPoint} from './useFocalPoint'; +import {useImageCanvas} from './useImageCanvas'; +import {useImageTransforms} from './useImageTransforms'; +import type {Dimensions, EditorView, RelativeFocalPoint} from './types'; + +export interface ImageEditorOptions { + assetId: number; + focalPoint: RelativeFocalPoint | null; + /** Whether the browser's image driver supports fractional rotation. */ + allowDegreeFractions?: boolean; + /** The canvases and container the editor draws into. */ + elements: EditorElements; +} + +export interface SaveResult { + newAssetId?: number; + newAssetUrl?: string; +} + +/** `replace` overwrites the asset's file; `copy` saves the result alongside it. */ +export type SaveMode = 'replace' | 'copy'; + +function defaultSettings( + allowDegreeFractions: boolean, + prefersReducedMotion: boolean +): EditorSettings { + const styles = window.getComputedStyle(document.documentElement); + + return { + animationDuration: prefersReducedMotion ? 1 : 100, + allowDegreeFractions, + colors: { + white: 'rgb(255, 255, 255)', + black: 'rgb(0, 0, 0)', + transparentBlack: 'rgba(0, 0, 0, 0.8)', + transparent: 'rgba(0,0,0,0)', + accent: styles.getPropertyValue('--blue-500') || 'rgb(59, 130, 246)', + }, + }; +} + +/** + * The image editor, assembled. + * + * Owns the parts no single feature does: loading the image, keeping everything + * sized to the editor element, moving between the rotate and crop views, and + * saving. Everything else is delegated to the composable that owns it. + */ +export function useImageEditor(options: ImageEditorOptions) { + const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ).matches; + + const state = useEditorState( + defaultSettings( + options.allowDegreeFractions ?? false, + prefersReducedMotion + ), + options.elements + ); + + const geometry = useEditorGeometry(state); + const canvas = useImageCanvas(state, geometry); + const announcements = useEditorAnnouncements(state); + const editing = useEditingState(); + const focalPoint = useFocalPoint(state, geometry, canvas, announcements); + const cropper = useCropper( + state, + geometry, + canvas, + announcements, + editing.focusContext + ); + const constraint = useCroppingConstraint(state, cropper, canvas); + const transforms = useImageTransforms( + state, + geometry, + canvas, + cropper, + focalPoint + ); + const interactions = useEditorInteractions( + state, + editing, + canvas, + cropper, + focalPoint, + announcements + ); + + const helpers = useHelpers(); + const {flash} = useFlashMessages(); + + const isReady = ref(false); + /** + * Which save is running, rather than a single flag — the two buttons post the + * same edits to the same asset, so only one runs at a time, but each spins on + * its own. + */ + const savingAs = ref(null); + const isSaving = computed(() => savingAs.value !== null); + /** Bumped on save so a reloaded image isn't served from cache. */ + const cacheBust = ref(Date.now()); + + /** + * Mode transitions animate, so overlapping ones would fight. They're chained + * onto a single promise rather than run concurrently. + */ + let transitionChain: Promise = Promise.resolve(); + + function enqueue(work: () => void): void { + transitionChain = transitionChain.then( + () => + new Promise((resolve) => { + work(); + resolve(); + }) + ); + } + + /** + * `getActionUrl()` can already carry a query string (`?site=…`), so the + * params go on through `searchParams` rather than being concatenated behind + * a second `?`. + */ + function imageUrl(): string { + const url = new URL(helpers.getActionUrl('assets/edit-image')); + + url.searchParams.set('assetId', String(options.assetId)); + url.searchParams.set('size', String(canvas.getMaxImageSize())); + url.searchParams.set('cacheBust', String(cacheBust.value)); + + return url.toString(); + } + + /** + * Re-lays out everything after the editor element changes size. Order + * matters: the zoom ratio has to settle before anything is repositioned + * against it. + */ + function updateSizeAndPosition(): void { + if (!state.image.value || !state.editorEl.value) { + return; + } + + const previous: Dimensions = { + width: state.editorWidth.value, + height: state.editorHeight.value, + }; + + canvas.measureEditor(); + canvas.resizeCanvases(); + + const cropping = state.currentView.value === 'crop'; + + if (cropping) { + state.zoomRatio.value = geometry.getZoomToFitRatio( + geometry.getScaledImageDimensions() + ); + + geometry.setFittedImageVerticeCoordinates(); + } else { + state.zoomRatio.value = + geometry.getZoomToCoverRatio(geometry.getScaledImageDimensions()) * + state.scaleFactor.value; + } + + canvas.repositionImage(previous); + canvas.repositionViewport(); + canvas.zoomImage(); + + // Only now that the image has taken its new position and size: both of + // these hold their place relative to the image, so they read it rather than + // being shifted by how much the editor changed. Nudging them beforehand + // measured them against where the image used to be, which is why they + // drifted away from it on every resize. + if (cropping) { + cropper.reposition(); + } + + focalPoint.positionFromState(); + + canvas.renderImage(); + + if (geometry.needsHigherResolution()) { + canvas.reloadImage(imageUrl(), updateSizeAndPosition); + } + } + + /** Animates the image and viewport between the rotate and crop layouts. */ + function transitionMode( + imageProperties: FabricAnimatable, + viewportProperties: FabricAnimatable, + onComplete: () => void + ): void { + const image = state.image.value; + const viewport = state.viewport.value; + + if (state.animationInProgress.value || !image || !viewport) { + return; + } + + state.animationInProgress.value = true; + + // The marker looks broken mid-animation, so it's lifted off and put back. + if (state.focalPoint.value) { + state.canvas.value?.remove(state.focalPoint.value); + canvas.renderImage(); + } + + image.animate(imageProperties, { + duration: state.settings.animationDuration, + onChange: () => state.canvas.value?.renderAll(), + onComplete: () => { + onComplete(); + state.animationInProgress.value = false; + canvas.renderImage(); + }, + }); + + viewport.animate(viewportProperties, { + duration: state.settings.animationDuration, + }); + } + + /** Zooms the whole image into view and puts the cropping rectangle back. */ + function enableCropMode(): void { + const dimensions = geometry.getScaledImageDimensions(); + state.zoomRatio.value = geometry.getZoomToFitRatio(dimensions); + + const fitScale = geometry.getImageScaleFor( + dimensions.width * state.zoomRatio.value + ); + + transitionMode( + { + scaleX: fitScale, + scaleY: fitScale, + left: state.editorWidth.value / 2, + top: state.editorHeight.value / 2, + }, + {width: state.editorWidth.value, height: state.editorHeight.value}, + () => { + geometry.setFittedImageVerticeCoordinates(); + + const cropperState = state.cropperState.value; + const image = state.image.value; + + if (!cropperState || !image) { + return; + } + + const sizeFactor = + geometry.getScaledImageDimensions().width / + cropperState.imageDimensions.width; + const scale = sizeFactor * state.zoomRatio.value; + + cropper.show({ + left: image.left + cropperState.offsetX * scale, + top: image.top + cropperState.offsetY * scale, + width: cropperState.width * scale, + height: cropperState.height * scale, + }); + + if (state.focalPoint.value) { + focalPoint.restoreFromState(); + } + } + ); + } + + /** Zooms back to the cropped region and tears the cropping layer down. */ + function disableCropMode(): void { + const clipper = state.clipper.value; + const image = state.image.value; + + if (!clipper || !image) { + return; + } + + const clipperBounds = { + left: clipper.left, + top: clipper.top, + width: clipper.width, + height: clipper.height, + }; + const offsetX = clipper.left - image.left; + const offsetY = clipper.top - image.top; + + cropper.hide(); + + const dimensions = geometry.getScaledImageDimensions(); + const targetZoom = + geometry.getZoomToCoverRatio(dimensions) * state.scaleFactor.value; + const inverseZoomFactor = targetZoom / state.zoomRatio.value; + state.zoomRatio.value = targetZoom; + + // A focal point outside the new crop no longer means anything, so it goes. + const marker = state.focalPoint.value; + + if ( + !marker || + !( + marker.left > clipperBounds.left - clipperBounds.width / 2 && + marker.top > clipperBounds.top - clipperBounds.height / 2 && + marker.left < clipperBounds.left + clipperBounds.width / 2 && + marker.top < clipperBounds.top + clipperBounds.height / 2 + ) + ) { + if (marker) { + focalPoint.toggle(); + } + + focalPoint.resetPosition(); + } + + const coverScale = geometry.getImageScaleFor( + dimensions.width * state.zoomRatio.value + ); + + transitionMode( + { + scaleX: coverScale, + scaleY: coverScale, + left: state.editorWidth.value / 2 - offsetX * inverseZoomFactor, + top: state.editorHeight.value / 2 - offsetY * inverseZoomFactor, + }, + { + width: clipperBounds.width * inverseZoomFactor, + height: clipperBounds.height * inverseZoomFactor, + }, + () => { + if (state.focalPoint.value) { + focalPoint.restoreFromState(); + } + } + ); + } + + function showView(view: EditorView): void { + if (state.currentView.value === view) { + return; + } + + const previousView = state.currentView.value; + + // Flip first, so the host can show or hide whatever this view owns — the + // crop sidebar — and settle at its new width before anything is measured. + state.currentView.value = view; + + void nextTick().then(() => { + // One measurement, taken once the sidebar is in place. Laying out against + // the old width and letting the resize correct it afterwards is what made + // the image lurch: it moved for the old width, animated towards a target + // computed for the old width, then moved again when the sidebar landed. + // + // Safe to measure without preserving the previous dimensions: both + // transitions set the image's position outright rather than shifting it + // by how much the editor changed. + canvas.measureEditor(); + canvas.resizeCanvases(); + + if (previousView === 'crop' && view !== 'crop') { + enqueue(disableCropMode); + } else if (previousView !== 'crop' && view === 'crop') { + enqueue(enableCropMode); + } + }); + } + + /** Parses the move icon out of the DOM so the cropper can draw it on canvas. */ + async function loadMoveIcon(): Promise { + const svg = state.editorEl.value + ?.closest('.image-editor') + ?.querySelector('#move-icon-wrapper svg')?.outerHTML; + + if (!svg) { + return; + } + + try { + const icon = await loadSvg(svg); + + icon.set({ + left: 0, + top: 0, + scaleX: 0.03, + scaleY: 0.03, + originX: 'center', + originY: 'center', + fill: 'white', + }); + + state.moveIcon.value = icon; + } catch { + // A missing move icon costs an affordance, not the editor. + } + } + + /** + * The focal point's offsets as the asset arrived, expressed as fractions of + * the image so a later editor resize doesn't read as a change. + */ + let seededFocalOffset: {x: number; y: number} | null = null; + + /** Seeds the focal point state from the asset's stored relative position. */ + function seedFocalPoint(): void { + const dimensions = geometry.getScaledImageDimensions(); + + const focalState = { + imageDimensions: dimensions, + offsetX: 0, + offsetY: 0, + }; + + if (options.focalPoint) { + focalState.offsetX = + dimensions.width * options.focalPoint.x - dimensions.width / 2; + focalState.offsetY = + dimensions.height * options.focalPoint.y - dimensions.height / 2; + } + + focalPoint.storeFocalPointState(focalState); + + seededFocalOffset = { + x: focalState.offsetX / dimensions.width, + y: focalState.offsetY / dimensions.height, + }; + + if (options.focalPoint) { + focalPoint.create(); + } + } + + /** + * Whether anything has been changed since the image loaded. + * + * Derived from the state rather than a flag the mutating operations have to + * remember to set — one missed call site and a user loses work to a + * confirmation that never appeared. + */ + const isDirty = computed(() => { + if (!isReady.value) { + return false; + } + + if ( + state.viewportRotation.value !== 0 || + state.imageStraightenAngle.value !== 0 || + state.flipData.value.x !== 0 || + state.flipData.value.y !== 0 + ) { + return true; + } + + // Sub-pixel wobble from repeated zoom maths isn't an edit. + const tolerance = 1; + const crop = state.cropperState.value; + + if ( + crop && + (Math.abs(crop.offsetX) > tolerance || + Math.abs(crop.offsetY) > tolerance || + crop.imageDimensions.width - crop.width > tolerance || + crop.imageDimensions.height - crop.height > tolerance) + ) { + return true; + } + + if (Boolean(state.focalPoint.value) !== Boolean(options.focalPoint)) { + return true; + } + + const focalState = state.focalPointState.value; + + if (state.focalPoint.value && focalState && seededFocalOffset) { + const moved = + Math.abs( + focalState.offsetX / focalState.imageDimensions.width - + seededFocalOffset.x + ) > 0.001 || + Math.abs( + focalState.offsetY / focalState.imageDimensions.height - + seededFocalOffset.y + ) > 0.001; + + if (moved) { + return true; + } + } + + return false; + }); + + /** + * Puts the image back the way it loaded, discarding every edit. + * + * Deliberately doesn't refetch: the source image is already on the canvas, + * and everything the editor does to it lives in state that can simply be + * wound back. The view is left alone — resetting while cropping should show + * the crop reset, not drop you somewhere else. + */ + function reset(): void { + const image = state.image.value; + + if (!image || state.animationInProgress.value) { + return; + } + + state.imageStraightenAngle.value = 0; + state.viewportRotation.value = 0; + state.scaleFactor.value = 1; + state.flipData.value = {x: 0, y: 0}; + + // `flipX`/`flipY` are fabric's own normalization of a negative scale, so + // both halves have to be cleared or the image stays mirrored. + image.flipX = false; + image.flipY = false; + image.set({ + angle: 0, + scaleX: 1, + scaleY: 1, + left: state.editorWidth.value / 2, + top: state.editorHeight.value / 2, + }); + + if (state.focalPoint.value) { + state.canvas.value?.remove(state.focalPoint.value); + state.focalPoint.value = null; + } + + state.previousFocalPoint.value = null; + + const dimensions = geometry.getScaledImageDimensions(); + const cropping = state.currentView.value === 'crop'; + + state.zoomRatio.value = cropping + ? geometry.getZoomToFitRatio(dimensions) + : geometry.getZoomToCoverRatio(dimensions); + + canvas.zoomImage(); + geometry.setFittedImageVerticeCoordinates(); + + seedFocalPoint(); + + cropper.storeCropperState({ + offsetX: 0, + offsetY: 0, + width: dimensions.width, + height: dimensions.height, + imageDimensions: dimensions, + }); + + canvas.repositionViewport(); + + if (cropping) { + cropper.restoreFromState(); + canvas.renderCropper(); + } + + canvas.renderImage(); + } + + async function load(): Promise { + canvas.measureEditor(); + + await loadMoveIcon(); + + try { + await canvas.createCanvas(imageUrl()); + } catch (error) { + // Surfaced as well as flashed: the flash says something went wrong, the + // console says what, which a bare `catch` would have thrown away. + console.error('Image editor failed to load the image:', error); + flash('error', t('Could not load the image for editing.')); + return; + } + + canvas.resizeCanvases(); + geometry.setFittedImageVerticeCoordinates(); + + // The zoom has to be established before anything is positioned against it. + state.zoomRatio.value = + geometry.getZoomToCoverRatio(geometry.getScaledImageDimensions()) * + state.scaleFactor.value; + + canvas.zoomImage(); + + seedFocalPoint(); + canvas.createViewport(); + cropper.storeCropperState(); + canvas.renderImage(); + + isReady.value = true; + } + + const { + data: saveResult, + state: saveState, + execute: postSave, + } = useActionClient('assets/save-image', { + onError: () => flash('error', t('Could not save the image.')), + }); + + /** + * Posts the accumulated edits. The server replays them against the original + * file, so what goes up is the description of the transform, not pixels. + */ + async function save(mode: SaveMode): Promise { + if (savingAs.value) { + return null; + } + + savingAs.value = mode; + + const cropperState = state.cropperState.value; + const dimensions = + cropperState?.imageDimensions ?? geometry.getScaledImageDimensions(); + + try { + await postSave({ + assetId: options.assetId, + viewportRotation: state.viewportRotation.value, + imageRotation: state.imageStraightenAngle.value, + replace: mode === 'replace' ? 1 : 0, + imageDimensions: {...dimensions}, + ...(cropperState + ? { + cropData: { + height: cropperState.height, + width: cropperState.width, + offsetX: cropperState.offsetX, + offsetY: cropperState.offsetY, + }, + } + : {}), + ...(state.focalPoint.value && state.focalPointState.value + ? { + focalPoint: { + offsetX: state.focalPointState.value.offsetX, + offsetY: state.focalPointState.value.offsetY, + imageDimensions: { + ...state.focalPointState.value.imageDimensions, + }, + }, + } + : {}), + flipData: {...state.flipData.value}, + zoom: state.zoomRatio.value, + }); + + // `execute` resolves whether or not the request succeeded — it reports + // failure through `state` and `onError` instead of throwing — so success + // has to be checked rather than assumed. + if (saveState.value !== 'success') { + return null; + } + + cacheBust.value = Date.now(); + + flash( + 'success', + mode === 'replace' + ? t('Image saved.') + : t('Image saved as a new asset.') + ); + + return saveResult.value ?? {}; + } finally { + savingAs.value = null; + } + } + + let loadStarted = false; + /** A resize arrived mid-animation and still needs applying. */ + let resizePending = false; + /** Set once the host says the editor's container has settled. */ + let started = false; + + /** + * The editor takes every measurement from its container, so nothing can + * happen until that container has a size. Inside a dialog it has none at all + * until the dialog opens — loading before then measured zero, which put the + * image's centre at the origin and made the zoom ratio `NaN`. + * + * So the first real size drives the load, and every size after it drives a + * re-layout. + */ + function onEditorResized(): void { + // Read the element without storing the result. `updateSizeAndPosition()` + // shifts the image by how much the editor changed, which it works out from + // the previous `editorWidth`/`editorHeight` — measuring here first would + // overwrite those with the new size, make the delta zero, and leave the + // image parked where the old size put it. + const el = state.editorEl.value; + + if (!el?.clientWidth || !el?.clientHeight) { + return; + } + + if (!isReady.value) { + if (started && !loadStarted) { + loadStarted = true; + void load(); + } + + return; + } + + // A transition animates the image towards targets worked out from the + // editor's size when it started. Re-laying out underneath it would be + // undone the moment it lands — the image would keep the old geometry while + // the zoom and the image quad had moved on, and every containment test + // against that stale image would fail. So wait for it to finish. + if (state.animationInProgress.value) { + resizePending = true; + + return; + } + + updateSizeAndPosition(); + } + + // Covers every animation, not just mode transitions: rotate, flip and the + // constraint reshape all park `animationInProgress` the same way. + watch( + () => state.animationInProgress.value, + (busy) => { + if (!busy && resizePending) { + resizePending = false; + // `updateSizeAndPosition` re-derives the rectangle and the focal + // point from their stored state, which is what a transition placed + // against a since-moved image needs. + updateSizeAndPosition(); + } + } + ); + + // Registered during setup rather than in `onMounted`: VueUse hangs its + // cleanup on the active effect scope, and there isn't one inside a mounted + // hook, so these would never be torn down. + useResizeObserver(state.editorEl, onEditorResized); + useEventListener(document, 'keydown', interactions.onKeyDown); + useEventListener(document, 'keyup', interactions.onKeyUp); + + /** + * Stands the cropping rectangle the other way up, for the orientation switch. + * + * The rectangle carries the ratio once it has turned, so an active constraint + * is re-read from the new shape rather than recomputed from the option — that + * keeps `original` (the image's own ratio) correct too, which inverting the + * option's value wouldn't, since it isn't a number to invert. + */ + function turnCrop(): void { + const hadConstraint = state.croppingConstraint.value !== false; + const turned = cropper.transpose(); + + if (turned && hadConstraint) { + state.croppingConstraint.value = turned.width / turned.height; + } + } + + /** + * Begins loading, once the host confirms the editor's container has settled + * at its final size. + * + * Everything here is measured off that container, and a container that + * resizes *after* layout is what produced a run of bugs: the image ended up + * sized for one editor while the zoom, the image quad and the cropping + * rectangle were computed for another, and every containment test against + * that mismatch failed. Inside a dialog the container has no size until it + * opens and keeps changing while it animates, so the dialog waits for + * `craft-after-show` — opened *and* finished updating — before calling this. + */ + function start(): void { + started = true; + onEditorResized(); + } + + onBeforeUnmount(() => { + canvas.destroy(); + }); + + return { + state, + start, + reset, + isDirty, + isReady, + isSaving, + savingAs, + cursor: interactions.cursor, + editing, + + // Views + showView, + + // Transforms + rotate: transforms.rotate, + flip: transforms.flip, + straighten: transforms.straighten, + showGrid: transforms.showGrid, + hideGrid: transforms.hideGrid, + cleanupFocalPointAfterStraighten: focalPoint.cleanupAfterStraighten, + + // Focal point + toggleFocalPoint: focalPoint.toggle, + + // Cropping constraint + applyConstraint: (value: ConstraintValue) => constraint.apply(value), + turnCrop, + applyCustomConstraint: (width: number, height: number) => { + constraint.setCustomConstraint(width, height); + constraint.enforce(); + }, + + // Pointer + onPointerDown: interactions.onPointerDown, + onPointerMove: interactions.onPointerMove, + onPointerUp: interactions.onPointerUp, + onPointerLeave: interactions.onPointerLeave, + + // Keyboard editing + onEditButtonClick: interactions.onEditButtonClick, + onEditButtonKeydown: interactions.onEditButtonKeydown, + onEditButtonFocus: interactions.onEditButtonFocus, + onEditButtonBlur: interactions.onEditButtonBlur, + + save, + updateSizeAndPosition, + }; +} diff --git a/resources/js/modules/image-editor/useImageTransforms.ts b/resources/js/modules/image-editor/useImageTransforms.ts new file mode 100644 index 00000000000..0a6daa0b1ee --- /dev/null +++ b/resources/js/modules/image-editor/useImageTransforms.ts @@ -0,0 +1,461 @@ +import { + Group, + Line, + Rect, + type FabricAnimatable, + type FabricObject, +} from './fabric'; +import { + getZoomRatioToFitRectangle, + isCenterInside, + rotatePoint, +} from './geometry'; +import type {Cropper} from './useCropper'; +import type {EditorGeometry, EditorState} from './useEditorState'; +import type {FocalPoint} from './useFocalPoint'; +import type {ImageCanvas} from './useImageCanvas'; + +/** How many guide lines the straightening grid draws per axis. */ +const GRID_LINE_COUNT = 8; + +/** + * Rotating, flipping and straightening. + * + * Rotation and straightening are two different things here: rotation turns the + * viewport in 90° steps and is animated, while straightening tilts the image + * underneath a fixed viewport and zooms to cover the gap that opens at the + * corners. + */ +export function useImageTransforms( + state: EditorState, + geometry: EditorGeometry, + canvas: ImageCanvas, + cropper: Cropper, + focalPoint: FocalPoint +) { + /** Rotates the viewport a quarter turn, animating the image with it. */ + function rotate(degrees: 90 | -90): void { + const image = state.image.value; + const viewport = state.viewport.value; + const cropperState = state.cropperState.value; + + if ( + state.animationInProgress.value || + !image || + !viewport || + !cropperState + ) { + return; + } + + state.animationInProgress.value = true; + state.viewportRotation.value = Math.trunc( + (state.viewportRotation.value + degrees + 360) % 360 + ); + + const scaled = geometry.getScaledImageDimensions(); + + let imageZoomRatio = geometry.hasOrientationChanged() + ? geometry.getZoomToCoverRatio({ + height: scaled.width, + width: scaled.height, + }) + : geometry.getZoomToCoverRatio(scaled); + + // Respect a zoom the user has already applied. + imageZoomRatio = Math.max(imageZoomRatio, state.zoomRatio.value); + + // A viewport taller than the editor is wide (or vice versa) has to shrink + // to fit once it turns onto its side. + let scaleFactor = 1; + + if (state.scaleFactor.value < 1) { + scaleFactor = 1 / state.scaleFactor.value; + state.scaleFactor.value = 1; + } else { + if (viewport.width > state.editorHeight.value) { + scaleFactor = state.editorHeight.value / viewport.width; + } else if (viewport.height > state.editorWidth.value) { + scaleFactor = state.editorWidth.value / viewport.height; + } + + state.scaleFactor.value = scaleFactor; + } + + // Scaled rather than resized: fabric draws an image at its natural size + // times its scale, so this is the displayed width the turn should land on, + // converted. See `getImageScaleFor()`. + const turnedScale = geometry.getImageScaleFor( + scaled.width * imageZoomRatio * (scaleFactor < 1 ? scaleFactor : 1) + ); + + const imageProperties: FabricAnimatable = { + angle: image.angle + degrees, + scaleX: turnedScale, + scaleY: turnedScale, + }; + + // Swing the stored crop offset around the same arc so the same region + // stays framed after the turn. + const rotated = rotatePoint( + {x: cropperState.offsetX, y: cropperState.offsetY}, + degrees + ); + + const sizeFactor = scaled.width / cropperState.imageDimensions.width; + const perOffsetPixel = + sizeFactor * state.zoomRatio.value * state.scaleFactor.value; + + imageProperties.left = + state.editorWidth.value / 2 - rotated.x * perOffsetPixel; + imageProperties.top = + state.editorHeight.value / 2 - rotated.y * perOffsetPixel; + + cropper.storeCropperState({ + ...cropperState, + offsetX: rotated.x, + offsetY: rotated.y, + width: cropperState.height, + height: cropperState.width, + }); + + if (state.focalPoint.value) { + state.canvas.value?.remove(state.focalPoint.value); + } + + viewport.animate( + {angle: degrees === 90 ? '+=90' : '-=90'}, + { + duration: state.settings.animationDuration, + onComplete: () => { + const height = viewport.height * scaleFactor; + viewport.height = viewport.width * scaleFactor; + viewport.width = height; + viewport.set({angle: 0}); + }, + } + ); + + image.animate(imageProperties, { + duration: state.settings.animationDuration, + onChange: () => state.canvas.value?.renderAll(), + onComplete: () => { + image.set({angle: (image.angle + 360) % 360}); + state.animationInProgress.value = false; + + if (state.focalPoint.value) { + focalPoint.adjustByAngle(degrees); + straighten(state.imageStraightenAngle.value); + state.canvas.value?.add(state.focalPoint.value); + } else { + focalPoint.resetPosition(); + } + }, + }); + } + + /** + * Mirrors the image on one axis. + * + * Which axis the user means depends on how the viewport is turned: with the + * image on its side, "flip vertical" is a horizontal flip of the underlying + * picture. + */ + function flip(axis: 'x' | 'y'): void { + const image = state.image.value; + const cropperState = state.cropperState.value; + const focalPointState = state.focalPointState.value; + + if (state.animationInProgress.value || !image || !cropperState) { + return; + } + + state.animationInProgress.value = true; + + const effectiveAxis = geometry.hasOrientationChanged() + ? axis === 'y' + ? 'x' + : 'y' + : axis; + + if (state.focalPoint.value) { + state.canvas.value?.remove(state.focalPoint.value); + } else { + focalPoint.resetPosition(); + } + + const center = geometry.getEditorCenter(); + + // Flipping mirrors the straightening angle too, so a tilted horizon stays + // tilted the same way relative to the picture. + state.imageStraightenAngle.value = -state.imageStraightenAngle.value; + + const properties: FabricAnimatable = { + angle: state.viewportRotation.value + state.imageStraightenAngle.value, + }; + + const nextCropperState = {...cropperState}; + const nextFocalState = focalPointState ? {...focalPointState} : null; + + if (effectiveAxis === 'x') { + nextCropperState.offsetX = -nextCropperState.offsetX; + + if (nextFocalState) { + nextFocalState.offsetX = -nextFocalState.offsetX; + } + + properties.left = center.x - (image.left - center.x); + } else { + nextCropperState.offsetY = -nextCropperState.offsetY; + + if (nextFocalState) { + nextFocalState.offsetY = -nextFocalState.offsetY; + } + + properties.top = center.y - (image.top - center.y); + } + + if (axis === 'y') { + properties.scaleY = image.scaleY * -1; + state.flipData.value = { + ...state.flipData.value, + y: 1 - state.flipData.value.y, + }; + } else { + properties.scaleX = image.scaleX * -1; + state.flipData.value = { + ...state.flipData.value, + x: 1 - state.flipData.value.x, + }; + } + + cropper.storeCropperState(nextCropperState); + + if (nextFocalState) { + focalPoint.storeFocalPointState(nextFocalState); + } + + // fabric normalizes a negative scale by flipping the corresponding + // flipX/flipY flag and making the value positive. `set()` runs on every + // animation frame, so each frame that passes a negative scale toggles the + // flag again and the final state depends on the frame count. Bypassing + // `_set` for the scale keys keeps the animation deterministic. + // Captured unbound on purpose — it's put back on the same object below. + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalSet = image._set; + + image._set = function (key: string, value: unknown) { + if (key === 'scaleX' || key === 'scaleY') { + (this as unknown as Record)[key] = value; + this.dirty = true; + return this; + } + + return originalSet.call(this, key, value); + }; + + image.flipX = false; + image.flipY = false; + + image.animate(properties, { + duration: state.settings.animationDuration, + onChange: () => state.canvas.value?.renderAll(), + onComplete: () => { + image._set = originalSet; + state.animationInProgress.value = false; + + if (state.focalPoint.value) { + focalPoint.adjustByAngle(0); + state.canvas.value?.add(state.focalPoint.value); + } + }, + }); + } + + /** + * Tilts the image under a fixed viewport, zooming enough to keep the corners + * covered. + */ + function straighten(angle: number): void { + const image = state.image.value; + + if (state.animationInProgress.value || !image) { + return; + } + + state.animationInProgress.value = true; + + const previousAngle = image.angle; + + state.imageStraightenAngle.value = + (state.settings.allowDegreeFractions ? angle : Math.round(angle)) % 360; + + image.set({ + angle: state.viewportRotation.value + state.imageStraightenAngle.value, + }); + + state.zoomRatio.value = + geometry.getZoomToCoverRatio(geometry.getScaledImageDimensions()) * + state.scaleFactor.value; + + canvas.zoomImage(); + + if (state.cropperState.value) { + adjustEditorElementsOnStraighten(previousAngle); + } + + canvas.renderImage(); + state.animationInProgress.value = false; + } + + /** + * Keeps the cropped region centered as the image tilts, zooming in far + * enough that no image edge creeps into the viewport. + * + * The zoom and the offset depend on each other — zooming in moves the corners + * — so this iterates until a pass needs no further adjustment. + */ + function adjustEditorElementsOnStraighten(previousAngle: number): void { + const image = state.image.value; + const viewport = state.viewport.value; + const cropperState = state.cropperState.value; + + if (!image || !viewport || !cropperState) { + return; + } + + const scaled = geometry.getScaledImageDimensions(); + const angleDelta = image.angle - previousAngle; + const center = geometry.getEditorCenter(); + + let currentZoomRatio = state.zoomRatio.value; + let adjustmentRatio = 1; + let newCenter = {x: cropperState.offsetX, y: cropperState.offsetY}; + let delta = {x: 0, y: 0}; + let sizeFactor = 1; + + do { + newCenter = rotatePoint( + {x: cropperState.offsetX, y: cropperState.offsetY}, + angleDelta + ); + + sizeFactor = scaled.width / cropperState.imageDimensions.width; + + delta = { + x: newCenter.x * currentZoomRatio * sizeFactor, + y: newCenter.y * currentZoomRatio * sizeFactor, + }; + + adjustmentRatio = getZoomRatioToFitRectangle( + { + width: viewport.width, + height: viewport.height, + left: center.x - viewport.width / 2 + delta.x, + top: center.y - viewport.height / 2 + delta.y, + }, + geometry.getImageVerticeCoords(currentZoomRatio), + center + ); + + currentZoomRatio *= adjustmentRatio; + } while (adjustmentRatio !== 1); + + image.set({left: center.x - delta.x, top: center.y - delta.y}); + + cropper.storeCropperState({ + ...cropperState, + offsetX: newCenter.x, + offsetY: newCenter.y, + width: viewport.width / currentZoomRatio / sizeFactor, + height: viewport.height / currentZoomRatio / sizeFactor, + }); + + state.zoomRatio.value = currentZoomRatio; + + if (state.focalPoint.value) { + focalPoint.adjustByAngle(angleDelta); + focalPoint.updateVisibilityForViewport(); + } else if (angleDelta !== 0) { + focalPoint.resetPosition(); + } + + canvas.zoomImage(); + } + + /** Draws the alignment grid shown while the straighten slider is in use. */ + function showGrid(): void { + const viewport = state.viewport.value; + + if (state.grid.value || !viewport) { + return; + } + + const strokeOptions = {strokeWidth: 1, stroke: 'rgba(255,255,255,0.5)'}; + const gridWidth = viewport.width; + const gridHeight = viewport.height; + const xStep = gridWidth / (GRID_LINE_COUNT + 1); + const yStep = gridHeight / (GRID_LINE_COUNT + 1); + + const parts: FabricObject[] = [ + new Rect({ + strokeWidth: 2, + stroke: state.settings.colors.white, + originX: 'center', + originY: 'center', + width: gridWidth, + height: gridHeight, + left: gridWidth / 2, + top: gridHeight / 2, + fill: 'rgba(255,255,255,0)', + }), + ]; + + for (let i = 1; i <= GRID_LINE_COUNT; i++) { + parts.push( + new Line([i * xStep, 0, i * xStep, gridHeight], strokeOptions) + ); + parts.push(new Line([0, i * yStep, gridWidth, i * yStep], strokeOptions)); + } + + state.grid.value = new Group(parts, { + left: state.editorWidth.value / 2, + top: state.editorHeight.value / 2, + originX: 'center', + originY: 'center', + angle: viewport.angle, + }); + + state.canvas.value?.add(state.grid.value); + canvas.renderImage(); + } + + function hideGrid(): void { + if (!state.grid.value) { + return; + } + + state.canvas.value?.remove(state.grid.value); + state.grid.value = null; + canvas.renderImage(); + } + + /** Whether the focal point is still inside the viewport after a straighten. */ + function focalPointEscapedViewport(): boolean { + const marker = state.focalPoint.value; + const viewport = state.viewport.value; + + return Boolean(marker && viewport && !isCenterInside(marker, viewport)); + } + + return { + rotate, + flip, + straighten, + showGrid, + hideGrid, + focalPointEscapedViewport, + }; +} + +export type ImageTransforms = ReturnType; diff --git a/resources/js/pages/assets/Edit.vue b/resources/js/pages/assets/Edit.vue index 7a5ae3e0a26..597d3cb4ed3 100644 --- a/resources/js/pages/assets/Edit.vue +++ b/resources/js/pages/assets/Edit.vue @@ -1,8 +1,24 @@