Skip to content

Commit a2d9738

Browse files
List View: Add keyboard clipboard events for cut, copy, paste (#57838)
* 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]>
1 parent 2e01f22 commit a2d9738

7 files changed

Lines changed: 430 additions & 93 deletions

File tree

packages/block-editor/src/components/list-view/block.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ function ListViewBlock( {
187187
selectBlock( undefined, focusClientId, null, null );
188188
}
189189

190-
focusListItem( focusClientId, treeGridElementRef );
190+
focusListItem( focusClientId, treeGridElementRef?.current );
191191
},
192192
[ selectBlock, treeGridElementRef ]
193193
);

packages/block-editor/src/components/list-view/index.js

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import useListViewExpandSelectedItem from './use-list-view-expand-selected-item'
4242
import { store as blockEditorStore } from '../../store';
4343
import { BlockSettingsDropdown } from '../block-settings-menu/block-settings-dropdown';
4444
import { focusListItem } from './utils';
45+
import useClipboardHandler from './use-clipboard-handler';
4546

4647
const expanded = ( state, action ) => {
4748
if ( Array.isArray( action.clientIds ) ) {
@@ -137,14 +138,6 @@ function ListViewComponent(
137138

138139
const [ expandedState, setExpandedState ] = useReducer( expanded, {} );
139140

140-
const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone( {
141-
dropZoneElement,
142-
expandedState,
143-
setExpandedState,
144-
} );
145-
const elementRef = useRef();
146-
const treeGridRef = useMergeRefs( [ elementRef, dropZoneRef, ref ] );
147-
148141
const [ insertedBlock, setInsertedBlock ] = useState( null );
149142

150143
const { setSelectedTreeId } = useListViewExpandSelectedItem( {
@@ -166,11 +159,31 @@ function ListViewComponent(
166159
},
167160
[ setSelectedTreeId, updateBlockSelection, onSelect, getBlock ]
168161
);
162+
163+
const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone( {
164+
dropZoneElement,
165+
expandedState,
166+
setExpandedState,
167+
} );
168+
const elementRef = useRef();
169+
170+
// Allow handling of copy, cut, and paste events.
171+
const clipBoardRef = useClipboardHandler( {
172+
selectBlock: selectEditorBlock,
173+
} );
174+
175+
const treeGridRef = useMergeRefs( [
176+
clipBoardRef,
177+
elementRef,
178+
dropZoneRef,
179+
ref,
180+
] );
181+
169182
useEffect( () => {
170183
// If a blocks are already selected when the list view is initially
171184
// mounted, shift focus to the first selected block.
172185
if ( selectedClientIds?.length ) {
173-
focusListItem( selectedClientIds[ 0 ], elementRef );
186+
focusListItem( selectedClientIds[ 0 ], elementRef?.current );
174187
}
175188
// Disable reason: Only focus on the selected item when the list view is mounted.
176189
// eslint-disable-next-line react-hooks/exhaustive-deps
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { useDispatch, useSelect } from '@wordpress/data';
5+
import { useRefEffect } from '@wordpress/compose';
6+
7+
/**
8+
* Internal dependencies
9+
*/
10+
import { store as blockEditorStore } from '../../store';
11+
import { useNotifyCopy } from '../../utils/use-notify-copy';
12+
import { focusListItem } from './utils';
13+
import { getPasteBlocks, setClipboardBlocks } from '../writing-flow/utils';
14+
15+
// This hook borrows from useClipboardHandler in ../writing-flow/use-clipboard-handler.js
16+
// and adds behaviour for the list view, while skipping partial selection.
17+
export default function useClipboardHandler( { selectBlock } ) {
18+
const {
19+
getBlockOrder,
20+
getBlockRootClientId,
21+
getBlocksByClientId,
22+
getPreviousBlockClientId,
23+
getSelectedBlockClientIds,
24+
getSettings,
25+
canInsertBlockType,
26+
canRemoveBlocks,
27+
} = useSelect( blockEditorStore );
28+
const { flashBlock, removeBlocks, replaceBlocks, insertBlocks } =
29+
useDispatch( blockEditorStore );
30+
const notifyCopy = useNotifyCopy();
31+
32+
return useRefEffect( ( node ) => {
33+
function updateFocusAndSelection( focusClientId, shouldSelectBlock ) {
34+
if ( shouldSelectBlock ) {
35+
selectBlock( undefined, focusClientId, null, null );
36+
}
37+
38+
focusListItem( focusClientId, node );
39+
}
40+
41+
// Determine which blocks to update:
42+
// If the current (focused) block is part of the block selection, use the whole selection.
43+
// If the focused block is not part of the block selection, only update the focused block.
44+
function getBlocksToUpdate( clientId ) {
45+
const selectedBlockClientIds = getSelectedBlockClientIds();
46+
const isUpdatingSelectedBlocks =
47+
selectedBlockClientIds.includes( clientId );
48+
const firstBlockClientId = isUpdatingSelectedBlocks
49+
? selectedBlockClientIds[ 0 ]
50+
: clientId;
51+
const firstBlockRootClientId =
52+
getBlockRootClientId( firstBlockClientId );
53+
54+
const blocksToUpdate = isUpdatingSelectedBlocks
55+
? selectedBlockClientIds
56+
: [ clientId ];
57+
58+
return {
59+
blocksToUpdate,
60+
firstBlockClientId,
61+
firstBlockRootClientId,
62+
originallySelectedBlockClientIds: selectedBlockClientIds,
63+
};
64+
}
65+
66+
function handler( event ) {
67+
if ( event.defaultPrevented ) {
68+
// This was possibly already handled in rich-text/use-paste-handler.js.
69+
return;
70+
}
71+
72+
// Only handle events that occur within the list view.
73+
if ( ! node.contains( event.target.ownerDocument.activeElement ) ) {
74+
return;
75+
}
76+
77+
// Retrieve the block clientId associated with the focused list view row.
78+
// This enables applying copy / cut / paste behavior to the focused block,
79+
// rather than just the blocks that are currently selected.
80+
const listViewRow =
81+
event.target.ownerDocument.activeElement?.closest(
82+
'[role=row]'
83+
);
84+
const clientId = listViewRow?.dataset?.block;
85+
if ( ! clientId ) {
86+
return;
87+
}
88+
89+
const {
90+
blocksToUpdate: selectedBlockClientIds,
91+
firstBlockClientId,
92+
firstBlockRootClientId,
93+
originallySelectedBlockClientIds,
94+
} = getBlocksToUpdate( clientId );
95+
96+
if ( selectedBlockClientIds.length === 0 ) {
97+
return;
98+
}
99+
100+
event.preventDefault();
101+
102+
if ( event.type === 'copy' || event.type === 'cut' ) {
103+
if ( selectedBlockClientIds.length === 1 ) {
104+
flashBlock( selectedBlockClientIds[ 0 ] );
105+
}
106+
107+
notifyCopy( event.type, selectedBlockClientIds );
108+
const blocks = getBlocksByClientId( selectedBlockClientIds );
109+
setClipboardBlocks( event, blocks );
110+
}
111+
112+
if ( event.type === 'cut' ) {
113+
// Don't update the selection if the blocks cannot be deleted.
114+
if (
115+
! canRemoveBlocks(
116+
selectedBlockClientIds,
117+
firstBlockRootClientId
118+
)
119+
) {
120+
return;
121+
}
122+
123+
let blockToFocus =
124+
getPreviousBlockClientId( firstBlockClientId ) ??
125+
// If the previous block is not found (when the first block is deleted),
126+
// fallback to focus the parent block.
127+
firstBlockRootClientId;
128+
129+
// Remove blocks, but don't update selection, and it will be handled below.
130+
removeBlocks( selectedBlockClientIds, false );
131+
132+
// Update the selection if the original selection has been removed.
133+
const shouldUpdateSelection =
134+
originallySelectedBlockClientIds.length > 0 &&
135+
getSelectedBlockClientIds().length === 0;
136+
137+
// If there's no previous block nor parent block, focus the first block.
138+
if ( ! blockToFocus ) {
139+
blockToFocus = getBlockOrder()[ 0 ];
140+
}
141+
142+
updateFocusAndSelection( blockToFocus, shouldUpdateSelection );
143+
} else if ( event.type === 'paste' ) {
144+
const {
145+
__experimentalCanUserUseUnfilteredHTML:
146+
canUserUseUnfilteredHTML,
147+
} = getSettings();
148+
const blocks = getPasteBlocks(
149+
event,
150+
canUserUseUnfilteredHTML
151+
);
152+
153+
if ( selectedBlockClientIds.length === 1 ) {
154+
const [ selectedBlockClientId ] = selectedBlockClientIds;
155+
156+
// If a single block is focused, and the blocks to be posted can
157+
// be inserted within the block, then append the pasted blocks
158+
// within the focused block. For example, if you have copied a paragraph
159+
// block and paste it within a single Group block, this will append
160+
// the paragraph block within the Group block.
161+
if (
162+
blocks.every( ( block ) =>
163+
canInsertBlockType(
164+
block.name,
165+
selectedBlockClientId
166+
)
167+
)
168+
) {
169+
insertBlocks(
170+
blocks,
171+
undefined,
172+
selectedBlockClientId
173+
);
174+
updateFocusAndSelection( blocks[ 0 ]?.clientId, false );
175+
return;
176+
}
177+
}
178+
179+
replaceBlocks(
180+
selectedBlockClientIds,
181+
blocks,
182+
blocks.length - 1,
183+
-1
184+
);
185+
updateFocusAndSelection( blocks[ 0 ]?.clientId, false );
186+
}
187+
}
188+
189+
node.ownerDocument.addEventListener( 'copy', handler );
190+
node.ownerDocument.addEventListener( 'cut', handler );
191+
node.ownerDocument.addEventListener( 'paste', handler );
192+
193+
return () => {
194+
node.ownerDocument.removeEventListener( 'copy', handler );
195+
node.ownerDocument.removeEventListener( 'cut', handler );
196+
node.ownerDocument.removeEventListener( 'paste', handler );
197+
};
198+
}, [] );
199+
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,12 @@ export function getCommonDepthClientIds(
6363
*
6464
* @typedef {import('@wordpress/element').RefObject} RefObject
6565
*
66-
* @param {string} focusClientId The client ID of the block to focus.
67-
* @param {RefObject<HTMLElement>} treeGridElementRef The container element to search within.
66+
* @param {string} focusClientId The client ID of the block to focus.
67+
* @param {?HTMLElement} treeGridElement The container element to search within.
6868
*/
69-
export function focusListItem( focusClientId, treeGridElementRef ) {
69+
export function focusListItem( focusClientId, treeGridElement ) {
7070
const getFocusElement = () => {
71-
const row = treeGridElementRef.current?.querySelector(
71+
const row = treeGridElement?.querySelector(
7272
`[role=row][data-block="${ focusClientId }"]`
7373
);
7474
if ( ! row ) return null;

0 commit comments

Comments
 (0)