From 7825662eca0f12d1d72280ebf0fce018995217fc Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Fri, 14 Aug 2026 14:43:36 +0200 Subject: [PATCH] Block API: Scope variation keyboard shortcuts to their own block type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A variation shortcut applied to any block with a transform to the variation's block type, so a shortcut on a variation of a block with a wildcard transform — group, quote, details, columns — would have fired on every selected block. It now applies only where the selected block is already of that type. Reaching a variation from another block type is a transform, and is declared as one: a `type: 'block'` transform takes a `shortcuts` list rather than a single `shortcut`, and each entry may name the variation it produces. One transform can therefore carry a shortcut per variation without gaining an entry in the block switcher for each. The heading level shortcuts are declared in both places under one name: on the variations, for a heading, and on the transform from a paragraph. Registering a name more than once is expected as long as the declarations agree; only conflicting ones warn. Co-Authored-By: Claude Opus 5 --- .../block-api/block-transforms.md | 50 ++++++++++---- .../block-api/block-variations.md | 4 +- .../block-keyboard-shortcuts/index.js | 61 ++++++++++------- .../block-library/src/heading/transforms.js | 49 +++++++++----- .../blocks/src/store/private-selectors.ts | 50 +++++++------- .../src/store/test/private-selectors.js | 65 +++++++++++++++++-- packages/blocks/src/types.ts | 36 +++++++--- 7 files changed, 222 insertions(+), 93 deletions(-) diff --git a/docs/reference-guides/block-api/block-transforms.md b/docs/reference-guides/block-api/block-transforms.md index 27fa7198af45cb..444b0dbaa5ced8 100644 --- a/docs/reference-guides/block-api/block-transforms.md +++ b/docs/reference-guides/block-api/block-transforms.md @@ -46,20 +46,21 @@ A transformation of type `block` is an object that takes the following parameter - **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. -- **shortcut** _(object, optional)_: a keyboard shortcut that applies the transform to the selected block. See [Keyboard shortcuts](#keyboard-shortcuts) below. +- **shortcuts** _(array, optional)_: keyboard shortcuts that apply the transform to the selected block. See [Keyboard shortcuts](#keyboard-shortcuts) below. #### Keyboard shortcuts -A transform of type `block` can declare a keyboard shortcut, which is registered with the editor's keyboard shortcuts store and listed in the keyboard shortcuts help modal. Pressing the key combination transforms the selected block, as long as the block is fully editable. +A transform of type `block` can declare keyboard shortcuts, which are registered with the editor's keyboard shortcuts store and listed in the keyboard shortcuts help modal. Pressing the key combination transforms the selected block, as long as the block is fully editable. -The `shortcut` object takes the following parameters: +Each entry of `shortcuts` takes the following parameters: - **name** _(string)_: a unique and machine-readable shortcut name, e.g. `core/block-editor/transform-heading-to-paragraph`. - **description** _(string)_: a translated description, displayed in the keyboard shortcuts help modal. - **keyCombination** _(object)_: the key combination that triggers the shortcut, as a `character` and an optional `modifier` (one of the modifiers supported by the [`wp-keycodes` package](/packages/keycodes/README.md), e.g. `access` or `primary`). - **aliases** _(array, optional)_: alternative key combinations that trigger the same shortcut. +- **variationName** _(string, optional)_: the variation of the produced block the shortcut targets, defaulting to the transform's own `variationName`. Declaring it per shortcut is what lets a single transform offer one shortcut per variation without appearing more than once in the block switcher. -On a `to` transform the shortcut applies to the block declaring it, and produces the first entry of `blocks`. On a `from` transform it applies to any block listed in `blocks`, and produces the block declaring it. +On a `to` transform the shortcuts apply to the block declaring them, and produce the first entry of `blocks`. On a `from` transform they apply to any block listed in `blocks`, and produce the block declaring them. **Example: transform a Heading block into a Paragraph block with Alt+Shift+0** @@ -69,13 +70,15 @@ transforms: { { type: 'block', blocks: [ 'core/paragraph' ], - shortcut: { - name: 'core/block-editor/transform-heading-to-paragraph', - description: __( - 'Transform the selected heading into a paragraph.' - ), - keyCombination: { modifier: 'access', character: '0' }, - }, + shortcuts: [ + { + name: 'core/block-editor/transform-heading-to-paragraph', + description: __( + 'Transform the selected heading into a paragraph.' + ), + keyCombination: { modifier: 'access', character: '0' }, + }, + ], transform: ( { content } ) => { return createBlock( 'core/paragraph', { content } ); }, @@ -84,9 +87,32 @@ transforms: { }, ``` +**Example: transform any block into a specific heading level** + +The Heading block hangs one shortcut per level on its single transform from Paragraph, so that Alt+Shift+2 on a paragraph produces an H2 while the block switcher still offers "Heading" once: + +```js +transforms: { + from: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + shortcuts: [ 1, 2, 3, 4, 5, 6 ].map( ( level ) => ( { + name: `core/block-editor/transform-to-heading-${ level }`, + description: /* … */, + keyCombination: { modifier: 'access', character: `${ level }` }, + variationName: `h${ level }`, + } ) ), + transform: ( { content } ) => + createBlock( 'core/heading', { content } ), + }, + ], +}, +``` + Key combinations are matched globally, so a combination already claimed by another block or by the editor itself will not reliably reach your transform. In development builds a warning is logged when two blocks declare the same combination. -To apply a variation of a block type rather than the block type itself, declare the shortcut on the [block variation](/docs/reference-guides/block-api/block-variations.md#using-shortcut) instead. +To switch between variations of a block that is *already* of the target type, declare the shortcut on the [block variation](/docs/reference-guides/block-api/block-variations.md#using-shortcut) instead. A shortcut that should do both — reach the variation from another block type and switch to it within the type — is declared in both places under the same `name`. **Example: from Paragraph block to Heading block** diff --git a/docs/reference-guides/block-api/block-variations.md b/docs/reference-guides/block-api/block-variations.md index 5bcfcd138ed993..bfb5d2f4a22019 100644 --- a/docs/reference-guides/block-api/block-variations.md +++ b/docs/reference-guides/block-api/block-variations.md @@ -276,7 +276,9 @@ wp.blocks.registerBlockVariation( 'core/heading', { } ); ``` -The shortcut is not limited to blocks of the variation's own block type. When the selected block is already of that type, the variation's `attributes` are applied to it — unless `isActive` reports the variation as already active, in which case nothing happens. Otherwise the selected block is transformed to the variation's block type first, which requires a [block transform](/docs/reference-guides/block-api/block-transforms.md#block) between the two block types to exist. In the example above, that is what lets the shortcut turn a Paragraph block into a Heading block as well as change the level of an existing one. +The shortcut only applies to blocks that are already of the variation's own block type: it sets the variation's `attributes` on the selected block, and does nothing when `isActive` reports the variation as already active. In the example above, that is what lets the shortcut change the level of an existing Heading block. + +To reach the variation from a *different* block type, declare the same shortcut on a [block transform](/docs/reference-guides/block-api/block-transforms.md#keyboard-shortcuts) that produces this block, giving it the same `name` and a `variationName` pointing back at the variation. The Heading block does both, which is why Alt+Shift+2 turns a Paragraph into an H2 as well as changing an existing heading's level. Declaring one shortcut in several places is expected, as long as every declaration agrees on the key combination and description; the two can never both apply to the same selection. Shortcuts only apply to blocks that are fully editable, and are ignored for blocks in a restricted editing mode such as content-only editing. diff --git a/packages/block-editor/src/components/block-keyboard-shortcuts/index.js b/packages/block-editor/src/components/block-keyboard-shortcuts/index.js index 7be4b41c1537fe..f670cef2c0c784 100644 --- a/packages/block-editor/src/components/block-keyboard-shortcuts/index.js +++ b/packages/block-editor/src/components/block-keyboard-shortcuts/index.js @@ -60,7 +60,7 @@ function useApplyBlockShortcut() { } const blockName = getBlockName( clientId ); - if ( blockNames && ! blockNames.includes( blockName ) ) { + if ( ! blockNames.includes( blockName ) ) { return false; } @@ -159,30 +159,47 @@ function BlockShortcutsRegister( { shortcuts } ) { const { registerShortcut, unregisterShortcut } = useDispatch( keyboardShortcutsStore ); - // Shortcuts registered by a previous run, so that those belonging to a - // block type that has since been unregistered can be removed. Registration - // deliberately outlives this component: providers can be nested, and - // unmounting one of them must not take the shortcuts away from the others. - const registeredRef = useRef( new Set() ); + // Shortcuts registered by a previous run, keyed by name, so that those + // belonging to a block type that has since been unregistered can be + // removed. Registration deliberately outlives this component: providers can + // be nested, and unmounting one of them must not take the shortcuts away + // from the others. + const registeredRef = useRef( new Map() ); useEffect( () => { - const registered = new Set(); + const registered = new Map(); const combinations = new Map(); for ( const shortcut of shortcuts ) { - // The store keys shortcuts by name, so the second registration - // would overwrite the first and leave one of the two blocks - // answering to the other's key combination. - if ( registered.has( shortcut.name ) ) { - warning( - `Block keyboard shortcut "${ shortcut.name }" is declared more than once.` - ); + const config = { + name: shortcut.name, + category: 'block', + description: shortcut.description, + keyCombination: shortcut.keyCombination, + aliases: shortcut.aliases, + }; + const serialized = JSON.stringify( config ); + + // One shortcut is declared once per block change it can make, so + // the same name legitimately appears more than once: the heading + // level shortcuts are declared both on the variation, for a + // heading, and on the transform that produces one from a + // paragraph. Only declarations that disagree are a mistake, since + // the store keys shortcuts by name and the last one registered + // would decide what the others are matched against. + const previous = registered.get( shortcut.name ); + if ( previous !== undefined ) { + if ( previous !== serialized ) { + warning( + `Block keyboard shortcut "${ shortcut.name }" is declared more than once with different settings.` + ); + } continue; } // Handlers are matched by key combination rather than by name, so - // two blocks claiming the same one both run on the same event, in - // registration order, and the first that applies takes it. + // two shortcuts claiming the same one both run on the same event, + // in registration order, and the first that applies takes it. const { modifier, character } = shortcut.keyCombination; const combination = `${ modifier ?? '' }+${ character }`; if ( combinations.has( combination ) ) { @@ -196,17 +213,11 @@ function BlockShortcutsRegister( { shortcuts } ) { } combinations.set( combination, shortcut.name ); - registerShortcut( { - name: shortcut.name, - category: 'block', - description: shortcut.description, - keyCombination: shortcut.keyCombination, - aliases: shortcut.aliases, - } ); - registered.add( shortcut.name ); + registerShortcut( config ); + registered.set( shortcut.name, serialized ); } - for ( const name of registeredRef.current ) { + for ( const name of registeredRef.current.keys() ) { if ( ! registered.has( name ) ) { unregisterShortcut( name ); } diff --git a/packages/block-library/src/heading/transforms.js b/packages/block-library/src/heading/transforms.js index 5de8260cb567c2..c00fdc836c2a26 100644 --- a/packages/block-library/src/heading/transforms.js +++ b/packages/block-library/src/heading/transforms.js @@ -1,5 +1,5 @@ import { createBlock, getBlockAttributes } from '@wordpress/blocks'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { getLevelFromHeadingNodeName } from './shared'; import { getTransformedAttributes } from '../utils/get-transformed-attributes'; @@ -9,6 +9,23 @@ const transforms = { type: 'block', isMultiBlock: true, blocks: [ 'core/paragraph' ], + // The level shortcuts are declared here as well as on the + // variations, so that they reach a paragraph and not only a + // heading. Both declarations describe the same shortcut, and only + // one of them can apply to any given selection. + shortcuts: [ 1, 2, 3, 4, 5, 6 ].map( ( level ) => ( { + name: `core/block-editor/transform-to-heading-${ level }`, + description: sprintf( + /* translators: %d: heading level e.g: "1", "2", "3" */ + __( 'Transform the selected block into a heading %d.' ), + level + ), + keyCombination: { + modifier: 'access', + character: `${ level }`, + }, + variationName: `h${ level }`, + } ) ), transform: ( attributes ) => attributes.map( ( _attributes ) => { const { content, anchor, style } = _attributes; @@ -97,22 +114,24 @@ const transforms = { type: 'block', isMultiBlock: true, blocks: [ 'core/paragraph' ], - shortcut: { - name: 'core/block-editor/transform-heading-to-paragraph', - description: __( - 'Transform the selected heading into a paragraph.' - ), - keyCombination: { - modifier: 'access', - character: '0', - }, - aliases: [ - { + shortcuts: [ + { + name: 'core/block-editor/transform-heading-to-paragraph', + description: __( + 'Transform the selected heading into a paragraph.' + ), + keyCombination: { modifier: 'access', - character: '7', + character: '0', }, - ], - }, + aliases: [ + { + modifier: 'access', + character: '7', + }, + ], + }, + ], transform: ( attributes ) => attributes.map( ( _attributes ) => { const { content, style } = _attributes; diff --git a/packages/blocks/src/store/private-selectors.ts b/packages/blocks/src/store/private-selectors.ts index 80dde375116c26..832e70b9dace3b 100644 --- a/packages/blocks/src/store/private-selectors.ts +++ b/packages/blocks/src/store/private-selectors.ts @@ -21,10 +21,9 @@ type ResolvedBlockShortcut = BlockShortcut & { */ targetBlockName: string; /** - * The block types the shortcut applies to. When undefined, the shortcut - * applies to any block that can be transformed to `targetBlockName`. + * The block types the shortcut applies to. */ - blockNames?: string[]; + blockNames: string[]; /** * The variation of `targetBlockName` the shortcut produces, if any. */ @@ -324,10 +323,9 @@ export const getBlockKeyboardShortcuts = createSelector( const shortcuts: ResolvedBlockShortcut[] = []; for ( const blockName of Object.keys( state.blockTypes ) ) { - // A variation shortcut produces the variation of the block type it - // is declared on. It is not restricted to a source block type: any - // block with a transform to this block type can be its subject, - // which is what makes `access+2` on a paragraph produce a heading. + // A variation shortcut only applies to blocks that are already of + // the block type declaring it. Reaching the variation from another + // block type is a transform, and is declared as one. for ( const variation of state.blockVariations[ blockName ] ?? [] ) { if ( ! variation.shortcut ) { @@ -336,6 +334,7 @@ export const getBlockKeyboardShortcuts = createSelector( shortcuts.push( { ...variation.shortcut, targetBlockName: blockName, + blockNames: [ blockName ], variationName: variation.name, } ); } @@ -346,35 +345,36 @@ export const getBlockKeyboardShortcuts = createSelector( // A `to` transform can list several target blocks, but a shortcut // can only produce one of them. The first is used. const targetBlockName = transform.blocks?.[ 0 ]; - if ( - ! transform.shortcut || - transform.type !== 'block' || - ! targetBlockName - ) { + if ( transform.type !== 'block' || ! targetBlockName ) { continue; } - shortcuts.push( { - ...transform.shortcut, - targetBlockName, - blockNames: [ blockName ], - variationName: transform.variationName, - } ); + for ( const shortcut of transform.shortcuts ?? [] ) { + shortcuts.push( { + ...shortcut, + targetBlockName, + blockNames: [ blockName ], + variationName: + shortcut.variationName ?? transform.variationName, + } ); + } } for ( const transform of transforms?.from ?? [] ) { if ( - ! transform.shortcut || transform.type !== 'block' || ! transform.blocks?.length ) { continue; } - shortcuts.push( { - ...transform.shortcut, - targetBlockName: blockName, - blockNames: transform.blocks, - variationName: transform.variationName, - } ); + for ( const shortcut of transform.shortcuts ?? [] ) { + shortcuts.push( { + ...shortcut, + targetBlockName: blockName, + blockNames: transform.blocks, + variationName: + shortcut.variationName ?? transform.variationName, + } ); + } } } diff --git a/packages/blocks/src/store/test/private-selectors.js b/packages/blocks/src/store/test/private-selectors.js index eaa9a78ddeaf35..fcdfca498225b0 100644 --- a/packages/blocks/src/store/test/private-selectors.js +++ b/packages/blocks/src/store/test/private-selectors.js @@ -237,7 +237,7 @@ describe( 'private selectors', () => { ).toEqual( [] ); } ); - it( 'resolves a variation shortcut to the declaring block type, without restricting the blocks it applies to', () => { + it( 'restricts a variation shortcut to the block type declaring it', () => { expect( getBlockKeyboardShortcuts( getState( { @@ -251,6 +251,7 @@ describe( 'private selectors', () => { { ...shortcut, targetBlockName: 'core/heading', + blockNames: [ 'core/heading' ], variationName: 'h1', }, ] ); @@ -268,7 +269,7 @@ describe( 'private selectors', () => { { type: 'block', blocks: [ 'core/paragraph' ], - shortcut, + shortcuts: [ shortcut ], }, ], }, @@ -301,7 +302,7 @@ describe( 'private selectors', () => { 'core/paragraph', 'core/list', ], - shortcut, + shortcuts: [ shortcut ], }, ], }, @@ -319,6 +320,55 @@ describe( 'private selectors', () => { ] ); } ); + it( 'lets one transform carry a shortcut per target variation', () => { + expect( + getBlockKeyboardShortcuts( + getState( { + blockTypes: [ + { + name: 'core/heading', + transforms: { + from: [ + { + type: 'block', + blocks: [ 'core/paragraph' ], + shortcuts: [ + { + ...shortcut, + name: 'test/h1', + variationName: 'h1', + }, + { + ...shortcut, + name: 'test/h2', + variationName: 'h2', + }, + ], + }, + ], + }, + }, + ], + } ) + ) + ).toEqual( [ + { + ...shortcut, + name: 'test/h1', + targetBlockName: 'core/heading', + blockNames: [ 'core/paragraph' ], + variationName: 'h1', + }, + { + ...shortcut, + name: 'test/h2', + targetBlockName: 'core/heading', + blockNames: [ 'core/paragraph' ], + variationName: 'h2', + }, + ] ); + } ); + it( 'ignores shortcuts on transforms that cannot apply them', () => { expect( getBlockKeyboardShortcuts( @@ -332,11 +382,16 @@ describe( 'private selectors', () => { { type: 'prefix', prefix: '#', - shortcut, + shortcuts: [ shortcut ], }, ], // No target block to switch to. - to: [ { type: 'block', shortcut } ], + to: [ + { + type: 'block', + shortcuts: [ shortcut ], + }, + ], }, }, ], diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts index 23eef5b9eaed72..6a228d31958384 100644 --- a/packages/blocks/src/types.ts +++ b/packages/blocks/src/types.ts @@ -70,6 +70,12 @@ export interface BlockShortcutKeyCombination { * * Shortcuts are registered with the `core/keyboard-shortcuts` store while the * block editor is mounted, and apply to the selected block. + * + * The same shortcut may be declared in more than one place, so long as every + * declaration agrees on the key combination and description. A shortcut that + * both switches a block's type and picks a variation of it is declared twice: + * once on the variation, for a block that is already of that type, and once on + * the transform that produces it. */ export interface BlockShortcut { /** @@ -90,6 +96,13 @@ export interface BlockShortcut { * Alternative key combinations that trigger the same shortcut. */ aliases?: BlockShortcutKeyCombination[]; + /** + * The variation of the produced block type this shortcut targets. Only + * meaningful on a block transform, where it lets one transform carry a + * shortcut per variation of the block it produces. Defaults to the + * transform's own `variationName`. + */ + variationName?: string; } /** @@ -155,10 +168,10 @@ export interface BlockVariation< /** * A keyboard shortcut that applies this variation to the selected block. * - * When the selected block is already of this block type, the variation's - * attributes are applied to it, unless `isActive` reports the variation as - * already active. Otherwise the selected block is transformed to this block - * type first, which requires a matching block transform to exist. + * It only applies to blocks that are already of this block type, and does + * nothing when `isActive` reports the variation as already active. To reach + * the variation from another block type, declare the same shortcut on a + * block transform that produces this one. */ shortcut?: BlockShortcut; /** @@ -217,14 +230,17 @@ export interface BlockTransform< */ variationName?: string; /** - * A keyboard shortcut that applies this transform to the selected block. - * Only supported on transforms of type `block`. + * Keyboard shortcuts that apply this transform to the selected block. Only + * supported on transforms of type `block`. * - * On a `to` transform the shortcut applies to the block declaring it, and - * produces the first entry of `blocks`. On a `from` transform it applies to - * any block listed in `blocks`, and produces the block declaring it. + * On a `to` transform they apply to the block declaring it, and produce the + * first entry of `blocks`. On a `from` transform they apply to any block + * listed in `blocks`, and produce the block declaring it. Each shortcut may + * target a different variation of the produced block through its own + * `variationName`, which is what lets one transform carry a shortcut per + * variation without appearing more than once in the block switcher. */ - shortcut?: BlockShortcut; + shortcuts?: BlockShortcut[]; priority?: number; isMultiBlock?: boolean; isMatch?: (