From c900fd3b0fc0c7e950e5619522694df2702303dd Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Mon, 15 Jan 2024 12:18:39 +1100 Subject: [PATCH 01/10] List View: Try adding keyboard clipboard events for cut, copy, paste --- .../block-editor/src/components/list-view/index.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index 5270a7af3a2962..016661505b5c8d 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 '../writing-flow/use-clipboard-handler'; const expanded = ( state, action ) => { if ( Array.isArray( action.clientIds ) ) { @@ -143,7 +144,16 @@ function ListViewComponent( setExpandedState, } ); const elementRef = useRef(); - const treeGridRef = useMergeRefs( [ elementRef, dropZoneRef, ref ] ); + + // Allow handling of copy, cut, and paste events. + const clipBoardRef = useClipboardHandler(); + + const treeGridRef = useMergeRefs( [ + clipBoardRef, + elementRef, + dropZoneRef, + ref, + ] ); const [ insertedBlock, setInsertedBlock ] = useState( null ); From a12cac8a72ad7b218a07cf3815e1e8f260266ee5 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Wed, 17 Jan 2024 12:39:52 +1100 Subject: [PATCH 02/10] Make useClipboardHandler that works with the list view --- .../src/components/list-view/index.js | 2 +- .../list-view/use-clipboard-handler.js | 232 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 packages/block-editor/src/components/list-view/use-clipboard-handler.js diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index 016661505b5c8d..915857505169be 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -42,7 +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 '../writing-flow/use-clipboard-handler'; +import useClipboardHandler from './use-clipboard-handler'; const expanded = ( state, action ) => { if ( Array.isArray( action.clientIds ) ) { 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..0b33569a06227b --- /dev/null +++ b/packages/block-editor/src/components/list-view/use-clipboard-handler.js @@ -0,0 +1,232 @@ +/** + * WordPress dependencies + */ +import { + serialize, + pasteHandler, + createBlock, + findTransform, + getBlockTransforms, +} from '@wordpress/blocks'; +import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { useDispatch, useSelect } from '@wordpress/data'; +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'; + +export default function useClipboardHandler() { + const { + getBlockRootClientId, + getBlocksByClientId, + getSelectedBlockClientIds, + getSettings, + canInsertBlockType, + canRemoveBlocks, + } = useSelect( blockEditorStore ); + const { flashBlock, removeBlocks, replaceBlocks, insertBlocks } = + useDispatch( blockEditorStore ); + const notifyCopy = useNotifyCopy(); + + return useRefEffect( ( 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, + 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, + firstBlockRootClientId, + } = 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 ); + let blocks; + // Check if we have partial selection. + blocks = getBlocksByClientId( selectedBlockClientIds ); + + 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 ); + } + + if ( event.type === 'cut' ) { + // Don't update the selection if the blocks cannot be deleted. + if ( + ! canRemoveBlocks( + selectedBlockClientIds, + firstBlockRootClientId + ) + ) { + return; + } + removeBlocks( selectedBlockClientIds ); + } else if ( event.type === 'paste' ) { + const { + __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, + } ); + } + + if ( selectedBlockClientIds.length === 1 ) { + const [ selectedBlockClientId ] = selectedBlockClientIds; + + if ( + blocks.every( ( block ) => + canInsertBlockType( + block.name, + selectedBlockClientId + ) + ) + ) { + insertBlocks( + blocks, + undefined, + selectedBlockClientId + ); + return; + } + } + + replaceBlocks( + selectedBlockClientIds, + blocks, + blocks.length - 1, + -1 + ); + } + } + + 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 ); + }; + }, [] ); +} + +/** + * 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' ); +} From 46b59f208d38e691f58775c55cd22853a6d2f12a Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Wed, 17 Jan 2024 14:06:29 +1100 Subject: [PATCH 03/10] Ensure focus remains within list view after cut and paste --- .../src/components/list-view/block.js | 2 +- .../src/components/list-view/index.js | 39 ++++++++-------- .../list-view/use-clipboard-handler.js | 46 +++++++++++++++++-- .../src/components/list-view/utils.js | 8 ++-- 4 files changed, 69 insertions(+), 26 deletions(-) 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 915857505169be..f11eca023463f3 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -138,23 +138,6 @@ function ListViewComponent( const [ expandedState, setExpandedState ] = useReducer( expanded, {} ); - const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone( { - dropZoneElement, - expandedState, - setExpandedState, - } ); - const elementRef = useRef(); - - // Allow handling of copy, cut, and paste events. - const clipBoardRef = useClipboardHandler(); - - const treeGridRef = useMergeRefs( [ - clipBoardRef, - elementRef, - dropZoneRef, - ref, - ] ); - const [ insertedBlock, setInsertedBlock ] = useState( null ); const { setSelectedTreeId } = useListViewExpandSelectedItem( { @@ -176,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 index 0b33569a06227b..0a5d61c9dae4d0 100644 --- a/packages/block-editor/src/components/list-view/use-clipboard-handler.js +++ b/packages/block-editor/src/components/list-view/use-clipboard-handler.js @@ -18,11 +18,14 @@ import { useRefEffect } from '@wordpress/compose'; import { getPasteEventData } from '../../utils/pasting'; import { store as blockEditorStore } from '../../store'; import { useNotifyCopy } from '../../utils/use-notify-copy'; +import { focusListItem } from './utils'; -export default function useClipboardHandler() { +export default function useClipboardHandler( { selectBlock } ) { const { + getBlockOrder, getBlockRootClientId, getBlocksByClientId, + getPreviousBlockClientId, getSelectedBlockClientIds, getSettings, canInsertBlockType, @@ -33,6 +36,14 @@ export default function useClipboardHandler() { 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. @@ -54,7 +65,7 @@ export default function useClipboardHandler() { blocksToUpdate, firstBlockClientId, firstBlockRootClientId, - selectedBlockClientIds, + originallySelectedBlockClientIds: selectedBlockClientIds, }; } @@ -83,7 +94,9 @@ export default function useClipboardHandler() { const { blocksToUpdate: selectedBlockClientIds, + firstBlockClientId, firstBlockRootClientId, + originallySelectedBlockClientIds, } = getBlocksToUpdate( clientId ); if ( selectedBlockClientIds.length === 0 ) { @@ -137,7 +150,27 @@ export default function useClipboardHandler() { ) { return; } - removeBlocks( selectedBlockClientIds ); + + 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: @@ -176,6 +209,11 @@ export default function useClipboardHandler() { 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( @@ -189,6 +227,7 @@ export default function useClipboardHandler() { undefined, selectedBlockClientId ); + updateFocusAndSelection( blocks[ 0 ]?.clientId, false ); return; } } @@ -199,6 +238,7 @@ export default function useClipboardHandler() { blocks.length - 1, -1 ); + updateFocusAndSelection( blocks[ 0 ]?.clientId, false ); } } diff --git a/packages/block-editor/src/components/list-view/utils.js b/packages/block-editor/src/components/list-view/utils.js index ed7a321dea0c86..3e596ca5015465 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; From 75d06fac9a5dec8d97e1bd00e66a38069edf5652 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Thu, 18 Jan 2024 11:22:14 +1100 Subject: [PATCH 04/10] Add e2e tests --- .../specs/editor/various/list-view.spec.js | 96 ++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) 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 ( { From eb2825f22598f1d786a63652b10157b91c039575 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Tue, 23 Jan 2024 12:04:31 +1100 Subject: [PATCH 05/10] Fix linting issue --- packages/block-editor/src/components/list-view/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index f11eca023463f3..895571755e4fa0 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -163,7 +163,7 @@ function ListViewComponent( const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone( { dropZoneElement, expandedState, - setExpandedState + setExpandedState, } ); const elementRef = useRef(); From 68ed6c7b31bdf308cc1987d5822800ef5c3bbb5d Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Tue, 23 Jan 2024 16:12:26 +1100 Subject: [PATCH 06/10] Update packages/block-editor/src/components/list-view/utils.js Co-authored-by: Robert Anderson --- packages/block-editor/src/components/list-view/utils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/block-editor/src/components/list-view/utils.js b/packages/block-editor/src/components/list-view/utils.js index 3e596ca5015465..c91376b0472116 100644 --- a/packages/block-editor/src/components/list-view/utils.js +++ b/packages/block-editor/src/components/list-view/utils.js @@ -63,8 +63,8 @@ export function getCommonDepthClientIds( * * @typedef {import('@wordpress/element').RefObject} RefObject * - * @param {string} focusClientId The client ID of the block to focus. - * @param {HTMLElement} treeGridElement 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, treeGridElement ) { const getFocusElement = () => { From 885b1477ae9442e6a305c6af8f83dc4a4a5b46ac Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Wed, 24 Jan 2024 15:58:42 +1100 Subject: [PATCH 07/10] Try consolidating some of the copy and paste behaviour --- .../list-view/use-clipboard-handler.js | 89 ++----------------- .../writing-flow/use-clipboard-handler.js | 84 ++--------------- .../src/components/writing-flow/utils.js | 89 +++++++++++++++++++ 3 files changed, 102 insertions(+), 160 deletions(-) create mode 100644 packages/block-editor/src/components/writing-flow/utils.js 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 index 0a5d61c9dae4d0..3145f18934b452 100644 --- a/packages/block-editor/src/components/list-view/use-clipboard-handler.js +++ b/packages/block-editor/src/components/list-view/use-clipboard-handler.js @@ -1,24 +1,16 @@ /** * WordPress dependencies */ -import { - serialize, - pasteHandler, - createBlock, - findTransform, - getBlockTransforms, -} from '@wordpress/blocks'; -import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; import { useDispatch, useSelect } from '@wordpress/data'; 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 { focusListItem } from './utils'; +import { getPasteBlocks, setClipboardBlocks } from '../writing-flow/utils'; export default function useClipboardHandler( { selectBlock } ) { const { @@ -111,33 +103,8 @@ export default function useClipboardHandler( { selectBlock } ) { } notifyCopy( event.type, selectedBlockClientIds ); - let blocks; - // Check if we have partial selection. - blocks = getBlocksByClientId( selectedBlockClientIds ); - - 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 ); + const blocks = getBlocksByClientId( selectedBlockClientIds ); + setClipboardBlocks( event, blocks ); } if ( event.type === 'cut' ) { @@ -176,35 +143,10 @@ export default function useClipboardHandler( { selectBlock } ) { __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; @@ -253,20 +195,3 @@ export default function useClipboardHandler( { selectBlock } ) { }; }, [] ); } - -/** - * 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/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..b0e230fbb1b2b9 --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/utils.js @@ -0,0 +1,89 @@ +/** + * 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'; + +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 ); +} + +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' ); +} From 3238c6ac7b6e422392d1365e6b0084a00bc95b87 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Wed, 24 Jan 2024 16:02:23 +1100 Subject: [PATCH 08/10] Add comment references the other useClipboardHandler --- .../src/components/list-view/use-clipboard-handler.js | 3 +++ 1 file changed, 3 insertions(+) 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 index 3145f18934b452..6b2ce85faa1154 100644 --- a/packages/block-editor/src/components/list-view/use-clipboard-handler.js +++ b/packages/block-editor/src/components/list-view/use-clipboard-handler.js @@ -12,6 +12,9 @@ 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. In the future, +// consider refactoring the two hooks to share more code. export default function useClipboardHandler( { selectBlock } ) { const { getBlockOrder, From cc37c0bce33f29947762935a7e93ff428f5dee3b Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Wed, 24 Jan 2024 16:11:46 +1100 Subject: [PATCH 09/10] Add JSDoc comments for the new utility functions --- .../src/components/writing-flow/utils.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/block-editor/src/components/writing-flow/utils.js b/packages/block-editor/src/components/writing-flow/utils.js index b0e230fbb1b2b9..ef1827077ccbf1 100644 --- a/packages/block-editor/src/components/writing-flow/utils.js +++ b/packages/block-editor/src/components/writing-flow/utils.js @@ -15,6 +15,13 @@ import { */ 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( @@ -39,6 +46,13 @@ export function setClipboardBlocks( event, blocks ) { 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 = []; From bf2d13f9b92bfc6c6c3419b08d575680010258b4 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Thu, 25 Jan 2024 10:03:51 +1100 Subject: [PATCH 10/10] Tidy up comment --- .../src/components/list-view/use-clipboard-handler.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 6b2ce85faa1154..cd25c71e9bf7c4 100644 --- a/packages/block-editor/src/components/list-view/use-clipboard-handler.js +++ b/packages/block-editor/src/components/list-view/use-clipboard-handler.js @@ -13,8 +13,7 @@ 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. In the future, -// consider refactoring the two hooks to share more code. +// and adds behaviour for the list view, while skipping partial selection. export default function useClipboardHandler( { selectBlock } ) { const { getBlockOrder,