Skip to content

Image Cropper: Move the media editor's cropper into the package (proof of concept) - #86157

Closed
ramonjd wants to merge 8 commits into
trunkfrom
try/image-cropper-engine
Closed

ramonjd wants to merge 8 commits into
trunkfrom
try/image-cropper-engine

Conversation

@ramonjd

@ramonjd ramonjd commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

What?

Proof of concept for #86145: moves the media editor's cropper into @wordpress/image-cropper as the package's default API. The existing react-easy-crop cropper stays, deprecated, in src/legacy/.

Why?

So others can build their own image editor or cropper without depending on @wordpress/media-editor.

How?

  • Move the cropper, its docs, tests and styles from media-editor/src/image-editor into image-cropper. The media editor imports it from the package.
  • Export only what a consumer imports: Cropper, useCropperController, createCropperState, areCropperStatesEqual, buildModifiers, the aspect-ratio presets and the zoom/rotation limits.
  • The cropper keeps no undo history. A consumer that wants one keeps the cropper state itself with useCropperController( { state, onChange } ); the media editor does this and keeps its own history.
  • The locked aspect ratio lives in the cropper state (controller.setAspectRatio), so Cropper no longer takes an aspectRatio prop.
  • Move buildModifiers (the instructions for the media /edit endpoint).
  • Rename the cropper's state type to CropperControllerState and its CSS prefix to wp-image-cropper.

Not done yet: rebuilding the old API on the new cropper and removing react-easy-crop.

Testing Instructions

  1. Enable the media editor experiment.
  2. Edit an image block's image: rotate, flip, change the aspect ratio, undo, redo, save.
  3. The image block's own crop (old API) works as before.

Move the existing cropper, its provider, hook, types, helpers and story
into `src/legacy/`, and mark each export as deprecated. The package's
exports and their behavior are unchanged.

Keeping everything the old cropper needs in one folder means it can be
removed in one step later, once the media editor's cropper moves into
this package and replaces it.

The ESLint suppression for the story follows the file to its new path.
Move the cropper the media editor uses from
`packages/media-editor/src/image-editor` into `@wordpress/image-cropper`
and make it the package's default API: `Cropper`, `useCropperController`,
`CropperProvider` / `useCropper`, the source-region helpers,
`exportCroppedImage`, the default aspect-ratio presets, and the zoom and
rotation limits for building controls. The media editor now imports all
of these from the package.

Also move `buildModifiers`, which turns a crop into the rotate, flip and
crop instructions the WordPress media REST endpoint expects. Its test
reads the cropper's camera maths, which the package does not export, so
it moves in the same change.

Other changes that come with the move:

- The cropper's state type is renamed from `CropperState` to
  `CropGeometry`, because the deprecated `react-easy-crop` cropper
  already exports a `CropperState`.
- CSS classes use the `wp-image-cropper` prefix instead of
  `wp-media-editor-image-editor`.
- The styles build to `build-style/style.css`, which the media editor's
  stylesheet loads.
- The docs, tests, story, test-policy exceptions and ESLint suppressions
  move with the files. The media editor's session notes move to
  `packages/media-editor/src/state/README.md`.

A few reducer-level helpers are exported only because the media editor's
state code still needs them. They are marked as such in `src/index.ts`
and can be dropped when that state code moves into the package.
…kage

Add `useCropSession` to `@wordpress/image-cropper`: a cropper controller
with undo and redo, gesture boundaries (a drag or pinch is one undo
entry), crop options, and aspect-ratio presets that reshape the crop in
one step. It is the media editor's history code, moved without its
media-specific parts. `resolveAspectRatio`, which turns a preset value
into a width / height ratio, moves with it.

A session can hold more state in the same undo history: pass a reducer
that handles your own actions and hands the rest to
`cropSessionReducer`, with an equality check and a function that builds
the initial state. The media editor now does exactly that for its named
edits (title, alt text and so on), so `useMediaEditorState` is a thin
wrapper around `useCropSession`.

The reducer-level helpers exported only for the media editor's state
code are no longer exported.

