Skip to content

Commit

Permalink
Table of Contents: Use a custom store subscription for observing head…
Browse files Browse the repository at this point in the history
…ings (#54094)

* Table of Contents: Use a custom store subscription for observing headings

Co-authored-by: Jarda Snajdr <jsnajdr@gmail.com>

* Provide attribute default

* If the block no longer exists in the store, skip the update.

* Regenerate fixtures

---------

Co-authored-by: Jarda Snajdr <jsnajdr@gmail.com>
  • Loading branch information
Mamaduka and jsnajdr committed Sep 4, 2023
1 parent c7ff22a commit 675ae89
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 163 deletions.
3 changes: 2 additions & 1 deletion packages/block-library/src/table-of-contents/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"type": "array",
"items": {
"type": "object"
}
},
"default": []
},
"onlyIncludeCurrentPage": {
"type": "boolean",
Expand Down
167 changes: 5 additions & 162 deletions packages/block-library/src/table-of-contents/edit.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
/**
* External dependencies
*/
import fastDeepEqual from 'fast-deep-equal/es6';

/**
* WordPress dependencies
*/
Expand All @@ -22,17 +17,16 @@ 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
*/
import icon from './icon';
import TableOfContentsList from './list';
import { linearToNestedHeadingList } from './utils';
import { useObserveHeadings } from './hooks';

/** @typedef {import('./utils').HeadingData} HeadingData */

Expand All @@ -53,6 +47,8 @@ export default function TableOfContentsEdit( {
clientId,
setAttributes,
} ) {
useObserveHeadings( clientId );

const blockProps = useBlockProps();

const canInsertList = useSelect(
Expand All @@ -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(
/(<br *\/?>)+/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 );

Expand Down
156 changes: 156 additions & 0 deletions packages/block-library/src/table-of-contents/hooks.js
Original file line number Diff line number Diff line change
@@ -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(
/(<br *\/?>)+/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 ] );
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"name": "core/table-of-contents",
"isValid": true,
"attributes": {
"headings": [],
"onlyIncludeCurrentPage": false
},
"innerBlocks": []
Expand Down

0 comments on commit 675ae89

Please sign in to comment.