From e4b6730d70fa3e11659ec7ad12d5cf1557f58c46 Mon Sep 17 00:00:00 2001 From: Miguel Fonseca Date: Fri, 15 May 2020 10:56:16 +0100 Subject: [PATCH] Writing flow: Copy whole block if no text is selected (#22186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Writing flow: Copy whole block if no text is selected * Append change to package changelog * Consider full selection when not copying/cutting * Copy notice: Reveal block type if only one block copied * Flash block outline in response to copy action * Notify of copy and cut actions w/ useNotifyCopy hook * Reduce block highlight time. * Block Editor: Add action `flashBlock` * E2E: Test whole-block copy, cut and paste Co-authored-by: Matías Ventura --- .../developers/data/data-core-block-editor.md | 9 +++ .../src/components/copy-handler/index.js | 78 +++++++++++++++++-- packages/block-editor/src/store/actions.js | 15 ++++ packages/block-editor/src/store/controls.js | 5 ++ packages/dom/CHANGELOG.md | 4 + packages/dom/README.md | 12 ++- packages/dom/src/dom.js | 28 ++++--- .../copy-cut-paste-whole-blocks.test.js.snap | 73 +++++++++++++++++ .../copy-cut-paste-whole-blocks.test.js | 66 ++++++++++++++++ 9 files changed, 270 insertions(+), 20 deletions(-) create mode 100644 packages/e2e-tests/specs/editor/various/__snapshots__/copy-cut-paste-whole-blocks.test.js.snap create mode 100644 packages/e2e-tests/specs/editor/various/copy-cut-paste-whole-blocks.test.js diff --git a/docs/designers-developers/developers/data/data-core-block-editor.md b/docs/designers-developers/developers/data/data-core-block-editor.md index 82e01275c5387..23e48402fb1cb 100644 --- a/docs/designers-developers/developers/data/data-core-block-editor.md +++ b/docs/designers-developers/developers/data/data-core-block-editor.md @@ -972,6 +972,15 @@ _Returns_ - `Object`: Action object. +# **flashBlock** + +Yields action objects used in signalling that the block corresponding to the +given clientId should appear to "flash" by rhythmically highlighting it. + +_Parameters_ + +- _clientId_ `string`: Target block client ID. + # **hideInsertionPoint** Returns an action object hiding the insertion point. diff --git a/packages/block-editor/src/components/copy-handler/index.js b/packages/block-editor/src/components/copy-handler/index.js index 61c93fe36be1d..b69f75520c746 100644 --- a/packages/block-editor/src/components/copy-handler/index.js +++ b/packages/block-editor/src/components/copy-handler/index.js @@ -1,16 +1,65 @@ /** * WordPress dependencies */ -import { useRef } from '@wordpress/element'; +import { useCallback, useRef } from '@wordpress/element'; import { serialize, pasteHandler } from '@wordpress/blocks'; -import { documentHasSelection } from '@wordpress/dom'; +import { documentHasSelection, documentHasTextSelection } from '@wordpress/dom'; import { useDispatch, useSelect } from '@wordpress/data'; +import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ import { getPasteEventData } from '../../utils/get-paste-event-data'; +function useNotifyCopy() { + const { getBlockName } = useSelect( + ( select ) => select( 'core/block-editor' ), + [] + ); + const { getBlockType } = useSelect( + ( select ) => select( 'core/blocks' ), + [] + ); + const { createSuccessNotice } = useDispatch( 'core/notices' ); + + return useCallback( ( eventType, selectedBlockClientIds ) => { + let notice = ''; + if ( selectedBlockClientIds.length === 1 ) { + const clientId = selectedBlockClientIds[ 0 ]; + const { title } = getBlockType( getBlockName( clientId ) ); + 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: Number of blocks being copied + __( 'Copied %d blocks to clipboard.' ), + selectedBlockClientIds.length + ) + : sprintf( + // Translators: Number of blocks being cut + __( 'Moved %d blocks to clipboard.' ), + selectedBlockClientIds.length + ); + } + createSuccessNotice( notice, { + type: 'snackbar', + } ); + }, [] ); +} + function CopyHandler( { children } ) { const containerRef = useRef(); @@ -21,7 +70,11 @@ function CopyHandler( { children } ) { getSettings, } = useSelect( ( select ) => select( 'core/block-editor' ), [] ); - const { removeBlocks, replaceBlocks } = useDispatch( 'core/block-editor' ); + const { flashBlock, removeBlocks, replaceBlocks } = useDispatch( + 'core/block-editor' + ); + + const notifyCopy = useNotifyCopy(); const { __experimentalCanUserUseUnfilteredHTML: canUserUseUnfilteredHTML, @@ -35,9 +88,18 @@ function CopyHandler( { children } ) { } // Always handle multiple selected blocks. - // Let native copy behaviour take over in input fields. - if ( ! hasMultiSelection() && documentHasSelection() ) { - return; + if ( ! hasMultiSelection() ) { + // 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' + ? documentHasTextSelection() + : documentHasSelection(); + + // Let native copy behaviour take over in input fields. + if ( hasSelection ) { + return; + } } if ( ! containerRef.current.contains( event.target ) ) { @@ -46,6 +108,10 @@ function CopyHandler( { children } ) { event.preventDefault(); 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 ); diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index bd1d275b673aa..5aef9e4ce2dd6 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1011,6 +1011,21 @@ export function toggleBlockHighlight( clientId, isHighlighted ) { }; } +/** + * Yields action objects used in signalling that the block corresponding to the + * given clientId should appear to "flash" by rhythmically highlighting it. + * + * @param {string} clientId Target block client ID. + */ +export function* flashBlock( clientId ) { + yield toggleBlockHighlight( clientId, true ); + yield { + type: 'SLEEP', + duration: 150, + }; + yield toggleBlockHighlight( clientId, false ); +} + /** * Returns an action object that sets whether the block has controlled innerblocks. * diff --git a/packages/block-editor/src/store/controls.js b/packages/block-editor/src/store/controls.js index 3a64e045f6966..f0cbed509f313 100644 --- a/packages/block-editor/src/store/controls.js +++ b/packages/block-editor/src/store/controls.js @@ -27,6 +27,11 @@ const controls = { return registry.select( storeName )[ selectorName ]( ...args ); } ), + SLEEP( { duration } ) { + return new Promise( ( resolve ) => { + setTimeout( resolve, duration ); + } ); + }, }; export default controls; diff --git a/packages/dom/CHANGELOG.md b/packages/dom/CHANGELOG.md index 2ca460fc66312..c548afcb01aeb 100644 --- a/packages/dom/CHANGELOG.md +++ b/packages/dom/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New Feature + +- Add `documentHasTextSelection` to inquire specifically about ranges of selected text, in addition to the existing `documentHasSelection`. + ## 2.1.0 (2019-03-06) ### Bug Fix diff --git a/packages/dom/README.md b/packages/dom/README.md index d45b843991578..656445efb52b8 100644 --- a/packages/dom/README.md +++ b/packages/dom/README.md @@ -24,8 +24,16 @@ _Returns_ # **documentHasSelection** -Check wether the current document has a selection. -This checks both for focus in an input field and general text selection. +Check whether the current document has a selection. This checks for both +focus in an input field and general text selection. + +_Returns_ + +- `boolean`: True if there is selection, false if not. + +# **documentHasTextSelection** + +Check whether the current document has selected text. _Returns_ diff --git a/packages/dom/src/dom.js b/packages/dom/src/dom.js index 8271abe1efcd9..476ee8134aba0 100644 --- a/packages/dom/src/dom.js +++ b/packages/dom/src/dom.js @@ -484,26 +484,30 @@ export function isNumberInput( element ) { } /** - * Check wether the current document has a selection. - * This checks both for focus in an input field and general text selection. + * Check whether the current document has selected text. * * @return {boolean} True if there is selection, false if not. */ -export function documentHasSelection() { - if ( isTextField( document.activeElement ) ) { - return true; - } - - if ( isNumberInput( document.activeElement ) ) { - return true; - } - +export function documentHasTextSelection() { const selection = window.getSelection(); const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null; - return range && ! range.collapsed; } +/** + * Check whether the current document has a selection. This checks for both + * focus in an input field and general text selection. + * + * @return {boolean} True if there is selection, false if not. + */ +export function documentHasSelection() { + return ( + isTextField( document.activeElement ) || + isNumberInput( document.activeElement ) || + documentHasTextSelection() + ); +} + /** * Check whether the contents of the element have been entirely selected. * Returns true if there is no possibility of selection. diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/copy-cut-paste-whole-blocks.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/copy-cut-paste-whole-blocks.test.js.snap new file mode 100644 index 0000000000000..519d50e98f504 --- /dev/null +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/copy-cut-paste-whole-blocks.test.js.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Multi-block selection should copy and paste individual blocks 1`] = ` +" +

Here is a unique string so we can test copying.

+ + + +

2

+" +`; + +exports[`Multi-block selection should copy and paste individual blocks 2`] = ` +" +

Here is a unique string so we can test copying.

+ + + +

2

+ + + +

Here is a unique string so we can test copying.

+ + + +

+" +`; + +exports[`Multi-block selection should cut and paste individual blocks 1`] = ` +" +

2

+" +`; + +exports[`Multi-block selection should cut and paste individual blocks 2`] = ` +" +

2

+ + + +

Yet another unique string.

+ + + +

+" +`; + +exports[`Multi-block selection should respect inline copy when text is selected 1`] = ` +" +

First block

+ + + +

Second block

+" +`; + +exports[`Multi-block selection should respect inline copy when text is selected 2`] = ` +" +

First block

+ + + +

ck

+ + + +

Second block

+" +`; diff --git a/packages/e2e-tests/specs/editor/various/copy-cut-paste-whole-blocks.test.js b/packages/e2e-tests/specs/editor/various/copy-cut-paste-whole-blocks.test.js new file mode 100644 index 0000000000000..28896cddceba7 --- /dev/null +++ b/packages/e2e-tests/specs/editor/various/copy-cut-paste-whole-blocks.test.js @@ -0,0 +1,66 @@ +/** + * WordPress dependencies + */ +import { + clickBlockAppender, + createNewPost, + pressKeyWithModifier, + getEditedPostContent, +} from '@wordpress/e2e-test-utils'; + +describe( 'Multi-block selection', () => { + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'should copy and paste individual blocks', async () => { + await clickBlockAppender(); + await page.keyboard.type( + 'Here is a unique string so we can test copying.' + ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + await page.keyboard.press( 'ArrowUp' ); + + await pressKeyWithModifier( 'primary', 'c' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.press( 'ArrowDown' ); + await pressKeyWithModifier( 'primary', 'v' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should cut and paste individual blocks', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'Yet another unique string.' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + await page.keyboard.press( 'ArrowUp' ); + + await pressKeyWithModifier( 'primary', 'x' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'ArrowDown' ); + await pressKeyWithModifier( 'primary', 'v' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should respect inline copy when text is selected', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'First block' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Second block' ); + await page.keyboard.press( 'ArrowUp' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + + await pressKeyWithModifier( 'primary', 'c' ); + await page.keyboard.press( 'ArrowRight' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.press( 'Enter' ); + await pressKeyWithModifier( 'primary', 'v' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} );