From bf6976b8c7b2b85054891c250e9d57571dc93a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20van=C2=A0Durpe?= Date: Fri, 29 Nov 2019 14:22:42 +0100 Subject: [PATCH] Smooth Native Multi Block Selection (#16835) * Squash: 7a9ce87694e8c6eaba9bd5713ef19c8476d39fc7 (HEAD -> try/native-multi-select, origin/try/native-multi-select) Fix selection in Firefox eb668d3852ee408b0d247f7e296f59ba3dc71a47 Fix bad rebase 71bf361bdd133258706b900ee563693a2452ddb1 Fix block deletion e2e test 386810adf5c8d9c97d79ccda924205abf21f2b33 Add mouse drag test 41cb592dd41bac789e2f28057146afb4d2c46c2d Rewrite tests c22a36b5ab2c247ecc04e790aa87b1162b07489f Polish 98327326e788943ffa83f3ecc491aea60ae61209 Remove mover min-height and multi select top border cac2c20e4e27cbfa3fdd7feab8a488180399a957 Fix cross selection delay in Safari 116e89f01cac53fdb3dbb718b745c3c243fe8759 Hide hover effect on mouseleave a3f1547217603b47d00a42e9952337827ee950b3 Correct toolbar position 24f817d142159e1a2264183740fcb31a0eff338e contenteditable false when there is multi selection 4eade53858dfe3109a94c7bbc700e6aa800392e2 Fix selection setting 9182fd3aaeb6a74065c09022844d967edb724035 Remove elements in the way of selection 98dae74909a3d7b229cf845e025b3668226458af Reselect block correctly f1b08ac4a587bca7d787d34212905619d18db6e7 Smooth selection 3fb633a6572e501d2df2af87afd5403d9055e4f5 Native Multi Block Selection * Add comment * E2e: wait for selection to update * Extract logic from componentDidUpdate --- .../src/components/block-list/block.js | 70 +++-- .../src/components/block-list/index.js | 238 ++++++++------- .../src/components/block-list/style.scss | 53 ++-- .../src/components/rich-text/index.js | 3 + .../src/components/rich-text/style.scss | 4 - .../src/components/warning/style.scss | 5 - .../various/multi-block-selection.test.js | 288 ++++++++++++------ packages/rich-text/src/component/index.js | 4 + 8 files changed, 399 insertions(+), 266 deletions(-) diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index d2695ac0e0b21..e797f1b5b2656 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -63,7 +63,6 @@ const preventDrag = ( event ) => { }; function BlockListBlock( { - blockRef, mode, isFocusMode, hasFixedToolbar, @@ -102,6 +101,7 @@ function BlockListBlock( { enableAnimation, isNavigationMode, setNavigationMode, + isMultiSelecting, } ) { // Random state used to rerender the component if needed, ideally we don't need this const [ , updateRerenderState ] = useState( {} ); @@ -109,9 +109,6 @@ function BlockListBlock( { // Reference of the wrapper const wrapper = useRef( null ); - useEffect( () => { - blockRef( wrapper.current, clientId ); - }, [] ); // Reference to the block edit node const blockNodeRef = useRef(); @@ -207,6 +204,19 @@ function BlockListBlock( { * @param {boolean} ignoreInnerBlocks Should not focus inner blocks. */ const focusTabbable = ( ignoreInnerBlocks ) => { + const selection = window.getSelection(); + + if ( selection.rangeCount && ! selection.isCollapsed ) { + const { startContainer, endContainer } = selection.getRangeAt( 0 ); + + if ( + ! blockNodeRef.current.contains( startContainer ) || + ! blockNodeRef.current.contains( endContainer ) + ) { + selection.removeAllRanges(); + } + } + // Focus is captured by the wrapper node, so while focus transition // should only consider tabbables within editable display, since it // may be the wrapper itself or a side control which triggered the @@ -333,12 +343,14 @@ function BlockListBlock( { } }; + const isPointerDown = useRef( false ); + /** * Begins tracking cursor multi-selection when clicking down within block. * * @param {MouseEvent} event A mousedown event. */ - const onPointerDown = ( event ) => { + const onMouseDown = ( event ) => { // Not the main button. // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button if ( event.button !== 0 ) { @@ -362,7 +374,7 @@ function BlockListBlock( { // Avoid triggering multi-selection if we click toolbars/inspectors // and all elements that are outside the Block Edit DOM tree. } else if ( blockNodeRef.current.contains( event.target ) ) { - onSelectionStart( clientId ); + isPointerDown.current = true; // Allow user to escape out of a multi-selection to a singular // selection of a block via click. This is handled here since @@ -375,6 +387,20 @@ function BlockListBlock( { } }; + const onMouseUp = () => { + isPointerDown.current = false; + }; + + const onMouseLeave = () => { + if ( isPointerDown.current ) { + onSelectionStart( clientId ); + } + + hideHoverEffects(); + + isPointerDown.current = false; + }; + const selectOnOpen = ( open ) => { if ( open && ! isSelected ) { onSelect(); @@ -412,9 +438,10 @@ function BlockListBlock( { ! showEmptyBlockSideInserter && ! isPartOfMultiSelection && ! isTypingWithinBlock; - const shouldShowBreadcrumb = + const shouldShowBreadcrumb = ! isMultiSelecting && ( ( isSelected && isNavigationMode ) || - ( ! isNavigationMode && ! isFocusMode && isHovered && ! isEmptyDefaultBlock ); + ( ! isNavigationMode && ! isFocusMode && isHovered && ! isEmptyDefaultBlock ) + ); const shouldShowContextualToolbar = ! isNavigationMode && ! hasFixedToolbar && @@ -427,9 +454,12 @@ function BlockListBlock( { // Insertion point can only be made visible if the block is at the // the extent of a multi-selection, or not in a multi-selection. - const shouldShowInsertionPoint = + const shouldShowInsertionPoint = ! isMultiSelecting && ( ( isPartOfMultiSelection && isFirstMultiSelected ) || - ! isPartOfMultiSelection; + ! isPartOfMultiSelection + ); + + const shouldRenderDropzone = shouldShowInsertionPoint; // The wp-block className is important for editor styles. // Generate the wrapper class names handling the different states of the block. @@ -529,22 +559,22 @@ function BlockListBlock( { rootClientId={ rootClientId } /> ) } - - { isFirstMultiSelected && ( - - ) } + /> }
+ { isFirstMultiSelected && ( + + ) } { shouldRenderMovers && ( moverDirection === 'vertical' ) && blockMover } { shouldShowBreadcrumb && ( diff --git a/packages/block-editor/src/components/block-list/index.js b/packages/block-editor/src/components/block-list/index.js index 54511ac941ecc..4f58be8831dde 100644 --- a/packages/block-editor/src/components/block-list/index.js +++ b/packages/block-editor/src/components/block-list/index.js @@ -1,19 +1,12 @@ /** * External dependencies */ -import { - findLast, - invert, - mapValues, - sortBy, - throttle, -} from 'lodash'; import classnames from 'classnames'; /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; +import { Component, createRef } from '@wordpress/element'; import { withSelect, withDispatch, @@ -27,7 +20,6 @@ import { compose } from '@wordpress/compose'; import BlockAsyncModeProvider from './block-async-mode-provider'; import BlockListBlock from './block'; import BlockListAppender from '../block-list-appender'; -import { getBlockDOMNode } from '../../utils/dom'; /** * If the block count exceeds the threshold, we disable the reordering animation @@ -43,71 +35,99 @@ const forceSyncUpdates = ( WrappedComponent ) => ( props ) => { ); }; +/** + * Returns for the deepest node at the start or end of a container node. Ignores + * any text nodes that only contain HTML formatting whitespace. + * + * @param {Element} node Container to search. + * @param {string} type 'start' or 'end'. + */ +function getDeepestNode( node, type ) { + const child = type === 'start' ? 'firstChild' : 'lastChild'; + const sibling = type === 'start' ? 'nextSibling' : 'previousSibling'; + + while ( node[ child ] ) { + node = node[ child ]; + + while ( + node.nodeType === node.TEXT_NODE && + /^[ \t\n]*$/.test( node.data ) && + node[ sibling ] + ) { + node = node[ sibling ]; + } + } + + return node; +} + class BlockList extends Component { constructor( props ) { super( props ); this.onSelectionStart = this.onSelectionStart.bind( this ); this.onSelectionEnd = this.onSelectionEnd.bind( this ); - this.setBlockRef = this.setBlockRef.bind( this ); - this.setLastClientY = this.setLastClientY.bind( this ); - this.onPointerMove = throttle( this.onPointerMove.bind( this ), 100 ); - // Browser does not fire `*move` event when the pointer position changes - // relative to the document, so fire it with the last known position. - this.onScroll = () => this.onPointerMove( { clientY: this.lastClientY } ); - - this.lastClientY = 0; - this.nodes = {}; - } - - componentDidMount() { - window.addEventListener( 'mousemove', this.setLastClientY ); - } + this.setSelection = this.setSelection.bind( this ); + this.updateNativeSelection = this.updateNativeSelection.bind( this ); - componentWillUnmount() { - window.removeEventListener( 'mousemove', this.setLastClientY ); + this.ref = createRef(); } - setLastClientY( { clientY } ) { - this.lastClientY = clientY; + componentDidUpdate() { + this.updateNativeSelection(); } - setBlockRef( node, clientId ) { - if ( node === null ) { - delete this.nodes[ clientId ]; - } else { - this.nodes = { - ...this.nodes, - [ clientId ]: node, - }; - } + componentWillUnmount() { + window.removeEventListener( 'mouseup', this.onSelectionEnd ); + window.cancelAnimationFrame( this.rafId ); } /** - * Handles a pointer move event to update the extent of the current cursor - * multi-selection. - * - * @param {MouseEvent} event A mousemove event object. + * When the component updates, and there is multi selection, we need to + * select the entire block contents. */ - onPointerMove( { clientY } ) { - // We don't start multi-selection until the mouse starts moving, so as - // to avoid dispatching multi-selection actions on an in-place click. - if ( ! this.props.isMultiSelecting ) { - this.props.onStartMultiSelect(); + updateNativeSelection() { + const { + hasMultiSelection, + blockClientIds, + // These must be in the right DOM order. + multiSelectedBlockClientIds, + } = this.props; + + if ( ! hasMultiSelection ) { + return; } - const blockContentBoundaries = getBlockDOMNode( this.selectionAtStart ).getBoundingClientRect(); + const { length } = multiSelectedBlockClientIds; + const start = multiSelectedBlockClientIds[ 0 ]; + const end = multiSelectedBlockClientIds[ length - 1 ]; + const startIndex = blockClientIds.indexOf( start ); - // prevent multi-selection from triggering when the selected block is a float - // and the cursor is still between the top and the bottom of the block. - if ( clientY >= blockContentBoundaries.top && clientY <= blockContentBoundaries.bottom ) { + // The selected block is not in this block list. + if ( startIndex === -1 ) { return; } - const y = clientY - blockContentBoundaries.top; - const key = findLast( this.coordMapKeys, ( coordY ) => coordY < y ); + let startNode = this.ref.current.querySelector( + `[data-block="${ start }"]` + ); + let endNode = this.ref.current.querySelector( + `[data-block="${ end }"]` + ); + + const selection = window.getSelection(); + const range = document.createRange(); + + // The most stable way to select the whole block contents is to start + // and end at the deepest points. + startNode = getDeepestNode( startNode, 'start' ); + endNode = getDeepestNode( endNode, 'end' ); - this.onSelectionChange( this.coordMap[ key ] ); + range.setStartBefore( startNode ); + range.setEndAfter( endNode ); + + selection.removeAllRanges(); + selection.addRange( range ); } /** @@ -121,73 +141,74 @@ class BlockList extends Component { return; } - const boundaries = this.nodes[ clientId ].getBoundingClientRect(); - - // Create a clientId to Y coördinate map. - const clientIdToCoordMap = mapValues( this.nodes, ( node ) => - node.getBoundingClientRect().top - boundaries.top ); + this.startClientId = clientId; + this.props.onStartMultiSelect(); - // Cache a Y coördinate to clientId map for use in `onPointerMove`. - this.coordMap = invert( clientIdToCoordMap ); - // Cache an array of the Y coördinates for use in `onPointerMove`. - // Sort the coördinates, as `this.nodes` will not necessarily reflect - // the current block sequence. - this.coordMapKeys = sortBy( Object.values( clientIdToCoordMap ) ); - this.selectionAtStart = clientId; - - window.addEventListener( 'mousemove', this.onPointerMove ); - // Capture scroll on all elements. - window.addEventListener( 'scroll', this.onScroll, true ); + // `onSelectionStart` is called after `mousedown` and `mouseleave` + // (from a block). The selection ends when `mouseup` happens anywhere + // in the window. window.addEventListener( 'mouseup', this.onSelectionEnd ); + + // Removing the contenteditable attributes within the block editor is + // essential for selection to work across editable areas. The edible + // hosts are removed, allowing selection to be extended outside the + // DOM element. `onStartMultiSelect` sets a flag in the store so the + // rich text components are updated, but the rerender may happen very + // slowly, especially in Safari for the blocks that are asynchonously + // rendered. To ensure the browser instantly removes the selection + // boundaries, we remove the contenteditable attributes manually. + Array.from( + this.ref.current.querySelectorAll( '.rich-text' ) + ).forEach( ( node ) => { + node.removeAttribute( 'contenteditable' ); + } ); } /** - * Handles multi-selection changes in response to pointer move. - * - * @param {string} clientId Client ID of block under cursor in multi-select - * drag. + * Handles a mouseup event to end the current mouse multi-selection. */ - onSelectionChange( clientId ) { - const { onMultiSelect, selectionStart, selectionEnd } = this.props; - const { selectionAtStart } = this; - const isAtStart = selectionAtStart === clientId; + onSelectionEnd() { + // Equivalent to attaching the listener once. + window.removeEventListener( 'mouseup', this.onSelectionEnd ); - if ( ! selectionAtStart || ! this.props.isSelectionEnabled ) { + if ( ! this.props.isMultiSelecting ) { return; } - // If multi-selecting and cursor extent returns to the start of - // selection, cancel multi-select. - if ( isAtStart && selectionStart ) { - onMultiSelect( null, null ); - } - - // Expand multi-selection to block under cursor. - if ( ! isAtStart && selectionEnd !== clientId ) { - onMultiSelect( selectionAtStart, clientId ); - } + // The browser selection won't have updated yet at this point, so wait + // until the next animation frame to get the browser selection. + this.rafId = window.requestAnimationFrame( this.setSelection ); } - /** - * Handles a mouseup event to end the current cursor multi-selection. - */ - onSelectionEnd() { - // Cancel throttled calls. - this.onPointerMove.cancel(); + setSelection() { + const selection = window.getSelection(); - delete this.coordMap; - delete this.coordMapKeys; - delete this.selectionAtStart; + // If no selection is found, end multi selection. + if ( ! selection.rangeCount || selection.isCollapsed ) { + this.props.onStopMultiSelect(); + return; + } - window.removeEventListener( 'mousemove', this.onPointerMove ); - window.removeEventListener( 'scroll', this.onScroll, true ); - window.removeEventListener( 'mouseup', this.onSelectionEnd ); + let { focusNode } = selection; + let clientId; - // We may or may not be in a multi-selection when mouseup occurs (e.g. - // an in-place mouse click), so only trigger stop if multi-selecting. - if ( this.props.isMultiSelecting ) { + // Find the client ID of the block where the selection ends. + do { + focusNode = focusNode.parentElement; + } while ( + focusNode && + ! ( clientId = focusNode.getAttribute( 'data-block' ) ) + ); + + // If the final selection doesn't leave the block, there is no multi + // selection. + if ( this.startClientId === clientId ) { this.props.onStopMultiSelect(); + return; } + + this.props.onMultiSelect( this.startClientId, clientId ); + this.props.onStopMultiSelect(); } render() { @@ -202,15 +223,17 @@ class BlockList extends Component { hasMultiSelection, renderAppender, enableAnimation, + isMultiSelecting, } = this.props; return ( -
+ ) } + > { blockClientIds.map( ( clientId, index ) => { const isBlockInSelection = hasMultiSelection ? multiSelectedBlockClientIds.includes( clientId ) : @@ -225,11 +248,10 @@ class BlockList extends Component { .block-editor-block-list__block-edit::before { // Use opacity to work in various editor styles. border-color: $dark-opacity-light-800; @@ -158,6 +157,28 @@ } } + // Selected style. + &.is-multi-selected { + > .block-editor-block-list__block-edit::before { + border-left-color: $dark-opacity-light-800; + box-shadow: inset $block-left-border-width 0 0 0 $dark-gray-500; + + .is-dark-theme & { + border-left-color: $light-opacity-light-800; + box-shadow: inset $block-left-border-width 0 0 0 $light-gray-600; + } + + // Switch to outset borders on larger screens. + @include break-small() { + box-shadow: -$block-left-border-width 0 0 0 $dark-gray-500; + + .is-dark-theme & { + box-shadow: -$block-left-border-width 0 0 0 $light-gray-600; + } + } + } + } + // Hover style. &.is-hovered:not(.is-navigate-mode) > .block-editor-block-list__block-edit::before { box-shadow: -$block-left-border-width 0 0 0 $dark-opacity-light-500; @@ -248,36 +269,6 @@ * Cross-block selection */ -.block-editor-block-list__layout .block-editor-block-list__block { - ::-moz-selection { - background-color: $blue-medium-highlight; - } - - ::selection { - background-color: $blue-medium-highlight; - } - - // Selection style for multiple blocks. - &.is-multi-selected *::selection { - background-color: transparent; - } - - &.is-multi-selected .block-editor-block-list__block-edit::before { - background: $blue-medium-highlight; - - // Use opacity to work in various editor styles. - mix-blend-mode: multiply; - - // Collapse extra vertical padding on selection. - top: -$block-padding; - bottom: -$block-padding; - - .is-dark-theme & { - mix-blend-mode: soft-light; - } - } -} - /** * Block styles and alignments diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 1e7d6bfdb85cf..c970f58f12b06 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -472,6 +472,8 @@ const RichTextContainer = compose( [ getSettings, didAutomaticChange, __unstableGetBlockWithoutInnerBlocks, + isMultiSelecting, + hasMultiSelection, } = select( 'core/block-editor' ); const selectionStart = getSelectionStart(); @@ -505,6 +507,7 @@ const RichTextContainer = compose( [ selectionEnd: isSelected ? selectionEnd.offset : undefined, isSelected, didAutomaticChange: didAutomaticChange(), + disabled: isMultiSelecting() || hasMultiSelection(), ...extraProps, }; } ), diff --git a/packages/block-editor/src/components/rich-text/style.scss b/packages/block-editor/src/components/rich-text/style.scss index 8d3eeb019834d..bb4818a6e47d0 100644 --- a/packages/block-editor/src/components/rich-text/style.scss +++ b/packages/block-editor/src/components/rich-text/style.scss @@ -14,10 +14,6 @@ background: $light-gray-200; font-family: $editor-html-font; font-size: inherit; // This is necessary to override upstream CSS. - - .is-multi-selected & { - background: darken($blue-medium-highlight, 15%); - } } &:focus { diff --git a/packages/block-editor/src/components/warning/style.scss b/packages/block-editor/src/components/warning/style.scss index 3f6c78825c68b..da6306932af85 100644 --- a/packages/block-editor/src/components/warning/style.scss +++ b/packages/block-editor/src/components/warning/style.scss @@ -8,11 +8,6 @@ text-align: left; padding: 10px $block-padding $block-padding; - // Avoid conflict with the multi-selection highlight color. - .has-warning.is-multi-selected & { - background-color: transparent; - } - .is-selected & { // Use opacity to work in various editor styles. border-color: $dark-opacity-light-800; diff --git a/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js b/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js index 6bf7a62bf7789..f0f7945a16df9 100644 --- a/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js +++ b/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js @@ -3,134 +3,107 @@ */ import { clickBlockAppender, - insertBlock, createNewPost, pressKeyWithModifier, pressKeyTimes, getEditedPostContent, } from '@wordpress/e2e-test-utils'; -describe( 'Multi-block selection', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - it( 'Should select/unselect multiple blocks', async () => { - const firstBlockSelector = '[data-type="core/paragraph"]'; - const secondBlockSelector = '[data-type="core/image"]'; - const thirdBlockSelector = '[data-type="core/quote"]'; - const multiSelectedCssClass = 'is-multi-selected'; +async function getSelectedFlatIndices() { + return await page.evaluate( () => { + const indices = []; + let single; - // Creating test blocks - await clickBlockAppender(); - await page.keyboard.type( 'First Paragraph' ); - await insertBlock( 'Image' ); - await insertBlock( 'Quote' ); - await page.keyboard.type( 'Quote Block' ); - - const blocks = [ firstBlockSelector, secondBlockSelector, thirdBlockSelector ]; - const expectMultiSelected = async ( selectors, areMultiSelected ) => { - for ( const selector of selectors ) { - const className = await page.$eval( selector, ( element ) => element.className ); - if ( areMultiSelected ) { - expect( className ).toEqual( expect.stringContaining( multiSelectedCssClass ) ); - } else { - expect( className ).not.toEqual( expect.stringContaining( multiSelectedCssClass ) ); - } + Array.from( + document.querySelectorAll( '.wp-block' ) + ).forEach( ( node, index ) => { + if ( node.classList.contains( 'is-selected' ) ) { + single = index; } - }; - // Default: No selection - await expectMultiSelected( blocks, false ); - - // Multiselect via Shift + click - await page.mouse.move( 200, 300 ); - await page.click( firstBlockSelector ); - await page.keyboard.down( 'Shift' ); - await page.click( thirdBlockSelector ); - await page.keyboard.up( 'Shift' ); - - // Verify selection - await expectMultiSelected( blocks, true ); + if ( node.classList.contains( 'is-multi-selected' ) ) { + indices.push( index ); + } + } ); - // Unselect - await page.click( secondBlockSelector ); + return single !== undefined ? single : indices; + } ); +} - // No selection - await expectMultiSelected( blocks, false ); +/** + * Tests if the native selection matches the block selection. + */ +async function testNativeSelection() { + // Wait for the selection to update. + await page.evaluate( () => new Promise( window.requestAnimationFrame ) ); + await page.evaluate( () => { + const selection = window.getSelection(); + const elements = Array.from( + document.querySelectorAll( '.is-multi-selected' ) + ); + + if ( ! elements.length ) { + const element = document.querySelector( '.is-selected' ); + + if ( ! element || ! selection.rangeCount ) { + return; + } - // Multiselect via keyboard - await page.click( 'body' ); - await pressKeyWithModifier( 'primary', 'a' ); + const { startContainer, endContainer } = selection.getRangeAt( 0 ); - // Verify selection - await expectMultiSelected( blocks, true ); + if ( ! element.contains( startContainer ) ) { + throw 'expected selection to start in the selected block'; + } - // Unselect - await page.keyboard.press( 'Escape' ); + if ( ! element.contains( endContainer ) ) { + throw 'expected selection to start in the selected block'; + } - // No selection - await expectMultiSelected( blocks, false ); + return; + } - // Select all via double shortcut. - await page.click( firstBlockSelector ); - await pressKeyWithModifier( 'primary', 'a' ); - await pressKeyWithModifier( 'primary', 'a' ); - await expectMultiSelected( blocks, true ); - } ); + if ( ! selection.rangeCount === 1 ) { + throw 'expected one range'; + } - it( 'Should select/unselect multiple blocks using Shift + Arrows', async () => { - const firstBlockSelector = '[data-type="core/paragraph"]'; - const secondBlockSelector = '[data-type="core/image"]'; - const thirdBlockSelector = '[data-type="core/quote"]'; - const multiSelectedCssClass = 'is-multi-selected'; + if ( selection.isCollapsed ) { + throw 'expected an uncollapsed selection'; + } - // Creating test blocks - await clickBlockAppender(); - await page.keyboard.type( 'First Paragraph' ); - await insertBlock( 'Image' ); - await insertBlock( 'Quote' ); - await page.keyboard.type( 'Quote Block' ); - - const blocks = [ firstBlockSelector, secondBlockSelector, thirdBlockSelector ]; - const expectMultiSelected = async ( selectors, areMultiSelected ) => { - for ( const selector of selectors ) { - const className = await page.$eval( selector, ( element ) => element.className ); - if ( areMultiSelected ) { - expect( className ).toEqual( expect.stringContaining( multiSelectedCssClass ) ); - } else { - expect( className ).not.toEqual( expect.stringContaining( multiSelectedCssClass ) ); - } - } - }; + const firstElement = elements[ 0 ]; + const lastElement = elements[ elements.length - 1 ]; + const { startContainer, endContainer } = selection.getRangeAt( 0 ); - // Default: No selection - await expectMultiSelected( blocks, false ); + if ( ! firstElement.contains( startContainer ) ) { + throw 'expected selection to start in the first selected block'; + } - // Multiselect via Shift + click - await page.mouse.move( 200, 300 ); - await page.click( firstBlockSelector ); - await page.keyboard.down( 'Shift' ); - await page.keyboard.press( 'ArrowDown' ); // Two blocks selected - await page.keyboard.press( 'ArrowDown' ); // Three blocks selected - await page.keyboard.up( 'Shift' ); + if ( ! lastElement.contains( endContainer ) ) { + throw 'expected selection to end in the last selected block'; + } + } ); +} - // Verify selection - await expectMultiSelected( blocks, true ); +describe( 'Multi-block selection', () => { + beforeEach( async () => { + await createNewPost(); } ); - it( 'should speak() number of blocks selected with multi-block selection', async () => { + it( 'should select with double ctrl+a and speak', async () => { await clickBlockAppender(); - await page.keyboard.type( 'First Paragraph' ); - await insertBlock( 'Paragraph' ); - await page.keyboard.type( 'Second Paragraph' ); - await insertBlock( 'Paragraph' ); - await page.keyboard.type( 'Third Paragraph' ); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '3' ); // Multiselect via keyboard. await pressKeyWithModifier( 'primary', 'a' ); await pressKeyWithModifier( 'primary', 'a' ); + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toEqual( [ 1, 2, 3 ] ); + // TODO: It would be great to do this test by spying on `wp.a11y.speak`, // but it's very difficult to do that because `wp.a11y` has // DOM-dependant side-effect setup code and doesn't seem straightforward @@ -188,6 +161,7 @@ describe( 'Multi-block selection', () => { await page.keyboard.press( 'ArrowLeft' ); await pressKeyWithModifier( 'shift', 'ArrowRight' ); await pressKeyWithModifier( 'shift', 'ArrowUp' ); + await testNativeSelection(); // This delete all blocks. await page.keyboard.press( 'Backspace' ); @@ -203,4 +177,120 @@ describe( 'Multi-block selection', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'should select and deselect with shift and arrow keys', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '3' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '4' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '5' ); + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.press( 'ArrowUp' ); + await pressKeyWithModifier( 'shift', 'ArrowDown' ); + + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toEqual( [ 3, 4 ] ); + + await pressKeyWithModifier( 'shift', 'ArrowDown' ); + + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toEqual( [ 3, 4, 5 ] ); + + await pressKeyWithModifier( 'shift', 'ArrowUp' ); + + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toEqual( [ 3, 4 ] ); + + await pressKeyWithModifier( 'shift', 'ArrowUp' ); + + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toBe( 3 ); + + await pressKeyWithModifier( 'shift', 'ArrowUp' ); + + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toEqual( [ 2, 3 ] ); + + await pressKeyWithModifier( 'shift', 'ArrowUp' ); + + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toEqual( [ 1, 2, 3 ] ); + + await pressKeyWithModifier( 'shift', 'ArrowDown' ); + + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toEqual( [ 2, 3 ] ); + + await pressKeyWithModifier( 'shift', 'ArrowDown' ); + + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toBe( 3 ); + } ); + + it( 'should deselect with Escape', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'a' ); + + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toEqual( [ 1, 2 ] ); + + await page.keyboard.press( 'Escape' ); + + expect( await getSelectedFlatIndices() ).toEqual( [] ); + } ); + + it( 'should select with shift + click', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + await page.keyboard.down( 'Shift' ); + await page.click( '.wp-block-paragraph' ); + await page.keyboard.up( 'Shift' ); + + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toEqual( [ 1, 2 ] ); + } ); + + it( 'should select by dragging', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + await page.keyboard.press( 'ArrowUp' ); + + const [ coord1, coord2 ] = await page.evaluate( () => { + const elements = Array.from( document.querySelectorAll( '.wp-block-paragraph' ) ); + const rect1 = elements[ 0 ].getBoundingClientRect(); + const rect2 = elements[ 1 ].getBoundingClientRect(); + return [ + { + x: rect1.x + ( rect1.width / 2 ), + y: rect1.y + ( rect1.height / 2 ), + }, + { + x: rect2.x + ( rect2.width / 2 ), + y: rect2.y + ( rect2.height / 2 ), + }, + ]; + } ); + + await page.mouse.move( coord1.x, coord1.y ); + await page.mouse.down(); + await page.mouse.move( coord2.x, coord2.y, { steps: 10 } ); + await page.mouse.up(); + + await testNativeSelection(); + expect( await getSelectedFlatIndices() ).toEqual( [ 1, 2 ] ); + } ); } ); diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index f4e1af80ec42a..a71904801b1fc 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -496,6 +496,10 @@ class RichText extends Component { return; } + if ( this.props.disabled ) { + return; + } + // In case of a keyboard event, ignore selection changes during // composition. if (