@wordpress/block-editor
Version:
304 lines (276 loc) • 8.44 kB
JavaScript
/**
* WordPress dependencies
*/
import { useCallback } from '@wordpress/element';
import {
serialize,
pasteHandler,
store as blocksStore,
createBlock,
findTransform,
getBlockTransforms,
} from '@wordpress/blocks';
import {
documentHasSelection,
documentHasUncollapsedSelection,
__unstableStripHTML as stripHTML,
} from '@wordpress/dom';
import { useDispatch, useSelect } from '@wordpress/data';
import { __, _n, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { useRefEffect } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { getPasteEventData } from '../../utils/pasting';
import { store as blockEditorStore } from '../../store';
export function useNotifyCopy() {
const { getBlockName } = useSelect( blockEditorStore );
const { getBlockType } = useSelect( blocksStore );
const { createSuccessNotice } = useDispatch( noticesStore );
return useCallback( ( eventType, selectedBlockClientIds ) => {
let notice = '';
if ( selectedBlockClientIds.length === 1 ) {
const clientId = selectedBlockClientIds[ 0 ];
const title = getBlockType( getBlockName( clientId ) )?.title;
notice =
eventType === 'copy'
? sprintf(
// Translators: Name of the block being copied, e.g. "Paragraph".
__( 'Copied "%s" to clipboard.' ),
title
)
: sprintf(
// Translators: Name of the block being cut, e.g. "Paragraph".
__( 'Moved "%s" to clipboard.' ),
title
);
} else {
notice =
eventType === 'copy'
? sprintf(
// Translators: %d: Number of blocks being copied.
_n(
'Copied %d block to clipboard.',
'Copied %d blocks to clipboard.',
selectedBlockClientIds.length
),
selectedBlockClientIds.length
)
: sprintf(
// Translators: %d: Number of blocks being cut.
_n(
'Moved %d block to clipboard.',
'Moved %d blocks to clipboard.',
selectedBlockClientIds.length
),
selectedBlockClientIds.length
);
}
createSuccessNotice( notice, {
type: 'snackbar',
} );
}, [] );
}
export function useClipboardHandler() {
const {
getBlocksByClientId,
getSelectedBlockClientIds,
hasMultiSelection,
getSettings,
__unstableIsFullySelected,
__unstableIsSelectionCollapsed,
__unstableIsSelectionMergeable,
__unstableGetSelectedBlocksWithPartialSelection,
canInsertBlockType,
} = useSelect( blockEditorStore );
const {
flashBlock,
removeBlocks,
replaceBlocks,
__unstableDeleteSelection,
__unstableExpandSelection,
insertBlocks,
} = useDispatch( blockEditorStore );
const notifyCopy = useNotifyCopy();
return useRefEffect( ( node ) => {
function handler( event ) {
const selectedBlockClientIds = getSelectedBlockClientIds();
if ( selectedBlockClientIds.length === 0 ) {
return;
}
// Always handle multiple selected blocks.
if ( ! hasMultiSelection() ) {
const { target } = event;
const { ownerDocument } = target;
// If copying, only consider actual text selection as selection.
// Otherwise, any focus on an input field is considered.
const hasSelection =
event.type === 'copy' || event.type === 'cut'
? documentHasUncollapsedSelection( ownerDocument )
: documentHasSelection( ownerDocument );
// Let native copy behaviour take over in input fields.
if ( hasSelection ) {
return;
}
}
if ( ! node.contains( event.target.ownerDocument.activeElement ) ) {
return;
}
const eventDefaultPrevented = event.defaultPrevented;
event.preventDefault();
const isSelectionMergeable = __unstableIsSelectionMergeable();
const shouldHandleWholeBlocks =
__unstableIsSelectionCollapsed() || __unstableIsFullySelected();
const expandSelectionIsNeeded =
! shouldHandleWholeBlocks && ! isSelectionMergeable;
if ( event.type === 'copy' || event.type === 'cut' ) {
if ( selectedBlockClientIds.length === 1 ) {
flashBlock( selectedBlockClientIds[ 0 ] );
}
// 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 ( shouldHandleWholeBlocks ) {
blocks = getBlocksByClientId( selectedBlockClientIds );
} else {
const [ head, tail ] =
__unstableGetSelectedBlocksWithPartialSelection();
const inBetweenBlocks = getBlocksByClientId(
selectedBlockClientIds.slice(
1,
selectedBlockClientIds.length - 1
)
);
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 );
}
}
if ( event.type === 'cut' ) {
// 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 ( shouldHandleWholeBlocks && ! expandSelectionIsNeeded ) {
removeBlocks( selectedBlockClientIds );
} else {
__unstableDeleteSelection();
}
} else if ( event.type === 'paste' ) {
if ( eventDefaultPrevented ) {
// This was likely already handled in rich-text/use-paste-handler.js.
return;
}
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 );
};
}, [] );
}
function CopyHandler( { children } ) {
return <div ref={ useClipboardHandler() }>{ children }</div>;
}
/**
* 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( /<br>/g, '\n' );
const plainText = stripHTML( html ).trim();
// Merge any consecutive line breaks
return plainText.replace( /\n\n+/g, '\n\n' );
}
/**
* @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/copy-handler/README.md
*/
export default CopyHandler;