Skip to content

Commit

Permalink
Writing Flow: Fix vertical arrow navigation skips
Browse files Browse the repository at this point in the history
  • Loading branch information
aduth committed Jul 10, 2018
1 parent a5d7295 commit f370e8a
Show file tree
Hide file tree
Showing 2 changed files with 80 additions and 78 deletions.
12 changes: 9 additions & 3 deletions editor/components/writing-flow/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ import {
hasInnerBlocksContext,
} from '../../utils/dom';

/**
* Browser constants
*/

const { getSelection } = window;

/**
* Given an element, returns true if the element is a tabbable text field, or
* false otherwise.
Expand Down Expand Up @@ -240,7 +246,7 @@ class WritingFlow extends Component {

if ( isShift && ( hasMultiSelection || (
this.isTabbableEdge( target, isReverse ) &&
isNavEdge( target, isReverse, true )
isNavEdge( target, isReverse )
) ) ) {
// Shift key is down, and there is multi selection or we're at the end of the current block.
this.expandSelection( isReverse );
Expand All @@ -249,14 +255,14 @@ class WritingFlow extends Component {
// Moving from block multi-selection to single block selection
this.moveSelection( isReverse );
event.preventDefault();
} else if ( isVertical && isVerticalEdge( target, isReverse, isShift ) ) {
} else if ( isVertical && isVerticalEdge( target, isReverse ) ) {
const closestTabbable = this.getClosestTabbable( target, isReverse );

if ( closestTabbable ) {
placeCaretAtVerticalEdge( closestTabbable, isReverse, this.verticalRect );
event.preventDefault();
}
} else if ( isHorizontal && isHorizontalEdge( target, isReverse, isShift ) ) {
} else if ( isHorizontal && getSelection().isCollapsed && isHorizontalEdge( target, isReverse ) ) {
const closestTabbable = this.getClosestTabbable( target, isReverse );
placeCaretAtHorizontalEdge( closestTabbable, isReverse );
event.preventDefault();
Expand Down
146 changes: 71 additions & 75 deletions packages/dom/src/dom.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,58 @@
/**
* External dependencies
*/
import { includes, first } from 'lodash';
import tinymce from 'tinymce';
import { includes } from 'lodash';

/**
* Browser dependencies
*/
const { getComputedStyle, DOMRect } = window;
const { TEXT_NODE, ELEMENT_NODE } = window.Node;

const { getComputedStyle } = window;
const {
TEXT_NODE,
ELEMENT_NODE,
DOCUMENT_POSITION_PRECEDING,
} = window.Node;

/**
* Check whether the caret is horizontally at the edge of the container.
* Returns true if the given selection object is in the forward direction, or
* false otherwise.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
*
* @param {Element} container Focusable element.
* @param {boolean} isReverse Set to true to check left, false for right.
* @param {boolean} collapseRanges Whether or not to collapse the selection range before the check.
* @param {Selection} selection Selection object to check.
*
* @return {boolean} Whether the selection is forward.
*/
function isSelectionForward( selection ) {
const {
anchorNode,
focusNode,
anchorOffset,
focusOffset,
} = selection;

const position = anchorNode.compareDocumentPosition( focusNode );

return (
// Compare whether anchor node precedes focus node.
position !== DOCUMENT_POSITION_PRECEDING &&

// `compareDocumentPosition` returns 0 when passed the same node, in
// which case compare offsets.
! ( position === 0 && anchorOffset > focusOffset )
);
}