The media editor's state tests are split: the 21 that cover undo, redo,
gestures, aspect ratios and loading a new image move to the package and
run against `useCropSession`; the 10 that cover named edits and the
saved-image check stay.
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

Preview in WordPress Playground

🏷️ Labels

This pull request needs exactly one label indicating its type, and has 0.

  • Required: any label starting with [Type].
  • Found: none.

Read more about Type labels in Gutenberg. If you cannot add labels yourself, a reviewer can do it for you.

📦 Bundle size

Size Change: +181 B (0%)

Total Size: 8.58 MB

📦 View Changed
Filename Size Change
build/scripts/editor/index.min.js 559 kB +186 B (+0.03%)
build/styles/editor/style-rtl.css 30.6 kB +7 B (+0.02%)
build/styles/editor/style-rtl.min.css 26.3 kB -13 B (-0.05%)
build/styles/editor/style.css 30.7 kB +15 B (+0.05%)
build/styles/editor/style.min.css 26.2 kB -14 B (-0.05%)

1f2d15a Run

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric cbd3063 trunk % Change
timeToFirstByte 56.15 ms +7.39% -1.87% 55.45 ms +4.24% -4.87% 1.26%
largestContentfulPaint 94 ms +6.38% -4.26% 90 ms +11.11% -2.22% 4.44%
lcpMinusTtfb 34.85 ms +15.35% -4.16% 35.95 ms +15.72% -5.98% -3.06%
wpBeforeTemplate 29.66 ms +12.61% -1.82% 29.17 ms +8.64% -3.46% 1.68%
wpTemplate 22.38 ms +2.77% -4.29% 21.65 ms +7.99% -5.68% 3.37%
wpTotal 52.22 ms +8.35% -1.63% 51.63 ms +4.71% -5.25% 1.14%
wpMemoryUsage 7.71 MB +0% -0% 7.67 MB +0% -0% 0.44%
wpDbQueries 16 +0% -0% 16 +0% -0% 0%

front-end-classic-theme

Metric cbd3063 trunk % Change
timeToFirstByte 42.65 ms +6.45% -2.58% 46.25 ms +9.51% -1.95% -7.78%
largestContentfulPaint 92 ms +0% -4.35% 100 ms +4% -0% -8%
lcpMinusTtfb 47.1 ms +2.34% -1.59% 54.4 ms +1.38% -1.19% -13.42%
wpBeforeTemplate 26.35 ms +9.15% -1.94% 26.61 ms +11.61% -0.98% -0.98%
wpTemplate 12.7 ms +4.17% -3.07% 16.63 ms +4.81% -3.43% -23.63%
wpTotal 39.67 ms +6.63% -2.62% 43.14 ms +10.01% -1.81% -8.04%
wpMemoryUsage 6.28 MB +0% -0% 6.36 MB +0% -0% -1.21%
wpDbQueries 9 +0% -0% 13 +0% -0% -30.77%

media-processing

Metric cbd3063 trunk % Change
mediaProcessingJpeg 404.07 ms +1.12% -0.45% 401.98 ms +1.2% -1.08% 0.52%
mediaProcessingAvif 6156.68 ms +0.17% -0.54% 6150.55 ms +0.96% -0.14% 0.1%
mediaProcessingJpegToAvif 4236.88 ms +0.32% -0.33% 4246.88 ms +0.48% -0.12% -0.24%

media-upload

Metric cbd3063 trunk % Change
jpegUploadProcessing 1743.4 ms +12.84% -16.32% 1474.13 ms +1.65% -1.84% 18.27%
pngUploadProcessing 172.52 ms +58.05% -4.68% 253.45 ms +9.74% -33.61% -31.93%
largeJpegUploadProcessing 1464.25 ms +1% -1.41% 1464.23 ms +33.31% -0.83% 0%
multipleImageUploadProcessing 2036.89 ms +0.53% -0.94% 2035.62 ms +1.17% -0.33% 0.06%

post-editor

