Skip to content

Media Editor experiment: add experimental image editor and cropper#77479

Open
ramonjd wants to merge 9 commits intoWordPress:trunkfrom
ramonjd:update/custom-freeform-cropper
Open

Media Editor experiment: add experimental image editor and cropper#77479
ramonjd wants to merge 9 commits intoWordPress:trunkfrom
ramonjd:update/custom-freeform-cropper

Conversation

@ramonjd
Copy link
Copy Markdown
Member

@ramonjd ramonjd commented Apr 20, 2026

Summary

A modular image cropper inside @wordpress/media-editor with a custom implementation built on gl-matrix for precise 2D transforms.

Desktop

Kapture.2026-04-20.at.12.00.18.mp4

Mobile

ScreenRecording_04-20-2026.11-55-26_1.mp4

Architecture

Two layers in one package:

  • Core (core/) — Framework-agnostic pure functions. Zero React dependency.
  • React (react/) — Thin adapter: hooks wrapping core functions, and components for rendering.

Features

  • Rectangular crop with 8 resize handles and aspect ratio lock
  • Fixed crop mode (react-easy-crop style) and freeform crop mode with settle animation
  • Continuous ±45° rotation slider + 90° snap rotation (Google Photos style, preserves image selection)
  • Focal-point zoom (mouse wheel and pinch-to-zoom center on cursor/finger position)
  • Touch support: single-finger pan, two-finger pinch zoom with combined pan, double-tap zoom
  • Gesture-based undo/redo via onGestureStart/onGestureEnd callbacks
  • JSON-serializable transform pipeline (TransformOperation[]) for AI agent integration
  • Canvas export: applyToCanvas() accepts any CanvasImageSource for multi-step editing pipelines
  • getSourceRegion() / getSourceRegionPercent() for server-side processing (FFmpeg, WP REST API)
  • Headless processing: stateFromPipeline() replays operations without DOM
  • Pluggable stencils via StencilProps interface
  • Full keyboard accessibility (arrow keys, +/-, R) with ARIA live region announcements
  • CSS-only theming via BEM classes (.wp-media-editor-image-cropper__*)
  • Pointer capture for iframe-safe drag interactions

Public API (26 exports)

React: Cropper, CropperProvider, useCropper, useCropperState
Functions: getSourceRegion, getSourceRegionPercent, exportCroppedImage, applyToCanvas, stateFromPipeline, applyOperationToState
Constants: DEFAULT_STATE, DEFAULT_ASPECT_RATIOS, ORIGINAL_ASPECT_RATIO
Types: CropperState, CropperAction, TransformOperation, NormalizedRect, NormalizedPoint, Size, Flip, StencilProps, SourceRegion, SourceRegionPercent, AspectRatioPreset, CropperProps, UseCropperStateReturn

New dependency: gl-matrix

The cropper uses gl-matrix (v3) for 2D affine matrix math — specifically mat2d (5 functions) and vec2 (2 functions). This provides:

  • Composable transforms: pan, rotation, flip, zoom as a single mat2d matrix
  • Invertible camera: restriction projects crop corners through the inverse camera, guaranteeing the restriction agrees with what's rendered
  • JIT-optimized Float32Array-backed operations
  • ~15 KB minified tree-shaken (only mat2d + vec2 + common are included; sideEffects: false enables tree-shaking)
  • Well-tested, stable library with no transitive dependencies

The alternative — hand-rolling 2D affine math — would save ~15 KB but introduce maintenance surface for matrix inversion and composition with no user-visible benefit.

Documentation

  • README.md — Full public API reference
  • docs/architecture.md — Coordinate spaces, data flow, design decisions
  • docs/recipes.md — Getting started, extension points, integration patterns

Test coverage

254 unit tests across 5 core test suites:

  • Camera math and containment invariant (184 tests, including 200 random state combinations)
  • State reducer: SETTLE_CROP accuracy, enforceContainment ordering, focal-point zoom (16 tests)
  • Interaction controller: pointer drag, wheel zoom, keyboard, touch pan/pinch transitions (30 tests)
  • Plus pipeline and export tests

Test plan

Automated

  • npx wp-scripts test-unit-js --testPathPattern="image-editor/core/test" — 254 tests pass
  • npx tsc --project tsconfig.json --noEmit — zero errors

