From 8d6a64f692c51db7d3240053cf2ae3ab1c64fc2a Mon Sep 17 00:00:00 2001 From: Alex Lende Date: Fri, 23 Jun 2023 05:37:28 -0500 Subject: [PATCH] Add image block aspect ratio control (#51545) * Simplify ImageSizeControl by using Auto as a placeholder * Rename imageWidth and imageHeight props to naturalWidth and naturalHeight * Convert NumberControl onChange values to Numbers * Simplify LatestPostsEdit to use updated ImageSizeControl * Add JSDoc types for debugging * Remove unnecessary noop * Fix possible undefined values in NumberControl onChange * Fix onChangeImage param type which may be undefined * Rename OnChange callback prop * Inline JSDoc props instead of new object * Simplify handing undefined and NaN in onChange * Revert prop name change since this isn't a private API * Add a privateApis export for experimental ImageSizeControl * Use the privateApis version of ImageSizeControl * Add deprecation notice to the original component * Revert image-size-control and create image-dimensions-control instead * Re-add deprecation notice to image-size-control * Try making a whole new component * Revert changes to image, latest-posts, and media-text blocks * Organize and update the dimensions tool panel item * Reword size help text * Reorganize into reusable components * Add stories for other individual tools * Update stories path * Remove SelectControl __next prop * Pass through isShownByDefault to ResolutionTool * Remove unused scss * Deprecate experimental ImageSizeControl * Simplify ScaleTool onChange * Add better defaults for value and onChange * Fix circular dependency * Update comment about auto and custom aspect ratios * Add JSDoc types for ScaleTool * Add JSDoc types for WidthHeightTool * Add default value and onChange for WidthHeightTool * Remove unused import * Add aspectRatio to image block attributes * Add scale to image block attributes * Update JSDoc comment * Add dimensions tool to image block * Rename naturalAspectRatio for clarity * Fix aspect-ratio-tool lint * Fix scale-tool lint * Fix width-height-tool lint * Fix dimensions-tool lint * Fix resolution-tool lint * Add @emption/styled to block-editor * Fix image block lint * Update components changelog * Fix AspectRatioTool reference * Support 'auto' in width-height-tool * Make null/undefined values mean 'auto' instead of defaultValue in aspectRatioTool * Add deprecation for image block * Fix ResizableBox interactions * Add comments for default values * Fix ResizableBox with auto w/h * Clear aspect-ratio on resize * Add TODO comment for ResolutionTool defaultValue * Move the scale hide/show into dimensions controls * Add first test * Fix scale being set after it was deleted * WIP writing tests * Update test * UI tweaks * Move alt text as ToolsPanelItem * Tweak default scale option help text * Only use contain and cover for image scale options * Update test * Test the remaining callback values * Add comment about toStrictEqual * Add test for setting custom aspect ratio and then resetting * Move custom scaleOptions to the image block * Remember last aspect ratio so it can be restored when with/height are unset then set * Remove unused import * Format code * Remove image w/h reset when a new image is added * Use UnitControl's default units instead of spacing.units * Provide the complete set of object-fit options by default * Update TODO that will be committed * Clean up evalAspectRatio and add docs * Someone can file a bug report if offsetWidth/offsetHeight causes issues * I couldn't figure out why height depended on having a custom border, but things seem to work without that * Update docs for image block * Update comment about default value * Fix redundant wording * I think the img width and height attributes can be removed if they're specified in the style attribute * Update package-lock.json with @emotion/styled dependency * Update mock calls for test example * Simplify test values * Consolidate mock calls expect * Require defaultScale and defaultAspectRatio for DimensionsTool * Add DimensionsTool tests for all custom transitions * Remove comment about matching aspect ratio options * Remove redundant check in tests * Add comments to defaultAspectRatio and defaultScale * Organize tests by which field is being updated * Fix type conversion * Add state diagram for last two tests * Refactor and fix some tests * Fix and simplify WidthHeightTool onChange * Remove default scale option in image block.json * Simplify DimensionsTool onChange logic * Update block deprecations with width and height * Revert image block width and height attributes to numbers since we only support px units for now * Revert "Update block deprecations with width and height" This reverts commit 941a81149ed4bc344ac2c0e183624069e33d75ad. * Prevent NaN width/height * Fix DimensionTool width/height units * Fix JSDoc Dimenstions width/height types * No default needed for ResolutionTool * Fix drag handle aspect ratio reset * Simplify null checks * Stop using pxWidth and pxHeight * Remove e2e tests that reference the scale button that was removed * Fix image scaling for small images * Try fixing aspectRatio only images * Update test to respect the new aspect ratio behavior --------- Co-authored-by: Alex Lende Co-authored-by: Rich Tabor Co-authored-by: Jerry Jones --- docs/reference-guides/core-blocks.md | 2 +- package-lock.json | 1 + packages/block-editor/package.json | 1 + .../dimensions-tool/aspect-ratio-tool.js | 124 ++++ .../src/components/dimensions-tool/index.js | 212 ++++++ .../components/dimensions-tool/scale-tool.js | 124 ++++ .../stories/aspect-ratio-tool.js | 52 ++ .../dimensions-tool/stories/index.js | 54 ++ .../dimensions-tool/stories/scale-tool.js | 48 ++ .../stories/width-height-tool.js | 54 ++ .../components/dimensions-tool/test/index.js | 641 ++++++++++++++++++ .../dimensions-tool/width-height-tool.js | 113 +++ .../image-editor/aspect-ratio-dropdown.js | 2 +- .../components/image-editor/use-save-image.js | 1 - .../components/image-size-control/index.js | 6 + .../src/components/resolution-tool/index.js | 56 ++ .../resolution-tool/stories/index.js | 48 ++ packages/block-editor/src/private-apis.js | 4 + packages/block-library/src/image/block.json | 6 + packages/block-library/src/image/edit.js | 4 - packages/block-library/src/image/image.js | 192 ++++-- packages/block-library/src/image/save.js | 8 +- packages/block-library/src/image/utils.js | 16 + packages/components/CHANGELOG.md | 1 + .../components/src/select-control/index.tsx | 1 + .../components/src/select-control/types.ts | 6 + test/e2e/specs/editor/blocks/buttons.spec.js | 22 - test/e2e/specs/editor/blocks/image.spec.js | 145 ---- .../specs/editor/plugins/image-size.spec.js | 2 +- 29 files changed, 1708 insertions(+), 238 deletions(-) create mode 100644 packages/block-editor/src/components/dimensions-tool/aspect-ratio-tool.js create mode 100644 packages/block-editor/src/components/dimensions-tool/index.js create mode 100644 packages/block-editor/src/components/dimensions-tool/scale-tool.js create mode 100644 packages/block-editor/src/components/dimensions-tool/stories/aspect-ratio-tool.js create mode 100644 packages/block-editor/src/components/dimensions-tool/stories/index.js create mode 100644 packages/block-editor/src/components/dimensions-tool/stories/scale-tool.js create mode 100644 packages/block-editor/src/components/dimensions-tool/stories/width-height-tool.js create mode 100644 packages/block-editor/src/components/dimensions-tool/test/index.js create mode 100644 packages/block-editor/src/components/dimensions-tool/width-height-tool.js create mode 100644 packages/block-editor/src/components/resolution-tool/index.js create mode 100644 packages/block-editor/src/components/resolution-tool/stories/index.js diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index f59b472bdabdd1..73e4186fce9b67 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -340,7 +340,7 @@ Insert an image to make a visual statement. ([Source](https://github.com/WordPre - **Name:** core/image - **Category:** media - **Supports:** anchor, behaviors (lightbox), color (~~background~~, ~~text~~), filter (duotone) -- **Attributes:** align, alt, caption, height, href, id, linkClass, linkDestination, linkTarget, rel, sizeSlug, title, url, width +- **Attributes:** align, alt, aspectRatio, caption, height, href, id, linkClass, linkDestination, linkTarget, rel, scale, sizeSlug, title, url, width ## Latest Comments diff --git a/package-lock.json b/package-lock.json index b5ca06675e4a08..79effbe979b53e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17252,6 +17252,7 @@ "version": "file:packages/block-editor", "requires": { "@babel/runtime": "^7.16.0", + "@emotion/styled": "^11.6.0", "@react-spring/web": "^9.4.5", "@wordpress/a11y": "file:packages/a11y", "@wordpress/api-fetch": "file:packages/api-fetch", diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 07d790b2e0e234..64cd3f080543d1 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -32,6 +32,7 @@ ], "dependencies": { "@babel/runtime": "^7.16.0", + "@emotion/styled": "^11.6.0", "@react-spring/web": "^9.4.5", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", diff --git a/packages/block-editor/src/components/dimensions-tool/aspect-ratio-tool.js b/packages/block-editor/src/components/dimensions-tool/aspect-ratio-tool.js new file mode 100644 index 00000000000000..988c6b5c286869 --- /dev/null +++ b/packages/block-editor/src/components/dimensions-tool/aspect-ratio-tool.js @@ -0,0 +1,124 @@ +/** + * WordPress dependencies + */ +import { + SelectControl, + __experimentalToolsPanelItem as ToolsPanelItem, +} from '@wordpress/components'; +import { __, _x } from '@wordpress/i18n'; + +/** + * @typedef {import('@wordpress/components/build-types/select-control/types').SelectControlProps} SelectControlProps + */ + +/** + * @type {SelectControlProps[]} + */ +export const DEFAULT_ASPECT_RATIO_OPTIONS = [ + { + label: _x( 'Original', 'Aspect ratio option for dimensions control' ), + value: 'auto', + }, + { + label: _x( + 'Square - 1:1', + 'Aspect ratio option for dimensions control' + ), + value: '1', + }, + { + label: _x( + 'Standard - 4:3', + 'Aspect ratio option for dimensions control' + ), + value: '4/3', + }, + { + label: _x( + 'Portrait - 3:4', + 'Aspect ratio option for dimensions control' + ), + value: '3/4', + }, + { + label: _x( + 'Classic - 3:2', + 'Aspect ratio option for dimensions control' + ), + value: '3/2', + }, + { + label: _x( + 'Classic Portrait - 2:3', + 'Aspect ratio option for dimensions control' + ), + value: '2/3', + }, + { + label: _x( + 'Wide - 16:9', + 'Aspect ratio option for dimensions control' + ), + value: '16/9', + }, + { + label: _x( + 'Tall - 9:16', + 'Aspect ratio option for dimensions control' + ), + value: '9/16', + }, + { + label: _x( 'Custom', 'Aspect ratio option for dimensions control' ), + value: 'custom', + disabled: true, + hidden: true, + }, +]; + +/** + * @callback AspectRatioToolPropsOnChange + * @param {string} [value] New aspect ratio value. + * @return {void} No return. + */ + +/** + * @typedef {Object} AspectRatioToolProps + * @property {string} [panelId] ID of the panel this tool is associated with. + * @property {string} [value] Current aspect ratio value. + * @property {AspectRatioToolPropsOnChange} [onChange] Callback to update the aspect ratio value. + * @property {SelectControlProps[]} [options] Aspect ratio options. + * @property {string} [defaultValue] Default aspect ratio value. + * @property {boolean} [isShownByDefault] Whether the tool is shown by default. + */ + +export default function AspectRatioTool( { + panelId, + value, + onChange = () => {}, + options = DEFAULT_ASPECT_RATIO_OPTIONS, + defaultValue = DEFAULT_ASPECT_RATIO_OPTIONS[ 0 ].value, + isShownByDefault = true, +} ) { + // Match the CSS default so if the value is used directly in CSS it will look correct in the control. + const displayValue = value ?? 'auto'; + + return ( + displayValue !== defaultValue } + label={ __( 'Aspect ratio' ) } + onDeselect={ () => onChange( undefined ) } + isShownByDefault={ isShownByDefault } + panelId={ panelId } + > + + + ); +} diff --git a/packages/block-editor/src/components/dimensions-tool/index.js b/packages/block-editor/src/components/dimensions-tool/index.js new file mode 100644 index 00000000000000..a81a399b7b79d8 --- /dev/null +++ b/packages/block-editor/src/components/dimensions-tool/index.js @@ -0,0 +1,212 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import AspectRatioTool from './aspect-ratio-tool'; +import ScaleTool from './scale-tool'; +import WidthHeightTool from './width-height-tool'; + +/** + * @typedef {import('@wordpress/components/build-types/select-control/types').SelectControlProps} SelectControlProps + */ + +/** + * @typedef {import('@wordpress/components/build-types/unit-control/types').WPUnitControlUnit} WPUnitControlUnit + */ + +/** + * @typedef {Object} Dimensions + * @property {string} [width] CSS width property. + * @property {string} [height] CSS height property. + * @property {string} [scale] CSS object-fit property. + * @property {string} [aspectRatio] CSS aspect-ratio property. + */ + +/** + * @callback DimensionsControlsOnChange + * @param {Dimensions} nextValue + * @return {void} + */ + +/** + * @typedef {Object} DimensionsControlsProps + * @property {string} [panelId] ID of the panel that contains the controls. + * @property {Dimensions} [value] Current dimensions values. + * @property {DimensionsControlsOnChange} [onChange] Callback to update the dimensions values. + * @property {SelectControlProps[]} [aspectRatioOptions] Aspect ratio options. + * @property {SelectControlProps[]} [scaleOptions] Scale options. + * @property {WPUnitControlUnit[]} [unitsOptions] Units options. + */ + +/** + * Component that renders controls to edit the dimensions of an image or container. + * + * @param {DimensionsControlsProps} props The component props. + * + * @return {WPElement} The dimensions controls. + */ +function DimensionsTool( { + panelId, + value = {}, + onChange = () => {}, + aspectRatioOptions, // Default options handled by AspectRatioTool. + defaultAspectRatio = 'auto', // Match CSS default value for aspect-ratio. + scaleOptions, // Default options handled by ScaleTool. + defaultScale = 'fill', // Match CSS default value for object-fit. + unitsOptions, // Default options handled by UnitControl. +} ) { + // Coerce undefined and CSS default values to be null. + const width = + value.width === undefined || value.width === 'auto' + ? null + : value.width; + const height = + value.height === undefined || value.height === 'auto' + ? null + : value.height; + const aspectRatio = + value.aspectRatio === undefined || value.aspectRatio === 'auto' + ? null + : value.aspectRatio; + const scale = + value.scale === undefined || value.scale === 'fill' + ? null + : value.scale; + + // Keep track of state internally, so when the value is cleared by means + // other than directly editing that field, it's easier to restore the + // previous value. + const [ lastScale, setLastScale ] = useState( scale ); + const [ lastAspectRatio, setLastAspectRatio ] = useState( aspectRatio ); + + // 'custom' is not a valid value for CSS aspect-ratio, but it is used in the + // dropdown to indicate that setting both the width and height is the same + // as a custom aspect ratio. + const aspectRatioValue = width && height ? 'custom' : lastAspectRatio; + + const showScaleControl = aspectRatio || ( width && height ); + + return ( + <> + { + const nextValue = { ...value }; + + // 'auto' is CSS default, so it gets treated as null. + nextAspectRatio = + nextAspectRatio === 'auto' ? null : nextAspectRatio; + + setLastAspectRatio( nextAspectRatio ); + + // Update aspectRatio. + if ( ! nextAspectRatio ) { + delete nextValue.aspectRatio; + } else { + nextValue.aspectRatio = nextAspectRatio; + } + + // Auto-update scale. + if ( ! nextAspectRatio ) { + delete nextValue.scale; + } else if ( lastScale ) { + nextValue.scale = lastScale; + } else { + nextValue.scale = defaultScale; + setLastScale( defaultScale ); + } + + // Auto-update width and height. + if ( nextAspectRatio && width && height ) { + delete nextValue.height; + } + + onChange( nextValue ); + } } + /> + { showScaleControl && ( + { + const nextValue = { ...value }; + + // 'fill' is CSS default, so it gets treated as null. + nextScale = nextScale === 'fill' ? null : nextScale; + + setLastScale( nextScale ); + + // Update scale. + if ( ! nextScale ) { + delete nextValue.scale; + } else { + nextValue.scale = nextScale; + } + + onChange( nextValue ); + } } + /> + ) } + { + const nextValue = { ...value }; + + // 'auto' is CSS default, so it gets treated as null. + nextWidth = nextWidth === 'auto' ? null : nextWidth; + nextHeight = nextHeight === 'auto' ? null : nextHeight; + + // Update width. + if ( ! nextWidth ) { + delete nextValue.width; + } else { + nextValue.width = nextWidth; + } + + // Update height. + if ( ! nextHeight ) { + delete nextValue.height; + } else { + nextValue.height = nextHeight; + } + + // Auto-update aspectRatio. + if ( nextWidth && nextHeight ) { + delete nextValue.aspectRatio; + } else if ( lastAspectRatio ) { + nextValue.aspectRatio = lastAspectRatio; + } else { + // No setting defaultAspectRatio here, because + // aspectRatio is optional in this scenario, + // unlike scale. + } + + // Auto-update scale. + if ( ! lastAspectRatio && !! nextWidth !== !! nextHeight ) { + delete nextValue.scale; + } else if ( lastScale ) { + nextValue.scale = lastScale; + } else { + nextValue.scale = defaultScale; + setLastScale( defaultScale ); + } + + onChange( nextValue ); + } } + /> + + ); +} + +export default DimensionsTool; diff --git a/packages/block-editor/src/components/dimensions-tool/scale-tool.js b/packages/block-editor/src/components/dimensions-tool/scale-tool.js new file mode 100644 index 00000000000000..e39ee1d837f5c7 --- /dev/null +++ b/packages/block-editor/src/components/dimensions-tool/scale-tool.js @@ -0,0 +1,124 @@ +/** + * WordPress dependencies + */ +import { + __experimentalToolsPanelItem as ToolsPanelItem, + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOption as ToggleGroupControlOption, +} from '@wordpress/components'; +import { useMemo } from '@wordpress/element'; +import { __, _x } from '@wordpress/i18n'; + +/** + * @typedef {import('@wordpress/components/build-types/select-control/types').SelectControlProps} SelectControlProps + */ + +/** + * The descriptions are purposely made generic as object-fit could be used for + * any replaced element. Provide your own set of options if you need different + * help text or labels. + * + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/Replaced_element + * + * @type {SelectControlProps[]} + */ +const DEFAULT_SCALE_OPTIONS = [ + { + value: 'fill', + label: _x( 'Fill', 'Scale option for dimensions control' ), + help: __( 'Fill the space by stretching the content.' ), + }, + { + value: 'contain', + label: _x( 'Contain', 'Scale option for dimensions control' ), + help: __( 'Fit the content to the space without clipping.' ), + }, + { + value: 'cover', + label: _x( 'Cover', 'Scale option for dimensions control' ), + help: __( "Fill the space by clipping what doesn't fit." ), + }, + { + value: 'none', + label: _x( 'None', 'Scale option for dimensions control' ), + help: __( + 'Do not adjust the sizing of the content. Content that is too large will be clipped, and content that is too small will have additional padding.' + ), + }, + { + value: 'scale-down', + label: _x( 'Scale down', 'Scale option for dimensions control' ), + help: __( + 'Scale down the content to fit the space if it is too big. Content that is too small will have additional padding.' + ), + }, +]; + +/** + * @callback ScaleToolPropsOnChange + * @param {string} nextValue New scale value. + * @return {void} + */ + +/** + * @typedef {Object} ScaleToolProps + * @property {string} [panelId] ID of the panel that contains the controls. + * @property {string} [value] Current scale value. + * @property {ScaleToolPropsOnChange} [onChange] Callback to update the scale value. + * @property {SelectControlProps[]} [options] Scale options. + * @property {string} [defaultValue] Default scale value. + * @property {boolean} [showControl=true] Whether to show the control. + * @property {boolean} [isShownByDefault=true] Whether the tool panel is shown by default. + */ + +/** + * A tool to select the CSS object-fit property for the image. + * + * @param {ScaleToolProps} props + * + * @return {import('@wordpress/element').WPElement} The scale tool. + */ +export default function ScaleTool( { + panelId, + value, + onChange, + options = DEFAULT_SCALE_OPTIONS, + defaultValue = DEFAULT_SCALE_OPTIONS[ 0 ].value, + isShownByDefault = true, +} ) { + // Match the CSS default so if the value is used directly in CSS it will look correct in the control. + const displayValue = value ?? 'fill'; + + const scaleHelp = useMemo( () => { + return options.reduce( ( acc, option ) => { + acc[ option.value ] = option.help; + return acc; + }, {} ); + }, [ options ] ); + + return ( + displayValue !== defaultValue } + onDeselect={ () => onChange( defaultValue ) } + panelId={ panelId } + > + + { options.map( ( option ) => ( + + ) ) } + + + ); +} diff --git a/packages/block-editor/src/components/dimensions-tool/stories/aspect-ratio-tool.js b/packages/block-editor/src/components/dimensions-tool/stories/aspect-ratio-tool.js new file mode 100644 index 00000000000000..9b82404a23c255 --- /dev/null +++ b/packages/block-editor/src/components/dimensions-tool/stories/aspect-ratio-tool.js @@ -0,0 +1,52 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { + Panel, + __experimentalToolsPanel as ToolsPanel, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import AspectRatioTool from '../aspect-ratio-tool'; + +export default { + title: 'BlockEditor (Private APIs)/DimensionsTool/AspectRatioTool', + component: AspectRatioTool, + argTypes: { + panelId: { control: { type: null } }, + onChange: { action: 'changed' }, + }, +}; + +export const Default = ( { panelId, onChange: onChangeProp, ...props } ) => { + const [ value, setValue ] = useState( undefined ); + const resetAll = () => { + setValue( undefined ); + onChangeProp( undefined ); + }; + return ( + + + { + setValue( nextValue ); + onChangeProp( nextValue ); + } } + value={ value } + { ...props } + /> + + + ); +}; +Default.args = { + panelId: 'panel-id', +}; diff --git a/packages/block-editor/src/components/dimensions-tool/stories/index.js b/packages/block-editor/src/components/dimensions-tool/stories/index.js new file mode 100644 index 00000000000000..d9e1a82771282e --- /dev/null +++ b/packages/block-editor/src/components/dimensions-tool/stories/index.js @@ -0,0 +1,54 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { + Panel, + __experimentalToolsPanel as ToolsPanel, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import DimensionsTool from '..'; + +export default { + title: 'BlockEditor (Private APIs)/DimensionsTool', + component: DimensionsTool, + argTypes: { + panelId: { control: { type: null } }, + onChange: { action: 'changed' }, + }, +}; + +const EMPTY_OBJECT = {}; + +export const Default = ( { panelId, onChange, ...props } ) => { + const [ value, setValue ] = useState( EMPTY_OBJECT ); + const resetAll = () => { + setValue( EMPTY_OBJECT ); + onChange( EMPTY_OBJECT ); + }; + return ( + + + { + setValue( nextValue ); + onChange( nextValue ); + } } + value={ value } + { ...props } + /> + + + ); +}; +Default.args = { + panelId: 'panel-id', +}; diff --git a/packages/block-editor/src/components/dimensions-tool/stories/scale-tool.js b/packages/block-editor/src/components/dimensions-tool/stories/scale-tool.js new file mode 100644 index 00000000000000..a5ff9a81b5304b --- /dev/null +++ b/packages/block-editor/src/components/dimensions-tool/stories/scale-tool.js @@ -0,0 +1,48 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { + Panel, + __experimentalToolsPanel as ToolsPanel, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import ScaleTool from '../scale-tool'; + +export default { + title: 'BlockEditor (Private APIs)/DimensionsTool/ScaleTool', + component: ScaleTool, + argTypes: { + panelId: { control: { type: null } }, + onChange: { action: 'changed' }, + }, +}; + +export const Default = ( { panelId, onChange: onChangeProp, ...props } ) => { + const [ value, setValue ] = useState( undefined ); + const resetAll = () => { + setValue( undefined ); + onChangeProp( undefined ); + }; + return ( + + + { + setValue( nextValue ); + onChangeProp( nextValue ); + } } + value={ value } + { ...props } + /> + + + ); +}; +Default.args = { + panelId: 'panel-id', +}; diff --git a/packages/block-editor/src/components/dimensions-tool/stories/width-height-tool.js b/packages/block-editor/src/components/dimensions-tool/stories/width-height-tool.js new file mode 100644 index 00000000000000..4a9d9782ad16b7 --- /dev/null +++ b/packages/block-editor/src/components/dimensions-tool/stories/width-height-tool.js @@ -0,0 +1,54 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { + Panel, + __experimentalToolsPanel as ToolsPanel, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import WidthHeightTool from '../width-height-tool'; + +export default { + title: 'BlockEditor (Private APIs)/DimensionsTool/WidthHeightTool', + component: WidthHeightTool, + argTypes: { + panelId: { control: { type: null } }, + onChange: { action: 'changed' }, + }, +}; + +const EMPTY_OBJECT = {}; + +export const Default = ( { panelId, onChange: onChangeProp, ...props } ) => { + const [ value, setValue ] = useState( EMPTY_OBJECT ); + const resetAll = () => { + setValue( EMPTY_OBJECT ); + onChangeProp( EMPTY_OBJECT ); + }; + return ( + + + { + setValue( nextValue ); + onChangeProp( nextValue ); + } } + value={ value } + { ...props } + /> + + + ); +}; +Default.args = { + panelId: 'panel-id', +}; diff --git a/packages/block-editor/src/components/dimensions-tool/test/index.js b/packages/block-editor/src/components/dimensions-tool/test/index.js new file mode 100644 index 00000000000000..6bfa2af057f7c6 --- /dev/null +++ b/packages/block-editor/src/components/dimensions-tool/test/index.js @@ -0,0 +1,641 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * WordPress dependencies + */ +import { __experimentalToolsPanel as ToolsPanel } from '@wordpress/components'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import DimensionsTool from '../'; + +const EMPTY_OBJECT = {}; + +function Example( { initialValue, onChange, ...props } ) { + const [ value, setValue ] = useState( initialValue ); + const resetAll = () => { + setValue( EMPTY_OBJECT ); + onChange( EMPTY_OBJECT ); + }; + return ( + + { + setValue( nextValue ); + onChange( nextValue ); + } } + defaultScale="cover" + defaultAspectRatio="auto" + value={ value } + { ...props } + /> + + ); +} + +// (xxxx) -> (yyyy) is a shorthand for categorizing the test cases by initial +// state (xxxx) and final state (yyyy). Each digit represents whether or not the +// value is set, in the order: [aspectRatio, scale, width, height]. +// +// See https://github.com/WordPress/gutenberg/pull/51545#issuecomment-1601326289 + +// Using expect( onChange.mock.calls ).toStrictEqual(...) so undefined +// properties are treated differently from missing properties. + +describe( 'DimensionsTool', () => { + describe( 'updating aspectRatio', () => { + it( 'when starting with empty initial state, setting aspectRatio also sets scale (0000) -> (1100)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + // aspectRatio, + // scale, + // width, + // height, + }; + + render( + + ); + + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + await user.selectOptions( aspectRatioSelect, '16/9' ); + + expect( aspectRatioSelect ).toHaveValue( '16/9' ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).toBeInTheDocument(); + + const scaleCoverRadio = screen.getByRole( 'radio', { + name: 'Cover', + } ); + + expect( scaleCoverRadio ).toBeChecked(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { aspectRatio: '16/9', scale: 'cover' } ], + ] ); + } ); + + it( 'when starting with just height, setting aspectRatio also sets scale (0001) -> (1101)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + // aspectRatio, + // scale, + // width, + height: '6px', + }; + + render( + + ); + + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + await user.selectOptions( aspectRatioSelect, '16/9' ); + + expect( aspectRatioSelect ).toHaveValue( '16/9' ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).toBeInTheDocument(); + + const scaleCoverRadio = screen.getByRole( 'radio', { + name: 'Cover', + } ); + + expect( scaleCoverRadio ).toBeChecked(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { aspectRatio: '16/9', scale: 'cover', height: '6px' } ], + ] ); + } ); + + it( 'when starting with just width, setting aspectRatio also sets scale (0010) -> (1110)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + // aspectRatio, + // scale, + width: '8px', + // height, + }; + + render( + + ); + + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + await user.selectOptions( aspectRatioSelect, '16/9' ); + + expect( aspectRatioSelect ).toHaveValue( '16/9' ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).toBeInTheDocument(); + + const scaleCoverRadio = screen.getByRole( 'radio', { + name: 'Cover', + } ); + + expect( scaleCoverRadio ).toBeChecked(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { aspectRatio: '16/9', scale: 'cover', width: '8px' } ], + ] ); + } ); + + it( 'when starting with scale, width, and height, setting aspectRatio also clears height (0111) -> (1110)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + // aspectRatio, + scale: 'cover', + width: '8px', + height: '6px', + }; + + render( + + ); + + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + await user.selectOptions( aspectRatioSelect, '16/9' ); + + expect( aspectRatioSelect ).toHaveValue( '16/9' ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).toBeInTheDocument(); + + const scaleCoverRadio = screen.getByRole( 'radio', { + name: 'Cover', + } ); + + expect( scaleCoverRadio ).toBeChecked(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { aspectRatio: '16/9', scale: 'cover', width: '8px' } ], + ] ); + } ); + + it( 'when starting with aspectRatio and scale, setting aspectRatio to "Original" also clears scale (1100) -> (0000)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + aspectRatio: '16/9', + scale: 'cover', + // width, + // height, + }; + + render( + + ); + + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + await user.selectOptions( aspectRatioSelect, 'auto' ); + + expect( aspectRatioSelect ).toHaveValue( 'auto' ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).not.toBeInTheDocument(); + + expect( onChange.mock.calls ).toStrictEqual( [ [ {} ] ] ); + } ); + + it( 'when starting with aspectRatio, scale, and height, setting aspectRatio to "Original" also clears scale (1101) -> (0001)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + aspectRatio: '16/9', + scale: 'cover', + // width, + height: '6px', + }; + + render( + + ); + + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + await user.selectOptions( aspectRatioSelect, 'auto' ); + + expect( aspectRatioSelect ).toHaveValue( 'auto' ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).not.toBeInTheDocument(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { height: '6px' } ], + ] ); + } ); + + it( 'when starting with aspectRatio, scale, and width, setting aspectRatio to "Original" also clears scale (1110) -> (0010)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + aspectRatio: '16/9', + scale: 'cover', + width: '8px', + // height, + }; + + render( + + ); + + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + await user.selectOptions( aspectRatioSelect, 'auto' ); + + expect( aspectRatioSelect ).toHaveValue( 'auto' ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).not.toBeInTheDocument(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { width: '8px' } ], + ] ); + } ); + } ); + + describe( 'updating scale', () => { + // No custom interactions here. Things should just update normally. + } ); + + describe( 'updating dimensions', () => { + it( 'when starting with just height, setting width also sets scale (0001) -> (0111)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + // aspectRatio, + // scale, + // width, + height: '6px', + }; + + render( + + ); + + const widthInput = screen.getByRole( 'spinbutton', { + name: 'Width', + } ); + + await user.type( widthInput, '8' ); + + expect( widthInput ).toHaveValue( 8 ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).toBeInTheDocument(); + + const scaleCoverRadio = screen.getByRole( 'radio', { + name: 'Cover', + } ); + + expect( scaleCoverRadio ).toBeChecked(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { scale: 'cover', width: '8px', height: '6px' } ], + ] ); + } ); + + it( 'when starting with just width, setting height also sets scale (0010) -> (0111)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + // aspectRatio, + // scale, + width: '8px', + // height, + }; + + render( + + ); + + const heightInput = screen.getByRole( 'spinbutton', { + name: 'Height', + } ); + + await user.type( heightInput, '6' ); + + expect( heightInput ).toHaveValue( 6 ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).toBeInTheDocument(); + + const scaleCoverRadio = screen.getByRole( 'radio', { + name: 'Cover', + } ); + + expect( scaleCoverRadio ).toBeChecked(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { scale: 'cover', width: '8px', height: '6px' } ], + ] ); + } ); + + it( 'when starting with scale, width, and height, clearing width also clears scale (0111) -> (0001)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + // aspectRatio, + scale: 'cover', + width: '8px', + height: '6px', + }; + + render( + + ); + + const widthInput = screen.getByRole( 'spinbutton', { + name: 'Width', + } ); + + await user.clear( widthInput ); + + expect( widthInput ).toHaveValue( null ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).not.toBeInTheDocument(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { height: '6px' } ], + ] ); + } ); + + it( 'when starting with scale, width, and height, clearing height also clears scale (0111) -> (0010)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + // aspectRatio, + scale: 'cover', + width: '8px', + height: '6px', + }; + + render( + + ); + + const heightInput = screen.getByRole( 'spinbutton', { + name: 'Height', + } ); + + await user.clear( heightInput ); + + expect( heightInput ).toHaveValue( null ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).not.toBeInTheDocument(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { width: '8px' } ], + ] ); + } ); + + it( 'when starting with aspectRatio, scale, and height, setting width also clears aspectRatio (1101) -> (0111)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + aspectRatio: '16/9', + scale: 'cover', + // width, + height: '6px', + }; + + render( + + ); + + const widthInput = screen.getByRole( 'spinbutton', { + name: 'Width', + } ); + + await user.type( widthInput, '8' ); + + expect( widthInput ).toHaveValue( 8 ); + + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + expect( aspectRatioSelect ).toHaveValue( 'custom' ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).toBeInTheDocument(); + + const scaleCoverRadio = screen.getByRole( 'radio', { + name: 'Cover', + } ); + + expect( scaleCoverRadio ).toBeChecked(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { scale: 'cover', width: '8px', height: '6px' } ], + ] ); + } ); + + it( 'when starting with aspectRatio, scale, and width, setting height also clears aspectRatio (1110) -> (0111)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + aspectRatio: '16/9', + scale: 'cover', + width: '8px', + // height, + }; + + render( + + ); + + const heightInput = screen.getByRole( 'spinbutton', { + name: 'Height', + } ); + + await user.type( heightInput, '6' ); + + expect( heightInput ).toHaveValue( 6 ); + + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + expect( aspectRatioSelect ).toHaveValue( 'custom' ); + + const scaleRadioGroup = screen.queryByRole( 'radiogroup', { + name: 'Scale', + } ); + + expect( scaleRadioGroup ).toBeInTheDocument(); + + const scaleCoverRadio = screen.getByRole( 'radio', { + name: 'Cover', + } ); + + expect( scaleCoverRadio ).toBeChecked(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { scale: 'cover', width: '8px', height: '6px' } ], + ] ); + } ); + } ); + + describe( 'internal component state', () => { + it( 'when aspect ratio is change to custom by setting width and height then removing a width value should return the original aspect ratio (1100) -> (1110) -> (0111) -> (1101)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const value = { + aspectRatio: '16/9', + scale: 'cover', + }; + + render( ); + + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + const widthInput = screen.getByRole( 'spinbutton', { + name: 'Width', + } ); + + const heightInput = screen.getByRole( 'spinbutton', { + name: 'Height', + } ); + + await user.type( widthInput, '8' ); + expect( aspectRatioSelect ).toHaveValue( '16/9' ); + + await user.type( heightInput, '6' ); + expect( aspectRatioSelect ).toHaveValue( 'custom' ); + + await user.clear( widthInput, '' ); + expect( aspectRatioSelect ).toHaveValue( '16/9' ); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { aspectRatio: '16/9', scale: 'cover', width: '8px' } ], + [ { scale: 'cover', width: '8px', height: '6px' } ], + [ { aspectRatio: '16/9', scale: 'cover', height: '6px' } ], + ] ); + } ); + + it( 'when custom scale is set then aspect ratio is set to original and then aspect ratio is changed back (1100) -> (1100) -> (0000) -> (1100)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const value = { + aspectRatio: '16/9', + scale: 'cover', + }; + + render( ); + + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + const scaleContainRadio = screen.getByRole( 'radio', { + name: 'Contain', + } ); + + await user.click( scaleContainRadio ); + expect( scaleContainRadio ).toBeChecked(); + + await user.selectOptions( aspectRatioSelect, 'auto' ); + expect( aspectRatioSelect ).toHaveValue( 'auto' ); + + await user.selectOptions( aspectRatioSelect, '16/9' ); + expect( aspectRatioSelect ).toHaveValue( '16/9' ); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { aspectRatio: '16/9', scale: 'contain' } ], + [ {} ], + [ + { + aspectRatio: '16/9', + scale: 'contain', + }, + ], + ] ); + } ); + } ); +} ); diff --git a/packages/block-editor/src/components/dimensions-tool/width-height-tool.js b/packages/block-editor/src/components/dimensions-tool/width-height-tool.js new file mode 100644 index 00000000000000..2c1db64954ff93 --- /dev/null +++ b/packages/block-editor/src/components/dimensions-tool/width-height-tool.js @@ -0,0 +1,113 @@ +/** + * External dependencies + */ +import styled from '@emotion/styled'; + +/** + * WordPress dependencies + */ +import { + __experimentalToolsPanelItem as ToolsPanelItem, + __experimentalUnitControl as UnitControl, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +const SingleColumnToolsPanelItem = styled( ToolsPanelItem )` + grid-column: span 1; +`; + +/** + * @typedef {import('@wordpress/components/build-types/unit-control/types').WPUnitControlUnit} WPUnitControlUnit + */ + +/** + * @typedef {Object} WidthHeightToolValue + * @property {string} [width] Width CSS value. + * @property {string} [height] Height CSS value. + */ + +/** + * @callback WidthHeightToolOnChange + * @param {WidthHeightToolValue} nextValue Next dimensions value. + * @return {void} + */ + +/** + * @typedef {Object} WidthHeightToolProps + * @property {string} [panelId] ID of the panel that contains the controls. + * @property {WidthHeightToolValue} [value] Current dimensions values. + * @property {WidthHeightToolOnChange} [onChange] Callback to update the dimensions values. + * @property {WPUnitControlUnit[]} [units] Units options. + * @property {boolean} [isShownByDefault] Whether the panel is shown by default. + */ + +/** + * Component that renders controls to edit the dimensions of an image or container. + * + * @param {WidthHeightToolProps} props The component props. + * + * @return {import('@wordpress/element').WPElement} The width and height tool. + */ +export default function WidthHeightTool( { + panelId, + value = {}, + onChange = () => {}, + units, + isShownByDefault = true, +} ) { + // null, undefined, and 'auto' all represent the default value. + const width = value.width === 'auto' ? '' : value.width ?? ''; + const height = value.height === 'auto' ? '' : value.height ?? ''; + + const onDimensionChange = ( dimension ) => ( nextDimension ) => { + const nextValue = { ...value }; + // Empty strings or undefined may be passed and both represent removing the value. + if ( ! nextDimension ) { + delete nextValue[ dimension ]; + } else { + nextValue[ dimension ] = nextDimension; + } + onChange( nextValue ); + }; + + return ( + <> + width !== '' } + onDeselect={ onDimensionChange( 'width' ) } + panelId={ panelId } + > + + + height !== '' } + onDeselect={ onDimensionChange( 'height' ) } + panelId={ panelId } + > + + + + ); +} diff --git a/packages/block-editor/src/components/image-editor/aspect-ratio-dropdown.js b/packages/block-editor/src/components/image-editor/aspect-ratio-dropdown.js index c2e8a2cefe4773..5019da9c515d05 100644 --- a/packages/block-editor/src/components/image-editor/aspect-ratio-dropdown.js +++ b/packages/block-editor/src/components/image-editor/aspect-ratio-dropdown.js @@ -54,7 +54,7 @@ export default function AspectRatioDropdown( { toggleProps } ) { } } value={ aspect } aspectRatios={ [ - // All ratios should be mirrored in PostFeaturedImage in @wordpress/block-library + // All ratios should be mirrored in AspectRatioTool in @wordpress/block-editor. { title: __( 'Original' ), aspect: defaultAspect, diff --git a/packages/block-editor/src/components/image-editor/use-save-image.js b/packages/block-editor/src/components/image-editor/use-save-image.js index 2d1515ff0e3f0e..dbd95323225cb7 100644 --- a/packages/block-editor/src/components/image-editor/use-save-image.js +++ b/packages/block-editor/src/components/image-editor/use-save-image.js @@ -66,7 +66,6 @@ export default function useSaveImage( { onSaveImage( { id: response.id, url: response.source_url, - height: height && width ? width / aspect : undefined, } ); } ) .catch( ( error ) => { diff --git a/packages/block-editor/src/components/image-size-control/index.js b/packages/block-editor/src/components/image-size-control/index.js index 46e87de60f2fc8..d929b129313938 100644 --- a/packages/block-editor/src/components/image-size-control/index.js +++ b/packages/block-editor/src/components/image-size-control/index.js @@ -8,6 +8,7 @@ import { __experimentalNumberControl as NumberControl, __experimentalHStack as HStack, } from '@wordpress/components'; +import deprecated from '@wordpress/deprecated'; import { __ } from '@wordpress/i18n'; /** @@ -30,6 +31,11 @@ export default function ImageSizeControl( { onChange, onChangeImage = noop, } ) { + deprecated( 'wp.blockEditor.__experimentalImageSizeControl', { + since: '6.3', + alternative: + 'wp.blockEditor.privateApis.DimensionsTool and wp.blockEditor.privateApis.ResolutionTool', + } ); const { currentHeight, currentWidth, updateDimension, updateDimensions } = useDimensionHandler( height, width, imageHeight, imageWidth, onChange ); diff --git a/packages/block-editor/src/components/resolution-tool/index.js b/packages/block-editor/src/components/resolution-tool/index.js new file mode 100644 index 00000000000000..71c7e508ca3edb --- /dev/null +++ b/packages/block-editor/src/components/resolution-tool/index.js @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { + SelectControl, + __experimentalToolsPanelItem as ToolsPanelItem, +} from '@wordpress/components'; +import { __, _x } from '@wordpress/i18n'; + +const DEFAULT_SIZE_OPTIONS = [ + { + label: _x( 'Thumbnail', 'Image size option for resolution control' ), + value: 'thumbnail', + }, + { + label: _x( 'Medium', 'Image size option for resolution control' ), + value: 'medium', + }, + { + label: _x( 'Large', 'Image size option for resolution control' ), + value: 'large', + }, + { + label: _x( 'Full Size', 'Image size option for resolution control' ), + value: 'full', + }, +]; + +export default function ResolutionTool( { + panelId, + value, + onChange, + options = DEFAULT_SIZE_OPTIONS, + defaultValue = DEFAULT_SIZE_OPTIONS[ 0 ].value, + isShownByDefault = true, +} ) { + const displayValue = value ?? defaultValue; + return ( + displayValue !== defaultValue } + label={ __( 'Resolution' ) } + onDeselect={ () => onChange( defaultValue ) } + isShownByDefault={ isShownByDefault } + panelId={ panelId } + > + + + ); +} diff --git a/packages/block-editor/src/components/resolution-tool/stories/index.js b/packages/block-editor/src/components/resolution-tool/stories/index.js new file mode 100644 index 00000000000000..ed598acd4df98f --- /dev/null +++ b/packages/block-editor/src/components/resolution-tool/stories/index.js @@ -0,0 +1,48 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { + Panel, + __experimentalToolsPanel as ToolsPanel, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import ResolutionTool from '..'; + +export default { + title: 'BlockEditor (Private APIs)/ResolutionControl', + component: ResolutionTool, + argTypes: { + panelId: { control: { type: null } }, + onChange: { action: 'changed' }, + }, +}; + +export const Default = ( { panelId, onChange: onChangeProp, ...props } ) => { + const [ resolution, setResolution ] = useState( undefined ); + const resetAll = () => { + setResolution( undefined ); + onChangeProp( undefined ); + }; + return ( + + + { + setResolution( newValue ); + onChangeProp( newValue ); + } } + value={ resolution } + { ...props } + /> + + + ); +}; +Default.args = { + panelId: 'panel-id', +}; diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js index 9cef7e0cda8e0f..dd8d2d8ff411f7 100644 --- a/packages/block-editor/src/private-apis.js +++ b/packages/block-editor/src/private-apis.js @@ -16,6 +16,8 @@ import BlockQuickNavigation from './components/block-quick-navigation'; import { LayoutStyle } from './components/block-list/layout'; import { BlockRemovalWarningModal } from './components/block-removal-warning-modal'; import { useLayoutClasses, useLayoutStyles } from './hooks'; +import DimensionsTool from './components/dimensions-tool'; +import ResolutionTool from './components/resolution-tool'; /** * Private @wordpress/block-editor APIs. @@ -37,4 +39,6 @@ lock( privateApis, { BlockRemovalWarningModal, useLayoutClasses, useLayoutStyles, + DimensionsTool, + ResolutionTool, } ); diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index 436331e37c3321..7c8b2c2715c99a 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -69,6 +69,12 @@ "height": { "type": "number" }, + "aspectRatio": { + "type": "string" + }, + "scale": { + "type": "string" + }, "sizeSlug": { "type": "string" }, diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js index dc82b7227a701d..9bd175607c5248 100644 --- a/packages/block-library/src/image/edit.js +++ b/packages/block-library/src/image/edit.js @@ -183,8 +183,6 @@ export function ImageEdit( { // Reset the dimension attributes if changing to a different image. if ( ! media.id || media.id !== id ) { additionalAttributes = { - width: undefined, - height: undefined, // Fallback to size "full" if there's no default image size. // It means the image is smaller, and the block will use a full-size URL. sizeSlug: hasDefaultSize( media, imageDefaultSize ) @@ -248,8 +246,6 @@ export function ImageEdit( { setAttributes( { url: newURL, id: undefined, - width: undefined, - height: undefined, sizeSlug: imageDefaultSize, } ); } diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index d9da5067f36259..2dc39751de7101 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -4,13 +4,15 @@ import { isBlobURL } from '@wordpress/blob'; import { ExternalLink, - PanelBody, ResizableBox, Spinner, TextareaControl, TextControl, ToolbarButton, ToolbarGroup, + __experimentalToolsPanel as ToolsPanel, + __experimentalToolsPanelItem as ToolsPanelItem, + __experimentalUseCustomUnits as useCustomUnits, } from '@wordpress/components'; import { useViewportMatch, usePrevious } from '@wordpress/compose'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -18,7 +20,6 @@ import { BlockControls, InspectorControls, RichText, - __experimentalImageSizeControl as ImageSizeControl, __experimentalImageURLInputUI as ImageURLInputUI, MediaReplaceFlow, store as blockEditorStore, @@ -26,6 +27,7 @@ import { __experimentalImageEditor as ImageEditor, __experimentalGetElementClassName, __experimentalUseBorderProps as useBorderProps, + privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { useEffect, @@ -34,7 +36,7 @@ import { useRef, useCallback, } from '@wordpress/element'; -import { __, sprintf, isRTL } from '@wordpress/i18n'; +import { __, _x, sprintf, isRTL } from '@wordpress/i18n'; import { getFilename } from '@wordpress/url'; import { createBlock, @@ -53,6 +55,7 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ +import { unlock } from '../lock-unlock'; import { createUpgradedEmbedBlock } from '../embed/util'; import useClientWidth from './use-client-width'; import { isExternalImage } from './edit'; @@ -61,6 +64,22 @@ import { isExternalImage } from './edit'; * Module constants */ import { MIN_SIZE, ALLOWED_MEDIA_TYPES } from './constants'; +import { evalAspectRatio } from './utils'; + +const { DimensionsTool, ResolutionTool } = unlock( blockEditorPrivateApis ); + +const scaleOptions = [ + { + value: 'cover', + label: _x( 'Cover', 'Scale option for dimensions control' ), + help: __( 'Image covers the space evenly.' ), + }, + { + value: 'contain', + label: _x( 'Contain', 'Scale option for dimensions control' ), + help: __( 'Image is contained without distortion.' ), + }, +]; export default function Image( { temporaryURL, @@ -90,6 +109,8 @@ export default function Image( { title, width, height, + aspectRatio, + scale, linkTarget, sizeSlug, } = attributes; @@ -272,8 +293,6 @@ export default function Image( { setAttributes( { url: newUrl, - width: undefined, - height: undefined, sizeSlug: newSizeSlug, } ); } @@ -329,6 +348,13 @@ export default function Image( { ); } + // TODO: Can allow more units after figuring out how they should interact + // with the ResizableBox and ImageEditor components. Calculations later on + // for those components are currently assuming px units. + const dimensionsUnitsOptions = useCustomUnits( { + availableUnits: [ 'px' ], + } ); + const controls = ( <> @@ -407,41 +433,78 @@ export default function Image( { ) } - + + setAttributes( { + width: undefined, + height: undefined, + scale: undefined, + aspectRatio: undefined, + } ) + } + > { ! multiImageSelection && ( - - - { __( - 'Describe the purpose of the image.' - ) } - -
- { __( 'Leave empty if decorative.' ) } - + isShownByDefault={ true } + hasValue={ () => alt !== '' } + onDeselect={ () => + setAttributes( { alt: undefined } ) } - /> + > + + + { __( + 'Describe the purpose of the image.' + ) } + +
+ { __( 'Leave empty if decorative.' ) } + + } + __nextHasNoMarginBottom + /> + ) } - setAttributes( value ) } - slug={ sizeSlug } - width={ width } - height={ height } - imageSizeOptions={ imageSizeOptions } - isResizable={ isResizable } - imageWidth={ naturalWidth } - imageHeight={ naturalHeight } - imageSizeHelp={ __( - 'Select the size of the source image.' - ) } + { + // Rebuilding the object forces setting `undefined` + // for values that are removed since setAttributes + // doesn't do anything with keys that aren't set. + setAttributes( { + width: + newValue.width && + parseInt( newValue.width, 10 ), + height: + newValue.height && + parseInt( newValue.height, 10 ), + scale: newValue.scale, + aspectRatio: newValue.aspectRatio, + } ); + } } + defaultScale="cover" + defaultAspectRatio="auto" + scaleOptions={ scaleOptions } + unitsOptions={ dimensionsUnitsOptions } + /> + -
+
0 ); let img = ( // Disable reason: Image itself is not meant to be interactive, but @@ -504,25 +564,20 @@ export default function Image( { } } ref={ imageRef } className={ borderProps.className } - style={ borderProps.style } + style={ { + width: + ( width && height ) || aspectRatio ? '100%' : 'inherit', + height: + ( width && height ) || aspectRatio ? '100%' : 'inherit', + objectFit: scale, + ...borderProps.style, + } } /> { temporaryURL && } /* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */ ); - let imageWidthWithinContainer; - let imageHeightWithinContainer; - - if ( clientWidth && naturalWidth && naturalHeight ) { - const exceedMaxWidth = naturalWidth > clientWidth; - const ratio = naturalHeight / naturalWidth; - imageWidthWithinContainer = exceedMaxWidth ? clientWidth : naturalWidth; - imageHeightWithinContainer = exceedMaxWidth - ? clientWidth * ratio - : naturalHeight; - } - // clientWidth needs to be a number for the image Cropper to work, but sometimes it's 0 // So we try using the imageRef width first and fallback to clientWidth. const fallbackClientWidth = imageRef.current?.width || clientWidth; @@ -546,13 +601,17 @@ export default function Image( { borderProps={ isRounded ? undefined : borderProps } /> ); - } else if ( ! isResizable || ! imageWidthWithinContainer ) { - img =
{ img }
; + } else if ( ! isResizable ) { + img =
{ img }
; } else { - const currentWidth = width || imageWidthWithinContainer; - const currentHeight = height || imageHeightWithinContainer; + const ratio = + ( aspectRatio && evalAspectRatio( aspectRatio ) ) || + ( width && height && width / height ) || + naturalWidth / naturalHeight; + + const currentWidth = ! width && height ? height * ratio : width; + const currentHeight = ! height && width ? width / ratio : height; - const ratio = naturalWidth / naturalHeight; const minWidth = naturalWidth < naturalHeight ? MIN_SIZE : MIN_SIZE * ratio; const minHeight = @@ -600,16 +659,24 @@ export default function Image( { img = ( { + onResizeStop={ ( event, direction, elt ) => { onResizeStop(); setAttributes( { - width: parseInt( currentWidth + delta.width, 10 ), - height: parseInt( currentHeight + delta.height, 10 ), + width: elt.offsetWidth, + height: elt.offsetHeight, + aspectRatio: undefined, } ); } } resizeRatio={ align === 'center' ? 2 : 1 } diff --git a/packages/block-library/src/image/save.js b/packages/block-library/src/image/save.js index d0fd5ef3d6f98b..95e8803dd67858 100644 --- a/packages/block-library/src/image/save.js +++ b/packages/block-library/src/image/save.js @@ -24,6 +24,8 @@ export default function save( { attributes } ) { linkClass, width, height, + aspectRatio, + scale, id, linkTarget, sizeSlug, @@ -52,7 +54,11 @@ export default function save( { attributes } ) { src={ url } alt={ alt } className={ imageClasses || undefined } - style={ borderProps.style } + style={ { + ...borderProps.style, + aspectRatio, + objectFit: scale, + } } width={ width } height={ height } title={ title } diff --git a/packages/block-library/src/image/utils.js b/packages/block-library/src/image/utils.js index 839628fa978b00..1ef7973b4e57a3 100644 --- a/packages/block-library/src/image/utils.js +++ b/packages/block-library/src/image/utils.js @@ -3,6 +3,22 @@ */ import { NEW_TAB_REL } from './constants'; +/** + * Evaluates a CSS aspect-ratio property value as a number. + * + * Degenerate or invalid ratios behave as 'auto'. And 'auto' ratios return NaN. + * + * @see https://drafts.csswg.org/css-sizing-4/#aspect-ratio + * + * @param {string} value CSS aspect-ratio property value. + * @return {number} Numerical aspect ratio or NaN if invalid. + */ +export function evalAspectRatio( value ) { + const [ width, height = 1 ] = value.split( '/' ).map( Number ); + const aspectRatio = width / height; + return aspectRatio === Infinity || aspectRatio === 0 ? NaN : aspectRatio; +} + export function removeNewTabRel( currentRel ) { let newRel = currentRel; diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 38d41246bfa161..40fd71c6dc75b9 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -4,6 +4,7 @@ ### Enhancements +- `SelectControl`: Added option to set hidden options. ([#51545](https://github.com/WordPress/gutenberg/pull/51545)) - `UnitControl`: Revamp support for changing unit by typing ([#39303](https://github.com/WordPress/gutenberg/pull/39303)). - `Modal`: Update corner radius to be between buttons and the site view frame, in a 2-4-8 system. ([#51254](https://github.com/WordPress/gutenberg/pull/51254)). - `ItemGroup`: Update button focus state styles to be inline with other button focus states in the editor. ([#51576](https://github.com/WordPress/gutenberg/pull/51576)). diff --git a/packages/components/src/select-control/index.tsx b/packages/components/src/select-control/index.tsx index 57af9bee92b044..8edd322ece883d 100644 --- a/packages/components/src/select-control/index.tsx +++ b/packages/components/src/select-control/index.tsx @@ -135,6 +135,7 @@ function UnforwardedSelectControl( key={ key } value={ option.value } disabled={ option.disabled } + hidden={ option.hidden } > { option.label } diff --git a/packages/components/src/select-control/types.ts b/packages/components/src/select-control/types.ts index d052f81203a7ef..a5699bc7f5e04e 100644 --- a/packages/components/src/select-control/types.ts +++ b/packages/components/src/select-control/types.ts @@ -40,6 +40,12 @@ type SelectControlBaseProps = Pick< * @default false */ disabled?: boolean; + /** + * Whether or not the option should be hidden. + * + * @default false + */ + hidden?: boolean; }[]; /** * As an alternative to the `options` prop, `optgroup`s and `options` can be diff --git a/test/e2e/specs/editor/blocks/buttons.spec.js b/test/e2e/specs/editor/blocks/buttons.spec.js index 8eacb7e2bed2e9..670f337db4e92a 100644 --- a/test/e2e/specs/editor/blocks/buttons.spec.js +++ b/test/e2e/specs/editor/blocks/buttons.spec.js @@ -150,28 +150,6 @@ test.describe( 'Buttons', () => { ); } ); - test( 'can resize width', async ( { editor, page } ) => { - await editor.insertBlock( { name: 'core/buttons' } ); - await page.keyboard.type( 'Content' ); - await editor.openDocumentSettingsSidebar(); - await page.click( - `role=region[name="Editor settings"i] >> role=tab[name="Settings"i]` - ); - await page.click( - 'role=group[name="Button width"i] >> role=button[name="25%"i]' - ); - - // Check the content. - const content = await editor.getEditedPostContent(); - expect( content ).toBe( - ` - -` - ); - } ); - test( 'can apply named colors', async ( { editor, page } ) => { await editor.insertBlock( { name: 'core/buttons' } ); await page.keyboard.type( 'Content' ); diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index 5f802d0e850634..240fda1068f29b 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -59,92 +59,6 @@ test.describe( 'Image', () => { expect( await editor.getEditedPostContent() ).toMatch( regex ); } ); - test( 'should replace, reset size, and keep selection', async ( { - editor, - page, - imageBlockUtils, - } ) => { - await editor.insertBlock( { name: 'core/image' } ); - - const imageBlock = editor.canvas.locator( - 'role=document[name="Block: Image"i]' - ); - const image = imageBlock.locator( 'role=img' ); - - const filename = await imageBlockUtils.upload( - imageBlock.locator( 'data-testid=form-file-upload-input' ) - ); - - { - await expect( image ).toBeVisible(); - await expect( image ).toHaveAttribute( - 'src', - new RegExp( filename ) - ); - - const regex = new RegExp( - ` -
-` - ); - expect( await editor.getEditedPostContent() ).toMatch( regex ); - } - - { - await editor.openDocumentSettingsSidebar(); - await page.click( - 'role=group[name="Image size presets"i] >> role=button[name="25%"i]' - ); - - await expect( image ).toHaveCSS( 'width', '3px' ); - await expect( image ).toHaveCSS( 'height', '3px' ); - - const regex = new RegExp( - ` -
<\\/figure> -` - ); - - expect( await editor.getEditedPostContent() ).toMatch( regex ); - } - - { - await editor.showBlockToolbar(); - await page.click( 'role=button[name="Replace"i]' ); - - const replacedFilename = await imageBlockUtils.upload( - page - // Ideally the menu should have the name of "Replace" but is currently missing. - // Hence, we fallback to using CSS classname instead. - .locator( '.block-editor-media-replace-flow__options' ) - .locator( 'data-testid=form-file-upload-input' ) - ); - - await expect( image ).toHaveAttribute( - 'src', - new RegExp( replacedFilename ) - ); - await expect( image ).toBeVisible(); - - const regex = new RegExp( - ` -
-` - ); - expect( await editor.getEditedPostContent() ).toMatch( regex ); - } - - { - // Focus outside the block to avoid the image caption being selected - // It can happen on CI specially. - await editor.canvas.click( 'role=textbox[name="Add title"i]' ); - await image.click(); - await page.keyboard.press( 'Backspace' ); - - expect( await editor.getEditedPostContent() ).toBe( '' ); - } - } ); - test( 'should place caret on caption when clicking to add one', async ( { editor, page, @@ -461,65 +375,6 @@ test.describe( 'Image', () => { ).toMatchSnapshot(); } ); - test( 'Should reset dimensions on change URL', async ( { - editor, - page, - imageBlockUtils, - } ) => { - await editor.insertBlock( { name: 'core/image' } ); - - const imageBlock = editor.canvas.locator( - 'role=document[name="Block: Image"i]' - ); - const image = imageBlock.locator( 'role=img' ); - - { - // Upload an initial image. - const filename = await imageBlockUtils.upload( - imageBlock.locator( 'data-testid=form-file-upload-input' ) - ); - await expect( image ).toHaveAttribute( - 'src', - new RegExp( filename ) - ); - - // Resize the Uploaded Image. - await editor.openDocumentSettingsSidebar(); - await page.click( - 'role=group[name="Image size presets"i] >> role=button[name="25%"i]' - ); - - const regex = new RegExp( - ` -
-` - ); - - // Check if dimensions are changed. - expect( await editor.getEditedPostContent() ).toMatch( regex ); - } - - { - const imageUrl = '/wp-includes/images/w-logo-blue.png'; - - // Replace uploaded image with an URL. - await editor.clickBlockToolbarButton( 'Replace' ); - await page.click( 'role=button[name="Edit"i]' ); - // Replace the url. - await page.fill( 'role=combobox[name="URL"i]', imageUrl ); - await page.click( 'role=button[name="Save"i]' ); - - const regex = new RegExp( - ` -
-` - ); - - // Check if dimensions are reset. - expect( await editor.getEditedPostContent() ).toMatch( regex ); - } - } ); - test( 'should undo without broken temporary state', async ( { editor, pageUtils, diff --git a/test/e2e/specs/editor/plugins/image-size.spec.js b/test/e2e/specs/editor/plugins/image-size.spec.js index f2ba0e91b6733a..1e0bc91407565d 100644 --- a/test/e2e/specs/editor/plugins/image-size.spec.js +++ b/test/e2e/specs/editor/plugins/image-size.spec.js @@ -56,6 +56,6 @@ test.describe( 'changing image size', () => { ).toHaveCSS( 'width', '499px' ); await expect( page.locator( 'role=spinbutton[name="Width"i]' ) - ).toHaveValue( '499' ); + ).toHaveValue( '' ); } ); } );