Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions packages/image-cropper/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## Unreleased

### New Features

- Add `Cropper` and `useCropperController`: the cropper the media editor uses, with pan, zoom, rotation, flip, a resizable crop area and a lockable aspect ratio. Keep its state yourself (`state` and `onChange`) to put it in your own undo history. Also add `buildModifiers`, which turns a crop into the instructions the WordPress media REST endpoint expects, and the default aspect-ratio presets. This API is experimental. Its styles ship in `build-style/style.css`.

### Deprecations

- `ImageCropper`, `ImageCropperProvider`, `useImageCropper` and `normalizeRotation` are deprecated. Use `Cropper` instead.

### Internal

- Stop publishing stories and tests, and move `@wordpress/components` to development dependencies, since only the stories use it.

## 2.0.0 (2026-10-20)

### Breaking Changes
Expand Down
115 changes: 96 additions & 19 deletions packages/image-cropper/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,93 @@
# Image Cropper

An implementation of [react-easy-crop](https://www.npmjs.com/package/react-easy-crop).
An image cropper with pan, zoom, rotation, flip and a resizable crop area, and helpers to send the result to WordPress.

## Current features
<div class="callout callout-alert">
The `Cropper` API is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
</div>

## Installation

Install the module

```bash
npm install @wordpress/image-cropper --save
```

## Styles

The cropper's styles ship in `build-style/style.css`. Load them in your stylesheet:

```scss
@use "@wordpress/image-cropper/build-style/style.css";
```

## Quick Start

```tsx
import { Cropper, useCropperController } from '@wordpress/image-cropper';

function ImageCropper() {
const controller = useCropperController();

return (
<div style={ { width: 600, height: 400 } }>
<Cropper
src="https://example.com/photo.jpg"
controller={ controller }
freeformCrop
showDimming
showGrid="interactive"
/>
</div>
);
}
```

`useCropperController` returns a controller with the current `state`, named setters, `reset`, `isDirty` and `getCroppedImage`. It keeps no undo history; to add one, keep the state yourself (see below).

## API

### `Cropper`

Fills its parent container. Required props are `src` and `controller`. Common options include `freeformCrop`, `showGrid`, `showDimming`, `showDimensions`, `onGestureStart` and `onGestureEnd`.

### `useCropperController( options? )`

React hook for the cropper. Returns a `CropperController`: the current state, named setters, `reset`, `isDirty` and `getCroppedImage`. Lock the crop to a ratio with `controller.setAspectRatio( ratio )`, or unlock it with `undefined`; the ratio lives in `controller.state.aspectRatio`, and `<Cropper>` reads it from there.

With no options, the controller keeps its own state (pass `initialState` to start from something other than the default). To keep the state yourself, for example in an editor's own undo history, pass `state` and `onChange`. The controller works out each change and calls `onChange( nextState, { isEdit } )`. `isEdit` is `false` for changes the person did not make, such as an image loading or the crop being refitted after a window resize, which usually should not get an undo entry. Use `onGestureStart` and `onGestureEnd` on `<Cropper>` to group a drag into one undo entry.

### `createCropperState( image? )`

The state a cropper starts from for an image. Use it to build your starting state when you keep the state yourself.

### `areCropperStatesEqual( a, b )`

Whether two cropper states are the same crop, ignoring tiny floating-point differences. Use it to skip no-op entries in your own undo history.

### `buildModifiers( controller )`

The rotate, flip and crop instructions the WordPress media REST endpoint (`/wp/v2/media/<id>/edit`) expects, in the order the server applies them.

### `DEFAULT_ASPECT_RATIOS`, `resolveAspectRatio( presetKey, image )`

The default aspect-ratio presets, and a function that turns a preset value into the ratio to pass to `controller.setAspectRatio`, resolving "Original" from the loaded image.

### `MAX_ZOOM`, `MAX_ROTATION_OFFSET`, `getMinZoom`

Limits for building your own zoom and rotation controls.

## More

- [src/docs/architecture.md](src/docs/architecture.md)
- [src/docs/recipes.md](src/docs/recipes.md)

## Deprecated: `ImageCropper`

`ImageCropper`, `ImageCropperProvider`, `useImageCropper` and `normalizeRotation` are deprecated. They are built on [react-easy-crop](https://www.npmjs.com/package/react-easy-crop) and will be rebuilt on `Cropper`, then removed. New code should use `Cropper`.

### Current features

- **Image Cropping**: Interactive aspect ratio-based crop area with drag and resize functionality
- **Rotation**: Rotate images in 90-degree increments
Expand All @@ -12,21 +97,13 @@ An implementation of [react-easy-crop](https://www.npmjs.com/package/react-easy-
- **State Management**: Centralized state management with React Context
- **Custom Reset State**: Configure custom initial states for reset operations

## Future features
### Future features

- [ ] Freeform cropping

## Installation

Install the module

```bash
npm install @wordpress/image-cropper --save
```

## API
### API

### Components
#### Components

- `ImageCropper` - The main cropping component that provides the cropping canvas.

Expand All @@ -41,7 +118,7 @@ interface ImageCropperProps {

- `ImageCropperProvider` - Context provider for state management. This component implements the React Context pattern to share cropper state across your application. It manages all internal cropper state including crop position, zoom levels, rotation, flip transformations, aspect ratios, and media dimensions. Any component that needs to read or modify cropper state must be a descendant of this provider. The provider also handles state persistence and provides methods for resetting to custom initial states.

### Hooks
#### Hooks

- `useImageCropper` - Provides access to all cropper state and methods.

Expand All @@ -64,15 +141,15 @@ const {
} = useImageCropper();
```

### Types
#### Types

- `ImageCropperState` - State interface for cropper data
- `ImageCropperProps` - Props interface for ImageCropper component
- `ImageCropperContextValue` - Context value interface
- `Flip` - Flip state interface
- `Point`, `Area`, `MediaSize` - Re-exported from react-easy-crop

### Utilities
#### Utilities

- `normalizeRotation` - Utility function to normalize rotation values to 0-360 degrees

Expand All @@ -83,9 +160,9 @@ const normalized = normalizeRotation( -90 ); // Returns 270
const normalized2 = normalizeRotation( 450 ); // Returns 90
```

## Usage
### Usage

### Basic Implementation
#### Basic Implementation

The image cropper provides the core cropping functionality without any built-in UI controls. You must implement your own tools using the `useImageCropper` hook.

Expand Down Expand Up @@ -168,7 +245,7 @@ function ImageEditorTools() {
}
```

### Advanced Usage with Custom Reset State
#### Advanced Usage with Custom Reset State

You can configure a custom initial state that will be used when the reset function is called:

Expand Down
5 changes: 5 additions & 0 deletions packages/image-cropper/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*
* With typeRoots set in tsconfig, global types outside those directories,
* like the jest-dom matchers, must be referenced explicitly.
*/
import '@testing-library/jest-dom/vitest';
21 changes: 18 additions & 3 deletions packages/image-cropper/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
"src",
"build",
"build-module",
"build-types"
"build-style",
"build-types",
"!**/stories/**",
"!**/test/**"
],
"main": "build/index.cjs",
"module": "build-module/index.mjs",
Expand All @@ -36,19 +39,31 @@
"import": "./build-module/index.mjs",
"require": "./build/index.cjs"
},
"./build-style/*": "./build-style/*",
"./package.json": "./package.json"
},
"types": "build-types",
"sideEffects": false,
"dependencies": {
"@wordpress/components": "file:../components",
"@wordpress/a11y": "file:../a11y",
"@wordpress/base-styles": "file:../base-styles",
"@wordpress/element": "file:../element",
"@wordpress/i18n": "file:../i18n",
"clsx": "^2.1.1",
"dequal": "^2.0.3",
"gl-matrix": "^3.4.3",
"react-easy-crop": "^5.4.2"
},
"devDependencies": {
"@wordpress/ui": "file:../ui"
"@storybook/react-vite": "^10.6.3",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@wordpress/components": "file:../components",
"@wordpress/icons": "file:../icons",
"@wordpress/ui": "file:../ui",
"vitest": "^5.0.3",
"vitest-browser-react": "^2.3.0"
},
"peerDependencies": {
"@types/react": "^18 || ^19",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CropperController } from '../../image-editor';
import { getRotatedBBox } from '../../image-editor/core/camera';
import type { CropperController } from './react/hooks';
import { getRotatedBBox } from './core/camera';

/**
* A single modifier in the Core REST `/edit` payload. Order is
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mat2d, vec2 } from 'gl-matrix';
import type {
CropperState,
CropperControllerState,
NormalizedPoint,
NormalizedRect,
Size,
Expand Down Expand Up @@ -182,7 +182,7 @@ export function getViewScale(
* @return The composed camera matrix.
*/
export function createCamera(
state: CropperState,
state: CropperControllerState,
containerSize: Size,
imageSize: Size
): Camera {
Expand Down Expand Up @@ -380,7 +380,7 @@ export function getVisibleBounds( camera: Camera ): VisualBounds {
* @return The composed export camera matrix.
*/
export function createExportCamera(
state: CropperState,
state: CropperControllerState,
imageSize: Size,
outputSize: Size
): Camera {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { NormalizedRect, Flip, CropperState } from './types';
import type { NormalizedRect, Flip, CropperControllerState } from './types';

/**
* Default/resting zoom. A full, unrotated crop needs at least 1x to cover the
Expand Down Expand Up @@ -90,7 +90,7 @@ const DEFAULT_PAN: { x: number; y: number } = {
y: 0,
};

export const DEFAULT_STATE: CropperState = {
export const DEFAULT_STATE: CropperControllerState = {
image: null,
pan: { ...DEFAULT_PAN },
zoom: MIN_ZOOM,
Expand Down
Loading
Loading