diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index aab9d444a5948f..6dedabb48f8b85 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -187,7 +187,7 @@ function ListViewBlock( { selectBlock( undefined, focusClientId, null, null ); } - focusListItem( focusClientId, treeGridElementRef ); + focusListItem( focusClientId, treeGridElementRef?.current ); }, [ selectBlock, treeGridElementRef ] ); diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index 5270a7af3a2962..895571755e4fa0 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -42,6 +42,7 @@ import useListViewExpandSelectedItem from './use-list-view-expand-selected-item' import { store as blockEditorStore } from '../../store'; import { BlockSettingsDropdown } from '../block-settings-menu/block-settings-dropdown'; import { focusListItem } from './utils'; +import useClipboardHandler from './use-clipboard-handler'; const expanded = ( state, action ) => { if ( Array.isArray( action.clientIds ) ) { @@ -137,14 +138,6 @@ function ListViewComponent( const [ expandedState, setExpandedState ] = useReducer( expanded, {} ); - const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone( { - dropZoneElement, - expandedState, - setExpandedState, - } ); - const elementRef = useRef(); - const treeGridRef = useMergeRefs( [ elementRef, dropZoneRef, ref ] ); - const [ insertedBlock, setInsertedBlock ] = useState( null ); const { setSelectedTreeId } = useListViewExpandSelectedItem( { @@ -166,11 +159,31 @@ function ListViewComponent( }, [ setSelectedTreeId, updateBlockSelection, onSelect, getBlock ] ); + + const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone( { + dropZoneElement, + expandedState, + setExpandedState, + } ); + const elementRef = useRef(); + + // Allow handling of copy, cut, and paste events. + const clipBoardRef = useClipboardHandler( { + selectBlock: selectEditorBlock, + } ); + + const treeGridRef = useMergeRefs( [ + clipBoardRef, + elementRef, + dropZoneRef, + ref, + ] ); + useEffect( () => { // If a blocks are already selected when the list view is initially // mounted, shift focus to the first selected block. if ( selectedClientIds?.length ) { - focusListItem( selectedClientIds[ 0 ], elementRef ); + focusListItem( selectedClientIds[ 0 ], elementRef?.current ); } // Disable reason: Only focus on the selected item when the list view is mounted. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/block-editor/src/components/list-view/use-clipboard-handler.js b/packages/block-editor/src/components/list-view/use-clipboard-handler.js new file mode 100644 index 00000000000000..cd25c71e9bf7c4 --- /dev/null +++ b/packages/block-editor/src/components/list-view/use-clipboard-handler.js @@ -0,0 +1,199 @@ +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { useRefEffect } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import { useNotifyCopy } from '../../utils/use-notify-copy'; +import { focusListItem } from './utils'; +import { getPasteBlocks, setClipboardBlocks } from '../writing-flow/utils'; + +// This hook borrows from useClipboardHandler in ../writing-flow/use-clipboard-handler.js +// and adds behaviour for the list view, while skipping partial selection. +export default function useClipboardHandler( { selectBlock } ) { + const { + getBlockOrder, + getBlockRootClientId, + getBlocksByClientId, + getPreviousBlockClientId, + getSelectedBlockClientIds, + getSettings, + canInsertBlockType, + canRemoveBlocks, + } = useSelect( blockEditorStore ); + const { flashBlock, removeBlocks, replaceBlocks, insertBlocks } = + useDispatch( blockEditorStore ); + const notifyCopy = useNotifyCopy(); + + return useRefEffect( ( node ) => { + function updateFocusAndSelection( focusClientId, shouldSelectBlock ) { + if ( shouldSelectBlock ) { + selectBlock( undefined, focusClientId, null, null ); + } + + focusListItem( focusClientId, node ); + } + + // Determine which blocks to update: + // If the current (focused) block is part of the block selection, use the whole selection. + // If the focused block is not part of the block selection, only update the focused block. + function getBlocksToUpdate( clientId ) { + const selectedBlockClientIds = getSelectedBlockClientIds(); + const isUpdatingSelectedBlocks = + selectedBlockClientIds.includes( clientId ); + const firstBlockClientId = isUpdatingSelectedBlocks + ? selectedBlockClientIds[ 0 ] + : clientId; + const firstBlockRootClientId = + getBlockRootClientId( firstBlockClientId ); + + const blocksToUpdate = isUpdatingSelectedBlocks + ? selectedBlockClientIds + : [ clientId ]; + + return { + blocksToUpdate, + firstBlockClientId, + firstBlockRootClientId, + originallySelectedBlockClientIds: selectedBlockClientIds, + }; + } + + function handler( event ) { + if ( event.defaultPrevented ) { + // This was possibly already handled in rich-text/use-paste-handler.js. + return; + } + + // Only handle events that occur within the list view. + if ( ! node.contains( event.target.ownerDocument.activeElement ) ) { + return; + } + + // Retrieve the block clientId associated with the focused list view row. + // This enables applying copy / cut / paste behavior to the focused block, + // rather than just the blocks that are currently selected. + const listViewRow = + event.target.ownerDocument.activeElement?.closest( + '[role=row]' + ); + const clientId = listViewRow?.dataset?.block; + if ( ! clientId ) { + return; + } + + const { + blocksToUpdate: selectedBlockClientIds, + firstBlockClientId, + firstBlockRootClientId, + originallySelectedBlockClientIds, + } = getBlocksToUpdate( clientId ); + + if ( selectedBlockClientIds.length === 0 ) { + return; + } + + event.preventDefault(); + + if ( event.type === 'copy' || event.type === 'cut' ) { + if ( selectedBlockClientIds.length === 1 ) { + flashBlock( selectedBlockClientIds[ 0 ] ); + } + + notifyCopy( event.type, selectedBlockClientIds ); + const blocks = getBlocksByClientId( selectedBlockClientIds ); + setClipboardBlocks( event, blocks ); + } + + if ( event.type === 'cut' ) { + // Don't update the selection if the blocks cannot be deleted. + if ( + ! canRemoveBlocks( + selectedBlockClientIds, + firstBlockRootClientId + ) + ) { + return; + } + + let blockToFocus = + getPreviousBlockClientId( firstBlockClientId ) ?? + // If the previous block is not found (when the first block is deleted), + // fallback to focus the parent block. + firstBlockRootClientId; + + // Remove blocks, but don't update selection, and it will be handled below. + removeBlocks( selectedBlockClientIds, false ); + + // Update the selection if the original selection has been removed. + const shouldUpdateSelection = + originallySelectedBlockClientIds.length > 0 && + getSelectedBlockClientIds().length === 0; + + // If there's no previous block nor parent block, focus the first block. + if ( ! blockToFocus ) { + blockToFocus = getBlockOrder()[ 0 ]; + } + + updateFocusAndSelection( blockToFocus, shouldUpdateSelection ); + } else if ( event.type === 'paste' ) { + const { + __experimentalCanUserUseUnfilteredHTML: + canUserUseUnfilteredHTML, + } = getSettings(); + const blocks = getPasteBlocks( + event, + canUserUseUnfilteredHTML + ); + + if ( selectedBlockClientIds.length === 1 ) { + const [ selectedBlockClientId ] = selectedBlockClientIds; + + // If a single block is focused, and the blocks to be posted can + // be inserted within the block, then append the pasted blocks + // within the focused block. For example, if you have copied a paragraph + // block and paste it within a single Group block, this will append + // the paragraph block within the Group block. + if ( + blocks.every( ( block ) => + canInsertBlockType( + block.name, + selectedBlockClientId + ) + ) + ) { + insertBlocks( + blocks, + undefined, + selectedBlockClientId + ); + updateFocusAndSelection( blocks[ 0 ]?.clientId, false ); + return; + } + } + + replaceBlocks( + selectedBlockClientIds, + blocks, + blocks.length - 1, + -1 + ); + updateFocusAndSelection( blocks[ 0 ]?.clientId, false ); + } + } + + node.ownerDocument.addEventListener( 'copy', handler ); + node.ownerDocument.addEventListener( 'cut', handler ); + node.ownerDocument.addEventListener( 'paste', handler ); + + return () => { + node.ownerDocument.removeEventListener( 'copy', handler ); + node.ownerDocument.removeEventListener( 'cut', handler ); + node.ownerDocument.removeEventListener( 'paste', handler ); + }; + }, [] ); +} diff --git a/packages/block-editor/src/components/list-view/utils.js b/packages/block-editor/src/components/list-view/utils.js index ed7a321dea0c86..c91376b0472116 100644 --- a/packages/block-editor/src/components/list-view/utils.js +++ b/packages/block-editor/src/components/list-view/utils.js @@ -63,12 +63,12 @@ export function getCommonDepthClientIds( * * @typedef {import('@wordpress/element').RefObject} RefObject * - * @param {string} focusClientId The client ID of the block to focus. - * @param {RefObject} treeGridElementRef The container element to search within. + * @param {string} focusClientId The client ID of the block to focus. + * @param {?HTMLElement} treeGridElement The container element to search within. */ -export function focusListItem( focusClientId, treeGridElementRef ) { +export function focusListItem( focusClientId, treeGridElement ) { const getFocusElement = () => { - const row = treeGridElementRef.current?.querySelector( + const row = treeGridElement?.querySelector( `[role=row][data-block="${ focusClientId }"]` ); if ( ! row ) return null; diff --git a/packages/block-editor/src/components/writing-flow/use-clipboard-handler.js b/packages/block-editor/src/components/writing-flow/use-clipboard-handler.js index 5b78d2f8656b61..8528655c1dcc9e 100644 --- a/packages/block-editor/src/components/writing-flow/use-clipboard-handler.js +++ b/packages/block-editor/src/components/writing-flow/use-clipboard-handler.js @@ -1,17 +1,9 @@ /** * WordPress dependencies */ -import { - serialize, - pasteHandler, - createBlock, - findTransform, - getBlockTransforms, -} from '@wordpress/blocks'; import { documentHasSelection, documentHasUncollapsedSelection, - __unstableStripHTML as stripHTML, } from '@wordpress/dom'; import { useDispatch, useSelect } from '@wordpress/data'; import { useRefEffect } from '@wordpress/compose'; @@ -19,9 +11,9 @@ import { useRefEffect } from '@wordpress/compose'; /** * Internal dependencies */ -import { getPasteEventData } from '../../utils/pasting'; import { store as blockEditorStore } from '../../store'; import { useNotifyCopy } from '../../utils/use-notify-copy'; +import { getPasteBlocks, setClipboardBlocks } from './utils'; export default function useClipboardHandler() { const { @@ -112,29 +104,7 @@ export default function useClipboardHandler() { blocks = [ head, ...inBetweenBlocks, tail ]; } - const wrapperBlockName = event.clipboardData.getData( - '__unstableWrapperBlockName' - ); - - if ( wrapperBlockName ) { - blocks = createBlock( - wrapperBlockName, - JSON.parse( - event.clipboardData.getData( - '__unstableWrapperBlockAttributes' - ) - ), - blocks - ); - } - - const serialized = serialize( blocks ); - - event.clipboardData.setData( - 'text/plain', - toPlainText( serialized ) - ); - event.clipboardData.setData( 'text/html', serialized ); + setClipboardBlocks( event, blocks ); } } @@ -153,35 +123,10 @@ export default function useClipboardHandler() { __experimentalCanUserUseUnfilteredHTML: canUserUseUnfilteredHTML, } = getSettings(); - const { plainText, html, files } = getPasteEventData( event ); - let blocks = []; - - if ( files.length ) { - const fromTransforms = getBlockTransforms( 'from' ); - blocks = files - .reduce( ( accumulator, file ) => { - const transformation = findTransform( - fromTransforms, - ( transform ) => - transform.type === 'files' && - transform.isMatch( [ file ] ) - ); - if ( transformation ) { - accumulator.push( - transformation.transform( [ file ] ) - ); - } - return accumulator; - }, [] ) - .flat(); - } else { - blocks = pasteHandler( { - HTML: html, - plainText, - mode: 'BLOCKS', - canUserUseUnfilteredHTML, - } ); - } + const blocks = getPasteBlocks( + event, + canUserUseUnfilteredHTML + ); if ( selectedBlockClientIds.length === 1 ) { const [ selectedBlockClientId ] = selectedBlockClientIds; @@ -223,20 +168,3 @@ export default function useClipboardHandler() { }; }, [] ); } - -/** - * Given a string of HTML representing serialized blocks, returns the plain - * text extracted after stripping the HTML of any tags and fixing line breaks. - * - * @param {string} html Serialized blocks. - * @return {string} The plain-text content with any html removed. - */ -function toPlainText( html ) { - // Manually handle BR tags as line breaks prior to `stripHTML` call - html = html.replace( /
/g, '\n' ); - - const plainText = stripHTML( html ).trim(); - - // Merge any consecutive line breaks - return plainText.replace( /\n\n+/g, '\n\n' ); -} diff --git a/packages/block-editor/src/components/writing-flow/utils.js b/packages/block-editor/src/components/writing-flow/utils.js new file mode 100644 index 00000000000000..ef1827077ccbf1 --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/utils.js @@ -0,0 +1,103 @@ +/** + * WordPress dependencies + */ +import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { + serialize, + createBlock, + pasteHandler, + findTransform, + getBlockTransforms, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { getPasteEventData } from '../../utils/pasting'; + +/** + * Sets the clipboard data for the provided blocks, with both HTML and plain + * text representations. + * + * @param {ClipboardEvent} event Clipboard event. + * @param {WPBlock[]} blocks Blocks to set as clipboard data. + */ +export function setClipboardBlocks( event, blocks ) { + let _blocks = blocks; + const wrapperBlockName = event.clipboardData.getData( + '__unstableWrapperBlockName' + ); + + if ( wrapperBlockName ) { + _blocks = createBlock( + wrapperBlockName, + JSON.parse( + event.clipboardData.getData( + '__unstableWrapperBlockAttributes' + ) + ), + _blocks + ); + } + + const serialized = serialize( _blocks ); + + event.clipboardData.setData( 'text/plain', toPlainText( serialized ) ); + event.clipboardData.setData( 'text/html', serialized ); +} + +/** + * Returns the blocks to be pasted from the clipboard event. + * + * @param {ClipboardEvent} event The clipboard event. + * @param {boolean} canUserUseUnfilteredHTML Whether the user can or can't post unfiltered HTML. + * @return {Array|string} A list of blocks or a string, depending on `handlerMode`. + */ +export function getPasteBlocks( event, canUserUseUnfilteredHTML ) { + const { plainText, html, files } = getPasteEventData( event ); + let blocks = []; + + if ( files.length ) { + const fromTransforms = getBlockTransforms( 'from' ); + blocks = files + .reduce( ( accumulator, file ) => { + const transformation = findTransform( + fromTransforms, + ( transform ) => + transform.type === 'files' && + transform.isMatch( [ file ] ) + ); + if ( transformation ) { + accumulator.push( transformation.transform( [ file ] ) ); + } + return accumulator; + }, [] ) + .flat(); + } else { + blocks = pasteHandler( { + HTML: html, + plainText, + mode: 'BLOCKS', + canUserUseUnfilteredHTML, + } ); + } + + return blocks; +} + +/** + * Given a string of HTML representing serialized blocks, returns the plain + * text extracted after stripping the HTML of any tags and fixing line breaks. + * + * @param {string} html Serialized blocks. + * @return {string} The plain-text content with any html removed. + */ +function toPlainText( html ) { + // Manually handle BR tags as line breaks prior to `stripHTML` call + html = html.replace( /
/g, '\n' ); + + const plainText = stripHTML( html ).trim(); + + // Merge any consecutive line breaks + return plainText.replace( /\n\n+/g, '\n\n' ); +} diff --git a/test/e2e/specs/editor/various/list-view.spec.js b/test/e2e/specs/editor/various/list-view.spec.js index 00f21b4e51c5ea..cb15c12c84b490 100644 --- a/test/e2e/specs/editor/various/list-view.spec.js +++ b/test/e2e/specs/editor/various/list-view.spec.js @@ -450,7 +450,7 @@ test.describe( 'List View', () => { ).toBeFocused(); } ); - test( 'should select, duplicate, delete, and deselect blocks using keyboard', async ( { + test( 'should cut, copy, paste, select, duplicate, delete, and deselect blocks using keyboard', async ( { editor, page, pageUtils, @@ -808,6 +808,100 @@ test.describe( 'List View', () => { { name: 'core/heading', selected: false, focused: false }, { name: 'core/file', selected: false, focused: true }, ] ); + + // Copy and paste blocks. To begin, add another Group block. + await editor.insertBlock( { + name: 'core/group', + innerBlocks: [ + { name: 'core/paragraph' }, + { name: 'core/pullquote' }, + ], + } ); + + // Click the newly inserted Group block List View item to ensure it is focused. + await listView + .getByRole( 'link', { + name: 'Group', + expanded: false, + } ) + .click(); + + // Move down to group block, expand, and then move to the paragraph block. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await pageUtils.pressKeys( 'primary+c' ); + await page.keyboard.press( 'ArrowUp' ); + await pageUtils.pressKeys( 'primary+v' ); + + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Should be able to copy focused block and paste in the list view via keyboard shortcuts' + ) + .toMatchObject( [ + { name: 'core/heading', selected: false, focused: false }, + { name: 'core/file', selected: false, focused: false }, + { + name: 'core/group', + selected: true, + innerBlocks: [ + { + name: 'core/pullquote', + selected: false, + focused: true, + }, + { + name: 'core/pullquote', + selected: false, + focused: false, + }, + ], + }, + ] ); + + // Cut and paste blocks. + await page.keyboard.press( 'ArrowUp' ); + await pageUtils.pressKeys( 'primary+x' ); + + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Should be able to cut a block in the list view, with the preceding block being selected' + ) + .toMatchObject( [ + { name: 'core/heading', selected: false, focused: false }, + { name: 'core/file', selected: true, focused: true }, + ] ); + + await pageUtils.pressKeys( 'primary+v' ); + + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Should be able to paste previously cut block in the list view via keyboard shortcuts' + ) + .toMatchObject( [ + { name: 'core/heading', selected: false, focused: false }, + { + name: 'core/group', + selected: true, + focused: true, + innerBlocks: [ + { + name: 'core/pullquote', + selected: false, + focused: false, + }, + { + name: 'core/pullquote', + selected: false, + focused: false, + }, + ], + }, + ] ); } ); test( 'block settings dropdown menu', async ( {