From c30eeedf18abb9176a1de5d3fc5c887f03bc25d7 Mon Sep 17 00:00:00 2001 From: iseulde Date: Mon, 30 Jul 2018 18:46:39 +0200 Subject: [PATCH] RichText state --- core-blocks/audio/edit.js | 2 +- core-blocks/audio/index.js | 4 +- core-blocks/button/index.js | 4 +- core-blocks/cover-image/index.js | 10 +- .../test/__snapshots__/index.js.snap | 33 +- core-blocks/embed/index.js | 7 +- core-blocks/gallery/index.js | 4 +- core-blocks/heading/index.js | 4 +- core-blocks/image/edit.js | 2 +- core-blocks/image/index.js | 10 +- core-blocks/list/index.js | 35 +- core-blocks/paragraph/index.js | 11 +- core-blocks/preformatted/index.js | 4 +- core-blocks/pullquote/index.js | 37 +- core-blocks/quote/index.js | 106 +- core-blocks/subhead/index.js | 4 +- core-blocks/table/index.js | 27 +- .../test/fixtures/core-embed__animoto.json | 37 +- .../test/fixtures/core-embed__cloudup.json | 37 +- .../fixtures/core-embed__collegehumor.json | 42 +- .../fixtures/core-embed__dailymotion.json | 41 +- .../test/fixtures/core-embed__facebook.json | 38 +- .../test/fixtures/core-embed__flickr.json | 36 +- .../test/fixtures/core-embed__funnyordie.json | 40 +- .../test/fixtures/core-embed__hulu.json | 34 +- .../test/fixtures/core-embed__imgur.json | 35 +- .../test/fixtures/core-embed__instagram.json | 39 +- .../test/fixtures/core-embed__issuu.json | 35 +- .../fixtures/core-embed__kickstarter.json | 41 +- .../test/fixtures/core-embed__meetup-com.json | 40 +- .../test/fixtures/core-embed__mixcloud.json | 38 +- .../fixtures/core-embed__photobucket.json | 41 +- .../test/fixtures/core-embed__polldaddy.json | 39 +- .../test/fixtures/core-embed__reddit.json | 36 +- .../fixtures/core-embed__reverbnation.json | 42 +- .../test/fixtures/core-embed__screencast.json | 40 +- .../test/fixtures/core-embed__scribd.json | 36 +- .../test/fixtures/core-embed__slideshare.json | 40 +- .../test/fixtures/core-embed__smugmug.json | 37 +- .../test/fixtures/core-embed__soundcloud.json | 40 +- .../fixtures/core-embed__speakerdeck.json | 50 + .../test/fixtures/core-embed__spotify.json | 37 +- .../test/fixtures/core-embed__ted.json | 33 +- .../test/fixtures/core-embed__tumblr.json | 36 +- .../test/fixtures/core-embed__twitter.json | 25 +- .../test/fixtures/core-embed__videopress.json | 40 +- .../test/fixtures/core-embed__vimeo.json | 35 +- .../fixtures/core-embed__wordpress-tv.json | 42 +- .../test/fixtures/core-embed__wordpress.json | 39 +- .../test/fixtures/core-embed__youtube.json | 37 +- .../test/fixtures/core__button__center.json | 28 +- core-blocks/test/fixtures/core__column.json | 66 +- core-blocks/test/fixtures/core__columns.json | 134 +- .../test/fixtures/core__cover-image.json | 19 +- core-blocks/test/fixtures/core__embed.json | 44 +- core-blocks/test/fixtures/core__gallery.json | 10 +- .../test/fixtures/core__gallery__columns.json | 10 +- .../test/fixtures/core__heading__h2-em.json | 64 +- .../test/fixtures/core__heading__h2.json | 66 +- core-blocks/test/fixtures/core__image.json | 5 +- .../core__image__attachment-link.json | 5 +- .../fixtures/core__image__center-caption.json | 75 +- .../fixtures/core__image__custom-link.json | 5 +- .../fixtures/core__image__media-link.json | 5 +- core-blocks/test/fixtures/core__list__ul.json | 257 +++- .../core__paragraph__align-right.json | 78 +- .../test/fixtures/core__preformatted.json | 103 +- .../core__preformatted.serialized.html | 2 +- .../test/fixtures/core__pullquote.json | 62 +- .../core__pullquote__multi-paragraph.json | 87 +- .../test/fixtures/core__quote__style-1.json | 271 +++- .../test/fixtures/core__quote__style-2.json | 97 +- core-blocks/test/fixtures/core__subhead.json | 61 +- core-blocks/test/fixtures/core__table.html | 1 - core-blocks/test/fixtures/core__table.json | 265 ---- .../test/fixtures/core__table.parsed.json | 2 +- .../test/fixtures/core__table.serialized.html | 3 - .../test/fixtures/core__text-columns.json | 22 +- .../core__text__converts-to-paragraph.json | 115 +- core-blocks/test/fixtures/core__verse.json | 58 +- .../test/fixtures/core__verse.serialized.html | 2 +- core-blocks/test/fixtures/core__video.json | 5 +- core-blocks/test/full-content.js | 33 +- core-blocks/text-columns/index.js | 4 +- core-blocks/verse/index.js | 4 +- core-blocks/video/edit.js | 2 +- core-blocks/video/index.js | 6 +- packages/blocks/src/api/children.js | 132 -- packages/blocks/src/api/factory.js | 9 +- packages/blocks/src/api/index.js | 5 +- packages/blocks/src/api/matchers.js | 33 +- packages/blocks/src/api/node.js | 112 -- packages/blocks/src/api/parser.js | 10 +- .../blocks/src/api/rich-text-structure.js | 653 ++++++++++ packages/blocks/src/api/test/children.js | 137 -- packages/blocks/src/api/test/matchers.js | 50 - packages/blocks/src/api/test/node.js | 77 -- .../src/api/test/rich-text-structure.js | 1107 +++++++++++++++++ packages/components/src/autocomplete/index.js | 316 ++--- .../components/src/autocomplete/test/index.js | 883 ------------- .../src/components/autocompleters/block.js | 15 +- .../src/components/autocompleters/user.js | 15 +- .../rich-text/format-toolbar/index.js | 107 +- .../editor/src/components/rich-text/format.js | 253 +--- .../editor/src/components/rich-text/index.js | 372 +++--- .../src/components/rich-text/patterns.js | 254 +--- .../test/__snapshots__/format.js.snap | 44 - .../src/components/rich-text/test/format.js | 213 ---- .../src/components/rich-text/test/index.js | 59 +- .../src/components/rich-text/tinymce.js | 4 +- test/integration/fixtures/evernote-out.html | 4 +- .../integration/fixtures/google-docs-out.html | 6 +- test/integration/fixtures/markdown-out.html | 2 +- .../fixtures/ms-word-online-out.html | 10 +- test/integration/fixtures/plain-out.html | 4 +- 115 files changed, 5070 insertions(+), 3454 deletions(-) create mode 100644 core-blocks/test/fixtures/core-embed__speakerdeck.json delete mode 100644 core-blocks/test/fixtures/core__table.json delete mode 100644 core-blocks/test/fixtures/core__table.serialized.html delete mode 100644 packages/blocks/src/api/children.js delete mode 100644 packages/blocks/src/api/node.js create mode 100644 packages/blocks/src/api/rich-text-structure.js delete mode 100644 packages/blocks/src/api/test/children.js delete mode 100644 packages/blocks/src/api/test/matchers.js delete mode 100644 packages/blocks/src/api/test/node.js create mode 100644 packages/blocks/src/api/test/rich-text-structure.js delete mode 100644 packages/components/src/autocomplete/test/index.js delete mode 100644 packages/editor/src/components/rich-text/test/__snapshots__/format.js.snap delete mode 100644 packages/editor/src/components/rich-text/test/format.js diff --git a/core-blocks/audio/edit.js b/core-blocks/audio/edit.js index 3f2591abc70d8..cd566f1215b20 100644 --- a/core-blocks/audio/edit.js +++ b/core-blocks/audio/edit.js @@ -135,7 +135,7 @@ class AudioEdit extends Component {
); }, diff --git a/core-blocks/button/index.js b/core-blocks/button/index.js index c69ad0d7c52d8..bfc2703e5b58f 100644 --- a/core-blocks/button/index.js +++ b/core-blocks/button/index.js @@ -33,8 +33,8 @@ const blockAttributes = { attribute: 'title', }, text: { - type: 'array', - source: 'children', + type: 'object', + source: 'rich-text', selector: 'a', }, backgroundColor: { diff --git a/core-blocks/cover-image/index.js b/core-blocks/cover-image/index.js index 332c3c38143a7..8daa270d920fb 100644 --- a/core-blocks/cover-image/index.js +++ b/core-blocks/cover-image/index.js @@ -31,8 +31,8 @@ const validAlignments = [ 'left', 'center', 'right', 'wide', 'full' ]; const blockAttributes = { title: { - type: 'array', - source: 'children', + type: 'object', + source: 'rich-text', selector: 'p', }, url: { @@ -245,7 +245,7 @@ export const settings = { return (
- { title && title.length > 0 && ( + { ! RichText.isEmpty( title ) && ( ) }
@@ -256,8 +256,8 @@ export const settings = { attributes: { ...blockAttributes, title: { - type: 'array', - source: 'children', + type: 'object', + source: 'rich-text', selector: 'h2', }, }, diff --git a/core-blocks/cover-image/test/__snapshots__/index.js.snap b/core-blocks/cover-image/test/__snapshots__/index.js.snap index f6690fdae6fe9..40be79ecf3f6d 100644 --- a/core-blocks/cover-image/test/__snapshots__/index.js.snap +++ b/core-blocks/cover-image/test/__snapshots__/index.js.snap @@ -7,21 +7,26 @@ exports[`core/cover-image block edit matches snapshot 1`] = `
- - Cover Image +
+
+
+
+
+
+
{ `\n${ url }\n` /* URL needs to be on its own line. */ } - { caption && caption.length > 0 && } + { ! RichText.isEmpty( caption ) && } ); }, diff --git a/core-blocks/gallery/index.js b/core-blocks/gallery/index.js index e02377a8ff345..51668065e6d79 100644 --- a/core-blocks/gallery/index.js +++ b/core-blocks/gallery/index.js @@ -46,8 +46,8 @@ const blockAttributes = { attribute: 'data-id', }, caption: { - type: 'array', - source: 'children', + type: 'object', + source: 'rich-text', selector: 'figcaption', }, }, diff --git a/core-blocks/heading/index.js b/core-blocks/heading/index.js index cbad44d4f0ffe..a14e0b88b482e 100644 --- a/core-blocks/heading/index.js +++ b/core-blocks/heading/index.js @@ -39,8 +39,8 @@ const supports = { const schema = { content: { - type: 'array', - source: 'children', + type: 'object', + source: 'rich-text', selector: 'h1,h2,h3,h4,h5,h6', }, level: { diff --git a/core-blocks/image/edit.js b/core-blocks/image/edit.js index 8b2c728906983..f50a3c59bc79d 100644 --- a/core-blocks/image/edit.js +++ b/core-blocks/image/edit.js @@ -467,7 +467,7 @@ class ImageEdit extends Component { ); } } - { ( caption && caption.length > 0 ) || isSelected ? ( + { ! RichText.isEmpty( caption ) || isSelected ? ( { href ? { image } : image } - { caption && caption.length > 0 && } + { ! RichText.isEmpty( caption ) && } ); }, @@ -244,7 +244,7 @@ export const settings = { return (
{ href ? { image } : image } - { caption && caption.length > 0 && } + { ! RichText.isEmpty( caption ) && }
); }, @@ -267,7 +267,7 @@ export const settings = { return (
{ href ? { image } : image } - { caption && caption.length > 0 && } + { ! RichText.isEmpty( caption ) && }
); }, diff --git a/core-blocks/list/index.js b/core-blocks/list/index.js index f6788f435d111..cec87c3f4666b 100644 --- a/core-blocks/list/index.js +++ b/core-blocks/list/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { find, compact, get, initial, last, isEmpty, omit } from 'lodash'; +import { find, omit } from 'lodash'; /** * WordPress dependencies @@ -52,9 +52,9 @@ const schema = { }, values: { type: 'array', - source: 'children', + source: 'rich-text', selector: 'ol,ul', - default: [], + multiline: 'li', }, }; @@ -78,10 +78,8 @@ export const settings = { isMultiBlock: true, blocks: [ 'core/paragraph' ], transform: ( blockAttributes ) => { - const items = blockAttributes.map( ( { content } ) => content ); - const hasItems = ! items.every( isEmpty ); return createBlock( 'core/list', { - values: hasItems ? items.map( ( content, index ) =>
  • { content }
  • ) : [], + values: blockAttributes.map( ( { content } ) => content ), } ); }, }, @@ -89,13 +87,12 @@ export const settings = { type: 'block', blocks: [ 'core/quote' ], transform: ( { value, citation } ) => { - const items = value.map( ( p ) => get( p, [ 'children', 'props', 'children' ] ) ); - if ( ! isEmpty( citation ) ) { - items.push( citation ); + if ( ! RichText.isEmpty( citation ) ) { + value.push( citation ); } - const hasItems = ! items.every( isEmpty ); + return createBlock( 'core/list', { - values: hasItems ? items.map( ( content, index ) =>
  • { content }
  • ) : [], + values: value, } ); }, }, @@ -121,7 +118,7 @@ export const settings = { regExp: /^[*-]\s/, transform: ( { content } ) => { return createBlock( 'core/list', { - values: [
  • { content }
  • ], + values: [ content ], } ); }, }, @@ -131,7 +128,7 @@ export const settings = { transform: ( { content } ) => { return createBlock( 'core/list', { ordered: true, - values: [
  • { content }
  • ], + values: [ content ], } ); }, }, @@ -141,20 +138,14 @@ export const settings = { type: 'block', blocks: [ 'core/paragraph' ], transform: ( { values } ) => - compact( values.map( ( value ) => get( value, [ 'props', 'children' ], null ) ) ) - .map( ( content ) => createBlock( 'core/paragraph', { - content: [ content ], - } ) ), + values.map( ( content ) => createBlock( 'core/paragraph', { content } ) ), }, { type: 'block', blocks: [ 'core/quote' ], transform: ( { values } ) => { return createBlock( 'core/quote', { - value: compact( ( values.length === 1 ? values : initial( values ) ) - .map( ( value ) => get( value, [ 'props', 'children' ], null ) ) ) - .map( ( children ) => ( { children:

    { children }

    } ) ), - citation: ( values.length === 1 ? undefined : [ get( last( values ), [ 'props', 'children' ] ) ] ), + value: values, } ); }, }, @@ -370,7 +361,7 @@ export const settings = { const tagName = ordered ? 'ol' : 'ul'; return ( - + ); }, }; diff --git a/core-blocks/paragraph/index.js b/core-blocks/paragraph/index.js index f1ebe00170930..39e9e7053ca9b 100644 --- a/core-blocks/paragraph/index.js +++ b/core-blocks/paragraph/index.js @@ -34,7 +34,6 @@ import { import { createBlock, getPhrasingContentSchema, - children, } from '@wordpress/blocks'; import { compose } from '@wordpress/compose'; @@ -246,10 +245,9 @@ const supports = { const schema = { content: { - type: 'array', - source: 'children', + type: 'object', + source: 'rich-text', selector: 'p', - default: [], }, align: { type: 'string', @@ -425,10 +423,7 @@ export const settings = { merge( attributes, attributesToMerge ) { return { - content: children.concat( - attributes.content, - attributesToMerge.content - ), + content: RichText.concat( attributes.content, attributesToMerge.content ), }; }, diff --git a/core-blocks/preformatted/index.js b/core-blocks/preformatted/index.js index 1b7e1255256e5..71dc64ad53487 100644 --- a/core-blocks/preformatted/index.js +++ b/core-blocks/preformatted/index.js @@ -24,8 +24,8 @@ export const settings = { attributes: { content: { - type: 'array', - source: 'children', + type: 'object', + source: 'rich-text', selector: 'pre', }, }, diff --git a/core-blocks/pullquote/index.js b/core-blocks/pullquote/index.js index 02faeed99d0d8..e93bcb81f6537 100644 --- a/core-blocks/pullquote/index.js +++ b/core-blocks/pullquote/index.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { map } from 'lodash'; - /** * WordPress dependencies */ @@ -18,24 +13,16 @@ import './editor.scss'; import './style.scss'; import './theme.scss'; -const toRichTextValue = ( value ) => map( value, ( ( subValue ) => subValue.children ) ); -const fromRichTextValue = ( value ) => map( value, ( subValue ) => ( { - children: subValue, -} ) ); const blockAttributes = { value: { type: 'array', - source: 'query', - selector: 'blockquote > p', - query: { - children: { - source: 'node', - }, - }, + source: 'rich-text', + selector: 'blockquote', + multiline: 'p', }, citation: { - type: 'array', - source: 'children', + type: 'object', + source: 'rich-text', selector: 'cite', }, }; @@ -65,17 +52,17 @@ export const settings = {
    setAttributes( { - value: fromRichTextValue( nextValue ), + value: nextValue, } ) } /* translators: the text of the quotation */ placeholder={ __( 'Write quote…' ) } wrapperClassName="core-blocks-pullquote__content" /> - { ( citation || isSelected ) && ( + { ( ! RichText.isEmpty( citation ) || isSelected ) && ( - - { citation && citation.length > 0 && } + + { ! RichText.isEmpty( citation ) && }
    ); }, @@ -122,8 +109,8 @@ export const settings = { return (
    - - { citation && citation.length > 0 && } + + { ! RichText.isEmpty( citation ) && }
    ); }, diff --git a/core-blocks/quote/index.js b/core-blocks/quote/index.js index 34903efa574c6..2c472908136c5 100644 --- a/core-blocks/quote/index.js +++ b/core-blocks/quote/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { castArray, get, isString, isEmpty, omit } from 'lodash'; +import { omit } from 'lodash'; /** * WordPress dependencies @@ -22,26 +22,16 @@ import './style.scss'; import './editor.scss'; import './theme.scss'; -const toRichTextValue = ( value ) => value.map( ( ( subValue ) => subValue.children ) ); -const fromRichTextValue = ( value ) => value.map( ( subValue ) => ( { - children: subValue, -} ) ); - const blockAttributes = { value: { type: 'array', - source: 'query', - selector: 'blockquote > p', - query: { - children: { - source: 'node', - }, - }, - default: [], + source: 'rich-text', + selector: 'blockquote', + multiline: 'p', }, citation: { - type: 'array', - source: 'children', + type: 'object', + source: 'rich-text', selector: 'cite', }, align: { @@ -71,12 +61,8 @@ export const settings = { isMultiBlock: true, blocks: [ 'core/paragraph' ], transform: ( attributes ) => { - const items = attributes.map( ( { content } ) => content ); - const hasItems = ! items.every( isEmpty ); return createBlock( 'core/quote', { - value: hasItems ? - items.map( ( content, index ) => ( { children:

    { content }

    } ) ) : - [], + value: attributes.map( ( { content } ) => content ), } ); }, }, @@ -85,9 +71,7 @@ export const settings = { blocks: [ 'core/heading' ], transform: ( { content } ) => { return createBlock( 'core/quote', { - value: [ - { children:

    { content }

    }, - ], + value: [ content ], } ); }, }, @@ -96,9 +80,7 @@ export const settings = { regExp: /^>\s/, transform: ( { content } ) => { return createBlock( 'core/quote', { - value: [ - { children:

    { content }

    }, - ], + value: [ content ], } ); }, }, @@ -121,16 +103,11 @@ export const settings = { type: 'block', blocks: [ 'core/paragraph' ], transform: ( { value, citation } ) => { - // transforming an empty quote - if ( ( ! value || ! value.length ) && ! citation ) { - return createBlock( 'core/paragraph' ); + if ( ! RichText.isEmpty( citation ) ) { + value.push( citation ); } - // transforming a quote with content - return ( value || [] ).map( ( item ) => createBlock( 'core/paragraph', { - content: [ get( item, [ 'children', 'props', 'children' ], '' ) ], - } ) ).concat( citation ? createBlock( 'core/paragraph', { - content: citation, - } ) : [] ); + + return value.map( ( content ) => createBlock( 'core/paragraph', { content } ) ); }, }, { @@ -139,39 +116,22 @@ export const settings = { transform: ( { value, citation, ...attrs } ) => { // if no text content exist just transform the quote into an heading block // using citation as the content, it may be empty creating an empty heading block. - if ( ( ! value || ! value.length ) ) { + if ( RichText.isEmpty( value ) ) { return createBlock( 'core/heading', { content: citation, } ); } - const firstValue = get( value, [ 0, 'children' ] ); - const headingContent = castArray( isString( firstValue ) ? - firstValue : - get( firstValue, [ 'props', 'children' ], '' ) - ); - - // if the quote content just contains a paragraph and no citation exist - // convert the quote content into and heading block. - if ( ! citation && value.length === 1 ) { - return createBlock( 'core/heading', { - content: headingContent, - } ); - } - - // In the normal case convert the first paragraph of quote into an heading - // and create a new quote block equal tl what we had excluding the first paragraph - const heading = createBlock( 'core/heading', { - content: headingContent, - } ); - - const quote = createBlock( 'core/quote', { - ...attrs, - citation, - value: value.slice( 1 ), - } ); - - return [ heading, quote ]; + return [ + createBlock( 'core/heading', { + content: value[ 0 ], + } ), + createBlock( 'core/quote', { + ...attrs, + citation, + value: value.slice( 1 ), + } ), + ]; }, }, ], @@ -193,10 +153,10 @@ export const settings = {
    setAttributes( { - value: fromRichTextValue( nextValue ), + value: nextValue, } ) } onMerge={ mergeBlocks } @@ -209,7 +169,7 @@ export const settings = { /* translators: the text of the quotation */ placeholder={ __( 'Write quote…' ) } /> - { ( ( citation && citation.length > 0 ) || isSelected ) && ( + { ( ! RichText.isEmpty( citation ) || isSelected ) && ( - - { citation && citation.length > 0 && } + + { ! RichText.isEmpty( citation ) && }
    ); }, @@ -267,8 +227,8 @@ export const settings = { className={ style === 2 ? 'is-large' : '' } style={ { textAlign: align ? align : null } } > - - { citation && citation.length > 0 && } + + { ! RichText.isEmpty( citation ) && } ); }, @@ -295,8 +255,8 @@ export const settings = { className={ `blocks-quote-style-${ style }` } style={ { textAlign: align ? align : null } } > - - { citation && citation.length > 0 && } + + { ! RichText.isEmpty( citation ) && } ); }, diff --git a/core-blocks/subhead/index.js b/core-blocks/subhead/index.js index 552d91ce47c73..12f6d6e7c987e 100644 --- a/core-blocks/subhead/index.js +++ b/core-blocks/subhead/index.js @@ -33,8 +33,8 @@ export const settings = { attributes: { content: { - type: 'array', - source: 'children', + type: 'object', + source: 'rich-text', selector: 'p', }, align: { diff --git a/core-blocks/table/index.js b/core-blocks/table/index.js index 41c33d78699d8..cc69c87266aa1 100644 --- a/core-blocks/table/index.js +++ b/core-blocks/table/index.js @@ -65,16 +65,25 @@ export const settings = { category: 'formatting', attributes: { - content: { + rows: { type: 'array', - source: 'children', - selector: 'table', - default: [ - -

    -

    - , - ], + default: [], + source: 'query', + selector: 'tr', + query: { + cells: { + type: 'array', + default: [], + source: 'query', + selector: 'th,tr', + query: { + cell: { + type: 'object', + source: 'rich-text', + }, + }, + }, + }, }, hasFixedLayout: { type: 'boolean', diff --git a/core-blocks/test/fixtures/core-embed__animoto.json b/core-blocks/test/fixtures/core-embed__animoto.json index 136fce645d4ee..f4969f0201363 100644 --- a/core-blocks/test/fixtures/core-embed__animoto.json +++ b/core-blocks/test/fixtures/core-embed__animoto.json @@ -5,9 +5,40 @@ "isValid": true, "attributes": { "url": "https://animoto.com/", - "caption": [ - "Embedded content from animoto" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from animoto" + } }, "innerBlocks": [], "originalContent": "
    \n https://animoto.com/\n
    Embedded content from animoto
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__cloudup.json b/core-blocks/test/fixtures/core-embed__cloudup.json index 88e4a195e1ea4..ec534db4c790d 100644 --- a/core-blocks/test/fixtures/core-embed__cloudup.json +++ b/core-blocks/test/fixtures/core-embed__cloudup.json @@ -5,9 +5,40 @@ "isValid": true, "attributes": { "url": "https://cloudup.com/", - "caption": [ - "Embedded content from cloudup" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from cloudup" + } }, "innerBlocks": [], "originalContent": "
    \n https://cloudup.com/\n
    Embedded content from cloudup
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__collegehumor.json b/core-blocks/test/fixtures/core-embed__collegehumor.json index 86722305b5ab0..59ef71bf35d67 100644 --- a/core-blocks/test/fixtures/core-embed__collegehumor.json +++ b/core-blocks/test/fixtures/core-embed__collegehumor.json @@ -5,9 +5,45 @@ "isValid": true, "attributes": { "url": "https://collegehumor.com/", - "caption": [ - "Embedded content from collegehumor" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from collegehumor" + } }, "innerBlocks": [], "originalContent": "
    \n https://collegehumor.com/\n
    Embedded content from collegehumor
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__dailymotion.json b/core-blocks/test/fixtures/core-embed__dailymotion.json index 317a615c5c78a..d2272321f8e54 100644 --- a/core-blocks/test/fixtures/core-embed__dailymotion.json +++ b/core-blocks/test/fixtures/core-embed__dailymotion.json @@ -5,9 +5,44 @@ "isValid": true, "attributes": { "url": "https://dailymotion.com/", - "caption": [ - "Embedded content from dailymotion" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from dailymotion" + } }, "innerBlocks": [], "originalContent": "
    \n https://dailymotion.com/\n
    Embedded content from dailymotion
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__facebook.json b/core-blocks/test/fixtures/core-embed__facebook.json index df9b5d5cb6e82..e7df024966845 100644 --- a/core-blocks/test/fixtures/core-embed__facebook.json +++ b/core-blocks/test/fixtures/core-embed__facebook.json @@ -5,9 +5,41 @@ "isValid": true, "attributes": { "url": "https://facebook.com/", - "caption": [ - "Embedded content from facebook" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from facebook" + } }, "innerBlocks": [], "originalContent": "
    \n https://facebook.com/\n
    Embedded content from facebook
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__flickr.json b/core-blocks/test/fixtures/core-embed__flickr.json index 103a3912a7ab4..47ca065d97ca1 100644 --- a/core-blocks/test/fixtures/core-embed__flickr.json +++ b/core-blocks/test/fixtures/core-embed__flickr.json @@ -5,9 +5,39 @@ "isValid": true, "attributes": { "url": "https://flickr.com/", - "caption": [ - "Embedded content from flickr" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from flickr" + } }, "innerBlocks": [], "originalContent": "
    \n https://flickr.com/\n
    Embedded content from flickr
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__funnyordie.json b/core-blocks/test/fixtures/core-embed__funnyordie.json index c8e313cd80e43..39b454c8d1915 100644 --- a/core-blocks/test/fixtures/core-embed__funnyordie.json +++ b/core-blocks/test/fixtures/core-embed__funnyordie.json @@ -5,9 +5,43 @@ "isValid": true, "attributes": { "url": "https://funnyordie.com/", - "caption": [ - "Embedded content from funnyordie" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from funnyordie" + } }, "innerBlocks": [], "originalContent": "
    \n https://funnyordie.com/\n
    Embedded content from funnyordie
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__hulu.json b/core-blocks/test/fixtures/core-embed__hulu.json index b9185cd0f68fb..43ac673306c7b 100644 --- a/core-blocks/test/fixtures/core-embed__hulu.json +++ b/core-blocks/test/fixtures/core-embed__hulu.json @@ -5,9 +5,37 @@ "isValid": true, "attributes": { "url": "https://hulu.com/", - "caption": [ - "Embedded content from hulu" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from hulu" + } }, "innerBlocks": [], "originalContent": "
    \n https://hulu.com/\n
    Embedded content from hulu
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__imgur.json b/core-blocks/test/fixtures/core-embed__imgur.json index a80705b701127..be3a53014b0c6 100644 --- a/core-blocks/test/fixtures/core-embed__imgur.json +++ b/core-blocks/test/fixtures/core-embed__imgur.json @@ -5,9 +5,38 @@ "isValid": true, "attributes": { "url": "https://imgur.com/", - "caption": [ - "Embedded content from imgur" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from imgur" + } }, "innerBlocks": [], "originalContent": "
    \n https://imgur.com/\n
    Embedded content from imgur
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__instagram.json b/core-blocks/test/fixtures/core-embed__instagram.json index fb19a254a1040..dc94fd3b5a1e8 100644 --- a/core-blocks/test/fixtures/core-embed__instagram.json +++ b/core-blocks/test/fixtures/core-embed__instagram.json @@ -5,9 +5,42 @@ "isValid": true, "attributes": { "url": "https://instagram.com/", - "caption": [ - "Embedded content from instagram" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from instagram" + } }, "innerBlocks": [], "originalContent": "
    \n https://instagram.com/\n
    Embedded content from instagram
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__issuu.json b/core-blocks/test/fixtures/core-embed__issuu.json index 91fc678f576d4..9955b8ae1e555 100644 --- a/core-blocks/test/fixtures/core-embed__issuu.json +++ b/core-blocks/test/fixtures/core-embed__issuu.json @@ -5,9 +5,38 @@ "isValid": true, "attributes": { "url": "https://issuu.com/", - "caption": [ - "Embedded content from issuu" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from issuu" + } }, "innerBlocks": [], "originalContent": "
    \n https://issuu.com/\n
    Embedded content from issuu
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__kickstarter.json b/core-blocks/test/fixtures/core-embed__kickstarter.json index a0c4f3d932bbd..964f8eac58e13 100644 --- a/core-blocks/test/fixtures/core-embed__kickstarter.json +++ b/core-blocks/test/fixtures/core-embed__kickstarter.json @@ -5,9 +5,44 @@ "isValid": true, "attributes": { "url": "https://kickstarter.com/", - "caption": [ - "Embedded content from kickstarter" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from kickstarter" + } }, "innerBlocks": [], "originalContent": "
    \n https://kickstarter.com/\n
    Embedded content from kickstarter
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__meetup-com.json b/core-blocks/test/fixtures/core-embed__meetup-com.json index e6e4e10f91e7d..90aef74884e9f 100644 --- a/core-blocks/test/fixtures/core-embed__meetup-com.json +++ b/core-blocks/test/fixtures/core-embed__meetup-com.json @@ -5,9 +5,43 @@ "isValid": true, "attributes": { "url": "https://meetup.com/", - "caption": [ - "Embedded content from meetup-com" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from meetup-com" + } }, "innerBlocks": [], "originalContent": "
    \n https://meetup.com/\n
    Embedded content from meetup-com
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__mixcloud.json b/core-blocks/test/fixtures/core-embed__mixcloud.json index 6155fa124229f..c137b0523701c 100644 --- a/core-blocks/test/fixtures/core-embed__mixcloud.json +++ b/core-blocks/test/fixtures/core-embed__mixcloud.json @@ -5,9 +5,41 @@ "isValid": true, "attributes": { "url": "https://mixcloud.com/", - "caption": [ - "Embedded content from mixcloud" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from mixcloud" + } }, "innerBlocks": [], "originalContent": "
    \n https://mixcloud.com/\n
    Embedded content from mixcloud
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__photobucket.json b/core-blocks/test/fixtures/core-embed__photobucket.json index d4a5a6031dab5..4da6c59239529 100644 --- a/core-blocks/test/fixtures/core-embed__photobucket.json +++ b/core-blocks/test/fixtures/core-embed__photobucket.json @@ -5,9 +5,44 @@ "isValid": true, "attributes": { "url": "https://photobucket.com/", - "caption": [ - "Embedded content from photobucket" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from photobucket" + } }, "innerBlocks": [], "originalContent": "
    \n https://photobucket.com/\n
    Embedded content from photobucket
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__polldaddy.json b/core-blocks/test/fixtures/core-embed__polldaddy.json index 1b4fd5fe9c9f8..6daa433d5f62a 100644 --- a/core-blocks/test/fixtures/core-embed__polldaddy.json +++ b/core-blocks/test/fixtures/core-embed__polldaddy.json @@ -5,9 +5,42 @@ "isValid": true, "attributes": { "url": "https://polldaddy.com/", - "caption": [ - "Embedded content from polldaddy" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from polldaddy" + } }, "innerBlocks": [], "originalContent": "
    \n https://polldaddy.com/\n
    Embedded content from polldaddy
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__reddit.json b/core-blocks/test/fixtures/core-embed__reddit.json index d3c0a401484be..e9c3bacd7bc51 100644 --- a/core-blocks/test/fixtures/core-embed__reddit.json +++ b/core-blocks/test/fixtures/core-embed__reddit.json @@ -5,9 +5,39 @@ "isValid": true, "attributes": { "url": "https://reddit.com/", - "caption": [ - "Embedded content from reddit" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from reddit" + } }, "innerBlocks": [], "originalContent": "
    \n https://reddit.com/\n
    Embedded content from reddit
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__reverbnation.json b/core-blocks/test/fixtures/core-embed__reverbnation.json index 4114e247dda08..070d416c0ea47 100644 --- a/core-blocks/test/fixtures/core-embed__reverbnation.json +++ b/core-blocks/test/fixtures/core-embed__reverbnation.json @@ -5,9 +5,45 @@ "isValid": true, "attributes": { "url": "https://reverbnation.com/", - "caption": [ - "Embedded content from reverbnation" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from reverbnation" + } }, "innerBlocks": [], "originalContent": "
    \n https://reverbnation.com/\n
    Embedded content from reverbnation
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__screencast.json b/core-blocks/test/fixtures/core-embed__screencast.json index 9439ecc21cbc1..f09f584e81294 100644 --- a/core-blocks/test/fixtures/core-embed__screencast.json +++ b/core-blocks/test/fixtures/core-embed__screencast.json @@ -5,9 +5,43 @@ "isValid": true, "attributes": { "url": "https://screencast.com/", - "caption": [ - "Embedded content from screencast" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from screencast" + } }, "innerBlocks": [], "originalContent": "
    \n https://screencast.com/\n
    Embedded content from screencast
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__scribd.json b/core-blocks/test/fixtures/core-embed__scribd.json index 151487f1d9386..9f8c4e43f2fa5 100644 --- a/core-blocks/test/fixtures/core-embed__scribd.json +++ b/core-blocks/test/fixtures/core-embed__scribd.json @@ -5,9 +5,39 @@ "isValid": true, "attributes": { "url": "https://scribd.com/", - "caption": [ - "Embedded content from scribd" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from scribd" + } }, "innerBlocks": [], "originalContent": "
    \n https://scribd.com/\n
    Embedded content from scribd
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__slideshare.json b/core-blocks/test/fixtures/core-embed__slideshare.json index 860a12582c580..0e8a16352aec9 100644 --- a/core-blocks/test/fixtures/core-embed__slideshare.json +++ b/core-blocks/test/fixtures/core-embed__slideshare.json @@ -5,9 +5,43 @@ "isValid": true, "attributes": { "url": "https://slideshare.com/", - "caption": [ - "Embedded content from slideshare" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from slideshare" + } }, "innerBlocks": [], "originalContent": "
    \n https://slideshare.com/\n
    Embedded content from slideshare
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__smugmug.json b/core-blocks/test/fixtures/core-embed__smugmug.json index 9b9c253816f2d..851302abfc2b2 100644 --- a/core-blocks/test/fixtures/core-embed__smugmug.json +++ b/core-blocks/test/fixtures/core-embed__smugmug.json @@ -5,9 +5,40 @@ "isValid": true, "attributes": { "url": "https://smugmug.com/", - "caption": [ - "Embedded content from smugmug" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from smugmug" + } }, "innerBlocks": [], "originalContent": "
    \n https://smugmug.com/\n
    Embedded content from smugmug
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__soundcloud.json b/core-blocks/test/fixtures/core-embed__soundcloud.json index 71917b33b0fcb..f8e50276da7e9 100644 --- a/core-blocks/test/fixtures/core-embed__soundcloud.json +++ b/core-blocks/test/fixtures/core-embed__soundcloud.json @@ -5,9 +5,43 @@ "isValid": true, "attributes": { "url": "https://soundcloud.com/", - "caption": [ - "Embedded content from soundcloud" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from soundcloud" + } }, "innerBlocks": [], "originalContent": "
    \n https://soundcloud.com/\n
    Embedded content from soundcloud
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__speakerdeck.json b/core-blocks/test/fixtures/core-embed__speakerdeck.json new file mode 100644 index 0000000000000..bb7dac0822685 --- /dev/null +++ b/core-blocks/test/fixtures/core-embed__speakerdeck.json @@ -0,0 +1,50 @@ +[ + { + "clientId": "_clientId_0", + "name": "core-embed/speakerdeck", + "isValid": true, + "attributes": { + "url": "https://speakerdeck.com/", + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from speakerdeck" + } + }, + "innerBlocks": [], + "originalContent": "
    \n\thttps://speakerdeck.com/\n\t
    Embedded content from speakerdeck
    \n
    " + } +] diff --git a/core-blocks/test/fixtures/core-embed__spotify.json b/core-blocks/test/fixtures/core-embed__spotify.json index 34ae81f8800c7..11b4e7bec1161 100644 --- a/core-blocks/test/fixtures/core-embed__spotify.json +++ b/core-blocks/test/fixtures/core-embed__spotify.json @@ -5,9 +5,40 @@ "isValid": true, "attributes": { "url": "https://spotify.com/", - "caption": [ - "Embedded content from spotify" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from spotify" + } }, "innerBlocks": [], "originalContent": "
    \n https://spotify.com/\n
    Embedded content from spotify
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__ted.json b/core-blocks/test/fixtures/core-embed__ted.json index 71cf962f2299d..e657d11633eae 100644 --- a/core-blocks/test/fixtures/core-embed__ted.json +++ b/core-blocks/test/fixtures/core-embed__ted.json @@ -5,9 +5,36 @@ "isValid": true, "attributes": { "url": "https://ted.com/", - "caption": [ - "Embedded content from ted" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from ted" + } }, "innerBlocks": [], "originalContent": "
    \n https://ted.com/\n
    Embedded content from ted
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__tumblr.json b/core-blocks/test/fixtures/core-embed__tumblr.json index d9d8ec226b6d7..c76724b3a59fa 100644 --- a/core-blocks/test/fixtures/core-embed__tumblr.json +++ b/core-blocks/test/fixtures/core-embed__tumblr.json @@ -5,9 +5,39 @@ "isValid": true, "attributes": { "url": "https://tumblr.com/", - "caption": [ - "Embedded content from tumblr" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from tumblr" + } }, "innerBlocks": [], "originalContent": "
    \n https://tumblr.com/\n
    Embedded content from tumblr
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__twitter.json b/core-blocks/test/fixtures/core-embed__twitter.json index 14f254a84f7d1..6ffede453daef 100644 --- a/core-blocks/test/fixtures/core-embed__twitter.json +++ b/core-blocks/test/fixtures/core-embed__twitter.json @@ -5,9 +5,28 @@ "isValid": true, "attributes": { "url": "https://twitter.com/automattic", - "caption": [ - "We are Automattic" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "We are Automattic" + } }, "innerBlocks": [], "originalContent": "
    \n https://twitter.com/automattic\n
    We are Automattic
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__videopress.json b/core-blocks/test/fixtures/core-embed__videopress.json index 30a559d9c2057..6249d06687092 100644 --- a/core-blocks/test/fixtures/core-embed__videopress.json +++ b/core-blocks/test/fixtures/core-embed__videopress.json @@ -5,9 +5,43 @@ "isValid": true, "attributes": { "url": "https://videopress.com/", - "caption": [ - "Embedded content from videopress" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from videopress" + } }, "innerBlocks": [], "originalContent": "
    \n https://videopress.com/\n
    Embedded content from videopress
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__vimeo.json b/core-blocks/test/fixtures/core-embed__vimeo.json index a0f2b798e75a3..d719eb76af9c9 100644 --- a/core-blocks/test/fixtures/core-embed__vimeo.json +++ b/core-blocks/test/fixtures/core-embed__vimeo.json @@ -5,9 +5,38 @@ "isValid": true, "attributes": { "url": "https://vimeo.com/", - "caption": [ - "Embedded content from vimeo" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from vimeo" + } }, "innerBlocks": [], "originalContent": "
    \n https://vimeo.com/\n
    Embedded content from vimeo
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__wordpress-tv.json b/core-blocks/test/fixtures/core-embed__wordpress-tv.json index 7c1f6cf966c82..247467cecbe9d 100644 --- a/core-blocks/test/fixtures/core-embed__wordpress-tv.json +++ b/core-blocks/test/fixtures/core-embed__wordpress-tv.json @@ -5,9 +5,45 @@ "isValid": true, "attributes": { "url": "https://wordpress.tv/", - "caption": [ - "Embedded content from wordpress-tv" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from wordpress-tv" + } }, "innerBlocks": [], "originalContent": "
    \n https://wordpress.tv/\n
    Embedded content from wordpress-tv
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__wordpress.json b/core-blocks/test/fixtures/core-embed__wordpress.json index 25980b1db72ba..1fba166d511d0 100644 --- a/core-blocks/test/fixtures/core-embed__wordpress.json +++ b/core-blocks/test/fixtures/core-embed__wordpress.json @@ -5,9 +5,42 @@ "isValid": true, "attributes": { "url": "https://wordpress.com/", - "caption": [ - "Embedded content from WordPress" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from WordPress" + } }, "innerBlocks": [], "originalContent": "
    \n https://wordpress.com/\n
    Embedded content from WordPress
    \n
    " diff --git a/core-blocks/test/fixtures/core-embed__youtube.json b/core-blocks/test/fixtures/core-embed__youtube.json index c98fb7d333cf2..a766e1a29561a 100644 --- a/core-blocks/test/fixtures/core-embed__youtube.json +++ b/core-blocks/test/fixtures/core-embed__youtube.json @@ -5,9 +5,40 @@ "isValid": true, "attributes": { "url": "https://youtube.com/", - "caption": [ - "Embedded content from youtube" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from youtube" + } }, "innerBlocks": [], "originalContent": "
    \n https://youtube.com/\n
    Embedded content from youtube
    \n
    " diff --git a/core-blocks/test/fixtures/core__button__center.json b/core-blocks/test/fixtures/core__button__center.json index 497b74f660166..4ccdc68fc2090 100644 --- a/core-blocks/test/fixtures/core__button__center.json +++ b/core-blocks/test/fixtures/core__button__center.json @@ -5,9 +5,31 @@ "isValid": true, "attributes": { "url": "https://github.com/WordPress/gutenberg", - "text": [ - "Help build Gutenberg" - ], + "text": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Help build Gutenberg" + }, "align": "center" }, "innerBlocks": [], diff --git a/core-blocks/test/fixtures/core__column.json b/core-blocks/test/fixtures/core__column.json index 427423f08b835..358a0d4e94a75 100644 --- a/core-blocks/test/fixtures/core__column.json +++ b/core-blocks/test/fixtures/core__column.json @@ -10,9 +10,36 @@ "name": "core/paragraph", "isValid": true, "attributes": { - "content": [ - "Column One, Paragraph One" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Column One, Paragraph One" + }, "dropCap": false }, "innerBlocks": [], @@ -23,9 +50,36 @@ "name": "core/paragraph", "isValid": true, "attributes": { - "content": [ - "Column One, Paragraph Two" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Column One, Paragraph Two" + }, "dropCap": false }, "innerBlocks": [], diff --git a/core-blocks/test/fixtures/core__columns.json b/core-blocks/test/fixtures/core__columns.json index 5b234e0980bee..bbadafd319fb8 100644 --- a/core-blocks/test/fixtures/core__columns.json +++ b/core-blocks/test/fixtures/core__columns.json @@ -18,9 +18,36 @@ "name": "core/paragraph", "isValid": true, "attributes": { - "content": [ - "Column One, Paragraph One" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Column One, Paragraph One" + }, "dropCap": false }, "innerBlocks": [], @@ -31,9 +58,36 @@ "name": "core/paragraph", "isValid": true, "attributes": { - "content": [ - "Column One, Paragraph Two" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Column One, Paragraph Two" + }, "dropCap": false }, "innerBlocks": [], @@ -53,9 +107,36 @@ "name": "core/paragraph", "isValid": true, "attributes": { - "content": [ - "Column Two, Paragraph One" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Column Two, Paragraph One" + }, "dropCap": false }, "innerBlocks": [], @@ -66,9 +147,38 @@ "name": "core/paragraph", "isValid": true, "attributes": { - "content": [ - "Column Three, Paragraph One" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Column Three, Paragraph One" + }, "dropCap": false }, "innerBlocks": [], diff --git a/core-blocks/test/fixtures/core__cover-image.json b/core-blocks/test/fixtures/core__cover-image.json index d4e5877c330b7..1dfe4234cc147 100644 --- a/core-blocks/test/fixtures/core__cover-image.json +++ b/core-blocks/test/fixtures/core__cover-image.json @@ -4,9 +4,22 @@ "name": "core/cover-image", "isValid": true, "attributes": { - "title": [ - "Guten Berg!" - ], + "title": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Guten Berg!" + }, "url": "https://cldup.com/uuUqE_dXzy.jpg", "contentAlign": "center", "hasParallax": false, diff --git a/core-blocks/test/fixtures/core__embed.json b/core-blocks/test/fixtures/core__embed.json index ec2cbd732a343..12d865ffab939 100644 --- a/core-blocks/test/fixtures/core__embed.json +++ b/core-blocks/test/fixtures/core__embed.json @@ -5,9 +5,47 @@ "isValid": true, "attributes": { "url": "https://example.com/", - "caption": [ - "Embedded content from an example URL" - ] + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embedded content from an example URL" + } }, "innerBlocks": [], "originalContent": "
    \n https://example.com/\n
    Embedded content from an example URL
    \n
    " diff --git a/core-blocks/test/fixtures/core__gallery.json b/core-blocks/test/fixtures/core__gallery.json index acc930adeb1cc..2dcebc80a03af 100644 --- a/core-blocks/test/fixtures/core__gallery.json +++ b/core-blocks/test/fixtures/core__gallery.json @@ -8,12 +8,18 @@ { "url": "https://cldup.com/uuUqE_dXzy.jpg", "alt": "title", - "caption": [] + "caption": { + "formats": [], + "text": "" + } }, { "url": "http://google.com/hi.png", "alt": "title", - "caption": [] + "caption": { + "formats": [], + "text": "" + } } ], "imageCrop": true, diff --git a/core-blocks/test/fixtures/core__gallery__columns.json b/core-blocks/test/fixtures/core__gallery__columns.json index 09ae7dd3b138c..a9d27c457cb01 100644 --- a/core-blocks/test/fixtures/core__gallery__columns.json +++ b/core-blocks/test/fixtures/core__gallery__columns.json @@ -8,12 +8,18 @@ { "url": "https://cldup.com/uuUqE_dXzy.jpg", "alt": "title", - "caption": [] + "caption": { + "formats": [], + "text": "" + } }, { "url": "http://google.com/hi.png", "alt": "title", - "caption": [] + "caption": { + "formats": [], + "text": "" + } } ], "columns": 1, diff --git a/core-blocks/test/fixtures/core__heading__h2-em.json b/core-blocks/test/fixtures/core__heading__h2-em.json index 1635da5f8efbb..786643107f39e 100644 --- a/core-blocks/test/fixtures/core__heading__h2-em.json +++ b/core-blocks/test/fixtures/core__heading__h2-em.json @@ -4,16 +4,60 @@ "name": "core/heading", "isValid": true, "attributes": { - "content": [ - "The ", - { - "type": "em", - "children": [ - "Inserter" - ] - }, - " Tool" - ], + "content": { + "formats": [ + null, + null, + null, + null, + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + null, + null, + null, + null, + null + ], + "text": "The Inserter Tool" + }, "level": 2 }, "innerBlocks": [], diff --git a/core-blocks/test/fixtures/core__heading__h2.json b/core-blocks/test/fixtures/core__heading__h2.json index f50eb3b39cd55..388f98214362d 100644 --- a/core-blocks/test/fixtures/core__heading__h2.json +++ b/core-blocks/test/fixtures/core__heading__h2.json @@ -4,9 +4,69 @@ "name": "core/heading", "isValid": true, "attributes": { - "content": [ - "A picture is worth a thousand words, or so the saying goes" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "A picture is worth a thousand words, or so the saying goes" + }, "level": 2 }, "innerBlocks": [], diff --git a/core-blocks/test/fixtures/core__image.json b/core-blocks/test/fixtures/core__image.json index 159ec45e1a4ca..dd89c78069c8e 100644 --- a/core-blocks/test/fixtures/core__image.json +++ b/core-blocks/test/fixtures/core__image.json @@ -6,7 +6,10 @@ "attributes": { "url": "https://cldup.com/uuUqE_dXzy.jpg", "alt": "", - "caption": [], + "caption": { + "formats": [], + "text": "" + }, "linkDestination": "none" }, "innerBlocks": [], diff --git a/core-blocks/test/fixtures/core__image__attachment-link.json b/core-blocks/test/fixtures/core__image__attachment-link.json index 927d2e6699428..3aaa94408659a 100644 --- a/core-blocks/test/fixtures/core__image__attachment-link.json +++ b/core-blocks/test/fixtures/core__image__attachment-link.json @@ -6,7 +6,10 @@ "attributes": { "url": "https://cldup.com/uuUqE_dXzy.jpg", "alt": "", - "caption": [], + "caption": { + "formats": [], + "text": "" + }, "href": "http://localhost:8888/?attachment_id=7", "linkDestination": "attachment" }, diff --git a/core-blocks/test/fixtures/core__image__center-caption.json b/core-blocks/test/fixtures/core__image__center-caption.json index c9b19750b1868..b1eabe566a8af 100644 --- a/core-blocks/test/fixtures/core__image__center-caption.json +++ b/core-blocks/test/fixtures/core__image__center-caption.json @@ -6,9 +6,78 @@ "attributes": { "url": "https://cldup.com/YLYhpou2oq.jpg", "alt": "", - "caption": [ - "Give it a try. Press the \"really wide\" button on the image toolbar." - ], + "caption": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Give it a try. Press the \"really wide\" button on the image toolbar." + }, "align": "center", "linkDestination": "none" }, diff --git a/core-blocks/test/fixtures/core__image__custom-link.json b/core-blocks/test/fixtures/core__image__custom-link.json index 55e604712ac01..136ae7357e680 100644 --- a/core-blocks/test/fixtures/core__image__custom-link.json +++ b/core-blocks/test/fixtures/core__image__custom-link.json @@ -6,7 +6,10 @@ "attributes": { "url": "https://cldup.com/uuUqE_dXzy.jpg", "alt": "", - "caption": [], + "caption": { + "formats": [], + "text": "" + }, "href": "https://wordpress.org/", "linkDestination": "custom" }, diff --git a/core-blocks/test/fixtures/core__image__media-link.json b/core-blocks/test/fixtures/core__image__media-link.json index 7e10bde887a5f..6b329e94a4837 100644 --- a/core-blocks/test/fixtures/core__image__media-link.json +++ b/core-blocks/test/fixtures/core__image__media-link.json @@ -6,7 +6,10 @@ "attributes": { "url": "https://cldup.com/uuUqE_dXzy.jpg", "alt": "", - "caption": [], + "caption": { + "formats": [], + "text": "" + }, "href": "https://cldup.com/uuUqE_dXzy.jpg", "linkDestination": "media" }, diff --git a/core-blocks/test/fixtures/core__list__ul.json b/core-blocks/test/fixtures/core__list__ul.json index 6d2ba10a4cb33..c5d7e526b7f01 100644 --- a/core-blocks/test/fixtures/core__list__ul.json +++ b/core-blocks/test/fixtures/core__list__ul.json @@ -7,47 +7,242 @@ "ordered": false, "values": [ { - "type": "li", - "children": [ - "Text & Headings" - ] + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Text & Headings" }, { - "type": "li", - "children": [ - "Images & Videos" - ] + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Images & Videos" }, { - "type": "li", - "children": [ - "Galleries" - ] + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Galleries" }, { - "type": "li", - "children": [ - "Embeds, like YouTube, Tweets, or other WordPress posts." - ] + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Embeds, like YouTube, Tweets, or other WordPress posts." }, { - "type": "li", - "children": [ - "Layout blocks, like Buttons, Hero Images, Separators, etc." - ] + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Layout blocks, like Buttons, Hero Images, Separators, etc." }, { - "type": "li", - "children": [ - "And ", - { - "type": "em", - "children": [ - "Lists" - ] - }, - " like this one of course :)" - ] + "formats": [ + null, + null, + null, + null, + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "And Lists like this one of course :)" } ] }, diff --git a/core-blocks/test/fixtures/core__paragraph__align-right.json b/core-blocks/test/fixtures/core__paragraph__align-right.json index 9d33128ef3af0..cc4e7274557bd 100644 --- a/core-blocks/test/fixtures/core__paragraph__align-right.json +++ b/core-blocks/test/fixtures/core__paragraph__align-right.json @@ -4,9 +4,81 @@ "name": "core/paragraph", "isValid": true, "attributes": { - "content": [ - "... like this one, which is separate from the above and right aligned." - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "... like this one, which is separate from the above and right aligned." + }, "align": "right", "dropCap": false }, diff --git a/core-blocks/test/fixtures/core__preformatted.json b/core-blocks/test/fixtures/core__preformatted.json index 6bf8d032b3e40..5c7db59e09b97 100644 --- a/core-blocks/test/fixtures/core__preformatted.json +++ b/core-blocks/test/fixtures/core__preformatted.json @@ -4,21 +4,94 @@ "name": "core/preformatted", "isValid": true, "attributes": { - "content": [ - "Some ", - { - "type": "em", - "children": [ - "preformatted" - ] - }, - " text...", - { - "type": "br", - "children": [] - }, - "And more!" - ] + "content": { + "formats": [ + null, + null, + null, + null, + null, + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Some preformatted text...\nAnd more!" + } }, "innerBlocks": [], "originalContent": "
    Some preformatted text...
    And more!
    " diff --git a/core-blocks/test/fixtures/core__preformatted.serialized.html b/core-blocks/test/fixtures/core__preformatted.serialized.html index 86bf1df537b25..8c7cf539e4ea2 100644 --- a/core-blocks/test/fixtures/core__preformatted.serialized.html +++ b/core-blocks/test/fixtures/core__preformatted.serialized.html @@ -1,3 +1,3 @@ -
    Some preformatted text...
    And more!
    +
    Some preformatted text...
    And more!
    diff --git a/core-blocks/test/fixtures/core__pullquote.json b/core-blocks/test/fixtures/core__pullquote.json index 11d576614228c..54317c84e6474 100644 --- a/core-blocks/test/fixtures/core__pullquote.json +++ b/core-blocks/test/fixtures/core__pullquote.json @@ -6,19 +6,59 @@ "attributes": { "value": [ { - "children": { - "type": "p", - "props": { - "children": [ - "Testing pullquote block..." - ] - } - } + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Testing pullquote block..." } ], - "citation": [ - "...with a caption" - ] + "citation": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "...with a caption" + } }, "innerBlocks": [], "originalContent": "
    \n

    Testing pullquote block...

    ...with a caption\n
    " diff --git a/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json b/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json index 266568fa51bc0..03d7d8bd9bd44 100644 --- a/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json +++ b/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json @@ -6,37 +6,70 @@ "attributes": { "value": [ { - "children": { - "type": "p", - "props": { - "children": [ - "Paragraph ", - { - "type": "strong", - "props": { - "children": [ - "one" - ] - } - } - ] - } - } + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + [ + { + "type": "strong" + } + ], + [ + { + "type": "strong" + } + ], + [ + { + "type": "strong" + } + ] + ], + "text": "Paragraph one" }, { - "children": { - "type": "p", - "props": { - "children": [ - "Paragraph two" - ] - } - } + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Paragraph two" } ], - "citation": [ - "by whomever" - ] + "citation": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "by whomever" + } }, "innerBlocks": [], "originalContent": "
    \n

    Paragraph one

    \n

    Paragraph two

    \n by whomever\n
    " diff --git a/core-blocks/test/fixtures/core__quote__style-1.json b/core-blocks/test/fixtures/core__quote__style-1.json index 67722f404cd0a..71e500dae91bc 100644 --- a/core-blocks/test/fixtures/core__quote__style-1.json +++ b/core-blocks/test/fixtures/core__quote__style-1.json @@ -6,19 +6,268 @@ "attributes": { "value": [ { - "children": { - "type": "p", - "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." - ] - } - } + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "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." } ], - "citation": [ - "Matt Mullenweg, 2017" - ] + "citation": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Matt Mullenweg, 2017" + } }, "innerBlocks": [], "originalContent": "

    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.

    Matt Mullenweg, 2017
    " diff --git a/core-blocks/test/fixtures/core__quote__style-2.json b/core-blocks/test/fixtures/core__quote__style-2.json index ebf72ece86181..017e4d20ae88b 100644 --- a/core-blocks/test/fixtures/core__quote__style-2.json +++ b/core-blocks/test/fixtures/core__quote__style-2.json @@ -6,19 +6,94 @@ "attributes": { "value": [ { - "children": { - "type": "p", - "props": { - "children": [ - "There is no greater agony than bearing an untold story inside you." - ] - } - } + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "There is no greater agony than bearing an untold story inside you." } ], - "citation": [ - "Maya Angelou" - ], + "citation": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Maya Angelou" + }, "className": "is-style-large" }, "innerBlocks": [], diff --git a/core-blocks/test/fixtures/core__subhead.json b/core-blocks/test/fixtures/core__subhead.json index 64d8bb2f030c7..291374ad1a6c1 100644 --- a/core-blocks/test/fixtures/core__subhead.json +++ b/core-blocks/test/fixtures/core__subhead.json @@ -4,16 +4,57 @@ "name": "core/subhead", "isValid": true, "attributes": { - "content": [ - "This is a ", - { - "type": "em", - "children": [ - "subhead" - ] - }, - "." - ] + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + null + ], + "text": "This is a subhead." + } }, "innerBlocks": [], "originalContent": "

    This is a subhead.

    " diff --git a/core-blocks/test/fixtures/core__table.html b/core-blocks/test/fixtures/core__table.html index 632c38d213f74..b9b41659e835a 100644 --- a/core-blocks/test/fixtures/core__table.html +++ b/core-blocks/test/fixtures/core__table.html @@ -1,4 +1,3 @@
    VersionMusicianDate
    .70No musician chosen.May 27, 2003
    1.0Miles DavisJanuary 3, 2004
    Lots of versions skipped, see the full list
    4.4Clifford BrownDecember 8, 2015
    4.5Coleman HawkinsApril 12, 2016
    4.6Pepper AdamsAugust 16, 2016
    4.7Sarah VaughanDecember 6, 2016
    - diff --git a/core-blocks/test/fixtures/core__table.json b/core-blocks/test/fixtures/core__table.json deleted file mode 100644 index f6a6c42c9c545..0000000000000 --- a/core-blocks/test/fixtures/core__table.json +++ /dev/null @@ -1,265 +0,0 @@ -[ - { - "clientId": "_clientId_0", - "name": "core/table", - "isValid": true, - "attributes": { - "content": [ - { - "type": "thead", - "children": [ - { - "type": "tr", - "children": [ - { - "type": "th", - "children": [ - "Version" - ] - }, - { - "type": "th", - "children": [ - "Musician" - ] - }, - { - "type": "th", - "children": [ - "Date" - ] - } - ] - } - ] - }, - { - "type": "tbody", - "children": [ - { - "type": "tr", - "children": [ - { - "type": "td", - "children": [ - { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2003/05/wordpress-now-available/" - }, - "children": [ - ".70" - ] - } - ] - }, - { - "type": "td", - "children": [ - "No musician chosen." - ] - }, - { - "type": "td", - "children": [ - "May 27, 2003" - ] - } - ] - }, - { - "type": "tr", - "children": [ - { - "type": "td", - "children": [ - { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2004/01/wordpress-10/" - }, - "children": [ - "1.0" - ] - } - ] - }, - { - "type": "td", - "children": [ - "Miles Davis" - ] - }, - { - "type": "td", - "children": [ - "January 3, 2004" - ] - } - ] - }, - { - "type": "tr", - "children": [ - { - "type": "td", - "children": [ - "Lots of versions skipped, see ", - { - "type": "a", - "attributes": { - "href": "https://codex.wordpress.org/WordPress_Versions" - }, - "children": [ - "the full list" - ] - } - ] - }, - { - "type": "td", - "children": [ - "…" - ] - }, - { - "type": "td", - "children": [ - "…" - ] - } - ] - }, - { - "type": "tr", - "children": [ - { - "type": "td", - "children": [ - { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2015/12/clifford/" - }, - "children": [ - "4.4" - ] - } - ] - }, - { - "type": "td", - "children": [ - "Clifford Brown" - ] - }, - { - "type": "td", - "children": [ - "December 8, 2015" - ] - } - ] - }, - { - "type": "tr", - "children": [ - { - "type": "td", - "children": [ - { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2016/04/coleman/" - }, - "children": [ - "4.5" - ] - } - ] - }, - { - "type": "td", - "children": [ - "Coleman Hawkins" - ] - }, - { - "type": "td", - "children": [ - "April 12, 2016" - ] - } - ] - }, - { - "type": "tr", - "children": [ - { - "type": "td", - "children": [ - { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2016/08/pepper/" - }, - "children": [ - "4.6" - ] - } - ] - }, - { - "type": "td", - "children": [ - "Pepper Adams" - ] - }, - { - "type": "td", - "children": [ - "August 16, 2016" - ] - } - ] - }, - { - "type": "tr", - "children": [ - { - "type": "td", - "children": [ - { - "type": "a", - "attributes": { - "href": "https://wordpress.org/news/2016/12/vaughan/" - }, - "children": [ - "4.7" - ] - } - ] - }, - { - "type": "td", - "children": [ - "Sarah Vaughan" - ] - }, - { - "type": "td", - "children": [ - "December 6, 2016" - ] - } - ] - } - ] - } - ], - "hasFixedLayout": false - }, - "innerBlocks": [], - "originalContent": "
    VersionMusicianDate
    .70No musician chosen.May 27, 2003
    1.0Miles DavisJanuary 3, 2004
    Lots of versions skipped, see the full list
    4.4Clifford BrownDecember 8, 2015
    4.5Coleman HawkinsApril 12, 2016
    4.6Pepper AdamsAugust 16, 2016
    4.7Sarah VaughanDecember 6, 2016
    " - } -] diff --git a/core-blocks/test/fixtures/core__table.parsed.json b/core-blocks/test/fixtures/core__table.parsed.json index 0051d503b8d62..737ac5ea49098 100644 --- a/core-blocks/test/fixtures/core__table.parsed.json +++ b/core-blocks/test/fixtures/core__table.parsed.json @@ -7,6 +7,6 @@ }, { "attrs": {}, - "innerHTML": "\n\n" + "innerHTML": "\n" } ] diff --git a/core-blocks/test/fixtures/core__table.serialized.html b/core-blocks/test/fixtures/core__table.serialized.html deleted file mode 100644 index a791eb149f627..0000000000000 --- a/core-blocks/test/fixtures/core__table.serialized.html +++ /dev/null @@ -1,3 +0,0 @@ - -
    VersionMusicianDate
    .70No musician chosen.May 27, 2003
    1.0Miles DavisJanuary 3, 2004
    Lots of versions skipped, see the full list
    4.4Clifford BrownDecember 8, 2015
    4.5Coleman HawkinsApril 12, 2016
    4.6Pepper AdamsAugust 16, 2016
    4.7Sarah VaughanDecember 6, 2016
    - diff --git a/core-blocks/test/fixtures/core__text-columns.json b/core-blocks/test/fixtures/core__text-columns.json index a1f531dd8fe3c..2a31ae6ff865f 100644 --- a/core-blocks/test/fixtures/core__text-columns.json +++ b/core-blocks/test/fixtures/core__text-columns.json @@ -6,14 +6,24 @@ "attributes": { "content": [ { - "children": [ - "One" - ] + "children": { + "formats": [ + null, + null, + null + ], + "text": "One" + } }, { - "children": [ - "Two" - ] + "children": { + "formats": [ + null, + null, + null + ], + "text": "Two" + } } ], "columns": 2, 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 10066958f0b3b..951cc3900f3c1 100644 --- a/core-blocks/test/fixtures/core__text__converts-to-paragraph.json +++ b/core-blocks/test/fixtures/core__text__converts-to-paragraph.json @@ -4,16 +4,111 @@ "name": "core/paragraph", "isValid": true, "attributes": { - "content": [ - "This is an old-style text block. Changed to ", - { - "type": "code", - "children": [ - "paragraph" - ] - }, - " in #2135." - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + [ + { + "type": "code" + } + ], + [ + { + "type": "code" + } + ], + [ + { + "type": "code" + } + ], + [ + { + "type": "code" + } + ], + [ + { + "type": "code" + } + ], + [ + { + "type": "code" + } + ], + [ + { + "type": "code" + } + ], + [ + { + "type": "code" + } + ], + [ + { + "type": "code" + } + ], + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "This is an old-style text block. Changed to paragraph in #2135." + }, "dropCap": false }, "innerBlocks": [], diff --git a/core-blocks/test/fixtures/core__verse.json b/core-blocks/test/fixtures/core__verse.json index 4412cb039d849..e7d92ea856bff 100644 --- a/core-blocks/test/fixtures/core__verse.json +++ b/core-blocks/test/fixtures/core__verse.json @@ -4,21 +4,49 @@ "name": "core/verse", "isValid": true, "attributes": { - "content": [ - "A ", - { - "type": "em", - "children": [ - "verse" - ] - }, - "…", - { - "type": "br", - "children": [] - }, - "And more!" - ] + "content": { + "formats": [ + null, + null, + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "A verse…\nAnd more!" + } }, "innerBlocks": [], "originalContent": "
    A verse
    And more!
    " diff --git a/core-blocks/test/fixtures/core__verse.serialized.html b/core-blocks/test/fixtures/core__verse.serialized.html index ff4983491f13d..a601b259912a8 100644 --- a/core-blocks/test/fixtures/core__verse.serialized.html +++ b/core-blocks/test/fixtures/core__verse.serialized.html @@ -1,3 +1,3 @@ -
    A verse
    And more!
    +
    A verse
    And more!
    diff --git a/core-blocks/test/fixtures/core__video.json b/core-blocks/test/fixtures/core__video.json index 7992d3b94fb3e..b86cde8579424 100644 --- a/core-blocks/test/fixtures/core__video.json +++ b/core-blocks/test/fixtures/core__video.json @@ -5,7 +5,10 @@ "isValid": true, "attributes": { "autoplay": false, - "caption": [], + "caption": { + "formats": [], + "text": "" + }, "controls": true, "loop": false, "muted": false, diff --git a/core-blocks/test/full-content.js b/core-blocks/test/full-content.js index f1b79b1007c52..73e49bfd126dd 100644 --- a/core-blocks/test/full-content.js +++ b/core-blocks/test/full-content.js @@ -3,7 +3,7 @@ */ import fs from 'fs'; import path from 'path'; -import { uniq, isObject, omit, startsWith, get } from 'lodash'; +import { uniq, startsWith, get } from 'lodash'; import { format } from 'util'; /** @@ -54,31 +54,6 @@ function writeFixtureFile( filename, content ) { ); } -function normalizeReactTree( element ) { - if ( Array.isArray( element ) ) { - return element.map( ( child ) => normalizeReactTree( child ) ); - } - - // Check if we got an object first, then if it actually has a `type` like a - // React component. Sometimes we get other stuff here, which probably - // indicates a bug. - if ( isObject( element ) && element.type && element.props ) { - const toReturn = { - type: element.type, - }; - const attributes = omit( element.props, 'children' ); - if ( Object.keys( attributes ).length ) { - toReturn.attributes = attributes; - } - if ( element.props.children ) { - toReturn.children = normalizeReactTree( element.props.children ); - } - return toReturn; - } - - return element; -} - function normalizeParsedBlocks( blocks ) { return blocks.map( ( block, index ) => { // Clone and remove React-instance-specific stuff; also, attribute @@ -90,12 +65,6 @@ function normalizeParsedBlocks( blocks ) { // TODO: Remove in 3.5 "UID" deprecation. delete block.uid; - // Walk each attribute and get a more concise representation of any - // React elements - for ( const k in block.attributes ) { - block.attributes[ k ] = normalizeReactTree( block.attributes[ k ] ); - } - // Recurse to normalize inner blocks block.innerBlocks = normalizeParsedBlocks( block.innerBlocks ); diff --git a/core-blocks/text-columns/index.js b/core-blocks/text-columns/index.js index fc8245daa798e..103d7f51121be 100644 --- a/core-blocks/text-columns/index.js +++ b/core-blocks/text-columns/index.js @@ -46,10 +46,10 @@ export const settings = { selector: 'p', query: { children: { - source: 'children', + source: 'rich-text', }, }, - default: [ [], [] ], + default: [ {}, {} ], }, columns: { type: 'number', diff --git a/core-blocks/verse/index.js b/core-blocks/verse/index.js index 810dae1d1249c..974fd4b1eecec 100644 --- a/core-blocks/verse/index.js +++ b/core-blocks/verse/index.js @@ -30,8 +30,8 @@ export const settings = { attributes: { content: { - type: 'array', - source: 'children', + type: 'object', + source: 'rich-text', selector: 'pre', }, textAlign: { diff --git a/core-blocks/video/edit.js b/core-blocks/video/edit.js index 19dd60c226fa3..4419570b50a7d 100644 --- a/core-blocks/video/edit.js +++ b/core-blocks/video/edit.js @@ -157,7 +157,7 @@ class VideoEdit extends Component {
    diff --git a/packages/blocks/src/api/children.js b/packages/blocks/src/api/children.js deleted file mode 100644 index 3fde2804fb4ed..0000000000000 --- a/packages/blocks/src/api/children.js +++ /dev/null @@ -1,132 +0,0 @@ -/** - * 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/factory.js b/packages/blocks/src/api/factory.js index ef9de61959bd5..2abc5293feced 100644 --- a/packages/blocks/src/api/factory.js +++ b/packages/blocks/src/api/factory.js @@ -25,6 +25,7 @@ import { createHooks, applyFilters } from '@wordpress/hooks'; * Internal dependencies */ import { getBlockType, getBlockTypes } from './registration'; +import { create } from './rich-text-structure'; /** * Returns a block object given its type and attributes. @@ -41,12 +42,14 @@ export function createBlock( name, blockAttributes = {}, innerBlocks = [] ) { // Ensure attributes contains only values defined by block type, and merge // default values for missing attributes. - const attributes = reduce( blockType.attributes, ( result, source, key ) => { + const attributes = reduce( blockType.attributes, ( result, schema, key ) => { const value = blockAttributes[ key ]; if ( undefined !== value ) { result[ key ] = value; - } else if ( source.hasOwnProperty( 'default' ) ) { - result[ key ] = source.default; + } else if ( schema.source === 'rich-text' ) { + result[ key ] = create( null, schema.multiline ); + } else if ( schema.hasOwnProperty( 'default' ) ) { + result[ key ] = schema.default; } return result; diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js index 4b77d4a00b788..95bb39f7f5b1d 100644 --- a/packages/blocks/src/api/index.js +++ b/packages/blocks/src/api/index.js @@ -1,3 +1,5 @@ +import * as richTextStructure from './rich-text-structure'; + export { createBlock, cloneBlock, @@ -53,5 +55,4 @@ export { doBlocksMatchTemplate, synchronizeBlocksWithTemplate, } from './templates'; -export { default as children } from './children'; -export { default as node } from './node'; +export { richTextStructure }; diff --git a/packages/blocks/src/api/matchers.js b/packages/blocks/src/api/matchers.js index 5a86c2c9b85a8..f329fadca32c8 100644 --- a/packages/blocks/src/api/matchers.js +++ b/packages/blocks/src/api/matchers.js @@ -1,10 +1,33 @@ /** - * External dependencies + * WordPress dependencies */ -export { attr, prop, html, text, query } from 'hpq'; +import { create } from './rich-text-structure'; /** - * Internal dependencies + * External dependencies */ -export { matcher as node } from './node'; -export { matcher as children } from './children'; +export { attr, prop, html, text, query } from 'hpq'; + +export const children = ( selector, multiline ) => { + return ( domNode ) => { + let match = domNode; + + if ( selector ) { + match = domNode.querySelector( selector ); + } + + return create( match, multiline ); + }; +}; + +export const node = ( selector ) => { + return ( domNode ) => { + let match = domNode; + + if ( selector ) { + match = domNode.querySelector( selector ); + } + + return create( match ); + }; +}; diff --git a/packages/blocks/src/api/node.js b/packages/blocks/src/api/node.js deleted file mode 100644 index 34bcfd394429c..0000000000000 --- a/packages/blocks/src/api/node.js +++ /dev/null @@ -1,112 +0,0 @@ -/** - * 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/parser.js b/packages/blocks/src/api/parser.js index 5f2ebf531f169..700142a919d04 100644 --- a/packages/blocks/src/api/parser.js +++ b/packages/blocks/src/api/parser.js @@ -20,6 +20,7 @@ import { createBlock } from './factory'; import { isValidBlock } from './validation'; import { getCommentDelimitedContent } from './serializer'; import { attr, prop, html, text, query, node, children } from './matchers'; +import { create } from './rich-text-structure'; /** * Higher-order hpq matcher which enhances an attribute matcher to return true @@ -118,7 +119,8 @@ export function matcherFromSource( sourceConfig ) { case 'text': return text( sourceConfig.selector ); case 'children': - return children( sourceConfig.selector ); + case 'rich-text': + return children( sourceConfig.selector, sourceConfig.multiline ); case 'node': return node( sourceConfig.selector ); case 'query': @@ -157,6 +159,11 @@ export function parseWithAttributeSchema( innerHTML, attributeSchema ) { */ export function getBlockAttribute( attributeKey, attributeSchema, innerHTML, commentAttributes ) { let value; + + if ( attributeSchema.source === 'rich-text' ) { + attributeSchema.default = create( null, attributeSchema.multiline ); + } + switch ( attributeSchema.source ) { // undefined source means that it's an attribute serialized to the block's "comment" case undefined: @@ -167,6 +174,7 @@ export function getBlockAttribute( attributeKey, attributeSchema, innerHTML, com case 'html': case 'text': case 'children': + case 'rich-text': case 'node': case 'query': value = parseWithAttributeSchema( innerHTML, attributeSchema ); diff --git a/packages/blocks/src/api/rich-text-structure.js b/packages/blocks/src/api/rich-text-structure.js new file mode 100644 index 0000000000000..28b6db41be112 --- /dev/null +++ b/packages/blocks/src/api/rich-text-structure.js @@ -0,0 +1,653 @@ +import { find } from 'lodash'; + +/** + * Browser dependencies + */ + +const { TEXT_NODE, ELEMENT_NODE } = window.Node; + +export function createWithSelection( element, range, multiline, settings ) { + if ( ! multiline ) { + return createRecord( element, range, settings ); + } + + if ( ! element || ! element.hasChildNodes() ) { + return { + value: [], + selection: {}, + }; + } + + return Array.from( element.childNodes ).reduce( ( acc, child, index ) => { + if ( child.nodeName.toLowerCase() === multiline ) { + const { selection, value } = createRecord( child, range, settings ); + + if ( range ) { + if ( selection.start !== undefined ) { + acc.selection.start = [ index ].concat( selection.start ); + } else if ( child === range.startContainer ) { + acc.selection.start = [ index ]; + } + + if ( selection.end !== undefined ) { + acc.selection.end = [ index ].concat( selection.end ); + } else if ( child === range.endContainer ) { + acc.selection.end = [ index ]; + } + } + + acc.value.push( value ); + } + + return acc; + }, { + value: [], + selection: {}, + } ); +} + +export function create( element, multiline, settings ) { + return createWithSelection( element, null, multiline, settings ).value; +} + +function createRecord( element, range, settings = {} ) { + if ( ! element ) { + return { + value: { + formats: [], + text: '', + }, + selection: {}, + }; + } + + const { + removeNodeMatch = () => false, + unwrapNodeMatch = () => false, + filterString = ( string ) => string, + } = settings; + + if ( + element.nodeName === 'BR' && + ! removeNodeMatch( element ) && + ! unwrapNodeMatch( element ) + ) { + return { + value: { + formats: [ undefined ], + text: '\n', + }, + selection: {}, + }; + } + + if ( ! element.hasChildNodes() ) { + return { + value: { + formats: [], + text: '', + }, + selection: {}, + }; + } + + return Array.from( element.childNodes ).reduce( ( accumulator, node ) => { + const { formats } = accumulator.value; + + if ( node.nodeType === TEXT_NODE ) { + if ( range ) { + if ( node === range.startContainer ) { + accumulator.selection.start = accumulator.value.text.length + filterString( node.nodeValue.slice( 0, range.startOffset ) ).length; + } + + if ( node === range.endContainer ) { + accumulator.selection.end = accumulator.value.text.length + filterString( node.nodeValue.slice( 0, range.endOffset ) ).length; + } + } + + const text = filterString( node.nodeValue, accumulator.selection ); + accumulator.value.text += text; + formats.push( ...Array( text.length ) ); + } else if ( node.nodeType === ELEMENT_NODE ) { + if ( removeNodeMatch( node ) ) { + return accumulator; + } + + if ( range ) { + if ( + node.parentNode === range.startContainer && + node === range.startContainer.childNodes[ range.startOffset ] + ) { + accumulator.selection.start = accumulator.value.text.length; + } + + if ( + node.parentNode === range.endContainer && + node === range.endContainer.childNodes[ range.endOffset ] + ) { + accumulator.selection.end = accumulator.value.text.length; + } + } + + let format; + + if ( ! unwrapNodeMatch( node ) && node.nodeName !== 'BR' ) { + const type = node.nodeName.toLowerCase(); + const attributes = getAttributes( node, settings ); + + format = attributes ? { type, attributes } : { type }; + } + + const { value, selection } = createRecord( node, range, settings ); + const text = value.text; + const start = accumulator.value.text.length; + + if ( format && text.length === 0 ) { + format.object = true; + + if ( formats[ start ] ) { + formats[ start ].unshift( format ); + } else { + formats[ start ] = [ format ]; + } + } else { + accumulator.value.text += text; + + let i = value.formats.length; + + while ( i-- ) { + const index = start + i; + + if ( format ) { + if ( formats[ index ] ) { + formats[ index ].push( format ); + } else { + formats[ index ] = [ format ]; + } + } + + if ( value.formats[ i ] ) { + if ( formats[ index ] ) { + formats[ index ].push( ...value.formats[ i ] ); + } else { + formats[ index ] = value.formats[ i ]; + } + } + + if ( ! formats[ index ] ) { + formats[ index ] = undefined; + } + } + } + + if ( selection.start !== undefined ) { + accumulator.selection.start = start + selection.start; + } + + if ( selection.end !== undefined ) { + accumulator.selection.end = start + selection.end; + } + } + + return accumulator; + }, { + value: { + formats: [], + text: '', + }, + selection: {}, + } ); +} + +export function apply( value, current, multiline ) { + const { body: future, selection } = toDOM( value, multiline ); + let i = 0; + + while ( future.firstChild ) { + const currentChild = current.childNodes[ i ]; + const futureNodeType = future.firstChild.nodeType; + + if ( ! currentChild ) { + current.appendChild( future.firstChild ); + } else if ( + futureNodeType !== currentChild.nodeType || + futureNodeType !== TEXT_NODE || + future.firstChild.nodeValue !== currentChild.nodeValue + ) { + current.replaceChild( future.firstChild, currentChild ); + } else { + future.removeChild( future.firstChild ); + } + + i++; + } + + while ( current.childNodes[ i ] ) { + current.removeChild( current.childNodes[ i ] ); + } + + if ( ! selection.startPath.length ) { + return; + } + + const { node: startContainer, offset: startOffset } = getNodeByPath( current, selection.startPath ); + const { node: endContainer, offset: endOffset } = getNodeByPath( current, selection.endPath ); + + const sel = window.getSelection(); + const range = current.ownerDocument.createRange(); + const collapsed = startContainer === endContainer && startOffset === endOffset; + + if ( + collapsed && + startOffset === 0 && + startContainer.previousSibling && + startContainer.previousSibling.nodeType === ELEMENT_NODE && + startContainer.previousSibling.nodeName !== 'BR' + ) { + startContainer.insertData( 0, '\uFEFF' ); + range.setStart( startContainer, 1 ); + range.setEnd( endContainer, 1 ); + } else { + range.setStart( startContainer, startOffset ); + range.setEnd( endContainer, endOffset ); + } + + sel.removeAllRanges(); + sel.addRange( range ); +} + +function getAttributes( element, settings = {} ) { + if ( ! element.hasAttributes() ) { + return; + } + + const { + removeAttributeMatch = () => false, + } = settings; + + return Array.from( element.attributes ).reduce( ( acc, { name, value } ) => { + if ( ! removeAttributeMatch( name ) ) { + acc = acc || {}; + acc[ name ] = value; + } + + return acc; + }, undefined ); +} + +function createPathToNode( node, rootNode, path ) { + const parentNode = node.parentNode; + let i = 0; + + while ( ( node = node.previousSibling ) ) { + i++; + } + + path = [ i, ...path ]; + + if ( parentNode !== rootNode ) { + path = createPathToNode( parentNode, rootNode, path ); + } + + return path; +} + +function getNodeByPath( node, path ) { + path = [ ...path ]; + + while ( node && path.length > 1 ) { + node = node.childNodes[ path.shift() ]; + } + + return { + node, + offset: path[ 0 ], + }; +} + +export function toDOM( { value, selection = {} }, multiline, _tag ) { + const doc = document.implementation.createHTMLDocument( '' ); + let { body } = doc; + let startPath = []; + let endPath = []; + + if ( multiline ) { + value.forEach( ( piece, index ) => { + const start = selection.start && selection.start[ 0 ] === index ? selection.start[ 1 ] : undefined; + const end = selection.end && selection.end[ 0 ] === index ? selection.end[ 1 ] : undefined; + const dom = toDOM( { + value: piece, + selection: { + start, + end, + }, + }, false, multiline ); + + body.appendChild( dom.body ); + + if ( dom.selection.startPath.length ) { + startPath = [ index, ...dom.selection.startPath ]; + } + + if ( dom.selection.endPath.length ) { + endPath = [ index, ...dom.selection.endPath ]; + } + } ); + + return { + body, + selection: { startPath, endPath }, + }; + } + + const { formats, text } = value; + const { start, end } = selection; + + if ( _tag ) { + body = body.appendChild( doc.createElement( _tag ) ); + } + + for ( let i = 0, max = text.length; i < max; i++ ) { + const character = text.charAt( i ); + const nextFormats = formats[ i ] || []; + let pointer = body.lastChild || body.appendChild( doc.createTextNode( '' ) ); + + if ( nextFormats ) { + nextFormats.forEach( ( { type, attributes, object } ) => { + if ( pointer && type === pointer.nodeName.toLowerCase() ) { + pointer = pointer.lastChild; + return; + } + + const newNode = doc.createElement( type ); + const parentNode = pointer.parentNode; + + for ( const key in attributes ) { + newNode.setAttribute( key, attributes[ key ] ); + } + + parentNode.appendChild( newNode ); + pointer = ( object ? parentNode : newNode ).appendChild( doc.createTextNode( '' ) ); + } ); + } + + if ( character === '\n' ) { + pointer = pointer.parentNode.appendChild( doc.createElement( 'br' ) ); + } else if ( pointer.nodeType === TEXT_NODE ) { + pointer.appendData( character ); + } else { + pointer = pointer.parentNode.appendChild( doc.createTextNode( character ) ); + } + + if ( start === i ) { + const initialPath = pointer.nodeValue ? [ pointer.nodeValue.length - 1 ] : []; + startPath = createPathToNode( pointer, body, initialPath ); + } + + if ( end === i ) { + const initialPath = pointer.nodeValue ? [ pointer.nodeValue.length - 1 ] : []; + endPath = createPathToNode( pointer, body, initialPath ); + } + } + + const last = text.length; + + if ( formats[ last ] ) { + formats[ last ].reduce( ( element, { type, attributes } ) => { + const newNode = doc.createElement( type ); + + for ( const key in attributes ) { + newNode.setAttribute( key, attributes[ key ] ); + } + + return element.appendChild( newNode ); + }, body ); + } + + return { + body, + selection: { startPath, endPath }, + }; +} + +export function toString( record, multiline ) { + return toDOM( { value: record }, multiline ).body.innerHTML; +} + +export function concat( record, ...records ) { + if ( Array.isArray( record ) ) { + return record.concat( ...records ); + } + + return records.reduce( ( accu, { formats, text } ) => { + accu.text += text; + accu.formats.push( ...formats ); + return accu; + }, { ...record } ); +} + +export function isEmpty( record ) { + if ( Array.isArray( record ) ) { + return record.length === 0 || ( record.length === 1 && isEmpty( record[ 0 ] ) ); + } + + const { text, formats } = record; + + return text.length === 0 && formats.length === 0; +} + +export function splice( { formats, text, selection, value }, start, deleteCount, textToInsert = '', formatsToInsert ) { + if ( value !== undefined ) { + start = start || selection.start; + deleteCount = deleteCount || selection.end - selection.start; + + const diff = textToInsert.length - deleteCount; + + return { + selection: { + start: selection.start + ( selection.start >= start ? diff : 0 ), + end: selection.end + ( selection.end >= start ? diff : 0 ), + }, + value: splice( value, start, deleteCount, textToInsert, formatsToInsert ), + }; + } + + if ( ! Array.isArray( formatsToInsert ) ) { + const newFormats = formatsToInsert ? [ formatsToInsert ] : formats[ start ]; + formatsToInsert = Array( textToInsert.length ).fill( newFormats ); + } + + formats.splice( start, deleteCount, ...formatsToInsert ); + text = text.slice( 0, start ) + textToInsert + text.slice( start + deleteCount ); + + return { formats, text }; +} + +export function getTextContent( { text, value, selection } ) { + if ( value !== undefined ) { + if ( Array.isArray( value ) ) { + if ( isCollapsed( { selection } ) ) { + const [ index ] = selection.start; + return value[ index ].text; + } + + return; + } + + return value.text; + } + + return text; +} + +export function applyFormat( { formats, text, value, selection }, format, start, end ) { + if ( value !== undefined ) { + start = start || selection.start; + end = end || selection.end; + + if ( Array.isArray( value ) ) { + return { + selection, + value: value.map( ( item, index ) => { + const [ startRecord, startOffset ] = start; + const [ endRecord, endOffset ] = end; + + if ( startRecord === endRecord && startRecord === index ) { + return applyFormat( item, format, startOffset, endOffset ); + } + + if ( startRecord === index ) { + return applyFormat( item, format, startOffset, item.text.length ); + } + + if ( endRecord === index ) { + return applyFormat( item, format, 0, endOffset ); + } + + if ( index > startRecord && index < endRecord ) { + return applyFormat( item, format, 0, item.text.length ); + } + + return item; + } ), + }; + } + + return { + selection, + value: applyFormat( value, format, start, end ), + }; + } + + for ( let i = start; i < end; i++ ) { + if ( formats[ i ] ) { + const newFormats = formats[ i ].filter( ( { type } ) => type !== format.type ); + newFormats.push( format ); + formats[ i ] = newFormats; + } else { + formats[ i ] = [ format ]; + } + } + + return { formats, text }; +} + +export function removeFormat( { formats, text, value, selection }, formatType, start, end ) { + if ( value !== undefined ) { + start = start || selection.start; + end = end || selection.end; + + if ( Array.isArray( value ) ) { + return { + selection, + value: value.map( ( item, index ) => { + const [ startRecord, startOffset ] = start; + const [ endRecord, endOffset ] = end; + + if ( startRecord === endRecord && startRecord === index ) { + return removeFormat( item, formatType, startOffset, endOffset ); + } + + if ( startRecord === index ) { + return removeFormat( item, formatType, startOffset, item.text.length ); + } + + if ( endRecord === index ) { + return removeFormat( item, formatType, 0, endOffset ); + } + + if ( index > startRecord && index < endRecord ) { + return removeFormat( item, formatType, 0, item.text.length ); + } + + return item; + } ), + }; + } + + return { + selection, + value: removeFormat( value, formatType, start, end ), + }; + } + + for ( let i = start; i < end; i++ ) { + if ( formats[ i ] ) { + const newFormats = formats[ i ].filter( ( { type } ) => type !== formatType ); + formats[ i ] = newFormats.length ? newFormats : undefined; + } + } + + return { formats, text }; +} + +export function getActiveFormat( { value, selection }, formatType ) { + if ( ! selection || ! selection.start ) { + return false; + } + + if ( Array.isArray( value ) ) { + return getActiveFormat( { + value: value[ selection.start[ 0 ] ], + selection: { + start: selection.start[ 1 ], + end: selection.end[ 1 ], + }, + }, formatType ); + } + + const formats = value.formats[ selection.start ]; + + return find( formats, { type: formatType } ); +} + +export function split( { text, formats, selection, value }, start, end ) { + if ( value !== undefined ) { + start = start || selection.start; + end = end || selection.end; + + const [ startValue, endValue ] = split( value, start, end ); + + return [ + { + selection: {}, + value: startValue, + }, + { + selection: { + start: 0, + end: 0, + }, + value: endValue, + }, + ]; + } + + return [ + { + formats: formats.slice( 0, start ), + text: text.slice( 0, start ), + }, + { + formats: formats.slice( end ), + text: text.slice( end ), + }, + ]; +} + +export function isCollapsed( { selection } ) { + const { start, end } = selection; + + if ( ! start ) { + return; + } + + if ( typeof start === 'number' ) { + return start === end; + } + + const [ startRecord, startOffset ] = start; + const [ endRecord, endOffset ] = end; + + return startRecord === endRecord && startOffset === endOffset; +} diff --git a/packages/blocks/src/api/test/children.js b/packages/blocks/src/api/test/children.js deleted file mode 100644 index cc3fe5d76d28a..0000000000000 --- a/packages/blocks/src/api/test/children.js +++ /dev/null @@ -1,137 +0,0 @@ -/** - * 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/matchers.js b/packages/blocks/src/api/test/matchers.js deleted file mode 100644 index 32d1246a2e9d6..0000000000000 --- a/packages/blocks/src/api/test/matchers.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * External dependencies - */ -import { parse } from 'hpq'; - -/** - * WordPress dependencies - */ -import { renderToString } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import * as sources from '../matchers'; - -describe( 'matchers', () => { - describe( 'children()', () => { - it( 'should return a source function', () => { - const source = sources.children(); - - expect( typeof source ).toBe( 'function' ); - } ); - - it( 'should return HTML equivalent WPElement of matched element', () => { - // Assumption here is that we can cleanly convert back and forth - // between a string and WPElement representation - const html = '

    A delicious sundae dessert

    '; - const match = parse( html, sources.children() ); - - expect( renderToString( match ) ).toBe( html ); - } ); - } ); - - describe( 'node()', () => { - it( 'should return a source function', () => { - const source = sources.node(); - - expect( typeof source ).toBe( 'function' ); - } ); - - it( 'should return HTML equivalent WPElement of matched element', () => { - // Assumption here is that we can cleanly convert back and forth - // between a string and WPElement representation - const html = '

    A delicious sundae dessert

    '; - const match = parse( html, sources.node() ); - - expect( renderToString( match ) ).toBe( `${ html }` ); - } ); - } ); -} ); diff --git a/packages/blocks/src/api/test/node.js b/packages/blocks/src/api/test/node.js deleted file mode 100644 index 447395a24ed12..0000000000000 --- a/packages/blocks/src/api/test/node.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * 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/blocks/src/api/test/rich-text-structure.js b/packages/blocks/src/api/test/rich-text-structure.js new file mode 100644 index 0000000000000..b18647fe5f61e --- /dev/null +++ b/packages/blocks/src/api/test/rich-text-structure.js @@ -0,0 +1,1107 @@ +import { deepEqual } from 'assert'; +import { JSDOM } from 'jsdom'; + +const { window } = new JSDOM(); +const { document } = window; + +import { + create, + createWithSelection, + toString, + concat, + isEmpty, + splice, + applyFormat, + removeFormat, + split, + getActiveFormat, +} from '../rich-text-structure'; + +function createNode( HTML ) { + const doc = document.implementation.createHTMLDocument( '' ); + doc.body.innerHTML = HTML; + return doc.body.firstChild; +} + +describe( 'create', () => { + it( 'should extract text with formats', () => { + const element = createNode( '

    one two 🍒 three

    ' ); + const range = { + startOffset: 1, + startContainer: element.querySelector( 'em' ).firstChild, + endOffset: 0, + endContainer: element.querySelector( 'strong' ).firstChild, + }; + + deepEqual( createWithSelection( element, range ), { + value: { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + undefined, + [ { type: 'a', attributes: { href: '#' } }, { type: 'img', attributes: { src: '' }, object: true }, { type: 'strong' } ], + [ { type: 'a', attributes: { href: '#' } }, { type: 'strong' } ], + [ { type: 'a', attributes: { href: '#' } }, { type: 'strong' } ], + [ { type: 'a', attributes: { href: '#' } }, { type: 'strong' } ], + [ { type: 'a', attributes: { href: '#' } }, { type: 'strong' } ], + [ { type: 'img', attributes: { src: '' }, object: true } ], + ], + text: 'one two 🍒 three', + }, + selection: { + start: 5, + end: 11, + }, + } ); + } ); + + it( 'should extract multiline text', () => { + const element = createNode( '

    one two three

    test

    ' ); + const range = { + startOffset: 1, + startContainer: element.querySelector( 'em' ).firstChild, + endOffset: 0, + endContainer: element.lastChild, + }; + + deepEqual( createWithSelection( element, range, 'p' ), { + value: [ + { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + }, + { + formats: [ + undefined, + undefined, + undefined, + undefined, + ], + text: 'test', + }, + ], + selection: { + start: [ 0, 5 ], + end: [ 1 ], + }, + } ); + } ); + + it( 'should extract multiline text list', () => { + const element = createNode( '
    • one
      • two
    • three
    ' ); + + deepEqual( create( element, 'li' ), [ + { + formats: [ + undefined, + undefined, + undefined, + [ { type: 'ul' }, { type: 'li' } ], + [ { type: 'ul' }, { type: 'li' } ], + [ { type: 'ul' }, { type: 'li' } ], + ], + text: 'onetwo', + }, + { + formats: [ + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'three', + }, + ] ); + } ); + + it( 'should skip bogus 1', () => { + const element = createNode( '

    test

    ' ); + const range = { + startOffset: 1, + startContainer: element.querySelector( 'strong' ).firstChild, + endOffset: 1, + endContainer: element.querySelector( 'strong' ).firstChild, + }; + const settings = { + removeNodeMatch: ( node ) => node.getAttribute( 'data-mce-bogus' ) === 'all', + unwrapNodeMatch: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + removeAttributeMatch: ( attribute ) => attribute.indexOf( 'data-mce-' ) === 0, + filterString: ( string ) => string.replace( '\uFEFF', '' ), + }; + + deepEqual( createWithSelection( element, range, false, settings ), { + value: { + formats: [ + [ { type: 'strong' } ], + [ { type: 'strong' } ], + [ { type: 'strong' } ], + [ { type: 'strong' } ], + ], + text: 'test', + }, + selection: { + start: 0, + end: 0, + }, + } ); + } ); + + it( 'should skip bogus 2', () => { + const element = createNode( '

    testtest test

    ' ); + const range = { + startOffset: 1, + startContainer: element.lastChild, + endOffset: 1, + endContainer: element.lastChild, + }; + const settings = { + removeNodeMatch: ( node ) => node.getAttribute( 'data-mce-bogus' ) === 'all', + unwrapNodeMatch: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + removeAttributeMatch: ( attribute ) => attribute.indexOf( 'data-mce-' ) === 0, + filterString: ( string ) => string.replace( '\uFEFF', '' ), + }; + + deepEqual( createWithSelection( element, range, false, settings ), { + value: { + formats: [ + [ { type: 'strong' } ], + [ { type: 'strong' } ], + [ { type: 'strong' } ], + [ { type: 'strong' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'test test', + }, + selection: { + start: 5, + end: 5, + }, + } ); + } ); + + it( 'should handle br', () => { + const element = createNode( '

    test
    test

    ' ); + const range1 = { + startOffset: 1, + startContainer: element, + endOffset: 1, + endContainer: element, + }; + const range2 = { + startOffset: 0, + startContainer: element.lastChild, + endOffset: 0, + endContainer: element.lastChild, + }; + + deepEqual( createWithSelection( element, range1, false ), { + value: { + formats: [ + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'test\ntest', + }, + selection: { + start: 4, + end: 4, + }, + } ); + + deepEqual( createWithSelection( element, range2, false ), { + value: { + formats: [ + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'test\ntest', + }, + selection: { + start: 5, + end: 5, + }, + } ); + } ); +} ); + +describe( 'toString', () => { + it( 'should extract recreate HTML 1', () => { + const HTML = 'one two 🍒 three'; + + deepEqual( toString( create( createNode( `

    ${ HTML }

    ` ) ) ), HTML ); + } ); + + it( 'should extract recreate HTML 2', () => { + const HTML = 'one two 🍒 test three'; + + deepEqual( toString( create( createNode( `

    ${ HTML }

    ` ) ) ), HTML ); + } ); + + it( 'should extract recreate HTML 3', () => { + const HTML = ''; + + deepEqual( toString( create( createNode( `

    ${ HTML }

    ` ) ) ), HTML ); + } ); + + it( 'should extract recreate HTML 4', () => { + const HTML = ''; + + deepEqual( toString( create( createNode( `

    ${ HTML }

    ` ) ) ), HTML ); + } ); + + it( 'should extract recreate HTML 5', () => { + const HTML = 'two 🍒'; + + deepEqual( toString( create( createNode( `

    ${ HTML }

    ` ) ) ), HTML ); + } ); + + it( 'should extract recreate HTML 6', () => { + 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.'; + + deepEqual( toString( create( createNode( `

    ${ HTML }

    ` ) ) ), HTML ); + } ); + + it( 'should extract recreate HTML 7', () => { + const HTML = '
  • one
    • two
  • three
  • '; + + deepEqual( toString( create( createNode( `
      ${ HTML }
    ` ), 'li' ), 'li' ), HTML ); + } ); +} ); + +describe( 'create with settings', () => { + const settings = { + removeNodeMatch: ( node ) => node.getAttribute( 'data-mce-bogus' ) === 'all', + unwrapNodeMatch: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + removeAttributeMatch: ( attribute ) => attribute.indexOf( 'data-mce-' ) === 0, + filterString: ( string ) => string.replace( '\uFEFF', '' ), + }; + + it( 'should skip bogus 1', () => { + const HTML = '
    '; + + deepEqual( toString( create( createNode( `

    ${ HTML }

    ` ), false, settings ) ), '' ); + } ); + + it( 'should skip bogus 2', () => { + const HTML = ''; + + deepEqual( toString( create( createNode( `

    ${ HTML }

    ` ), false, settings ) ), '' ); + } ); + + it( 'should skip bogus 3', () => { + const HTML = 'test test'; + + deepEqual( toString( create( createNode( `

    ${ HTML }

    ` ), false, settings ) ), 'test test' ); + } ); + + it( 'should skip bogus 4', () => { + const HTML = 'test'; + + deepEqual( toString( create( createNode( `

    ${ HTML }

    ` ), false, settings ) ), '' ); + } ); + + it( 'should skip bogus 5', () => { + const HTML = 'test'; + + deepEqual( toString( create( createNode( `

    ${ HTML }

    ` ), false, settings ) ), 'test' ); + } ); +} ); + +describe( 'concat', () => { + it( 'should merge records', () => { + const one = { + formats: [ + undefined, + undefined, + [ { type: 'em' } ], + ], + text: 'one', + }; + const two = { + formats: [ + [ { type: 'em' } ], + undefined, + undefined, + ], + text: 'two', + }; + const three = { + formats: [ + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + undefined, + undefined, + ], + text: 'onetwo', + }; + + const merged = concat( one, two ); + + expect( merged ).not.toBe( one ); + expect( merged ).toEqual( three ); + } ); + + it( 'should merge multiline records', () => { + const one = [ { + formats: [ + undefined, + undefined, + [ { type: 'em' } ], + ], + text: 'one', + } ]; + const two = [ { + formats: [ + undefined, + undefined, + [ { type: 'em' } ], + ], + text: 'two', + } ]; + + const merged = concat( one, two ); + + expect( merged ).not.toBe( one ); + expect( merged ).toEqual( [ ...one, ...two ] ); + } ); +} ); + +describe( 'isEmpty', () => { + const emptyRecord = { + formats: [], + text: '', + }; + + it( 'should return true', () => { + const one = emptyRecord; + const two = [ emptyRecord ]; + const three = []; + + expect( isEmpty( one ) ).toBe( true ); + expect( isEmpty( two ) ).toBe( true ); + expect( isEmpty( three ) ).toBe( true ); + } ); + + it( 'should return false', () => { + const one = { + formats: [], + text: 'test', + }; + const two = { + formats: [ + [ { type: 'image' } ], + ], + text: '', + }; + const three = [ emptyRecord, one ]; + const four = [ one ]; + + expect( isEmpty( one ) ).toBe( false ); + expect( isEmpty( two ) ).toBe( false ); + expect( isEmpty( three ) ).toBe( false ); + expect( isEmpty( four ) ).toBe( false ); + } ); +} ); + +describe( 'splice', () => { + it( 'should delete and insert', () => { + const record = { + value: { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + }, + selection: { + start: 6, + end: 6, + }, + }; + + const expected = { + value: { + formats: [ + undefined, + undefined, + [ { type: 'strong' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'onao three', + }, + selection: { + start: 3, + end: 3, + }, + }; + + expect( splice( record, 2, 4, 'a', [ [ { type: 'strong' } ] ] ) ).toEqual( expected ); + } ); + + it( 'should insert line break with selection', () => { + const record = { + value: { + formats: [ + undefined, + undefined, + ], + text: 'tt', + }, + selection: { + start: 1, + end: 1, + }, + }; + + const expected = { + value: { + formats: [ + undefined, + undefined, + undefined, + ], + text: 't\nt', + }, + selection: { + start: 2, + end: 2, + }, + }; + + expect( splice( record, undefined, 0, '\n' ) ).toEqual( expected ); + } ); + + // it( 'should delete and insert multiline', () => { + // const record = { + // value: [ + // { + // formats: [ + // undefined, + // undefined, + // undefined, + // undefined, + // [ { type: 'em' } ], + // [ { type: 'em' } ], + // [ { type: 'em' } ], + // undefined, + // undefined, + // undefined, + // undefined, + // undefined, + // undefined, + // ], + // text: 'one two three', + // } + // ], + // selection: { + // start: [ 0, 6 ], + // end: [ 0, 6 ], + // }, + // }; + + // const expected = { + // value: [ + // { + // formats: [ + // undefined, + // undefined, + // [ { type: 'strong' } ], + // [ { type: 'em' } ], + // undefined, + // undefined, + // undefined, + // undefined, + // undefined, + // undefined, + // ], + // text: 'onao three', + // } + // ], + // selection: { + // start: [ 0, 3 ], + // end: [ 0, 3 ], + // }, + // }; + + // expect( splice( record, [ 0, 2 ], [ 0, 4 ], 'a', [ [ { type: 'strong' } ] ] ) ).toEqual( expected ); + // } ); +} ); + +describe( 'applyFormat', () => { + it( 'should apply format', () => { + const record = { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + }; + + const expected = { + formats: [ + undefined, + undefined, + undefined, + [ { type: 'strong' } ], + [ { type: 'em' }, { type: 'strong' } ], + [ { type: 'em' }, { type: 'strong' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + }; + + expect( applyFormat( record, { type: 'strong' }, 3, 6 ) ).toEqual( expected ); + } ); + + it( 'should apply format by selection', () => { + const record = { + value: { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + }, + selection: { + start: 3, + end: 6, + }, + }; + + const expected = { + value: { + formats: [ + undefined, + undefined, + undefined, + [ { type: 'strong' } ], + [ { type: 'em' }, { type: 'strong' } ], + [ { type: 'em' }, { type: 'strong' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + }, + selection: { + start: 3, + end: 6, + }, + }; + + expect( applyFormat( record, { type: 'strong' } ) ).toEqual( expected ); + } ); + + it( 'should apply format for multiline', () => { + const record = { + value: [ + { + formats: [ + undefined, + undefined, + undefined, + ], + text: 'one', + }, + { + formats: [ + undefined, + undefined, + undefined, + ], + text: 'two', + }, + { + formats: [ + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'three', + }, + { + formats: [ + undefined, + undefined, + undefined, + undefined, + ], + text: 'four', + }, + ], + selection: { + start: [ 0, 2 ], + end: [ 2, 1 ], + }, + }; + + const expected = { + value: [ + { + formats: [ + undefined, + undefined, + [ { type: 'em' } ], + ], + text: 'one', + }, + { + formats: [ + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + ], + text: 'two', + }, + { + formats: [ + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + ], + text: 'three', + }, + { + formats: [ + undefined, + undefined, + undefined, + undefined, + ], + text: 'four', + }, + ], + selection: { + start: [ 0, 2 ], + end: [ 2, 1 ], + }, + }; + + expect( applyFormat( record, { type: 'em' } ) ).toEqual( expected ); + } ); +} ); + +describe( 'removeFormat', () => { + it( 'should remove format', () => { + const record = { + formats: [ + undefined, + undefined, + undefined, + [ { type: 'strong' } ], + [ { type: 'em' }, { type: 'strong' } ], + [ { type: 'em' }, { type: 'strong' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + }; + + const expected = { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + }; + + expect( removeFormat( record, 'strong', 3, 6 ) ).toEqual( expected ); + } ); + + it( 'should remove format for multiline', () => { + const record = { + value: [ + { + formats: [ + undefined, + undefined, + [ { type: 'em' } ], + ], + text: 'one', + }, + { + formats: [ + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + ], + text: 'two', + }, + { + formats: [ + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + ], + text: 'three', + }, + { + formats: [ + undefined, + undefined, + undefined, + undefined, + ], + text: 'four', + }, + ], + selection: { + start: [ 0, 2 ], + end: [ 2, 1 ], + }, + }; + + const expected = { + value: [ + { + formats: [ + undefined, + undefined, + undefined, + ], + text: 'one', + }, + { + formats: [ + undefined, + undefined, + undefined, + ], + text: 'two', + }, + { + formats: [ + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'three', + }, + { + formats: [ + undefined, + undefined, + undefined, + undefined, + ], + text: 'four', + }, + ], + selection: { + start: [ 0, 2 ], + end: [ 2, 1 ], + }, + }; + + expect( removeFormat( record, 'em' ) ).toEqual( expected ); + } ); +} ); + +describe( 'getActiveFormat', () => { + it( 'should get format by selection', () => { + const record = { + value: { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + }, + selection: { + start: 4, + end: 4, + }, + }; + + const expected = { type: 'em' }; + + expect( getActiveFormat( record, 'em' ) ).toEqual( expected ); + } ); + + it( 'should get format by selection for multiline', () => { + const record = { + value: [ { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + } ], + selection: { + start: [ 0, 4 ], + end: [ 0, 4 ], + }, + }; + + const expected = { type: 'em' }; + + expect( getActiveFormat( record, 'em' ) ).toEqual( expected ); + } ); +} ); + +describe( 'split', () => { + it( 'should split', () => { + const record = { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + }; + + const expected = [ + { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + ], + text: 'one tw', + }, + { + formats: [ + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'o three', + }, + ]; + + expect( split( record, 6, 6 ) ).toEqual( expected ); + } ); + + it( 'should split with selection', () => { + const record = { + value: { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'one two three', + }, + selection: { + start: 6, + end: 6, + }, + }; + + const expected = [ + { + value: { + formats: [ + undefined, + undefined, + undefined, + undefined, + [ { type: 'em' } ], + [ { type: 'em' } ], + ], + text: 'one tw', + }, + selection: {}, + }, + { + value: { + formats: [ + [ { type: 'em' } ], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + text: 'o three', + }, + selection: { + start: 0, + end: 0, + }, + }, + ]; + + expect( split( record ) ).toEqual( expected ); + } ); + + it( 'should split empty', () => { + const record = { + formats: [], + text: '', + }; + + const expected = [ + record, + record, + ]; + + expect( split( record, 6, 6 ) ).toEqual( expected ); + } ); +} ); diff --git a/packages/components/src/autocomplete/index.js b/packages/components/src/autocomplete/index.js index 17a2d29aeab0c..fcb32d6599b55 100644 --- a/packages/components/src/autocomplete/index.js +++ b/packages/components/src/autocomplete/index.js @@ -2,16 +2,18 @@ * External dependencies */ import classnames from 'classnames'; -import { escapeRegExp, find, filter, map, debounce } from 'lodash'; +import { escapeRegExp, find, map, debounce } from 'lodash'; import 'element-closest'; /** * WordPress dependencies */ -import { Component, renderToString } from '@wordpress/element'; +import { Component } from '@wordpress/element'; import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, SPACE } from '@wordpress/keycodes'; import { __, _n, sprintf } from '@wordpress/i18n'; import { withInstanceId, compose } from '@wordpress/compose'; +import { richTextStructure } from '@wordpress/blocks'; +import { getRectangleFromRange } from '@wordpress/dom'; /** * Internal dependencies @@ -104,74 +106,6 @@ import withSpokenMessages from '../higher-order/with-spoken-messages'; * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. */ -/** - * Recursively select the firstChild until hitting a leaf node. - * - * @param {Node} node The node to find the recursive first child. - * - * @return {Node} The first leaf-node >= node in the ordering. - */ -function descendFirst( node ) { - let n = node; - while ( n.firstChild ) { - n = n.firstChild; - } - return n; -} - -/** - * Recursively select the lastChild until hitting a leaf node. - * - * @param {Node} node The node to find the recursive last child. - * - * @return {Node} The first leaf-node <= node in the ordering. - */ -function descendLast( node ) { - let n = node; - while ( n.lastChild ) { - n = n.lastChild; - } - return n; -} - -/** - * Is the node a text node. - * - * @param {?Node} node The node to check. - * - * @return {boolean} True if the node is a text node. - */ -function isTextNode( node ) { - return node !== null && node.nodeType === 3; -} - -/** - * Return the node only if it is a text node, otherwise return null. - * - * @param {?Node} node The node to filter. - * - * @return {?Node} The node or null if it is not a text node. - */ -function onlyTextNode( node ) { - return isTextNode( node ) ? node : null; -} - -/** - * Find the index of the last character in the text that is whitespace. - * - * @param {string} text The text to search. - * - * @return {number} The last index of a white space character in the text or -1. - */ -function lastIndexOfSpace( text ) { - for ( let i = text.length - 1; i >= 0; i-- ) { - if ( /\s/.test( text.charAt( i ) ) ) { - return i; - } - } - return -1; -} - function filterOptions( search, options = [], maxResults = 10 ) { const filtered = []; for ( let i = 0; i < options.length; i++ ) { @@ -199,6 +133,14 @@ function filterOptions( search, options = [], maxResults = 10 ) { return filtered; } +function getCaretRect() { + const range = window.getSelection().getRangeAt( 0 ); + + if ( range ) { + return getRectangleFromRange( range ); + } +} + export class Autocomplete extends Component { static getInitialState() { return { @@ -219,9 +161,7 @@ export class Autocomplete extends Component { this.select = this.select.bind( this ); this.reset = this.reset.bind( this ); this.resetWhenSuppressed = this.resetWhenSuppressed.bind( this ); - this.search = this.search.bind( this ); this.handleKeyDown = this.handleKeyDown.bind( this ); - this.getWordRect = this.getWordRect.bind( this ); this.debouncedLoadOptions = debounce( this.loadOptions, 250 ); this.state = this.constructor.getInitialState(); @@ -231,31 +171,19 @@ export class Autocomplete extends Component { this.node = node; } - insertCompletion( range, replacement ) { - const container = document.createElement( 'div' ); - container.innerHTML = renderToString( replacement ); - while ( container.firstChild ) { - const child = container.firstChild; - container.removeChild( child ); - range.insertNode( child ); - range.setStartAfter( child ); - } - range.deleteContents(); + insertCompletion( replacement ) { + const { record, onChange } = this.props; - let inputEvent; - if ( undefined !== window.InputEvent ) { - inputEvent = new window.InputEvent( 'input', { bubbles: true, cancelable: false } ); - } else { - // IE11 doesn't provide an InputEvent constructor. - inputEvent = document.createEvent( 'UIEvent' ); - inputEvent.initEvent( 'input', true /* bubbles */, false /* cancelable */ ); - } - range.commonAncestorContainer.closest( '[contenteditable=true]' ).dispatchEvent( inputEvent ); + replacement = replacement.slice( 1 + this.state.query.length ); + + const newRecord = richTextStructure.splice( record, undefined, 0, replacement ); + + onChange( newRecord ); } select( option ) { const { onReplace } = this.props; - const { open, range, query } = this.state; + const { open, query } = this.state; const { getOptionCompletion } = open || {}; if ( option.isDisabled ) { @@ -263,7 +191,7 @@ export class Autocomplete extends Component { } if ( getOptionCompletion ) { - const completion = getOptionCompletion( option.value, range, query ); + const completion = getOptionCompletion( option.value, query ); const { action, value } = ( undefined === completion.action || undefined === completion.value ) ? @@ -273,7 +201,7 @@ export class Autocomplete extends Component { if ( 'replace' === action ) { onReplace( [ value ] ); } else if ( 'insert-at-caret' === action ) { - this.insertCompletion( range, value ); + this.insertCompletion( value ); } } @@ -303,31 +231,6 @@ export class Autocomplete extends Component { this.reset(); } - // this method is separate so it can be overridden in tests - getCursor( container ) { - const selection = window.getSelection(); - if ( selection.isCollapsed ) { - if ( 'production' !== process.env.NODE_ENV ) { - if ( ! container.contains( selection.anchorNode ) ) { - throw new Error( 'Invalid assumption: expected selection to be within the autocomplete container' ); - } - } - return { - node: selection.anchorNode, - offset: selection.anchorOffset, - }; - } - return null; - } - - // this method is separate so it can be overridden in tests - createRange( startNode, startOffset, endNode, endOffset ) { - const range = document.createRange(); - range.setStart( startNode, startOffset ); - range.setEnd( endNode, endOffset ); - return range; - } - announce( filteredOptions ) { const { debouncedSpeak } = this.props; if ( ! debouncedSpeak ) { @@ -391,119 +294,6 @@ export class Autocomplete extends Component { } ); } - findMatch( container, cursor, allCompleters, wasOpen ) { - const allowAnything = () => true; - let endTextNode; - let endIndex; - // search backwards to find the first preceding space or non-text node. - if ( isTextNode( cursor.node ) ) { // TEXT node - endTextNode = cursor.node; - endIndex = cursor.offset; - } else if ( cursor.offset === 0 ) { - endTextNode = onlyTextNode( descendFirst( cursor.node ) ); - endIndex = 0; - } else { - endTextNode = onlyTextNode( descendLast( cursor.node.childNodes[ cursor.offset - 1 ] ) ); - endIndex = endTextNode ? endTextNode.nodeValue.length : 0; - } - if ( endTextNode === null ) { - return null; - } - // store the index of a completer in the object so we can use it to reference the options - let completers = map( allCompleters, ( completer, idx ) => ( { ...completer, idx } ) ); - if ( wasOpen ) { - // put the open completer at the start so it has priority - completers = [ - wasOpen, - ...filter( completers, ( completer ) => completer.idx !== wasOpen.idx ), - ]; - } - // filter the completers to those that could handle this node - completers = filter( completers, - ( { allowNode = allowAnything } ) => allowNode( endTextNode, container ) ); - // exit early if nothing can handle it - if ( completers.length === 0 ) { - return null; - } - let startTextNode = endTextNode; - let text = endTextNode.nodeValue.substring( 0, endIndex ); - let pos = lastIndexOfSpace( text ); - while ( pos === -1 ) { - const prev = onlyTextNode( startTextNode.previousSibling ); - if ( prev === null ) { - break; - } - // filter the completers to those that could handle this node - completers = filter( completers, - ( { allowNode = allowAnything } ) => allowNode( endTextNode, container ) ); - // exit early if nothing can handle it - if ( completers.length === 0 ) { - return null; - } - startTextNode = prev; - text = prev.nodeValue + text; - pos = lastIndexOfSpace( prev.nodeValue ); - } - // exit early if nothing can handle it - if ( text.length <= pos + 1 ) { - return null; - } - // find a completer that matches - const open = find( completers, ( { triggerPrefix = '', allowContext = allowAnything } ) => { - if ( text.substr( pos + 1, triggerPrefix.length ) !== triggerPrefix ) { - return false; - } - const before = this.createRange( container, 0, startTextNode, pos + 1 ); - const after = this.createRange( endTextNode, endIndex, container, container.childNodes.length ); - return allowContext( before, after ); - } ); - // exit if no completers match - if ( ! open ) { - return null; - } - const { triggerPrefix = '' } = open; - const range = this.createRange( startTextNode, pos + 1, endTextNode, endIndex ); - const query = text.substr( pos + 1 + triggerPrefix.length ); - return { open, range, query }; - } - - search( event ) { - const { completers } = this.props; - const { open: wasOpen, suppress: wasSuppress, query: wasQuery } = this.state; - const container = event.target; - - // ensure that the cursor location is unambiguous - const cursor = this.getCursor( container ); - if ( ! cursor ) { - return; - } - // look for the trigger prefix and search query just before the cursor location - const match = this.findMatch( container, cursor, completers, wasOpen ); - const { open, query, range } = match || {}; - // asynchronously load the options for the open completer - if ( open && ( ! wasOpen || open.idx !== wasOpen.idx || query !== wasQuery ) ) { - if ( open.isDebounced ) { - this.debouncedLoadOptions( open, query ); - } else { - this.loadOptions( open, query ); - } - } - // create a regular expression to filter the options - const search = open ? new RegExp( '(?:\\b|\\s|^)' + escapeRegExp( query ), 'i' ) : /./; - // filter the options we already have - const filteredOptions = open ? filterOptions( search, this.state[ 'options_' + open.idx ] ) : []; - // check if we should still suppress the popover - const suppress = ( open && wasSuppress === open.idx ) ? wasSuppress : undefined; - // update the state - if ( wasOpen || open ) { - this.setState( { selectedIndex: 0, filteredOptions, suppress, search, open, query, range } ); - } - // announce the count of filtered options but only if they have loaded - if ( open && this.state[ 'options_' + open.idx ] ) { - this.announce( filteredOptions ); - } - } - handleKeyDown( event ) { const { open, suppress, selectedIndex, filteredOptions } = this.state; if ( ! open ) { @@ -568,15 +358,6 @@ export class Autocomplete extends Component { event.stopPropagation(); } - getWordRect() { - const { range } = this.state; - if ( ! range ) { - return; - } - - return range.getBoundingClientRect(); - } - toggleKeyEvents( isListening ) { // This exists because we must capture ENTER key presses before RichText. // It seems that react fires the simulated capturing events after the @@ -588,10 +369,53 @@ export class Autocomplete extends Component { } componentDidUpdate( prevProps, prevState ) { - const { open } = this.state; + const { record, completers } = this.props; + const { record: prevRecord } = prevProps; const { open: prevOpen } = prevState; - if ( ( ! open ) !== ( ! prevOpen ) ) { - this.toggleKeyEvents( ! ! open ); + + if ( ( ! this.state.open ) !== ( ! prevOpen ) ) { + this.toggleKeyEvents( ! ! this.state.open ); + } + + if ( richTextStructure.isCollapsed( record ) ) { + const text = richTextStructure.getTextContent( record ); + const prevText = richTextStructure.getTextContent( prevRecord ); + const textToSearch = text.slice( 0, record.selection.start ); + const prevTextToSearch = prevText.slice( 0, prevRecord.selection.start ); + + if ( textToSearch !== prevTextToSearch ) { + const allCompleters = map( completers, ( completer, idx ) => ( { ...completer, idx } ) ); + const open = find( allCompleters, ( settings ) => settings.test( textToSearch ) ); + + if ( ! open ) { + return; + } + + const query = open.getQuery( textToSearch ); + const { open: wasOpen, suppress: wasSuppress, query: wasQuery } = this.state; + + if ( open && ( ! wasOpen || open.idx !== wasOpen.idx || query !== wasQuery ) ) { + if ( open.isDebounced ) { + this.debouncedLoadOptions( open, query ); + } else { + this.loadOptions( open, query ); + } + } + // create a regular expression to filter the options + const search = open ? new RegExp( '(?:\\b|\\s|^)' + escapeRegExp( query ), 'i' ) : /./; + // filter the options we already have + const filteredOptions = open ? filterOptions( search, this.state[ 'options_' + open.idx ] ) : []; + // check if we should still suppress the popover + const suppress = ( open && wasSuppress === open.idx ) ? wasSuppress : undefined; + // update the state + if ( wasOpen || open ) { + this.setState( { selectedIndex: 0, filteredOptions, suppress, search, open, query } ); + } + // announce the count of filtered options but only if they have loaded + if ( open && this.state[ 'options_' + open.idx ] ) { + this.announce( filteredOptions ); + } + } } } @@ -608,12 +432,12 @@ export class Autocomplete extends Component { const isExpanded = suppress !== idx && filteredOptions.length > 0; const listBoxId = isExpanded ? `components-autocomplete-listbox-${ instanceId }` : null; const activeId = isExpanded ? `components-autocomplete-item-${ instanceId }-${ selectedKey }` : null; + // Disable reason: Clicking the editor should reset the autocomplete when the menu is suppressed /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ return (
    @@ -624,7 +448,7 @@ export class Autocomplete extends Component { onClose={ this.reset } position="top right" className="components-autocomplete__popover" - getAnchorRect={ this.getWordRect } + getAnchorRect={ getCaretRect } >
    - { children } -
    - ); - } -} - -function makeAutocompleter( completers, { - AutocompleteComponent = Autocomplete, - mountImplementation = mount, - onReplace = noop, -} = {} ) { - return mountImplementation( - - { ( { isExpanded, listBoxId, activeId } ) => ( - - ) } - - ); -} - -/** - * Create a text node. - * - * @param {string} text Text of text node. - - * @return {Node} A text node. - */ -function tx( text ) { - return document.createTextNode( text ); -} - -/** - * Create a paragraph node with the arguments as children. - - * @return {Node} A paragraph node. - */ -function par( /* arguments */ ) { - const p = document.createElement( 'p' ); - Array.from( arguments ).forEach( ( element ) => p.appendChild( element ) ); - return p; -} - -/** - * Simulate typing into the fake editor by updating the content and simulating - * an input event. It also updates the data-cursor attribute which is used to - * simulate the cursor position in the test mocks. - * - * @param {*} wrapper Enzyme wrapper around react node - * containing a FakeEditor. - * @param {Array.} nodeList Array of dom nodes. - * @param {Array.} cursorPosition Array specifying the child indexes and - * offset of the cursor. - */ -function simulateInput( wrapper, nodeList, cursorPosition ) { - // update the editor content - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.innerHTML = ''; - nodeList.forEach( ( element ) => fakeEditor.appendChild( element ) ); - if ( cursorPosition && cursorPosition.length >= 1 ) { - fakeEditor.setAttribute( 'data-cursor', cursorPosition.join( ',' ) ); - } else { - fakeEditor.removeAttribute( 'data-cursor' ); - } - // simulate input event - wrapper.find( '.fake-editor' ).simulate( 'input', { - target: fakeEditor, - } ); - wrapper.update(); -} - -/** - * Same as simulateInput except configured for use with React.TestUtils - * @param {*} wrapper Wrapper around react node - * containing a FakeEditor. - * @param {Array.} nodeList Array of dom nodes. - * @param {Array.} cursorPosition Array specifying the child indexes and - * offset of the cursor. - */ -function simulateInputForUtils( wrapper, nodeList, cursorPosition ) { - // update the editor content - const fakeEditor = TestUtils.findRenderedDOMComponentWithClass( - wrapper, - 'fake-editor' - ); - fakeEditor.innerHTML = ''; - nodeList.forEach( ( element ) => fakeEditor.appendChild( element ) ); - if ( cursorPosition && cursorPosition.length >= 1 ) { - fakeEditor.setAttribute( 'data-cursor', cursorPosition.join( ',' ) ); - } else { - fakeEditor.removeAttribute( 'data-cursor' ); - } - TestUtils.Simulate.input( - fakeEditor, - { - target: fakeEditor, - } - ); -} - -/** - * Fire a native keydown event on the fake editor in the wrapper. - * - * @param {*} wrapper The wrapper containing the FakeEditor where the event will - * be dispatched. - * @param {*} keyCode The keycode of the key event. - */ -function simulateKeydown( wrapper, keyCode ) { - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - const event = new KeyboardEvent( 'keydown', { keyCode } ); // eslint-disable-line - fakeEditor.dispatchEvent( event ); - wrapper.update(); -} - -/** - * Check that the autocomplete matches the initial state. - * - * @param {*} wrapper The enzyme react wrapper. - */ -function expectInitialState( wrapper ) { - expect( wrapper.state( 'open' ) ).toBeUndefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toBeUndefined(); - expect( wrapper.state( 'search' ) ).toEqual( /./ ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 0 ); - expect( wrapper.find( '.components-autocomplete__result' ) ).toHaveLength( 0 ); -} - -describe( 'Autocomplete', () => { - const options = [ - { - id: 1, - label: 'Bananas', - keywords: [ 'fruit' ], - }, - { - id: 2, - label: 'Apple', - keywords: [ 'fruit' ], - }, - { - id: 3, - label: 'Avocado', - keywords: [ 'fruit' ], - }, - ]; - - const basicCompleter = { - options, - getOptionLabel: ( option ) => option.label, - getOptionKeywords: ( option ) => option.keywords, - isOptionDisabled: ( option ) => option.isDisabled, - }; - - const slashCompleter = { - triggerPrefix: '/', - ...basicCompleter, - }; - - let realGetCursor, realCreateRange; - - beforeAll( () => { - realGetCursor = Autocomplete.prototype.getCursor; - - Autocomplete.prototype.getCursor = jest.fn( ( container ) => { - if ( container.hasAttribute( 'data-cursor' ) ) { - // the cursor position is specified by a list of child indexes (relative to the container) and the offset - const path = container.getAttribute( 'data-cursor' ).split( ',' ).map( ( val ) => parseInt( val, 10 ) ); - const offset = path.pop(); - let node = container; - for ( let i = 0; i < path.length; i++ ) { - node = container.childNodes[ path[ i ] ]; - } - return { node, offset }; - } - // by default we say the cursor is at the end of the editor - return { - node: container, - offset: container.childNodes.length, - }; - } ); - - realCreateRange = Autocomplete.prototype.createRange; - - Autocomplete.prototype.createRange = jest.fn( ( startNode, startOffset, endNode, endOffset ) => { - const fakeBounds = { x: 0, y: 0, width: 1, height: 1, top: 0, right: 1, bottom: 1, left: 0 }; - return { - startNode, - startOffset, - endNode, - endOffset, - getClientRects: () => [ fakeBounds ], - getBoundingClientRect: () => fakeBounds, - }; - } ); - } ); - - afterAll( () => { - Autocomplete.prototype.getCursor = realGetCursor; - Autocomplete.prototype.createRange = realCreateRange; - } ); - - describe( 'render()', () => { - it( 'renders children', () => { - const wrapper = makeAutocompleter( [] ); - expect( wrapper.state().open ).toBeUndefined(); - expect( wrapper.childAt( 0 ).hasClass( 'components-autocomplete' ) ).toBe( true ); - expect( wrapper.find( '.fake-editor' ) ).toHaveLength( 1 ); - } ); - - it( 'opens on absent trigger prefix search', ( done ) => { - const wrapper = makeAutocompleter( [ basicCompleter ] ); - expectInitialState( wrapper ); - // simulate typing 'b' - simulateInput( wrapper, [ par( tx( 'b' ) ) ] ); - // wait for async popover display - process.nextTick( function() { - wrapper.update(); - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( 'b' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)b/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - expect( wrapper.find( 'Popover' ).prop( 'focusOnMount' ) ).toBe( false ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 1 ); - done(); - } ); - } ); - - it( 'does not render popover as open if no results', ( done ) => { - const wrapper = makeAutocompleter( [ basicCompleter ] ); - expectInitialState( wrapper ); - // simulate typing 'zzz' - simulateInput( wrapper, [ tx( 'zzz' ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // now check that we've opened the popup and filtered the options to empty - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'query' ) ).toEqual( 'zzz' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)zzz/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 0 ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 0 ); - done(); - } ); - } ); - - it( 'does not open without trigger prefix', ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - expectInitialState( wrapper ); - // simulate typing 'b' - simulateInput( wrapper, [ par( tx( 'b' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // now check that the popup is not open - expectInitialState( wrapper ); - done(); - } ); - } ); - - it( 'opens on trigger prefix search', ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // now check that we've opened the popup and filtered the options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( '' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 3 ); - done(); - } ); - } ); - - it( 'searches by keywords', ( done ) => { - const wrapper = makeAutocompleter( [ basicCompleter ] ); - expectInitialState( wrapper ); - // simulate typing fruit (split over 2 text nodes because these things happen) - simulateInput( wrapper, [ par( tx( 'fru' ), tx( 'it' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // now check that we've opened the popup and filtered the options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( 'fruit' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)fruit/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 3 ); - done(); - } ); - } ); - - it( 'closes when search ends (whitespace)', ( done ) => { - const wrapper = makeAutocompleter( [ basicCompleter ] ); - expectInitialState( wrapper ); - // simulate typing 'a' - simulateInput( wrapper, [ tx( 'a' ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // now check that we've opened the popup and all options are displayed - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( 'a' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)a/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 2 ); - // simulate typing 'p' - simulateInput( wrapper, [ tx( 'ap' ) ] ); - // now check that the popup is still open and we've filtered the options to just the apple - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( 'ap' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)ap/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 1 ); - // simulate typing ' ' - simulateInput( wrapper, [ tx( 'ap ' ) ] ); - // check the popup closes - expectInitialState( wrapper ); - done(); - } ); - } ); - - it( 'renders options provided via array', ( done ) => { - const wrapper = makeAutocompleter( [ - { ...slashCompleter, options }, - ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - - const itemWrappers = wrapper.find( 'button.components-autocomplete__result' ); - expect( itemWrappers ).toHaveLength( 3 ); - - const expectedLabelContent = options.map( ( o ) => o.label ); - const actualLabelContent = itemWrappers.map( ( itemWrapper ) => itemWrapper.text() ); - expect( actualLabelContent ).toEqual( expectedLabelContent ); - - done(); - } ); - } ); - it( 'renders options provided via function that returns array', ( done ) => { - const optionsMock = jest.fn( () => options ); - - const wrapper = makeAutocompleter( [ - { ...slashCompleter, options: optionsMock }, - ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - - const itemWrappers = wrapper.find( 'button.components-autocomplete__result' ); - expect( itemWrappers ).toHaveLength( 3 ); - - const expectedLabelContent = options.map( ( o ) => o.label ); - const actualLabelContent = itemWrappers.map( ( itemWrapper ) => itemWrapper.text() ); - expect( actualLabelContent ).toEqual( expectedLabelContent ); - - done(); - } ); - } ); - it( 'renders options provided via function that returns promise', ( done ) => { - const optionsMock = jest.fn( () => Promise.resolve( options ) ); - - const wrapper = makeAutocompleter( [ - { ...slashCompleter, options: optionsMock }, - ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - - const itemWrappers = wrapper.find( 'button.components-autocomplete__result' ); - expect( itemWrappers ).toHaveLength( 3 ); - - const expectedLabelContent = options.map( ( o ) => o.label ); - const actualLabelContent = itemWrappers.map( ( itemWrapper ) => itemWrapper.text() ); - expect( actualLabelContent ).toEqual( expectedLabelContent ); - - done(); - } ); - } ); - - it( 'set the disabled attribute on results', ( done ) => { - const wrapper = makeAutocompleter( [ - { - ...slashCompleter, - options: [ - { - id: 1, - label: 'Bananas', - keywords: [ 'fruit' ], - isDisabled: true, - }, - { - id: 2, - label: 'Apple', - keywords: [ 'fruit' ], - isDisabled: false, - }, - ], - }, - ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - - const firstItem = wrapper.find( 'button.components-autocomplete__result' ).at( 0 ).getDOMNode(); - expect( firstItem.hasAttribute( 'disabled' ) ).toBe( true ); - - const secondItem = wrapper.find( 'button.components-autocomplete__result' ).at( 1 ).getDOMNode(); - expect( secondItem.hasAttribute( 'disabled' ) ).toBe( false ); - - done(); - } ); - } ); - - it( 'navigates options by arrow keys', ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - // listen to keydown events on the editor to see if it gets them - const editorKeydown = jest.fn(); - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.addEventListener( 'keydown', editorKeydown, false ); - expectInitialState( wrapper ); - // the menu is not open so press an arrow and see if the editor gets it - expect( editorKeydown ).not.toHaveBeenCalled(); - simulateKeydown( wrapper, DOWN ); - expect( editorKeydown ).toHaveBeenCalledTimes( 1 ); - // clear the call count - editorKeydown.mockClear(); - // simulate typing '/', the menu is open so the editor should not get key down events - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - simulateKeydown( wrapper, DOWN ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 1 ); - simulateKeydown( wrapper, DOWN ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 2 ); - simulateKeydown( wrapper, DOWN ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - simulateKeydown( wrapper, UP ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 2 ); - simulateKeydown( wrapper, UP ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 1 ); - simulateKeydown( wrapper, UP ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - simulateKeydown( wrapper, UP ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 2 ); - expect( editorKeydown ).not.toHaveBeenCalled(); - done(); - } ); - } ); - - it( 'resets selected index on subsequent search', ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - simulateKeydown( wrapper, DOWN ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 1 ); - // simulate typing 'f - simulateInput( wrapper, [ par( tx( '/f' ) ) ] ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - done(); - } ); - } ); - - it( 'closes by escape', ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - // listen to keydown events on the editor to see if it gets them - const editorKeydown = jest.fn(); - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.addEventListener( 'keydown', editorKeydown, false ); - expectInitialState( wrapper ); - // the menu is not open so press escape and see if the editor gets it - expect( editorKeydown ).not.toHaveBeenCalled(); - simulateKeydown( wrapper, ESCAPE ); - expect( editorKeydown ).toHaveBeenCalledTimes( 1 ); - // clear the call count - editorKeydown.mockClear(); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with all options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'suppress' ) ).toBeUndefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( '' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - // pressing escape should suppress the dialog but it maintains the state - simulateKeydown( wrapper, ESCAPE ); - expect( wrapper.state( 'suppress' ) ).toEqual( 0 ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 0 ); - // the editor should not have gotten the event - expect( editorKeydown ).not.toHaveBeenCalled(); - done(); - } ); - } ); - - it( 'closes by blur', () => { - jest.spyOn( Autocomplete.prototype, 'handleFocusOutside' ); - // required because TestUtils doesn't handle stateless components for some - // reason. Without this, wrapper would end up with the value of null. - class Enhanced extends Component { - render() { - return ; - } - } - - const wrapper = makeAutocompleter( [], { - AutocompleteComponent: Enhanced, - mountImplementation: TestUtils.renderIntoDocument, - } ); - simulateInputForUtils( wrapper, [ par( tx( '/' ) ) ] ); - TestUtils.Simulate.blur( - TestUtils.findRenderedDOMComponentWithClass( - wrapper, - 'fake-editor' - ) - ); - - jest.runAllTimers(); - - expect( Autocomplete.prototype.handleFocusOutside ).toHaveBeenCalled(); - } ); - - it( 'selects by enter', ( done ) => { - const getOptionCompletion = jest.fn().mockReturnValue( { - action: 'non-existent-action', - value: 'dummy-value', - } ); - const wrapper = makeAutocompleter( [ { ...slashCompleter, getOptionCompletion } ] ); - // listen to keydown events on the editor to see if it gets them - const editorKeydown = jest.fn(); - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.addEventListener( 'keydown', editorKeydown, false ); - expectInitialState( wrapper ); - // the menu is not open so press enter and see if the editor gets it - expect( editorKeydown ).not.toHaveBeenCalled(); - simulateKeydown( wrapper, ENTER ); - expect( editorKeydown ).toHaveBeenCalledTimes( 1 ); - // clear the call count - editorKeydown.mockClear(); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with all options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( '' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - // pressing enter should reset and call getOptionCompletion - simulateKeydown( wrapper, ENTER ); - expectInitialState( wrapper ); - expect( getOptionCompletion ).toHaveBeenCalled(); - // the editor should not have gotten the event - expect( editorKeydown ).not.toHaveBeenCalled(); - done(); - } ); - } ); - - it( 'does not select when option is disabled', ( done ) => { - const getOptionCompletion = jest.fn(); - const testOptions = [ - { - id: 1, - label: 'Bananas', - keywords: [ 'fruit' ], - isDisabled: true, - }, - { - id: 2, - label: 'Apple', - keywords: [ 'fruit' ], - isDisabled: false, - }, - ]; - const wrapper = makeAutocompleter( [ { ...slashCompleter, getOptionCompletion, options: testOptions } ] ); - // listen to keydown events on the editor to see if it gets them - const editorKeydown = jest.fn(); - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.addEventListener( 'keydown', editorKeydown, false ); - expectInitialState( wrapper ); - // the menu is not open so press enter and see if the editor gets it - expect( editorKeydown ).not.toHaveBeenCalled(); - simulateKeydown( wrapper, ENTER ); - expect( editorKeydown ).toHaveBeenCalledTimes( 1 ); - // clear the call count - editorKeydown.mockClear(); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with all options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( '' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: testOptions[ 0 ], label: 'Bananas', keywords: [ 'fruit' ], isDisabled: true }, - { key: '0-1', value: testOptions[ 1 ], label: 'Apple', keywords: [ 'fruit' ], isDisabled: false }, - ] ); - // pressing enter should NOT reset and NOT call getOptionCompletion - simulateKeydown( wrapper, ENTER ); - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( getOptionCompletion ).not.toHaveBeenCalled(); - // the editor should not have gotten the event - expect( editorKeydown ).not.toHaveBeenCalled(); - done(); - } ); - } ); - - it( "doesn't otherwise interfere with keydown behavior", ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - // listen to keydown events on the editor to see if it gets them - const editorKeydown = jest.fn(); - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.addEventListener( 'keydown', editorKeydown, false ); - expectInitialState( wrapper ); - [ UP, DOWN, ENTER, ESCAPE, SPACE ].forEach( ( keyCode, i ) => { - simulateKeydown( wrapper, keyCode ); - expect( editorKeydown ).toHaveBeenCalledTimes( i + 1 ); - } ); - expect( editorKeydown ).toHaveBeenCalledTimes( 5 ); - done(); - } ); - - it( 'selects by click', ( done ) => { - const getOptionCompletion = jest.fn().mockReturnValue( { - action: 'non-existent-action', - value: 'dummy-value', - } ); - const wrapper = makeAutocompleter( [ { ...slashCompleter, getOptionCompletion } ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with all options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( '' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - // clicking should reset and select the item - wrapper.find( '.components-autocomplete__result Button' ).at( 0 ).simulate( 'click' ); - wrapper.update(); - expectInitialState( wrapper ); - expect( getOptionCompletion ).toHaveBeenCalled(); - done(); - } ); - } ); - - it( 'calls insertCompletion for a completion with action `insert-at-caret`', ( done ) => { - const getOptionCompletion = jest.fn() - .mockReturnValueOnce( { - action: 'insert-at-caret', - value: 'expected-value', - } ); - - const insertCompletion = jest.fn(); - - const wrapper = makeAutocompleter( - [ { ...slashCompleter, getOptionCompletion } ], - { - AutocompleteComponent: class extends Autocomplete { - insertCompletion( ...args ) { - return insertCompletion( ...args ); - } - }, - } - ); - expectInitialState( wrapper ); - - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with at least one option - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'filteredOptions' ).length ).toBeGreaterThanOrEqual( 1 ); - - // clicking should reset and select the item - wrapper.find( '.components-autocomplete__result Button' ).at( 0 ).simulate( 'click' ); - wrapper.update(); - - expect( insertCompletion ).toHaveBeenCalledTimes( 1 ); - expect( insertCompletion.mock.calls[ 0 ][ 1 ] ).toBe( 'expected-value' ); - done(); - } ); - } ); - - it( 'calls insertCompletion for a completion without an action property', ( done ) => { - const getOptionCompletion = jest.fn().mockReturnValueOnce( 'expected-value' ); - - const insertCompletion = jest.fn(); - - const wrapper = makeAutocompleter( - [ { ...slashCompleter, getOptionCompletion } ], - { - AutocompleteComponent: class extends Autocomplete { - insertCompletion( ...args ) { - return insertCompletion( ...args ); - } - }, - } - ); - expectInitialState( wrapper ); - - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with at least one option - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'filteredOptions' ).length ).toBeGreaterThanOrEqual( 1 ); - - // clicking should reset and select the item - wrapper.find( '.components-autocomplete__result Button' ).at( 0 ).simulate( 'click' ); - wrapper.update(); - - expect( insertCompletion ).toHaveBeenCalledTimes( 1 ); - expect( insertCompletion.mock.calls[ 0 ][ 1 ] ).toBe( 'expected-value' ); - done(); - } ); - } ); - - it( 'calls onReplace for a completion with action `replace`', ( done ) => { - const getOptionCompletion = jest.fn() - .mockReturnValueOnce( { - action: 'replace', - value: 'expected-value', - } ); - - const onReplace = jest.fn(); - - const wrapper = makeAutocompleter( - [ { ...slashCompleter, getOptionCompletion } ], - { onReplace } ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with at least one option - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'filteredOptions' ).length ).toBeGreaterThanOrEqual( 1 ); - - // clicking should reset and select the item - wrapper.find( '.components-autocomplete__result Button' ).at( 0 ).simulate( 'click' ); - wrapper.update(); - - expect( onReplace ).toHaveBeenCalledTimes( 1 ); - expect( onReplace ).toHaveBeenLastCalledWith( [ 'expected-value' ] ); - done(); - } ); - } ); - } ); -} ); diff --git a/packages/editor/src/components/autocompleters/block.js b/packages/editor/src/components/autocompleters/block.js index 01b26951b08ce..dd849106dd13f 100644 --- a/packages/editor/src/components/autocompleters/block.js +++ b/packages/editor/src/components/autocompleters/block.js @@ -58,7 +58,17 @@ export function createBlockCompleter( { return { name: 'blocks', className: 'editor-autocompleters__block', - triggerPrefix: '/', + test( string ) { + if ( string.indexOf( '/' ) !== 0 ) { + return false; + } + + return /^\/\w*$/.test( string ); + }, + getQuery( string ) { + const match = string.match( /^\/(\w*)$/ ); + return match && match[ 1 ]; + }, options() { const selectedBlockName = getSelectedBlockName(); return getInserterItems( getBlockInsertionParentClientId() ).filter( @@ -77,9 +87,6 @@ export function createBlockCompleter( { title, ]; }, - allowContext( before, after ) { - return ! ( /\S/.test( before.toString() ) || /\S/.test( after.toString() ) ); - }, getOptionCompletion( inserterItem ) { const { name, initialAttributes } = inserterItem; return { diff --git a/packages/editor/src/components/autocompleters/user.js b/packages/editor/src/components/autocompleters/user.js index 19c7527e015df..c2f3628971a29 100644 --- a/packages/editor/src/components/autocompleters/user.js +++ b/packages/editor/src/components/autocompleters/user.js @@ -11,7 +11,17 @@ import apiFetch from '@wordpress/api-fetch'; export default { name: 'users', className: 'editor-autocompleters__user', - triggerPrefix: '@', + test( string ) { + if ( string.indexOf( '@' ) === -1 ) { + return false; + } + + return /(?:\s|^)@\w*$/.test( string ); + }, + getQuery( string ) { + const match = string.match( /@(\w*)$/ ); + return match && match[ 1 ]; + }, options( search ) { let payload = ''; if ( search ) { @@ -30,9 +40,6 @@ export default { { user.slug }, ]; }, - allowNode() { - return true; - }, getOptionCompletion( user ) { return `@${ user.slug }`; }, diff --git a/packages/editor/src/components/rich-text/format-toolbar/index.js b/packages/editor/src/components/rich-text/format-toolbar/index.js index f588f18a30919..e1df679b66424 100644 --- a/packages/editor/src/components/rich-text/format-toolbar/index.js +++ b/packages/editor/src/components/rich-text/format-toolbar/index.js @@ -14,6 +14,7 @@ import { } from '@wordpress/components'; import { ESCAPE, LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER, displayShortcut } from '@wordpress/keycodes'; import { prependHTTP } from '@wordpress/url'; +import { richTextStructure } from '@wordpress/blocks'; /** * Internal dependencies @@ -28,24 +29,28 @@ const FORMATTING_CONTROLS = [ title: __( 'Bold' ), shortcut: displayShortcut.primary( 'b' ), format: 'bold', + selector: 'strong', }, { icon: 'editor-italic', title: __( 'Italic' ), shortcut: displayShortcut.primary( 'i' ), format: 'italic', + selector: 'em', }, { icon: 'editor-strikethrough', title: __( 'Strikethrough' ), shortcut: displayShortcut.access( 'd' ), format: 'strikethrough', + selector: 'del', }, { icon: 'admin-links', title: __( 'Link' ), shortcut: displayShortcut.primary( 'k' ), format: 'link', + selector: 'a', }, ]; @@ -118,9 +123,16 @@ class FormatToolbar extends Component { toggleFormat( format ) { return () => { - this.props.onChange( { - [ format ]: ! this.props.formats[ format ], - } ); + const { record } = this.props; + let newRecord; + + if ( richTextStructure.getActiveFormat( record, format.type ) ) { + newRecord = richTextStructure.removeFormat( record, format.type ); + } else { + newRecord = richTextStructure.applyFormat( record, format ); + } + + this.props.onChange( newRecord ); }; } @@ -140,65 +152,86 @@ class FormatToolbar extends Component { } addLink() { + const newRecord = richTextStructure.applyFormat( this.props.record, { + type: 'a', + attributes: { + href: '', + }, + } ); + + this.props.onChange( newRecord ); this.setState( { linkValue: '' } ); - this.props.onChange( { link: { isAdding: true } } ); } dropLink() { - this.props.onChange( { link: null } ); + const newRecord = richTextStructure.removeFormat( this.props.record, 'a' ); + + this.props.onChange( newRecord ); this.setState( { linkValue: '' } ); } editLink( event ) { + const format = richTextStructure.getActiveFormat( this.props.record, 'a' ); + + this.setState( { linkValue: format.attributes.href, isEditing: true } ); event.preventDefault(); - this.props.onChange( { link: { ...this.props.formats.link, isAdding: true } } ); - this.setState( { linkValue: this.props.formats.link.value } ); } submitLink( event ) { - event.preventDefault(); - const value = prependHTTP( this.state.linkValue ); - this.props.onChange( { link: { - isAdding: false, - target: this.state.opensInNewWindow ? '_blank' : null, - rel: this.state.opensInNewWindow ? 'noreferrer noopener' : null, - value, - } } ); + const { linkValue, opensInNewWindow } = this.state; + const { record } = this.props; + const href = prependHTTP( linkValue ); + const format = { + type: 'a', + attributes: { + href, + }, + }; - this.setState( { linkValue: value } ); - if ( ! this.props.formats.link.value ) { - this.props.speak( __( 'Link added.' ), 'assertive' ); + if ( opensInNewWindow ) { + format.attributes.target = '_blank'; + format.attributes.rel = 'noreferrer noopener'; } + + this.props.onChange( richTextStructure.applyFormat( record, format ) ); + + this.setState( { linkValue: href } ); + + // if ( ! this.props.formats.link.value ) { + // this.props.speak( __( 'Link added.' ), 'assertive' ); + // } + + event.preventDefault(); } - isFormatActive( format ) { - return this.props.formats[ format ] && this.props.formats[ format ].isActive; + getActiveFormat( formatType ) { + return richTextStructure.getActiveFormat( this.props.record, formatType ); } render() { - const { formats, enabledControls = DEFAULT_CONTROLS, customControls = [], selectedNodeId } = this.props; - const { linkValue, settingsVisible, opensInNewWindow } = this.state; - const isAddingLink = formats.link && formats.link.isAdding; + const { enabledControls = DEFAULT_CONTROLS, customControls = [], selectedNodeId } = this.props; + const { linkValue, settingsVisible, opensInNewWindow, isEditing } = this.state; + const link = this.getActiveFormat( 'a' ); + + const isEditingLink = isEditing || ( link && ( ! link.attributes || ! link.attributes.href ) ); const toolbarControls = FORMATTING_CONTROLS.concat( customControls ) .filter( ( control ) => enabledControls.indexOf( control.format ) !== -1 ) .map( ( control ) => { if ( control.format === 'link' ) { - const isFormatActive = this.isFormatActive( 'link' ); - const isActive = isFormatActive || isAddingLink; return { ...control, - icon: isFormatActive ? 'editor-unlink' : 'admin-links', // TODO: Need proper unlink icon - title: isFormatActive ? __( 'Unlink' ) : __( 'Link' ), - onClick: isActive ? this.dropLink : this.addLink, - isActive, + icon: link ? 'editor-unlink' : 'admin-links', // TODO: Need proper unlink icon + title: link ? __( 'Unlink' ) : __( 'Link' ), + onClick: link ? this.dropLink : this.addLink, + isActive: !! link, }; } return { ...control, - onClick: this.toggleFormat( control.format ), - isActive: this.isFormatActive( control.format ), + onClick: this.toggleFormat( { type: control.selector } ), + isActive: !! this.getActiveFormat( control.selector ), }; } ); @@ -215,15 +248,15 @@ class FormatToolbar extends Component {
    - { ( isAddingLink || formats.link ) && ( + { link && ( - { isAddingLink && ( + { isEditingLink && ( // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
    - { formats.link.value && filterURLForDisplay( decodeURI( formats.link.value ) ) } + { link.attributes.href && filterURLForDisplay( decodeURI( link.attributes.href ) ) } node.getAttribute( 'data-mce-bogus' ) === 'all', + unwrapNodeMatch: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + removeAttributeMatch: ( attribute ) => attribute.indexOf( 'data-mce-' ) === 0, + filterString: ( string ) => string.replace( '\uFEFF', '' ), +}; /** * Transforms a value in a given format into string. * - * @param {Array|string?} value DOM Elements. - * @param {string} format Output format (string or element) + * @param {Object|Array|string?} value RichText value. + * @param {string?} multiline Multitine tag. + * @param {string?} format Output format (string or element). * * @return {string} HTML output as string. */ -export function valueToString( value, format ) { - if ( ! value ) { - return ''; - } - +export function valueToString( value, multiline, format ) { switch ( format ) { case 'string': - return value; - - case 'element': - return elementToString( value ); - - case 'children': - return children.toHTML( value ); + return value || ''; + default: + return richTextStructure.toString( value, multiline ); } } -/** - * Strips out TinyMCE specific attributes and nodes from a WPElement - * - * @param {string} type Element type - * @param {Object} props Element Props - * @param {Array} children Element Children - * - * @return {Element} WPElement. - */ -export function createTinyMCEElement( type, props, ...elementChildren ) { - if ( props[ 'data-mce-bogus' ] === 'all' ) { - return null; - } - - if ( props.hasOwnProperty( 'data-mce-bogus' ) ) { - return elementChildren; - } - - return createElement( - type, - omitBy( props, ( _, key ) => key.indexOf( 'data-mce-' ) === 0 ), - ...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. - * - * @param {Array} value DOM Elements. - * - * @return {WPElement} WP Element. - */ -export function domToElement( value ) { - return nodeListToReact( value || [], createTinyMCEElement ); -} - -/** - * 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 TinyMCE DOM elements. - * - * @return {WPBlockChildren} WPBlockChildren equivalent value to element. - */ -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; - } - - if ( ! isTinyMCEBogusWrapperElement( node ) ) { - result.push( createBlockChildrenFromTinyMCEElement( node ) ); - } else if ( node.hasChildNodes() ) { - result.push( ...domToBlockChildren( node.childNodes ) ); - } - break; - } - } - - return result; -} - -/** - * 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 ) ); -} - /** * Transforms an array of DOM Elements to the given format. * - * @param {Array} value DOM Elements. - * @param {string} format Output format (string or element) + * @param {Array} value DOM element or fragment. + * @param {string?} multiline Multitine tag. + * @param {string?} format Output format (string or element). * * @return {*} Output. */ -export function domToFormat( value, format ) { +export function domToFormat( value, multiline, format ) { + value = richTextStructure.create( value, multiline, settings ); + switch ( format ) { case 'string': - return domToString( value ); - - case 'element': - return domToElement( value ); - - case 'children': - return domToBlockChildren( value ); + return valueToString( value, 'element', multiline ); + default: + return value; } } diff --git a/packages/editor/src/components/rich-text/index.js b/packages/editor/src/components/rich-text/index.js index 06d954e775615..c0b83ed3053cd 100644 --- a/packages/editor/src/components/rich-text/index.js +++ b/packages/editor/src/components/rich-text/index.js @@ -5,7 +5,6 @@ import classnames from 'classnames'; import { isEqual, forEach, - merge, identity, find, defer, @@ -26,8 +25,7 @@ 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, children } from '@wordpress/blocks'; -import deprecated from '@wordpress/deprecated'; +import { rawHandler, richTextStructure } from '@wordpress/blocks'; import { withInstanceId, withSafeTimeout, compose } from '@wordpress/compose'; /** @@ -59,19 +57,16 @@ const { Node } = window; */ const TINYMCE_ZWSP = '\uFEFF'; -export function getFormatProperties( formatName, parents ) { - switch ( formatName ) { - case 'link' : { - const anchor = find( parents, ( node ) => node.nodeName.toLowerCase() === 'a' ); - return !! anchor ? { value: anchor.getAttribute( 'href' ) || '', target: anchor.getAttribute( 'target' ) || '', node: anchor } : {}; - } - default: - return {}; - } -} - const DEFAULT_FORMATS = [ 'bold', 'italic', 'strikethrough', 'link', 'code' ]; +const { isEmpty } = richTextStructure; +const settings = { + removeNodeMatch: ( node ) => node.getAttribute( 'data-mce-bogus' ) === 'all', + unwrapNodeMatch: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + removeAttributeMatch: ( attribute ) => attribute.indexOf( 'data-mce-' ) === 0, + filterString: ( string ) => string.replace( '\uFEFF', '' ), +}; + export class RichText extends Component { constructor() { super( ...arguments ); @@ -85,19 +80,20 @@ export class RichText extends Component { this.onHorizontalNavigationKeyDown = this.onHorizontalNavigationKeyDown.bind( this ); this.onKeyDown = this.onKeyDown.bind( this ); this.onKeyUp = this.onKeyUp.bind( this ); - this.changeFormats = this.changeFormats.bind( this ); this.onPropagateUndo = this.onPropagateUndo.bind( this ); this.onPastePreProcess = this.onPastePreProcess.bind( this ); this.onPaste = this.onPaste.bind( this ); this.onCreateUndoLevel = this.onCreateUndoLevel.bind( this ); this.setFocusedElement = this.setFocusedElement.bind( this ); + this.onInput = this.onInput.bind( this ); + this.onSelectionChange = this.onSelectionChange.bind( this ); + + this.containerRef = createRef(); + this.patterns = patterns.call( this ); this.state = { - formats: {}, - selectedNodeId: 0, + selection: {}, }; - - this.containerRef = createRef(); } /** @@ -105,12 +101,12 @@ export class RichText extends Component { * * Allows passing in settings which will be overwritten. * - * @param {Object} settings The settings to overwrite. + * @param {Object} mceSettings The settings to overwrite. * @return {Object} The settings for this block. */ - getSettings( settings ) { + getSettings( mceSettings ) { return ( this.props.getSettings || identity )( { - ...settings, + ...mceSettings, forced_root_block: this.props.multiline || false, // Allow TinyMCE to keep one undo level for comparing changes. // Prevent it otherwise from accumulating any history. @@ -137,9 +133,10 @@ export class RichText extends Component { editor.on( 'PastePreProcess', this.onPastePreProcess, true /* Add before core handlers */ ); editor.on( 'paste', this.onPaste, true /* Add before core handlers */ ); editor.on( 'focus', this.onFocus ); - editor.on( 'input', this.onChange ); + editor.on( 'input', this.onInput ); // The change event in TinyMCE fires every time an undo level is added. editor.on( 'change', this.onCreateUndoLevel ); + editor.on( 'selectionchange', this.onSelectionChange ); patterns.apply( this, [ editor ] ); @@ -155,7 +152,7 @@ export class RichText extends Component { } onInit() { - this.registerCustomFormatters(); + // this.registerCustomFormatters(); this.editor.shortcuts.add( rawShortcut.primary( 'k' ), '', () => this.changeFormats( { link: { isAdding: true } } ) ); this.editor.shortcuts.add( rawShortcut.access( 'a' ), '', () => this.changeFormats( { link: { isAdding: true } } ) ); @@ -236,7 +233,7 @@ export class RichText extends Component { mode: 'BLOCKS', tagName: this.props.tagName, } ); - const shouldReplace = this.props.onReplace && this.isEmpty(); + const shouldReplace = this.props.onReplace && isEmpty( this.props.value ); // Allows us to ask for this information when we get a report. window.console.log( 'Received item:\n\n', file ); @@ -268,6 +265,8 @@ export class RichText extends Component { * by TinyMCE. */ onPastePreProcess( event ) { + const { value } = this.props; + const { selection } = this.state; const HTML = this.isPlainTextPaste ? '' : event.content; event.preventDefault(); @@ -283,10 +282,15 @@ export class RichText extends Component { // A URL was pasted, turn the selection into a link if ( linkRegExp.test( pastedText ) ) { - this.editor.execCommand( 'mceInsertLink', false, { - href: this.editor.dom.decode( pastedText ), + const record = richTextStructure.applyFormat( { value, selection }, { + type: 'a', + attributes: { + href: this.editor.dom.decode( pastedText ), + }, } ); + this.onChange( record ); + // Allows us to ask for this information when we get a report. window.console.log( 'Created link:\n\n', pastedText ); @@ -294,7 +298,7 @@ export class RichText extends Component { } } - const shouldReplace = this.props.onReplace && this.isEmpty(); + const shouldReplace = this.props.onReplace && isEmpty( this.props.value ); let mode = 'INLINE'; @@ -352,12 +356,57 @@ export class RichText extends Component { } /** - * Handles any case where the content of the TinyMCE instance has changed. + * Handles the input event and transformations on the new content. */ + onInput() { + const { multiline } = this.props; + const rootNode = this.editor.getBody(); + const range = this.editor.selection.getRng(); + let record = richTextStructure.createWithSelection( rootNode, range, multiline, settings ); + const transformed = this.patterns.reduce( ( accu, transform ) => transform( accu ), record ); + + if ( record !== transformed ) { + richTextStructure.apply( transformed, this.editor.getBody(), multiline ); + record = transformed; + } - onChange() { - this.savedContent = this.getContent(); - this.props.onChange( this.savedContent ); + this.savedContent = record.value; + this.props.onChange( record.value ); + } + + onChange( record ) { + const { multiline } = this.props; + const rootNode = this.editor.getBody(); + + if ( ! record ) { + const range = this.editor.selection.getRng(); + record = richTextStructure.createWithSelection( rootNode, range, multiline, settings ); + } else { + richTextStructure.apply( record, rootNode, multiline ); + } + + this.savedContent = record.value; + this.props.onChange( record.value ); + } + + onSelectionChange() { + const rootNode = this.editor.getBody(); + + if ( document.activeElement !== rootNode ) { + return; + } + + const range = this.editor.selection.getRng(); + + // Ignore selection movement to paste bin. + if ( range.startContainer.parentNode.id === 'mcepastebin' ) { + return; + } + + const { multiline } = this.props; + const { selection } = richTextStructure.createWithSelection( rootNode, range, multiline, settings ); + + this.setState( { selection } ); } onCreateUndoLevel( event ) { @@ -450,7 +499,7 @@ export class RichText extends Component { this.props.onMerge( forward ); } - if ( this.props.onRemove && this.isEmpty() ) { + if ( this.props.onRemove && isEmpty( this.props.value ) ) { this.props.onRemove( forward ); } @@ -491,17 +540,29 @@ export class RichText extends Component { const index = dom.nodeIndex( selectedNode ); const beforeNodes = childNodes.slice( 0, index ); const afterNodes = childNodes.slice( index + 1 ); + const beforeFragment = document.createDocumentFragment(); + const afterFragment = document.createDocumentFragment(); - const { format } = this.props; - const before = domToFormat( beforeNodes, format ); - const after = domToFormat( afterNodes, format ); + beforeNodes.forEach( ( node ) => { + beforeFragment.appendChild( node ); + } ); + afterNodes.forEach( ( node ) => { + afterFragment.appendChild( node ); + } ); + + const { format, multiline } = this.props; + const before = domToFormat( beforeFragment, multiline, format ); + const after = domToFormat( afterFragment, multiline, format ); this.props.onSplit( before, after ); } else { event.preventDefault(); if ( event.shiftKey || ! this.props.onSplit ) { - this.editor.execCommand( 'InsertLineBreak', false, event ); + const { value } = this.props; + const { selection } = this.state; + const record = richTextStructure.splice( { value, selection }, undefined, 0, '\n' ); + this.onChange( record ); } else { this.splitContent(); } @@ -567,53 +628,31 @@ export class RichText extends Component { * @param {Object} context The context for splitting. */ splitContent( blocks = [], context = {} ) { - const { onSplit } = this.props; + const { onSplit, value } = this.props; + const { selection } = this.state; + if ( ! onSplit ) { return; } - const rootNode = this.editor.getBody(); - - let before, after; - if ( rootNode.childNodes.length ) { - const { dom } = this.editor; - const beforeRange = dom.createRng(); - const afterRange = dom.createRng(); - const selectionRange = this.editor.selection.getRng(); - - beforeRange.setStart( rootNode, 0 ); - beforeRange.setEnd( selectionRange.startContainer, selectionRange.startOffset ); - - afterRange.setStart( selectionRange.endContainer, selectionRange.endOffset ); - afterRange.setEnd( rootNode, dom.nodeIndex( rootNode.lastChild ) + 1 ); - - const beforeFragment = beforeRange.cloneContents(); - const afterFragment = afterRange.cloneContents(); - - const { format } = this.props; - before = domToFormat( beforeFragment.childNodes, format ); - after = domToFormat( afterFragment.childNodes, format ); - } else { - before = []; - after = []; - } + let [ before, after ] = richTextStructure.split( value, selection.start, selection.end ); // In case split occurs at the trailing or leading edge of the field, // assume that the before/after values respectively reflect the current // value. This also provides an opportunity for the parent component to // determine whether the before/after value has changed using a trivial // strict equality operation. - if ( this.isEmpty( after ) ) { - before = this.props.value; - } else if ( this.isEmpty( before ) ) { - after = this.props.value; + if ( isEmpty( after ) ) { + before = value; + } else if ( isEmpty( before ) ) { + after = value; } // If pasting and the split would result in no content other than the // pasted blocks, remove the before and after blocks. if ( context.paste ) { - before = this.isEmpty( before ) ? null : before; - after = this.isEmpty( after ) ? null : after; + before = isEmpty( before ) ? null : before; + after = isEmpty( after ) ? null : after; } onSplit( before, after, ...blocks ); @@ -624,18 +663,6 @@ export class RichText extends Component { return; } - const formatNames = this.props.formattingControls; - const formats = this.editor.formatter.matchAll( formatNames ).reduce( ( accFormats, activeFormat ) => { - accFormats[ activeFormat ] = { - isActive: true, - ...getFormatProperties( activeFormat, parents ), - }; - - return accFormats; - }, {} ); - - this.setState( { formats, selectedNodeId: this.state.selectedNodeId + 1 } ); - if ( this.props.isViewportSmall ) { let rect; const selectedAnchor = find( parents, ( node ) => node.tagName === 'A' ); @@ -654,45 +681,20 @@ export class RichText extends Component { } } - setContent( content ) { - const { format } = this.props; - - // If editor has focus while content is being set, save the selection - // and restore caret position after content is set. - let bookmark; - if ( this.editor.hasFocus() ) { - bookmark = this.editor.selection.getBookmark( 2, true ); - } - - this.savedContent = content; - this.editor.setContent( valueToString( content, format ) ); - - if ( bookmark ) { - this.editor.selection.moveToBookmark( bookmark ); - } - } - - getContent() { - const { format } = this.props; - - return domToFormat( this.editor.getBody().childNodes, format ); - } - componentDidUpdate( prevProps ) { - // The `savedContent` var allows us to avoid updating the content right after an `onChange` call + const { tagName, value, multiline } = this.props; + const { selection } = this.state; + if ( - !! this.editor && - this.props.tagName === prevProps.tagName && - this.props.value !== prevProps.value && - this.props.value !== this.savedContent && - - // Comparing using isEqual is necessary especially to avoid unnecessary updateContent calls - // This fixes issues in multi richText blocks like quotes when moving the focus between - // the different editables. - ! isEqual( this.props.value, prevProps.value ) && - ! isEqual( this.props.value, this.savedContent ) + this.editor && + tagName === prevProps.tagName && + value !== prevProps.value && + value !== this.savedContent ) { - this.setContent( this.props.value ); + richTextStructure.apply( { + value, + selection: this.editor.hasFocus() ? selection : undefined, + }, this.editor.getBody(), multiline ); } if ( 'development' === process.env.NODE_ENV ) { @@ -703,76 +705,6 @@ export class RichText extends Component { } } - /** - * Returns true if the field is currently empty, or false otherwise. - * - * @param {Array} value Content to check. - * - * @return {boolean} Whether field is empty. - */ - isEmpty( value = this.props.value ) { - return ! value || ! value.length; - } - - isFormatActive( format ) { - return this.state.formats[ format ] && this.state.formats[ format ].isActive; - } - - removeFormat( format ) { - this.editor.focus(); - this.editor.formatter.remove( format ); - // Formatter does not trigger a change event like `execCommand` does. - this.onCreateUndoLevel(); - } - - applyFormat( format, args, node ) { - this.editor.focus(); - this.editor.formatter.apply( format, args, node ); - // Formatter does not trigger a change event like `execCommand` does. - this.onCreateUndoLevel(); - } - - changeFormats( formats ) { - forEach( formats, ( formatValue, format ) => { - if ( format === 'link' ) { - if ( !! formatValue ) { - if ( formatValue.isAdding ) { - return; - } - - const { value: href, target } = formatValue; - - if ( ! this.isFormatActive( 'link' ) && this.editor.selection.isCollapsed() ) { - // When no link or text is selected, insert a link with the URL as its text - const anchorHTML = this.editor.dom.createHTML( - 'a', - { href, target }, - this.editor.dom.encode( href ) - ); - this.editor.insertContent( anchorHTML ); - } else { - // Use built-in TinyMCE command turn the selection into a link. This takes - // care of deleting any existing links within the selection - this.editor.execCommand( 'mceInsertLink', false, { href, target } ); - } - } else { - this.editor.execCommand( 'Unlink' ); - } - } else { - const isActive = this.isFormatActive( format ); - if ( isActive && ! formatValue ) { - this.removeFormat( format ); - } else if ( ! isActive && formatValue ) { - this.applyFormat( format ); - } - } - } ); - - this.setState( ( state ) => ( { - formats: merge( {}, state.formats, formats ), - } ) ); - } - render() { const { tagName: Tagname = 'div', @@ -791,20 +723,21 @@ export class RichText extends Component { format, } = this.props; + const { selection } = this.state; + const record = { value, selection }; 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 isPlaceholderVisible = placeholder && ( ! isSelected || keepPlaceholderOnFocus ) && isEmpty( value ); const classes = classnames( wrapperClassName, 'editor-rich-text' ); const formatToolbar = ( @@ -831,7 +764,13 @@ export class RichText extends Component { containerRef={ this.containerRef } /> } - + + { ( { isExpanded, listBoxId, activeId } ) => ( { isPlaceholderVisible && { - let content; - switch ( format ) { - case 'string': - content = { value }; - break; - - 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; +RichTextContainer.Content = ( { value, format = 'element', tagName: Tag, multiline, ...props } ) => { + let children; + + if ( multiline ) { + children = ( + + { value.map( ( line, index ) => + + ) } + + ); + } else { + children = { valueToString( value, multiline, format ) }; } if ( Tag ) { - return { content }; + return { children }; } - return content; + return children; }; RichTextContainer.Content.defaultProps = { format: 'children', }; +RichTextContainer.isEmpty = isEmpty; +RichTextContainer.concat = richTextStructure.concat; + export default RichTextContainer; diff --git a/packages/editor/src/components/rich-text/patterns.js b/packages/editor/src/components/rich-text/patterns.js index 5a9ec12c913b5..ea36055f72956 100644 --- a/packages/editor/src/components/rich-text/patterns.js +++ b/packages/editor/src/components/rich-text/patterns.js @@ -1,229 +1,91 @@ /** * External dependencies */ -import tinymce from 'tinymce'; -import { filter, escapeRegExp, groupBy, drop } from 'lodash'; +import { filter, groupBy } from 'lodash'; /** * WordPress dependencies */ -import { ESCAPE, ENTER, SPACE, BACKSPACE } from '@wordpress/keycodes'; -import { getBlockTransforms, findTransform } from '@wordpress/blocks'; +import { getBlockTransforms, findTransform, richTextStructure } from '@wordpress/blocks'; -export default function( editor ) { - const getContent = this.getContent.bind( this ); - const { setTimeout, onReplace } = this.props; - - const VK = tinymce.util.VK; - const settings = editor.settings.wptextpattern || {}; +export default function() { + const { onReplace, multiline } = this.props; + const { splice, applyFormat, getTextContent } = richTextStructure; const { - enter: enterPatterns, - undefined: spacePatterns, + // enter: enterPatterns, + undefined: patterns, } = groupBy( filter( getBlockTransforms( 'from' ), { type: 'pattern' } ), 'trigger' ); - const inlinePatterns = settings.inline || [ - { delimiter: '`', format: 'code' }, - ]; - - let canUndo; - - editor.on( 'selectionchange', function() { - canUndo = null; - } ); - - editor.on( 'keydown', function( event ) { - const { keyCode } = event; - - if ( ( canUndo && keyCode === ESCAPE ) || ( canUndo === 'space' && keyCode === BACKSPACE ) ) { - editor.undoManager.undo(); - event.preventDefault(); - event.stopImmediatePropagation(); - } - - if ( VK.metaKeyPressed( event ) ) { - return; - } - - if ( keyCode === ENTER ) { - enter( event ); - // Wait for the browser to insert the character. - } else if ( keyCode === SPACE ) { - setTimeout( () => searchFirstText( spacePatterns ) ); - } else if ( keyCode > 47 && ! ( keyCode >= 91 && keyCode <= 93 ) ) { - setTimeout( inline ); - } - }, true ); - - function inline() { - const range = editor.selection.getRng(); - const node = range.startContainer; - const carretOffset = range.startOffset; - - // We need a non empty text node with an offset greater than zero. - if ( ! node || node.nodeType !== 3 || ! node.data.length || ! carretOffset ) { - return; - } - - const textBeforeCaret = node.data.slice( 0, carretOffset ); - const charBeforeCaret = node.data.charAt( carretOffset - 1 ); - - const { start, pattern } = inlinePatterns.reduce( ( acc, item ) => { - if ( acc.result ) { - return acc; - } - - if ( charBeforeCaret !== item.delimiter.slice( -1 ) ) { - return acc; - } - - const escapedDelimiter = escapeRegExp( item.delimiter ); - const regExp = new RegExp( '(.*)' + escapedDelimiter + '.+' + escapedDelimiter + '$' ); - const match = textBeforeCaret.match( regExp ); - - if ( ! match ) { - return acc; + return [ + ( record ) => { + if ( ! onReplace ) { + return record; } - const startOffset = match[ 1 ].length; - const endOffset = carretOffset - item.delimiter.length; - const before = textBeforeCaret.charAt( startOffset - 1 ); - const after = textBeforeCaret.charAt( startOffset + item.delimiter.length ); - const delimiterFirstChar = item.delimiter.charAt( 0 ); - - // test*test* => format applied - // test *test* => applied - // test* test* => not applied - if ( startOffset && /\S/.test( before ) ) { - if ( /\s/.test( after ) || before === delimiterFirstChar ) { - return acc; - } - } - - const contentRegEx = new RegExp( '^[\\s' + escapeRegExp( delimiterFirstChar ) + ']+$' ); - const content = textBeforeCaret.slice( startOffset, endOffset ); + const text = getTextContent( record ); + const transformation = findTransform( patterns, ( item ) => { + return item.regExp.test( text ); + } ); - // Do not replace when only whitespace and delimiter characters. - if ( contentRegEx.test( content ) ) { - return acc; + if ( ! transformation ) { + return record; } - return { - start: startOffset, - pattern: item, - }; - }, {} ); - - if ( ! pattern ) { - return; - } - - const { delimiter, format } = pattern; - const formats = editor.formatter.get( format ); - - if ( ! formats || ! formats[ 0 ].inline ) { - return; - } - - editor.undoManager.add(); - editor.undoManager.transact( () => { - node.insertData( carretOffset, '\uFEFF' ); - - const newNode = node.splitText( start ); - const zero = newNode.splitText( carretOffset - start ); + const result = text.match( transformation.regExp ); - newNode.deleteData( 0, delimiter.length ); - newNode.deleteData( newNode.data.length - delimiter.length, delimiter.length ); - - editor.formatter.apply( format, {}, newNode ); - editor.selection.setCursorLocation( zero, 1 ); - - // We need to wait for native events to be triggered. - setTimeout( () => { - canUndo = 'space'; - - editor.once( 'selectionchange', () => { - if ( zero ) { - const zeroOffset = zero.data.indexOf( '\uFEFF' ); - - if ( zeroOffset !== -1 ) { - zero.deleteData( zeroOffset, zeroOffset + 1 ); - } - } - } ); + const block = transformation.transform( { + content: splice( record.value, 0, result[ 0 ].length ), + match: result, } ); - } ); - } - - function searchFirstText( patterns ) { - if ( ! onReplace ) { - return; - } - - // Merge text nodes. - editor.getBody().normalize(); - - const content = getContent(); - - if ( ! content.length ) { - return; - } - const firstText = content[ 0 ]; + onReplace( [ block ] ); - const transformation = findTransform( patterns, ( item ) => { - return item.regExp.test( firstText ); - } ); + return record; + }, + // To do: only on enter. + // ( record ) => { + // if ( ! onReplace ) { + // return record; + // } - if ( ! transformation ) { - return; - } + // const transformation = findTransform( enterPatterns, ( item ) => { + // return item.regExp.test( record.text ); + // } ); - const result = firstText.match( transformation.regExp ); + // if ( ! transformation ) { + // return record; + // } - const range = editor.selection.getRng(); - const matchLength = result[ 0 ].length; - const remainingText = firstText.slice( matchLength ); + // const block = transformation.transform( { content: record.text } ); - // The caret position must be at the end of the match. - if ( range.startOffset !== matchLength ) { - return; - } - - const block = transformation.transform( { - content: [ remainingText, ...drop( content ) ], - match: result, - } ); - - onReplace( [ block ] ); - } - - function enter( event ) { - if ( ! onReplace ) { - return; - } + // onReplace( [ block ] ); + // }, + ( record ) => { + if ( multiline ) { + return record; + } - // Merge text nodes. - editor.getBody().normalize(); + const text = getTextContent( record ); - const content = getContent(); + if ( text.indexOf( '`' ) === -1 ) { + return record; + } - if ( ! content.length ) { - return; - } + const match = text.match( /`([^`]+)`/ ); - const pattern = findTransform( enterPatterns, ( { regExp } ) => regExp.test( content[ 0 ] ) ); + if ( ! match ) { + return record; + } - if ( ! pattern ) { - return; - } + const start = match.index; + const end = start + match[ 1 ].length; - const block = pattern.transform( { content } ); - onReplace( [ block ] ); + record = splice( record, match.index + match[ 0 ].length - 1, 1 ); + record = splice( record, start, 1 ); + record = applyFormat( record, { type: 'code' }, start, end ); - // We call preventDefault to prevent additional newlines. - event.preventDefault(); - // stopImmediatePropagation is called to prevent TinyMCE's own processing of keydown which conflicts with the block replacement. - event.stopImmediatePropagation(); - } + return record; + }, + ]; } diff --git a/packages/editor/src/components/rich-text/test/__snapshots__/format.js.snap b/packages/editor/src/components/rich-text/test/__snapshots__/format.js.snap deleted file mode 100644 index 87da08bd7ecad..0000000000000 --- a/packages/editor/src/components/rich-text/test/__snapshots__/format.js.snap +++ /dev/null @@ -1,44 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`createTinyMCEElement should render a TinyMCE element 1`] = ` -
    -

    - Child -

    -
    -`; - -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 [ -
    - - content - -
    , -] -`; diff --git a/packages/editor/src/components/rich-text/test/format.js b/packages/editor/src/components/rich-text/test/format.js deleted file mode 100644 index fd78aadcbf86a..0000000000000 --- a/packages/editor/src/components/rich-text/test/format.js +++ /dev/null @@ -1,213 +0,0 @@ -/** - * External dependencies - */ -import { shallow } from 'enzyme'; - -/** - * WordPress dependencies - */ -import { createElement } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { - isTinyMCEInternalAttribute, - isTinyMCEBogusElement, - isTinyMCEBogusWrapperElement, - getCleanTextNodeValue, - createTinyMCEElement, - createBlockChildrenFromTinyMCEElement, - elementToString, - domToElement, - domToBlockChildren, - domToString, -} from '../format'; - -describe( 'isTinyMCEInternalAttribute', () => { - 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

    ; - - test( 'should return null', () => { - const props = { - 'data-mce-bogus': 'all', - }; - - expect( createTinyMCEElement( type, props, children ) ).toBeNull(); - } ); - - test( 'should return children', () => { - const props = { - 'data-mce-bogus': '', - }; - - const wrapper = createTinyMCEElement( type, props, children ); - expect( wrapper ).toEqual( [ children ] ); - } ); - - test( 'should render a TinyMCE element', () => { - const props = { - 'data-prop': 'hi', - }; - - const wrapper = shallow( createTinyMCEElement( type, props, children ) ); - expect( wrapper ).toMatchSnapshot(); - } ); -} ); - -describe( 'elementToString', () => { - test( 'should return an empty string for null element', () => { - expect( elementToString( null ) ).toBe( '' ); - } ); - - test( 'should return an empty string for an empty array', () => { - expect( elementToString( [] ) ).toBe( '' ); - } ); - - test( 'should return the HTML content ', () => { - const element = createElement( 'div', { className: 'container' }, - createElement( 'strong', {}, 'content' ) - ); - expect( elementToString( element ) ).toBe( '
    content
    ' ); - } ); -} ); - -describe( 'domToElement', () => { - test( 'should return an empty array', () => { - expect( domToElement( [] ) ).toEqual( [] ); - } ); - - test( 'should return the corresponding element ', () => { - const domElement = document.createElement( 'div' ); - domElement.innerHTML = '
    content
    '; - expect( domToElement( domElement.childNodes ) ).toMatchSnapshot(); - } ); -} ); - -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', () => { - const domElement = document.createElement( 'div' ); - const content = '
    content
    '; - domElement.innerHTML = content; - expect( domToString( domElement.childNodes ) ).toBe( content ); - } ); -} ); - diff --git a/packages/editor/src/components/rich-text/test/index.js b/packages/editor/src/components/rich-text/test/index.js index c7e7f7509cdfe..4d3054c88494b 100644 --- a/packages/editor/src/components/rich-text/test/index.js +++ b/packages/editor/src/components/rich-text/test/index.js @@ -6,66 +6,9 @@ import { shallow } from 'enzyme'; /** * Internal dependencies */ -import { - RichText, - getFormatProperties, -} from '../'; +import { RichText } from '../'; import { diffAriaProps, pickAriaProps } from '../aria'; -describe( 'getFormatProperties', () => { - const formatName = 'link'; - const node = { - nodeName: 'A', - attributes: { - href: 'https://www.testing.com', - target: '_blank', - }, - }; - - test( 'should return an empty object', () => { - expect( getFormatProperties( 'ofSomething' ) ).toEqual( {} ); - } ); - - test( 'should return an empty object if no anchor element is found', () => { - expect( getFormatProperties( formatName, [ { ...node, nodeName: 'P' } ] ) ).toEqual( {} ); - } ); - - test( 'should return a populated object', () => { - const mockNode = { - ...node, - getAttribute: jest.fn().mockImplementation( ( attr ) => mockNode.attributes[ attr ] ), - }; - - const parents = [ - mockNode, - ]; - - expect( getFormatProperties( formatName, parents ) ).toEqual( { - value: 'https://www.testing.com', - target: '_blank', - node: mockNode, - } ); - } ); - - test( 'should return an object with empty values when no link is found', () => { - const mockNode = { - ...node, - attributes: {}, - getAttribute: jest.fn().mockImplementation( ( attr ) => mockNode.attributes[ attr ] ), - }; - - const parents = [ - mockNode, - ]; - - expect( getFormatProperties( formatName, parents ) ).toEqual( { - value: '', - target: '', - node: mockNode, - } ); - } ); -} ); - describe( 'RichText', () => { describe( 'Component', () => { describe( '.adaptFormatter', () => { diff --git a/packages/editor/src/components/rich-text/tinymce.js b/packages/editor/src/components/rich-text/tinymce.js index e41fc77d3114e..12c6e72f8f766 100644 --- a/packages/editor/src/components/rich-text/tinymce.js +++ b/packages/editor/src/components/rich-text/tinymce.js @@ -198,7 +198,7 @@ export default class TinyMCE extends Component { } render() { - const { tagName = 'div', style, defaultValue, className, isPlaceholderVisible, format } = this.props; + const { tagName = 'div', style, defaultValue, className, isPlaceholderVisible, format, multiline } = this.props; const ariaProps = pickAriaProps( this.props ); /* @@ -223,7 +223,7 @@ export default class TinyMCE extends Component { ref: this.bindEditorNode, style, suppressContentEditableWarning: true, - dangerouslySetInnerHTML: { __html: valueToString( defaultValue, format ) }, + dangerouslySetInnerHTML: { __html: valueToString( defaultValue, multiline, format ) }, } ); } } diff --git a/test/integration/fixtures/evernote-out.html b/test/integration/fixtures/evernote-out.html index f9dee59c5259d..4d8c6c43b9c99 100644 --- a/test/integration/fixtures/evernote-out.html +++ b/test/integration/fixtures/evernote-out.html @@ -1,7 +1,7 @@

    This is a paragraph. -
    This is a link. -

    +
    This is a link. +

    diff --git a/test/integration/fixtures/google-docs-out.html b/test/integration/fixtures/google-docs-out.html index d76b19eee2488..a8d31000403d6 100644 --- a/test/integration/fixtures/google-docs-out.html +++ b/test/integration/fixtures/google-docs-out.html @@ -1,5 +1,5 @@ -

    This is a title

    +

    This is a title

    @@ -7,7 +7,7 @@

    This is a heading

    -

    This is a paragraph with a link.

    +

    This is a paragraph with a link.

    @@ -27,7 +27,7 @@

    This is a heading

    -

    An image:

    +

    An image:

    diff --git a/test/integration/fixtures/markdown-out.html b/test/integration/fixtures/markdown-out.html index 46b1f91d82abe..cf83cf40050c4 100644 --- a/test/integration/fixtures/markdown-out.html +++ b/test/integration/fixtures/markdown-out.html @@ -7,7 +7,7 @@

    This is a heading with italic

    -

    Preserve
    +

    Preserve
    line breaks please.

    diff --git a/test/integration/fixtures/ms-word-online-out.html b/test/integration/fixtures/ms-word-online-out.html index b61d20c6eb9c8..7de40b0e330ef 100644 --- a/test/integration/fixtures/ms-word-online-out.html +++ b/test/integration/fixtures/ms-word-online-out.html @@ -1,5 +1,5 @@ -

    This is a heading 

    +

    This is a heading 

    @@ -7,19 +7,19 @@ -
    • Bulleted 
    • Indented 
    • List 
    +
    • Bulleted 
    • Indented 
    • List 
    -
    1. One 
    2. Two 
    3. Three 
    +
    1. One 
    2. Two 
    3. Three 
    -
    One Two Three 
    II III 
    +
    One Two Three 
    II III 
    -

    An image: 

    +

    An image: 

    diff --git a/test/integration/fixtures/plain-out.html b/test/integration/fixtures/plain-out.html index bc455fd4d0120..2cebc072c71b0 100644 --- a/test/integration/fixtures/plain-out.html +++ b/test/integration/fixtures/plain-out.html @@ -1,7 +1,7 @@ -

    test
    test

    +

    test
    test

    -

    test

    +

    test