Skip to content

Commit 6e3107c

Browse files
committed
Media editor: replace the zoom slider with +/- buttons
1 parent 32fb7d1 commit 6e3107c

9 files changed

Lines changed: 263 additions & 132 deletions

File tree

Lines changed: 15 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,18 @@
11
/**
22
* WordPress dependencies
33
*/
4-
import { RangeControl, SelectControl } from '@wordpress/components';
4+
import { SelectControl } from '@wordpress/components';
55
import { Stack, VisuallyHidden } from '@wordpress/ui';
6-
import { __, sprintf } from '@wordpress/i18n';
6+
import { __ } from '@wordpress/i18n';
77

88
/**
99
* Internal dependencies
1010
*/
11-
import { useMediaEditor } from '../../state';
12-
import {
13-
useCropGestureHandlers,
14-
CROP_CONTROL_ATTR,
15-
} from '../../hooks/use-crop-gesture-handlers';
11+
import { CROP_CONTROL_ATTR } from '../../hooks/use-crop-gesture-handlers';
1612
import MediaEditorTransformControls from '../media-editor-transform-controls';
17-
import { MAX_ZOOM } from '../../image-editor/core/constants';
18-
import { getMinZoom } from '../../image-editor/core/containment';
13+
import MediaEditorZoomControls from '../media-editor-zoom-controls';
1914
import type { AspectRatioPreset } from '../../image-editor/core/constants';
2015

21-
const ZOOM_PERCENTAGE_SCALE = 100;
22-
const MAX_ZOOM_PERCENTAGE = MAX_ZOOM * ZOOM_PERCENTAGE_SCALE;
23-
24-
function getZoomPercentageForDisplay( zoom: number ): number {
25-
return Math.round( zoom * ZOOM_PERCENTAGE_SCALE );
26-
}
27-
28-
function getMinZoomPercentageForDisplay( zoom: number ): number {
29-
return Math.ceil( zoom * ZOOM_PERCENTAGE_SCALE );
30-
}
31-
3216
export interface MediaEditorCropPanelProps {
3317
/**
3418
* Selected aspect-ratio preset value as a string (so it round-trips
@@ -38,8 +22,6 @@ export interface MediaEditorCropPanelProps {
3822
aspectRatioValue: string;
3923
/** Setter for the aspect-ratio preset value. */
4024
onAspectRatioChange: ( value: string ) => void;
41-
/** Signal that a placement-oriented control is being adjusted. */
42-
onPlacementControlInteraction?: () => void;
4325
/** Aspect-ratio presets to display in the selector. */
4426
aspectRatioOptions: AspectRatioPreset[];
4527
/**
@@ -48,31 +30,31 @@ export interface MediaEditorCropPanelProps {
4830
* them.
4931
*/
5032
showTransformControls?: boolean;
33+
/**
34+
* When `true`, render the zoom +/- controls. Used on wide viewports;
35+
* on narrow viewports they live in the footer instead.
36+
*/
37+
showZoomControls?: boolean;
5138
}
5239

5340
/**
54-
* Sidebar panel for crop-shape controls. The tactile verbs (rotate, flip)
55-
* live in the bottom toolbar instead.
41+
* Sidebar panel for crop controls. Renders the aspect-ratio selector, plus
42+
* the rotate/flip and zoom controls on wide viewports (these move to the
43+
* footer toolbar when the sidebar collapses).
5644
* @param props
5745
* @param props.aspectRatioValue
5846
* @param props.onAspectRatioChange
59-
* @param props.onPlacementControlInteraction
6047
* @param props.aspectRatioOptions
6148
* @param props.showTransformControls
49+
* @param props.showZoomControls
6250
*/
6351
export default function MediaEditorCropPanel( {
6452
aspectRatioValue,
6553
onAspectRatioChange,
66-
onPlacementControlInteraction,
6754
aspectRatioOptions,
6855
showTransformControls = false,
56+
showZoomControls = false,
6957
}: MediaEditorCropPanelProps ) {
70-
const { state, setZoom } = useMediaEditor();
71-
const zoomGestureHandlers = useCropGestureHandlers();
72-
const minZoom = getMinZoom( state );
73-
const zoomPercentage = getZoomPercentageForDisplay( state.zoom );
74-
const minZoomPercentage = getMinZoomPercentageForDisplay( minZoom );
75-
7658
return (
7759
// Tag the whole panel as a crop-control region so the modal's
7860
// Cmd+Z handler doesn't mistake the SelectControl input for a
@@ -88,6 +70,7 @@ export default function MediaEditorCropPanel( {
8870
{ showTransformControls && (
8971
<MediaEditorTransformControls withLabels />
9072
) }
73+
{ showZoomControls && <MediaEditorZoomControls withLabels /> }
9174
<SelectControl
9275
__next40pxDefaultSize
9376
label={ __( 'Aspect ratio' ) }
@@ -98,36 +81,6 @@ export default function MediaEditorCropPanel( {
9881
value: preset.value.toString(),
9982
} ) ) }
10083
/>
101-
<div role="presentation" { ...zoomGestureHandlers }>
102-
<RangeControl
103-
__next40pxDefaultSize
104-
label={ __( 'Zoom (%)' ) }
105-
min={ minZoomPercentage }
106-
max={ MAX_ZOOM_PERCENTAGE }
107-
step={ 1 }
108-
shiftStep={ 10 }
109-
value={ zoomPercentage }
110-
onChange={ ( value ) => {
111-
onPlacementControlInteraction?.();
112-
setZoom(
113-
typeof value === 'number'
114-
? value / ZOOM_PERCENTAGE_SCALE
115-
: minZoom
116-
);
117-
} }
118-
renderTooltipContent={ ( value ) => {
119-
const percentage =
120-
typeof value === 'number'
121-
? value
122-
: minZoomPercentage;
123-
return sprintf(
124-
/* translators: %d: zoom level as a percentage. */
125-
__( '%d%%' ),
126-
Math.round( percentage )
127-
);
128-
} }
129-
/>
130-
</div>
13184
</Stack>
13285
);
13386
}

