Skip to content

Commit

Permalink
List View: Add keyboard clipboard events for cut, copy, paste (#57838)
Browse files Browse the repository at this point in the history
* List View: Try adding keyboard clipboard events for cut, copy, paste

* Make useClipboardHandler that works with the list view

* Ensure focus remains within list view after cut and paste

* Add e2e tests

* Fix linting issue

* Update packages/block-editor/src/components/list-view/utils.js

Co-authored-by: Robert Anderson <[email protected]>

* Try consolidating some of the copy and paste behaviour

* Add comment references the other useClipboardHandler

* Add JSDoc comments for the new utility functions

* Tidy up comment

---------

Co-authored-by: Robert Anderson <[email protected]>
  • Loading branch information
andrewserong and noisysocks authored Jan 25, 2024
1 parent 2e01f22 commit a2d9738
Show file tree
Hide file tree
Showing 7 changed files with 430 additions and 93 deletions.
2 changes: 1 addition & 1 deletion packages/block-editor/src/components/list-view/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ function ListViewBlock( {
selectBlock( undefined, focusClientId, null, null );
}

focusListItem( focusClientId, treeGridElementRef );
focusListItem( focusClientId, treeGridElementRef?.current );
},
[ selectBlock, treeGridElementRef ]
);
Expand Down
31 changes: 22 additions & 9 deletions packages/block-editor/src/components/list-view/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) ) {
Expand Down Expand Up @@ -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( {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 );
};
}, [] );
}
8 changes: 4 additions & 4 deletions packages/block-editor/src/components/list-view/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>} 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;
Expand Down
Loading

1 comment on commit a2d9738

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flaky tests detected in a2d9738.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/7648054136
📝 Reported issues:

Please sign in to comment.