diff --git a/packages/block-library/src/button/test/__snapshots__/index.js.snap b/packages/block-library/src/button/test/__snapshots__/index.js.snap
index e153279891096..fec3d15878b50 100644
--- a/packages/block-library/src/button/test/__snapshots__/index.js.snap
+++ b/packages/block-library/src/button/test/__snapshots__/index.js.snap
@@ -18,17 +18,17 @@ exports[`core/button block edit matches snapshot 1`] = `
aria-autocomplete="list"
aria-label="Add text…"
aria-multiline="true"
- class="wp-block-button__link editor-rich-text__tinymce"
+ class="wp-block-button__link editor-rich-text__editable"
contenteditable="true"
data-is-placeholder-visible="true"
role="textbox"
>
Start writing or type / to choose a block
diff --git a/packages/block-library/src/preformatted/test/__snapshots__/index.js.snap b/packages/block-library/src/preformatted/test/__snapshots__/index.js.snap index d89042667a97c..a18b12cb5728f 100644 --- a/packages/block-library/src/preformatted/test/__snapshots__/index.js.snap +++ b/packages/block-library/src/preformatted/test/__snapshots__/index.js.snap @@ -13,17 +13,17 @@ exports[`core/preformatted block edit matches snapshot 1`] = ` aria-autocomplete="list" aria-label="Write preformatted text…" aria-multiline="true" - class="editor-rich-text__tinymce" + class="editor-rich-text__editable" contenteditable="true" data-is-placeholder-visible="true" role="textbox" >Write preformatted text…diff --git a/packages/block-library/src/pullquote/test/__snapshots__/index.js.snap b/packages/block-library/src/pullquote/test/__snapshots__/index.js.snap index aed466a208142..824b6c491a8ad 100644 --- a/packages/block-library/src/pullquote/test/__snapshots__/index.js.snap +++ b/packages/block-library/src/pullquote/test/__snapshots__/index.js.snap @@ -17,19 +17,19 @@ exports[`core/pullquote block edit matches snapshot 1`] = ` aria-autocomplete="list" aria-label="Write quote…" aria-multiline="true" - class="editor-rich-text__tinymce" + class="editor-rich-text__editable" contenteditable="true" data-is-placeholder-visible="true" role="textbox" >
Write quote… diff --git a/packages/block-library/src/quote/test/__snapshots__/index.js.snap b/packages/block-library/src/quote/test/__snapshots__/index.js.snap index 1365b8581bcff..886ef7e4560a8 100644 --- a/packages/block-library/src/quote/test/__snapshots__/index.js.snap +++ b/packages/block-library/src/quote/test/__snapshots__/index.js.snap @@ -16,19 +16,19 @@ exports[`core/quote block edit matches snapshot 1`] = ` aria-autocomplete="list" aria-label="Write quote…" aria-multiline="true" - class="editor-rich-text__tinymce" + class="editor-rich-text__editable" contenteditable="true" data-is-placeholder-visible="true" role="textbox" >
Write quote…
diff --git a/packages/block-library/src/text-columns/test/__snapshots__/index.js.snap b/packages/block-library/src/text-columns/test/__snapshots__/index.js.snap
index aabf7c36e121f..4f89f08374630 100644
--- a/packages/block-library/src/text-columns/test/__snapshots__/index.js.snap
+++ b/packages/block-library/src/text-columns/test/__snapshots__/index.js.snap
@@ -19,17 +19,17 @@ exports[`core/text-columns block edit matches snapshot 1`] = `
aria-autocomplete="list"
aria-label="New Column"
aria-multiline="true"
- class="editor-rich-text__tinymce"
+ class="editor-rich-text__editable"
contenteditable="true"
data-is-placeholder-visible="true"
role="textbox"
>
New Column
@@ -53,17 +53,17 @@ exports[`core/text-columns block edit matches snapshot 1`] = ` aria-autocomplete="list" aria-label="New Column" aria-multiline="true" - class="editor-rich-text__tinymce" + class="editor-rich-text__editable" contenteditable="true" data-is-placeholder-visible="true" role="textbox" >New Column
diff --git a/packages/block-library/src/verse/test/__snapshots__/index.js.snap b/packages/block-library/src/verse/test/__snapshots__/index.js.snap index 8595e740857ac..617e1296fbcad 100644 --- a/packages/block-library/src/verse/test/__snapshots__/index.js.snap +++ b/packages/block-library/src/verse/test/__snapshots__/index.js.snap @@ -13,17 +13,17 @@ exports[`core/verse block edit matches snapshot 1`] = ` aria-autocomplete="list" aria-label="Write…" aria-multiline="true" - class="editor-rich-text__tinymce" + class="editor-rich-text__editable" contenteditable="true" data-is-placeholder-visible="true" role="textbox" >Write…diff --git a/packages/dom/src/dom.js b/packages/dom/src/dom.js index 7bfe0d101ddb9..a51a29cc8b973 100644 --- a/packages/dom/src/dom.js +++ b/packages/dom/src/dom.js @@ -130,14 +130,21 @@ export function isHorizontalEdge( container, isReverse ) { // If confirmed to be at extent, traverse up through DOM, verifying that // the node is at first or last child for reverse or forward respectively. // Continue until container is reached. - const order = isReverse ? 'first' : 'last'; + const order = isReverse ? 'previous' : 'next'; + while ( node !== container ) { - const parentNode = node.parentNode; - if ( parentNode[ `${ order }Child` ] !== node ) { + let next = node[ `${ order }Sibling` ]; + + // Skip over empty text nodes. + while ( next && next.nodeType === TEXT_NODE && next.data === '' ) { + next = next[ `${ order }Sibling` ]; + } + + if ( next ) { return false; } - node = parentNode; + node = node.parentNode; } // If reached, range is assumed to be at edge. diff --git a/packages/e2e-tests/specs/__snapshots__/undo.test.js.snap b/packages/e2e-tests/specs/__snapshots__/undo.test.js.snap index 49a8c19fe89fa..799e5c2c0879c 100644 --- a/packages/e2e-tests/specs/__snapshots__/undo.test.js.snap +++ b/packages/e2e-tests/specs/__snapshots__/undo.test.js.snap @@ -28,7 +28,7 @@ exports[`undo should undo typing after a pause 2`] = ` exports[`undo should undo typing after non input change 1`] = ` " -
before keyboard after keyboard
+before keyboard after keyboard
" `; diff --git a/packages/e2e-tests/specs/links.test.js b/packages/e2e-tests/specs/links.test.js index 85ba99badcf24..7b0302b9400a1 100644 --- a/packages/e2e-tests/specs/links.test.js +++ b/packages/e2e-tests/specs/links.test.js @@ -6,7 +6,6 @@ import { getEditedPostContent, createNewPost, pressKeyWithModifier, - pressKeyTimes, insertBlock, } from '@wordpress/e2e-test-utils'; @@ -244,7 +243,8 @@ describe( 'Links', () => { it( 'can be edited with collapsed selection', async () => { await createAndReselectLink(); // Make a collapsed selection inside the link - await pressKeyTimes( 'ArrowRight', 3 ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowRight' ); await moveMouse(); await page.click( 'button[aria-label="Edit"]' ); await waitForAutoFocus(); diff --git a/packages/editor/src/components/rich-text/editable.js b/packages/editor/src/components/rich-text/editable.js new file mode 100644 index 0000000000000..263dae14ef25b --- /dev/null +++ b/packages/editor/src/components/rich-text/editable.js @@ -0,0 +1,199 @@ +/** + * External dependencies + */ +import { isEqual } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Component, createElement } from '@wordpress/element'; +import { BACKSPACE, DELETE } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { diffAriaProps, pickAriaProps } from './aria'; + +/** + * Browser dependencies + */ + +const { userAgent } = window.navigator; + +/** + * Applies a fix that provides `input` events for contenteditable in Internet Explorer. + * + * @param {Element} editorNode The root editor node. + * + * @return {Function} A function to remove the fix (for cleanup). + */ +function applyInternetExplorerInputFix( editorNode ) { + /** + * Dispatches `input` events in response to `textinput` events. + * + * IE provides a `textinput` event that is similar to an `input` event, + * and we use it to manually dispatch an `input` event. + * `textinput` is dispatched for text entry but for not deletions. + * + * @param {Event} textInputEvent An Internet Explorer `textinput` event. + */ + function mapTextInputEvent( textInputEvent ) { + textInputEvent.stopImmediatePropagation(); + + const inputEvent = document.createEvent( 'Event' ); + inputEvent.initEvent( 'input', true, false ); + inputEvent.data = textInputEvent.data; + textInputEvent.target.dispatchEvent( inputEvent ); + } + + /** + * Dispatches `input` events in response to Delete and Backspace keyup. + * + * It would be better dispatch an `input` event after each deleting + * `keydown` because the DOM is updated after each, but it is challenging + * to determine the right time to dispatch `input` since propagation of + * `keydown` can be stopped at any point. + * + * It's easier to listen for `keyup` in the capture phase and dispatch + * `input` before `keyup` propagates further. It's not perfect, but should + * be good enough. + * + * @param {KeyboardEvent} keyUp + * @param {Node} keyUp.target The event target. + * @param {number} keyUp.keyCode The key code. + */ + function mapDeletionKeyUpEvents( { target, keyCode } ) { + const isDeletion = BACKSPACE === keyCode || DELETE === keyCode; + + if ( isDeletion && editorNode.contains( target ) ) { + const inputEvent = document.createEvent( 'Event' ); + inputEvent.initEvent( 'input', true, false ); + inputEvent.data = null; + target.dispatchEvent( inputEvent ); + } + } + + editorNode.addEventListener( 'textinput', mapTextInputEvent ); + document.addEventListener( 'keyup', mapDeletionKeyUpEvents, true ); + return function removeInternetExplorerInputFix() { + editorNode.removeEventListener( 'textinput', mapTextInputEvent ); + document.removeEventListener( 'keyup', mapDeletionKeyUpEvents, true ); + }; +} + +const IS_PLACEHOLDER_VISIBLE_ATTR_NAME = 'data-is-placeholder-visible'; +const CLASS_NAME = 'editor-rich-text__editable'; + +/** + * Whether or not the user agent is Internet Explorer. + * + * @type {boolean} + */ +const IS_IE = userAgent.indexOf( 'Trident' ) >= 0; + +export default class Editable extends Component { + constructor() { + super(); + this.bindEditorNode = this.bindEditorNode.bind( this ); + this.onFocus = this.onFocus.bind( this ); + } + + onFocus() { + if ( this.props.onFocus ) { + this.props.onFocus(); + } + } + + // We must prevent rerenders because the browser will modify the DOM. React + // will rerender the DOM fine, but we're losing selection and it would be + // more expensive to do so as it would just set the inner HTML through + // `dangerouslySetInnerHTML`. Instead RichText does it's own diffing and + // selection setting. + // + // Because we never update the component, we have to look through props and + // update the attributes on the wrapper nodes here. `componentDidUpdate` + // will never be called. + shouldComponentUpdate( nextProps ) { + this.configureIsPlaceholderVisible( nextProps.isPlaceholderVisible ); + + if ( ! isEqual( this.props.style, nextProps.style ) ) { + this.editorNode.setAttribute( 'style', '' ); + Object.assign( this.editorNode.style, nextProps.style ); + } + + if ( ! isEqual( this.props.className, nextProps.className ) ) { + this.editorNode.className = classnames( nextProps.className, CLASS_NAME ); + } + + const { removedKeys, updatedKeys } = diffAriaProps( this.props, nextProps ); + removedKeys.forEach( ( key ) => + this.editorNode.removeAttribute( key ) ); + updatedKeys.forEach( ( key ) => + this.editorNode.setAttribute( key, nextProps[ key ] ) ); + + return false; + } + + configureIsPlaceholderVisible( isPlaceholderVisible ) { + const isPlaceholderVisibleString = String( !! isPlaceholderVisible ); + if ( this.editorNode.getAttribute( IS_PLACEHOLDER_VISIBLE_ATTR_NAME ) !== isPlaceholderVisibleString ) { + this.editorNode.setAttribute( IS_PLACEHOLDER_VISIBLE_ATTR_NAME, isPlaceholderVisibleString ); + } + } + + bindEditorNode( editorNode ) { + this.editorNode = editorNode; + + if ( this.props.setRef ) { + this.props.setRef( editorNode ); + } + + if ( IS_IE ) { + if ( editorNode ) { + // Mounting: + this.removeInternetExplorerInputFix = applyInternetExplorerInputFix( editorNode ); + } else { + // Unmounting: + this.removeInternetExplorerInputFix(); + } + } + } + + render() { + const ariaProps = pickAriaProps( this.props ); + const { + tagName = 'div', + style, + record, + valueToEditableHTML, + className, + isPlaceholderVisible, + onPaste, + onInput, + onKeyDown, + onCompositionEnd, + onBlur, + } = this.props; + + ariaProps.role = 'textbox'; + ariaProps[ 'aria-multiline' ] = true; + + return createElement( tagName, { + ...ariaProps, + className: classnames( className, CLASS_NAME ), + contentEditable: true, + [ IS_PLACEHOLDER_VISIBLE_ATTR_NAME ]: isPlaceholderVisible, + ref: this.bindEditorNode, + style, + suppressContentEditableWarning: true, + dangerouslySetInnerHTML: { __html: valueToEditableHTML( record ) }, + onPaste, + onInput, + onFocus: this.onFocus, + onBlur, + onKeyDown, + onCompositionEnd, + } ); + } +} diff --git a/packages/editor/src/components/rich-text/index.js b/packages/editor/src/components/rich-text/index.js index 21ae5a91b5cac..e85e0c72b55bb 100644 --- a/packages/editor/src/components/rich-text/index.js +++ b/packages/editor/src/components/rich-text/index.js @@ -55,7 +55,7 @@ import Autocomplete from '../autocomplete'; import BlockFormatControls from '../block-format-controls'; import FormatEdit from './format-edit'; import FormatToolbar from './format-toolbar'; -import TinyMCE, { TINYMCE_ZWSP } from './tinymce'; +import Editable from './editable'; import { pickAriaProps } from './aria'; import { getPatterns } from './patterns'; import { withBlockEditContext } from '../block-edit/context'; @@ -149,9 +149,9 @@ export class RichText extends Component { */ getRecord() { const { formats, text } = this.formatToValue( this.props.value ); - const { start, end } = this.state; + const { start, end, selectedFormat } = this.state; - return { formats, text, start, end }; + return { formats, text, start, end, selectedFormat }; } createRecord() { @@ -162,26 +162,18 @@ export class RichText extends Component { range, multilineTag: this.multilineTag, multilineWrapperTags: this.multilineWrapperTags, - removeNode: ( node ) => node.getAttribute( 'data-mce-bogus' ) === 'all', - unwrapNode: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), - removeAttribute: ( attribute ) => attribute.indexOf( 'data-mce-' ) === 0, - filterString: ( string ) => string.replace( TINYMCE_ZWSP, '' ), prepareEditableTree: this.props.prepareEditableTree, } ); } - applyRecord( record ) { + applyRecord( record, domOnly ) { apply( { value: record, current: this.editableRef, multilineTag: this.multilineTag, multilineWrapperTags: this.multilineWrapperTags, - createLinePadding( doc ) { - const element = doc.createElement( 'br' ); - element.setAttribute( 'data-mce-bogus', '1' ); - return element; - }, prepareEditableTree: this.props.prepareEditableTree, + domOnly, } ); } @@ -377,7 +369,8 @@ export class RichText extends Component { * Handles the `selectionchange` event: sync the selection to local state. */ onSelectionChange() { - const { start, end, formats } = this.createRecord(); + const value = this.createRecord(); + const { start, end, formats, selectedFormat } = value; if ( start !== this.state.start || end !== this.state.end ) { const isCaretWithinFormattedText = this.props.isCaretWithinFormattedText; @@ -387,7 +380,29 @@ export class RichText extends Component { this.props.onExitFormattedText(); } - this.setState( { start, end } ); + this.setState( { start, end, selectedFormat } ); + + const selection = getSelection(); + const range = selection.getRangeAt( 0 ); + + // Prevent the browser selection from being overwritten if at a zero + // width space. + if ( + range.collapsed && + range.startContainer.nodeType === window.Node.TEXT_NODE && + range.startOffset === 1 && + range.startContainer.data[ 0 ] === '\ufeff' + ) { + this.applyRecord( value, true ); + } else { + this.applyRecord( value ); + } + } else if ( + this.state.selectedFormat !== undefined && + selectedFormat !== this.state.selectedFormat + ) { + this.setState( { start, end, selectedFormat } ); + this.applyRecord( value, true ); } } @@ -415,13 +430,13 @@ export class RichText extends Component { onChange( record, { withoutHistory } = {} ) { this.applyRecord( record ); - const { start, end } = record; + const { start, end, selectedFormat } = record; this.onChangeEditableValue( record ); this.savedContent = this.valueToFormat( record ); this.props.onChange( this.savedContent ); - this.setState( { start, end } ); + this.setState( { start, end, selectedFormat } ); if ( ! withoutHistory ) { this.onCreateUndoLevel(); @@ -739,11 +754,6 @@ export class RichText extends Component { value, multilineTag: this.multilineTag, multilineWrapperTags: this.multilineWrapperTags, - createLinePadding( doc ) { - const element = doc.createElement( 'br' ); - element.setAttribute( 'data-mce-bogus', '1' ); - return element; - }, prepareEditableTree: this.props.prepareEditableTree, } ).body.innerHTML; } @@ -783,6 +793,7 @@ export class RichText extends Component { value, multilineTag: this.multilineTag, multilineWrapperTags: this.multilineWrapperTags, + isEditableTree: false, } ).body.childNodes ); } @@ -812,13 +823,12 @@ export class RichText extends Component { onTagNameChange, } = this.props; + // Generating a key that includes `tagName` ensures that if the tag + // changes, we replace the relevant element. This is needed because we + // prevent Editable component updates. + const key = Tagname; const MultilineTag = this.multilineTag; const ariaProps = pickAriaProps( this.props ); - - // Generating a key that includes `tagName` ensures that if the tag - // changes, we unmount and destroy the previous TinyMCE element, then - // mount and initialize a new child element in its place. - const key = [ 'editor', Tagname ].join(); const isPlaceholderVisible = placeholder && ( ! isSelected || keepPlaceholderOnFocus ) && this.isEmpty(); const classes = classnames( wrapperClassName, 'editor-rich-text' ); const record = this.getRecord(); @@ -853,7 +863,7 @@ export class RichText extends Component { > { ( { listBoxId, activeId } ) => ( inline boundaries need special treatment because their
- // un-selected style is already padded.
- code[data-mce-selected] {
- background: $light-gray-400;
- box-shadow: 0 0 0 1px $light-gray-400;
- }
+ // Enforce a dark text color so active inline boundaries
+ // are always readable.
+ // See https://github.com/WordPress/gutenberg/issues/9508
+ color: $dark-gray-900;
}
- img {
- &[data-mce-selected] {
- outline: none;
- }
+ // Link inline boundaries get special colors.
+ a[data-rich-text-format-boundary] {
+ box-shadow: 0 0 0 1px $blue-medium-100;
+ background: $blue-medium-100;
+ color: $blue-medium-900;
+ }
- &::selection {
- background: none !important;
- }
+ // inline boundaries need special treatment because their
+ // un-selected style is already padded.
+ code[data-rich-text-format-boundary] {
+ background: $light-gray-400;
+ box-shadow: 0 0 0 1px $light-gray-400;
}
&[data-is-placeholder-visible="true"] {
@@ -113,7 +88,7 @@
}
// Placeholder text.
- & + .editor-rich-text__tinymce {
+ & + .editor-rich-text__editable {
pointer-events: none;
// Use opacity to work in various editor styles.
@@ -126,7 +101,7 @@
// Captions may have lighter (gray) text, or be shown on a range of different background luminosites.
// To ensure legibility, we increase the default placeholder opacity to ensure contrast.
- &[data-is-placeholder-visible="true"] + figcaption.editor-rich-text__tinymce {
+ &[data-is-placeholder-visible="true"] + figcaption.editor-rich-text__editable {
opacity: 0.8;
}
}
diff --git a/packages/editor/src/components/rich-text/tinymce.js b/packages/editor/src/components/rich-text/tinymce.js
deleted file mode 100644
index 529fa15c1a54c..0000000000000
--- a/packages/editor/src/components/rich-text/tinymce.js
+++ /dev/null
@@ -1,381 +0,0 @@
-/**
- * External dependencies
- */
-import tinymce from 'tinymce';
-import { isEqual, noop } from 'lodash';
-import classnames from 'classnames';
-
-/**
- * WordPress dependencies
- */
-import { Component, createElement } from '@wordpress/element';
-import { BACKSPACE, DELETE, ENTER, LEFT, RIGHT } from '@wordpress/keycodes';
-import { isEntirelySelected } from '@wordpress/dom';
-
-/**
- * Internal dependencies
- */
-import { diffAriaProps, pickAriaProps } from './aria';
-
-/**
- * Browser dependencies
- */
-
-const { getSelection } = window;
-const { TEXT_NODE } = window.Node;
-const { userAgent } = window.navigator;
-
-/**
- * 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}
- */
-export const TINYMCE_ZWSP = '\uFEFF';
-
-/**
- * Applies a fix that provides `input` events for contenteditable in Internet Explorer.
- *
- * @param {Element} editorNode The root editor node.
- *
- * @return {Function} A function to remove the fix (for cleanup).
- */
-function applyInternetExplorerInputFix( editorNode ) {
- /**
- * Dispatches `input` events in response to `textinput` events.
- *
- * IE provides a `textinput` event that is similar to an `input` event,
- * and we use it to manually dispatch an `input` event.
- * `textinput` is dispatched for text entry but for not deletions.
- *
- * @param {Event} textInputEvent An Internet Explorer `textinput` event.
- */
- function mapTextInputEvent( textInputEvent ) {
- textInputEvent.stopImmediatePropagation();
-
- const inputEvent = document.createEvent( 'Event' );
- inputEvent.initEvent( 'input', true, false );
- inputEvent.data = textInputEvent.data;
- textInputEvent.target.dispatchEvent( inputEvent );
- }
-
- /**
- * Dispatches `input` events in response to Delete and Backspace keyup.
- *
- * It would be better dispatch an `input` event after each deleting
- * `keydown` because the DOM is updated after each, but it is challenging
- * to determine the right time to dispatch `input` since propagation of
- * `keydown` can be stopped at any point.
- *
- * It's easier to listen for `keyup` in the capture phase and dispatch
- * `input` before `keyup` propagates further. It's not perfect, but should
- * be good enough.
- *
- * @param {KeyboardEvent} keyUp
- * @param {Node} keyUp.target The event target.
- * @param {number} keyUp.keyCode The key code.
- */
- function mapDeletionKeyUpEvents( { target, keyCode } ) {
- const isDeletion = BACKSPACE === keyCode || DELETE === keyCode;
-
- if ( isDeletion && editorNode.contains( target ) ) {
- const inputEvent = document.createEvent( 'Event' );
- inputEvent.initEvent( 'input', true, false );
- inputEvent.data = null;
- target.dispatchEvent( inputEvent );
- }
- }
-
- editorNode.addEventListener( 'textinput', mapTextInputEvent );
- document.addEventListener( 'keyup', mapDeletionKeyUpEvents, true );
- return function removeInternetExplorerInputFix() {
- editorNode.removeEventListener( 'textinput', mapTextInputEvent );
- document.removeEventListener( 'keyup', mapDeletionKeyUpEvents, true );
- };
-}
-
-const IS_PLACEHOLDER_VISIBLE_ATTR_NAME = 'data-is-placeholder-visible';
-
-/**
- * Whether or not the user agent is Internet Explorer.
- *
- * @type {boolean}
- */
-const IS_IE = userAgent.indexOf( 'Trident' ) >= 0;
-
-export default class TinyMCE extends Component {
- constructor() {
- super();
- this.bindEditorNode = this.bindEditorNode.bind( this );
- this.onFocus = this.onFocus.bind( this );
- this.onKeyDown = this.onKeyDown.bind( this );
- this.initialize = this.initialize.bind( this );
- }
-
- onFocus() {
- if ( this.props.onFocus ) {
- this.props.onFocus();
- }
-
- this.initialize();
- }
-
- // We must prevent rerenders because RichText, the browser, and TinyMCE will
- // modify the DOM. React will rerender the DOM fine, but we're losing
- // selection and it would be more expensive to do so as it would just set
- // the inner HTML through `dangerouslySetInnerHTML`. Instead RichText does
- // it's own diffing and selection setting.
- //
- // Because we never update the component, we have to look through props and
- // update the attributes on the wrapper nodes here. `componentDidUpdate`
- // will never be called.
- shouldComponentUpdate( nextProps ) {
- this.configureIsPlaceholderVisible( nextProps.isPlaceholderVisible );
-
- if ( ! isEqual( this.props.style, nextProps.style ) ) {
- this.editorNode.setAttribute( 'style', '' );
- Object.assign( this.editorNode.style, nextProps.style );
- }
-
- if ( ! isEqual( this.props.className, nextProps.className ) ) {
- this.editorNode.className = classnames( nextProps.className, 'editor-rich-text__tinymce' );
- }
-
- const { removedKeys, updatedKeys } = diffAriaProps( this.props, nextProps );
- removedKeys.forEach( ( key ) =>
- this.editorNode.removeAttribute( key ) );
- updatedKeys.forEach( ( key ) =>
- this.editorNode.setAttribute( key, nextProps[ key ] ) );
-
- return false;
- }
-
- componentWillUnmount() {
- if ( ! this.editor ) {
- return;
- }
-
- this.editor.destroy();
- delete this.editor;
- }
-
- configureIsPlaceholderVisible( isPlaceholderVisible ) {
- const isPlaceholderVisibleString = String( !! isPlaceholderVisible );
- if ( this.editorNode.getAttribute( IS_PLACEHOLDER_VISIBLE_ATTR_NAME ) !== isPlaceholderVisibleString ) {
- this.editorNode.setAttribute( IS_PLACEHOLDER_VISIBLE_ATTR_NAME, isPlaceholderVisibleString );
- }
- }
-
- /**
- * Initializes TinyMCE. Can only be called once per instance.
- */
- initialize() {
- if ( this.initialize.called ) {
- return;
- }
-
- this.initialize.called = true;
-
- const { multilineTag } = this.props;
- const settings = {
- theme: false,
- inline: true,
- toolbar: false,
- browser_spellcheck: true,
- entity_encoding: 'raw',
- convert_urls: false,
- // Disables TinyMCE's parsing to verify HTML. It makes
- // initialisation a bit faster. Since we're setting raw HTML
- // already with dangerouslySetInnerHTML, we don't need this to be
- // verified.
- verify_html: false,
- inline_boundaries_selector: 'a[href],code,b,i,strong,em,del,ins,sup,sub',
- plugins: [],
- forced_root_block: multilineTag || false,
- // Allow TinyMCE to keep one undo level for comparing changes.
- // Prevent it otherwise from accumulating any history.
- custom_undo_redo_levels: 1,
- lists_indent_on_tab: false,
- };
-
- tinymce.init( {
- ...settings,
- target: this.editorNode,
- setup: ( editor ) => {
- this.editor = editor;
-
- // TinyMCE resets the element content on initialization, even
- // when it's already identical to what exists currently. This
- // behavior clobbers a selection which exists at the time of
- // initialization, thus breaking writing flow navigation. The
- // hack here neutralizes setHTML during initialization.
- let setHTML;
-
- editor.on( 'preinit', () => {
- setHTML = editor.dom.setHTML;
- editor.dom.setHTML = () => {};
- } );
-
- editor.on( 'init', () => {
- // History is handled internally by RichText.
- //
- // See: https://github.com/tinymce/tinymce/blob/master/src/core/main/ts/api/UndoManager.ts
- [ 'z', 'y' ].forEach( ( character ) => {
- editor.shortcuts.remove( `meta+${ character }` );
- } );
- editor.shortcuts.remove( 'meta+shift+z' );
-
- // Reset TinyMCE's default formatting shortcuts, since
- // RichText supports only registered formats.
- //
- // See: https://github.com/tinymce/tinymce/blob/master/src/core/main/ts/keyboard/FormatShortcuts.ts
- [ 'b', 'i', 'u' ].forEach( ( character ) => {
- editor.shortcuts.remove( `meta+${ character }` );
- } );
- [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ].forEach( ( number ) => {
- editor.shortcuts.remove( `access+${ number }` );
- } );
-
- // Restore the original `setHTML` once initialized.
- editor.dom.setHTML = setHTML;
-
- // In IE11, focus is lost to parent after initialising
- // TinyMCE, so we have to set it back.
- if (
- IS_IE &&
- document.activeElement !== this.editorNode &&
- document.activeElement.contains( this.editorNode )
- ) {
- this.editorNode.focus();
- }
- } );
-
- editor.on( 'keydown', this.onKeyDown, true );
- },
- } );
- }
-
- bindEditorNode( editorNode ) {
- this.editorNode = editorNode;
-
- if ( this.props.setRef ) {
- this.props.setRef( editorNode );
- }
-
- if ( IS_IE ) {
- if ( editorNode ) {
- // Mounting:
- this.removeInternetExplorerInputFix = applyInternetExplorerInputFix( editorNode );
- } else {
- // Unmounting:
- this.removeInternetExplorerInputFix();
- }
- }
- }
-
- onKeyDown( event ) {
- const { keyCode } = event;
- const isDelete = keyCode === DELETE || keyCode === BACKSPACE;
-
- // Disables TinyMCE behaviour.
- if (
- keyCode === ENTER ||
- ( isDelete && isEntirelySelected( this.editorNode ) )
- ) {
- event.preventDefault();
- // For some reason this is needed to also prevent the insertion of
- // line breaks.
- return false;
- }
-
- // Handles a horizontal navigation key down event to handle the case
- // where TinyMCE attempts to preventDefault when on the outside edge of
- // an inline boundary when arrowing _away_ from the boundary, not within
- // it. Replaces the TinyMCE event `preventDefault` behavior with a noop,
- // such that those relying on `defaultPrevented` are not misinformed
- // about the arrow event.
- //
- // If TinyMCE#4476 is resolved, this handling may be removed.
- //
- // @see https://github.com/tinymce/tinymce/issues/4476
- if ( keyCode !== LEFT && keyCode !== RIGHT ) {
- return;
- }
-
- const { focusNode } = getSelection();
- const { nodeType, nodeValue } = focusNode;
-
- if ( nodeType !== TEXT_NODE ) {
- return;
- }
-
- if ( nodeValue.length !== 1 || nodeValue[ 0 ] !== TINYMCE_ZWSP ) {
- return;
- }
-
- // Consider to be moving away from inline boundary based on:
- //
- // 1. Within a text fragment consisting only of ZWSP.
- // 2. If in reverse, there is no previous sibling. If forward, there is
- // no next sibling (i.e. end of node).
- const isReverse = event.keyCode === LEFT;
- const edgeSibling = isReverse ? 'previousSibling' : 'nextSibling';
- if ( ! focusNode[ edgeSibling ] ) {
- // Note: This is not reassigning on the native event, rather the
- // "fixed" TinyMCE copy, which proxies its preventDefault to the
- // native event. By reassigning here, we're effectively preventing
- // the proxied call on the native event, but not otherwise mutating
- // the original event object.
- event.preventDefault = noop;
- }
- }
-
- render() {
- const ariaProps = pickAriaProps( this.props );
- const {
- tagName = 'div',
- style,
- record,
- valueToEditableHTML,
- className,
- isPlaceholderVisible,
- onPaste,
- onInput,
- onKeyDown,
- onCompositionEnd,
- onBlur,
- } = this.props;
-
- /*
- * The role=textbox and aria-multiline=true must always be used together
- * as TinyMCE always behaves like a sort of textarea where text wraps in
- * multiple lines. Only the table block editable element is excluded.
- */
- if ( tagName !== 'table' ) {
- ariaProps.role = 'textbox';
- ariaProps[ 'aria-multiline' ] = true;
- }
-
- // If a default value is provided, render it into the DOM even before
- // TinyMCE finishes initializing. This avoids a short delay by allowing
- // us to show and focus the content before it's truly ready to edit.
- return createElement( tagName, {
- ...ariaProps,
- className: classnames( className, 'editor-rich-text__tinymce' ),
- contentEditable: true,
- [ IS_PLACEHOLDER_VISIBLE_ATTR_NAME ]: isPlaceholderVisible,
- ref: this.bindEditorNode,
- style,
- suppressContentEditableWarning: true,
- dangerouslySetInnerHTML: { __html: valueToEditableHTML( record ) },
- onPaste,
- onInput,
- onFocus: this.onFocus,
- onBlur,
- onKeyDown,
- onCompositionEnd,
- } );
- }
-}
diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js
index d320e5fcfcdf2..b81684a973217 100644
--- a/packages/rich-text/src/create.js
+++ b/packages/rich-text/src/create.js
@@ -13,6 +13,7 @@ import { createElement } from './create-element';
import {
LINE_SEPARATOR,
OBJECT_REPLACEMENT_CHARACTER,
+ ZERO_WIDTH_NO_BREAK_SPACE,
} from './special-characters';
/**
@@ -98,14 +99,6 @@ function toFormat( { type, attributes } ) {
* multiline.
* @param {?Array} $1.multilineWrapperTags Tags where lines can be found if
* nesting is possible.
- * @param {?Function} $1.removeNode Function to declare whether the
- * given node should be removed.
- * @param {?Function} $1.unwrapNode Function to declare whether the
- * given node should be unwrapped.
- * @param {?Function} $1.filterString Function to filter the given
- * string.
- * @param {?Function} $1.removeAttribute Wether to remove an attribute
- * based on the name.
*
* @return {Object} A rich text value.
*/
@@ -116,10 +109,6 @@ export function create( {
range,
multilineTag,
multilineWrapperTags,
- removeNode,
- unwrapNode,
- filterString,
- removeAttribute,
} = {} ) {
if ( typeof text === 'string' && text.length > 0 ) {
return {
@@ -140,10 +129,6 @@ export function create( {
return createFromElement( {
element,
range,
- removeNode,
- unwrapNode,
- filterString,
- removeAttribute,
} );
}
@@ -152,10 +137,6 @@ export function create( {
range,
multilineTag,
multilineWrapperTags,
- removeNode,
- unwrapNode,
- filterString,
- removeAttribute,
} );
}
@@ -163,12 +144,13 @@ export function create( {
* Helper to accumulate the value's selection start and end from the current
* node and range.
*
- * @param {Object} accumulator Object to accumulate into.
- * @param {Node} node Node to create value with.
- * @param {Range} range Range to create value with.
- * @param {Object} value Value that is being accumulated.
+ * @param {Object} accumulator Object to accumulate into.
+ * @param {Node} node Node to create value with.
+ * @param {Range} range Range to create value with.
+ * @param {Object} value Value that is being accumulated.
+ * @param {boolean} depth Depth of the format.
*/
-function accumulateSelection( accumulator, node, range, value ) {
+function accumulateSelection( accumulator, node, range, value, depth ) {
if ( ! range ) {
return;
}
@@ -180,9 +162,24 @@ function accumulateSelection( accumulator, node, range, value ) {
// Selection can be extracted from value.
if ( value.start !== undefined ) {
accumulator.start = currentLength + value.start;
+
+ if ( value.selectedFormat !== undefined ) {
+ accumulator.selectedFormat = value.selectedFormat;
+ }
// Range indicates that the current node has selection.
} else if ( node === startContainer && node.nodeType === TEXT_NODE ) {
accumulator.start = currentLength + startOffset;
+
+ if (
+ startOffset === 0 &&
+ node.data[ startOffset ] === ZERO_WIDTH_NO_BREAK_SPACE
+ ) {
+ accumulator.selectedFormat = depth;
+ }
+
+ if ( filterString( node.data ).length === startOffset ) {
+ accumulator.selectedFormat = depth;
+ }
// Range indicates that the current node is selected.
} else if (
parentNode === startContainer &&
@@ -252,6 +249,15 @@ function filterRange( node, range, filter ) {
return { startContainer, startOffset, endContainer, endOffset };
}
+function filterString( string ) {
+ // Reduce any whitespace used for HTML formatting to one space
+ // character, because it will also be displayed as such by the browser.
+ string = string.replace( /[\n\r\t]+/g, ' ' );
+ string = string.replace( new RegExp( ZERO_WIDTH_NO_BREAK_SPACE, 'g' ), '' );
+
+ return string;
+}
+
/**
* Creates a Rich Text value from a DOM element and range.
*
@@ -262,14 +268,6 @@ function filterRange( node, range, filter ) {
* multiline.
* @param {?Array} $1.multilineWrapperTags Tags where lines can be found if
* nesting is possible.
- * @param {?Function} $1.removeNode Function to declare whether the
- * given node should be removed.
- * @param {?Function} $1.unwrapNode Function to declare whether the
- * given node should be unwrapped.
- * @param {?Function} $1.filterString Function to filter the given
- * string.
- * @param {?Function} $1.removeAttribute Wether to remove an attribute
- * based on the name.
*
* @return {Object} A rich text value.
*/
@@ -279,10 +277,7 @@ function createFromElement( {
multilineTag,
multilineWrapperTags,
currentWrapperTags = [],
- removeNode,
- unwrapNode,
- filterString,
- removeAttribute,
+ depth = 0,
} ) {
const accumulator = createEmptyValue();
@@ -297,27 +292,15 @@ function createFromElement( {
const length = element.childNodes.length;
- const filterStringComplete = ( string ) => {
- // Reduce any whitespace used for HTML formatting to one space
- // character, because it will also be displayed as such by the browser.
- string = string.replace( /[\n\r\t]+/g, ' ' );
-
- if ( filterString ) {
- string = filterString( string );
- }
-
- return string;
- };
-
// Optimise for speed.
for ( let index = 0; index < length; index++ ) {
const node = element.childNodes[ index ];
const type = node.nodeName.toLowerCase();
if ( node.nodeType === TEXT_NODE ) {
- const text = filterStringComplete( node.nodeValue );
- range = filterRange( node, range, filterStringComplete );
- accumulateSelection( accumulator, node, range, { text } );
+ const text = filterString( node.nodeValue );
+ range = filterRange( node, range, filterString );
+ accumulateSelection( accumulator, node, range, { text }, depth );
accumulator.text += text;
// Create a sparse array of the same length as `text`, in which
// formats can be added.
@@ -329,16 +312,13 @@ function createFromElement( {
continue;
}
- if (
- ( removeNode && removeNode( node ) ) ||
- ( unwrapNode && unwrapNode( node ) && ! node.hasChildNodes() )
- ) {
- accumulateSelection( accumulator, node, range, createEmptyValue() );
+ if ( node.getAttribute( 'data-rich-text-padding' ) ) {
+ accumulateSelection( accumulator, node, range, createEmptyValue(), depth );
continue;
}
if ( type === 'br' ) {
- accumulateSelection( accumulator, node, range, createEmptyValue() );
+ accumulateSelection( accumulator, node, range, createEmptyValue(), depth );
accumulator.text += '\n';
accumulator.formats.length += 1;
continue;
@@ -346,38 +326,30 @@ function createFromElement( {
const lastFormats = accumulator.formats[ accumulator.formats.length - 1 ];
const lastFormat = lastFormats && lastFormats[ lastFormats.length - 1 ];
- let format;
- let value;
+ const newFormat = toFormat( {
+ type,
+ attributes: getAttributes( { element: node } ),
+ } );
- if ( ! unwrapNode || ! unwrapNode( node ) ) {
- const newFormat = toFormat( {
- type,
- attributes: getAttributes( {
- element: node,
- removeAttribute,
- } ),
- } );
+ let format;
- if ( newFormat ) {
- // Reuse the last format if it's equal.
- if ( isFormatEqual( newFormat, lastFormat ) ) {
- format = lastFormat;
- } else {
- format = newFormat;
- }
+ if ( newFormat ) {
+ // Reuse the last format if it's equal.
+ if ( isFormatEqual( newFormat, lastFormat ) ) {
+ format = lastFormat;
+ } else {
+ format = newFormat;
}
}
+ let value;
+
if ( multilineWrapperTags && multilineWrapperTags.indexOf( type ) !== -1 ) {
value = createFromMultilineElement( {
element: node,
range,
multilineTag,
multilineWrapperTags,
- removeNode,
- unwrapNode,
- filterString,
- removeAttribute,
currentWrapperTags: [ ...currentWrapperTags, format ],
} );
format = undefined;
@@ -387,17 +359,14 @@ function createFromElement( {
range,
multilineTag,
multilineWrapperTags,
- removeNode,
- unwrapNode,
- filterString,
- removeAttribute,
+ depth: depth + 1,
} );
}
const text = value.text;
const start = accumulator.text.length;
- accumulateSelection( accumulator, node, range, value );
+ accumulateSelection( accumulator, node, range, value, depth );
// Don't apply the element as formatting if it has no content.
if ( isEmpty( value ) && format && ! format.attributes ) {
@@ -458,14 +427,6 @@ function createFromElement( {
* multiline.
* @param {?Array} $1.multilineWrapperTags Tags where lines can be found if
* nesting is possible.
- * @param {?Function} $1.removeNode Function to declare whether the
- * given node should be removed.
- * @param {?Function} $1.unwrapNode Function to declare whether the
- * given node should be unwrapped.
- * @param {?Function} $1.filterString Function to filter the given
- * string.
- * @param {?Function} $1.removeAttribute Wether to remove an attribute
- * based on the name.
* @param {boolean} $1.currentWrapperTags Whether to prepend a line
* separator.
*
@@ -476,10 +437,6 @@ function createFromMultilineElement( {
range,
multilineTag,
multilineWrapperTags,
- removeNode,
- unwrapNode,
- filterString,
- removeAttribute,
currentWrapperTags = [],
} ) {
const accumulator = createEmptyValue();
@@ -504,10 +461,6 @@ function createFromMultilineElement( {
multilineTag,
multilineWrapperTags,
currentWrapperTags,
- removeNode,
- unwrapNode,
- filterString,
- removeAttribute,
} );
// If a line consists of one single line break (invisible), consider the
@@ -548,16 +501,11 @@ function createFromMultilineElement( {
*
* @param {Object} $1 Named argements.
* @param {Element} $1.element Element to get attributes from.
- * @param {?Function} $1.removeAttribute Wether to remove an attribute based on
- * the name.
*
* @return {?Object} Attribute object or `undefined` if the element has no
* attributes.
*/
-function getAttributes( {
- element,
- removeAttribute,
-} ) {
+function getAttributes( { element } ) {
if ( ! element.hasAttributes() ) {
return;
}
@@ -569,7 +517,7 @@ function getAttributes( {
for ( let i = 0; i < length; i++ ) {
const { name, value } = element.attributes[ i ];
- if ( removeAttribute && removeAttribute( name ) ) {
+ if ( name === 'data-rich-text-format-boundary' ) {
continue;
}
diff --git a/packages/rich-text/src/get-active-format.js b/packages/rich-text/src/get-active-format.js
index 090a9f1d8ccbb..c11ec9bb42d02 100644
--- a/packages/rich-text/src/get-active-format.js
+++ b/packages/rich-text/src/get-active-format.js
@@ -15,10 +15,29 @@ import { find } from 'lodash';
*
* @return {?Object} Active format object of the specified type, or undefined.
*/
-export function getActiveFormat( { formats, start }, formatType ) {
+export function getActiveFormat( { formats, start, selectedFormat }, formatType ) {
if ( start === undefined ) {
return;
}
- return find( formats[ start ], { type: formatType } );
+ const formatsAtStart = formats[ start ] || [];
+ const formatsAtBeforeStart = formats[ start - 1 ] || [];
+
+ let f = formatsAtStart;
+
+ if ( formatsAtBeforeStart.length > formatsAtStart.length ) {
+ f = formatsAtBeforeStart;
+ }
+
+ if ( ! f.length ) {
+ return;
+ }
+
+ f = f.slice( 0, selectedFormat );
+
+ if ( ! f.length ) {
+ return;
+ }
+
+ return find( f, { type: formatType } );
}
diff --git a/packages/rich-text/src/special-characters.js b/packages/rich-text/src/special-characters.js
index 04ffc18ce8862..e806bf720b427 100644
--- a/packages/rich-text/src/special-characters.js
+++ b/packages/rich-text/src/special-characters.js
@@ -1,3 +1,3 @@
export const LINE_SEPARATOR = '\u2028';
export const OBJECT_REPLACEMENT_CHARACTER = '\ufffc';
-export const ZERO_WIDTH_NO_BREAK_SPACE = '\uFEFF';
+export const ZERO_WIDTH_NO_BREAK_SPACE = '\ufeff';
diff --git a/packages/rich-text/src/test/__snapshots__/to-dom.js.snap b/packages/rich-text/src/test/__snapshots__/to-dom.js.snap
index 0153822ba7ca8..3a09944707dda 100644
--- a/packages/rich-text/src/test/__snapshots__/to-dom.js.snap
+++ b/packages/rich-text/src/test/__snapshots__/to-dom.js.snap
@@ -2,33 +2,86 @@
exports[`recordToDom should create a value with formatting 1`] = `
-
- test
+
+ test
+
`;
+exports[`recordToDom should create a value with formatting 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ 5,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 1,
+ ],
+}
+`;
+
exports[`recordToDom should create a value with formatting for split tags 1`] = `
-
- test
+
+ test
+
`;
+exports[`recordToDom should create a value with formatting for split tags 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ 3,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 1,
+ ],
+}
+`;
+
exports[`recordToDom should create a value with formatting with attributes 1`] = `
- test
+ test
+
`;
+exports[`recordToDom should create a value with formatting with attributes 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ 5,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 1,
+ ],
+}
+`;
+
exports[`recordToDom should create a value with image object 1`] = `
`;
+exports[`recordToDom should create a value with image object 2`] = `
+Object {
+ "endPath": Array [
+ 1,
+ 0,
+ ],
+ "startPath": Array [
+ 1,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should create a value with image object and formatting 1`] = `
+
+
`;
+exports[`recordToDom should create a value with image object and formatting 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 2,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 2,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should create a value with image object and text after 1`] = `
+
te
+
st
`;
+exports[`recordToDom should create a value with image object and text after 2`] = `
+Object {
+ "endPath": Array [
+ 2,
+ 2,
+ ],
+ "startPath": Array [
+ 0,
+ 2,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should create a value with image object and text before 1`] = `
te
- st
+ st
+
`;
+exports[`recordToDom should create a value with image object and text before 2`] = `
+Object {
+ "endPath": Array [
+ 1,
+ 2,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should create a value with nested formatting 1`] = `
-
- test
+
+
+ test
+
+
`;
+exports[`recordToDom should create a value with nested formatting 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 1,
+ 0,
+ 5,
+ ],
+ "startPath": Array [
+ 0,
+ 1,
+ 0,
+ 1,
+ ],
+}
+`;
+
exports[`recordToDom should create a value without formatting 1`] = `
test
`;
+exports[`recordToDom should create a value without formatting 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 4,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should create an empty value 1`] = `
-
+
`;
+exports[`recordToDom should create an empty value 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should create an empty value from empty tags 1`] = `
-
+
`;
-exports[`recordToDom should filter format attributes with settings 1`] = `
+exports[`recordToDom should create an empty value from empty tags 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ ],
+}
+`;
+
+exports[`recordToDom should filter format boundary attributes 1`] = `
-
- test
+
+ test
+
`;
-exports[`recordToDom should filter text at end with settings 1`] = `
+exports[`recordToDom should filter format boundary attributes 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ 5,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 1,
+ ],
+}
+`;
+
+exports[`recordToDom should filter zero width space 1`] = `
+
+
+
+
+`;
+
+exports[`recordToDom should filter zero width space 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ ],
+}
+`;
+
+exports[`recordToDom should filter zero width space at end 1`] = `
test
`;
-exports[`recordToDom should filter text in format with settings 1`] = `
+exports[`recordToDom should filter zero width space at end 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 4,
+ ],
+ "startPath": Array [
+ 0,
+ 4,
+ ],
+}
+`;
+
+exports[`recordToDom should filter zero width space in format 1`] = `
-
- test
+
+ test
+
`;
-exports[`recordToDom should filter text outside format with settings 1`] = `
+exports[`recordToDom should filter zero width space in format 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ 5,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 5,
+ ],
+}
+`;
+
+exports[`recordToDom should filter zero width space outside format 1`] = `
- test
+ test
+
`;
-exports[`recordToDom should filter text with settings 1`] = `
-
-
-
-
+exports[`recordToDom should filter zero width space outside format 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ 5,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 5,
+ ],
+}
`;
exports[`recordToDom should handle br 1`] = `
@@ -155,17 +414,48 @@ exports[`recordToDom should handle br 1`] = `
`;
+exports[`recordToDom should handle br 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should handle br with formatting 1`] = `
-
-
+
+
+
`;
+exports[`recordToDom should handle br with formatting 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 2,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 1,
+ ],
+}
+`;
+
exports[`recordToDom should handle br with text 1`] = `
te
@@ -174,6 +464,19 @@ exports[`recordToDom should handle br with text 1`] = `
`;
+exports[`recordToDom should handle br with text 2`] = `
+Object {
+ "endPath": Array [
+ 2,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 2,
+ ],
+}
+`;
+
exports[`recordToDom should handle double br 1`] = `
a
@@ -184,41 +487,109 @@ exports[`recordToDom should handle double br 1`] = `
`;
+exports[`recordToDom should handle double br 2`] = `
+Object {
+ "endPath": Array [
+ 4,
+ 0,
+ ],
+ "startPath": Array [
+ 2,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should handle empty list value 1`] = `
-
-
+
`;
+exports[`recordToDom should handle empty list value 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should handle empty multiline value 1`] = `
-
+
`;
+exports[`recordToDom should handle empty multiline value 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should handle middle empty list value 1`] = `
-
-
+
-
-
+
-
-
+
`;
+exports[`recordToDom should handle middle empty list value 2`] = `
+Object {
+ "endPath": Array [
+ 1,
+ 0,
+ 0,
+ ],
+ "startPath": Array [
+ 1,
+ 0,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should handle multiline list value 1`] = `
-
@@ -246,6 +617,25 @@ exports[`recordToDom should handle multiline list value 1`] = `
`;
+exports[`recordToDom should handle multiline list value 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ 1,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should handle multiline value 1`] = `
@@ -257,6 +647,21 @@ exports[`recordToDom should handle multiline value 1`] = `
`;
+exports[`recordToDom should handle multiline value 2`] = `
+Object {
+ "endPath": Array [
+ 1,
+ 0,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 1,
+ ],
+}
+`;
+
exports[`recordToDom should handle multiline value with element selection 1`] = `
-
@@ -265,6 +670,21 @@ exports[`recordToDom should handle multiline value with element selection 1`] =
`;
+exports[`recordToDom should handle multiline value with element selection 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ 3,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 3,
+ ],
+}
+`;
+
exports[`recordToDom should handle multiline value with empty 1`] = `
@@ -272,25 +692,65 @@ exports[`recordToDom should handle multiline value with empty 1`] = `
-
+
`;
+exports[`recordToDom should handle multiline value with empty 2`] = `
+Object {
+ "endPath": Array [
+ 1,
+ 0,
+ 0,
+ ],
+ "startPath": Array [
+ 1,
+ 0,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should handle nested empty list value 1`] = `
-
-
+
-
-
+
`;
+exports[`recordToDom should handle nested empty list value 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should handle selection before br 1`] = `
a
@@ -301,61 +761,127 @@ exports[`recordToDom should handle selection before br 1`] = `
`;
+exports[`recordToDom should handle selection before br 2`] = `
+Object {
+ "endPath": Array [
+ 2,
+ 0,
+ ],
+ "startPath": Array [
+ 2,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should ignore formats at line separator 1`] = `
- one
+ one
- two
+ two
-
+
`;
+exports[`recordToDom should ignore formats at line separator 2`] = `
+Object {
+ "endPath": Array [],
+ "startPath": Array [],
+}
+`;
+
exports[`recordToDom should preserve emoji 1`] = `
🍒
`;
+exports[`recordToDom should preserve emoji 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 2,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ ],
+}
+`;
+
exports[`recordToDom should preserve emoji in formatting 1`] = `
-
- 🍒
+
+ 🍒
+
`;
+exports[`recordToDom should preserve emoji in formatting 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ 3,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ 1,
+ ],
+}
+`;
+
exports[`recordToDom should preserve non breaking space 1`] = `
test test
`;
-exports[`recordToDom should remove br with settings 1`] = `
-
-
-
-
+exports[`recordToDom should preserve non breaking space 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 5,
+ ],
+ "startPath": Array [
+ 0,
+ 5,
+ ],
+}
`;
-exports[`recordToDom should remove with children with settings 1`] = `
+exports[`recordToDom should remove br with settings 1`] = `
- two
+
+
`;
-exports[`recordToDom should remove with settings 1`] = `
-
-
-
-
+exports[`recordToDom should remove br with settings 2`] = `
+Object {
+ "endPath": Array [
+ 0,
+ 0,
+ ],
+ "startPath": Array [
+ 0,
+ 0,
+ ],
+}
`;
exports[`recordToDom should replace characters to format HTML with space 1`] = `
@@ -364,12 +890,15 @@ exports[`recordToDom should replace characters to format HTML with space 1`] = `