UNPKG

@gechiui/block-editor

Version:
330 lines (294 loc) 9.96 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 ); }; }, [] ); }