@wordpress/block-editor
Version:
470 lines (403 loc) • 17.6 kB
JavaScript
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