diff --git a/packages/block-editor/src/components/writing-flow/use-arrow-nav.js b/packages/block-editor/src/components/writing-flow/use-arrow-nav.js index 2bf7f717f901c8..295b388eb4378c 100644 --- a/packages/block-editor/src/components/writing-flow/use-arrow-nav.js +++ b/packages/block-editor/src/components/writing-flow/use-arrow-nav.js @@ -176,11 +176,15 @@ export default function useArrowNav() { const { getMultiSelectedBlocksStartClientId, getMultiSelectedBlocksEndClientId, + getNextBlockClientId, + getPreviousBlockClientId, + getSelectedBlockClientId, + getSelectionStart, getSettings, hasMultiSelection, __unstableIsFullySelected, } = useSelect( blockEditorStore ); - const { selectBlock } = useDispatch( blockEditorStore ); + const { selectBlock, multiSelect } = useDispatch( blockEditorStore ); return useRefEffect( ( node ) => { // Here a DOMRect is stored while moving the caret vertically so // vertical position of the start position can be restored. This is to @@ -244,6 +248,32 @@ export default function useArrowNav() { // selection to the start or end of the selection. if ( hasMultiSelection() ) { if ( shiftKey ) { + // A fully selected multi-selection has no native + // selection to extend (use-multi-selection cleared it), + // so grow or shrink it by one block at the focus end. + // Only without a usable native selection: a selection + // that is fully selected because it resolves to a + // nesting ancestor keeps its native selection, which + // the browser extends natively (and the observer + // promotes to the common level). + const selection = defaultView.getSelection(); + if ( + __unstableIsFullySelected() && + ( ! selection.rangeCount || selection.isCollapsed ) + ) { + const anchorClientId = + getMultiSelectedBlocksStartClientId(); + const focusClientId = + getMultiSelectedBlocksEndClientId(); + const nextClientId = isReverse + ? getPreviousBlockClientId( focusClientId ) + : getNextBlockClientId( focusClientId ); + + if ( nextClientId ) { + multiSelect( anchorClientId, nextClientId ); + event.preventDefault(); + } + } return; } @@ -264,6 +294,26 @@ export default function useArrowNav() { return; } + // A block selected without a text selection within it (e.g. an + // image or spacer) has no native selection to extend: start a + // block multi-selection with the adjacent block. + if ( + shiftKey && + getSelectedBlockClientId() && + ! getSelectionStart().attributeKey + ) { + const selectedClientId = getSelectedBlockClientId(); + const nextClientId = isReverse + ? getPreviousBlockClientId( selectedClientId ) + : getNextBlockClientId( selectedClientId ); + + if ( nextClientId ) { + multiSelect( selectedClientId, nextClientId ); + event.preventDefault(); + } + return; + } + // Abort if our current target is not a candidate for navigation // (e.g. preserve native input behaviors). if ( ! isNavigationCandidate( target, keyCode, hasModifier ) ) { diff --git a/test/e2e/specs/editor/various/multi-block-selection.spec.js b/test/e2e/specs/editor/various/multi-block-selection.spec.js index c4abb9a21b31f6..5e1c61783a7c49 100644 --- a/test/e2e/specs/editor/various/multi-block-selection.spec.js +++ b/test/e2e/specs/editor/various/multi-block-selection.spec.js @@ -1677,6 +1677,83 @@ test.describe( 'Multi-block selection (@firefox, @webkit)', () => { ] ); } ); + test( 'should grow and shrink a full selection with shift+arrow', async ( { + editor, + page, + multiBlockSelectionUtils, + } ) => { + await editor.insertBlock( { name: 'core/spacer' } ); + await editor.insertBlock( { name: 'core/spacer' } ); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'a paragraph' }, + } ); + + const spacerBlocks = editor.canvas.getByRole( 'document', { + name: 'Block: Spacer', + } ); + + await spacerBlocks.nth( 0 ).click(); + await spacerBlocks.nth( 1 ).click( { modifiers: [ 'Shift' ] } ); + + await expect + .poll( multiBlockSelectionUtils.getSelectedBlocks ) + .toMatchObject( [ + { name: 'core/spacer' }, + { name: 'core/spacer' }, + ] ); + + // A fully selected multi-selection grows by one block at the + // focus end. + await page.keyboard.press( 'Shift+ArrowDown' ); + await expect + .poll( multiBlockSelectionUtils.getSelectedBlocks ) + .toMatchObject( [ + { name: 'core/spacer' }, + { name: 'core/spacer' }, + { + name: 'core/paragraph', + attributes: { content: 'a paragraph' }, + }, + ] ); + + // And shrinks the same way. + await page.keyboard.press( 'Shift+ArrowUp' ); + await expect + .poll( multiBlockSelectionUtils.getSelectedBlocks ) + .toMatchObject( [ + { name: 'core/spacer' }, + { name: 'core/spacer' }, + ] ); + } ); + + test( 'should extend the selection from a block without a text selection', async ( { + page, + editor, + multiBlockSelectionUtils, + } ) => { + await editor.insertBlock( { name: 'core/spacer' } ); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'a paragraph' }, + } ); + + await editor.canvas + .getByRole( 'document', { name: 'Block: Spacer' } ) + .click(); + + await page.keyboard.press( 'Shift+ArrowDown' ); + await expect + .poll( multiBlockSelectionUtils.getSelectedBlocks ) + .toMatchObject( [ + { name: 'core/spacer' }, + { + name: 'core/paragraph', + attributes: { content: 'a paragraph' }, + }, + ] ); + } ); + test( 'should not scroll the canvas when the selection spans a scrolled page', async ( { editor, page,