From 8f30d8218502355a208597abe13910e3f3a6a072 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 25 May 2026 12:35:40 +1000 Subject: [PATCH 1/2] Docs: Update media editor documentation --- .../src/components/rotation-ruler/README.md | 36 +- .../media-editor/src/image-editor/README.md | 158 +--- .../src/image-editor/docs/architecture.md | 138 ++-- .../src/image-editor/docs/recipes.md | 782 +++--------------- .../src/image-editor/docs/roadmap.md | 66 -- 5 files changed, 210 insertions(+), 970 deletions(-) delete mode 100644 packages/media-editor/src/image-editor/docs/roadmap.md diff --git a/packages/media-editor/src/components/rotation-ruler/README.md b/packages/media-editor/src/components/rotation-ruler/README.md index 97d423f1d41820..8e9043f8ffaf62 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 and keyboard changes are clamped to `min` / `max` and quantized to `step`. ## 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 MyCropper() { - const controller = useCropperState(); - return ; + return ( +
+ +
+ ); } ``` -**StencilProps contract:** - -| Prop | Type | Description | -|------|------|-------------| -| `cropRect` | `NormalizedRect` | Current crop area in [0,1] normalized space | -| `containerSize` | `Size` | Container pixel dimensions | -| `imageSize` | `Size` | Visual (rotated) image pixel dimensions | -| `onCropChange` | `(rect) => void` | Call during drag to update the crop | -| `onResizeEnd` | `() => void` | Call on mouseup to trigger settle animation | -| `aspectRatio` | `number?` | Locked aspect ratio (width/height) | -| `freeformCrop` | `boolean?` | Whether handles are shown | -| `stencilTransition` | `string?` | CSS transition for settle animation | -| `cropBounds` | `{minX,minY,maxX,maxY}?` | Allowed crop handle limits | - -### 2. Transform pipeline (AI agent integration) +The cropper fills its parent. Always render it inside a sized container. -The pipeline is the primary interface for programmatic control. Operations are JSON-serializable, making them ideal for AI agents, undo/redo stacks, and remote control. - -```typescript -import { useCropperState } from '../image-editor'; -import type { TransformOperation } from '../image-editor'; - -// An AI agent generates a list of operations: -const operations: TransformOperation[] = [ - { type: 'crop', rect: { x: 0.1, y: 0.1, width: 0.8, height: 0.8 } }, - { type: 'rotate', degrees: 15 }, - { type: 'zoom', factor: 1.5 }, - { type: 'flip', direction: 'horizontal' }, -]; - -// Apply them: -const { applyOperation } = useCropperState(); -for ( const op of operations ) { - applyOperation( op ); -} -``` - -**Replay from scratch:** - -```typescript -import { stateFromPipeline } from '../image-editor'; - -// Replay a pipeline from initial state: -const finalState = stateFromPipeline( operations ); - -// Serialize for storage (operations are plain JSON): -const json = JSON.stringify( operations ); - -// Deserialize: -const ops = JSON.parse( json ); -``` - -**Adding new operation types:** - -1. Add the operation variant to `TransformOperation` in `core/types.ts` -2. Handle it in `applyOperationToState()` in `core/transforms/pipeline.ts` - -### 3. Custom export pipelines - -The export system converts cropper state to canvas output. Use `applyToCanvas()` to chain custom processing with the cropper transforms. - -```typescript -import { applyToCanvas } from '../image-editor'; - -// 1. Apply your own processing first (brightness, filters, etc.): -const processedCanvas = applyBrightness( sourceImage, { brightness: 1.2 } ); - -// 2. Apply the cropper transforms on top: -const finalCanvas = applyToCanvas( - processedCanvas, - { width: processedCanvas.width, height: processedCanvas.height }, - cropperState -); - -// 3. Export however you need: -const blob = await new Promise( ( resolve ) => - finalCanvas.toBlob( resolve, 'image/jpeg', 0.9 ) -); -``` - -`applyToCanvas()` accepts any `CanvasImageSource`: `HTMLImageElement`, `HTMLCanvasElement`, `OffscreenCanvas`, `ImageBitmap`, `HTMLVideoElement`. - -### 4. State management patterns - -The state is a plain object and the hook returns one `controller` bundle of state + named setters. There are two ways to wire it up depending on your component structure. - -**Direct hook (simple case):** - -When the cropper and controls are in the same component: +## Add Controls ```tsx -import { Cropper, useCropperState } from '../image-editor'; - -function ImageEditor() { - const controller = useCropperState(); - const { state, setZoom, snapRotate90, reset } = controller; - return ( -
- - - - -
- ); -} -``` - -**Provider pattern (deep component trees):** - -When controls and the cropper are in different parts of the tree, use `CropperProvider` to avoid prop-drilling. Any descendant can call `useCropper()` to access the controller: +function ImageCropperWithControls( { src }: { src: string } ) { + const controller = useCropperReducer(); + const { state, setZoom, snapRotate90, toggleFlip, reset } = controller; + + return ( + <> + + + + + + + ); +} +``` + +When internal controls are split across components, use `CropperProvider`. ```tsx import { Cropper, CropperProvider, useCropper } from '../image-editor'; -function ImageEditor() { - return ( - - - - - - ); +function Editor( { src }: { src: string } ) { + return ( + + + + + ); } function Toolbar() { - const { state, setZoom, snapRotate90 } = useCropper(); - return ( -
- - -
- ); + const { snapRotate90 } = useCropper(); + return ; } -function CropperPanel() { - const controller = useCropper(); - return ; -} - -function Sidebar() { - const { state } = useCropper(); - return

Zoom: { Math.round( state.zoom * 100 ) }%

; +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 `