UNPKG

@wordpress/block-editor

Version:
560 lines (500 loc) 17.3 kB
/** * External dependencies */ import { find, reverse, first, last } from 'lodash'; /** * WordPress dependencies */ import { useRef, useEffect } from '@wordpress/element'; import { computeCaretRect, focus, isHorizontalEdge, isVerticalEdge, placeCaretAtHorizontalEdge, placeCaretAtVerticalEdge, isEntirelySelected, isRTL, } from '@wordpress/dom'; import { UP, DOWN, LEFT, RIGHT, TAB, isKeyboardEvent, ESCAPE, } from '@wordpress/keycodes'; import { useSelect, useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ import { isInSameBlock } from '../../utils/dom'; import useMultiSelection from './use-multi-selection'; import { store as blockEditorStore } from '../../store'; /** * Useful for positioning an element within the viewport so focussing the * element does not scroll the page. */ const PREVENT_SCROLL_ON_FOCUS = { position: 'fixed' }; function isFormElement( element ) { const { tagName } = element; return ( tagName === 'INPUT' || tagName === 'BUTTON' || tagName === 'SELECT' || tagName === 'TEXTAREA' ); } /** * Returns true if the element should consider edge navigation upon a keyboard * event of the given directional key code, or false otherwise. * * @param {Element} element HTML element to test. * @param {number} keyCode KeyboardEvent keyCode to test. * @param {boolean} hasModifier Whether a modifier is pressed. * * @return {boolean} Whether element should consider edge navigation. */ export function isNavigationCandidate( element, keyCode, hasModifier ) { const isVertical = keyCode === UP || keyCode === DOWN; // Currently, all elements support unmodified vertical navigation. if ( isVertical && ! hasModifier ) { return true; } // Native inputs should not navigate horizontally. const { tagName } = element; return tagName !== 'INPUT' && tagName !== 'TEXTAREA'; } /** * Returns the optimal tab target from the given focused element in the * desired direction. A preference is made toward text fields, falling back * to the block focus stop if no other candidates exist for the block. * * @param {Element} target Currently focused text field. * @param {boolean} isReverse True if considering as the first field. * @param {Element} containerElement Element containing all blocks. * @param {boolean} onlyVertical Whether to only consider tabbable elements * that are visually above or under the * target. * * @return {?Element} Optimal tab target, if one exists. */ export function getClosestTabbable( target, isReverse, containerElement, onlyVertical ) { // Since the current focus target is not guaranteed to be a text field, // find all focusables. Tabbability is considered later. let focusableNodes = focus.focusable.find( containerElement ); if ( isReverse ) { focusableNodes = reverse( focusableNodes ); } // Consider as candidates those focusables after the current target. // It's assumed this can only be reached if the target is focusable // (on its keydown event), so no need to verify it exists in the set. focusableNodes = focusableNodes.slice( focusableNodes.indexOf( target ) + 1 ); let targetRect; if ( onlyVertical ) { targetRect = target.getBoundingClientRect(); } function isTabCandidate( node ) { // Not a candidate if the node is not tabbable. if ( ! focus.tabbable.isTabbableIndex( node ) ) { return false; } // Skip focusable elements such as links within content editable nodes. if ( node.isContentEditable && node.contentEditable !== 'true' ) { return false; } if ( onlyVertical ) { const nodeRect = node.getBoundingClientRect(); if ( nodeRect.left >= targetRect.right || nodeRect.right <= targetRect.left ) { return false; } } return true; } return find( focusableNodes, isTabCandidate ); } /** * Handles selection and navigation across blocks. This component should be * wrapped around BlockList. * * @param {Object} props Component properties. * @param {WPElement} props.children Children to be rendered. */ export default function WritingFlow( { children } ) { const container = useRef(); const focusCaptureBeforeRef = useRef(); const focusCaptureAfterRef = useRef(); const multiSelectionContainer = useRef(); const entirelySelected = useRef(); // Reference that holds the a flag for enabling or disabling // capturing on the focus capture elements. const noCapture = useRef(); // Here a DOMRect is stored while moving the caret vertically so vertical // position of the start position can be restored. This is to recreate // browser behaviour across blocks. const verticalRect = useRef(); const { hasMultiSelection, isMultiSelecting, isNavigationMode } = useSelect( ( select ) => { const selectors = select( blockEditorStore ); return { hasMultiSelection: selectors.hasMultiSelection(), isMultiSelecting: selectors.isMultiSelecting(), isNavigationMode: selectors.isNavigationMode(), }; }, [] ); const { getSelectedBlockClientId, getMultiSelectedBlocksStartClientId, getMultiSelectedBlocksEndClientId, getPreviousBlockClientId, getNextBlockClientId, getFirstMultiSelectedBlockClientId, getLastMultiSelectedBlockClientId, getBlockOrder, getSettings, } = useSelect( blockEditorStore ); const { multiSelect, selectBlock, setNavigationMode } = useDispatch( blockEditorStore ); function onMouseDown() { verticalRect.current = 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 ) { 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 two * text fields, this would return true when reversing from the first of the * two fields, but false when reversing from the second. * * @param {Element} target Currently focused text field. * @param {boolean} isReverse True if considering as the first field. * * @return {boolean} Whether field is at edge for tab transition. */ function isTabbableEdge( target, isReverse ) { const closestTabbable = getClosestTabbable( target, isReverse, container.current ); return ! closestTabbable || ! isInSameBlock( target, closestTabbable ); } function onKeyDown( event ) { const { keyCode, target } = event; // Handle only if the event occurred within the same DOM hierarchy as // the rendered container. This is used to distinguish between events // which bubble through React's virtual event system from those which // strictly occur in the DOM created by the component. // // The implication here is: If it's not desirable for a bubbled event to // be considered by WritingFlow, it can be avoided by rendering to a // distinct place in the DOM (e.g. using Slot/Fill). if ( ! container.current.contains( target ) ) { return; } const isUp = keyCode === UP; const isDown = keyCode === DOWN; const isLeft = keyCode === LEFT; const isRight = keyCode === RIGHT; const isTab = keyCode === TAB; const isEscape = keyCode === ESCAPE; const isReverse = isUp || isLeft; const isHorizontal = isLeft || isRight; const isVertical = isUp || isDown; const isNav = isHorizontal || isVertical; const isShift = event.shiftKey; const hasModifier = isShift || event.ctrlKey || event.altKey || event.metaKey; const isNavEdge = isVertical ? isVerticalEdge : isHorizontalEdge; const { ownerDocument } = container.current; const { defaultView } = ownerDocument; const selectedBlockClientId = getSelectedBlockClientId(); // In Edit mode, Tab should focus the first tabbable element after the // content, which is normally the sidebar (with block controls) and // Shift+Tab should focus the first tabbable element before the content, // which is normally the block toolbar. // Arrow keys can be used, and Tab and arrow keys can be used in // Navigation mode (press Esc), to navigate through blocks. if ( selectedBlockClientId ) { if ( isTab ) { const direction = isShift ? 'findPrevious' : 'findNext'; // Allow tabbing between form elements rendered in a block, // such as inside a placeholder. Form elements are generally // meant to be UI rather than part of the content. Ideally // these are not rendered in the content and perhaps in the // future they can be rendered in an iframe or shadow DOM. if ( isFormElement( target ) && isFormElement( focus.tabbable[ direction ]( target ) ) ) { return; } const next = isShift ? focusCaptureBeforeRef : focusCaptureAfterRef; // Disable focus capturing on the focus capture element, so it // doesn't refocus this block and so it allows default behaviour // (moving focus to the next tabbable element). noCapture.current = true; next.current.focus(); return; } else if ( isEscape ) { setNavigationMode( true ); } } // When presing any key other than up or down, the initial vertical // position must ALWAYS be reset. The vertical position is saved so it // can be restored as well as possible on sebsequent vertical arrow key // presses. It may not always be possible to restore the exact same // position (such as at an empty line), so it wouldn't be good to // compute the position right before any vertical arrow key press. if ( ! isVertical ) { verticalRect.current = null; } else if ( ! verticalRect.current ) { verticalRect.current = computeCaretRect( defaultView ); } // This logic inside this condition needs to be checked before // the check for event.nativeEvent.defaultPrevented. // The logic handles meta+a keypress and this event is default prevented // by RichText. if ( ! isNav ) { // Set immediately before the meta+a combination can be pressed. if ( isKeyboardEvent.primary( event ) ) { entirelySelected.current = isEntirelySelected( target ); } if ( isKeyboardEvent.primary( event, 'a' ) ) { // When the target is contentEditable, selection will already // have been set by the browser earlier in this call stack. We // need check the previous result, otherwise all blocks will be // selected right away. if ( target.isContentEditable ? entirelySelected.current : isEntirelySelected( target ) ) { const blocks = getBlockOrder(); multiSelect( first( blocks ), last( blocks ) ); event.preventDefault(); } // After pressing primary + A we can assume isEntirelySelected is true. // Calling right away isEntirelySelected after primary + A may still return false on some browsers. entirelySelected.current = true; } return; } // Abort if navigation has already been handled (e.g. RichText inline // boundaries). if ( event.nativeEvent.defaultPrevented ) { return; } // Abort if our current target is not a candidate for navigation (e.g. // preserve native input behaviors). if ( ! isNavigationCandidate( target, keyCode, hasModifier ) ) { return; } // In the case of RTL scripts, right means previous and left means next, // which is the exact reverse of LTR. const isReverseDir = isRTL( target ) ? ! isReverse : isReverse; const { keepCaretInsideBlock } = getSettings(); if ( isShift ) { const selectionEndClientId = getMultiSelectedBlocksEndClientId(); const selectionBeforeEndClientId = getPreviousBlockClientId( selectionEndClientId || selectedBlockClientId ); const selectionAfterEndClientId = getNextBlockClientId( selectionEndClientId || selectedBlockClientId ); if ( // Ensure that there is a target block. ( ( isReverse && selectionBeforeEndClientId ) || ( ! isReverse && selectionAfterEndClientId ) ) && 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(); } } else if ( isVertical && isVerticalEdge( target, isReverse ) && ! keepCaretInsideBlock ) { const closestTabbable = getClosestTabbable( target, isReverse, container.current, true ); if ( closestTabbable ) { placeCaretAtVerticalEdge( closestTabbable, isReverse, verticalRect.current ); event.preventDefault(); } } else if ( isHorizontal && defaultView.getSelection().isCollapsed && isHorizontalEdge( target, isReverseDir ) && ! keepCaretInsideBlock ) { const closestTabbable = getClosestTabbable( target, isReverseDir, container.current ); placeCaretAtHorizontalEdge( closestTabbable, isReverse ); event.preventDefault(); } } function onMultiSelectKeyDown( event ) { const { keyCode, shiftKey } = event; const isUp = keyCode === UP; const isDown = keyCode === DOWN; const isLeft = keyCode === LEFT; const isRight = keyCode === RIGHT; const isReverse = isUp || isLeft; const isHorizontal = isLeft || isRight; const isVertical = isUp || isDown; const isNav = isHorizontal || isVertical; if ( keyCode === TAB ) { // Disable focus capturing on the focus capture element, so it // doesn't refocus this element and so it allows default behaviour // (moving focus to the next tabbable element). noCapture.current = true; if ( shiftKey ) { focusCaptureBeforeRef.current.focus(); } else { focusCaptureAfterRef.current.focus(); } } else if ( isNav ) { const action = shiftKey ? expandSelection : moveSelection; action( isReverse ); event.preventDefault(); } } useEffect( () => { if ( hasMultiSelection && ! isMultiSelecting ) { multiSelectionContainer.current.focus(); } }, [ hasMultiSelection, isMultiSelecting ] ); // 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. useMultiSelection( container ); const lastFocus = useRef(); useEffect( () => { function onFocusOut( event ) { lastFocus.current = event.target; } container.current.addEventListener( 'focusout', onFocusOut ); return () => { container.current.removeEventListener( 'focusout', onFocusOut ); }; }, [] ); function onFocusCapture( event ) { // Do not capture incoming focus if set by us in WritingFlow. if ( noCapture.current ) { noCapture.current = null; } else if ( hasMultiSelection ) { multiSelectionContainer.current.focus(); } else if ( getSelectedBlockClientId() ) { lastFocus.current.focus(); } else { setNavigationMode( true ); const isBefore = // eslint-disable-next-line no-bitwise event.target.compareDocumentPosition( container.current ) & event.target.DOCUMENT_POSITION_FOLLOWING; const action = isBefore ? 'findNext' : 'findPrevious'; focus.tabbable[ action ]( event.target ).focus(); } } // Don't allow tabbing to this element in Navigation mode. const focusCaptureTabIndex = ! isNavigationMode ? '0' : undefined; // Disable reason: Wrapper itself is non-interactive, but must capture // bubbling events from children to determine focus transition intents. /* eslint-disable jsx-a11y/no-static-element-interactions */ return ( <> <div ref={ focusCaptureBeforeRef } tabIndex={ focusCaptureTabIndex } onFocus={ onFocusCapture } style={ PREVENT_SCROLL_ON_FOCUS } /> <div ref={ multiSelectionContainer } tabIndex={ hasMultiSelection ? '0' : undefined } aria-label={ hasMultiSelection ? __( 'Multiple selected blocks' ) : undefined } style={ PREVENT_SCROLL_ON_FOCUS } onKeyDown={ onMultiSelectKeyDown } /> <div ref={ container } className="block-editor-writing-flow" onKeyDown={ onKeyDown } onMouseDown={ onMouseDown } > { children } </div> <div ref={ focusCaptureAfterRef } tabIndex={ focusCaptureTabIndex } onFocus={ onFocusCapture } style={ PREVENT_SCROLL_ON_FOCUS } /> </> ); /* eslint-enable jsx-a11y/no-static-element-interactions */ }