From 5c1e9139c75b72cfd04af15da3a1b5affcc250cf Mon Sep 17 00:00:00 2001 From: Nik Tsekouras Date: Tue, 13 Apr 2021 20:06:54 +0300 Subject: [PATCH] Suggest block pattern transformations that are contextual to the currently selected 'simple' blocks (no InnerBlocks) (#30469) * spin off from #29890 for simple blocks without InnerBlocks * __experimentalGetBlockAttributesNamesByRole tests * update patterns * useTransformedPatterns and refactoring part 1 * getMatchingBlockByName tests + and other jsdoc * getPatternTransformedBlocks + transformMatchingBlock tests * add role:content to more blocks + a couple test patterns * pattern list padding * rename role to __experimentalRole * Update docs suggestion Co-authored-by: Miguel Fonseca * fix tests + docs * change patterns * update social pattern Co-authored-by: James Koster Co-authored-by: Miguel Fonseca --- lib/block-patterns.php | 26 ++ .../src/components/block-switcher/index.js | 46 ++- .../pattern-transformations-menu.js | 137 +++++++ .../src/components/block-switcher/style.scss | 41 ++- .../test/use-transformed.patterns.js | 336 ++++++++++++++++++ .../components/block-switcher/test/utils.js | 167 +++++++++ .../use-transformed-patterns.js | 116 ++++++ .../src/components/block-switcher/utils.js | 57 +++ packages/block-editor/src/store/selectors.js | 62 ++++ packages/block-library/src/heading/block.json | 3 +- packages/block-library/src/list/block.json | 6 +- .../block-library/src/paragraph/block.json | 3 +- .../block-library/src/preformatted/block.json | 3 +- .../block-library/src/pullquote/block.json | 6 +- packages/block-library/src/quote/block.json | 6 +- packages/block-library/src/search/block.json | 9 +- packages/block-library/src/verse/block.json | 3 +- packages/blocks/src/api/index.js | 1 + packages/blocks/src/api/test/utils.js | 89 +++++ packages/blocks/src/api/utils.js | 19 + 20 files changed, 1113 insertions(+), 23 deletions(-) create mode 100644 packages/block-editor/src/components/block-switcher/pattern-transformations-menu.js create mode 100644 packages/block-editor/src/components/block-switcher/test/use-transformed.patterns.js create mode 100644 packages/block-editor/src/components/block-switcher/test/utils.js create mode 100644 packages/block-editor/src/components/block-switcher/use-transformed-patterns.js create mode 100644 packages/block-editor/src/components/block-switcher/utils.js diff --git a/lib/block-patterns.php b/lib/block-patterns.php index b35f07a955ed3..d578cade9bf04 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' => ' + + ', + ) +); +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 ef0d601c495c8..16f720aabb439 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 0000000000000..c3516f7e3ec79 --- /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 && ( + + ) } + { + event.preventDefault(); + setShowTransforms( ! showTransforms ); + } } + icon={ chevronRight } + > + { __( 'Patterns' ) } + + + ); +} + +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 1a506d3d084b0..1900b6a1b3c9a 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 0000000000000..05ce545667d46 --- /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 0000000000000..38009601e1646 --- /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 0000000000000..7f8c956ea6988 --- /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 0000000000000..2004f1d65172b --- /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 3af1ae3ba200b..ffac34cea81fb 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 6cd496431f799..8d7e0fdd5c194 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 002f0bc82bc7b..509da21bef437 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 709eda64eee58..f7dee53633483 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 75ebf7ecb6661..56a325bf8f07a 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 8413ba8473483..9b64a2c253bc7 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 bba83461367fa..0f026e96f6abf 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 d6399a0857397..96e0f7a923c19 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 5ca413b2e1376..490d6a6ea30b4 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 625377c0df3c0..5fdd8aad97bdf 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 ea2aa6c214185..3ffc1bf5944bc 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 c82515c4e5cd2..c14f5d003dd25 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 + ); +}