diff --git a/packages/media-editor/src/components/rotation-ruler/README.md b/packages/media-editor/src/components/rotation-ruler/README.md
index 97d423f1d41820..493bf9250d955a 100644
--- a/packages/media-editor/src/components/rotation-ruler/README.md
+++ b/packages/media-editor/src/components/rotation-ruler/README.md
@@ -1,37 +1,23 @@
# RotationRuler
-Private to `@wordpress/media-editor`. A horizontal ruler-slider for
-fine-grained numeric values (used for fine-tune rotation in the media
-editor toolbar). Drag the ruler to scrub; the current value is shown
-in an active label sitting over the centered pointer triangle, with
-labelled major ticks running through the strip behind it. A visually
-hidden `` underneath provides keyboard access and
-accessibility. Drag values quantize to multiples of `step`.
+Private controlled ruler input for fine rotation. Pointer drag values are clamped to `min` / `max` and quantized to `step`. Keyboard arrow changes are clamped to `min` / `max` and increment or decrement by `step` (or `step / 2` with Shift).
## Usage
```tsx
```
-## Props
-
-See `index.tsx` for the full `RotationRulerProps` interface. The
-component is controlled (`value` / `onChange`); callers own state and
-clamp/transform values as they wish before passing them in.
-
## Keyboard
-- **← / ↓** — decrement by `step`
-- **→ / ↑** — increment by `step`
-- **Shift + arrow** — half-step (e.g. 0.5° when `step` is 1°), for
- keyboard precision when the integer drag-step is too coarse.
- **Shift + drag** does the same on the pointer.
-- **Home / End** — min / max
-- **PageUp / PageDown** — ±10% of range (native input behaviour)
+- Left / Down: decrement by `step`.
+- Right / Up: increment by `step`.
+- Shift + arrow: use half the configured `step`.
+- Home / End: move to min / max.
+- PageUp / PageDown: use native range input behavior.
diff --git a/packages/media-editor/src/image-editor/README.md b/packages/media-editor/src/image-editor/README.md
index 033ae410d9679d..307ff67306733b 100644
--- a/packages/media-editor/src/image-editor/README.md
+++ b/packages/media-editor/src/image-editor/README.md
@@ -1,163 +1,61 @@
# Image Editor
-> **Status: internal.** This module is not exported from `@wordpress/media-editor`'s public API. Only code inside `@wordpress/media-editor` should import it, via relative paths (e.g. `../image-editor`). When the module is promoted to the package's public surface, the `@wordpress/media-editor` import paths used in these docs will work.
+> **Status: internal.** This module is not part of `@wordpress/media-editor`'s public API. Import it only from inside the media-editor package, through the local `../image-editor` barrel.
-A modular image editor inside `@wordpress/media-editor`. Two layers:
+The image editor is the cropper engine used by the media editor:
-- **Core** — framework-agnostic state, math, and interaction logic. Pure TypeScript + `gl-matrix`. The export helpers in `core/export/` are browser-only (they use `HTMLCanvasElement` and `Image`), but everything else — reducer, camera math, interaction controller — has no DOM or React dependency. A non-React UI layer (Vue, Svelte, vanilla) can reuse core directly.
-- **React adapter** — thin wrappers over core. The public surface is ``, `useCropperState`, plus the optional `CropperProvider` / `useCropper` context pair. `useInteraction` and `useTransformStyle` exist internally but are not exported.
+- **Core:** state, camera math, containment, source-region, transforms, and canvas export.
+- **React:** ``, `useCropperReducer`, and the optional `CropperProvider` / `useCropper` context wrapper.
-## Quick start
+`useCropperReducer` does not provide undo/redo. The media editor's composite state layer adds history and sidebar crop options on top of the cropper reducer.
+
+## Quick Start
```tsx
-import { Cropper, useCropperState } from '../image-editor';
+import { Cropper, useCropperReducer } from '../image-editor';
+
+function ImageCropper() {
+ const controller = useCropperReducer();
-function ImageEditor() {
- const controller = useCropperState();
return (
);
}
```
-`useCropperState` returns a single `controller` object that bundles the current state and every setter. Pass it to `` as a single prop, and call setters like `controller.setZoom( 2 )` or `controller.snapRotate90( 1 )` from your own toolbars.
-
-## Styles
-
-The cropper's styles are compiled as part of `@wordpress/media-editor`'s SCSS build. Internal callers that render `` inherit them automatically when the package's stylesheet is loaded. All CSS classes use the `wp-media-editor-image-editor` prefix.
-
-## Docs
-
-- [docs/architecture.md](docs/architecture.md) — Data flow, coordinate spaces, and design decisions
-- [docs/recipes.md](docs/recipes.md) — Getting started walkthrough, extension points, and integration patterns
-- [docs/roadmap.md](docs/roadmap.md) — Planned follow-up work and phases
-
-## API Reference
-
-### React components
-
-#### `Cropper`
-
-Main cropper component. Fills its parent container.
-
-| Prop | Type | Default | Description |
-| ---------------- | ------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `src` | `string` | **required** | Image source URL |
-| `controller` | `UseCropperStateReturn` | **required** | The full object returned by `useCropperState()` |
-| `stencil` | `ComponentType` | `RectangleStencil` | Custom crop area UI |
-| `showGrid` | `boolean` | `false` | Rule-of-thirds grid overlay |
-| `showDimming` | `boolean` | `true` | Dimming overlay outside crop |
-| `showDimensions` | `boolean` | `true` | Live output-dimensions tooltip that follows the active resize handle |
-| `minZoom` | `number` | coverage-aware | Minimum zoom for interactive gestures (wheel, pinch, double-tap). Defaults to the coverage-aware floor, which can drop below `1` when the crop remains covered. |
-| `maxZoom` | `number` | `10` | Maximum zoom for interactive gestures. User-requested zoom is clamped to this maximum while containment still enforces the coverage-aware lower bound. |
-| `aspectRatio` | `number` | — | Fixed aspect ratio (width/height) |
-| `freeformCrop` | `boolean` | `false` | Enable resize handles |
-| `onImageLoaded` | `(size: Size) => void` | — | Image load callback |
-| `onStateChange` | `(state: CropperState) => void` | — | Fires on every state change (high frequency — at pointermove rate during drags). Avoid heavy work in the handler; for commit-style events use `onGestureEnd` instead. |
-| `onGestureStart` | `() => void` | — | Gesture boundary start |
-| `onGestureEnd` | `() => void` | — | Gesture boundary end |
-| `className` | `string` | — | Additional CSS class |
-
-#### `CropperProvider` / `useCropper()`
-
-Context wrapper for deep component trees. Wraps `useCropperState` and provides it to descendants via `useCropper()`.
-
-### React hooks
+`useCropperReducer` returns a controller object with the current `state`, named setters, `reset`, `isDirty`, and `getCroppedImage`.
-#### `useCropperState( initialState?: Partial ): UseCropperStateReturn`
+## Internal APIs
-State management hook. Returns a `controller` object with the current state and a named setter for each supported transition.
+### `Cropper`
-> **Pan vs. crop rectangle**: `setPan` / `state.pan` sets the image _pan offset_ (how the image is translated inside the viewport). `setCropRect` / `state.cropRect` sets the _crop rectangle_ (the region being selected).
+Fills its parent container. Required props are `src` and `controller`. Common options include `freeformCrop`, `aspectRatio`, `showGrid`, `showDimming`, `showDimensions`, `onGestureStart`, and `onGestureEnd`.
-| Field | Type | Description |
-| ----------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------- |
-| `state` | `CropperState` | Current state (read-only) |
-| `setImage` | `(image: CropperState['image']) => void` | Set the loaded image (src + natural size) |
-| `setPan` | `(pan: NormalizedPoint) => void` | Set image pan offset |
-| `setZoom` | `(zoom: number) => void` | Set zoom (clamped 1–10) |
-| `setZoomAtPoint` | `(zoom: number, pan: NormalizedPoint) => void` | Set zoom and pan together for focal-point zoom |
-| `setRotation` | `(degrees: number) => void` | Set rotation (normalized 0–360) |
-| `setFlip` | `(flip: Flip) => void` | Set flip state |
-| `snapRotate90` | `(direction: 1 \| -1) => void` | 90° snap rotation |
-| `setCropRect` | `(rect: NormalizedRect) => void` | Set crop rectangle |
-| `settleCrop` | `() => void` | Settle the crop rect after a resize drag (typically from `onResizeEnd`) |
-| `applyOperation` | `(op: TransformOperation) => void` | Apply a pipeline operation |
-| `reset` | `(state?: Partial) => void` | Reset to initial or given state |
-| `isDirty` | `boolean` | Whether state differs from initial |
-| `getCroppedImage` | `(mime?: string, quality?: number) => Promise` | Export as Blob. Throws on load/CORS/context failure — wrap in try/catch to recover. |
+### `useCropperReducer( initialState? )`
-The controller does not expose the reducer dispatch. Use the named setters or `applyOperation()` to change state.
+Pure React state hook for the cropper reducer. Returns a `CropperController`: current state, named setters, `reset`, `isDirty`, and `getCroppedImage`.
-### Source region
+### `CropperProvider` / `useCropper`
-#### `getSourceRegion( state, imageSize ): SourceRegion`
+Convenience context wrapper around `useCropperReducer` for component trees that need shared cropper state.
-Converts crop state to source-pixel coordinates: `{ x, y, width, height, rotation, flip, zoom }`. For server-side processing (FFmpeg, ImageMagick, etc.).
+## Core Helpers
-#### `getSourceRegionPercent( state, imageSize ): SourceRegionPercent`
+The local barrel also exposes helpers for reducer composition, source-region calculation, pipeline replay, and canvas export. Use named setters or `TransformOperation` values for normal state changes. Use reducer actions only when composing a controller such as `useMediaEditorState()`.
-Same as `getSourceRegion` but returns percentages (0–100): `{ x, y, width, height }`. Compatible with the WordPress REST API attachments `/edit` endpoint.
-
-### Export
-
-#### `exportCroppedImage( src, state, mimeType?, quality? ): Promise`
-
-End-to-end: load image, render with transforms, export as Blob. Browser-only (needs `HTMLCanvasElement`).
-
-Rejects on:
-
-- **Image load failures** (network error, 404) — the native load error is propagated.
-- **CORS / tainted canvas** — if the source doesn't set `Access-Control-Allow-Origin`, `canvas.toBlob()` rejects because the canvas is tainted. Fix at the server: send permissive CORS headers.
-- **Missing canvas context** — throws with a descriptive `Error`.
-
-Wrap in `try/catch` to distinguish failure modes.
-
-#### `applyToCanvas( source: CanvasImageSource, imageSize, state ): HTMLCanvasElement`
-
-Applies transforms to any `CanvasImageSource` (image, canvas, video frame, offscreen canvas). For multi-step editing pipelines.
-
-### Pipeline
-
-#### `stateFromPipeline( operations: TransformOperation[] ): CropperState`
-
-Replays a sequence of operations from default state. Pure function, no DOM needed. For headless/server-side processing.
-
-#### `applyOperationToState( state, operation ): CropperState`
-
-Applies a single operation to an existing state.
-
-### Types
-
-| Type | Description |
-| ----------------------- | --------------------------------------------------------------------------------- |
-| `CropperState` | `{ image, pan, zoom, rotation, flip, cropRect, basePan, baseZoom, baseRotation }` |
-| `UseCropperStateReturn` | The full shape returned by `useCropperState()`: state + setters |
-| `CropperProps` | Props for the `` component |
-| `StencilProps` | Contract for pluggable stencil components |
-| `TransformOperation` | `{ type: 'crop' \| 'rotate' \| 'flip' \| 'zoom', ... }` |
-| `NormalizedPoint` | `{ x: number, y: number }` in [0,1] space |
-| `NormalizedRect` | `{ x, y, width, height }` in [0,1] space |
-| `Size` | `{ width: number, height: number }` |
-| `Flip` | `{ horizontal: boolean, vertical: boolean }` |
-| `SourceRegion` | `{ x, y, width, height, rotation, flip, zoom }` in source pixels |
-| `SourceRegionPercent` | `{ x, y, width, height }` as percentages (0–100) |
-| `AspectRatioPreset` | `{ label: string, value: number }` |
+## Styles
-`CropperAction` (the reducer's action union) is internal. Drive state through the named setters on the controller object.
+Styles are bundled through the media-editor stylesheet. Classes use the `wp-media-editor-image-editor` prefix.
-### Constants
+## More
-| Constant | Value | Description |
-| ----------------------- | ----- | ------------------------------------------------------ |
-| `DEFAULT_STATE` | — | Default `CropperState` |
-| `DEFAULT_ASPECT_RATIOS` | Array | Preset aspect ratios (Free, Original, 1:1, 16:9, etc.) |
-| `ORIGINAL_ASPECT_RATIO` | `-1` | Sentinel value for "use image's original ratio" |
+- [docs/architecture.md](docs/architecture.md)
+- [docs/recipes.md](docs/recipes.md)
diff --git a/packages/media-editor/src/image-editor/docs/architecture.md b/packages/media-editor/src/image-editor/docs/architecture.md
index 63e47b66124b60..cc00e29461f8b2 100644
--- a/packages/media-editor/src/image-editor/docs/architecture.md
+++ b/packages/media-editor/src/image-editor/docs/architecture.md
@@ -1,97 +1,55 @@
-# Image Editor — Architecture
+# Image Editor Architecture
+
+## Layers
+
+| Layer | Files | Responsibility |
+| --- | --- | --- |
+| Core cropper | `src/image-editor/core/` | Pure state, camera math, containment, source-region, transforms, and canvas export. |
+| React cropper | `src/image-editor/react/` | DOM measurement, interaction hooks, overlays, stencil rendering, and the pure `useCropperReducer` hook. |
+| Media editor state | `src/state/` | Composite state for the media editor: cropper state, crop options, undo/redo, and gesture boundaries. |
+
+The cropper layer does not own editor history. History belongs to the composite media editor controller because sidebar controls and cropper gestures need one shared undo stack.
## Coordinate Spaces
| Space | What lives here | Coordinates |
-|-------|----------------|-------------|
-| **World** | Image, crop rect | Normalized 0–1, origin top-left of image |
-| **Camera** | View transform (zoom, pan, rotation, flip) | A `mat2d` mapping world → screen |
-| **Screen** | DOM container, mouse events, CSS | Pixels relative to container |
+| --- | --- | --- |
+| World | Image and crop rectangle | Normalized 0-1, origin at the top-left of the image. |
+| Camera | Pan, zoom, rotation, and flip transform | `mat2d` mapping world to screen. |
+| Screen | DOM container, pointer events, CSS transforms | Pixels relative to the cropper canvas. |
+| Viewport | Display-only pan/zoom of the cropper stage | CSS pixels; does not affect export. |
## Data Flow
-The camera is the source of truth for restriction (ensuring the image covers the crop). The render path uses lightweight manual math for CSS transforms and stencil positioning — these are simple, correct, and deliberately not routed through the camera to avoid prop-threading complexity.
-
-```mermaid
-graph TD
- subgraph State["CropperState"]
- pan["pan.x, pan.y"]
- zoom["zoom"]
- rotation["rotation"]
- flip["flip"]
- cropRect["cropRect"]
- end
-
- subgraph Camera["camera.ts — coordinate primitives"]
- createCamera["createCamera()"]
- getImageFit["getImageFit()"]
- createExportCamera["createExportCamera()"]
- end
-
- subgraph Containment["containment.ts — restriction source of truth"]
- restrictPanZoom["restrictPanZoom()\nbuilds camera → inverse → clamp"]
- restrictCropRect["restrictCropRect()"]
- getMinZoom["getMinZoom()"]
- getMinZoomForCover["getMinZoomForCover()"]
- getImageCropBounds["getImageCropBounds()"]
- end
-
- subgraph Rendering["Render Path"]
- useTransformStyle["use-transform-style.ts\ncos/sin → CSS matrix()"]
- stencil["rectangle-stencil.tsx\noffset + cropRect * visualSize"]
- dimming["dimming-overlay.tsx\noffset + cropRect * visualSize"]
- grid["grid-overlay.tsx\noffset + cropRect * visualSize"]
- end
-
- subgraph Interaction["Interaction Path"]
- useInteraction["use-interaction.ts\ndelta / visualSize → restrictPanZoom"]
- end
-
- subgraph Export["Export Path"]
- canvasRenderer["canvas-renderer.ts"]
- end
-
- State --> restrictPanZoom
- State --> restrictCropRect
- restrictPanZoom --> |"corrected crop/zoom"| State
- restrictCropRect --> |"corrected cropRect"| State
-
- State --> getImageFit
- getImageFit --> |"elementSize"| imgStyle["img element style"]
- getImageFit --> |"visualSize"| stencil
- getImageFit --> |"visualSize"| dimming
- getImageFit --> |"visualSize"| grid
- getImageFit --> |"visualSize"| useInteraction
- getImageFit --> |"visualSize"| useTransformStyle
-
- useInteraction --> |"named controller actions"| State
-
- State --> createExportCamera --> canvasRenderer
-```
-
-### Design decisions
-
-**Why restriction uses the camera but rendering doesn't:**
-- `restrictPanZoom` builds a camera internally and projects crop corners through its inverse. This guarantees the restriction and rendering agree on geometry — the camera IS the screen transform.
-- The render path (`use-transform-style`, stencils, overlays) uses simple manual math because the CSS transform operates in a different coordinate system (element-center origin vs container-center). Deriving CSS matrix from the camera mat2d would be more complex, not less.
-
-**Why `getImageFit` exists:**
-- `createCamera` needs to know the fitted image dimensions to build its matrix. `cropper.tsx` also needs them to size the `` element and compute `visualSize` for overlays. `getImageFit` is the shared contain-fit calculation used by both — no duplication.
-
-**UX invariant:**
-> After `restrictPanZoom`, transforming the 4 crop corners through `screenToWorld(camera, corner)` must produce world points inside [0,1]×[0,1].
-
-This is both the restriction algorithm AND the test. If the camera says it's covered, it's covered on screen — because the camera IS the screen transform.
-
-## Extension points
-
-See [recipes.md](recipes.md) for the full developer guide. Summary:
-
-| Extension | Mechanism |
-|-----------|-----------|
-| Custom crop area UI | `stencil` prop — any component implementing `StencilProps` |
-| AI agent control | `TransformOperation[]` pipeline — JSON-serializable, replayable |
-| Custom export | `exportCroppedImage()` or `applyToCanvas()` for multi-step pipelines |
-| Theming | BEM CSS classes (`.wp-media-editor-image-editor__*`) |
-| State observation | `onStateChange` (every frame), `onGestureStart`/`onGestureEnd` (gesture boundaries) |
-| Undo/redo | Snapshot state at gesture boundaries, `reset()` to restore — see recipes.md |
+`MediaEditorState` delegates cropper actions to `cropperReducer()`. Containment keeps the image covering the crop rectangle. React components measure the DOM and render the resulting state.
+
+## Design Decisions
+
+### Containment
+
+Restriction logic projects crop corners through the camera and checks that they stay inside the image.
+
+### Rendering
+
+The render path uses focused calculations for CSS transforms, stencil placement, dimming, and grid overlays.
+
+### State
+
+The core reducer is framework-agnostic and serializable. React and media-editor layers add DOM measurement, gestures, undo/redo, save behavior, and UI state.
+
+### Viewport
+
+Viewport pan/zoom lets the user inspect or follow the crop area without changing the crop output. It is not part of export and should not create undo entries.
+
+## Internal Integration Points
+
+These APIs let code inside `@wordpress/media-editor` compose the cropper. They are not supported extension points for themes or plugins.
+
+| Task | API |
+| --- | --- |
+| Alternate crop UI inside media-editor | `stencil` prop implementing `StencilProps`. |
+| Programmatic edits | `TransformOperation[]`, `applyOperation`, or `stateFromPipeline`. |
+| Crop data for REST requests | `getSourceRegion()` and `getSourceRegionPercent()`. |
+| Canvas export | `exportCroppedImage()` or `applyToCanvas()`. |
+| Shared cropper state across internal components | `CropperProvider` / `useCropper()`. |
+| Media editor history | `useMediaEditorState()` composite controller. |
diff --git a/packages/media-editor/src/image-editor/docs/recipes.md b/packages/media-editor/src/image-editor/docs/recipes.md
index 22c4fdc5fb16fd..c0d550e6237482 100644
--- a/packages/media-editor/src/image-editor/docs/recipes.md
+++ b/packages/media-editor/src/image-editor/docs/recipes.md
@@ -1,720 +1,184 @@
-# Recipes and Getting Started
+# Image Editor Recipes
-> **Status: internal.** The module is not exported from `@wordpress/media-editor`'s public API. Code examples use the relative path `../image-editor` because only internal callers can reach the module. When it's promoted to the package's public surface, swap the import for `@wordpress/media-editor`.
+> **Status: internal.** Import from `../image-editor` only inside `@wordpress/media-editor`.
-Getting started, extension points, and integration patterns.
-
-## Getting started
-
-### Step 1: Mount a basic cropper
-
-```tsx
-import { Cropper, useCropperState } from '../image-editor';
-
-function ImageEditor() {
- const controller = useCropperState();
- return (
-
- );
-}
-```
-
-The cropper fills its parent container. Wrap it in a sized element:
+## Mount a Cropper
```tsx
-
-
-
-```
-
-### Step 2: Add controls
-
-`useCropperState` returns a single `controller` object bundling the state and a named setter for every supported transition:
-
-```tsx
-const controller = useCropperState();
-const {
- state,
- setZoom, setZoomAtPoint, setRotation, setFlip, snapRotate90, setCropRect,
- applyOperation, reset, isDirty, getCroppedImage,
-} = controller;
-
-// Zoom slider
- setZoom( parseFloat( e.target.value ) ) } />
-
-// Rotation buttons
-
-
-
-// Flip
-
-
-// Reset
-
-```
-
-### Step 3: Export the result
-
-```tsx
-// As a Blob (for upload or further processing):
-const blob = await getCroppedImage( 'image/jpeg', 0.9 );
-
-// As source-pixel coordinates (for server-side processing):
-import { getSourceRegion } from '../image-editor';
-const region = getSourceRegion( state, { width: naturalWidth, height: naturalHeight } );
-
-// As percentages (for WP REST API /edit endpoint):
-import { getSourceRegionPercent } from '../image-editor';
-const pct = getSourceRegionPercent( state, { width: naturalWidth, height: naturalHeight } );
-```
+import { Cropper, useCropperReducer } from '../image-editor';
-## Architecture overview
+function ImageCropper( { src }: { src: string } ) {
+ const controller = useCropperReducer();
-```
-Consumer (plugin/theme/AI agent)
- |
- v
-useCropperState() -- State management (reducer + convenience setters)
- |
- v
- -- Orchestrates rendering and interaction
- |
- +-- stencil prop -- Pluggable crop area UI (StencilProps interface)
- +-- useInteraction() -- Mouse/touch/keyboard → named controller actions
- +-- useTransformStyle()-- State → CSS matrix
- |
- v
-Pipeline / Export -- TransformOperation[] → canvas → Blob
-```
-
-## Extension points
-
-### 1. Custom stencils
-
-The crop area UI is fully pluggable. Any component that implements `StencilProps` can replace the default `RectangleStencil`.
-
-```tsx
-import { Cropper, useCropperState } from '../image-editor';
-import type { StencilProps } from '../image-editor';
-
-function CircularStencil( { cropRect, containerSize, imageSize, onCropChange }: StencilProps ) {
- // Render a circular crop overlay using cropRect bounds.
- // Call onCropChange() when the user resizes.
- return
;
+function CropperPanel( { src }: { src: string } ) {
+ const controller = useCropper();
+ return ;
}
```
-**Programmatic control:**
-
-```typescript
-// Observe state:
-useEffect( () => {
- console.log( 'Crop changed:', state.cropRect );
- // Send to analytics, sync with server, update AI context, etc.
-}, [ state ] );
-
-// Control programmatically — use the named setters:
-controller.setZoom( 2.0 );
-controller.setRotation( 45 );
-controller.snapRotate90( 1 );
-controller.settleCrop();
-```
-
-### 5. Source region for external tools
-
-`getSourceRegion()` converts the current crop state to source-pixel coordinates. This is the bridge between the cropper and external tools (image processing libraries, AI APIs, server-side processing) that work in source-pixel coordinates.
+## Use the Media Editor Composite Controller
-```typescript
-import { getSourceRegion } from '../image-editor';
-
-const region = getSourceRegion( state, { width: naturalWidth, height: naturalHeight } );
-// region = { x, y, width, height, rotation, flip, zoom }
-
-// Send to server for processing:
-fetch( '/api/process', {
- method: 'POST',
- body: JSON.stringify( {
- imageId: 123,
- crop: { x: region.x, y: region.y, width: region.width, height: region.height },
- rotation: region.rotation,
- flip: region.flip,
- } ),
-} );
-
-// Send to AI API for region-specific editing:
-const aiRequest = {
- region: { x: region.x, y: region.y, width: region.width, height: region.height },
- prompt: 'Remove the background in this area',
-};
-```
-
-### 6. Multi-step editing pipelines
-
-`applyToCanvas()` applies the cropper's transform to an existing canvas or image source. This enables multi-step editing where an upstream tool (brightness, color, filters) has already processed the image.
-
-```typescript
-import { applyToCanvas } from '../image-editor';
-
-// Step 1: Apply brightness/color adjustments to a canvas
-const processedCanvas = applyBrightness( sourceImage, { brightness: 1.2 } );
-
-// Step 2: Apply the crop/rotate/flip on top
-const finalCanvas = applyToCanvas(
- processedCanvas,
- { width: processedCanvas.width, height: processedCanvas.height },
- cropperState
-);
-
-// Step 3: Export
-const blob = await canvasToBlob( finalCanvas, 'image/jpeg', 0.9 );
-```
-
-Accepts any `CanvasImageSource`: `HTMLImageElement`, `HTMLCanvasElement`, `OffscreenCanvas`, `ImageBitmap`, `HTMLVideoElement`.
-
-### 7. State change notifications
-
-The `Cropper` component provides two notification mechanisms:
-
-- **`onStateChange`** — fires on every state change (every frame during a drag). Use for real-time syncing, live previews, or analytics.
-- **`onGestureStart` / `onGestureEnd`** — fire at gesture boundaries (pointerdown/pointerup, wheel burst start/end). Use for undo/redo snapshots, debounced saves, or "user finished editing" detection.
+Use the media editor controller when cropper state must share history with controls such as aspect ratio and freeform crop.
```tsx
- {
- // Fires every frame during drag — good for live preview.
- updateLivePreview( currentState );
- } }
- onGestureStart={ () => {
- // User started interacting — snapshot for undo.
- saveSnapshot( state );
- } }
- onGestureEnd={ () => {
- // User finished interacting — safe to save/sync.
- autosaveDraft( state );
- } }
-/>
-```
+import { resolveAspectRatio, useMediaEditor } from '../../state';
+import { Cropper } from '../image-editor';
-### 8. Theming and styling
+function MediaEditorCanvas( { src }: { src: string } ) {
+ const controller = useMediaEditor();
+ const aspectRatio = resolveAspectRatio(
+ controller.cropOptions.aspectRatioValue,
+ controller.state.image
+ );
-The component uses BEM-style CSS classes that themes can override:
-
-```
-.wp-media-editor-image-editor -- Container (cursor: grab)
-.wp-media-editor-image-editor--dragging -- Applied during image pan drag (cursor: grabbing)
-.wp-media-editor-image-editor__image -- The image element
-.wp-media-editor-image-editor__stencil -- Crop area container (pointer-events: none)
-.wp-media-editor-image-editor__stencil-rect -- Crop border rectangle
-.wp-media-editor-image-editor__handle -- Resize handle (all, pointer-events: auto)
-.wp-media-editor-image-editor__handle--n -- North handle (cursor: ns-resize)
-.wp-media-editor-image-editor__handle--s -- South handle (cursor: ns-resize)
-.wp-media-editor-image-editor__handle--e -- East handle (cursor: ew-resize)
-.wp-media-editor-image-editor__handle--w -- West handle (cursor: ew-resize)
-.wp-media-editor-image-editor__handle--nw -- North-west handle (cursor: nwse-resize)
-.wp-media-editor-image-editor__handle--ne -- North-east handle (cursor: nesw-resize)
-.wp-media-editor-image-editor__handle--sw -- South-west handle (cursor: nesw-resize)
-.wp-media-editor-image-editor__handle--se -- South-east handle (cursor: nwse-resize)
-.wp-media-editor-image-editor__dimming -- Dimming overlay outside crop area
-.wp-media-editor-image-editor__grid -- Grid overlay container
-.wp-media-editor-image-editor__grid-line -- Individual grid line
-```
-
-All styles are in CSS classes with no inline style overrides, so consumers can override anything with equal or higher specificity.
-
-**Override handles and dimming:**
-
-```css
-.wp-media-editor-image-editor__handle {
- background: var(--wp--preset--color--primary);
- border-radius: 50%;
-}
-.wp-media-editor-image-editor__dimming {
- background: rgba(0, 0, 0, 0.6);
-}
-```
-
-**Override cursors:**
-
-```css
-/* Use crosshair instead of grab */
-.wp-media-editor-image-editor {
- cursor: crosshair;
-}
-.wp-media-editor-image-editor--dragging {
- cursor: move;
-}
-/* Custom handle cursor */
-.wp-media-editor-image-editor__handle {
- cursor: pointer;
+ return (
+
+ );
}
```
-**Override handle appearance per position:**
-
-```css
-/* Only show corner handles, hide edge handles */
-.wp-media-editor-image-editor__handle--n,
-.wp-media-editor-image-editor__handle--s,
-.wp-media-editor-image-editor__handle--e,
-.wp-media-editor-image-editor__handle--w {
- display: none;
-}
-```
-
-## AI agent integration patterns
-
-### Driving the cropper from an AI agent
+## Get Source Coordinates
-The pipeline API is designed for AI agents. An agent can:
+Use source-region helpers when saving crop data instead of rendering a `Blob`.
-1. **Analyze the image** and determine the optimal crop
-2. **Generate a `TransformOperation[]`** with crop, rotation, zoom
-3. **Apply it** via `applyOperation()` or `stateFromPipeline()`
-4. **Export the result** via `exportCroppedImage()`
+```ts
+import { getSourceRegion, getSourceRegionPercent } from '../image-editor';
-```typescript
-// Agent generates instructions:
-const agentInstructions = {
- crop: { x: 0.1, y: 0.05, width: 0.8, height: 0.9 },
- rotation: 2, // slight straighten
- zoom: 1.1,
-};
-
-// Apply:
-applyOperation( { type: 'crop', rect: agentInstructions.crop } );
-applyOperation( { type: 'rotate', degrees: agentInstructions.rotation } );
-applyOperation( { type: 'zoom', factor: agentInstructions.zoom } );
+const imageSize = { width: naturalWidth, height: naturalHeight };
+const region = getSourceRegion( controller.state, imageSize );
+const percent = getSourceRegionPercent( controller.state, imageSize );
```
-### Headless editing (no React, no DOM)
-
-The core layer is pure functions — no React or browser required for steps 1-2:
-
-```typescript
-import {
- stateFromPipeline,
- getSourceRegion,
- exportCroppedImage,
-} from '../image-editor';
-
-// 1. Build state from operations (pure — runs in Node, workers, anywhere)
-const state = stateFromPipeline( [
- { type: 'crop', rect: { x: 0.1, y: 0.1, width: 0.8, height: 0.8 } },
- { type: 'rotate', degrees: 5 },
- { type: 'zoom', factor: 1.2 },
- { type: 'flip', direction: 'horizontal' },
-] );
-
-// 2. Get source-pixel region (pure — for server-side FFmpeg/ImageMagick)
-const region = getSourceRegion( state, { width: 4000, height: 3000 } );
-// → { x: 400, y: 300, width: 3200, height: 2400, rotation: 5, flip: {...}, zoom: 1.2 }
+`getSourceRegionPercent()` matches the crop data shape used by the WordPress attachments `/edit` endpoint.
-// Pass to server:
-// ffmpeg -i input.jpg -vf "crop=3200:2400:400:300,rotate=0.087" output.jpg
+## Export a Cropped Image
-// 3. Or export to Blob (needs canvas — browser or node-canvas)
-const blob = await exportCroppedImage( imageUrl, state, 'image/jpeg', 0.9 );
+```ts
+const blob = await controller.getCroppedImage( 'image/jpeg', 0.9 );
```
-Steps 1 and 2 are pure functions with zero DOM dependencies. Step 3 needs `canvas` and `Image` (browser, jsdom, or node-canvas).
-
-## Multi-step editing integration
-
-The package is designed to be one step in a broader image editing pipeline. Key integration patterns:
-
-### Crop as a step (Google Photos style)
-
-The state is external and serializable. You can:
-1. Mount the Cropper, let the user crop
-2. Snapshot `state` (it's a plain object)
-3. Switch to a brightness/color tab (unmount Cropper, the state persists)
-4. Switch back — restore `state`, remount Cropper, pick up where you left off
+For a custom canvas pipeline, use `applyToCanvas()`.
-```typescript
-// Save state when switching tabs:
-const savedCropState = { ...state };
+```ts
+import { applyToCanvas } from '../image-editor';
-// Restore when coming back:
-const controller = useCropperState( savedCropState );
+const output = applyToCanvas(
+ processedCanvas,
+ { width: processedCanvas.width, height: processedCanvas.height },
+ controller.state
+);
```
-### Undo/redo with gesture support
+Canvas export is browser-only. Cross-origin images need valid CORS headers or the canvas will be tainted.
-The `Cropper` component fires `onGestureStart` and `onGestureEnd` callbacks at the boundaries of continuous interactions (pan drags, handle resizes, wheel/pinch zoom). These let you snapshot state before and after each gesture, treating the entire drag as a single undo step.
+## Programmatic Edits
-**Two kinds of undo entries:**
+`TransformOperation` values are plain JSON. Apply them one at a time or replay them from scratch.
-1. **Toolbar operations** (rotate, flip, zoom buttons) — snapshot state before calling a setter, then apply the `TransformOperation`.
-2. **Gestures** (drag, resize, wheel zoom) — snapshot state in `onGestureStart`, compare with the current state in `onGestureEnd`, and push the before-state onto the undo stack.
+```ts
+import { stateFromPipeline, type TransformOperation } from '../image-editor';
-See the `UndoRedo` story for a complete working example. Here is the core pattern:
+const operations: TransformOperation[] = [
+ { type: 'crop', rect: { x: 0.1, y: 0.1, width: 0.8, height: 0.8 } },
+ { type: 'rotate', degrees: 5 },
+ { type: 'flip', direction: 'horizontal' },
+];
-```tsx
-import { Cropper, useCropperState } from '../image-editor';
-import type { CropperState, TransformOperation } from '../image-editor';
-import { useState, useCallback, useRef, useEffect } from '@wordpress/element';
-
-function ImageEditorWithUndo( { src }: { src: string } ) {
- const controller = useCropperState();
- const { state, applyOperation, reset } = controller;
-
- // Keep a ref to the latest state so gesture callbacks never go stale.
- const stateRef = useRef( state );
- stateRef.current = state;
-
- // Undo/redo stacks store full CropperState snapshots.
- const [ past, setPast ] = useState< CropperState[] >( [] );
- const [ future, setFuture ] = useState< CropperState[] >( [] );
-
- // Ref to hold the state snapshot captured at gesture start.
- const snapshotRef = useRef< CropperState | null >( null );
-
- // --- Toolbar operations ---
-
- const applyToolbarOp = useCallback(
- ( op: TransformOperation ) => {
- // Snapshot current state before applying.
- setPast( ( prev ) => [ ...prev, { ...state } ] );
- setFuture( [] );
- applyOperation( op );
- },
- [ state, applyOperation ]
- );
-
- // --- Gesture-based undo ---
-
- const handleGestureStart = useCallback( () => {
- // Capture state at the start of the gesture (via ref to avoid stale closure).
- snapshotRef.current = { ...stateRef.current };
- }, [] );
-
- const handleGestureEnd = useCallback( () => {
- if ( ! snapshotRef.current ) {
- return;
- }
- const before = snapshotRef.current;
- snapshotRef.current = null;
-
- // Push the before-state as an undo entry.
- setPast( ( prev ) => [ ...prev, before ] );
- setFuture( [] );
- }, [] );
-
- // --- Undo / Redo ---
-
- const undo = useCallback( () => {
- if ( past.length === 0 ) {
- return;
- }
- const newPast = [ ...past ];
- const previous = newPast.pop()!;
- setPast( newPast );
- setFuture( ( prev ) => [ ...prev, { ...state } ] );
- reset( previous );
- }, [ past, state, reset ] );
-
- const redo = useCallback( () => {
- if ( future.length === 0 ) {
- return;
- }
- const newFuture = [ ...future ];
- const next = newFuture.pop()!;
- setPast( ( prev ) => [ ...prev, { ...state } ] );
- setFuture( newFuture );
- reset( next );
- }, [ future, state, reset ] );
-
- // --- Keyboard shortcuts (use refs to avoid stale closures) ---
-
- const undoRef = useRef( undo );
- const redoRef = useRef( redo );
- undoRef.current = undo;
- redoRef.current = redo;
-
- useEffect( () => {
- const handler = ( e: KeyboardEvent ) => {
- if ( ( e.metaKey || e.ctrlKey ) && e.key === 'z' ) {
- e.preventDefault();
- if ( e.shiftKey ) {
- redoRef.current();
- } else {
- undoRef.current();
- }
- }
- };
- document.addEventListener( 'keydown', handler );
- return () => document.removeEventListener( 'keydown', handler );
- }, [] );
-
- return (
-
-
-
-
-
-
-
-
-
- );
+for ( const operation of operations ) {
+ controller.applyOperation( operation );
}
-```
-
-**Key implementation details:**
-
-| Concern | Solution |
-|---------|----------|
-| Stale closures in gesture callbacks | Use `useRef` for state (`stateRef`) and snapshot (`snapshotRef`). The callbacks are stable (`[]` deps) and always read the latest value. |
-| Stale closures in keyboard handler | Use `undoRef` / `redoRef` updated on every render, with the listener registered once (`[]` deps). |
-| Wheel zoom boundaries | The `useInteraction` hook debounces wheel events — it fires `onGestureStart` on the first scroll tick and `onGestureEnd` after 300ms of inactivity, grouping a burst of scroll events into one undo step. |
-| Handle resize settle | `onGestureEnd` fires after the settle animation (crop re-centers). The undo snapshot captures the final settled state. |
-| Pipeline display | For toolbar ops, log the `TransformOperation`. For gestures, compare before/after state and generate a descriptive label (e.g., "gesture: pan, zoom 1.5x"). |
-
-## Accessibility
-The cropper is keyboard-accessible and screen-reader friendly:
-
-**Keyboard controls:**
-- **Arrow keys** on the container: pan the image (Shift for larger jumps)
-- **+/-** on the container: zoom in/out
-- **R** on the container: snap rotate 90°
-- **Tab** to crop handles, then **arrow keys** to resize (Shift for larger jumps)
-- Aspect ratio lock is respected during keyboard resize
-
-**Screen reader support:**
-- Container is a focusable `role="group"` with `aria-label="Image editor"`. We deliberately avoid `role="application"` because it disables the screen reader's default keybindings — too heavy for a single widget.
-- Resize handles are native `