/**
* Check whether the selection is horizontally at the edge of the container.
*
* @param {Element} container Focusable element.
* @param {boolean} isReverse Set to true to check left, false for right.
*
* @return {boolean} True if at the horizontal edge, false if not.
*/
export function isHorizontalEdge( container, isReverse, collapseRanges = false ) {
export function isHorizontalEdge( container, isReverse ) {
if ( includes( [ 'INPUT', 'TEXTAREA' ], container.tagName ) ) {
if ( container.selectionStart !== container.selectionEnd ) {
return false;
Expand All @@ -36,61 +69,34 @@ export function isHorizontalEdge( container, isReverse, collapseRanges = false )
return true;
}

// If the container is empty, the caret is always at the edge.
if ( tinymce.DOM.isEmpty( container ) ) {
return true;
}

const selection = window.getSelection();
let range = selection.rangeCount ? selection.getRangeAt( 0 ) : null;
if ( collapseRanges ) {
range = range.cloneRange();
range.collapse( isReverse );
}

if ( ! range || ! range.collapsed ) {
return false;
}

const position = isReverse ? 'start' : 'end';
const order = isReverse ? 'first' : 'last';
const offset = range[ `${ position }Offset` ];
// Create copy of range for setting selection to find effective offset.
const range = selection.getRangeAt( 0 ).cloneRange();

let node = range.startContainer;

if ( isReverse && offset !== 0 ) {
return false;
}

const maxOffset = node.nodeType === TEXT_NODE ? node.nodeValue.length : node.childNodes.length;

if ( ! isReverse && offset !== maxOffset ) {
return false;
// Collapse in direction of selection.
if ( ! selection.isCollapsed ) {
range.collapse( ! isSelectionForward( selection ) );
}

while ( node !== container ) {
const parentNode = node.parentNode;

if ( parentNode[ `${ order }Child` ] !== node ) {
return false;
}

node = parentNode;
}
const { endContainer, endOffset } = range;
range.selectNodeContents( container );
range.setEnd( endContainer, endOffset );

return true;
// Edge reached if effective caret position is at expected extreme.
const caretOffset = range.toString().length;
return caretOffset === ( isReverse ? 0 : container.textContent.length );
}

/**
* Check whether the caret is vertically at the edge of the container.
* Check whether the selection is vertically at the edge of the container.
*
* @param {Element} container Focusable element.
* @param {boolean} isReverse Set to true to check top, false for bottom.
* @param {boolean} collapseRanges Whether or not to collapse the selection range before the check.
* @param {Element} container Focusable element.
* @param {boolean} isReverse Set to true to check top, false for bottom.
*
* @return {boolean} True if at the edge, false if not.
*/
export function isVerticalEdge( container, isReverse, collapseRanges = false ) {
export function isVerticalEdge( container, isReverse ) {
if ( includes( [ 'INPUT', 'TEXTAREA' ], container.tagName ) ) {
return isHorizontalEdge( container, isReverse );
}
Expand All @@ -100,16 +106,8 @@ export function isVerticalEdge( container, isReverse, collapseRanges = false ) {
}

const selection = window.getSelection();
let range = selection.rangeCount ? selection.getRangeAt( 0 ) : null;
if ( collapseRanges && range && ! range.collapsed ) {
const newRange = document.createRange();
// Get the end point of the selection (see focusNode vs. anchorNode)
newRange.setStart( selection.focusNode, selection.focusOffset );
newRange.collapse( true );
range = newRange;
}

if ( ! range || ! range.collapsed ) {
const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null;
if ( ! range ) {
return false;
}

Expand Down Expand Up @@ -150,21 +148,19 @@ export function getRectangleFromRange( range ) {
return range.getBoundingClientRect();
}

// If the collapsed range starts (and therefore ends) at an element node,
// `getClientRects` will return undefined. To fix this we can get the
// bounding rectangle of the element node to create a DOMRect based on that.
if ( range.startContainer.nodeType === ELEMENT_NODE ) {
const { x, y, height } = range.startContainer.getBoundingClientRect();
let rect = range.getClientRects()[ 0 ];

// Create a new DOMRect with zero width.
return new DOMRect( x, y, 0, height );
// If the collapsed range starts (and therefore ends) at an element node,
// `getClientRects` can be empty in some browsers. This can be resolved
// by adding a temporary text node to the range.
if ( ! rect ) {
const padNode = document.createTextNode( '\u200b' );
range.insertNode( padNode );
rect = range.getClientRects()[ 0 ];
padNode.parentNode.removeChild( padNode );
}

// For normal collapsed ranges (exception above), the bounding rectangle of
// the range may be inaccurate in some browsers. There will only be one
// rectangle since it is a collapsed range, so it is safe to pass this as
// the union of them. This works consistently in all browsers.
return first( range.getClientRects() );
return rect;
}

/**
Expand All @@ -182,7 +178,7 @@ export function computeCaretRect( container ) {
const selection = window.getSelection();
const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null;

if ( ! range || ! range.collapsed ) {
if ( ! range ) {
return;
}

Expand Down Expand Up @@ -314,7 +310,7 @@ export function placeCaretAtVerticalEdge( container, isReverse, rect, mayUseScro
// equivalent to a point at half the height of a line of text.
const buffer = rect.height / 2;
const editableRect = container.getBoundingClientRect();
const x = rect.left + ( rect.width / 2 );
const x = rect.left;
const y = isReverse ? ( editableRect.bottom - buffer ) : ( editableRect.top + buffer );
const selection = window.getSelection();

Expand Down

0 comments on commit f370e8a

Please sign in to comment.