Skip to content

Commit

Permalink
[Multi-selection]: Copy/cut partial selected blocks (#40098)
Browse files Browse the repository at this point in the history
* [Multi-selection]: Copy/cut partial selected blocks

* handle `copy` button in block actions

* merge blocks on cut

* partial copy from block actions only when is mergeable

* update selector

* revert partial selection copy in block actions menu item

* add jsdoc for mapRichTextSettings util

* update tests

* add new e2e tests + pressKeyTimes playwright util
  • Loading branch information
ntsekouras authored and adamziel committed Apr 13, 2022
1 parent 7199ec2 commit 0e41033
Show file tree
Hide file tree
Showing 28 changed files with 417 additions and 67 deletions.
60 changes: 50 additions & 10 deletions packages/block-editor/src/components/copy-handler/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,17 @@ export function useClipboardHandler() {
getSelectedBlockClientIds,
hasMultiSelection,
getSettings,
__unstableIsFullySelected,
__unstableIsSelectionMergeable,
__unstableGetSelectedBlocksWithPartialSelection,
} = useSelect( blockEditorStore );
const { flashBlock, removeBlocks, replaceBlocks } = useDispatch(
blockEditorStore
);
const {
flashBlock,
removeBlocks,
replaceBlocks,
__unstableDeleteSelection,
__unstableExpandSelection,
} = useDispatch( blockEditorStore );
const notifyCopy = useNotifyCopy();

return useRefEffect( ( node ) => {
Expand Down Expand Up @@ -116,20 +123,53 @@ export function useClipboardHandler() {
const eventDefaultPrevented = event.defaultPrevented;
event.preventDefault();

const isFullySelected = __unstableIsFullySelected();
const isSelectionMergeable = __unstableIsSelectionMergeable();
const expandSelectionIsNeeded =
! isFullySelected && ! isSelectionMergeable;
if ( event.type === 'copy' || event.type === 'cut' ) {
if ( selectedBlockClientIds.length === 1 ) {
flashBlock( selectedBlockClientIds[ 0 ] );
}
notifyCopy( event.type, selectedBlockClientIds );
const blocks = getBlocksByClientId( selectedBlockClientIds );
const serialized = serialize( blocks );

event.clipboardData.setData( 'text/plain', serialized );
event.clipboardData.setData( 'text/html', serialized );
// If we have a partial selection that is not mergeable, just
// expand the selection to the whole blocks.
if ( expandSelectionIsNeeded ) {
__unstableExpandSelection();
} else {
notifyCopy( event.type, selectedBlockClientIds );
let blocks;
// Check if we have partial selection.
if ( isFullySelected ) {
blocks = getBlocksByClientId( selectedBlockClientIds );
} else {
const [
head,
tail,
] = __unstableGetSelectedBlocksWithPartialSelection();
const inBetweenBlocks = getBlocksByClientId(
selectedBlockClientIds.slice(
1,
selectedBlockClientIds.length - 1
)
);
blocks = [ head, ...inBetweenBlocks, tail ];
}
const serialized = serialize( blocks );

event.clipboardData.setData( 'text/plain', serialized );
event.clipboardData.setData( 'text/html', serialized );
}
}

if ( event.type === 'cut' ) {
removeBlocks( selectedBlockClientIds );
// We need to also check if at the start we needed to
// expand the selection, as in this point we might have
// programmatically fully selected the blocks above.
if ( isFullySelected && ! expandSelectionIsNeeded ) {
removeBlocks( selectedBlockClientIds );
} else {
__unstableDeleteSelection();
}
} else if ( event.type === 'paste' ) {
if ( eventDefaultPrevented ) {
// This was likely already handled in rich-text/use-paste-handler.js.
Expand Down
18 changes: 5 additions & 13 deletions packages/block-editor/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import { __, _n, sprintf } from '@wordpress/i18n';
import { create, insert, remove, toHTMLString } from '@wordpress/rich-text';
import deprecated from '@wordpress/deprecated';

/**
* Internal dependencies
*/
import { mapRichTextSettings } from './utils';

/**
* Action which will insert a default block insert action if there
* are no other blocks at the root of the editor. This action should be used
Expand Down Expand Up @@ -667,19 +672,6 @@ export const synchronizeTemplate = () => ( { select, dispatch } ) => {
dispatch.resetBlocks( updatedBlockList );
};

function mapRichTextSettings( attributeDefinition ) {
const {
multiline: multilineTag,
__unstableMultilineWrapperTags: multilineWrapperTags,
__unstablePreserveWhiteSpace: preserveWhiteSpace,
} = attributeDefinition;
return {
multilineTag,
multilineWrapperTags,
preserveWhiteSpace,
};
}

/**
* Delete the current selection.
*
Expand Down
107 changes: 107 additions & 0 deletions packages/block-editor/src/store/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ import { Platform } from '@wordpress/element';
import { applyFilters } from '@wordpress/hooks';
import { symbol } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
import { create, remove, toHTMLString } from '@wordpress/rich-text';

/**
* Internal dependencies
*/
import { mapRichTextSettings } from './utils';

/**
* A block selection object.
Expand Down Expand Up @@ -1004,6 +1010,107 @@ export function __unstableIsSelectionMergeable( state, isForward ) {
return blocksToMerge && blocksToMerge.length;
}

/**
* Get partial selected blocks with their content updated
* based on the selection.
*
* @param {Object} state Editor state.
*
* @return {Object[]} Updated partial selected blocks.
*/
export const __unstableGetSelectedBlocksWithPartialSelection = ( state ) => {
const selectionAnchor = getSelectionStart( state );
const selectionFocus = getSelectionEnd( state );

if ( selectionAnchor.clientId === selectionFocus.clientId ) {
return EMPTY_ARRAY;
}

// Can't split if the selection is not set.
if (
! selectionAnchor.attributeKey ||
! selectionFocus.attributeKey ||
typeof selectionAnchor.offset === 'undefined' ||
typeof selectionFocus.offset === 'undefined'
) {
return EMPTY_ARRAY;
}

const anchorRootClientId = getBlockRootClientId(
state,
selectionAnchor.clientId
);
const focusRootClientId = getBlockRootClientId(
state,
selectionFocus.clientId
);

// It's not splittable if the selection doesn't start and end in the same
// block list. Maybe in the future it should be allowed.
if ( anchorRootClientId !== focusRootClientId ) {
return EMPTY_ARRAY;
}

const blockOrder = getBlockOrder( state, anchorRootClientId );
const anchorIndex = blockOrder.indexOf( selectionAnchor.clientId );
const focusIndex = blockOrder.indexOf( selectionFocus.clientId );

// Reassign selection start and end based on order.
const [ selectionStart, selectionEnd ] =
anchorIndex > focusIndex
? [ selectionFocus, selectionAnchor ]
: [ selectionAnchor, selectionFocus ];

const blockA = getBlock( state, selectionStart.clientId );
const blockAType = getBlockType( blockA.name );

const blockB = getBlock( state, selectionEnd.clientId );
const blockBType = getBlockType( blockB.name );

const htmlA = blockA.attributes[ selectionStart.attributeKey ];
const htmlB = blockB.attributes[ selectionEnd.attributeKey ];

const attributeDefinitionA =
blockAType.attributes[ selectionStart.attributeKey ];
const attributeDefinitionB =
blockBType.attributes[ selectionEnd.attributeKey ];

let valueA = create( {
html: htmlA,
...mapRichTextSettings( attributeDefinitionA ),
} );
let valueB = create( {
html: htmlB,
...mapRichTextSettings( attributeDefinitionB ),
} );

valueA = remove( valueA, 0, selectionStart.offset );
valueB = remove( valueB, selectionEnd.offset, valueB.text.length );

return [
{
...blockA,
attributes: {
...blockA.attributes,
[ selectionStart.attributeKey ]: toHTMLString( {
value: valueA,
...mapRichTextSettings( attributeDefinitionA ),
} ),
},
},
{
...blockB,
attributes: {
...blockB.attributes,
[ selectionEnd.attributeKey ]: toHTMLString( {
value: valueB,
...mapRichTextSettings( attributeDefinitionB ),
} ),
},
},
];
};

/**
* Returns an array containing all block client IDs in the editor in the order
* they appear. Optionally accepts a root client ID of the block list for which
Expand Down
19 changes: 19 additions & 0 deletions packages/block-editor/src/store/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Helper function that maps attribute definition properties to the
* ones used by RichText utils like `create, toHTMLString, etc..`.
*
* @param {Object} attributeDefinition A block's attribute definition object.
* @return {Object} The mapped object.
*/
export function mapRichTextSettings( attributeDefinition ) {
const {
multiline: multilineTag,
__unstableMultilineWrapperTags: multilineWrapperTags,
__unstablePreserveWhiteSpace: preserveWhiteSpace,
} = attributeDefinition;
return {
multilineTag,
multilineWrapperTags,
preserveWhiteSpace,
};
}
2 changes: 2 additions & 0 deletions packages/e2e-test-utils-playwright/src/page/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
setClipboardData,
pressKeyWithModifier,
} from './press-key-with-modifier';
import { pressKeyTimes } from './press-key-times';
import { openPreviewPage } from './preview';
import { setBrowserViewport } from './set-browser-viewport';
import { showBlockToolbar } from './show-block-toolbar';
Expand Down Expand Up @@ -48,6 +49,7 @@ class PageUtils {
openDocumentSettingsSidebar = openDocumentSettingsSidebar;
openPreviewPage = openPreviewPage;
setBrowserViewport = setBrowserViewport;
pressKeyTimes = pressKeyTimes;
}

export { PageUtils };
11 changes: 11 additions & 0 deletions packages/e2e-test-utils-playwright/src/page/press-key-times.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Presses the given keyboard key a number of times in sequence.
*
* @param {string} key Key to press.
* @param {number} count Number of times to press.
*/
export async function pressKeyTimes( key, count ) {
while ( count-- ) {
await this.page.keyboard.press( key );
}
}

This file was deleted.

Loading

0 comments on commit 0e41033

Please sign in to comment.