Skip to content

[6.x] Modernize image editor - #19600

Draft
brianjhanson wants to merge 18 commits into
6.xfrom
feature/image-editor
Draft

[6.x] Modernize image editor#19600
brianjhanson wants to merge 18 commits into
6.xfrom
feature/image-editor

Conversation

@brianjhanson

Copy link
Copy Markdown
Contributor

Brings the image editor into the new world. I shifted a few things around in the process which hopefully doesn't ruffle too many feathers.

Full description will come when this is out of draft state.

brianjhanson and others added 14 commits September 9, 2026 22:53
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
`.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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
Renders the ported editor as a fullscreen craft-dialog on the asset
edit page, so it gets a real <dialog>'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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
A toolbar control that turns something on and stays that way is a toggle
button — `aria-pressed` plus a visibly pressed state — not a switch, which
is what craft-switch is for.

`active` already carried the visible half, but nothing tied `aria-pressed`
to it: two things to set, and setting one and forgetting the other is the
easy mistake. With `toggle`, `aria-pressed` is derived from `active`, so
what a screen reader hears and what people see can't drift apart.

It never flips `active` itself. Selection may be owned elsewhere —
craft-button-group writes `active` on its children, and a consumer may
refuse the change — so it reports intent through a cancelable
`craft-toggle` and leaves the decision to whoever owns the state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
`requestClose()` is the single funnel for the close button, Escape and the
backdrop, so asking there covers every way out at once. It now fires a
cancelable `craft-before-hide`, for a dialog holding work that would be
lost.

Deliberately silent when `opened` is set to false directly: that's a
decision already taken, so saving and closing doesn't prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
The graduations were `display: inline-block`, so the newlines between them
in the template each rendered as a space — about 3.8px per graduation.
That made the strip 1942px where the positioning maths assumes 10px
apiece, or 1400, and put zero some 265px from the cursor: around 20
degrees out. Flex lays them flush, and `flex: none` keeps them 10px
whatever the width of the window they slide behind.

Two related fixes while in here. The graduations element is the window the
strip slides behind and the maths centres on its width, so it clips itself
now rather than relying on a wrapper around the component — the legacy
markup had one, its rule scoped to the modal. And repositioning bails when
that window has no layout at all, rather than centring against zero and
burning in an offset the resize observer never revisits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
The inset that gives the cropper's handles room to be drawn lived inside
`getZoomToFitRatio`, which only runs while cropping. Every other view laid
the image out against the full editor, so opening the crop controls
reframed the image by the margin doubled — 40px on a 1168x574 editor — on
top of the zoom change. That reads as a lurch.

There's one content box now, used by every view, so moving between them is
a zoom and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
Both were moved by however much the editor had changed, which compounds:
every resize left them a little further off the picture. Both are stored
relative to the image at a zoom of 1, so reading that back puts them on the
same part of it whatever the editor did.

The focal point's restore is split in two. It ended by adding the marker to
the canvas, which fabric does unconditionally — right for a transition that
lifted it off, but it would stack up duplicates on every resize.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
`reset()` winds every edit back without refetching: the source is already
on the canvas and everything done to it lives in state. `isDirty` is
derived from that state rather than a flag each mutating call has to
remember to set — one missed call site and someone loses work to a
confirmation that never appeared.

Two ordering fixes alongside. Switching views laid the image out and
started its transition before the view flipped, so both ran against a width
the crop sidebar was about to change; the view flips first now, and one
measurement is taken once it lands. And the cropper and focal point are
repositioned after the image has taken its new size, not before — they read
where it is, so reading it early put them where it used to be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
Crop is a toggle that shows and hides the sidebar, reading its state off
the editor rather than keeping a second copy. Focal Point shows as active
while the asset has one — existence, not the transient picked-up state,
which also fixes the button removing a focal point it thought was absent.

Reset to original sits at the far end of the toolbar, disabled until
there's something to discard, and asks first. Closing with unsaved edits
asks too, through the dialog's new `craft-before-hide`.

The dialog is always dark: judging an image against light chrome skews how
it reads. Its body loses its padding so the editor meets the edges, and the
sidebar states its width — it holds block-level fields, which fill their
parent rather than asking for a width, so flex had nothing to size it from
and it collapsed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
The generated palette already had a dark set, keyed on `[data-theme='dark']`
as a plain attribute selector — but putting that on anything other than the
root did nothing, so the image editor dialog stayed light.

A custom property carrying a `var()` is substituted where it's declared, not
where it's used. `:root { --c-surface-raised: var(--color-base-50) }` resolves
to white at the root, and that resolved value is what inherits; redeclaring
the palette further down can never reach back into it. Measured in Chromium:
the raw palette followed the subtree, everything built out of it did not.

So the two semantic layers repeat themselves on `[data-theme]` — one in
`tokens.css`, one emitted by `generate-colors.js` — which re-resolves them
against that element's palette.

`color` needs the same treatment for the same reason, and its absence is how
this surfaced: dark text on a themed dark background, a contrast ratio of
1.07, caught by the accessibility check on a new story. Backgrounds are left
alone — they're painted deliberately by whatever owns the surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
So a link can land straight in the editor rather than on the edit screen
with a button to press. Read server-side like the rest of this screen's
state, and guarded on there being an image editor at all, so the parameter
can't ask for one this asset doesn't get.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/uiopen Storybook

Changed components:

resources/jsopen Storybook

No changed components detected in this Storybook.

brianjhanson and others added 4 commits September 10, 2026 14:13
Opening the image editor puts `?editing=true` in the URL and closing it
takes it back out, so the address bar says what's on screen and a refresh
lands where you left off.

Driven by a watcher on the open state rather than the handlers, so it covers
every way out — Escape, the close button, a save — not only the paths that
happen to run through a function here.

`history.replaceState` rather than an Inertia visit: this is UI state, and a
round-trip to re-render the page behind an open dialog just to toggle a query
parameter is a lot of work for nothing. Inertia's own history entry is handed
straight back so only the URL changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
It was pinned to landscape, so a portrait photo opened with the wrong
button selected and every ratio the wrong way round until you noticed.
The asset's dimensions come through with the rest of the editor's props,
so the orientation is right before anything renders rather than flipping
once the image loads.

Reset returns to the same, not to landscape. Square images and ones with
no dimensions on record read as landscape, which is the button that ends
up selected for them anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
Saving already dismissed the dialog, but the return trip left the screen
in the wrong place.

Saving a copy now redirects to the new asset. The URL comes from the
server, so the `{id}{slug}` path doesn't have to be rebuilt in JS.

Saving in place refetches for the new thumbnail, and that refetch has to
drop `editing` from the URL itself: the watcher that mirrors the editor
doesn't flush until after the handler returns, and a reload refetches
whatever the URL says at the time — so it came back with the editor
asked to open again, and reinstated the parameter in history on the way.

The dialog also closes before it emits, so a consumer that navigates on
the way out isn't doing it while the dialog still reads as open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
The editor opened itself on load with nothing having asked it to.

`craft-dialog` takes its state as the `opened` property, backed by the
`open` attribute, so `:open` went down Vue's attribute path. `patchAttr`
only strips a false boolean for the seven names in `isSpecialBooleanAttr`
— `open` is in the wider `isBooleanAttr` list, which it never consults —
so `false` was written out as the string "false", and Lit's boolean
converter reads attribute presence.

Bind `.opened` instead, which also survives the element not being
upgraded yet. The test pins the attribute form too, so switching it back
fails loudly rather than quietly showing the dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY6JVxWjJjmZUMuSiBUW9p
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant