UNPKG

@wordpress/block-editor

Version:
470 lines (403 loc) 17.6 kB
import { createElement, Fragment } from "@wordpress/element"; /** * 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 createElement(Fragment, null, createElement("div", { ref: focusCaptureBeforeRef, tabIndex: focusCaptureTabIndex, onFocus: onFocusCapture, style: PREVENT_SCROLL_ON_FOCUS }), createElement("div", { ref: multiSelectionContainer, tabIndex: hasMultiSelection ? '0' : undefined, "aria-label": hasMultiSelection ? __('Multiple selected blocks') : undefined, style: PREVENT_SCROLL_ON_FOCUS, onKeyDown: onMultiSelectKeyDown }), createElement("div", { ref: container, className: "block-editor-writing-flow", onKeyDown: onKeyDown, onMouseDown: onMouseDown }, children), createElement("div", { ref: focusCaptureAfterRef, tabIndex: focusCaptureTabIndex, onFocus: onFocusCapture, style: PREVENT_SCROLL_ON_FOCUS })); /* eslint-enable jsx-a11y/no-static-element-interactions */ } //# sourceMappingURL=index.js.map