Skip to content

Commit

Permalink
Writing flow: Copy whole block if no text is selected (#22186)
Browse files Browse the repository at this point in the history
* 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 <mv@matiasventura.com>
  • Loading branch information
mcsf and mtias committed May 15, 2020
1 parent 6559e84 commit e4b6730
Show file tree
Hide file tree
Showing 9 changed files with 270 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,15 @@ _Returns_

- `Object`: Action object.

<a name="flashBlock" href="#flashBlock">#</a> **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.

<a name="hideInsertionPoint" href="#hideInsertionPoint">#</a> **hideInsertionPoint**

Returns an action object hiding the insertion point.
Expand Down
78 changes: 72 additions & 6 deletions packages/block-editor/src/components/copy-handler/index.js
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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,
Expand All @@ -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 ) ) {
Expand All @@ -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 );

Expand Down
15 changes: 15 additions & 0 deletions packages/block-editor/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
5 changes: 5 additions & 0 deletions packages/block-editor/src/store/controls.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ const controls = {
return registry.select( storeName )[ selectorName ]( ...args );
}
),
SLEEP( { duration } ) {
return new Promise( ( resolve ) => {
setTimeout( resolve, duration );
} );
},
};

export default controls;
4 changes: 4 additions & 0 deletions packages/dom/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions packages/dom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,16 @@ _Returns_

<a name="documentHasSelection" href="#documentHasSelection">#</a> **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.

<a name="documentHasTextSelection" href="#documentHasTextSelection">#</a> **documentHasTextSelection**

Check whether the current document has selected text.

_Returns_

Expand Down
28 changes: 16 additions & 12 deletions packages/dom/src/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Multi-block selection should copy and paste individual blocks 1`] = `
"<!-- wp:paragraph -->
<p>Here is a unique string so we can test copying.</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>2</p>
<!-- /wp:paragraph -->"
`;

exports[`Multi-block selection should copy and paste individual blocks 2`] = `
"<!-- wp:paragraph -->
<p>Here is a unique string so we can test copying.</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>2</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>Here is a unique string so we can test copying.</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p></p>
<!-- /wp:paragraph -->"
`;

exports[`Multi-block selection should cut and paste individual blocks 1`] = `
"<!-- wp:paragraph -->
<p>2</p>
<!-- /wp:paragraph -->"
`;

exports[`Multi-block selection should cut and paste individual blocks 2`] = `
"<!-- wp:paragraph -->
<p>2</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>Yet another unique string.</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p></p>
<!-- /wp:paragraph -->"
`;

exports[`Multi-block selection should respect inline copy when text is selected 1`] = `
"<!-- wp:paragraph -->
<p>First block</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>Second block</p>
<!-- /wp:paragraph -->"
`;

exports[`Multi-block selection should respect inline copy when text is selected 2`] = `
"<!-- wp:paragraph -->
<p>First block</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>ck</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>Second block</p>
<!-- /wp:paragraph -->"
`;
Original file line number Diff line number Diff line change
@@ -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();
} );
} );

0 comments on commit e4b6730

Please sign in to comment.