UNPKG

@gechiui/block-editor

Version:
275 lines (234 loc) 10.1 kB
/** * External dependencies */ import { find, reverse } from 'lodash'; /** * GeChiUI dependencies */ import { computeCaretRect, focus, isHorizontalEdge, isVerticalEdge, placeCaretAtHorizontalEdge, placeCaretAtVerticalEdge, isRTL } from '@gechiui/dom'; import { UP, DOWN, LEFT, RIGHT } from '@gechiui/keycodes'; import { useSelect, useDispatch } from '@gechiui/data'; import { useRefEffect } from '@gechiui/compose'; /** * Internal dependencies */ import { isInSameBlock } from '../../utils/dom'; import { store as blockEditorStore } from '../../store'; /** * 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); } export default function useArrowNav() { const { getSelectedBlockClientId, getMultiSelectedBlocksStartClientId, getMultiSelectedBlocksEndClientId, getPreviousBlockClientId, getNextBlockClientId, getFirstMultiSelectedBlockClientId, getLastMultiSelectedBlockClientId, getSettings, hasMultiSelection } = useSelect(blockEditorStore); const { multiSelect, selectBlock } = useDispatch(blockEditorStore); return useRefEffect(node => { // 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. let verticalRect; function onMouseDown() { verticalRect = 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) { if (selectionStartClientId === nextSelectionEndClientId) { selectBlock(nextSelectionEndClientId); } else { 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, node); return !closestTabbable || !isInSameBlock(target, closestTabbable); } function onKeyDown(event) { const { keyCode, target } = 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; const isShift = event.shiftKey; const hasModifier = isShift || event.ctrlKey || event.altKey || event.metaKey; const isNavEdge = isVertical ? isVerticalEdge : isHorizontalEdge; const { ownerDocument } = node; const { defaultView } = ownerDocument; if (hasMultiSelection()) { if (isNav) { const action = isShift ? expandSelection : moveSelection; action(isReverse); event.preventDefault(); } return; } // 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 = null; } else if (!verticalRect) { verticalRect = computeCaretRect(defaultView); } // Abort if navigation has already been handled (e.g. RichText // inline boundaries). if (event.defaultPrevented) { return; } if (!isNav) { 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(); const selectedBlockClientId = getSelectedBlockClientId(); 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, node, true); if (closestTabbable) { placeCaretAtVerticalEdge(closestTabbable, isReverse, verticalRect); event.preventDefault(); } } else if (isHorizontal && defaultView.getSelection().isCollapsed && isHorizontalEdge(target, isReverseDir) && !keepCaretInsideBlock) { const closestTabbable = getClosestTabbable(target, isReverseDir, node); placeCaretAtHorizontalEdge(closestTabbable, isReverse); event.preventDefault(); } } node.addEventListener('mousedown', onMouseDown); node.addEventListener('keydown', onKeyDown); return () => { node.removeEventListener('mousedown', onMouseDown); node.removeEventListener('keydown', onKeyDown); }; }, []); } //# sourceMappingURL=use-arrow-nav.js.map