From f0ee75922094f4d96cd72947970da4f956af61dd Mon Sep 17 00:00:00 2001
From: Andrew Serong <14988353+andrewserong@users.noreply.github.com>
Date: Fri, 3 Sep 2021 09:35:35 +1000
Subject: [PATCH] Block Support: Add gap block support feature (#33991)
* Add gap block support feature based on #32571
* Server-render the gap block support, and skip serialization on save in the editor
* Add PHP tests for spacing gap support server-side rendering
* Rename support from customBlockGap to blockGap
* Align whitespace
---
lib/block-supports/spacing.php | 53 +++++++
lib/class-wp-theme-json-gutenberg.php | 1 +
lib/theme.json | 1 +
packages/block-editor/src/hooks/dimensions.js | 29 +++-
packages/block-editor/src/hooks/gap.js | 128 ++++++++++++++++
packages/block-editor/src/hooks/style.js | 49 +++++-
packages/block-editor/src/hooks/test/style.js | 4 +
packages/blocks/src/api/constants.js | 1 +
.../components/sidebar/dimensions-panel.js | 37 ++++-
phpunit/block-supports/spacing-test.php | 142 ++++++++++++++++++
10 files changed, 435 insertions(+), 10 deletions(-)
create mode 100644 packages/block-editor/src/hooks/gap.js
create mode 100644 phpunit/block-supports/spacing-test.php
diff --git a/lib/block-supports/spacing.php b/lib/block-supports/spacing.php
index 78f5b59f90fb04..ca7b77f43864b9 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 ee12dad41fa089..474294caa2a218 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 6b52969009beed..5d952b381ef932 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 697955670b497e..d85ce67104b391 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 00000000000000..d20b94c2b72cd6
--- /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 87bf4685d50d42..9bc7c0e5997c8f 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 706d9a93ed0ba4..e8c3264eeba6b1 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 e60d868fc2227b..ff8cf6fdb94249 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 05b4aadd2d5bf0..72059768b30189 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 00000000000000..1038cfcb5ca97d
--- /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(
+ '',
+ $this->test_gap_block_value
+ );
+
+ $this->assertSame(
+ '',
+ $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(
+ '',
+ $this->test_gap_block_value
+ );
+
+ $this->assertSame(
+ '',
+ $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(
+ '',
+ $this->test_gap_block_value
+ );
+
+ $this->assertSame(
+ '',
+ $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
+ );
+ }
+}