packages/media-editor/src/components/media-editor-crop-panel/test/index.tsx

Lines changed: 10 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@ import { fireEvent, render, screen } from '@testing-library/react';
88
*/
99
import MediaEditorCropPanel from '..';
1010
import type { MediaEditorCropPanelProps } from '..';
11-
import { MediaEditorStateProvider, useMediaEditor } from '../../../state';
12-
import type { CropperState } from '../../../image-editor';
11+
import { MediaEditorStateProvider } from '../../../state';
1312

1413
function setupCropPanel(
15-
overrides: Partial< MediaEditorCropPanelProps > = {},
16-
initialCropperState?: Partial< CropperState >
14+
overrides: Partial< MediaEditorCropPanelProps > = {}
1715
) {
1816
const props: MediaEditorCropPanelProps = {
1917
aspectRatioValue: '1',
@@ -27,33 +25,15 @@ function setupCropPanel(
2725
};
2826

2927
render(
30-
<MediaEditorStateProvider initialCropperState={ initialCropperState }>
28+
<MediaEditorStateProvider>
3129
<MediaEditorCropPanel { ...props } />
32-
<CurrentZoomValue />
3330
</MediaEditorStateProvider>
3431
);
3532

3633
return props;
3734
}
3835

39-
function CurrentZoomValue() {
40-
const { state } = useMediaEditor();
41-
42-
return <output data-testid="current-zoom">{ state.zoom }</output>;
43-
}
44-
4536
describe( 'MediaEditorCropPanel', () => {
46-
it( 'renders crop shape controls before zoom controls', () => {
47-
setupCropPanel();
48-
49-
const aspectRatio = screen.getByLabelText( 'Aspect ratio' );
50-
const zoom = screen.getByRole( 'slider', { name: 'Zoom (%)' } );
51-
52-
expect( aspectRatio.compareDocumentPosition( zoom ) ).toBe(
53-
Node.DOCUMENT_POSITION_FOLLOWING
54-
);
55-
} );
56-
5737
it( 'passes selected aspect ratio changes to the caller', () => {
5838
const controls = setupCropPanel( {
5939
aspectRatioValue: '1',
@@ -69,37 +49,6 @@ describe( 'MediaEditorCropPanel', () => {
6949
).toBe( '0' );
7050
} );
7151

72-
it( 'displays zoom as a percentage without changing cropper state', () => {
73-
setupCropPanel( {}, { zoom: 3.749999999999999 } );
74-
75-
const zoomInput = screen.getByRole( 'spinbutton', {
76-
name: 'Zoom (%)',
77-
} );
78-
79-
expect( zoomInput ).toHaveValue( 375 );
80-
expect( screen.getByTestId( 'current-zoom' ) ).toHaveTextContent(
81-
'3.749999999999999'
82-
);
83-
} );
84-
85-
it( 'converts percentage input back to the cropper zoom multiplier', () => {
86-
const controls = setupCropPanel( {
87-
onPlacementControlInteraction: jest.fn(),
88-
} );
89-
90-
fireEvent.change(
91-
screen.getByRole( 'spinbutton', { name: 'Zoom (%)' } ),
92-
{
93-
target: { value: '250' },
94-
}
95-
);
96-
97-
expect( screen.getByTestId( 'current-zoom' ) ).toHaveTextContent(
98-
'2.5'
99-
);
100-
expect( controls.onPlacementControlInteraction ).toHaveBeenCalled();
101-
} );
102-
10352
it( 'omits transform controls by default', () => {
10453
setupCropPanel();
10554

@@ -119,4 +68,11 @@ describe( 'MediaEditorCropPanel', () => {
11968
screen.getByRole( 'button', { name: 'Flip horizontal' } )
12069
).toBeInTheDocument();
12170
} );
71+
72+
it( 'renders zoom controls when showZoomControls is set', () => {
73+
setupCropPanel( { showZoomControls: true } );
74+
expect(
75+
screen.getByRole( 'button', { name: 'Zoom in' } )
76+
).toBeInTheDocument();
77+
} );
12278
} );

packages/media-editor/src/components/media-editor-modal/style.scss

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,11 @@
5454
flex-direction: column;
5555
}
5656

57-
// Narrow only: transform cluster sits on its own row, centered.
58-
&.is-narrow > .media-editor-transform-controls {
57+
// Narrow only: the transform and zoom controls share one centered row.
58+
&.is-narrow > .media-editor-modal__footer-tools {
59+
display: flex;
60+
align-items: center;
61+
gap: $grid-unit-20;
5962
align-self: center;
6063
}
6164
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { Button } from '@wordpress/components';
5+
import { __ } from '@wordpress/i18n';
6+
import { lineSolid, plus } from '@wordpress/icons';
7+
8+
/**
9+
* Internal dependencies
10+
*/
11+
import { useMediaEditor } from '../../state';
12+
import { MAX_ZOOM } from '../../image-editor/core/constants';
13+
import { getMinZoom } from '../../image-editor/core/containment';
14+
15+
/**
16+
* Default zoom increment per +/- click (a zoom-multiplier delta), matching
17+
* the slider's old `shiftStep` of 10 percentage points. Overridable via the
18+
* `zoomStep` prop.
19+
*/
20+
export const DEFAULT_ZOOM_STEP = 0.1;
21+
22+
export interface MediaEditorZoomControlsProps {
23+
/**
24+
* When `true`, render under a "Zoom" heading matching the other Crop
25+
* panel control labels. When `false` (default), render the icon pair
26+
* only — the footer layout.
27+
*/
28+
withLabels?: boolean;
29+
/**
30+
* Amount to change the zoom by per +/- click (a zoom-multiplier delta).
31+
* Defaults to `DEFAULT_ZOOM_STEP` (0.1×).
32+
*/
33+
zoomStep?: number;
34+
}
35+
36+
/**
37+
* Zoom out (−) / Zoom in (+) buttons. Steps the cropper zoom in fixed
38+
* increments, clamped to the coverage-aware minimum and `MAX_ZOOM`. No
39+
* numeric readout; visual feedback comes from the canvas. Rendered in the
40+
* Crop panel on wide viewports and in the footer toolbar when the sidebar
41+
* collapses.
42+
*
43+
* @param props
44+
* @param props.withLabels
45+
* @param props.zoomStep
46+
*/
47+
export default function MediaEditorZoomControls( {
48+
withLabels = false,
49+
zoomStep = DEFAULT_ZOOM_STEP,
50+
}: MediaEditorZoomControlsProps ) {
51+
const { state, setZoom } = useMediaEditor();
52+
const minZoom = getMinZoom( state );
53+
const step = ( delta: number ) => {
54+
setZoom(
55+
Math.min( MAX_ZOOM, Math.max( minZoom, state.zoom + delta ) )
56+
);
57+
};
58+
59+
const buttons = (
60+
<div className="media-editor-zoom-controls__buttons">
61+
<Button
62+
size="compact"
63+
icon={ plus }
64+
label={ __( 'Zoom in' ) }
65+
showTooltip
66+
disabled={ state.zoom >= MAX_ZOOM }
67+
accessibleWhenDisabled
68+
onClick={ () => step( zoomStep ) }
69+
/>
70+
<Button
71+
size="compact"
72+
icon={ lineSolid }
73+
label={ __( 'Zoom out' ) }
74+
showTooltip
75+
disabled={ state.zoom <= minZoom }
76+
accessibleWhenDisabled
77+
onClick={ () => step( -zoomStep ) }
78+
/>
79+
</div>
80+
);
81+
82+
if ( withLabels ) {
83+
return (
84+
<div
85+
className="media-editor-zoom-controls is-stacked"
86+
role="group"
87+
aria-label={ __( 'Zoom' ) }
88+
>
89+
<span
90+
className="media-editor-zoom-controls__label"
91+
aria-hidden="true"
92+
>
93+
{ __( 'Zoom' ) }
94+
</span>
95+
{ buttons }
96+
</div>
97+
);
98+
}
99+
100+
return <div className="media-editor-zoom-controls">{ buttons }</div>;
101+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
@use "@wordpress/base-styles/variables" as *;
2+
3+
.media-editor-zoom-controls {
4+
display: flex;
5+
align-items: center;
6+
7+
// Stacked layout used inside the Crop panel: a "Zoom" heading above the
8+
// button pair, matching the Rotate/Flip groups.
9+
&.is-stacked {
10+
flex-direction: column;
11+
align-items: flex-start;
12+
gap: $grid-unit-05;
13+
}
14+
15+
// Match the uppercase 11px heading the panel's other controls use.
16+
// Mirrors `baseLabelTypography` from `@wordpress/components`.
17+
&__label {
18+
font-size: 11px;
19+
font-weight: 500;
20+
line-height: 1.4;
21+
text-transform: uppercase;
22+
}
23+
24+
&__buttons {
25+
display: flex;
26+
gap: $grid-unit-10;
27+
// Small leading inset so the leftmost button's focus ring isn't
28+
// clipped by the sidebar's `overflow: hidden` (matches the transform
29+
// controls).
30+
padding-inline: 2px 0;
31+
}
32+
}

0 commit comments

Comments
 (0)