Storybook (manual)

Run npm run storybook and test each story under MediaEditor/ImageEditor:

  • Default — Image renders, can pan by dragging, mouse wheel zooms
  • WithControls — Rotation slider, zoom slider, flip buttons, aspect ratio presets, freeform toggle all work. Export preview updates live.
  • WithPreview — Freeform crop toggle works. Export preview matches cropper view at all zoom/pan/rotation/flip states.

Touch (manual, on mobile/tablet)

  • Single-finger pan works
  • Two-finger pinch zoom works reliably (fingers can land at different times)
  • Double-tap toggles between 1x and 2x zoom
  • Freeform crop handles work without triggering image pan
  • Releasing one finger after pinch does NOT switch to pan

Keyboard (manual)

  • Arrow keys pan the image
  • +/- zoom in/out
  • R rotates 90°
  • Tab to crop handles, arrow keys resize (in freeform mode)

Use of AI

I used various models (mainly Opus 4.6/47, GPT 5.4) to build, test, review, debug and document this feature.

Specs were human-driven, so too manual and acceptance criteria testing.

Introduce an experimental image editor inside @wordpress/media-editor.
Provides an interactive image cropping component with pan, zoom, rotate,
and flip, plus a pluggable stencil system, JSON-serializable transform
pipeline, and browser-based export (Blob / canvas / source region).

Architecture:
- core/ — framework-agnostic state, math, camera (gl-matrix), export.
  Pure TypeScript with zero React or DOM dependencies outside of
  core/export/ (which uses HTMLCanvasElement for rendering).
- react/ — thin React adapter: <Cropper>, useCropperState,
  CropperProvider. Every framework-specific concern lives here.

Key invariants:
- Preview ↔ export parity: the pixels the user sees inside the stencil
  are the pixels produced on export. Locked in by a parametric test
  suite sweeping rotation × zoom × pan × flip × crop × output size.
- Viewport-relative flip: flip mirrors across the viewport axes
  regardless of rotation.
