Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 4 commits into from
Sep 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ZebulanStanphill, do you remember the reason for using absolute URLs instead of relative ones?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relative links don't work in situations where the contents of the post (and therefore the Table of Contents, if the post has one) appear on a page other than the one dedicated to the post itself. The most common example of this: a blog template where the entire contents of multiple posts are laid out (which was the default home page template for several past WordPress themes).


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
Loading