Metric cbd3063 trunk % Change
serverResponse 446.54 ms +8.75% -1.01% 482.42 ms +1.91% -8.4% -7.44%
firstPaint 493.45 ms +1.57% -26.32% 391.03 ms +10.73% -11.58% 26.19%
domContentLoaded 865.86 ms +2.4% -1.4% 867.11 ms +1.44% -1.72% -0.14%
loaded 866.93 ms +2.41% -1.37% 868.19 ms +1.45% -1.72% -0.15%
firstContentfulPaint 502.23 ms +8% -1.61% 501.4 ms +2.17% -2.18% 0.17%
firstBlock 2231.48 ms +1.54% -0.59% 2198.03 ms +1.47% -0.97% 1.52%
type 18.8 ms +10.9% -8.14% 18.62 ms +5.91% -7.3% 0.97%
typeWithoutInspector 17.74 ms +4.51% -9.36% 17.93 ms +8.7% -10.76% -1.06%
typeWithTopToolbar 21.93 ms +12.81% -5.97% 23.37 ms +3.04% -5.35% -6.16%
typeContainer 8.11 ms +10.6% -11.96% 8.31 ms +9.27% -9.39% -2.41%
focus 121.82 ms +1.58% -3.66% 116.31 ms +9.27% -4.06% 4.74%
firstFocus 267.86 ms +0% -0% 281.57 ms +0% -0% -4.87%
selectAll 391.22 ms +8.15% -2.41% 419.52 ms +7.22% -2.04% -6.75%
undo 28.94 ms +2.9% -3.49% 28.67 ms +7.32% -3.24% 0.94%
listViewOpen 80.96 ms +6.26% -13.28% 79.55 ms +3.87% -2.64% 1.77%
inserterOpen 41.27 ms +11.63% -8.63% 42.54 ms +9.54% -9.76% -2.99%
inserterHover 11.96 ms +8.86% -9.53% 11.78 ms +8.32% -13.75% 1.53%
inserterSearch 8.64 ms +4.05% -13.19% 8.19 ms +4.64% -6.59% 5.49%
loadPatterns 634.11 ms +5.25% -7.55% 661.35 ms +1.76% -3.68% -4.12%
wpTotal 436.85 ms +8.97% -1.11% 472.64 ms +1.92% -8.54% -7.57%
wpMemoryUsage 14.75 MB +0% -0% 14.72 MB +0% -0% 0.24%
wpDbQueries 55 +0% -0% 55 +0% -0% 0%

site-editor

Metric cbd3063 trunk % Change
serverResponse 482.1 ms +3.49% -7.3% 484.76 ms +0.4% -1.38% -0.55%
firstPaint 363.15 ms +5.42% -12.52% 343.83 ms +11.72% -4.9% 5.62%
domContentLoaded 876.45 ms +4.22% -1.89% 871.83 ms +0.88% -1.05% 0.53%
loaded 877.45 ms +4.22% -1.88% 872.78 ms +0.91% -1.04% 0.54%
firstContentfulPaint 500.65 ms +4.88% -1.57% 503.26 ms +0.94% -0.88% -0.52%
firstBlock 2600.71 ms +2.41% -2.14% 2591.82 ms +0.46% -0.45% 0.34%
type 19.42 ms +5.92% -6.69% 20.62 ms +14.26% -7.66% -5.82%
navigate 120.36 ms +5.54% -1.51% 132.28 ms +0.37% -8.57% -9.01%
loadPatterns 1353.86 ms +11.14% -7.58% 1243.26 ms +8.31% -2.42% 8.9%
loadPages 1064.29 ms +1.06% -4.16% 1058.23 ms +1.48% -2.8% 0.57%
wpTotal 472.41 ms +3.49% -7.42% 475.18 ms +0.39% -1.37% -0.58%
wpMemoryUsage 12.77 MB +0% -0% 12.73 MB +0% -0% 0.28%
wpDbQueries 45 +0% -0% 45 +0% -0% 0%

1f2d15a Run

🏁 Flaky tests

Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

