diff --git a/lib/load.php b/lib/load.php index 2deda54b6646b8..90a19ee8c89ae4 100644 --- a/lib/load.php +++ b/lib/load.php @@ -107,6 +107,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/blocks.php'; require __DIR__ . '/block-patterns.php'; +require __DIR__ . '/test-block-patterns.php'; require __DIR__ . '/client-assets.php'; require __DIR__ . '/demo.php'; require __DIR__ . '/widgets.php'; diff --git a/lib/test-block-patterns.php b/lib/test-block-patterns.php new file mode 100644 index 00000000000000..162d2e60af4195 --- /dev/null +++ b/lib/test-block-patterns.php @@ -0,0 +1,204 @@ + __( 'Paragraph version 1', 'gutenberg' ), + 'scope' => array( + 'inserter' => false, + 'transform' => array( 'core/paragraph' ), + ), + 'content' => ' +

Hello my paragraph!

+ ', + ) +); +register_block_pattern( + 'paragraph/v2', + array( + 'title' => __( 'Paragraph version 2', 'gutenberg' ), + 'scope' => array( + 'inserter' => false, + 'transform' => array( 'core/paragraph' ), + ), + 'content' => ' +

Hello my paragraph!

+ ', + ) +); + +// Multi block transform patterns. +register_block_pattern( + 'multi/v2', + array( + 'title' => __( 'Multi blocks v2 - deep nesting', 'gutenberg' ), + 'scope' => array( + 'inserter' => false, + 'transform' => array( 'core/paragraph', 'core/heading' ), + ), + 'content' => ' +
+ +

2.Which treats of the first sally the ingenious Don Quixote made from home

+ + +
+ +

These preliminaries settled, he did not care to put off any longer the execution of his design, urged on to it by the thought of all the world was losing by his delay, seeing what wrongs he intended to right, grievances to redress, injustices to repair, abuses to remove, and duties to discharge.

+ +
+ + +

Pattern Heading

+ +
+ ', + ) +); +register_block_pattern( + 'multi/v1', + array( + 'title' => __( 'Multi blocks v1', 'gutenberg' ), + 'scope' => array( + 'inserter' => false, + 'transform' => array( 'core/paragraph', 'core/heading' ), + ), + 'content' => ' +
+

2.Which treats of the first sally the ingenious Don Quixote made from home

+ + +

These preliminaries settled, he did not care to put off any longer the execution of his design, urged on to it by the thought of all the world was losing by his delay, seeing what wrongs he intended to right, grievances to redress, injustices to repair, abuses to remove, and duties to discharge.

+
+ ', + ) +); + +// Template Parts Patterns. +// Headers. +register_block_pattern( + 'header/v1', + array( + 'title' => __( 'Header v1', 'gutenberg' ), + 'scope' => array( + 'inserter' => false, + 'transform' => array( 'core/template-part/header' ), + ), + 'content' => ' + + + + +
+ +
+ + +
+ +
+ ', + ) +); +register_block_pattern( + 'header/v2', + array( + 'title' => __( 'Header v2', 'gutenberg' ), + 'scope' => array( + 'inserter' => false, + 'transform' => array( 'core/template-part/header' ), + ), + 'content' => ' + +

This is the Header

+ + +
+
+ + +
+ +
+
+ ', + ) +); +register_block_pattern( + 'footer/v1', + array( + 'title' => __( 'Footer v1', 'gutenberg' ), + 'scope' => array( + 'inserter' => false, + 'transform' => array( 'core/template-part/footer' ), + ), + 'content' => ' + +

This is a Footer

+ + +
+
+

example@example.com
T. +00 (0)1 22 33 44 55

+
+ + +
+

2, Rue Losuis-Boilly
Paris, France

