diff --git a/docs/manifest.json b/docs/manifest.json index 0ff5638b903a25..0aa03353f8a81d 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -383,6 +383,12 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/element/README.md", "parent": "packages" }, + { + "title": "@wordpress/escape-html", + "slug": "packages-escape-html", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/escape-html/README.md", + "parent": "packages" + }, { "title": "@wordpress/hooks", "slug": "packages-hooks", @@ -467,6 +473,12 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/redux-routine/README.md", "parent": "packages" }, + { + "title": "@wordpress/rich-text", + "slug": "packages-rich-text", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/rich-text/README.md", + "parent": "packages" + }, { "title": "@wordpress/scripts", "slug": "packages-scripts", diff --git a/docs/reference/deprecated.md b/docs/reference/deprecated.md index 5755fb3d47ea28..d99cde6e7a15ba 100644 --- a/docs/reference/deprecated.md +++ b/docs/reference/deprecated.md @@ -1,5 +1,16 @@ Gutenberg's deprecation policy is intended to support backwards-compatibility for two minor releases, when possible. The current deprecations are listed below and are grouped by _the version at which they will be removed completely_. If your plugin depends on these behaviors, you must update to the recommended alternative before the noted version. +## 4.4.0 + +- The block attribute sources `children` and `node` have been removed. Please use the `rich-text` source instead. See the core blocks for examples. +- `wp.blocks.node.matcher` has been removed. Please use `wp.richTextValue.create` instead. +- `wp.blocks.node.toHTML` has been removed. Please use `wp.richTextValue.toHTMLString` instead. +- `wp.blocks.node.fromDOM` has been removed. Please use `wp.richTextValue.create` instead. +- `wp.blocks.children.toHTML` has been removed. Please use `wp.richTextValue.toHTMLString` instead. +- `wp.blocks.children.fromDOM` has been removed. Please use `wp.richTextValue.create` instead. +- `wp.blocks.children.concat` has been removed. Please use `wp.richTextValue.concat` instead. +- `wp.blocks.children.getChildrenArray` has been removed. Please use `wp.richTextValue.create` instead. + ## 4.2.0 - Writing resolvers as async generators has been removed. Use the controls plugin instead. diff --git a/lib/client-assets.php b/lib/client-assets.php index 08e575def38c57..96424d35d98592 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -360,10 +360,24 @@ function gutenberg_register_scripts_and_styles() { wp_register_script( 'wp-element', gutenberg_url( 'build/element/index.js' ), - array( 'wp-polyfill', 'react', 'react-dom', 'lodash' ), + array( 'wp-polyfill', 'react', 'react-dom', 'lodash', 'wp-escape-html' ), filemtime( gutenberg_dir_path() . 'build/element/index.js' ), true ); + wp_register_script( + 'wp-escape-html', + gutenberg_url( 'build/escape-html/index.js' ), + array( 'wp-polyfill' ), + filemtime( gutenberg_dir_path() . 'build/element/index.js' ), + true + ); + wp_register_script( + 'wp-rich-text', + gutenberg_url( 'build/rich-text/index.js' ), + array( 'wp-polyfill', 'wp-escape-html', 'lodash' ), + filemtime( gutenberg_dir_path() . 'build/rich-text/index.js' ), + true + ); wp_register_script( 'wp-components', gutenberg_url( 'build/components/index.js' ), @@ -383,6 +397,7 @@ function gutenberg_register_scripts_and_styles() { 'wp-keycodes', 'wp-polyfill', 'wp-url', + 'wp-rich-text', ), filemtime( gutenberg_dir_path() . 'build/components/index.js' ), true @@ -408,6 +423,7 @@ function gutenberg_register_scripts_and_styles() { 'wp-polyfill', 'wp-shortcode', 'lodash', + 'wp-rich-text', ), filemtime( gutenberg_dir_path() . 'build/blocks/index.js' ), true @@ -442,6 +458,7 @@ function gutenberg_register_scripts_and_styles() { 'wp-polyfill', 'wp-url', 'wp-viewport', + 'wp-rich-text', ), filemtime( gutenberg_dir_path() . 'build/block-library/index.js' ), true @@ -601,6 +618,7 @@ function gutenberg_register_scripts_and_styles() { 'wp-url', 'wp-viewport', 'wp-wordcount', + 'wp-rich-text', ), filemtime( gutenberg_dir_path() . 'build/editor/index.js' ) ); diff --git a/package-lock.json b/package-lock.json index 55c8e46351c306..70ca02897fb2f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2266,11 +2266,18 @@ "version": "file:packages/element", "requires": { "@babel/runtime": "^7.0.0", + "@wordpress/escape-html": "file:packages/escape-html", "lodash": "^4.17.10", "react": "^16.4.1", "react-dom": "^16.4.1" } }, + "@wordpress/escape-html": { + "version": "file:packages/escape-html", + "requires": { + "@babel/runtime": "^7.0.0" + } + }, "@wordpress/hooks": { "version": "file:packages/hooks", "requires": { @@ -2404,6 +2411,13 @@ } } }, + "@wordpress/rich-text": { + "version": "file:packages/rich-text", + "requires": { + "@babel/runtime": "^7.0.0", + "lodash": "^4.17.10" + } + }, "@wordpress/scripts": { "version": "file:packages/scripts", "dev": true, diff --git a/package.json b/package.json index af420ecd4b92ac..c3f63283803578 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@wordpress/dom-ready": "file:packages/dom-ready", "@wordpress/editor": "file:packages/editor", "@wordpress/element": "file:packages/element", + "@wordpress/escape-html": "file:packages/escape-html", "@wordpress/hooks": "file:packages/hooks", "@wordpress/html-entities": "file:packages/html-entities", "@wordpress/i18n": "file:packages/i18n", @@ -41,6 +42,7 @@ "@wordpress/nux": "file:packages/nux", "@wordpress/plugins": "file:packages/plugins", "@wordpress/redux-routine": "file:packages/redux-routine", + "@wordpress/rich-text": "file:packages/rich-text", "@wordpress/shortcode": "file:packages/shortcode", "@wordpress/token-list": "file:packages/token-list", "@wordpress/url": "file:packages/url", diff --git a/packages/block-library/src/audio/index.js b/packages/block-library/src/audio/index.js index 6ae69447623914..3df0228df9f9e3 100644 --- a/packages/block-library/src/audio/index.js +++ b/packages/block-library/src/audio/index.js @@ -30,8 +30,7 @@ export const settings = { attribute: 'src', }, caption: { - type: 'array', - source: 'children', + source: 'rich-text', selector: 'figcaption', }, id: { diff --git a/packages/block-library/src/button/index.js b/packages/block-library/src/button/index.js index 4735d0a47ede2a..6794b9b765c679 100644 --- a/packages/block-library/src/button/index.js +++ b/packages/block-library/src/button/index.js @@ -32,8 +32,7 @@ const blockAttributes = { attribute: 'title', }, text: { - type: 'array', - source: 'children', + source: 'rich-text', selector: 'a', }, backgroundColor: { diff --git a/packages/block-library/src/cover-image/index.js b/packages/block-library/src/cover-image/index.js index 29c4d6d491bb32..2ccdc8e9c1d741 100644 --- a/packages/block-library/src/cover-image/index.js +++ b/packages/block-library/src/cover-image/index.js @@ -1,7 +1,6 @@ /** * External dependencies */ -import { isEmpty } from 'lodash'; import classnames from 'classnames'; /** @@ -25,8 +24,7 @@ const validAlignments = [ 'left', 'center', 'right', 'wide', 'full' ]; const blockAttributes = { title: { - type: 'array', - source: 'children', + source: 'rich-text', selector: 'p', }, url: { @@ -195,7 +193,7 @@ export const settings = { ); if ( ! url ) { - const hasTitle = ! isEmpty( title ); + const hasTitle = ! RichText.isEmpty( title ); const icon = hasTitle ? undefined : 'format-image'; const label = hasTitle ? ( { - let items = blockAttributes.map( ( { content } ) => content ); - const hasItems = ! items.every( RichText.isEmpty ); - - // Look for line breaks if converting a single paragraph, - // then treat each line as a list item. - if ( hasItems && items.length === 1 ) { - items = splitOnLineBreak( items[ 0 ] ); - } - return createBlock( 'core/list', { - values: hasItems ? items.map( ( content, index ) =>
  • { content }
  • ) : [], + values: join( blockAttributes.map( ( { content } ) => + replace( content, /\n/g, '\u2028' ) + ), '\u2028' ), } ); }, }, { type: 'block', blocks: [ 'core/quote' ], - transform: ( { value, citation } ) => { - const items = value.map( ( p ) => get( p, [ 'children', 'props', 'children' ] ) ); - if ( ! RichText.isEmpty( citation ) ) { - items.push( citation ); - } - const hasItems = ! items.every( RichText.isEmpty ); + transform: ( { value } ) => { return createBlock( 'core/list', { - values: hasItems ? items.map( ( content, index ) =>
  • { content }
  • ) : [], + values: value, } ); }, }, @@ -128,7 +111,7 @@ export const settings = { regExp: /^[*-]\s/, transform: ( { content } ) => { return createBlock( 'core/list', { - values: [
  • { content }
  • ], + values: content, } ); }, }, @@ -138,7 +121,7 @@ export const settings = { transform: ( { content } ) => { return createBlock( 'core/list', { ordered: true, - values: [
  • { content }
  • ], + values: content, } ); }, }, @@ -148,20 +131,16 @@ 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 ], - } ) ), + split( values, '\u2028' ).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, } ); }, }, @@ -203,19 +182,16 @@ export const settings = { ], merge( attributes, attributesToMerge ) { - const valuesToMerge = attributesToMerge.values || []; + const { values, content } = attributesToMerge; + const valueToMerge = values || content; - // Standard text-like block attribute. - if ( attributesToMerge.content ) { - valuesToMerge.push( attributesToMerge.content ); + if ( isEmpty( valueToMerge ) ) { + return attributes; } return { ...attributes, - values: [ - ...attributes.values, - ...valuesToMerge, - ], + values: join( [ attributes.values, valueToMerge ], '\u2028' ), }; }, @@ -353,7 +329,7 @@ export const settings = { blocks.push( createBlock( 'core/paragraph' ) ); } - if ( after.length ) { + if ( ! RichText.isEmpty( after ) ) { blocks.push( createBlock( 'core/list', { ordered, values: after, @@ -377,7 +353,7 @@ export const settings = { const tagName = ordered ? 'ol' : 'ul'; return ( - + ); }, }; diff --git a/packages/block-library/src/list/split-on-line-break.js b/packages/block-library/src/list/split-on-line-break.js deleted file mode 100644 index 5cdcd0b2ac61a7..00000000000000 --- a/packages/block-library/src/list/split-on-line-break.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * External dependencies - */ -import { last } from 'lodash'; - -/** - * WordPress dependencies - */ -import { - children as childrenAPI, - node as nodeAPI, -} from '@wordpress/blocks'; - -const { getChildrenArray } = childrenAPI; -const { isNodeOfType } = nodeAPI; - -/** - * Split the content of a paragraph on line breaks ('
    ') into sets of - * content, each representing a list item. - * - * The term "content" refers to a rich-text description of block children. - * - * @see WPBlockChildren - * - * @param {WPBlockChildren} children Block children - * @return {Array} Array of block children - */ -export default function splitOnLineBreak( children ) { - return getChildrenArray( children ).reduce( ( acc, node, i ) => { - // Skip if node is a line break - if ( isNodeOfType( node, 'br' ) ) { - return acc; - } - - // If we've just skipped a line break, append the - // next node as a new item. - const prevFragment = i > 0 && children[ i - 1 ]; - if ( isNodeOfType( prevFragment, 'br' ) ) { - return [ ...acc, [ node ] ]; - } - - // Otherwise, append node to last item. - return [ - ...acc.slice( 0, acc.length - 1 ), - [ ...last( acc ), node ], - ]; - }, [ [] ] ); -} diff --git a/packages/block-library/src/list/test/__snapshots__/index.js.snap b/packages/block-library/src/list/test/__snapshots__/index.js.snap index 8287138cfeea76..b276a84668f603 100644 --- a/packages/block-library/src/list/test/__snapshots__/index.js.snap +++ b/packages/block-library/src/list/test/__snapshots__/index.js.snap @@ -18,7 +18,9 @@ exports[`core/list block edit matches snapshot 1`] = ` contenteditable="true" data-is-placeholder-visible="true" role="textbox" - /> + > +
  • +
      diff --git a/packages/block-library/src/list/test/split-on-line-break.js b/packages/block-library/src/list/test/split-on-line-break.js deleted file mode 100644 index 4759e6c7f10368..00000000000000 --- a/packages/block-library/src/list/test/split-on-line-break.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Internal dependencies - */ -import splitOnLineBreak from '../split-on-line-break'; - -describe( 'split-on-line-break', () => { - it( 'should handle an empty set', () => { - expect( splitOnLineBreak( [] ) ).toEqual( [ [] ] ); - } ); - - it( 'should handle a set with no line breaks', () => { - expect( splitOnLineBreak( [ - 'I like', - { type: 'strong', props: { - children: [ 'bold' ], - } }, - 'text', - ] ) ).toEqual( [ - [ - 'I like', - { type: 'strong', props: { - children: [ 'bold' ], - } }, - 'text', - ], - ] ); - } ); - - it( 'should handle a set with line breaks', () => { - expect( splitOnLineBreak( [ - 'One line', - { type: 'br', props: { - children: [], - } }, - { type: 'strong', props: { - children: [ 'Another' ], - } }, - 'line', - ] ) ).toEqual( [ - [ - 'One line', - ], - [ - { type: 'strong', props: { - children: [ 'Another' ], - } }, - 'line', - ], - ] ); - } ); -} ); diff --git a/packages/block-library/src/paragraph/index.js b/packages/block-library/src/paragraph/index.js index f5f10ed3e923b7..73aaab67184d0b 100644 --- a/packages/block-library/src/paragraph/index.js +++ b/packages/block-library/src/paragraph/index.js @@ -16,10 +16,8 @@ import { getFontSizeClass, RichText, } from '@wordpress/editor'; -import { - getPhrasingContentSchema, - children, -} from '@wordpress/blocks'; +import { getPhrasingContentSchema } from '@wordpress/blocks'; +import { create, concat } from '@wordpress/rich-text'; /** * Internal dependencies @@ -32,10 +30,8 @@ const supports = { const schema = { content: { - type: 'array', - source: 'children', + source: 'rich-text', selector: 'p', - default: [], }, align: { type: 'string', @@ -201,9 +197,7 @@ export const settings = { migrate( attributes ) { return { ...attributes, - content: [ - { attributes.content }, - ], + content: create( { html: attributes.content } ), }; }, }, @@ -211,10 +205,7 @@ export const settings = { merge( attributes, attributesToMerge ) { return { - content: children.concat( - attributes.content, - attributesToMerge.content - ), + content: concat( attributes.content, attributesToMerge.content ), }; }, diff --git a/packages/block-library/src/preformatted/index.js b/packages/block-library/src/preformatted/index.js index 35f0b9bf212276..376fdc722cfe10 100644 --- a/packages/block-library/src/preformatted/index.js +++ b/packages/block-library/src/preformatted/index.js @@ -2,8 +2,9 @@ * WordPress */ import { __ } from '@wordpress/i18n'; -import { children, createBlock, getPhrasingContentSchema } from '@wordpress/blocks'; +import { createBlock, getPhrasingContentSchema } from '@wordpress/blocks'; import { RichText } from '@wordpress/editor'; +import { create, concat } from '@wordpress/rich-text'; export const name = 'core/preformatted'; @@ -18,8 +19,7 @@ export const settings = { attributes: { content: { - type: 'array', - source: 'children', + source: 'rich-text', selector: 'pre', }, }, @@ -29,8 +29,10 @@ export const settings = { { type: 'block', blocks: [ 'core/code', 'core/paragraph' ], - transform: ( attributes ) => - createBlock( 'core/preformatted', attributes ), + transform: ( { content } ) => + createBlock( 'core/preformatted', { + content: create( { text: content } ), + } ), }, { type: 'raw', @@ -85,7 +87,7 @@ export const settings = { merge( attributes, attributesToMerge ) { return { - content: children.concat( attributes.content, attributesToMerge.content ), + content: concat( attributes.content, attributesToMerge.content ), }; }, }; diff --git a/packages/block-library/src/pullquote/edit.js b/packages/block-library/src/pullquote/edit.js index 0f042f5e0fa0ef..6e2f6c516e2c3c 100644 --- a/packages/block-library/src/pullquote/edit.js +++ b/packages/block-library/src/pullquote/edit.js @@ -2,7 +2,7 @@ * External dependencies */ import classnames from 'classnames'; -import { includes, map } from 'lodash'; +import { includes } from 'lodash'; /** * WordPress dependencies @@ -23,11 +23,6 @@ import { export const SOLID_COLOR_STYLE_NAME = 'solid-color'; export const SOLID_COLOR_CLASS = `is-style-${ SOLID_COLOR_STYLE_NAME }`; -export const toRichTextValue = ( value ) => map( value, ( ( subValue ) => subValue.children ) ); -export const fromRichTextValue = ( value ) => map( value, ( subValue ) => ( { - children: subValue, -} ) ); - class PullQuoteEdit extends Component { constructor( props ) { super( props ); @@ -83,10 +78,10 @@ class PullQuoteEdit extends Component {
      setAttributes( { - value: fromRichTextValue( nextValue ), + value: nextValue, } ) } /* translators: the text of the quotation */ diff --git a/packages/block-library/src/pullquote/index.js b/packages/block-library/src/pullquote/index.js index 1c611138ee4776..f001d16adf054b 100644 --- a/packages/block-library/src/pullquote/index.js +++ b/packages/block-library/src/pullquote/index.js @@ -21,23 +21,16 @@ import { default as edit, SOLID_COLOR_STYLE_NAME, SOLID_COLOR_CLASS, - toRichTextValue, } from './edit'; 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', + source: 'rich-text', selector: 'cite', }, mainColor: { @@ -115,7 +108,7 @@ export const settings = { return (
      - + { ! RichText.isEmpty( citation ) && }
      @@ -130,7 +123,7 @@ export const settings = { const { value, citation } = attributes; return (
      - + { ! RichText.isEmpty( citation ) && }
      ); @@ -139,8 +132,7 @@ export const settings = { attributes: { ...blockAttributes, citation: { - type: 'array', - source: 'children', + source: 'rich-text', selector: 'footer', }, align: { @@ -154,7 +146,7 @@ export const settings = { return (
      - + { ! RichText.isEmpty( citation ) && }
      ); diff --git a/packages/block-library/src/pullquote/test/__snapshots__/index.js.snap b/packages/block-library/src/pullquote/test/__snapshots__/index.js.snap index 4a96bb5863af57..5c19c96bee7383 100644 --- a/packages/block-library/src/pullquote/test/__snapshots__/index.js.snap +++ b/packages/block-library/src/pullquote/test/__snapshots__/index.js.snap @@ -22,7 +22,9 @@ exports[`core/pullquote block edit matches snapshot 1`] = ` contenteditable="true" data-is-placeholder-visible="true" role="textbox" - /> + > +

      +

      diff --git a/packages/block-library/src/quote/index.js b/packages/block-library/src/quote/index.js index b41a320ba1db29..f50aa75edacffa 100644 --- a/packages/block-library/src/quote/index.js +++ b/packages/block-library/src/quote/index.js @@ -1,40 +1,29 @@ /** * External dependencies */ -import { castArray, get, isString, isEmpty, omit } from 'lodash'; +import { omit } from 'lodash'; /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; import { Fragment } from '@wordpress/element'; -import { children, createBlock, getPhrasingContentSchema } from '@wordpress/blocks'; +import { createBlock, getPhrasingContentSchema } from '@wordpress/blocks'; import { BlockControls, AlignmentToolbar, RichText, } from '@wordpress/editor'; - -const toRichTextValue = ( value ) => value.map( ( ( subValue ) => subValue.children ) ); -const fromRichTextValue = ( value ) => value.map( ( subValue ) => ( { - children: subValue, -} ) ); +import { join, split, concat } from '@wordpress/rich-text'; 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', + source: 'rich-text', selector: 'cite', }, align: { @@ -65,12 +54,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: join( attributes.map( ( { content } ) => content ), '\u2028' ), } ); }, }, @@ -79,9 +64,7 @@ export const settings = { blocks: [ 'core/heading' ], transform: ( { content } ) => { return createBlock( 'core/quote', { - value: [ - { children:

      { content }

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

      { content }

      }, - ], + value: content, } ); }, }, @@ -114,59 +95,36 @@ export const settings = { { type: 'block', blocks: [ 'core/paragraph' ], - transform: ( { value, citation } ) => { - // Transforming an empty quote - if ( ( ! value || ! value.length ) && ! citation ) { - return createBlock( 'core/paragraph' ); - } - // 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, - } ) : [] ); - }, + transform: ( { value } ) => + split( value, '\u2028' ).map( ( content ) => + createBlock( 'core/paragraph', { content } ) + ), }, { type: 'block', blocks: [ 'core/heading' ], transform: ( { value, citation, ...attrs } ) => { - // If there is no quote content, use the citation as the content of the resulting - // heading. A nonexistent citation will result in an empty heading. - if ( ( ! value || ! value.length ) ) { + // If there is no quote content, use the citation as the + // content of the resulting heading. A nonexistent citation + // will result in an empty heading. + 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 single paragraph, and no citation exists, convert - // the quote content into a heading. - if ( ! citation && value.length === 1 ) { - return createBlock( 'core/heading', { - content: headingContent, - } ); - } - - // In the normal case (a quote containing multiple paragraphs) convert the first - // paragraph into a heading and create a new quote block containing the rest of the - // content. - const heading = createBlock( 'core/heading', { - content: headingContent, - } ); - - const quote = createBlock( 'core/quote', { - ...attrs, - citation, - value: value.slice( 1 ), - } ); + const values = split( value, '\u2028' ); - return [ heading, quote ]; + return [ + createBlock( 'core/heading', { + content: values[ 0 ], + } ), + createBlock( 'core/quote', { + ...attrs, + citation, + value: join( values.slice( 1 ), '\u2028' ), + } ), + ]; }, }, ], @@ -188,10 +146,10 @@ export const settings = {
      setAttributes( { - value: fromRichTextValue( nextValue ), + value: nextValue, } ) } onMerge={ mergeBlocks } @@ -227,7 +185,7 @@ export const settings = { return (
      - + { ! RichText.isEmpty( citation ) && }
      ); @@ -236,8 +194,8 @@ export const settings = { merge( attributes, attributesToMerge ) { return { ...attributes, - value: attributes.value.concat( attributesToMerge.value ), - citation: children.concat( attributes.citation, attributesToMerge.citation ), + value: join( [ attributes.value, attributesToMerge.value ], '\u2028' ), + citation: concat( attributes.citation, attributesToMerge.citation ), }; }, @@ -270,7 +228,7 @@ export const settings = { className={ style === 2 ? 'is-large' : '' } style={ { textAlign: align ? align : null } } > - + { ! RichText.isEmpty( citation ) && }
      ); @@ -280,8 +238,7 @@ export const settings = { attributes: { ...blockAttributes, citation: { - type: 'array', - source: 'children', + source: 'rich-text', selector: 'footer', }, style: { @@ -298,7 +255,7 @@ export const settings = { className={ `blocks-quote-style-${ style }` } style={ { textAlign: align ? align : null } } > - + { ! RichText.isEmpty( citation ) && }
      ); diff --git a/packages/block-library/src/quote/test/__snapshots__/index.js.snap b/packages/block-library/src/quote/test/__snapshots__/index.js.snap index 813b6d8417cb66..6ee688b9c96e70 100644 --- a/packages/block-library/src/quote/test/__snapshots__/index.js.snap +++ b/packages/block-library/src/quote/test/__snapshots__/index.js.snap @@ -21,7 +21,9 @@ exports[`core/quote block edit matches snapshot 1`] = ` contenteditable="true" data-is-placeholder-visible="true" role="textbox" - /> + > +

      +

      diff --git a/packages/block-library/src/subhead/index.js b/packages/block-library/src/subhead/index.js index 39249f9a3e0632..77c7c4324e2b22 100644 --- a/packages/block-library/src/subhead/index.js +++ b/packages/block-library/src/subhead/index.js @@ -30,8 +30,7 @@ export const settings = { attributes: { content: { - type: 'array', - source: 'children', + source: 'rich-text', selector: 'p', }, align: { diff --git a/packages/block-library/src/table/index.js b/packages/block-library/src/table/index.js index a59e70bee183a0..40dc0f260657fe 100644 --- a/packages/block-library/src/table/index.js +++ b/packages/block-library/src/table/index.js @@ -58,9 +58,7 @@ function getTableSectionAttributeSchema( section ) { selector: 'td,th', query: { content: { - type: 'array', - default: [], - source: 'children', + source: 'rich-text', }, tag: { type: 'string', diff --git a/packages/block-library/src/table/state.js b/packages/block-library/src/table/state.js index 559ba46f3d5ed6..1c00c2582db589 100644 --- a/packages/block-library/src/table/state.js +++ b/packages/block-library/src/table/state.js @@ -3,6 +3,11 @@ */ import { times } from 'lodash'; +/** + * WordPress dependencies + */ +import { create } from '@wordpress/rich-text'; + /** * Creates a table state. * @@ -18,7 +23,7 @@ export function createTable( { return { body: times( rowCount, () => ( { cells: times( columnCount, () => ( { - content: [], + content: create(), tag: 'td', } ) ), } ) ), @@ -84,7 +89,7 @@ export function insertRow( state, { ...state[ section ].slice( 0, rowIndex ), { cells: times( cellCount, () => ( { - content: [], + content: create(), tag: 'td', } ) ), }, @@ -129,7 +134,7 @@ export function insertColumn( state, { cells: [ ...row.cells.slice( 0, columnIndex ), { - content: [], + content: create(), tag: 'td', }, ...row.cells.slice( columnIndex ), diff --git a/packages/block-library/src/table/test/state.js b/packages/block-library/src/table/test/state.js index a0e84c4626c7e4..9bd1326f0a55f2 100644 --- a/packages/block-library/src/table/test/state.js +++ b/packages/block-library/src/table/test/state.js @@ -3,6 +3,11 @@ */ import deepFreeze from 'deep-freeze'; +/** + * WordPress dependencies + */ +import { create } from '@wordpress/rich-text'; + /** * Internal dependencies */ @@ -20,11 +25,11 @@ const table = deepFreeze( { { cells: [ { - content: [], + content: create(), tag: 'td', }, { - content: [], + content: create(), tag: 'td', }, ], @@ -32,11 +37,11 @@ const table = deepFreeze( { { cells: [ { - content: [], + content: create(), tag: 'td', }, { - content: [], + content: create(), tag: 'td', }, ], @@ -49,11 +54,11 @@ const tableWithContent = deepFreeze( { { cells: [ { - content: [], + content: create(), tag: 'td', }, { - content: [], + content: create(), tag: 'td', }, ], @@ -61,11 +66,11 @@ const tableWithContent = deepFreeze( { { cells: [ { - content: [], + content: create(), tag: 'td', }, { - content: [ 'test' ], + content: create( { text: 'test' } ), tag: 'td', }, ], @@ -87,7 +92,7 @@ describe( 'updateCellContent', () => { section: 'body', rowIndex: 1, columnIndex: 1, - content: [ 'test' ], + content: create( { text: 'test' } ), } ); expect( state ).toEqual( tableWithContent ); @@ -106,11 +111,11 @@ describe( 'insertRow', () => { { cells: [ { - content: [], + content: create(), tag: 'td', }, { - content: [], + content: create(), tag: 'td', }, ], @@ -118,11 +123,11 @@ describe( 'insertRow', () => { { cells: [ { - content: [], + content: create(), tag: 'td', }, { - content: [ 'test' ], + content: create( { text: 'test' } ), tag: 'td', }, ], @@ -130,11 +135,11 @@ describe( 'insertRow', () => { { cells: [ { - content: [], + content: create(), tag: 'td', }, { - content: [], + content: create(), tag: 'td', }, ], @@ -158,15 +163,15 @@ describe( 'insertColumn', () => { { cells: [ { - content: [], + content: create(), tag: 'td', }, { - content: [], + content: create(), tag: 'td', }, { - content: [], + content: create(), tag: 'td', }, ], @@ -174,15 +179,15 @@ describe( 'insertColumn', () => { { cells: [ { - content: [], + content: create(), tag: 'td', }, { - content: [ 'test' ], + content: create( { text: 'test' } ), tag: 'td', }, { - content: [], + content: create(), tag: 'td', }, ], @@ -206,11 +211,11 @@ describe( 'deleteRow', () => { { cells: [ { - content: [], + content: create(), tag: 'td', }, { - content: [ 'test' ], + content: create( { text: 'test' } ), tag: 'td', }, ], @@ -234,7 +239,7 @@ describe( 'deleteColumn', () => { { cells: [ { - content: [], + content: create(), tag: 'td', }, ], @@ -242,7 +247,7 @@ describe( 'deleteColumn', () => { { cells: [ { - content: [ 'test' ], + content: create( { text: 'test' } ), tag: 'td', }, ], @@ -259,7 +264,7 @@ describe( 'deleteColumn', () => { { cells: [ { - content: [], + content: create(), tag: 'td', }, ], @@ -267,7 +272,7 @@ describe( 'deleteColumn', () => { { cells: [ { - content: [ 'test' ], + content: create( { text: 'test' } ), tag: 'td', }, ], diff --git a/packages/block-library/src/text-columns/index.js b/packages/block-library/src/text-columns/index.js index 10e205c1d96e22..8747662cd3034f 100644 --- a/packages/block-library/src/text-columns/index.js +++ b/packages/block-library/src/text-columns/index.js @@ -17,6 +17,7 @@ import { RichText, } from '@wordpress/editor'; import deprecated from '@wordpress/deprecated'; +import { create } from '@wordpress/rich-text'; export const name = 'core/text-columns'; @@ -41,10 +42,17 @@ export const settings = { selector: 'p', query: { children: { - source: 'children', + source: 'rich-text', }, }, - default: [ [], [] ], + default: [ + { + children: create(), + }, + { + children: create(), + }, + ], }, columns: { type: 'number', diff --git a/packages/block-library/src/verse/index.js b/packages/block-library/src/verse/index.js index c57a4e620d283e..e48217fd8c0f20 100644 --- a/packages/block-library/src/verse/index.js +++ b/packages/block-library/src/verse/index.js @@ -3,12 +3,13 @@ */ import { __ } from '@wordpress/i18n'; import { Fragment } from '@wordpress/element'; -import { children, createBlock } from '@wordpress/blocks'; +import { createBlock } from '@wordpress/blocks'; import { RichText, BlockControls, AlignmentToolbar, } from '@wordpress/editor'; +import { concat } from '@wordpress/rich-text'; export const name = 'core/verse'; @@ -25,8 +26,7 @@ export const settings = { attributes: { content: { - type: 'array', - source: 'children', + source: 'rich-text', selector: 'pre', }, textAlign: { @@ -98,7 +98,7 @@ export const settings = { merge( attributes, attributesToMerge ) { return { - content: children.concat( attributes.content, attributesToMerge.content ), + content: concat( attributes.content, attributesToMerge.content ), }; }, }; diff --git a/packages/block-library/src/video/index.js b/packages/block-library/src/video/index.js index 8f34b648ddf831..9a482826dce105 100644 --- a/packages/block-library/src/video/index.js +++ b/packages/block-library/src/video/index.js @@ -32,8 +32,7 @@ export const settings = { attribute: 'autoplay', }, caption: { - type: 'array', - source: 'children', + source: 'rich-text', selector: 'figcaption', }, controls: { diff --git a/packages/blocks/src/api/children.js b/packages/blocks/src/api/children.js index c31064f5e22a92..0974c52fb4e8e8 100644 --- a/packages/blocks/src/api/children.js +++ b/packages/blocks/src/api/children.js @@ -6,6 +6,7 @@ import { castArray } from 'lodash'; /** * WordPress dependencies */ +import deprecated from '@wordpress/deprecated'; import { renderToString } from '@wordpress/element'; /** @@ -44,6 +45,12 @@ export function getSerializeCapableElement( children ) { * @return {Array} An array of individual block nodes. */ function getChildrenArray( children ) { + deprecated( 'children and node source', { + alternative: 'rich-text source', + plugin: 'Gutenberg', + version: '4.4', + } ); + // The fact that block children are 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. @@ -59,6 +66,12 @@ function getChildrenArray( children ) { * @return {WPBlockChildren} Concatenated block node. */ export function concat( ...blockNodes ) { + deprecated( 'wp.blocks.children.concat', { + alternative: 'wp.richText.concat', + plugin: 'Gutenberg', + version: '4.4', + } ); + const result = []; for ( let i = 0; i < blockNodes.length; i++ ) { const blockNode = castArray( blockNodes[ i ] ); @@ -89,6 +102,12 @@ export function concat( ...blockNodes ) { * @return {WPBlockChildren} Block children equivalent to DOM nodes. */ export function fromDOM( domNodes ) { + deprecated( 'wp.blocks.children.fromDom', { + alternative: 'wp.richText.create', + plugin: 'Gutenberg', + version: '4.4', + } ); + const result = []; for ( let i = 0; i < domNodes.length; i++ ) { try { @@ -109,6 +128,12 @@ export function fromDOM( domNodes ) { * @return {string} String HTML representation of block node. */ export function toHTML( children ) { + deprecated( 'wp.blocks.children.toHTML', { + alternative: 'wp.richText.toHTMLString', + plugin: 'Gutenberg', + version: '4.4', + } ); + const element = getSerializeCapableElement( children ); return renderToString( element ); @@ -123,6 +148,12 @@ export function toHTML( children ) { * @return {Function} hpq matcher. */ export function matcher( selector ) { + deprecated( 'wp.blocks.children.matcher', { + hint: 'Use the rich-text source.', + plugin: 'Gutenberg', + version: '4.4', + } ); + return ( domNode ) => { let match = domNode; diff --git a/packages/blocks/src/api/factory.js b/packages/blocks/src/api/factory.js index b7c7474f7dbc39..b515cab33323a9 100644 --- a/packages/blocks/src/api/factory.js +++ b/packages/blocks/src/api/factory.js @@ -20,6 +20,8 @@ import { * WordPress dependencies */ import { createHooks, applyFilters } from '@wordpress/hooks'; +import { create } from '@wordpress/rich-text'; +import deprecated from '@wordpress/deprecated'; /** * Internal dependencies @@ -41,12 +43,38 @@ 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.hasOwnProperty( 'default' ) ) { + result[ key ] = schema.default; + } + + if ( schema.source === 'rich-text' ) { + // Ensure value passed is always a rich text value. + if ( typeof result[ key ] === 'string' ) { + result[ key ] = create( { text: result[ key ] } ); + } else if ( ! result[ key ] || ! result[ key ].text ) { + result[ key ] = create(); + } + } + + if ( [ 'node', 'children' ].indexOf( schema.source ) !== -1 ) { + deprecated( `${ schema.source } source`, { + alternative: 'rich-text source', + plugin: 'Gutenberg', + version: '4.4', + } ); + + // Ensure value passed is always an array, which we're expecting in + // the RichText component to handle the deprecated value. + if ( typeof result[ key ] === 'string' ) { + result[ key ] = [ result[ key ] ]; + } else if ( ! Array.isArray( result[ key ] ) ) { + result[ key ] = []; + } } return result; diff --git a/packages/blocks/src/api/matchers.js b/packages/blocks/src/api/matchers.js index 5a86c2c9b85a89..604dfb6249e0f6 100644 --- a/packages/blocks/src/api/matchers.js +++ b/packages/blocks/src/api/matchers.js @@ -3,8 +3,28 @@ */ export { attr, prop, html, text, query } from 'hpq'; +/** + * WordPress dependencies + */ +import { create } from '@wordpress/rich-text'; + /** * Internal dependencies */ export { matcher as node } from './node'; export { matcher as children } from './children'; + +export function richText( selector, multilineTag ) { + return ( domNode ) => { + let match = domNode; + + if ( selector ) { + match = domNode.querySelector( selector ); + } + + return create( { + element: match, + multilineTag, + } ); + }; +} diff --git a/packages/blocks/src/api/node.js b/packages/blocks/src/api/node.js index abcacf2b41c0c8..8be74944fb7265 100644 --- a/packages/blocks/src/api/node.js +++ b/packages/blocks/src/api/node.js @@ -1,3 +1,8 @@ +/** + * WordPress dependencies + */ +import deprecated from '@wordpress/deprecated'; + /** * Internal dependencies */ @@ -29,6 +34,12 @@ const { TEXT_NODE, ELEMENT_NODE } = window.Node; * @return {boolean} Whether node is of intended type. */ function isNodeOfType( node, type ) { + deprecated( 'wp.blocks.node.isNodeOfType', { + alternative: 'node.type', + plugin: 'Gutenberg', + version: '4.4', + } ); + return node && node.type === type; } @@ -63,6 +74,12 @@ export function getNamedNodeMapAsObject( nodeMap ) { * @return {WPBlockNode} Block node equivalent to DOM node. */ export function fromDOM( domNode ) { + deprecated( 'wp.blocks.node.fromDOM', { + alternative: 'wp.richText.create', + plugin: 'Gutenberg', + version: '4.4', + } ); + if ( domNode.nodeType === TEXT_NODE ) { return domNode.nodeValue; } @@ -91,6 +108,12 @@ export function fromDOM( domNode ) { * @return {string} String HTML representation of block node. */ export function toHTML( node ) { + deprecated( 'wp.blocks.node.toHTML', { + alternative: 'wp.richText.toHTMLString', + plugin: 'Gutenberg', + version: '4.4', + } ); + return children.toHTML( [ node ] ); } @@ -103,6 +126,12 @@ export function toHTML( node ) { * @return {Function} hpq matcher. */ export function matcher( selector ) { + deprecated( 'node source', { + alternative: 'rich-text source', + plugin: 'Gutenberg', + version: '4.4', + } ); + return ( domNode ) => { let match = domNode; diff --git a/packages/blocks/src/api/parser.js b/packages/blocks/src/api/parser.js index 6390d3a8852a35..d730ba3e7020ad 100644 --- a/packages/blocks/src/api/parser.js +++ b/packages/blocks/src/api/parser.js @@ -18,7 +18,7 @@ import { getBlockType, getUnknownTypeHandlerName } from './registration'; import { createBlock } from './factory'; import { isValidBlock } from './validation'; import { getCommentDelimitedContent } from './serializer'; -import { attr, html, text, query, node, children, prop } from './matchers'; +import { attr, html, text, query, node, children, prop, richText } from './matchers'; /** * Higher-order hpq matcher which enhances an attribute matcher to return true @@ -112,6 +112,8 @@ export function matcherFromSource( sourceConfig ) { return children( sourceConfig.selector ); case 'node': return node( sourceConfig.selector ); + case 'rich-text': + return richText( sourceConfig.selector, sourceConfig.multiline ); case 'query': const subMatchers = mapValues( sourceConfig.query, matcherFromSource ); return query( sourceConfig.selector, subMatchers ); @@ -152,7 +154,9 @@ export function parseWithAttributeSchema( innerHTML, attributeSchema ) { * @return {*} Attribute value. */ export function getBlockAttribute( attributeKey, attributeSchema, innerHTML, commentAttributes ) { + let { type } = attributeSchema; let value; + switch ( attributeSchema.source ) { // undefined source means that it's an attribute serialized to the block's "comment" case undefined: @@ -168,9 +172,12 @@ export function getBlockAttribute( attributeKey, attributeSchema, innerHTML, com case 'tag': value = parseWithAttributeSchema( innerHTML, attributeSchema ); break; + case 'rich-text': + type = 'object'; + value = parseWithAttributeSchema( innerHTML, attributeSchema ); } - return value === undefined ? attributeSchema.default : asType( value, attributeSchema.type ); + return value === undefined ? attributeSchema.default : asType( value, type ); } /** diff --git a/packages/blocks/src/api/test/children.js b/packages/blocks/src/api/test/children.js index cc3fe5d76d28ad..0b997bb2ada909 100644 --- a/packages/blocks/src/api/test/children.js +++ b/packages/blocks/src/api/test/children.js @@ -55,6 +55,7 @@ describe( 'concat', () => { }, ); + expect( console ).toHaveWarned(); expect( result ).toEqual( [ { type: 'strong', @@ -111,6 +112,7 @@ describe( 'toHTML', () => { const html = toHTML( children ); + expect( console ).toHaveWarned(); expect( html ).toBe( 'This is a test!' ); } ); } ); @@ -122,6 +124,7 @@ describe( 'fromDOM', () => { const blockNode = fromDOM( node.childNodes ); + expect( console ).toHaveWarned(); expect( blockNode ).toEqual( [ 'This ', { diff --git a/packages/blocks/src/api/test/factory.js b/packages/blocks/src/api/test/factory.js index c8fae328094527..6399f09493f399 100644 --- a/packages/blocks/src/api/test/factory.js +++ b/packages/blocks/src/api/test/factory.js @@ -78,6 +78,85 @@ describe( 'block factory', () => { expect( block.innerBlocks[ 0 ].name ).toBe( 'core/test-block' ); expect( typeof block.clientId ).toBe( 'string' ); } ); + + it( 'should cast children and node source attributes with default undefined', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + attributes: { + content: { + type: 'array', + source: 'children', + }, + }, + } ); + + const block = createBlock( 'core/test-block' ); + + expect( console ).toHaveWarned(); + expect( block.attributes ).toEqual( { + content: [], + } ); + } ); + + it( 'should cast children and node source attributes with string as default', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + attributes: { + content: { + type: 'array', + source: 'children', + default: 'test', + }, + }, + } ); + + const block = createBlock( 'core/test-block' ); + + expect( block.attributes ).toEqual( { + content: [ 'test' ], + } ); + } ); + + it( 'should cast children and node source attributes with unknown type as default', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + attributes: { + content: { + type: 'array', + source: 'children', + default: 1, + }, + }, + } ); + + const block = createBlock( 'core/test-block' ); + + expect( block.attributes ).toEqual( { + content: [], + } ); + } ); + + it( 'should cast rich-text source attributes', () => { + registerBlockType( 'core/test-block', { + ...defaultBlockSettings, + attributes: { + content: { + source: 'rich-text', + }, + }, + } ); + + const block = createBlock( 'core/test-block', { + content: 'test', + } ); + + expect( block.attributes ).toEqual( { + content: { + formats: [ , , , , ], + text: 'test', + }, + } ); + } ); } ); describe( 'cloneBlock()', () => { diff --git a/packages/blocks/src/api/test/matchers.js b/packages/blocks/src/api/test/matchers.js index 32d1246a2e9d6e..36ce2c07a1f341 100644 --- a/packages/blocks/src/api/test/matchers.js +++ b/packages/blocks/src/api/test/matchers.js @@ -18,6 +18,7 @@ describe( 'matchers', () => { it( 'should return a source function', () => { const source = sources.children(); + expect( console ).toHaveWarned(); expect( typeof source ).toBe( 'function' ); } ); @@ -27,6 +28,7 @@ describe( 'matchers', () => { const html = '

      A delicious sundae dessert

      '; const match = parse( html, sources.children() ); + expect( console ).toHaveWarned(); expect( renderToString( match ) ).toBe( html ); } ); } ); @@ -35,6 +37,7 @@ describe( 'matchers', () => { it( 'should return a source function', () => { const source = sources.node(); + expect( console ).toHaveWarned(); expect( typeof source ).toBe( 'function' ); } ); diff --git a/packages/blocks/src/api/test/node.js b/packages/blocks/src/api/test/node.js index 447395a24ed125..6f78a5f96c518e 100644 --- a/packages/blocks/src/api/test/node.js +++ b/packages/blocks/src/api/test/node.js @@ -31,6 +31,7 @@ describe( 'toHTML', () => { const html = toHTML( blockNode ); + expect( console ).toHaveWarned(); expect( html ).toBe( 'This is a test' ); } ); } ); @@ -41,6 +42,7 @@ describe( 'fromDOM', () => { const blockNode = fromDOM( node ); + expect( console ).toHaveWarned(); expect( blockNode ).toBe( 'Hello world' ); } ); @@ -57,6 +59,7 @@ describe( 'fromDOM', () => { const blockNode = fromDOM( node ); + expect( console ).toHaveWarned(); expect( blockNode ).toEqual( { type: 'strong', props: { diff --git a/packages/components/src/autocomplete/index.js b/packages/components/src/autocomplete/index.js index dff2c8bc5eeb43..f41ceb7c53bbbc 100644 --- a/packages/components/src/autocomplete/index.js +++ b/packages/components/src/autocomplete/index.js @@ -2,7 +2,7 @@ * External dependencies */ import classnames from 'classnames'; -import { escapeRegExp, find, filter, map, debounce } from 'lodash'; +import { escapeRegExp, find, map, debounce } from 'lodash'; /** * WordPress dependencies @@ -11,6 +11,14 @@ import { Component, renderToString } 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 { + create, + slice, + insert, + isCollapsed, + getTextContent, +} from '@wordpress/rich-text'; +import { getRectangleFromRange } from '@wordpress/dom'; /** * Internal dependencies @@ -103,74 +111,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++ ) { @@ -198,6 +138,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 { @@ -206,7 +154,6 @@ export class Autocomplete extends Component { suppress: undefined, open: undefined, query: undefined, - range: undefined, filteredOptions: [], }; } @@ -218,9 +165,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(); @@ -230,31 +175,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 { open, query } = this.state; + const { record, onChange } = this.props; + const end = record.start; + const start = end - open.triggerPrefix.length - query.length; + const toInsert = create( renderToString( replacement ) ); - 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 ); + onChange( insert( record, toInsert, start, end ) ); } select( option ) { const { onReplace } = this.props; - const { open, range, query } = this.state; + const { open, query } = this.state; const { getOptionCompletion } = open || {}; if ( option.isDisabled ) { @@ -262,7 +195,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 ) ? @@ -272,7 +205,7 @@ export class Autocomplete extends Component { if ( 'replace' === action ) { onReplace( [ value ] ); } else if ( 'insert-at-caret' === action ) { - this.insertCompletion( range, value ); + this.insertCompletion( value ); } } @@ -302,31 +235,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 ) { @@ -390,119 +298,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 ) { @@ -567,15 +362,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 @@ -587,10 +373,66 @@ 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 ( isCollapsed( record ) ) { + const text = getTextContent( slice( record, 0 ) ); + const prevText = getTextContent( slice( prevRecord, 0 ) ); + + if ( text !== prevText ) { + const textAfterSelection = getTextContent( slice( record, undefined, getTextContent( record ).length ) ); + const allCompleters = map( completers, ( completer, idx ) => ( { ...completer, idx } ) ); + const open = find( allCompleters, ( { triggerPrefix, allowContext } ) => { + const index = text.lastIndexOf( triggerPrefix ); + + if ( index === -1 ) { + return false; + } + + if ( allowContext && ! allowContext( text.slice( 0, index ), textAfterSelection ) ) { + return false; + } + + return /^\w*$/.test( text.slice( index + triggerPrefix.length ) ); + } ); + + if ( ! open ) { + this.reset(); + return; + } + + const match = text.match( new RegExp( `${ open.triggerPrefix }(\\w*)$` ) ); + const query = match && match[ 1 ]; + 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 ); + } + } } } @@ -607,12 +449,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 (
      @@ -623,7 +465,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 a3e821da8acc13..acd061c87a2d8d 100644 --- a/packages/editor/src/components/autocompleters/block.js +++ b/packages/editor/src/components/autocompleters/block.js @@ -78,7 +78,7 @@ export function createBlockCompleter( { ]; }, allowContext( before, after ) { - return ! ( /\S/.test( before.toString() ) || /\S/.test( after.toString() ) ); + return ! ( /\S/.test( before ) || /\S/.test( after ) ); }, getOptionCompletion( inserterItem ) { const { name, initialAttributes } = inserterItem; diff --git a/packages/editor/src/components/autocompleters/user.js b/packages/editor/src/components/autocompleters/user.js index 19c7527e015df8..0ca90dad349675 100644 --- a/packages/editor/src/components/autocompleters/user.js +++ b/packages/editor/src/components/autocompleters/user.js @@ -30,9 +30,6 @@ export default { { user.slug }, ]; }, - allowNode() { - return true; - }, getOptionCompletion( user ) { return `@${ user.slug }`; }, diff --git a/packages/editor/src/components/block-list/test/block-html.js b/packages/editor/src/components/block-list/test/block-html.js index 442e55de3f5bba..7410aa74ba933d 100644 --- a/packages/editor/src/components/block-list/test/block-html.js +++ b/packages/editor/src/components/block-list/test/block-html.js @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; */ import { createBlock } from '@wordpress/blocks'; import { registerCoreBlocks } from '@wordpress/block-library'; +import { create } from '@wordpress/rich-text'; /** * Internal dependencies @@ -34,7 +35,7 @@ describe( 'BlockHTML', () => { it( 'use block content for a valid block', () => { const block = createBlock( 'core/paragraph', { - content: 'test-block', + content: create( { text: 'test-block' } ), isValid: true, } ); diff --git a/packages/editor/src/components/document-outline/test/__snapshots__/index.js.snap b/packages/editor/src/components/document-outline/test/__snapshots__/index.js.snap index b0d595e5d3e90b..7f799c687d2405 100644 --- a/packages/editor/src/components/document-outline/test/__snapshots__/index.js.snap +++ b/packages/editor/src/components/document-outline/test/__snapshots__/index.js.snap @@ -13,9 +13,29 @@ exports[`DocumentOutline header blocks present should match snapshot 1`] = ` path={Array []} >
    @@ -48,9 +87,24 @@ exports[`DocumentOutline header blocks present should render warnings for multip path={Array []} >

    { paragraph = createBlock( 'core/paragraph' ); headingH1 = createBlock( 'core/heading', { - content: 'Heading 1', + content: create( { text: 'Heading 1' } ), level: 1, } ); headingParent = createBlock( 'core/heading', { - content: 'Heading parent', + content: create( { text: 'Heading parent' } ), level: 2, } ); headingChild = createBlock( 'core/heading', { - content: 'Heading child', + content: create( { text: 'Heading child' } ), level: 3, } ); 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 3ab442f07ad1e2..db06869771df00 100644 --- a/packages/editor/src/components/rich-text/format-toolbar/index.js +++ b/packages/editor/src/components/rich-text/format-toolbar/index.js @@ -1,19 +1,18 @@ -/** - * External dependencies - */ -import { get } from 'lodash'; - /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { Toolbar } from '@wordpress/components'; +import { rawShortcut } from '@wordpress/keycodes'; import { Component } from '@wordpress/element'; import { - Toolbar, - withSpokenMessages, -} from '@wordpress/components'; -import { ESCAPE, LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER } from '@wordpress/keycodes'; -import { prependHTTP } from '@wordpress/url'; + applyFormat, + removeFormat, + getActiveFormat, + getTextContent, + slice, +} from '@wordpress/rich-text'; +import { isURL } from '@wordpress/url'; /** * Internal dependencies @@ -22,177 +21,138 @@ import { FORMATTING_CONTROLS } from '../formatting-controls'; import LinkContainer from './link-container'; import ToolbarContainer from './toolbar-container'; -/** - * Returns the Format Toolbar state given a set of props. - * - * @param {Object} props Component props. - * - * @return {Object} State object. - */ -function computeDerivedState( props ) { - return { - selectedNodeId: props.selectedNodeId, - settingsVisible: false, - opensInNewWindow: !! props.formats.link && !! props.formats.link.target, - isEditingLink: false, - linkValue: get( props, [ 'formats', 'link', 'value' ], '' ), - }; -} - class FormatToolbar extends Component { - constructor() { + constructor( { toggleFormat, editor } ) { super( ...arguments ); - this.state = {}; + this.removeLink = this.removeLink.bind( this ); this.addLink = this.addLink.bind( this ); - this.editLink = this.editLink.bind( this ); - this.dropLink = this.dropLink.bind( this ); - this.submitLink = this.submitLink.bind( this ); - this.onKeyDown = this.onKeyDown.bind( this ); - this.onChangeLinkValue = this.onChangeLinkValue.bind( this ); - this.toggleLinkSettingsVisibility = this.toggleLinkSettingsVisibility.bind( this ); - this.setLinkTarget = this.setLinkTarget.bind( this ); - } - - onKeyDown( event ) { - if ( event.keyCode === ESCAPE ) { - const link = this.props.formats.link; - const isAddingLink = link && link.isAdding; - if ( isAddingLink ) { - event.stopPropagation(); - if ( ! link.value ) { - this.dropLink(); - } else { - this.props.onChange( { link: { ...link, isAdding: false } } ); - } - } - } - if ( [ LEFT, DOWN, RIGHT, UP, BACKSPACE, ENTER ].indexOf( event.keyCode ) > -1 ) { - // Stop the key event from propagating up to maybeStartTyping in BlockListBlock. - event.stopPropagation(); - } - } + this.stopAddingLink = this.stopAddingLink.bind( this ); + this.applyFormat = this.applyFormat.bind( this ); + this.removeFormat = this.removeFormat.bind( this ); + this.getActiveFormat = this.getActiveFormat.bind( this ); + this.toggleFormat = this.toggleFormat.bind( this ); + + this.state = { + addingLink: false, + }; - static getDerivedStateFromProps( props, state ) { - if ( state.selectedNodeId !== props.selectedNodeId ) { - return computeDerivedState( props ); + if ( editor ) { + editor.shortcuts.add( rawShortcut.primary( 'k' ), '', this.addLink ); + editor.shortcuts.add( rawShortcut.access( 'a' ), '', this.addLink ); + editor.shortcuts.add( rawShortcut.access( 's' ), '', this.removeLink ); + editor.shortcuts.add( rawShortcut.access( 'd' ), '', () => toggleFormat( { type: 'del' } ) ); + editor.shortcuts.add( rawShortcut.access( 'x' ), '', () => toggleFormat( { type: 'code' } ) ); } - - return null; } - onChangeLinkValue( value ) { - this.setState( { linkValue: value } ); + removeLink() { + this.removeFormat( 'a' ); } - toggleFormat( format ) { - return () => { - this.props.onChange( { - [ format ]: ! this.props.formats[ format ], + addLink() { + const text = getTextContent( slice( this.props.record ) ); + + if ( text && isURL( text ) ) { + this.applyFormat( { + type: 'a', + attributes: { + href: text, + }, } ); - }; - } - - toggleLinkSettingsVisibility() { - this.setState( ( state ) => ( { settingsVisible: ! state.settingsVisible } ) ); - } - - setLinkTarget( opensInNewWindow ) { - this.setState( { opensInNewWindow } ); - if ( this.props.formats.link && ! this.props.formats.link.isAdding ) { - this.props.onChange( { link: { - value: this.props.formats.link.value, - target: opensInNewWindow ? '_blank' : null, - rel: opensInNewWindow ? 'noreferrer noopener' : null, - } } ); + } else { + this.setState( { addingLink: true } ); } } - addLink() { - this.props.onChange( { link: { isAdding: true } } ); + stopAddingLink() { + this.setState( { addingLink: false } ); } - dropLink() { - this.props.onChange( { link: null } ); + /** + * Apply a format with the current value and selection. + * + * @param {Object} format The format to apply. + */ + applyFormat( format ) { + this.props.onChange( applyFormat( this.props.record, format ) ); } - editLink( event ) { - event.preventDefault(); - this.setState( { linkValue: this.props.formats.link.value, isEditingLink: true } ); + /** + * Remove a format from the current value with the current selection. + * + * @param {string} formatType The type of format to remove. + */ + removeFormat( formatType ) { + this.props.onChange( removeFormat( this.props.record, formatType ) ); } - 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, - } } ); - if ( ! this.props.formats.link.value ) { - this.props.speak( __( 'Link added.' ), 'assertive' ); - } + /** + * Get the current format based on the selection + * + * @param {string} formatType The type of format to check. + * + * @return {boolean} Whether the format is active or not. + */ + getActiveFormat( formatType ) { + return getActiveFormat( this.props.record, formatType ); } - isFormatActive( format ) { - return this.props.formats[ format ] && this.props.formats[ format ].isActive; + /** + * Toggle a format based on the selection. + * + * @param {Object} format The format to toggle. + */ + toggleFormat( format ) { + if ( this.getActiveFormat( format.type ) ) { + this.removeFormat( format.type ); + } else { + this.applyFormat( format ); + } } render() { - const { formats, enabledControls = [], customControls = [], selectedNodeId } = this.props; - const { linkValue, settingsVisible, opensInNewWindow, isEditingLink } = this.state; - const isAddingLink = formats.link && formats.link.isAdding; - const isEditing = isAddingLink || isEditingLink; - const isPreviewing = ! isEditing && formats.link; - - const toolbarControls = FORMATTING_CONTROLS.concat( customControls ) - .filter( ( control ) => enabledControls.indexOf( control.format ) !== -1 ) + const link = this.getActiveFormat( 'a' ); + const toolbarControls = FORMATTING_CONTROLS + .filter( ( control ) => this.props.enabledControls.indexOf( control.format ) !== -1 ) .map( ( control ) => { if ( control.format === 'link' ) { - const isFormatActive = this.isFormatActive( 'link' ); - const isActive = isFormatActive || isAddingLink; + const linkIsActive = link !== undefined; + return { ...control, - shortcut: isFormatActive ? control.activeShortcut : control.shortcut, - icon: isFormatActive ? 'editor-unlink' : 'admin-links', // TODO: Need proper unlink icon - title: isFormatActive ? __( 'Unlink' ) : __( 'Link' ), - onClick: isActive ? this.dropLink : this.addLink, - isActive, + shortcut: linkIsActive ? control.activeShortcut : control.shortcut, + icon: linkIsActive ? 'editor-unlink' : 'admin-links', // TODO: Need proper unlink icon + title: linkIsActive ? __( 'Unlink' ) : __( 'Link' ), + onClick: linkIsActive ? this.removeLink : this.addLink, + isActive: !! linkIsActive, }; } return { ...control, - onClick: this.toggleFormat( control.format ), - isActive: this.isFormatActive( control.format ), + onClick: () => this.toggleFormat( { type: control.selector } ), + isActive: this.getActiveFormat( control.selector ) !== undefined, }; } ); return ( - - { ( isEditing || isPreviewing ) && ( - - ) } + ); } } -export default withSpokenMessages( FormatToolbar ); +export default FormatToolbar; diff --git a/packages/editor/src/components/rich-text/format-toolbar/link-container.js b/packages/editor/src/components/rich-text/format-toolbar/link-container.js index a26f5334fe8c23..80e5ee36989aae 100644 --- a/packages/editor/src/components/rich-text/format-toolbar/link-container.js +++ b/packages/editor/src/components/rich-text/format-toolbar/link-container.js @@ -2,13 +2,23 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; import { ExternalLink, Fill, IconButton, Popover, ToggleControl, + withSpokenMessages, } from '@wordpress/components'; +import { ESCAPE, LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER } from '@wordpress/keycodes'; +import { prependHTTP } from '@wordpress/url'; +import { + create, + insert, + isCollapsed, + applyFormat, +} from '@wordpress/rich-text'; /** * Internal dependencies @@ -19,97 +29,212 @@ import { filterURLForDisplay } from '../../../utils/url'; const stopKeyPropagation = ( event ) => event.stopPropagation(); -const LinkContainer = ( props ) => { - const { - editLink, - formats, - isEditing, - isPreviewing, - linkValue, - onChangeLinkValue, - onKeyDown, - opensInNewWindow, - selectedNodeId, - setLinkTarget, - settingsVisible, - submitLink, - toggleLinkSettingsVisibility, - } = props; - - const linkSettings = settingsVisible && ( -
    - -
    - ); - - return ( - - - -1 ) { + // Stop the key event from propagating up to maybeStartTyping in BlockListBlock. + event.stopPropagation(); + } + } + + onChangeInputValue( inputValue ) { + this.setState( { inputValue } ); + } + + toggleLinkSettingsVisibility() { + this.setState( ( state ) => ( { settingsVisible: ! state.settingsVisible } ) ); + } + + setLinkTarget( opensInNewWindow ) { + this.setState( { opensInNewWindow } ); + + // Apply now if URL is not being edited. + if ( ! isShowingInput( this.props, this.state ) ) { + const { href } = getLinkAttributesFromFormat( this.props.link ); + this.props.applyFormat( createLinkFormat( { href, opensInNewWindow } ) ); + } + } + + editLink( event ) { + this.setState( { editLink: true } ); + event.preventDefault(); + } + + submitLink( event ) { + const { link, record } = this.props; + const { inputValue, opensInNewWindow } = this.state; + const href = prependHTTP( inputValue ); + const format = createLinkFormat( { href, opensInNewWindow } ); + + if ( isCollapsed( record ) ) { + const toInsert = applyFormat( create( { text: href } ), format, 0, href.length ); + this.props.onChange( insert( record, toInsert ) ); + } else { + this.props.applyFormat( format ); + } + + this.resetState(); + + if ( ! link ) { + this.props.speak( __( 'Link added.' ), 'assertive' ); + } + + event.preventDefault(); + } + + resetState() { + this.props.stopAddingLink(); + this.setState( { editLink: false } ); + } + + render() { + const { link, addingLink, record } = this.props; + + if ( ! link && ! addingLink ) { + return null; + } + + const { inputValue, settingsVisible, opensInNewWindow } = this.state; + const { href } = getLinkAttributesFromFormat( link ); + const showInput = isShowingInput( this.props, this.state ); + + const linkSettings = settingsVisible && ( +
    + +
    + ); + + return ( + + - { isEditing && ( - // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar - /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ -
    -
    - - - -
    - { linkSettings } -
    - /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ - ) } - - { isPreviewing && ( - // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar - /* eslint-disable jsx-a11y/no-static-element-interactions */ -
    -
    - - { formats.link.value && filterURLForDisplay( decodeURI( formats.link.value ) ) } - - - + + { showInput && ( + // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ +
    +
    + + + +
    + { linkSettings } +
    + /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ + ) } + + { ! showInput && ( + // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar + /* eslint-disable jsx-a11y/no-static-element-interactions */ +
    +
    + + { filterURLForDisplay( decodeURI( href ) ) } + + + +
    + { linkSettings }
    - { linkSettings } -
    - /* eslint-enable jsx-a11y/no-static-element-interactions */ - ) } - - - - ); -}; - -export default LinkContainer; + /* eslint-enable jsx-a11y/no-static-element-interactions */ + ) } + + + + ); + } +} + +export default withSpokenMessages( LinkContainer ); diff --git a/packages/editor/src/components/rich-text/format-toolbar/link-container.native.js b/packages/editor/src/components/rich-text/format-toolbar/link-container.native.js deleted file mode 100644 index 074f81188c8560..00000000000000 --- a/packages/editor/src/components/rich-text/format-toolbar/link-container.native.js +++ /dev/null @@ -1,4 +0,0 @@ -const LinkContainer = () => { - return null; -}; -export default LinkContainer; diff --git a/packages/editor/src/components/rich-text/format-toolbar/toolbar-container.native.js b/packages/editor/src/components/rich-text/format-toolbar/toolbar-container.native.js deleted file mode 100644 index f0b8ae6b4d6f15..00000000000000 --- a/packages/editor/src/components/rich-text/format-toolbar/toolbar-container.native.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * External dependencies - */ -import { View } from 'react-native'; - -const ToolbarContainer = ( props ) => ( - - { props.children } - -); -export default ToolbarContainer; diff --git a/packages/editor/src/components/rich-text/format.js b/packages/editor/src/components/rich-text/format.js deleted file mode 100644 index 9bac598ce00382..00000000000000 --- a/packages/editor/src/components/rich-text/format.js +++ /dev/null @@ -1,196 +0,0 @@ -/** - * WordPress dependencies - */ -import { children } from '@wordpress/blocks'; - -const { TEXT_NODE, ELEMENT_NODE } = window.Node; - -/** - * Zero-width space character used by TinyMCE as a caret landing point for - * inline boundary nodes. - * - * @see tinymce/src/core/main/ts/text/Zwsp.ts - * - * @type {string} - */ -const TINYMCE_ZWSP = '\uFEFF'; - -/** - * Regular expression matching TinyMCE zero-width space character globally. - * - * @type {RegExp} - */ -const REGEXP_TINYMCE_ZWSP = new RegExp( TINYMCE_ZWSP, 'g' ); - -/** - * Returns true if the given attribute name is a TinyMCE internal temporary - * attribute which should not be included in the serialized output, or false - * otherwise. Would use the given editor's serializer as basis for determining - * temporary attributes, but fails to include attributes like `data-mce-src`. - * - * @param {string} attributeName Attribute name to test. - * - * @return {boolean} Whether attribute is an internal temporary attribute. - */ -export function isTinyMCEInternalAttribute( attributeName ) { - return attributeName.indexOf( 'data-mce-' ) === 0; -} - -/** - * Returns true if the given HTMLElement is a TinyMCE bogus element. During - * serialization, a bogus element should be skipped. - * - * @param {HTMLElement} element Element to test. - * - * @return {boolean} Whether element is a TinyMCE bogus element. - */ -export function isTinyMCEBogusElement( element ) { - return element.getAttribute( 'data-mce-bogus' ) === 'all'; -} - -/** - * Returns true if the given HTMLElement is a TinyMCE bogus wrapper. During - * serialization, a bogus wrapper should be substituted with its childrens' - * content. - * - * @param {HTMLElement} element Element to test. - * - * @return {boolean} Whether element is a TinyMCE bogus wrapper. - */ -export function isTinyMCEBogusWrapperElement( element ) { - return ( - element.hasAttribute( 'data-mce-bogus' ) && - ! isTinyMCEBogusElement( element ) - ); -} - -/** - * Given a text node, returns its node value with any TinyMCE internal zero- - * width space characters omitted. - * - * @param {Text} node Text node from which to derive value. - * - * @return {string} Cleaned text node value. - */ -export function getCleanTextNodeValue( node ) { - const { nodeValue } = node; - return nodeValue.replace( REGEXP_TINYMCE_ZWSP, '' ); -} - -/** - * Transforms a value in a given format into string. - * - * @param {Array|string?} value DOM Elements. - * @param {string} format Output format (string or element) - * - * @return {string} HTML output as string. - */ -export function valueToString( value, format ) { - if ( ! value ) { - return ''; - } - - switch ( format ) { - case 'string': - return value; - - case 'children': - return children.toHTML( value ); - } -} - -/** - * 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 ), - }, - }; -} - -/** - * 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) - * - * @return {*} Output. - */ -export function domToFormat( value, format ) { - switch ( format ) { - case 'string': - return domToString( value ); - - case 'children': - return domToBlockChildren( value ); - } -} diff --git a/packages/editor/src/components/rich-text/formatting-controls.js b/packages/editor/src/components/rich-text/formatting-controls.js index 3f912cf6201977..2699a17fbbe7ca 100644 --- a/packages/editor/src/components/rich-text/formatting-controls.js +++ b/packages/editor/src/components/rich-text/formatting-controls.js @@ -10,12 +10,14 @@ export 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: 'admin-links', @@ -23,11 +25,13 @@ export const FORMATTING_CONTROLS = [ shortcut: displayShortcut.primary( 'k' ), activeShortcut: displayShortcut.access( 's' ), format: 'link', + selector: 'a', }, { icon: 'editor-strikethrough', title: __( 'Strikethrough' ), shortcut: displayShortcut.access( 'd' ), format: 'strikethrough', + selector: 'del', }, ]; diff --git a/packages/editor/src/components/rich-text/index.js b/packages/editor/src/components/rich-text/index.js index e9af70f7fc57f8..38b2b0337a9a4f 100644 --- a/packages/editor/src/components/rich-text/index.js +++ b/packages/editor/src/components/rich-text/index.js @@ -4,15 +4,13 @@ import classnames from 'classnames'; import { defer, - difference, find, - forEach, identity, - isEqual, isNil, - merge, noop, + isEqual, } from 'lodash'; +import memize from 'memize'; /** * WordPress dependencies @@ -27,9 +25,21 @@ import { createBlobURL } from '@wordpress/blob'; import { BACKSPACE, DELETE, ENTER, LEFT, RIGHT, rawShortcut } from '@wordpress/keycodes'; import { Slot } from '@wordpress/components'; import { withDispatch, withSelect } from '@wordpress/data'; -import { rawHandler, children } from '@wordpress/blocks'; +import { rawHandler, children, getBlockTransforms, findTransform } from '@wordpress/blocks'; import { withInstanceId, withSafeTimeout, compose } from '@wordpress/compose'; import { isURL } from '@wordpress/url'; +import { + isEmpty, + create, + apply, + applyFormat, + split, + toHTMLString, + getTextContent, + insert, + isEmptyLine, + unstableToDom, +} from '@wordpress/rich-text'; /** * Internal dependencies @@ -40,11 +50,9 @@ import { FORMATTING_CONTROLS } from './formatting-controls'; import FormatToolbar from './format-toolbar'; import TinyMCE from './tinymce'; import { pickAriaProps } from './aria'; -import patterns from './patterns'; +import { getPatterns } from './patterns'; import { withBlockEditContext } from '../block-edit/context'; -import { domToFormat, valueToString } from './format'; import TokenUI from './tokens/ui'; -import { isRichTextValueEmpty } from './utils'; /** * Browser dependencies @@ -62,27 +70,8 @@ const { Node, getSelection } = window; */ const TINYMCE_ZWSP = '\uFEFF'; -export function getFormatValue( formatName, parents ) { - if ( formatName === 'link' ) { - const anchor = find( parents, ( node ) => node.nodeName === 'A' ); - if ( anchor ) { - if ( anchor.hasAttribute( 'data-wp-placeholder' ) ) { - return { isAdding: true }; - } - return { - isActive: true, - value: anchor.getAttribute( 'href' ) || '', - target: anchor.getAttribute( 'target' ) || '', - node: anchor, - }; - } - } - - return { isActive: true }; -} - export class RichText extends Component { - constructor() { + constructor( { value, onReplace, multiline } ) { super( ...arguments ); this.onInit = this.onInit.bind( this ); @@ -95,18 +84,48 @@ 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.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.getRecord = this.getRecord.bind( this ); + this.createRecord = this.createRecord.bind( this ); + this.applyRecord = this.applyRecord.bind( this ); + this.isEmpty = this.isEmpty.bind( this ); + this.valueToFormat = this.valueToFormat.bind( this ); + this.setRef = this.setRef.bind( this ); + this.isActive = this.isActive.bind( this ); + + this.formatToValue = memize( this.formatToValue.bind( this ), { size: 1 } ); + + this.savedContent = value; + this.containerRef = createRef(); + this.patterns = getPatterns( { onReplace, multiline } ); + this.enterPatterns = getBlockTransforms( 'from' ).filter( ( { type, trigger } ) => + type === 'pattern' && trigger === 'enter' + ); - this.state = { - formats: {}, - selectedNodeId: 0, - }; + this.state = {}; - this.containerRef = createRef(); + this.usedDeprecatedChildrenSource = Array.isArray( value ); + } + + componentDidMount() { + document.addEventListener( 'selectionchange', this.onSelectionChange ); + } + + componentWillUnmount() { + document.removeEventListener( 'selectionchange', this.onSelectionChange ); + } + + setRef( node ) { + this.editableRef = node; + } + + isActive() { + return this.editableRef === document.activeElement; } /** @@ -151,12 +170,9 @@ export class RichText extends Component { editor.on( 'keyup', this.onKeyUp ); editor.on( 'BeforeExecCommand', this.onPropagateUndo ); editor.on( 'focus', this.onFocus ); - editor.on( 'input', this.onChange ); // The change event in TinyMCE fires every time an undo level is added. editor.on( 'change', this.onCreateUndoLevel ); - patterns.apply( this, [ editor ] ); - const { unstableOnSetup } = this.props; if ( unstableOnSetup ) { unstableOnSetup( editor ); @@ -170,13 +186,6 @@ export class RichText extends Component { } onInit() { - 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 } } ) ); - this.editor.shortcuts.add( rawShortcut.access( 's' ), '', () => this.changeFormats( { link: undefined } ) ); - this.editor.shortcuts.add( rawShortcut.access( 'd' ), '', () => this.changeFormats( { strikethrough: ! this.state.formats.strikethrough } ) ); - this.editor.shortcuts.add( rawShortcut.access( 'x' ), '', () => this.changeFormats( { code: ! this.state.formats.code } ) ); this.editor.shortcuts.add( rawShortcut.primary( 'z' ), '', 'Undo' ); this.editor.shortcuts.add( rawShortcut.primaryShift( 'z' ), '', 'Redo' ); @@ -185,23 +194,6 @@ export class RichText extends Component { this.editor.shortcuts.remove( 'meta+y', '', 'Redo' ); } - adaptFormatter( options ) { - switch ( options.type ) { - case 'inline-style': { - return { - inline: 'span', - styles: { ...options.style }, - }; - } - } - } - - registerCustomFormatters() { - forEach( this.props.formatters, ( formatter ) => { - this.editor.formatter.register( formatter.format, this.adaptFormatter( formatter ) ); - } ); - } - /** * Handles an undo event from TinyMCE. * @@ -222,6 +214,43 @@ export class RichText extends Component { } } + /** + * Get the current record (value and selection) from props and state. + * + * @return {Object} The current record (value and selection). + */ + getRecord() { + const { formats, text } = this.formatToValue( this.props.value ); + const { start, end } = this.state; + + return { formats, text, start, end }; + } + + createRecord() { + const { multiline } = this.props; + const range = window.getSelection().getRangeAt( 0 ); + + return create( { + element: this.editableRef, + range, + multilineTag: multiline, + removeNode: ( node ) => node.getAttribute( 'data-mce-bogus' ) === 'all', + unwrapNode: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + removeAttribute: ( attribute ) => attribute.indexOf( 'data-mce-' ) === 0, + filterString: ( string ) => string.replace( TINYMCE_ZWSP, '' ), + } ); + } + + applyRecord( record ) { + const { multiline } = this.props; + + apply( record, this.editableRef, multiline ); + } + + isEmpty() { + return isEmpty( this.formatToValue( this.props.value ) ); + } + /** * Handles a paste event from TinyMCE. * @@ -297,9 +326,12 @@ export class RichText extends Component { // A URL was pasted, turn the selection into a link if ( isURL( pastedText ) ) { - this.editor.execCommand( 'mceInsertLink', false, { - href: this.editor.dom.decode( pastedText ), - } ); + this.onChange( applyFormat( this.getRecord(), { + type: 'a', + attributes: { + href: this.editor.dom.decode( pastedText ), + }, + } ) ); // Allows us to ask for this information when we get a report. window.console.log( 'Created link:\n\n', pastedText ); @@ -327,7 +359,8 @@ export class RichText extends Component { } ); if ( typeof content === 'string' ) { - this.editor.insertContent( content ); + const recordToInsert = create( { html: content } ); + this.onChange( insert( this.getRecord(), recordToInsert ) ); } else if ( this.props.onSplit ) { if ( ! content.length ) { return; @@ -366,11 +399,52 @@ export class RichText extends Component { } /** - * Handles any case where the content of the TinyMCE instance has changed. + * Handle input on the next selection change event. */ - onChange() { - this.savedContent = this.getContent(); + onInput() { + const record = this.createRecord(); + const transformed = this.patterns.reduce( ( accumlator, transform ) => transform( accumlator ), record ); + + // Don't apply changes if there's no transform. Content will be up to + // date. In the future we could always let it flow back in the live DOM + // if there are no performance issues. + this.onChange( transformed, record === transformed ); + } + + /** + * Handles the `selectionchange` event: sync the selection to local state. + */ + onSelectionChange() { + // Ensure it's the active element. This is a global event. + if ( ! this.isActive() ) { + return; + } + + const { start, end } = this.createRecord(); + + if ( start !== this.state.start || end !== this.state.end ) { + this.setState( { start, end } ); + } + } + + /** + * Sync the value to global state. The node tree and selection will also be + * updated if differences are found. + * + * @param {Object} record The record to sync and apply. + * @param {boolean} _withoutApply If true, the record won't be applied to + * the live DOM. + */ + onChange( record, _withoutApply ) { + if ( ! _withoutApply ) { + this.applyRecord( record ); + } + + const { start, end } = record; + + this.savedContent = this.valueToFormat( record ); this.props.onChange( this.savedContent ); + this.setState( { start, end } ); } onCreateUndoLevel( event ) { @@ -389,7 +463,7 @@ export class RichText extends Component { // input event. Avoid dispatching an action if the original event is // blur because the content will already be up-to-date. if ( ! event || ! event.originalEvent || event.originalEvent.type !== 'blur' ) { - this.onChange(); + this.onChange( this.createRecord(), true ); } this.props.onCreateUndoLevel(); @@ -420,15 +494,13 @@ export class RichText extends Component { return; } + const empty = this.isEmpty(); + // It is important to consider emptiness because an empty container // will include a bogus TinyMCE BR node _after_ the caret, so in a // forward deletion the isHorizontalEdge function will incorrectly // interpret the presence of the bogus node as not being at the edge. - const isEmpty = this.isEmpty(); - const isEdge = ( - isEmpty || - isHorizontalEdge( this.editor.getBody(), isReverse ) - ); + const isEdge = ( empty || isHorizontalEdge( this.editableRef, isReverse ) ); if ( ! isEdge ) { return; @@ -442,7 +514,7 @@ export class RichText extends Component { // an intentional user interaction distinguishing between Backspace and // Delete to remove the empty field, but also to avoid merge & remove // causing destruction of two fields (merge, then removed merged). - if ( onRemove && isEmpty && isReverse ) { + if ( onRemove && empty && isReverse ) { onRemove( ! isReverse ); } @@ -504,8 +576,6 @@ export class RichText extends Component { * @param {KeydownEvent} event The keydown event as triggered by TinyMCE. */ onKeyDown( event ) { - const dom = this.editor.dom; - const rootNode = this.editor.getBody(); const { keyCode } = event; const isDelete = keyCode === DELETE || keyCode === BACKSPACE; @@ -521,33 +591,39 @@ export class RichText extends Component { // If we click shift+Enter on inline RichTexts, we avoid creating two contenteditables // We also split the content and call the onSplit prop if provided. if ( keyCode === ENTER ) { - if ( this.props.multiline ) { - if ( ! this.props.onSplit ) { + if ( this.props.onReplace ) { + const text = getTextContent( this.getRecord() ); + const transformation = findTransform( this.enterPatterns, ( item ) => { + return item.regExp.test( text ); + } ); + + if ( transformation ) { + // Calling onReplace() will destroy the editor, so it's + // important that we stop other handlers (e.g. ones + // registered by TinyMCE) from also handling this event. + event.stopImmediatePropagation(); + event.preventDefault(); + this.props.onReplace( [ + transformation.transform( { content: text } ), + ] ); return; } + } - const selectedNode = this.editor.selection.getNode(); - - if ( selectedNode.parentNode !== rootNode ) { + if ( this.props.multiline ) { + if ( ! this.props.onSplit ) { return; } - if ( ! dom.isEmpty( selectedNode ) ) { + const record = this.getRecord(); + + if ( ! isEmptyLine( record ) ) { return; } event.preventDefault(); - const childNodes = Array.from( rootNode.childNodes ); - const index = dom.nodeIndex( selectedNode ); - const beforeNodes = childNodes.slice( 0, index ); - const afterNodes = childNodes.slice( index + 1 ); - - const { format } = this.props; - const before = domToFormat( beforeNodes, format ); - const after = domToFormat( afterNodes, format ); - - this.props.onSplit( before, after ); + this.props.onSplit( ...split( record ).map( this.valueToFormat ) ); } else { event.preventDefault(); @@ -569,7 +645,7 @@ export class RichText extends Component { // The input event does not fire when the whole field is selected and // BACKSPACE is pressed. if ( keyCode === BACKSPACE ) { - this.onChange(); + this.onChange( this.createRecord(), true ); } // `scrollToRect` is called on `nodechange`, whereas calling it on @@ -583,7 +659,7 @@ export class RichText extends Component { scrollToRect( rect ) { const { top: caretTop } = rect; - const container = getScrollContainer( this.editor.getBody() ); + const container = getScrollContainer( this.editableRef ); if ( ! container ) { return; @@ -619,73 +695,48 @@ export class RichText extends Component { */ splitContent( blocks = [], context = {} ) { const { onSplit } = this.props; + const record = this.createRecord(); + 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 ] = split( record ); // 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 ( isRichTextValueEmpty( after ) ) { - before = this.props.value; - } else if ( isRichTextValueEmpty( before ) ) { - after = this.props.value; + if ( isEmpty( after ) ) { + before = record; + } else if ( isEmpty( before ) ) { + after = record; } // 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 = isRichTextValueEmpty( before ) ? null : before; - after = isRichTextValueEmpty( after ) ? null : after; + before = isEmpty( before ) ? null : before; + after = isEmpty( after ) ? null : after; + } + + if ( before ) { + before = this.valueToFormat( before ); + } + + if ( after ) { + after = this.valueToFormat( after ); } onSplit( before, after, ...blocks ); } onNodeChange( { parents } ) { - if ( document.activeElement !== this.editor.getBody() ) { + if ( ! this.isActive() ) { return; } - // Remove *non-selected* placeholder links when the selection is changed. - this.removePlaceholderLinks( parents ); - - const formatNames = this.props.formattingControls; - const formats = this.editor.formatter.matchAll( formatNames ).reduce( ( accFormats, activeFormat ) => { - accFormats[ activeFormat ] = getFormatValue( 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' ); @@ -704,176 +755,76 @@ 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 } = this.props; + 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 ) + tagName === prevProps.tagName && + value !== prevProps.value && + value !== this.savedContent ) { - this.setContent( this.props.value ); - } + // Handle deprecated `children` and `node` sources. + // The old way of passing a value with the `node` matcher required + // the value to be mapped first, creating a new array each time, so + // a shallow check wouldn't work. We need to check deep equality. + // This is only executed for a deprecated API and will eventually be + // removed. + if ( Array.isArray( value ) && isEqual( value, this.savedContent ) ) { + return; + } + + const record = this.formatToValue( value ); - if ( 'development' === process.env.NODE_ENV ) { - if ( ! isEqual( this.props.formatters, prevProps.formatters ) ) { - // eslint-disable-next-line no-console - console.error( 'Formatters passed via `formatters` prop will only be registered once. Formatters can be enabled/disabled via the `formattingControls` prop.' ); + if ( this.isActive() ) { + const length = getTextContent( prevProps.value ).length; + record.start = length; + record.end = length; } - } - // When the block is unselected, remove placeholder links and hide the formatting toolbar. - if ( ! this.props.isSelected && prevProps.isSelected ) { - this.removePlaceholderLinks(); - this.setState( { formats: {} } ); + this.applyRecord( record ); } } - /** - * Removes any placeholder links from the editor DOM. Placeholder links are - * used when adding a link to indicate which text will become a link. - * - * @param {HTMLElement[]=} linksToKeep If specified, these links will *not* - * be removed. Useful for keeping the - * currently selected link as is. - */ - removePlaceholderLinks( linksToKeep = [] ) { - const placeholderLinks = this.editor.$( 'a[data-wp-placeholder]' ).toArray(); - for ( const placeholderLink of difference( placeholderLinks, linksToKeep ) ) { - this.editor.dom.remove( placeholderLink, /* keepChildren: */ true ); - } - } + formatToValue( value ) { + const { format, multiline } = this.props; - /** - * Returns true if the component's value prop is currently empty, or false otherwise. - * - * @return {boolean} Whether this.props.value is empty. - */ - isEmpty() { - return isRichTextValueEmpty( this.props.value ); - } + // Handle deprecated `children` and `node` sources. + if ( Array.isArray( value ) ) { + return create( { + html: children.toHTML( value ), + multilineTag: multiline, + } ); + } - isFormatActive( format ) { - return this.state.formats[ format ] && this.state.formats[ format ].isActive; - } + if ( format === 'string' ) { + return create( { + html: value, + multilineTag: multiline, + } ); + } - removeFormat( format ) { - this.editor.focus(); - this.editor.formatter.remove( format ); - // Formatter does not trigger a change event like `execCommand` does. - this.onCreateUndoLevel(); - } + // Guard for blocks passing `null` in onSplit callbacks. May be removed + // if onSplit is revised to not pass a `null` value. + if ( value === null ) { + return create(); + } - 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(); + return value; } - changeFormats( formats ) { - forEach( formats, ( formatValue, format ) => { - const isActive = this.isFormatActive( format ); + valueToFormat( { formats, text } ) { + const { format, multiline } = this.props; - if ( format === 'link' ) { - // Remove the selected link when `formats.link` is set to a falsey value. - if ( ! formatValue ) { - this.editor.execCommand( 'Unlink' ); - return; - } - - const { isAdding, value: href, target } = formatValue; - const isSelectionCollapsed = this.editor.selection.isCollapsed(); - - // Are we creating a new link? - if ( isAdding ) { - // If the selected text is a URL, instantly turn it into a link. - const selectedText = this.editor.selection.getContent( { format: 'text' } ); - if ( isURL( selectedText ) ) { - formatValue.isAdding = false; - this.editor.execCommand( 'mceInsertLink', false, { - href: selectedText, - } ); - return; - } - - // Create a placeholder so that there's something to indicate which - // text will become a link. Placeholder links are stripped from - // getContent() and removed when the selection changes. - if ( ! isSelectionCollapsed ) { - this.editor.formatter.apply( format, { - href: '#', - 'data-wp-placeholder': true, - 'data-mce-bogus': true, - } ); - } - - // Bail early if the link is still being added. will ask the user - // for a URL and then update `formats.link`. - return; - } - - // When no link or text is selected, use the URL as the link's text. - if ( isSelectionCollapsed && ! isActive ) { - this.editor.insertContent( this.editor.dom.createHTML( - 'a', - { href, target }, - this.editor.dom.encode( href ) - ) ); - return; - } - - // Use built-in TinyMCE command turn the selection into a link. This takes - // care of deleting any existing links within the current selection. - this.editor.execCommand( 'mceInsertLink', false, { - href, - target, - 'data-wp-placeholder': null, - 'data-mce-bogus': null, - } ); - return; - } + // Handle deprecated `children` and `node` sources. + if ( this.usedDeprecatedChildrenSource ) { + return children.fromDOM( unstableToDom( { formats, text }, multiline ).body.childNodes ); + } - if ( isActive && ! formatValue ) { - this.removeFormat( format ); - } else if ( ! isActive && formatValue ) { - this.applyFormat( format ); - } - } ); + if ( format === 'string' ) { + return toHTMLString( { formats, text }, multiline ); + } - this.setState( ( state ) => ( { - formats: merge( {}, state.formats, formats ), - } ) ); + return { formats, text }; } render() { @@ -889,9 +840,7 @@ export class RichText extends Component { multiline: MultilineTag, keepPlaceholderOnFocus = false, isSelected, - formatters, autocompleters, - format, } = this.props; const ariaProps = pickAriaProps( this.props ); @@ -902,14 +851,14 @@ export class RichText extends Component { const key = [ 'editor', Tagname ].join(); const isPlaceholderVisible = placeholder && ( ! isSelected || keepPlaceholderOnFocus ) && this.isEmpty(); const classes = classnames( wrapperClassName, 'editor-rich-text' ); + const record = this.getRecord(); - const formatToolbar = ( + const formatToolbar = this.editor && ( ); @@ -934,7 +883,13 @@ export class RichText extends Component { containerRef={ this.containerRef } /> } - + + { ( { isExpanded, listBoxId, activeId } ) => ( { isPlaceholderVisible && format ), - formatters: [], - format: 'children', + format: 'rich-text', }; const RichTextContainer = compose( [ @@ -1023,18 +979,18 @@ const RichTextContainer = compose( [ withSafeTimeout, ] )( RichText ); -RichTextContainer.Content = ( { value, format, tagName: Tag, ...props } ) => { - let content; - switch ( format ) { - case 'string': - content = { value }; - break; +RichTextContainer.Content = ( { value, format, tagName: Tag, multiline, ...props } ) => { + let html = value; - case 'children': - content = { children.toHTML( value ) }; - break; + // Handle deprecated `children` and `node` sources. + if ( Array.isArray( value ) ) { + html = children.toHTML( value ); + } else if ( format !== 'string' ) { + html = toHTMLString( value, multiline ); } + const content = { html }; + if ( Tag ) { return { content }; } @@ -1042,10 +998,21 @@ RichTextContainer.Content = ( { value, format, tagName: Tag, ...props } ) => { return content; }; -RichTextContainer.isEmpty = isRichTextValueEmpty; +RichTextContainer.isEmpty = ( value ) => { + // Handle deprecated `children` and `node` sources. + if ( Array.isArray( value ) ) { + return ! value || value.length === 0; + } + + if ( typeof value === 'string' ) { + return value.length === 0; + } + + return isEmpty( value ); +}; RichTextContainer.Content.defaultProps = { - format: 'children', + format: 'rich-text', }; export default RichTextContainer; diff --git a/packages/editor/src/components/rich-text/index.native.js b/packages/editor/src/components/rich-text/index.native.js index 3907fe1138c927..617f5f0ea04b34 100644 --- a/packages/editor/src/components/rich-text/index.native.js +++ b/packages/editor/src/components/rich-text/index.native.js @@ -11,16 +11,19 @@ import { /** * WordPress dependencies */ -import { Component, RawHTML, renderToString } from '@wordpress/element'; +import { Component, RawHTML } from '@wordpress/element'; import { withInstanceId, compose } from '@wordpress/compose'; -import { children } from '@wordpress/blocks'; +import { toHTMLString } from '@wordpress/rich-text'; +import { Toolbar } from '@wordpress/components'; /** * Internal dependencies */ -import FormatToolbar from './format-toolbar'; import { FORMATTING_CONTROLS } from './formatting-controls'; -import { isRichTextValueEmpty } from './utils'; + +const isRichTextValueEmpty = ( value ) => { + return ! value || ! value.length; +}; export function getFormatValue( formatName ) { if ( 'link' === formatName ) { @@ -36,6 +39,7 @@ export class RichText extends Component { this.onEnter = this.onEnter.bind( this ); this.onContentSizeChange = this.onContentSizeChange.bind( this ); this.changeFormats = this.changeFormats.bind( this ); + this.toggleFormat = this.toggleFormat.bind( this ); this.onActiveFormatsChange = this.onActiveFormatsChange.bind( this ); this.onHTMLContentWithCursor = this.onHTMLContentWithCursor.bind( this ); this.state = { @@ -152,30 +156,36 @@ export class RichText extends Component { } ) ); } + toggleFormat( format ) { + return () => this.changeFormats( { + [ format ]: ! this.state.formats[ format ], + } ); + } + render() { const { tagName, style, formattingControls, - formatters, value, } = this.props; - const formatToolbar = ( - - ); + const toolbarControls = FORMATTING_CONTROLS + .filter( ( control ) => formattingControls.indexOf( control.format ) !== -1 ) + .map( ( control ) => ( { + ...control, + onClick: this.toggleFormat( control.format ), + isActive: this.isFormatActive( control.format ), + } ) ); // Save back to HTML from React tree - const html = '<' + tagName + '>' + renderToString( value ) + ''; + const html = '<' + tagName + '>' + toHTMLString( value ) + ''; return ( - { formatToolbar } + + + { this._editor = ref; @@ -198,7 +208,6 @@ export class RichText extends Component { RichText.defaultProps = { formattingControls: FORMATTING_CONTROLS.map( ( { format } ) => format ), - formatters: [], format: 'children', }; @@ -214,7 +223,7 @@ RichTextContainer.Content = ( { value, format, tagName: Tag, ...props } ) => { break; case 'children': - content = { children.toHTML( value ) }; + content = { toHTMLString( value ) }; break; } diff --git a/packages/editor/src/components/rich-text/patterns.js b/packages/editor/src/components/rich-text/patterns.js index 5a9ec12c913b5a..b38bd9295fb337 100644 --- a/packages/editor/src/components/rich-text/patterns.js +++ b/packages/editor/src/components/rich-text/patterns.js @@ -1,229 +1,71 @@ /** * External dependencies */ -import tinymce from 'tinymce'; -import { filter, escapeRegExp, groupBy, drop } from 'lodash'; +import { filter } from 'lodash'; /** * WordPress dependencies */ -import { ESCAPE, ENTER, SPACE, BACKSPACE } from '@wordpress/keycodes'; import { getBlockTransforms, findTransform } from '@wordpress/blocks'; +import { remove, applyFormat, getTextContent } from '@wordpress/rich-text'; -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 || {}; - - const { - enter: enterPatterns, - undefined: spacePatterns, - } = groupBy( filter( getBlockTransforms( 'from' ), { type: 'pattern' } ), 'trigger' ); - - const inlinePatterns = settings.inline || [ - { delimiter: '`', format: 'code' }, - ]; - - let canUndo; - - editor.on( 'selectionchange', function() { - canUndo = null; +export function getPatterns( { onReplace, multiline } ) { + const patterns = filter( getBlockTransforms( 'from' ), ( { type, trigger } ) => { + return type === 'pattern' && trigger === undefined; } ); - 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: remove( record, 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 ]; - - const transformation = findTransform( patterns, ( item ) => { - return item.regExp.test( firstText ); - } ); - if ( ! transformation ) { - return; - } + onReplace( [ block ] ); - const result = firstText.match( transformation.regExp ); - - const range = editor.selection.getRng(); - const matchLength = result[ 0 ].length; - const remainingText = firstText.slice( matchLength ); - - // 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; - } + return record; + }, + ( record ) => { + if ( multiline ) { + return record; + } - // Merge text nodes. - editor.getBody().normalize(); + const text = getTextContent( record ); - const content = getContent(); + // Quick check the text for the necessary character. + 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 = remove( record, start, start + 1 ); + record = remove( record, end, end + 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 f37c07abf9b01d..00000000000000 --- a/packages/editor/src/components/rich-text/test/__snapshots__/format.js.snap +++ /dev/null @@ -1,22 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -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", - }, -] -`; 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 c55f2a54c24d0d..00000000000000 --- a/packages/editor/src/components/rich-text/test/format.js +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Internal dependencies - */ -import { - isTinyMCEInternalAttribute, - isTinyMCEBogusElement, - isTinyMCEBogusWrapperElement, - getCleanTextNodeValue, - createBlockChildrenFromTinyMCEElement, - 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( '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 e918d451b80058..bca5420da2d0d6 100644 --- a/packages/editor/src/components/rich-text/test/index.js +++ b/packages/editor/src/components/rich-text/test/index.js @@ -3,106 +3,21 @@ */ import { shallow } from 'enzyme'; +/** + * WordPress dependencies + */ +import { create } from '@wordpress/rich-text'; + /** * Internal dependencies */ -import { - RichText, - getFormatValue, -} from '../'; +import { RichText } from '../'; import { diffAriaProps, pickAriaProps } from '../aria'; -describe( 'getFormatValue', () => { - function createMockNode( nodeName, attributes = {} ) { - return { - nodeName, - hasAttribute( name ) { - return !! attributes[ name ]; - }, - getAttribute( name ) { - return attributes[ name ]; - }, - }; - } - - test( 'basic formatting', () => { - expect( getFormatValue( 'bold' ) ).toEqual( { - isActive: true, - } ); - } ); - - test( 'link formatting when no anchor is found', () => { - const formatValue = getFormatValue( 'link', [ - createMockNode( 'P' ), - ] ); - expect( formatValue ).toEqual( { - isActive: true, - } ); - } ); - - test( 'link formatting', () => { - const mockNode = createMockNode( 'A', { - href: 'https://www.testing.com', - target: '_blank', - } ); - - const formatValue = getFormatValue( 'link', [ mockNode ] ); - - expect( formatValue ).toEqual( { - isActive: true, - value: 'https://www.testing.com', - target: '_blank', - node: mockNode, - } ); - } ); - - test( 'link formatting when the anchor has no attributes', () => { - const mockNode = createMockNode( 'A' ); - - const formatValue = getFormatValue( 'link', [ mockNode ] ); - - expect( formatValue ).toEqual( { - isActive: true, - value: '', - target: '', - node: mockNode, - } ); - } ); - - test( 'link formatting when the link is still being added', () => { - const formatValue = getFormatValue( 'link', [ - createMockNode( 'A', { - href: '#', - 'data-wp-placeholder': 'true', - 'data-mce-bogus': 'true', - } ), - ] ); - expect( formatValue ).toEqual( { - isAdding: true, - } ); - } ); -} ); - describe( 'RichText', () => { describe( 'Component', () => { - describe( '.adaptFormatter', () => { - const wrapper = shallow( ); - const options = { - type: 'inline-style', - style: { - 'font-weight': '600', - }, - }; - - test( 'should return an object on inline: span, and a styles property matching the style object provided', () => { - expect( wrapper.instance().adaptFormatter( options ) ).toEqual( { - inline: 'span', - styles: options.style, - } ); - } ); - } ); describe( '.getSettings', () => { - const value = [ 'Hi!' ]; + const value = create(); const settings = { setting: 'hi', }; @@ -119,7 +34,7 @@ describe( 'RichText', () => { test( 'should be overriden', () => { const mock = jest.fn().mockImplementation( () => 'mocked' ); - expect( shallow( ).instance().getSettings( settings ) ).toEqual( 'mocked' ); + expect( shallow( ).instance().getSettings( settings ) ).toEqual( 'mocked' ); } ); } ); } ); diff --git a/packages/editor/src/components/rich-text/tinymce.js b/packages/editor/src/components/rich-text/tinymce.js index 33e193046641f1..c8180622c91e8d 100644 --- a/packages/editor/src/components/rich-text/tinymce.js +++ b/packages/editor/src/components/rich-text/tinymce.js @@ -10,12 +10,13 @@ import classnames from 'classnames'; */ import { Component, createElement } from '@wordpress/element'; import { BACKSPACE, DELETE } from '@wordpress/keycodes'; +import { toHTMLString } from '@wordpress/rich-text'; +import { children } from '@wordpress/blocks'; /** * Internal dependencies */ import { diffAriaProps, pickAriaProps } from './aria'; -import { valueToString } from './format'; /** * Determines whether we need a fix to provide `input` events for contenteditable. @@ -159,9 +160,6 @@ export default class TinyMCE extends Component { convert_urls: false, inline_boundaries_selector: 'a[href],code,b,i,strong,em,del,ins,sup,sub', plugins: [], - formats: { - strikethrough: { inline: 'del' }, - }, } ); tinymce.init( { @@ -177,6 +175,10 @@ export default class TinyMCE extends Component { bindEditorNode( editorNode ) { this.editorNode = editorNode; + if ( this.props.setRef ) { + this.props.setRef( editorNode ); + } + /** * A ref function can be used for cleanup because React calls it with * `null` when unmounting. @@ -192,8 +194,17 @@ export default class TinyMCE extends Component { } render() { - const { tagName = 'div', style, defaultValue, className, isPlaceholderVisible, format, onPaste } = this.props; const ariaProps = pickAriaProps( this.props ); + const { + tagName = 'div', + style, + defaultValue, + className, + isPlaceholderVisible, + onPaste, + onInput, + multilineTag, + } = this.props; /* * The role=textbox and aria-multiline=true must always be used together @@ -208,6 +219,14 @@ export default class TinyMCE extends Component { // If a default value is provided, render it into the DOM even before // TinyMCE finishes initializing. This avoids a short delay by allowing // us to show and focus the content before it's truly ready to edit. + let initialHTML = defaultValue; + + // Handle deprecated `children` and `node` sources. + if ( Array.isArray( defaultValue ) ) { + initialHTML = children.toHTML( defaultValue ); + } else if ( typeof defaultValue !== 'string' ) { + initialHTML = toHTMLString( defaultValue, multilineTag ); + } return createElement( tagName, { ...ariaProps, @@ -217,8 +236,9 @@ export default class TinyMCE extends Component { ref: this.bindEditorNode, style, suppressContentEditableWarning: true, - dangerouslySetInnerHTML: { __html: valueToString( defaultValue, format ) }, + dangerouslySetInnerHTML: { __html: initialHTML }, onPaste, + onInput, } ); } } diff --git a/packages/editor/src/components/rich-text/utils.js b/packages/editor/src/components/rich-text/utils.js deleted file mode 100644 index cd7123193fa747..00000000000000 --- a/packages/editor/src/components/rich-text/utils.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Check if the given `RichText` value is empty on not. - * - * @param {Array} value `RichText` value. - * - * @return {boolean} True if empty, false if not. - */ -export const isRichTextValueEmpty = ( value ) => { - return ! value || ! value.length; -}; diff --git a/packages/element/package.json b/packages/element/package.json index 048d4f76bb82f4..7b6a9e750ead20 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -22,6 +22,7 @@ "react-native": "src/index", "dependencies": { "@babel/runtime": "^7.0.0", + "@wordpress/escape-html": "file:../escape-html", "lodash": "^4.17.10", "react": "^16.4.1", "react-dom": "^16.4.1" diff --git a/packages/element/src/serialize.js b/packages/element/src/serialize.js index 66d6b394a84f96..b29057cea1be9e 100644 --- a/packages/element/src/serialize.js +++ b/packages/element/src/serialize.js @@ -29,7 +29,6 @@ * External dependencies */ import { - flowRight, isEmpty, castArray, omit, @@ -38,6 +37,11 @@ import { isPlainObject, } from 'lodash'; +/** + * WordPress dependencies + */ +import { escapeHTML, escapeAttribute, isValidAttributeName } from '@wordpress/escape-html'; + /** * Internal dependencies */ @@ -234,104 +238,6 @@ const CSS_PROPERTIES_SUPPORTS_UNITLESS = new Set( [ 'zoom', ] ); -/** - * Regular expression matching invalid attribute names. - * - * "Attribute names must consist of one or more characters other than controls, - * U+0020 SPACE, U+0022 ("), U+0027 ('), U+003E (>), U+002F (/), U+003D (=), - * and noncharacters." - * - * @link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 - * - * @type {RegExp} - */ -const REGEXP_INVALID_ATTRIBUTE_NAME = /[\u007F-\u009F "'>/="\uFDD0-\uFDEF]/; - -/** - * Returns a string with ampersands escaped. Note that this is an imperfect - * implementation, where only ampersands which do not appear as a pattern of - * named, decimal, or hexadecimal character references are escaped. Invalid - * named references (i.e. ambiguous ampersand) are are still permitted. - * - * @link https://w3c.github.io/html/syntax.html#character-references - * @link https://w3c.github.io/html/syntax.html#ambiguous-ampersand - * @link https://w3c.github.io/html/syntax.html#named-character-references - * - * @param {string} value Original string. - * - * @return {string} Escaped string. - */ -export function escapeAmpersand( value ) { - return value.replace( /&(?!([a-z0-9]+|#[0-9]+|#x[a-f0-9]+);)/gi, '&' ); -} - -/** - * Returns a string with quotation marks replaced. - * - * @param {string} value Original string. - * - * @return {string} Escaped string. - */ -export function escapeQuotationMark( value ) { - return value.replace( /"/g, '"' ); -} - -/** - * Returns a string with less-than sign replaced. - * - * @param {string} value Original string. - * - * @return {string} Escaped string. - */ -export function escapeLessThan( value ) { - return value.replace( / { - const result = implementation( 'foo & bar & & baz Σ &#bad; Σ Σ vil;' ); - - expect( result ).toBe( 'foo & bar & & baz Σ &#bad; Σ Σ &#xevil;' ); - } ); -} - -function testEscapeQuotationMark( implementation ) { - it( 'should escape quotation mark', () => { - const result = implementation( '"Be gone!"' ); - - expect( result ).toBe( '"Be gone!"' ); - } ); -} - -function testEscapeLessThan( implementation ) { - it( 'should escape less than', () => { - const result = implementation( 'Chicken < Ribs' ); - - expect( result ).toBe( 'Chicken < Ribs' ); - } ); -} - -describe( 'escapeAmpersand', () => { - testEscapeAmpersand( escapeAmpersand ); -} ); - -describe( 'escapeQuotationMark', () => { - testEscapeQuotationMark( escapeQuotationMark ); -} ); - -describe( 'escapeLessThan', () => { - testEscapeLessThan( escapeLessThan ); -} ); - -describe( 'escapeAttribute', () => { - testEscapeAmpersand( escapeAttribute ); - testEscapeQuotationMark( escapeAttribute ); -} ); - -describe( 'escapeHTML', () => { - testEscapeAmpersand( escapeHTML ); - testEscapeLessThan( escapeHTML ); -} ); - -describe( 'isValidAttributeName', () => { - it( 'should return false for attribute with controls', () => { - const result = isValidAttributeName( 'bad\u007F' ); - - expect( result ).toBe( false ); - } ); - - it( 'should return false for attribute with non-permitted characters', () => { - const result = isValidAttributeName( 'bad"' ); - - expect( result ).toBe( false ); - } ); - - it( 'should return false for attribute with noncharacters', () => { - const result = isValidAttributeName( 'bad\uFDD0' ); - - expect( result ).toBe( false ); - } ); - - it( 'should return true for valid attribute name', () => { - const result = isValidAttributeName( 'good' ); - - expect( result ).toBe( true ); - } ); -} ); - describe( 'serialize()', () => { it( 'should allow only valid attribute names', () => { const element = createElement( diff --git a/packages/escape-html/.npmrc b/packages/escape-html/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/escape-html/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/escape-html/CHANGELOG.md b/packages/escape-html/CHANGELOG.md new file mode 100644 index 00000000000000..16c97e5a1bf32f --- /dev/null +++ b/packages/escape-html/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 (Unreleased) + +- Initial release. diff --git a/packages/escape-html/README.md b/packages/escape-html/README.md new file mode 100644 index 00000000000000..310034d07cd8e1 --- /dev/null +++ b/packages/escape-html/README.md @@ -0,0 +1,15 @@ +# Escape HTML + +Escape HTML utils. + +## Installation + +Install the module + +```bash +npm install @wordpress/escape-html +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +

    Code is Poetry.

    diff --git a/packages/escape-html/package.json b/packages/escape-html/package.json new file mode 100644 index 00000000000000..0606dc0a5d54ef --- /dev/null +++ b/packages/escape-html/package.json @@ -0,0 +1,27 @@ +{ + "name": "@wordpress/escape-html", + "version": "1.0.0-beta.0", + "description": "Escape HTML utils.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/escape-html/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/escape-html/src/index.js b/packages/escape-html/src/index.js new file mode 100644 index 00000000000000..6b3f74e834564d --- /dev/null +++ b/packages/escape-html/src/index.js @@ -0,0 +1,95 @@ +/** + * Regular expression matching invalid attribute names. + * + * "Attribute names must consist of one or more characters other than controls, + * U+0020 SPACE, U+0022 ("), U+0027 ('), U+003E (>), U+002F (/), U+003D (=), + * and noncharacters." + * + * @link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 + * + * @type {RegExp} + */ +const REGEXP_INVALID_ATTRIBUTE_NAME = /[\u007F-\u009F "'>/="\uFDD0-\uFDEF]/; + +/** + * Returns a string with ampersands escaped. Note that this is an imperfect + * implementation, where only ampersands which do not appear as a pattern of + * named, decimal, or hexadecimal character references are escaped. Invalid + * named references (i.e. ambiguous ampersand) are are still permitted. + * + * @link https://w3c.github.io/html/syntax.html#character-references + * @link https://w3c.github.io/html/syntax.html#ambiguous-ampersand + * @link https://w3c.github.io/html/syntax.html#named-character-references + * + * @param {string} value Original string. + * + * @return {string} Escaped string. + */ +export function escapeAmpersand( value ) { + return value.replace( /&(?!([a-z0-9]+|#[0-9]+|#x[a-f0-9]+);)/gi, '&' ); +} + +/** + * Returns a string with quotation marks replaced. + * + * @param {string} value Original string. + * + * @return {string} Escaped string. + */ +export function escapeQuotationMark( value ) { + return value.replace( /"/g, '"' ); +} + +/** + * Returns a string with less-than sign replaced. + * + * @param {string} value Original string. + * + * @return {string} Escaped string. + */ +export function escapeLessThan( value ) { + return value.replace( / { + const result = implementation( 'foo & bar & & baz Σ &#bad; Σ Σ vil;' ); + + expect( result ).toBe( 'foo & bar & & baz Σ &#bad; Σ Σ &#xevil;' ); + } ); +} + +function testEscapeQuotationMark( implementation ) { + it( 'should escape quotation mark', () => { + const result = implementation( '"Be gone!"' ); + + expect( result ).toBe( '"Be gone!"' ); + } ); +} + +function testEscapeLessThan( implementation ) { + it( 'should escape less than', () => { + const result = implementation( 'Chicken < Ribs' ); + + expect( result ).toBe( 'Chicken < Ribs' ); + } ); +} + +describe( 'escapeAmpersand', () => { + testEscapeAmpersand( escapeAmpersand ); +} ); + +describe( 'escapeQuotationMark', () => { + testEscapeQuotationMark( escapeQuotationMark ); +} ); + +describe( 'escapeLessThan', () => { + testEscapeLessThan( escapeLessThan ); +} ); + +describe( 'escapeAttribute', () => { + testEscapeAmpersand( escapeAttribute ); + testEscapeQuotationMark( escapeAttribute ); +} ); + +describe( 'escapeHTML', () => { + testEscapeAmpersand( escapeHTML ); + testEscapeLessThan( escapeHTML ); +} ); + +describe( 'isValidAttributeName', () => { + it( 'should return false for attribute with controls', () => { + const result = isValidAttributeName( 'bad\u007F' ); + + expect( result ).toBe( false ); + } ); + + it( 'should return false for attribute with non-permitted characters', () => { + const result = isValidAttributeName( 'bad"' ); + + expect( result ).toBe( false ); + } ); + + it( 'should return false for attribute with noncharacters', () => { + const result = isValidAttributeName( 'bad\uFDD0' ); + + expect( result ).toBe( false ); + } ); + + it( 'should return true for valid attribute name', () => { + const result = isValidAttributeName( 'good' ); + + expect( result ).toBe( true ); + } ); +} ); diff --git a/packages/rich-text/.npmrc b/packages/rich-text/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/rich-text/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/rich-text/CHANGELOG.md b/packages/rich-text/CHANGELOG.md new file mode 100644 index 00000000000000..16c97e5a1bf32f --- /dev/null +++ b/packages/rich-text/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 (Unreleased) + +- Initial release. diff --git a/packages/rich-text/README.md b/packages/rich-text/README.md new file mode 100644 index 00000000000000..7655b43c4cc4f7 --- /dev/null +++ b/packages/rich-text/README.md @@ -0,0 +1,146 @@ +# Rich Text + +This module contains helper functions to convert HTML or a DOM tree into a rich text value and back, and to modify the value with functions that are similar to `String` methods, plus some additional ones for formatting. + +## Installation + +Install the module + +```bash +npm install @wordpress/rich-text +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +### create + +```js +create( ?input: Element | string, ?range: Range, ?multilineTag: string, ?settings: Object ): Object +``` + +Create a RichText value from an `Element` tree (DOM), an HTML string or a plain text string, with optionally a `Range` object to set the selection. If called without a given `input`, an empty value will be created. If `multilineTag` is provided, any content of direct children whose type matches `multilineTag` will be separated by two newlines. The `settings` object can be used to filter out content. + +### toHTMLString + +```js +toHTMLString( value: Object, ?multilineTag: string ): string +``` + +Create an HTML string from a Rich Text value. If a `multilineTag` is provided, text separated by two new lines will be wrapped in it. + +### apply + +```js +apply( value: Object, current: Element, ?multilineTag ): void +``` + +Create an `Element` tree from a Rich Text value and applies the difference to the `Element` tree contained by `current`. If a `multilineTag` is provided, text separated by two new lines will be wrapped in an `Element` of that type. + +### isCollapsed + +```js +isCollapsed( value: Object ): ?boolean +``` + +Check if the selection of a Rich Text value is collapsed or not. Collapsed means that no characters are selected, but there is a caret present. If there is no selection, `undefined` will be returned. This is similar to `window.getSelection().isCollapsed()`. + +### isEmpty + +```js +isEmpty( value: Object ): boolean +``` + +Check if a Rich Text value is Empty, meaning it contains no text or any objects (such as images). + +### applyFormat + +```js +applyFormat( value: Object, format: Object, ?startIndex: number, ?endIndex: number ): Object +``` + +Apply a format object to a Rich Text value from the given `startIndex` to the given `endIndex`. Indices are retrieved from the selection if none are provided. + +### removeFormat + +```js +removeFormat( value: Object, formatType: string, ?startIndex: number, ?endIndex: number ): Object +``` + +Remove any format object from a Rich Text value by type from the given `startIndex` to the given `endIndex`. Indices are retrieved from the selection if none are provided. + + +### getActiveFormat + +```js +getActiveFormat( value: Object, formatType: string ): ?Object +``` + +Get any format object by type at the start of the selection. This can be used to get e.g. the URL of a link format at the current selection, but also to check if a format is active at the selection. Returns undefined if there is no format at the selection. + +### getTextContent + +```js +getTextContent( value: Object ): string +``` + +Get the textual content of a Rich Text value. This is similar to `Element.textContent`. + +### slice + +```js +slice( value: Object, ?startIndex: number, ?endIndex: number ): Object +``` + +Slice a Rich Text value from `startIndex` to `endIndex`. Indices are retrieved from the selection if none are provided. This is similar to `String.prototype.slice`. + +### replace + +```js +replace( value: Object, pattern: RegExp, replacement: Object | string ): Object +``` + +Search a Rich Text value and replace the match(es) with `replacement`. This is similar to `String.prototype.replace`. + +### insert + +```js +insert( value: Object, valueToInsert: Object | string, ?startIndex: number, ?endIndex: number ): Object +``` + +Insert a Rich Text value, an HTML string, or a plain text string, into a Rich Text value at the given `startIndex`. Any content between `startIndex` and `endIndex` will be removed. Indices are retrieved from the selection if none are provided. + +### remove + +```js +remove( value: Object, ?startIndex: number, ?endIndex: number ): Object +``` + +Remove content from a Rich Text value between the given `startIndex` and `endIndex`. Indices are retrieved from the selection if none are provided. + +### split + +```js +split( value: Object, ?startIndex: number | string | RegExp, ?endIndex: number ): Array +``` + +Split a Rich Text value in two at the given `startIndex` and `endIndex`, or split at the given separator. This is similar to `String.prototype.split`. Indices are retrieved from the selection if none are provided. + +### join + +```js +join( values: Array, ?separator: Object | string ): Object +``` + +Combine an array of Rich Text values into one, optionally separated by `separator`, which can be a Rich Text value, HTML string, or plain text string. This is similar to `Array.prototype.join`. + +### concat + +```js +concat( ...values: Array ): Object +``` + +Combine all Rich Text values into one. This is similar to `String.prototype.concat`. + +

    Code is Poetry.

    diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json new file mode 100644 index 00000000000000..56f1bb8fa6dcf1 --- /dev/null +++ b/packages/rich-text/package.json @@ -0,0 +1,34 @@ +{ + "name": "@wordpress/rich-text", + "version": "1.0.0-beta.0", + "description": "Rich text value and manipulation API.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "rich-text" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/rich-text/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.0.0", + "@wordpress/escape-html": "file:../escape-html", + "lodash": "^4.17.10" + }, + "devDependencies": { + "deep-freeze": "^0.0.1", + "jsdom": "^11.12.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/rich-text/src/apply-format.js b/packages/rich-text/src/apply-format.js new file mode 100644 index 00000000000000..75212c001adc9a --- /dev/null +++ b/packages/rich-text/src/apply-format.js @@ -0,0 +1,38 @@ +/** + * Internal dependencies + */ + +import { normaliseFormats } from './normalise-formats'; + +/** + * Apply a format object to a Rich Text value from the given `startIndex` to the + * given `endIndex`. Indices are retrieved from the selection if none are + * provided. + * + * @param {Object} record Record to modify. + * @param {Object} format Format to apply. + * @param {number} startIndex Start index. + * @param {number} endIndex End index. + * + * @return {Object} A new record with the format applied. + */ +export function applyFormat( + { formats, text, start, end }, + format, + startIndex = start, + endIndex = end +) { + const newFormats = formats.slice( 0 ); + + for ( let index = startIndex; index < endIndex; index++ ) { + if ( formats[ index ] ) { + const newFormatsAtIndex = formats[ index ].filter( ( { type } ) => type !== format.type ); + newFormatsAtIndex.push( format ); + newFormats[ index ] = newFormatsAtIndex; + } else { + newFormats[ index ] = [ format ]; + } + } + + return normaliseFormats( { formats: newFormats, text, start, end } ); +} diff --git a/packages/rich-text/src/concat.js b/packages/rich-text/src/concat.js new file mode 100644 index 00000000000000..838f36b0501a6d --- /dev/null +++ b/packages/rich-text/src/concat.js @@ -0,0 +1,20 @@ +/** + * Internal dependencies + */ + +import { normaliseFormats } from './normalise-formats'; + +/** + * Combine all Rich Text values into one. This is similar to + * `String.prototype.concat`. + * + * @param {...[object]} values An array of all values to combine. + * + * @return {Object} A new value combining all given records. + */ +export function concat( ...values ) { + return normaliseFormats( values.reduce( ( accumlator, { formats, text } ) => ( { + text: accumlator.text + text, + formats: accumlator.formats.concat( formats ), + } ) ) ); +} diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js new file mode 100644 index 00000000000000..5048f355bfeca4 --- /dev/null +++ b/packages/rich-text/src/create.js @@ -0,0 +1,451 @@ +/** + * Internal dependencies + */ + +import { isEmpty } from './is-empty'; +import { isFormatEqual } from './is-format-equal'; + +/** + * Browser dependencies + */ + +const { TEXT_NODE, ELEMENT_NODE } = window.Node; + +/** + * Parse the given HTML into a body element. + * + * @param {string} html The HTML to parse. + * + * @return {HTMLBodyElement} Body element with parsed HTML. + */ +function createElement( html ) { + const htmlDocument = document.implementation.createHTMLDocument( '' ); + + htmlDocument.body.innerHTML = html; + + return htmlDocument.body; +} + +function createEmptyValue() { + return { formats: [], text: '' }; +} + +/** + * Create a RichText value from an `Element` tree (DOM), an HTML string or a + * plain text string, with optionally a `Range` object to set the selection. If + * called without any input, an empty value will be created. If + * `multilineTag` is provided, any content of direct children whose type matches + * `multilineTag` will be separated by two newlines. The optional functions can + * be used to filter out content. + * + * @param {?Object} $1 Optional named argements. + * @param {?Element} $1.element Element to create value from. + * @param {?string} $1.text Text to create value from. + * @param {?string} $1.html HTML to create value from. + * @param {?Range} $1.range Range to create value from. + * @param {?string} $1.multilineTag Multiline tag if the structure is + * multiline. + * @param {?Function} $1.removeNode Function to declare whether the given + * node should be removed. + * @param {?Function} $1.unwrapNode Function to declare whether the given + * node should be unwrapped. + * @param {?Function} $1.filterString Function to filter the given string. + * @param {?Function} $1.removeAttribute Wether to remove an attribute based on + * the name. + * + * @return {Object} A rich text value. + */ +export function create( { + element, + text, + html, + range, + multilineTag, + removeNode, + unwrapNode, + filterString, + removeAttribute, +} = {} ) { + if ( typeof text === 'string' && text.length > 0 ) { + return { + formats: Array( text.length ), + text: text, + }; + } + + if ( typeof html === 'string' && html.length > 0 ) { + element = createElement( html ); + } + + if ( typeof element !== 'object' ) { + return createEmptyValue(); + } + + if ( ! multilineTag ) { + return createFromElement( { + element, + range, + removeNode, + unwrapNode, + filterString, + removeAttribute, + } ); + } + + return createFromMultilineElement( { + element, + range, + multilineTag, + removeNode, + unwrapNode, + filterString, + removeAttribute, + } ); +} + +/** + * Helper to accumulate the value's selection start and end from the current + * node and range. + * + * @param {Object} accumulator Object to accumulate into. + * @param {Node} node Node to create value with. + * @param {Range} range Range to create value with. + * @param {Object} value Value that is being accumulated. + */ +function accumulateSelection( accumulator, node, range, value ) { + if ( ! range ) { + return; + } + + const { parentNode } = node; + const { startContainer, startOffset, endContainer, endOffset } = range; + const currentLength = accumulator.text.length; + + // Selection can be extracted from value. + if ( value.start !== undefined ) { + accumulator.start = currentLength + value.start; + // Range indicates that the current node has selection. + } else if ( node === startContainer ) { + accumulator.start = currentLength + startOffset; + // Range indicates that the current node is selected. + } else if ( + parentNode === startContainer && + node === startContainer.childNodes[ startOffset ] + ) { + accumulator.start = currentLength; + } + + // Selection can be extracted from value. + if ( value.end !== undefined ) { + accumulator.end = currentLength + value.end; + // Range indicates that the current node has selection. + } else if ( node === endContainer ) { + accumulator.end = currentLength + endOffset; + // Range indicates that the current node is selected. + } else if ( + parentNode === endContainer && + node === endContainer.childNodes[ endOffset - 1 ] + ) { + accumulator.end = currentLength + value.text.length; + } +} + +/** + * Adjusts the start and end offsets from a range based on a text filter. + * + * @param {Node} node Node of which the text should be filtered. + * @param {Range} range The range to filter. + * @param {Function} filter Function to use to filter the text. + * + * @return {?Object} Object containing range properties. + */ +function filterRange( node, range, filter ) { + if ( ! range ) { + return; + } + + const { startContainer, endContainer } = range; + let { startOffset, endOffset } = range; + + if ( node === startContainer ) { + startOffset = filter( node.nodeValue.slice( 0, startOffset ) ).length; + } + + if ( node === endContainer ) { + endOffset = filter( node.nodeValue.slice( 0, endOffset ) ).length; + } + + return { startContainer, startOffset, endContainer, endOffset }; +} + +/** + * Creates a Rich Text value from a DOM element and range. + * + * @param {Object} $1 Named argements. + * @param {?Element} $1.element Element to create value from. + * @param {?Range} $1.range Range to create value from. + * @param {?Function} $1.removeNode Function to declare whether the given + * node should be removed. + * @param {?Function} $1.unwrapNode Function to declare whether the given + * node should be unwrapped. + * @param {?Function} $1.filterString Function to filter the given string. + * @param {?Function} $1.removeAttribute Wether to remove an attribute based on + * the name. + * + * @return {Object} A rich text value. + */ +function createFromElement( { + element, + range, + removeNode, + unwrapNode, + filterString, + removeAttribute, +} ) { + const accumulator = createEmptyValue(); + + if ( ! element ) { + return accumulator; + } + + if ( ! element.hasChildNodes() ) { + accumulateSelection( accumulator, element, range, createEmptyValue() ); + return accumulator; + } + + const length = element.childNodes.length; + + // Remove any line breaks in text nodes. They are not content, but used to + // format the HTML. Line breaks in HTML are stored as BR elements. + // See https://www.w3.org/TR/html5/syntax.html#newlines. + const filterStringComplete = ( string ) => { + string = string.replace( /[\r\n]/g, '' ); + + if ( filterString ) { + string = filterString( string ); + } + + return string; + }; + + // Optimise for speed. + for ( let index = 0; index < length; index++ ) { + const node = element.childNodes[ index ]; + + if ( node.nodeType === TEXT_NODE ) { + const text = filterStringComplete( node.nodeValue ); + range = filterRange( node, range, filterStringComplete ); + accumulateSelection( accumulator, node, range, { text } ); + accumulator.text += text; + // Create a sparse array of the same length as `text`, in which + // formats can be added. + accumulator.formats.length += text.length; + continue; + } + + if ( node.nodeType !== ELEMENT_NODE ) { + continue; + } + + if ( + ( removeNode && removeNode( node ) ) || + ( unwrapNode && unwrapNode( node ) && ! node.hasChildNodes() ) + ) { + accumulateSelection( accumulator, node, range, createEmptyValue() ); + continue; + } + + if ( node.nodeName === 'BR' ) { + accumulateSelection( accumulator, node, range, createEmptyValue() ); + accumulator.text += '\n'; + accumulator.formats.length += 1; + continue; + } + + const lastFormats = accumulator.formats[ accumulator.formats.length - 1 ]; + const lastFormat = lastFormats && lastFormats[ lastFormats.length - 1 ]; + let format; + + if ( ! unwrapNode || ! unwrapNode( node ) ) { + const type = node.nodeName.toLowerCase(); + const attributes = getAttributes( { + element: node, + removeAttribute, + } ); + const newFormat = attributes ? { type, attributes } : { type }; + + // Reuse the last format if it's equal. + if ( isFormatEqual( newFormat, lastFormat ) ) { + format = lastFormat; + } else { + format = newFormat; + } + } + + const value = createFromElement( { + element: node, + range, + removeNode, + unwrapNode, + filterString, + removeAttribute, + } ); + + const text = value.text; + const start = accumulator.text.length; + + accumulateSelection( accumulator, node, range, value ); + + // Don't apply the element as formatting if it has no content. + if ( isEmpty( value ) && format && ! format.attributes ) { + continue; + } + + const { formats } = accumulator; + + if ( format && format.attributes && text.length === 0 ) { + format.object = true; + // Object replacement character. + accumulator.text += '\ufffc'; + + if ( formats[ start ] ) { + formats[ start ].unshift( format ); + } else { + formats[ start ] = [ format ]; + } + } else { + accumulator.text += text; + + let i = value.formats.length; + + // Optimise for speed. + while ( i-- ) { + const formatIndex = start + i; + + if ( format ) { + if ( formats[ formatIndex ] ) { + formats[ formatIndex ].push( format ); + } else { + formats[ formatIndex ] = [ format ]; + } + } + + if ( value.formats[ i ] ) { + if ( formats[ formatIndex ] ) { + formats[ formatIndex ].push( ...value.formats[ i ] ); + } else { + formats[ formatIndex ] = value.formats[ i ]; + } + } + } + } + } + + return accumulator; +} + +/** + * Creates a rich text value from a DOM element and range that should be + * multiline. + * + * @param {Object} $1 Named argements. + * @param {?Element} $1.element Element to create value from. + * @param {?Range} $1.range Range to create value from. + * @param {?string} $1.multilineTag Multiline tag if the structure is + * multiline. + * @param {?Function} $1.removeNode Function to declare whether the given + * node should be removed. + * @param {?Function} $1.unwrapNode Function to declare whether the given + * node should be unwrapped. + * @param {?Function} $1.filterString Function to filter the given string. + * @param {?Function} $1.removeAttribute Wether to remove an attribute based on + * the name. + * + * @return {Object} A rich text value. + */ +function createFromMultilineElement( { + element, + range, + multilineTag, + removeNode, + unwrapNode, + filterString, + removeAttribute, +} ) { + const accumulator = createEmptyValue(); + + if ( ! element || ! element.hasChildNodes() ) { + return accumulator; + } + + const length = element.children.length; + + // Optimise for speed. + for ( let index = 0; index < length; index++ ) { + const node = element.children[ index ]; + + if ( node.nodeName.toLowerCase() !== multilineTag ) { + continue; + } + + const value = createFromElement( { + element: node, + range, + multilineTag, + removeNode, + unwrapNode, + filterString, + removeAttribute, + } ); + + // Multiline value text should be separated by a double line break. + if ( index !== 0 ) { + accumulator.formats = accumulator.formats.concat( [ , ] ); + accumulator.text += '\u2028'; + } + + accumulateSelection( accumulator, node, range, value ); + + accumulator.formats = accumulator.formats.concat( value.formats ); + accumulator.text += value.text; + } + + return accumulator; +} + +/** + * Gets the attributes of an element in object shape. + * + * @param {Object} $1 Named argements. + * @param {Element} $1.element Element to get attributes from. + * @param {?Function} $1.removeAttribute Wether to remove an attribute based on + * the name. + * + * @return {?Object} Attribute object or `undefined` if the element has no + * attributes. + */ +function getAttributes( { + element, + removeAttribute, +} ) { + if ( ! element.hasAttributes() ) { + return; + } + + const length = element.attributes.length; + let accumulator; + + // Optimise for speed. + for ( let i = 0; i < length; i++ ) { + const { name, value } = element.attributes[ i ]; + + if ( removeAttribute && removeAttribute( name ) ) { + continue; + } + + accumulator = accumulator || {}; + accumulator[ name ] = value; + } + + return accumulator; +} diff --git a/packages/rich-text/src/get-active-format.js b/packages/rich-text/src/get-active-format.js new file mode 100644 index 00000000000000..090a9f1d8ccbb3 --- /dev/null +++ b/packages/rich-text/src/get-active-format.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ + +import { find } from 'lodash'; + +/** + * Gets the format object by type at the start of the selection. This can be + * used to get e.g. the URL of a link format at the current selection, but also + * to check if a format is active at the selection. Returns undefined if there + * is no format at the selection. + * + * @param {Object} value Value to inspect. + * @param {string} formatType Format type to look for. + * + * @return {?Object} Active format object of the specified type, or undefined. + */ +export function getActiveFormat( { formats, start }, formatType ) { + if ( start === undefined ) { + return; + } + + return find( formats[ start ], { type: formatType } ); +} diff --git a/packages/rich-text/src/get-text-content.js b/packages/rich-text/src/get-text-content.js new file mode 100644 index 00000000000000..75961a2b242baf --- /dev/null +++ b/packages/rich-text/src/get-text-content.js @@ -0,0 +1,11 @@ +/** + * Get the textual content of a Rich Text value. This is similar to + * `Element.textContent`. + * + * @param {Object} value Value to use. + * + * @return {string} The text content. + */ +export function getTextContent( { text } ) { + return text; +} diff --git a/packages/rich-text/src/index.js b/packages/rich-text/src/index.js new file mode 100644 index 00000000000000..61f149cc047677 --- /dev/null +++ b/packages/rich-text/src/index.js @@ -0,0 +1,16 @@ +export { applyFormat } from './apply-format'; +export { concat } from './concat'; +export { create } from './create'; +export { getActiveFormat } from './get-active-format'; +export { getTextContent } from './get-text-content'; +export { isCollapsed } from './is-collapsed'; +export { isEmpty, isEmptyLine } from './is-empty'; +export { join } from './join'; +export { removeFormat } from './remove-format'; +export { remove } from './remove'; +export { replace } from './replace'; +export { insert } from './insert'; +export { slice } from './slice'; +export { split } from './split'; +export { apply, toDom as unstableToDom } from './to-dom'; +export { toHTMLString } from './to-html-string'; diff --git a/packages/rich-text/src/insert.js b/packages/rich-text/src/insert.js new file mode 100644 index 00000000000000..10c2ec0e9621ac --- /dev/null +++ b/packages/rich-text/src/insert.js @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ + +import { create } from './create'; +import { normaliseFormats } from './normalise-formats'; + +/** + * Insert a Rich Text value, an HTML string, or a plain text string, into a + * Rich Text value at the given `startIndex`. Any content between `startIndex` + * and `endIndex` will be removed. Indices are retrieved from the selection if + * none are provided. + * + * @param {Object} value Value to modify. + * @param {string} valueToInsert Value to insert. + * @param {number} startIndex Start index. + * @param {number} endIndex End index. + * + * @return {Object} A new value with the value inserted. + */ +export function insert( + { formats, text, start, end }, + valueToInsert, + startIndex = start, + endIndex = end +) { + if ( typeof valueToInsert === 'string' ) { + valueToInsert = create( { text: valueToInsert } ); + } + + const index = startIndex + valueToInsert.text.length; + + return normaliseFormats( { + formats: formats.slice( 0, startIndex ).concat( valueToInsert.formats, formats.slice( endIndex ) ), + text: text.slice( 0, startIndex ) + valueToInsert.text + text.slice( endIndex ), + start: index, + end: index, + } ); +} diff --git a/packages/rich-text/src/is-collapsed.js b/packages/rich-text/src/is-collapsed.js new file mode 100644 index 00000000000000..7c1e49b5a7cd42 --- /dev/null +++ b/packages/rich-text/src/is-collapsed.js @@ -0,0 +1,18 @@ +/** + * Check if the selection of a Rich Text value is collapsed or not. Collapsed + * means that no characters are selected, but there is a caret present. If there + * is no selection, `undefined` will be returned. This is similar to + * `window.getSelection().isCollapsed()`. + * + * @param {Object} value The rich text value to check. + * + * @return {?boolean} True if the selection is collapsed, false if not, + * undefined if there is no selection. + */ +export function isCollapsed( { start, end } ) { + if ( start === undefined || end === undefined ) { + return; + } + + return start === end; +} diff --git a/packages/rich-text/src/is-empty.js b/packages/rich-text/src/is-empty.js new file mode 100644 index 00000000000000..a669314ee45032 --- /dev/null +++ b/packages/rich-text/src/is-empty.js @@ -0,0 +1,39 @@ +/** + * Check if a Rich Text value is Empty, meaning it contains no text or any + * objects (such as images). + * + * @param {Object} value Value to use. + * + * @return {boolean} True if the value is empty, false if not. + */ +export function isEmpty( { text } ) { + return text.length === 0; +} + +/** + * Check if the current collapsed selection is on an empty line in case of a + * multiline value. + * + * @param {Object} value Value te check. + * + * @return {boolean} True if the line is empty, false if not. + */ +export function isEmptyLine( { text, start, end } ) { + if ( start !== end ) { + return false; + } + + if ( text.length === 0 ) { + return true; + } + + if ( start === 0 && text.slice( 0, 1 ) === '\u2028' ) { + return true; + } + + if ( start === text.length && text.slice( -1 ) === '\u2028' ) { + return true; + } + + return text.slice( start - 1, end + 1 ) === '\u2028\u2028'; +} diff --git a/packages/rich-text/src/is-format-equal.js b/packages/rich-text/src/is-format-equal.js new file mode 100644 index 00000000000000..b6d5c64c0c0d28 --- /dev/null +++ b/packages/rich-text/src/is-format-equal.js @@ -0,0 +1,56 @@ +/** + * Optimised equality check for format objects. + * + * @param {?Object} format1 Format to compare. + * @param {?Object} format2 Format to compare. + * + * @return {boolean} True if formats are equal, false if not. + */ +export function isFormatEqual( format1, format2 ) { + // Both not defined. + if ( format1 === format2 ) { + return true; + } + + // Either not defined. + if ( ! format1 || ! format2 ) { + return false; + } + + if ( format1.type !== format2.type ) { + return false; + } + + const attributes1 = format1.attributes; + const attributes2 = format2.attributes; + + // Both not defined. + if ( attributes1 === attributes2 ) { + return true; + } + + // Either not defined. + if ( ! attributes1 || ! attributes2 ) { + return false; + } + + const keys1 = Object.keys( attributes1 ); + const keys2 = Object.keys( attributes2 ); + + if ( keys1.length !== keys2.length ) { + return false; + } + + const length = keys1.length; + + // Optimise for speed. + for ( let i = 0; i < length; i++ ) { + const name = keys1[ i ]; + + if ( attributes1[ name ] !== attributes2[ name ] ) { + return false; + } + } + + return true; +} diff --git a/packages/rich-text/src/join.js b/packages/rich-text/src/join.js new file mode 100644 index 00000000000000..c2e3c103b0674c --- /dev/null +++ b/packages/rich-text/src/join.js @@ -0,0 +1,27 @@ +/** + * Internal dependencies + */ + +import { create } from './create'; +import { normaliseFormats } from './normalise-formats'; + +/** + * Combine an array of Rich Text values into one, optionally separated by + * `separator`, which can be a Rich Text value, HTML string, or plain text + * string. This is similar to `Array.prototype.join`. + * + * @param {Array} values An array of values to join. + * @param {string|Object} separator Separator string or value. + * + * @return {Object} A new combined value. + */ +export function join( values, separator = '' ) { + if ( typeof separator === 'string' ) { + separator = create( { text: separator } ); + } + + return normaliseFormats( values.reduce( ( accumlator, { formats, text } ) => ( { + text: accumlator.text + separator.text + text, + formats: accumlator.formats.concat( separator.formats, formats ), + } ) ) ); +} diff --git a/packages/rich-text/src/normalise-formats.js b/packages/rich-text/src/normalise-formats.js new file mode 100644 index 00000000000000..349540df9dc256 --- /dev/null +++ b/packages/rich-text/src/normalise-formats.js @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ + +import { isFormatEqual } from './is-format-equal'; + +/** + * Normalises formats: ensures subsequent equal formats have the same reference. + * + * @param {Object} value Value to normalise formats of. + * + * @return {Object} New value with normalised formats. + */ +export function normaliseFormats( { formats, text, start, end } ) { + const newFormats = formats.slice( 0 ); + + newFormats.forEach( ( formatsAtIndex, index ) => { + const lastFormatsAtIndex = newFormats[ index - 1 ]; + + if ( lastFormatsAtIndex ) { + const newFormatsAtIndex = formatsAtIndex.slice( 0 ); + + newFormatsAtIndex.forEach( ( format, formatIndex ) => { + const lastFormat = lastFormatsAtIndex[ formatIndex ]; + + if ( isFormatEqual( format, lastFormat ) ) { + newFormatsAtIndex[ formatIndex ] = lastFormat; + } + } ); + + newFormats[ index ] = newFormatsAtIndex; + } + } ); + + return { formats: newFormats, text, start, end }; +} diff --git a/packages/rich-text/src/remove-format.js b/packages/rich-text/src/remove-format.js new file mode 100644 index 00000000000000..d6f2890f3afaf4 --- /dev/null +++ b/packages/rich-text/src/remove-format.js @@ -0,0 +1,68 @@ +/** + * External dependencies + */ + +import { find } from 'lodash'; + +/** + * Internal dependencies + */ + +import { normaliseFormats } from './normalise-formats'; + +/** + * Remove any format object from a Rich Text value by type from the given + * `startIndex` to the given `endIndex`. Indices are retrieved from the + * selection if none are provided. + * + * @param {Object} value Value to modify. + * @param {string} formatType Format type to remove. + * @param {number} startIndex Start index. + * @param {number} endIndex End index. + * + * @return {Object} A new value with the format applied. + */ +export function removeFormat( + { formats, text, start, end }, + formatType, + startIndex = start, + endIndex = end +) { + const newFormats = formats.slice( 0 ); + + // If the selection is collapsed, expand start and end to the edges of the + // format. + if ( startIndex === endIndex ) { + const format = find( newFormats[ startIndex ], { type: formatType } ); + + while ( find( newFormats[ startIndex ], format ) ) { + filterFormats( newFormats, startIndex, formatType ); + startIndex--; + } + + endIndex++; + + while ( find( newFormats[ endIndex ], format ) ) { + filterFormats( newFormats, endIndex, formatType ); + endIndex++; + } + } else { + for ( let i = startIndex; i < endIndex; i++ ) { + if ( newFormats[ i ] ) { + filterFormats( newFormats, i, formatType ); + } + } + } + + return normaliseFormats( { formats: newFormats, text, start, end } ); +} + +function filterFormats( formats, index, formatType ) { + const newFormats = formats[ index ].filter( ( { type } ) => type !== formatType ); + + if ( newFormats.length ) { + formats[ index ] = newFormats; + } else { + delete formats[ index ]; + } +} diff --git a/packages/rich-text/src/remove.js b/packages/rich-text/src/remove.js new file mode 100644 index 00000000000000..de4241ad301e69 --- /dev/null +++ b/packages/rich-text/src/remove.js @@ -0,0 +1,20 @@ +/** + * Internal dependencies + */ + +import { insert } from './insert'; +import { create } from './create'; + +/** + * Remove content from a Rich Text value between the given `startIndex` and + * `endIndex`. Indices are retrieved from the selection if none are provided. + * + * @param {Object} value Value to modify. + * @param {number} startIndex Start index. + * @param {number} endIndex End index. + * + * @return {Object} A new value with the content removed. + */ +export function remove( value, startIndex, endIndex ) { + return insert( value, create(), startIndex, endIndex ); +} diff --git a/packages/rich-text/src/replace.js b/packages/rich-text/src/replace.js new file mode 100644 index 00000000000000..110fc186bd6386 --- /dev/null +++ b/packages/rich-text/src/replace.js @@ -0,0 +1,54 @@ +/** + * Internal dependencies + */ + +import { normaliseFormats } from './normalise-formats'; + +/** + * Search a Rich Text value and replace the match(es) with `replacement`. This + * is similar to `String.prototype.replace`. + * + * @param {Object} value The value to modify. + * @param {RegExp|string} pattern A RegExp object or literal. Can also be + * a string. It is treated as a verbatim + * string and is not interpreted as a + * regular expression. Only the first + * occurrence will be replaced. + * @param {Function|string} replacement The match or matches are replaced with + * the specified or the value returned by + * the specified function. + * + * @return {Object} A new value with replacements applied. + */ +export function replace( { formats, text, start, end }, pattern, replacement ) { + text = text.replace( pattern, ( match, ...rest ) => { + const offset = rest[ rest.length - 2 ]; + let newText = replacement; + let newFormats; + + if ( typeof newText === 'function' ) { + newText = replacement( match, ...rest ); + } + + if ( typeof newText === 'object' ) { + newFormats = newText.formats; + newText = newText.text; + } else { + newFormats = Array( newText.length ); + + if ( formats[ offset ] ) { + newFormats = newFormats.fill( formats[ offset ] ); + } + } + + formats = formats.slice( 0, offset ).concat( newFormats, formats.slice( offset + match.length ) ); + + if ( start ) { + start = end = offset + newText.length; + } + + return newText; + } ); + + return normaliseFormats( { formats, text, start, end } ); +} diff --git a/packages/rich-text/src/slice.js b/packages/rich-text/src/slice.js new file mode 100644 index 00000000000000..3a54642b98cc7f --- /dev/null +++ b/packages/rich-text/src/slice.js @@ -0,0 +1,25 @@ +/** + * Slice a Rich Text value from `startIndex` to `endIndex`. Indices are + * retrieved from the selection if none are provided. This is similar to + * `String.prototype.slice`. + * + * @param {Object} value Value to modify. + * @param {number} startIndex Start index. + * @param {number} endIndex End index. + * + * @return {Object} A new extracted value. + */ +export function slice( + { formats, text, start, end }, + startIndex = start, + endIndex = end +) { + if ( startIndex === undefined || endIndex === undefined ) { + return { formats, text }; + } + + return { + formats: formats.slice( startIndex, endIndex ), + text: text.slice( startIndex, endIndex ), + }; +} diff --git a/packages/rich-text/src/split.js b/packages/rich-text/src/split.js new file mode 100644 index 00000000000000..f757186b25152e --- /dev/null +++ b/packages/rich-text/src/split.js @@ -0,0 +1,73 @@ +/** + * Internal dependencies + */ + +import { replace } from './replace'; + +/** + * Split a Rich Text value in two at the given `startIndex` and `endIndex`, or + * split at the given separator. This is similar to `String.prototype.split`. + * Indices are retrieved from the selection if none are provided. + * + * @param {Object} value Value to modify. + * @param {number|string} string Start index, or string at which to split. + * @param {number} end End index. + * + * @return {Array} An array of new values. + */ +export function split( { formats, text, start, end }, string ) { + if ( typeof string !== 'string' ) { + return splitAtSelection( ...arguments ); + } + + let nextStart = 0; + + return text.split( string ).map( ( substring ) => { + const startIndex = nextStart; + const value = { + formats: formats.slice( startIndex, startIndex + substring.length ), + text: substring, + }; + + nextStart += string.length + substring.length; + + if ( start !== undefined && end !== undefined ) { + if ( start > startIndex && start < nextStart ) { + value.start = start - startIndex; + } else if ( start < startIndex && end > startIndex ) { + value.start = 0; + } + + if ( end > startIndex && end < nextStart ) { + value.end = end - startIndex; + } else if ( start < nextStart && end > nextStart ) { + value.end = substring.length; + } + } + + return value; + } ); +} + +function splitAtSelection( + { formats, text, start, end }, + startIndex = start, + endIndex = end +) { + const before = { + formats: formats.slice( 0, startIndex ), + text: text.slice( 0, startIndex ), + }; + const after = { + formats: formats.slice( endIndex ), + text: text.slice( endIndex ), + start: 0, + end: 0, + }; + + return [ + // Ensure newlines are trimmed. + replace( before, /\u2028+$/, '' ), + replace( after, /^\u2028+/, '' ), + ]; +} diff --git a/packages/rich-text/src/test/apply-format.js b/packages/rich-text/src/test/apply-format.js new file mode 100644 index 00000000000000..20320fed0a024d --- /dev/null +++ b/packages/rich-text/src/test/apply-format.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { applyFormat } from '../apply-format'; +import { getSparseArrayLength } from './helpers'; + +describe( 'applyFormat', () => { + const strong = { type: 'strong' }; + const em = { type: 'em' }; + + it( 'should apply format', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + }; + const expected = { + formats: [ , , , [ strong ], [ em, strong ], [ em, strong ], [ em ], , , , , , , ], + text: 'one two three', + }; + const result = applyFormat( deepFreeze( record ), strong, 3, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 4 ); + } ); + + it( 'should apply format by selection', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + start: 3, + end: 6, + }; + const expected = { + formats: [ , , , [ strong ], [ em, strong ], [ em, strong ], [ em ], , , , , , , ], + text: 'one two three', + start: 3, + end: 6, + }; + const result = applyFormat( deepFreeze( record ), strong ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 4 ); + } ); +} ); diff --git a/packages/rich-text/src/test/concat.js b/packages/rich-text/src/test/concat.js new file mode 100644 index 00000000000000..9ac2aa2dc75569 --- /dev/null +++ b/packages/rich-text/src/test/concat.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { concat } from '../concat'; +import { getSparseArrayLength } from './helpers'; + +describe( 'concat', () => { + const em = { type: 'em' }; + + it( 'should merge records', () => { + const one = { + formats: [ , , [ em ] ], + text: 'one', + }; + const two = { + formats: [ [ em ], , , ], + text: 'two', + }; + const three = { + formats: [ , , [ em ], [ em ], , , ], + text: 'onetwo', + }; + + const merged = concat( deepFreeze( one ), deepFreeze( two ) ); + + expect( merged ).not.toBe( one ); + expect( merged ).toEqual( three ); + expect( getSparseArrayLength( merged.formats ) ).toBe( 2 ); + } ); +} ); diff --git a/packages/rich-text/src/test/create.js b/packages/rich-text/src/test/create.js new file mode 100644 index 00000000000000..200f3e6470c1d7 --- /dev/null +++ b/packages/rich-text/src/test/create.js @@ -0,0 +1,564 @@ +/** + * External dependencies + */ + +import { JSDOM } from 'jsdom'; + +/** + * Internal dependencies + */ + +import { create } from '../create'; +import { getSparseArrayLength } from './helpers'; + +const { window } = new JSDOM(); +const { document } = window; + +function createElement( html ) { + const htmlDocument = document.implementation.createHTMLDocument( '' ); + + htmlDocument.body.innerHTML = html; + + return htmlDocument.body; +} + +describe( 'create', () => { + const em = { type: 'em' }; + const strong = { type: 'strong' }; + const img = { type: 'img', attributes: { src: '' }, object: true }; + const a = { type: 'a', attributes: { href: '#' } }; + const list = [ { type: 'ul' }, { type: 'li' } ]; + + const spec = [ + { + description: 'should create an empty value', + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 0, + endContainer: element, + } ), + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should ignore line breaks to format HTML', + html: '\n\n\r\n', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should create an empty value from empty tags', + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should create a value without formatting', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.firstChild, + endOffset: 4, + endContainer: element.firstChild, + } ), + record: { + start: 0, + end: 4, + formats: [ , , , , ], + text: 'test', + }, + }, + { + description: 'should preserve emoji', + html: '🍒', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 2, + formats: [ , , ], + text: '🍒', + }, + }, + { + description: 'should preserve emoji in formatting', + html: '🍒', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 2, + formats: [ [ em ], [ em ] ], + text: '🍒', + }, + }, + { + description: 'should create a value with formatting', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.firstChild, + endOffset: 1, + endContainer: element.firstChild, + } ), + record: { + start: 0, + end: 4, + formats: [ [ em ], [ em ], [ em ], [ em ] ], + text: 'test', + }, + }, + { + description: 'should create a value with nested formatting', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 4, + formats: [ [ em, strong ], [ em, strong ], [ em, strong ], [ em, strong ] ], + text: 'test', + }, + }, + { + description: 'should create a value with formatting for split tags', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.querySelector( 'em' ), + endOffset: 1, + endContainer: element.querySelector( 'em' ), + } ), + record: { + start: 0, + end: 2, + formats: [ [ em ], [ em ], [ em ], [ em ] ], + text: 'test', + }, + }, + { + description: 'should create a value with formatting with attributes', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 4, + formats: [ [ a ], [ a ], [ a ], [ a ] ], + text: 'test', + }, + }, + { + description: 'should create a value with image object', + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 0, + formats: [ [ img ] ], + text: '\ufffc', + }, + }, + { + description: 'should create a value with image object and formatting', + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.querySelector( 'img' ), + endOffset: 1, + endContainer: element.querySelector( 'img' ), + } ), + record: { + start: 0, + end: 1, + formats: [ [ em, img ] ], + text: '\ufffc', + }, + }, + { + description: 'should create a value with image object and text before', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 2, + endContainer: element, + } ), + record: { + start: 0, + end: 5, + formats: [ , , [ em ], [ em ], [ em, img ] ], + text: 'test\ufffc', + }, + }, + { + description: 'should create a value with image object and text after', + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 2, + endContainer: element, + } ), + record: { + start: 0, + end: 5, + formats: [ [ em, img ], [ em ], [ em ], , , ], + text: '\ufffctest', + }, + }, + { + description: 'should handle br', + html: '
    ', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 0, + formats: [ , ], + text: '\n', + }, + }, + { + description: 'should handle br with text', + html: 'te
    st', + createRange: ( element ) => ( { + startOffset: 1, + startContainer: element, + endOffset: 2, + endContainer: element, + } ), + record: { + start: 2, + end: 2, + formats: [ , , , , , ], + text: 'te\nst', + }, + }, + { + description: 'should handle br with formatting', + html: '
    ', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 1, + formats: [ [ em ] ], + text: '\n', + }, + }, + { + description: 'should handle multiline value', + multilineTag: 'p', + html: '

    one

    two

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

    one

    ', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.lastChild, + endOffset: 0, + endContainer: element.lastChild, + } ), + record: { + start: 4, + end: 4, + formats: [ , , , , ], + text: 'one\u2028', + }, + }, + { + description: 'should remove with settings', + settings: { + unwrapNode: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + }, + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should remove br with settings', + settings: { + unwrapNode: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + }, + html: '
    ', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should unwrap with settings', + settings: { + unwrapNode: ( node ) => !! node.getAttribute( 'data-mce-bogus' ), + }, + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 4, + formats: [ , , [ em ], [ em ] ], + text: 'test', + }, + }, + { + description: 'should remove with children with settings', + settings: { + removeNode: ( node ) => node.getAttribute( 'data-mce-bogus' ) === 'all', + }, + html: 'onetwo', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element.lastChild, + endOffset: 1, + endContainer: element.lastChild, + } ), + record: { + start: 0, + end: 1, + formats: [ , , , ], + text: 'two', + }, + }, + { + description: 'should filter format attributes with settings', + settings: { + removeAttribute: ( attribute ) => attribute.indexOf( 'data-mce-' ) === 0, + }, + html: 'test', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 4, + formats: [ [ strong ], [ strong ], [ strong ], [ strong ] ], + text: 'test', + }, + }, + { + description: 'should filter text with settings', + settings: { + filterString: ( string ) => string.replace( '\uFEFF', '' ), + }, + html: '', + createRange: ( element ) => ( { + startOffset: 0, + startContainer: element, + endOffset: 1, + endContainer: element, + } ), + record: { + start: 0, + end: 0, + formats: [], + text: '', + }, + }, + { + description: 'should filter text at end with settings', + settings: { + filterString: ( string ) => string.replace( '\uFEFF', '' ), + }, + html: 'test', + createRange: ( element ) => ( { + startOffset: 4, + startContainer: element.firstChild, + endOffset: 4, + endContainer: element.firstChild, + } ), + record: { + start: 4, + end: 4, + formats: [ , , , , ], + text: 'test', + }, + }, + { + description: 'should filter text in format with settings', + settings: { + filterString: ( string ) => string.replace( '\uFEFF', '' ), + }, + html: 'test', + createRange: ( element ) => ( { + startOffset: 5, + startContainer: element.querySelector( 'em' ).firstChild, + endOffset: 5, + endContainer: element.querySelector( 'em' ).firstChild, + } ), + record: { + start: 4, + end: 4, + formats: [ [ em ], [ em ], [ em ], [ em ] ], + text: 'test', + }, + }, + { + description: 'should filter text outside format with settings', + settings: { + filterString: ( string ) => string.replace( '\uFEFF', '' ), + }, + html: 'test', + createRange: ( element ) => ( { + startOffset: 1, + startContainer: element.lastChild, + endOffset: 1, + endContainer: element.lastChild, + } ), + record: { + start: 4, + end: 4, + formats: [ [ em ], [ em ], [ em ], [ em ] ], + text: 'test', + }, + }, + ]; + + spec.forEach( ( { description, multilineTag, settings, html, createRange, record } ) => { + it( description, () => { + const element = createElement( html ); + const range = createRange( element ); + const createdRecord = create( { element, range, multilineTag, ...settings } ); + const formatsLength = getSparseArrayLength( record.formats ); + const createdFormatsLength = getSparseArrayLength( createdRecord.formats ); + + expect( createdRecord ).toEqual( record ); + expect( createdFormatsLength ).toEqual( formatsLength ); + } ); + } ); + + it( 'should reference formats', () => { + const value = create( { html: 'test' } ); + + expect( value ).toEqual( { + formats: [ [ em ], [ em ], [ em, strong ], [ em, strong ] ], + text: 'test', + } ); + + expect( value.formats[ 0 ][ 0 ] ).toBe( value.formats[ 1 ][ 0 ] ); + expect( value.formats[ 0 ][ 0 ] ).toBe( value.formats[ 2 ][ 0 ] ); + expect( value.formats[ 2 ][ 1 ] ).toBe( value.formats[ 3 ][ 1 ] ); + } ); + + it( 'should use same reference for equal format', () => { + const value = create( { html: 'aa' } ); + expect( value.formats[ 0 ][ 0 ] ).toBe( value.formats[ 1 ][ 0 ] ); + } ); + + it( 'should use different reference for different format', () => { + const value = create( { html: 'aa' } ); + expect( value.formats[ 0 ][ 0 ] ).not.toBe( value.formats[ 1 ][ 0 ] ); + } ); +} ); diff --git a/packages/rich-text/src/test/get-active-format.js b/packages/rich-text/src/test/get-active-format.js new file mode 100644 index 00000000000000..bce78080409902 --- /dev/null +++ b/packages/rich-text/src/test/get-active-format.js @@ -0,0 +1,31 @@ +/** + * Internal dependencies + */ + +import { getActiveFormat } from '../get-active-format'; + +describe( 'getActiveFormat', () => { + const em = { type: 'em' }; + + it( 'should get format by selection', () => { + const record = { + formats: [ [ em ], , , ], + text: 'one', + start: 0, + end: 0, + }; + + expect( getActiveFormat( record, 'em' ) ).toEqual( em ); + } ); + + it( 'should get format by selection using the start', () => { + const record = { + formats: [ [ em ], , [ em ] ], + text: 'one', + start: 1, + end: 1, + }; + + expect( getActiveFormat( record, 'em' ) ).toBe( undefined ); + } ); +} ); diff --git a/packages/rich-text/src/test/helpers/index.js b/packages/rich-text/src/test/helpers/index.js new file mode 100644 index 00000000000000..836641c8af015d --- /dev/null +++ b/packages/rich-text/src/test/helpers/index.js @@ -0,0 +1,3 @@ +export function getSparseArrayLength( array ) { + return array.reduce( ( i ) => i + 1, 0 ); +} diff --git a/packages/rich-text/src/test/insert.js b/packages/rich-text/src/test/insert.js new file mode 100644 index 00000000000000..64de9f44828a23 --- /dev/null +++ b/packages/rich-text/src/test/insert.js @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { insert } from '../insert'; +import { getSparseArrayLength } from './helpers'; + +describe( 'insert', () => { + const em = { type: 'em' }; + const strong = { type: 'strong' }; + + it( 'should delete and insert', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const toInsert = { + formats: [ [ strong ] ], + text: 'a', + }; + const expected = { + formats: [ , , [ strong ], [ em ], , , , , , , ], + text: 'onao three', + start: 3, + end: 3, + }; + const result = insert( deepFreeze( record ), toInsert, 2, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); + + it( 'should insert line break with selection', () => { + const record = { + formats: [ , , ], + text: 'tt', + start: 1, + end: 1, + }; + const toInsert = { + formats: [ , ], + text: '\n', + }; + const expected = { + formats: [ , , , ], + text: 't\nt', + start: 2, + end: 2, + }; + const result = insert( deepFreeze( record ), toInsert ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); +} ); diff --git a/packages/rich-text/src/test/is-collapsed.js b/packages/rich-text/src/test/is-collapsed.js new file mode 100644 index 00000000000000..e32ef447fea526 --- /dev/null +++ b/packages/rich-text/src/test/is-collapsed.js @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ + +import { isCollapsed } from '../is-collapsed'; + +describe( 'isCollapsed', () => { + it( 'should return true for a collapsed selection', () => { + const record = { + start: 4, + end: 4, + }; + + expect( isCollapsed( record ) ).toBe( true ); + } ); +} ); diff --git a/packages/rich-text/src/test/is-empty.js b/packages/rich-text/src/test/is-empty.js new file mode 100644 index 00000000000000..0080ba3a0914fb --- /dev/null +++ b/packages/rich-text/src/test/is-empty.js @@ -0,0 +1,84 @@ +/** + * Internal dependencies + */ + +import { isEmpty, isEmptyLine } from '../is-empty'; + +describe( 'isEmpty', () => { + it( 'should return true', () => { + const one = { + formats: [], + text: '', + }; + + expect( isEmpty( one ) ).toBe( true ); + } ); + + it( 'should return false', () => { + const one = { + formats: [], + text: 'test', + }; + + expect( isEmpty( one ) ).toBe( false ); + } ); +} ); + +describe( 'isEmptyLine', () => { + it( 'should return true', () => { + const one = { + formats: [], + text: '', + start: 0, + end: 0, + }; + const two = { + formats: [ , , ], + text: '\u2028', + start: 0, + end: 0, + }; + const three = { + formats: [ , , ], + text: '\u2028', + start: 1, + end: 1, + }; + const four = { + formats: [ , , , , ], + text: '\u2028\u2028', + start: 1, + end: 1, + }; + const five = { + formats: [ , , , , ], + text: 'a\u2028\u2028b', + start: 2, + end: 2, + }; + + expect( isEmptyLine( one ) ).toBe( true ); + expect( isEmptyLine( two ) ).toBe( true ); + expect( isEmptyLine( three ) ).toBe( true ); + expect( isEmptyLine( four ) ).toBe( true ); + expect( isEmptyLine( five ) ).toBe( true ); + } ); + + it( 'should return false', () => { + const one = { + formats: [ , , , , ], + text: '\u2028a\u2028', + start: 1, + end: 1, + }; + const two = { + formats: [ , , , , ], + text: '\u2028\n', + start: 1, + end: 1, + }; + + expect( isEmptyLine( one ) ).toBe( false ); + expect( isEmptyLine( two ) ).toBe( false ); + } ); +} ); diff --git a/packages/rich-text/src/test/is-format-equal.js b/packages/rich-text/src/test/is-format-equal.js new file mode 100644 index 00000000000000..baad9a435c6180 --- /dev/null +++ b/packages/rich-text/src/test/is-format-equal.js @@ -0,0 +1,70 @@ +/** + * Internal dependencies + */ + +import { isFormatEqual } from '../is-format-equal'; + +describe( 'isFormatEqual', () => { + const spec = [ + { + format1: undefined, + format2: undefined, + isEqual: true, + description: 'should return true if both are undefined', + }, + { + format1: {}, + format2: undefined, + isEqual: false, + description: 'should return false if one is undefined', + }, + { + format1: { type: 'bold' }, + format2: { type: 'bold' }, + isEqual: true, + description: 'should return true if both have same type', + }, + { + format1: { type: 'bold' }, + format2: { type: 'italic' }, + isEqual: false, + description: 'should return false if one has different type', + }, + { + format1: { type: 'bold', attributes: {} }, + format2: { type: 'bold' }, + isEqual: false, + description: 'should return false if one has undefined attributes', + }, + { + format1: { type: 'bold', attributes: { a: '1' } }, + format2: { type: 'bold', attributes: { a: '1' } }, + isEqual: true, + description: 'should return true if both have same attributes', + }, + { + format1: { type: 'bold', attributes: { a: '1' } }, + format2: { type: 'bold', attributes: { b: '1' } }, + isEqual: false, + description: 'should return false if one has different attributes', + }, + { + format1: { type: 'bold', attributes: { a: '1' } }, + format2: { type: 'bold', attributes: { a: '1', b: '1' } }, + isEqual: false, + description: 'should return false if one has a different amount of attributes', + }, + { + format1: { type: 'bold', attributes: { b: '1', a: '1' } }, + format2: { type: 'bold', attributes: { a: '1', b: '1' } }, + isEqual: true, + description: 'should return true both have same attributes but different order', + }, + ]; + + spec.forEach( ( { format1, format2, isEqual, description } ) => { + it( description, () => { + expect( isFormatEqual( format1, format2 ) ).toBe( isEqual ); + } ); + } ); +} ); diff --git a/packages/rich-text/src/test/join.js b/packages/rich-text/src/test/join.js new file mode 100644 index 00000000000000..fb2f20b1b2784e --- /dev/null +++ b/packages/rich-text/src/test/join.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { join } from '../join'; +import { getSparseArrayLength } from './helpers'; + +describe( 'join', () => { + const em = { type: 'em' }; + const separators = [ + ' ', + { + text: ' ', + formats: [ , ], + }, + ]; + + separators.forEach( ( separator ) => { + it( 'should join records with string separator', () => { + const one = { + formats: [ , , [ em ] ], + text: 'one', + }; + const two = { + formats: [ [ em ], , , ], + text: 'two', + }; + const three = { + formats: [ , , [ em ], , [ em ], , , ], + text: 'one two', + }; + const result = join( [ deepFreeze( one ), deepFreeze( two ) ], separator ); + + expect( result ).not.toBe( one ); + expect( result ).not.toBe( two ); + expect( result ).toEqual( three ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); + } ); +} ); diff --git a/packages/rich-text/src/test/normalise-formats.js b/packages/rich-text/src/test/normalise-formats.js new file mode 100644 index 00000000000000..8984bcb17771b8 --- /dev/null +++ b/packages/rich-text/src/test/normalise-formats.js @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { normaliseFormats } from '../normalise-formats'; +import { getSparseArrayLength } from './helpers'; + +describe( 'normaliseFormats', () => { + const strong = { type: 'strong' }; + const em = { type: 'em' }; + + it( 'should normalise formats', () => { + const record = { + formats: [ , [ em ], [ { ...em }, { ...strong } ], [ em, strong ] ], + text: 'one two three', + }; + const result = normaliseFormats( deepFreeze( record ) ); + + expect( result ).toEqual( record ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 3 ); + expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 2 ][ 0 ] ); + expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 3 ][ 0 ] ); + expect( result.formats[ 2 ][ 1 ] ).toBe( result.formats[ 3 ][ 1 ] ); + } ); +} ); diff --git a/packages/rich-text/src/test/remove-format.js b/packages/rich-text/src/test/remove-format.js new file mode 100644 index 00000000000000..4d6b10f0d27fbe --- /dev/null +++ b/packages/rich-text/src/test/remove-format.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { removeFormat } from '../remove-format'; +import { getSparseArrayLength } from './helpers'; + +describe( 'removeFormat', () => { + const strong = { type: 'strong' }; + const em = { type: 'em' }; + + it( 'should remove format', () => { + const record = { + formats: [ , , , [ strong ], [ em, strong ], [ em, strong ], [ em ], , , , , , , ], + text: 'one two three', + }; + const expected = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + }; + const result = removeFormat( deepFreeze( record ), 'strong', 3, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 3 ); + } ); + + it( 'should remove format for collased selection', () => { + const record = { + formats: [ , , , [ strong ], [ em, strong ], [ em, strong ], [ em ], , , , , , , ], + text: 'one two three', + }; + const expected = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + }; + const result = removeFormat( deepFreeze( record ), 'strong', 4, 4 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 3 ); + } ); +} ); diff --git a/packages/rich-text/src/test/replace.js b/packages/rich-text/src/test/replace.js new file mode 100644 index 00000000000000..f3c7d9aa923e9f --- /dev/null +++ b/packages/rich-text/src/test/replace.js @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { replace } from '../replace'; +import { getSparseArrayLength } from './helpers'; + +describe( 'replace', () => { + const em = { type: 'em' }; + + it( 'should replace string to string', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const expected = { + formats: [ , , , , [ em ], , , , , , , ], + text: 'one 2 three', + start: 5, + end: 5, + }; + const result = replace( deepFreeze( record ), 'two', '2' ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 1 ); + } ); + + it( 'should replace string to record', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const replacement = { + formats: [ , ], + text: '2', + }; + const expected = { + formats: [ , , , , , , , , , , , ], + text: 'one 2 three', + start: 5, + end: 5, + }; + const result = replace( deepFreeze( record ), 'two', replacement ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); + + it( 'should replace string to function', () => { + const record = { + formats: [ , , , , , , , , , , , , ], + text: 'abc12345#$*%', + start: 6, + end: 6, + }; + const expected = { + formats: [ , , , , , , , , , , , , , , , , , , ], + text: 'abc - 12345 - #$*%', + start: 18, + end: 18, + }; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace + const result = replace( deepFreeze( record ), /([^\d]*)(\d*)([^\w]*)/, ( match, p1, p2, p3 ) => { + return [ p1, p2, p3 ].join( ' - ' ); + } ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); +} ); diff --git a/packages/rich-text/src/test/slice.js b/packages/rich-text/src/test/slice.js new file mode 100644 index 00000000000000..9d40f7a6de0377 --- /dev/null +++ b/packages/rich-text/src/test/slice.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { slice } from '../slice'; +import { getSparseArrayLength } from './helpers'; + +describe( 'slice', () => { + const em = { type: 'em' }; + + it( 'should slice', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + }; + const expected = { + formats: [ , [ em ], [ em ] ], + text: ' tw', + }; + const result = slice( deepFreeze( record ), 3, 6 ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); + + it( 'should slice record', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + start: 3, + end: 6, + }; + const expected = { + formats: [ , [ em ], [ em ] ], + text: ' tw', + }; + const result = slice( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + expect( result ).not.toBe( record ); + expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); + } ); +} ); diff --git a/packages/rich-text/src/test/split.js b/packages/rich-text/src/test/split.js new file mode 100644 index 00000000000000..1cdfbae9630d23 --- /dev/null +++ b/packages/rich-text/src/test/split.js @@ -0,0 +1,209 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ + +import { split } from '../split'; +import { getSparseArrayLength } from './helpers'; + +describe( 'split', () => { + const em = { type: 'em' }; + + it( 'should split', () => { + const record = { + start: 5, + end: 10, + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + }; + const expected = [ + { + formats: [ , , , , [ em ], [ em ] ], + text: 'one tw', + }, + { + start: 0, + end: 0, + formats: [ [ em ], , , , , , , ], + text: 'o three', + }, + ]; + const result = split( deepFreeze( record ), 6, 6 ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); + + it( 'should split with selection', () => { + const record = { + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + start: 6, + end: 6, + }; + const expected = [ + { + formats: [ , , , , [ em ], [ em ] ], + text: 'one tw', + }, + { + formats: [ [ em ], , , , , , , ], + text: 'o three', + start: 0, + end: 0, + }, + ]; + const result = split( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); + + it( 'should split empty', () => { + const record = { + formats: [], + text: '', + start: 0, + end: 0, + }; + const expected = [ + { + formats: [], + text: '', + }, + { + formats: [], + text: '', + start: 0, + end: 0, + }, + ]; + const result = split( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); + + it( 'should split multiline', () => { + const record = { + formats: [ , , , , , , , , , , ], + text: 'test\u2028\u2028test', + start: 5, + end: 5, + }; + const expected = [ + { + formats: [ , , , , ], + text: 'test', + }, + { + formats: [ , , , , ], + text: 'test', + start: 0, + end: 0, + }, + ]; + const result = split( deepFreeze( record ) ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); + + it( 'should split search', () => { + const record = { + start: 6, + end: 16, + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , , , , , , , , , , , ], + text: 'one two three four five', + }; + const expected = [ + { + formats: [ , , , ], + text: 'one', + }, + { + start: 2, + end: 3, + formats: [ [ em ], [ em ], [ em ] ], + text: 'two', + }, + { + start: 0, + end: 5, + formats: [ , , , , , ], + text: 'three', + }, + { + start: 0, + end: 2, + formats: [ , , , , ], + text: 'four', + }, + { + formats: [ , , , , ], + text: 'five', + }, + ]; + const result = split( deepFreeze( record ), ' ' ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); + + it( 'should split search 2', () => { + const record = { + start: 5, + end: 6, + formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ], + text: 'one two three', + }; + const expected = [ + { + formats: [ , , , ], + text: 'one', + }, + { + start: 1, + end: 2, + formats: [ [ em ], [ em ], [ em ] ], + text: 'two', + }, + { + formats: [ , , , , , ], + text: 'three', + }, + ]; + const result = split( deepFreeze( record ), ' ' ); + + expect( result ).toEqual( expected ); + result.forEach( ( item, index ) => { + expect( item ).not.toBe( record ); + expect( getSparseArrayLength( item.formats ) ) + .toBe( getSparseArrayLength( expected[ index ].formats ) ); + } ); + } ); +} ); diff --git a/packages/rich-text/src/test/to-dom.js b/packages/rich-text/src/test/to-dom.js new file mode 100644 index 00000000000000..b641085831980f --- /dev/null +++ b/packages/rich-text/src/test/to-dom.js @@ -0,0 +1,193 @@ +/** + * External dependencies + */ + +import { JSDOM } from 'jsdom'; + +/** + * Internal dependencies + */ + +import { create } from '../create'; +import { toDom, applyValue } from '../to-dom'; + +const { window } = new JSDOM(); +const { document } = window; + +function createNode( HTML ) { + const doc = document.implementation.createHTMLDocument( '' ); + doc.body.innerHTML = HTML; + return doc.body.firstChild; +} + +function createElement( html ) { + const htmlDocument = document.implementation.createHTMLDocument( '' ); + htmlDocument.body.innerHTML = html; + return htmlDocument.body; +} + +describe( 'recordToDom', () => { + it( 'should extract recreate HTML 1', () => { + const HTML = 'one two 🍒 three'; + const element = createNode( `

    ${ HTML }

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

    ${ HTML }

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

    ${ HTML }

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

    ${ HTML }

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

    ${ HTML }

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

    ${ HTML }

    ` ); + const range = { + startOffset: 1, + startContainer: element, + endOffset: 2, + endContainer: element, + }; + const { body, selection } = toDom( create( { element, range } ) ); + + expect( body.innerHTML ).toEqual( element.innerHTML ); + expect( selection ).toEqual( { + startPath: [ 0, 5 ], + endPath: [ 1, 0, 6 ], + } ); + } ); + + it( 'should extract recreate HTML 6', () => { + const HTML = '
  • one
    • two
  • three
  • '; + const element = createNode( `
      ${ HTML }
    ` ); + const range = { + startOffset: 1, + startContainer: element.querySelector( 'li' ).firstChild, + endOffset: 2, + endContainer: element.querySelector( 'li' ).firstChild, + }; + const multilineTag = 'li'; + const { body, selection } = toDom( create( { element, range, multilineTag } ), 'li' ); + + expect( body.innerHTML ).toEqual( element.innerHTML ); + expect( selection ).toEqual( { + startPath: [ 0, 0, 1 ], + endPath: [ 0, 0, 2 ], + } ); + } ); +} ); + +describe( 'applyValue', () => { + const cases = [ + { + current: 'test', + future: '', + movedCount: 0, + description: 'should remove nodes', + }, + { + current: '', + future: 'test', + movedCount: 1, + description: 'should add nodes', + }, + { + current: 'test', + future: 'test', + movedCount: 0, + description: 'should not modify', + }, + ]; + + cases.forEach( ( { current, future, description, movedCount } ) => { + it( description, () => { + const body = createElement( current ); + const futureBody = createElement( future ); + const childNodes = Array.from( futureBody.childNodes ); + applyValue( futureBody, body ); + const count = childNodes.reduce( ( acc, { parentNode } ) => { + return parentNode === body ? acc + 1 : acc; + }, 0 ); + expect( body.innerHTML ).toEqual( future ); + expect( count ).toEqual( movedCount ); + } ); + } ); +} ); diff --git a/packages/rich-text/src/test/to-html-string.js b/packages/rich-text/src/test/to-html-string.js new file mode 100644 index 00000000000000..a65c3cb772447d --- /dev/null +++ b/packages/rich-text/src/test/to-html-string.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ + +import { JSDOM } from 'jsdom'; + +/** + * Internal dependencies + */ + +import { create } from '../create'; +import { toHTMLString } from '../to-html-string'; + +const { window } = new JSDOM(); +const { document } = window; + +function createNode( HTML ) { + const doc = document.implementation.createHTMLDocument( '' ); + doc.body.innerHTML = HTML; + return doc.body.firstChild; +} + +describe( 'toHTMLString', () => { + it( 'should extract recreate HTML 1', () => { + const HTML = 'one two 🍒 three'; + const element = createNode( `

    ${ HTML }

    ` ); + + expect( toHTMLString( create( { element } ) ) ).toEqual( HTML ); + } ); + + it( 'should extract recreate HTML 2', () => { + const HTML = 'one two 🍒 test three'; + const element = createNode( `

    ${ HTML }

    ` ); + + expect( toHTMLString( create( { element } ) ) ).toEqual( HTML ); + } ); + + it( 'should extract recreate HTML 3', () => { + const HTML = ''; + const element = createNode( `

    ${ HTML }

    ` ); + + expect( toHTMLString( create( { element } ) ) ).toEqual( HTML ); + } ); + + it( 'should extract recreate HTML 4', () => { + const HTML = 'two 🍒'; + const element = createNode( `

    ${ HTML }

    ` ); + + expect( toHTMLString( create( { element } ) ) ).toEqual( HTML ); + } ); + + it( 'should extract recreate HTML 5', () => { + const HTML = 'If you want to learn more about how to build additional blocks, or if you are interested in helping with the project, head over to the GitHub repository.'; + const element = createNode( `

    ${ HTML }

    ` ); + + expect( toHTMLString( create( { element } ) ) ).toEqual( HTML ); + } ); + + it( 'should extract recreate HTML 6', () => { + const HTML = '
  • one
    • two
  • three
  • '; + const element = createNode( `
      ${ HTML }
    ` ); + const multilineTag = 'li'; + + expect( toHTMLString( create( { element, multilineTag } ), 'li' ) ).toEqual( HTML ); + } ); + + it( 'should serialize neighbouring formats of same type', () => { + const HTML = 'aa'; + const element = createNode( `

    ${ HTML }

    ` ); + + expect( toHTMLString( create( { element } ) ) ).toEqual( HTML ); + } ); + + it( 'should serialize neighbouring same formats', () => { + const HTML = 'aa'; + const element = createNode( `

    ${ HTML }

    ` ); + const expectedHTML = 'aa'; + + expect( toHTMLString( create( { element } ) ) ).toEqual( expectedHTML ); + } ); +} ); diff --git a/packages/rich-text/src/to-dom.js b/packages/rich-text/src/to-dom.js new file mode 100644 index 00000000000000..ec8c5a610b4323 --- /dev/null +++ b/packages/rich-text/src/to-dom.js @@ -0,0 +1,230 @@ +/** + * Internal dependencies + */ + +import { toTree } from './to-tree'; + +/** + * Browser dependencies + */ + +const { TEXT_NODE, ELEMENT_NODE } = window.Node; + +/** + * Creates a path as an array of indices from the given root node to the given + * node. + * + * @param {Node} node Node to find the path of. + * @param {HTMLElement} rootNode Root node to find the path from. + * @param {Array} path Initial path to build on. + * + * @return {Array} The path from the root node to the node. + */ +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; +} + +/** + * Gets a node given a path (array of indices) from the given node. + * + * @param {HTMLElement} node Root node to find the wanted node in. + * @param {Array} path Path (indices) to the wanted node. + * + * @return {Object} Object with the found node and the remaining offset (if any). + */ +function getNodeByPath( node, path ) { + path = [ ...path ]; + + while ( node && path.length > 1 ) { + node = node.childNodes[ path.shift() ]; + } + + return { + node, + offset: path[ 0 ], + }; +} + +function createEmpty( type ) { + const { body } = document.implementation.createHTMLDocument( '' ); + + if ( type ) { + return body.appendChild( body.ownerDocument.createElement( type ) ); + } + + return body; +} + +function append( element, child ) { + if ( typeof child === 'string' ) { + child = element.ownerDocument.createTextNode( child ); + } + + const { type, attributes } = child; + + if ( type ) { + child = element.ownerDocument.createElement( type ); + + for ( const key in attributes ) { + child.setAttribute( key, attributes[ key ] ); + } + } + + return element.appendChild( child ); +} + +function appendText( node, text ) { + node.appendData( text ); +} + +function getLastChild( { lastChild } ) { + return lastChild; +} + +function getParent( { parentNode } ) { + return parentNode; +} + +function isText( { nodeType } ) { + return nodeType === TEXT_NODE; +} + +function getText( { nodeValue } ) { + return nodeValue; +} + +function remove( node ) { + return node.parentNode.removeChild( node ); +} + +export function toDom( value, multilineTag ) { + let startPath = []; + let endPath = []; + + const tree = toTree( value, multilineTag, { + createEmpty, + append, + getLastChild, + getParent, + isText, + getText, + remove, + appendText, + onStartIndex( body, pointer, multilineIndex ) { + startPath = createPathToNode( pointer, body, [ pointer.nodeValue.length ] ); + + if ( multilineIndex !== undefined ) { + startPath = [ multilineIndex, ...startPath ]; + } + }, + onEndIndex( body, pointer, multilineIndex ) { + endPath = createPathToNode( pointer, body, [ pointer.nodeValue.length ] ); + + if ( multilineIndex !== undefined ) { + endPath = [ multilineIndex, ...endPath ]; + } + }, + } ); + + return { + body: tree, + selection: { startPath, endPath }, + }; +} + +/** + * Create an `Element` tree from a Rich Text value and applies the difference to + * the `Element` tree contained by `current`. If a `multilineTag` is provided, + * text separated by two new lines will be wrapped in an `Element` of that type. + * + * @param {Object} value Value to apply. + * @param {HTMLElement} current The live root node to apply the element + * tree to. + * @param {string} multilineTag Multiline tag. + */ +export function apply( value, current, multilineTag ) { + // Construct a new element tree in memory. + const { body, selection } = toDom( value, multilineTag ); + + applyValue( body, current ); + + if ( value.start !== undefined ) { + applySelection( selection, current ); + } +} + +export function applyValue( future, current ) { + 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 ] ); + } +} + +export function applySelection( selection, current ) { + const { node: startContainer, offset: startOffset } = getNodeByPath( current, selection.startPath ); + const { node: endContainer, offset: endOffset } = getNodeByPath( current, selection.endPath ); + + const windowSelection = 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 if ( + collapsed && + startOffset === 0 && + startContainer === TEXT_NODE && + startContainer.nodeValue.length === 0 + ) { + startContainer.insertData( 0, '\uFEFF' ); + range.setStart( startContainer, 1 ); + range.setEnd( endContainer, 1 ); + } else { + range.setStart( startContainer, startOffset ); + range.setEnd( endContainer, endOffset ); + } + + windowSelection.removeAllRanges(); + windowSelection.addRange( range ); +} diff --git a/packages/rich-text/src/to-html-string.js b/packages/rich-text/src/to-html-string.js new file mode 100644 index 00000000000000..14dff51f04ee8f --- /dev/null +++ b/packages/rich-text/src/to-html-string.js @@ -0,0 +1,100 @@ +/** + * Internal dependencies + */ + +import { escapeHTML, escapeAttribute } from '@wordpress/escape-html'; + +/** + * Internal dependencies + */ + +import { toTree } from './to-tree'; + +/** + * Create an HTML string from a Rich Text value. If a `multilineTag` is + * provided, text separated by two new lines will be wrapped in it. + * + * @param {Object} value Rich text value. + * @param {string} multilineTag Multiline tag. + * + * @return {string} HTML string. + */ +export function toHTMLString( value, multilineTag ) { + const tree = toTree( value, multilineTag, { + createEmpty, + append, + getLastChild, + getParent, + isText, + getText, + remove, + appendText, + } ); + + return createChildrenHTML( tree.children ); +} + +function createEmpty( type ) { + return { type }; +} + +function getLastChild( { children } ) { + return children && children[ children.length - 1 ]; +} + +function append( parent, object ) { + if ( typeof object === 'string' ) { + object = { text: object }; + } + + object.parent = parent; + parent.children = parent.children || []; + parent.children.push( object ); + return object; +} + +function appendText( object, text ) { + object.text += text; +} + +function getParent( { parent } ) { + return parent; +} + +function isText( { text } ) { + return typeof text === 'string'; +} + +function getText( { text } ) { + return text; +} + +function remove( object ) { + const index = object.parent.children.indexOf( object ); + + if ( index !== -1 ) { + object.parent.children.splice( index, 1 ); + } + + return object; +} + +function createElementHTML( { type, attributes, object, children } ) { + let attributeString = ''; + + for ( const key in attributes ) { + attributeString += ` ${ key }="${ escapeAttribute( attributes[ key ] ) }"`; + } + + if ( object ) { + return `<${ type }${ attributeString }>`; + } + + return `<${ type }${ attributeString }>${ createChildrenHTML( children ) }`; +} + +function createChildrenHTML( children = [] ) { + return children.map( ( child ) => { + return child.text === undefined ? createElementHTML( child ) : escapeHTML( child.text ); + } ).join( '' ); +} diff --git a/packages/rich-text/src/to-tree.js b/packages/rich-text/src/to-tree.js new file mode 100644 index 00000000000000..8f228654c74bbb --- /dev/null +++ b/packages/rich-text/src/to-tree.js @@ -0,0 +1,93 @@ +/** + * Internal dependencies + */ + +import { split } from './split'; + +export function toTree( value, multilineTag, settings ) { + if ( multilineTag ) { + const { createEmpty, append } = settings; + const tree = createEmpty(); + + split( value, '\u2028' ).forEach( ( piece, index ) => { + append( tree, toTree( piece, null, { + ...settings, + tag: multilineTag, + multilineIndex: index, + } ) ); + } ); + + return tree; + } + + const { + tag, + multilineIndex, + createEmpty, + append, + getLastChild, + getParent, + isText, + getText, + remove, + appendText, + onStartIndex, + onEndIndex, + } = settings; + const { formats, text, start, end } = value; + const formatsLength = formats.length + 1; + const tree = createEmpty( tag ); + + append( tree, '' ); + + for ( let i = 0; i < formatsLength; i++ ) { + const character = text.charAt( i ); + const characterFormats = formats[ i ]; + const lastCharacterFormats = formats[ i - 1 ]; + + let pointer = getLastChild( tree ); + + if ( characterFormats ) { + characterFormats.forEach( ( format, formatIndex ) => { + if ( + pointer && + lastCharacterFormats && + format === lastCharacterFormats[ formatIndex ] + ) { + pointer = getLastChild( pointer ); + return; + } + + const { type, attributes, object } = format; + const parent = getParent( pointer ); + const newNode = append( parent, { type, attributes, object } ); + + if ( isText( pointer ) && getText( pointer ).length === 0 ) { + remove( pointer ); + } + + pointer = append( object ? parent : newNode, '' ); + } ); + } + + if ( character !== '\ufffc' ) { + if ( character === '\n' ) { + pointer = append( getParent( pointer ), { type: 'br', object: true } ); + } else if ( ! isText( pointer ) ) { + pointer = append( getParent( pointer ), character ); + } else { + appendText( pointer, character ); + } + } + + if ( onStartIndex && start === i + 1 ) { + onStartIndex( tree, pointer, multilineIndex ); + } + + if ( onEndIndex && end === i + 1 ) { + onEndIndex( tree, pointer, multilineIndex ); + } + } + + return tree; +} diff --git a/test/e2e/specs/__snapshots__/adding-blocks.test.js.snap b/test/e2e/specs/__snapshots__/adding-blocks.test.js.snap index eaf8254a78cc85..30b83349a7030a 100644 --- a/test/e2e/specs/__snapshots__/adding-blocks.test.js.snap +++ b/test/e2e/specs/__snapshots__/adding-blocks.test.js.snap @@ -42,7 +42,7 @@ exports[`adding blocks Should insert content using the placeholder and the regul -
    Pre text

    Foo
    +
    Pre text

    Foo
    diff --git a/test/e2e/specs/__snapshots__/deprecated-node-matcher.test.js.snap b/test/e2e/specs/__snapshots__/deprecated-node-matcher.test.js.snap new file mode 100644 index 00000000000000..7ceacf3ab3ee69 --- /dev/null +++ b/test/e2e/specs/__snapshots__/deprecated-node-matcher.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Deprecated Node Matcher should insert block with children source 1`] = ` +" +

    test
    test

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

    test

    +" +`; diff --git a/test/e2e/specs/__snapshots__/templates.test.js.snap b/test/e2e/specs/__snapshots__/templates.test.js.snap index 57e3f63acf6743..52f4469dddeeae 100644 --- a/test/e2e/specs/__snapshots__/templates.test.js.snap +++ b/test/e2e/specs/__snapshots__/templates.test.js.snap @@ -10,7 +10,7 @@ exports[`templates Using a CPT with a predefined template Should add a custom po -
    +

    @@ -36,7 +36,7 @@ exports[`templates Using a CPT with a predefined template Should respect user ed -
    +

    diff --git a/test/e2e/specs/__snapshots__/writing-flow.test.js.snap b/test/e2e/specs/__snapshots__/writing-flow.test.js.snap index 5dc0ca53857853..70d32a6440c4b9 100644 --- a/test/e2e/specs/__snapshots__/writing-flow.test.js.snap +++ b/test/e2e/specs/__snapshots__/writing-flow.test.js.snap @@ -24,11 +24,7 @@ exports[`adding blocks Should navigate inner blocks with arrow keys 1`] = ` " `; -exports[`adding blocks should clean TinyMCE content 1`] = ` -" -

    -" -`; +exports[`adding blocks should clean TinyMCE content 1`] = `""`; exports[`adding blocks should clean TinyMCE content 2`] = ` " diff --git a/test/e2e/specs/adding-inline-tokens.test.js b/test/e2e/specs/adding-inline-tokens.test.js index 0e2e54e62fb0d8..00fa8c45869d3c 100644 --- a/test/e2e/specs/adding-inline-tokens.test.js +++ b/test/e2e/specs/adding-inline-tokens.test.js @@ -21,7 +21,7 @@ describe( 'adding inline tokens', () => { await newPost(); } ); - it( 'Should insert inline image', async () => { + it( 'should insert inline image', async () => { // Create a paragraph. await clickBlockAppender(); await page.keyboard.type( 'a ' ); @@ -38,13 +38,13 @@ describe( 'adding inline tokens', () => { await inputElement.uploadFile( tmpFileName ); // Wait for upload. - await page.waitForSelector( '.media-modal li[aria-label="' + filename + '"]' ); + await page.waitForSelector( `.media-modal li[aria-label="${ filename }"]` ); // Insert the uploaded image. await page.click( '.media-modal button.media-button-select' ); // Check the content. - const regex = new RegExp( '\\s*

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

    a\\u00A0<\\/p>\\s*` ); expect( await getEditedPostContent() ).toMatch( regex ); } ); } ); diff --git a/test/e2e/specs/deprecated-node-matcher.test.js b/test/e2e/specs/deprecated-node-matcher.test.js new file mode 100644 index 00000000000000..b78de5e4e61f5a --- /dev/null +++ b/test/e2e/specs/deprecated-node-matcher.test.js @@ -0,0 +1,43 @@ +/** + * Internal dependencies + */ +import { + newPost, + insertBlock, + getEditedPostContent, + META_KEY, + pressWithModifier, +} from '../support/utils'; +import { activatePlugin, deactivatePlugin } from '../support/plugins'; + +describe( 'Deprecated Node Matcher', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-deprecated-node-matcher' ); + } ); + + beforeEach( async () => { + await newPost(); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-deprecated-node-matcher' ); + } ); + + it( 'should insert block with node source', async () => { + await insertBlock( 'Deprecated Node Matcher' ); + await page.keyboard.type( 'test' ); + await page.keyboard.press( 'Enter' ); + expect( console ).toHaveWarned(); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should insert block with children source', async () => { + await insertBlock( 'Deprecated Children Matcher' ); + await page.keyboard.type( 'test' ); + await page.keyboard.press( 'Enter' ); + await pressWithModifier( META_KEY, 'b' ); + await page.keyboard.type( 'test' ); + expect( console ).toHaveWarned(); + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/test/e2e/specs/links.test.js b/test/e2e/specs/links.test.js index 0968805fbd49d4..cd8931709389f9 100644 --- a/test/e2e/specs/links.test.js +++ b/test/e2e/specs/links.test.js @@ -21,6 +21,10 @@ describe( 'Links', () => { await newPost(); } ); + const waitForAutoFocus = async () => { + await page.waitForFunction( () => !! document.activeElement.closest( '.editor-url-input' ) ); + }; + it( 'can be created by selecting text and clicking Link', async () => { // Create a block with some text await clickBlockAppender(); @@ -32,8 +36,8 @@ describe( 'Links', () => { // Click on the Link button await page.click( 'button[aria-label="Link"]' ); - // A placeholder link should have been inserted - expect( await page.$( 'a[data-wp-placeholder]' ) ).not.toBeNull(); + // Wait for the URL field to auto-focus + await waitForAutoFocus(); // Type a URL await page.keyboard.type( 'https://wordpress.org/gutenberg' ); @@ -41,9 +45,6 @@ describe( 'Links', () => { // Click on the Apply button await page.click( 'button[aria-label="Apply"]' ); - // There should no longer be a placeholder link - expect( await page.$( 'a[data-wp-placeholder]' ) ).toBeNull(); - // The link should have been inserted expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -59,8 +60,8 @@ describe( 'Links', () => { // Press Cmd+K to insert a link await pressWithModifier( META_KEY, 'K' ); - // A placeholder link should have been inserted - expect( await page.$( 'a[data-wp-placeholder]' ) ).not.toBeNull(); + // Wait for the URL field to auto-focus + await waitForAutoFocus(); // Type a URL await page.keyboard.type( 'https://wordpress.org/gutenberg' ); @@ -68,9 +69,6 @@ describe( 'Links', () => { // Press Enter to apply the link await page.keyboard.press( 'Enter' ); - // There should no longer be a placeholder link - expect( await page.$( 'a[data-wp-placeholder]' ) ).toBeNull(); - // The link should have been inserted expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -80,15 +78,15 @@ describe( 'Links', () => { await clickBlockAppender(); await page.keyboard.type( 'This is Gutenberg: ' ); - // Press Cmd+K to insert a link - await pressWithModifier( META_KEY, 'K' ); - // Trigger isTyping = false await page.mouse.move( 200, 300, { steps: 10 } ); await page.mouse.move( 250, 350, { steps: 10 } ); - // A placeholder link should not have been inserted - expect( await page.$( 'a[data-wp-placeholder]' ) ).toBeNull(); + // Press Cmd+K to insert a link + await pressWithModifier( META_KEY, 'K' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); // Type a URL await page.keyboard.type( 'https://wordpress.org/gutenberg' ); @@ -114,9 +112,6 @@ describe( 'Links', () => { // Click on the Link button await page.click( 'button[aria-label="Link"]' ); - // A placeholder link should not have been inserted - expect( await page.$( 'a[data-wp-placeholder]' ) ).toBeNull(); - // A link with the selected URL as its href should have been inserted expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -132,14 +127,14 @@ describe( 'Links', () => { // Click on the Link button await page.click( 'button[aria-label="Link"]' ); + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + // Type a URL await page.keyboard.type( 'https://wordpress.org/gutenberg' ); // Click somewhere else - it doesn't really matter where await page.click( '.editor-post-title' ); - - // A placeholder link should not have been inserted - expect( await page.$( 'a[data-wp-placeholder]' ) ).toBeNull(); } ); const createAndReselectLink = async () => { @@ -154,19 +149,13 @@ describe( 'Links', () => { await page.click( 'button[aria-label="Link"]' ); // Wait for the URL field to auto-focus - await page.waitForFunction( () => !! document.activeElement.closest( '.editor-url-input' ) ); + await waitForAutoFocus(); // Type a URL await page.keyboard.type( 'https://wordpress.org/gutenberg' ); // Click on the Apply button await page.click( 'button[aria-label="Apply"]' ); - - // Click somewhere else - it doesn't really matter where - await page.click( '.editor-post-title' ); - - // Select the link again - await page.click( 'a[href="https://wordpress.org/gutenberg"]' ); }; it( 'can be edited', async () => { @@ -175,6 +164,9 @@ describe( 'Links', () => { // Click on the Edit button await page.click( 'button[aria-label="Edit"]' ); + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + // Change the URL await page.keyboard.type( '/handbook' ); diff --git a/test/e2e/specs/writing-flow.test.js b/test/e2e/specs/writing-flow.test.js index cd598e1ec93b1b..dfc6bf625c7a6d 100644 --- a/test/e2e/specs/writing-flow.test.js +++ b/test/e2e/specs/writing-flow.test.js @@ -155,6 +155,7 @@ describe( 'adding blocks', () => { // Ensure no data-mce-selected. Notably, this can occur when content // is saved while typing within an inline boundary. + await clickBlockAppender(); await pressWithModifier( META_KEY, 'b' ); await page.keyboard.type( 'Inside' ); expect( await getEditedPostContent() ).toMatchSnapshot(); diff --git a/test/e2e/test-plugins/block-icons.php b/test/e2e/test-plugins/block-icons.php index c89997d093ae11..b07a1e903657e4 100644 --- a/test/e2e/test-plugins/block-icons.php +++ b/test/e2e/test-plugins/block-icons.php @@ -15,7 +15,8 @@ 'wp-element', 'wp-editor', 'wp-hooks', - 'wp-i18n' + 'wp-i18n', + 'wp-rich-text', ), filemtime( plugin_dir_path( __FILE__ ) . 'block-icons/index.js' ), true diff --git a/test/e2e/test-plugins/block-icons/index.js b/test/e2e/test-plugins/block-icons/index.js index 705b03ebfbc91b..1c351147fda3f9 100644 --- a/test/e2e/test-plugins/block-icons/index.js +++ b/test/e2e/test-plugins/block-icons/index.js @@ -1,5 +1,6 @@ ( function() { var registerBlockType = wp.blocks.registerBlockType; + var create = wp.richText.create; var el = wp.element.createElement; var InnerBlocks = wp.editor.InnerBlocks; var circle = el( 'circle', { cx: 10, cy: 10, r: 10, fill: 'red', stroke: 'blue', strokeWidth: '10' } ); @@ -18,7 +19,7 @@ allowedBlocks: [ 'core/paragraph', 'core/image' ], template: [ [ 'core/paragraph', { - content: 'TestSimpleSvgIcon', + content: create( { text: 'TestSimpleSvgIcon' } ), } ], ], } @@ -46,7 +47,7 @@ allowedBlocks: [ 'core/paragraph', 'core/image' ], template: [ [ 'core/paragraph', { - content: 'TestDashIcon', + content: create( { text: 'TestDashIcon' } ), } ], ], } @@ -76,7 +77,7 @@ allowedBlocks: [ 'core/paragraph', 'core/image' ], template: [ [ 'core/paragraph', { - content: 'TestFunctionIcon', + content: create( { text: 'TestFunctionIcon' } ), } ], ], } @@ -108,7 +109,7 @@ allowedBlocks: [ 'core/paragraph', 'core/image' ], template: [ [ 'core/paragraph', { - content: 'TestIconColors', + content: create( { text: 'TestIconColors' } ), } ], ], } @@ -139,7 +140,7 @@ allowedBlocks: [ 'core/paragraph', 'core/image' ], template: [ [ 'core/paragraph', { - content: 'TestIconColors', + content: create( { text: 'TestIconColors' } ), } ], ], } diff --git a/test/e2e/test-plugins/deprecated-node-matcher.php b/test/e2e/test-plugins/deprecated-node-matcher.php new file mode 100644 index 00000000000000..9892b688cff5ee --- /dev/null +++ b/test/e2e/test-plugins/deprecated-node-matcher.php @@ -0,0 +1,20 @@ + p', + query: { + children: { + source: 'node', + }, + }, + }, + }, + category: 'formatting', + edit: function( { attributes, setAttributes } ) { + return el( 'blockquote', {}, + el( RichText, { + multiline: 'p', + value: torichText( attributes.value ), + onChange: function( nextValue ) { + setAttributes( { + value: fromrichText( nextValue ), + } ); + }, + } ) + ); + }, + save: function( { attributes } ) { + return el( 'blockquote', {}, + el( RichText.Content, { + value: torichText( attributes.value ), + } ) + ); + }, + } ); +} )(); + diff --git a/test/e2e/test-plugins/inner-blocks-templates/index.js b/test/e2e/test-plugins/inner-blocks-templates/index.js index ce7ed69613f4d4..d4ca5d70c521d1 100644 --- a/test/e2e/test-plugins/inner-blocks-templates/index.js +++ b/test/e2e/test-plugins/inner-blocks-templates/index.js @@ -2,9 +2,13 @@ var registerBlockType = wp.blocks.registerBlockType; var el = wp.element.createElement; var InnerBlocks = wp.editor.InnerBlocks; + var create = wp.richText.create; var __ = wp.i18n.__; var TEMPLATE = [ - [ 'core/paragraph', { fontSize: 'large', content: 'Content…' } ], + [ 'core/paragraph', { + fontSize: 'large', + content: create( { text: 'Content…' } ), + } ], ]; var save = function() { diff --git a/test/integration/fixtures/apple-out.html b/test/integration/fixtures/apple-out.html index 530bf8d34140c2..7c9e3a5b59fedd 100644 --- a/test/integration/fixtures/apple-out.html +++ b/test/integration/fixtures/apple-out.html @@ -19,25 +19,7 @@ -
    -One - -Two - -Three -
    -1 - -2 - -3 -
    -I - -II - -III -
    +
    OneTwoThree
    123
    IIIIII
    diff --git a/test/integration/fixtures/evernote-out.html b/test/integration/fixtures/evernote-out.html index f9dee59c5259d3..27c8dc98ffc113 100644 --- a/test/integration/fixtures/evernote-out.html +++ b/test/integration/fixtures/evernote-out.html @@ -1,7 +1,5 @@ -

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

    +

    This is a paragraph.
    This is a link.

    @@ -17,13 +15,7 @@ -
    One -Two -Three -
    Four -Five -Six -
    +
    OneTwoThree
    FourFiveSix
    diff --git a/test/integration/fixtures/google-docs-out.html b/test/integration/fixtures/google-docs-out.html index d76b19eee24887..a8d31000403d60 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 46b1f91d82abe4..62a402c79f001c 100644 --- a/test/integration/fixtures/markdown-out.html +++ b/test/integration/fixtures/markdown-out.html @@ -7,8 +7,7 @@

    This is a heading with italic

    -

    Preserve
    -line breaks please.

    +

    Preserve
    line breaks please.

    diff --git a/test/integration/fixtures/ms-word-out.html b/test/integration/fixtures/ms-word-out.html index c53c5aaeba2e97..edf7209a4f8f3f 100644 --- a/test/integration/fixtures/ms-word-out.html +++ b/test/integration/fixtures/ms-word-out.html @@ -1,11 +1,9 @@ -

    This is a -title

    +

    This is atitle

    -

    This is a -subtitle

    +

    This is asubtitle

    @@ -29,25 +27,7 @@

    This is a heading level 2

    -
    - One - - Two - - Three -
    - 1 - - 2 - - 3 -
    - I - - II - - III -
    +
    One Two Three
    1 2 3
    I II III
    diff --git a/test/integration/fixtures/ms-word-styled-out.html b/test/integration/fixtures/ms-word-styled-out.html index 9b95440e889045..a35ee97a421636 100644 --- a/test/integration/fixtures/ms-word-styled-out.html +++ b/test/integration/fixtures/ms-word-styled-out.html @@ -1,15 +1,7 @@ -

    -Lorem -ipsum dolor sit amet, consectetur adipiscing elit  -

    +

    Loremipsum dolor sit amet, consectetur adipiscing elit 

    -

    -Lorem -ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque -aliquet hendrerit auctor. Nam lobortis, est vel lacinia tincidunt, -purus tellus vehicula ex, nec pharetra justo dui sed lorem. Nam -congue laoreet massa, quis varius est tincidunt ut.

    +

    Loremipsum dolor sit amet, consectetur adipiscing elit. Pellentesquealiquet hendrerit auctor. Nam lobortis, est vel lacinia tincidunt,purus tellus vehicula ex, nec pharetra justo dui sed lorem. Namcongue laoreet massa, quis varius est tincidunt ut.

    diff --git a/test/integration/fixtures/plain-out.html b/test/integration/fixtures/plain-out.html index bc455fd4d01201..2cebc072c71b02 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

    diff --git a/test/integration/full-content/fixtures/core__audio.json b/test/integration/full-content/fixtures/core__audio.json index 6d6e5ba5bca664..879f5f8371c9a2 100644 --- a/test/integration/full-content/fixtures/core__audio.json +++ b/test/integration/full-content/fixtures/core__audio.json @@ -5,7 +5,10 @@ "isValid": true, "attributes": { "src": "https://media.simplecast.com/episodes/audio/80564/draft-podcast-51-livePublish2.mp3", - "caption": [], + "caption": { + "formats": [], + "text": "" + }, "autoplay": false, "loop": false, "align": "right" diff --git a/test/integration/full-content/fixtures/core__button__center.json b/test/integration/full-content/fixtures/core__button__center.json index 497b74f6601668..4ccdc68fc2090f 100644 --- a/test/integration/full-content/fixtures/core__button__center.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__column.json b/test/integration/full-content/fixtures/core__column.json index 427423f08b8357..358a0d4e94a75c 100644 --- a/test/integration/full-content/fixtures/core__column.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__columns.json b/test/integration/full-content/fixtures/core__columns.json index 5b234e0980bee8..bbadafd319fb80 100644 --- a/test/integration/full-content/fixtures/core__columns.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__cover-image.json b/test/integration/full-content/fixtures/core__cover-image.json index d4e5877c330b78..1dfe4234cc147d 100644 --- a/test/integration/full-content/fixtures/core__cover-image.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__embed.json b/test/integration/full-content/fixtures/core__embed.json index f166aa82687210..11575f599e3316 100644 --- a/test/integration/full-content/fixtures/core__embed.json +++ b/test/integration/full-content/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" + }, "allowResponsive": true }, "innerBlocks": [], diff --git a/test/integration/full-content/fixtures/core__gallery.json b/test/integration/full-content/fixtures/core__gallery.json index acc930adeb1cc3..2dcebc80a03af8 100644 --- a/test/integration/full-content/fixtures/core__gallery.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__gallery__columns.json b/test/integration/full-content/fixtures/core__gallery__columns.json index 09ae7dd3b138c8..a9d27c457cb01d 100644 --- a/test/integration/full-content/fixtures/core__gallery__columns.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__heading__h2-em.json b/test/integration/full-content/fixtures/core__heading__h2-em.json index 1635da5f8efbb1..786643107f39ec 100644 --- a/test/integration/full-content/fixtures/core__heading__h2-em.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__heading__h2.json b/test/integration/full-content/fixtures/core__heading__h2.json index f50eb3b39cd55f..388f98214362da 100644 --- a/test/integration/full-content/fixtures/core__heading__h2.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__image.json b/test/integration/full-content/fixtures/core__image.json index 159ec45e1a4ca6..dd89c78069c8eb 100644 --- a/test/integration/full-content/fixtures/core__image.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__image__attachment-link.json b/test/integration/full-content/fixtures/core__image__attachment-link.json index 927d2e6699428f..3aaa94408659ad 100644 --- a/test/integration/full-content/fixtures/core__image__attachment-link.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__image__center-caption.json b/test/integration/full-content/fixtures/core__image__center-caption.json index 6ce23c75b766c8..571e6ad5a676fe 100644 --- a/test/integration/full-content/fixtures/core__image__center-caption.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__image__custom-link.json b/test/integration/full-content/fixtures/core__image__custom-link.json index 55e604712ac01e..136ae7357e6803 100644 --- a/test/integration/full-content/fixtures/core__image__custom-link.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__image__media-link.json b/test/integration/full-content/fixtures/core__image__media-link.json index 7e10bde887a5f5..6b329e94a48378 100644 --- a/test/integration/full-content/fixtures/core__image__media-link.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__list__ul.json b/test/integration/full-content/fixtures/core__list__ul.json index 6d2ba10a4cb336..56fecc822876c1 100644 --- a/test/integration/full-content/fixtures/core__list__ul.json +++ b/test/integration/full-content/fixtures/core__list__ul.json @@ -5,51 +5,224 @@ "isValid": true, "attributes": { "ordered": false, - "values": [ - { - "type": "li", - "children": [ - "Text & Headings" - ] - }, - { - "type": "li", - "children": [ - "Images & Videos" - ] - }, - { - "type": "li", - "children": [ - "Galleries" - ] - }, - { - "type": "li", - "children": [ - "Embeds, like YouTube, Tweets, or other WordPress posts." - ] - }, - { - "type": "li", - "children": [ - "Layout blocks, like Buttons, Hero Images, Separators, etc." - ] - }, - { - "type": "li", - "children": [ - "And ", + "values": { + "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, + [ { - "type": "em", - "children": [ - "Lists" - ] - }, - " like this one of course :)" - ] - } - ] + "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": "Text & Headings
Images & Videos
Galleries
Embeds, like YouTube, Tweets, or other WordPress posts.
Layout blocks, like Buttons, Hero Images, Separators, etc.
And Lists like this one of course :)" + } }, "innerBlocks": [], "originalContent": "
    • Text & Headings
    • Images & Videos
    • Galleries
    • Embeds, like YouTube, Tweets, or other WordPress posts.
    • Layout blocks, like Buttons, Hero Images, Separators, etc.
    • And Lists like this one of course :)
    " diff --git a/test/integration/full-content/fixtures/core__paragraph__align-right.json b/test/integration/full-content/fixtures/core__paragraph__align-right.json index 9d33128ef3af0e..cc4e7274557bde 100644 --- a/test/integration/full-content/fixtures/core__paragraph__align-right.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__paragraph__deprecated.json b/test/integration/full-content/fixtures/core__paragraph__deprecated.json index 4279147742265d..daf5da7008a224 100644 --- a/test/integration/full-content/fixtures/core__paragraph__deprecated.json +++ b/test/integration/full-content/fixtures/core__paragraph__deprecated.json @@ -4,17 +4,56 @@ "name": "core/paragraph", "isValid": true, "attributes": { - "content": [ - { - "key": "html", - "ref": null, - "props": { - "children": "Unwrapped is still valid." - }, - "_owner": null, - "_store": {} - } - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + [ + { + "type": "em" + } + ], + null, + null, + null, + null, + null, + null, + null + ], + "text": "Unwrapped is still valid." + }, "dropCap": false }, "innerBlocks": [], diff --git a/test/integration/full-content/fixtures/core__preformatted.json b/test/integration/full-content/fixtures/core__preformatted.json index 6bf8d032b3e40f..5c7db59e09b97c 100644 --- a/test/integration/full-content/fixtures/core__preformatted.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__preformatted.serialized.html b/test/integration/full-content/fixtures/core__preformatted.serialized.html index 86bf1df537b252..8c7cf539e4ea2e 100644 --- a/test/integration/full-content/fixtures/core__preformatted.serialized.html +++ b/test/integration/full-content/fixtures/core__preformatted.serialized.html @@ -1,3 +1,3 @@ -
    Some preformatted text...
    And more!
    +
    Some preformatted text...
    And more!
    diff --git a/test/integration/full-content/fixtures/core__pullquote.json b/test/integration/full-content/fixtures/core__pullquote.json index a288af83867dad..89cbf3529f8962 100644 --- a/test/integration/full-content/fixtures/core__pullquote.json +++ b/test/integration/full-content/fixtures/core__pullquote.json @@ -4,21 +4,59 @@ "name": "core/pullquote", "isValid": true, "attributes": { - "value": [ - { - "children": { - "type": "p", - "props": { - "children": [ - "Testing pullquote block..." - ] - } - } - } - ], - "citation": [ - "...with a caption" - ] + "value": { + "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": { + "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
    \n

    Testing pullquote block...

    ...with a caption\n
    \n
    " diff --git a/test/integration/full-content/fixtures/core__pullquote__multi-paragraph.json b/test/integration/full-content/fixtures/core__pullquote__multi-paragraph.json index c889d363b653ca..d2f54ac170e1b9 100644 --- a/test/integration/full-content/fixtures/core__pullquote__multi-paragraph.json +++ b/test/integration/full-content/fixtures/core__pullquote__multi-paragraph.json @@ -4,39 +4,66 @@ "name": "core/pullquote", "isValid": true, "attributes": { - "value": [ - { - "children": { - "type": "p", - "props": { - "children": [ - "Paragraph ", - { - "type": "strong", - "props": { - "children": [ - "one" - ] - } - } - ] + "value": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + [ + { + "type": "strong" } - } - }, - { - "children": { - "type": "p", - "props": { - "children": [ - "Paragraph two" - ] + ], + [ + { + "type": "strong" } - } - } - ], - "citation": [ - "by whomever" - ] + ], + [ + { + "type": "strong" + } + ], + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Paragraph one
Paragraph two" + }, + "citation": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "by whomever" + } }, "innerBlocks": [], "originalContent": "
    \n
    \n

    Paragraph one

    \n

    Paragraph two

    \n by whomever\n\t
    \n
    " diff --git a/test/integration/full-content/fixtures/core__quote__style-1.json b/test/integration/full-content/fixtures/core__quote__style-1.json index 67722f404cd0ae..cddef91c400543 100644 --- a/test/integration/full-content/fixtures/core__quote__style-1.json +++ b/test/integration/full-content/fixtures/core__quote__style-1.json @@ -4,21 +4,268 @@ "name": "core/quote", "isValid": true, "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." - ] - } - } - } - ], - "citation": [ - "Matt Mullenweg, 2017" - ] + "value": { + "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": { + "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/test/integration/full-content/fixtures/core__quote__style-2.json b/test/integration/full-content/fixtures/core__quote__style-2.json index ebf72ece861815..29ed76be417ea5 100644 --- a/test/integration/full-content/fixtures/core__quote__style-2.json +++ b/test/integration/full-content/fixtures/core__quote__style-2.json @@ -4,21 +4,94 @@ "name": "core/quote", "isValid": true, "attributes": { - "value": [ - { - "children": { - "type": "p", - "props": { - "children": [ - "There is no greater agony than bearing an untold story inside you." - ] - } - } - } - ], - "citation": [ - "Maya Angelou" - ], + "value": { + "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": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Maya Angelou" + }, "className": "is-style-large" }, "innerBlocks": [], diff --git a/test/integration/full-content/fixtures/core__subhead.json b/test/integration/full-content/fixtures/core__subhead.json index 64d8bb2f030c7e..291374ad1a6c1d 100644 --- a/test/integration/full-content/fixtures/core__subhead.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__table.html b/test/integration/full-content/fixtures/core__table.html index 632c38d213f740..b9b41659e835aa 100644 --- a/test/integration/full-content/fixtures/core__table.html +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__table.json b/test/integration/full-content/fixtures/core__table.json index 3adc4861093f7e..bdd286d4eaa097 100644 --- a/test/integration/full-content/fixtures/core__table.json +++ b/test/integration/full-content/fixtures/core__table.json @@ -9,21 +9,46 @@ { "cells": [ { - "content": [ - "Version" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null + ], + "text": "Version" + }, "tag": "th" }, { - "content": [ - "Musician" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Musician" + }, "tag": "th" }, { - "content": [ - "Date" - ], + "content": { + "formats": [ + null, + null, + null, + null + ], + "text": "Date" + }, "tag": "th" } ] @@ -33,29 +58,82 @@ { "cells": [ { - "content": [ - { - "type": "a", - "props": { - "href": "https://wordpress.org/news/2003/05/wordpress-now-available/", - "children": [ - ".70" - ] - } - } - ], + "content": { + "formats": [ + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2003/05/wordpress-now-available/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2003/05/wordpress-now-available/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2003/05/wordpress-now-available/" + } + } + ] + ], + "text": ".70" + }, "tag": "td" }, { - "content": [ - "No musician chosen." - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "No musician chosen." + }, "tag": "td" }, { - "content": [ - "May 27, 2003" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "May 27, 2003" + }, "tag": "td" } ] @@ -63,29 +141,77 @@ { "cells": [ { - "content": [ - { - "type": "a", - "props": { - "href": "https://wordpress.org/news/2004/01/wordpress-10/", - "children": [ - "1.0" - ] - } - } - ], + "content": { + "formats": [ + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2004/01/wordpress-10/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2004/01/wordpress-10/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2004/01/wordpress-10/" + } + } + ] + ], + "text": "1.0" + }, "tag": "td" }, { - "content": [ - "Miles Davis" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Miles Davis" + }, "tag": "td" }, { - "content": [ - "January 3, 2004" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "January 3, 2004" + }, "tag": "td" } ] @@ -93,30 +219,163 @@ { "cells": [ { - "content": [ - "Lots of versions skipped, see ", - { - "type": "a", - "props": { - "href": "https://codex.wordpress.org/WordPress_Versions", - "children": [ - "the full list" - ] - } - } - ], + "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, + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://codex.wordpress.org/WordPress_Versions" + } + } + ] + ], + "text": "Lots of versions skipped, see the full list" + }, "tag": "td" }, { - "content": [ - "…" - ], + "content": { + "formats": [ + null + ], + "text": "…" + }, "tag": "td" }, { - "content": [ - "…" - ], + "content": { + "formats": [ + null + ], + "text": "…" + }, "tag": "td" } ] @@ -124,29 +383,81 @@ { "cells": [ { - "content": [ - { - "type": "a", - "props": { - "href": "https://wordpress.org/news/2015/12/clifford/", - "children": [ - "4.4" - ] - } - } - ], + "content": { + "formats": [ + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2015/12/clifford/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2015/12/clifford/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2015/12/clifford/" + } + } + ] + ], + "text": "4.4" + }, "tag": "td" }, { - "content": [ - "Clifford Brown" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Clifford Brown" + }, "tag": "td" }, { - "content": [ - "December 8, 2015" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "December 8, 2015" + }, "tag": "td" } ] @@ -154,29 +465,80 @@ { "cells": [ { - "content": [ - { - "type": "a", - "props": { - "href": "https://wordpress.org/news/2016/04/coleman/", - "children": [ - "4.5" - ] - } - } - ], + "content": { + "formats": [ + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/04/coleman/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/04/coleman/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/04/coleman/" + } + } + ] + ], + "text": "4.5" + }, "tag": "td" }, { - "content": [ - "Coleman Hawkins" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Coleman Hawkins" + }, "tag": "td" }, { - "content": [ - "April 12, 2016" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "April 12, 2016" + }, "tag": "td" } ] @@ -184,29 +546,78 @@ { "cells": [ { - "content": [ - { - "type": "a", - "props": { - "href": "https://wordpress.org/news/2016/08/pepper/", - "children": [ - "4.6" - ] - } - } - ], + "content": { + "formats": [ + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/08/pepper/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/08/pepper/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/08/pepper/" + } + } + ] + ], + "text": "4.6" + }, "tag": "td" }, { - "content": [ - "Pepper Adams" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Pepper Adams" + }, "tag": "td" }, { - "content": [ - "August 16, 2016" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "August 16, 2016" + }, "tag": "td" } ] @@ -214,29 +625,80 @@ { "cells": [ { - "content": [ - { - "type": "a", - "props": { - "href": "https://wordpress.org/news/2016/12/vaughan/", - "children": [ - "4.7" - ] - } - } - ], + "content": { + "formats": [ + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/12/vaughan/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/12/vaughan/" + } + } + ], + [ + { + "type": "a", + "attributes": { + "href": "https://wordpress.org/news/2016/12/vaughan/" + } + } + ] + ], + "text": "4.7" + }, "tag": "td" }, { - "content": [ - "Sarah Vaughan" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "Sarah Vaughan" + }, "tag": "td" }, { - "content": [ - "December 6, 2016" - ], + "content": { + "formats": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "text": "December 6, 2016" + }, "tag": "td" } ] diff --git a/test/integration/full-content/fixtures/core__table.parsed.json b/test/integration/full-content/fixtures/core__table.parsed.json index 0051d503b8d629..737ac5ea49098c 100644 --- a/test/integration/full-content/fixtures/core__table.parsed.json +++ b/test/integration/full-content/fixtures/core__table.parsed.json @@ -7,6 +7,6 @@ }, { "attrs": {}, - "innerHTML": "\n\n" + "innerHTML": "\n" } ] diff --git a/test/integration/full-content/fixtures/core__text-columns.json b/test/integration/full-content/fixtures/core__text-columns.json index a1f531dd8fe3c4..2a31ae6ff865fa 100644 --- a/test/integration/full-content/fixtures/core__text-columns.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__text__converts-to-paragraph.json b/test/integration/full-content/fixtures/core__text__converts-to-paragraph.json index 10066958f0b3bb..951cc3900f3c13 100644 --- a/test/integration/full-content/fixtures/core__text__converts-to-paragraph.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__verse.json b/test/integration/full-content/fixtures/core__verse.json index 4412cb039d8490..e7d92ea856bff2 100644 --- a/test/integration/full-content/fixtures/core__verse.json +++ b/test/integration/full-content/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/test/integration/full-content/fixtures/core__verse.serialized.html b/test/integration/full-content/fixtures/core__verse.serialized.html index ff4983491f13db..a601b259912a8e 100644 --- a/test/integration/full-content/fixtures/core__verse.serialized.html +++ b/test/integration/full-content/fixtures/core__verse.serialized.html @@ -1,3 +1,3 @@ -
    A verse
    And more!
    +
    A verse
    And more!
    diff --git a/test/integration/full-content/fixtures/core__video.json b/test/integration/full-content/fixtures/core__video.json index ecf1d82f60133f..b0a67a574890a1 100644 --- a/test/integration/full-content/fixtures/core__video.json +++ b/test/integration/full-content/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/test/integration/full-content/full-content.spec.js b/test/integration/full-content/full-content.spec.js index 1e81908e687196..99ae5c7f252a9c 100644 --- a/test/integration/full-content/full-content.spec.js +++ b/test/integration/full-content/full-content.spec.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'; /** @@ -50,31 +50,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 @@ -84,12 +59,6 @@ function normalizeParsedBlocks( blocks ) { // Change client IDs to a predictable value block.clientId = '_clientId_' + index; - // 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/test/integration/full-content/server-registered.json b/test/integration/full-content/server-registered.json index 0434a63b6634be..65eb48d6d53f28 100644 --- a/test/integration/full-content/server-registered.json +++ b/test/integration/full-content/server-registered.json @@ -1 +1 @@ -{"core\/block":{"attributes":{"ref":{"type":"number"}}},"core\/latest-comments":{"attributes":{"className":{"type":"string"},"commentsToShow":{"type":"number","default":5,"minimum":1,"maximum":100},"displayAvatar":{"type":"boolean","default":true},"displayDate":{"type":"boolean","default":true},"displayExcerpt":{"type":"boolean","default":true},"align":{"type":"string","enum":["center","left","right","wide","full",""]}}},"core\/archives":{"attributes":{"align":{"type":"string"},"className":{"type":"string"},"displayAsDropdown":{"type":"boolean","default":false},"showPostCounts":{"type":"boolean","default":false}}},"core\/latest-posts":{"attributes":{"categories":{"type":"string"},"className":{"type":"string"},"postsToShow":{"type":"number","default":5},"displayPostDate":{"type":"boolean","default":false},"postLayout":{"type":"string","default":"list"},"columns":{"type":"number","default":3},"align":{"type":"string"},"order":{"type":"string","default":"desc"},"orderBy":{"type":"string","default":"date"}}}} +{"core\/block":{"attributes":{"ref":{"type":"number"}}},"core\/latest-comments":{"attributes":{"className":{"type":"string"},"commentsToShow":{"type":"number","default":5,"minimum":1,"maximum":100},"displayAvatar":{"type":"boolean","default":true},"displayDate":{"type":"boolean","default":true},"displayExcerpt":{"type":"boolean","default":true},"align":{"type":"string","enum":["center","left","right","wide","full",""]}}},"core\/archives":{"attributes":{"align":{"type":"string"},"className":{"type":"string"},"displayAsDropdown":{"type":"boolean","default":false},"showPostCounts":{"type":"boolean","default":false}}},"core\/latest-posts":{"attributes":{"categories":{"type":"string"},"className":{"type":"string"},"postsToShow":{"type":"number","default":5},"displayPostDate":{"type":"boolean","default":false},"postLayout":{"type":"string","default":"list"},"columns":{"type":"number","default":3},"align":{"type":"string"},"order":{"type":"string","default":"desc"},"orderBy":{"type":"string","default":"date"}}}} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index fbd8a0feb0d454..1bc044099563fa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -98,6 +98,7 @@ const gutenbergPackages = [ 'dom-ready', 'editor', 'element', + 'escape-html', 'hooks', 'html-entities', 'i18n', @@ -107,6 +108,7 @@ const gutenbergPackages = [ 'nux', 'plugins', 'redux-routine', + 'rich-text', 'shortcode', 'token-list', 'url',