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 4331a9251d563..517a0a080cedc 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,6 +31,7 @@ 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 { store as blockEditorStore } from '../../../store'; /** @@ -108,6 +109,7 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { 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-multi-selection.js b/packages/block-editor/src/components/block-list/use-block-props/use-multi-selection.js new file mode 100644 index 0000000000000..2494db3cb6064 --- /dev/null +++ b/packages/block-editor/src/components/block-list/use-block-props/use-multi-selection.js @@ -0,0 +1,178 @@ +/** + * 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'; + +function toggleRichText( container, toggle ) { + Array.from( + container + .closest( '.is-root-container' ) + .querySelectorAll( '.rich-text' ) + ).forEach( ( node ) => { + if ( toggle ) { + node.setAttribute( 'contenteditable', true ); + } else { + node.removeAttribute( 'contenteditable' ); + } + } ); +} + +/** + * 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 } = 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 enable all rich + // text areas. + if ( ! selection.rangeCount || selection.isCollapsed ) { + toggleRichText( node, true ); + 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, rich text elements that were + // previously disabled can now be enabled again. + if ( isSelectionEnd ) { + toggleRichText( node, true ); + + 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 ); + + // 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. `startMultiSelect` 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. + toggleRichText( node, false ); + } + + node.addEventListener( 'mouseleave', onMouseLeave ); + + return () => { + 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-selected-block-event-handlers.js b/packages/block-editor/src/components/block-list/use-block-props/use-selected-block-event-handlers.js index d5ee93fe3f988..8f3d5a5ec3953 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-selected-block-event-handlers.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-selected-block-event-handlers.js @@ -1,7 +1,6 @@ /** * WordPress dependencies */ -import { useContext } from '@wordpress/element'; import { isTextField } from '@wordpress/dom'; import { ENTER, BACKSPACE, DELETE } from '@wordpress/keycodes'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -10,20 +9,17 @@ import { useRefEffect } from '@wordpress/compose'; /** * Internal dependencies */ -import { SelectionStart } from '../../writing-flow'; import { store as blockEditorStore } from '../../../store'; /** * Adds block behaviour: * - Removes the block on BACKSPACE. * - Inserts a default block on ENTER. - * - Initiates selection start for multi-selection. * - Disables dragging of block contents. * * @param {string} clientId Block client ID. */ export function useEventHandlers( clientId ) { - const onSelectionStart = useContext( SelectionStart ); const isSelected = useSelect( ( select ) => select( blockEditorStore ).isBlockSelected( clientId ), [ clientId ] @@ -76,14 +72,6 @@ export function useEventHandlers( clientId ) { } } - 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 ) { - onSelectionStart( clientId ); - } - } - /** * Prevents default dragging behavior within a block. To do: we must * handle this in the future and clean up the drag target. @@ -95,11 +83,9 @@ export function useEventHandlers( clientId ) { } node.addEventListener( 'keydown', onKeyDown ); - node.addEventListener( 'mouseleave', onMouseLeave ); node.addEventListener( 'dragstart', onDragStart ); return () => { - node.removeEventListener( 'mouseleave', onMouseLeave ); node.removeEventListener( 'keydown', onKeyDown ); node.removeEventListener( 'dragstart', onDragStart ); }; @@ -109,7 +95,6 @@ export function useEventHandlers( clientId ) { isSelected, getBlockRootClientId, getBlockIndex, - onSelectionStart, insertDefaultBlock, removeBlock, ] diff --git a/packages/block-editor/src/components/writing-flow/index.js b/packages/block-editor/src/components/writing-flow/index.js index a545eefbe46e1..bde59f9144945 100644 --- a/packages/block-editor/src/components/writing-flow/index.js +++ b/packages/block-editor/src/components/writing-flow/index.js @@ -6,7 +6,7 @@ import { find, reverse, first, last } from 'lodash'; /** * WordPress dependencies */ -import { useRef, useEffect, createContext } from '@wordpress/element'; +import { useRef, useEffect } from '@wordpress/element'; import { computeCaretRect, focus, @@ -36,8 +36,6 @@ import { isInSameBlock, getBlockClientId } from '../../utils/dom'; import useMultiSelection from './use-multi-selection'; import { store as blockEditorStore } from '../../store'; -export const SelectionStart = createContext(); - /** * Useful for positioning an element within the viewport so focussing the * element does not scroll the page. @@ -523,7 +521,7 @@ export default function WritingFlow( { children } ) { // This hook sets the selection after the user makes a multi-selection. For // some browsers, like Safari, it is important that this happens AFTER // setting focus on the multi-selection container above. - const onSelectionStart = useMultiSelection( container ); + useMultiSelection( container ); const lastFocus = useRef(); @@ -566,7 +564,7 @@ export default function WritingFlow( { children } ) { // bubbling events from children to determine focus transition intents. /* eslint-disable jsx-a11y/no-static-element-interactions */ return ( - + <>
- + ); /* eslint-enable jsx-a11y/no-static-element-interactions */ } 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 71ded141fce5d..7bb224d3beda5 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 @@ -6,13 +6,12 @@ import { first, last } from 'lodash'; /** * WordPress dependencies */ -import { useEffect, useRef, useCallback } from '@wordpress/element'; +import { useEffect } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; /** * Internal dependencies */ -import { getBlockClientId } from '../../utils/dom'; import { store as blockEditorStore } from '../../store'; import { __unstableUseBlockRef as useBlockRef } from '../block-list/use-block-props/use-block-refs'; @@ -44,7 +43,6 @@ function getDeepestNode( node, type ) { function selector( select ) { const { - isSelectionEnabled, isMultiSelecting, getMultiSelectedBlockClientIds, hasMultiSelection, @@ -52,7 +50,6 @@ function selector( select ) { } = select( blockEditorStore ); return { - isSelectionEnabled: isSelectionEnabled(), isMultiSelecting: isMultiSelecting(), multiSelectedBlockClientIds: getMultiSelectedBlockClientIds(), hasMultiSelection: hasMultiSelection(), @@ -60,36 +57,14 @@ function selector( select ) { }; } -function toggleRichText( container, toggle ) { - Array.from( container.querySelectorAll( '.rich-text' ) ).forEach( - ( node ) => { - if ( toggle ) { - node.setAttribute( 'contenteditable', true ); - } else { - node.removeAttribute( 'contenteditable' ); - } - } - ); -} - export default function useMultiSelection( ref ) { const { - isSelectionEnabled, isMultiSelecting, multiSelectedBlockClientIds, hasMultiSelection, selectedBlockClientId, } = useSelect( selector, [] ); - const { getBlockParents } = useSelect( blockEditorStore ); - const { - startMultiSelect, - stopMultiSelect, - multiSelect, - selectBlock, - } = useDispatch( blockEditorStore ); - const rafId = useRef(); - const startClientId = useRef(); - const anchorElement = useRef(); + const { selectBlock } = useDispatch( blockEditorStore ); const selectedRef = useBlockRef( selectedBlockClientId ); // These must be in the right DOM order. const startRef = useBlockRef( first( multiSelectedBlockClientIds ) ); @@ -156,133 +131,4 @@ export default function useMultiSelection( ref ) { selectBlock, selectedBlockClientId, ] ); - - const onSelectionChange = useCallback( - ( { isSelectionEnd } ) => { - const { ownerDocument } = ref.current; - const { defaultView } = ownerDocument; - const selection = defaultView.getSelection(); - - // If no selection is found, end multi selection and enable all rich - // text areas. - if ( ! selection.rangeCount || selection.isCollapsed ) { - toggleRichText( ref.current, true ); - return; - } - - const clientId = getBlockClientId( selection.focusNode ); - const isSingularSelection = startClientId.current === clientId; - - 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, rich - // text elements that were previously disabled can now be enabled - // again. - if ( isSelectionEnd ) { - toggleRichText( ref.current, true ); - - if ( selection.rangeCount ) { - const { - commonAncestorContainer, - } = selection.getRangeAt( 0 ); - - if ( - anchorElement.current.contains( - commonAncestorContainer - ) - ) { - anchorElement.current.focus(); - } - } - } - } else { - const startPath = [ - ...getBlockParents( startClientId.current ), - startClientId.current, - ]; - const endPath = [ ...getBlockParents( clientId ), clientId ]; - const depth = Math.min( startPath.length, endPath.length ) - 1; - - multiSelect( startPath[ depth ], endPath[ depth ] ); - } - }, - [ selectBlock, getBlockParents, multiSelect ] - ); - - /** - * Handles a mouseup event to end the current mouse multi-selection. - */ - const onSelectionEnd = useCallback( () => { - const { ownerDocument } = ref.current; - const { defaultView } = ownerDocument; - 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.current = defaultView.requestAnimationFrame( () => { - onSelectionChange( { isSelectionEnd: true } ); - stopMultiSelect(); - } ); - }, [ onSelectionChange, stopMultiSelect ] ); - - // Only clean up when unmounting, these are added and cleaned up elsewhere. - useEffect( () => { - const { ownerDocument } = ref.current; - const { defaultView } = ownerDocument; - - return () => { - ownerDocument.removeEventListener( - 'selectionchange', - onSelectionChange - ); - defaultView.removeEventListener( 'mouseup', onSelectionEnd ); - defaultView.cancelAnimationFrame( rafId.current ); - }; - }, [ onSelectionChange, onSelectionEnd ] ); - - /** - * Binds event handlers to the document for tracking a pending multi-select - * in response to a mousedown event occurring in a rendered block. - */ - return useCallback( - ( clientId ) => { - if ( ! isSelectionEnabled ) { - return; - } - - const { ownerDocument } = ref.current; - const { defaultView } = ownerDocument; - - startClientId.current = clientId; - anchorElement.current = 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 ); - - // 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. `startMultiSelect` 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. - toggleRichText( ref.current, false ); - }, - [ isSelectionEnabled, startMultiSelect, onSelectionEnd ] - ); }