diff --git a/package-lock.json b/package-lock.json
index e4b4aeb47f6bad..f65b8101b9da90 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -54674,6 +54674,7 @@
"@wordpress/i18n": "file:../i18n",
"@wordpress/is-shallow-equal": "file:../is-shallow-equal",
"@wordpress/private-apis": "file:../private-apis",
+ "@wordpress/rich-text": "file:../rich-text",
"@wordpress/shortcode": "file:../shortcode",
"change-case": "^4.1.2",
"colord": "^2.7.0",
@@ -70139,6 +70140,7 @@
"@wordpress/i18n": "file:../i18n",
"@wordpress/is-shallow-equal": "file:../is-shallow-equal",
"@wordpress/private-apis": "file:../private-apis",
+ "@wordpress/rich-text": "file:../rich-text",
"@wordpress/shortcode": "file:../shortcode",
"change-case": "^4.1.2",
"colord": "^2.7.0",
diff --git a/packages/block-editor/src/components/rich-text/content.js b/packages/block-editor/src/components/rich-text/content.js
index 9762582f86f141..92e150fb174edb 100644
--- a/packages/block-editor/src/components/rich-text/content.js
+++ b/packages/block-editor/src/components/rich-text/content.js
@@ -5,36 +5,43 @@ import { RawHTML } from '@wordpress/element';
import { children as childrenSource } from '@wordpress/blocks';
import deprecated from '@wordpress/deprecated';
+/**
+ * Internal dependencies
+ */
+import RichText from './';
+
/**
* Internal dependencies
*/
import { getMultilineTag } from './utils';
-export const Content = ( { value, tagName: Tag, multiline, ...props } ) => {
- // Handle deprecated `children` and `node` sources.
- if ( Array.isArray( value ) ) {
+export function Content( {
+ value,
+ tagName: Tag,
+ multiline,
+ format,
+ ...props
+} ) {
+ if ( RichText.isEmpty( value ) ) {
+ const MultilineTag = getMultilineTag( multiline );
+ value = MultilineTag ?
); diff --git a/packages/block-library/src/details/block.json b/packages/block-library/src/details/block.json index d449d42e1e10c4..a71d3af2a5ed39 100644 --- a/packages/block-library/src/details/block.json +++ b/packages/block-library/src/details/block.json @@ -13,8 +13,8 @@ "default": false }, "summary": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "summary" } }, diff --git a/packages/block-library/src/embed/block.json b/packages/block-library/src/embed/block.json index 9ca54db871db19..5aac8bbd6b8cab 100644 --- a/packages/block-library/src/embed/block.json +++ b/packages/block-library/src/embed/block.json @@ -12,8 +12,8 @@ "__experimentalRole": "content" }, "caption": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "figcaption", "__experimentalRole": "content" }, diff --git a/packages/block-library/src/file/block.json b/packages/block-library/src/file/block.json index 0cc20b3f501e9b..9dc6677e4adce3 100644 --- a/packages/block-library/src/file/block.json +++ b/packages/block-library/src/file/block.json @@ -21,8 +21,8 @@ "attribute": "id" }, "fileName": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "a:not([download])" }, "textLinkHref": { @@ -42,8 +42,8 @@ "default": true }, "downloadButtonText": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "a[download]" }, "displayPreview": { diff --git a/packages/block-library/src/file/save.js b/packages/block-library/src/file/save.js index 6d0684ac76b8ef..f5eb1ce3c2b14e 100644 --- a/packages/block-library/src/file/save.js +++ b/packages/block-library/src/file/save.js @@ -25,7 +25,11 @@ export default function save( { attributes } ) { previewHeight, } = attributes; - const pdfEmbedLabel = RichText.isEmpty( fileName ) ? 'PDF embed' : fileName; + const pdfEmbedLabel = RichText.isEmpty( fileName ) + ? 'PDF embed' + : // To do: use toPlainText, but we need ensure it's RichTextData. See + // https://github.com/WordPress/gutenberg/pull/56710. + fileName.toString(); const hasFilename = ! RichText.isEmpty( fileName ); diff --git a/packages/block-library/src/form-input/block.json b/packages/block-library/src/form-input/block.json index 067b7ac69430c4..53aa0be6744cb9 100644 --- a/packages/block-library/src/form-input/block.json +++ b/packages/block-library/src/form-input/block.json @@ -19,10 +19,10 @@ "type": "string" }, "label": { - "type": "string", + "type": "rich-text", "default": "Label", "selector": ".wp-block-form-input__label-content", - "source": "html", + "source": "rich-text", "__experimentalRole": "content" }, "inlineLabel": { diff --git a/packages/block-library/src/gallery/block.json b/packages/block-library/src/gallery/block.json index 0867989af4ec7e..fad92aed59bf77 100644 --- a/packages/block-library/src/gallery/block.json +++ b/packages/block-library/src/gallery/block.json @@ -46,8 +46,8 @@ "attribute": "data-id" }, "caption": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": ".blocks-gallery-item__caption" } } @@ -72,8 +72,8 @@ "maximum": 8 }, "caption": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": ".blocks-gallery-caption" }, "imageCrop": { diff --git a/packages/block-library/src/heading/block.json b/packages/block-library/src/heading/block.json index dfd5bb72b63314..72cc67caddd9ea 100644 --- a/packages/block-library/src/heading/block.json +++ b/packages/block-library/src/heading/block.json @@ -12,10 +12,9 @@ "type": "string" }, "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "h1,h2,h3,h4,h5,h6", - "default": "", "__experimentalRole": "content" }, "level": { diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index cfe91a71ff4f97..c5191e3dd86543 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -25,8 +25,8 @@ "__experimentalRole": "content" }, "caption": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "figcaption", "__experimentalRole": "content" }, diff --git a/packages/block-library/src/list-item/block.json b/packages/block-library/src/list-item/block.json index 07797be8623a51..06997c2ac23f8e 100644 --- a/packages/block-library/src/list-item/block.json +++ b/packages/block-library/src/list-item/block.json @@ -12,10 +12,9 @@ "type": "string" }, "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "li", - "default": "", "__experimentalRole": "content" } }, diff --git a/packages/block-library/src/paragraph/block.json b/packages/block-library/src/paragraph/block.json index 85f56f4a838f50..3fe4fbb34e1029 100644 --- a/packages/block-library/src/paragraph/block.json +++ b/packages/block-library/src/paragraph/block.json @@ -13,10 +13,9 @@ "type": "string" }, "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "p", - "default": "", "__experimentalRole": "content" }, "dropCap": { diff --git a/packages/block-library/src/preformatted/block.json b/packages/block-library/src/preformatted/block.json index ec6ea839385eb2..def870e7ad2fb7 100644 --- a/packages/block-library/src/preformatted/block.json +++ b/packages/block-library/src/preformatted/block.json @@ -8,10 +8,9 @@ "textdomain": "default", "attributes": { "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "pre", - "default": "", "__unstablePreserveWhiteSpace": true, "__experimentalRole": "content" } diff --git a/packages/block-library/src/pullquote/block.json b/packages/block-library/src/pullquote/block.json index 1d6c74dbc4ae04..7fc81d5683bd19 100644 --- a/packages/block-library/src/pullquote/block.json +++ b/packages/block-library/src/pullquote/block.json @@ -8,16 +8,15 @@ "textdomain": "default", "attributes": { "value": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "p", "__experimentalRole": "content" }, "citation": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "cite", - "default": "", "__experimentalRole": "content" }, "textAlign": { diff --git a/packages/block-library/src/quote/block.json b/packages/block-library/src/quote/block.json index 7ed406c0d20965..9deca000efe06b 100644 --- a/packages/block-library/src/quote/block.json +++ b/packages/block-library/src/quote/block.json @@ -17,10 +17,9 @@ "__experimentalRole": "content" }, "citation": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "cite", - "default": "", "__experimentalRole": "content" }, "align": { diff --git a/packages/block-library/src/table/block.json b/packages/block-library/src/table/block.json index d1139d6c55addf..470886a1247fe1 100644 --- a/packages/block-library/src/table/block.json +++ b/packages/block-library/src/table/block.json @@ -12,10 +12,9 @@ "default": false }, "caption": { - "type": "string", - "source": "html", - "selector": "figcaption", - "default": "" + "type": "rich-text", + "source": "rich-text", + "selector": "figcaption" }, "head": { "type": "array", @@ -30,8 +29,8 @@ "selector": "td,th", "query": { "content": { - "type": "string", - "source": "html" + "type": "rich-text", + "source": "rich-text" }, "tag": { "type": "string", @@ -75,8 +74,8 @@ "selector": "td,th", "query": { "content": { - "type": "string", - "source": "html" + "type": "rich-text", + "source": "rich-text" }, "tag": { "type": "string", @@ -120,8 +119,8 @@ "selector": "td,th", "query": { "content": { - "type": "string", - "source": "html" + "type": "rich-text", + "source": "rich-text" }, "tag": { "type": "string", diff --git a/packages/block-library/src/utils/remove-anchor-tag.js b/packages/block-library/src/utils/remove-anchor-tag.js index 31d1877082f50d..82e7b03423648d 100644 --- a/packages/block-library/src/utils/remove-anchor-tag.js +++ b/packages/block-library/src/utils/remove-anchor-tag.js @@ -6,5 +6,6 @@ * @return {string} The value with anchor tags removed. */ export default function removeAnchorTag( value ) { - return value.replace( /<\/?a[^>]*>/g, '' ); + // To do: Refactor this to use rich text's removeFormat instead. + return value.toString().replace( /<\/?a[^>]*>/g, '' ); } diff --git a/packages/block-library/src/verse/block.json b/packages/block-library/src/verse/block.json index fa0bc30798212e..846a1dc99caafc 100644 --- a/packages/block-library/src/verse/block.json +++ b/packages/block-library/src/verse/block.json @@ -9,10 +9,9 @@ "textdomain": "default", "attributes": { "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "pre", - "default": "", "__unstablePreserveWhiteSpace": true, "__experimentalRole": "content" }, diff --git a/packages/block-library/src/video/block.json b/packages/block-library/src/video/block.json index debe6f20fe53f7..5d4680f39e79a8 100644 --- a/packages/block-library/src/video/block.json +++ b/packages/block-library/src/video/block.json @@ -15,8 +15,8 @@ "attribute": "autoplay" }, "caption": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "figcaption", "__experimentalRole": "content" }, diff --git a/packages/blocks/package.json b/packages/blocks/package.json index 961cb338d73378..928d9d94740b4f 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -42,6 +42,7 @@ "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/private-apis": "file:../private-apis", + "@wordpress/rich-text": "file:../rich-text", "@wordpress/shortcode": "file:../shortcode", "change-case": "^4.1.2", "colord": "^2.7.0", diff --git a/packages/blocks/src/api/matchers.js b/packages/blocks/src/api/matchers.js index 7a6ac84891658a..950f1539440a0b 100644 --- a/packages/blocks/src/api/matchers.js +++ b/packages/blocks/src/api/matchers.js @@ -3,6 +3,11 @@ */ export { attr, prop, text, query } from 'hpq'; +/** + * WordPress dependencies + */ +import { RichTextData } from '@wordpress/rich-text'; + /** * Internal dependencies */ @@ -41,3 +46,10 @@ export function html( selector, multilineTag ) { return match.innerHTML; }; } + +export const richText = ( selector, preserveWhiteSpace ) => ( el ) => { + const target = selector ? el.querySelector( selector ) : el; + return target + ? RichTextData.fromHTMLElement( target, { preserveWhiteSpace } ) + : RichTextData.empty(); +}; diff --git a/packages/blocks/src/api/parser/get-block-attributes.js b/packages/blocks/src/api/parser/get-block-attributes.js index cc81c108005529..24faae73704636 100644 --- a/packages/blocks/src/api/parser/get-block-attributes.js +++ b/packages/blocks/src/api/parser/get-block-attributes.js @@ -9,12 +9,22 @@ import memoize from 'memize'; */ import { pipe } from '@wordpress/compose'; import { applyFilters } from '@wordpress/hooks'; +import { RichTextData } from '@wordpress/rich-text'; /** * Internal dependencies */ -import { attr, html, text, query, node, children, prop } from '../matchers'; -import { normalizeBlockType } from '../utils'; +import { + attr, + html, + text, + query, + node, + children, + prop, + richText, +} from '../matchers'; +import { normalizeBlockType, getDefault } from '../utils'; /** * Higher-order hpq matcher which enhances an attribute matcher to return true @@ -58,6 +68,9 @@ export const toBooleanAttributeMatcher = ( matcher ) => */ export function isOfType( value, type ) { switch ( type ) { + case 'rich-text': + return value instanceof RichTextData; + case 'string': return typeof value === 'string'; @@ -134,6 +147,7 @@ export function getBlockAttribute( case 'property': case 'html': case 'text': + case 'rich-text': case 'children': case 'node': case 'query': @@ -152,7 +166,7 @@ export function getBlockAttribute( } if ( value === undefined ) { - value = attributeSchema.default; + value = getDefault( attributeSchema ); } return value; @@ -211,6 +225,11 @@ export const matcherFromSource = memoize( ( sourceConfig ) => { return html( sourceConfig.selector, sourceConfig.multiline ); case 'text': return text( sourceConfig.selector ); + case 'rich-text': + return richText( + sourceConfig.selector, + sourceConfig.__unstablePreserveWhiteSpace + ); case 'children': return children( sourceConfig.selector ); case 'node': diff --git a/packages/blocks/src/api/raw-handling/test/paste-handler.js b/packages/blocks/src/api/raw-handling/test/paste-handler.js index 6938ad0d9c4081..9b3dad39a0a5b8 100644 --- a/packages/blocks/src/api/raw-handling/test/paste-handler.js +++ b/packages/blocks/src/api/raw-handling/test/paste-handler.js @@ -73,9 +73,9 @@ describe( 'pasteHandler', () => { expect( console ).toHaveLogged(); + delete result.attributes.caption; expect( result.attributes ).toEqual( { hasFixedLayout: false, - caption: '', head: [ { cells: [ @@ -113,9 +113,9 @@ describe( 'pasteHandler', () => { expect( console ).toHaveLogged(); + delete result.attributes.caption; expect( result.attributes ).toEqual( { hasFixedLayout: false, - caption: '', head: [ { cells: [ diff --git a/packages/blocks/src/api/utils.js b/packages/blocks/src/api/utils.js index c43445c6272264..60a94117b36e23 100644 --- a/packages/blocks/src/api/utils.js +++ b/packages/blocks/src/api/utils.js @@ -11,6 +11,7 @@ import a11yPlugin from 'colord/plugins/a11y'; import { Component, isValidElement } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { RichTextData } from '@wordpress/rich-text'; /** * Internal dependencies @@ -47,8 +48,12 @@ export function isUnmodifiedBlock( block ) { const newBlock = isUnmodifiedBlock[ block.name ]; const blockType = getBlockType( block.name ); - return Object.keys( blockType?.attributes ?? {} ).every( - ( key ) => newBlock.attributes[ key ] === block.attributes[ key ] + function isEqual( a, b ) { + return ( a?.valueOf() ?? a ) === ( b?.valueOf() ?? b ); + } + + return Object.keys( blockType?.attributes ?? {} ).every( ( key ) => + isEqual( newBlock.attributes[ key ], block.attributes[ key ] ) ); } @@ -243,6 +248,16 @@ export function getAccessibleBlockLabel( ); } +export function getDefault( attributeSchema ) { + if ( attributeSchema.default !== undefined ) { + return attributeSchema.default; + } + + if ( attributeSchema.type === 'rich-text' ) { + return new RichTextData(); + } +} + /** * Ensure attributes contains only values defined by block type, and merge * default values for missing attributes. @@ -264,9 +279,26 @@ export function __experimentalSanitizeBlockAttributes( name, attributes ) { const value = attributes[ key ]; if ( undefined !== value ) { - accumulator[ key ] = value; - } else if ( schema.hasOwnProperty( 'default' ) ) { - accumulator[ key ] = schema.default; + if ( schema.type === 'rich-text' ) { + if ( value instanceof RichTextData ) { + accumulator[ key ] = value; + } else if ( typeof value === 'string' ) { + accumulator[ key ] = + RichTextData.fromHTMLString( value ); + } + } else if ( + schema.type === 'string' && + value instanceof RichTextData + ) { + accumulator[ key ] = value.toHTMLString(); + } else { + accumulator[ key ] = value; + } + } else { + const _default = getDefault( schema ); + if ( undefined !== _default ) { + accumulator[ key ] = _default; + } } if ( [ 'node', 'children' ].indexOf( schema.source ) !== -1 ) { diff --git a/packages/core-data/src/footnotes/get-footnotes-order.js b/packages/core-data/src/footnotes/get-footnotes-order.js index 42adeed7621e8f..fcaeae660ec1aa 100644 --- a/packages/core-data/src/footnotes/get-footnotes-order.js +++ b/packages/core-data/src/footnotes/get-footnotes-order.js @@ -1,8 +1,3 @@ -/** - * WordPress dependencies - */ -import { create } from '@wordpress/rich-text'; - /** * Internal dependencies */ @@ -14,18 +9,16 @@ function getBlockFootnotesOrder( block ) { if ( ! cache.has( block ) ) { const order = []; for ( const value of getRichTextValuesCached( block ) ) { - if ( ! value || ! value.includes( 'data-fn' ) ) { + if ( ! value ) { continue; } // replacements is a sparse array, use forEach to skip empty slots. - create( { html: value } ).replacements.forEach( - ( { type, attributes } ) => { - if ( type === 'core/footnote' ) { - order.push( attributes[ 'data-fn' ] ); - } + value.replacements.forEach( ( { type, attributes } ) => { + if ( type === 'core/footnote' ) { + order.push( attributes[ 'data-fn' ] ); } - ); + } ); } cache.set( block, order ); } diff --git a/packages/core-data/src/footnotes/index.js b/packages/core-data/src/footnotes/index.js index fa1c5fad5c7e7d..9458290f9cb40b 100644 --- a/packages/core-data/src/footnotes/index.js +++ b/packages/core-data/src/footnotes/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { create, toHTMLString } from '@wordpress/rich-text'; +import { RichTextData, create, toHTMLString } from '@wordpress/rich-text'; /** * Internal dependencies @@ -53,15 +53,18 @@ export function updateFootnotesFromMeta( blocks, meta ) { continue; } - if ( typeof value !== 'string' ) { + // To do, remove support for string values? + if ( + typeof value !== 'string' && + ! ( value instanceof RichTextData ) + ) { continue; } - if ( value.indexOf( 'data-fn' ) === -1 ) { - continue; - } - - const richTextValue = create( { html: value } ); + const richTextValue = + typeof value === 'string' + ? RichTextData.fromHTMLString( value ) + : value; richTextValue.replacements.forEach( ( replacement ) => { if ( replacement.type === 'core/footnote' ) { @@ -78,7 +81,10 @@ export function updateFootnotesFromMeta( blocks, meta ) { } } ); - attributes[ key ] = toHTMLString( { value: richTextValue } ); + attributes[ key ] = + typeof value === 'string' + ? richTextValue.toHTMLString() + : richTextValue; } return attributes; diff --git a/packages/rich-text/README.md b/packages/rich-text/README.md index 90726ff238c1bd..90fd15e1c905c5 100644 --- a/packages/rich-text/README.md +++ b/packages/rich-text/README.md @@ -355,6 +355,19 @@ _Returns_ - `RichTextValue`: A new value with replacements applied. +### RichTextData + +The RichTextData class is used to instantiate a wrapper around rich text values, with methods that can be used to transform or manipulate the data. + +- Create an emtpy instance: `new RichTextData()`. +- Create one from an html string: `RichTextData.fromHTMLString( +'hello' )`. +- Create one from a wrapper HTMLElement: `RichTextData.fromHTMLElement( +document.querySelector( 'p' ) )`. +- Create one from plain text: `RichTextData.fromPlainText( '1\n2' )`. +- Create one from a rich text value: `new RichTextData( { text: '...', +formats: [ ... ] } )`. + ### RichTextValue An object which represents a formatted string. See main `@wordpress/rich-text` documentation for more information. diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index 0e9291b7a5e03d..a2b5734d5c2048 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -8,7 +8,7 @@ import { useRegistry } from '@wordpress/data'; /** * Internal dependencies */ -import { collapseWhiteSpace, create } from '../create'; +import { create, RichTextData } from '../create'; import { apply } from '../to-dom'; import { toHTMLString } from '../to-html-string'; import { useDefaultStyle } from './use-default-style'; @@ -70,11 +70,18 @@ export function useRichText( { function setRecordFromProps() { _value.current = value; - record.current = create( { - html: preserveWhiteSpace - ? value - : collapseWhiteSpace( typeof value === 'string' ? value : '' ), - } ); + record.current = value; + if ( ! ( value instanceof RichTextData ) ) { + record.current = value + ? RichTextData.fromHTMLString( value, { preserveWhiteSpace } ) + : RichTextData.empty(); + } + // To do: make rich text internally work with RichTextData. + record.current = { + text: record.current.text, + formats: record.current.formats, + replacements: record.current.replacements, + }; if ( disableFormats ) { record.current.formats = Array( value.length ); record.current.replacements = Array( value.length ); @@ -117,17 +124,18 @@ export function useRichText( { if ( disableFormats ) { _value.current = newRecord.text; } else { - _value.current = toHTMLString( { - value: __unstableBeforeSerialize - ? { - ...newRecord, - formats: __unstableBeforeSerialize( newRecord ), - } - : newRecord, - } ); + const newFormats = __unstableBeforeSerialize + ? __unstableBeforeSerialize( newRecord ) + : newRecord.formats; + newRecord = { ...newRecord, formats: newFormats }; + if ( typeof value === 'string' ) { + _value.current = toHTMLString( { value: newRecord } ); + } else { + _value.current = new RichTextData( newRecord ); + } } - const { start, end, formats, text } = newRecord; + const { start, end, formats, text } = record.current; // Selection must be updated first, so it is recorded in history when // the content change happens. diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index a23baf70078bc9..a35fabbd4e2fad 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -10,6 +10,8 @@ import { store as richTextStore } from './store'; import { createElement } from './create-element'; import { mergePair } from './concat'; import { OBJECT_REPLACEMENT_CHARACTER, ZWNBSP } from './special-characters'; +import { toHTMLString } from './to-html-string'; +import { getTextContent } from './get-text-content'; /** @typedef {import('./types').RichTextValue} RichTextValue */ @@ -96,6 +98,86 @@ function toFormat( { tagName, attributes } ) { }; } +// Ideally we use a private property. +const RichTextInternalData = Symbol( 'RichTextInternalData' ); + +/** + * The RichTextData class is used to instantiate a wrapper around rich text + * values, with methods that can be used to transform or manipulate the data. + * + * - Create an emtpy instance: `new RichTextData()`. + * - Create one from an html string: `RichTextData.fromHTMLString( + * 'hello' )`. + * - Create one from a wrapper HTMLElement: `RichTextData.fromHTMLElement( + * document.querySelector( 'p' ) )`. + * - Create one from plain text: `RichTextData.fromPlainText( '1\n2' )`. + * - Create one from a rich text value: `new RichTextData( { text: '...', + * formats: [ ... ] } )`. + * + * @todo Add methods to manipulate the data, such as applyFormat, slice etc. + */ +export class RichTextData { + static empty() { + return new RichTextData(); + } + static fromPlainText( text ) { + return new RichTextData( create( { text } ) ); + } + static fromHTMLString( html ) { + return new RichTextData( create( { html } ) ); + } + static fromHTMLElement( htmlElement, options = {} ) { + const { preserveWhiteSpace = false } = options; + const element = preserveWhiteSpace + ? htmlElement + : collapseWhiteSpace( htmlElement ); + const richTextData = new RichTextData( create( { element } ) ); + Object.defineProperty( richTextData, 'originalHTML', { + value: htmlElement.innerHTML, + } ); + return richTextData; + } + constructor( init = createEmptyValue() ) { + // Setting text, formats, and replacements as enumerable properties + // unfortunately visualises these in the e2e tests. As long as the class + // instance doesn't have any enumerable properties, it will be + // visualised as a string. + Object.defineProperty( this, RichTextInternalData, { value: init } ); + } + toPlainText() { + return getTextContent( this[ RichTextInternalData ] ); + } + // We could expose `toHTMLElement` at some point as well, but we'd only use + // it internally. + toHTMLString() { + return ( + this.originalHTML || + toHTMLString( { value: this[ RichTextInternalData ] } ) + ); + } + valueOf() { + return this.toHTMLString(); + } + toString() { + return this.toHTMLString(); + } + toJSON() { + return this.toHTMLString(); + } + get length() { + return this.text.length; + } + get formats() { + return this[ RichTextInternalData ].formats; + } + get replacements() { + return this[ RichTextInternalData ].replacements; + } + get text() { + return this[ RichTextInternalData ].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 @@ -128,7 +210,6 @@ function toFormat( { tagName, attributes } ) { * @param {string} [$1.html] HTML to create value from. * @param {Range} [$1.range] Range to create value from. * @param {boolean} [$1.__unstableIsEditableTree] - * * @return {RichTextValue} A rich text value. */ export function create( { @@ -138,6 +219,14 @@ export function create( { range, __unstableIsEditableTree: isEditableTree, } = {} ) { + if ( html instanceof RichTextData ) { + return { + text: html.text, + formats: html.formats, + replacements: html.replacements, + }; + } + if ( typeof text === 'string' && text.length > 0 ) { return { formats: Array( text.length ), @@ -268,10 +357,42 @@ function filterRange( node, range, filter ) { * @see * https://developer.mozilla.org/en-US/docs/Web/CSS/white-space-collapse#collapsing_of_white_space * - * @param {string} string + * @param {HTMLElement} element + * @param {boolean} isRoot + * + * @return {HTMLElement} New element with collapsed whitespace. */ -export function collapseWhiteSpace( string ) { - return string.replace( /[\n\r\t]+/g, ' ' ); +function collapseWhiteSpace( element, isRoot = true ) { + const clone = element.cloneNode( true ); + clone.normalize(); + Array.from( clone.childNodes ).forEach( ( node, i, nodes ) => { + if ( node.nodeType === node.TEXT_NODE ) { + let newNodeValue = node.nodeValue; + + if ( /[\n\t\r\f]/.test( newNodeValue ) ) { + newNodeValue = newNodeValue.replace( /[\n\t\r\f]+/g, ' ' ); + } + + if ( newNodeValue.indexOf( ' ' ) !== -1 ) { + newNodeValue = newNodeValue.replace( / {2,}/g, ' ' ); + } + + if ( i === 0 && newNodeValue.startsWith( ' ' ) ) { + newNodeValue = newNodeValue.slice( 1 ); + } else if ( + isRoot && + i === nodes.length - 1 && + newNodeValue.endsWith( ' ' ) + ) { + newNodeValue = newNodeValue.slice( 0, -1 ); + } + + node.nodeValue = newNodeValue; + } else if ( node.nodeType === node.ELEMENT_NODE ) { + collapseWhiteSpace( node, false ); + } + } ); + return clone; } /** diff --git a/packages/rich-text/src/index.ts b/packages/rich-text/src/index.ts index 14d26cab8f7fb1..f82317d81573d0 100644 --- a/packages/rich-text/src/index.ts +++ b/packages/rich-text/src/index.ts @@ -1,7 +1,7 @@ export { store } from './store'; export { applyFormat } from './apply-format'; export { concat } from './concat'; -export { create } from './create'; +export { RichTextData, create } from './create'; export { getActiveFormat } from './get-active-format'; export { getActiveFormats } from './get-active-formats'; export { getActiveObject } from './get-active-object'; diff --git a/schemas/json/block.json b/schemas/json/block.json index d5b4a04452eaa6..7e0c8715a4abda 100644 --- a/schemas/json/block.json +++ b/schemas/json/block.json @@ -114,6 +114,7 @@ "object", "array", "string", + "rich-text", "integer", "number" ] @@ -159,6 +160,7 @@ "enum": [ "attribute", "text", + "rich-text", "html", "raw", "query", diff --git a/test/integration/fixtures/blocks/core__gallery__deprecated-1.json b/test/integration/fixtures/blocks/core__gallery__deprecated-1.json index 9e15ee7f1c7149..bd6108a97230a3 100644 --- a/test/integration/fixtures/blocks/core__gallery__deprecated-1.json +++ b/test/integration/fixtures/blocks/core__gallery__deprecated-1.json @@ -16,6 +16,7 @@ "attributes": { "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", "alt": "title", + "caption": "", "linkDestination": "none" }, "innerBlocks": [] @@ -26,6 +27,7 @@ "attributes": { "url": "data:image/jpeg;base64,/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=", "alt": "title", + "caption": "", "linkDestination": "none" }, "innerBlocks": [] diff --git a/test/integration/fixtures/documents/ms-word-online-out.html b/test/integration/fixtures/documents/ms-word-online-out.html index 398281520f2542..8187b598f9a91a 100644 --- a/test/integration/fixtures/documents/ms-word-online-out.html +++ b/test/integration/fixtures/documents/ms-word-online-out.html @@ -8,33 +8,33 @@
code
',
@@ -30,7 +30,7 @@ describe( 'Handling of non matched tags in block transforms', () => {
expect( codeResult ).toHaveLength( 1 );
expect( codeResult[ 0 ].name ).toBe( 'core/code' );
- expect( codeResult[ 0 ].attributes.content ).toBe( 'code' );
+ expect( codeResult[ 0 ].attributes.content.valueOf() ).toBe( 'code' );
expect( console ).toHaveLogged();
} );
} );