Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Allow the media editor cropper to lower the effective minimum zoom be…
…low 1 when containment math confirms the crop remains covered.

This keeps 1x as the default/resting zoom, but removes the hard floor that prevented thin, tall crops on fine-rotated portrait images from filling the available space.

Add coverage for:
- sub-1 containment on fine-rotated portrait crops
- settle behavior for tall rotated crops
- export/server modifier parity at sub-1 zoom
  • Loading branch information
ramonjd committed May 19, 2026
commit 958312b8d9666af06585301d124e980abcce95b9
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { __, sprintf } from '@wordpress/i18n';
*/
import { useCropper } from '../../image-editor';
import { useCropGestureHandlers } from '../../hooks/use-crop-gesture-handlers';
import { MAX_ZOOM, MIN_ZOOM } from '../../image-editor/core/constants';
import { MAX_ZOOM } from '../../image-editor/core/constants';
import { getMinZoom } from '../../image-editor/core/containment';
import type { AspectRatioPreset } from '../../image-editor/core/constants';

export interface MediaEditorCropPanelProps {
Expand Down Expand Up @@ -58,6 +59,7 @@ export default function MediaEditorCropPanel( {
}: MediaEditorCropPanelProps ) {
const { state, setZoom } = useCropper();
const zoomGestureHandlers = useCropGestureHandlers();
const minZoom = getMinZoom( state );

return (
<Stack direction="column" gap="md">
Expand Down Expand Up @@ -87,17 +89,17 @@ export default function MediaEditorCropPanel( {
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( 'Zoom' ) }
min={ MIN_ZOOM }
min={ minZoom }
max={ MAX_ZOOM }
step={ 0.1 }
value={ state.zoom }
onChange={ ( value ) => {
onPlacementControlInteraction?.();
setZoom( typeof value === 'number' ? value : MIN_ZOOM );
setZoom( typeof value === 'number' ? value : minZoom );
} }
renderTooltipContent={ ( value ) => {
const zoom =
typeof value === 'number' ? value : MIN_ZOOM;
typeof value === 'number' ? value : minZoom;
return sprintf(
/* translators: %d: zoom level as a percentage. */
__( '%d%%' ),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,20 @@ function buildRows(): ParityRow[] {
}
}
}
const subOneZoomState = makeState( {
rotation: 19,
zoom: 0.98,
cropRect: { x: 0.48, y: 0, width: 0.04, height: 1 },
} );
for ( const { label: probeLabel, u, v } of PROBES ) {
rows.push( {
label: `fine-rotation sub-1 zoom probe=${ probeLabel }`,
state: subOneZoomState,
probeLabel,
u,
v,
} );
}
return rows;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/media-editor/src/image-editor/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import { __ } from '@wordpress/i18n';
*/
import type { NormalizedRect, Flip, CropperState } from './types';

/**
* Default/resting zoom. A full, unrotated crop needs at least 1x to cover the
* crop area, but smaller/fine-rotated crops can be valid below 1x — the true
* floor for those comes from `getMinZoom`.
*/
export const MIN_ZOOM = 1;
Comment thread
ramonjd marked this conversation as resolved.
export const ABSOLUTE_MIN_ZOOM = 0.1;
export const MAX_ZOOM = 10;

/**
Expand Down
43 changes: 32 additions & 11 deletions packages/media-editor/src/image-editor/core/containment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { mat2d, vec2 } from 'gl-matrix';
* Internal dependencies
*/
import type { CropperState, NormalizedRect, Size } from './types';
import { ABSOLUTE_MIN_ZOOM, MIN_ZOOM } from './constants';
import { degreesToRadians } from './math/rotation';
import {
isValidSize,
Expand Down Expand Up @@ -44,18 +45,19 @@ function getVisualDimensions(
}

/**
* Calculates the minimum zoom factor needed for the rotated image to fully cover
* the crop rectangle, using normalized coordinates and imageAspectRatio.
*
* Works in pixel-proportional space where the unrotated image is a×1.
* The crop rect is in visual-normalized space and must be scaled by the
* The geometric minimum zoom required for the rotated image to fully cover the
* crop rectangle. Works in pixel-proportional space where the unrotated image
* is a×1; the crop rect lives in visual-normalized space and is scaled by the
* rotation-dependent visual dimensions before projecting into the image-local
* frame.
*
* Returns 0 for a degenerate (zero-area) crop — callers compose the result
* with their own floor (typically the current zoom or `MIN_ZOOM`).
*
* @param rotation Rotation angle in degrees.
* @param imageAspectRatio Image width / height ratio.
* @param cropRect The crop rectangle in normalized coordinates.
* @return The minimum zoom factor (always >= 1).
* @return The minimum zoom factor needed for coverage.
*/
function getMinZoomForCover(
rotation: number,
Expand Down Expand Up @@ -88,7 +90,27 @@ function getMinZoomForCover(
const zoomFromAlpha = ( 2 * spanAlpha ) / aspectRatio;
const zoomFromBeta = 2 * spanBeta;

return Math.max( 1, zoomFromAlpha, zoomFromBeta );
return Math.max( zoomFromAlpha, zoomFromBeta );

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the most important line, where the old hard 1x floor is removed from the geometric minimum.

That enables the new behavior: zoom can go below 1 when coverage math says the crop is still fully covered.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for flagging the key part of this! Helps with the reading 👀 😄

}

/**
* Resolves the effective minimum zoom for the current state — the coverage-
* aware floor when an image is loaded, falling back to `MIN_ZOOM` otherwise.
* Used by both the cropper's interaction layer and the zoom slider so the
* floor stays consistent across surfaces.
*
* @param state The current cropper state.
* @return The minimum zoom factor.
*/
export function getMinZoom( state: CropperState ): number {
if ( ! state.image ) {
return MIN_ZOOM;
}
const aspectRatio = state.image.naturalWidth / state.image.naturalHeight;
return Math.max(
ABSOLUTE_MIN_ZOOM,
getMinZoomForCover( state.rotation, aspectRatio, state.cropRect )
Comment thread
ramonjd marked this conversation as resolved.
Outdated
);
}

/**
Expand Down Expand Up @@ -300,10 +322,9 @@ export function restrictPanZoom(
const aspectRatio = isValidSize( imageSize )
? imageSize.width / imageSize.height
: 1;
const minZoom = getMinZoomForCover(
safeState.rotation,
aspectRatio,
safeCropRect
const minZoom = Math.max(
ABSOLUTE_MIN_ZOOM,
getMinZoomForCover( safeState.rotation, aspectRatio, safeCropRect )
);
const zoom = Math.max( safeState.zoom, minZoom );

Expand Down
21 changes: 18 additions & 3 deletions packages/media-editor/src/image-editor/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
* Internal dependencies
*/
import type { CropperAction, CropperState, TransformOperation } from './types';
import { DEFAULT_STATE, MAX_ZOOM } from './constants';
import {
ABSOLUTE_MIN_ZOOM,
DEFAULT_STATE,
MAX_ZOOM,
MIN_ZOOM,
} from './constants';
import { normalizeRotation, degreesToRadians } from './math/rotation';
import { restrictPanZoom, restrictCropRect } from './containment';

Expand All @@ -13,6 +18,16 @@ function nearlyEqual( a: number, b: number ): boolean {
return Math.abs( a - b ) < STATE_EPSILON;
}

function clampRequestedZoom( state: CropperState, zoom: number ): number {
// With an image, enforceContainment raises zoom to the coverage-aware
// floor below. Without an image, no enforcement runs, so MIN_ZOOM stays
// the conservative resting default.
if ( state.image ) {
return Math.min( MAX_ZOOM, Math.max( ABSOLUTE_MIN_ZOOM, zoom ) );
}
return Math.min( MAX_ZOOM, Math.max( MIN_ZOOM, zoom ) );
}

/**
* Translate a pipeline transform operation into the equivalent
* reducer action. Pipeline ops aren't 1:1 with reducer actions —
Expand Down Expand Up @@ -192,7 +207,7 @@ export function cropperReducer(
);

case 'SET_ZOOM': {
const z = Math.min( MAX_ZOOM, Math.max( 1, action.payload ) );
const z = clampRequestedZoom( state, action.payload );
return commitBase(
enforceContainment( {
...state,
Expand All @@ -202,7 +217,7 @@ export function cropperReducer(
}

case 'SET_ZOOM_AT_POINT': {
const z = Math.min( MAX_ZOOM, Math.max( 1, action.payload.zoom ) );
const z = clampRequestedZoom( state, action.payload.zoom );
return commitBase(
enforceContainment( {
...state,
Expand Down
66 changes: 66 additions & 0 deletions packages/media-editor/src/image-editor/core/test/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { CropperState, Size } from '../types';

const CONTAINER: Size = { width: 800, height: 600 };
const IMAGE: Size = { width: 1600, height: 900 };
const PORTRAIT_IMAGE: Size = { width: 900, height: 1600 };

function makeState( overrides: Partial< CropperState > = {} ): CropperState {
return {
Expand All @@ -32,6 +33,47 @@ function makeState( overrides: Partial< CropperState > = {} ): CropperState {
};
}

function expectImageCoversCrop( state: CropperState, imageSize: Size ): void {
const container: Size = { width: 1000, height: 1000 };
const camera = createCamera( state, container, imageSize );
const snapRotation = Math.round( state.rotation / 90 ) * 90;
const baseCamera = createCamera(
{
...state,
pan: { x: 0, y: 0 },
zoom: 1,
rotation: snapRotation,
},
container,
imageSize
);
const vb = getVisibleBounds( baseCamera );
const cr = state.cropRect;
const stencilCorners: [ number, number ][] = [
[ vb.left + cr.x * vb.width, vb.top + cr.y * vb.height ],
[ vb.left + ( cr.x + cr.width ) * vb.width, vb.top + cr.y * vb.height ],
[
vb.left + ( cr.x + cr.width ) * vb.width,
vb.top + ( cr.y + cr.height ) * vb.height,
],
[
vb.left + cr.x * vb.width,
vb.top + ( cr.y + cr.height ) * vb.height,
],
];

for ( const corner of stencilCorners ) {
const w = screenToWorld( camera, {
x: corner[ 0 ],
y: corner[ 1 ],
} );
expect( w.x ).toBeGreaterThanOrEqual( -0.001 );
expect( w.x ).toBeLessThanOrEqual( 1.001 );
expect( w.y ).toBeGreaterThanOrEqual( -0.001 );
expect( w.y ).toBeLessThanOrEqual( 1.001 );
}
}

describe( 'createCamera', () => {
it( 'returns a mat2d', () => {
const camera = createCamera( makeState(), CONTAINER, IMAGE );
Expand Down Expand Up @@ -122,6 +164,30 @@ describe( 'restrictPanZoom', () => {
const result = restrictPanZoom( state, IMAGE, state.cropRect );
expect( result.zoom ).toBeGreaterThanOrEqual( 1 );
} );
it( 'allows zoom below 1 when a fine-rotated portrait crop remains covered', () => {
const cropRect = { x: 0.46, y: 0, width: 0.08, height: 1 };
const state = makeState( {
image: {
src: 'portrait.jpg',
naturalWidth: PORTRAIT_IMAGE.width,
naturalHeight: PORTRAIT_IMAGE.height,
},
rotation: 19,
zoom: 0.9,
cropRect,
} );
const result = restrictPanZoom( state, PORTRAIT_IMAGE, cropRect );

expect( result.zoom ).toBeGreaterThan( 0.9 );
expect( result.zoom ).toBeLessThan( 1 );

const restrictedState = makeState( {
...state,
pan: result.pan,
zoom: result.zoom,
} );
expectImageCoversCrop( restrictedState, PORTRAIT_IMAGE );
} );
it( 'at 90° with zoom=1, allows zero pan on landscape image', () => {
// At zoom=1, 90° rotation, the image exactly covers the visual area.
// No pan should be possible in either direction.
Expand Down
50 changes: 49 additions & 1 deletion packages/media-editor/src/image-editor/core/test/state.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CropperState, Size } from '../types';
import { DEFAULT_STATE, MAX_ZOOM } from '../constants';
import { ABSOLUTE_MIN_ZOOM, DEFAULT_STATE, MAX_ZOOM } from '../constants';
import { cropperReducer, enforceContainment, isStateDirty } from '../state';
import {
createCamera,
Expand All @@ -9,6 +9,7 @@ import {
} from '../camera';

const IMAGE: Size = { width: 1600, height: 900 };
const PORTRAIT_IMAGE: Size = { width: 900, height: 1600 };
const CONTAINER: Size = { width: 800, height: 600 };

function makeState( overrides: Partial< CropperState > = {} ): CropperState {
Expand Down Expand Up @@ -105,6 +106,19 @@ describe( 'enforceContainment', () => {
expect( result.cropRect ).toEqual( state.cropRect );
} );

it( 'keeps zoom at the absolute minimum for a degenerate crop rect', () => {
// A degenerate crop has coverage minimum 0; the floor must come
// from ABSOLUTE_MIN_ZOOM. Use a sub-floor (but >EPSILON) zoom so
// sanitization doesn't bump it to 1 before restrictPanZoom runs.
const state = makeState( {
cropRect: { x: 0.5, y: 0.5, width: 0, height: 0 },
zoom: ABSOLUTE_MIN_ZOOM / 2,
} );
const result = enforceContainment( state );

expect( result.zoom ).toBe( ABSOLUTE_MIN_ZOOM );
} );

it( 'bumps zoom before shrinking crop for small rotation', () => {
const state = makeState( {
rotation: 15,
Expand Down Expand Up @@ -290,6 +304,40 @@ describe( 'cropperReducer — SETTLE_CROP', () => {
expectSameVisibleRegion( state, settled );
} );

it( 'allows a fine-rotated tall portrait crop to settle below 1x zoom', () => {
const state = makeState( {
image: {
src: 'portrait.jpg',
naturalWidth: PORTRAIT_IMAGE.width,
naturalHeight: PORTRAIT_IMAGE.height,
},
cropRect: { x: 0.46, y: -0.02, width: 0.08, height: 1.04 },
rotation: 19,
zoom: 1,
pan: { x: 0, y: 0 },
} );
const settled = cropperReducer( state, { type: 'SETTLE_CROP' } );

expect( settled.cropRect.height ).toBeCloseTo( 1, 5 );
expect( settled.zoom ).toBeLessThan( 1 );
expect( settled.zoom ).toBeGreaterThan( 0.95 );

const regionBefore = getCropWorldRegion(
state,
PORTRAIT_IMAGE,
CONTAINER
);
const regionAfter = getCropWorldRegion(
settled,
PORTRAIT_IMAGE,
CONTAINER
);
expect( regionAfter.minX ).toBeCloseTo( regionBefore.minX, 1 );
expect( regionAfter.minY ).toBeCloseTo( regionBefore.minY, 1 );
expect( regionAfter.maxX ).toBeCloseTo( regionBefore.maxX, 1 );
expect( regionAfter.maxY ).toBeCloseTo( regionBefore.maxY, 1 );
} );

it( 'is a no-op when crop is already full-sized and centered', () => {
const state = makeState( {
cropRect: { x: 0, y: 0, width: 1, height: 1 },
Expand Down
Loading