diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d9ee91e..c7ed6bf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,7 +30,8 @@ "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.6.3", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vitest": "^4.1.0" } }, "node_modules/@babel/code-frame": { @@ -1437,6 +1438,13 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/react-store": { "version": "0.7.7", "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", @@ -1756,6 +1764,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1765,6 +1784,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1903,6 +1929,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webgpu/types": { "version": "0.1.69", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", @@ -1924,6 +2063,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2042,6 +2191,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -2301,6 +2460,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2402,6 +2568,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3147,6 +3333,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -4075,6 +4271,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", @@ -4136,6 +4343,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4964,6 +5178,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -5012,6 +5233,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5059,6 +5294,23 @@ "node": ">=12" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5076,6 +5328,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5515,6 +5777,88 @@ } } }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -5531,6 +5875,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0f06a3b..da1691a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@blocknote/core": "^0.47.1", @@ -31,6 +32,7 @@ "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.6.3", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vitest": "^4.1.0" } } diff --git a/frontend/src/canvas/CropOverlay.ts b/frontend/src/canvas/CropOverlay.ts index 7a3e6fe..2140d35 100644 --- a/frontend/src/canvas/CropOverlay.ts +++ b/frontend/src/canvas/CropOverlay.ts @@ -10,7 +10,15 @@ import { Container, Graphics, FederatedPointerEvent, Polygon, Rectangle } from ' import type { Viewport } from 'pixi-viewport'; import type { SceneItem } from './SceneManager'; import type { ImageObject, CropRect } from './scene-format'; -import { getImageViewRectWorldCorners, imageViewPointToWorld, worldToImageViewPoint } from './imageTransforms'; +import { + applyImageDisplayTransform, + displayCropRectToSourceCrop, + getImageDisplayCropRect, + getImageEditorGeometry, + getImageViewRectWorldCorners, + imageViewPointToWorld, + worldToImageViewPoint, +} from './imageTransforms'; import { ImageSprite } from './sprites/ImageSprite'; const HANDLE_SIZE = 8; @@ -37,6 +45,7 @@ export class CropOverlay extends Container { private _handles = new Map(); private _crop: CropRect = { x: 0, y: 0, w: 1, h: 1 }; private _originalCrop: CropRect | undefined; + private _editorData: ImageObject | null = null; private _drag: { mode: DragMode; startCrop: CropRect; startPoint: { x: number; y: number } } | null = null; private _onConfirm: ((item: SceneItem, crop: CropRect, anchorWorld: { x: number; y: number }) => void) | null = null; private _onCancel: (() => void) | null = null; @@ -94,9 +103,12 @@ export class CropOverlay extends Container { this._item = item; const imgData = item.data as ImageObject; this._originalCrop = imgData.crop ? { ...imgData.crop } : undefined; - this._crop = imgData.crop ? { ...imgData.crop } : { x: 0, y: 0, w: 1, h: 1 }; + const editor = getImageEditorGeometry(imgData); + this._editorData = editor.editorData; + this._crop = editor.sourceCrop; if (item.displayObject instanceof ImageSprite) { item.displayObject.applyCrop(undefined); + applyImageDisplayTransform(item.displayObject, this._editorData); } this._viewport.plugins.pause('drag'); this.visible = true; @@ -120,8 +132,8 @@ export class CropOverlay extends Container { if (!this._item) return; const item = this._item; const crop = { ...this._crop }; - const data = this._getWorkingImageData(); - const viewCrop = this._getViewCrop(); + const data = this._getEditorData(); + const viewCrop = getImageDisplayCropRect({ crop, flipX: data.flipX, flipY: data.flipY }); const anchorWorld = imageViewPointToWorld(data, viewCrop.x, viewCrop.y); const isFullImage = crop.x < 0.001 && crop.y < 0.001 && crop.w > 0.999 && crop.h > 0.999; this._cleanup(); @@ -130,9 +142,6 @@ export class CropOverlay extends Container { /** Cancel cropping — restore original state. */ cancel(): void { - if (this._item?.displayObject instanceof ImageSprite) { - this._item.displayObject.applyCrop(this._originalCrop); - } this._cleanup(); this._onCancel?.(); } @@ -143,8 +152,10 @@ export class CropOverlay extends Container { private _cleanup(): void { this._removeDomDragListeners(); this._viewport.plugins.resume('drag'); + this._restoreDisplayState(); this._item = null; this._originalCrop = undefined; + this._editorData = null; this._drag = null; this.visible = false; this._onStateChange?.(false); @@ -162,9 +173,9 @@ export class CropOverlay extends Container { private _draw(): void { if (!this._item) return; - const data = this._getWorkingImageData(); + const data = this._getEditorData(); const zoom = this._viewport.scale.x; - const viewCrop = this._getViewCrop(); + const viewCrop = getImageDisplayCropRect({ crop: this._crop, flipX: data.flipX, flipY: data.flipY }); const fullCorners = getImageViewRectWorldCorners(data, { x: 0, y: 0, w: 1, h: 1 }); const cropCorners = getImageViewRectWorldCorners(data, viewCrop); @@ -241,12 +252,12 @@ export class CropOverlay extends Container { if ('preventDefault' in e.nativeEvent && typeof e.nativeEvent.preventDefault === 'function') { e.nativeEvent.preventDefault(); } - const data = this._getWorkingImageData(); + const data = this._getEditorData(); const world = this._viewport.toWorld(e.global.x, e.global.y); const viewPoint = worldToImageViewPoint(data, world.x, world.y); this._drag = { mode, - startCrop: this._getViewCrop(), + startCrop: getImageDisplayCropRect({ crop: this._crop, flipX: data.flipX, flipY: data.flipY }), startPoint: { x: clamp01(viewPoint.x), y: clamp01(viewPoint.y) }, }; this._removeDomDragListeners(); @@ -260,7 +271,7 @@ export class CropOverlay extends Container { private _onMoveScreen(screenX: number, screenY: number): void { if (!this._drag || !this._item) return; - const data = this._getWorkingImageData(); + const data = this._getEditorData(); const world = this._viewport.toWorld(screenX, screenY); const viewPoint = worldToImageViewPoint(data, world.x, world.y); const nx = clamp01(viewPoint.x); @@ -272,7 +283,7 @@ export class CropOverlay extends Container { if (mode === 'move') { crop.x = Math.max(0, Math.min(1 - sc.w, sc.x + (nx - startPoint.x))); crop.y = Math.max(0, Math.min(1 - sc.h, sc.y + (ny - startPoint.y))); - this._setViewCrop(crop); + this._crop = displayCropRectToSourceCrop(data, crop); this._draw(); return; } @@ -314,7 +325,7 @@ export class CropOverlay extends Container { break; } - this._setViewCrop(crop); + this._crop = displayCropRectToSourceCrop(data, crop); this._draw(); } @@ -329,31 +340,6 @@ export class CropOverlay extends Container { this._removeDomDragListeners(); } - private _getViewCrop(): CropRect { - if (!this._item) return { ...this._crop }; - const data = this._item.data as ImageObject; - return { - x: data.flipX ? 1 - (this._crop.x + this._crop.w) : this._crop.x, - y: data.flipY ? 1 - (this._crop.y + this._crop.h) : this._crop.y, - w: this._crop.w, - h: this._crop.h, - }; - } - - private _setViewCrop(viewCrop: CropRect): void { - if (!this._item) { - this._crop = { ...viewCrop }; - return; - } - const data = this._item.data as ImageObject; - this._crop = { - x: data.flipX ? 1 - (viewCrop.x + viewCrop.w) : viewCrop.x, - y: data.flipY ? 1 - (viewCrop.y + viewCrop.h) : viewCrop.y, - w: viewCrop.w, - h: viewCrop.h, - }; - } - private _drawPolygon(graphics: Graphics, points: Array<{ x: number; y: number }>): void { if (points.length === 0) return; graphics.moveTo(points[0].x, points[0].y); @@ -390,11 +376,17 @@ export class CropOverlay extends Container { }; } - private _getWorkingImageData(): ImageObject { - const data = this._item!.data as ImageObject; - return { - ...data, - crop: undefined, - }; + private _restoreDisplayState(): void { + if (!this._item || !(this._item.displayObject instanceof ImageSprite)) return; + const data = this._item.data as ImageObject; + this._item.displayObject.applyCrop(this._originalCrop); + applyImageDisplayTransform(this._item.displayObject, data); + } + + private _getEditorData(): ImageObject { + if (!this._editorData) { + throw new Error('Crop editor data not initialized'); + } + return this._editorData; } } diff --git a/frontend/src/canvas/clipboard.ts b/frontend/src/canvas/clipboard.ts index 4750d3a..984b950 100644 --- a/frontend/src/canvas/clipboard.ts +++ b/frontend/src/canvas/clipboard.ts @@ -91,8 +91,8 @@ export async function writeCanvasToClipboard( for (const item of items) { const tex = (item.displayObject as any)?.texture; const displayW = item.type === 'image' - ? getImageVisibleLocalRect(item.data as ImageObject).w - : item.data.w; + ? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx) + : item.data.w * Math.abs(item.data.sx); if (tex && tex.width > 1 && displayW > 1) { maxRatio = Math.max(maxRatio, tex.width / displayW); } diff --git a/frontend/src/canvas/export.ts b/frontend/src/canvas/export.ts index 2274de7..650f3e5 100644 --- a/frontend/src/canvas/export.ts +++ b/frontend/src/canvas/export.ts @@ -71,8 +71,8 @@ function renderToCanvas( for (const item of items) { const tex = (item.displayObject as any)?.texture; const displayW = item.type === 'image' - ? getImageVisibleLocalRect(item.data as ImageObject).w - : item.data.w; + ? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx) + : item.data.w * Math.abs(item.data.sx); if (tex && tex.width > 1 && displayW > 1) { maxRatio = Math.max(maxRatio, tex.width / displayW); } @@ -182,8 +182,8 @@ export function getExportDimensions( for (const item of items) { const tex = (item.displayObject as any)?.texture; const displayW = item.type === 'image' - ? getImageVisibleLocalRect(item.data as ImageObject).w - : item.data.w; + ? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx) + : item.data.w * Math.abs(item.data.sx); if (tex && tex.width > 1 && displayW > 1) { maxRatio = Math.max(maxRatio, tex.width / displayW); } diff --git a/frontend/src/canvas/grouping.ts b/frontend/src/canvas/grouping.ts index 97c02dc..70a7120 100644 --- a/frontend/src/canvas/grouping.ts +++ b/frontend/src/canvas/grouping.ts @@ -14,7 +14,7 @@ import { getItemWorldBounds, rebuildGroupChildSet } from './SceneManager'; import type { SelectionManager } from './SelectionManager'; import type { GroupObject, ImageObject } from './scene-format'; import { randomFrameColor } from './sprites/FrameSprite'; -import { applyImageDisplayTransform, transformPoint } from './imageTransforms'; +import { applyImageDisplayTransform, getImageDisplayTransform, getImageVisibleLocalRect, transformPoint } from './imageTransforms'; /** * Group selected items into a single group container. @@ -132,32 +132,55 @@ export function ungroupItems( const obj = childItem.displayObject; - // Convert local position to world space, accounting for group scale - const worldOrigin = transformPoint( - { x: childItem.data.x, y: childItem.data.y }, - { x: groupX, y: groupY, sx: groupSx, sy: groupSy, angle: groupAngle }, - ); - const worldX = worldOrigin.x; - const worldY = worldOrigin.y; - - // Propagate group scale to child - childItem.data.sx *= groupSx; - childItem.data.sy *= groupSy; - - // Propagate group angle to child - childItem.data.angle = (childItem.data.angle || 0) + groupAngle; - - // Update DATA to store world coords - childItem.data.x = worldX; - childItem.data.y = worldY; - - // Reparent display object - obj.parent?.removeChild(obj); - viewport.addChild(obj); if (childItem.type === 'image') { - applyImageDisplayTransform(obj, childItem.data as ImageObject); + const imgData = childItem.data as ImageObject; + + // For images, transform the display position (with flip compensation) to world, + // then back-calculate canonical data.x/y from the new scale/angle. + // This is necessary because data.x/y is the canonical anchor, not the visual + // top-left — the flip offset must be rotated through the group transform. + const localDisplay = getImageDisplayTransform(imgData); + const worldDisplay = transformPoint( + { x: localDisplay.x, y: localDisplay.y }, + { x: groupX, y: groupY, sx: groupSx, sy: groupSy, angle: groupAngle }, + ); + + // Propagate group scale and angle + imgData.sx *= groupSx; + imgData.sy *= groupSy; + imgData.angle = (imgData.angle || 0) + groupAngle; + + // Back-calculate canonical x/y: display.x = data.x + flipOffset + const newSx = Math.abs(imgData.sx); + const newSy = Math.abs(imgData.sy); + const visibleRect = getImageVisibleLocalRect(imgData); + imgData.x = worldDisplay.x - (imgData.flipX ? visibleRect.w * newSx : 0); + imgData.y = worldDisplay.y - (imgData.flipY ? visibleRect.h * newSy : 0); + + // Reparent display object + obj.parent?.removeChild(obj); + viewport.addChild(obj); + applyImageDisplayTransform(obj, imgData); } else { - obj.position.set(worldX, worldY); + // Convert local position to world space, accounting for group scale + const worldOrigin = transformPoint( + { x: childItem.data.x, y: childItem.data.y }, + { x: groupX, y: groupY, sx: groupSx, sy: groupSy, angle: groupAngle }, + ); + + // Propagate group scale and angle + childItem.data.sx *= groupSx; + childItem.data.sy *= groupSy; + childItem.data.angle = (childItem.data.angle || 0) + groupAngle; + + // Update DATA to store world coords + childItem.data.x = worldOrigin.x; + childItem.data.y = worldOrigin.y; + + // Reparent display object + obj.parent?.removeChild(obj); + viewport.addChild(obj); + obj.position.set(worldOrigin.x, worldOrigin.y); obj.scale.set(childItem.data.sx, childItem.data.sy); obj.angle = childItem.data.angle; } diff --git a/frontend/src/canvas/imageTransforms.test.ts b/frontend/src/canvas/imageTransforms.test.ts new file mode 100644 index 0000000..72b220d --- /dev/null +++ b/frontend/src/canvas/imageTransforms.test.ts @@ -0,0 +1,341 @@ +import { describe, it, expect } from 'vitest'; +import type { ImageObject } from './scene-format'; +import { + getImageSourceRect, + getImageVisibleLocalRect, + getImageDisplayTransform, + getImageDisplayGeometry, + getImageEditorGeometry, + getImageDisplayCropRect, + displayCropRectToSourceCrop, + imageViewPointToWorld, + worldToImageViewPoint, + normalizeImageTransformData, + getImageTransformedCorners, + getImageWorldBounds, +} from './imageTransforms'; + +/** Helper to build a minimal ImageObject for testing. */ +function makeImage(overrides: Partial = {}): ImageObject { + return { + id: 'test', + type: 'image', + x: 0, + y: 0, + w: 200, + h: 100, + sx: 1, + sy: 1, + angle: 0, + z: 1, + opacity: 1, + locked: false, + name: '', + visible: true, + asset: 'test.png', + filters: [], + flipX: false, + flipY: false, + ...overrides, + }; +} + +function expectClose(actual: number, expected: number, tolerance = 0.5) { + expect(Math.abs(actual - expected)).toBeLessThan(tolerance); +} + +// ─── Source Rect ────────────────────────────────────────────── + +describe('getImageSourceRect', () => { + it('returns full image when no crop', () => { + const r = getImageSourceRect(makeImage()); + expect(r).toEqual({ x: 0, y: 0, w: 200, h: 100 }); + }); + + it('returns cropped region in pixel coords', () => { + const r = getImageSourceRect(makeImage({ crop: { x: 0.25, y: 0.1, w: 0.5, h: 0.8 } })); + expect(r).toEqual({ x: 50, y: 10, w: 100, h: 80 }); + }); +}); + +// ─── Visible Local Rect ────────────────────────────────────── + +describe('getImageVisibleLocalRect', () => { + it('full image → same as w/h', () => { + const r = getImageVisibleLocalRect(makeImage()); + expect(r).toEqual({ x: 0, y: 0, w: 200, h: 100 }); + }); + + it('cropped → visible dimensions match crop region', () => { + const r = getImageVisibleLocalRect(makeImage({ crop: { x: 0.25, y: 0, w: 0.5, h: 1 } })); + expect(r).toEqual({ x: 0, y: 0, w: 100, h: 100 }); + }); +}); + +// ─── Display Transform ─────────────────────────────────────── + +describe('getImageDisplayTransform', () => { + it('no flip → position equals data.x/y', () => { + const t = getImageDisplayTransform(makeImage({ x: 50, y: 30 })); + expect(t.x).toBe(50); + expect(t.y).toBe(30); + expect(t.scaleX).toBe(1); + expect(t.scaleY).toBe(1); + }); + + it('flipX → position shifts right by visible width', () => { + const t = getImageDisplayTransform(makeImage({ x: 50, y: 30, flipX: true })); + expect(t.x).toBe(250); // 50 + 200*1 + expect(t.y).toBe(30); + expect(t.scaleX).toBe(-1); + }); + + it('flipY → position shifts down by visible height', () => { + const t = getImageDisplayTransform(makeImage({ x: 50, y: 30, flipY: true })); + expect(t.x).toBe(50); + expect(t.y).toBe(130); // 30 + 100*1 + expect(t.scaleY).toBe(-1); + }); + + it('flipX + crop → shift uses cropped visible width', () => { + const data = makeImage({ x: 0, y: 0, flipX: true, crop: { x: 0, y: 0, w: 0.5, h: 1 } }); + const t = getImageDisplayTransform(data); + expect(t.x).toBe(100); // 0 + 100*1 (half width cropped) + }); + + it('sx=2 + flipX → shift uses scaled visible width', () => { + const t = getImageDisplayTransform(makeImage({ x: 0, y: 0, sx: 2, flipX: true })); + expect(t.x).toBe(400); // 200 * 2 + expect(t.scaleX).toBe(-2); + }); +}); + +// ─── Display Geometry ──────────────────────────────────────── + +describe('getImageDisplayGeometry', () => { + it('world bounds match visible frame for unflipped image', () => { + const g = getImageDisplayGeometry(makeImage({ x: 100, y: 50 })); + expect(g.worldBounds.x).toBe(100); + expect(g.worldBounds.y).toBe(50); + expect(g.worldBounds.w).toBe(200); + expect(g.worldBounds.h).toBe(100); + }); + + it('flipped image has same world bounds as unflipped', () => { + const normal = getImageDisplayGeometry(makeImage({ x: 100, y: 50 })); + const flipped = getImageDisplayGeometry(makeImage({ x: 100, y: 50, flipX: true, flipY: true })); + expectClose(flipped.worldBounds.x, normal.worldBounds.x); + expectClose(flipped.worldBounds.y, normal.worldBounds.y); + expectClose(flipped.worldBounds.w, normal.worldBounds.w); + expectClose(flipped.worldBounds.h, normal.worldBounds.h); + }); + + it('cropped image has smaller world bounds', () => { + const g = getImageDisplayGeometry(makeImage({ x: 0, y: 0, crop: { x: 0, y: 0, w: 0.5, h: 0.5 } })); + expect(g.worldBounds.w).toBe(100); // half + expect(g.worldBounds.h).toBe(50); // half + }); +}); + +// ─── Editor Geometry (crop session) ────────────────────────── + +describe('getImageEditorGeometry', () => { + it('uncropped image → editor shows full image at same position', () => { + const data = makeImage({ x: 100, y: 50 }); + const eg = getImageEditorGeometry(data); + expect(eg.sourceCrop).toEqual({ x: 0, y: 0, w: 1, h: 1 }); + expectClose(eg.editorData.x, 100); + expectClose(eg.editorData.y, 50); + }); + + it('crop entry preserves visible region anchor', () => { + const data = makeImage({ x: 100, y: 50, crop: { x: 0.25, y: 0.25, w: 0.5, h: 0.5 } }); + const displayBefore = getImageDisplayGeometry(data); + + const eg = getImageEditorGeometry(data); + // The crop anchor world point should match the display geometry's top-left + expectClose(eg.cropAnchorWorld.x, displayBefore.worldBounds.x, 1); + expectClose(eg.cropAnchorWorld.y, displayBefore.worldBounds.y, 1); + }); + + it('crop entry after flipX preserves anchor', () => { + const data = makeImage({ x: 100, y: 50, flipX: true, crop: { x: 0.25, y: 0, w: 0.5, h: 1 } }); + const displayBefore = getImageDisplayGeometry(data); + + const eg = getImageEditorGeometry(data); + expectClose(eg.cropAnchorWorld.x, displayBefore.worldBounds.x, 1); + expectClose(eg.cropAnchorWorld.y, displayBefore.worldBounds.y, 1); + }); + + it('crop entry after flipY preserves anchor', () => { + const data = makeImage({ x: 100, y: 50, flipY: true, crop: { x: 0, y: 0.25, w: 1, h: 0.5 } }); + const displayBefore = getImageDisplayGeometry(data); + + const eg = getImageEditorGeometry(data); + expectClose(eg.cropAnchorWorld.x, displayBefore.worldBounds.x, 1); + expectClose(eg.cropAnchorWorld.y, displayBefore.worldBounds.y, 1); + }); + + it('crop entry after flipX+flipY preserves anchor', () => { + const data = makeImage({ x: 100, y: 50, flipX: true, flipY: true, crop: { x: 0.1, y: 0.2, w: 0.6, h: 0.5 } }); + // Anchor is the display crop's top-left in world space (not AABB min) + const displayCrop = getImageDisplayCropRect(data); + const expectedAnchor = imageViewPointToWorld(data, displayCrop.x, displayCrop.y); + + const eg = getImageEditorGeometry(data); + expectClose(eg.cropAnchorWorld.x, expectedAnchor.x, 0.01); + expectClose(eg.cropAnchorWorld.y, expectedAnchor.y, 0.01); + + // The editor's crop region should match the original crop region in world space + const editorCropCorner = imageViewPointToWorld(eg.editorData, displayCrop.x, displayCrop.y); + expectClose(editorCropCorner.x, expectedAnchor.x, 1); + expectClose(editorCropCorner.y, expectedAnchor.y, 1); + }); + + it('crop entry after rotate preserves anchor', () => { + const data = makeImage({ x: 100, y: 50, angle: 45, crop: { x: 0.25, y: 0.25, w: 0.5, h: 0.5 } }); + const displayCrop = getImageDisplayCropRect(data); + const expectedAnchor = imageViewPointToWorld(data, displayCrop.x, displayCrop.y); + + const eg = getImageEditorGeometry(data); + expectClose(eg.cropAnchorWorld.x, expectedAnchor.x, 0.01); + expectClose(eg.cropAnchorWorld.y, expectedAnchor.y, 0.01); + + // The editor's crop region should match the original crop region in world space + const editorCropCorner = imageViewPointToWorld(eg.editorData, displayCrop.x, displayCrop.y); + expectClose(editorCropCorner.x, expectedAnchor.x, 1); + expectClose(editorCropCorner.y, expectedAnchor.y, 1); + }); +}); + +// ─── Crop Rect Conversions ─────────────────────────────────── + +describe('crop rect conversions', () => { + it('round-trip: display → source → display', () => { + const data = makeImage({ flipX: true, flipY: true, crop: { x: 0.1, y: 0.2, w: 0.5, h: 0.3 } }); + const display = getImageDisplayCropRect(data); + const source = displayCropRectToSourceCrop(data, display); + expectClose(source.x, data.crop!.x); + expectClose(source.y, data.crop!.y); + expectClose(source.w, data.crop!.w); + expectClose(source.h, data.crop!.h); + }); + + it('no flip → display crop equals source crop', () => { + const data = makeImage({ crop: { x: 0.2, y: 0.3, w: 0.4, h: 0.5 } }); + const display = getImageDisplayCropRect(data); + expect(display.x).toBe(0.2); + expect(display.y).toBe(0.3); + }); + + it('flipX mirrors crop horizontally', () => { + const data = makeImage({ flipX: true, crop: { x: 0.1, y: 0, w: 0.3, h: 1 } }); + const display = getImageDisplayCropRect(data); + expectClose(display.x, 0.6); // 1 - (0.1 + 0.3) + expect(display.w).toBe(0.3); + }); +}); + +// ─── Coordinate Round-Trips ────────────────────────────────── + +describe('coordinate conversions', () => { + it('source → world → source round-trip (no flip)', () => { + const data = makeImage({ x: 100, y: 50 }); + const world = imageViewPointToWorld(data, 0.5, 0.5); + const back = worldToImageViewPoint(data, world.x, world.y); + expectClose(back.x, 0.5); + expectClose(back.y, 0.5); + }); + + it('source → world → source round-trip (flipX)', () => { + const data = makeImage({ x: 100, y: 50, flipX: true }); + const world = imageViewPointToWorld(data, 0.3, 0.7); + const back = worldToImageViewPoint(data, world.x, world.y); + expectClose(back.x, 0.3); + expectClose(back.y, 0.7); + }); + + it('source → world → source round-trip (flipX + flipY + crop)', () => { + const data = makeImage({ x: 100, y: 50, flipX: true, flipY: true, crop: { x: 0.1, y: 0.2, w: 0.6, h: 0.5 } }); + const world = imageViewPointToWorld(data, 0.4, 0.5); + const back = worldToImageViewPoint(data, world.x, world.y); + expectClose(back.x, 0.4); + expectClose(back.y, 0.5); + }); + + it('source → world → source round-trip (rotated)', () => { + const data = makeImage({ x: 100, y: 50, angle: 90 }); + const world = imageViewPointToWorld(data, 0.25, 0.75); + const back = worldToImageViewPoint(data, world.x, world.y); + expectClose(back.x, 0.25); + expectClose(back.y, 0.75); + }); + + it('source → world → source round-trip (rotated + flipped + cropped)', () => { + const data = makeImage({ x: 100, y: 50, angle: 45, flipX: true, crop: { x: 0.2, y: 0.1, w: 0.6, h: 0.8 } }); + const world = imageViewPointToWorld(data, 0.5, 0.5); + const back = worldToImageViewPoint(data, world.x, world.y); + expectClose(back.x, 0.5, 0.01); + expectClose(back.y, 0.5, 0.01); + }); +}); + +// ─── Negative Scale Normalization ──────────────────────────── + +describe('normalizeImageTransformData', () => { + it('normalizes negative sx to positive + flipX', () => { + const data = makeImage({ sx: -2, flipX: false }); + normalizeImageTransformData(data); + expect(data.sx).toBe(2); + expect(data.flipX).toBe(true); + }); + + it('normalizes negative sy to positive + flipY', () => { + const data = makeImage({ sy: -1.5, flipY: false }); + normalizeImageTransformData(data); + expect(data.sy).toBe(1.5); + expect(data.flipY).toBe(true); + }); + + it('double negative sx toggles existing flipX', () => { + const data = makeImage({ sx: -1, flipX: true }); + normalizeImageTransformData(data); + expect(data.sx).toBe(1); + expect(data.flipX).toBe(false); // was true, negated + }); + + it('positive scale is unchanged', () => { + const data = makeImage({ sx: 2, sy: 3, flipX: true, flipY: false }); + normalizeImageTransformData(data); + expect(data.sx).toBe(2); + expect(data.sy).toBe(3); + expect(data.flipX).toBe(true); + expect(data.flipY).toBe(false); + }); +}); + +// ─── World Bounds Invariants ───────────────────────────────── + +describe('world bounds invariants', () => { + it('flip does not change world bounds', () => { + const base = makeImage({ x: 50, y: 50, sx: 1.5, sy: 1.5 }); + const bounds0 = getImageWorldBounds(base); + + const fx = makeImage({ ...base, flipX: true }); + const boundsX = getImageWorldBounds(fx); + expectClose(boundsX.x, bounds0.x); + expectClose(boundsX.w, bounds0.w); + + const fy = makeImage({ ...base, flipY: true }); + const boundsY = getImageWorldBounds(fy); + expectClose(boundsY.y, bounds0.y); + expectClose(boundsY.h, bounds0.h); + }); + + it('crop reduces world bounds proportionally', () => { + const full = getImageWorldBounds(makeImage({ x: 0, y: 0 })); + const half = getImageWorldBounds(makeImage({ x: 0, y: 0, crop: { x: 0, y: 0, w: 0.5, h: 0.5 } })); + expectClose(half.w, full.w / 2); + expectClose(half.h, full.h / 2); + }); +}); diff --git a/frontend/src/canvas/imageTransforms.ts b/frontend/src/canvas/imageTransforms.ts index 3e9cd46..9579bd7 100644 --- a/frontend/src/canvas/imageTransforms.ts +++ b/frontend/src/canvas/imageTransforms.ts @@ -37,6 +37,11 @@ export interface ImageVisibleFrame { display: ImageDisplayTransform; } +export interface ImageDisplayGeometry extends ImageVisibleFrame { + worldCorners: Point2D[]; + worldBounds: { x: number; y: number; w: number; h: number }; +} + export interface ImageLocalRect { x: number; y: number; @@ -44,6 +49,15 @@ export interface ImageLocalRect { h: number; } +export interface ImageEditorGeometry { + editorData: ImageObject; + sourceCrop: CropRect; + displayCrop: CropRect; + fullWorldCorners: Point2D[]; + cropWorldCorners: Point2D[]; + cropAnchorWorld: Point2D; +} + /** * The canonical runtime image model: * - `sourceRect` is the sampled rectangle inside the original asset @@ -120,6 +134,61 @@ export function getImageVisibleFrame(data: Pick): CropRect { + const crop = data.crop ?? { x: 0, y: 0, w: 1, h: 1 }; + return { + x: data.flipX ? 1 - (crop.x + crop.w) : crop.x, + y: data.flipY ? 1 - (crop.y + crop.h) : crop.y, + w: crop.w, + h: crop.h, + }; +} + +export function displayCropRectToSourceCrop( + data: Pick, + displayCrop: CropRect, +): CropRect { + return { + x: data.flipX ? 1 - (displayCrop.x + displayCrop.w) : displayCrop.x, + y: data.flipY ? 1 - (displayCrop.y + displayCrop.h) : displayCrop.y, + w: displayCrop.w, + h: displayCrop.h, + }; +} + +export function getImageDisplayGeometry( + data: Pick, +): ImageDisplayGeometry { + const frame = getImageVisibleFrame(data); + const worldCorners = getImageTransformedCorners(data); + return { + ...frame, + worldCorners, + worldBounds: getBoundsFromPoints(worldCorners), + }; +} + +export function getImageEditorGeometry(data: ImageObject): ImageEditorGeometry { + const sourceCrop = data.crop ? { ...data.crop } : { x: 0, y: 0, w: 1, h: 1 }; + const displayCrop = getImageDisplayCropRect(data); + const cropAnchorWorld = imageViewPointToWorld(data, displayCrop.x, displayCrop.y); + const editorData: ImageObject = { + ...data, + crop: undefined, + }; + const editorAnchorWorld = imageViewPointToWorld(editorData, displayCrop.x, displayCrop.y); + editorData.x += cropAnchorWorld.x - editorAnchorWorld.x; + editorData.y += cropAnchorWorld.y - editorAnchorWorld.y; + return { + editorData, + sourceCrop, + displayCrop, + fullWorldCorners: getImageViewRectWorldCorners(editorData, { x: 0, y: 0, w: 1, h: 1 }), + cropWorldCorners: getImageViewRectWorldCorners(editorData, displayCrop), + cropAnchorWorld, + }; +} + export function applyImageDisplayTransform(displayObject: Container, data: Pick): void { const t = getImageDisplayTransform(data); displayObject.position.set(t.x, t.y);