@wordpress/block-editor
Version:
491 lines (410 loc) • 18.2 kB
JavaScript
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.isNavigationCandidate = isNavigationCandidate;
exports.getClosestTabbable = getClosestTabbable;
exports.default = WritingFlow;
var _element = require("@wordpress/element");
var _lodash = require("lodash");
var _dom = require("@wordpress/dom");
var _keycodes = require("@wordpress/keycodes");
var _data = require("@wordpress/data");
var _i18n = require("@wordpress/i18n");
var _dom2 = require("../../utils/dom");
var _useMultiSelection = _interopRequireDefault(require("./use-multi-selection"));
var _store = require("../../store");
/**
* External dependencies
*/
/**
* WordPress dependencies
*/
/**
* Internal dependencies
*/
/**
* 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.
*/
function isNavigationCandidate(element, keyCode, hasModifier) {
const isVertical = keyCode === _keycodes.UP || keyCode === _keycodes.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.
*/
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 = _dom.focus.focusable.find(containerElement);
if (isReverse) {
focusableNodes = (0, _lodash.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 (!_dom.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 (0, _lodash.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.
*/
function WritingFlow({
children
}) {
const container = (0, _element.useRef)();
const focusCaptureBeforeRef = (0, _element.useRef)();
const focusCaptureAfterRef = (0, _element.useRef)();
const multiSelectionContainer = (0, _element.useRef)();
const entirelySelected = (0, _element.useRef)(); // Reference that holds the a flag for enabling or disabling
// capturing on the focus capture elements.
const noCapture = (0, _element.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 = (0, _element.useRef)();
const {
hasMultiSelection,
isMultiSelecting,
isNavigationMode
} = (0, _data.useSelect)(select => {
const selectors = select(_store.store);
return {
hasMultiSelection: selectors.hasMultiSelection(),
isMultiSelecting: selectors.isMultiSelecting(),
isNavigationMode: selectors.isNavigationMode()
};
}, []);
const {
getSelectedBlockClientId,
getMultiSelectedBlocksStartClientId,
getMultiSelectedBlocksEndClientId,
getPreviousBlockClientId,
getNextBlockClientId,
getFirstMultiSelectedBlockClientId,
getLastMultiSelectedBlockClientId,
getBlockOrder,
getSettings
} = (0, _data.useSelect)(_store.store);
const {
multiSelect,
selectBlock,
setNavigationMode
} = (0, _data.useDispatch)(_store.store);
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 || !(0, _dom2.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 === _keycodes.UP;
const isDown = keyCode === _keycodes.DOWN;
const isLeft = keyCode === _keycodes.LEFT;
const isRight = keyCode === _keycodes.RIGHT;
const isTab = keyCode === _keycodes.TAB;
const isEscape = keyCode === _keycodes.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 ? _dom.isVerticalEdge : _dom.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(_dom.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 = (0, _dom.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 (_keycodes.isKeyboardEvent.primary(event)) {
entirelySelected.current = (0, _dom.isEntirelySelected)(target);
}
if (_keycodes.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 : (0, _dom.isEntirelySelected)(target)) {
const blocks = getBlockOrder();
multiSelect((0, _lodash.first)(blocks), (0, _lodash.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 = (0, _dom.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 && (0, _dom.isVerticalEdge)(target, isReverse) && !keepCaretInsideBlock) {
const closestTabbable = getClosestTabbable(target, isReverse, container.current, true);
if (closestTabbable) {
(0, _dom.placeCaretAtVerticalEdge)(closestTabbable, isReverse, verticalRect.current);
event.preventDefault();
}
} else if (isHorizontal && defaultView.getSelection().isCollapsed && (0, _dom.isHorizontalEdge)(target, isReverseDir) && !keepCaretInsideBlock) {
const closestTabbable = getClosestTabbable(target, isReverseDir, container.current);
(0, _dom.placeCaretAtHorizontalEdge)(closestTabbable, isReverse);
event.preventDefault();
}
}
function onMultiSelectKeyDown(event) {
const {
keyCode,
shiftKey
} = event;
const isUp = keyCode === _keycodes.UP;
const isDown = keyCode === _keycodes.DOWN;
const isLeft = keyCode === _keycodes.LEFT;
const isRight = keyCode === _keycodes.RIGHT;
const isReverse = isUp || isLeft;
const isHorizontal = isLeft || isRight;
const isVertical = isUp || isDown;
const isNav = isHorizontal || isVertical;
if (keyCode === _keycodes.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();
}
}
(0, _element.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.
(0, _useMultiSelection.default)(container);
const lastFocus = (0, _element.useRef)();
(0, _element.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';
_dom.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 (0, _element.createElement)(_element.Fragment, null, (0, _element.createElement)("div", {
ref: focusCaptureBeforeRef,
tabIndex: focusCaptureTabIndex,
onFocus: onFocusCapture,
style: PREVENT_SCROLL_ON_FOCUS
}), (0, _element.createElement)("div", {
ref: multiSelectionContainer,
tabIndex: hasMultiSelection ? '0' : undefined,
"aria-label": hasMultiSelection ? (0, _i18n.__)('Multiple selected blocks') : undefined,
style: PREVENT_SCROLL_ON_FOCUS,
onKeyDown: onMultiSelectKeyDown
}), (0, _element.createElement)("div", {
ref: container,
className: "block-editor-writing-flow",
onKeyDown: onKeyDown,
onMouseDown: onMouseDown
}, children), (0, _element.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