diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 6c6879d36107a..26bc942d63f8f 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -278,6 +278,15 @@ Add a link to a downloadable file. ([Source](https://github.com/WordPress/gutenb - **Supports:** align, anchor, color (background, gradients, link, ~~text~~) - **Attributes:** displayPreview, downloadButtonText, fileId, fileName, href, id, previewHeight, showDownloadButton, textLinkHref, textLinkTarget +## Footnotes + + ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/footnotes)) + +- **Name:** core/footnotes +- **Category:** text +- **Supports:** ~~html~~ +- **Attributes:** + ## Classic Use the classic WordPress editor. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/freeform)) diff --git a/lib/experimental/class-wp-footnote-processor.php b/lib/experimental/class-wp-footnote-processor.php new file mode 100644 index 0000000000000..5e6c5f46f0bb5 --- /dev/null +++ b/lib/experimental/class-wp-footnote-processor.php @@ -0,0 +1,242 @@ +get_footer()`. + * + * @return string Transformed HTML with links instead of inline footnotes. + */ + public function replace_footnotes() { + while ( $this->find_opener() ) { + if ( ! $this->find_balanced_closer() ) { + return $this->get_updated_html(); + } + + $note = substr( $this->get_note_content(), 2, -1 ); + $id = md5( $note ); + + if ( isset( $this->notes[ $id ] ) ) { + $this->notes[ $id ]['count'] += 1; + } else { + $this->notes[ $id ] = array( + 'note' => $note, + 'count' => 1, + ); + } + + // List starts at 1. If the note already exists, use the existing index. + $index = 1 + array_search( $id, array_keys( $this->notes ), true ); + $count = $this->notes[ $id ]['count']; + + $footnote_content = sprintf( + '[%d]', + esc_attr( $note ), + $id, + $id, + $count, + $index + ); + + $this->replace_footnote( $footnote_content ); + } + + return $this->get_updated_html(); + } + + /** + * Generates a list of footnote items that can be linked to in the post. + * + * @return string The list of footnote items, if any, otherwise an empty string. + */ + public function get_footer() { + if ( empty( $this->notes ) ) { + return ''; + } + + $output = '
    '; + foreach ( $this->notes as $id => $info ) { + $note = $info['note']; + $count = $info['count']; + $output .= sprintf( '
  1. ', $id ); + $output .= $note; + $label = $count > 1 ? + /* translators: %s: footnote occurrence */ + __( 'Back to content (%s)', 'gutenberg' ) : + __( 'Back to content', 'gutenberg' ); + $links = ''; + while ( $count ) { + $links .= sprintf( + '↩︎', + $id, + $count, + sprintf( $label, $count ) + ); + $count--; + } + $output .= ' ' . $links; + $output .= '
  2. '; + } + $output .= '
'; + + return $output; + } + + /** + * Finds the start of the next footnote. + * + * Looks for a superscript tag with the `value=footnote` attribute. + * + * @return bool + */ + private function find_opener() { + while ( $this->next_tag( array( 'tag_name' => 'sup' ) ) ) { + if ( 'fn' === $this->get_attribute( 'class' ) ) { + $this->set_bookmark( 'start' ); + return true; + } + } + + return false; + } + + /** + * Naively finds the end of the current footnote. + * + * @return bool Whether the end of the current footnote was found. + */ + private function find_balanced_closer() { + $depth = 1; + $query = array( + 'tag_name' => 'sup', + 'tag_closers' => 'visit', + ); + + while ( $this->next_tag( $query ) ) { + if ( ! $this->is_tag_closer() ) { + $depth++; + } else { + $depth--; + } + + if ( 0 <= $depth ) { + $this->set_bookmark( 'end' ); + return true; + } + } + + return false; + } + + /** + * Returns the content inside footnote superscript tags. + * + * @return string The content found inside footnote superscript tags. + */ + private function get_note_content() { + $open = $this->bookmarks['start']; + $close = $this->bookmarks['end']; + + return substr( $this->html, $open->end, $close->start - $open->end ); + } + + /** + * Replaces the footnote entirely with new HTML. + * + * @param string $new_content Content to store in place of the existing footnote. + * + * @return void + */ + private function replace_footnote( $new_content ) { + $start = $this->bookmarks['start']->start; + $end = $this->bookmarks['end']->end + 1; + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end, $new_content ); + $this->get_updated_html(); + + $this->bookmarks['start']->start = $start; + $this->bookmarks['start']->end = $end; + $this->seek( 'start' ); + } +} + +add_filter( + 'block_parser_class', + /** + * Hack to inject a per-render singleton footnote processor. + */ + function ( $parser_class ) { + $notes = array(); + + add_filter( + 'render_block', + function ( $html, $block ) use ( &$notes ) { + if ( 'core/footnotes' === $block['blockName'] ) { + if ( 0 === count( $notes ) ) { + return $html; + } + + $p = new WP_Footnote_Processor( $html ); + $p->notes = $notes; + $list = $p->get_footer(); + $notes = array(); + + return $html . $list; + } + + $p = new WP_Footnote_Processor( $html ); + $p->notes = $notes; + $p->replace_footnotes(); + $notes = $p->notes; + + return $p->get_updated_html(); + }, + 1000, + 2 + ); + + add_filter( + 'the_content', + function ( $html ) use ( &$notes ) { + if ( 0 === count( $notes ) ) { + return $html; + } + + $p = new WP_Footnote_Processor( $html ); + $p->notes = $notes; + $list = $p->get_footer(); + $notes = array(); + + return $html . $list; + }, + 1000, + 1 + ); + + return $parser_class; + } +); diff --git a/lib/load.php b/lib/load.php index 84b6e239d5da1..5e42aba0b5d0e 100644 --- a/lib/load.php +++ b/lib/load.php @@ -115,6 +115,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/block-editor-settings-mobile.php'; require __DIR__ . '/experimental/block-editor-settings.php'; require __DIR__ . '/experimental/blocks.php'; +require __DIR__ . '/experimental/class-wp-footnote-processor.php'; require __DIR__ . '/experimental/navigation-theme-opt-in.php'; require __DIR__ . '/experimental/kses.php'; require __DIR__ . '/experimental/l10n.php'; diff --git a/package-lock.json b/package-lock.json index 5154297bb1711..9a356ac64026a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18177,6 +18177,7 @@ "@wordpress/element": "file:packages/element", "@wordpress/escape-html": "file:packages/escape-html", "@wordpress/i18n": "file:packages/i18n", + "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/keycodes": "file:packages/keycodes", "memize": "^1.1.0", "rememo": "^4.0.2" @@ -28873,7 +28874,7 @@ "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", "dev": true }, "code-point-at": { diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 4217d6de58899..7d1b1789c2cd9 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -12,6 +12,7 @@ import { useCallback, forwardRef, createContext, + createPortal, } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { children as childrenSource } from '@wordpress/blocks'; @@ -280,6 +281,7 @@ function RichTextWrapper( getValue, onChange, ref: richTextRef, + replacementRefs, } = useRichText( { value: adjustedValue, onChange( html, { __unstableFormats, __unstableText } ) { @@ -413,6 +415,41 @@ function RichTextWrapper( 'rich-text' ) } /> + { Object.keys( replacementRefs ).map( ( index ) => { + const ref = replacementRefs[ index ]; + const { render: Render } = formatTypes.find( + ( { name } ) => name === ref.value + ); + const i = parseInt( index, 10 ); + function setAttributes( attributes ) { + const newReplacements = value.replacements.slice(); + const currentObject = newReplacements[ i ]; + newReplacements[ i ] = { + ...currentObject, + attributes: { + ...currentObject.attributes, + ...attributes, + }, + }; + onChange( { + ...value, + replacements: newReplacements, + } ); + } + return ( + ref && + createPortal( + , + ref + ) + ); + } ) } ); } diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index eec1a25e5f6d8..70b400a48efd1 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -52,6 +52,7 @@ @import "./query-pagination-numbers/editor.scss"; @import "./post-featured-image/editor.scss"; @import "./post-comments-form/editor.scss"; +@import "./footnotes/editor.scss"; @import "./editor-elements.scss"; diff --git a/packages/block-library/src/footnotes/block.json b/packages/block-library/src/footnotes/block.json new file mode 100644 index 0000000000000..9021768b7aa49 --- /dev/null +++ b/packages/block-library/src/footnotes/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "core/footnotes", + "title": "Footnotes", + "category": "text", + "description": "", + "keywords": [ "references" ], + "textdomain": "default", + "supports": { + "html": false + }, + "editorStyle": "wp-block-footnotes-editor", + "style": "wp-block-footnotes" +} diff --git a/packages/block-library/src/footnotes/edit.js b/packages/block-library/src/footnotes/edit.js new file mode 100644 index 0000000000000..56ffbbc758e38 --- /dev/null +++ b/packages/block-library/src/footnotes/edit.js @@ -0,0 +1,61 @@ +/** + * WordPress dependencies + */ +import { useBlockProps } from '@wordpress/block-editor'; +import { Fragment, useState } from '@wordpress/element'; +import { useRefEffect } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { Slot } from './slot-fill'; + +export default function FootnotesEdit() { + const [ order, setOrder ] = useState( [] ); + const ref = useRefEffect( ( element ) => { + const { ownerDocument } = element; + const { defaultView } = ownerDocument; + const config = { childList: true, subtree: true }; + const observer = new defaultView.MutationObserver( () => { + const newOrder = Array.from( + ownerDocument.querySelectorAll( 'a.note-link' ) + ).map( ( node ) => { + return node.getAttribute( 'href' ).slice( 1 ); + } ); + setOrder( ( state ) => + state.join( '' ) === newOrder.join( '' ) ? state : newOrder + ); + } ); + + observer.observe( ownerDocument, config ); + return () => { + observer.disconnect(); + }; + }, [] ); + return ( + + ); +} diff --git a/packages/block-library/src/footnotes/editor.scss b/packages/block-library/src/footnotes/editor.scss new file mode 100644 index 0000000000000..594a0cc0815f6 --- /dev/null +++ b/packages/block-library/src/footnotes/editor.scss @@ -0,0 +1,11 @@ +.editor-styles-wrapper { + counter-reset: footnotes; +} + +.note-link { + counter-increment: footnotes; +} + +.note-link::after { + content: "[" counter(footnotes) "]"; +} diff --git a/packages/block-library/src/footnotes/format.js b/packages/block-library/src/footnotes/format.js new file mode 100644 index 0000000000000..43685a61a44a7 --- /dev/null +++ b/packages/block-library/src/footnotes/format.js @@ -0,0 +1,163 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { insertObject } from '@wordpress/rich-text'; +import { + RichText, + RichTextToolbarButton, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { formatListNumbered } from '@wordpress/icons'; +import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; +import { createBlock } from '@wordpress/blocks'; +import { useId, useRef } from '@wordpress/element'; +import { useMergeRefs, useRefEffect } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { Fill } from './slot-fill'; + +const name = 'core/footnote'; +const title = __( 'Footnote' ); + +export const format = { + name, + title, + tagName: 'data', + attributes: { + note: ( element ) => + element.innerHTML.replace( /^\[/, '' ).replace( /\]$/, '' ), + }, + render: function Render( { + attributes: { note }, + setAttributes, + isSelected, + } ) { + const id = useId(); + const linkRef = useRef(); + const footnoteRef = useRef(); + + function onClickBackLink( event ) { + linkRef.current.focus(); + event.preventDefault(); + } + + return ( + + { + footnoteRef.current.focus(); + event.preventDefault(); + } } + > + { '' } + + +
  • + { + if ( + element.ownerDocument + .activeElement === + element.ownerDocument.body && + isSelected + ) { + element.focus(); + } + }, + [ isSelected ] + ), + ] ) } + tagName="span" + value={ note } + onChange={ ( value ) => + setAttributes( { note: value } ) + } + />{ ' ' } + + ↩︎ + +
  • +
    +
    + ); + }, + saveFallback( { attributes: { note } } ) { + return `[${ note }]`; + }, + edit: function Edit( { isObjectActive, value, onChange } ) { + const registry = useRegistry(); + const { + getSelectedBlockClientId, + getBlockRootClientId, + getBlockName, + getBlocks, + } = useSelect( blockEditorStore ); + const { insertBlock } = useDispatch( blockEditorStore ); + + function onClick() { + registry.batch( () => { + const newValue = insertObject( value, { + tagName: 'data', + type: name, + attributes: { + note: '', + }, + } ); + newValue.start = newValue.end - 1; + + const flattenBlocks = ( blocks ) => + blocks.reduce( + ( acc, block ) => [ + ...acc, + block, + ...flattenBlocks( block.innerBlocks ), + ], + [] + ); + + let fnBlock = flattenBlocks( getBlocks() ).find( + ( block ) => block.name === 'core/footnotes' + ); + + if ( ! fnBlock ) { + const clientId = getSelectedBlockClientId(); + let rootClientId = getBlockRootClientId( clientId ); + + while ( + rootClientId && + getBlockName( rootClientId ) !== 'core/post-content' + ) { + rootClientId = getBlockRootClientId( rootClientId ); + } + + fnBlock = createBlock( 'core/footnotes' ); + + insertBlock( fnBlock, undefined, rootClientId ); + } + + onChange( newValue ); + } ); + } + + return ( + <> + + + ); + }, +}; diff --git a/packages/block-library/src/footnotes/index.js b/packages/block-library/src/footnotes/index.js new file mode 100644 index 0000000000000..b5856933dac2c --- /dev/null +++ b/packages/block-library/src/footnotes/index.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { group as icon } from '@wordpress/icons'; +import { registerFormatType } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import edit from './edit'; +import metadata from './block.json'; +import save from './save'; +import { format } from './format'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + icon, + edit, + save, +}; + +export const init = () => { + initBlock( { name, metadata, settings } ); + registerFormatType( 'core/footnote', format ); +}; diff --git a/packages/block-library/src/footnotes/save.js b/packages/block-library/src/footnotes/save.js new file mode 100644 index 0000000000000..8d83a97154dc7 --- /dev/null +++ b/packages/block-library/src/footnotes/save.js @@ -0,0 +1,3 @@ +export default function Save() { + return null; +} diff --git a/packages/block-library/src/footnotes/slot-fill.js b/packages/block-library/src/footnotes/slot-fill.js new file mode 100644 index 0000000000000..8fc24ebcb21a0 --- /dev/null +++ b/packages/block-library/src/footnotes/slot-fill.js @@ -0,0 +1,6 @@ +/** + * WordPress dependencies + */ +import { createSlotFill } from '@wordpress/components'; + +export const { Fill, Slot } = createSlotFill( 'fn' ); diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 581b2658fa52d..eb83d9efb3076 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -118,6 +118,7 @@ import * as termDescription from './term-description'; import * as textColumns from './text-columns'; import * as verse from './verse'; import * as video from './video'; +import * as footnotes from './footnotes'; import isBlockMetadataExperimental from './utils/is-block-metadata-experimental'; @@ -177,6 +178,7 @@ const getAllBlocks = () => { textColumns, verse, video, + footnotes, // theme blocks navigation, diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json index bb45ed339082a..61d95d0b98a90 100644 --- a/packages/rich-text/package.json +++ b/packages/rich-text/package.json @@ -37,6 +37,7 @@ "@wordpress/element": "file:../element", "@wordpress/escape-html": "file:../escape-html", "@wordpress/i18n": "file:../i18n", + "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/keycodes": "file:../keycodes", "memize": "^1.1.0", "rememo": "^4.0.2" diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index 8e8d1fa8f1ffe..303027e0036d8 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -4,6 +4,7 @@ import { useRef, useLayoutEffect, useReducer } from '@wordpress/element'; import { useMergeRefs, useRefEffect } from '@wordpress/compose'; import { useRegistry } from '@wordpress/data'; +import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies @@ -70,11 +71,27 @@ export function useRichText( { __unstableDomOnly: domOnly, placeholder, } ); + + const newRefs = {}; + const dataElements = Array.from( + ref.current.querySelectorAll( 'data' ) + ); + newRecord.replacements.forEach( ( replacement, i ) => { + if ( replacement.tagName !== 'data' ) return; + newRefs[ i ] = dataElements.shift(); + } ); + + // check if the new refs are different from the old refs + if ( ! isShallowEqual( replacementRefs.current, newRefs ) ) { + replacementRefs.current = newRefs; + forceRender(); + } } // Internal values are updated synchronously, unlike props and state. const _value = useRef( value ); const record = useRef(); + const replacementRefs = useRef( {} ); function setRecordFromProps() { _value.current = value; @@ -158,7 +175,8 @@ export function useRichText( { // the content change happens. // We batch both calls to only attempt to rerender once. registry.batch( () => { - onSelectionChange( start, end ); + if ( start !== undefined && end !== undefined ) + onSelectionChange( start, end ); onChange( _value.current, { __unstableFormats: formats, __unstableText: text, @@ -258,6 +276,7 @@ export function useRichText( { getValue: () => record.current, onChange: handleChange, ref: mergedRefs, + replacementRefs: replacementRefs.current, }; } diff --git a/packages/rich-text/src/component/use-select-object.js b/packages/rich-text/src/component/use-select-object.js index 0866815be1575..7dd5bb9f2aeb3 100644 --- a/packages/rich-text/src/component/use-select-object.js +++ b/packages/rich-text/src/component/use-select-object.js @@ -9,7 +9,11 @@ export function useSelectObject() { const { target } = event; // If the child element has no text content, it must be an object. - if ( target === element || target.textContent ) { + if ( + target === element || + target.textContent || + ! target.isContentEditable + ) { return; } diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index 7fdcf8adba762..af0a49c635a5a 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -426,6 +426,35 @@ function createFromElement( { continue; } + dataFormat: if ( tagName === 'data' ) { + const { value: type, dataset } = node; + const formatType = select( richTextStore ).getFormatType( type ); + if ( ! formatType ) break dataFormat; + const clonedDataset = { ...dataset }; + const { attributes } = formatType; + + for ( const key in attributes ) { + if ( clonedDataset[ key ] === undefined ) { + clonedDataset[ key ] = attributes[ key ]( node ); + } + } + + const value = { + formats: [ , ], + replacements: [ + { + type, + attributes: clonedDataset, + tagName, + }, + ], + text: OBJECT_REPLACEMENT_CHARACTER, + }; + accumulateSelection( accumulator, node, range, value ); + mergePair( accumulator, value ); + continue; + } + if ( tagName === 'br' ) { accumulateSelection( accumulator, node, range, createEmptyValue() ); mergePair( accumulator, create( { text: '\n' } ) ); diff --git a/packages/rich-text/src/register-format-type.js b/packages/rich-text/src/register-format-type.js index 8ea19a97f595f..2052761954bfa 100644 --- a/packages/rich-text/src/register-format-type.js +++ b/packages/rich-text/src/register-format-type.js @@ -62,7 +62,8 @@ export function registerFormatType( name, settings ) { if ( ( typeof settings.className !== 'string' || settings.className === '' ) && - settings.className !== null + settings.className !== null && + settings.tagName !== 'data' ) { window.console.error( 'Format class names must be a string, or null to handle bare elements.' @@ -77,30 +78,32 @@ export function registerFormatType( name, settings ) { return; } - if ( settings.className === null ) { - const formatTypeForBareElement = select( - richTextStore - ).getFormatTypeForBareElement( settings.tagName ); + if ( settings.tagName !== 'data' ) { + if ( settings.className === null ) { + const formatTypeForBareElement = select( + richTextStore + ).getFormatTypeForBareElement( settings.tagName ); - if ( - formatTypeForBareElement && - formatTypeForBareElement.name !== 'core/unknown' - ) { - window.console.error( - `Format "${ formatTypeForBareElement.name }" is already registered to handle bare tag name "${ settings.tagName }".` - ); - return; - } - } else { - const formatTypeForClassName = select( - richTextStore - ).getFormatTypeForClassName( settings.className ); + if ( + formatTypeForBareElement && + formatTypeForBareElement.name !== 'core/unknown' + ) { + window.console.error( + `Format "${ formatTypeForBareElement.name }" is already registered to handle bare tag name "${ settings.tagName }".` + ); + return; + } + } else { + const formatTypeForClassName = select( + richTextStore + ).getFormatTypeForClassName( settings.className ); - if ( formatTypeForClassName ) { - window.console.error( - `Format "${ formatTypeForClassName.name }" is already registered to handle class name "${ settings.className }".` - ); - return; + if ( formatTypeForClassName ) { + window.console.error( + `Format "${ formatTypeForClassName.name }" is already registered to handle class name "${ settings.className }".` + ); + return; + } } } diff --git a/packages/rich-text/src/to-dom.js b/packages/rich-text/src/to-dom.js index 4e8a51ae5cb0e..8d3c6ca9b1dc3 100644 --- a/packages/rich-text/src/to-dom.js +++ b/packages/rich-text/src/to-dom.js @@ -61,7 +61,7 @@ function append( element, child ) { child = element.ownerDocument.createTextNode( child ); } - const { type, attributes } = child; + const { type, attributes, dataset } = child; if ( type ) { child = element.ownerDocument.createElement( type ); @@ -69,6 +69,12 @@ function append( element, child ) { for ( const key in attributes ) { child.setAttribute( key, attributes[ key ] ); } + + for ( const key in dataset ) { + if ( dataset[ key ] ) { + child.dataset[ key ] = dataset[ key ]; + } + } } return element.appendChild( child ); @@ -240,7 +246,10 @@ export function applyValue( future, current ) { } } - applyValue( futureChild, currentChild ); + if ( currentChild.nodeName !== 'DATA' ) { + applyValue( futureChild, currentChild ); + } + future.removeChild( futureChild ); } } else { diff --git a/packages/rich-text/src/to-html-string.js b/packages/rich-text/src/to-html-string.js index 05a77211db983..6b5e12dd30752 100644 --- a/packages/rich-text/src/to-html-string.js +++ b/packages/rich-text/src/to-html-string.js @@ -91,10 +91,11 @@ function remove( object ) { return object; } -function createElementHTML( { type, attributes, object, children } ) { +function createElementHTML( { type, attributes, dataset, object, children } ) { let attributeString = ''; for ( const key in attributes ) { + if ( ! attributes[ key ] ) continue; if ( ! isValidAttributeName( key ) ) { continue; } @@ -104,6 +105,20 @@ function createElementHTML( { type, attributes, object, children } ) { ) }"`; } + for ( const key in dataset ) { + if ( ! dataset[ key ] ) continue; + + const htmlKey = key.replace( /[A-Z]/g, '-$&' ).toLowerCase(); + + if ( ! isValidAttributeName( htmlKey ) ) { + continue; + } + + attributeString += ` data-${ htmlKey }="${ escapeAttribute( + dataset[ key ] + ) }"`; + } + if ( object ) { return `<${ type }${ attributeString }>`; } diff --git a/packages/rich-text/src/to-tree.js b/packages/rich-text/src/to-tree.js index 74cc08581e83c..65f6f57b0dd4e 100644 --- a/packages/rich-text/src/to-tree.js +++ b/packages/rich-text/src/to-tree.js @@ -1,3 +1,8 @@ +/** + * WordPress dependencies + */ +import { renderToString } from '@wordpress/element'; + /** * Internal dependencies */ @@ -291,7 +296,11 @@ export function toTree( { } if ( character === OBJECT_REPLACEMENT_CHARACTER ) { - if ( ! isEditableTree && replacements[ i ]?.type === 'script' ) { + const replacement = replacements[ i ]; + if ( ! replacement ) continue; + const { type, attributes } = replacement; + const formatType = getFormatType( type ); + if ( ! isEditableTree && type === 'script' ) { pointer = append( getParent( pointer ), fromFormat( { @@ -301,14 +310,37 @@ export function toTree( { ); append( pointer, { html: decodeURIComponent( - replacements[ i ].attributes[ 'data-rich-text-script' ] + attributes[ 'data-rich-text-script' ] ), } ); + } else if ( formatType?.tagName === 'data' ) { + const clonedAttributes = { ...attributes }; + let html; + + if ( ! isEditableTree && formatType.saveFallback ) { + html = renderToString( + formatType.saveFallback( { attributes } ) + ); + for ( const key in formatType.attributes ) { + delete clonedAttributes[ key ]; + } + } + + pointer = append( getParent( pointer ), { + type: 'data', + attributes: { + contenteditable: isEditableTree ? 'false' : undefined, + value: type, + }, + dataset: clonedAttributes, + } ); + + if ( html ) append( pointer, { html } ); } else { pointer = append( getParent( pointer ), fromFormat( { - ...replacements[ i ], + ...replacement, object: true, isEditableTree, } )