diff --git a/core-blocks/heading/index.js b/core-blocks/heading/index.js index 1d440482bba2c..cbad44d4f0ffe 100644 --- a/core-blocks/heading/index.js +++ b/core-blocks/heading/index.js @@ -7,12 +7,12 @@ import { omit } from 'lodash'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { concatChildren } from '@wordpress/element'; import { createBlock, getPhrasingContentSchema, getBlockAttributes, getBlockType, + children, } from '@wordpress/blocks'; import { RichText } from '@wordpress/editor'; @@ -167,7 +167,10 @@ export const settings = { merge( attributes, attributesToMerge ) { return { - content: concatChildren( attributes.content, attributesToMerge.content ), + content: children.concat( + attributes.content, + attributesToMerge.content + ), }; }, diff --git a/core-blocks/paragraph/index.js b/core-blocks/paragraph/index.js index acdc498c55faf..5ebc8f7fff8a2 100644 --- a/core-blocks/paragraph/index.js +++ b/core-blocks/paragraph/index.js @@ -9,7 +9,6 @@ import { isFinite, find, omit } from 'lodash'; */ import { __ } from '@wordpress/i18n'; import { - concatChildren, Component, Fragment, RawHTML, @@ -30,7 +29,11 @@ import { PanelColor, RichText, } from '@wordpress/editor'; -import { createBlock, getPhrasingContentSchema } from '@wordpress/blocks'; +import { + createBlock, + getPhrasingContentSchema, + children, +} from '@wordpress/blocks'; import { compose } from '@wordpress/compose'; /** @@ -470,7 +473,10 @@ export const settings = { merge( attributes, attributesToMerge ) { return { - content: concatChildren( attributes.content, attributesToMerge.content ), + content: children.concat( + attributes.content, + attributesToMerge.content + ), }; }, diff --git a/core-blocks/test/fixtures/core__heading__h2-em.json b/core-blocks/test/fixtures/core__heading__h2-em.json index ea753522fc29b..1635da5f8efbb 100644 --- a/core-blocks/test/fixtures/core__heading__h2-em.json +++ b/core-blocks/test/fixtures/core__heading__h2-em.json @@ -8,7 +8,9 @@ "The ", { "type": "em", - "children": "Inserter" + "children": [ + "Inserter" + ] }, " Tool" ], diff --git a/core-blocks/test/fixtures/core__list__ul.json b/core-blocks/test/fixtures/core__list__ul.json index ff66a5426b967..6d2ba10a4cb33 100644 --- a/core-blocks/test/fixtures/core__list__ul.json +++ b/core-blocks/test/fixtures/core__list__ul.json @@ -8,23 +8,33 @@ "values": [ { "type": "li", - "children": "Text & Headings" + "children": [ + "Text & Headings" + ] }, { "type": "li", - "children": "Images & Videos" + "children": [ + "Images & Videos" + ] }, { "type": "li", - "children": "Galleries" + "children": [ + "Galleries" + ] }, { "type": "li", - "children": "Embeds, like YouTube, Tweets, or other WordPress posts." + "children": [ + "Embeds, like YouTube, Tweets, or other WordPress posts." + ] }, { "type": "li", - "children": "Layout blocks, like Buttons, Hero Images, Separators, etc." + "children": [ + "Layout blocks, like Buttons, Hero Images, Separators, etc." + ] }, { "type": "li", @@ -32,7 +42,9 @@ "And ", { "type": "em", - "children": "Lists" + "children": [ + "Lists" + ] }, " like this one of course :)" ] diff --git a/core-blocks/test/fixtures/core__preformatted.json b/core-blocks/test/fixtures/core__preformatted.json index 842557c90b428..6bf8d032b3e40 100644 --- a/core-blocks/test/fixtures/core__preformatted.json +++ b/core-blocks/test/fixtures/core__preformatted.json @@ -8,11 +8,14 @@ "Some ", { "type": "em", - "children": "preformatted" + "children": [ + "preformatted" + ] }, " text...", { - "type": "br" + "type": "br", + "children": [] }, "And more!" ] diff --git a/core-blocks/test/fixtures/core__pullquote.json b/core-blocks/test/fixtures/core__pullquote.json index cc8642543873d..754ba9d007cc1 100644 --- a/core-blocks/test/fixtures/core__pullquote.json +++ b/core-blocks/test/fixtures/core__pullquote.json @@ -8,13 +8,11 @@ { "children": { "type": "p", - "key": null, - "ref": null, "props": { - "children": "Testing pullquote block..." - }, - "_owner": null, - "_store": {} + "children": [ + "Testing pullquote block..." + ] + } } } ], diff --git a/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json b/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json index dfb28dafea49c..efd2a82c1d116 100644 --- a/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json +++ b/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json @@ -8,37 +8,29 @@ { "children": { "type": "p", - "key": null, - "ref": null, "props": { "children": [ "Paragraph ", { "type": "strong", - "key": "_domReact73", - "ref": null, "props": { - "children": "one" - }, - "_owner": null, - "_store": {} + "children": [ + "one" + ] + } } ] - }, - "_owner": null, - "_store": {} + } } }, { "children": { "type": "p", - "key": null, - "ref": null, "props": { - "children": "Paragraph two" - }, - "_owner": null, - "_store": {} + "children": [ + "Paragraph two" + ] + } } } ], diff --git a/core-blocks/test/fixtures/core__quote__style-1.json b/core-blocks/test/fixtures/core__quote__style-1.json index f152d155dac51..67722f404cd0a 100644 --- a/core-blocks/test/fixtures/core__quote__style-1.json +++ b/core-blocks/test/fixtures/core__quote__style-1.json @@ -8,13 +8,11 @@ { "children": { "type": "p", - "key": null, - "ref": null, "props": { - "children": "The editor will endeavour to create a new page and post building experience that makes writing rich posts effortless, and has “blocks” to make it easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery." - }, - "_owner": null, - "_store": {} + "children": [ + "The editor will endeavour to create a new page and post building experience that makes writing rich posts effortless, and has “blocks” to make it easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery." + ] + } } } ], diff --git a/core-blocks/test/fixtures/core__quote__style-2.json b/core-blocks/test/fixtures/core__quote__style-2.json index 2b910b66eeb1e..ebf72ece86181 100644 --- a/core-blocks/test/fixtures/core__quote__style-2.json +++ b/core-blocks/test/fixtures/core__quote__style-2.json @@ -8,13 +8,11 @@ { "children": { "type": "p", - "key": null, - "ref": null, "props": { - "children": "There is no greater agony than bearing an untold story inside you." - }, - "_owner": null, - "_store": {} + "children": [ + "There is no greater agony than bearing an untold story inside you." + ] + } } } ], diff --git a/core-blocks/test/fixtures/core__subhead.json b/core-blocks/test/fixtures/core__subhead.json index 6bee5aa70dc8c..64d8bb2f030c7 100644 --- a/core-blocks/test/fixtures/core__subhead.json +++ b/core-blocks/test/fixtures/core__subhead.json @@ -8,7 +8,9 @@ "This is a ", { "type": "em", - "children": "subhead" + "children": [ + "subhead" + ] }, "." ] diff --git a/core-blocks/test/fixtures/core__table.json b/core-blocks/test/fixtures/core__table.json index b0ae80fa903d1..f6a6c42c9c545 100644 --- a/core-blocks/test/fixtures/core__table.json +++ b/core-blocks/test/fixtures/core__table.json @@ -7,23 +7,31 @@ "content": [ { "type": "thead", - "children": { - "type": "tr", - "children": [ - { - "type": "th", - "children": "Version" - }, - { - "type": "th", - "children": "Musician" - }, - { - "type": "th", - "children": "Date" - } - ] - } + "children": [ + { + "type": "tr", + "children": [ + { + "type": "th", + "children": [ + "Version" + ] + }, + { + "type": "th", + "children": [ + "Musician" + ] + }, + { + "type": "th", + "children": [ + "Date" + ] + } + ] + } + ] }, { "type": "tbody", @@ -33,21 +41,29 @@ "children": [ { "type": "td", - "children": { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2003/05/wordpress-now-available/" - }, - "children": ".70" - } + "children": [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2003/05/wordpress-now-available/" + }, + "children": [ + ".70" + ] + } + ] }, { "type": "td", - "children": "No musician chosen." + "children": [ + "No musician chosen." + ] }, { "type": "td", - "children": "May 27, 2003" + "children": [ + "May 27, 2003" + ] } ] }, @@ -56,21 +72,29 @@ "children": [ { "type": "td", - "children": { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2004/01/wordpress-10/" - }, - "children": "1.0" - } + "children": [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2004/01/wordpress-10/" + }, + "children": [ + "1.0" + ] + } + ] }, { "type": "td", - "children": "Miles Davis" + "children": [ + "Miles Davis" + ] }, { "type": "td", - "children": "January 3, 2004" + "children": [ + "January 3, 2004" + ] } ] }, @@ -86,17 +110,23 @@ "attributes": { "href": "https://codex.wordpress.org/WordPress_Versions" }, - "children": "the full list" + "children": [ + "the full list" + ] } ] }, { "type": "td", - "children": "…" + "children": [ + "…" + ] }, { "type": "td", - "children": "…" + "children": [ + "…" + ] } ] }, @@ -105,21 +135,29 @@ "children": [ { "type": "td", - "children": { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2015/12/clifford/" - }, - "children": "4.4" - } + "children": [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2015/12/clifford/" + }, + "children": [ + "4.4" + ] + } + ] }, { "type": "td", - "children": "Clifford Brown" + "children": [ + "Clifford Brown" + ] }, { "type": "td", - "children": "December 8, 2015" + "children": [ + "December 8, 2015" + ] } ] }, @@ -128,21 +166,29 @@ "children": [ { "type": "td", - "children": { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2016/04/coleman/" - }, - "children": "4.5" - } + "children": [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/04/coleman/" + }, + "children": [ + "4.5" + ] + } + ] }, { "type": "td", - "children": "Coleman Hawkins" + "children": [ + "Coleman Hawkins" + ] }, { "type": "td", - "children": "April 12, 2016" + "children": [ + "April 12, 2016" + ] } ] }, @@ -151,21 +197,29 @@ "children": [ { "type": "td", - "children": { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2016/08/pepper/" - }, - "children": "4.6" - } + "children": [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/08/pepper/" + }, + "children": [ + "4.6" + ] + } + ] }, { "type": "td", - "children": "Pepper Adams" + "children": [ + "Pepper Adams" + ] }, { "type": "td", - "children": "August 16, 2016" + "children": [ + "August 16, 2016" + ] } ] }, @@ -174,21 +228,29 @@ "children": [ { "type": "td", - "children": { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2016/12/vaughan/" - }, - "children": "4.7" - } + "children": [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/12/vaughan/" + }, + "children": [ + "4.7" + ] + } + ] }, { "type": "td", - "children": "Sarah Vaughan" + "children": [ + "Sarah Vaughan" + ] }, { "type": "td", - "children": "December 6, 2016" + "children": [ + "December 6, 2016" + ] } ] } diff --git a/core-blocks/test/fixtures/core__text__converts-to-paragraph.json b/core-blocks/test/fixtures/core__text__converts-to-paragraph.json index b2e12e5ce758f..10066958f0b3b 100644 --- a/core-blocks/test/fixtures/core__text__converts-to-paragraph.json +++ b/core-blocks/test/fixtures/core__text__converts-to-paragraph.json @@ -8,7 +8,9 @@ "This is an old-style text block. Changed to ", { "type": "code", - "children": "paragraph" + "children": [ + "paragraph" + ] }, " in #2135." ], diff --git a/core-blocks/test/fixtures/core__verse.json b/core-blocks/test/fixtures/core__verse.json index b002134e73471..4412cb039d849 100644 --- a/core-blocks/test/fixtures/core__verse.json +++ b/core-blocks/test/fixtures/core__verse.json @@ -8,11 +8,14 @@ "A ", { "type": "em", - "children": "verse" + "children": [ + "verse" + ] }, "…", { - "type": "br" + "type": "br", + "children": [] }, "And more!" ] diff --git a/docs/reference/deprecated.md b/docs/reference/deprecated.md index 1656ee5f5bbab..57075082b4e68 100644 --- a/docs/reference/deprecated.md +++ b/docs/reference/deprecated.md @@ -17,6 +17,7 @@ Gutenberg's deprecation policy is intended to support backwards-compatibility fo - The `wp.editor.UrlInput` component has been renamed to `wp.editor.URLInput`. - The Text Columns block has been removed. Please use the Columns block instead. - `InnerBlocks` grouped layout is removed. Use intermediary nested inner blocks instead. See Columns / Column block for reference implementation. + - `RichText` explicit `element` format removed. Please use the compatible `children` format instead. ## 3.4.0 diff --git a/editor/components/rich-text/README.md b/editor/components/rich-text/README.md index 306bb28cd7b85..b50833379d378 100644 --- a/editor/components/rich-text/README.md +++ b/editor/components/rich-text/README.md @@ -6,9 +6,9 @@ Render a rich [`contenteditable` input](https://developer.mozilla.org/en-US/docs ### `format: String` -*Optional.* Format of the RichText provided value prop. It can be `element` or `string`. +*Optional.* Format of the RichText provided value prop. It can be `children` or `string`. -*Default: `element`*. +*Default: `children`*. ### `value: Array|String` diff --git a/editor/components/rich-text/format.js b/editor/components/rich-text/format.js index 221966b81fa7e..c6da19e4190a4 100644 --- a/editor/components/rich-text/format.js +++ b/editor/components/rich-text/format.js @@ -8,6 +8,81 @@ import { nodeListToReact } from 'dom-react'; * WordPress dependencies */ import { createElement, renderToString } from '@wordpress/element'; +import { children } from '@wordpress/blocks'; + +const { TEXT_NODE, ELEMENT_NODE } = window.Node; + +/** + * 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'; + +/** + * Regular expression matching TinyMCE zero-width space character globally. + * + * @type {RegExp} + */ +const REGEXP_TINYMCE_ZWSP = new RegExp( TINYMCE_ZWSP, 'g' ); + +/** + * Returns true if the given attribute name is a TinyMCE internal temporary + * attribute which should not be included in the serialized output, or false + * otherwise. Would use the given editor's serializer as basis for determining + * temporary attributes, but fails to include attributes like `data-mce-src`. + * + * @param {string} attributeName Attribute name to test. + * + * @return {boolean} Whether attribute is an internal temporary attribute. + */ +export function isTinyMCEInternalAttribute( attributeName ) { + return attributeName.indexOf( 'data-mce-' ) === 0; +} + +/** + * Returns true if the given HTMLElement is a TinyMCE bogus element. During + * serialization, a bogus element should be skipped. + * + * @param {HTMLElement} element Element to test. + * + * @return {boolean} Whether element is a TinyMCE bogus element. + */ +export function isTinyMCEBogusElement( element ) { + return element.getAttribute( 'data-mce-bogus' ) === 'all'; +} + +/** + * Returns true if the given HTMLElement is a TinyMCE bogus wrapper. During + * serialization, a bogus wrapper should be substituted with its childrens' + * content. + * + * @param {HTMLElement} element Element to test. + * + * @return {boolean} Whether element is a TinyMCE bogus wrapper. + */ +export function isTinyMCEBogusWrapperElement( element ) { + return ( + element.hasAttribute( 'data-mce-bogus' ) && + ! isTinyMCEBogusElement( element ) + ); +} + +/** + * Given a text node, returns its node value with any TinyMCE internal zero- + * width space characters omitted. + * + * @param {Text} node Text node from which to derive value. + * + * @return {string} Cleaned text node value. + */ +export function getCleanTextNodeValue( node ) { + const { nodeValue } = node; + return nodeValue.replace( REGEXP_TINYMCE_ZWSP, '' ); +} /** * Transforms a WP Element to its corresponding HTML string. @@ -29,11 +104,19 @@ export function elementToString( value ) { * @return {string} HTML output as string. */ export function valueToString( value, format ) { + if ( ! value ) { + return ''; + } + switch ( format ) { case 'string': - return value || ''; - default: + return value; + + case 'element': return elementToString( value ); + + case 'children': + return children.toHTML( value ); } } @@ -46,22 +129,50 @@ export function valueToString( value, format ) { * * @return {Element} WPElement. */ -export function createTinyMCEElement( type, props, ...children ) { +export function createTinyMCEElement( type, props, ...elementChildren ) { if ( props[ 'data-mce-bogus' ] === 'all' ) { return null; } if ( props.hasOwnProperty( 'data-mce-bogus' ) ) { - return children; + return elementChildren; } return createElement( type, omitBy( props, ( _, key ) => key.indexOf( 'data-mce-' ) === 0 ), - ...children + ...elementChildren ); } +/** + * Given an HTMLElement from a TinyMCE editor body element, returns equivalent + * WPBlockChildren value. The element may undergo some preprocessing to remove + * temporary or internal elements and attributes. + * + * @param {HTMLElement} element TinyMCE DOM element. + * + * @return {WPBlockChildren} WPBlockChildren equivalent value to element. + */ +export function createBlockChildrenFromTinyMCEElement( element ) { + const attributes = {}; + for ( let i = 0; i < element.attributes.length; i++ ) { + const { name, value } = element.attributes[ i ]; + + if ( ! isTinyMCEInternalAttribute( name ) ) { + attributes[ name ] = value; + } + } + + return { + type: element.nodeName.toLowerCase(), + props: { + ...attributes, + children: domToBlockChildren( element.childNodes ), + }, + }; +} + /** * Transforms an array of DOM Elements to their corresponding WP element. * @@ -74,21 +185,53 @@ export function domToElement( value ) { } /** - * Transforms an array of DOM Elements to their corresponding HTML string output. + * Given an array of HTMLElement from a TinyMCE editor body element, returns an + * equivalent WPBlockChildren value. The element may undergo some preprocessing + * to remove temporary or internal elements and attributes. * - * @param {Array} value DOM Elements. - * @param {Editor} editor TinyMCE editor instance. + * @param {Array} value TinyMCE DOM elements. * - * @return {string} HTML. + * @return {WPBlockChildren} WPBlockChildren equivalent value to element. */ -export function domToString( value, editor ) { - const doc = document.implementation.createHTMLDocument( '' ); +export function domToBlockChildren( value ) { + const result = []; + + for ( let i = 0; i < value.length; i++ ) { + let node = value[ i ]; + switch ( node.nodeType ) { + case TEXT_NODE: + node = getCleanTextNodeValue( node ); + if ( node.length ) { + result.push( node ); + } + break; + + case ELEMENT_NODE: + if ( isTinyMCEBogusElement( node ) ) { + break; + } - Array.from( value ).forEach( ( child ) => { - doc.body.appendChild( child ); - } ); + if ( ! isTinyMCEBogusWrapperElement( node ) ) { + result.push( createBlockChildrenFromTinyMCEElement( node ) ); + } else if ( node.hasChildNodes() ) { + result.push( ...domToBlockChildren( node.childNodes ) ); + } + break; + } + } + + return result; +} - return editor ? editor.serializer.serialize( doc.body ) : doc.body.innerHTML; +/** + * Transforms an array of DOM Elements to their corresponding HTML string output. + * + * @param {Array} value DOM Elements. + * + * @return {string} HTML. + */ +export function domToString( value ) { + return children.toHTML( domToBlockChildren( value ) ); } /** @@ -96,15 +239,18 @@ export function domToString( value, editor ) { * * @param {Array} value DOM Elements. * @param {string} format Output format (string or element) - * @param {Editor} editor TinyMCE editor instance. * * @return {*} Output. */ -export function domToFormat( value, format, editor ) { +export function domToFormat( value, format ) { switch ( format ) { case 'string': - return domToString( value, editor ); - default: + return domToString( value ); + + case 'element': return domToElement( value ); + + case 'children': + return domToBlockChildren( value ); } } diff --git a/editor/components/rich-text/index.js b/editor/components/rich-text/index.js index 2134d9c3fe6f2..ce14e3b91f64a 100644 --- a/editor/components/rich-text/index.js +++ b/editor/components/rich-text/index.js @@ -10,7 +10,6 @@ import { find, defer, noop, - reject, } from 'lodash'; import 'element-closest'; @@ -27,7 +26,8 @@ import { createBlobURL } from '@wordpress/blob'; import { BACKSPACE, DELETE, ENTER, LEFT, RIGHT, rawShortcut } from '@wordpress/keycodes'; import { Slot } from '@wordpress/components'; import { withSelect } from '@wordpress/data'; -import { rawHandler } from '@wordpress/blocks'; +import { rawHandler, children } from '@wordpress/blocks'; +import deprecated from '@wordpress/deprecated'; import { withInstanceId, withSafeTimeout, compose } from '@wordpress/compose'; /** @@ -60,50 +60,6 @@ const { Node } = window; */ const TINYMCE_ZWSP = '\uFEFF'; -/** - * Returns true if the node is the inline node boundary. This is used in node - * filtering prevent the inline boundary from being included in the split which - * occurs while within but at the end of an inline node, since TinyMCE includes - * a placeholder caret character at the end. - * - * @see https://github.com/tinymce/tinymce/blob/master/src/plugins/link/main/ts/core/Utils.ts - * - * @param {Node} node Node to test. - * - * @return {boolean} Whether node is inline boundary. - */ -export function isEmptyInlineBoundary( node ) { - const text = node.nodeName === 'A' ? node.innerText : node.textContent; - return text === TINYMCE_ZWSP; -} - -/** - * Returns true if the node is empty, meaning it contains only the placeholder - * caret character or is an empty text node. - * - * @param {Node} node Node to test. - * - * @return {boolean} Whether node is empty. - */ -export function isEmptyNode( node ) { - return ( - '' === node.nodeValue || - isEmptyInlineBoundary( node ) - ); -} - -/** - * Given a set of Nodes, filters to set to exclude any empty nodes: those with - * either empty text nodes or only including the inline boundary caret. - * - * @param {Node[]} childNodes Nodes to filter. - * - * @return {Node[]} Non-empty nodes. - */ -export function filterEmptyNodes( childNodes ) { - return reject( childNodes, isEmptyNode ); -} - export function getFormatProperties( formatName, parents ) { switch ( formatName ) { case 'link' : { @@ -539,8 +495,8 @@ export class RichText extends Component { const afterNodes = childNodes.slice( index + 1 ); const { format } = this.props; - const before = domToFormat( beforeNodes, format, this.editor ); - const after = domToFormat( afterNodes, format, this.editor ); + const before = domToFormat( beforeNodes, format ); + const after = domToFormat( afterNodes, format ); this.props.onSplit( before, after ); } else { @@ -637,8 +593,8 @@ export class RichText extends Component { const afterFragment = afterRange.cloneContents(); const { format } = this.props; - before = domToFormat( filterEmptyNodes( beforeFragment.childNodes ), format, this.editor ); - after = domToFormat( filterEmptyNodes( afterFragment.childNodes ), format, this.editor ); + before = domToFormat( beforeFragment.childNodes, format ); + after = domToFormat( afterFragment.childNodes, format ); } else { before = []; after = []; @@ -721,12 +677,7 @@ export class RichText extends Component { getContent() { const { format } = this.props; - switch ( format ) { - case 'string': - return this.editor.getContent(); - default: - return domToFormat( this.editor.getBody().childNodes || [], 'element', this.editor ); - } + return domToFormat( this.editor.getBody().childNodes, format ); } componentDidUpdate( prevProps ) { @@ -928,7 +879,7 @@ RichText.contextTypes = { RichText.defaultProps = { formattingControls: DEFAULT_FORMATS, formatters: [], - format: 'element', + format: 'children', }; const RichTextContainer = compose( [ @@ -963,22 +914,39 @@ const RichTextContainer = compose( [ withSafeTimeout, ] )( RichText ); -RichTextContainer.Content = ( { value, format = 'element', tagName: Tag, ...props } ) => { - let children; +RichTextContainer.Content = ( { value, format, tagName: Tag, ...props } ) => { + let content; switch ( format ) { case 'string': - children = { value }; + content = { value }; break; - default: - children = value; + + case 'element': + // NOTE: In removing this, ensure to remove also every related + // function from `format.js`, including the `dom-react` dependency. + deprecated( 'RichText `element` format', { + version: '3.5', + plugin: 'Gutenberg', + alternative: 'the compatible `children` format', + } ); + + content = value; + break; + + case 'children': + content = { children.toHTML( value ) }; break; } if ( Tag ) { - return { children }; + return { content }; } - return children; + return content; +}; + +RichTextContainer.Content.defaultProps = { + format: 'children', }; export default RichTextContainer; diff --git a/editor/components/rich-text/test/__snapshots__/format.js.snap b/editor/components/rich-text/test/__snapshots__/format.js.snap index 731d9765c970d..87da08bd7ecad 100644 --- a/editor/components/rich-text/test/__snapshots__/format.js.snap +++ b/editor/components/rich-text/test/__snapshots__/format.js.snap @@ -10,6 +10,27 @@ exports[`createTinyMCEElement should render a TinyMCE element 1`] = ` `; +exports[`domToBlockChildren should return the corresponding element 1`] = ` +Array [ + Object { + "props": Object { + "children": Array [ + Object { + "props": Object { + "children": Array [ + "content", + ], + }, + "type": "strong", + }, + ], + "class": "container", + }, + "type": "div", + }, +] +`; + exports[`domToElement should return the corresponding element 1`] = ` Array [
{ + it( 'should return false for non-internal tinymce attribute', () => { + const result = isTinyMCEInternalAttribute( 'class' ); + + expect( result ).toBe( false ); + } ); + + it( 'should return true for internal tinymce attribute', () => { + const result = isTinyMCEInternalAttribute( 'data-mce-selected' ); + + expect( result ).toBe( true ); + } ); +} ); + +describe( 'isTinyMCEBogusElement', () => { + it( 'should return false for non-bogus element', () => { + const element = document.createElement( 'span' ); + + const result = isTinyMCEBogusElement( element ); + + expect( result ).toBe( false ); + } ); + + it( 'should return false for non-"bogus=all" element', () => { + const element = document.createElement( 'span' ); + element.setAttribute( 'data-mce-bogus', '' ); + + const result = isTinyMCEBogusElement( element ); + + expect( result ).toBe( false ); + } ); + + it( 'should return true for "bogus=all" element', () => { + const element = document.createElement( 'span' ); + element.setAttribute( 'data-mce-bogus', 'all' ); + + const result = isTinyMCEBogusElement( element ); + + expect( result ).toBe( true ); + } ); +} ); + +describe( 'isTinyMCEBogusWrapperElement', () => { + it( 'should return false for non-bogus element', () => { + const element = document.createElement( 'span' ); + + const result = isTinyMCEBogusWrapperElement( element ); + + expect( result ).toBe( false ); + } ); + + it( 'should return false for "bogus=all" element', () => { + const element = document.createElement( 'span' ); + element.setAttribute( 'data-mce-bogus', 'all' ); + + const result = isTinyMCEBogusWrapperElement( element ); + + expect( result ).toBe( false ); + } ); + + it( 'should return true for non-"bogus=all" element', () => { + const element = document.createElement( 'span' ); + element.setAttribute( 'data-mce-bogus', '' ); + + const result = isTinyMCEBogusWrapperElement( element ); + + expect( result ).toBe( true ); + } ); +} ); + +describe( 'getCleanTextNodeValue', () => { + it( 'returns text node value without zwsp', () => { + const node = document.createTextNode( 'Aaaargh\uFEFF' ); + + const result = getCleanTextNodeValue( node ); + + expect( result ).toBe( 'Aaaargh' ); + } ); +} ); + describe( 'createTinyMCEElement', () => { const type = 'div'; const children =

Child

; @@ -78,12 +164,46 @@ describe( 'domToElement', () => { } ); } ); +describe( 'createBlockChildrenFromTinyMCEElement', () => { + it( 'returns recusrively cleaned tinymce element as block children', () => { + const element = document.createElement( 'div' ); + element.setAttribute( 'style', 'color: red' ); + const text = document.createTextNode( 'Aaaargh\uFEFF' ); + element.appendChild( text ); + const br = document.createElement( 'br' ); + br.setAttribute( 'data-mce-bogus', 'all' ); + element.appendChild( br ); + + const result = createBlockChildrenFromTinyMCEElement( element ); + + expect( result ).toEqual( { + type: 'div', + props: { + style: 'color: red', + children: [ 'Aaaargh' ], + }, + } ); + } ); +} ); + +describe( 'domToBlockChildren', () => { + test( 'should return an empty array', () => { + expect( domToBlockChildren( [] ) ).toEqual( [] ); + } ); + + test( 'should return the corresponding element ', () => { + const domElement = document.createElement( 'div' ); + domElement.innerHTML = '
content
'; + expect( domToBlockChildren( domElement.childNodes ) ).toMatchSnapshot(); + } ); +} ); + describe( 'domToString', () => { test( 'should return an empty string', () => { expect( domToString( [] ) ).toEqual( '' ); } ); - test( 'should return the HTML ', () => { + test( 'should return the HTML', () => { const domElement = document.createElement( 'div' ); const content = '
content
'; domElement.innerHTML = content; diff --git a/editor/components/rich-text/test/index.js b/editor/components/rich-text/test/index.js index f34c7b5fd9e8e..c7e7f7509cdfe 100644 --- a/editor/components/rich-text/test/index.js +++ b/editor/components/rich-text/test/index.js @@ -8,105 +8,10 @@ import { shallow } from 'enzyme'; */ import { RichText, - isEmptyInlineBoundary, - isEmptyNode, - filterEmptyNodes, getFormatProperties, } from '../'; import { diffAriaProps, pickAriaProps } from '../aria'; -describe( 'isEmptyInlineBoundary', () => { - describe( 'link', () => { - const node = document.createElement( 'a' ); - node.innerText = '\uFEFF'; - - test( 'should return true for a valid link boundary', () => { - expect( isEmptyInlineBoundary( node ) ).toBe( true ); - } ); - - test( 'should return false for an invalid link boundary', () => { - const invalid = { ...node, childNodes: [] }; - expect( isEmptyInlineBoundary( invalid ) ).toBe( false ); - } ); - } ); - - describe( 'code', () => { - const node = document.createElement( 'code' ); - node.textContent = '\uFEFF'; - - test( 'should return true for a valid link boundary', () => { - expect( isEmptyInlineBoundary( node ) ).toBe( true ); - } ); - - test( 'should return false for an invalid link boundary', () => { - const invalid = { ...node, childNodes: [] }; - expect( isEmptyInlineBoundary( invalid ) ).toBe( false ); - } ); - } ); -} ); - -describe( 'isEmptyNode', () => { - it( 'returns true for empty text node', () => { - const node = document.createTextNode( '' ); - - expect( isEmptyNode( node ) ).toBe( true ); - } ); - - it( 'returns false for non-empty text node', () => { - const node = document.createTextNode( 'rabbit' ); - - expect( isEmptyNode( node ) ).toBe( false ); - } ); - - it( 'returns false for element node', () => { - const node = document.createElement( 'br' ); - - expect( isEmptyNode( node ) ).toBe( false ); - } ); -} ); - -describe( 'filterEmptyNodes', () => { - it( 'preserves newlines', () => { - const node = document.createElement( 'div' ); - node.innerHTML = 'a
foo'; - - expect( filterEmptyNodes( node.childNodes ) ).toEqual( [ - document.createTextNode( 'a' ), - document.createElement( 'br' ), - document.createTextNode( 'foo' ), - ] ); - } ); - - it( 'omits text node', () => { - const node = document.createElement( 'div' ); - node.appendChild( document.createTextNode( '' ) ); - - expect( filterEmptyNodes( node.childNodes ) ).toHaveLength( 0 ); - } ); - - it( 'omits prefixing text node', () => { - const node = document.createElement( 'div' ); - node.innerHTML = '
foo'; - node.insertBefore( document.createTextNode( '' ), node.firstChild ); - - expect( filterEmptyNodes( node.childNodes ) ).toEqual( [ - document.createElement( 'br' ), - document.createTextNode( 'foo' ), - ] ); - } ); - - it( 'omits trailing text node', () => { - const node = document.createElement( 'div' ); - node.innerHTML = '
foo'; - node.appendChild( document.createTextNode( '' ) ); - - expect( filterEmptyNodes( node.childNodes ) ).toEqual( [ - document.createElement( 'br' ), - document.createTextNode( 'foo' ), - ] ); - } ); -} ); - describe( 'getFormatProperties', () => { const formatName = 'link'; const node = { diff --git a/packages/blocks/src/api/children.js b/packages/blocks/src/api/children.js new file mode 100644 index 0000000000000..3fde2804fb4ed --- /dev/null +++ b/packages/blocks/src/api/children.js @@ -0,0 +1,132 @@ +/** + * External dependencies + */ +import { castArray } from 'lodash'; + +/** + * WordPress dependencies + */ +import { renderToString } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import * as node from './node'; + +/** + * A representation of a block's rich text value. + * + * @typedef {WPBlockChild[]} WPBlockChildren + */ + +/** + * Given a block node, returns a serialize-capable WordPress element. + * + * @param {WPBlockChildren} children Block node to convert. + * + * @return {WPElement} A serialize-capable element. + */ +export function getSerializeCapableElement( children ) { + // The fact that a block node is compatible with the element serializer is + // merely an implementation detail that currently serves to be true, but + // should not be mistaken as being a guarantee on the external API. The + // public API only offers guarantees to work with strings (toHTML) and DOM + // elements (fromDOM), and should provide utilities to manipulate the value + // rather than expect consumers to inspect or construct its shape (concat). + return children; +} + +/** + * Given two or more block nodes, returns a new block node representing a + * concatenation of its values. + * + * @param {...WPBlockChildren} blockNodes Block nodes to concatenate. + * + * @return {WPBlockChildren} Concatenated block node. + */ +export function concat( ...blockNodes ) { + const result = []; + for ( let i = 0; i < blockNodes.length; i++ ) { + const blockNode = castArray( blockNodes[ i ] ); + for ( let j = 0; j < blockNode.length; j++ ) { + const child = blockNode[ j ]; + const canConcatToPreviousString = ( + typeof child === 'string' && + typeof result[ result.length - 1 ] === 'string' + ); + + if ( canConcatToPreviousString ) { + result[ result.length - 1 ] += child; + } else { + result.push( child ); + } + } + } + + return result; +} + +/** + * Given an iterable set of DOM nodes, returns equivalent block children. + * Ignores any non-element/text nodes included in set. + * + * @param {Iterable.} domNodes Iterable set of DOM nodes to convert. + * + * @return {WPBlockChildren} Block children equivalent to DOM nodes. + */ +export function fromDOM( domNodes ) { + const result = []; + for ( let i = 0; i < domNodes.length; i++ ) { + try { + result.push( node.fromDOM( domNodes[ i ] ) ); + } catch ( error ) { + // Simply ignore if DOM node could not be converted. + } + } + + return result; +} + +/** + * Given a block node, returns its HTML string representation. + * + * @param {WPBlockChildren} children Block node(s) to convert to string. + * + * @return {string} String HTML representation of block node. + */ +export function toHTML( children ) { + const element = getSerializeCapableElement( children ); + + return renderToString( element ); +} + +/** + * Given a selector, returns an hpq matcher generating a WPBlockChildren value + * matching the selector result. + * + * @param {string} selector DOM selector. + * + * @return {Function} hpq matcher. + */ +export function matcher( selector ) { + return ( domNode ) => { + let match = domNode; + + if ( selector ) { + match = domNode.querySelector( selector ); + } + + if ( match ) { + return fromDOM( match.childNodes ); + } + + return []; + }; +} + +export default { + concat, + fromDOM, + toHTML, + matcher, +}; diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js index b041cb6eceaff..2113b4eb622a1 100644 --- a/packages/blocks/src/api/index.js +++ b/packages/blocks/src/api/index.js @@ -51,3 +51,5 @@ export { doBlocksMatchTemplate, synchronizeBlocksWithTemplate, } from './templates'; +export { default as children } from './children'; +export { default as node } from './node'; diff --git a/packages/blocks/src/api/matchers.js b/packages/blocks/src/api/matchers.js index 0ccf4cf8d8832..5a86c2c9b85a8 100644 --- a/packages/blocks/src/api/matchers.js +++ b/packages/blocks/src/api/matchers.js @@ -1,38 +1,10 @@ -/** - * WordPress dependencies - */ -import { createElement } from '@wordpress/element'; - /** * External dependencies */ -import { nodeListToReact, nodeToReact } from 'dom-react'; export { attr, prop, html, text, query } from 'hpq'; -export const children = ( selector ) => { - return ( domNode ) => { - let match = domNode; - - if ( selector ) { - match = domNode.querySelector( selector ); - } - - if ( match ) { - return nodeListToReact( match.childNodes || [], createElement ); - } - - return []; - }; -}; - -export const node = ( selector ) => { - return ( domNode ) => { - let match = domNode; - - if ( selector ) { - match = domNode.querySelector( selector ); - } - - return nodeToReact( match, createElement ); - }; -}; +/** + * Internal dependencies + */ +export { matcher as node } from './node'; +export { matcher as children } from './children'; diff --git a/packages/blocks/src/api/node.js b/packages/blocks/src/api/node.js new file mode 100644 index 0000000000000..34bcfd394429c --- /dev/null +++ b/packages/blocks/src/api/node.js @@ -0,0 +1,112 @@ +/** + * Internal dependencies + */ +import * as children from './children'; + +/** + * Browser dependencies + */ +const { TEXT_NODE, ELEMENT_NODE } = window.Node; + +/** + * A representation of a single node within a block's rich text value. If + * representing a text node, the value is simply a string of the node value. + * As representing an element node, it is an object of: + * + * 1. `type` (string): Tag name. + * 2. `props` (object): Attributes and children array of WPBlockNode. + * + * @typedef {string|Object} WPBlockNode + */ + +/** + * Given an object implementing the NamedNodeMap interface, returns a plain + * object equivalent value of name, value key-value pairs. + * + * @see https://dom.spec.whatwg.org/#interface-namednodemap + * + * @param {NamedNodeMap} nodeMap NamedNodeMap to convert to object. + * + * @return {Object} Object equivalent value of NamedNodeMap. + */ +export function getNamedNodeMapAsObject( nodeMap ) { + const result = {}; + for ( let i = 0; i < nodeMap.length; i++ ) { + const { name, value } = nodeMap[ i ]; + result[ name ] = value; + } + + return result; +} + +/** + * Given a DOM Element or Text node, returns an equivalent block node. Throws + * if passed any node type other than element or text. + * + * @throws {TypeError} If non-element/text node is passed. + * + * @param {Node} domNode DOM node to convert. + * + * @return {WPBlockNode} Block node equivalent to DOM node. + */ +export function fromDOM( domNode ) { + if ( domNode.nodeType === TEXT_NODE ) { + return domNode.nodeValue; + } + + if ( domNode.nodeType !== ELEMENT_NODE ) { + throw new TypeError( + 'A block node can only be created from a node of type text or ' + + 'element.' + ); + } + + return { + type: domNode.nodeName.toLowerCase(), + props: { + ...getNamedNodeMapAsObject( domNode.attributes ), + children: children.fromDOM( domNode.childNodes ), + }, + }; +} + +/** + * Given a block node, returns its HTML string representation. + * + * @param {WPBlockNode} node Block node to convert to string. + * + * @return {string} String HTML representation of block node. + */ +export function toHTML( node ) { + return children.toHTML( [ node ] ); +} + +/** + * Given a selector, returns an hpq matcher generating a WPBlockNode value + * matching the selector result. + * + * @param {string} selector DOM selector. + * + * @return {Function} hpq matcher. + */ +export function matcher( selector ) { + return ( domNode ) => { + let match = domNode; + + if ( selector ) { + match = domNode.querySelector( selector ); + } + + try { + return fromDOM( match ); + } catch ( error ) { + return null; + } + }; +} + +export default { + fromDOM, + toHTML, + matcher, +}; diff --git a/packages/blocks/src/api/test/children.js b/packages/blocks/src/api/test/children.js new file mode 100644 index 0000000000000..cc3fe5d76d28a --- /dev/null +++ b/packages/blocks/src/api/test/children.js @@ -0,0 +1,137 @@ +/** + * WordPress dependencies + */ +import { renderToString } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + getSerializeCapableElement, + concat, + toHTML, + fromDOM, +} from '../children'; + +describe( 'getSerializeCapableElement', () => { + it( 'returns a serialize capable element', () => { + const blockNode = [ + 'This ', + { + type: 'strong', + props: { + class: 'is-extra-strong', + children: [ 'is' ], + }, + }, + ' a test', + ]; + + const element = getSerializeCapableElement( blockNode ); + + // Intentionally avoid introspecting the shape of the generated element + // since all that is cared about is that it can be serialized. + const html = renderToString( element ); + + expect( html ).toBe( 'This is a test' ); + } ); +} ); + +describe( 'concat', () => { + it( 'should combine two or more sets of block nodes', () => { + const result = concat( + { + type: 'strong', + props: { + children: [ 'Hello' ], + }, + }, + ' ', + { + type: 'em', + props: { + children: [ 'world' ], + }, + }, + ); + + expect( result ).toEqual( [ + { + type: 'strong', + props: { + children: [ 'Hello' ], + }, + }, + ' ', + { + type: 'em', + props: { + children: [ 'world' ], + }, + }, + ] ); + } ); + + it( 'should merge adjacent strings', () => { + const result = concat( + 'Hello', + ' ', + { + type: 'strong', + props: { + children: [ 'World' ], + }, + }, + ); + + expect( result ).toEqual( [ + 'Hello ', + { + type: 'strong', + props: { + children: [ 'World' ], + }, + }, + ] ); + } ); +} ); + +describe( 'toHTML', () => { + it( 'should convert a children array of block nodes to its equivalent html string', () => { + const children = [ + 'This is a ', + { + type: 'strong', + props: { + children: [ 'test' ], + }, + }, + '!', + ]; + + const html = toHTML( children ); + + expect( html ).toBe( 'This is a test!' ); + } ); +} ); + +describe( 'fromDOM', () => { + it( 'should return an equivalent block children', () => { + const node = document.createElement( 'div' ); + node.innerHTML = 'This is a test'; + + const blockNode = fromDOM( node.childNodes ); + + expect( blockNode ).toEqual( [ + 'This ', + { + type: 'strong', + props: { + class: 'is-extra-strong', + children: [ 'is' ], + }, + }, + ' a test', + ] ); + } ); +} ); diff --git a/packages/blocks/src/api/test/node.js b/packages/blocks/src/api/test/node.js new file mode 100644 index 0000000000000..447395a24ed12 --- /dev/null +++ b/packages/blocks/src/api/test/node.js @@ -0,0 +1,77 @@ +/** + * Internal dependencies + */ +import { + getNamedNodeMapAsObject, + toHTML, + fromDOM, +} from '../node'; + +describe( 'getNamedNodeMapAsObject', () => { + it( 'should return an object of node attributes', () => { + const node = document.createElement( 'img' ); + node.setAttribute( 'src', 'https://s.w.org/style/images/wporg-logo.svg' ); + + const object = getNamedNodeMapAsObject( node.attributes ); + expect( object ).toEqual( { + src: 'https://s.w.org/style/images/wporg-logo.svg', + } ); + } ); +} ); + +describe( 'toHTML', () => { + it( 'should convert a block node to its equivalent html string', () => { + const blockNode = { + type: 'strong', + props: { + class: 'is-extra-strong', + children: [ 'This is a test' ], + }, + }; + + const html = toHTML( blockNode ); + + expect( html ).toBe( 'This is a test' ); + } ); +} ); + +describe( 'fromDOM', () => { + it( 'should return a text node as its string node value', () => { + const node = document.createTextNode( 'Hello world' ); + + const blockNode = fromDOM( node ); + + expect( blockNode ).toBe( 'Hello world' ); + } ); + + it( 'should throw an error on receiving non-element/text node', () => { + expect( () => { + fromDOM( document.createDocumentFragment() ); + } ).toThrow( TypeError ); + } ); + + it( 'should return an equivalent block node, including children', () => { + const node = document.createElement( 'strong' ); + node.setAttribute( 'class', 'is-extra-strong' ); + node.innerHTML = 'Hello world!'; + + const blockNode = fromDOM( node ); + + expect( blockNode ).toEqual( { + type: 'strong', + props: { + class: 'is-extra-strong', + children: [ + 'Hello ', + { + type: 'em', + props: { + children: [ 'world' ], + }, + }, + '!', + ], + }, + } ); + } ); +} ); diff --git a/packages/element/src/serialize.js b/packages/element/src/serialize.js index c225667c4308d..0d42580162dcf 100644 --- a/packages/element/src/serialize.js +++ b/packages/element/src/serialize.js @@ -28,7 +28,15 @@ /** * External dependencies */ -import { flowRight, isEmpty, castArray, omit, startsWith, kebabCase } from 'lodash'; +import { + flowRight, + isEmpty, + castArray, + omit, + startsWith, + kebabCase, + isPlainObject, +} from 'lodash'; /** * WordPress dependencies @@ -618,6 +626,11 @@ export function renderAttributes( props ) { * @return {string} Style attribute value. */ export function renderStyle( style ) { + // Only generate from object, e.g. tolerate string value. + if ( ! isPlainObject( style ) ) { + return style; + } + let result; for ( const property in style ) { diff --git a/packages/element/src/test/serialize.js b/packages/element/src/test/serialize.js index 24727a906c876..2ffa6956e3177 100644 --- a/packages/element/src/test/serialize.js +++ b/packages/element/src/test/serialize.js @@ -516,6 +516,12 @@ describe( 'renderAttributes()', () => { } ); describe( 'renderStyle()', () => { + it( 'should return string verbatim', () => { + const result = renderStyle( 'color:red' ); + + expect( result ).toBe( 'color:red' ); + } ); + it( 'should return undefined if empty', () => { const result = renderStyle( {} ); diff --git a/test/e2e/specs/__snapshots__/writing-flow.test.js.snap b/test/e2e/specs/__snapshots__/writing-flow.test.js.snap index 578cc425e4874..5dc0ca5385785 100644 --- a/test/e2e/specs/__snapshots__/writing-flow.test.js.snap +++ b/test/e2e/specs/__snapshots__/writing-flow.test.js.snap @@ -24,6 +24,18 @@ exports[`adding blocks Should navigate inner blocks with arrow keys 1`] = ` " `; +exports[`adding blocks should clean TinyMCE content 1`] = ` +" +

+" +`; + +exports[`adding blocks should clean TinyMCE content 2`] = ` +" +

Inside

+" +`; + exports[`adding blocks should navigate around inline boundaries 1`] = ` "

FirstAfter

diff --git a/test/e2e/specs/adding-inline-tokens.test.js b/test/e2e/specs/adding-inline-tokens.test.js index 8e611b300f71e..3d0457ef80d63 100644 --- a/test/e2e/specs/adding-inline-tokens.test.js +++ b/test/e2e/specs/adding-inline-tokens.test.js @@ -41,7 +41,7 @@ describe( 'adding inline tokens', () => { await page.click( '.media-modal button.media-button-select' ); // Check the content. - const regex = new RegExp( '\\s*

a\\u00A0<\\/p>\\s*' ); + const regex = new RegExp( '\\s*

a\\u00A0<\\/p>\\s*' ); expect( await getEditedPostContent() ).toMatch( regex ); } ); } ); diff --git a/test/e2e/specs/writing-flow.test.js b/test/e2e/specs/writing-flow.test.js index 854a209c9dca5..6753e7aba078d 100644 --- a/test/e2e/specs/writing-flow.test.js +++ b/test/e2e/specs/writing-flow.test.js @@ -142,4 +142,22 @@ describe( 'adding blocks', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'should clean TinyMCE content', async () => { + // Ensure no zero-width space character. Notably, this can occur when + // save occurs while at an inline boundary edge. + await page.click( '.editor-default-block-appender__content' ); + await pressWithModifier( 'mod', 'b' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + + // When returning to Visual mode, backspace in selected block should + // reset to the provisional block. + await page.keyboard.press( 'Backspace' ); + + // Ensure no data-mce-selected. Notably, this can occur when content + // is saved while typing within an inline boundary. + await pressWithModifier( 'mod', 'b' ); + await page.keyboard.type( 'Inside' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } );