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( ); + + expect( result ).toBe( '' ); + } ); + it( 'renders RawHTML as its unescaped children', () => { const result = renderElement( { '' } ); diff --git a/lib/client-assets.php b/lib/client-assets.php index fd8eab145b69bd..2299129cce5220 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -130,7 +130,7 @@ function gutenberg_register_scripts_and_styles() { wp_register_script( 'wp-utils', gutenberg_url( 'build/utils/index.js' ), - array( 'tinymce-latest', 'lodash' ), + array( 'lodash' ), filemtime( gutenberg_dir_path() . 'build/utils/index.js' ), true ); @@ -472,7 +472,7 @@ function gutenberg_register_vendor_scripts() { 'https://unpkg.com/moment@2.21.0/' . $moment_script, array() ); - $tinymce_version = '4.7.2'; + $tinymce_version = '4.7.12'; gutenberg_register_vendor_script( 'tinymce-latest', 'https://unpkg.com/tinymce@' . $tinymce_version . '/tinymce' . $suffix . '.js' diff --git a/test/e2e/specs/__snapshots__/writing-flow.test.js.snap b/test/e2e/specs/__snapshots__/writing-flow.test.js.snap new file mode 100644 index 00000000000000..4a64a76f30700f --- /dev/null +++ b/test/e2e/specs/__snapshots__/writing-flow.test.js.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`adding blocks Should navigate with arrow keys 1`] = `"Hello World!"`; + +exports[`adding blocks Should navigate with arrow keys 2`] = `"Hello"`; + +exports[`adding blocks Should navigate with arrow keys 3`] = `"Hello to the World!"`; + +exports[`adding blocks Should navigate with arrow keys 4`] = `"Greeting: Hello to the World!"`; + +exports[`adding blocks Should navigate with arrow keys 5`] = `"The Greeting:
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();