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( '- ', $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 .= '
';
+ }
+ $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,
} )