diff --git a/backport-changelog/7.1/12077.md b/backport-changelog/7.1/12077.md new file mode 100644 index 00000000000000..849dc1167bac18 --- /dev/null +++ b/backport-changelog/7.1/12077.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/12077 + +* https://github.com/WordPress/gutenberg/pull/78795 \ No newline at end of file diff --git a/lib/block-supports/dimensions.php b/lib/block-supports/dimensions.php index df37b612294c0a..f1ddd78522e6d3 100644 --- a/lib/block-supports/dimensions.php +++ b/lib/block-supports/dimensions.php @@ -79,6 +79,22 @@ function gutenberg_apply_dimensions_support( $block_type, $block_attributes ) { return $attributes; } +/** + * Checks whether an aspectRatio block-support value is explicitly set. + * + * @param mixed $aspect_ratio Aspect-ratio value. + * @return bool Whether the value is an explicit aspect ratio. + */ +function gutenberg_is_explicit_aspect_ratio_value( $aspect_ratio ) { + if ( ! is_string( $aspect_ratio ) && ! is_numeric( $aspect_ratio ) ) { + return false; + } + + $aspect_ratio = strtolower( trim( (string) $aspect_ratio ) ); + + return '' !== $aspect_ratio && 'auto' !== $aspect_ratio; +} + /** * Renders server-side dimensions styles to the block wrapper. * This block support uses the `render_block` hook to ensure that @@ -105,7 +121,7 @@ function gutenberg_render_dimensions_support( $block_content, $block ) { // To ensure the aspect ratio does not get overridden by `minHeight` or `height` unset any existing rule. if ( - isset( $dimensions_block_styles['aspectRatio'] ) + gutenberg_is_explicit_aspect_ratio_value( $dimensions_block_styles['aspectRatio'] ) ) { $dimensions_block_styles['minHeight'] = 'unset'; $dimensions_block_styles['height'] = 'unset'; @@ -142,7 +158,7 @@ function gutenberg_render_dimensions_support( $block_content, $block ) { foreach ( explode( ' ', $styles['classnames'] ) as $class_name ) { if ( str_contains( $class_name, 'aspect-ratio' ) && - ! isset( $block_attributes['style']['dimensions']['aspectRatio'] ) + ! gutenberg_is_explicit_aspect_ratio_value( $block_attributes['style']['dimensions']['aspectRatio'] ?? null ) ) { continue; } diff --git a/lib/block-supports/states.php b/lib/block-supports/states.php index 27d641b2df0549..27e5d3550c6c6f 100644 --- a/lib/block-supports/states.php +++ b/lib/block-supports/states.php @@ -90,6 +90,54 @@ function gutenberg_get_state_declarations_with_fallback_border_styles( $declarat return $declarations; } +/** + * Adds fallback dimension styles for aspectRatio and height block-support values. + * + * @param array $state_style State style object. + * @return array State style object with fallback dimension styles applied where needed. + */ +function gutenberg_get_state_style_with_fallback_dimension_styles( $state_style ) { + if ( ! is_array( $state_style ) ) { + return $state_style; + } + + $dimensions = isset( $state_style['dimensions'] ) && is_array( $state_style['dimensions'] ) + ? $state_style['dimensions'] + : array(); + + if ( empty( $dimensions ) ) { + return $state_style; + } + + if ( gutenberg_is_explicit_aspect_ratio_value( $dimensions['aspectRatio'] ?? null ) ) { + return array_replace_recursive( + $state_style, + array( + 'dimensions' => array( + 'minHeight' => 'unset', + 'height' => 'unset', + ), + ) + ); + } + + $has_min_height = isset( $dimensions['minHeight'] ) && ( is_string( $dimensions['minHeight'] ) || is_numeric( $dimensions['minHeight'] ) ) && '' !== trim( (string) $dimensions['minHeight'] ); + $has_height = isset( $dimensions['height'] ) && ( is_string( $dimensions['height'] ) || is_numeric( $dimensions['height'] ) ) && '' !== trim( (string) $dimensions['height'] ); + + if ( $has_min_height || $has_height ) { + return array_replace_recursive( + $state_style, + array( + 'dimensions' => array( + 'aspectRatio' => 'unset', + ), + ) + ); + } + + return $state_style; +} + /** * Adds a style fragment to a selector-keyed state style group. * @@ -216,8 +264,9 @@ function gutenberg_get_block_state_style_rules( $state_styles, $block_type, $rul } foreach ( gutenberg_get_state_style_groups( $state_style, $block_selectors ) as $group ) { + $style = gutenberg_get_state_style_with_fallback_dimension_styles( $group['style'] ); $compiled = gutenberg_style_engine_get_styles( - gutenberg_normalize_state_style_for_css_output( $group['style'] ) + gutenberg_normalize_state_style_for_css_output( $style ) ); if ( ! empty( $compiled['declarations'] ) ) { @@ -431,8 +480,8 @@ function gutenberg_render_block_states_support( $block_content, $block ) { */ $style_rules = array(); foreach ( $css_rules as $rule ) { - $declarations = array(); - foreach ( $rule['declarations'] as $property => $value ) { + $declarations = $rule['declarations']; + foreach ( $declarations as $property => $value ) { $declarations[ $property ] = is_string( $value ) && str_contains( $value, '!important' ) ? $value : $value . ' !important'; diff --git a/packages/block-editor/src/components/dimensions-tool/index.js b/packages/block-editor/src/components/dimensions-tool/index.js index 49d0fe03b4cb48..2527f1b67ec36c 100644 --- a/packages/block-editor/src/components/dimensions-tool/index.js +++ b/packages/block-editor/src/components/dimensions-tool/index.js @@ -56,7 +56,7 @@ function DimensionsTool( { 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. + defaultScale = 'cover', unitsOptions, // Default options handled by UnitControl. tools = [ 'aspectRatio', 'widthHeight', 'scale' ], } ) { @@ -73,21 +73,19 @@ function DimensionsTool( { value.aspectRatio === undefined || value.aspectRatio === 'auto' ? null : value.aspectRatio; - const scale = - value.scale === undefined || value.scale === 'fill' - ? null - : value.scale; + const scale = value.scale === undefined ? 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 ); + const hasCustomAspectRatio = !! ( width && height ); // '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 aspectRatioValue = hasCustomAspectRatio ? 'custom' : aspectRatio; const showScaleControl = aspectRatio || ( width && height ); @@ -197,9 +195,6 @@ function DimensionsTool( { onChange={ ( nextScale ) => { const nextValue = { ...value }; - // 'fill' is CSS default, so it gets treated as null. - nextScale = nextScale === 'fill' ? null : nextScale; - setLastScale( nextScale ); // Update scale. diff --git a/packages/block-editor/src/components/dimensions-tool/scale-tool.js b/packages/block-editor/src/components/dimensions-tool/scale-tool.js index 212bd487d530e9..0c7254e9a99e46 100644 --- a/packages/block-editor/src/components/dimensions-tool/scale-tool.js +++ b/packages/block-editor/src/components/dimensions-tool/scale-tool.js @@ -86,8 +86,7 @@ export default function ScaleTool( { 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 displayValue = value ?? defaultValue; const scaleHelp = useMemo( () => { return options.reduce( ( acc, option ) => { diff --git a/packages/block-editor/src/components/dimensions-tool/test/index.js b/packages/block-editor/src/components/dimensions-tool/test/index.js index f6f7d71eb2dcbf..8b0b6e47d1465e 100644 --- a/packages/block-editor/src/components/dimensions-tool/test/index.js +++ b/packages/block-editor/src/components/dimensions-tool/test/index.js @@ -16,6 +16,17 @@ import { useState } from '@wordpress/element'; import DimensionsTool from '../'; const EMPTY_OBJECT = {}; +const ASPECT_RATIO_OPTIONS = [ + { label: 'Original', value: 'auto' }, + { label: '16/9', value: '16/9' }, + { label: '4/3', value: '4/3' }, + { + label: 'Custom', + value: 'custom', + disabled: true, + hidden: true, + }, +]; function Example( { initialValue, onChange, ...props } ) { const [ value, setValue ] = useState( initialValue ); @@ -33,16 +44,27 @@ function Example( { initialValue, onChange, ...props } ) { } } defaultScale="cover" defaultAspectRatio="auto" - aspectRatioOptions={ [ - { label: 'Original', value: 'auto' }, - { label: '16/9', value: '16/9' }, - { - label: 'Custom', - value: 'custom', - disabled: true, - hidden: true, - }, - ] } + aspectRatioOptions={ ASPECT_RATIO_OPTIONS } + value={ value } + { ...props } + /> + + ); +} + +function ControlledExample( { value, onChange, ...props } ) { + return ( + onChange( EMPTY_OBJECT ) } + > + @@ -60,6 +82,39 @@ function Example( { initialValue, onChange, ...props } ) { // properties are treated differently from missing properties. describe( 'DimensionsTool', () => { + describe( 'controlled values', () => { + it( 'updates the aspect ratio control when the value prop changes', () => { + const onChange = jest.fn(); + const { rerender } = render( + + ); + const aspectRatioSelect = screen.getByRole( 'combobox', { + name: 'Aspect ratio', + } ); + + expect( aspectRatioSelect ).toHaveValue( '16/9' ); + + rerender( + + ); + expect( aspectRatioSelect ).toHaveValue( '4/3' ); + + rerender( + + ); + expect( aspectRatioSelect ).toHaveValue( 'auto' ); + } ); + } ); + describe( 'updating aspectRatio', () => { it( 'when starting with empty initial state, setting aspectRatio also sets scale (0000) -> (1100)', async () => { const user = userEvent.setup(); @@ -323,7 +378,30 @@ describe( 'DimensionsTool', () => { } ); describe( 'updating scale', () => { - // No custom interactions here. Things should just update normally. + it( 'when default scale is cover, setting scale to fill preserves the fill value', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + const initialValue = { + aspectRatio: '16/9', + scale: 'cover', + }; + + render( + + ); + + const scaleFillRadio = screen.getByRole( 'radio', { + name: 'Fill', + } ); + + await user.click( scaleFillRadio ); + expect( scaleFillRadio ).toBeChecked(); + + expect( onChange.mock.calls ).toStrictEqual( [ + [ { aspectRatio: '16/9', scale: 'fill' } ], + ] ); + } ); } ); describe( 'updating dimensions', () => { diff --git a/packages/block-editor/src/components/global-styles/dimensions-panel.js b/packages/block-editor/src/components/global-styles/dimensions-panel.js index 05b1b93f5e49a8..cf1a718760c2d3 100644 --- a/packages/block-editor/src/components/global-styles/dimensions-panel.js +++ b/packages/block-editor/src/components/global-styles/dimensions-panel.js @@ -32,7 +32,6 @@ import { setImmutably } from '../../utils/object'; import { DEFAULT_BLOCK_STYLE_STATE, hasPseudoBlockStyleState, - isDefaultBlockStyleState, hasViewportBlockStyleState, } from '../../hooks/block-style-state'; @@ -96,7 +95,7 @@ function hasWidth( settings ) { function hasAspectRatio( settings, styleState = DEFAULT_BLOCK_STYLE_STATE ) { return ( - isDefaultBlockStyleState( styleState ) && + ! hasPseudoBlockStyleState( styleState ) && settings?.dimensions?.aspectRatio ); } diff --git a/packages/block-editor/src/hooks/dimensions.js b/packages/block-editor/src/hooks/dimensions.js index b968e356b146b3..61555b5aa5256a 100644 --- a/packages/block-editor/src/hooks/dimensions.js +++ b/packages/block-editor/src/hooks/dimensions.js @@ -194,6 +194,14 @@ export function hasDimensionsSupport( blockName, feature = 'any' ) { return !! support?.[ feature ]; } +export function isExplicitAspectRatio( aspectRatio ) { + if ( ! aspectRatio ) { + return false; + } + + return `${ aspectRatio }`.trim().toLowerCase() !== 'auto'; +} + export default { useBlockProps, attributeKeys: [ 'height', 'minHeight', 'width', 'style' ], @@ -210,8 +218,11 @@ function useBlockProps( { name, height, minHeight, style } ) { return {}; } + const hasExplicitAspectRatio = isExplicitAspectRatio( + style?.dimensions?.aspectRatio + ); const className = clsx( { - 'has-aspect-ratio': !! style?.dimensions?.aspectRatio, + 'has-aspect-ratio': hasExplicitAspectRatio, } ); // Allow dimensions-based inline style overrides to override any global styles rules that @@ -219,12 +230,12 @@ function useBlockProps( { name, height, minHeight, style } ) { const inlineStyleOverrides = {}; // Apply rules to unset incompatible styles. - // Note that a set `aspectRatio` will win out if both an aspect ratio and height-related properties are set. + // Note that an explicit `aspectRatio` will win out if both an aspect ratio and height-related properties are set. // This is because the aspect ratio is a newer block support, so (in theory) any aspect ratio // that is set should be intentional and should override any existing height properties. The Cover block // and dimensions controls have logic that will manually clear the aspect ratio if height properties // are set. - if ( style?.dimensions?.aspectRatio ) { + if ( hasExplicitAspectRatio ) { // To ensure the aspect ratio does not get overridden by `minHeight` or `height` unset any existing rule. inlineStyleOverrides.minHeight = 'unset'; inlineStyleOverrides.height = 'unset'; diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 8d1300065cacc0..87d68e780e46c8 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -29,6 +29,7 @@ import { DIMENSIONS_SUPPORT_KEY, SPACING_SUPPORT_KEY, DimensionsPanel, + isExplicitAspectRatio, } from './dimensions'; import { cleanEmptyObject, @@ -134,6 +135,37 @@ function getStateFallbackBorderStyles( stateStyles ) { return cleanEmptyObject( { border: cleanEmptyObject( fallbackBorder ) } ); } +/** + * Returns fallback dimension styles that keep state styles aligned with the + * default dimensions block-support output. + * + * @param {Object} stateStyles State style object. + * @return {Object|undefined} Style object containing fallback dimension styles. + */ +function getStateFallbackDimensionStyles( stateStyles ) { + const dimensions = stateStyles?.dimensions; + if ( ! dimensions ) { + return undefined; + } + + if ( isExplicitAspectRatio( dimensions.aspectRatio ) ) { + return { + dimensions: { + minHeight: 'unset', + height: 'unset', + }, + }; + } + + if ( dimensions.minHeight || dimensions.height ) { + return { + dimensions: { + aspectRatio: 'unset', + }, + }; + } +} + /** * Generates CSS for a block instance state style object. * @@ -146,7 +178,12 @@ function getStateFallbackBorderStyles( stateStyles ) { * @return {string} Generated stylesheet. */ export function getStateStylesCSS( stateStyles, selector ) { - const css = compileCSS( stateStyles, { selector } ); + const fallbackDimensionStyles = + getStateFallbackDimensionStyles( stateStyles ); + const stylesWithDimensionFallbacks = fallbackDimensionStyles + ? mergeStyleObjects( stateStyles, fallbackDimensionStyles ) + : stateStyles; + const css = compileCSS( stylesWithDimensionFallbacks, { selector } ); const importantCSS = css ? css.replace( /;/g, ' !important;' ) : undefined; const fallbackBorderStyles = getStateFallbackBorderStyles( stateStyles ); const fallbackCSS = fallbackBorderStyles diff --git a/packages/block-editor/src/hooks/test/dimensions.js b/packages/block-editor/src/hooks/test/dimensions.js index 304ad13a02e51d..3b7e49142e4663 100644 --- a/packages/block-editor/src/hooks/test/dimensions.js +++ b/packages/block-editor/src/hooks/test/dimensions.js @@ -1,6 +1,16 @@ +/** + * WordPress dependencies + */ +import { + getBlockType, + registerBlockType, + unregisterBlockType, +} from '@wordpress/blocks'; + /** * Internal dependencies */ +import dimensions from '../dimensions'; import { getDimensionsClassesAndStyles } from '../use-dimensions-props'; describe( 'getDimensionsClassesAndStyles', () => { @@ -126,3 +136,64 @@ describe( 'getDimensionsClassesAndStyles', () => { } ); } ); } ); + +describe( 'useBlockProps', () => { + const blockName = 'test/dimensions-with-aspect-ratio'; + + afterEach( () => { + if ( getBlockType( blockName ) ) { + unregisterBlockType( blockName ); + } + } ); + + const registerDimensionsBlock = () => + registerBlockType( blockName, { + apiVersion: 3, + title: 'Dimensions with aspect ratio', + category: 'text', + supports: { + dimensions: { + aspectRatio: true, + }, + }, + } ); + + it( 'unsets height styles when aspect ratio is explicit', () => { + registerDimensionsBlock(); + + expect( + dimensions.useBlockProps( { + name: blockName, + style: { + dimensions: { + aspectRatio: '16/9', + }, + }, + } ) + ).toEqual( { + className: 'has-aspect-ratio', + style: { + minHeight: 'unset', + height: 'unset', + }, + } ); + } ); + + it( 'does not unset height styles when aspect ratio is the default', () => { + registerDimensionsBlock(); + + expect( + dimensions.useBlockProps( { + name: blockName, + style: { + dimensions: { + aspectRatio: 'auto', + }, + }, + } ) + ).toEqual( { + className: '', + style: {}, + } ); + } ); +} ); diff --git a/packages/block-editor/src/hooks/test/style.js b/packages/block-editor/src/hooks/test/style.js index 4b09375f07e6ee..d43b02ae242ce9 100644 --- a/packages/block-editor/src/hooks/test/style.js +++ b/packages/block-editor/src/hooks/test/style.js @@ -208,6 +208,49 @@ describe( 'getStateStylesCSS', () => { '.wp-block-test:hover { border-top-color: #0000ff !important; }\n.wp-block-test:hover { border-top-style: solid; }' ); } ); + + it( 'adds important fallback dimensions when aspect ratio is set', () => { + expect( + getStateStylesCSS( + { + dimensions: { + aspectRatio: '16/9', + }, + }, + '.wp-block-test' + ) + ).toBe( + '.wp-block-test { height: unset !important; min-height: unset !important; aspect-ratio: 16/9 !important; }' + ); + } ); + + it( 'does not add fallback dimensions when aspect ratio is the default', () => { + expect( + getStateStylesCSS( + { + dimensions: { + aspectRatio: 'auto', + }, + }, + '.wp-block-test' + ) + ).toBe( '.wp-block-test { aspect-ratio: auto !important; }' ); + } ); + + it( 'adds important fallback aspect ratio when height is set', () => { + expect( + getStateStylesCSS( + { + dimensions: { + height: '20rem', + }, + }, + '.wp-block-test' + ) + ).toBe( + '.wp-block-test { height: 20rem !important; aspect-ratio: unset !important; }' + ); + } ); } ); describe( 'getBlockStateStylesCSS', () => { @@ -286,10 +329,24 @@ describe( 'getResponsiveStateCSSRules', () => { }, }, } ); + + registerBlockType( 'test/state-image', { + apiVersion: 3, + title: 'State Image', + category: 'media', + attributes: {}, + edit: () => null, + save: () => null, + selectors: { + root: '.wp-block-test-state-image', + dimensions: '.wp-block-test-state-image img', + }, + } ); } ); afterEach( () => { unregisterBlockType( 'test/state-button' ); + unregisterBlockType( 'test/state-image' ); } ); it( 'generates media-query scoped root styles for viewport states', () => { @@ -325,6 +382,22 @@ describe( 'getResponsiveStateCSSRules', () => { ] ); } ); + it( 'outputs explicit fill object fit for viewport states', () => { + expect( + getResponsiveStateCSSRules( + { + mobile: { + dimensions: { objectFit: 'fill' }, + }, + }, + 'test/state-image', + '.wp-elements-1' + ) + ).toEqual( [ + '@media (width <= 480px){.wp-elements-1 img { object-fit: fill !important; }}', + ] ); + } ); + it( 'generates media-query scoped pseudo styles for viewport states', () => { expect( getResponsiveStateCSSRules( diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js index 4dbd42bd34c251..7bccf12d758386 100644 --- a/packages/block-editor/src/private-apis.js +++ b/packages/block-editor/src/private-apis.js @@ -16,6 +16,11 @@ import { PrivateListView } from './components/list-view'; import InspectorControlsLastItem from './components/inspector-controls/last-item'; import { useHasBlockToolbar } from './components/block-toolbar/use-has-block-toolbar'; import { cleanEmptyObject, usePrivateStyleOverride } from './hooks/utils'; +import { + getStyleForState, + isDefaultBlockStyleState, + setStyleForState, +} from './hooks/block-style-state'; import BlockQuickNavigation from './components/block-quick-navigation'; import { LayoutStyle } from './components/block-list/layout'; import BlockManager from './components/block-manager'; @@ -98,6 +103,9 @@ lock( privateApis, { InspectorControlsLastItem, useHasBlockToolbar, cleanEmptyObject, + getStyleForState, + isDefaultBlockStyleState, + setStyleForState, usePrivateStyleOverride, BlockQuickNavigation, LayoutStyle, diff --git a/packages/block-library/src/cover/edit/inspector-controls.js b/packages/block-library/src/cover/edit/inspector-controls.js index 4d554c14fa2788..786437984b0628 100644 --- a/packages/block-library/src/cover/edit/inspector-controls.js +++ b/packages/block-library/src/cover/edit/inspector-controls.js @@ -34,12 +34,21 @@ import { Link } from '@wordpress/ui'; import { COVER_MIN_HEIGHT, mediaPosition } from '../shared'; import { unlock } from '../../lock-unlock'; import { useToolsPanelDropdownMenuProps } from '../../utils/hooks'; +import { + getActiveDimensionValue, + getDimensionResetAttributes, + getDimensionUpdateAttributes, + getStyleStateKey, +} from '../../utils/style-state'; import { DEFAULT_MEDIA_SIZE_SLUG } from '../constants'; import PosterImage from '../../utils/poster-image'; -const { cleanEmptyObject, ResolutionTool, HTMLElementControl } = unlock( - blockEditorPrivateApis -); +const { + cleanEmptyObject, + isDefaultBlockStyleState, + ResolutionTool, + HTMLElementControl, +} = unlock( blockEditorPrivateApis ); function CoverHeightInput( { onChange, @@ -124,20 +133,45 @@ export default function CoverInspectorControls( { const sizeSlug = attributes.sizeSlug || DEFAULT_MEDIA_SIZE_SLUG; const { gradientValue, setGradient } = __experimentalUseGradient(); - const { imageSizes, hasSelectedStyleState } = useSelect( + const { imageSizes, selectedStyleState } = useSelect( ( select ) => { - const { - getSettings, - hasSelectedStyleState: hasSelectedBlockStyleState, - } = unlock( select( blockEditorStore ) ); + const { getSettings, getSelectedBlockStyleState } = unlock( + select( blockEditorStore ) + ); return { imageSizes: getSettings()?.imageSizes, - hasSelectedStyleState: hasSelectedBlockStyleState( clientId ), + selectedStyleState: getSelectedBlockStyleState( clientId ), }; }, [ clientId ] ); + const hasSelectedStyleState = + ! isDefaultBlockStyleState( selectedStyleState ); + const selectedStyleStateKey = getStyleStateKey( selectedStyleState ); + const stateMinHeight = getActiveDimensionValue( { + attributes, + selectedState: selectedStyleState, + hasSelectedStyleState, + attributeKey: 'minHeight', + styleKey: 'minHeight', + rootValue: undefined, + } ); + const [ stateMinHeightValue, stateMinHeightUnit ] = + parseQuantityAndUnitFromRawValue( stateMinHeight || '' ); + const activeMinHeight = hasSelectedStyleState + ? stateMinHeightValue + : minHeight; + const activeMinHeightUnit = hasSelectedStyleState + ? stateMinHeightUnit || minHeightUnit + : minHeightUnit; + const activeAspectRatio = getActiveDimensionValue( { + attributes, + selectedState: selectedStyleState, + hasSelectedStyleState, + attributeKey: 'aspectRatio', + rootValue: attributes?.style?.dimensions?.aspectRatio, + } ); const image = useSelect( ( select ) => @@ -203,6 +237,52 @@ export default function CoverInspectorControls( { const showOverlayControls = colorGradientSettings.hasColorsOrGradients && ! hasSelectedStyleState; + const setMinHeightAttributes = ( nextMinHeight, nextUnit ) => { + if ( hasSelectedStyleState ) { + setAttributes( + getDimensionUpdateAttributes( { + style: attributes.style, + selectedState: selectedStyleState, + hasSelectedStyleState, + nextDimensions: { + minHeight: + nextMinHeight === undefined + ? undefined + : `${ nextMinHeight }${ + nextUnit || activeMinHeightUnit || 'px' + }`, + aspectRatio: undefined, + }, + } ) + ); + return; + } + + setAttributes( { + minHeight: nextMinHeight, + style: cleanEmptyObject( { + ...attributes?.style, + dimensions: { + ...attributes?.style?.dimensions, + aspectRatio: undefined, // Reset aspect ratio when minHeight is set. + }, + } ), + } ); + }; + + const getResetMinHeightAttributes = ( attrs = attributes ) => { + return getDimensionResetAttributes( { + style: attrs.style, + selectedState: selectedStyleState, + hasSelectedStyleState, + keys: [ 'minHeight' ], + defaultAttributes: { + minHeight: undefined, + minHeightUnit: undefined, + }, + } ); + }; + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); return ( @@ -401,53 +481,43 @@ export default function CoverInspectorControls( { ) } - { ! hasSelectedStyleState && ( - - !! minHeight } - label={ __( 'Minimum height' ) } - onDeselect={ () => - setAttributes( { - minHeight: undefined, - minHeightUnit: undefined, - } ) + + !! activeMinHeight } + label={ __( 'Minimum height' ) } + onDeselect={ () => + setAttributes( getResetMinHeightAttributes() ) + } + resetAllFilter={ getResetMinHeightAttributes } + isShownByDefault + panelId={ clientId } + > + + setMinHeightAttributes( newMinHeight ) } - resetAllFilter={ () => ( { - minHeight: undefined, - minHeightUnit: undefined, - } ) } - isShownByDefault - panelId={ clientId } - > - - setAttributes( { - minHeight: newMinHeight, - style: cleanEmptyObject( { - ...attributes?.style, - dimensions: { - ...attributes?.style?.dimensions, - aspectRatio: undefined, // Reset aspect ratio when minHeight is set. - }, - } ), - } ) - } - onUnitChange={ ( nextUnit ) => - setAttributes( { - minHeightUnit: nextUnit, - } ) + onUnitChange={ ( nextUnit ) => { + if ( hasSelectedStyleState ) { + if ( activeMinHeight !== undefined ) { + setMinHeightAttributes( + activeMinHeight, + nextUnit + ); + } + return; } - /> - - - ) } + + setAttributes( { + minHeightUnit: nextUnit, + } ); + } } + /> + + { + if ( ! isSingleSelected ) { + return undefined; + } + const { getSelectedBlockStyleState } = unlock( + select( blockEditorStore ) + ); + return getSelectedBlockStyleState( clientId ); + }, + [ clientId, isSingleSelected ] + ); + const hasSelectedStyleState = + ! isDefaultBlockStyleState( selectedStyleState ); + const selectedStyleStateKey = getStyleStateKey( selectedStyleState ); + const activeWidth = getActiveDimensionValue( { + attributes, + selectedState: selectedStyleState, + hasSelectedStyleState, + attributeKey: 'width', + } ); + const activeHeight = getActiveDimensionValue( { + attributes, + selectedState: selectedStyleState, + hasSelectedStyleState, + attributeKey: 'height', + } ); + const activeAspectRatio = getActiveDimensionValue( { + attributes, + selectedState: selectedStyleState, + hasSelectedStyleState, + attributeKey: 'aspectRatio', + } ); + const activeScale = getActiveDimensionValue( { + attributes, + selectedState: selectedStyleState, + hasSelectedStyleState, + attributeKey: 'scale', + styleKey: 'objectFit', + } ); + const setDimensionAttributes = ( nextDimensions ) => { + setAttributes( + getDimensionUpdateAttributes( { + style: attributes.style, + selectedState: selectedStyleState, + hasSelectedStyleState, + nextDimensions, + dimensionKeyMap: { scale: 'objectFit' }, + } ) + ); + }; + const dimensionsControl = showDimensionsControls && ( SIZED_LAYOUTS.includes( parentLayoutType ) ? ( { - setAttributes( { + setDimensionAttributes( { aspectRatio: newAspectRatio, scale: 'cover', } ); @@ -690,26 +752,29 @@ export default function 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( { + setDimensionAttributes( { // CSS includes `height: auto`, but we need // `width: auto` to fix the aspect ratio when // only height is set due to the width and // height attributes set via the server. width: ! newWidth && newHeight ? 'auto' : newWidth, height: newHeight, - scale: newScale, aspectRatio: newAspectRatio, + scale: newScale, } ); } } defaultScale="cover" @@ -742,14 +807,11 @@ export default function Image( { lockTitleControls = false, lockTitleControlsMessage, hideCaptionControls = false, - hasSelectedStyleState = false, } = useSelect( ( select ) => { if ( ! isSingleSelected ) { return {}; } - const { hasSelectedStyleState: hasSelectedBlockStyleState } = - unlock( select( blockEditorStore ) ); const { url: urlBinding, alt: altBinding, @@ -767,7 +829,6 @@ export default function Image( { titleBinding?.source ); return { - hasSelectedStyleState: hasSelectedBlockStyleState( clientId ), lockUrlControls: !! urlBinding && ! urlBindingSource?.canUserEditValue?.( { @@ -812,7 +873,6 @@ export default function Image( { }, [ arePatternOverridesEnabled, - clientId, context, isSingleSelected, metadata?.bindings, @@ -1018,47 +1078,52 @@ export default function Image( { ) } - { ! hasSelectedStyleState && ( - ( { - ...attrs, - aspectRatio: undefined, - width: undefined, - height: undefined, - scale: undefined, - focalPoint: undefined, - } ) } - > - { dimensionsControl } - { url && scale && ( - { + return getDimensionResetAttributes( { + attributes: attrs, + selectedState: selectedStyleState, + hasSelectedStyleState, + keys: [ 'aspectRatio', 'height', 'objectFit', 'width' ], + defaultAttributes: { + aspectRatio: undefined, + width: undefined, + height: undefined, + scale: undefined, + focalPoint: undefined, + }, + } ); + } } + > + { dimensionsControl } + { ! hasSelectedStyleState && url && scale && ( + !! focalPoint } + onDeselect={ () => + setAttributes( { + focalPoint: undefined, + } ) + } + panelId={ clientId } + > + !! focalPoint } - onDeselect={ () => + url={ url } + value={ focalPoint } + onDragStart={ imperativeFocalPointPreview } + onDrag={ imperativeFocalPointPreview } + onChange={ ( newFocalPoint ) => setAttributes( { - focalPoint: undefined, + focalPoint: newFocalPoint, } ) } - panelId={ clientId } - > - - setAttributes( { - focalPoint: newFocalPoint, - } ) - } - /> - - ) } - - ) } + /> + + ) } + { !! imageSizeOptions.length && ( - - - - -); +/** + * Internal dependencies + */ +import { + getActiveDimensionValue, + getDimensionUpdateAttributes, + getStyleStateKey, +} from '../utils/style-state'; +import { unlock } from '../lock-unlock'; + +const { DimensionsTool } = unlock( blockEditorPrivateApis ); const DEFAULT_SCALE = 'cover'; +const DIMENSION_KEYS = [ 'aspectRatio', 'width', 'height', 'scale' ]; -const scaleHelp = { - cover: __( - 'Image is scaled and cropped to fill the entire space without being distorted.' - ), - contain: __( - 'Image is scaled to fill the space without clipping nor distorting.' - ), - fill: __( - 'Image will be stretched and distorted to completely fill the space.' - ), -}; +const scaleOptions = [ + { + value: 'cover', + label: _x( 'Cover', 'Scale option for Image dimension control' ), + help: __( + 'Image is scaled and cropped to fill the entire space without being distorted.' + ), + }, + { + value: 'contain', + label: _x( 'Contain', 'Scale option for Image dimension control' ), + help: __( + 'Image is scaled to fill the space without clipping nor distorting.' + ), + }, + { + value: 'fill', + label: _x( 'Fill', 'Scale option for Image dimension control' ), + help: __( + 'Image will be stretched and distorted to completely fill the space.' + ), + }, +]; const DimensionControls = ( { clientId, - attributes: { aspectRatio, width, height, scale }, + attributes, setAttributes, + selectedStyleState, + hasSelectedStyleState = false, } ) => { - const [ availableUnits, defaultRatios, themeRatios, showDefaultRatios ] = - useSettings( - 'spacing.units', - 'dimensions.aspectRatios.default', - 'dimensions.aspectRatios.theme', - 'dimensions.defaultAspectRatios' - ); + const { style } = attributes; + const selectedStyleStateKey = getStyleStateKey( selectedStyleState ); + const activeAspectRatio = getActiveDimensionValue( { + attributes, + selectedState: selectedStyleState, + hasSelectedStyleState, + attributeKey: 'aspectRatio', + } ); + const activeWidth = getActiveDimensionValue( { + attributes, + selectedState: selectedStyleState, + hasSelectedStyleState, + attributeKey: 'width', + } ); + const activeHeight = getActiveDimensionValue( { + attributes, + selectedState: selectedStyleState, + hasSelectedStyleState, + attributeKey: 'height', + } ); + const activeScale = getActiveDimensionValue( { + attributes, + selectedState: selectedStyleState, + hasSelectedStyleState, + attributeKey: 'scale', + styleKey: 'objectFit', + } ); + + const [ availableUnits ] = useSettings( 'spacing.units' ); const units = useCustomUnits( { availableUnits: availableUnits || [ 'px', '%', 'vw', 'em', 'rem' ], } ); - const onDimensionChange = ( dimension, nextValue ) => { - const parsedValue = parseFloat( nextValue ); - /** - * If we have no value set and we change the unit, - * we don't want to set the attribute, as it would - * end up having the unit as value without any number. - */ - if ( isNaN( parsedValue ) && nextValue ) { - return; - } - setAttributes( { - [ dimension ]: parsedValue < 0 ? '0' : nextValue, - } ); - }; - const scaleLabel = _x( 'Scale', 'Image scaling options' ); - - const showScaleControl = - height || ( aspectRatio && aspectRatio !== 'auto' ); + const setDimensionAttributes = ( nextDimensions ) => { + const nextImageDimensions = { + ...nextDimensions, + width: + ! nextDimensions.width && nextDimensions.height + ? 'auto' + : nextDimensions.width, + }; - const themeOptions = themeRatios?.map( ( { name, ratio } ) => ( { - label: name, - value: ratio, - } ) ); - - const defaultOptions = defaultRatios?.map( ( { name, ratio } ) => ( { - label: name, - value: ratio, - } ) ); - - const aspectRatioOptions = [ - { - label: _x( - 'Original', - 'Aspect ratio option for dimensions control' - ), - value: 'auto', - }, - ...( showDefaultRatios ? defaultOptions : [] ), - ...( themeOptions ? themeOptions : [] ), - ]; + setAttributes( + getDimensionUpdateAttributes( { + style, + selectedState: selectedStyleState, + hasSelectedStyleState, + nextDimensions: nextImageDimensions, + dimensionKeyMap: { scale: 'objectFit' }, + dimensionKeys: DIMENSION_KEYS, + } ) + ); + }; return ( - <> - !! aspectRatio } - label={ __( 'Aspect ratio' ) } - onDeselect={ () => setAttributes( { aspectRatio: undefined } ) } - resetAllFilter={ () => ( { - aspectRatio: undefined, - } ) } - isShownByDefault - panelId={ clientId } - > - - setAttributes( { aspectRatio: nextAspectRatio } ) - } - /> - - !! height } - label={ __( 'Height' ) } - onDeselect={ () => setAttributes( { height: undefined } ) } - resetAllFilter={ () => ( { - height: undefined, - } ) } - isShownByDefault - panelId={ clientId } - > - - onDimensionChange( 'height', nextHeight ) - } - units={ units } - /> - - !! width } - label={ __( 'Width' ) } - onDeselect={ () => setAttributes( { width: undefined } ) } - resetAllFilter={ () => ( { - width: undefined, - } ) } - isShownByDefault - panelId={ clientId } - > - - onDimensionChange( 'width', nextWidth ) - } - units={ units } - /> - - { showScaleControl && ( - !! scale && scale !== DEFAULT_SCALE } - label={ scaleLabel } - onDeselect={ () => - setAttributes( { - scale: DEFAULT_SCALE, - } ) - } - resetAllFilter={ () => ( { - scale: DEFAULT_SCALE, - } ) } - isShownByDefault - panelId={ clientId } - > - - setAttributes( { - scale: value, - } ) - } - isBlock - > - { SCALE_OPTIONS } - - - ) } - + ); }; diff --git a/packages/block-library/src/post-featured-image/edit.js b/packages/block-library/src/post-featured-image/edit.js index 71ffde9e591aa6..514a25c463d2ad 100644 --- a/packages/block-library/src/post-featured-image/edit.js +++ b/packages/block-library/src/post-featured-image/edit.js @@ -49,9 +49,15 @@ import OverlayControls from './overlay-controls'; import Overlay from './overlay'; import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; import { unlock } from '../lock-unlock'; +import { getDimensionResetAttributes } from '../utils/style-state'; const ALLOWED_MEDIA_TYPES = [ 'image' ]; -const { ResolutionTool } = unlock( blockEditorPrivateApis ); +const { isDefaultBlockStyleState, ResolutionTool } = unlock( + blockEditorPrivateApis +); + +const hasDimensionValue = ( value ) => + value !== undefined && value !== null && value !== ''; const DEFAULT_MEDIA_SIZE_SLUG = 'full'; function FeaturedImageResolutionTool( { image, value, onChange } ) { @@ -137,12 +143,13 @@ export default function PostFeaturedImageEdit( { return imageId; }, [ storedFeaturedImage, useFirstImageFromPost, postContent ] ); - const { media, postType, postPermalink, hasSelectedStyleState } = useSelect( + const { media, postType, postPermalink, selectedStyleState } = useSelect( ( select ) => { const { getEntityRecord, getPostType, getEditedEntityRecord } = select( coreStore ); - const { hasSelectedStyleState: hasSelectedBlockStyleState } = - unlock( select( blockEditorStore ) ); + const { getSelectedBlockStyleState } = unlock( + select( blockEditorStore ) + ); return { media: featuredImage && @@ -155,18 +162,19 @@ export default function PostFeaturedImageEdit( { postTypeSlug, postId )?.link, - hasSelectedStyleState: hasSelectedBlockStyleState( clientId ), + selectedStyleState: getSelectedBlockStyleState( clientId ), }; }, [ clientId, featuredImage, postTypeSlug, postId ] ); + const hasSelectedStyleState = + ! isDefaultBlockStyleState( selectedStyleState ); const mediaUrl = media?.media_details?.sizes?.[ sizeSlug ]?.source_url || media?.source_url; const blockProps = useBlockProps( { - style: { width, height, aspectRatio }, className: clsx( { 'is-transient': temporaryURL, } ), @@ -184,8 +192,13 @@ export default function PostFeaturedImageEdit( { ) } withIllustration style={ { - height: !! aspectRatio && '100%', - width: !! aspectRatio && '100%', + aspectRatio, + height: hasDimensionValue( height ) + ? height + : hasDimensionValue( width ) && 'auto', + width: hasDimensionValue( width ) + ? width + : !! aspectRatio && '100%', ...borderProps.style, ...shadowProps.style, } } @@ -240,16 +253,32 @@ export default function PostFeaturedImageEdit( { clientId={ clientId } /> - { ! hasSelectedStyleState && ( - - - - ) } + { + return getDimensionResetAttributes( { + attributes: attrs, + selectedState: selectedStyleState, + hasSelectedStyleState, + keys: [ 'aspectRatio', 'height', 'objectFit', 'width' ], + defaultAttributes: { + aspectRatio: undefined, + height: undefined, + scale: undefined, + width: undefined, + }, + } ); + } } + > + + { ( featuredImage || isDescendentOfQueryLoop || ! postId ) && ( $attributes['style']['shadow'] ) ); @@ -90,6 +100,13 @@ function render_block_core_post_featured_image( $attributes, $content, $block ) foreach ( $processor->get_attribute_names_with_prefix( '' ) as $name ) { $tag_html->set_attribute( $name, $processor->get_attribute( $name ) ); } + if ( ! empty( $attr['style'] ) ) { + $existing_style = $tag_html->get_attribute( 'style' ); + $style = is_string( $existing_style ) && '' !== $existing_style + ? rtrim( $existing_style, ';' ) . ';' . $attr['style'] + : $attr['style']; + $tag_html->set_attribute( 'style', $style ); + } $featured_image = $tag_html->get_updated_html(); } } @@ -101,13 +118,11 @@ function render_block_core_post_featured_image( $attributes, $content, $block ) if ( $is_link ) { $link_target = $attributes['linkTarget']; $rel = ! empty( $attributes['rel'] ) ? 'rel="' . esc_attr( $attributes['rel'] ) . '"' : ''; - $height = ! empty( $attributes['height'] ) ? 'style="' . esc_attr( safecss_filter_attr( 'height:' . $attributes['height'] ) ) . '"' : ''; $featured_image = sprintf( - '%5$s%6$s', + '%4$s%5$s', esc_url( get_the_permalink( $post_ID ) ), esc_attr( $link_target ), $rel, - $height, $featured_image, $overlay_markup ); @@ -115,20 +130,7 @@ function render_block_core_post_featured_image( $attributes, $content, $block ) $featured_image = $featured_image . $overlay_markup; } - $aspect_ratio = ! empty( $attributes['aspectRatio'] ) - ? esc_attr( safecss_filter_attr( 'aspect-ratio:' . $attributes['aspectRatio'] ) ) . ';' - : ''; - $width = ! empty( $attributes['width'] ) - ? esc_attr( safecss_filter_attr( 'width:' . $attributes['width'] ) ) . ';' - : ''; - $height = ! empty( $attributes['height'] ) - ? esc_attr( safecss_filter_attr( 'height:' . $attributes['height'] ) ) . ';' - : ''; - if ( ! $height && ! $width && ! $aspect_ratio ) { - $wrapper_attributes = get_block_wrapper_attributes(); - } else { - $wrapper_attributes = get_block_wrapper_attributes( array( 'style' => $aspect_ratio . $width . $height ) ); - } + $wrapper_attributes = get_block_wrapper_attributes(); return "
{$featured_image}
"; } diff --git a/packages/block-library/src/utils/style-state.js b/packages/block-library/src/utils/style-state.js new file mode 100644 index 00000000000000..56584b26144726 --- /dev/null +++ b/packages/block-library/src/utils/style-state.js @@ -0,0 +1,151 @@ +/** + * WordPress dependencies + */ +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { unlock } from '../lock-unlock'; + +const { cleanEmptyObject, getStyleForState, setStyleForState } = unlock( + blockEditorPrivateApis +); + +function getStateStyle( style, selectedState ) { + return getStyleForState( style, selectedState ) || {}; +} + +function getMappedDimensions( dimensions, dimensionKeyMap = {} ) { + return Object.fromEntries( + Object.entries( dimensions ).map( ( [ key, value ] ) => [ + dimensionKeyMap[ key ] || key, + value, + ] ) + ); +} + +function getControlledDimensions( dimensions, dimensionKeys ) { + if ( ! dimensionKeys ) { + return dimensions; + } + + return Object.fromEntries( + dimensionKeys.map( ( key ) => [ key, dimensions[ key ] ] ) + ); +} + +export function getStyleStateKey( selectedState ) { + return [ + selectedState?.viewport || 'default', + selectedState?.pseudo || 'default', + ].join( ':' ); +} + +export function getStateDimensions( style, selectedState ) { + return getStateStyle( style, selectedState )?.dimensions || {}; +} + +export function getActiveDimensionValue( options = {} ) { + const { + attributes = {}, + style = attributes?.style, + selectedState, + hasSelectedStyleState, + attributeKey, + styleKey = attributeKey, + rootValue, + } = options; + + if ( hasSelectedStyleState ) { + return getStateDimensions( style, selectedState )?.[ styleKey ]; + } + + if ( Object.hasOwn( options, 'rootValue' ) ) { + return rootValue; + } + + return attributes?.[ attributeKey ]; +} + +export function setStateDimensions( style, selectedState, nextDimensions ) { + const stateStyle = getStateStyle( style, selectedState ); + + return setStyleForState( + style, + selectedState, + cleanEmptyObject( { + ...stateStyle, + dimensions: cleanEmptyObject( { + ...stateStyle?.dimensions, + ...nextDimensions, + } ), + } ) + ); +} + +export function getDimensionUpdateAttributes( { + style, + selectedState, + hasSelectedStyleState, + nextDimensions, + dimensionKeyMap, + dimensionKeys, +} ) { + const controlledDimensions = getControlledDimensions( + nextDimensions, + dimensionKeys + ); + + if ( ! hasSelectedStyleState ) { + return controlledDimensions; + } + + return { + style: setStateDimensions( + style, + selectedState, + getMappedDimensions( controlledDimensions, dimensionKeyMap ) + ), + }; +} + +export function resetDimensions( style, keys ) { + const dimensionsReset = Object.fromEntries( + keys.map( ( key ) => [ key, undefined ] ) + ); + + return cleanEmptyObject( { + ...style, + dimensions: cleanEmptyObject( { + ...style?.dimensions, + ...dimensionsReset, + } ), + } ); +} + +export function resetStateDimensions( style, selectedState, keys ) { + return setStyleForState( + style, + selectedState, + resetDimensions( getStateStyle( style, selectedState ), keys ) + ); +} + +export function getDimensionResetAttributes( { + attributes = {}, + style = attributes?.style, + selectedState, + hasSelectedStyleState, + keys, + defaultAttributes = {}, +} ) { + return { + ...( hasSelectedStyleState + ? {} + : { ...attributes, ...defaultAttributes } ), + style: hasSelectedStyleState + ? resetStateDimensions( style, selectedState, keys ) + : resetDimensions( style, keys ), + }; +} diff --git a/packages/block-library/src/utils/test/style-state.js b/packages/block-library/src/utils/test/style-state.js new file mode 100644 index 00000000000000..f9625e85cced13 --- /dev/null +++ b/packages/block-library/src/utils/test/style-state.js @@ -0,0 +1,307 @@ +/** + * Internal dependencies + */ +import { + getActiveDimensionValue, + getDimensionResetAttributes, + getDimensionUpdateAttributes, + getStyleStateKey, + resetDimensions, + resetStateDimensions, + setStateDimensions, +} from '../style-state'; + +describe( 'style state dimension utilities', () => { + it( 'resets root dimensions without changing viewport dimensions', () => { + const style = { + dimensions: { + aspectRatio: '1', + minHeight: '40px', + }, + mobile: { + dimensions: { + aspectRatio: '2', + }, + }, + }; + + expect( resetDimensions( style, [ 'aspectRatio' ] ) ).toEqual( { + dimensions: { + minHeight: '40px', + }, + mobile: { + dimensions: { + aspectRatio: '2', + }, + }, + } ); + } ); + + it( 'resets dimensions only for the selected viewport state', () => { + const style = { + dimensions: { + aspectRatio: '1', + }, + mobile: { + dimensions: { + aspectRatio: '2', + width: '200px', + }, + }, + tablet: { + dimensions: { + aspectRatio: '3', + }, + }, + }; + + expect( + resetStateDimensions( + style, + { viewport: 'mobile', pseudo: 'default' }, + [ 'aspectRatio' ] + ) + ).toEqual( { + dimensions: { + aspectRatio: '1', + }, + mobile: { + dimensions: { + width: '200px', + }, + }, + tablet: { + dimensions: { + aspectRatio: '3', + }, + }, + } ); + } ); + + it( 'sets dimensions only for the selected viewport state', () => { + const style = { + mobile: { + dimensions: { + width: '200px', + }, + }, + tablet: { + dimensions: { + width: '300px', + }, + }, + }; + + expect( + setStateDimensions( + style, + { viewport: 'mobile', pseudo: 'default' }, + { height: '100px' } + ) + ).toEqual( { + mobile: { + dimensions: { + height: '100px', + width: '200px', + }, + }, + tablet: { + dimensions: { + width: '300px', + }, + }, + } ); + } ); + + it( 'generates a stable key for selected style states', () => { + expect( + getStyleStateKey( { viewport: 'mobile', pseudo: ':hover' } ) + ).toBe( 'mobile::hover' ); + expect( getStyleStateKey( undefined ) ).toBe( 'default:default' ); + } ); + + it( 'reads root attribute dimensions for the default state', () => { + expect( + getActiveDimensionValue( { + attributes: { + width: '200px', + }, + attributeKey: 'width', + hasSelectedStyleState: false, + } ) + ).toBe( '200px' ); + } ); + + it( 'reads mapped dimensions for selected style states', () => { + expect( + getActiveDimensionValue( { + attributes: { + scale: 'cover', + style: { + mobile: { + dimensions: { + objectFit: 'contain', + }, + }, + }, + }, + selectedState: { viewport: 'mobile', pseudo: 'default' }, + hasSelectedStyleState: true, + attributeKey: 'scale', + styleKey: 'objectFit', + } ) + ).toBe( 'contain' ); + } ); + + it( 'maps root dimension attributes to selected style state dimensions', () => { + expect( + getDimensionUpdateAttributes( { + style: { + mobile: { + dimensions: { + width: '200px', + }, + }, + }, + selectedState: { viewport: 'mobile', pseudo: 'default' }, + hasSelectedStyleState: true, + nextDimensions: { + scale: 'contain', + }, + dimensionKeyMap: { + scale: 'objectFit', + }, + } ) + ).toEqual( { + style: { + mobile: { + dimensions: { + objectFit: 'contain', + width: '200px', + }, + }, + }, + } ); + } ); + + it( 'clears omitted controlled root dimension attributes', () => { + expect( + getDimensionUpdateAttributes( { + hasSelectedStyleState: false, + nextDimensions: { + aspectRatio: '16/9', + width: '200px', + scale: 'cover', + }, + dimensionKeys: [ 'aspectRatio', 'width', 'height', 'scale' ], + } ) + ).toEqual( { + aspectRatio: '16/9', + width: '200px', + height: undefined, + scale: 'cover', + } ); + } ); + + it( 'clears omitted controlled selected style state dimensions', () => { + expect( + getDimensionUpdateAttributes( { + style: { + mobile: { + dimensions: { + height: '100px', + width: '200px', + }, + }, + }, + selectedState: { viewport: 'mobile', pseudo: 'default' }, + hasSelectedStyleState: true, + nextDimensions: { + aspectRatio: '16/9', + width: '200px', + scale: 'cover', + }, + dimensionKeyMap: { + scale: 'objectFit', + }, + dimensionKeys: [ 'aspectRatio', 'width', 'height', 'scale' ], + } ) + ).toEqual( { + style: { + mobile: { + dimensions: { + aspectRatio: '16/9', + objectFit: 'cover', + width: '200px', + }, + }, + }, + } ); + } ); + + it( 'resets selected style state dimensions without root attributes', () => { + expect( + getDimensionResetAttributes( { + attributes: { + width: '200px', + style: { + dimensions: { + width: '300px', + }, + mobile: { + dimensions: { + width: '400px', + }, + }, + }, + }, + selectedState: { viewport: 'mobile', pseudo: 'default' }, + hasSelectedStyleState: true, + keys: [ 'width' ], + defaultAttributes: { + width: undefined, + }, + } ) + ).toEqual( { + style: { + dimensions: { + width: '300px', + }, + }, + } ); + } ); + + it( 'resets default dimensions and root attributes', () => { + expect( + getDimensionResetAttributes( { + attributes: { + width: '200px', + style: { + dimensions: { + width: '300px', + }, + mobile: { + dimensions: { + width: '400px', + }, + }, + }, + }, + hasSelectedStyleState: false, + keys: [ 'width' ], + defaultAttributes: { + width: undefined, + }, + } ) + ).toEqual( { + width: undefined, + style: { + mobile: { + dimensions: { + width: '400px', + }, + }, + }, + } ); + } ); +} ); diff --git a/packages/style-engine/src/class-wp-style-engine.php b/packages/style-engine/src/class-wp-style-engine.php index 447bc24140848b..f46099cf5cfd84 100644 --- a/packages/style-engine/src/class-wp-style-engine.php +++ b/packages/style-engine/src/class-wp-style-engine.php @@ -237,6 +237,12 @@ final class WP_Style_Engine { 'dimension' => '--wp--preset--dimension--$slug', ), ), + 'objectFit' => array( + 'property_keys' => array( + 'default' => 'object-fit', + ), + 'path' => array( 'dimensions', 'objectFit' ), + ), 'width' => array( 'property_keys' => array( 'default' => 'width', diff --git a/packages/style-engine/src/styles/dimensions/index.ts b/packages/style-engine/src/styles/dimensions/index.ts index 3be3e318892c58..29be1e8e54580a 100644 --- a/packages/style-engine/src/styles/dimensions/index.ts +++ b/packages/style-engine/src/styles/dimensions/index.ts @@ -64,4 +64,16 @@ const width = { }, }; -export default [ height, minHeight, minWidth, aspectRatio, width ]; +const objectFit = { + name: 'objectFit', + generate: ( style: Style, options: StyleOptions ) => { + return generateRule( + style, + options, + [ 'dimensions', 'objectFit' ], + 'objectFit' + ); + }, +}; + +export default [ height, minHeight, minWidth, aspectRatio, width, objectFit ]; diff --git a/packages/style-engine/src/test/index.js b/packages/style-engine/src/test/index.js index 5135b06d7c2330..4b15c81ed303e9 100644 --- a/packages/style-engine/src/test/index.js +++ b/packages/style-engine/src/test/index.js @@ -57,6 +57,7 @@ describe( 'generate', () => { dimensions: { minHeight: '50vh', minWidth: '25vw', + objectFit: 'cover', }, spacing: { padding: { top: '10px', bottom: '5px' }, @@ -90,7 +91,7 @@ describe( 'generate', () => { } ) ).toEqual( - ".some-selector { color: #cccccc; background: linear-gradient(135deg,rgb(255,203,112) 0%,rgb(33,32,33) 42%,rgb(65,88,208) 100%); background-color: #111111; min-height: 50vh; min-width: 25vw; outline-color: red; outline-style: dashed; outline-offset: 2px; outline-width: 4px; margin-top: 11px; margin-right: 12px; margin-bottom: 13px; margin-left: 14px; padding-top: 10px; padding-bottom: 5px; font-family: 'Helvetica Neue',sans-serif; font-size: 2.2rem; font-style: italic; font-weight: 800; letter-spacing: 12px; line-height: 3.3; column-count: 2; text-decoration: line-through; text-transform: uppercase; }" + ".some-selector { color: #cccccc; background: linear-gradient(135deg,rgb(255,203,112) 0%,rgb(33,32,33) 42%,rgb(65,88,208) 100%); background-color: #111111; min-height: 50vh; min-width: 25vw; object-fit: cover; outline-color: red; outline-style: dashed; outline-offset: 2px; outline-width: 4px; margin-top: 11px; margin-right: 12px; margin-bottom: 13px; margin-left: 14px; padding-top: 10px; padding-bottom: 5px; font-family: 'Helvetica Neue',sans-serif; font-size: 2.2rem; font-style: italic; font-weight: 800; letter-spacing: 12px; line-height: 3.3; column-count: 2; text-decoration: line-through; text-transform: uppercase; }" ); } ); diff --git a/packages/style-engine/src/types.ts b/packages/style-engine/src/types.ts index e434053ba0820b..45428d5d2d088a 100644 --- a/packages/style-engine/src/types.ts +++ b/packages/style-engine/src/types.ts @@ -52,6 +52,7 @@ export interface Style { height?: CSSProperties[ 'height' ]; minHeight?: CSSProperties[ 'minHeight' ]; minWidth?: CSSProperties[ 'minWidth' ]; + objectFit?: CSSProperties[ 'objectFit' ]; width?: CSSProperties[ 'width' ]; }; spacing?: { diff --git a/phpunit/block-supports/dimensions-test.php b/phpunit/block-supports/dimensions-test.php index 9620f94bdf9384..8816747cfaf0f2 100644 --- a/phpunit/block-supports/dimensions-test.php +++ b/phpunit/block-supports/dimensions-test.php @@ -358,4 +358,41 @@ public function test_min_width_with_individual_skipped_serialization_block_suppo $this->assertSame( $expected, $actual ); } + + public function test_default_aspect_ratio_does_not_unset_height_styles() { + $this->test_block_name = 'test/default-aspect-ratio-does-not-unset-height-styles'; + register_block_type( + $this->test_block_name, + array( + 'api_version' => 3, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( + 'dimensions' => array( + 'aspectRatio' => true, + ), + ), + ) + ); + + $actual = gutenberg_render_dimensions_support( + '
Hello
', + array( + 'blockName' => $this->test_block_name, + 'attrs' => array( + 'style' => array( + 'dimensions' => array( + 'aspectRatio' => 'auto', + ), + ), + ), + ) + ); + + $this->assertStringNotContainsString( 'height:unset', $actual ); + $this->assertStringNotContainsString( 'min-height:unset', $actual ); + } } diff --git a/phpunit/block-supports/states-test.php b/phpunit/block-supports/states-test.php index ece71c396669a6..fea2e6395d4576 100644 --- a/phpunit/block-supports/states-test.php +++ b/phpunit/block-supports/states-test.php @@ -124,6 +124,81 @@ public function test_preserves_authored_border_style_declarations() { ); } + /** + * Tests that fallback dimension styles are added for aspect ratio. + * + * @covers ::gutenberg_get_state_style_with_fallback_dimension_styles + */ + public function test_adds_fallback_dimension_styles_for_aspect_ratio() { + $actual = gutenberg_get_state_style_with_fallback_dimension_styles( + array( + 'dimensions' => array( + 'aspectRatio' => '16/9', + ), + ) + ); + + $this->assertSame( + array( + 'dimensions' => array( + 'aspectRatio' => '16/9', + 'minHeight' => 'unset', + 'height' => 'unset', + ), + ), + $actual + ); + } + + /** + * Tests that fallback dimension styles are not added for the default aspect ratio. + * + * @covers ::gutenberg_get_state_style_with_fallback_dimension_styles + */ + public function test_does_not_add_fallback_dimension_styles_for_default_aspect_ratio() { + $actual = gutenberg_get_state_style_with_fallback_dimension_styles( + array( + 'dimensions' => array( + 'aspectRatio' => 'auto', + ), + ) + ); + + $this->assertSame( + array( + 'dimensions' => array( + 'aspectRatio' => 'auto', + ), + ), + $actual + ); + } + + /** + * Tests that fallback aspectRatio styles are added for height. + * + * @covers ::gutenberg_get_state_style_with_fallback_dimension_styles + */ + public function test_adds_fallback_aspect_ratio_style_for_height() { + $actual = gutenberg_get_state_style_with_fallback_dimension_styles( + array( + 'dimensions' => array( + 'height' => '20rem', + ), + ) + ); + + $this->assertSame( + array( + 'dimensions' => array( + 'height' => '20rem', + 'aspectRatio' => 'unset', + ), + ), + $actual + ); + } + /** * Tests that modifier classes on the first compound selector are preserved * when state selectors are scoped to the block wrapper.