From a78e12cf236c4694b7c39fe79cfb894e31c5fdd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20van=C2=A0Durpe?= Date: Tue, 4 Apr 2023 15:33:59 +0300 Subject: [PATCH 01/18] Rich text: add dynamic data API --- lib/client-assets.php | 54 +++++++++++++++++++ .../src/components/rich-text/index.js | 11 ++++ packages/format-library/package.json | 1 + .../format-library/src/current-year/index.js | 43 +++++++++++++++ .../format-library/src/default-formats.js | 2 + packages/rich-text/src/component/index.js | 12 +++++ .../src/component/use-select-object.js | 6 ++- packages/rich-text/src/create.js | 29 ++++++++++ .../rich-text/src/register-format-type.js | 49 +++++++++-------- packages/rich-text/src/to-dom.js | 13 ++++- packages/rich-text/src/to-html-string.js | 17 +++++- packages/rich-text/src/to-tree.js | 38 +++++++++++-- 12 files changed, 245 insertions(+), 30 deletions(-) create mode 100644 packages/format-library/src/current-year/index.js diff --git a/lib/client-assets.php b/lib/client-assets.php index 0f6e64c27c0144..41135a79d80848 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -565,3 +565,57 @@ function gutenberg_register_vendor_scripts( $scripts ) { // Enqueue stored styles. add_action( 'wp_enqueue_scripts', 'gutenberg_enqueue_stored_styles' ); add_action( 'wp_footer', 'gutenberg_enqueue_stored_styles', 1 ); + +/** + * Given a string of HTML, replaces all matching tags with the result of + * the callback function. + * + * @param string $content The HTML content. + * @param string $type The type of data. + * @param callable $callback The callback function, called with the data + * attributes and fallback content. + * + * @return string The HTML content with the tags replaced. + */ +function replaceDataByType( $content, $type, $callback ) { + if ( ! strpos( $content, ']+)>(.*?)<\/data>/', + function( $matches ) use ( $type, $callback ) { + // shortcode_parse_atts works on HTML attributes too. + $attrs = shortcode_parse_atts( $matches[1] ); + $fallback = $matches[2]; + + if ( ! isset( $attrs['value'] ) || $attrs['value'] !== $type ) { + return $matches[0]; + } + + $data = array(); + + foreach ( $attrs as $key => $value ) { + if ( strpos( $key, 'data-' ) === 0 ) { + $data[ substr( $key, 5 ) ] = $value; + } + } + + return $callback( $data, $fallback ); + }, + $content + ); +} + +add_filter( + 'render_block', + function( $block_content ) { + return replaceDataByType( + $block_content, + 'core/current-year', + function() { + return ''; + } + ); + } +); diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 030df831886f55..7fd026888b8530 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'; @@ -281,6 +282,7 @@ function RichTextWrapper( getValue, onChange, ref: richTextRef, + replacementRefs, } = useRichText( { value: adjustedValue, onChange( html, { __unstableFormats, __unstableText } ) { @@ -414,6 +416,15 @@ function RichTextWrapper( 'rich-text' ) } /> + { replacementRefs.map( ( ref ) => { + const { render: Render } = formatTypes.find( + ( { name } ) => name === ref.value + ); + return ( + ref && + createPortal( , ref ) + ); + } ) } ); } diff --git a/packages/format-library/package.json b/packages/format-library/package.json index a11861ef9635a7..51697fe43bf836 100644 --- a/packages/format-library/package.json +++ b/packages/format-library/package.json @@ -31,6 +31,7 @@ "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", + "@wordpress/date": "file:../date", "@wordpress/element": "file:../element", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", diff --git a/packages/format-library/src/current-year/index.js b/packages/format-library/src/current-year/index.js new file mode 100644 index 00000000000000..503783e09dea6f --- /dev/null +++ b/packages/format-library/src/current-year/index.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { insertObject } from '@wordpress/rich-text'; +import { RichTextToolbarButton } from '@wordpress/block-editor'; +import { formatListNumbered } from '@wordpress/icons'; +import { dateI18n } from '@wordpress/date'; + +const name = 'core/current-year'; +const title = __( 'Current Year' ); + +function Time() { + const year = dateI18n( 'Y', new Date() ); + return ; +} + +export const currentYear = { + name, + title, + tagName: 'data', + render: Time, + saveFallback: Time, + edit( { isObjectActive, value, onChange, onFocus } ) { + function onClick() { + const newValue = insertObject( value, { + type: name, + } ); + newValue.start = newValue.end - 1; + onChange( newValue ); + onFocus(); + } + + return ( + + ); + }, +}; diff --git a/packages/format-library/src/default-formats.js b/packages/format-library/src/default-formats.js index 791cabb1f118e4..e7653308fdd316 100644 --- a/packages/format-library/src/default-formats.js +++ b/packages/format-library/src/default-formats.js @@ -13,6 +13,7 @@ import { subscript } from './subscript'; import { superscript } from './superscript'; import { keyboard } from './keyboard'; import { unknown } from './unknown'; +import { currentYear } from './current-year'; export default [ bold, @@ -27,4 +28,5 @@ export default [ superscript, keyboard, unknown, + currentYear, ]; diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index 8e8d1fa8f1ffeb..35c806fc006670 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -19,6 +19,7 @@ import { useSelectObject } from './use-select-object'; import { useInputAndSelection } from './use-input-and-selection'; import { useSelectionChangeCompat } from './use-selection-change-compat'; import { useDelete } from './use-delete'; +import { getFormatType } from '../get-format-type'; export function useRichText( { value = '', @@ -70,11 +71,21 @@ export function useRichText( { __unstableDomOnly: domOnly, placeholder, } ); + + Array.from( ref.current.querySelectorAll( 'data' ) ) + .filter( ( node ) => !! getFormatType( node.value ) ) + .forEach( ( node, i ) => { + if ( replacementRefs.current[ i ] !== node ) { + replacementRefs.current[ i ] = node; + 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; @@ -258,6 +269,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 0866815be15758..be8c17d9fd1fc4 100644 --- a/packages/rich-text/src/component/use-select-object.js +++ b/packages/rich-text/src/component/use-select-object.js @@ -9,7 +9,10 @@ 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; } @@ -21,6 +24,7 @@ export function useSelectObject() { range.selectNode( target ); selection.removeAllRanges(); selection.addRange( range ); + event.preventDefault(); } element.addEventListener( 'click', onClick ); diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index 7fdcf8adba7628..af0a49c635a5a5 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 8ea19a97f595ff..2052761954bfa0 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 4e8a51ae5cb0e5..8d3c6ca9b1dc37 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 05a77211db9831..6b5e12dd30752a 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 74cc08581e83c4..65f6f57b0dd4ed 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, } ) From 4a626af21f3030443786003c9d47755a2e8c720b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20van=C2=A0Durpe?= Date: Wed, 1 Feb 2023 22:10:46 +0200 Subject: [PATCH 02/18] Try with shortcode --- lib/client-assets.php | 69 +++++++++++++++++++ .../format-library/src/default-formats.js | 2 + packages/format-library/src/footnote/index.js | 35 ++++++++++ 3 files changed, 106 insertions(+) create mode 100644 packages/format-library/src/footnote/index.js diff --git a/lib/client-assets.php b/lib/client-assets.php index 41135a79d80848..4f981e6708a83c 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -619,3 +619,72 @@ function() { ); } ); + + +// We'd like to do some custom handling without attribute parsing. +add_shortcode( '#', function( $attr, $content, $tag ) { + return $content; +} ); + +add_filter( 'do_shortcode_tag' , function( $output, $tag, $attr, $m ) { + if ( $tag !== '#' ) { + return $output; + } + + // $m is the match: + // $m[0] is the full match. + // $m[1] is to check shortcode escaping. + // $m[2] is the shortcode name. + // $m[3] is the contents within the shortcode after the name. This includes + // the space between the name and the contents. + // $m[5] is the contents between the opening and closing shortcode tags (if + // any). + $note = $m[3]; + $content = isset( $m[5] ) ? $m[5] : ''; + + // To do: find the most accessible markup. + return ( + '' . + $content . + '' . + '' . + $note . + '' . + // To do: move to a proper stylesheet. + // The numbering is just a stylistic choice, it could also just be an + // asterisk or something else. We're not creating a numbered list so it + // doesn't matter. + '' + ); +}, 10, 4 ); diff --git a/packages/format-library/src/default-formats.js b/packages/format-library/src/default-formats.js index e7653308fdd316..aa048557504f13 100644 --- a/packages/format-library/src/default-formats.js +++ b/packages/format-library/src/default-formats.js @@ -14,6 +14,7 @@ import { superscript } from './superscript'; import { keyboard } from './keyboard'; import { unknown } from './unknown'; import { currentYear } from './current-year'; +import { footnote } from './footnote'; export default [ bold, @@ -29,4 +30,5 @@ export default [ keyboard, unknown, currentYear, + footnote, ]; diff --git a/packages/format-library/src/footnote/index.js b/packages/format-library/src/footnote/index.js new file mode 100644 index 00000000000000..780e05bec653cf --- /dev/null +++ b/packages/format-library/src/footnote/index.js @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { insert } from '@wordpress/rich-text'; +import { RichTextToolbarButton } from '@wordpress/block-editor'; +import { formatListNumbered } from '@wordpress/icons'; + +const name = 'core/footnote'; +const title = __( 'Footnote' ); + +export const footnote = { + name, + title, + tagName: 'ruby', + className: 'core-footnote', + edit( { isActive, value, onChange, onFocus } ) { + function onClick() { + const newValue = insert( value, '[# ]' ); + newValue.start -= 1; + newValue.end -= 1; + onChange( newValue ); + onFocus(); + } + + return ( + + ); + }, +}; From 9d48aa9def068d6dc32ba74fc48897ac30953f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20van=C2=A0Durpe?= Date: Thu, 2 Feb 2023 12:40:35 +0200 Subject: [PATCH 03/18] Try rendering below content again. --- lib/client-assets.php | 160 +++++++++++++++++++++++++----------------- 1 file changed, 97 insertions(+), 63 deletions(-) diff --git a/lib/client-assets.php b/lib/client-assets.php index 4f981e6708a83c..b14d1382007971 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -622,69 +622,103 @@ function() { // We'd like to do some custom handling without attribute parsing. -add_shortcode( '#', function( $attr, $content, $tag ) { - return $content; -} ); +// add_shortcode( '#', function( $attr, $content, $tag ) { +// return $content; +// } ); + +// add_filter( 'do_shortcode_tag' , function( $output, $tag, $attr, $m ) { +// if ( $tag !== '#' ) { +// return $output; +// } + +// // $m is the match: +// // $m[0] is the full match. +// // $m[1] is to check shortcode escaping. +// // $m[2] is the shortcode name. +// // $m[3] is the contents within the shortcode after the name. This includes +// // the space between the name and the contents. +// // $m[5] is the contents between the opening and closing shortcode tags (if +// // any). +// $note = $m[3]; +// $content = isset( $m[5] ) ? $m[5] : ''; + +// // To do: find the most accessible markup. +// return ( +// '' . +// $content . +// '' . +// '' . +// $note . +// '' . +// // To do: move to a proper stylesheet. +// // The numbering is just a stylistic choice, it could also just be an +// // asterisk or something else. We're not creating a numbered list so it +// // doesn't matter. +// '' +// ); +// }, 10, 4 ); + +// To do: make it work with pagination, excerpt. +add_filter( 'the_content', function( $content ) { + if ( strpos( $content, '[#' ) === false ) { + return $content; + } + + $notes = array(); + + $content = preg_replace_callback( '/\[#\s([^\]]+)\]/', function( $matches ) use ( &$notes ) { + $notes[] = $matches[1]; + $id = md5( $matches[1] ); + return ''; + }, $content ); -add_filter( 'do_shortcode_tag' , function( $output, $tag, $attr, $m ) { - if ( $tag !== '#' ) { - return $output; + $list = '
    '; + + foreach ( $notes as $note ) { + $id = md5( $note ); + + $list .= '
  1. '; + $list .= ''; + $list .= ' ' . $note; + $list .= '
  2. '; } - // $m is the match: - // $m[0] is the full match. - // $m[1] is to check shortcode escaping. - // $m[2] is the shortcode name. - // $m[3] is the contents within the shortcode after the name. This includes - // the space between the name and the contents. - // $m[5] is the contents between the opening and closing shortcode tags (if - // any). - $note = $m[3]; - $content = isset( $m[5] ) ? $m[5] : ''; - - // To do: find the most accessible markup. - return ( - '' . - $content . - '' . - '' . - $note . - '' . - // To do: move to a proper stylesheet. - // The numbering is just a stylistic choice, it could also just be an - // asterisk or something else. We're not creating a numbered list so it - // doesn't matter. - '' - ); -}, 10, 4 ); + $list .= '
'; + + // To do: move to a proper stylesheet. + // To do: need to add a post class instead of using .entry-content. + $style = ''; + + return $content . $list . $style; +} ); From 55da3a4c24d826f7d9b855aa9538aa178ed09b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20van=C2=A0Durpe?= Date: Tue, 28 Mar 2023 19:08:53 +0300 Subject: [PATCH 04/18] wip --- lib/client-assets.php | 2 +- packages/format-library/src/footnote/index.js | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/client-assets.php b/lib/client-assets.php index b14d1382007971..f3a09a99b11232 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -718,7 +718,7 @@ function() { // To do: move to a proper stylesheet. // To do: need to add a post class instead of using .entry-content. - $style = ''; + $style = ''; return $content . $list . $style; } ); diff --git a/packages/format-library/src/footnote/index.js b/packages/format-library/src/footnote/index.js index 780e05bec653cf..9b6fe1d9323e3e 100644 --- a/packages/format-library/src/footnote/index.js +++ b/packages/format-library/src/footnote/index.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { insert } from '@wordpress/rich-text'; +import { applyFormat, create, insert } from '@wordpress/rich-text'; import { RichTextToolbarButton } from '@wordpress/block-editor'; import { formatListNumbered } from '@wordpress/icons'; @@ -12,11 +12,21 @@ const title = __( 'Footnote' ); export const footnote = { name, title, - tagName: 'ruby', - className: 'core-footnote', + tagName: 'sup', + className: 'footnote', edit( { isActive, value, onChange, onFocus } ) { function onClick() { - const newValue = insert( value, '[# ]' ); + const newValue = insert( + value, + applyFormat( + create( { text: '[# ]' } ), + { + type: name, + }, + 0, + 4 + ) + ); newValue.start -= 1; newValue.end -= 1; onChange( newValue ); @@ -32,4 +42,7 @@ export const footnote = { /> ); }, + save( { attributes } ) { + return `[# ${ attributes.content }]`; + }, }; From 3de9f6e11b84f4c1268bab0ae2c75024d481f714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20van=C2=A0Durpe?= Date: Thu, 30 Mar 2023 10:45:40 +0300 Subject: [PATCH 05/18] Add UI --- lib/client-assets.php | 2 +- .../src/components/rich-text/content.scss | 11 ++ .../src/components/rich-text/index.js | 88 +++++++++++- packages/format-library/src/footnote/index.js | 127 ++++++++++++++---- packages/rich-text/src/component/index.js | 12 +- 5 files changed, 196 insertions(+), 44 deletions(-) diff --git a/lib/client-assets.php b/lib/client-assets.php index f3a09a99b11232..776a3cf06aeac5 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -718,7 +718,7 @@ function() { // To do: move to a proper stylesheet. // To do: need to add a post class instead of using .entry-content. - $style = ''; + $style = ''; return $content . $list . $style; } ); diff --git a/packages/block-editor/src/components/rich-text/content.scss b/packages/block-editor/src/components/rich-text/content.scss index 6f6d88c777ea74..d1052ff9a9afb0 100644 --- a/packages/block-editor/src/components/rich-text/content.scss +++ b/packages/block-editor/src/components/rich-text/content.scss @@ -1,3 +1,6 @@ +// Should be in theme compatibility file. +.editor-styles-wrapper { counter-reset:footnotes } + .rich-text { [data-rich-text-placeholder] { pointer-events: none; @@ -18,6 +21,14 @@ border-radius: 2px; } } + + // Should be in theme compatibility file. + .note-link { counter-increment:footnotes } + .note-link::after { + content: "[" counter( footnotes ) "]"; + vertical-align: super; + font-size: smaller; + } } .block-editor-rich-text__editable { diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 7fd026888b8530..92c5457a0cb330 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -21,9 +21,12 @@ import { __unstableUseRichText as useRichText, __unstableCreateElement, removeFormat, + insertObject, + insert, } from '@wordpress/rich-text'; import deprecated from '@wordpress/deprecated'; import { Popover } from '@wordpress/components'; +import { regexp } from '@wordpress/shortcode'; /** * Internal dependencies @@ -247,11 +250,80 @@ function RichTextWrapper( allowedFormats: adjustedAllowedFormats, } ); + function parseShortcodes( value ) { + if ( value.text.indexOf( '[' ) === -1 ) { + return value; + } + + formatTypes.forEach( ( { shortcode, name } ) => { + if ( ! shortcode ) return; + + const exp = regexp( shortcode ); + + let match; + + while ( ( match = exp.exec( value.text ) ) !== null ) { + // If we matched an escaped shortcode, try again. + if ( '[' === match[ 1 ] && ']' === match[ 7 ] ) { + continue; + } + + value = insertObject( + value, + { + type: name, + attributes: { + contenteditable: 'false', + 'data-shortcode-content': match[ 3 ].trim(), + }, + }, + match.index, + match.index + match[ 0 ].length + ); + } + } ); + + return value; + } + + function serializeShortcodes( value ) { + let index; + let fromIndex = 0; + + while ( ( index = value.text.indexOf( '\ufffc', fromIndex ) ) !== -1 ) { + const object = value.replacements[ index ]; + + fromIndex = index + 1; + + const formatType = formatTypes.find( + ( type ) => type.name === object.type + ); + + if ( formatType.shortcode ) { + const { 'data-shortcode-content': content } = object.attributes; + + value = insert( + value, + `[${ formatType.shortcode }${ + content ? ' ' + content : '' + }]`, + index, + index + 1 + ); + } + } + + return value; + } + function addEditorOnlyFormats( value ) { - return valueHandlers.reduce( - ( accumulator, fn ) => fn( accumulator, value.text ), - value.formats - ); + return { + ...value, + formats: valueHandlers.reduce( + ( accumulator, fn ) => fn( accumulator, value.text ), + value.formats + ), + }; } function removeEditorOnlyFormats( value ) { @@ -267,7 +339,7 @@ function RichTextWrapper( } } ); - return value.formats; + return value; } function addInvisibleFormats( value ) { @@ -300,8 +372,10 @@ function RichTextWrapper( __unstableDisableFormats: disableFormats, preserveWhiteSpace, __unstableDependencies: [ ...dependencies, tagName ], - __unstableAfterParse: addEditorOnlyFormats, - __unstableBeforeSerialize: removeEditorOnlyFormats, + __unstableAfterParse: ( v ) => + addEditorOnlyFormats( parseShortcodes( v ) ), + __unstableBeforeSerialize: ( v ) => + removeEditorOnlyFormats( serializeShortcodes( v ) ), __unstableAddInvisibleFormats: addInvisibleFormats, } ); const autocompleteProps = useBlockEditorAutocompleteProps( { diff --git a/packages/format-library/src/footnote/index.js b/packages/format-library/src/footnote/index.js index 9b6fe1d9323e3e..65c49f3d9f46c1 100644 --- a/packages/format-library/src/footnote/index.js +++ b/packages/format-library/src/footnote/index.js @@ -2,9 +2,16 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { applyFormat, create, insert } from '@wordpress/rich-text'; +import { insertObject, useAnchor } from '@wordpress/rich-text'; import { RichTextToolbarButton } from '@wordpress/block-editor'; -import { formatListNumbered } from '@wordpress/icons'; +import { formatListNumbered, keyboardReturn } from '@wordpress/icons'; +import { + Popover, + Button, + TextControl, + __experimentalHStack as HStack, +} from '@wordpress/components'; +import { useState } from '@wordpress/element'; const name = 'core/footnote'; const title = __( 'Footnote' ); @@ -12,37 +19,103 @@ const title = __( 'Footnote' ); export const footnote = { name, title, - tagName: 'sup', - className: 'footnote', - edit( { isActive, value, onChange, onFocus } ) { + tagName: 'a', + className: 'note-link', + shortcode: '#', + edit( { + isObjectActive, + value, + onChange, + onFocus, + contentRef, + activeObjectAttributes, + } ) { function onClick() { - const newValue = insert( - value, - applyFormat( - create( { text: '[# ]' } ), - { - type: name, - }, - 0, - 4 - ) - ); - newValue.start -= 1; - newValue.end -= 1; + const newValue = insertObject( value, { + type: name, + attributes: { + contenteditable: 'false', + 'data-shortcode-content': '', + }, + } ); + newValue.start = newValue.end - 1; onChange( newValue ); onFocus(); } return ( - + <> + + { isObjectActive && ( + + ) } + ); }, - save( { attributes } ) { - return `[# ${ attributes.content }]`; - }, }; + +function InlineUI( { value, onChange, activeObjectAttributes, contentRef } ) { + const { 'data-shortcode-content': shortcodeContent } = + activeObjectAttributes; + const [ note, setNote ] = useState( shortcodeContent ); + const popoverAnchor = useAnchor( { + editableContentElement: contentRef.current, + settings: footnote, + } ); + + return ( + +
{ + const newReplacements = value.replacements.slice(); + + newReplacements[ value.start ] = { + type: name, + attributes: { + ...activeObjectAttributes, + 'data-shortcode-content': note, + }, + }; + + onChange( { + ...value, + replacements: newReplacements, + } ); + + event.preventDefault(); + } } + > + + setNote( newNote ) } + /> +