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..13ba64f4d1e88 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,94 @@ 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 ); - componentWillUnmount() { - window.removeEventListener( 'mousemove', this.setLastClientY ); - } - - setLastClientY( { clientY } ) { - this.lastClientY = clientY; - } - - setBlockRef( node, clientId ) { - if ( node === null ) { - delete this.nodes[ clientId ]; - } else { - this.nodes = { - ...this.nodes, - [ clientId ]: node, - }; - } + this.ref = createRef(); } /** - * 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(); + componentDidUpdate() { + 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(); - this.onSelectionChange( this.coordMap[ key ] ); + // 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' ); + + range.setStartBefore( startNode ); + range.setEndAfter( endNode ); + + selection.removeAllRanges(); + selection.addRange( range ); + } + + componentWillUnmount() { + window.removeEventListener( 'mouseup', this.onSelectionEnd ); + window.cancelAnimationFrame( this.rafId ); } /** @@ -121,73 +136,72 @@ 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 ); - - // 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; + this.startClientId = clientId; + this.props.onStartMultiSelect(); - 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 ); - } + 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(); + + // If no selection is found, end multi selection. + if ( ! selection.rangeCount || selection.isCollapsed ) { + this.props.onStopMultiSelect(); + return; + } - delete this.coordMap; - delete this.coordMapKeys; - delete this.selectionAtStart; + let { focusNode } = selection; + let clientId; - window.removeEventListener( 'mousemove', this.onPointerMove ); - window.removeEventListener( 'scroll', this.onScroll, true ); - window.removeEventListener( 'mouseup', this.onSelectionEnd ); + // Find the client ID of the block where the selection ends. + do { + focusNode = focusNode.parentElement; + } while ( + focusNode && + ! ( clientId = focusNode.getAttribute( 'data-block' ) ) + ); - // 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 ) { + // 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 +216,17 @@ class BlockList extends Component { hasMultiSelection, renderAppender, enableAnimation, + isMultiSelecting, } = this.props; return ( -
+ ) } + > { blockClientIds.map( ( clientId, index ) => { const isBlockInSelection = hasMultiSelection ? multiSelectedBlockClientIds.includes( clientId ) : @@ -225,11 +241,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..0b9bef3825b67 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,105 @@ */ 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() { + 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 +159,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 +175,121 @@ 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 page.evaluate( () => new Promise( window.requestAnimationFrame ) ); + + expect( await getSelectedFlatIndices() ).toEqual( [ 1, 2 ] ); + await testNativeSelection(); + } ); } ); diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index f4e1af80ec42a..bec244f7929de 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.__unstableContentEditable === false ) { + return; + } + // In case of a keyboard event, ignore selection changes during // composition. if (