diff --git a/lib/block-patterns.php b/lib/block-patterns.php
index b35f07a955ed3c..d578cade9bf04d 100644
--- a/lib/block-patterns.php
+++ b/lib/block-patterns.php
@@ -132,3 +132,29 @@
',
)
);
+
+// Initial block patterns to be used in block transformations with patterns.
+register_block_pattern(
+ 'paragraph/large-with-background-color',
+ array(
+ 'title' => __( 'Large Paragraph with background color', 'gutenberg' ),
+ 'blockTypes' => array( 'core/paragraph' ),
+ 'viewportWidth' => 500,
+ 'content' => '
+
The whole series of my life appeared to me as a dream; I sometimes doubted if indeed it were all true, for it never presented itself to my mind with the force of reality.
+ ',
+ )
+);
+register_block_pattern(
+ 'social-links/shared-background-color',
+ array(
+ 'title' => __( 'Social links with a shared background color', 'gutenberg' ),
+ 'blockTypes' => array( 'core/social-links' ),
+ 'viewportWidth' => 500,
+ 'content' => '
+
+ ',
+ )
+);
diff --git a/packages/block-editor/src/components/block-switcher/index.js b/packages/block-editor/src/components/block-switcher/index.js
index ef0d601c495c86..16f720aabb4396 100644
--- a/packages/block-editor/src/components/block-switcher/index.js
+++ b/packages/block-editor/src/components/block-switcher/index.js
@@ -31,6 +31,7 @@ import BlockIcon from '../block-icon';
import BlockTitle from '../block-title';
import BlockTransformationsMenu from './block-transformations-menu';
import BlockStylesMenu from './block-styles-menu';
+import PatternTransformationsMenu from './pattern-transformations-menu';
export const BlockSwitcherDropdownMenu = ( { clientIds, blocks } ) => {
const { replaceBlocks } = useDispatch( blockEditorStore );
@@ -40,12 +41,14 @@ export const BlockSwitcherDropdownMenu = ( { clientIds, blocks } ) => {
hasBlockStyles,
icon,
blockTitle,
+ patterns,
} = useSelect(
( select ) => {
- const { getBlockRootClientId, getBlockTransformItems } = select(
- blockEditorStore
- );
-
+ const {
+ getBlockRootClientId,
+ getBlockTransformItems,
+ __experimentalGetPatternTransformItems,
+ } = select( blockEditorStore );
const { getBlockStyles, getBlockType } = select( blocksStore );
const rootClientId = getBlockRootClientId(
castArray( clientIds )[ 0 ]
@@ -66,7 +69,6 @@ export const BlockSwitcherDropdownMenu = ( { clientIds, blocks } ) => {
? getBlockType( firstBlockName )?.icon
: stack;
}
-
return {
possibleBlockTransformations: getBlockTransformItems(
blocks,
@@ -75,6 +77,10 @@ export const BlockSwitcherDropdownMenu = ( { clientIds, blocks } ) => {
hasBlockStyles: !! styles?.length,
icon: _icon,
blockTitle: getBlockType( firstBlockName ).title,
+ patterns: __experimentalGetPatternTransformItems(
+ blocks,
+ rootClientId
+ ),
};
},
[ clientIds, blocks, blockInformation?.icon ]
@@ -83,9 +89,14 @@ export const BlockSwitcherDropdownMenu = ( { clientIds, blocks } ) => {
const isReusable = blocks.length === 1 && isReusableBlock( blocks[ 0 ] );
const isTemplate = blocks.length === 1 && isTemplatePart( blocks[ 0 ] );
- const onTransform = ( name ) =>
+ // Simple block tranformation based on the `Block Transforms` API.
+ const onBlockTransform = ( name ) =>
replaceBlocks( clientIds, switchToBlockType( blocks, name ) );
+ // Pattern transformation through the `Patterns` API.
+ const onPatternTransform = ( transformedBlocks ) =>
+ replaceBlocks( clientIds, transformedBlocks );
const hasPossibleBlockTransformations = !! possibleBlockTransformations.length;
+ const hasPatternTransformation = !! patterns?.length;
if ( ! hasBlockStyles && ! hasPossibleBlockTransformations ) {
return (
@@ -114,6 +125,10 @@ export const BlockSwitcherDropdownMenu = ( { clientIds, blocks } ) => {
blocks.length
);
+ const showDropDown =
+ hasBlockStyles ||
+ hasPossibleBlockTransformations ||
+ hasPatternTransformation;
return (
@@ -147,9 +162,22 @@ export const BlockSwitcherDropdownMenu = ( { clientIds, blocks } ) => {
menuProps={ { orientation: 'both' } }
>
{ ( { onClose } ) =>
- ( hasBlockStyles ||
- hasPossibleBlockTransformations ) && (
+ showDropDown && (
+ { hasPatternTransformation && (
+
{
+ onPatternTransform(
+ transformedBlocks
+ );
+ onClose();
+ } }
+ />
+ ) }
{ hasPossibleBlockTransformations && (
{
}
blocks={ blocks }
onSelect={ ( name ) => {
- onTransform( name );
+ onBlockTransform( name );
onClose();
} }
/>
diff --git a/packages/block-editor/src/components/block-switcher/pattern-transformations-menu.js b/packages/block-editor/src/components/block-switcher/pattern-transformations-menu.js
new file mode 100644
index 00000000000000..c3516f7e3ec794
--- /dev/null
+++ b/packages/block-editor/src/components/block-switcher/pattern-transformations-menu.js
@@ -0,0 +1,137 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useState } from '@wordpress/element';
+import { useInstanceId } from '@wordpress/compose';
+import { chevronRight } from '@wordpress/icons';
+
+import {
+ MenuGroup,
+ MenuItem,
+ Popover,
+ VisuallyHidden,
+ __unstableComposite as Composite,
+ __unstableUseCompositeState as useCompositeState,
+ __unstableCompositeItem as CompositeItem,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import BlockPreview from '../block-preview';
+import useTransformedPatterns from './use-transformed-patterns';
+
+function PatternTransformationsMenu( {
+ blocks,
+ patterns: statePatterns,
+ onSelect,
+} ) {
+ const [ showTransforms, setShowTransforms ] = useState( false );
+ const patterns = useTransformedPatterns( statePatterns, blocks );
+ if ( ! patterns.length ) return null;
+
+ return (
+
+ { showTransforms && (
+
+ ) }
+
+
+ );
+}
+
+function PreviewPatternsPopover( { patterns, onSelect } ) {
+ return (
+
+
+
+
+
+ { __( 'Preview' ) }
+
+
+
+
+
+
+ );
+}
+
+function BlockPatternsList( { patterns, onSelect } ) {
+ const composite = useCompositeState();
+ return (
+
+ { patterns.map( ( pattern ) => (
+
+ ) ) }
+
+ );
+}
+
+function BlockPattern( { pattern, onSelect, composite } ) {
+ // TODO check pattern/preview width...
+ const baseClassName =
+ 'block-editor-block-switcher__preview-patterns-container';
+ const descriptionId = useInstanceId(
+ BlockPattern,
+ `${ baseClassName }-list__item-description`
+ );
+ return (
+
+
onSelect( pattern.transformedBlocks ) }
+ >
+
+
+ { pattern.title }
+
+
+ { !! pattern.description && (
+
+ { pattern.description }
+
+ ) }
+
+ );
+}
+
+export default PatternTransformationsMenu;
diff --git a/packages/block-editor/src/components/block-switcher/style.scss b/packages/block-editor/src/components/block-switcher/style.scss
index 1a506d3d084b09..1900b6a1b3c9a9 100644
--- a/packages/block-editor/src/components/block-switcher/style.scss
+++ b/packages/block-editor/src/components/block-switcher/style.scss
@@ -93,7 +93,6 @@
padding: 0;
.components-menu-group {
- padding: $grid-unit-20;
margin: 0;
}
}
@@ -149,6 +148,7 @@
.block-editor-block-switcher__preview {
width: 300px;
height: auto;
+ max-height: 500px;
padding: $grid-unit-20;
}
}
@@ -182,3 +182,42 @@
}
}
}
+
+.block-editor-block-switcher__preview-patterns-container {
+ padding-bottom: $grid-unit-20;
+
+ .block-editor-block-switcher__preview-patterns-container-list__list-item {
+ margin-top: $grid-unit-20;
+
+ .block-editor-block-preview__container {
+ cursor: pointer;
+ }
+
+ .block-editor-block-switcher__preview-patterns-container-list__item {
+ height: 100%;
+ border-radius: $radius-block-ui;
+ transition: all 0.05s ease-in-out;
+ position: relative;
+ border: $border-width solid transparent;
+
+ &:hover,
+ &:focus {
+ box-shadow: inset 0 0 0 1px $white, 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
+
+ // Windows High Contrast mode will show this outline, but not the box-shadow.
+ outline: 2px solid transparent;
+ }
+
+ &:hover {
+ box-shadow: inset 0 0 0 1px $white, 0 0 0 var(--wp-admin-border-width-focus) $gray-900;
+ }
+
+ .block-editor-block-switcher__preview-patterns-container-list__item-title {
+ padding: $grid-unit-05;
+ font-size: 12px;
+ text-align: center;
+ cursor: pointer;
+ }
+ }
+ }
+}
diff --git a/packages/block-editor/src/components/block-switcher/test/use-transformed.patterns.js b/packages/block-editor/src/components/block-switcher/test/use-transformed.patterns.js
new file mode 100644
index 00000000000000..05ce545667d464
--- /dev/null
+++ b/packages/block-editor/src/components/block-switcher/test/use-transformed.patterns.js
@@ -0,0 +1,336 @@
+/**
+ * WordPress dependencies
+ */
+import { unregisterBlockType, registerBlockType } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import {
+ transformMatchingBlock,
+ getPatternTransformedBlocks,
+} from '../use-transformed-patterns';
+
+describe( 'use-transformed-patterns', () => {
+ beforeAll( () => {
+ registerBlockType( 'core/test-block-1', {
+ attributes: {
+ align: {
+ type: 'string',
+ },
+ content: {
+ type: 'boolean',
+ __experimentalRole: 'content',
+ },
+ level: {
+ type: 'number',
+ __experimentalRole: 'content',
+ },
+ color: {
+ type: 'string',
+ __experimentalRole: 'other',
+ },
+ },
+ save() {},
+ category: 'text',
+ title: 'test block 1',
+ } );
+ registerBlockType( 'core/test-block-2', {
+ attributes: {
+ align: { type: 'string' },
+ content: { type: 'boolean' },
+ color: { type: 'string' },
+ },
+ save() {},
+ category: 'text',
+ title: 'test block 2',
+ } );
+ } );
+ afterAll( () => {
+ [ 'core/test-block-1', 'core/test-block-2' ].forEach(
+ unregisterBlockType
+ );
+ } );
+ describe( 'transformMatchingBlock', () => {
+ it( 'should properly update the matching block - No retained block attributes', () => {
+ const match = {
+ clientId: 'block-2',
+ name: 'core/test-block-2',
+ attributes: { align: 'center' },
+ };
+ const selectedBlock = {
+ clientId: 'selected-block-2',
+ name: 'core/test-block-2',
+ attributes: { align: 'right', content: 'hi' },
+ };
+ transformMatchingBlock( match, selectedBlock );
+ expect( match ).toEqual(
+ expect.objectContaining( {
+ clientId: 'block-2',
+ name: 'core/test-block-2',
+ attributes: expect.objectContaining( {
+ align: 'right',
+ content: 'hi',
+ } ),
+ } )
+ );
+ } );
+ it( 'should properly update the matching block - WITH retained block attributes', () => {
+ const match = {
+ clientId: 'block-1',
+ name: 'core/test-block-1',
+ attributes: {
+ align: 'center',
+ content: 'from match',
+ level: 3,
+ color: 'red',
+ },
+ };
+ const selectedBlock = {
+ clientId: 'selected-block-1',
+ name: 'core/test-block-1',
+ attributes: {
+ align: 'left',
+ content: 'from selected block',
+ level: 1,
+ color: 'green',
+ },
+ };
+ transformMatchingBlock( match, selectedBlock );
+ expect( match ).toEqual(
+ expect.objectContaining( {
+ clientId: 'block-1',
+ name: 'core/test-block-1',
+ attributes: expect.objectContaining( {
+ align: 'center',
+ content: 'from selected block',
+ level: 1,
+ color: 'red',
+ } ),
+ } )
+ );
+ } );
+ } );
+ describe( 'getPatternTransformedBlocks', () => {
+ const patternBlocks = [
+ {
+ clientId: 'client-1',
+ name: 'core/test-block-1',
+ attributes: { content: 'top level block 1', color: 'red' },
+ innerBlocks: [
+ {
+ clientId: 'client-1-1',
+ name: 'core/test-block-2',
+ innerBlocks: [],
+ },
+ {
+ clientId: 'client-1-2',
+ name: 'core/test-block-2',
+ innerBlocks: [
+ {
+ clientId: 'client-1-2-1',
+ name: 'core/test-block-1',
+ attributes: {
+ content: 'nested block 1',
+ level: 6,
+ color: 'yellow',
+ },
+ innerBlocks: [],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ clientId: 'client-2',
+ name: 'core/test-block-2',
+ innerBlocks: [
+ {
+ clientId: 'client-1-1',
+ name: 'core/test-block-2',
+ innerBlocks: [],
+ },
+ {
+ clientId: 'client-1-2',
+ name: 'nested block',
+ innerBlocks: [
+ {
+ clientId: 'client-1-2-1',
+ name: 'core/test-block-1',
+ attributes: {
+ content: 'nested block 1',
+ level: 6,
+ color: 'yellow',
+ },
+ innerBlocks: [],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ clientId: 'client-3',
+ name: 'core/test-block-1',
+ attributes: { content: 'top level block 3', color: 'purple' },
+ innerBlocks: [],
+ },
+ ];
+ describe( 'return nothing', () => {
+ it( 'when no match is found', () => {
+ const selectedBlocks = [
+ {
+ clientId: 'selected-1',
+ name: 'selected-1',
+ innerBlocks: [],
+ },
+ ];
+ const res = getPatternTransformedBlocks(
+ selectedBlocks,
+ patternBlocks
+ );
+ expect( res ).toBeUndefined();
+ } );
+ it( 'when not ALL blocks are matched', () => {
+ const selectedBlocks = [
+ {
+ clientId: 'selected-1',
+ name: 'core/test-block-1',
+ attributes: {
+ content: 'from selected',
+ color: 'green',
+ },
+ innerBlocks: [],
+ },
+ {
+ clientId: 'selected-2',
+ name: 'not in pattern',
+ innerBlocks: [],
+ },
+ ];
+ const res = getPatternTransformedBlocks(
+ selectedBlocks,
+ patternBlocks
+ );
+ expect( res ).toBeUndefined();
+ } );
+ } );
+ describe( 'return properly transformed pattern blocks', () => {
+ it( 'when single block is selected', () => {
+ const selectedBlocks = [
+ {
+ clientId: 'selected-1',
+ name: 'core/test-block-1',
+ attributes: {
+ content: 'from selected',
+ color: 'green',
+ },
+ innerBlocks: [],
+ },
+ ];
+ const res = getPatternTransformedBlocks(
+ selectedBlocks,
+ patternBlocks
+ );
+ expect( res ).toHaveLength( 3 );
+ expect( res ).toEqual(
+ expect.arrayContaining( [
+ expect.objectContaining( {
+ name: 'core/test-block-1',
+ attributes: expect.objectContaining( {
+ content: 'from selected',
+ color: 'red',
+ } ),
+ } ),
+ expect.objectContaining( {
+ name: 'core/test-block-2',
+ } ),
+ expect.objectContaining( {
+ name: 'core/test-block-1',
+ attributes: {
+ content: 'top level block 3',
+ color: 'purple',
+ },
+ } ),
+ ] )
+ );
+ } );
+ it( 'when multiple selected blocks', () => {
+ /**
+ * The matching is performed recursively searching depth first,
+ * so top level blocks' InnerBlocks are search before trying
+ * the next top level pattern's block.
+ */
+ const selectedBlocks = [
+ {
+ clientId: 'selected-1',
+ name: 'core/test-block-1',
+ attributes: {
+ content: 'from selected 1',
+ color: 'green',
+ },
+ innerBlocks: [],
+ },
+ {
+ clientId: 'selected-2',
+ name: 'core/test-block-1',
+ attributes: {
+ content: 'from selected 2',
+ level: 1,
+ },
+ innerBlocks: [],
+ },
+ {
+ clientId: 'selected-3',
+ name: 'core/test-block-1',
+ attributes: {
+ content: 'from selected 3',
+ color: 'white',
+ },
+ innerBlocks: [],
+ },
+ ];
+ const res = getPatternTransformedBlocks(
+ selectedBlocks,
+ patternBlocks
+ );
+ expect( res ).toHaveLength( 3 );
+ expect( res ).toEqual(
+ expect.arrayContaining( [
+ expect.objectContaining( {
+ name: 'core/test-block-1',
+ attributes: expect.objectContaining( {
+ content: 'from selected 1',
+ color: 'red',
+ } ),
+ } ),
+ expect.objectContaining( {
+ name: 'core/test-block-2',
+ innerBlocks: expect.arrayContaining( [
+ expect.objectContaining( {
+ name: 'nested block',
+ innerBlocks: [
+ expect.objectContaining( {
+ name: 'core/test-block-1',
+ attributes: {
+ content: 'from selected 2',
+ level: 1,
+ color: 'yellow',
+ },
+ } ),
+ ],
+ } ),
+ ] ),
+ } ),
+ expect.objectContaining( {
+ name: 'core/test-block-1',
+ attributes: {
+ content: 'from selected 3',
+ color: 'purple',
+ },
+ } ),
+ ] )
+ );
+ } );
+ } );
+ } );
+} );
diff --git a/packages/block-editor/src/components/block-switcher/test/utils.js b/packages/block-editor/src/components/block-switcher/test/utils.js
new file mode 100644
index 00000000000000..38009601e16468
--- /dev/null
+++ b/packages/block-editor/src/components/block-switcher/test/utils.js
@@ -0,0 +1,167 @@
+/**
+ * WordPress dependencies
+ */
+import { unregisterBlockType, registerBlockType } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import { getMatchingBlockByName, getRetainedBlockAttributes } from '../utils';
+
+describe( 'BlockSwitcher - utils', () => {
+ describe( 'getRetainedBlockAttributes', () => {
+ beforeAll( () => {
+ registerBlockType( 'core/test-block-1', {
+ attributes: {
+ align: {
+ type: 'string',
+ },
+ content: {
+ type: 'boolean',
+ __experimentalRole: 'content',
+ },
+ level: {
+ type: 'number',
+ __experimentalRole: 'content',
+ },
+ color: {
+ type: 'string',
+ __experimentalRole: 'other',
+ },
+ },
+ save() {},
+ category: 'text',
+ title: 'test block 1',
+ } );
+ registerBlockType( 'core/test-block-2', {
+ attributes: {
+ align: { type: 'string' },
+ content: { type: 'boolean' },
+ color: { type: 'string' },
+ },
+ save() {},
+ category: 'text',
+ title: 'test block 2',
+ } );
+ } );
+ afterAll( () => {
+ [ 'core/test-block-1', 'core/test-block-2' ].forEach(
+ unregisterBlockType
+ );
+ } );
+ it( 'should return passed attributes if no `role:content` attributes were found', () => {
+ const attributes = { align: 'right' };
+ const res = getRetainedBlockAttributes(
+ 'core/test-block-2',
+ attributes
+ );
+ expect( res ).toEqual( attributes );
+ } );
+ it( 'should return only the `role:content` attributes that exist in passed attributes', () => {
+ const attributes = { align: 'right', level: 2 };
+ const res = getRetainedBlockAttributes(
+ 'core/test-block-1',
+ attributes
+ );
+ expect( res ).toEqual( { level: 2 } );
+ } );
+ } );
+ describe( 'getMatchingBlockByName', () => {
+ it( 'should return nothing if no match is found', () => {
+ const block = {
+ clientId: 'client-1',
+ name: 'test-1',
+ innerBlocks: [
+ {
+ clientId: 'client-1-1',
+ name: 'test-1-1',
+ innerBlocks: [],
+ },
+ ],
+ };
+ const res = getMatchingBlockByName( block, 'not-a-match' );
+ expect( res ).toBeUndefined();
+ } );
+ it( 'should return nothing if provided block has already been consumed', () => {
+ const block = {
+ clientId: 'client-1',
+ name: 'test-1',
+ innerBlocks: [
+ {
+ clientId: 'client-1-1',
+ name: 'test-1-1',
+ innerBlocks: [],
+ },
+ {
+ clientId: 'client-1-2',
+ name: 'test-1-2',
+ innerBlocks: [],
+ },
+ ],
+ };
+ const res = getMatchingBlockByName(
+ block,
+ 'test-1-2',
+ new Set( [ 'client-1-2' ] )
+ );
+ expect( res ).toBeUndefined();
+ } );
+ describe( 'should return the matched block', () => {
+ it( 'if top level block', () => {
+ const block = {
+ clientId: 'client-1',
+ name: 'test-1',
+ innerBlocks: [],
+ };
+ const res = getMatchingBlockByName(
+ block,
+ 'test-1',
+ new Set( [ 'client-1-2' ] )
+ );
+ expect( res ).toEqual(
+ expect.objectContaining( {
+ clientId: 'client-1',
+ name: 'test-1',
+ innerBlocks: [],
+ } )
+ );
+ } );
+ it( 'if nested block', () => {
+ const block = {
+ clientId: 'client-1',
+ name: 'test-1',
+ innerBlocks: [
+ {
+ clientId: 'client-1-1',
+ name: 'test-1-1',
+ innerBlocks: [],
+ },
+ {
+ clientId: 'client-1-2',
+ name: 'test-1-2',
+ innerBlocks: [
+ {
+ clientId: 'client-1-2-1',
+ name: 'test-1-2-1',
+ innerBlocks: [],
+ },
+ ],
+ },
+ ],
+ };
+ const res = getMatchingBlockByName(
+ block,
+ 'test-1-2-1',
+ new Set( [ 'someId' ] )
+ );
+ expect( res ).toEqual(
+ expect.objectContaining( {
+ clientId: 'client-1-2-1',
+ name: 'test-1-2-1',
+ innerBlocks: [],
+ } )
+ );
+ } );
+ } );
+ } );
+} );
diff --git a/packages/block-editor/src/components/block-switcher/use-transformed-patterns.js b/packages/block-editor/src/components/block-switcher/use-transformed-patterns.js
new file mode 100644
index 00000000000000..7f8c956ea69883
--- /dev/null
+++ b/packages/block-editor/src/components/block-switcher/use-transformed-patterns.js
@@ -0,0 +1,116 @@
+/**
+ * WordPress dependencies
+ */
+import { useMemo } from '@wordpress/element';
+import { cloneBlock } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import { getMatchingBlockByName, getRetainedBlockAttributes } from './utils';
+
+/**
+ * Mutate the matched block's attributes by getting
+ * which block type's attributes to retain and prioritize
+ * them in the merging of the attributes.
+ *
+ * @param {WPBlock} match The matched block.
+ * @param {WPBlock} selectedBlock The selected block.
+ * @return {void}
+ */
+export const transformMatchingBlock = ( match, selectedBlock ) => {
+ // Get the block attributes to retain through the transformation.
+ const retainedBlockAttributes = getRetainedBlockAttributes(
+ selectedBlock.name,
+ selectedBlock.attributes
+ );
+ match.attributes = {
+ ...match.attributes,
+ ...retainedBlockAttributes,
+ };
+};
+
+/**
+ * By providing the selected blocks and pattern's blocks
+ * find the matching blocks, transform them and return them.
+ * If not all selected blocks are matched, return nothing.
+ *
+ * @param {WPBlock[]} selectedBlocks The selected blocks.
+ * @param {WPBlock[]} patternBlocks The pattern's blocks.
+ * @return {WPBlock[]|void} The transformed pattern's blocks or undefined if not all selected blocks have been matched.
+ */
+export const getPatternTransformedBlocks = (
+ selectedBlocks,
+ patternBlocks
+) => {
+ // Clone Pattern's blocks to produce new clientIds and be able to mutate the matches.
+ const _patternBlocks = patternBlocks.map( ( block ) =>
+ cloneBlock( block )
+ );
+ /**
+ * Keep track of the consumed pattern blocks.
+ * This is needed because we loop the selected blocks
+ * and for example we may have selected two paragraphs and
+ * the pattern's blocks could have more `paragraphs`.
+ */
+ const consumedBlocks = new Set();
+ for ( const selectedBlock of selectedBlocks ) {
+ let isMatch = false;
+ for ( const patternBlock of _patternBlocks ) {
+ const match = getMatchingBlockByName(
+ patternBlock,
+ selectedBlock.name,
+ consumedBlocks
+ );
+ if ( ! match ) continue;
+ isMatch = true;
+ consumedBlocks.add( match.clientId );
+ // We update (mutate) the matching pattern block.
+ transformMatchingBlock( match, selectedBlock );
+ // No need to loop through other pattern's blocks.
+ break;
+ }
+ // Bail eary if a selected block has not been matched.
+ if ( ! isMatch ) return;
+ }
+ return _patternBlocks;
+};
+
+/**
+ * @typedef {WPBlockPattern & {transformedBlocks: WPBlock[]}} TransformedBlockPattern
+ */
+
+/**
+ * Custom hook that accepts patterns from state and the selected
+ * blocks and tries to match these with the pattern's blocks.
+ * If all selected blocks are matched with a Pattern's block,
+ * we transform them by retaining block's attributes with `role:content`.
+ * The transformed pattern's blocks are set to a new pattern
+ * property `transformedBlocks`.
+ *
+ * @param {WPBlockPattern[]} patterns Patterns from state.
+ * @param {WPBlock[]} selectedBlocks The currently selected blocks.
+ * @return {TransformedBlockPattern[]} Returns the eligible matched patterns with all the selected blocks.
+ */
+// TODO tests
+const useTransformedPatterns = ( patterns, selectedBlocks ) => {
+ return useMemo(
+ () =>
+ patterns.reduce( ( accumulator, _pattern ) => {
+ const transformedBlocks = getPatternTransformedBlocks(
+ selectedBlocks,
+ _pattern.blocks
+ );
+ if ( transformedBlocks ) {
+ accumulator.push( {
+ ..._pattern,
+ transformedBlocks,
+ } );
+ }
+ return accumulator;
+ }, [] ),
+ [ patterns, selectedBlocks ]
+ );
+};
+
+export default useTransformedPatterns;
diff --git a/packages/block-editor/src/components/block-switcher/utils.js b/packages/block-editor/src/components/block-switcher/utils.js
new file mode 100644
index 00000000000000..2004f1d65172bc
--- /dev/null
+++ b/packages/block-editor/src/components/block-switcher/utils.js
@@ -0,0 +1,57 @@
+/**
+ * WordPress dependencies
+ */
+import { __experimentalGetBlockAttributesNamesByRole as getBlockAttributesNamesByRole } from '@wordpress/blocks';
+
+/**
+ * Try to find a matching block by a block's name in a provided
+ * block. We recurse through InnerBlocks and return the reference
+ * of the matched block (it could be an InnerBlock).
+ * If no match is found return nothing.
+ *
+ * @param {WPBlock} block The block to try to find a match.
+ * @param {string} selectedBlockName The block's name to use for matching condition.
+ * @param {Set} consumedBlocks A set holding the previously matched/consumed blocks.
+ *
+ * @return {WPBlock?} The matched block if found or nothing(`undefined`).
+ */
+export const getMatchingBlockByName = (
+ block,
+ selectedBlockName,
+ consumedBlocks = new Set()
+) => {
+ const { clientId, name, innerBlocks = [] } = block;
+ // Check if block has been consumed already.
+ if ( consumedBlocks.has( clientId ) ) return;
+ if ( name === selectedBlockName ) return block;
+ // Try to find a matching block from InnerBlocks recursively.
+ for ( const innerBlock of innerBlocks ) {
+ const match = getMatchingBlockByName(
+ innerBlock,
+ selectedBlockName,
+ consumedBlocks
+ );
+ if ( match ) return match;
+ }
+};
+
+/**
+ * Find and return the block attributes to retain through
+ * the transformation, based on Block Type's `role:content`
+ * attributes. If no `role:content` attributes exist,
+ * return selected block's attributes.
+ *
+ * @param {string} name Block type's namespaced name.
+ * @param {Object} attributes Selected block's attributes.
+ * @return {Object} The block's attributes to retain.
+ */
+export const getRetainedBlockAttributes = ( name, attributes ) => {
+ const contentAttributes = getBlockAttributesNamesByRole( name, 'content' );
+ if ( ! contentAttributes?.length ) return attributes;
+
+ return contentAttributes.reduce( ( _accumulator, attribute ) => {
+ if ( attributes[ attribute ] )
+ _accumulator[ attribute ] = attributes[ attribute ];
+ return _accumulator;
+ }, {} );
+};
diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js
index 3af1ae3ba200b8..ffac34cea81fbb 100644
--- a/packages/block-editor/src/store/selectors.js
+++ b/packages/block-editor/src/store/selectors.js
@@ -1874,6 +1874,68 @@ export const __experimentalGetPatternsByBlockTypes = createSelector(
]
);
+/**
+ * Determines the items that appear in the available pattern transforms list.
+ *
+ * For now we only handle blocks without InnerBlocks and take into account
+ * the `__experimentalRole` property of blocks' attributes for the transformation.
+ *
+ * We return the first set of possible eligible block patterns,
+ * by checking the `blockTypes` property. We still have to recurse through
+ * block pattern's blocks and try to find matches from the selected blocks.
+ * Now this happens in the consumer to avoid heavy operations in the selector.
+ *
+ * @param {Object} state Editor state.
+ * @param {Object[]} blocks The selected blocks.
+ * @param {?string} rootClientId Optional root client ID of block list.
+ *
+ * @return {WPBlockPattern[]} Items that are eligible for a pattern transformation.
+ */
+// TODO tests
+export const __experimentalGetPatternTransformItems = createSelector(
+ ( state, blocks, rootClientId = null ) => {
+ if ( ! blocks ) return EMPTY_ARRAY;
+ /**
+ * For now we only handle blocks without InnerBlocks and take into account
+ * the `__experimentalRole` property of blocks' attributes for the transformation.
+ * Note that the blocks have been retrieved through `getBlock`, which doesn't
+ * return the inner blocks of an inner block controller, so we still need
+ * to check for this case too.
+ */
+ if (
+ blocks.some(
+ ( { clientId, innerBlocks } ) =>
+ innerBlocks.length ||
+ areInnerBlocksControlled( state, clientId )
+ )
+ ) {
+ return EMPTY_ARRAY;
+ }
+
+ // Create a Set of the selected block names that is used in patterns filtering.
+ const selectedBlockNames = Array.from(
+ new Set( blocks.map( ( { name } ) => name ) )
+ );
+ /**
+ * Here we will return first set of possible eligible block patterns,
+ * by checking the `blockTypes` property. We still have to recurse through
+ * block pattern's blocks and try to find matches from the selected blocks.
+ * Now this happens in the consumer to avoid heavy operations in the selector.
+ */
+ return __experimentalGetPatternsByBlockTypes(
+ state,
+ selectedBlockNames,
+ rootClientId
+ );
+ },
+ ( state, rootClientId ) => [
+ ...__experimentalGetPatternsByBlockTypes.getDependants(
+ state,
+ rootClientId
+ ),
+ ]
+);
+
/**
* Returns the Block List settings of a block, if any exist.
*
diff --git a/packages/block-library/src/heading/block.json b/packages/block-library/src/heading/block.json
index 6cd496431f799d..8d7e0fdd5c1940 100644
--- a/packages/block-library/src/heading/block.json
+++ b/packages/block-library/src/heading/block.json
@@ -10,7 +10,8 @@
"type": "string",
"source": "html",
"selector": "h1,h2,h3,h4,h5,h6",
- "default": ""
+ "default": "",
+ "__experimentalRole": "content"
},
"level": {
"type": "number",
diff --git a/packages/block-library/src/list/block.json b/packages/block-library/src/list/block.json
index 002f0bc82bc7bb..509da21bef4373 100644
--- a/packages/block-library/src/list/block.json
+++ b/packages/block-library/src/list/block.json
@@ -5,7 +5,8 @@
"attributes": {
"ordered": {
"type": "boolean",
- "default": false
+ "default": false,
+ "__experimentalRole": "content"
},
"values": {
"type": "string",
@@ -13,7 +14,8 @@
"selector": "ol,ul",
"multiline": "li",
"__unstableMultilineWrapperTags": [ "ol", "ul" ],
- "default": ""
+ "default": "",
+ "__experimentalRole": "content"
},
"type": {
"type": "string"
diff --git a/packages/block-library/src/paragraph/block.json b/packages/block-library/src/paragraph/block.json
index 709eda64eee58f..f7dee53633483d 100644
--- a/packages/block-library/src/paragraph/block.json
+++ b/packages/block-library/src/paragraph/block.json
@@ -10,7 +10,8 @@
"type": "string",
"source": "html",
"selector": "p",
- "default": ""
+ "default": "",
+ "__experimentalRole": "content"
},
"dropCap": {
"type": "boolean",
diff --git a/packages/block-library/src/preformatted/block.json b/packages/block-library/src/preformatted/block.json
index 75ebf7ecb6661c..56a325bf8f07a6 100644
--- a/packages/block-library/src/preformatted/block.json
+++ b/packages/block-library/src/preformatted/block.json
@@ -8,7 +8,8 @@
"source": "html",
"selector": "pre",
"default": "",
- "__unstablePreserveWhiteSpace": true
+ "__unstablePreserveWhiteSpace": true,
+ "__experimentalRole": "content"
}
},
"supports": {
diff --git a/packages/block-library/src/pullquote/block.json b/packages/block-library/src/pullquote/block.json
index 8413ba8473483d..9b64a2c253bc73 100644
--- a/packages/block-library/src/pullquote/block.json
+++ b/packages/block-library/src/pullquote/block.json
@@ -7,13 +7,15 @@
"type": "string",
"source": "html",
"selector": "blockquote",
- "multiline": "p"
+ "multiline": "p",
+ "__experimentalRole": "content"
},
"citation": {
"type": "string",
"source": "html",
"selector": "cite",
- "default": ""
+ "default": "",
+ "__experimentalRole": "content"
},
"mainColor": {
"type": "string"
diff --git a/packages/block-library/src/quote/block.json b/packages/block-library/src/quote/block.json
index bba83461367fa1..0f026e96f6abf8 100644
--- a/packages/block-library/src/quote/block.json
+++ b/packages/block-library/src/quote/block.json
@@ -8,13 +8,15 @@
"source": "html",
"selector": "blockquote",
"multiline": "p",
- "default": ""
+ "default": "",
+ "__experimentalRole": "content"
},
"citation": {
"type": "string",
"source": "html",
"selector": "cite",
- "default": ""
+ "default": "",
+ "__experimentalRole": "content"
},
"align": {
"type": "string"
diff --git a/packages/block-library/src/search/block.json b/packages/block-library/src/search/block.json
index d6399a0857397e..96e0f7a923c192 100644
--- a/packages/block-library/src/search/block.json
+++ b/packages/block-library/src/search/block.json
@@ -4,7 +4,8 @@
"category": "widgets",
"attributes": {
"label": {
- "type": "string"
+ "type": "string",
+ "__experimentalRole": "content"
},
"showLabel": {
"type": "boolean",
@@ -12,7 +13,8 @@
},
"placeholder": {
"type": "string",
- "default": ""
+ "default": "",
+ "__experimentalRole": "content"
},
"width": {
"type": "number"
@@ -21,7 +23,8 @@
"type": "string"
},
"buttonText": {
- "type": "string"
+ "type": "string",
+ "__experimentalRole": "content"
},
"buttonPosition": {
"type": "string",
diff --git a/packages/block-library/src/verse/block.json b/packages/block-library/src/verse/block.json
index 5ca413b2e13769..490d6a6ea30b4d 100644
--- a/packages/block-library/src/verse/block.json
+++ b/packages/block-library/src/verse/block.json
@@ -8,7 +8,8 @@
"source": "html",
"selector": "pre",
"default": "",
- "__unstablePreserveWhiteSpace": true
+ "__unstablePreserveWhiteSpace": true,
+ "__experimentalRole": "content"
},
"textAlign": {
"type": "string"
diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js
index 625377c0df3c0f..5fdd8aad97bdf6 100644
--- a/packages/blocks/src/api/index.js
+++ b/packages/blocks/src/api/index.js
@@ -141,6 +141,7 @@ export {
getBlockLabel as __experimentalGetBlockLabel,
getAccessibleBlockLabel as __experimentalGetAccessibleBlockLabel,
__experimentalSanitizeBlockAttributes,
+ __experimentalGetBlockAttributesNamesByRole,
} from './utils';
// Templates are, in a general sense, a basic collection of block nodes with any
diff --git a/packages/blocks/src/api/test/utils.js b/packages/blocks/src/api/test/utils.js
index ea2aa6c2141859..3ffc1bf5944bc5 100644
--- a/packages/blocks/src/api/test/utils.js
+++ b/packages/blocks/src/api/test/utils.js
@@ -18,6 +18,7 @@ import {
getAccessibleBlockLabel,
getBlockLabel,
__experimentalSanitizeBlockAttributes,
+ __experimentalGetBlockAttributesNamesByRole,
} from '../utils';
describe( 'block helpers', () => {
@@ -310,3 +311,91 @@ describe( 'sanitizeBlockAttributes', () => {
} );
} );
} );
+
+describe( '__experimentalGetBlockAttributesNamesByRole', () => {
+ beforeAll( () => {
+ registerBlockType( 'core/test-block-1', {
+ attributes: {
+ align: {
+ type: 'string',
+ },
+ content: {
+ type: 'boolean',
+ __experimentalRole: 'content',
+ },
+ level: {
+ type: 'number',
+ __experimentalRole: 'content',
+ },
+ color: {
+ type: 'string',
+ __experimentalRole: 'other',
+ },
+ },
+ save: noop,
+ category: 'text',
+ title: 'test block 1',
+ } );
+ registerBlockType( 'core/test-block-2', {
+ attributes: {
+ align: { type: 'string' },
+ content: { type: 'boolean' },
+ color: { type: 'string' },
+ },
+ save: noop,
+ category: 'text',
+ title: 'test block 2',
+ } );
+ registerBlockType( 'core/test-block-3', {
+ save: noop,
+ category: 'text',
+ title: 'test block 3',
+ } );
+ } );
+ afterAll( () => {
+ [
+ 'core/test-block-1',
+ 'core/test-block-2',
+ 'core/test-block-3',
+ ].forEach( unregisterBlockType );
+ } );
+ it( 'should return empty array if block has no attributes', () => {
+ expect(
+ __experimentalGetBlockAttributesNamesByRole( 'core/test-block-3' )
+ ).toEqual( [] );
+ } );
+ it( 'should return all attribute names if no role is provided', () => {
+ expect(
+ __experimentalGetBlockAttributesNamesByRole( 'core/test-block-1' )
+ ).toEqual(
+ expect.arrayContaining( [ 'align', 'content', 'level', 'color' ] )
+ );
+ } );
+ it( 'should return proper results with existing attributes and provided role', () => {
+ expect(
+ __experimentalGetBlockAttributesNamesByRole(
+ 'core/test-block-1',
+ 'content'
+ )
+ ).toEqual( expect.arrayContaining( [ 'content', 'level' ] ) );
+ expect(
+ __experimentalGetBlockAttributesNamesByRole(
+ 'core/test-block-1',
+ 'other'
+ )
+ ).toEqual( [ 'color' ] );
+ expect(
+ __experimentalGetBlockAttributesNamesByRole(
+ 'core/test-block-1',
+ 'not-exists'
+ )
+ ).toEqual( [] );
+ // A block with no `role` in any attributes.
+ expect(
+ __experimentalGetBlockAttributesNamesByRole(
+ 'core/test-block-2',
+ 'content'
+ )
+ ).toEqual( [] );
+ } );
+} );
diff --git a/packages/blocks/src/api/utils.js b/packages/blocks/src/api/utils.js
index c82515c4e5cd2a..c14f5d003dd255 100644
--- a/packages/blocks/src/api/utils.js
+++ b/packages/blocks/src/api/utils.js
@@ -275,3 +275,22 @@ export function __experimentalSanitizeBlockAttributes( name, attributes ) {
{}
);
}
+
+/**
+ * Filter block attributes by `role` and return their names.
+ *
+ * @param {string} name Block attribute's name.
+ * @param {string} role The role of a block attribute.
+ *
+ * @return {string[]} The attribute names that have the provided role.
+ */
+export function __experimentalGetBlockAttributesNamesByRole( name, role ) {
+ const attributes = getBlockType( name )?.attributes;
+ if ( ! attributes ) return [];
+ const attributesNames = Object.keys( attributes );
+ if ( ! role ) return attributesNames;
+ return attributesNames.filter(
+ ( attributeName ) =>
+ attributes[ attributeName ]?.__experimentalRole === role
+ );
+}