From 1d33194d6b8d27478ea04cfadd15309e58c5f573 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Wed, 27 May 2026 16:38:33 +1000 Subject: [PATCH 1/3] Try allowing transforms to a variation of another block --- .../block-api/block-transforms.md | 1 + .../data/data-core-block-editor.md | 1 + .../block-transformations-menu.js | 34 +++-- .../block-transformations-menu.native.js | 17 ++- .../src/components/block-switcher/index.js | 8 +- .../components/use-block-commands/index.js | 10 +- packages/block-editor/src/store/selectors.js | 53 +++++-- .../block-editor/src/store/test/selectors.js | 76 ++++++++++ .../src/columns/test/transforms.js | 110 +++++++++++++++ .../block-library/src/columns/transforms.js | 34 +++++ .../src/gallery/test/transforms.js | 131 ++++++++++++++++++ .../block-library/src/gallery/transforms.js | 41 ++++++ packages/blocks/README.md | 3 +- packages/blocks/src/api/factory.ts | 92 ++++++++---- packages/blocks/src/api/test/factory.js | 103 ++++++++++++++ packages/blocks/src/types.ts | 5 + 16 files changed, 646 insertions(+), 73 deletions(-) create mode 100644 packages/block-library/src/columns/test/transforms.js create mode 100644 packages/block-library/src/gallery/test/transforms.js diff --git a/docs/reference-guides/block-api/block-transforms.md b/docs/reference-guides/block-api/block-transforms.md index 59176ef2a8cc4b..33d418cc68dd5b 100644 --- a/docs/reference-guides/block-api/block-transforms.md +++ b/docs/reference-guides/block-api/block-transforms.md @@ -42,6 +42,7 @@ A transformation of type `block` is an object that takes the following parameter - **type** _(string)_: the value `block`. - **blocks** _(array)_: a list of known block types. It also accepts the wildcard value (`"*"`), meaning that the transform is available to _all_ block types (eg: all blocks can transform into `core/group`). - **transform** _(function)_: a callback that receives the attributes and inner blocks of the block being processed. It should return a block object or an array of block objects. +- **variationName** _(string, optional)_: the name of the target block variation when the transform creates a variation of the transformed block type. This lets the transform UI show the variation title and icon instead of the base block type. - **isMatch** _(function, optional)_: a callback that receives the block attributes as the first argument and the block object as the second argument and should return a boolean. Returning `false` from this function will prevent the transform from being available and displayed as an option to the user. - **isMultiBlock** _(boolean, optional)_: whether the transformation can be applied when multiple blocks are selected. If true, the `transform` function's first parameter will be an array containing each selected block's attributes, and the second an array of each selected block's inner blocks. False by default. - **priority** _(number, optional)_: controls the priority with which a transformation is applied, where a lower value will take precedence over higher values. This behaves much like a [WordPress hook](https://developer.wordpress.org/reference/#Hook_to_WordPress). Like hooks, the default priority is `10` when not otherwise set. diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 40aae1223254a8..bd29ed3efa447a 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -470,6 +470,7 @@ _Properties_ - _id_ `string`: Unique identifier for the item. - _name_ `string`: The type of block to create. +- _variationName_ `?string`: The target block variation name. - _title_ `string`: Title of the item, as it appears in the inserter. - _icon_ `string`: Dashicon for the item, as it appears in the inserter. - _isDisabled_ `boolean`: Whether or not the user should be prevented from inserting this item. diff --git a/packages/block-editor/src/components/block-switcher/block-transformations-menu.js b/packages/block-editor/src/components/block-switcher/block-transformations-menu.js index f0f2e99a79d76b..cb9f4cb7db87ef 100644 --- a/packages/block-editor/src/components/block-switcher/block-transformations-menu.js +++ b/packages/block-editor/src/components/block-switcher/block-transformations-menu.js @@ -88,8 +88,7 @@ const BlockTransformationsMenu = ( { onSelectVariation, blocks, } ) => { - const [ hoveredTransformItemName, setHoveredTransformItemName ] = - useState(); + const [ hoveredTransformItem, setHoveredTransformItem ] = useState(); const { priorityTextTransformations, restTransformations } = useGroupedTransforms( possibleBlockTransformations ); @@ -101,17 +100,18 @@ const BlockTransformationsMenu = ( { ); return ( <> - { hoveredTransformItemName && ( + { hoveredTransformItem && ( ) } @@ -126,12 +126,10 @@ const BlockTransformationsMenu = ( { ) } { priorityTextTransformations.map( ( item ) => ( ) ) } { ! hasBothContentTransformations && restTransformItems } @@ -148,14 +146,14 @@ const BlockTransformationsMenu = ( { function RestTransformationItems( { restTransformations, onSelect, - setHoveredTransformItemName, + setHoveredTransformItem, } ) { return restTransformations.map( ( item ) => ( ) ); } @@ -163,7 +161,7 @@ function RestTransformationItems( { function BlockTransformationItem( { item, onSelect, - setHoveredTransformItemName, + setHoveredTransformItem, } ) { const { name, icon, title, isDisabled } = item; return ( @@ -171,13 +169,13 @@ function BlockTransformationItem( { className={ getBlockMenuDefaultClassName( name ) } onClick={ ( event ) => { event.preventDefault(); - onSelect( name ); + onSelect( name, item.variationName ); } } disabled={ isDisabled } - onMouseLeave={ () => setHoveredTransformItemName( null ) } - onMouseEnter={ () => setHoveredTransformItemName( name ) } - onFocus={ () => setHoveredTransformItemName( name ) } - onBlur={ () => setHoveredTransformItemName( null ) } + onMouseLeave={ () => setHoveredTransformItem( null ) } + onMouseEnter={ () => setHoveredTransformItem( item ) } + onFocus={ () => setHoveredTransformItem( item ) } + onBlur={ () => setHoveredTransformItem( null ) } > { title } diff --git a/packages/block-editor/src/components/block-switcher/block-transformations-menu.native.js b/packages/block-editor/src/components/block-switcher/block-transformations-menu.native.js index 4b95af2c59d523..133d50cb9df295 100644 --- a/packages/block-editor/src/components/block-switcher/block-transformations-menu.native.js +++ b/packages/block-editor/src/components/block-switcher/block-transformations-menu.native.js @@ -56,19 +56,28 @@ const BlockTransformationsMenu = ( { anchorNodeRef ? findNodeHandle( anchorNodeRef ) : undefined; function onPickerSelect( value ) { + const selectedItem = possibleTransformations.find( + ( item ) => item.id === value + ); + if ( ! selectedItem ) { + return; + } replaceBlocks( selectedBlockClientId, - switchToBlockType( selectedBlock, value ) + switchToBlockType( + selectedBlock, + selectedItem.name, + selectedItem.variationName + ) ); - - const selectedItem = pickerOptions().find( + const selectedOption = pickerOptions().find( ( item ) => item.value === value ); const successNotice = sprintf( /* translators: 1: From block title, e.g. Paragraph. 2: To block title, e.g. Header. */ __( '%1$s transformed to %2$s' ), blockTitle, - selectedItem.label + selectedOption?.label || selectedItem.title ); createSuccessNotice( successNotice ); } diff --git a/packages/block-editor/src/components/block-switcher/index.js b/packages/block-editor/src/components/block-switcher/index.js index cf3ae9beb1ecec..48fa74aa96482f 100644 --- a/packages/block-editor/src/components/block-switcher/index.js +++ b/packages/block-editor/src/components/block-switcher/index.js @@ -87,8 +87,8 @@ function BlockSwitcherDropdownMenuContents( { onClose, clientIds } ) { } } // Simple block transformation based on the `Block Transforms` API. - function onBlockTransform( name ) { - const newBlocks = switchToBlockType( blocks, name ); + function onBlockTransform( name, variationName ) { + const newBlocks = switchToBlockType( blocks, name, variationName ); replaceBlocks( clientIds, newBlocks ); selectForMultipleBlocks( newBlocks ); } @@ -166,8 +166,8 @@ function BlockSwitcherDropdownMenuContents( { onClose, clientIds } ) { blockVariationTransformations } blocks={ blocks } - onSelect={ ( name ) => { - onBlockTransform( name ); + onSelect={ ( name, variationName ) => { + onBlockTransform( name, variationName ); onClose(); } } onSelectVariation={ ( name ) => { diff --git a/packages/block-editor/src/components/use-block-commands/index.js b/packages/block-editor/src/components/use-block-commands/index.js index e0f8f134b23a72..9c6fc05f473980 100644 --- a/packages/block-editor/src/components/use-block-commands/index.js +++ b/packages/block-editor/src/components/use-block-commands/index.js @@ -93,8 +93,8 @@ const getTransformCommands = () => } // Simple block transformation based on the `Block Transforms` API. - function onBlockTransform( name ) { - const newBlocks = switchToBlockType( blocks, name ); + function onBlockTransform( name, variationName ) { + const newBlocks = switchToBlockType( blocks, name, variationName ); replaceBlocks( clientIds, newBlocks ); selectForMultipleBlocks( newBlocks ); } @@ -117,7 +117,7 @@ const getTransformCommands = () => const commands = possibleBlockTransformations.map( ( transformation ) => { - const { name, title, icon } = transformation; + const { id, name, title, icon, variationName } = transformation; /* * Command menu uses Icon from @wordpress/icons, which expects a ReactElement * (cloneElement). Normalize to blockDefaultIcon to avoid crash. See #55668 / PR #55676. @@ -132,13 +132,13 @@ const getTransformCommands = () => return { name: 'core/block-editor/transform-to-' + - name.replace( '/', '-' ), + ( id || name ).replace( /\//g, '-' ), /* translators: %s: Block or block variation name. */ label: sprintf( __( 'Transform to %s' ), title ), icon: blockIcon?.src, category: 'command', callback: ( { close } ) => { - onBlockTransform( name ); + onBlockTransform( name, variationName ); close(); }, }; diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 740fdd885966d6..07abddb841151e 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2191,6 +2191,28 @@ const getItemFromVariation = ( state, item ) => ( variation ) => { }; }; +const getBlockTransformItemFromVariation = ( state, item, variationName ) => { + if ( ! variationName ) { + return item; + } + const variation = getBlockVariations( item.name, 'transform' )?.find( + ( { name } ) => name === variationName + ); + if ( ! variation ) { + return item; + } + const variationId = `${ item.id }/${ variation.name }`; + const { time, count = 0 } = getInsertUsage( state, variationId ) || {}; + return { + ...item, + id: variationId, + icon: variation.icon || item.icon, + title: variation.title || item.title, + frecency: calculateFrecency( time, count ), + variationName: variation.name, + }; +}; + /** * Returns the calculated frecency. * @@ -2474,20 +2496,21 @@ export const getInserterItems = createRegistrySelector( ( select ) => * * Items are returned ordered descendingly by their 'frecency'. * - * @param {Object} state Editor state. - * @param {Object|Object[]} blocks Block object or array objects. - * @param {?string} rootClientId Optional root client ID of block list. + * @param {Object} state Editor state. + * @param {Object|Object[]} blocks Block object or array objects. + * @param {?string} rootClientId Optional root client ID of block list. * * @return {WPEditorTransformItem[]} Items that appear in inserter. * * @typedef {Object} WPEditorTransformItem - * @property {string} id Unique identifier for the item. - * @property {string} name The type of block to create. - * @property {string} title Title of the item, as it appears in the inserter. - * @property {string} icon Dashicon for the item, as it appears in the inserter. - * @property {boolean} isDisabled Whether or not the user should be prevented from inserting - * this item. - * @property {number} frecency Heuristic that combines frequency and recency. + * @property {string} id Unique identifier for the item. + * @property {string} name The type of block to create. + * @property {?string} variationName The target block variation name. + * @property {string} title Title of the item, as it appears in the inserter. + * @property {string} icon Dashicon for the item, as it appears in the inserter. + * @property {boolean} isDisabled Whether or not the user should be prevented from inserting + * this item. + * @property {number} frecency Heuristic that combines frequency and recency. */ export const getBlockTransformItems = createRegistrySelector( ( select ) => createSelector( @@ -2518,13 +2541,19 @@ export const getBlockTransformItems = createRegistrySelector( ( select ) => normalizedBlocks ).reduce( ( accumulator, block ) => { if ( itemsByName[ block?.name ] ) { - accumulator.push( itemsByName[ block.name ] ); + accumulator.push( + getBlockTransformItemFromVariation( + state, + itemsByName[ block.name ], + block.variationName + ) + ); } return accumulator; }, [] ); return orderBy( possibleTransforms, - ( block ) => itemsByName[ block.name ].frecency, + ( block ) => block.frecency, 'desc' ); }, diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index 37dd9255037ae0..64c7f554246565 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -4031,6 +4031,82 @@ describe( 'selectors', () => { } ) ); } ); + + it( 'should use variation metadata for transformation items', () => { + registerBlockType( 'core/variation-transform-source', { + apiVersion: 3, + category: 'text', + title: 'Variation Transform Source', + edit: () => {}, + save: () => {}, + transforms: { + to: [ + { + type: 'block', + blocks: [ 'core/variation-transform-target' ], + variationName: 'grid', + transform: () => {}, + }, + ], + }, + } ); + registerBlockType( 'core/variation-transform-target', { + apiVersion: 3, + category: 'design', + title: 'Group', + icon: 'group', + edit: () => {}, + save: () => {}, + variations: [ + { + name: 'grid', + title: 'Grid', + icon: 'grid', + attributes: { layout: { type: 'grid' } }, + scope: [ 'transform' ], + }, + ], + } ); + + const state = { + blocks: { + byClientId: new Map(), + attributes: new Map(), + order: new Map(), + parents: new Map(), + cache: {}, + blockEditingModes: new Map(), + }, + preferences: { + insertUsage: { + 'core/variation-transform-target/grid': { + count: 10, + time: 1000, + }, + }, + }, + blockListSettings: new Map(), + settings: {}, + }; + const blocks = [ { name: 'core/variation-transform-source' } ]; + + try { + const items = getBlockTransformItems( state, blocks ); + + expect( items ).toHaveLength( 1 ); + expect( items[ 0 ] ).toMatchObject( { + id: 'core/variation-transform-target/grid', + name: 'core/variation-transform-target', + variationName: 'grid', + title: 'Grid', + icon: 'grid', + frecency: 2.5, + } ); + } finally { + unregisterBlockType( 'core/variation-transform-source' ); + unregisterBlockType( 'core/variation-transform-target' ); + } + } ); } ); describe( 'isValidTemplate', () => { diff --git a/packages/block-library/src/columns/test/transforms.js b/packages/block-library/src/columns/test/transforms.js new file mode 100644 index 00000000000000..2fd84ffbd5782f --- /dev/null +++ b/packages/block-library/src/columns/test/transforms.js @@ -0,0 +1,110 @@ +/** + * WordPress dependencies + */ +import { + createBlock, + getBlockTypes, + registerBlockType, + switchToBlockType, + unregisterBlockType, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { + metadata as columnsMetadata, + settings as columnsSettings, +} from '../index'; +import { + metadata as columnMetadata, + settings as columnSettings, +} from '../../column'; +import { + metadata as groupMetadata, + settings as groupSettings, +} from '../../group'; + +describe( 'transforms', () => { + beforeAll( () => { + registerBlockType( columnsMetadata, columnsSettings ); + registerBlockType( columnMetadata, columnSettings ); + registerBlockType( groupMetadata, groupSettings ); + registerBlockType( 'core/paragraph', { + apiVersion: 3, + attributes: { + content: { + type: 'string', + }, + }, + save: () => {}, + category: 'text', + title: 'Paragraph', + } ); + } ); + + afterAll( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + it( 'transforms Columns to the Grid variation of Group', () => { + const block = createBlock( + 'core/columns', + { + align: 'wide', + isStackedOnMobile: false, + verticalAlignment: 'center', + }, + [ + createBlock( 'core/column', {}, [ + createBlock( 'core/paragraph', { content: 'One' } ), + ] ), + createBlock( 'core/column', {}, [ + createBlock( 'core/paragraph', { content: 'Two' } ), + createBlock( 'core/paragraph', { content: 'Three' } ), + ] ), + ] + ); + + const transformedBlocks = switchToBlockType( + block, + 'core/group', + 'group-grid' + ); + + expect( transformedBlocks[ 0 ] ).toMatchObject( { + name: 'core/group', + attributes: { + align: 'wide', + layout: { type: 'grid' }, + }, + } ); + expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty( + 'isStackedOnMobile' + ); + expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty( + 'verticalAlignment' + ); + expect( transformedBlocks[ 0 ].innerBlocks ).toHaveLength( 2 ); + expect( transformedBlocks[ 0 ].innerBlocks[ 0 ] ).toMatchObject( { + name: 'core/paragraph', + attributes: { content: 'One' }, + } ); + expect( transformedBlocks[ 0 ].innerBlocks[ 1 ] ).toMatchObject( { + name: 'core/group', + attributes: { layout: { type: 'constrained' } }, + innerBlocks: [ + expect.objectContaining( { + name: 'core/paragraph', + attributes: { content: 'Two' }, + } ), + expect.objectContaining( { + name: 'core/paragraph', + attributes: { content: 'Three' }, + } ), + ], + } ); + } ); +} ); diff --git a/packages/block-library/src/columns/transforms.js b/packages/block-library/src/columns/transforms.js index 3a15892431f251..e00ddef06ce366 100644 --- a/packages/block-library/src/columns/transforms.js +++ b/packages/block-library/src/columns/transforms.js @@ -8,6 +8,21 @@ import { const MAXIMUM_SELECTED_BLOCKS = 6; +const getGridInnerBlocks = ( innerBlocks ) => + innerBlocks.flatMap( ( column ) => { + const columnInnerBlocks = column.innerBlocks || []; + if ( columnInnerBlocks.length > 1 ) { + return [ + createBlock( + 'core/group', + { layout: { type: 'constrained' } }, + columnInnerBlocks + ), + ]; + } + return columnInnerBlocks; + } ); + const transforms = { from: [ { @@ -105,6 +120,25 @@ const transforms = { }, }, ], + to: [ + { + type: 'block', + blocks: [ 'core/group' ], + variationName: 'group-grid', + transform: ( attributes, innerBlocks ) => { + return createBlock( + 'core/group', + { + ...attributes, + isStackedOnMobile: undefined, + verticalAlignment: undefined, + layout: { type: 'grid' }, + }, + getGridInnerBlocks( innerBlocks ) + ); + }, + }, + ], ungroup: ( attributes, innerBlocks ) => innerBlocks.flatMap( ( innerBlock ) => innerBlock.innerBlocks ), }; diff --git a/packages/block-library/src/gallery/test/transforms.js b/packages/block-library/src/gallery/test/transforms.js new file mode 100644 index 00000000000000..7ef57237495131 --- /dev/null +++ b/packages/block-library/src/gallery/test/transforms.js @@ -0,0 +1,131 @@ +/** + * WordPress dependencies + */ +import { + createBlock, + getBlockTypes, + registerBlockType, + switchToBlockType, + unregisterBlockType, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { + metadata as galleryMetadata, + settings as gallerySettings, +} from '../index'; +import { + metadata as groupMetadata, + settings as groupSettings, +} from '../../group'; + +describe( 'transforms', () => { + beforeAll( () => { + registerBlockType( galleryMetadata, gallerySettings ); + registerBlockType( groupMetadata, groupSettings ); + registerBlockType( 'core/image', { + apiVersion: 3, + attributes: { + url: { + type: 'string', + }, + alt: { + type: 'string', + }, + caption: { + type: 'rich-text', + }, + id: { + type: 'number', + }, + sizeSlug: { + type: 'string', + }, + }, + save: () => {}, + category: 'media', + title: 'Image', + } ); + } ); + + afterAll( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + it( 'transforms Gallery to the Grid variation of Group with images as direct children', () => { + const firstImage = createBlock( 'core/image', { + url: 'https://example.com/one.jpg', + alt: 'One', + id: 1, + sizeSlug: 'large', + } ); + const secondImage = createBlock( 'core/image', { + url: 'https://example.com/two.jpg', + alt: 'Two', + id: 2, + sizeSlug: 'large', + } ); + const block = createBlock( + 'core/gallery', + { + align: 'wide', + caption: 'Gallery caption', + columns: 2, + imageCrop: false, + }, + [ firstImage, secondImage ] + ); + + const transformedBlocks = switchToBlockType( + block, + 'core/group', + 'group-grid' + ); + + expect( transformedBlocks[ 0 ] ).toMatchObject( { + name: 'core/group', + attributes: { + align: 'wide', + layout: { type: 'grid' }, + }, + } ); + expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty( + 'caption' + ); + expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty( + 'columns' + ); + expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty( + 'imageCrop' + ); + expect( transformedBlocks[ 0 ].innerBlocks ).toHaveLength( 2 ); + expect( transformedBlocks[ 0 ].innerBlocks[ 0 ] ).toMatchObject( { + name: 'core/image', + attributes: { + url: 'https://example.com/one.jpg', + alt: 'One', + id: 1, + sizeSlug: 'large', + }, + } ); + expect( transformedBlocks[ 0 ].innerBlocks[ 1 ] ).toMatchObject( { + name: 'core/image', + attributes: { + url: 'https://example.com/two.jpg', + alt: 'Two', + id: 2, + sizeSlug: 'large', + }, + } ); + expect( transformedBlocks[ 0 ].innerBlocks[ 0 ].clientId ).not.toBe( + firstImage.clientId + ); + expect( transformedBlocks[ 0 ].innerBlocks[ 1 ].clientId ).not.toBe( + secondImage.clientId + ); + } ); +} ); diff --git a/packages/block-library/src/gallery/transforms.js b/packages/block-library/src/gallery/transforms.js index b57e74a868effe..4dbe9dddc064d7 100644 --- a/packages/block-library/src/gallery/transforms.js +++ b/packages/block-library/src/gallery/transforms.js @@ -22,6 +22,15 @@ const parseShortcodeIds = ( ids ) => { return ids.split( ',' ).map( ( id ) => parseInt( id, 10 ) ); }; +const cloneInnerBlocks = ( innerBlocks ) => + innerBlocks.map( ( innerBlock ) => + createBlock( + innerBlock.name, + innerBlock.attributes, + cloneInnerBlocks( innerBlock.innerBlocks || [] ) + ) + ); + /** * Third party block plugins don't have an easy way to detect if the * innerBlocks version of the Gallery is running when they run a @@ -261,6 +270,38 @@ const transforms = { return createBlock( 'core/image', { align } ); }, }, + { + type: 'block', + blocks: [ 'core/group' ], + variationName: 'group-grid', + transform: ( attributes, innerBlocks ) => { + const { + allowResize, + aspectRatio, + caption, + columns, + fixedHeight, + ids, + imageCrop, + images, + linkTarget, + linkTo, + navigationButtonType, + randomOrder, + shortCodeTransforms, + sizeSlug, + ...rest + } = attributes; + return createBlock( + 'core/group', + { + ...rest, + layout: { type: 'grid' }, + }, + cloneInnerBlocks( innerBlocks ) + ); + }, + }, ], }; diff --git a/packages/blocks/README.md b/packages/blocks/README.md index ee80df05735c50..d027785ebaef5f 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -280,7 +280,7 @@ _Parameters_ _Returns_ -- `BlockType[]`: Block types that the blocks argument can be transformed to. +- `BlockTypeWithTransformMetadata[]`: Block types that the blocks argument can be transformed to. ### getSaveContent @@ -826,6 +826,7 @@ _Parameters_ - _blocks_ `Block[] | Block`: Blocks array or block object. - _name_ `string`: Block name. +- _variationName_ `string`: Optional target block variation name. _Returns_ diff --git a/packages/blocks/src/api/factory.ts b/packages/blocks/src/api/factory.ts index 363e2a6e12cf28..3d79d693c33a38 100644 --- a/packages/blocks/src/api/factory.ts +++ b/packages/blocks/src/api/factory.ts @@ -23,6 +23,18 @@ import { } from './utils'; import type { Block, BlockType, BlockTransform } from '../types'; +type BlockTypeWithTransformMetadata = BlockType & { + variationName?: string; +}; + +const getBlockTypeWithTransformMetadata = ( + blockType: BlockType, + transform: BlockTransform +): BlockTypeWithTransformMetadata => + transform.variationName + ? { ...blockType, variationName: transform.variationName } + : blockType; + /** * Returns a block object given its type and attributes. * @@ -260,24 +272,24 @@ const isPossibleTransformForSource = ( */ const getBlockTypesForPossibleFromTransforms = ( blocks: Block[] -): BlockType[] => { +): BlockTypeWithTransformMetadata[] => { if ( ! blocks.length ) { return []; } const allBlockTypes = getBlockTypes(); - // filter all blocks to find those with a 'from' transform. - const blockTypesWithPossibleFromTransforms = allBlockTypes.filter( + // Filter all blocks to find those with a 'from' transform. + const blockTypesWithPossibleFromTransforms = allBlockTypes.flatMap( ( blockType ) => { const fromTransforms = getBlockTransforms( 'from', blockType.name ); - return !! findTransform( fromTransforms, ( transform ) => { - return isPossibleTransformForSource( - transform, - 'from', - blocks + return fromTransforms + .filter( ( transform ) => + isPossibleTransformForSource( transform, 'from', blocks ) + ) + .map( ( transform ) => + getBlockTypeWithTransformMetadata( blockType, transform ) ); - } ); } ); @@ -294,7 +306,7 @@ const getBlockTypesForPossibleFromTransforms = ( */ const getBlockTypesForPossibleToTransforms = ( blocks: Block[] -): BlockType[] => { +): BlockTypeWithTransformMetadata[] => { if ( ! blocks.length ) { return []; } @@ -312,16 +324,20 @@ const getBlockTypesForPossibleToTransforms = ( ); } ); - // Build a list of block names using the possible 'to' transforms. - const blockNames = possibleTransforms - .map( ( transformation ) => transformation.blocks ) - .flat(); - // Map block names to block types. - return blockNames - .filter( ( name ): name is string => !! name ) - .map( getBlockType ) - .filter( ( bt ): bt is BlockType => !! bt ); + return possibleTransforms + .flatMap( ( transformation ) => { + return ( transformation.blocks || [] ).map( ( name ) => { + const transformedBlockType = getBlockType( name ); + return transformedBlockType + ? getBlockTypeWithTransformMetadata( + transformedBlockType, + transformation + ) + : undefined; + } ); + } ) + .filter( ( bt ): bt is BlockTypeWithTransformMetadata => !! bt ); }; /** @@ -363,7 +379,7 @@ export const isContainerGroupBlock = ( name: string ): boolean => */ export function getPossibleBlockTransformations( blocks: Block[] -): BlockType[] { +): BlockTypeWithTransformMetadata[] { if ( ! blocks.length ) { return []; } @@ -373,12 +389,24 @@ export function getPossibleBlockTransformations( const blockTypesForToTransforms = getBlockTypesForPossibleToTransforms( blocks ); - return [ - ...new Set( [ - ...blockTypesForFromTransforms, - ...blockTypesForToTransforms, - ] ), - ]; + const blockTypesByNameAndVariation = new Map< + string, + BlockTypeWithTransformMetadata + >(); + + for ( const blockType of [ + ...blockTypesForFromTransforms, + ...blockTypesForToTransforms, + ] ) { + const key = blockType.variationName + ? `${ blockType.name }/${ blockType.variationName }` + : blockType.name; + if ( ! blockTypesByNameAndVariation.has( key ) ) { + blockTypesByNameAndVariation.set( key, blockType ); + } + } + + return [ ...blockTypesByNameAndVariation.values() ]; } /** @@ -511,14 +539,16 @@ function maybeCheckTransformIsMatch( /** * Switch one or more blocks into one or more blocks of the new block type. * - * @param blocks Blocks array or block object. - * @param name Block name. + * @param blocks Blocks array or block object. + * @param name Block name. + * @param variationName Optional target block variation name. * * @return Array of blocks or null. */ export function switchToBlockType( blocks: Block[] | Block, - name: string + name: string, + variationName?: string ): Block[] | null { const blocksArray = Array.isArray( blocks ) ? blocks : [ blocks ]; const isMultiBlock = blocksArray.length > 1; @@ -529,12 +559,15 @@ export function switchToBlockType( // transformation. const transformationsFrom = getBlockTransforms( 'from', name ); const transformationsTo = getBlockTransforms( 'to', sourceName ); + const isMatchingVariation = ( t: BlockTransform ) => + variationName ? t.variationName === variationName : ! t.variationName; const transformation = findTransform( transformationsTo, ( t ) => t.type === 'block' && + isMatchingVariation( t ) && ( isWildcardBlockTransform( t ) || t.blocks!.indexOf( name ) !== -1 ) && ( ! isMultiBlock || !! t.isMultiBlock ) && @@ -544,6 +577,7 @@ export function switchToBlockType( transformationsFrom, ( t ) => t.type === 'block' && + isMatchingVariation( t ) && ( isWildcardBlockTransform( t ) || t.blocks!.indexOf( sourceName ) !== -1 ) && ( ! isMultiBlock || !! t.isMultiBlock ) && diff --git a/packages/blocks/src/api/test/factory.js b/packages/blocks/src/api/test/factory.js index 454a5e1563d6c8..f3a09de9efb518 100644 --- a/packages/blocks/src/api/test/factory.js +++ b/packages/blocks/src/api/test/factory.js @@ -530,6 +530,45 @@ describe( 'block factory', () => { expect( availableBlocks[ 0 ].name ).toBe( 'core/text-block' ); } ); + it( 'should preserve variation metadata for possible transformations', () => { + registerBlockType( 'core/updated-text-block', { + apiVersion: 3, + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ + { + type: 'block', + blocks: [ 'core/text-block' ], + variationName: 'grid', + transform: noop, + }, + ], + }, + save: noop, + category: 'text', + title: 'updated text block', + } ); + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const block = createBlock( 'core/updated-text-block', { + value: 'ribs', + } ); + + const availableBlocks = getPossibleBlockTransformations( [ + block, + ] ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ] ).toMatchObject( { + name: 'core/text-block', + variationName: 'grid', + } ); + } ); + it( 'should not show a transformation if multiple blocks are passed and the transformation is not multi block (for a "from" transform)', () => { registerBlockType( 'core/updated-text-block', { apiVersion: 3, @@ -1260,6 +1299,70 @@ describe( 'block factory', () => { } ); } ); + it( 'should switch the blockType of a block using a variation transform', () => { + registerBlockType( 'core/group-block', { + ...defaultBlockSettings, + attributes: { + layout: { + type: 'object', + }, + }, + } ); + registerBlockType( 'core/text-block', { + apiVersion: 3, + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + to: [ + { + type: 'block', + blocks: [ 'core/group-block' ], + transform: () => + createBlock( 'core/group-block', { + layout: { type: 'constrained' }, + } ), + }, + { + type: 'block', + blocks: [ 'core/group-block' ], + variationName: 'grid', + transform: () => + createBlock( 'core/group-block', { + layout: { type: 'grid' }, + } ), + }, + ], + }, + save: noop, + category: 'text', + title: 'text-block', + } ); + + const block = createBlock( 'core/text-block', { + value: 'ribs', + } ); + + const transformedBlocks = switchToBlockType( + block, + 'core/group-block', + 'grid' + ); + const defaultTransformedBlocks = switchToBlockType( + block, + 'core/group-block' + ); + + expect( transformedBlocks[ 0 ].attributes ).toEqual( { + layout: { type: 'grid' }, + } ); + expect( defaultTransformedBlocks[ 0 ].attributes ).toEqual( { + layout: { type: 'constrained' }, + } ); + } ); + it( 'should return null if no transformation is found', () => { registerBlockType( 'core/updated-text-block', diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts index b6519a43317cc8..83894f5027a0a1 100644 --- a/packages/blocks/src/types.ts +++ b/packages/blocks/src/types.ts @@ -156,6 +156,11 @@ export interface BlockTransform< > { type: 'block' | 'enter' | 'files' | 'prefix' | 'raw' | 'shortcode'; blocks?: string[]; + /** + * The target block variation name for block transforms that produce a + * variation of the transformed block type. + */ + variationName?: string; priority?: number; isMultiBlock?: boolean; isMatch?: ( From d4c8d8cf41777b324d1813e6dde7607708879a26 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 28 May 2026 13:35:28 +1000 Subject: [PATCH 2/3] ensure correct number of columns --- .../src/columns/test/transforms.js | 56 ++++++++++++++++++- .../block-library/src/columns/transforms.js | 42 +++++++++++++- .../src/gallery/test/transforms.js | 26 ++++++++- .../block-library/src/gallery/transforms.js | 8 ++- 4 files changed, 128 insertions(+), 4 deletions(-) diff --git a/packages/block-library/src/columns/test/transforms.js b/packages/block-library/src/columns/test/transforms.js index 2fd84ffbd5782f..8652b06b9e8916 100644 --- a/packages/block-library/src/columns/test/transforms.js +++ b/packages/block-library/src/columns/test/transforms.js @@ -78,7 +78,7 @@ describe( 'transforms', () => { name: 'core/group', attributes: { align: 'wide', - layout: { type: 'grid' }, + layout: { type: 'grid', columnCount: 2 }, }, } ); expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty( @@ -107,4 +107,58 @@ describe( 'transforms', () => { ], } ); } ); + + it( 'transforms Grid variation of Group to Columns using the explicit grid column count', () => { + const block = createBlock( + 'core/group', + { + align: 'wide', + layout: { type: 'grid', columnCount: 3 }, + }, + [ + createBlock( 'core/paragraph', { content: 'One' } ), + createBlock( 'core/paragraph', { content: 'Two' } ), + ] + ); + + const transformedBlocks = switchToBlockType( block, 'core/columns' ); + + expect( transformedBlocks[ 0 ] ).toMatchObject( { + name: 'core/columns', + attributes: { + align: 'wide', + }, + } ); + expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty( + 'layout' + ); + expect( transformedBlocks[ 0 ].innerBlocks ).toHaveLength( 3 ); + expect( transformedBlocks[ 0 ].innerBlocks ).toEqual( [ + expect.objectContaining( { + name: 'core/column', + attributes: { width: '33.33%' }, + innerBlocks: [ + expect.objectContaining( { + name: 'core/paragraph', + attributes: { content: 'One' }, + } ), + ], + } ), + expect.objectContaining( { + name: 'core/column', + attributes: { width: '33.33%' }, + innerBlocks: [ + expect.objectContaining( { + name: 'core/paragraph', + attributes: { content: 'Two' }, + } ), + ], + } ), + expect.objectContaining( { + name: 'core/column', + attributes: { width: '33.33%' }, + innerBlocks: [], + } ), + ] ); + } ); } ); diff --git a/packages/block-library/src/columns/transforms.js b/packages/block-library/src/columns/transforms.js index e00ddef06ce366..a1b8db0491aeea 100644 --- a/packages/block-library/src/columns/transforms.js +++ b/packages/block-library/src/columns/transforms.js @@ -8,6 +8,23 @@ import { const MAXIMUM_SELECTED_BLOCKS = 6; +const getColumnBlocksFromGrid = ( innerBlocks, columnCount ) => { + const columnWidth = +( 100 / columnCount ).toFixed( 2 ); + const innerBlocksTemplate = Array.from( + { length: columnCount }, + ( _, columnIndex ) => [ + 'core/column', + { width: `${ columnWidth }%` }, + innerBlocks.filter( + ( _innerBlock, blockIndex ) => + blockIndex % columnCount === columnIndex + ), + ] + ); + + return createBlocksFromInnerBlocksTemplate( innerBlocksTemplate ); +}; + const getGridInnerBlocks = ( innerBlocks ) => innerBlocks.flatMap( ( column ) => { const columnInnerBlocks = column.innerBlocks || []; @@ -25,6 +42,25 @@ const getGridInnerBlocks = ( innerBlocks ) => const transforms = { from: [ + { + type: 'block', + blocks: [ 'core/group' ], + priority: 1, + transform: ( attributes, innerBlocks ) => { + const { layout, ...rest } = attributes; + const { columnCount } = layout; + + return createBlock( + 'core/columns', + rest, + getColumnBlocksFromGrid( innerBlocks, columnCount ) + ); + }, + isMatch: ( { layout } ) => + layout?.type === 'grid' && + Number.isInteger( layout?.columnCount ) && + layout.columnCount > 0, + }, { type: 'block', isMultiBlock: true, @@ -126,13 +162,17 @@ const transforms = { blocks: [ 'core/group' ], variationName: 'group-grid', transform: ( attributes, innerBlocks ) => { + const columnCount = innerBlocks.length; return createBlock( 'core/group', { ...attributes, isStackedOnMobile: undefined, verticalAlignment: undefined, - layout: { type: 'grid' }, + layout: { + type: 'grid', + ...( columnCount && { columnCount } ), + }, }, getGridInnerBlocks( innerBlocks ) ); diff --git a/packages/block-library/src/gallery/test/transforms.js b/packages/block-library/src/gallery/test/transforms.js index 7ef57237495131..80563fbfe9efcf 100644 --- a/packages/block-library/src/gallery/test/transforms.js +++ b/packages/block-library/src/gallery/test/transforms.js @@ -90,7 +90,7 @@ describe( 'transforms', () => { name: 'core/group', attributes: { align: 'wide', - layout: { type: 'grid' }, + layout: { type: 'grid', columnCount: 2 }, }, } ); expect( transformedBlocks[ 0 ].attributes ).not.toHaveProperty( @@ -128,4 +128,28 @@ describe( 'transforms', () => { secondImage.clientId ); } ); + + it( 'transforms Gallery to the Grid variation of Group with the default gallery column count', () => { + const block = createBlock( 'core/gallery', {}, [ + createBlock( 'core/image', { + url: 'https://example.com/one.jpg', + } ), + createBlock( 'core/image', { + url: 'https://example.com/two.jpg', + } ), + ] ); + + const transformedBlocks = switchToBlockType( + block, + 'core/group', + 'group-grid' + ); + + expect( transformedBlocks[ 0 ] ).toMatchObject( { + name: 'core/group', + attributes: { + layout: { type: 'grid', columnCount: 2 }, + }, + } ); + } ); } ); diff --git a/packages/block-library/src/gallery/transforms.js b/packages/block-library/src/gallery/transforms.js index 4dbe9dddc064d7..f23af191574509 100644 --- a/packages/block-library/src/gallery/transforms.js +++ b/packages/block-library/src/gallery/transforms.js @@ -13,6 +13,7 @@ import { LINK_DESTINATION_NONE, LINK_DESTINATION_MEDIA, } from './constants'; +import { defaultColumnsNumber } from './shared'; const parseShortcodeIds = ( ids ) => { if ( ! ids ) { @@ -296,7 +297,12 @@ const transforms = { 'core/group', { ...rest, - layout: { type: 'grid' }, + layout: { + type: 'grid', + columnCount: + columns ?? + defaultColumnsNumber( innerBlocks.length ), + }, }, cloneInnerBlocks( innerBlocks ) ); From 3bbe0f76fc1d37ffcace6342b9f0060f23a53a96 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Mon, 1 Jun 2026 10:11:00 +1000 Subject: [PATCH 3/3] simplify helper function --- packages/block-editor/src/store/selectors.js | 68 +++++++++++--------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 07abddb841151e..b634fb16f670ac 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2191,28 +2191,6 @@ const getItemFromVariation = ( state, item ) => ( variation ) => { }; }; -const getBlockTransformItemFromVariation = ( state, item, variationName ) => { - if ( ! variationName ) { - return item; - } - const variation = getBlockVariations( item.name, 'transform' )?.find( - ( { name } ) => name === variationName - ); - if ( ! variation ) { - return item; - } - const variationId = `${ item.id }/${ variation.name }`; - const { time, count = 0 } = getInsertUsage( state, variationId ) || {}; - return { - ...item, - id: variationId, - icon: variation.icon || item.icon, - title: variation.title || item.title, - frecency: calculateFrecency( time, count ), - variationName: variation.name, - }; -}; - /** * Returns the calculated frecency. * @@ -2316,6 +2294,19 @@ const buildBlockTypeItem = }; }; +const buildBlockVariationItem = ( state, item ) => ( variation ) => { + const variationId = `${ item.id }/${ variation.name }`; + const { time, count = 0 } = getInsertUsage( state, variationId ) || {}; + return { + ...item, + id: variationId, + icon: variation.icon || item.icon, + title: variation.title || item.title, + frecency: calculateFrecency( time, count ), + variationName: variation.name, + }; +}; + /** * Determines the items that appear in the inserter. Includes both static * items (e.g. a regular block type) and dynamic items (e.g. a reusable block). @@ -2540,15 +2531,32 @@ export const getBlockTransformItems = createRegistrySelector( ( select ) => const possibleTransforms = getPossibleBlockTransformations( normalizedBlocks ).reduce( ( accumulator, block ) => { - if ( itemsByName[ block?.name ] ) { - accumulator.push( - getBlockTransformItemFromVariation( - state, - itemsByName[ block.name ], - block.variationName - ) - ); + const item = itemsByName[ block?.name ]; + + if ( ! item ) { + return accumulator; } + + const { variationName } = block; + + if ( ! variationName ) { + accumulator.push( item ); + return accumulator; + } + + const variation = getBlockVariations( + item.name, + 'transform' + )?.find( ( { name } ) => name === variationName ); + + if ( ! variation ) { + accumulator.push( item ); + return accumulator; + } + + accumulator.push( + buildBlockVariationItem( state, item )( variation ) + ); return accumulator; }, [] ); return orderBy(