+
+ + +
+ +
+
+', + ) +); + + +// Tests with InnerBlocks like Buttons. +register_block_pattern( + 'buttons/rigas', + array( + 'title' => __( 'Buttons v1', 'gutenberg' ), + 'scope' => array( + 'inserter' => false, + 'transform' => array( 'core/buttons' ), + ), + '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..99e5d657cc2107 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,10 @@ export const BlockSwitcherDropdownMenu = ( { clientIds, blocks } ) => { ? getBlockType( firstBlockName )?.icon : stack; } - + const _patterns = __experimentalGetPatternTransformItems( + blocks, + rootClientId + ); return { possibleBlockTransformations: getBlockTransformItems( blocks, @@ -75,6 +81,7 @@ export const BlockSwitcherDropdownMenu = ( { clientIds, blocks } ) => { hasBlockStyles: !! styles?.length, icon: _icon, blockTitle: getBlockType( firstBlockName ).title, + patterns: _patterns, }; }, [ clientIds, blocks, blockInformation?.icon ] @@ -83,9 +90,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 +126,10 @@ export const BlockSwitcherDropdownMenu = ( { clientIds, blocks } ) => { blocks.length ); + const showDropDown = + hasBlockStyles || + hasPossibleBlockTransformations || + hasPatternTransformation; return ( @@ -147,9 +163,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..35ca1f7bb38572 --- /dev/null +++ b/packages/block-editor/src/components/block-switcher/pattern-transformations-menu.js @@ -0,0 +1,248 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState, useMemo } from '@wordpress/element'; +import { useInstanceId } from '@wordpress/compose'; +import { chevronRight } from '@wordpress/icons'; +import { + cloneBlock, + __experimentalGetBlockAttributesNamesByRole as getBlockAttributesNamesByRole, +} from '@wordpress/blocks'; +import { + MenuGroup, + MenuItem, + Popover, + VisuallyHidden, + __unstableComposite as Composite, + __unstableUseCompositeState as useCompositeState, + __unstableCompositeItem as CompositeItem, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import BlockPreview from '../block-preview'; + +/** + * Find a selected block match in a pattern and return it. + * We return a reference to the block object to mutate it. + * We have first cloned the pattern blocks in a new property + * `transformedBlocks` and we mutate this. + * + * @param {WPBlock} parsedBlock The pattern's parsed block to try to find a match. + * @param {string} selectedBlockName The current selected block's name. + * @param {Set} transformedBlocks A set holding the previously matched blocks. + * + * @return {WPBlock|boolean} The matched block if found or `false`. + */ +// TODO tests +function findMatchingBlockInPattern( + parsedBlock, + selectedBlockName, + transformedBlocks +) { + const { clientId, name, innerBlocks = [] } = parsedBlock; + // Check if parsedBlock has been transformed already. + // This is needed because we loop the selected blocks + // and for example we may have selected two paragraphs and + // the patterns could have more `paragraphs`. + if ( transformedBlocks.has( clientId ) ) return false; + if ( name === selectedBlockName ) { + // We have found a matched block type, so + // add it to the transformed blocks Set and return it. + transformedBlocks.add( clientId ); + return parsedBlock; + } + // Recurse through the inner blocks of a parsed block and + // try to find a matching block. + for ( const innerBlock of innerBlocks ) { + const match = findMatchingBlockInPattern( + innerBlock, + selectedBlockName, + transformedBlocks + ); + if ( match ) return match; + } +} + +function PatternTransformationsMenu( { + blocks, + patterns: statePatterns, + onSelect, +} ) { + const [ showTransforms, setShowTransforms ] = useState( false ); + const patterns = useMemo( () => { + const _patterns = statePatterns.reduce( + ( accumulator, statePattern ) => { + // Clone the parsed pattern's block in `transformedBlocks` + // to mutate this prop. + const pattern = { + ...statePattern, + transformedBlocks: statePattern.blocks.map( ( block ) => + cloneBlock( block ) + ), + }; + const { transformedBlocks: patternBlocks } = pattern; + const transformedBlocksSet = new Set(); + blocks.forEach( ( block ) => { + // Recurse through every pattern block + // to find matches with each selected block, + // and transform these blocks (we mutate patternBlocks). + patternBlocks.forEach( ( patternBlock ) => { + const match = findMatchingBlockInPattern( + patternBlock, + block.name, + transformedBlocksSet + ); + if ( ! match ) return; + // Found a match, so find and retain block attributes + // with `content` role. Everything else comes from the + // pattern's block. If no `content` attributes found, + // update the match with all the selected block's attributes. + const contentAttributes = getBlockAttributesNamesByRole( + block.name, + 'content' + ); + let retainedBlockAttributes = block.attributes; + if ( contentAttributes?.length ) { + retainedBlockAttributes = contentAttributes.reduce( + ( _accumulator, attribute ) => { + if ( block.attributes[ attribute ] ) + _accumulator[ attribute ] = + block.attributes[ attribute ]; + return _accumulator; + }, + {} + ); + } + match.attributes = { + ...match.attributes, + ...retainedBlockAttributes, + }; + // When we have a match with inner blocks keep only the + // blocks from the selected block and skip the inner blocks + // from the pattern. + match.innerBlocks = block.innerBlocks; + } ); + } ); + // If we haven't matched all the selected blocks, don't add + // the pattern to the transformation list. + if ( blocks.length !== transformedBlocksSet.size ) { + return accumulator; + } + // TODO Maybe prioritize first matches with fewer tries to find a match? + accumulator.push( pattern ); + return accumulator; + }, + [] + ); + return _patterns; + }, [ statePatterns ] ); + + 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 ) => ( + + ) ) } + + ); +} + +// TODO: This needs to be consolidated to probably be reused across: Patterns in Placeholder, Inserter and here. +function BlockPattern( { pattern, onSelect, composite } ) { + 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..ba74c190b361d8 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,40 @@ } } } + +.block-editor-block-switcher__preview-patterns-container { + .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/store/selectors.js b/packages/block-editor/src/store/selectors.js index 3af1ae3ba200b8..ce1526eb95fc74 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -1874,6 +1874,70 @@ export const __experimentalGetPatternsByBlockTypes = createSelector( ] ); +/** + * Determines the items that appear in the available pattern transforms list. + * There is special handling in two cases: + * 1. For some blocks (`blocksToSkip`) when multiple blocks are selected, + * don't show any transforms, as it doesn't make sense to try to be too smart. + * 2. There are some blocks (`nestedSingleBlocksToHandle`) that makes sense to + * replace everything when they are the only block selected. + * + * For the rest blocks we return a first set of possible eligible block patterns, + * by checking the `scope` Patterns API. 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 hanlde blocks without InnerBlocks and take into account + * the `role` property of block's attributes for the transformation. + * Noting that blocks have been retrieved through `getBlock`, that doen't + * return the child 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 `scope` 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..b54ad9860ade11 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": "", + "role": "content" }, "level": { "type": "number", diff --git a/packages/block-library/src/paragraph/block.json b/packages/block-library/src/paragraph/block.json index 709eda64eee58f..2f95db4ac1c851 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": "", + "role": "content" }, "dropCap": { "type": "boolean", 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/utils.js b/packages/blocks/src/api/utils.js index c82515c4e5cd2a..5048dc9e276e94 100644 --- a/packages/blocks/src/api/utils.js +++ b/packages/blocks/src/api/utils.js @@ -275,3 +275,23 @@ export function __experimentalSanitizeBlockAttributes( name, attributes ) { {} ); } + +/** + * I created this wrapper to hide the complexity for the consumer.. + * + * @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. + */ +// TODO jsdoc +// TODO tests +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 ]?.role === role + ); +}