- Visual rotation: snapRotate90(+1) and pipeline \`rotate\` ops always
  rotate the image clockwise on screen, even when a single axis is
  flipped.
- Framing preservation: pan/rotate/flip operations keep the same image
  content framed inside the stencil.

Marked status-experimental in Storybook; exported from the
@wordpress/media-editor package root but not yet promoted for wider
consumption. See packages/media-editor/src/image-editor/docs/ for
architecture, recipes, and roadmap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ramonjd ramonjd requested a review from andrewserong April 20, 2026 02:02
@ramonjd ramonjd self-assigned this Apr 20, 2026
@ramonjd ramonjd added [Feature] Media Anything that impacts the experience of managing media [Type] Experimental Experimental feature or API. labels Apr 20, 2026
Comment thread packages/media-editor/src/index.ts Outdated
Remove the image editor barrel re-export from @wordpress/media-editor's
public surface. The module is only intended for internal consumers
inside the package (via relative paths); it's not ready to be part of
the published API.

Update README and recipes docs: replace the @wordpress/media-editor
import paths with the internal `../image-editor` path, add a "Status:
internal" banner to both, and drop the "WordPress integration patterns"
and "REST API and media library" sections that implied a public API
and external-consumer patterns that don't exist yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: ramonjd <ramonopoly@git.wordpress.org>
Co-authored-by: andrewserong <andrewserong@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

return match[ 1 ].split( ',' ).map( ( v ) => parseFloat( v.trim() ) );
}

test.describe( 'MediaEditor ImageEditor', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an idle thought about the tests in this file: I like that we're adding some e2e tests for the storybook case: I didn't realise we could do that in the GB repo. Even though they're only run manually, given the granular nature of the cropping tools, I do like that we're adding coverage, as I think it'll help with developing the library.

A question re: the readImageMatrix tests: are these duplicative of some of the unit tests already in place? Totally fine if what we need to do here is the actual integration testing (especially of mouse wheel events, etc), but just wondered how useful the tests are.

(I don't mind at all including them, it just stood out as I was reading over so thought I'd leave a comment)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The reducer logic is covered in unit tests.

I asked for this to act as integration smoke tests. They are meant to prove that the browser events (wheel, pointer capture, keyboard focus) fire and do something.

Happy to narrow them down if that’s too much for manual-run tests

I'll add a comment here to explain why they exist for now. Happy to remove them all once we do a "real" integration.

Comment on lines +459 to +477
/**
* Restricts a crop rectangle so that the rotated, zoomed image can fully cover it.
* If the crop rect is too large for the current zoom and rotation, it is scaled
* down proportionally and re-centered.
*
* Works in pixel-proportional space where the unrotated image is a×1.
*
* @param cropRect The crop rectangle in normalized coordinates.
* @param zoom The current zoom factor.
* @param rotation The rotation angle in degrees.
* @param imageAspectRatio The image width / height ratio.
* @return The restricted crop rectangle.
*/
export function restrictCropRect(
cropRect: NormalizedRect,
zoom: number,
rotation: number,
imageAspectRatio: number
): NormalizedRect {
Copy link
Copy Markdown
Contributor

@andrewserong andrewserong Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Totally optional!) This is a bit of a code quality nit, but the camera.ts file is pretty big. I'm wondering if it would make sense for some or all of the restrict* functions to be moved into a separate file, possibly named containment.ts so that the camera.ts file is more about the camera layer?

Basically I'm thinking that the containment logic is important enough to warrant its own file.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good call. Almost as if we need a "containment" split. Let me see if we can break it down into logical streams. I appreciate your feedback here!

@andrewserong
Copy link
Copy Markdown
Contributor

This is testing really nicely so far! Love all the interactions and especially how nice the freeform crop feels to use. It looks like you've done a lot of polishing on the interactions before opening up a Gutenberg PR, nice work 👍

A couple of things I noticed while testing this out in Storybook. The first I think might just be a bug in the story, but would be good to fix up as it's the example usage for the component. In the fine-grained rotation slider, if you drag it to > 224 degrees, it goes into a bit of an infinite loop if you continue dragging, which I think is due to the calculation. I'm new to looking at this code so I used Claude to look into this locally, here's a couple of suggestions from Claude (please take it with a grain of salt). Happy to dig in further if you'd like:

Slider rotation wraps at ±MAX_ROTATION_OFFSET (rectangle-crop.story.tsx:148-167, and same pattern at :514-533)

Dragging the fine-rotation slider past 224° makes the thumb jump back to the left edge and the rotation value oscillates. baseAngle is computed as Math.round(state.rotation / 90) * 90 every render, so at rotation = 225°, Math.round(2.5) === 3 flips baseAngle from 180→270 and fineOffset wraps from +45→−45. While the user is still dragging, the native range input re-syncs the thumb to the pointer, which re-triggers the wrap.

Two fixes worth considering: (a) clamp the slider so the exact midpoint (225°/135°/315°) is unreachable — e.g. cap max at MAX_ROTATION_OFFSET − step; or (b) track baseAngle as local component state, updated only via snapRotate90/the ±90 buttons, so the slider's reference frame is stable across renders.

Here's where the bug appears in my testing:

2026-04-20.14.37.23.mp4

The other thing I noticed is that the logic that ensures that we zoom in (and restrict the zoom values) when rotation occurs so that the crop area doesn't fall outside of the area of the image itself, feels a little too restrictive. In the following video note that the zooming goes a little more zoomed in than looks necessary visually, and I can't zoom out to get it closer to the edge. Apologies, I haven't looked closely at the code that determines this just yet, but I'm wondering if the calculation is slightly off, or if there's a threshold that's used, if it's a little too wide:

2026-04-20.14.42.53.mp4

In general, as discussed on your earlier work on this, I really like the architectural direction for this, for a few reasons:

  • The world/camera/screen idea sets things up nicely for further features that allow browsing and panning the image independently, so we could eventually add a Photoshop navigator-like feature or just zooming and interacting with larger images
  • In lots of the previous PRs and work we've done on image cropping in the block editor and in media editor experiments we've consistently run into issues with 3rd party cropping libraries, either bugs, missing features, or the cropping libraries being in variable states of maintenance. Especially since media management is such an important part of WordPress, I think the timing is right to include a bespoke cropping library.
  • I like the transform operation pipeline, and the idea that we'll be able to store or replay a stack of operations 👍
  • Proposing the image-editor within the media-editor gives us a good place to try out and iterate on this cropper as part of media editor experiments, without exposing it directly as an API. I think this helps build confidence in the approach, as we'll be able to see how the cropper feels (both in UI and in its code) while still being able to remove it or replace with a 3rd party library if it winds up being too complex to maintain ourselves
  • However, all up I'm feeling pretty confident in this being a good direction 🎉

I've only skimmed the code so far, but I'll give it a bit more of a closer look and drop further comments as I test. But this is an exciting PR!

@andrewserong
Copy link
Copy Markdown
Contributor

I left a comment about potentially splitting out part of camera.ts into a separate file. Some other ideas about further splitting things up for readability / maintainability — these are just thoughts, not a blocker to this PR as it could be explored separately in follow-ups, and I don't want to delay any work here needlessly. But to capture the thoughts:

  • interaction-controller.ts is pretty big, especially in the handleTouchStart method. Is there an opportunity to further split the innards of that up into smaller parts, i.e. helpers in a separate file called interaction-touch or something like that? handleTouchStart in this class would still be the entry point.
  • In cropper.tsx there might be opportunities to extract some logic into separate hooks, as currently there's a lot going on in the file with lots of useEffects.
    • Could we create a hook to include buildAnnouncement function and the useEffect that calls it? E.g. something like useAriaAnnouncer?
    • Around line 235 there's a useEffect to auto-size the crop rect. Similarly around line 275 there's a useEffect for computing the largest inscribed rect of the new ratio. Could these be moved into something like a useCropRectAutoResize hook or something like that?
  • In rectangle-stencil.tsx there's potentially another opportunity to break things down into smaller hooks. This is a smaller file altogether, though, so the benefit might be minimal. Just something to keep in mind as it grows.

Comment thread packages/media-editor/src/image-editor/react/components/cropper.tsx Outdated
@ramonjd
Copy link
Copy Markdown
Member Author

ramonjd commented Apr 20, 2026

The first I think might just be a bug in the story, but would be good to fix up as it's the example usage for the component. In the fine-grained rotation slider, if you drag it to > 224 degrees, it goes into a bit of an infinite loop if you continue dragging

yes! I also saw this while testing and the conclusion was that it’s a known quirk of the native element. When you drag past the rail edge, the native slider keeps tracking the pointer’s x-coordinate and snaps to the min or max. I'll see if the custom RangeControl component works better here.

Edit: it doesn't it's a native control issue. Maybe the remedy is to use a different control or wrap the control and add some events to prevent mouse drag. I'll keep it on the list, but don't think it's a blocker yet.

@andrewserong
Copy link
Copy Markdown
Contributor

Edit: it doesn't it's a native control issue. Maybe the remedy is to use a different control or wrap the control and add some events to prevent mouse drag. I'll keep it on the list, but don't think it's a blocker yet.

Good stuff. We can revisit if we run into any issues when implementing the cropper in media-editor components 👍

getMinZoomForCover and restrictCropRect scaled the crop rect's
fractions by the TRUE-rotation visual bbox, but crop fractions live
in the SNAP-rotation bbox (the stencil's reference frame — see the
preview/export parity fix). At any fine angle the true-rotation bbox
is larger, so the crop's pixel size was over-estimated, which bumped
the required zoom above what coverage actually demanded. Users saw
the image over-zoom on rotation and couldn't zoom back out toward
the image edges even when there was obvious room.

Switch both functions to use the snap-rotation visual bbox for crop
scaling, keeping the true-rotation cos/sin for projection into the
image-local frame. The containment invariant (property-based test
in core/test/camera.ts) and the preview/export parity suite both
pass with the tighter — but still correct — zoom floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ramonjd
Copy link
Copy Markdown
Member Author

ramonjd commented Apr 20, 2026

en rotation occurs so that the crop area doesn't fall outside of the area of the image itself, feels a little too restrictive.

Looks like we can tweak the threshold - it was over-estimated

ramonjd and others added 3 commits April 20, 2026 16:53
Drop the redundant local naturalSize state in Cropper. The reducer
already owns the image dimensions via setImage on load; mirroring
them in component state was a duplicate source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a one-line comment at the top of the describe block noting that
unit tests cover the reducer/camera math and these Playwright tests
exist to verify the browser event pipeline (passive-wheel, pointer
capture, keyboard focus) reaches the reducer and the CSS transform
lands on the DOM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address PR review feedback on file size and organization:

- Split core/camera.ts (934 lines) into three cohesive files:
  - camera.ts — primitives: createCamera, createExportCamera,
    worldToScreen, screenToWorld, getVisibleBounds, getImageFit,
    getRotatedBBox.
  - containment.ts — restriction source of truth: restrictPanZoom,
    restrictCropRect, getCropBounds, plus the private
    getVisualDimensions / getMinZoomForCover helpers.
  - source-region.ts — pixel-region queries: getSourceRegion,
    getSourceRegionPercent and their types.

- Extract the ARIA-live announcement logic in cropper.tsx into a
  dedicated useAriaAnnouncer hook. Reduces the component by ~30
  lines and makes the debounce/dedupe surface reusable.

- DRY the two aspect-ratio auto-resize effects in cropper.tsx by
  factoring their shared math into a computeInscribedRect helper.
  The effects stay in place because they have different triggers
  (fixed vs. freeform modes).

- Simplify InteractionController.handleTouchStart by moving the
  inline onTouchMove closure (~170 lines) into a bound
  handleTouchMove arrow-property, and extracting double-tap
  detection into a tryDoubleTap method.

- Sync stale references in docs / comments after the moves
  (architecture.md diagram, createExportCamera docstring, etc.).

Net line counts: camera.ts 934→357, cropper.tsx 559→497, plus
cohesive new files. No behavior change — all 12,458 unit tests
(including the preview↔export parity sweep) still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ramonjd
Copy link
Copy Markdown
Member Author

ramonjd commented Apr 20, 2026

I think I've addressed most of the feedback.

One UX improvement I'm looking at in parallel is to explore more intelligent zooming, e.g., drag handle zoom-out, increasing crop size if it fits the container.

This video shows the latter.

2026-04-20.19.36.41.mp4

To make it work consistently requires a rewire so I'm treating it as a follow up on the roadmap.

ramonjd and others added 2 commits April 20, 2026 21:44
The clean-state snapshot used by isDirty was taken once at hook mount
(before any image loaded) and never refreshed when the image was later
set or the state was reset. Both paths run through enforceContainment,
which can nudge pan/zoom/cropRect by float-precision amounts on
non-square images — the baseline and post-action state would then
differ by ulps, and isDirty reported true at a "clean" state.

- setImage: re-enforce the baseline with the new image.
- reset: re-enforce the baseline with the currently-loaded image
  (previously discarded image, producing a baseline that could never
  match the reducer's RESET result).

Add two regression tests — both fail without this fix:
- isDirty is false after setImage on a non-square image.
- isDirty is false after reset on a loaded image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nents

Replace ad-hoc inputs/buttons with the WordPress design system so the
story reads as a real editor rather than a dev scratchpad:

- Icon buttons for rotate / flip (secondary variant, compact icon-only
  with tooltips).
- Primary buttons for action-y controls: Upload, Reset, Download.
- RangeControl for fine rotation and zoom.
- SelectControl for aspect ratio (WithControls) and export format
  (Debug only).
- ToggleControl for freeform mode.
- Flex/FlexItem layout — single toolbar row with slider row below.

Reset button is disabled via `!controller.isDirty` (accessible when
disabled). Download moves to the Debug story alongside the live export
preview; WithControls focuses on crop UX.

Upload control reads the picked file as a data URL so the cropper can
swap images without leaving the page. The controller state is reset on
image change so the old framing doesn't linger.

Register the mediaeditor- story id prefix in Storybook's package-style
config so the components stylesheet actually loads in this story.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ramonjd
Copy link
Copy Markdown
Member Author

ramonjd commented Apr 20, 2026

Screenshot 2026-04-20 at 9 45 50 pm

Just updated the story with proper WordPress components and an upload button so we can test different original aspect ratios

Relocates the live export preview alongside the debug data panel
rather than below the whole story. Caps preview width at 200px and
drops the redundant "Debug" and "Export Preview" headings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/media-editor/src/image-editor/react/components/cropper.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Media Anything that impacts the experience of managing media [Type] Experimental Experimental feature or API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants