UNPKG

@gechiui/block-editor

Version:
236 lines (213 loc) 6.95 kB
/** * GeChiUI dependencies */ import { useSelect, useDispatch } from '@gechiui/data'; import { useRefEffect } from '@gechiui/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, 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 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 ); } 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 sfift+click on a child. if ( start !== end ) { toggleRichText( node, false ); 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, ] ); }