From c092ffb4e917a139cd31bd6babf20c63e7d5773c Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 9 Sep 2026 22:53:20 -0500 Subject: [PATCH 01/30] Keep the element editor's sticky header below overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It sat at z-index 1000, above the slideout panels (100), the modal shade (100) and the image editor modal, so it painted over anything opened on top of the page. Nothing in the editor's own content sits between 10 and 99, so lowering it keeps it above the page it scrolls over without competing with overlays. The matching `.element-editor__header` rule is dead — the class is never applied — but carried the same value, so it moves too rather than waiting to reintroduce this if it's ever revived. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../js/modules/elements/components/ElementEditScreen.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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; } From 7cc8f50a2427565b1faca3900eb09829ef966865 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 9 Sep 2026 22:53:20 -0500 Subject: [PATCH 02/30] Make craft-dialog's fullscreen actually fill the viewport `.surface` set `inline-size` on the inline axis but only `max-block-size` on the block axis, so a fullscreen dialog came out full-width and as tall as its content. That also starved anything slotted into it: the body row is `1fr` of an auto-height grid, so `block-size: 100%` on a child resolved against nothing. Adds a `--c-dialog-block-size` hook mirroring the inline-axis ones, defaulting to `auto` and to `100dvh` under `fullscreen`. Non-fullscreen dialogs are unchanged. The Fullscreen story existed but asserted nothing, which is why this shipped; it now checks the surface reports viewport dimensions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../src/components/dialog/dialog.stories.ts | 19 ++++++++++++++++++- .../src/components/dialog/dialog.styles.ts | 9 +++++++++ .../src/components/dialog/dialog.ts | 3 +++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/craftcms-ui/src/components/dialog/dialog.stories.ts b/packages/craftcms-ui/src/components/dialog/dialog.stories.ts index a817c77c951..d350363c7f9 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. */ 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.ts b/packages/craftcms-ui/src/components/dialog/dialog.ts index 0136d002dcf..4a39ac3fadc 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. From d2cfa5c83d3220855357768a653c2ba96ac8c466 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 9 Sep 2026 22:53:21 -0500 Subject: [PATCH 03/30] Let craft-button-group adopt a selection given in markup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting `name` puts the group in single-select mode, where it rewrites `active` on its children from its own `value`. With `value` unset that cleared every child, so a group handed its selection in markup lost it, and a consumer setting `active` had it stripped on the next sync with nothing to say why. It now seeds `value` from whichever child is marked `active`, the way craft-radio-group adopts its `name` from slotted inputs. An explicit `value` still wins, adoption happens once, and multi-select is untouched — it already reads `active` off its children. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../button-group/button-group.stories.ts | 29 +++++++ .../button-group/button-group.test.ts | 80 +++++++++++++++++++ .../components/button-group/button-group.ts | 41 +++++++++- 3 files changed, 149 insertions(+), 1 deletion(-) 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') { From bb70b4de740a9ee92f4fbbaa24de67139ce293ee Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 9 Sep 2026 22:53:21 -0500 Subject: [PATCH 04/30] Port the asset image editor to TypeScript composables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites Craft.AssetImageEditor — 4,400 lines of jQuery-and-Garnish — as a Vue module, keeping the same behaviour and the fabric.js 1.7 API for now. Split by responsibility rather than as one class: geometry.ts pure maths — containment, vertices, hit-testing, resize algebra, transposition constraints.ts aspect-ratio options and their orientation fabric.ts the only file that touches fabric, so the v7 upgrade is a change here and nowhere else useEditorState reactive state and the geometry derived from it useImageCanvas canvas lifecycle, sizing, zoom, viewport mask useCropper cropping layer, handles, move and resize useFocalPoint the focal marker useImageTransforms rotate, flip, straighten useEditorInteractions pointer and keyboard editing useImageEditor load, resize, view transitions, save jQuery is gone: mouse/touch pairs become pointer events with capture, `$el.offset()` becomes `getBoundingClientRect()`, and Craft.Queue becomes a promise chain. Screen-reader announcements go through `t()`; they were bare template literals and stayed English in every locale. Presentation is the component's: the cursor is a bound ref rather than `$('.body').css()`, and the custom-constraint inputs are markup rather than built from JS and read back with `.val()`. Covered by tests over the geometry pipeline — the containment invariant every drag depends on, handle clearance, and the zero-size guards. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../components/ImageEditorDialog.vue | 636 ++++++++++++++++ .../modules/image-editor/constraints.test.ts | 89 +++ .../js/modules/image-editor/constraints.ts | 62 ++ resources/js/modules/image-editor/fabric.ts | 138 ++++ .../js/modules/image-editor/geometry.test.ts | 152 ++++ resources/js/modules/image-editor/geometry.ts | 511 +++++++++++++ resources/js/modules/image-editor/types.ts | 74 ++ .../js/modules/image-editor/useCropper.ts | 652 +++++++++++++++++ .../image-editor/useCroppingConstraint.ts | 136 ++++ .../image-editor/useEditorAnnouncements.ts | 98 +++ .../image-editor/useEditorInteractions.ts | 446 ++++++++++++ .../image-editor/useEditorState.test.ts | 160 ++++ .../js/modules/image-editor/useEditorState.ts | 394 ++++++++++ .../js/modules/image-editor/useFocalPoint.ts | 367 ++++++++++ .../js/modules/image-editor/useImageCanvas.ts | 262 +++++++ .../js/modules/image-editor/useImageEditor.ts | 686 ++++++++++++++++++ .../image-editor/useImageTransforms.ts | 455 ++++++++++++ 17 files changed, 5318 insertions(+) create mode 100644 resources/js/modules/image-editor/components/ImageEditorDialog.vue create mode 100644 resources/js/modules/image-editor/constraints.test.ts create mode 100644 resources/js/modules/image-editor/constraints.ts create mode 100644 resources/js/modules/image-editor/fabric.ts create mode 100644 resources/js/modules/image-editor/geometry.test.ts create mode 100644 resources/js/modules/image-editor/geometry.ts create mode 100644 resources/js/modules/image-editor/types.ts create mode 100644 resources/js/modules/image-editor/useCropper.ts create mode 100644 resources/js/modules/image-editor/useCroppingConstraint.ts create mode 100644 resources/js/modules/image-editor/useEditorAnnouncements.ts create mode 100644 resources/js/modules/image-editor/useEditorInteractions.ts create mode 100644 resources/js/modules/image-editor/useEditorState.test.ts create mode 100644 resources/js/modules/image-editor/useEditorState.ts create mode 100644 resources/js/modules/image-editor/useFocalPoint.ts create mode 100644 resources/js/modules/image-editor/useImageCanvas.ts create mode 100644 resources/js/modules/image-editor/useImageEditor.ts create mode 100644 resources/js/modules/image-editor/useImageTransforms.ts 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..dfb85f6f52c --- /dev/null +++ b/resources/js/modules/image-editor/components/ImageEditorDialog.vue @@ -0,0 +1,636 @@ + + + + + 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..8769dfcc73a --- /dev/null +++ b/resources/js/modules/image-editor/fabric.ts @@ -0,0 +1,138 @@ +/** + * The editor still runs on fabric.js 1.7, which `FabricAsset` loads as a global + * UMD bundle rather than something we import. Everything the editor touches is + * funnelled through this one file so the upgrade to fabric 6/7 — which is ESM, + * renames `fabric.Image` to `FabricImage`, and returns promises instead of + * taking callbacks — is a change here and nowhere else. + */ + +export interface FabricObject { + left: number; + top: number; + width: number; + height: number; + angle: number; + scaleX: number; + scaleY: number; + flipX: boolean; + flipY: boolean; + opacity: number; + dirty: boolean; + globalCompositeOperation?: string; + set(properties: Record): FabricObject; + get(property: string): unknown; + animate( + properties: Record, + options: { + duration?: number; + onChange?: () => void; + onComplete?: () => void; + } + ): void; + _set(key: string, value: unknown): FabricObject; +} + +export interface FabricGroup extends FabricObject { + add(object: FabricObject): FabricGroup; + item(index: number): FabricObject; +} + +export interface FabricImage extends FabricObject { + getWidth(): number; + getHeight(): number; + setSrc(src: string, callback: (image: FabricImage) => void): void; +} + +export interface FabricCanvas { + width: number; + height: number; + enableRetinaScaling: boolean; + add(object: FabricObject): FabricCanvas; + remove(object: FabricObject | null): FabricCanvas; + renderAll(): void; + setDimensions(dimensions: {width: number; height: number}): void; + dispose(): void; +} + +interface FabricNamespace { + Rect: new (options: Record) => FabricObject; + Circle: new (options: Record) => FabricObject; + Line: new ( + points: number[], + options: Record + ) => FabricObject; + Path: new (path: string, options: Record) => FabricObject; + Group: new ( + objects: FabricObject[], + options?: Record + ) => FabricGroup; + StaticCanvas: new ( + element: HTMLCanvasElement | string, + options?: Record + ) => FabricCanvas; + Image: { + fromURL(url: string, callback: (image: FabricImage) => void): void; + }; + loadSVGFromString( + svg: string, + callback: ( + objects: FabricObject[], + options: Record + ) => void + ): void; + util: { + groupSVGElements( + objects: FabricObject[], + options: Record + ): FabricObject; + }; +} + +declare global { + interface Window { + fabric?: FabricNamespace; + } +} + +/** + * Throws rather than returning undefined: every caller needs fabric, and a + * missing global means `FabricAsset` didn't register, which is worth surfacing + * loudly instead of failing later on a property access. + */ +export function fabric(): FabricNamespace { + if (!window.fabric) { + throw new Error( + 'fabric.js is not loaded. The image editor needs FabricAsset registered.' + ); + } + + return window.fabric; +} + +/** Promise wrapper over fabric 1.x's callback-style image loading. */ +export function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + fabric().Image.fromURL(url, (image) => { + if (!image) { + reject(new Error(`Could not load image: ${url}`)); + return; + } + + resolve(image); + }); + }); +} + +/** Promise wrapper over fabric 1.x's callback-style SVG parsing. */ +export function loadSvg(svg: string): Promise { + return new Promise((resolve, reject) => { + fabric().loadSVGFromString(svg, (objects, options) => { + if (!objects?.length) { + reject(new Error('Could not parse SVG.')); + return; + } + + resolve(fabric().util.groupSVGElements(objects, 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..41eeedbbbb6 --- /dev/null +++ b/resources/js/modules/image-editor/useCropper.ts @@ -0,0 +1,652 @@ +import {fabric, type FabricGroup, type FabricObject} from './fabric'; +import { + arePointsInsideRectangle, + getBoundingRectangle, + getFarthestAllowedDeltas, + getHandlePosition, + getRectangleVertices, + resizeRectangle, + transposeRectangle, +} from './geometry'; +import type { + CropHandle, + CropperState, + Dimensions, + 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 { + const lineOptions = { + strokeWidth: 4, + stroke: state.settings.colors.white, + fill: false, + }; + + 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 (fabric().Path)(path, lineOptions)); + + return new (fabric().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 = [ + [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], + ].map((points) => new (fabric().Line)(points, gridOptions)); + + return new (fabric().Group)(lines, { + 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', + }; + + const outerOutline = new (fabric().Rect)({ + ...shared, + width: clipper.width + strokeWidth * 4, + height: clipper.height + strokeWidth * 4, + stroke: null, + }); + + const innerOutline = new (fabric().Rect)({ + ...shared, + width: clipper.width + strokeWidth * 2, + height: clipper.height + strokeWidth * 2, + stroke: null, + }); + + const outline = new (fabric().Rect)({ + ...shared, + width: clipper.width, + height: clipper.height, + stroke: state.settings.colors.white, + }); + + const group = new (fabric().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 (fabric().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', + }; + + const rings = [ + new (fabric().Circle)({ + ...shared, + radius: size + width * 2, + stroke: state.settings.colors.accent, + }), + new (fabric().Circle)({ + ...shared, + radius: size + width, + stroke: state.settings.colors.white, + }), + new (fabric().Circle)({ + ...shared, + radius: size, + stroke: state.settings.colors.accent, + }), + ]; + + const focusRing = new (fabric().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; + } + + state.croppingCanvas.value?.dispose(); + + const croppingCanvas = new (fabric().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 (fabric().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 (fabric().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; + } + + 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(); + } + + /** Scales the cropper along with the image as the editor resizes. */ + function reposition(previousImageArea: Dimensions): void { + const croppingCanvas = state.croppingCanvas.value; + const clipper = state.clipper.value; + const shade = state.croppingShade.value; + const coords = state.imageVerticeCoords.value; + + if (!croppingCanvas || !clipper || !shade || !coords) { + return; + } + + const offset = { + x: clipper.left - croppingCanvas.width / 2, + y: clipper.top - croppingCanvas.height / 2, + }; + + croppingCanvas.setDimensions({ + width: state.editorWidth.value, + height: state.editorHeight.value, + }); + + const areaFactor = + getBoundingRectangle(coords).width / previousImageArea.width; + + clipper.width = Math.round(clipper.width * areaFactor); + clipper.height = Math.round(clipper.height * areaFactor); + clipper.left = state.editorWidth.value / 2 + offset.x * areaFactor; + clipper.top = state.editorHeight.value / 2 + offset.y * areaFactor; + + shade.set({ + width: state.editorWidth.value, + height: state.editorHeight.value, + left: state.editorWidth.value / 2, + top: state.editorHeight.value / 2, + }); + + redrawElements(); + 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..b5758caf43b --- /dev/null +++ b/resources/js/modules/image-editor/useEditorAnnouncements.ts @@ -0,0 +1,98 @@ +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.width / 2) / image.width) * + 100 + ).toFixed(1); + const y = ( + ((item.top - image.top + image.height / 2) / image.height) * + 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..42075cee8ff --- /dev/null +++ b/resources/js/modules/image-editor/useEditorState.test.ts @@ -0,0 +1,160 @@ +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); +}); diff --git a/resources/js/modules/image-editor/useEditorState.ts b/resources/js/modules/image-editor/useEditorState.ts new file mode 100644 index 00000000000..7086d0d76bf --- /dev/null +++ b/resources/js/modules/image-editor/useEditorState.ts @@ -0,0 +1,394 @@ +import { + computed, + ref, + shallowRef, + toValue, + type MaybeRefOrGetter, + type Ref, + type ShallowRef, +} from 'vue'; +import {getBoundingRectangle} from './geometry'; +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 while cropping. + * + * 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 — so they + * can't be seen or dragged until something shrinks the rectangle. + */ +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 size the image occupies in the editor with no straightening or rotation + * applied — the basis every other measurement is expressed against. + */ + function getScaledImageDimensions(): Dimensions { + const originalWidth = state.originalWidth.value; + const originalHeight = state.originalHeight.value; + const editorWidth = state.editorWidth.value; + const editorHeight = state.editorHeight.value; + + if (originalHeight / originalWidth > editorHeight / editorWidth) { + const height = Math.min(editorHeight, originalHeight); + + return { + height, + width: Math.round(originalWidth / (originalHeight / height)), + }; + } + + const width = Math.min(editorWidth, 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; + } + + // Fit inside an inset area, not the whole editor, so the cropper's handles + // always have somewhere to be drawn. Everything that has to agree on where + // the image is — the vertice coords, the cropper's size, the containment + // tests — comes back through here, so they inset together. + const availableWidth = Math.max( + state.editorWidth.value - CROP_HANDLE_MARGIN * 2, + 1 + ); + const availableHeight = Math.max( + state.editorHeight.value - CROP_HANDLE_MARGIN * 2, + 1 + ); + + 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 + ); + } + + /** The area the image currently occupies, as an axis-aligned box. */ + function getOccupiedArea(): Dimensions | null { + return state.imageVerticeCoords.value + ? getBoundingRectangle(state.imageVerticeCoords.value) + : null; + } + + return { + hasOrientationChanged, + getScaledImageDimensions, + getZoomToCoverRatio, + getImageBoundingBox, + getZoomToFitRatio, + getCombinedZoomRatio, + getImageVerticeCoords, + setFittedImageVerticeCoordinates, + getEditorCenter, + needsHigherResolution, + getOccupiedArea, + }; +} + +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..7eb3135d95b --- /dev/null +++ b/resources/js/modules/image-editor/useFocalPoint.ts @@ -0,0 +1,367 @@ +import {fabric, type FabricGroup} from './fabric'; +import { + arePointsInsideRectangle, + isCenterInside, + rotatePoint, +} from './geometry'; +import type {Dimensions, 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 (fabric().Circle)({ + radius: 12, + strokeWidth: 0, + stroke: 'rgba(255,255,255,0.8)', + left: 0, + top: 0, + originX: 'center', + originY: 'center', + }); + + const outerCircle = new (fabric().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 (fabric().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 (fabric().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(); + } + + /** Keeps the marker's relative position as the editor resizes. */ + function reposition(previous: Dimensions): void { + const focalPoint = state.focalPoint.value; + const image = state.image.value; + + if (!focalPoint || !image) { + return; + } + + const newWidth = + geometry.getScaledImageDimensions().width * state.zoomRatio.value; + const ratio = newWidth / image.width / state.scaleFactor.value; + + const offsetX = + (focalPoint.left - + state.editorWidth.value / 2 - + (previous.width - state.editorWidth.value) / 2) * + ratio; + const offsetY = + (focalPoint.top - + state.editorHeight.value / 2 - + (previous.height - state.editorHeight.value) / 2) * + ratio; + + focalPoint.set({ + left: state.editorWidth.value / 2 + offsetX, + top: state.editorHeight.value / 2 + offsetY, + }); + } + + /** + * 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, + }); + } + + /** Repositions the marker onto the image after a mode change or resize. */ + function restoreFromState(): 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; + + state.canvas.value?.add(focalPoint); + } + + /** 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, + reposition, + 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..9283da2ac2e --- /dev/null +++ b/resources/js/modules/image-editor/useImageCanvas.ts @@ -0,0 +1,262 @@ +import {fabric, 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 (fabric().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.getWidth(); + state.originalHeight.value = image.getHeight(); + 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; + + image.setSrc(imageUrl, (loaded) => { + state.originalWidth.value = loaded.getWidth(); + state.originalHeight.value = loaded.getHeight(); + 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 (fabric().Rect)({ + width: image.width, + height: image.height, + 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(); + + state.image.value?.set({ + width: dimensions.width * state.zoomRatio.value, + height: dimensions.height * state.zoomRatio.value, + }); + } + + /** + * 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(); + state.croppingCanvas.value?.dispose(); + 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..1e61c43820d --- /dev/null +++ b/resources/js/modules/image-editor/useImageEditor.ts @@ -0,0 +1,686 @@ +import {computed, 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} 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; +} + +/** `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(); + + if (state.currentView.value === 'crop') { + state.zoomRatio.value = geometry.getZoomToFitRatio( + geometry.getScaledImageDimensions() + ); + + const previouslyOccupied = geometry.getOccupiedArea(); + geometry.setFittedImageVerticeCoordinates(); + + if (previouslyOccupied) { + cropper.reposition(previouslyOccupied); + } + } else { + state.zoomRatio.value = + geometry.getZoomToCoverRatio(geometry.getScaledImageDimensions()) * + state.scaleFactor.value; + } + + canvas.repositionImage(previous); + canvas.repositionViewport(); + focalPoint.reposition(previous); + canvas.zoomImage(); + canvas.renderImage(); + + if (geometry.needsHigherResolution()) { + canvas.reloadImage(imageUrl(), updateSizeAndPosition); + } + } + + /** Animates the image and viewport between the rotate and crop layouts. */ + function transitionMode( + imageProperties: Record, + viewportProperties: Record, + 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); + + transitionMode( + { + width: dimensions.width * state.zoomRatio.value, + height: dimensions.height * state.zoomRatio.value, + 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(); + } + + transitionMode( + { + width: dimensions.width * state.zoomRatio.value, + height: dimensions.height * state.zoomRatio.value, + 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; + + updateSizeAndPosition(); + + if (previousView === 'crop' && view !== 'crop') { + enqueue(disableCropMode); + } else if (previousView !== 'crop' && view === 'crop') { + enqueue(enableCropMode); + } + + state.currentView.value = view; + } + + /** 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. + } + } + + /** 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); + + if (options.focalPoint) { + focalPoint.create(); + } + } + + 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(); + + // The transition placed the rectangle against wherever the image was + // when the animation started; the resize we just applied has since + // moved the image. Re-derive rather than translate, or the two stay + // out of step and every containment test fails. + if (state.currentView.value === 'crop') { + cropper.restoreFromState(); + canvas.renderCropper(); + } + } + } + ); + + // 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, + 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..fba53859f97 --- /dev/null +++ b/resources/js/modules/image-editor/useImageTransforms.ts @@ -0,0 +1,455 @@ +import {fabric} 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; + } + + const imageProperties: Record = { + angle: image.angle + degrees, + width: + scaled.width * imageZoomRatio * (scaleFactor < 1 ? scaleFactor : 1), + height: + scaled.height * imageZoomRatio * (scaleFactor < 1 ? scaleFactor : 1), + }; + + // 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: Record = { + 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 = [ + new (fabric().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 (fabric().Line)( + [i * xStep, 0, i * xStep, gridHeight], + strokeOptions + ) + ); + parts.push( + new (fabric().Line)([0, i * yStep, gridWidth, i * yStep], strokeOptions) + ); + } + + state.grid.value = new (fabric().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; From 14b61e868760f4da91cd4c8e2d177d65742b5a73 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 9 Sep 2026 22:53:22 -0500 Subject: [PATCH 05/30] Open the image editor from the asset edit screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the ported editor as a fullscreen craft-dialog on the asset edit page, so it gets a real 's focus containment and Escape handling rather than a hand-rolled modal. AssetEditViewModel::imageEditor() supplies what the dialog needs, or null when the asset isn't an image the user may edit — the same value decides whether the dialog renders at all, keeping the permission and format checks server-side. The Edit Image button drops its jQuery blob for a `data-image-editor` attribute; the screen delegates on it from the preview fragment's `ready` event. Saving in place reloads so the thumbnail refreshes; saving a copy leaves this asset alone and needs nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- resources/js/pages/assets/Edit.vue | 56 +++++++++++++++++++++- src/Asset/Elements/Asset.php | 44 +++++++---------- src/Http/ViewModels/AssetEditViewModel.php | 39 +++++++++++++++ 3 files changed, 110 insertions(+), 29 deletions(-) diff --git a/resources/js/pages/assets/Edit.vue b/resources/js/pages/assets/Edit.vue index 7a5ae3e0a26..0ef3e115da8 100644 --- a/resources/js/pages/assets/Edit.vue +++ b/resources/js/pages/assets/Edit.vue @@ -1,8 +1,21 @@ diff --git a/resources/js/modules/image-editor/useEditorInteractions.ts b/resources/js/modules/image-editor/useEditorInteractions.ts index 1e09f0410ab..454cf7610fb 100644 --- a/resources/js/modules/image-editor/useEditorInteractions.ts +++ b/resources/js/modules/image-editor/useEditorInteractions.ts @@ -128,7 +128,11 @@ export function useEditorInteractions( ? hitTestHandle(point, state.clipper.value) : null; - if (state.focalPoint.value && isOver(point, state.focalPoint.value)) { + if ( + focalEditable() && + state.focalPoint.value && + isOver(point, state.focalPoint.value) + ) { cursor.value = 'pointer'; } else if (handle) { cursor.value = getCursorForHandle(handle); @@ -141,6 +145,18 @@ export function useEditorInteractions( } } + /** + * Whether the focal point can be picked up or moved. + * + * Not while cropping, where the marker is taken off the canvas. It still + * exists and still has a position, and hit-testing reads that position + * rather than whether it's drawn -- so without this an invisible marker + * would take presses meant for the cropper's handles. + */ + function focalEditable(): boolean { + return state.currentView.value !== 'crop'; + } + /** Whether a press is currently driving the cropper or focal point. */ function isDragging(): boolean { return ( @@ -155,7 +171,9 @@ export function useEditorInteractions( // Focal point wins over a resize handle, which wins over a drag. const overFocal = - Boolean(state.focalPoint.value) && isOver(point, state.focalPoint.value); + focalEditable() && + Boolean(state.focalPoint.value) && + isOver(point, state.focalPoint.value); const handle = state.clipper.value ? hitTestHandle(point, state.clipper.value) : null; @@ -251,6 +269,7 @@ export function useEditorInteractions( } } else if ( editing.focalPickedUp.value && + focalEditable() && !draggingFocal.value && !draggingCropper.value && !scalingCropper.value diff --git a/resources/js/modules/image-editor/useImageEditor.ts b/resources/js/modules/image-editor/useImageEditor.ts index 64af7caafad..3bc82d6425a 100644 --- a/resources/js/modules/image-editor/useImageEditor.ts +++ b/resources/js/modules/image-editor/useImageEditor.ts @@ -282,9 +282,12 @@ export function useImageEditor(options: ImageEditorOptions) { height: cropperState.height * scale, }); - if (state.focalPoint.value) { - focalPoint.restoreFromState(); - } + // The transition lifted the marker off, and it stays off while + // cropping. It's still positioned for the crop's zoom, though: + // `disableCropMode` checks it against the rectangle in these + // coordinates to decide whether it survives the crop, and then puts + // it back itself. + focalPoint.positionFromState(); } ); } @@ -365,14 +368,22 @@ export function useImageEditor(options: ImageEditorOptions) { 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. + // straightening rule under the image goes away while cropping — and settle + // at its new size before anything is measured. state.currentView.value = view; + // Nothing picked up survives the switch. A crop handle has no cropper to + // drive on the Rotate tab, and the focal point can't be moved while + // cropping, so a marker picked up on Rotate is put back down here rather + // than left answering the arrow keys. + editing.reset(); + focalPoint.setPickedUpStyles(false); + 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. + // One measurement, taken once the layout has settled. Laying out against + // the old size and letting the resize correct it afterwards is what made + // the image lurch: it moved for the old size, animated towards a target + // computed for the old size, then moved again when the layout landed. // // Safe to measure without preserving the previous dimensions: both // transitions set the image's position outright rather than shifting it From 076c86d4a392c953334eafa3bc4c8d143fdae22d Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Mon, 14 Sep 2026 11:32:27 -0500 Subject: [PATCH 27/30] Add undo and redo to the image editor Every edit is now a step that can be undone and redone: rotating, flipping, straightening, resizing and moving the crop, changing the constraint or orientation, and adding, removing or moving the focal point. Undo and Redo buttons sit beside Reset, with Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z or Ctrl+Y while the dialog is open and nothing is being typed into. The history holds before-and-after snapshots rather than operations, and undoing writes the recorded state back. Some edits have no clean inverse -- straightening back to the old angle doesn't return the old crop, since straightening fits the crop to the viewport as it goes -- so restoring what was recorded is the only way an undo lands exactly. A snapshot also carries the dialog's own control state through a small adapter, so a restored crop comes back under the constraint it had. A step from the other tab is restored in its own view, switching tabs first, so an undo never rotates under the cropper. A drag or a straightening slide is one step; a run of keyboard nudges in a pick-up session, or of arrow keys on the straightening rule, merges into one. Nothing moves through the history while an animation, gesture or recording is still settling, and Reset clears it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p --- .../components/ImageEditorDialog.vue | 156 ++++++- .../image-editor/useEditHistory.test.ts | 124 +++++ .../js/modules/image-editor/useEditHistory.ts | 124 +++++ .../image-editor/useEditorInteractions.ts | 34 +- .../js/modules/image-editor/useImageEditor.ts | 433 +++++++++++++++++- resources/translations/en/app.php | 1 + 6 files changed, 847 insertions(+), 25 deletions(-) create mode 100644 resources/js/modules/image-editor/useEditHistory.test.ts create mode 100644 resources/js/modules/image-editor/useEditHistory.ts diff --git a/resources/js/modules/image-editor/components/ImageEditorDialog.vue b/resources/js/modules/image-editor/components/ImageEditorDialog.vue index 16bd32ec5ee..0523071c32c 100644 --- a/resources/js/modules/image-editor/components/ImageEditorDialog.vue +++ b/resources/js/modules/image-editor/components/ImageEditorDialog.vue @@ -1,5 +1,6 @@ @@ -310,7 +428,7 @@ function onStraightenEnd(): void { `craft-tabs` owns the tablist: it assigns each tab its id, role, `aria-controls`/`aria-selected` and roving tabindex, pairs tabs with panels by document order, and drives panel visibility. --> - +
@@ -565,7 +683,7 @@ function onStraightenEnd(): void { id="slide-rule" :label="t('Rotate')" :value="straightenValue" - @start="editor.showGrid" + @start="onStraightenStart" @change="onStraightenChange" @end="onStraightenEnd" /> @@ -578,6 +696,22 @@ function onStraightenEnd(): void {
+ + + useEditHistory({equals: (a, b) => a === b, limit}); + +describe('useEditHistory', () => { + it('undoes and redoes steps in order', () => { + const h = history(); + + h.record(0, 1); + h.record(1, 2); + + expect(h.undo()).toBe(1); + expect(h.undo()).toBe(0); + expect(h.undo()).toBe(null); + + expect(h.redo()).toBe(1); + expect(h.redo()).toBe(2); + expect(h.redo()).toBe(null); + }); + + it('tracks whether there is anything to undo or redo', () => { + const h = history(); + + expect(h.canUndo.value).toBe(false); + expect(h.canRedo.value).toBe(false); + + h.record(0, 1); + expect(h.canUndo.value).toBe(true); + + h.undo(); + expect(h.canUndo.value).toBe(false); + expect(h.canRedo.value).toBe(true); + }); + + it('drops the redo stack when a new edit is made after undoing', () => { + const h = history(); + + h.record(0, 1); + h.record(1, 2); + h.undo(); + h.record(1, 5); + + expect(h.canRedo.value).toBe(false); + expect(h.undo()).toBe(1); + }); + + it('ignores a step that changed nothing', () => { + const h = history(); + + h.record(3, 3); + + expect(h.canUndo.value).toBe(false); + }); + + it('merges a keyed run into one step that undoes to where it started', () => { + // A keyboard pick-up session: every nudge records, but it's one edit. + const h = history(); + + h.record(0, 1, 'nudge'); + h.record(1, 2, 'nudge'); + h.record(2, 3, 'nudge'); + + expect(h.undo()).toBe(0); + expect(h.canUndo.value).toBe(false); + expect(h.redo()).toBe(3); + }); + + it('drops a keyed run that ends back where it started', () => { + const h = history(); + + h.record(0, 1, 'nudge'); + h.record(1, 0, 'nudge'); + + expect(h.canUndo.value).toBe(false); + }); + + it('starts a new step after a run is sealed', () => { + const h = history(); + + h.record(0, 1, 'nudge'); + h.seal(); + h.record(1, 2, 'nudge'); + + expect(h.undo()).toBe(1); + expect(h.undo()).toBe(0); + }); + + it('does not merge into a step that has been undone and redone', () => { + // A redone step is finished, not a run still in progress. + const h = history(); + + h.record(0, 1, 'nudge'); + h.undo(); + h.redo(); + h.record(1, 2, 'nudge'); + + expect(h.undo()).toBe(1); + }); + + it('keeps only the most recent steps once past the limit', () => { + const h = history(2); + + h.record(0, 1); + h.record(1, 2); + h.record(2, 3); + + expect(h.undo()).toBe(2); + expect(h.undo()).toBe(1); + expect(h.undo()).toBe(null); + }); + + it('forgets everything when cleared', () => { + const h = history(); + + h.record(0, 1); + h.undo(); + h.clear(); + + expect(h.canUndo.value).toBe(false); + expect(h.canRedo.value).toBe(false); + }); +}); diff --git a/resources/js/modules/image-editor/useEditHistory.ts b/resources/js/modules/image-editor/useEditHistory.ts new file mode 100644 index 00000000000..7df6b87340c --- /dev/null +++ b/resources/js/modules/image-editor/useEditHistory.ts @@ -0,0 +1,124 @@ +import {computed, shallowRef, type ComputedRef} from 'vue'; + +export interface HistoryEntry { + /** What undoing this entry returns to. */ + before: T; + /** What redoing it returns to. */ + after: T; + /** + * Recording again under the same key merges into this entry instead of + * adding one -- how a run of keyboard nudges becomes a single step. + */ + key?: string; +} + +export interface EditHistoryOptions { + /** Whether two states are the same edit, so recording them is a no-op. */ + equals?: (a: T, b: T) => boolean; + /** How many steps are kept before the oldest is dropped. */ + limit?: number; +} + +export interface EditHistory { + canUndo: ComputedRef; + canRedo: ComputedRef; + record(before: T, after: T, key?: string): void; + undo(): T | null; + redo(): T | null; + seal(): void; + clear(): void; +} + +/** + * An undo/redo stack of before-and-after states. + * + * It holds states rather than operations: undoing hands back the state to + * restore, not an action to reverse. Some edits have no clean inverse -- + * straightening back to the old angle doesn't give the old crop back, because + * straightening fits the crop to the viewport as it goes -- so putting the + * recorded state back is the only way an undo lands exactly. + * + * Knows nothing about images; the editor decides what a state is and how to + * restore one. + */ +export function useEditHistory({ + equals, + limit = 100, +}: EditHistoryOptions = {}): EditHistory { + const past = shallowRef[]>([]); + const future = shallowRef[]>([]); + + const canUndo = computed(() => past.value.length > 0); + const canRedo = computed(() => future.value.length > 0); + + function isNoOp(before: T, after: T): boolean { + return equals?.(before, after) ?? false; + } + + /** Adds a step, or extends the last one if it was recorded under `key`. */ + function record(before: T, after: T, key?: string): void { + const top = past.value.at(-1); + + if (key !== undefined && top?.key === key) { + const rest = past.value.slice(0, -1); + + // Nudged back to where the run started: nothing left to undo. + past.value = isNoOp(top.before, after) + ? rest + : [...rest, {...top, after}]; + future.value = []; + return; + } + + if (isNoOp(before, after)) { + return; + } + + past.value = [...past.value, {before, after, key}].slice(-limit); + future.value = []; + } + + /** Steps back, returning the state to restore. */ + function undo(): T | null { + const top = past.value.at(-1); + + if (!top) { + return null; + } + + past.value = past.value.slice(0, -1); + future.value = [...future.value, {...top, key: undefined}]; + + return top.before; + } + + /** Steps forward again, returning the state to restore. */ + function redo(): T | null { + const top = future.value.at(-1); + + if (!top) { + return null; + } + + future.value = future.value.slice(0, -1); + past.value = [...past.value, top]; + + return top.after; + } + + /** Ends a keyed run, so the next record under that key is its own step. */ + function seal(): void { + const top = past.value.at(-1); + + if (top?.key !== undefined) { + past.value = [...past.value.slice(0, -1), {...top, key: undefined}]; + } + } + + function clear(): void { + past.value = []; + future.value = []; + } + + return {canUndo, canRedo, record, undo, redo, seal, clear}; +} diff --git a/resources/js/modules/image-editor/useEditorInteractions.ts b/resources/js/modules/image-editor/useEditorInteractions.ts index 454cf7610fb..132efdd5bfa 100644 --- a/resources/js/modules/image-editor/useEditorInteractions.ts +++ b/resources/js/modules/image-editor/useEditorInteractions.ts @@ -16,6 +16,21 @@ import type {EditorState} from './useEditorState'; import type {FocalPoint} from './useFocalPoint'; import type {ImageCanvas} from './useImageCanvas'; +/** + * How an edit made here reaches the editor's history. + * + * Passed in rather than imported: the editor owns the history and assembles + * this composable, so a direct import would run the other way round. + */ +export interface ChangeRecorder { + /** Takes the "before" state at the start of a gesture. */ + begin(): void; + /** Records the gesture once whatever it animated has settled. */ + commit(): void; + /** Records a single change made by `work`; a shared `key` merges a run. */ + record(work: () => void, key?: string): void; +} + const NUDGE_KEYS: Record = { ArrowUp: 'up', ArrowDown: 'down', @@ -82,7 +97,8 @@ export function useEditorInteractions( canvas: ImageCanvas, cropper: Cropper, focalPoint: FocalPoint, - announcements: EditorAnnouncements + announcements: EditorAnnouncements, + recorder: ChangeRecorder ) { /** Bound to the editor element, so the cursor stays a template concern. */ const cursor = ref('default'); @@ -94,6 +110,8 @@ export function useEditorInteractions( const draggingFocal = ref(false); const draggingCropper = ref(false); const scalingCropper = ref(false); + /** Bumped per pick-up, so a session's nudges merge into one history step. */ + let nudgeSession = 0; /** Pointer position relative to the cropping canvas's top-left. */ function toCanvasPoint(event: PointerEvent): Point { @@ -183,6 +201,8 @@ export function useEditorInteractions( return; } + recorder.begin(); + previousPointer.value = {x: event.clientX, y: event.clientY}; if (overFocal) { @@ -275,7 +295,8 @@ export function useEditorInteractions( !scalingCropper.value ) { // While picked up, clicking anywhere moves the focal point there. - focalPoint.moveTo(toCanvasPoint(event)); + const point = toCanvasPoint(event); + recorder.record(() => focalPoint.moveTo(point)); } draggingCropper.value = false; @@ -284,6 +305,8 @@ export function useEditorInteractions( pointerHandle.value = null; draggingFocal.value = false; focalClicked.value = false; + + recorder.commit(); } function onPointerLeave(event: PointerEvent): void { @@ -304,6 +327,7 @@ export function useEditorInteractions( function pickUp(handle: FabricElementHandle, label?: string): void { editing.reset(); + nudgeSession += 1; if (handle === 'rectangle') { editing.rectanglePickedUp.value = true; @@ -356,7 +380,7 @@ export function useEditorInteractions( label?: string ): void { if (handle === 'focalpoint') { - focalPoint.toggle(); + recorder.record(() => focalPoint.toggle()); } if (pressed) { @@ -369,6 +393,10 @@ export function useEditorInteractions( /** Moves whatever is currently picked up. */ function nudge(direction: NudgeDirection): void { + recorder.record(() => applyNudge(direction), `nudge-${nudgeSession}`); + } + + function applyNudge(direction: NudgeDirection): void { const deltas = getDeltasFromDirection(direction); if (editing.rectanglePickedUp.value) { diff --git a/resources/js/modules/image-editor/useImageEditor.ts b/resources/js/modules/image-editor/useImageEditor.ts index 3bc82d6425a..1643a25c515 100644 --- a/resources/js/modules/image-editor/useImageEditor.ts +++ b/resources/js/modules/image-editor/useImageEditor.ts @@ -21,7 +21,15 @@ import { import {useFocalPoint} from './useFocalPoint'; import {useImageCanvas} from './useImageCanvas'; import {useImageTransforms} from './useImageTransforms'; -import type {Dimensions, EditorView, RelativeFocalPoint} from './types'; +import {useEditHistory} from './useEditHistory'; +import type { + CropperState, + Dimensions, + EditorView, + FlipData, + FocalPointState, + RelativeFocalPoint, +} from './types'; export interface ImageEditorOptions { assetId: number; @@ -40,6 +48,78 @@ export interface SaveResult { /** `replace` overwrites the asset's file; `copy` saves the result alongside it. */ export type SaveMode = 'replace' | 'copy'; +/** + * The host's own control state -- the selected constraint, the orientation -- + * which a history step has to put back alongside the image. + */ +export interface UiAdapter { + capture(): unknown; + /** Called after the editor has restored a snapshot. */ + apply(ui: unknown): void; +} + +/** Everything an edit can change, as it stood at one moment. */ +interface EditorSnapshot { + view: EditorView; + editorWidth: number; + editorHeight: number; + viewportRotation: number; + imageStraightenAngle: number; + flipData: FlipData; + zoomRatio: number; + scaleFactor: number; + cropperState: CropperState | null; + croppingConstraint: number | false; + hasFocalPoint: boolean; + focalPointState: FocalPointState | null; + image: { + angle: number; + left: number; + top: number; + flipX: boolean; + flipY: boolean; + }; + viewport: { + left: number; + top: number; + width: number; + height: number; + angle: number; + }; + ui: unknown; +} + +/** + * The part of a snapshot that is the edit itself, for telling whether a step + * changed anything. Positions, zoom and the view are consequences of the edit + * and of the editor's size, not the edit; offsets are taken relative to the + * image so a resize between two snapshots doesn't read as a change. + */ +function editOf(snapshot: EditorSnapshot): string { + const round = (value: number) => Math.round(value * 1000) / 1000; + const crop = snapshot.cropperState; + const focal = snapshot.focalPointState; + + return JSON.stringify({ + rotation: snapshot.viewportRotation, + straighten: round(snapshot.imageStraightenAngle), + flip: snapshot.flipData, + crop: crop + ? [crop.offsetX, crop.offsetY, crop.width, crop.height].map((value) => + round(value / crop.imageDimensions.width) + ) + : null, + constraint: snapshot.croppingConstraint, + focal: + snapshot.hasFocalPoint && focal + ? [focal.offsetX, focal.offsetY].map((value) => + round(value / focal.imageDimensions.width) + ) + : null, + ui: snapshot.ui, + }); +} + function defaultSettings( allowDegreeFractions: boolean, prefersReducedMotion: boolean @@ -105,7 +185,8 @@ export function useImageEditor(options: ImageEditorOptions) { canvas, cropper, focalPoint, - announcements + announcements, + {begin: beginChange, commit: commitChange, record: recordChange} ); const helpers = useHelpers(); @@ -122,6 +203,317 @@ export function useImageEditor(options: ImageEditorOptions) { /** Bumped on save so a reloaded image isn't served from cache. */ const cacheBust = ref(Date.now()); + const history = useEditHistory({ + equals: (a, b) => editOf(a) === editOf(b), + }); + /** True while a history step is being put back. */ + const restoring = ref(false); + /** Changes still waiting to settle before they can be recorded. */ + const pendingRecords = ref(0); + /** The "before" of a gesture that has started and not yet been recorded. */ + const gestureBefore = ref(null); + let recordingDepth = 0; + let uiAdapter: UiAdapter | null = null; + + /** + * Nothing moves through the history while an edit is unfinished: a step + * restored underneath a running animation, or recorded after an undo, would + * leave the stack describing something that never happened. + */ + const canStep = computed( + () => + isReady.value && + !restoring.value && + !state.animationInProgress.value && + pendingRecords.value === 0 && + gestureBefore.value === null + ); + const canUndo = computed(() => canStep.value && history.canUndo.value); + const canRedo = computed(() => canStep.value && history.canRedo.value); + + function captureSnapshot(): EditorSnapshot | null { + const image = state.image.value; + const viewport = state.viewport.value; + const crop = state.cropperState.value; + const focal = state.focalPointState.value; + + if (!image || !viewport) { + return null; + } + + return { + view: state.currentView.value, + editorWidth: state.editorWidth.value, + editorHeight: state.editorHeight.value, + viewportRotation: state.viewportRotation.value, + imageStraightenAngle: state.imageStraightenAngle.value, + flipData: {...state.flipData.value}, + zoomRatio: state.zoomRatio.value, + scaleFactor: state.scaleFactor.value, + cropperState: crop + ? {...crop, imageDimensions: {...crop.imageDimensions}} + : null, + croppingConstraint: state.croppingConstraint.value, + hasFocalPoint: state.focalPoint.value !== null, + focalPointState: focal + ? {...focal, imageDimensions: {...focal.imageDimensions}} + : null, + image: { + angle: image.angle, + left: image.left, + top: image.top, + flipX: image.flipX, + flipY: image.flipY, + }, + viewport: { + left: viewport.left, + top: viewport.top, + width: viewport.width, + height: viewport.height, + angle: viewport.angle, + }, + ui: uiAdapter?.capture() ?? null, + }; + } + + /** Resolves once a view change, and anything it animates, has finished. */ + async function settle(): Promise { + await nextTick(); + await new Promise((resolve) => + requestAnimationFrame(() => resolve()) + ); + + if (!state.animationInProgress.value) { + return; + } + + await new Promise((resolve) => { + const stop = watch( + () => state.animationInProgress.value, + (busy) => { + if (!busy) { + stop(); + resolve(); + } + } + ); + }); + } + + function recordAfterSettling(before: EditorSnapshot, key?: string): void { + pendingRecords.value += 1; + + void settle().then(() => { + pendingRecords.value -= 1; + const after = captureSnapshot(); + + if (after) { + history.record(before, after, key); + } + }); + } + + /** + * Records whatever `work` changes as one history step. Nests: an operation + * that records itself, run from inside a larger change, is part of that + * change rather than a step of its own. + */ + function recordChange(work: () => void, key?: string): void { + if ( + recordingDepth > 0 || + restoring.value || + gestureBefore.value || + !isReady.value + ) { + work(); + return; + } + + const before = captureSnapshot(); + recordingDepth += 1; + + try { + work(); + } finally { + recordingDepth -= 1; + } + + if (before) { + recordAfterSettling(before, key); + } + } + + /** Opens a gesture -- a drag, a straightening slide -- as one step. */ + function beginChange(): void { + if (gestureBefore.value || restoring.value || !isReady.value) { + return; + } + + gestureBefore.value = captureSnapshot(); + } + + function commitChange(key?: string): void { + const before = gestureBefore.value; + gestureBefore.value = null; + + if (before) { + recordAfterSettling(before, key); + } + } + + /** + * Puts a snapshot back exactly: every value is written as it was recorded, + * rather than worked out again. Straightening and rotating fit the crop to + * the viewport as they go, so replaying them in reverse doesn't land where + * the edit started. + */ + async function restoreSnapshot(snapshot: EditorSnapshot): Promise { + restoring.value = true; + + try { + // The other tab's edit is restored in its own view, never across one -- + // rotating under the cropper is what the tabs exist to prevent. + if (snapshot.view !== state.currentView.value) { + showView(snapshot.view); + await settle(); + } + + const image = state.image.value; + const viewport = state.viewport.value; + + if (!image || !viewport) { + return; + } + + const measured = { + width: state.editorWidth.value, + height: state.editorHeight.value, + }; + + // Written against the size the snapshot was taken at; a resize since is + // corrected below from the restored state. + state.editorWidth.value = snapshot.editorWidth; + state.editorHeight.value = snapshot.editorHeight; + state.viewportRotation.value = snapshot.viewportRotation; + state.imageStraightenAngle.value = snapshot.imageStraightenAngle; + state.flipData.value = {...snapshot.flipData}; + state.zoomRatio.value = snapshot.zoomRatio; + state.scaleFactor.value = snapshot.scaleFactor; + state.cropperState.value = snapshot.cropperState + ? { + ...snapshot.cropperState, + imageDimensions: {...snapshot.cropperState.imageDimensions}, + } + : null; + state.croppingConstraint.value = snapshot.croppingConstraint; + state.focalPointState.value = snapshot.focalPointState + ? { + ...snapshot.focalPointState, + imageDimensions: {...snapshot.focalPointState.imageDimensions}, + } + : null; + + image.set({ + angle: snapshot.image.angle, + left: snapshot.image.left, + top: snapshot.image.top, + }); + image.flipX = snapshot.image.flipX; + image.flipY = snapshot.image.flipY; + canvas.zoomImage(); + viewport.set({...snapshot.viewport}); + transforms.hideGrid(); + + restoreFocalMarker(snapshot); + + if ( + measured.width !== snapshot.editorWidth || + measured.height !== snapshot.editorHeight + ) { + updateSizeAndPosition(); + } else { + geometry.setFittedImageVerticeCoordinates(); + + if (state.currentView.value === 'crop') { + cropper.restoreFromState(); + canvas.renderCropper(); + } + } + + editing.reset(); + uiAdapter?.apply(snapshot.ui); + canvas.renderImage(); + } finally { + restoring.value = false; + } + } + + /** + * Brings the focal point marker in line with a snapshot. It's taken off the + * canvas first whatever happens, so it can never end up there twice. + */ + function restoreFocalMarker(snapshot: EditorSnapshot): void { + const existing = state.focalPoint.value; + + if (existing) { + state.canvas.value?.remove(existing); + } + + if (!snapshot.hasFocalPoint) { + state.focalPoint.value = null; + return; + } + + if (!existing) { + // `create()` places a marker at the middle of the view when its offset + // is zero; the recorded state is put back over that below. + const recorded = state.focalPointState.value; + focalPoint.create(); + state.focalPointState.value = recorded; + } + + const marker = state.focalPoint.value; + + if (!marker) { + return; + } + + state.canvas.value?.remove(marker); + focalPoint.positionFromState(); + focalPoint.setPickedUpStyles(false); + + // Off the canvas while cropping, as it is whenever the crop view opens. + if (state.currentView.value !== 'crop') { + state.canvas.value?.add(marker); + focalPoint.updateVisibilityForViewport(); + } + } + + function undo(): void { + if (!canUndo.value) { + return; + } + + history.seal(); + const snapshot = history.undo(); + + if (snapshot) { + void restoreSnapshot(snapshot); + } + } + + function redo(): void { + if (!canRedo.value) { + return; + } + + const snapshot = history.redo(); + + if (snapshot) { + void restoreSnapshot(snapshot); + } + } + /** * Mode transitions animate, so overlapping ones would fight. They're chained * onto a single promise rather than run concurrently. @@ -590,6 +982,10 @@ export function useImageEditor(options: ImageEditorOptions) { } canvas.renderImage(); + + // Back to the original, so there is nothing before it to undo to. + history.clear(); + gestureBefore.value = null; } async function load(): Promise { @@ -826,24 +1222,39 @@ export function useImageEditor(options: ImageEditorOptions) { // Views showView, + // History + undo, + redo, + canUndo, + canRedo, + recordChange, + beginChange, + commitChange, + setUiAdapter: (adapter: UiAdapter | null) => { + uiAdapter = adapter; + }, + // Transforms - rotate: transforms.rotate, - flip: transforms.flip, + rotate: (degrees: 90 | -90) => + recordChange(() => transforms.rotate(degrees)), + flip: (axis: 'x' | 'y') => recordChange(() => transforms.flip(axis)), straighten: transforms.straighten, showGrid: transforms.showGrid, hideGrid: transforms.hideGrid, cleanupFocalPointAfterStraighten: focalPoint.cleanupAfterStraighten, // Focal point - toggleFocalPoint: focalPoint.toggle, + toggleFocalPoint: () => recordChange(() => focalPoint.toggle()), // Cropping constraint - applyConstraint: (value: ConstraintValue) => constraint.apply(value), - turnCrop, - applyCustomConstraint: (width: number, height: number) => { - constraint.setCustomConstraint(width, height); - constraint.enforce(); - }, + applyConstraint: (value: ConstraintValue) => + recordChange(() => constraint.apply(value)), + turnCrop: () => recordChange(turnCrop), + applyCustomConstraint: (width: number, height: number) => + recordChange(() => { + constraint.setCustomConstraint(width, height); + constraint.enforce(); + }), // Pointer onPointerDown: interactions.onPointerDown, diff --git a/resources/translations/en/app.php b/resources/translations/en/app.php index aaff0942fe7..ebdc669ce3f 100644 --- a/resources/translations/en/app.php +++ b/resources/translations/en/app.php @@ -1424,6 +1424,7 @@ 'Recovery codes can be used as a backup form of verification, when you’re unable to use your primary method.' => 'Recovery codes can be used as a backup form of verification, when you’re unable to use your primary method.', 'Recovery codes generated.' => 'Recovery codes generated.', 'Red' => 'Red', + 'Redo' => 'Redo', 'Refresh' => 'Refresh', 'Regenerate' => 'Regenerate', 'Regenerating project config YAML files from the loaded project config…' => 'Regenerating project config YAML files from the loaded project config…', From c0902d1123fde9b4dbf3cc1eb859a82d7c40f76b Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Mon, 14 Sep 2026 11:42:53 -0500 Subject: [PATCH 28/30] Emit the toggle attribute from the Button builder Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8 --- src/Cp/Components/Button.php | 14 ++++++++++++++ tests/Unit/Cp/Components/ButtonTest.php | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/src/Cp/Components/Button.php b/src/Cp/Components/Button.php index 00281fc874f..52b97e2b835 100644 --- a/src/Cp/Components/Button.php +++ b/src/Cp/Components/Button.php @@ -45,6 +45,8 @@ class Button extends ViewComponent protected bool $active = false; + protected bool $toggle = false; + protected ?string $accessibleName = null; protected ?string $align = null; @@ -153,6 +155,17 @@ public function active(bool $active = true): static return $this; } + /** + * Makes the button a toggle: `aria-pressed` follows `active`, and activating + * it fires a cancelable `craft-toggle` for whoever owns `active` to act on. + */ + public function toggle(bool $toggle = true): static + { + $this->toggle = $toggle; + + return $this; + } + /** Accessible name override, for icon-only buttons. */ public function accessibleName(?string $accessibleName): static { @@ -243,6 +256,7 @@ protected function hostAttributes(): array 'icon-position' => $this->iconPosition, 'loading' => $this->loading, 'active' => $this->active ? 'true' : null, + 'toggle' => $this->toggle, 'value' => $this->value, 'disabled' => $this->isDisabled(), 'accessible-name' => $this->accessibleName, diff --git a/tests/Unit/Cp/Components/ButtonTest.php b/tests/Unit/Cp/Components/ButtonTest.php index 6d3068f43c6..1f23fd5bb56 100644 --- a/tests/Unit/Cp/Components/ButtonTest.php +++ b/tests/Unit/Cp/Components/ButtonTest.php @@ -41,6 +41,13 @@ ->and($html)->toContain(' disabled'); }); + it('renders the toggle flag only when set', function () { + expect(Button::make()->toggle()->active()->toHtml()) + ->toContain(' toggle') + ->toContain('active="true"') + ->and(Button::make()->toHtml())->not->toContain('toggle'); + }); + it('renders as a link with href, dropping the type', function () { $html = Button::make() ->type('submit') From beffb505ac4eae9c3e519c3fe5100ed631a0943e Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Mon, 14 Sep 2026 12:35:05 -0500 Subject: [PATCH 29/30] Tame the comments --- .ai/rules/craftcms-ui.md | 6 + .ai/rules/js.md | 6 + .ai/rules/tests-laravel.md | 3 + .ai/rules/tests.md | 3 + .../craftcms-ui/scripts/generate-colors.js | 8 +- .../button-group/button-group.stories.ts | 9 +- .../button-group/button-group.test.ts | 16 +- .../components/button-group/button-group.ts | 14 +- .../components/button/button.browser.test.ts | 3 - .../src/components/button/button.ts | 34 +-- .../src/components/dialog/dialog.stories.ts | 10 +- .../src/components/dialog/dialog.styles.ts | 7 +- .../slide-rule/slide-rule.stories.ts | 255 ------------------ .../slide-rule/slide-rule.styles.ts | 124 +-------- .../components/slide-rule/slide-rule.test.ts | 5 +- .../src/components/slide-rule/slide-rule.ts | 50 +--- .../craftcms-ui/src/styles/shared/base.css | 10 +- .../src/styles/shared/colorable.css | 8 +- .../craftcms-ui/src/styles/shared/tokens.css | 8 +- .../components/ImageEditorDialog.vue | 81 ++---- .../components/dialogOpenBinding.test.ts | 11 +- .../modules/image-editor/constraints.test.ts | 2 - .../js/modules/image-editor/fabric.test.ts | 7 +- resources/js/modules/image-editor/fabric.ts | 38 +-- .../js/modules/image-editor/geometry.test.ts | 4 - resources/js/modules/image-editor/geometry.ts | 22 +- .../js/modules/image-editor/useCropper.ts | 54 +--- .../js/modules/image-editor/useEditHistory.ts | 12 +- .../image-editor/useEditorAnnouncements.ts | 6 - .../image-editor/useEditorInteractions.ts | 27 +- .../image-editor/useEditorState.test.ts | 22 +- .../js/modules/image-editor/useEditorState.ts | 32 +-- .../js/modules/image-editor/useImageCanvas.ts | 4 - .../js/modules/image-editor/useImageEditor.ts | 129 ++------- .../image-editor/useImageTransforms.ts | 23 +- resources/js/pages/assets/Edit.vue | 25 +- src/Http/ViewModels/AssetEditViewModel.php | 3 +- 37 files changed, 170 insertions(+), 911 deletions(-) diff --git a/.ai/rules/craftcms-ui.md b/.ai/rules/craftcms-ui.md index de05e57cadf..0a560a48768 100644 --- a/.ai/rules/craftcms-ui.md +++ b/.ai/rules/craftcms-ui.md @@ -7,3 +7,9 @@ paths: ## Build the UI package before consumers Build `@craftcms/ui` with `vp run build:ui` before building or checking the main Vite application after UI package changes. Run its tests with `vp run test:ui`. + +## Don't comment CSS unless it's a hack +Leave CSS uncommented — `.css`/`.scss` files, Lit `css` templates and Vue `