diff --git a/packages/core-data/src/entity-provider.js b/packages/core-data/src/entity-provider.js index d32b3853627b32..e2274629006ee2 100644 --- a/packages/core-data/src/entity-provider.js +++ b/packages/core-data/src/entity-provider.js @@ -9,20 +9,17 @@ import { } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; -import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ import { STORE_NAME } from './name'; -import { unlock } from './private-apis'; +import { updateFootnotesFromMeta } from './footnotes'; /** @typedef {import('@wordpress/blocks').WPBlock} WPBlock */ const EMPTY_ARRAY = []; -let oldFootnotes = {}; - /** * Internal dependencies */ @@ -182,136 +179,7 @@ export function useEntityBlockEditor( kind, name, { id: _id } = {} ) { }, [ editedBlocks, content ] ); const updateFootnotes = useCallback( - ( _blocks ) => { - const output = { blocks: _blocks }; - if ( ! meta ) return output; - // If meta.footnotes is empty, it means the meta is not registered. - if ( meta.footnotes === undefined ) return output; - - const { getRichTextValues } = unlock( blockEditorPrivateApis ); - const _content = getRichTextValues( _blocks ).join( '' ) || ''; - const newOrder = []; - - // This can be avoided when - // https://github.com/WordPress/gutenberg/pull/43204 lands. We can then - // get the order directly from the rich text values. - if ( _content.indexOf( 'data-fn' ) !== -1 ) { - const regex = /data-fn="([^"]+)"/g; - let match; - while ( ( match = regex.exec( _content ) ) !== null ) { - newOrder.push( match[ 1 ] ); - } - } - - const footnotes = meta.footnotes - ? JSON.parse( meta.footnotes ) - : []; - const currentOrder = footnotes.map( ( fn ) => fn.id ); - - if ( currentOrder.join( '' ) === newOrder.join( '' ) ) - return output; - - const newFootnotes = newOrder.map( - ( fnId ) => - footnotes.find( ( fn ) => fn.id === fnId ) || - oldFootnotes[ fnId ] || { - id: fnId, - content: '', - } - ); - - function updateAttributes( attributes ) { - // Only attempt to update attributes, if attributes is an object. - if ( - ! attributes || - Array.isArray( attributes ) || - typeof attributes !== 'object' - ) { - return attributes; - } - - attributes = { ...attributes }; - - for ( const key in attributes ) { - const value = attributes[ key ]; - - if ( Array.isArray( value ) ) { - attributes[ key ] = value.map( updateAttributes ); - continue; - } - - if ( typeof value !== 'string' ) { - continue; - } - - if ( value.indexOf( 'data-fn' ) === -1 ) { - continue; - } - - // When we store rich text values, this would no longer - // require a regex. - const regex = - /(]+data-fn="([^"]+)"[^>]*>]*>)[\d*]*<\/a><\/sup>/g; - - attributes[ key ] = value.replace( - regex, - ( match, opening, fnId ) => { - const index = newOrder.indexOf( fnId ); - return `${ opening }${ index + 1 }`; - } - ); - - const compatRegex = - /]+data-fn="([^"]+)"[^>]*>\*<\/a>/g; - - attributes[ key ] = attributes[ key ].replace( - compatRegex, - ( match, fnId ) => { - const index = newOrder.indexOf( fnId ); - return `${ - index + 1 - }`; - } - ); - } - - return attributes; - } - - function updateBlocksAttributes( __blocks ) { - return __blocks.map( ( block ) => { - return { - ...block, - attributes: updateAttributes( block.attributes ), - innerBlocks: updateBlocksAttributes( - block.innerBlocks - ), - }; - } ); - } - - // We need to go through all block attributes deeply and update the - // footnote anchor numbering (textContent) to match the new order. - const newBlocks = updateBlocksAttributes( _blocks ); - - oldFootnotes = { - ...oldFootnotes, - ...footnotes.reduce( ( acc, fn ) => { - if ( ! newOrder.includes( fn.id ) ) { - acc[ fn.id ] = fn; - } - return acc; - }, {} ), - }; - - return { - meta: { - ...meta, - footnotes: JSON.stringify( newFootnotes ), - }, - blocks: newBlocks, - }; - }, + ( _blocks ) => updateFootnotesFromMeta( _blocks, meta ), [ meta ] ); diff --git a/packages/core-data/src/footnotes/get-footnotes-order.js b/packages/core-data/src/footnotes/get-footnotes-order.js new file mode 100644 index 00000000000000..e974c4a6e11893 --- /dev/null +++ b/packages/core-data/src/footnotes/get-footnotes-order.js @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import getRichTextValuesCached from './get-rich-text-values-cached'; + +const cache = new WeakMap(); + +function getBlockFootnotesOrder( block ) { + if ( ! cache.has( block ) ) { + const content = getRichTextValuesCached( block ).join( '' ); + const newOrder = []; + + // https://github.com/WordPress/gutenberg/pull/43204 lands. We can then + // get the order directly from the rich text values. + if ( content.indexOf( 'data-fn' ) !== -1 ) { + const regex = /data-fn="([^"]+)"/g; + let match; + while ( ( match = regex.exec( content ) ) !== null ) { + newOrder.push( match[ 1 ] ); + } + } + cache.set( block, newOrder ); + } + + return cache.get( block ); +} + +export default function getFootnotesOrder( blocks ) { + return blocks.flatMap( getBlockFootnotesOrder ); +} diff --git a/packages/core-data/src/footnotes/get-rich-text-values-cached.js b/packages/core-data/src/footnotes/get-rich-text-values-cached.js new file mode 100644 index 00000000000000..06a01c5ef63fdd --- /dev/null +++ b/packages/core-data/src/footnotes/get-rich-text-values-cached.js @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { unlock } from '../private-apis'; + +// TODO: The following line should have been: +// +// const unlockedApis = unlock( blockEditorPrivateApis ); +// +// But there are hidden circular dependencies in RNMobile code, specifically in +// certain native components in the `components` package that depend on +// `block-editor`. What follows is a workaround that defers the `unlock` call +// to prevent native code from failing. +// +// Fix once https://github.com/WordPress/gutenberg/issues/52692 is closed. +let unlockedApis; + +const cache = new WeakMap(); + +export default function getRichTextValuesCached( block ) { + if ( ! unlockedApis ) { + unlockedApis = unlock( blockEditorPrivateApis ); + } + + if ( ! cache.has( block ) ) { + const values = unlockedApis.getRichTextValues( [ block ] ); + cache.set( block, values ); + } + return cache.get( block ); +} diff --git a/packages/core-data/src/footnotes/index.js b/packages/core-data/src/footnotes/index.js new file mode 100644 index 00000000000000..b5c075b372e31e --- /dev/null +++ b/packages/core-data/src/footnotes/index.js @@ -0,0 +1,119 @@ +/** + * Internal dependencies + */ +import getFootnotesOrder from './get-footnotes-order'; + +let oldFootnotes = {}; + +export function updateFootnotesFromMeta( blocks, meta ) { + const output = { blocks }; + if ( ! meta ) return output; + + // If meta.footnotes is empty, it means the meta is not registered. + if ( meta.footnotes === undefined ) return output; + + const newOrder = getFootnotesOrder( blocks ); + + const footnotes = meta.footnotes ? JSON.parse( meta.footnotes ) : []; + const currentOrder = footnotes.map( ( fn ) => fn.id ); + + if ( currentOrder.join( '' ) === newOrder.join( '' ) ) return output; + + const newFootnotes = newOrder.map( + ( fnId ) => + footnotes.find( ( fn ) => fn.id === fnId ) || + oldFootnotes[ fnId ] || { + id: fnId, + content: '', + } + ); + + function updateAttributes( attributes ) { + // Only attempt to update attributes, if attributes is an object. + if ( + ! attributes || + Array.isArray( attributes ) || + typeof attributes !== 'object' + ) { + return attributes; + } + + attributes = { ...attributes }; + + for ( const key in attributes ) { + const value = attributes[ key ]; + + if ( Array.isArray( value ) ) { + attributes[ key ] = value.map( updateAttributes ); + continue; + } + + if ( typeof value !== 'string' ) { + continue; + } + + if ( value.indexOf( 'data-fn' ) === -1 ) { + continue; + } + + // When we store rich text values, this would no longer + // require a regex. + const regex = + /(]+data-fn="([^"]+)"[^>]*>]*>)[\d*]*<\/a><\/sup>/g; + + attributes[ key ] = value.replace( + regex, + ( match, opening, fnId ) => { + const index = newOrder.indexOf( fnId ); + return `${ opening }${ index + 1 }`; + } + ); + + const compatRegex = /]+data-fn="([^"]+)"[^>]*>\*<\/a>/g; + + attributes[ key ] = attributes[ key ].replace( + compatRegex, + ( match, fnId ) => { + const index = newOrder.indexOf( fnId ); + return `${ + index + 1 + }`; + } + ); + } + + return attributes; + } + + function updateBlocksAttributes( __blocks ) { + return __blocks.map( ( block ) => { + return { + ...block, + attributes: updateAttributes( block.attributes ), + innerBlocks: updateBlocksAttributes( block.innerBlocks ), + }; + } ); + } + + // We need to go through all block attributes deeply and update the + // footnote anchor numbering (textContent) to match the new order. + const newBlocks = updateBlocksAttributes( blocks ); + + oldFootnotes = { + ...oldFootnotes, + ...footnotes.reduce( ( acc, fn ) => { + if ( ! newOrder.includes( fn.id ) ) { + acc[ fn.id ] = fn; + } + return acc; + }, {} ), + }; + + return { + meta: { + ...meta, + footnotes: JSON.stringify( newFootnotes ), + }, + blocks: newBlocks, + }; +}