diff --git a/editor/components/rich-text/format.js b/editor/components/rich-text/format.js
index 221966b81fa7e4..2a0edf9cb2ff78 100644
--- a/editor/components/rich-text/format.js
+++ b/editor/components/rich-text/format.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { omitBy } from 'lodash';
+import { omitBy, get } from 'lodash';
import { nodeListToReact } from 'dom-react';
/**
@@ -9,6 +9,12 @@ import { nodeListToReact } from 'dom-react';
*/
import { createElement, renderToString } from '@wordpress/element';
+/**
+ * Browser dependencies
+ */
+
+const { Node } = window;
+
/**
* Transforms a WP Element to its corresponding HTML string.
*
@@ -62,6 +68,34 @@ export function createTinyMCEElement( type, props, ...children ) {
);
}
+/**
+ * Given a TinyMCE Node instance, returns an equivalent WordPress element.
+ *
+ * @param {tinyMCE.html.Node} node TinyMCE node
+ *
+ * @return {WPElement} WordPress element
+ */
+export function tinyMCENodeToElement( node ) {
+ if ( node.type === Node.TEXT_NODE ) {
+ return node.value;
+ }
+
+ const children = [];
+
+ let child = node.firstChild;
+ while ( child ) {
+ children.push( tinyMCENodeToElement( child ) );
+ child = child.next;
+ }
+
+ if ( node.type === Node.DOCUMENT_FRAGMENT_NODE ) {
+ return children;
+ }
+
+ const attributes = get( node.attributes, [ 'map' ], {} );
+ return createElement( node.name, attributes, ...children );
+}
+
/**
* Transforms an array of DOM Elements to their corresponding WP element.
*
diff --git a/editor/components/rich-text/index.js b/editor/components/rich-text/index.js
index 200c34eacd3540..df1122c78e2e96 100644
--- a/editor/components/rich-text/index.js
+++ b/editor/components/rich-text/index.js
@@ -18,7 +18,7 @@ import 'element-closest';
/**
* WordPress dependencies
*/
-import { Component, Fragment, compose, RawHTML, createRef } from '@wordpress/element';
+import { Component, Fragment, compose, RawHTML, Children, createRef } from '@wordpress/element';
import {
keycodes,
createBlobURL,
@@ -43,9 +43,33 @@ import { pickAriaProps } from './aria';
import patterns from './patterns';
import { EVENTS } from './constants';
import { withBlockEditContext } from '../block-edit/context';
-import { domToFormat, valueToString } from './format';
+import {
+ domToFormat,
+ valueToString,
+ tinyMCENodeToElement,
+} from './format';
+
+/**
+ * Browser dependencies
+ */
+
+const { getSelection, Node } = window;
+
+/**
+ * Module constants
+ */
+
+const { LEFT, RIGHT, BACKSPACE, DELETE, ENTER, rawShortcut } = keycodes;
-const { BACKSPACE, DELETE, ENTER, rawShortcut } = keycodes;
+/**
+ * Zero-width space character used by TinyMCE as a caret landing point for
+ * inline boundary nodes.
+ *
+ * @see tinymce/src/core/main/ts/text/Zwsp.ts
+ *
+ * @type {string}
+ */
+const TINYMCE_ZWSP = '\uFEFF';
/**
* Returns true if the node is the inline node boundary. This is used in node
@@ -61,7 +85,7 @@ const { BACKSPACE, DELETE, ENTER, rawShortcut } = keycodes;
*/
export function isEmptyInlineBoundary( node ) {
const text = node.nodeName === 'A' ? node.innerText : node.textContent;
- return text === '\uFEFF';
+ return text === TINYMCE_ZWSP;
}
/**
@@ -122,6 +146,7 @@ export class RichText extends Component {
this.onPaste = this.onPaste.bind( this );
this.onCreateUndoLevel = this.onCreateUndoLevel.bind( this );
this.setFocusedElement = this.setFocusedElement.bind( this );
+ this.removeZwsp = this.removeZwsp.bind( this );
this.state = {
formats: {},
@@ -186,6 +211,7 @@ export class RichText extends Component {
editor.on( 'PastePreProcess', this.onPastePreProcess, true /* Add before core handlers */ );
editor.on( 'paste', this.onPaste, true /* Add before core handlers */ );
editor.on( 'input', this.onChange );
+ editor.on( 'focusout', this.removeZwsp );
// The change event in TinyMCE fires every time an undo level is added.
editor.on( 'change', this.onCreateUndoLevel );
@@ -202,6 +228,27 @@ export class RichText extends Component {
}
}
+ /**
+ * Cleans up after TinyMCE when leaving the field, removing lingering zero-
+ * width space characters. Without removal, future horizontal navigation
+ * into the field would land on the zero-width space, where it's preferred
+ * to consistently land within an inline boundary where the zero-width
+ * space had existed to delineate.
+ */
+ removeZwsp() {
+ const rootNode = this.editor.getBody();
+
+ const stack = [ ...rootNode.childNodes ];
+ while ( stack.length ) {
+ const node = stack.pop();
+ if ( node.nodeType === Node.TEXT_NODE && node.nodeValue === TINYMCE_ZWSP ) {
+ node.parentNode.removeChild( node );
+ }
+
+ stack.push( ...node.childNodes );
+ }
+ }
+
/**
* Allows prop event handlers to handle an event.
*
@@ -440,40 +487,101 @@ export class RichText extends Component {
}
/**
- * Handles a keydown event from tinyMCE.
+ * Handles a Backspace or Delete keydown event to delegate merge or remove
+ * if key event occurs while at the extent edge of the field. Prevents
+ * default browser behavior if delegated to prop callback handler.
*
- * @param {KeydownEvent} event The keydow event as triggered by tinyMCE.
+ * @param {KeyboardEvent} event Keydown event.
*/
- onKeyDown( event ) {
- const dom = this.editor.dom;
+ onDeleteKeyDown( event ) {
+ const { onMerge, onRemove } = this.props;
+ if ( ! onMerge && ! onRemove ) {
+ return;
+ }
+
+ if ( ! getSelection().isCollapsed ) {
+ return;
+ }
+
+ const isForward = ( event.keyCode === DELETE );
const rootNode = this.editor.getBody();
+ if ( ! isHorizontalEdge( rootNode, ! isForward ) ) {
+ return;
+ }
- if (
- ( event.keyCode === BACKSPACE && isHorizontalEdge( rootNode, true ) ) ||
- ( event.keyCode === DELETE && isHorizontalEdge( rootNode, false ) )
- ) {
- if ( ! this.props.onMerge && ! this.props.onRemove ) {
- return;
- }
+ this.onCreateUndoLevel();
- this.onCreateUndoLevel();
+ if ( onMerge ) {
+ onMerge( isForward );
+ }
- const forward = event.keyCode === DELETE;
+ if ( onRemove && this.isEmpty() ) {
+ onRemove( isForward );
+ }
- if ( this.props.onMerge ) {
- this.props.onMerge( forward );
- }
+ event.preventDefault();
- if ( this.props.onRemove && this.isEmpty() ) {
- this.props.onRemove( forward );
- }
+ // Calling onMerge() or onRemove() will destroy the editor, so it's
+ // important that other handlers (e.g. ones registered by TinyMCE) do
+ // not also attempt to handle this event.
+ event.stopImmediatePropagation();
+ }
- event.preventDefault();
+ /**
+ * Handles a horizontal navigation key down event to stop propagation if it
+ * can be inferred that it will be handled by TinyMCE (notably transitions
+ * out of an inline boundary node).
+ *
+ * @param {KeyboardEvent} event Keydown event.
+ */
+ onHorizontalNavigationKeyDown( event ) {
+ const { focusNode, focusOffset } = window.getSelection();
+ const { nodeType, nodeValue } = focusNode;
+
+ if ( nodeType !== Node.TEXT_NODE ) {
+ return;
+ }
+
+ const isReverse = event.keyCode === LEFT;
- // Calling onMerge() or onRemove() will destroy the editor, so it's important
- // that we stop other handlers (e.g. ones registered by TinyMCE) from
- // also handling this event.
- event.stopImmediatePropagation();
+ let offset = focusOffset;
+ if ( isReverse ) {
+ offset--;
+ }
+
+ // [WORKAROUND]: When in a new paragraph in a new inline boundary node,
+ // while typing the zero-width space occurs as the first child instead
+ // of at the end of the inline boundary where the caret is. This should
+ // only be exempt when focusNode is not _only_ the ZWSP, which occurs
+ // when caret is placed on the right outside edge of inline boundary.
+ if ( ! isReverse && focusOffset === nodeValue.length &&
+ nodeValue.length > 1 && nodeValue[ 0 ] === TINYMCE_ZWSP ) {
+ offset = 0;
+ }
+
+ if ( nodeValue[ offset ] === TINYMCE_ZWSP ) {
+ event.stopPropagation();
+ }
+ }
+
+ /**
+ * Handles a keydown event from tinyMCE.
+ *
+ * @param {KeyboardEvent} event Keydown event.
+ */
+ onKeyDown( event ) {
+ const { keyCode } = event;
+ const dom = this.editor.dom;
+ const rootNode = this.editor.getBody();
+
+ const isDeleteKey = keyCode === BACKSPACE || keyCode === DELETE;
+ if ( isDeleteKey ) {
+ this.onDeleteKeyDown( event );
+ }
+
+ const isHorizontalNavigation = keyCode === LEFT || keyCode === RIGHT;
+ if ( isHorizontalNavigation ) {
+ this.onHorizontalNavigationKeyDown( event );
}
// If we click shift+Enter on inline RichTexts, we avoid creating two contenteditables
@@ -716,9 +824,7 @@ export class RichText extends Component {
case 'string':
return this.editor.getContent();
default:
- return this.editor.dom.isEmpty( this.editor.getBody() ) ?
- [] :
- domToFormat( this.editor.getBody().childNodes || [], 'element', this.editor );
+ return tinyMCENodeToElement( this.editor.getContent( { format: 'tree' } ) );
}
}
@@ -755,8 +861,15 @@ export class RichText extends Component {
* @return {boolean} Whether field is empty.
*/
isEmpty() {
- const { value } = this.props;
- return ! value || ! value.length;
+ const { value, format } = this.props;
+ if ( ! value ) {
+ return true;
+ }
+
+ return (
+ format === 'string' ||
+ ! Children.count( value )
+ );
}
isFormatActive( format ) {
diff --git a/editor/components/writing-flow/index.js b/editor/components/writing-flow/index.js
index 45fe750446e2f4..50242ef55ab983 100644
--- a/editor/components/writing-flow/index.js
+++ b/editor/components/writing-flow/index.js
@@ -28,6 +28,12 @@ import {
isInSameBlock,
} from '../../utils/dom';
+/**
+ * Browser constants
+ */
+
+const { getSelection } = window;
+
/**
* Module Constants
*/
@@ -206,7 +212,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 );
@@ -215,14 +221,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();
diff --git a/element/serialize.js b/element/serialize.js
index 91746f0fb57d45..6ef93817d8c938 100644
--- a/element/serialize.js
+++ b/element/serialize.js
@@ -406,14 +406,14 @@ export function renderNativeComponent( type, props, context = {} ) {
// Textarea children can be assigned as value prop. If it is, render in
// place of children. Ensure to omit so it is not assigned as attribute
// as well.
- content = renderChildren( [ props.value ], context );
+ content = renderChildren( props.value, context );
props = omit( props, 'value' );
} else if ( props.dangerouslySetInnerHTML &&
typeof props.dangerouslySetInnerHTML.__html === 'string' ) {
// Dangerous content is left unescaped.
content = props.dangerouslySetInnerHTML.__html;
} else if ( typeof props.children !== 'undefined' ) {
- content = renderChildren( castArray( props.children ), context );
+ content = renderChildren( props.children, context );
}
if ( ! type ) {
@@ -465,6 +465,8 @@ export function renderComponent( Component, props, context = {} ) {
function renderChildren( children, context = {} ) {
let result = '';
+ children = castArray( children );
+
for ( let i = 0; i < children.length; i++ ) {
const child = children[ i ];
diff --git a/element/test/serialize.js b/element/test/serialize.js
index 610e8e29563533..c4a16fec749b2a 100644
--- a/element/test/serialize.js
+++ b/element/test/serialize.js
@@ -199,6 +199,12 @@ describe( 'renderElement()', () => {
expect( result ).toBe( 'Hello' );
} );
+ it( 'renders Fragment with undefined children', () => {
+ const result = renderElement(
Hello to the World!"`;
+
+exports[`adding blocks Should navigate with arrow keys 6`] = `"Prefix: Hello"`;
+
+exports[`adding blocks Should navigate with arrow keys 7`] = `"The Greeting:
Hello to the World! (Suffix)"`;
+
+exports[`adding blocks Should navigate with arrow keys 8`] = `"Bolded
"`;
+
+exports[`adding blocks Should navigate with arrow keys 9`] = `"Bolded After
"`;
+
+exports[`adding blocks Should navigate with arrow keys 10`] = `"The Greeting:
Hello to the World! (Suffixed)"`;
+
+exports[`adding blocks Should navigate with arrow keys 11`] = `"More Bolded (Still Bolded)
"`;
+
+exports[`adding blocks Should navigate with arrow keys 12`] = `"The Prefix: Hello"`;
+
+exports[`adding blocks Should navigate with arrow keys 13`] = `
+"
+
The Greeting:
Hello to the World! (Suffixed)
Bolded After
+ + + +More Bolded (Still Bolded)
+ + + +The Prefix: Hello
+" +`; diff --git a/test/e2e/specs/writing-flow.test.js b/test/e2e/specs/writing-flow.test.js new file mode 100644 index 00000000000000..96b15abbd1541e --- /dev/null +++ b/test/e2e/specs/writing-flow.test.js @@ -0,0 +1,217 @@ +/** + * External dependencies + */ +import { times } from 'lodash'; + +/** + * Internal dependencies + */ +import '../support/bootstrap'; +import { + newPost, + newDesktopBrowserPage, + pressWithModifier, + getHTMLFromCodeEditor, +} from '../support/utils'; + +describe( 'adding blocks', () => { + beforeAll( async () => { + await newDesktopBrowserPage(); + await newPost(); + } ); + + /** + * Given an array of functions, each returning a promise, performs all + * promises in sequence (waterfall) order. + * + * @param {Function[]} sequence Array of promise creators. + * + * @return {Promise} Promise resolving once all in the sequence complete. + */ + async function promiseSequence( sequence ) { + return sequence.reduce( + ( current, next ) => current.then( next ), + Promise.resolve() + ); + } + + /** + * Asserts that the nth paragraph matches expected snapshot content. + * + * @param {number} nthChild 1-based index of paragraph to assert against. + */ + async function expectParagraphToMatchSnapshot( nthChild ) { + const content = await page.$eval( + `.editor-block-list__block:nth-child( ${ nthChild } ) .wp-block-paragraph`, + ( element ) => element.innerHTML + ); + + expect( content ).toMatchSnapshot(); + } + + /** + * Waits for TinyMCE to finish initializing within as the current active + * element at the nth paragraph block. + * + * TODO: Asynchronous code smell; if our tests are too fast for TinyMCE, + * there's a good chance a fast user could encounter this as well. + * + * @param {number} nthChild 1-based index of paragraph. + */ + async function waitUntilNthParagraphActive( nthChild ) { + await page.waitForFunction( ( _nthChild ) => { + const paragraph = `.editor-block-list__block:nth-child( ${ _nthChild } ) .wp-block-paragraph[id^="mce_"]`; + return document.activeElement = paragraph; + }, nthChild ); + } + + /** + * Presses the bold text key combination. TinyMCE initialization may not + * have occurred, so must wait for it to become ready. + * + * TODO: Asynchronous code smell; if our tests are too fast for TinyMCE, + * there's a good chance a fast user could encounter this as well. + */ + async function bold() { + await page.waitForFunction( () => { + return document.activeElement.id.startsWith( 'mce_' ); + } ); + await pressWithModifier( 'Mod', 'B' ); + } + + it( 'Should navigate with arrow keys', async () => { + // Add demo content + await page.click( '.editor-default-block-appender__content' ); + await page.keyboard.type( 'Hello World' ); + await page.keyboard.press( 'Enter' ); + await waitUntilNthParagraphActive( 2 ); + await page.keyboard.type( 'Hello World' ); + + // Traverse back to first paragraph. With identical content, assume + // caret will be at end of content. Append. + await page.keyboard.press( 'ArrowUp' ); + await waitUntilNthParagraphActive( 1 ); + await page.keyboard.type( '!' ); + await expectParagraphToMatchSnapshot( 1 ); + + // Likewise, pressing arrow down when subsequent paragraph is shorter + // should move caret to end of the paragraph. Remove second word. + await page.keyboard.press( 'ArrowDown' ); + await waitUntilNthParagraphActive( 2 ); + await promiseSequence( times( 6, () => () => page.keyboard.press( 'Backspace' ) ) ); + await expectParagraphToMatchSnapshot( 2 ); + + // Arrow up should preserve caret X position. Add content to middle. + await page.keyboard.press( 'ArrowUp' ); + await waitUntilNthParagraphActive( 1 ); + await page.keyboard.type( ' to the' ); + await expectParagraphToMatchSnapshot( 1 ); + + // Arrow up from uncollapsed selection behaves same as collapsed. + // Highlight first word of second paragraph and prepend to first. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.down( 'Shift' ); + await promiseSequence( times( 5, () => () => page.keyboard.press( 'ArrowLeft' ) ) ); + await page.keyboard.up( 'Shift' ); + await page.keyboard.press( 'ArrowUp' ); + await waitUntilNthParagraphActive( 1 ); + await page.keyboard.type( 'Greeting: ' ); + await expectParagraphToMatchSnapshot( 1 ); + + // Arrow up from second line of paragraph should _not_ place caret out + // of the current block. + await pressWithModifier( 'Shift', 'Enter' ); + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.type( 'The ' ); + await expectParagraphToMatchSnapshot( 1 ); + + // Pressing down from last paragraph should move caret to end of the + // text of the last paragraph + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await waitUntilNthParagraphActive( 2 ); + + // Regression test: Ensure selection can be progressively collapsed at + // beginning and end of text while shift is held. + await promiseSequence( times( 5, () => () => page.keyboard.press( 'ArrowLeft' ) ) ); + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.up( 'Shift' ); + await page.keyboard.type( 'Prefix: ' ); + await expectParagraphToMatchSnapshot( 2 ); + + // Likewise, ensure progressive collapse from previous block (previous + // to guard against multi-selection behavior). Asserts arrow left to + // traverse horizontally to previous. + await promiseSequence( times( 9, () => () => page.keyboard.press( 'ArrowLeft' ) ) ); + await waitUntilNthParagraphActive( 1 ); + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.up( 'Shift' ); + await page.keyboard.type( ' (Suffix)' ); + await expectParagraphToMatchSnapshot( 1 ); + + // Should arrow once to escape out of inline boundary (bold, etc), and + // escaping out should nullify any block traversal. + await page.keyboard.press( 'Enter' ); + await waitUntilNthParagraphActive( 2 ); + await bold(); + await page.keyboard.type( 'Bolded' ); + await expectParagraphToMatchSnapshot( 2 ); + + // But typing immediately after escaping should not be within. + await page.keyboard.press( 'ArrowRight' ); + // [BUGFIX]: This appears to be a bug within TinyMCE that, in Chromium, + // pressing space after escaping inline boundary on the right resumes + // inline node. Workaround is to navigate back in and out again. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowRight' ); + // [/ENDBUGFIX] + await page.keyboard.type( ' After' ); + await expectParagraphToMatchSnapshot( 2 ); + + // Navigate back to previous block. Change "(Suffix)" to "(Suffixed)" + // + // "Bolded After" = 12 characters + // + 2 inline boundaries + // + 1 horizontal block traversal + // + 1 into parenthesis + // = 16 + await promiseSequence( times( 16, () => () => page.keyboard.press( 'ArrowLeft' ) ) ); + await waitUntilNthParagraphActive( 1 ); + await page.keyboard.type( 'ed' ); + await expectParagraphToMatchSnapshot( 1 ); + + // Should require two arrow presses while at end of paragraph within + // inline boundary to horizontal navigate to next block + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'Enter' ); + await waitUntilNthParagraphActive( 3 ); + await bold(); + await page.keyboard.type( 'More Bolded' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowRight' ); // Before "Prefix:" + await waitUntilNthParagraphActive( 4 ); + + // Should navigate across empty paragraph (where bogus `br` nodes from + // TinyMCE exist) in both directions + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'ArrowLeft' ); // In empty new paragraph + await page.keyboard.press( 'ArrowLeft' ); // In "More Bolded" + await waitUntilNthParagraphActive( 3 ); + await page.keyboard.type( ' (Still Bolded)' ); + await expectParagraphToMatchSnapshot( 3 ); + await page.keyboard.press( 'ArrowRight' ); // After inline boundary + await page.keyboard.press( 'ArrowRight' ); // In empty paragraph + await page.keyboard.press( 'ArrowRight' ); // Before "Prefix:" + await waitUntilNthParagraphActive( 4 ); + await page.keyboard.press( 'Backspace' ); + await page.keyboard.type( 'The ' ); + await expectParagraphToMatchSnapshot( 4 ); + + expect( await getHTMLFromCodeEditor() ).toMatchSnapshot(); + } ); +} ); diff --git a/test/e2e/support/utils.js b/test/e2e/support/utils.js index 1f6f0950323aaa..0730f2f9654400 100644 --- a/test/e2e/support/utils.js +++ b/test/e2e/support/utils.js @@ -19,6 +19,13 @@ const { */ const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; +/** + * Regular expression matching zero-width space characters. + * + * @type {RegExp} + */ +const REGEXP_ZWSP = /[\u200B\u200C\u200D\uFEFF]/; + function getUrl( WPPath, query = '' ) { const url = new URL( WP_BASE_URL ); @@ -78,6 +85,12 @@ export async function getHTMLFromCodeEditor() { await switchToEditor( 'Code' ); const textEditorContent = await page.$eval( '.editor-post-text-editor', ( element ) => element.value ); await switchToEditor( 'Visual' ); + + // Globally guard against zero-width characters. + if ( REGEXP_ZWSP.test( textEditorContent ) ) { + throw new Error( 'Unexpected zero-width space character in editor content.' ); + } + return textEditorContent; } diff --git a/utils/dom.js b/utils/dom.js index f0dbeb5c5ae8f8..3de7af5af7f8da 100644 --- a/utils/dom.js +++ b/utils/dom.js @@ -1,25 +1,53 @@ /** * 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, 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. * - * @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. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition + * + * @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; @@ -36,61 +64,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 ); } @@ -100,16 +101,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; } @@ -150,21 +143,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; } /** @@ -182,7 +173,7 @@ export function computeCaretRect( container ) { const selection = window.getSelection(); const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null; - if ( ! range || ! range.collapsed ) { + if ( ! range ) { return; } @@ -314,7 +305,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();