Media Editor experiment: add experimental image editor and cropper#77479
Media Editor experiment: add experimental image editor and cropper#77479ramonjd wants to merge 9 commits intoWordPress:trunkfrom
Conversation
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>
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>
|
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. 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', () => { |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
| /** | ||
| * 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 { |
There was a problem hiding this comment.
(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.
There was a problem hiding this comment.
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!
|
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:
Here's where the bug appears in my testing: 2026-04-20.14.37.23.mp4The 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.mp4In general, as discussed on your earlier work on this, I really like the architectural direction for this, for a few reasons:
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! |
|
I left a comment about potentially splitting out part of
|
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. |
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>
Looks like we can tweak the threshold - it was over-estimated |
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>
|
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.mp4To make it work consistently requires a rewire so I'm treating it as a follow up on the roadmap. |
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>
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>

Summary
A modular image cropper inside
@wordpress/media-editorwith a custom implementation built ongl-matrixfor 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/) — Framework-agnostic pure functions. Zero React dependency.react/) — Thin adapter: hooks wrapping core functions, and components for rendering.Features
onGestureStart/onGestureEndcallbacksTransformOperation[]) for AI agent integrationapplyToCanvas()accepts anyCanvasImageSourcefor multi-step editing pipelinesgetSourceRegion()/getSourceRegionPercent()for server-side processing (FFmpeg, WP REST API)stateFromPipeline()replays operations without DOMStencilPropsinterface.wp-media-editor-image-cropper__*)Public API (26 exports)
React:
Cropper,CropperProvider,useCropper,useCropperStateFunctions:
getSourceRegion,getSourceRegionPercent,exportCroppedImage,applyToCanvas,stateFromPipeline,applyOperationToStateConstants:
DEFAULT_STATE,DEFAULT_ASPECT_RATIOS,ORIGINAL_ASPECT_RATIOTypes:
CropperState,CropperAction,TransformOperation,NormalizedRect,NormalizedPoint,Size,Flip,StencilProps,SourceRegion,SourceRegionPercent,AspectRatioPreset,CropperProps,UseCropperStateReturnNew dependency:
gl-matrixThe cropper uses
gl-matrix(v3) for 2D affine matrix math — specificallymat2d(5 functions) andvec2(2 functions). This provides:mat2dmatrixFloat32Array-backed operationsmat2d+vec2+commonare included;sideEffects: falseenables tree-shaking)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 referencedocs/architecture.md— Coordinate spaces, data flow, design decisionsdocs/recipes.md— Getting started, extension points, integration patternsTest coverage
254 unit tests across 5 core test suites:
Test plan
Automated
npx wp-scripts test-unit-js --testPathPattern="image-editor/core/test"— 254 tests passnpx tsc --project tsconfig.json --noEmit— zero errorsStorybook (manual)
Run
npm run storybookand test each story underMediaEditor/ImageEditor:Touch (manual, on mobile/tablet)
Keyboard (manual)
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.