default to my most recently created menu in /test/e2e/specs/editor/blocks/navigation.spec.js, passed after 1 failed attempt.
Error: apiRequestContext.fetch: socket hang up
Call log:
  - → GET http://localhost:8891/wp-json/wp/v2/navigation/
    - user-agent: Playwright/1.63.0 (x64; ubuntu 24.04) node/24.20 CI/1
    - accept: */*
    - accept-encoding: gzip,deflate,br
    - X-WP-Nonce: 79aba475c5
    - content-type: application/json
    - content-length: 90
    - cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_da433b7faafd8180ebeb1624ceeeab7e=admin%7C1793777083%7CJOXcf5HxMuzrFfg4FIsO5jPdhQTL2MHz9esTp3tvjF7%7C2f635b3da9a4035383b879a60188ecd066769125dff3402e268f7359f16a285f; wp-settings-time-1=1793604384

    at RequestUtils.rest (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/request-utils/rest.ts:111:39)
    at RequestUtils.deleteAllMenus (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/request-utils/menus.ts:74:30)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/navigation.spec.js:19:18
moves selected text within a paragraph in /test/e2e/specs/editor/various/draggable-blocks.spec.js, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 1
+ Received  + 1

  Array [
    Object {
      "attributes": Object {
-       "content": "second First third",
+       "content": "First second third",
      },
      "name": "core/paragraph",
    },
  ]

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/draggable-blocks.spec.js:1209:41
should not have a dead zone above an aligned block in /test/e2e/specs/editor/various/writing-flow.spec.js, passed after 1 failed attempt.
TypeError: Cannot read properties of null (reading 'y')
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/writing-flow.spec.js:1053:39

From 3de34f1, not the current head. Run

@github-actions github-actions Bot added the [Package] Image Cropper Related to packages/image-cropper label Nov 2, 2026
The type holds the cropper's whole state: the source image, how the
image sits under the crop (pan, zoom, rotation, flip), the crop
rectangle, and internal fine-rotation bookkeeping. "Geometry" described
only part of that. The new name says what it is, the state a
CropperController holds, and does not clash with the deprecated API's
CropperState or ImageCropperState.
Only the stories import @wordpress/components, so move it to the
development dependencies and drop it from the build's TypeScript
references. Exclude stories and tests from the published files, as
the media editor does.
A browser test that uses only what the package exports, as an outside
consumer would: it mounts Cropper with useCropSession, applies a square
preset, rotates and flips, checks the server instructions, then undoes
and redoes.
The locked aspect ratio now lives in the cropper's state
(`state.aspectRatio`), and `controller.setAspectRatio( ratio )` sets it
and reshapes the crop in one change, so one undo restores both.
`Cropper` loses its `aspectRatio` prop: it reads the ratio from the
state to lock its resize handles and to keep a fixed crop fitted when
the viewport resizes, and it no longer reshapes the crop by itself.

`useCropSession`'s `setAspectRatioValue` keeps the preset the dropdown
shows and hands the reshape to the same state change. The plain
`useCropperController` now keeps the rendered size it is given, which
`setAspectRatio` needs. A ratio-only change is recorded in the undo
history but does not make `cropper.isDirty` true, since it does not
change the cropped image on its own.

The media editor stops passing the prop and reads the ratio from the
state.
Export only what a consumer imports. The new API is now Cropper,
useCropperController, createCropperState, areCropperStatesEqual,
buildModifiers, the default aspect-ratio presets with
resolveAspectRatio, and the zoom and rotation limits for building
controls, plus the types those need.

The cropper no longer keeps an undo history. History covers everything
an editor changes, not only the crop, so it belongs to the consumer.
To put the crop in your own history, keep the cropper state yourself:
useCropperController( { state, onChange } ) works out each change and
reports the next state, with isEdit: false for changes the person did
not make (an image loading, a refit after a window resize).

useCropSession and its reducer exports are gone, and so are
CropperProvider / useCropper, the stencil prop and its types, the
source-region and canvas export helpers, and the geometry types.
Nothing outside the package used them. The old API keeps all of its
exports, including Flip.

The media editor keeps its own history again, with the cropper state
as one slice next to the sidebar options and named edits. Its 31
session tests from before the move run against it unchanged, plus the
aspect-ratio checks.
@ramonjd

ramonjd commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Image Cropper Related to packages/image-cropper

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant