From 721e3c4d3c1854ca839b0dd0713e8756060aaacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20van=C2=A0Durpe?= <4710635+ellatrix@users.noreply.github.com> Date: Mon, 28 Mar 2022 12:47:21 +0300 Subject: [PATCH] Multi-selection: allow partial block selection (#38892) --- .../data/data-core-block-editor.md | 4 +- .../components/block-list-appender/index.js | 5 + .../src/components/block-list/style.scss | 9 +- .../block-list/use-block-props/index.js | 5 - .../use-focus-first-element.js | 10 +- .../use-block-props/use-focus-handler.js | 8 + .../use-block-props/use-multi-selection.js | 227 ----------- .../use-block-props/use-scroll-into-view.js | 73 ---- .../src/components/block-tools/index.js | 19 - .../components/keyboard-shortcuts/index.js | 2 +- .../src/components/rich-text/index.js | 24 +- .../rich-text/use-firefox-compat.js | 39 ++ .../src/components/writing-flow/index.js | 8 + .../src/components/writing-flow/readme.md | 28 ++ .../components/writing-flow/use-arrow-nav.js | 57 +-- .../writing-flow/use-click-selection.js | 65 +++ .../writing-flow/use-drag-selection.js | 126 ++++++ .../src/components/writing-flow/use-input.js | 83 ++++ .../writing-flow/use-multi-selection.js | 49 +-- .../writing-flow/use-selection-observer.js | 153 +++++++ packages/block-editor/src/store/actions.js | 381 ++++++++++++++++-- packages/block-editor/src/store/reducer.js | 35 +- packages/block-editor/src/store/selectors.js | 97 +++++ .../__snapshots__/block-deletion.test.js.snap | 32 +- ...ock-editor-keyboard-shortcuts.test.js.snap | 26 ++ .../multi-block-selection.test.js.snap | 112 ++++- .../block-editor-keyboard-shortcuts.test.js | 8 +- .../copy-cut-paste-whole-blocks.test.js | 4 + .../various/multi-block-selection.test.js | 140 ++++++- .../src/components/visual-editor/index.js | 5 +- packages/rich-text/src/component/index.js | 4 + .../src/component/use-input-and-selection.js | 70 ++-- 32 files changed, 1382 insertions(+), 526 deletions(-) delete mode 100644 packages/block-editor/src/components/block-list/use-block-props/use-multi-selection.js delete mode 100644 packages/block-editor/src/components/block-list/use-block-props/use-scroll-into-view.js create mode 100644 packages/block-editor/src/components/rich-text/use-firefox-compat.js create mode 100644 packages/block-editor/src/components/writing-flow/readme.md create mode 100644 packages/block-editor/src/components/writing-flow/use-click-selection.js create mode 100644 packages/block-editor/src/components/writing-flow/use-drag-selection.js create mode 100644 packages/block-editor/src/components/writing-flow/use-input.js create mode 100644 packages/block-editor/src/components/writing-flow/use-selection-observer.js diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 798871d54b1e8d..602251b853c949 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -1390,7 +1390,7 @@ Action that changes the position of the user caret. _Parameters_ -- _clientId_ `string`: The selected block client ID. +- _clientId_ `string|WPSelection`: The selected block client ID. - _attributeKey_ `string`: The selected block attribute key. - _startOffset_ `number`: The start offset. - _endOffset_ `number`: The end offset. @@ -1462,7 +1462,7 @@ _Parameters_ - _rootClientId_ `?string`: Optional root client ID of block list on which to insert. - _index_ `?number`: Index at which block should be inserted. -- _\_\_unstableOptions_ `Object`: Wether or not to show an inserter button. +- _\_\_unstableOptions_ `Object`: Whether or not to show an inserter button. _Returns_ diff --git a/packages/block-editor/src/components/block-list-appender/index.js b/packages/block-editor/src/components/block-list-appender/index.js index c9d114fef0c7cc..d6cc56d5e2cc9d 100644 --- a/packages/block-editor/src/components/block-list-appender/index.js +++ b/packages/block-editor/src/components/block-list-appender/index.js @@ -73,6 +73,11 @@ function BlockListAppender( { 'block-list-appender wp-block', className ) } + // Needed in case the whole editor is content editable (for multi + // selection). It fixes an edge case where ArrowDown and ArrowRight + // should collapse the selection to the end of that selection and + // not into the appender. + contentEditable={ false } // The appender exists to let you add the first Paragraph before // any is inserted. To that end, this appender should visually be // presented as a block. That means theme CSS should style it as if diff --git a/packages/block-editor/src/components/block-list/style.scss b/packages/block-editor/src/components/block-list/style.scss index 7b16dff3731a30..73f1579de94d4a 100644 --- a/packages/block-editor/src/components/block-list/style.scss +++ b/packages/block-editor/src/components/block-list/style.scss @@ -26,9 +26,9 @@ // When selecting multiple blocks, we provide an additional selection indicator. &.is-navigate-mode .block-editor-block-list__block.is-selected, &.is-navigate-mode .block-editor-block-list__block.is-hovered, + .block-editor-block-list__block.is-multi-selected:not([contenteditable]), .block-editor-block-list__block.is-highlighted, - .block-editor-block-list__block.is-multi-selected { - + .block-editor-block-list__block.is-highlighted ~ .is-multi-selected { &::after { // Show selection borders around every non-nested block's actual footprint. position: absolute; @@ -41,7 +41,7 @@ right: $border-width; // Everything else. - box-shadow: 0 0 0 $border-width var(--wp-admin-theme-color); + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); border-radius: $radius-block-ui - $border-width; // Border is outset, so subtract the width to achieve correct radius. // Windows High Contrast mode will show this outline. @@ -66,11 +66,10 @@ } .block-editor-block-list__block.is-highlighted::after { - box-shadow: 0 0 0 $border-width var(--wp-admin-theme-color); + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); outline: $border-width solid transparent; } - .block-editor-block-list__block.is-multi-selected::after, &.is-navigate-mode .block-editor-block-list__block.is-selected::after, & .is-block-moving-mode.block-editor-block-list__block.has-child-selected { box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); diff --git a/packages/block-editor/src/components/block-list/use-block-props/index.js b/packages/block-editor/src/components/block-list/use-block-props/index.js index d03cb5ecf70a1b..a2ecc329b1c547 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/index.js +++ b/packages/block-editor/src/components/block-list/use-block-props/index.js @@ -31,9 +31,7 @@ import { useBlockMovingModeClassNames } from './use-block-moving-mode-class-name import { useFocusHandler } from './use-focus-handler'; import { useEventHandlers } from './use-selected-block-event-handlers'; import { useNavModeExit } from './use-nav-mode-exit'; -import { useScrollIntoView } from './use-scroll-into-view'; import { useBlockRefProvider } from './use-block-refs'; -import { useMultiSelection } from './use-multi-selection'; import { useIntersectionObserver } from './use-intersection-observer'; import { store as blockEditorStore } from '../../../store'; @@ -115,11 +113,8 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { const mergedRefs = useMergeRefs( [ props.ref, useFocusFirstElement( clientId ), - // Must happen after focus because we check for focus in the block. - useScrollIntoView( clientId ), useBlockRefProvider( clientId ), useFocusHandler( clientId ), - useMultiSelection( clientId ), useEventHandlers( clientId ), useNavModeExit( clientId ), useIsHovered(), diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js b/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js index 0ee30500f4b128..8b95f19f3c9ff2 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js @@ -20,7 +20,6 @@ import { useSelect } from '@wordpress/data'; */ import { isInsideRootBlock } from '../../../utils/dom'; import { store as blockEditorStore } from '../../../store'; -import { setContentEditableWrapper } from './use-multi-selection'; /** @typedef {import('@wordpress/element').RefObject} RefObject */ @@ -37,7 +36,6 @@ function useInitialPosition( clientId ) { ( select ) => { const { getSelectedBlocksInitialCaretPosition, - isMultiSelecting, isNavigationMode, isBlockSelected, } = select( blockEditorStore ); @@ -46,7 +44,7 @@ function useInitialPosition( clientId ) { return; } - if ( isMultiSelecting() || isNavigationMode() ) { + if ( isNavigationMode() ) { return; } @@ -68,11 +66,11 @@ function useInitialPosition( clientId ) { export function useFocusFirstElement( clientId ) { const ref = useRef(); const initialPosition = useInitialPosition( clientId ); - const { isBlockSelected } = useSelect( blockEditorStore ); + const { isBlockSelected, isMultiSelecting } = useSelect( blockEditorStore ); useEffect( () => { // Check if the block is still selected at the time this effect runs. - if ( ! isBlockSelected( clientId ) ) { + if ( ! isBlockSelected( clientId ) || isMultiSelecting() ) { return; } @@ -121,8 +119,6 @@ export function useFocusFirstElement( clientId ) { } } - setContentEditableWrapper( ref.current, false ); - placeCaretAtHorizontalEdge( target, isReverse ); }, [ initialPosition, clientId ] ); diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-focus-handler.js b/packages/block-editor/src/components/block-list/use-block-props/use-focus-handler.js index ac9e0f8c6d1bdf..4e9ffa45725003 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-focus-handler.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-focus-handler.js @@ -30,6 +30,14 @@ export function useFocusHandler( clientId ) { * @param {FocusEvent} event Focus event. */ function onFocus( event ) { + // When the whole editor is editable, let writing flow handle + // selection. + if ( + node.parentElement.closest( '[contenteditable="true"]' ) + ) { + return; + } + // Check synchronously because a non-selected block might be // getting data through `useSelect` asynchronously. if ( isBlockSelected( clientId ) ) { diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-multi-selection.js b/packages/block-editor/src/components/block-list/use-block-props/use-multi-selection.js deleted file mode 100644 index 23fd567bb738d8..00000000000000 --- a/packages/block-editor/src/components/block-list/use-block-props/use-multi-selection.js +++ /dev/null @@ -1,227 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect, useDispatch } from '@wordpress/data'; -import { useRefEffect } from '@wordpress/compose'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../../store'; -import { getBlockClientId } from '../../../utils/dom'; - -/** - * Sets the `contenteditable` wrapper element to `value`. - * - * @param {HTMLElement} node Block element. - * @param {boolean} value `contentEditable` value (true or false) - */ -export function setContentEditableWrapper( node, value ) { - // Since `closest` considers `node` as a candidate, use `parentElement`. - node.parentElement.closest( '[contenteditable]' ).contentEditable = value; -} - -/** - * Sets a multi-selection based on the native selection across blocks. - * - * @param {string} clientId Block client ID. - */ -export function useMultiSelection( clientId ) { - const { - startMultiSelect, - stopMultiSelect, - multiSelect, - selectBlock, - } = useDispatch( blockEditorStore ); - const { - isSelectionEnabled, - isBlockSelected, - getBlockParents, - getBlockSelectionStart, - hasMultiSelection, - } = useSelect( blockEditorStore ); - return useRefEffect( - ( node ) => { - const { ownerDocument } = node; - const { defaultView } = ownerDocument; - - let anchorElement; - let rafId; - - function onSelectionChange( { isSelectionEnd } ) { - const selection = defaultView.getSelection(); - - // If no selection is found, end multi selection and disable the - // contentEditable wrapper. - if ( ! selection.rangeCount || selection.isCollapsed ) { - setContentEditableWrapper( node, false ); - return; - } - - const endClientId = getBlockClientId( selection.focusNode ); - const isSingularSelection = clientId === endClientId; - - if ( isSingularSelection ) { - selectBlock( clientId ); - - // If the selection is complete (on mouse up), and no - // multiple blocks have been selected, set focus back to the - // anchor element. if the anchor element contains the - // selection. Additionally, the contentEditable wrapper can - // now be disabled again. - if ( isSelectionEnd ) { - setContentEditableWrapper( node, false ); - - if ( selection.rangeCount ) { - const { - commonAncestorContainer, - } = selection.getRangeAt( 0 ); - - if ( - anchorElement.contains( - commonAncestorContainer - ) - ) { - anchorElement.focus(); - } - } - } - } else { - const startPath = [ - ...getBlockParents( clientId ), - clientId, - ]; - const endPath = [ - ...getBlockParents( endClientId ), - endClientId, - ]; - const depth = - Math.min( startPath.length, endPath.length ) - 1; - - multiSelect( startPath[ depth ], endPath[ depth ] ); - } - } - - function onSelectionEnd() { - ownerDocument.removeEventListener( - 'selectionchange', - onSelectionChange - ); - // Equivalent to attaching the listener once. - defaultView.removeEventListener( 'mouseup', onSelectionEnd ); - // The browser selection won't have updated yet at this point, - // so wait until the next animation frame to get the browser - // selection. - rafId = defaultView.requestAnimationFrame( () => { - onSelectionChange( { isSelectionEnd: true } ); - stopMultiSelect(); - } ); - } - - function onMouseLeave( { buttons } ) { - // The primary button must be pressed to initiate selection. - // See https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons - if ( buttons !== 1 ) { - return; - } - - if ( ! isSelectionEnabled() || ! isBlockSelected( clientId ) ) { - return; - } - - anchorElement = ownerDocument.activeElement; - startMultiSelect(); - - // `onSelectionStart` is called after `mousedown` and - // `mouseleave` (from a block). The selection ends when - // `mouseup` happens anywhere in the window. - ownerDocument.addEventListener( - 'selectionchange', - onSelectionChange - ); - defaultView.addEventListener( 'mouseup', onSelectionEnd ); - - // Allow cross contentEditable selection by temporarily making - // all content editable. We can't rely on using the store and - // React because re-rending happens too slowly. We need to be - // able to select across instances immediately. - setContentEditableWrapper( node, true ); - } - - function onMouseDown( event ) { - // The main button. - // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button - if ( ! isSelectionEnabled() || event.button !== 0 ) { - return; - } - - if ( event.shiftKey ) { - const blockSelectionStart = getBlockSelectionStart(); - // By checking `blockSelectionStart` to be set, we handle the - // case where we select a single block. We also have to check - // the selectionEnd (clientId) not to be included in the - // `blockSelectionStart`'s parents because the click event is - // propagated. - const startParents = getBlockParents( blockSelectionStart ); - if ( - blockSelectionStart && - blockSelectionStart !== clientId && - ! startParents?.includes( clientId ) - ) { - const startPath = [ - ...startParents, - blockSelectionStart, - ]; - const endPath = [ - ...getBlockParents( clientId ), - clientId, - ]; - const depth = - Math.min( startPath.length, endPath.length ) - 1; - const start = startPath[ depth ]; - const end = endPath[ depth ]; - // Handle the case of having selected a parent block and - // then shift+click on a child. - if ( start !== end ) { - setContentEditableWrapper( node, true ); - multiSelect( start, end ); - event.preventDefault(); - } - } - } else if ( hasMultiSelection() ) { - // Allow user to escape out of a multi-selection to a - // singular selection of a block via click. This is handled - // here since focus handling excludes blocks when there is - // multiselection, as focus can be incurred by starting a - // multiselection (focus moved to first block's multi- - // controls). - selectBlock( clientId ); - } - } - - node.addEventListener( 'mousedown', onMouseDown ); - node.addEventListener( 'mouseleave', onMouseLeave ); - - return () => { - node.removeEventListener( 'mousedown', onMouseDown ); - node.removeEventListener( 'mouseleave', onMouseLeave ); - ownerDocument.removeEventListener( - 'selectionchange', - onSelectionChange - ); - defaultView.removeEventListener( 'mouseup', onSelectionEnd ); - defaultView.cancelAnimationFrame( rafId ); - }; - }, - [ - clientId, - startMultiSelect, - stopMultiSelect, - multiSelect, - selectBlock, - isSelectionEnabled, - isBlockSelected, - getBlockParents, - ] - ); -} diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-scroll-into-view.js b/packages/block-editor/src/components/block-list/use-block-props/use-scroll-into-view.js deleted file mode 100644 index bd6aca0c43645a..00000000000000 --- a/packages/block-editor/src/components/block-list/use-block-props/use-scroll-into-view.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * External dependencies - */ -import scrollIntoView from 'dom-scroll-into-view'; - -/** - * WordPress dependencies - */ -/** - * WordPress dependencies - */ -import { useEffect, useRef } from '@wordpress/element'; -import { useSelect } from '@wordpress/data'; -import { getScrollContainer } from '@wordpress/dom'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../../store'; - -export function useScrollIntoView( clientId ) { - const ref = useRef(); - const isSelectionEnd = useSelect( - ( select ) => { - const { isBlockSelected, getBlockSelectionEnd } = select( - blockEditorStore - ); - - return ( - isBlockSelected( clientId ) || - getBlockSelectionEnd() === clientId - ); - }, - [ clientId ] - ); - - // Note that we can't use `useRefEffect` here, since an element change does - // not mean we can scroll. `isSelectionEnd` should be the sole dependency, - // while with `useRefEffect`, the element is a dependency as well. - useEffect( () => { - if ( ! isSelectionEnd ) { - return; - } - - const extentNode = ref.current; - - if ( ! extentNode ) { - return; - } - - // If the block is focused, the browser will already have scrolled into - // view if necessary. - if ( extentNode.contains( extentNode.ownerDocument.activeElement ) ) { - return; - } - - const scrollContainer = - getScrollContainer( extentNode ) || - extentNode.ownerDocument.defaultView; - - // If there's no scroll container, it follows that there's no scrollbar - // and thus there's no need to try to scroll into view. - if ( ! scrollContainer ) { - return; - } - - scrollIntoView( extentNode, scrollContainer, { - onlyScrollIfNeeded: true, - } ); - }, [ isSelectionEnd ] ); - - return ref; -} diff --git a/packages/block-editor/src/components/block-tools/index.js b/packages/block-editor/src/components/block-tools/index.js index e4d4c589a160bb..d581a43dae4a8f 100644 --- a/packages/block-editor/src/components/block-tools/index.js +++ b/packages/block-editor/src/components/block-tools/index.js @@ -92,25 +92,6 @@ export default function BlockTools( { event.preventDefault(); insertBeforeBlock( first( clientIds ) ); } - } else if ( - isMatch( 'core/block-editor/delete-multi-selection', event ) - ) { - /** - * Check if the target element is a text area, input or - * event.defaultPrevented and return early. In all these - * cases backspace could be handled elsewhere. - */ - if ( - [ 'INPUT', 'TEXTAREA' ].includes( event.target.nodeName ) || - event.defaultPrevented - ) { - return; - } - const clientIds = getSelectedBlockClientIds(); - if ( clientIds.length > 1 ) { - event.preventDefault(); - removeBlocks( clientIds ); - } } else if ( isMatch( 'core/block-editor/unselect', event ) ) { const clientIds = getSelectedBlockClientIds(); if ( clientIds.length > 1 ) { diff --git a/packages/block-editor/src/components/keyboard-shortcuts/index.js b/packages/block-editor/src/components/keyboard-shortcuts/index.js index 4c88b7675f8779..ae41f75e473fca 100644 --- a/packages/block-editor/src/components/keyboard-shortcuts/index.js +++ b/packages/block-editor/src/components/keyboard-shortcuts/index.js @@ -61,7 +61,7 @@ function KeyboardShortcutsRegister() { registerShortcut( { name: 'core/block-editor/delete-multi-selection', category: 'block', - description: __( 'Remove multiple selected blocks.' ), + description: __( 'Delete selection.' ), keyCombination: { character: 'del', }, diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 3a44454616ef6a..3e0adca5bf7790 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -45,6 +45,7 @@ import { useFormatTypes } from './use-format-types'; import { useRemoveBrowserShortcuts } from './use-remove-browser-shortcuts'; import { useShortcuts } from './use-shortcuts'; import { useInputEvents } from './use-input-events'; +import { useFirefoxCompat } from './use-firefox-compat'; import FormatEdit from './format-edit'; import { getMultilineTag, getAllowedFormats } from './utils'; @@ -131,6 +132,7 @@ function RichTextWrapper( if ( originalIsSelected === undefined ) { isSelected = selectionStart.clientId === clientId && + selectionEnd.clientId === clientId && selectionStart.attributeKey === identifier; } else if ( originalIsSelected ) { isSelected = selectionStart.clientId === clientId; @@ -171,7 +173,26 @@ function RichTextWrapper( const onSelectionChange = useCallback( ( start, end ) => { - selectionChange( clientId, identifier, start, end ); + const selection = {}; + const unset = start === undefined && end === undefined; + + if ( typeof start === 'number' || unset ) { + selection.start = { + clientId, + attributeKey: identifier, + offset: start, + }; + } + + if ( typeof end === 'number' || unset ) { + selection.end = { + clientId, + attributeKey: identifier, + offset: end, + }; + } + + selectionChange( selection ); }, [ clientId, identifier ] ); @@ -371,6 +392,7 @@ function RichTextWrapper( disableLineBreaks, onSplitAtEnd, } ), + useFirefoxCompat(), anchorRef, ] ) } contentEditable={ true } diff --git a/packages/block-editor/src/components/rich-text/use-firefox-compat.js b/packages/block-editor/src/components/rich-text/use-firefox-compat.js new file mode 100644 index 00000000000000..59a57185081638 --- /dev/null +++ b/packages/block-editor/src/components/rich-text/use-firefox-compat.js @@ -0,0 +1,39 @@ +/** + * WordPress dependencies + */ +import { useRefEffect } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +export function useFirefoxCompat() { + const { isMultiSelecting } = useSelect( blockEditorStore ); + return useRefEffect( ( element ) => { + function onFocus() { + if ( ! isMultiSelecting() ) { + return; + } + + // This is a little hack to work around focus issues with nested + // editable elements in Firefox. For some reason the editable child + // element sometimes regains focus, while it should not be focusable + // and focus should remain on the editable parent element. + // To do: try to find the cause of the shifting focus. + const parentEditable = element.parentElement.closest( + '[contenteditable="true"]' + ); + + if ( parentEditable ) { + parentEditable.focus(); + } + } + + element.addEventListener( 'focus', onFocus ); + return () => { + element.removeEventListener( 'focus', onFocus ); + }; + }, [] ); +} diff --git a/packages/block-editor/src/components/writing-flow/index.js b/packages/block-editor/src/components/writing-flow/index.js index 7f9907b57a41be..dfd44e828c7f8f 100644 --- a/packages/block-editor/src/components/writing-flow/index.js +++ b/packages/block-editor/src/components/writing-flow/index.js @@ -18,6 +18,10 @@ import useMultiSelection from './use-multi-selection'; import useTabNav from './use-tab-nav'; import useArrowNav from './use-arrow-nav'; import useSelectAll from './use-select-all'; +import useDragSelection from './use-drag-selection'; +import useSelectionObserver from './use-selection-observer'; +import useClickSelection from './use-click-selection'; +import useInput from './use-input'; import { store as blockEditorStore } from '../../store'; export function useWritingFlow() { @@ -31,6 +35,10 @@ export function useWritingFlow() { before, useMergeRefs( [ ref, + useInput(), + useDragSelection(), + useSelectionObserver(), + useClickSelection(), useMultiSelection(), useSelectAll(), useArrowNav(), diff --git a/packages/block-editor/src/components/writing-flow/readme.md b/packages/block-editor/src/components/writing-flow/readme.md new file mode 100644 index 00000000000000..eb8c720ca12e78 --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/readme.md @@ -0,0 +1,28 @@ +# Writing Flow + +This hook handles selection across blocks. + +## Partial multi-block selection + +Selecting across blocks is possible by temporarily setting the `contentEditable` +attribute to `true`. This sounds scary, but we prevent all default behaviours +except for selection, so don't worry. :) + +* For selection by mouse, we make everything editable when the mouse leaves an + editable field. +* For Shift+Click selection, we do it on `mousedown`. +* For keyboard selection we do it when the selection reaches the edge of an + editable field. + +In the future, we should consider using the `contentEditable` attribute for +arrow key navigation as well. + +Now that it's possible to select across blocks, we need to sync this state to +the block editor store. We can do this by listening to the `selectionchange` +event. In writing flow, we can sync the selected block client ID, but when the +selection starts or ends in a rich text field, it will be rich text that sync a +more precise position (the block attribute key and offset in addition to the +client ID). + +With the selection state in the block editor store, we can now handle things +like Backspace, Delete, and Enter. diff --git a/packages/block-editor/src/components/writing-flow/use-arrow-nav.js b/packages/block-editor/src/components/writing-flow/use-arrow-nav.js index 2bf957722f5e0f..d02233650c392f 100644 --- a/packages/block-editor/src/components/writing-flow/use-arrow-nav.js +++ b/packages/block-editor/src/components/writing-flow/use-arrow-nav.js @@ -16,7 +16,7 @@ import { isRTL, } from '@wordpress/dom'; import { UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; -import { useSelect, useDispatch } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; import { useRefEffect } from '@wordpress/compose'; /** @@ -120,16 +120,12 @@ export function getClosestTabbable( export default function useArrowNav() { const { getSelectedBlockClientId, - getMultiSelectedBlocksStartClientId, getMultiSelectedBlocksEndClientId, getPreviousBlockClientId, getNextBlockClientId, - getFirstMultiSelectedBlockClientId, - getLastMultiSelectedBlockClientId, getSettings, hasMultiSelection, } = useSelect( blockEditorStore ); - const { multiSelect, selectBlock } = useDispatch( blockEditorStore ); return useRefEffect( ( node ) => { // Here a DOMRect is stored while moving the caret vertically so // vertical position of the start position can be restored. This is to @@ -140,44 +136,6 @@ export default function useArrowNav() { verticalRect = null; } - function expandSelection( isReverse ) { - const selectedBlockClientId = getSelectedBlockClientId(); - const selectionStartClientId = getMultiSelectedBlocksStartClientId(); - const selectionEndClientId = getMultiSelectedBlocksEndClientId(); - const selectionBeforeEndClientId = getPreviousBlockClientId( - selectionEndClientId || selectedBlockClientId - ); - const selectionAfterEndClientId = getNextBlockClientId( - selectionEndClientId || selectedBlockClientId - ); - const nextSelectionEndClientId = isReverse - ? selectionBeforeEndClientId - : selectionAfterEndClientId; - - if ( nextSelectionEndClientId ) { - if ( selectionStartClientId === nextSelectionEndClientId ) { - selectBlock( nextSelectionEndClientId ); - } else { - multiSelect( - selectionStartClientId || selectedBlockClientId, - nextSelectionEndClientId - ); - } - } - } - - function moveSelection( isReverse ) { - const selectedFirstClientId = getFirstMultiSelectedBlockClientId(); - const selectedLastClientId = getLastMultiSelectedBlockClientId(); - const focusedBlockClientId = isReverse - ? selectedFirstClientId - : selectedLastClientId; - - if ( focusedBlockClientId ) { - selectBlock( focusedBlockClientId ); - } - } - /** * Returns true if the given target field is the last in its block which * can be considered for tab transition. For example, in a block with @@ -218,12 +176,6 @@ export default function useArrowNav() { const { defaultView } = ownerDocument; if ( hasMultiSelection() ) { - if ( isNav ) { - const action = isShift ? expandSelection : moveSelection; - action( isReverse ); - event.preventDefault(); - } - return; } @@ -278,10 +230,9 @@ export default function useArrowNav() { isTabbableEdge( target, isReverse ) && isNavEdge( target, isReverse ) ) { - // Shift key is down, and there is multi selection or we're - // at the end of the current block. - expandSelection( isReverse ); - event.preventDefault(); + node.contentEditable = true; + // Firefox doesn't automatically move focus. + node.focus(); } } else if ( isVertical && diff --git a/packages/block-editor/src/components/writing-flow/use-click-selection.js b/packages/block-editor/src/components/writing-flow/use-click-selection.js new file mode 100644 index 00000000000000..3d7aec8d4ea4cf --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/use-click-selection.js @@ -0,0 +1,65 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useRefEffect } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import { getBlockClientId } from '../../utils/dom'; + +export default function useClickSelection() { + const { multiSelect, selectBlock } = useDispatch( blockEditorStore ); + const { + isSelectionEnabled, + getBlockParents, + getBlockSelectionStart, + hasMultiSelection, + } = useSelect( blockEditorStore ); + return useRefEffect( + ( node ) => { + function onMouseDown( event ) { + // The main button. + // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button + if ( ! isSelectionEnabled() || event.button !== 0 ) { + return; + } + + const startClientId = getBlockSelectionStart(); + const clickedClientId = getBlockClientId( event.target ); + + if ( event.shiftKey ) { + if ( startClientId !== clickedClientId ) { + node.contentEditable = true; + // Firefox doesn't automatically move focus. + node.focus(); + } + } else if ( hasMultiSelection() ) { + // Allow user to escape out of a multi-selection to a + // singular selection of a block via click. This is handled + // here since focus handling excludes blocks when there is + // multiselection, as focus can be incurred by starting a + // multiselection (focus moved to first block's multi- + // controls). + selectBlock( clickedClientId ); + } + } + + node.addEventListener( 'mousedown', onMouseDown ); + + return () => { + node.removeEventListener( 'mousedown', onMouseDown ); + }; + }, + [ + multiSelect, + selectBlock, + isSelectionEnabled, + getBlockParents, + getBlockSelectionStart, + hasMultiSelection, + ] + ); +} diff --git a/packages/block-editor/src/components/writing-flow/use-drag-selection.js b/packages/block-editor/src/components/writing-flow/use-drag-selection.js new file mode 100644 index 00000000000000..f11c07a9d4c2ec --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/use-drag-selection.js @@ -0,0 +1,126 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useRefEffect } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +/** + * Sets the `contenteditable` wrapper element to `value`. + * + * @param {HTMLElement} node Block element. + * @param {boolean} value `contentEditable` value (true or false) + */ +function setContentEditableWrapper( node, value ) { + node.contentEditable = value; + // Firefox doesn't automatically move focus. + if ( value ) node.focus(); +} + +/** + * Sets a multi-selection based on the native selection across blocks. + */ +export default function useDragSelection() { + const { startMultiSelect, stopMultiSelect } = useDispatch( + blockEditorStore + ); + const { isSelectionEnabled, hasMultiSelection } = useSelect( + blockEditorStore + ); + return useRefEffect( + ( node ) => { + const { ownerDocument } = node; + const { defaultView } = ownerDocument; + + let anchorElement; + let rafId; + + function onMouseUp() { + stopMultiSelect(); + // Equivalent to attaching the listener once. + defaultView.removeEventListener( 'mouseup', onMouseUp ); + // The browser selection won't have updated yet at this point, + // so wait until the next animation frame to get the browser + // selection. + rafId = defaultView.requestAnimationFrame( () => { + if ( hasMultiSelection() ) { + return; + } + + // If the selection is complete (on mouse up), and no + // multiple blocks have been selected, set focus back to the + // anchor element. if the anchor element contains the + // selection. Additionally, the contentEditable wrapper can + // now be disabled again. + setContentEditableWrapper( node, false ); + + const selection = defaultView.getSelection(); + + if ( selection.rangeCount ) { + const { + commonAncestorContainer, + } = selection.getRangeAt( 0 ); + + if ( + anchorElement.contains( commonAncestorContainer ) + ) { + anchorElement.focus(); + } + } + } ); + } + + function onMouseLeave( { buttons, target } ) { + // The primary button must be pressed to initiate selection. + // See https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons + if ( buttons !== 1 ) { + return; + } + + // Check the attribute, not the contentEditable attribute. All + // child elements of the content editable wrapper are editable + // and return true for this property. We only want to start + // multi selecting when the mouse leaves the wrapper. + if ( ! target.getAttribute( 'contenteditable' ) ) { + return; + } + + if ( ! isSelectionEnabled() ) { + return; + } + + anchorElement = ownerDocument.activeElement; + startMultiSelect(); + + // `onSelectionStart` is called after `mousedown` and + // `mouseleave` (from a block). The selection ends when + // `mouseup` happens anywhere in the window. + defaultView.addEventListener( 'mouseup', onMouseUp ); + + // Allow cross contentEditable selection by temporarily making + // all content editable. We can't rely on using the store and + // React because re-rending happens too slowly. We need to be + // able to select across instances immediately. + setContentEditableWrapper( node, true ); + } + + node.addEventListener( 'mouseout', onMouseLeave ); + + return () => { + node.removeEventListener( 'mouseout', onMouseLeave ); + defaultView.removeEventListener( 'mouseup', onMouseUp ); + defaultView.cancelAnimationFrame( rafId ); + }; + }, + [ + startMultiSelect, + stopMultiSelect, + isSelectionEnabled, + hasMultiSelection, + ] + ); +} diff --git a/packages/block-editor/src/components/writing-flow/use-input.js b/packages/block-editor/src/components/writing-flow/use-input.js new file mode 100644 index 00000000000000..08aeb1cdccdc8a --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/use-input.js @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useRefEffect } from '@wordpress/compose'; +import { ENTER, BACKSPACE, DELETE } from '@wordpress/keycodes'; +import { createBlock, getDefaultBlockName } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +/** + * Handles input for selections across blocks. + */ +export default function useInput() { + const { + __unstableIsFullySelected, + getSelectedBlockClientIds, + __unstableIsSelectionMergeable, + hasMultiSelection, + } = useSelect( blockEditorStore ); + const { + replaceBlocks, + __unstableSplitSelection, + removeBlocks, + __unstableDeleteSelection, + __unstableExpandSelection, + } = useDispatch( blockEditorStore ); + + return useRefEffect( ( node ) => { + function onKeyDown( event ) { + if ( event.defaultPrevented ) { + return; + } + + if ( ! hasMultiSelection() ) { + return; + } + + if ( event.keyCode === ENTER ) { + event.preventDefault(); + if ( __unstableIsFullySelected() ) { + replaceBlocks( + getSelectedBlockClientIds(), + createBlock( getDefaultBlockName() ) + ); + } else { + __unstableSplitSelection(); + } + } else if ( + event.keyCode === BACKSPACE || + event.keyCode === DELETE + ) { + event.preventDefault(); + if ( __unstableIsFullySelected() ) { + removeBlocks( getSelectedBlockClientIds() ); + } else if ( __unstableIsSelectionMergeable() ) { + __unstableDeleteSelection( event.keyCode === DELETE ); + } else { + __unstableExpandSelection(); + } + } else if ( + // If key.length is longer than 1, it's a control key that doesn't + // input anything. + event.key.length === 1 && + ! ( event.metaKey || event.ctrlKey ) + ) { + if ( __unstableIsSelectionMergeable() ) { + __unstableDeleteSelection( event.keyCode === DELETE ); + } else { + event.preventDefault(); + } + } + } + + node.addEventListener( 'keydown', onKeyDown ); + return () => { + node.removeEventListener( 'keydown', onKeyDown ); + }; + }, [] ); +} diff --git a/packages/block-editor/src/components/writing-flow/use-multi-selection.js b/packages/block-editor/src/components/writing-flow/use-multi-selection.js index b4e7ed6bd0c528..01c492a7730135 100644 --- a/packages/block-editor/src/components/writing-flow/use-multi-selection.js +++ b/packages/block-editor/src/components/writing-flow/use-multi-selection.js @@ -15,32 +15,6 @@ import { useSelect } from '@wordpress/data'; import { store as blockEditorStore } from '../../store'; import { __unstableUseBlockRef as useBlockRef } from '../block-list/use-block-props/use-block-refs'; -/** - * 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; -} - function selector( select ) { const { isMultiSelecting, @@ -48,6 +22,7 @@ function selector( select ) { hasMultiSelection, getSelectedBlockClientId, getSelectedBlocksInitialCaretPosition, + __unstableIsFullySelected, } = select( blockEditorStore ); return { @@ -56,6 +31,7 @@ function selector( select ) { hasMultiSelection: hasMultiSelection(), selectedBlockClientId: getSelectedBlockClientId(), initialPosition: getSelectedBlocksInitialCaretPosition(), + isFullSelection: __unstableIsFullySelected(), }; } @@ -66,6 +42,7 @@ export default function useMultiSelection() { multiSelectedBlockClientIds, hasMultiSelection, selectedBlockClientId, + isFullSelection, } = useSelect( selector, [] ); const selectedRef = useBlockRef( selectedBlockClientId ); // These must be in the right DOM order. @@ -120,9 +97,7 @@ export default function useMultiSelection() { return; } - // The block refs might not be immediately available - // when dragging blocks into another block. - if ( ! startRef.current || ! endRef.current ) { + if ( ! isFullSelection ) { return; } @@ -136,17 +111,18 @@ export default function useMultiSelection() { // BEFORE selection. node.focus(); + // The block refs might not be immediately available + // when dragging blocks into another block. + if ( ! startRef.current || ! endRef.current ) { + return; + } + const selection = defaultView.getSelection(); const range = ownerDocument.createRange(); // These must be in the right DOM order. - // The most stable way to select the whole block contents is to start - // and end at the deepest points. - const startNode = getDeepestNode( startRef.current, 'start' ); - const endNode = getDeepestNode( endRef.current, 'end' ); - - range.setStartBefore( startNode ); - range.setEndAfter( endNode ); + range.setStartBefore( startRef.current ); + range.setEndAfter( endRef.current ); selection.removeAllRanges(); selection.addRange( range ); @@ -157,6 +133,7 @@ export default function useMultiSelection() { multiSelectedBlockClientIds, selectedBlockClientId, initialPosition, + isFullSelection, ] ); } diff --git a/packages/block-editor/src/components/writing-flow/use-selection-observer.js b/packages/block-editor/src/components/writing-flow/use-selection-observer.js new file mode 100644 index 00000000000000..9d65dc3d8656bd --- /dev/null +++ b/packages/block-editor/src/components/writing-flow/use-selection-observer.js @@ -0,0 +1,153 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useRefEffect } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import { getBlockClientId } from '../../utils/dom'; + +/** + * Extract the selection start node from the selection. When the anchor node is + * not a text node, the selection offset is the index of a child node. + * + * @param {Selection} selection The selection. + * + * @return {Element} The selection start node. + */ +function extractSelectionStartNode( selection ) { + const { anchorNode, anchorOffset } = selection; + + if ( anchorNode.nodeType === anchorNode.TEXT_NODE ) { + return anchorNode; + } + + return anchorNode.childNodes[ anchorOffset ]; +} + +/** + * Extract the selection end node from the selection. When the focus node is not + * a text node, the selection offset is the index of a child node. The selection + * reaches up to but excluding that child node. + * + * @param {Selection} selection The selection. + * + * @return {Element} The selection start node. + */ +function extractSelectionEndNode( selection ) { + const { focusNode, focusOffset } = selection; + + if ( focusNode.nodeType === focusNode.TEXT_NODE ) { + return focusNode; + } + + return focusNode.childNodes[ focusOffset - 1 ]; +} + +function findDepth( a, b ) { + let depth = 0; + + while ( a[ depth ] === b[ depth ] ) { + depth++; + } + + return depth; +} + +/** + * Sets the `contenteditable` wrapper element to `value`. + * + * @param {HTMLElement} node Block element. + * @param {boolean} value `contentEditable` value (true or false) + */ +function setContentEditableWrapper( node, value ) { + node.contentEditable = value; + // Firefox doesn't automatically move focus. + if ( value ) node.focus(); +} + +/** + * Sets a multi-selection based on the native selection across blocks. + */ +export default function useSelectionObserver() { + const { multiSelect, selectBlock, selectionChange } = useDispatch( + blockEditorStore + ); + const { getBlockParents } = useSelect( blockEditorStore ); + return useRefEffect( + ( node ) => { + const { ownerDocument } = node; + const { defaultView } = ownerDocument; + + function onSelectionChange() { + const selection = defaultView.getSelection(); + + // If no selection is found, end multi selection and disable the + // contentEditable wrapper. + if ( ! selection.rangeCount || selection.isCollapsed ) { + setContentEditableWrapper( node, false ); + return; + } + + const clientId = getBlockClientId( + extractSelectionStartNode( selection ) + ); + const endClientId = getBlockClientId( + extractSelectionEndNode( selection ) + ); + const isSingularSelection = clientId === endClientId; + + if ( isSingularSelection ) { + selectBlock( clientId ); + } else { + const startPath = [ + ...getBlockParents( clientId ), + clientId, + ]; + const endPath = [ + ...getBlockParents( endClientId ), + endClientId, + ]; + const depth = findDepth( startPath, endPath ); + + multiSelect( startPath[ depth ], endPath[ depth ] ); + } + } + + function addListeners() { + ownerDocument.addEventListener( + 'selectionchange', + onSelectionChange + ); + defaultView.addEventListener( 'mouseup', onSelectionChange ); + } + + function removeListeners() { + ownerDocument.removeEventListener( + 'selectionchange', + onSelectionChange + ); + defaultView.removeEventListener( 'mouseup', onSelectionChange ); + } + + function resetListeners() { + removeListeners(); + addListeners(); + } + + addListeners(); + // We must allow rich text to set selection first. This ensures that + // our `selectionchange` listener is always reset to be called after + // the rich text one. + node.addEventListener( 'focusin', resetListeners ); + return () => { + removeListeners(); + node.removeEventListener( 'focusin', resetListeners ); + }; + }, + [ multiSelect, selectBlock, selectionChange, getBlockParents ] + ); +} diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index bc5bf2133045bb..29e0775f1b621d 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -100,6 +100,15 @@ export const validateBlocksToTemplate = ( blocks ) => ( { * text value. See `wp.richText.create`. */ +/** + * A selection object. + * + * @typedef {Object} WPSelection + * + * @property {WPBlockSelection} start The selection start. + * @property {WPBlockSelection} end The selection end. + */ + /* eslint-disable jsdoc/valid-types */ /** * Returns an action object used in signalling that selection state should be @@ -601,7 +610,7 @@ export const insertBlocks = ( * @param {?string} rootClientId Optional root client ID of block list on * which to insert. * @param {?number} index Index at which block should be inserted. - * @param {Object} __unstableOptions Wether or not to show an inserter button. + * @param {Object} __unstableOptions Whether or not to show an inserter button. * * @return {Object} Action object. */ @@ -658,6 +667,326 @@ export const synchronizeTemplate = () => ( { select, dispatch } ) => { dispatch.resetBlocks( updatedBlockList ); }; +function mapRichTextSettings( attributeDefinition ) { + const { + multiline: multilineTag, + __unstableMultilineWrapperTags: multilineWrapperTags, + __unstablePreserveWhiteSpace: preserveWhiteSpace, + } = attributeDefinition; + return { + multilineTag, + multilineWrapperTags, + preserveWhiteSpace, + }; +} + +/** + * Delete the current selection. + * + * @param {boolean} isForward + */ +export const __unstableDeleteSelection = ( isForward ) => ( { + registry, + select, + dispatch, +} ) => { + const selectionAnchor = select.getSelectionStart(); + const selectionFocus = select.getSelectionEnd(); + + if ( selectionAnchor.clientId === selectionFocus.clientId ) return; + + // It's not mergeable if there's no rich text selection. + if ( + ! selectionAnchor.attributeKey || + ! selectionFocus.attributeKey || + typeof selectionAnchor.offset === 'undefined' || + typeof selectionFocus.offset === 'undefined' + ) + return false; + + const anchorRootClientId = select.getBlockRootClientId( + selectionAnchor.clientId + ); + const focusRootClientId = select.getBlockRootClientId( + selectionFocus.clientId + ); + + // It's not mergeable if the selection doesn't start and end in the same + // block list. Maybe in the future it should be allowed. + if ( anchorRootClientId !== focusRootClientId ) { + return; + } + + const blockOrder = select.getBlockOrder( anchorRootClientId ); + const anchorIndex = blockOrder.indexOf( selectionAnchor.clientId ); + const focusIndex = blockOrder.indexOf( selectionFocus.clientId ); + + // Reassign selection start and end based on order. + let selectionStart, selectionEnd; + + if ( anchorIndex > focusIndex ) { + selectionStart = selectionFocus; + selectionEnd = selectionAnchor; + } else { + selectionStart = selectionAnchor; + selectionEnd = selectionFocus; + } + + const targetSelection = isForward ? selectionEnd : selectionStart; + const targetBlock = select.getBlock( targetSelection.clientId ); + const targetBlockType = getBlockType( targetBlock.name ); + + if ( ! targetBlockType.merge ) { + return; + } + + const selectionA = selectionStart; + const selectionB = selectionEnd; + + const blockA = select.getBlock( selectionA.clientId ); + const blockAType = getBlockType( blockA.name ); + + const blockB = select.getBlock( selectionB.clientId ); + const blockBType = getBlockType( blockB.name ); + + const htmlA = blockA.attributes[ selectionA.attributeKey ]; + const htmlB = blockB.attributes[ selectionB.attributeKey ]; + + const attributeDefinitionA = + blockAType.attributes[ selectionA.attributeKey ]; + const attributeDefinitionB = + blockBType.attributes[ selectionB.attributeKey ]; + + let valueA = create( { + html: htmlA, + ...mapRichTextSettings( attributeDefinitionA ), + } ); + let valueB = create( { + html: htmlB, + ...mapRichTextSettings( attributeDefinitionB ), + } ); + + // A robust way to retain selection position through various transforms + // is to insert a special character at the position and then recover it. + const START_OF_SELECTED_AREA = '\u0086'; + + valueA = remove( valueA, selectionA.offset, valueA.text.length ); + valueB = insert( valueB, START_OF_SELECTED_AREA, 0, selectionB.offset ); + + // Clone the blocks so we don't manipulate the original. + const cloneA = cloneBlock( blockA, { + [ selectionA.attributeKey ]: toHTMLString( { + value: valueA, + ...mapRichTextSettings( attributeDefinitionA ), + } ), + } ); + const cloneB = cloneBlock( blockB, { + [ selectionB.attributeKey ]: toHTMLString( { + value: valueB, + ...mapRichTextSettings( attributeDefinitionB ), + } ), + } ); + + const followingBlock = isForward ? cloneA : cloneB; + + // We can only merge blocks with similar types + // thus, we transform the block to merge first + const blocksWithTheSameType = + blockA.name === blockB.name + ? [ followingBlock ] + : switchToBlockType( followingBlock, targetBlockType.name ); + + // If the block types can not match, do nothing + if ( ! blocksWithTheSameType || ! blocksWithTheSameType.length ) { + return; + } + + let updatedAttributes; + + if ( isForward ) { + const blockToMerge = blocksWithTheSameType.pop(); + updatedAttributes = targetBlockType.merge( + blockToMerge.attributes, + cloneB.attributes + ); + } else { + const blockToMerge = blocksWithTheSameType.shift(); + updatedAttributes = targetBlockType.merge( + cloneA.attributes, + blockToMerge.attributes + ); + } + + const newAttributeKey = findKey( + updatedAttributes, + ( v ) => + typeof v === 'string' && v.indexOf( START_OF_SELECTED_AREA ) !== -1 + ); + + const convertedHtml = updatedAttributes[ newAttributeKey ]; + const convertedValue = create( { + html: convertedHtml, + ...mapRichTextSettings( targetBlockType.attributes[ newAttributeKey ] ), + } ); + const newOffset = convertedValue.text.indexOf( START_OF_SELECTED_AREA ); + const newValue = remove( convertedValue, newOffset, newOffset + 1 ); + const newHtml = toHTMLString( { + value: newValue, + ...mapRichTextSettings( targetBlockType.attributes[ newAttributeKey ] ), + } ); + + updatedAttributes[ newAttributeKey ] = newHtml; + + const selectedBlockClientIds = select.getSelectedBlockClientIds(); + const replacement = [ + ...( isForward ? blocksWithTheSameType : [] ), + { + // Preserve the original client ID. + ...targetBlock, + attributes: { + ...targetBlock.attributes, + ...updatedAttributes, + }, + }, + ...( isForward ? [] : blocksWithTheSameType ), + ]; + + registry.batch( () => { + dispatch.selectionChange( + targetBlock.clientId, + newAttributeKey, + newOffset, + newOffset + ); + + dispatch.replaceBlocks( + selectedBlockClientIds, + replacement, + 0, // If we don't pass the `indexToSelect` it will default to the last block. + select.getSelectedBlocksInitialCaretPosition() + ); + } ); +}; + +/** + * Split the current selection. + */ +export const __unstableSplitSelection = () => ( { select, dispatch } ) => { + const selectionAnchor = select.getSelectionStart(); + const selectionFocus = select.getSelectionEnd(); + + if ( selectionAnchor.clientId === selectionFocus.clientId ) return; + + // Can't split if the selection is not set. + if ( + ! selectionAnchor.attributeKey || + ! selectionFocus.attributeKey || + typeof selectionAnchor.offset === 'undefined' || + typeof selectionFocus.offset === 'undefined' + ) + return; + + const anchorRootClientId = select.getBlockRootClientId( + selectionAnchor.clientId + ); + const focusRootClientId = select.getBlockRootClientId( + selectionFocus.clientId + ); + + // It's not splittable if the selection doesn't start and end in the same + // block list. Maybe in the future it should be allowed. + if ( anchorRootClientId !== focusRootClientId ) { + return; + } + + const blockOrder = select.getBlockOrder( anchorRootClientId ); + const anchorIndex = blockOrder.indexOf( selectionAnchor.clientId ); + const focusIndex = blockOrder.indexOf( selectionFocus.clientId ); + + // Reassign selection start and end based on order. + let selectionStart, selectionEnd; + + if ( anchorIndex > focusIndex ) { + selectionStart = selectionFocus; + selectionEnd = selectionAnchor; + } else { + selectionStart = selectionAnchor; + selectionEnd = selectionFocus; + } + + const selectionA = selectionStart; + const selectionB = selectionEnd; + + const blockA = select.getBlock( selectionA.clientId ); + const blockAType = getBlockType( blockA.name ); + + const blockB = select.getBlock( selectionB.clientId ); + const blockBType = getBlockType( blockB.name ); + + const htmlA = blockA.attributes[ selectionA.attributeKey ]; + const htmlB = blockB.attributes[ selectionB.attributeKey ]; + + const attributeDefinitionA = + blockAType.attributes[ selectionA.attributeKey ]; + const attributeDefinitionB = + blockBType.attributes[ selectionB.attributeKey ]; + + let valueA = create( { + html: htmlA, + ...mapRichTextSettings( attributeDefinitionA ), + } ); + let valueB = create( { + html: htmlB, + ...mapRichTextSettings( attributeDefinitionB ), + } ); + + valueA = remove( valueA, selectionA.offset, valueA.text.length ); + valueB = remove( valueB, 0, selectionB.offset ); + + dispatch.replaceBlocks( + select.getSelectedBlockClientIds(), + [ + { + // Preserve the original client ID. + ...blockA, + attributes: { + ...blockA.attributes, + [ selectionA.attributeKey ]: toHTMLString( { + value: valueA, + ...mapRichTextSettings( attributeDefinitionA ), + } ), + }, + }, + createBlock( getDefaultBlockName() ), + { + // Preserve the original client ID. + ...blockB, + attributes: { + ...blockB.attributes, + [ selectionB.attributeKey ]: toHTMLString( { + value: valueB, + ...mapRichTextSettings( attributeDefinitionB ), + } ), + }, + }, + ], + 1, // If we don't pass the `indexToSelect` it will default to the last block. + select.getSelectedBlocksInitialCaretPosition() + ); +}; + +/** + * Expand the selection to cover the entire blocks, removing partial selection. + */ +export const __unstableExpandSelection = () => ( { select, dispatch } ) => { + const selectionAnchor = select.getSelectionStart(); + const selectionFocus = select.getSelectionEnd(); + dispatch.selectionChange( { + start: { clientId: selectionAnchor.clientId }, + end: { clientId: selectionFocus.clientId }, + } ); +}; + /** * Action that merges two blocks. * @@ -719,17 +1048,10 @@ export const mergeBlocks = ( firstBlockClientId, secondBlockClientId ) => ( { if ( canRestoreTextSelection ) { const selectedBlock = clientId === clientIdA ? cloneA : cloneB; const html = selectedBlock.attributes[ attributeKey ]; - const { - multiline: multilineTag, - __unstableMultilineWrapperTags: multilineWrapperTags, - __unstablePreserveWhiteSpace: preserveWhiteSpace, - } = attributeDefinition; const value = insert( create( { html, - multilineTag, - multilineWrapperTags, - preserveWhiteSpace, + ...mapRichTextSettings( attributeDefinition ), } ), START_OF_SELECTED_AREA, offset, @@ -738,8 +1060,7 @@ export const mergeBlocks = ( firstBlockClientId, secondBlockClientId ) => ( { selectedBlock.attributes[ attributeKey ] = toHTMLString( { value, - multilineTag, - preserveWhiteSpace, + ...mapRichTextSettings( attributeDefinition ), } ); } @@ -769,23 +1090,15 @@ export const mergeBlocks = ( firstBlockClientId, secondBlockClientId ) => ( { v.indexOf( START_OF_SELECTED_AREA ) !== -1 ); const convertedHtml = updatedAttributes[ newAttributeKey ]; - const { - multiline: multilineTag, - __unstableMultilineWrapperTags: multilineWrapperTags, - __unstablePreserveWhiteSpace: preserveWhiteSpace, - } = blockAType.attributes[ newAttributeKey ]; const convertedValue = create( { html: convertedHtml, - multilineTag, - multilineWrapperTags, - preserveWhiteSpace, + ...mapRichTextSettings( blockAType.attributes[ newAttributeKey ] ), } ); const newOffset = convertedValue.text.indexOf( START_OF_SELECTED_AREA ); const newValue = remove( convertedValue, newOffset, newOffset + 1 ); const newHtml = toHTMLString( { value: newValue, - multilineTag, - preserveWhiteSpace, + ...mapRichTextSettings( blockAType.attributes[ newAttributeKey ] ), } ); updatedAttributes[ newAttributeKey ] = newHtml; @@ -978,10 +1291,10 @@ export function exitFormattedText() { /** * Action that changes the position of the user caret. * - * @param {string} clientId The selected block client ID. - * @param {string} attributeKey The selected block attribute key. - * @param {number} startOffset The start offset. - * @param {number} endOffset The end offset. + * @param {string|WPSelection} clientId The selected block client ID. + * @param {string} attributeKey The selected block attribute key. + * @param {number} startOffset The start offset. + * @param {number} endOffset The end offset. * * @return {Object} Action object. */ @@ -991,13 +1304,17 @@ export function selectionChange( startOffset, endOffset ) { - return { - type: 'SELECTION_CHANGE', - clientId, - attributeKey, - startOffset, - endOffset, - }; + if ( typeof clientId === 'string' ) { + return { + type: 'SELECTION_CHANGE', + clientId, + attributeKey, + startOffset, + endOffset, + }; + } + + return { type: 'SELECTION_CHANGE', ...clientId }; } /** diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 76e015b796128c..8691d04cef79ba 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1278,17 +1278,24 @@ function selectionHelper( state = {}, action ) { export function selection( state = {}, action ) { switch ( action.type ) { case 'SELECTION_CHANGE': + if ( action.clientId ) { + return { + selectionStart: { + clientId: action.clientId, + attributeKey: action.attributeKey, + offset: action.startOffset, + }, + selectionEnd: { + clientId: action.clientId, + attributeKey: action.attributeKey, + offset: action.endOffset, + }, + }; + } + return { - selectionStart: { - clientId: action.clientId, - attributeKey: action.attributeKey, - offset: action.startOffset, - }, - selectionEnd: { - clientId: action.clientId, - attributeKey: action.attributeKey, - offset: action.endOffset, - }, + selectionStart: action.start || state.selectionStart, + selectionEnd: action.end || state.selectionEnd, }; case 'RESET_SELECTION': const { selectionStart, selectionEnd } = action; @@ -1298,6 +1305,14 @@ export function selection( state = {}, action ) { }; case 'MULTI_SELECT': const { start, end } = action; + + if ( + start === state.selectionStart?.clientId && + end === state.selectionEnd?.clientId + ) { + return state; + } + return { selectionStart: { clientId: start }, selectionEnd: { clientId: end }, diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 7ded7911619439..df1e488da4822c 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -28,6 +28,7 @@ import { hasBlockSupport, getPossibleBlockTransformations, parse, + switchToBlockType, } from '@wordpress/blocks'; import { Platform } from '@wordpress/element'; import { applyFilters } from '@wordpress/hooks'; @@ -895,6 +896,102 @@ export function getMultiSelectedBlocksEndClientId( state ) { return selectionEnd.clientId || null; } +/** + * Returns true if the selection is not partial. + * + * @param {Object} state Editor state. + * + * @return {boolean} Whether the selection is mergeable. + */ +export function __unstableIsFullySelected( state ) { + const selectionAnchor = getSelectionStart( state ); + const selectionFocus = getSelectionEnd( state ); + return ( + ! selectionAnchor.attributeKey && + ! selectionFocus.attributeKey && + typeof selectionAnchor.offset === 'undefined' && + typeof selectionFocus.offset === 'undefined' + ); +} + +/** + * Check whether the selection is mergeable. + * + * @param {Object} state Editor state. + * @param {boolean} isForward Whether to merge forwards. + * + * @return {boolean} Whether the selection is mergeable. + */ +export function __unstableIsSelectionMergeable( state, isForward ) { + const selectionAnchor = getSelectionStart( state ); + const selectionFocus = getSelectionEnd( state ); + + // It's not mergeable if the start and end are within the same block. + if ( selectionAnchor.clientId === selectionFocus.clientId ) return false; + + // It's not mergeable if there's no rich text selection. + if ( + ! selectionAnchor.attributeKey || + ! selectionFocus.attributeKey || + typeof selectionAnchor.offset === 'undefined' || + typeof selectionFocus.offset === 'undefined' + ) + return false; + + const anchorRootClientId = getBlockRootClientId( + state, + selectionAnchor.clientId + ); + const focusRootClientId = getBlockRootClientId( + state, + selectionFocus.clientId + ); + + // It's not mergeable if the selection doesn't start and end in the same + // block list. Maybe in the future it should be allowed. + if ( anchorRootClientId !== focusRootClientId ) { + return false; + } + + const blockOrder = getBlockOrder( state, anchorRootClientId ); + const anchorIndex = blockOrder.indexOf( selectionAnchor.clientId ); + const focusIndex = blockOrder.indexOf( selectionFocus.clientId ); + + // Reassign selection start and end based on order. + let selectionStart, selectionEnd; + + if ( anchorIndex > focusIndex ) { + selectionStart = selectionFocus; + selectionEnd = selectionAnchor; + } else { + selectionStart = selectionAnchor; + selectionEnd = selectionFocus; + } + + const targetBlockClientId = isForward + ? selectionEnd.clientId + : selectionStart.clientId; + const blockToMergeClientId = isForward + ? selectionStart.clientId + : selectionEnd.clientId; + + const targetBlock = getBlock( state, targetBlockClientId ); + const targetBlockType = getBlockType( targetBlock.name ); + + if ( ! targetBlockType.merge ) return false; + + const blockToMerge = getBlock( state, blockToMergeClientId ); + + // It's mergeable if the blocks are of the same type. + if ( blockToMerge.name === targetBlock.name ) return true; + + // If the blocks are of a different type, try to transform the block being + // merged into the same type of block. + const blocksToMerge = switchToBlockType( blockToMerge, targetBlock.name ); + + return blocksToMerge && blocksToMerge.length; +} + /** * Returns an array containing all block client IDs in the editor in the order * they appear. Optionally accepts a root client ID of the block list for which diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/block-deletion.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/block-deletion.test.js.snap index 0eadd8df917678..a433a325d2a92e 100644 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/block-deletion.test.js.snap +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/block-deletion.test.js.snap @@ -1,26 +1,34 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`block deletion - deleting the third block using backspace in an empty block results in two remaining blocks and positions the caret at the end of the second block 1`] = ` +exports[`block deletion - deleting the third and fourth blocks using backspace with multi-block selection results in two remaining blocks and positions the caret at the end of the second block 1`] = ` "

First paragraph

Second paragraph

+ + + +

" `; -exports[`block deletion - deleting the third block using backspace in an empty block results in two remaining blocks and positions the caret at the end of the second block 2`] = ` +exports[`block deletion - deleting the third and fourth blocks using backspace with multi-block selection results in two remaining blocks and positions the caret at the end of the second block 2`] = ` "

First paragraph

-

Second paragraph - caret was here

-" +

Second paragraph

+ + + + +" `; -exports[`block deletion - deleting the third block using backspace with block wrapper selection results in three remaining blocks and positions the caret at the end of the third block 1`] = ` +exports[`block deletion - deleting the third block using backspace in an empty block results in two remaining blocks and positions the caret at the end of the second block 1`] = ` "

First paragraph

@@ -30,7 +38,7 @@ exports[`block deletion - deleting the third block using backspace with block wr " `; -exports[`block deletion - deleting the third block using backspace with block wrapper selection results in three remaining blocks and positions the caret at the end of the third block 2`] = ` +exports[`block deletion - deleting the third block using backspace in an empty block results in two remaining blocks and positions the caret at the end of the second block 2`] = ` "

First paragraph

@@ -40,7 +48,7 @@ exports[`block deletion - deleting the third block using backspace with block wr " `; -exports[`block deletion - deleting the third block using the Remove Block menu item results in two remaining blocks and positions the caret at the end of the second block 1`] = ` +exports[`block deletion - deleting the third block using backspace with block wrapper selection results in three remaining blocks and positions the caret at the end of the third block 1`] = ` "

First paragraph

@@ -50,7 +58,7 @@ exports[`block deletion - deleting the third block using the Remove Block menu i " `; -exports[`block deletion - deleting the third block using the Remove Block menu item results in two remaining blocks and positions the caret at the end of the second block 2`] = ` +exports[`block deletion - deleting the third block using backspace with block wrapper selection results in three remaining blocks and positions the caret at the end of the third block 2`] = ` "

First paragraph

@@ -60,7 +68,7 @@ exports[`block deletion - deleting the third block using the Remove Block menu i " `; -exports[`block deletion - deleting the third block using the Remove Block shortcut results in two remaining blocks and positions the caret at the end of the second block 1`] = ` +exports[`block deletion - deleting the third block using the Remove Block menu item results in two remaining blocks and positions the caret at the end of the second block 1`] = ` "

First paragraph

@@ -70,7 +78,7 @@ exports[`block deletion - deleting the third block using the Remove Block shortc " `; -exports[`block deletion - deleting the third block using the Remove Block shortcut results in two remaining blocks and positions the caret at the end of the second block 2`] = ` +exports[`block deletion - deleting the third block using the Remove Block menu item results in two remaining blocks and positions the caret at the end of the second block 2`] = ` "

First paragraph

@@ -80,7 +88,7 @@ exports[`block deletion - deleting the third block using the Remove Block shortc " `; -exports[`block deletion - deleting the third and fourth blocks using backspace with multi-block selection results in two remaining blocks and positions the caret at the end of the second block 1`] = ` +exports[`block deletion - deleting the third block using the Remove Block shortcut results in two remaining blocks and positions the caret at the end of the second block 1`] = ` "

First paragraph

@@ -90,7 +98,7 @@ exports[`block deletion - deleting the third and fourth blocks using backspace w " `; -exports[`block deletion - deleting the third and fourth blocks using backspace with multi-block selection results in two remaining blocks and positions the caret at the end of the second block 2`] = ` +exports[`block deletion - deleting the third block using the Remove Block shortcut results in two remaining blocks and positions the caret at the end of the second block 2`] = ` "

First paragraph

diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/block-editor-keyboard-shortcuts.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/block-editor-keyboard-shortcuts.test.js.snap index 3be07280f1846e..ccce7a2b3c3833 100644 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/block-editor-keyboard-shortcuts.test.js.snap +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/block-editor-keyboard-shortcuts.test.js.snap @@ -125,3 +125,29 @@ exports[`block editor keyboard shortcuts test shortcuts handling through portals

3rd

" `; + +exports[`block editor keyboard shortcuts test shortcuts handling through portals in the same tree should propagate properly and duplicate selected blocks 1`] = ` +" +

1st

+ + + +

2nd

+ + + +

3rd

+ + + +

1st

+ + + +

2nd

+ + + +

3rd

+" +`; diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/multi-block-selection.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/multi-block-selection.test.js.snap index 3ede4a98a20c0b..df1be62cbe878f 100644 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/multi-block-selection.test.js.snap +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/multi-block-selection.test.js.snap @@ -6,7 +6,11 @@ exports[`Multi-block selection should allow selecting outer edge if there is no " `; -exports[`Multi-block selection should always expand single line selection 1`] = `""`; +exports[`Multi-block selection should always expand single line selection 1`] = ` +" +

2

+" +`; exports[`Multi-block selection should clear selection when clicking next to blocks 1`] = ` " @@ -70,6 +74,26 @@ exports[`Multi-block selection should cut and paste 2`] = ` " `; +exports[`Multi-block selection should forward delete across blocks 1`] = ` +" +

1[

+ + + +

.

+ + + +

]2

+" +`; + +exports[`Multi-block selection should forward delete across blocks 2`] = ` +" +

1&2

+" +`; + exports[`Multi-block selection should gradually multi-select 1`] = ` "
@@ -94,6 +118,50 @@ exports[`Multi-block selection should gradually multi-select 2`] = ` " `; +exports[`Multi-block selection should handle Enter across blocks 1`] = ` +" +

1[

+ + + +

.

+ + + +

]2

+" +`; + +exports[`Multi-block selection should handle Enter across blocks 2`] = ` +" +

1

+ + + +

&

+ + + +

2

+" +`; + +exports[`Multi-block selection should merge into quote with correct selection 1`] = ` +" +

1[

+ + + +

]2

+" +`; + +exports[`Multi-block selection should merge into quote with correct selection 2`] = ` +" +

1

&2

+" +`; + exports[`Multi-block selection should multi-select from within the list block 1`] = ` "

1

@@ -124,25 +192,41 @@ exports[`Multi-block selection should only trigger multi-selection when at the e " `; -exports[`Multi-block selection should place the caret at the end of last pasted paragraph (paste mid-block) 1`] = ` +exports[`Multi-block selection should partially select with shift + click 1`] = ` " -

first paragra

+

1[

+

]2

+" +`; + +exports[`Multi-block selection should partially select with shift + click 2`] = ` +" +

1&2

+" +`; + +exports[`Multi-block selection should place the caret at the end of last pasted paragraph (paste mid-block) 1`] = ` +"

first paragraph

-

second paragrap

+

second paragr

+ + + +

first paragraph

-

ph

+

second paragrap

-

second paragraph

+

aph

" `; @@ -198,6 +282,22 @@ exports[`Multi-block selection should select all from empty selection 1`] = ` exports[`Multi-block selection should select all from empty selection 2`] = `""`; +exports[`Multi-block selection should select separator (single element block) 1`] = ` +" +
+ + + +

a

+" +`; + +exports[`Multi-block selection should select separator (single element block) 2`] = ` +" +

&

+" +`; + exports[`Multi-block selection should set attributes for multiple paragraphs 1`] = ` "

1

diff --git a/packages/e2e-tests/specs/editor/various/block-editor-keyboard-shortcuts.test.js b/packages/e2e-tests/specs/editor/various/block-editor-keyboard-shortcuts.test.js index 25c7fd5ccd224f..98f5e067900905 100644 --- a/packages/e2e-tests/specs/editor/various/block-editor-keyboard-shortcuts.test.js +++ b/packages/e2e-tests/specs/editor/various/block-editor-keyboard-shortcuts.test.js @@ -76,16 +76,14 @@ describe( 'block editor keyboard shortcuts', () => { await pressKeyWithModifier( 'primary', 'a' ); await pressKeyWithModifier( 'primary', 'a' ); } ); - it( 'should propagate properly and delete selected blocks', async () => { + it( 'should propagate properly and duplicate selected blocks', async () => { await clickBlockToolbarButton( 'Options' ); const label = 'Duplicate'; await page.$x( `//div[@role="menu"]//span[contains(concat(" ", @class, " "), " components-menu-item__item ")][contains(text(), "${ label }")]` ); - await page.keyboard.press( 'Delete' ); - expect( await getEditedPostContent() ).toMatchInlineSnapshot( - `""` - ); + await pressKeyWithModifier( 'primaryShift', 'd' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); } ); it( 'should prevent deleting multiple selected blocks from inputs', async () => { await clickBlockToolbarButton( 'Options' ); 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 index c141eb9bb94767..19d194d9607b7b 100644 --- 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 @@ -100,6 +100,8 @@ describe( 'Copy/cut/paste of whole blocks', () => { await page.click( '.editor-block-list-item-paragraph' ); await page.keyboard.type( 'P' ); await page.keyboard.press( 'ArrowLeft' ); + // Needs to be investigated why this is needed. + await page.evaluate( () => new Promise( requestAnimationFrame ) ); await page.keyboard.press( 'ArrowLeft' ); // Cut group. await pressKeyWithModifier( 'primary', 'x' ); @@ -145,6 +147,8 @@ describe( 'Copy/cut/paste of whole blocks', () => { await page.click( '.editor-block-list-item-paragraph' ); await page.keyboard.type( 'P' ); await page.keyboard.press( 'ArrowLeft' ); + // Needs to be investigated why this is needed. + await page.evaluate( () => new Promise( requestAnimationFrame ) ); await page.keyboard.press( 'ArrowLeft' ); // Cut group. await pressKeyWithModifier( 'primary', 'x' ); 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 b242912d1c1c42..29004182e01e85 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 @@ -79,14 +79,13 @@ async function testNativeSelection() { const firstElement = elements[ 0 ]; const lastElement = elements[ elements.length - 1 ]; - const { startContainer, endContainer } = selection.getRangeAt( 0 ); - if ( ! firstElement.contains( startContainer ) ) { - throw 'expected selection to start in the first selected block'; + if ( ! selection.containsNode( firstElement, true ) ) { + throw 'expected selection to include in the first selected block'; } - if ( ! lastElement.contains( endContainer ) ) { - throw 'expected selection to end in the last selected block'; + if ( ! selection.containsNode( lastElement, true ) ) { + throw 'expected selection to include in the last selected block'; } } ); } @@ -172,10 +171,10 @@ describe( 'Multi-block selection', () => { await page.keyboard.press( 'Enter' ); await page.keyboard.type( '12' ); await page.keyboard.press( 'ArrowLeft' ); - await pressKeyWithModifier( 'shift', 'ArrowRight' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); await pressKeyWithModifier( 'shift', 'ArrowUp' ); await testNativeSelection(); - // This delete all blocks. + // This deletes all blocks. await page.keyboard.press( 'Backspace' ); expect( await getEditedPostContent() ).toMatchSnapshot(); @@ -331,7 +330,6 @@ describe( 'Multi-block selection', () => { await page.keyboard.press( 'ArrowUp' ); await page.keyboard.up( 'Shift' ); await transformBlockTo( 'Group' ); - await page.keyboard.press( 'ArrowDown' ); // Click the first paragraph in the first Group block while pressing `shift` key. const firstParagraph = await page.waitForXPath( "//p[text()='first']" ); @@ -355,6 +353,7 @@ describe( 'Multi-block selection', () => { await page.mouse.move( x + 20, y ); await page.mouse.down(); await page.keyboard.up( 'Shift' ); + await page.mouse.up(); await page.keyboard.type( 'hi' ); expect( await getEditedPostContent() ).toMatchInlineSnapshot( ` " @@ -830,4 +829,129 @@ describe( 'Multi-block selection', () => { await page.keyboard.up( 'Shift' ); expect( await getSelectedFlatIndices() ).toEqual( [ 3, 4 ] ); } ); + + it( 'should forward delete across blocks', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1[' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '.' ); + await page.keyboard.press( 'Enter' ); + // "## " creates h2. + await page.keyboard.type( '## ]2' ); + await page.keyboard.press( 'ArrowLeft' ); + // Select everything between []. + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + + // Test setup. + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.press( 'Delete' ); + + // Ensure selection is in the correct place. + await page.keyboard.type( '&' ); + + // Expect a heading with "1&2" as its content. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should handle Enter across blocks', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1[' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '.' ); + await page.keyboard.press( 'Enter' ); + // "## " creates h2. + await page.keyboard.type( '## ]2' ); + await page.keyboard.press( 'ArrowLeft' ); + // Select everything between []. + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + + // Test setup. + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.press( 'Enter' ); + + // Ensure selection is in the correct place. + await page.keyboard.type( '&' ); + + // Expect two blocks with "&" in between. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should merge into quote with correct selection', async () => { + await clickBlockAppender(); + await page.keyboard.type( '> 1[' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( ']2' ); + await page.keyboard.press( 'ArrowLeft' ); + // Select everything between []. + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + await pressKeyWithModifier( 'shift', 'ArrowLeft' ); + + // Test setup. + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.press( 'Backspace' ); + + // Ensure selection is in the correct place. + await page.keyboard.type( '&' ); + + // Expect two blocks with "&" in between. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should select separator (single element block)', async () => { + await clickBlockAppender(); + await page.keyboard.type( '/hr' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'a' ); + await pressKeyWithModifier( 'shift', 'ArrowUp' ); + + // Test setup. + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.press( 'Backspace' ); + + // Ensure selection is in the correct place. + await page.keyboard.type( '&' ); + + // Expect two blocks with "&" in between. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should partially select with shift + click', async () => { + await clickBlockAppender(); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '1' ); + await pressKeyWithModifier( 'primary', 'b' ); + await page.keyboard.type( '[' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( ']2' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.down( 'Shift' ); + await page.click( 'strong' ); + await page.keyboard.up( 'Shift' ); + + // Test setup. + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.press( 'Backspace' ); + + // Ensure selection is in the correct place. + await page.keyboard.type( '&' ); + + // Expect two blocks with "&" in between. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/edit-post/src/components/visual-editor/index.js b/packages/edit-post/src/components/visual-editor/index.js index 6e5cf20d750edc..83788db45c5ad5 100644 --- a/packages/edit-post/src/components/visual-editor/index.js +++ b/packages/edit-post/src/components/visual-editor/index.js @@ -236,7 +236,10 @@ export default function VisualEditor( { styles } ) { /> ) } { ! isTemplateMode && ( -
+
) } diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index 3f6c9c1260374e..0cb718287c6849 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -214,6 +214,10 @@ export function useRichText( { return; } + if ( ref.current.ownerDocument.activeElement !== ref.current ) { + ref.current.focus(); + } + applyFromProps(); hadSelectionUpdate.current = false; }, [ hadSelectionUpdate.current ] ); diff --git a/packages/rich-text/src/component/use-input-and-selection.js b/packages/rich-text/src/component/use-input-and-selection.js index 2e0eebea699112..173a54dd463dd9 100644 --- a/packages/rich-text/src/component/use-input-and-selection.js +++ b/packages/rich-text/src/component/use-input-and-selection.js @@ -124,10 +124,6 @@ export function useInputAndSelection( props ) { * @param {Event|DOMHighResTimeStamp} event */ function handleSelectionChange( event ) { - if ( ownerDocument.activeElement !== element ) { - return; - } - const { record, applyRecord, @@ -136,14 +132,46 @@ export function useInputAndSelection( props ) { onSelectionChange, } = propsRef.current; - if ( event.type !== 'selectionchange' && ! isSelected ) { + // If the selection changes where the active element is a parent of + // the rich text instance (writing flow), call `onSelectionChange` + // for the rich text instance that contains the start or end of the + // selection. + if ( ownerDocument.activeElement !== element ) { + if ( ! ownerDocument.activeElement.contains( element ) ) { + return; + } + + const selection = defaultView.getSelection(); + const { anchorNode, focusNode } = selection; + + if ( + element.contains( anchorNode ) && + element !== anchorNode && + element.contains( focusNode ) && + element !== focusNode + ) { + const { start, end } = createRecord(); + record.current.activeFormats = EMPTY_ACTIVE_FORMATS; + onSelectionChange( start, end ); + } else if ( + element.contains( anchorNode ) && + element !== anchorNode + ) { + const { start, end: offset = start } = createRecord(); + record.current.activeFormats = EMPTY_ACTIVE_FORMATS; + onSelectionChange( offset ); + } else if ( + element.contains( focusNode ) && + element !== focusNode + ) { + const { start, end: offset = start } = createRecord(); + record.current.activeFormats = EMPTY_ACTIVE_FORMATS; + onSelectionChange( undefined, offset ); + } return; } - // Check if the implementor disabled editing. `contentEditable` - // does disable input, but not text selection, so we must ignore - // selection changes. - if ( element.contentEditable !== 'true' ) { + if ( event.type !== 'selectionchange' && ! isSelected ) { return; } @@ -231,6 +259,12 @@ export function useInputAndSelection( props ) { applyRecord, } = propsRef.current; + // When the whole editor is editable, let writing flow handle + // selection. + if ( element.parentElement.closest( '[contenteditable="true"]' ) ) { + return; + } + if ( ! isSelected ) { // We know for certain that on focus, the old selection is invalid. // It will be recalculated on the next mouseup, keyup, or touchend @@ -254,25 +288,12 @@ export function useInputAndSelection( props ) { // at this point, but this focus event is still too early to calculate // the selection. rafId = defaultView.requestAnimationFrame( handleSelectionChange ); - - ownerDocument.addEventListener( - 'selectionchange', - handleSelectionChange - ); - } - - function onBlur() { - ownerDocument.removeEventListener( - 'selectionchange', - handleSelectionChange - ); } element.addEventListener( 'input', onInput ); element.addEventListener( 'compositionstart', onCompositionStart ); element.addEventListener( 'compositionend', onCompositionEnd ); element.addEventListener( 'focus', onFocus ); - element.addEventListener( 'blur', onBlur ); // Selection updates must be done at these events as they // happen before the `selectionchange` event. In some cases, // the `selectionchange` event may not even fire, for @@ -280,6 +301,10 @@ export function useInputAndSelection( props ) { element.addEventListener( 'keyup', handleSelectionChange ); element.addEventListener( 'mouseup', handleSelectionChange ); element.addEventListener( 'touchend', handleSelectionChange ); + ownerDocument.addEventListener( + 'selectionchange', + handleSelectionChange + ); return () => { element.removeEventListener( 'input', onInput ); element.removeEventListener( @@ -288,7 +313,6 @@ export function useInputAndSelection( props ) { ); element.removeEventListener( 'compositionend', onCompositionEnd ); element.removeEventListener( 'focus', onFocus ); - element.removeEventListener( 'blur', onBlur ); element.removeEventListener( 'keyup', handleSelectionChange ); element.removeEventListener( 'mouseup', handleSelectionChange ); element.removeEventListener( 'touchend', handleSelectionChange );