Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,36 @@ export default function MediaEditorCropPanel( {
...( aspectRatioPresets ??
DEFAULT_ASPECT_RATIOS.filter( ( preset ) => preset.value > 0 ) ),
];
const handleAspectRatioChange = ( value: string ) => {
onAspectRatioChange( value );
if ( value === '0' && ! freeformCrop ) {
onFreeformChange( true );

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.

Testing with voice over, I'm wondering if this change needs an announcement, or if it's unsurprising

}
};

return (
<Stack direction="column" gap="md">
<VisuallyHidden render={ <h2 /> }>
{ __( 'Crop options' ) }
</VisuallyHidden>
<SelectControl
__next40pxDefaultSize
__nextHasNoMarginBottom
Comment thread
ramonjd marked this conversation as resolved.
label={ __( 'Aspect ratio' ) }
value={ aspectRatioValue }
onChange={ handleAspectRatioChange }
options={ aspectRatioOptions.map( ( preset ) => ( {
label: preset.label,
value: preset.value.toString(),
} ) ) }
/>
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Resize crop area' ) }
help={ __( 'Show handles to adjust the crop box.' ) }
checked={ freeformCrop }
onChange={ onFreeformChange }
/>
<div role="presentation" { ...zoomGestureHandlers }>
<RangeControl
__next40pxDefaultSize
Expand All @@ -126,26 +150,6 @@ export default function MediaEditorCropPanel( {
} }
/>
</div>
<SelectControl
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( 'Aspect ratio' ) }
value={ aspectRatioValue }
onChange={ onAspectRatioChange }
options={ aspectRatioOptions.map( ( preset ) => ( {
label: preset.label,
value: preset.value.toString(),
} ) ) }
/>
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Freeform crop' ) }
help={ __(
'Drag the crop edges to resize freely. When off, the crop is fixed to the selected ratio.'
) }
checked={ freeformCrop }
onChange={ onFreeformChange }
/>
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* External dependencies
*/
import { fireEvent, render, screen } from '@testing-library/react';

/**
* Internal dependencies
*/
import MediaEditorCropPanel from '..';
import type { MediaEditorCropPanelProps } from '..';
import { CropperProvider } from '../../../image-editor';

function setupCropPanel(
overrides: Partial< MediaEditorCropPanelProps > = {}
) {
const props: MediaEditorCropPanelProps = {
aspectRatioValue: '1',
onAspectRatioChange: jest.fn(),
freeformCrop: false,
onFreeformChange: jest.fn(),
...overrides,
};

render(
<CropperProvider>
<MediaEditorCropPanel { ...props } />
</CropperProvider>
);

return props;
}

function expectElementBefore( first: HTMLElement, second: HTMLElement ) {
expect( first.compareDocumentPosition( second ) ).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
}

describe( 'MediaEditorCropPanel', () => {
it( 'renders crop shape controls before zoom controls', () => {
setupCropPanel();

const aspectRatio = screen.getByLabelText( 'Aspect ratio' );
const resizeCropArea = screen.getByLabelText( 'Resize crop area' );
const zoom = screen.getByRole( 'slider', { name: 'Zoom' } );

expect(
screen.getByText( 'Show handles to adjust the crop box.' )
).toBeInTheDocument();
expectElementBefore( aspectRatio, resizeCropArea );
expectElementBefore( resizeCropArea, zoom );
} );

it( 'turns freeform crop on when Free is selected while handles are off', () => {
const controls = setupCropPanel( {
aspectRatioValue: '1',
freeformCrop: false,
} );

fireEvent.change( screen.getByLabelText( 'Aspect ratio' ), {
target: { value: '0' },
} );

expect( controls.onAspectRatioChange ).toHaveBeenCalledWith( '0' );
expect( controls.onFreeformChange ).toHaveBeenCalledWith( true );
} );

it( 'does not change freeform crop when a fixed ratio is selected', () => {
const controls = setupCropPanel( {
aspectRatioValue: '0',
freeformCrop: false,
} );

fireEvent.change( screen.getByLabelText( 'Aspect ratio' ), {
target: { value: '1' },
} );

expect( controls.onAspectRatioChange ).toHaveBeenCalledWith( '1' );
expect( controls.onFreeformChange ).not.toHaveBeenCalled();
} );

it( 'does not call onFreeformChange when Free is selected and handles are already on', () => {
const controls = setupCropPanel( {
aspectRatioValue: '1',
freeformCrop: true,
} );

fireEvent.change( screen.getByLabelText( 'Aspect ratio' ), {
target: { value: '0' },
} );

expect( controls.onAspectRatioChange ).toHaveBeenCalledWith( '0' );
expect( controls.onFreeformChange ).not.toHaveBeenCalled();
} );

it( 'does not call onFreeformChange when a fixed ratio is selected and handles are on', () => {
const controls = setupCropPanel( {
aspectRatioValue: '0',
freeformCrop: true,
} );

fireEvent.change( screen.getByLabelText( 'Aspect ratio' ), {
target: { value: '1' },
} );

expect( controls.onAspectRatioChange ).toHaveBeenCalledWith( '1' );
expect( controls.onFreeformChange ).not.toHaveBeenCalled();
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,16 @@ function CropperInner(
[ canvasSize, naturalWidth, naturalHeight, state.rotation ]
);

// In fixed-crop mode, auto-size the crop rect to fill the visual area
// while respecting the aspect ratio. The crop is always centered.
// In fixed-crop mode, auto-size the crop rect only when a fixed aspect
// ratio is selected. With "Free" selected, turning freeform handles off
// should preserve the user's current unconstrained crop.
useEffect( () => {
if (
freeformCrop ||
visualSize.width === 0 ||
visualSize.height === 0
visualSize.height === 0 ||
! aspectRatio ||
aspectRatio <= 0
) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
/**
* External dependencies
*/
import { act, fireEvent, render, screen } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';

/**
* Internal dependencies
Expand Down Expand Up @@ -37,6 +43,11 @@ function createController(): UseCropperStateReturn {
applyOperation: jest.fn(),
reset: jest.fn(),
isDirty: false,
hasUndo: false,
hasRedo: false,
undo: jest.fn(),
redo: jest.fn(),
commitHistory: jest.fn(),
getCroppedImage: jest.fn(),
};
}
Expand Down Expand Up @@ -156,6 +167,69 @@ describe( 'Cropper', () => {
expect( canvas ).toHaveClass( SHOW_GRID_CLASS );
} );

it( 'preserves a free crop when freeform handles are toggled off', async () => {
const controller = createController();
controller.state = {
...controller.state,
cropRect: { x: 0.1, y: 0.2, width: 0.5, height: 0.4 },
};
const { rerender } = render(
<Cropper
src="test.jpg"
controller={ controller }
showDimming={ false }
freeformCrop
/>
);

await screen.findByRole( 'button', {
name: 'Resize top-left corner',
} );
( controller.setCropRect as jest.Mock ).mockClear();

rerender(
<Cropper
src="test.jpg"
controller={ controller }
showDimming={ false }
freeformCrop={ false }
/>
);

expect(
screen.queryByRole( 'button', {
name: 'Resize top-left corner',
} )
).not.toBeInTheDocument();
expect( controller.setCropRect ).not.toHaveBeenCalled();
} );

it( 'centers a fixed-ratio crop when freeform handles are off', async () => {
const controller = createController();
controller.state = {
...controller.state,
cropRect: { x: 0.1, y: 0.2, width: 0.5, height: 0.4 },
};
render(
<Cropper
src="test.jpg"
controller={ controller }
showDimming={ false }
freeformCrop={ false }
aspectRatio={ 1 }
/>
);

await waitFor( () =>
expect( controller.setCropRect ).toHaveBeenCalledWith( {
x: expect.closeTo( 1 / 6, 5 ),
y: 0,
width: expect.closeTo( 2 / 3, 5 ),
height: 1,
} )
);
} );

it( 'clears settling state when a new resize starts before the settle timer fires', async () => {
jest.useFakeTimers();

Expand Down
Loading