From 675ae89e90a96d35043fbfed742fabaf7b552e94 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Mon, 4 Sep 2023 14:18:44 +0400 Subject: [PATCH] Table of Contents: Use a custom store subscription for observing headings (#54094) * Table of Contents: Use a custom store subscription for observing headings Co-authored-by: Jarda Snajdr * Provide attribute default * If the block no longer exists in the store, skip the update. * Regenerate fixtures --------- Co-authored-by: Jarda Snajdr --- .../src/table-of-contents/block.json | 3 +- .../src/table-of-contents/edit.js | 167 +----------------- .../src/table-of-contents/hooks.js | 156 ++++++++++++++++ .../core__table-of-contents__empty.json | 1 + 4 files changed, 164 insertions(+), 163 deletions(-) create mode 100644 packages/block-library/src/table-of-contents/hooks.js diff --git a/packages/block-library/src/table-of-contents/block.json b/packages/block-library/src/table-of-contents/block.json index 9623263166916..5fa7e37e6acbd 100644 --- a/packages/block-library/src/table-of-contents/block.json +++ b/packages/block-library/src/table-of-contents/block.json @@ -13,7 +13,8 @@ "type": "array", "items": { "type": "object" - } + }, + "default": [] }, "onlyIncludeCurrentPage": { "type": "boolean", diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 7f3bb6529bf32..915375606b10c 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import fastDeepEqual from 'fast-deep-equal/es6'; - /** * WordPress dependencies */ @@ -22,10 +17,8 @@ import { ToolbarGroup, } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; -import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; -import { renderToString, useEffect } from '@wordpress/element'; +import { renderToString } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; /** * Internal dependencies @@ -33,6 +26,7 @@ import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; import icon from './icon'; import TableOfContentsList from './list'; import { linearToNestedHeadingList } from './utils'; +import { useObserveHeadings } from './hooks'; /** @typedef {import('./utils').HeadingData} HeadingData */ @@ -53,6 +47,8 @@ export default function TableOfContentsEdit( { clientId, setAttributes, } ) { + useObserveHeadings( clientId ); + const blockProps = useBlockProps(); const canInsertList = useSelect( @@ -66,160 +62,7 @@ export default function TableOfContentsEdit( { [ clientId ] ); - const { __unstableMarkNextChangeAsNotPersistent, replaceBlocks } = - useDispatch( blockEditorStore ); - - /** - * The latest heading data, or null if the new data deeply equals the saved - * headings attribute. - * - * Since useSelect forces a re-render when its return value is shallowly - * inequal to its prior call, we would be re-rendering this block every time - * the stores change, even if the latest headings were deeply equal to the - * ones saved in the block attributes. - * - * By returning null when they're equal, we reduce that to 2 renders: one - * when there are new latest headings (and so it returns them), and one when - * they haven't changed (so it returns null). As long as the latest heading - * data remains the same, further calls of the useSelect callback will - * continue to return null, thus preventing any forced re-renders. - */ - const latestHeadings = useSelect( - ( select ) => { - const { - getBlockAttributes, - getBlockName, - getClientIdsWithDescendants, - __experimentalGetGlobalBlocksByName: getGlobalBlocksByName, - } = select( blockEditorStore ); - - // FIXME: @wordpress/block-library should not depend on @wordpress/editor. - // Blocks can be loaded into a *non-post* block editor, so to avoid - // declaring @wordpress/editor as a dependency, we must access its - // store by string. When the store is not available, editorSelectors - // will be null, and the block's saved markup will lack permalinks. - // eslint-disable-next-line @wordpress/data-no-store-string-literals - const editorSelectors = select( 'core/editor' ); - - const pageBreakClientIds = getGlobalBlocksByName( 'core/nextpage' ); - - const isPaginated = pageBreakClientIds.length !== 0; - - // Get the client ids of all blocks in the editor. - const allBlockClientIds = getClientIdsWithDescendants(); - - // If onlyIncludeCurrentPage is true, calculate the page (of a paginated post) this block is part of, so we know which headings to include; otherwise, skip the calculation. - let tocPage = 1; - - if ( isPaginated && onlyIncludeCurrentPage ) { - // We can't use getBlockIndex because it only returns the index - // relative to sibling blocks. - const tocIndex = allBlockClientIds.indexOf( clientId ); - - for ( const [ - blockIndex, - blockClientId, - ] of allBlockClientIds.entries() ) { - // If we've reached blocks after the Table of Contents, we've - // finished calculating which page the block is on. - if ( blockIndex >= tocIndex ) { - break; - } - if ( getBlockName( blockClientId ) === 'core/nextpage' ) { - tocPage++; - } - } - } - - const _latestHeadings = []; - - /** The page (of a paginated post) a heading will be part of. */ - let headingPage = 1; - - /** - * A permalink to the current post. If the core/editor store is - * unavailable, this variable will be null. - */ - const permalink = editorSelectors?.getPermalink() ?? null; - - let headingPageLink = null; - - // If the core/editor store is available, we can add permalinks to the - // generated table of contents. - if ( typeof permalink === 'string' ) { - headingPageLink = isPaginated - ? addQueryArgs( permalink, { page: headingPage } ) - : permalink; - } - - for ( const blockClientId of allBlockClientIds ) { - const blockName = getBlockName( blockClientId ); - if ( blockName === 'core/nextpage' ) { - headingPage++; - - // If we're only including headings from the current page (of - // a paginated post), then exit the loop if we've reached the - // pages after the one with the Table of Contents block. - if ( onlyIncludeCurrentPage && headingPage > tocPage ) { - break; - } - - if ( typeof permalink === 'string' ) { - headingPageLink = addQueryArgs( - removeQueryArgs( permalink, [ 'page' ] ), - { page: headingPage } - ); - } - } - // If we're including all headings or we've reached headings on - // the same page as the Table of Contents block, add them to the - // list. - else if ( - ! onlyIncludeCurrentPage || - headingPage === tocPage - ) { - if ( blockName === 'core/heading' ) { - const headingAttributes = - getBlockAttributes( blockClientId ); - - const canBeLinked = - typeof headingPageLink === 'string' && - typeof headingAttributes.anchor === 'string' && - headingAttributes.anchor !== ''; - - _latestHeadings.push( { - // Convert line breaks to spaces, and get rid of HTML tags in the headings. - content: stripHTML( - headingAttributes.content.replace( - /(
)+/g, - ' ' - ) - ), - level: headingAttributes.level, - link: canBeLinked - ? `${ headingPageLink }#${ headingAttributes.anchor }` - : null, - } ); - } - } - } - - if ( fastDeepEqual( headings, _latestHeadings ) ) { - return null; - } - return _latestHeadings; - }, - [ clientId, onlyIncludeCurrentPage, headings ] - ); - - useEffect( () => { - if ( latestHeadings !== null ) { - // This is required to keep undo working and not create 2 undo steps - // for each heading change. - __unstableMarkNextChangeAsNotPersistent(); - setAttributes( { headings: latestHeadings } ); - } - }, [ latestHeadings ] ); + const { replaceBlocks } = useDispatch( blockEditorStore ); const headingTree = linearToNestedHeadingList( headings ); diff --git a/packages/block-library/src/table-of-contents/hooks.js b/packages/block-library/src/table-of-contents/hooks.js new file mode 100644 index 0000000000000..af7b66568123f --- /dev/null +++ b/packages/block-library/src/table-of-contents/hooks.js @@ -0,0 +1,156 @@ +/** + * External dependencies + */ +import fastDeepEqual from 'fast-deep-equal/es6'; + +/** + * WordPress dependencies + */ +import { useRegistry } from '@wordpress/data'; +import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { useEffect } from '@wordpress/element'; +import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +function getLatestHeadings( select, clientId ) { + const { + getBlockAttributes, + getBlockName, + getClientIdsWithDescendants, + __experimentalGetGlobalBlocksByName: getGlobalBlocksByName, + } = select( blockEditorStore ); + + // FIXME: @wordpress/block-library should not depend on @wordpress/editor. + // Blocks can be loaded into a *non-post* block editor, so to avoid + // declaring @wordpress/editor as a dependency, we must access its + // store by string. When the store is not available, editorSelectors + // will be null, and the block's saved markup will lack permalinks. + // eslint-disable-next-line @wordpress/data-no-store-string-literals + const permalink = select( 'core/editor' ).getPermalink() ?? null; + + const isPaginated = getGlobalBlocksByName( 'core/nextpage' ).length !== 0; + const { onlyIncludeCurrentPage } = getBlockAttributes( clientId ) ?? {}; + + // Get the client ids of all blocks in the editor. + const allBlockClientIds = getClientIdsWithDescendants(); + + // If onlyIncludeCurrentPage is true, calculate the page (of a paginated post) this block is part of, so we know which headings to include; otherwise, skip the calculation. + let tocPage = 1; + + if ( isPaginated && onlyIncludeCurrentPage ) { + // We can't use getBlockIndex because it only returns the index + // relative to sibling blocks. + const tocIndex = allBlockClientIds.indexOf( clientId ); + + for ( const [ + blockIndex, + blockClientId, + ] of allBlockClientIds.entries() ) { + // If we've reached blocks after the Table of Contents, we've + // finished calculating which page the block is on. + if ( blockIndex >= tocIndex ) { + break; + } + if ( getBlockName( blockClientId ) === 'core/nextpage' ) { + tocPage++; + } + } + } + + const latestHeadings = []; + + /** The page (of a paginated post) a heading will be part of. */ + let headingPage = 1; + let headingPageLink = null; + + // If the core/editor store is available, we can add permalinks to the + // generated table of contents. + if ( typeof permalink === 'string' ) { + headingPageLink = isPaginated + ? addQueryArgs( permalink, { page: headingPage } ) + : permalink; + } + + for ( const blockClientId of allBlockClientIds ) { + const blockName = getBlockName( blockClientId ); + if ( blockName === 'core/nextpage' ) { + headingPage++; + + // If we're only including headings from the current page (of + // a paginated post), then exit the loop if we've reached the + // pages after the one with the Table of Contents block. + if ( onlyIncludeCurrentPage && headingPage > tocPage ) { + break; + } + + if ( typeof permalink === 'string' ) { + headingPageLink = addQueryArgs( + removeQueryArgs( permalink, [ 'page' ] ), + { page: headingPage } + ); + } + } + // If we're including all headings or we've reached headings on + // the same page as the Table of Contents block, add them to the + // list. + else if ( ! onlyIncludeCurrentPage || headingPage === tocPage ) { + if ( blockName === 'core/heading' ) { + const headingAttributes = getBlockAttributes( blockClientId ); + + const canBeLinked = + typeof headingPageLink === 'string' && + typeof headingAttributes.anchor === 'string' && + headingAttributes.anchor !== ''; + + latestHeadings.push( { + // Convert line breaks to spaces, and get rid of HTML tags in the headings. + content: stripHTML( + headingAttributes.content.replace( + /(
)+/g, + ' ' + ) + ), + level: headingAttributes.level, + link: canBeLinked + ? `${ headingPageLink }#${ headingAttributes.anchor }` + : null, + } ); + } + } + } + + return latestHeadings; +} + +function observeCallback( select, dispatch, clientId ) { + const { getBlockAttributes } = select( blockEditorStore ); + const { updateBlockAttributes, __unstableMarkNextChangeAsNotPersistent } = + dispatch( blockEditorStore ); + + /** + * If the block no longer exists in the store, skip the update. + * The "undo" action recreates the block and provides a new `clientId`. + * The hook still might be observing the changes while the old block unmounts. + */ + const attributes = getBlockAttributes( clientId ); + if ( attributes === null ) { + return; + } + + const headings = getLatestHeadings( select, clientId ); + if ( ! fastDeepEqual( headings, attributes.headings ) ) { + __unstableMarkNextChangeAsNotPersistent(); + updateBlockAttributes( clientId, { headings } ); + } +} + +export function useObserveHeadings( clientId ) { + const registry = useRegistry(); + useEffect( () => { + // Todo: Limit subscription to block editor store when data no longer depends on `getPermalink`. + // See: https://github.com/WordPress/gutenberg/pull/45513 + return registry.subscribe( () => + observeCallback( registry.select, registry.dispatch, clientId ) + ); + }, [ registry, clientId ] ); +} diff --git a/test/integration/fixtures/blocks/core__table-of-contents__empty.json b/test/integration/fixtures/blocks/core__table-of-contents__empty.json index 396280b2892af..62e5d60f0ddae 100644 --- a/test/integration/fixtures/blocks/core__table-of-contents__empty.json +++ b/test/integration/fixtures/blocks/core__table-of-contents__empty.json @@ -3,6 +3,7 @@ "name": "core/table-of-contents", "isValid": true, "attributes": { + "headings": [], "onlyIncludeCurrentPage": false }, "innerBlocks": []