diff --git a/packages/editor/src/components/rich-text/index.js b/packages/editor/src/components/rich-text/index.js index 6eb76ddafb3193..e7bf658f683d73 100644 --- a/packages/editor/src/components/rich-text/index.js +++ b/packages/editor/src/components/rich-text/index.js @@ -592,6 +592,8 @@ export class RichText extends Component { // If we click shift+Enter on inline RichTexts, we avoid creating two contenteditables // We also split the content and call the onSplit prop if provided. if ( keyCode === ENTER ) { + event.preventDefault(); + if ( this.props.onReplace ) { const text = getTextContent( this.getRecord() ); const transformation = findTransform( this.enterPatterns, ( item ) => { @@ -603,7 +605,6 @@ export class RichText extends Component { // important that we stop other handlers (e.g. ones // registered by TinyMCE) from also handling this event. event.stopImmediatePropagation(); - event.preventDefault(); this.props.onReplace( [ transformation.transform( { content: text } ), ] ); @@ -612,27 +613,33 @@ export class RichText extends Component { } if ( this.props.multiline ) { - if ( ! this.props.onSplit ) { - return; - } - const record = this.getRecord(); - if ( ! isEmptyLine( record ) ) { - return; + if ( this.props.onSplit && isEmptyLine( record ) ) { + this.props.onSplit( ...split( record ).map( this.valueToFormat ) ); + } else { + // Character is used to separate lines in multiline values. + this.onChange( insert( record, '\u2028' ) ); + } + } else if ( event.shiftKey || ! this.props.onSplit ) { + const record = this.getRecord(); + const text = getTextContent( record ); + const length = text.length; + let toInsert = '\n'; + + // If the caret is at the end of the text, and there is no + // trailing line break or no text at all, we have to insert two + // line breaks in order to create a new line visually and place + // the caret there. + if ( record.end === length && ( + text.charAt( length - 1 ) !== '\n' || length === 0 + ) ) { + toInsert = '\n\n'; } - event.preventDefault(); - - this.props.onSplit( ...split( record ).map( this.valueToFormat ) ); + this.onChange( insert( this.getRecord(), toInsert ) ); } else { - event.preventDefault(); - - if ( event.shiftKey || ! this.props.onSplit ) { - this.editor.execCommand( 'InsertLineBreak', false, event ); - } else { - this.splitContent(); - } + this.splitContent(); } } } diff --git a/packages/rich-text/src/create-element.js b/packages/rich-text/src/create-element.js new file mode 100644 index 00000000000000..0e2f6f3572fdf8 --- /dev/null +++ b/packages/rich-text/src/create-element.js @@ -0,0 +1,13 @@ +/** + * Parse the given HTML into a body element. + * + * @param {HTMLDocument} document The HTML document to use to parse. + * @param {string} html The HTML to parse. + * + * @return {HTMLBodyElement} Body element with parsed HTML. + */ +export function createElement( { implementation }, html ) { + const { body } = implementation.createHTMLDocument( '' ); + body.innerHTML = html; + return body; +} diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index 5048f355bfeca4..8998ca7199f2a5 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -4,6 +4,7 @@ import { isEmpty } from './is-empty'; import { isFormatEqual } from './is-format-equal'; +import { createElement } from './create-element'; /** * Browser dependencies @@ -11,21 +12,6 @@ import { isFormatEqual } from './is-format-equal'; const { TEXT_NODE, ELEMENT_NODE } = window.Node; -/** - * Parse the given HTML into a body element. - * - * @param {string} html The HTML to parse. - * - * @return {HTMLBodyElement} Body element with parsed HTML. - */ -function createElement( html ) { - const htmlDocument = document.implementation.createHTMLDocument( '' ); - - htmlDocument.body.innerHTML = html; - - return htmlDocument.body; -} - function createEmptyValue() { return { formats: [], text: '' }; } @@ -74,7 +60,7 @@ export function create( { } if ( typeof html === 'string' && html.length > 0 ) { - element = createElement( html ); + element = createElement( document, html ); } if ( typeof element !== 'object' ) { @@ -147,6 +133,12 @@ function accumulateSelection( accumulator, node, range, value ) { node === endContainer.childNodes[ endOffset - 1 ] ) { accumulator.end = currentLength + value.text.length; + // Range indicates that the selection is before the current node. + } else if ( + parentNode === endContainer && + node === endContainer.childNodes[ endOffset ] + ) { + accumulator.end = currentLength; } } diff --git a/packages/rich-text/src/split.js b/packages/rich-text/src/split.js index f757186b25152e..d8b40b5aafb75b 100644 --- a/packages/rich-text/src/split.js +++ b/packages/rich-text/src/split.js @@ -32,13 +32,13 @@ export function split( { formats, text, start, end }, string ) { nextStart += string.length + substring.length; if ( start !== undefined && end !== undefined ) { - if ( start > startIndex && start < nextStart ) { + if ( start >= startIndex && start < nextStart ) { value.start = start - startIndex; } else if ( start < startIndex && end > startIndex ) { value.start = 0; } - if ( end > startIndex && end < nextStart ) { + if ( end >= startIndex && end < nextStart ) { value.end = end - startIndex; } else if ( start < nextStart && end > nextStart ) { value.end = substring.length; diff --git a/packages/rich-text/src/test/__snapshots__/to-dom.js.snap b/packages/rich-text/src/test/__snapshots__/to-dom.js.snap new file mode 100644 index 00000000000000..77cfe39a7be885 --- /dev/null +++ b/packages/rich-text/src/test/__snapshots__/to-dom.js.snap @@ -0,0 +1,301 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`recordToDom should create a value with formatting 1`] = ` + + + test + + + +`; + +exports[`recordToDom should create a value with formatting for split tags 1`] = ` + + + test + + + +`; + +exports[`recordToDom should create a value with formatting with attributes 1`] = ` + + + test + + + +`; + +exports[`recordToDom should create a value with image object 1`] = ` + + + + +`; + +exports[`recordToDom should create a value with image object and formatting 1`] = ` + + + + + + + +`; + +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 before 1`] = ` + + te + + st + + + + + +`; + +exports[`recordToDom should create a value with nested formatting 1`] = ` + + + + test + + + + +`; + +exports[`recordToDom should create a value without formatting 1`] = ` + + test + +`; + +exports[`recordToDom should create an empty value 1`] = ` + + +
+ +`; + +exports[`recordToDom should create an empty value from empty tags 1`] = ` + + +
+ +`; + +exports[`recordToDom should filter format attributes with settings 1`] = ` + + + test + + + +`; + +exports[`recordToDom should filter text at end with settings 1`] = ` + + test + +`; + +exports[`recordToDom should filter text in format with settings 1`] = ` + + + test + + + +`; + +exports[`recordToDom should filter text outside format with settings 1`] = ` + + + test + + + +`; + +exports[`recordToDom should filter text with settings 1`] = ` + + +
+ +`; + +exports[`recordToDom should handle br 1`] = ` + + +
+ + +`; + +exports[`recordToDom should handle br with formatting 1`] = ` + + + +
+ +
+ + +`; + +exports[`recordToDom should handle br with text 1`] = ` + + te +
+ st + +`; + +exports[`recordToDom should handle double br 1`] = ` + + a +
+ +
+ b + +`; + +exports[`recordToDom should handle multiline list value 1`] = ` + +
  • + one + + +
  • +
  • + three +
  • + +`; + +exports[`recordToDom should handle multiline value 1`] = ` + +

    + one +

    +

    + two +

    + +`; + +exports[`recordToDom should handle multiline value with empty 1`] = ` + +

    + one +

    +

    + +
    +

    + +`; + +exports[`recordToDom should handle selection before br 1`] = ` + + a +
    + +
    + b + +`; + +exports[`recordToDom should ignore line breaks to format HTML 1`] = ` + + +
    + +`; + +exports[`recordToDom should preserve emoji 1`] = ` + + 🍒 + +`; + +exports[`recordToDom should preserve emoji in formatting 1`] = ` + + + 🍒 + + + +`; + +exports[`recordToDom should remove br with settings 1`] = ` + + +
    + +`; + +exports[`recordToDom should remove with children with settings 1`] = ` + + two + +`; + +exports[`recordToDom should remove with settings 1`] = ` + + +
    + +`; + +exports[`recordToDom should unwrap with settings 1`] = ` + + te + + st + + + +`; diff --git a/packages/rich-text/src/test/create.js b/packages/rich-text/src/test/create.js index 200f3e6470c1d7..619c38826b67dc 100644 --- a/packages/rich-text/src/test/create.js +++ b/packages/rich-text/src/test/create.js @@ -9,526 +9,19 @@ import { JSDOM } from 'jsdom'; */ import { create } from '../create'; -import { getSparseArrayLength } from './helpers'; +import { createElement } from '../create-element'; +import { getSparseArrayLength, spec } from './helpers'; const { window } = new JSDOM(); const { document } = window; -function createElement( html ) { - const htmlDocument = document.implementation.createHTMLDocument( '' ); - - htmlDocument.body.innerHTML = html; - - return htmlDocument.body; -} - describe( 'create', () => { const em = { type: 'em' }; const strong = { type: 'strong' }; - const img = { type: 'img', attributes: { src: '' }, object: true }; - const a = { type: 'a', attributes: { href: '#' } }; - const list = [ { type: 'ul' }, { type: 'li' } ]; - - const spec = [ - { - description: 'should create an empty value', - html: '', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 0, - endContainer: element, - } ), - record: { - start: 0, - end: 0, - formats: [], - text: '', - }, - }, - { - description: 'should ignore line breaks to format HTML', - html: '\n\n\r\n', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 0, - formats: [], - text: '', - }, - }, - { - description: 'should create an empty value from empty tags', - html: '', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 0, - formats: [], - text: '', - }, - }, - { - description: 'should create a value without formatting', - html: 'test', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element.firstChild, - endOffset: 4, - endContainer: element.firstChild, - } ), - record: { - start: 0, - end: 4, - formats: [ , , , , ], - text: 'test', - }, - }, - { - description: 'should preserve emoji', - html: '🍒', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 2, - formats: [ , , ], - text: '🍒', - }, - }, - { - description: 'should preserve emoji in formatting', - html: '🍒', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 2, - formats: [ [ em ], [ em ] ], - text: '🍒', - }, - }, - { - description: 'should create a value with formatting', - html: 'test', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element.firstChild, - endOffset: 1, - endContainer: element.firstChild, - } ), - record: { - start: 0, - end: 4, - formats: [ [ em ], [ em ], [ em ], [ em ] ], - text: 'test', - }, - }, - { - description: 'should create a value with nested formatting', - html: 'test', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 4, - formats: [ [ em, strong ], [ em, strong ], [ em, strong ], [ em, strong ] ], - text: 'test', - }, - }, - { - description: 'should create a value with formatting for split tags', - html: 'test', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element.querySelector( 'em' ), - endOffset: 1, - endContainer: element.querySelector( 'em' ), - } ), - record: { - start: 0, - end: 2, - formats: [ [ em ], [ em ], [ em ], [ em ] ], - text: 'test', - }, - }, - { - description: 'should create a value with formatting with attributes', - html: 'test', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 4, - formats: [ [ a ], [ a ], [ a ], [ a ] ], - text: 'test', - }, - }, - { - description: 'should create a value with image object', - html: '', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 0, - formats: [ [ img ] ], - text: '\ufffc', - }, - }, - { - description: 'should create a value with image object and formatting', - html: '', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element.querySelector( 'img' ), - endOffset: 1, - endContainer: element.querySelector( 'img' ), - } ), - record: { - start: 0, - end: 1, - formats: [ [ em, img ] ], - text: '\ufffc', - }, - }, - { - description: 'should create a value with image object and text before', - html: 'test', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 2, - endContainer: element, - } ), - record: { - start: 0, - end: 5, - formats: [ , , [ em ], [ em ], [ em, img ] ], - text: 'test\ufffc', - }, - }, - { - description: 'should create a value with image object and text after', - html: 'test', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 2, - endContainer: element, - } ), - record: { - start: 0, - end: 5, - formats: [ [ em, img ], [ em ], [ em ], , , ], - text: '\ufffctest', - }, - }, - { - description: 'should handle br', - html: '
    ', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 0, - formats: [ , ], - text: '\n', - }, - }, - { - description: 'should handle br with text', - html: 'te
    st', - createRange: ( element ) => ( { - startOffset: 1, - startContainer: element, - endOffset: 2, - endContainer: element, - } ), - record: { - start: 2, - end: 2, - formats: [ , , , , , ], - text: 'te\nst', - }, - }, - { - description: 'should handle br with formatting', - html: '
    ', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 1, - formats: [ [ em ] ], - text: '\n', - }, - }, - { - description: 'should handle multiline value', - multilineTag: 'p', - html: '

    one

    two

    ', - createRange: ( element ) => ( { - startOffset: 1, - startContainer: element.querySelector( 'p' ).firstChild, - endOffset: 0, - endContainer: element.lastChild, - } ), - record: { - start: 1, - end: 4, - formats: [ , , , , , , , ], - text: 'one\u2028two', - }, - }, - { - description: 'should handle multiline list value', - multilineTag: 'li', - html: '
  • one
  • three
  • ', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 6, - formats: [ , , , list, list, list, , , , , , , ], - text: 'onetwo\u2028three', - }, - }, - { - description: 'should handle multiline value with empty', - multilineTag: 'p', - html: '

    one

    ', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element.lastChild, - endOffset: 0, - endContainer: element.lastChild, - } ), - record: { - start: 4, - end: 4, - formats: [ , , , , ], - text: 'one\u2028', - }, - }, - { - description: 'should remove with settings', - settings: { - unwrapNode: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), - }, - html: '', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 0, - formats: [], - text: '', - }, - }, - { - description: 'should remove br with settings', - settings: { - unwrapNode: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), - }, - html: '
    ', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 0, - formats: [], - text: '', - }, - }, - { - description: 'should unwrap with settings', - settings: { - unwrapNode: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), - }, - html: 'test', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 4, - formats: [ , , [ em ], [ em ] ], - text: 'test', - }, - }, - { - description: 'should remove with children with settings', - settings: { - removeNode: ( node ) => node.getAttribute( 'data-mce-bogus' ) === 'all', - }, - html: 'onetwo', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element.lastChild, - endOffset: 1, - endContainer: element.lastChild, - } ), - record: { - start: 0, - end: 1, - formats: [ , , , ], - text: 'two', - }, - }, - { - description: 'should filter format attributes with settings', - settings: { - removeAttribute: ( attribute ) => attribute.indexOf( 'data-mce-' ) === 0, - }, - html: 'test', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 4, - formats: [ [ strong ], [ strong ], [ strong ], [ strong ] ], - text: 'test', - }, - }, - { - description: 'should filter text with settings', - settings: { - filterString: ( string ) => string.replace( '\uFEFF', '' ), - }, - html: '', - createRange: ( element ) => ( { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - } ), - record: { - start: 0, - end: 0, - formats: [], - text: '', - }, - }, - { - description: 'should filter text at end with settings', - settings: { - filterString: ( string ) => string.replace( '\uFEFF', '' ), - }, - html: 'test', - createRange: ( element ) => ( { - startOffset: 4, - startContainer: element.firstChild, - endOffset: 4, - endContainer: element.firstChild, - } ), - record: { - start: 4, - end: 4, - formats: [ , , , , ], - text: 'test', - }, - }, - { - description: 'should filter text in format with settings', - settings: { - filterString: ( string ) => string.replace( '\uFEFF', '' ), - }, - html: 'test', - createRange: ( element ) => ( { - startOffset: 5, - startContainer: element.querySelector( 'em' ).firstChild, - endOffset: 5, - endContainer: element.querySelector( 'em' ).firstChild, - } ), - record: { - start: 4, - end: 4, - formats: [ [ em ], [ em ], [ em ], [ em ] ], - text: 'test', - }, - }, - { - description: 'should filter text outside format with settings', - settings: { - filterString: ( string ) => string.replace( '\uFEFF', '' ), - }, - html: 'test', - createRange: ( element ) => ( { - startOffset: 1, - startContainer: element.lastChild, - endOffset: 1, - endContainer: element.lastChild, - } ), - record: { - start: 4, - end: 4, - formats: [ [ em ], [ em ], [ em ], [ em ] ], - text: 'test', - }, - }, - ]; spec.forEach( ( { description, multilineTag, settings, html, createRange, record } ) => { it( description, () => { - const element = createElement( html ); + const element = createElement( document, html ); const range = createRange( element ); const createdRecord = create( { element, range, multilineTag, ...settings } ); const formatsLength = getSparseArrayLength( record.formats ); diff --git a/packages/rich-text/src/test/helpers/index.js b/packages/rich-text/src/test/helpers/index.js index 836641c8af015d..f1d5b021a34863 100644 --- a/packages/rich-text/src/test/helpers/index.js +++ b/packages/rich-text/src/test/helpers/index.js @@ -1,3 +1,600 @@ export function getSparseArrayLength( array ) { return array.reduce( ( i ) => i + 1, 0 ); } + +const em = { type: 'em' }; +const strong = { type: 'strong' }; +const img = { type: 'img', attributes: { src: '' }, object: true }; +const a = { type: 'a', attributes: { href: '#' } }; +const list = [ { type: 'ul' }, { type: 'li' } ]; + +export const spec = [ + { + description: 'should create an empty value', + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 0, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 0 ], + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should ignore line breaks to format HTML', + html: '\n\n\r\n', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 0 ], + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should create an empty value from empty tags', + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 0 ], + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should create a value without formatting', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.firstChild, + endOffset: 4, + endContainer: element.firstChild, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 4 ], + record: { + start: 0, + end: 4, + formats: [ , , , , ], + text: 'test', + }, + }, + { + description: 'should preserve emoji', + html: '🍒', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 2 ], + record: { + start: 0, + end: 2, + formats: [ , , ], + text: '🍒', + }, + }, + { + description: 'should preserve emoji in formatting', + html: '🍒', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0, 0 ], + endPath: [ 0, 0, 2 ], + record: { + start: 0, + end: 2, + formats: [ [ em ], [ em ] ], + text: '🍒', + }, + }, + { + description: 'should create a value with formatting', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.firstChild, + endOffset: 1, + endContainer: element.firstChild, + } ), + startPath: [ 0, 0, 0 ], + endPath: [ 0, 0, 4 ], + record: { + start: 0, + end: 4, + formats: [ [ em ], [ em ], [ em ], [ em ] ], + text: 'test', + }, + }, + { + description: 'should create a value with nested formatting', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0, 0, 0 ], + endPath: [ 0, 0, 0, 4 ], + record: { + start: 0, + end: 4, + formats: [ [ em, strong ], [ em, strong ], [ em, strong ], [ em, strong ] ], + text: 'test', + }, + }, + { + description: 'should create a value with formatting for split tags', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.querySelector( 'em' ), + endOffset: 1, + endContainer: element.querySelector( 'em' ), + } ), + startPath: [ 0, 0, 0 ], + endPath: [ 0, 0, 2 ], + record: { + start: 0, + end: 2, + formats: [ [ em ], [ em ], [ em ], [ em ] ], + text: 'test', + }, + }, + { + description: 'should create a value with formatting with attributes', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0, 0 ], + endPath: [ 0, 0, 4 ], + record: { + start: 0, + end: 4, + formats: [ [ a ], [ a ], [ a ], [ a ] ], + text: 'test', + }, + }, + { + description: 'should create a value with image object', + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 1, 0 ], + endPath: [ 1, 0 ], + record: { + start: 0, + end: 0, + formats: [ [ img ] ], + text: '\ufffc', + }, + }, + { + description: 'should create a value with image object and formatting', + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.querySelector( 'img' ), + endOffset: 1, + endContainer: element.querySelector( 'img' ), + } ), + startPath: [ 0, 1, 0 ], + endPath: [ 0, 1, 0 ], + record: { + start: 0, + end: 1, + formats: [ [ em, img ] ], + text: '\ufffc', + }, + }, + { + description: 'should create a value with image object and text before', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 2, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 1, 2, 0 ], + record: { + start: 0, + end: 5, + formats: [ , , [ em ], [ em ], [ em, img ] ], + text: 'test\ufffc', + }, + }, + { + description: 'should create a value with image object and text after', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 2, + endContainer: element, + } ), + startPath: [ 0, 1, 0 ], + endPath: [ 1, 2 ], + record: { + start: 0, + end: 5, + formats: [ [ em, img ], [ em ], [ em ], , , ], + text: '\ufffctest', + }, + }, + { + description: 'should handle br', + html: '
    ', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 0 ], + record: { + start: 0, + end: 0, + formats: [ , ], + text: '\n', + }, + }, + { + description: 'should handle br with text', + html: 'te
    st', + createRange: ( element ) => ( { + startOffset: 1, + startContainer: element, + endOffset: 2, + endContainer: element, + } ), + startPath: [ 0, 2 ], + endPath: [ 2, 0 ], + record: { + start: 2, + end: 3, + formats: [ , , , , , ], + text: 'te\nst', + }, + }, + { + description: 'should handle br with formatting', + html: '
    ', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0, 0 ], + endPath: [ 0, 2, 0 ], + record: { + start: 0, + end: 1, + formats: [ [ em ] ], + text: '\n', + }, + }, + { + description: 'should handle double br', + html: 'a

    b', + createRange: ( element ) => ( { + startOffset: 2, + startContainer: element, + endOffset: 3, + endContainer: element, + } ), + startPath: [ 2, 0 ], + endPath: [ 4, 0 ], + record: { + formats: [ , , , , ], + text: 'a\n\nb', + start: 2, + end: 3, + }, + }, + { + description: 'should handle selection before br', + html: 'a

    b', + createRange: ( element ) => ( { + startOffset: 2, + startContainer: element, + endOffset: 2, + endContainer: element, + } ), + startPath: [ 2, 0 ], + endPath: [ 2, 0 ], + record: { + formats: [ , , , , ], + text: 'a\n\nb', + start: 2, + end: 2, + }, + }, + { + description: 'should handle multiline value', + multilineTag: 'p', + html: '

    one

    two

    ', + createRange: ( element ) => ( { + startOffset: 1, + startContainer: element.querySelector( 'p' ).firstChild, + endOffset: 0, + endContainer: element.lastChild, + } ), + startPath: [ 0, 0, 1 ], + endPath: [ 1, 0, 0 ], + record: { + start: 1, + end: 4, + formats: [ , , , , , , , ], + text: 'one\u2028two', + }, + }, + { + description: 'should handle multiline list value', + multilineTag: 'li', + html: '
  • one
  • three
  • ', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0, 0 ], + endPath: [ 1, 0, 0 ], + record: { + start: 0, + end: 7, + formats: [ , , , list, list, list, , , , , , , ], + text: 'onetwo\u2028three', + }, + }, + { + description: 'should handle multiline value with empty', + multilineTag: 'p', + html: '

    one

    ', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.lastChild, + endOffset: 0, + endContainer: element.lastChild, + } ), + startPath: [ 1, 0, 0 ], + endPath: [ 1, 0, 0 ], + record: { + start: 4, + end: 4, + formats: [ , , , , ], + text: 'one\u2028', + }, + }, + { + description: 'should remove with settings', + settings: { + unwrapNode: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + }, + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 0 ], + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should remove br with settings', + settings: { + unwrapNode: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + }, + html: '
    ', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 0 ], + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should unwrap with settings', + settings: { + unwrapNode: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + }, + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 1, 0, 2 ], + record: { + start: 0, + end: 4, + formats: [ , , [ em ], [ em ] ], + text: 'test', + }, + }, + { + description: 'should remove with children with settings', + settings: { + removeNode: ( node ) => node.getAttribute( 'data-mce-bogus' ) === 'all', + }, + html: 'onetwo', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.lastChild, + endOffset: 1, + endContainer: element.lastChild, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 1 ], + record: { + start: 0, + end: 1, + formats: [ , , , ], + text: 'two', + }, + }, + { + description: 'should filter format attributes with settings', + settings: { + removeAttribute: ( attribute ) => attribute.indexOf( 'data-mce-' ) === 0, + }, + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0, 0 ], + endPath: [ 0, 0, 4 ], + record: { + start: 0, + end: 4, + formats: [ [ strong ], [ strong ], [ strong ], [ strong ] ], + text: 'test', + }, + }, + { + description: 'should filter text with settings', + settings: { + filterString: ( string ) => string.replace( '\uFEFF', '' ), + }, + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + startPath: [ 0, 0 ], + endPath: [ 0, 0 ], + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should filter text at end with settings', + settings: { + filterString: ( string ) => string.replace( '\uFEFF', '' ), + }, + html: 'test', + createRange: ( element ) => ( { + startOffset: 4, + startContainer: element.firstChild, + endOffset: 4, + endContainer: element.firstChild, + } ), + startPath: [ 0, 4 ], + endPath: [ 0, 4 ], + record: { + start: 4, + end: 4, + formats: [ , , , , ], + text: 'test', + }, + }, + { + description: 'should filter text in format with settings', + settings: { + filterString: ( string ) => string.replace( '\uFEFF', '' ), + }, + html: 'test', + createRange: ( element ) => ( { + startOffset: 5, + startContainer: element.querySelector( 'em' ).firstChild, + endOffset: 5, + endContainer: element.querySelector( 'em' ).firstChild, + } ), + startPath: [ 0, 0, 4 ], + endPath: [ 0, 0, 4 ], + record: { + start: 4, + end: 4, + formats: [ [ em ], [ em ], [ em ], [ em ] ], + text: 'test', + }, + }, + { + description: 'should filter text outside format with settings', + settings: { + filterString: ( string ) => string.replace( '\uFEFF', '' ), + }, + html: 'test', + createRange: ( element ) => ( { + startOffset: 1, + startContainer: element.lastChild, + endOffset: 1, + endContainer: element.lastChild, + } ), + startPath: [ 0, 0, 4 ], + endPath: [ 0, 0, 4 ], + record: { + start: 4, + end: 4, + formats: [ [ em ], [ em ], [ em ], [ em ] ], + text: 'test', + }, + }, +]; diff --git a/packages/rich-text/src/test/to-dom.js b/packages/rich-text/src/test/to-dom.js index b641085831980f..f68a3dda498980 100644 --- a/packages/rich-text/src/test/to-dom.js +++ b/packages/rich-text/src/test/to-dom.js @@ -8,149 +8,19 @@ import { JSDOM } from 'jsdom'; * Internal dependencies */ -import { create } from '../create'; import { toDom, applyValue } from '../to-dom'; +import { createElement } from '../create-element'; +import { spec } from './helpers'; const { window } = new JSDOM(); const { document } = window; -function createNode( HTML ) { - const doc = document.implementation.createHTMLDocument( '' ); - doc.body.innerHTML = HTML; - return doc.body.firstChild; -} - -function createElement( html ) { - const htmlDocument = document.implementation.createHTMLDocument( '' ); - htmlDocument.body.innerHTML = html; - return htmlDocument.body; -} - describe( 'recordToDom', () => { - it( 'should extract recreate HTML 1', () => { - const HTML = 'one two 🍒 three'; - const element = createNode( `

    ${ HTML }

    ` ); - const range = { - startOffset: 1, - startContainer: element.querySelector( 'em' ).firstChild, - endOffset: 1, - endContainer: element.querySelector( 'strong' ).firstChild, - }; - const { body, selection } = toDom( create( { element, range } ) ); - - expect( body.innerHTML ).toEqual( element.innerHTML ); - expect( selection ).toEqual( { - startPath: [ 1, 0, 1 ], - endPath: [ 3, 1, 0, 1 ], - } ); - } ); - - it( 'should extract recreate HTML 2', () => { - const HTML = 'one two 🍒 test three'; - const element = createNode( `

    ${ HTML }

    ` ); - const range = { - startOffset: 1, - startContainer: element.querySelector( 'em' ).firstChild, - endOffset: 0, - endContainer: element.querySelector( 'strong' ).firstChild, - }; - const { body, selection } = toDom( create( { element, range } ) ); - - expect( body.innerHTML ).toEqual( element.innerHTML ); - expect( selection ).toEqual( { - startPath: [ 1, 0, 1 ], - endPath: [ 3, 2, 0 ], - } ); - } ); - - it( 'should extract recreate HTML 3', () => { - const HTML = ''; - const element = createNode( `

    ${ HTML }

    ` ); - const range = { - startOffset: 0, - startContainer: element, - endOffset: 1, - endContainer: element, - }; - const { body, selection } = toDom( create( { element, range } ) ); - - expect( body.innerHTML ).toEqual( element.innerHTML ); - expect( selection ).toEqual( { - startPath: [], - endPath: [], - } ); - } ); - - it( 'should extract recreate HTML 4', () => { - const HTML = 'two 🍒'; - const element = createNode( `

    ${ HTML }

    ` ); - const range = { - startOffset: 1, - startContainer: element.querySelector( 'em' ).firstChild, - endOffset: 2, - endContainer: element.querySelector( 'em' ).firstChild, - }; - const { body, selection } = toDom( create( { element, range } ) ); - - expect( body.innerHTML ).toEqual( element.innerHTML ); - expect( selection ).toEqual( { - startPath: [ 0, 0, 1 ], - endPath: [ 0, 0, 2 ], - } ); - } ); - - it( 'should extract recreate HTML 5', () => { - const HTML = 'If you want to learn more about how to build additional blocks, or if you are interested in helping with the project, head over to the GitHub repository.'; - const element = createNode( `

    ${ HTML }

    ` ); - const range = { - startOffset: 1, - startContainer: element.querySelector( 'em' ).firstChild, - endOffset: 0, - endContainer: element.querySelector( 'a' ).firstChild, - }; - const { body, selection } = toDom( create( { element, range } ) ); - - expect( body.innerHTML ).toEqual( element.innerHTML ); - expect( selection ).toEqual( { - startPath: [ 0, 0, 1 ], - endPath: [ 0, 0, 135 ], - } ); - } ); - - it( 'should create correct selection path ', () => { - const HTML = 'test italic'; - const element = createNode( `

    ${ HTML }

    ` ); - const range = { - startOffset: 1, - startContainer: element, - endOffset: 2, - endContainer: element, - }; - const { body, selection } = toDom( create( { element, range } ) ); - - expect( body.innerHTML ).toEqual( element.innerHTML ); - expect( selection ).toEqual( { - startPath: [ 0, 5 ], - endPath: [ 1, 0, 6 ], - } ); - } ); - - it( 'should extract recreate HTML 6', () => { - const HTML = '
  • one
  • three
  • '; - const element = createNode( `` ); - const range = { - startOffset: 1, - startContainer: element.querySelector( 'li' ).firstChild, - endOffset: 2, - endContainer: element.querySelector( 'li' ).firstChild, - }; - const multilineTag = 'li'; - const { body, selection } = toDom( create( { element, range, multilineTag } ), 'li' ); - - expect( body.innerHTML ).toEqual( element.innerHTML ); - expect( selection ).toEqual( { - startPath: [ 0, 0, 1 ], - endPath: [ 0, 0, 2 ], + spec.forEach( ( { description, multilineTag, record, startPath, endPath } ) => { + it( description, () => { + const { body, selection } = toDom( record, multilineTag ); + expect( body ).toMatchSnapshot(); + expect( selection ).toEqual( { startPath, endPath } ); } ); } ); } ); @@ -179,8 +49,8 @@ describe( 'applyValue', () => { cases.forEach( ( { current, future, description, movedCount } ) => { it( description, () => { - const body = createElement( current ); - const futureBody = createElement( future ); + const body = createElement( document, current ); + const futureBody = createElement( document, future ); const childNodes = Array.from( futureBody.childNodes ); applyValue( futureBody, body ); const count = childNodes.reduce( ( acc, { parentNode } ) => { diff --git a/packages/rich-text/src/to-dom.js b/packages/rich-text/src/to-dom.js index ec8c5a610b4323..018869337c533c 100644 --- a/packages/rich-text/src/to-dom.js +++ b/packages/rich-text/src/to-dom.js @@ -137,6 +137,11 @@ export function toDom( value, multilineTag ) { endPath = [ multilineIndex, ...endPath ]; } }, + onEmpty( body ) { + const br = body.ownerDocument.createElement( 'br' ); + br.setAttribute( 'data-mce-bogus', '1' ); + body.appendChild( br ); + }, } ); return { diff --git a/packages/rich-text/src/to-tree.js b/packages/rich-text/src/to-tree.js index 8f228654c74bbb..c1ced44a16c6b1 100644 --- a/packages/rich-text/src/to-tree.js +++ b/packages/rich-text/src/to-tree.js @@ -33,6 +33,7 @@ export function toTree( value, multilineTag, settings ) { appendText, onStartIndex, onEndIndex, + onEmpty, } = settings; const { formats, text, start, end } = value; const formatsLength = formats.length + 1; @@ -70,9 +71,21 @@ export function toTree( value, multilineTag, settings ) { } ); } + // If there is selection at 0, handle it before characters are inserted. + + if ( onStartIndex && start === 0 && i === 0 ) { + onStartIndex( tree, pointer, multilineIndex ); + } + + if ( onEndIndex && end === 0 && i === 0 ) { + onEndIndex( tree, pointer, multilineIndex ); + } + if ( character !== '\ufffc' ) { if ( character === '\n' ) { pointer = append( getParent( pointer ), { type: 'br', object: true } ); + // Ensure pointer is text node. + pointer = append( getParent( pointer ), '' ); } else if ( ! isText( pointer ) ) { pointer = append( getParent( pointer ), character ); } else { @@ -89,5 +102,9 @@ export function toTree( value, multilineTag, settings ) { } } + if ( onEmpty && text.length === 0 ) { + onEmpty( tree ); + } + return tree; } diff --git a/test/e2e/specs/__snapshots__/deprecated-node-matcher.test.js.snap b/test/e2e/specs/__snapshots__/deprecated-node-matcher.test.js.snap index 7ceacf3ab3ee69..a0cc53a6c18113 100644 --- a/test/e2e/specs/__snapshots__/deprecated-node-matcher.test.js.snap +++ b/test/e2e/specs/__snapshots__/deprecated-node-matcher.test.js.snap @@ -2,12 +2,12 @@ exports[`Deprecated Node Matcher should insert block with children source 1`] = ` " -

    test
    test

    +

    test
    a

    " `; exports[`Deprecated Node Matcher should insert block with node source 1`] = ` " -

    test

    +

    test


    " `; diff --git a/test/e2e/specs/__snapshots__/writing-flow.test.js.snap b/test/e2e/specs/__snapshots__/writing-flow.test.js.snap index 70d32a6440c4b9..ee6bcfc536604e 100644 --- a/test/e2e/specs/__snapshots__/writing-flow.test.js.snap +++ b/test/e2e/specs/__snapshots__/writing-flow.test.js.snap @@ -32,6 +32,36 @@ exports[`adding blocks should clean TinyMCE content 2`] = ` " `; +exports[`adding blocks should insert line break at end 1`] = ` +" +

    a

    +" +`; + +exports[`adding blocks should insert line break at end and continue writing 1`] = ` +" +

    a
    b

    +" +`; + +exports[`adding blocks should insert line break at start 1`] = ` +" +


    a

    +" +`; + +exports[`adding blocks should insert line break in empty container 1`] = ` +" +



    +" +`; + +exports[`adding blocks should insert line break mid text 1`] = ` +" +

    a
    b

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

    FirstAfter

    diff --git a/test/e2e/specs/deprecated-node-matcher.test.js b/test/e2e/specs/deprecated-node-matcher.test.js index b78de5e4e61f5a..e9de62e3e207bb 100644 --- a/test/e2e/specs/deprecated-node-matcher.test.js +++ b/test/e2e/specs/deprecated-node-matcher.test.js @@ -35,8 +35,11 @@ describe( 'Deprecated Node Matcher', () => { await insertBlock( 'Deprecated Children Matcher' ); await page.keyboard.type( 'test' ); await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'a' ); + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.up( 'Shift' ); await pressWithModifier( META_KEY, 'b' ); - await page.keyboard.type( 'test' ); expect( console ).toHaveWarned(); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); diff --git a/test/e2e/specs/writing-flow.test.js b/test/e2e/specs/writing-flow.test.js index dfc6bf625c7a6d..b5a088d112f860 100644 --- a/test/e2e/specs/writing-flow.test.js +++ b/test/e2e/specs/writing-flow.test.js @@ -160,4 +160,41 @@ describe( 'adding blocks', () => { await page.keyboard.type( 'Inside' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'should insert line break at end', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'a' ); + await pressWithModifier( 'Shift', 'Enter' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should insert line break at end and continue writing', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'a' ); + await pressWithModifier( 'Shift', 'Enter' ); + await page.keyboard.type( 'b' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should insert line break mid text', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'ab' ); + await page.keyboard.press( 'ArrowLeft' ); + await pressWithModifier( 'Shift', 'Enter' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should insert line break at start', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'a' ); + await page.keyboard.press( 'ArrowLeft' ); + await pressWithModifier( 'Shift', 'Enter' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should insert line break in empty container', async () => { + await clickBlockAppender(); + await pressWithModifier( 'Shift', 'Enter' ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } );