diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md index 57a379a9fafe1..2bc7fc8f0bf8b 100644 --- a/docs/getting-started/faq.md +++ b/docs/getting-started/faq.md @@ -224,6 +224,11 @@ This is the canonical list of keyboard shortcuts: / / + + Create a group block from the selected multiple blocks. + Ctrl+G + G + Remove multiple selected blocks. delbackspace diff --git a/packages/block-editor/src/components/block-tools/index.js b/packages/block-editor/src/components/block-tools/index.js index 3606a8f757cfd..dba0adbbd8325 100644 --- a/packages/block-editor/src/components/block-tools/index.js +++ b/packages/block-editor/src/components/block-tools/index.js @@ -6,6 +6,9 @@ import { isTextField } from '@wordpress/dom'; import { Popover } from '@wordpress/components'; import { __unstableUseShortcutEventMatch as useShortcutEventMatch } from '@wordpress/keyboard-shortcuts'; import { useRef } from '@wordpress/element'; +import { switchToBlockType, store as blocksStore } from '@wordpress/blocks'; +import { speak } from '@wordpress/a11y'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies @@ -64,9 +67,13 @@ export default function BlockTools( { [] ); const isMatch = useShortcutEventMatch(); - const { getSelectedBlockClientIds, getBlockRootClientId } = - useSelect( blockEditorStore ); - + const { + getBlocksByClientId, + getSelectedBlockClientIds, + getBlockRootClientId, + isGroupable, + } = useSelect( blockEditorStore ); + const { getGroupingBlockName } = useSelect( blocksStore ); const { showEmptyBlockSideInserter, showBreadcrumb, @@ -76,6 +83,7 @@ export default function BlockTools( { const { duplicateBlocks, removeBlocks, + replaceBlocks, insertAfterBlock, insertBeforeBlock, selectBlock, @@ -159,6 +167,19 @@ export default function BlockTools( { } event.preventDefault(); expandBlock( clientId ); + } else if ( isMatch( 'core/block-editor/group', event ) ) { + const clientIds = getSelectedBlockClientIds(); + if ( clientIds.length > 1 && isGroupable( clientIds ) ) { + event.preventDefault(); + const blocks = getBlocksByClientId( clientIds ); + const groupingBlockName = getGroupingBlockName(); + const newBlocks = switchToBlockType( + blocks, + groupingBlockName + ); + replaceBlocks( clientIds, newBlocks ); + speak( __( 'Selected blocks are grouped.' ) ); + } } } diff --git a/packages/block-editor/src/components/convert-to-group-buttons/index.js b/packages/block-editor/src/components/convert-to-group-buttons/index.js index c2bb3fb25b845..2a015d569e85b 100644 --- a/packages/block-editor/src/components/convert-to-group-buttons/index.js +++ b/packages/block-editor/src/components/convert-to-group-buttons/index.js @@ -4,7 +4,8 @@ import { MenuItem } from '@wordpress/components'; import { _x } from '@wordpress/i18n'; import { switchToBlockType } from '@wordpress/blocks'; -import { useDispatch } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { displayShortcut } from '@wordpress/keycodes'; /** * Internal dependencies @@ -22,6 +23,7 @@ function ConvertToGroupButton( { groupingBlockName, onClose = () => {}, } ) { + const { getSelectedBlockClientIds } = useSelect( blockEditorStore ); const { replaceBlocks } = useDispatch( blockEditorStore ); const onConvertToGroup = () => { // Activate the `transform` on the Grouping Block which does the conversion. @@ -52,10 +54,17 @@ function ConvertToGroupButton( { return null; } + const selectedBlockClientIds = getSelectedBlockClientIds(); + return ( <> { isGroupable && ( 1 + ? displayShortcut.primary( 'g' ) + : undefined + } onClick={ () => { onConvertToGroup(); onClose(); diff --git a/packages/block-editor/src/components/keyboard-shortcuts/index.js b/packages/block-editor/src/components/keyboard-shortcuts/index.js index 7ea36a14aa7a8..9b83844646922 100644 --- a/packages/block-editor/src/components/keyboard-shortcuts/index.js +++ b/packages/block-editor/src/components/keyboard-shortcuts/index.js @@ -143,6 +143,18 @@ function KeyboardShortcutsRegister() { character: 'l', }, } ); + + registerShortcut( { + name: 'core/block-editor/group', + category: 'block', + description: __( + 'Create a group block from the selected multiple blocks.' + ), + keyCombination: { + modifier: 'primary', + character: 'g', + }, + } ); }, [ registerShortcut ] ); return null; diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index cdbc5939e6a2a..453b484b3476d 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -6,7 +6,11 @@ import clsx from 'clsx'; /** * WordPress dependencies */ -import { hasBlockSupport } from '@wordpress/blocks'; +import { + hasBlockSupport, + switchToBlockType, + store as blocksStore, +} from '@wordpress/blocks'; import { __experimentalTreeGridCell as TreeGridCell, __experimentalTreeGridItem as TreeGridItem, @@ -25,6 +29,7 @@ import { __ } from '@wordpress/i18n'; import { BACKSPACE, DELETE } from '@wordpress/keycodes'; import isShallowEqual from '@wordpress/is-shallow-equal'; import { __unstableUseShortcutEventMatch as useShortcutEventMatch } from '@wordpress/keyboard-shortcuts'; +import { speak } from '@wordpress/a11y'; /** * Internal dependencies @@ -85,6 +90,7 @@ function ListViewBlock( { toggleBlockHighlight, duplicateBlocks, multiSelect, + replaceBlocks, removeBlocks, insertAfterBlock, insertBeforeBlock, @@ -100,7 +106,9 @@ function ListViewBlock( { getBlockParents, getBlocksByClientId, canRemoveBlocks, + isGroupable, } = useSelect( blockEditorStore ); + const { getGroupingBlockName } = useSelect( blocksStore ); const blockInformation = useBlockDisplayInformation( clientId ); @@ -324,6 +332,23 @@ function ListViewBlock( { collapseAll(); // Expand all parents of the current block. expand( blockParents ); + } else if ( isMatch( 'core/block-editor/group', event ) ) { + const { blocksToUpdate } = getBlocksToUpdate(); + if ( blocksToUpdate.length > 1 && isGroupable( blocksToUpdate ) ) { + event.preventDefault(); + const blocks = getBlocksByClientId( blocksToUpdate ); + const groupingBlockName = getGroupingBlockName(); + const newBlocks = switchToBlockType( + blocks, + groupingBlockName + ); + replaceBlocks( blocksToUpdate, newBlocks ); + speak( __( 'Selected blocks are grouped.' ) ); + const newlySelectedBlocks = getSelectedBlockClientIds(); + // Focus the first block of the newly inserted blocks, to keep focus within the list view. + setOpenedBlockSettingsMenu( undefined ); + updateFocusAndSelection( newlySelectedBlocks[ 0 ], false ); + } } } diff --git a/test/e2e/specs/editor/various/block-editor-keyboard-shortcuts.spec.js b/test/e2e/specs/editor/various/block-editor-keyboard-shortcuts.spec.js index 0e8c5c8e7bf53..1962e2bc4202a 100644 --- a/test/e2e/specs/editor/various/block-editor-keyboard-shortcuts.spec.js +++ b/test/e2e/specs/editor/various/block-editor-keyboard-shortcuts.spec.js @@ -217,4 +217,68 @@ test.describe( 'Block editor keyboard shortcuts', () => { ] ); } ); } ); + + test.describe( 'create a group block from the selected blocks', () => { + test( 'should propagate properly if multiple blocks are selected.', async ( { + editor, + page, + pageUtils, + } ) => { + await addTestParagraphBlocks( { editor, page } ); + + // Multiselect via keyboard. + await pageUtils.pressKeys( 'primary+a', { times: 2 } ); + + await pageUtils.pressKeys( 'primary+g' ); // Keyboard shortcut for Insert before. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/group', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: '1st' }, + }, + { + name: 'core/paragraph', + attributes: { content: '2nd' }, + }, + { + name: 'core/paragraph', + attributes: { content: '3rd' }, + }, + ], + }, + ] ); + } ); + + test( 'should prevent if a single block is selected.', async ( { + editor, + page, + pageUtils, + } ) => { + await addTestParagraphBlocks( { editor, page } ); + const firstParagraphBlock = editor.canvas + .getByRole( 'document', { + name: 'Block: Paragraph', + } ) + .first(); + await editor.selectBlocks( firstParagraphBlock ); + await pageUtils.pressKeys( 'primary+g' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1st' }, + }, + { + name: 'core/paragraph', + attributes: { content: '2nd' }, + }, + { + name: 'core/paragraph', + attributes: { content: '3rd' }, + }, + ] ); + } ); + } ); } ); diff --git a/test/e2e/specs/editor/various/list-view.spec.js b/test/e2e/specs/editor/various/list-view.spec.js index 8c14711084389..143fea43c09ee 100644 --- a/test/e2e/specs/editor/various/list-view.spec.js +++ b/test/e2e/specs/editor/various/list-view.spec.js @@ -986,6 +986,45 @@ test.describe( 'List View', () => { ] ); } ); + test( 'should create a group block from the selected multiple blocks', async ( { + editor, + pageUtils, + listViewUtils, + } ) => { + // Insert some blocks of different types. + await editor.insertBlock( { name: 'core/paragraph' } ); + await editor.insertBlock( { name: 'core/heading' } ); + await editor.insertBlock( { name: 'core/file' } ); + + await listViewUtils.openListView(); + + // Group Heading and File blocks. + await pageUtils.pressKeys( 'shift+ArrowUp' ); + await pageUtils.pressKeys( 'primary+g' ); + await expect + .poll( listViewUtils.getBlocksWithA11yAttributes ) + .toMatchObject( [ + { name: 'core/paragraph', selected: false, focused: false }, + { + name: 'core/group', + selected: true, + focused: true, + innerBlocks: [ + { + name: 'core/heading', + selected: false, + focused: false, + }, + { + name: 'core/file', + selected: false, + focused: false, + }, + ], + }, + ] ); + } ); + test( 'block settings dropdown menu', async ( { editor, page,