diff --git a/lib/block-supports/spacing.php b/lib/block-supports/spacing.php index 78f5b59f90fb0..ca7b77f43864b 100644 --- a/lib/block-supports/spacing.php +++ b/lib/block-supports/spacing.php @@ -90,6 +90,57 @@ function gutenberg_skip_spacing_serialization( $block_type ) { $spacing_support['__experimentalSkipSerialization']; } + +/** + * Renders the spacing gap support to the block wrapper, to ensure + * that the CSS variable is rendered in all environments. + * + * @param string $block_content Rendered block content. + * @param array $block Block object. + * @return string Filtered block content. + */ +function gutenberg_render_spacing_gap_support( $block_content, $block ) { + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + $has_gap_support = gutenberg_block_has_support( $block_type, array( 'spacing', 'blockGap' ), false ); + if ( ! $has_gap_support || ! isset( $block['attrs']['style']['spacing']['blockGap'] ) ) { + return $block_content; + } + + $gap_value = $block['attrs']['style']['spacing']['blockGap']; + + // Skip if gap value contains unsupported characters. + // Regex for CSS value borrowed from `safecss_filter_attr`, and used here + // because we only want to match against the value, not the CSS attribute. + if ( preg_match( '%[\\\(&=}]|/\*%', $gap_value ) ) { + return $block_content; + } + + $style = sprintf( + '--wp--style--block-gap: %s', + esc_attr( $gap_value ) + ); + + // Attempt to update an existing style attribute on the wrapper element. + $injected_style = preg_replace( + '/^([^>.]+?)(' . preg_quote( 'style="', '/' ) . ')(?=.+?>)/', + '$1$2' . $style . '; ', + $block_content, + 1 + ); + + // If there is no existing style attribute, add one to the wrapper element. + if ( $injected_style === $block_content ) { + $injected_style = preg_replace( + '/<([a-zA-Z0-9]+)([ >])/', + '<$1 style="' . $style . '"$2', + $block_content, + 1 + ); + }; + + return $injected_style; +} + // Register the block support. WP_Block_Supports::get_instance()->register( 'spacing', @@ -98,3 +149,5 @@ function gutenberg_skip_spacing_serialization( $block_type ) { 'apply' => 'gutenberg_apply_spacing_support', ) ); + +add_filter( 'render_block', 'gutenberg_render_spacing_gap_support', 10, 2 ); diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index ee12dad41fa08..474294caa2a21 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -99,6 +99,7 @@ class WP_Theme_JSON_Gutenberg { 'wideSize' => null, ), 'spacing' => array( + 'blockGap' => null, 'customMargin' => null, 'customPadding' => null, 'units' => null, diff --git a/lib/theme.json b/lib/theme.json index 6b52969009bee..5d952b381ef93 100644 --- a/lib/theme.json +++ b/lib/theme.json @@ -211,6 +211,7 @@ ] }, "spacing": { + "blockGap": false, "customMargin": false, "customPadding": false, "units": [ "px", "em", "rem", "vh", "vw", "%" ] diff --git a/packages/block-editor/src/hooks/dimensions.js b/packages/block-editor/src/hooks/dimensions.js index 697955670b497..d85ce67104b39 100644 --- a/packages/block-editor/src/hooks/dimensions.js +++ b/packages/block-editor/src/hooks/dimensions.js @@ -13,6 +13,13 @@ import { getBlockSupport } from '@wordpress/blocks'; * Internal dependencies */ import InspectorControls from '../components/inspector-controls'; +import { + GapEdit, + hasGapSupport, + hasGapValue, + resetGap, + useIsGapDisabled, +} from './gap'; import { MarginEdit, hasMarginSupport, @@ -41,6 +48,7 @@ export const AXIAL_SIDES = [ 'vertical', 'horizontal' ]; * @return {WPElement} Inspector controls for spacing support features. */ export function DimensionsPanel( props ) { + const isGapDisabled = useIsGapDisabled( props ); const isPaddingDisabled = useIsPaddingDisabled( props ); const isMarginDisabled = useIsMarginDisabled( props ); const isDisabled = useIsDimensionsDisabled( props ); @@ -64,6 +72,7 @@ export function DimensionsPanel( props ) { ...style, spacing: { ...style?.spacing, + blockGap: undefined, margin: undefined, padding: undefined, }, @@ -98,6 +107,17 @@ export function DimensionsPanel( props ) { ) } + { ! isGapDisabled && ( + hasGapValue( props ) } + label={ __( 'Block gap' ) } + onDeselect={ () => resetGap( props ) } + isShownByDefault={ defaultSpacingControls?.blockGap } + > + + + ) } ); @@ -115,7 +135,11 @@ export function hasDimensionsSupport( blockName ) { return false; } - return hasPaddingSupport( blockName ) || hasMarginSupport( blockName ); + return ( + hasGapSupport( blockName ) || + hasPaddingSupport( blockName ) || + hasMarginSupport( blockName ) + ); } /** @@ -126,10 +150,11 @@ export function hasDimensionsSupport( blockName ) { * @return {boolean} If spacing support is completely disabled. */ const useIsDimensionsDisabled = ( props = {} ) => { + const gapDisabled = useIsGapDisabled( props ); const paddingDisabled = useIsPaddingDisabled( props ); const marginDisabled = useIsMarginDisabled( props ); - return paddingDisabled && marginDisabled; + return gapDisabled && paddingDisabled && marginDisabled; }; /** diff --git a/packages/block-editor/src/hooks/gap.js b/packages/block-editor/src/hooks/gap.js new file mode 100644 index 0000000000000..d20b94c2b72cd --- /dev/null +++ b/packages/block-editor/src/hooks/gap.js @@ -0,0 +1,128 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Platform } from '@wordpress/element'; +import { getBlockSupport } from '@wordpress/blocks'; +import { + __experimentalUseCustomUnits as useCustomUnits, + __experimentalUnitControl as UnitControl, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import useSetting from '../components/use-setting'; +import { SPACING_SUPPORT_KEY } from './dimensions'; +import { cleanEmptyObject } from './utils'; + +/** + * Determines if there is gap support. + * + * @param {string|Object} blockType Block name or Block Type object. + * @return {boolean} Whether there is support. + */ +export function hasGapSupport( blockType ) { + const support = getBlockSupport( blockType, SPACING_SUPPORT_KEY ); + return !! ( true === support || support?.blockGap ); +} + +/** + * Checks if there is a current value in the gap block support attributes. + * + * @param {Object} props Block props. + * @return {boolean} Whether or not the block has a gap value set. + */ +export function hasGapValue( props ) { + return props.attributes.style?.spacing?.blockGap !== undefined; +} + +/** + * Resets the gap block support attribute. This can be used when disabling + * the gap support controls for a block via a progressive discovery panel. + * + * @param {Object} props Block props. + * @param {Object} props.attributes Block's attributes. + * @param {Object} props.setAttributes Function to set block's attributes. + */ +export function resetGap( { attributes = {}, setAttributes } ) { + const { style } = attributes; + + setAttributes( { + style: { + ...style, + spacing: { + ...style?.spacing, + blockGap: undefined, + }, + }, + } ); +} + +/** + * Custom hook that checks if gap settings have been disabled. + * + * @param {string} name The name of the block. + * @return {boolean} Whether the gap setting is disabled. + */ +export function useIsGapDisabled( { name: blockName } = {} ) { + const isDisabled = ! useSetting( 'spacing.blockGap' ); + return ! hasGapSupport( blockName ) || isDisabled; +} + +/** + * Inspector control panel containing the gap related configuration + * + * @param {Object} props + * + * @return {WPElement} Gap edit element. + */ +export function GapEdit( props ) { + const { + attributes: { style }, + setAttributes, + } = props; + + const units = useCustomUnits( { + availableUnits: useSetting( 'spacing.units' ) || [ + '%', + 'px', + 'em', + 'rem', + 'vw', + ], + } ); + + if ( useIsGapDisabled( props ) ) { + return null; + } + + const onChange = ( next ) => { + const newStyle = { + ...style, + spacing: { + ...style?.spacing, + blockGap: next, + }, + }; + + setAttributes( { + style: cleanEmptyObject( newStyle ), + } ); + }; + + return Platform.select( { + web: ( + <> + + + ), + native: null, + } ); +} diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 87bf4685d50d4..9bc7c0e5997c8 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -144,7 +144,14 @@ function addAttribute( settings ) { return settings; } -const skipSerializationPaths = { +/** + * A dictionary of paths to flag skipping block support serialization as the key, + * with values providing the style paths to be omitted from serialization. + * + * @constant + * @type {Record} + */ +const skipSerializationPathsEdit = { [ `${ BORDER_SUPPORT_KEY }.__experimentalSkipSerialization` ]: [ 'border' ], [ `${ COLOR_SUPPORT_KEY }.__experimentalSkipSerialization` ]: [ COLOR_SUPPORT_KEY, @@ -157,23 +164,46 @@ const skipSerializationPaths = { ], }; +/** + * A dictionary of paths to flag skipping block support serialization as the key, + * with values providing the style paths to be omitted from serialization. + * + * Extends the Edit skip paths to enable skipping additional paths in just + * the Save component. This allows a block support to be serialized within the + * editor, while using an alternate approach, such as server-side rendering, when + * the support is saved. + * + * @constant + * @type {Record} + */ +const skipSerializationPathsSave = { + ...skipSerializationPathsEdit, + [ `${ SPACING_SUPPORT_KEY }` ]: [ 'spacing.blockGap' ], +}; + /** * Override props assigned to save component to inject the CSS variables definition. * - * @param {Object} props Additional props applied to save element. - * @param {Object} blockType Block type. - * @param {Object} attributes Block attributes. + * @param {Object} props Additional props applied to save element. + * @param {Object} blockType Block type. + * @param {Object} attributes Block attributes. + * @param {?Record} skipPaths An object of keys and paths to skip serialization. * * @return {Object} Filtered props applied to save element. */ -export function addSaveProps( props, blockType, attributes ) { +export function addSaveProps( + props, + blockType, + attributes, + skipPaths = skipSerializationPathsSave +) { if ( ! hasStyleSupport( blockType ) ) { return props; } let { style } = attributes; - forEach( skipSerializationPaths, ( path, indicator ) => { + forEach( skipPaths, ( path, indicator ) => { if ( getBlockSupport( blockType, indicator ) ) { style = omit( style, path ); } @@ -207,7 +237,12 @@ export function addEditProps( settings ) { props = existingGetEditWrapperProps( attributes ); } - return addSaveProps( props, settings, attributes ); + return addSaveProps( + props, + settings, + attributes, + skipSerializationPathsEdit + ); }; return settings; diff --git a/packages/block-editor/src/hooks/test/style.js b/packages/block-editor/src/hooks/test/style.js index 706d9a93ed0ba..e8c3264eeba6b 100644 --- a/packages/block-editor/src/hooks/test/style.js +++ b/packages/block-editor/src/hooks/test/style.js @@ -24,11 +24,13 @@ describe( 'getInlineStyles', () => { color: '#21759b', }, spacing: { + blockGap: '1em', padding: { top: '10px' }, margin: { bottom: '15px' }, }, } ) ).toEqual( { + '--wp--style--block-gap': '1em', backgroundColor: 'black', borderColor: '#21759b', borderRadius: '10px', @@ -96,11 +98,13 @@ describe( 'getInlineStyles', () => { expect( getInlineStyles( { spacing: { + blockGap: '1em', margin: '10px', padding: '20px', }, } ) ).toEqual( { + '--wp--style--block-gap': '1em', margin: '10px', padding: '20px', } ); diff --git a/packages/blocks/src/api/constants.js b/packages/blocks/src/api/constants.js index e60d868fc2227..ff8cf6fdb9424 100644 --- a/packages/blocks/src/api/constants.js +++ b/packages/blocks/src/api/constants.js @@ -114,6 +114,7 @@ export const __EXPERIMENTAL_STYLE_PROPERTY = { }, '--wp--style--block-gap': { value: [ 'spacing', 'blockGap' ], + support: [ 'spacing', 'blockGap' ], }, }; diff --git a/packages/edit-site/src/components/sidebar/dimensions-panel.js b/packages/edit-site/src/components/sidebar/dimensions-panel.js index 05b4aadd2d5bf..72059768b3018 100644 --- a/packages/edit-site/src/components/sidebar/dimensions-panel.js +++ b/packages/edit-site/src/components/sidebar/dimensions-panel.js @@ -6,6 +6,7 @@ import { __experimentalToolsPanel as ToolsPanel, __experimentalToolsPanelItem as ToolsPanelItem, __experimentalBoxControl as BoxControl, + __experimentalUnitControl as UnitControl, __experimentalUseCustomUnits as useCustomUnits, } from '@wordpress/components'; import { __experimentalUseCustomSides as useCustomSides } from '@wordpress/block-editor'; @@ -20,8 +21,9 @@ const AXIAL_SIDES = [ 'horizontal', 'vertical' ]; export function useHasDimensionsPanel( context ) { const hasPadding = useHasPadding( context ); const hasMargin = useHasMargin( context ); + const hasGap = useHasGap( context ); - return hasPadding || hasMargin; + return hasPadding || hasMargin || hasGap; } function useHasPadding( { name, supports } ) { @@ -36,6 +38,12 @@ function useHasMargin( { name, supports } ) { return settings && supports.includes( 'margin' ); } +function useHasGap( { name, supports } ) { + const settings = useSetting( 'spacing.blockGap', name ); + + return settings && supports.includes( '--wp--style--block-gap' ); +} + function filterValuesBySides( values, sides ) { if ( ! sides ) { // If no custom side configuration all sides are opted into by default. @@ -78,6 +86,7 @@ export default function DimensionsPanel( { context, getStyle, setStyle } ) { const { name } = context; const showPaddingControl = useHasPadding( context ); const showMarginControl = useHasMargin( context ); + const showGapControl = useHasGap( context ); const units = useCustomUnits( { availableUnits: useSetting( 'spacing.units', name ) || [ '%', @@ -116,9 +125,18 @@ export default function DimensionsPanel( { context, getStyle, setStyle } ) { const hasMarginValue = () => marginValues && Object.keys( marginValues ).length; + const gapValue = getStyle( name, '--wp--style--block-gap' ); + + const setGapValue = ( newGapValue ) => { + setStyle( name, '--wp--style--block-gap', newGapValue ); + }; + const resetGapValue = () => setGapValue( undefined ); + const hasGapValue = () => !! gapValue; + const resetAll = () => { resetPaddingValue(); resetMarginValue(); + resetGapValue(); }; return ( @@ -163,6 +181,23 @@ export default function DimensionsPanel( { context, getStyle, setStyle } ) { /> ) } + { showGapControl && ( + + + + ) } ); } diff --git a/phpunit/block-supports/spacing-test.php b/phpunit/block-supports/spacing-test.php new file mode 100644 index 0000000000000..1038cfcb5ca97 --- /dev/null +++ b/phpunit/block-supports/spacing-test.php @@ -0,0 +1,142 @@ +Test'; + private $test_gap_block_value = array(); + private $test_gap_block_args = array(); + + function setUp() { + parent::setUp(); + + $this->test_gap_block_value = array( + 'blockName' => 'test/test-block', + 'attrs' => array( + 'style' => array( + 'spacing' => array( + 'blockGap' => '3em', + ), + ), + ), + ); + + $this->test_gap_block_args = array( + 'api_version' => 2, + 'supports' => array( + 'spacing' => array( + 'blockGap' => true, + ), + ), + ); + } + + function tearDown() { + unregister_block_type( 'test/test-block' ); + + parent::tearDown(); + } + + function test_spacing_gap_block_support_renders_block_inline_style() { + register_block_type( 'test/test-block', $this->test_gap_block_args ); + $render_output = gutenberg_render_spacing_gap_support( + $this->sample_block_content, + $this->test_gap_block_value + ); + + $this->assertSame( + '
Test
', + $render_output + ); + } + + function test_spacing_gap_block_support_renders_block_inline_style_with_inner_tag() { + register_block_type( 'test/test-block', $this->test_gap_block_args ); + $render_output = gutenberg_render_spacing_gap_support( + '

Test

', + $this->test_gap_block_value + ); + + $this->assertSame( + '

Test

', + $render_output + ); + } + + function test_spacing_gap_block_support_renders_block_inline_style_with_no_other_attributes() { + register_block_type( 'test/test-block', $this->test_gap_block_args ); + $render_output = gutenberg_render_spacing_gap_support( + '

Test

', + $this->test_gap_block_value + ); + + $this->assertSame( + '

Test

', + $render_output + ); + } + + function test_spacing_gap_block_support_renders_appended_block_inline_style() { + register_block_type( 'test/test-block', $this->test_gap_block_args ); + $render_output = gutenberg_render_spacing_gap_support( + '

Test

', + $this->test_gap_block_value + ); + + $this->assertSame( + '

Test

', + $render_output + ); + } + + function test_spacing_gap_block_support_does_not_render_style_when_support_is_false() { + $this->test_gap_block_args['supports']['spacing']['blockGap'] = false; + + register_block_type( 'test/test-block', $this->test_gap_block_args ); + $render_output = gutenberg_render_spacing_gap_support( + $this->sample_block_content, + $this->test_gap_block_value + ); + + $this->assertEquals( + $this->sample_block_content, + $render_output + ); + } + + function test_spacing_gap_block_support_does_not_render_style_when_gap_is_null() { + $this->test_gap_block_value['attrs']['style']['spacing']['blockGap'] = null; + $this->test_gap_block_args['supports']['spacing']['blockGap'] = true; + + register_block_type( 'test/test-block', $this->test_gap_block_args ); + $render_output = gutenberg_render_spacing_gap_support( + $this->sample_block_content, + $this->test_gap_block_value + ); + + $this->assertEquals( + $this->sample_block_content, + $render_output + ); + } + + function test_spacing_gap_block_support_does_not_render_style_when_gap_is_illegal_value() { + $this->test_gap_block_value['attrs']['style']['spacing']['blockGap'] = '" javascript="alert("hello");'; + $this->test_gap_block_args['supports']['spacing']['blockGap'] = true; + + register_block_type( 'test/test-block', $this->test_gap_block_args ); + $render_output = gutenberg_render_spacing_gap_support( + $this->sample_block_content, + $this->test_gap_block_value + ); + + $this->assertEquals( + $this->sample_block_content, + $render_output + ); + } +}