Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion docs/reference-guides/core-blocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,7 @@ Summarize your post with a list of headings. Add HTML anchors to Heading blocks
- **Experimental:** true
- **Category:** [design](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-design/)
- **Supports:** anchor, ariaLabel, background (backgroundImage, backgroundSize, gradient), color (background, gradients, link, text), interactivity (clientNavigation), shadow, spacing (margin, padding), typography (fontSize, lineHeight, textAlign), ~~html~~
- **Attributes:** headings, maxLevel, onlyIncludeCurrentPage, ordered
- **Attributes:** maxLevel, onlyIncludeCurrentPage, ordered

## Tabs

Expand Down
10 changes: 9 additions & 1 deletion packages/block-library/src/table-of-contents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ _Defined via the [`attributes`](https://developer.wordpress.org/block-editor/ref

| Attribute | [Type](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#type-validation) | [Default](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#default-value) | Description |
|-----------|------|---------|-------------|
| `headings` | `array` | `[]` | [Role](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#role): `local` |
| `onlyIncludeCurrentPage` | `boolean` | `false` | — |
| `maxLevel` | `number` | — | — |
| `ordered` | `boolean` | `true` | — |
Expand Down Expand Up @@ -49,6 +48,15 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer
- [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity):
- `clientNavigation`: `true`

## Context

_Defined via the [`usesContext` and `providesContext`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-context/) properties in block.json._

**Uses context:**

- `postId`
- `postType`

## Block Markup

This is a [**dynamic block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/static-dynamic-rendering/). It is rendered on the server and does not save HTML in post content.
Expand Down
9 changes: 1 addition & 8 deletions packages/block-library/src/table-of-contents/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,8 @@
"description": "Summarize your post with a list of headings. Add HTML anchors to Heading blocks to link them here.",
"keywords": [ "document outline", "summary" ],
"textdomain": "default",
"usesContext": [ "postId", "postType" ],
"attributes": {
"headings": {
"type": "array",
"items": {
"type": "object"
},
"default": [],
"role": "local"
},
"onlyIncludeCurrentPage": {
"type": "boolean",
"default": false
Expand Down
38 changes: 27 additions & 11 deletions packages/block-library/src/table-of-contents/edit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
} from '@wordpress/icons';
import TableOfContentsList from './list';
import { createListItemBlocks, linearToNestedHeadingList } from './utils';
import { useObserveHeadings } from './hooks';
import { useHeadings } from './hooks';
import { useToolsPanelDropdownMenuProps } from '../utils/hooks';

/** @typedef {import('./utils').HeadingData} HeadingData */
Expand Down Expand Up @@ -117,16 +117,18 @@ function TableOfContentsToolbar( {
}

export default function TableOfContentsEdit( {
attributes: {
headings = [],
onlyIncludeCurrentPage,
maxLevel,
ordered = true,
},
attributes: { onlyIncludeCurrentPage, maxLevel, ordered = true },
clientId,
context: { postId, postType },
setAttributes,
} ) {
useObserveHeadings( clientId );
const headings = useHeadings( {
clientId,
postId,
postType,
onlyIncludeCurrentPage,
maxLevel,
} );

const blockProps = useBlockProps();
const instanceId = useInstanceId(
Expand Down Expand Up @@ -238,15 +240,29 @@ export default function TableOfContentsEdit( {
// Note that the toolbar controls are intentionally omitted since the
// "Detach" option is useless to the placeholder state.
if ( headings.length === 0 ) {
let instructions = __(
'Start adding Heading blocks to create a table of contents. Headings with HTML anchors will be linked here.'
Comment thread
youknowriad marked this conversation as resolved.
Resolved
);
// Without a post ID, the block is in a template: either one that
// renders a single post of a known type (e.g. `single`), or one that
// doesn't render a single post at all (e.g. `archive`).
if ( ! postId ) {
instructions = postType
? __(
'This table of contents will show headings from the post being viewed.'
)
: __(
'Table of Contents needs a single post or page to list headings.'
);
Comment thread
youknowriad marked this conversation as resolved.
Resolved
}

return (
<>
<div { ...blockProps }>
<Placeholder
icon={ <BlockIcon icon={ icon } /> }
label={ __( 'Table of Contents' ) }
instructions={ __(
'Start adding Heading blocks to create a table of contents. Headings with HTML anchors will be linked here.'
) }
instructions={ instructions }
/>
</div>
{ inspectorControls }
Expand Down
171 changes: 91 additions & 80 deletions packages/block-library/src/table-of-contents/hooks.js
Original file line number Diff line number Diff line change
@@ -1,53 +1,49 @@
import fastDeepEqual from 'fast-deep-equal/es6/index.js';
import { useRegistry } from '@wordpress/data';
import { useEntityBlockEditor, store as coreStore } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
import { useEffect } from '@wordpress/element';
import { useMemo } from '@wordpress/element';
import { addQueryArgs, removeQueryArgs } from '@wordpress/url';
import { store as blockEditorStore } from '@wordpress/block-editor';

function getLatestHeadings( select, clientId ) {
const {
getBlockAttributes,
getBlockName,
getBlocksByName,
getClientIdsOfDescendants,
} = 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.
const permalink = select( 'core/editor' ).getPermalink() ?? null;

const isPaginated = getBlocksByName( 'core/nextpage' ).length !== 0;
const { onlyIncludeCurrentPage, maxLevel } =
getBlockAttributes( clientId ) ?? {};

// Get post-content block client ID.
const [ postContentClientId = '' ] = getBlocksByName( 'core/post-content' );
const EMPTY_ARRAY = [];

/**
* Flattens a block tree in document order, the same order the server walks
* the post content in.
*
* @param {Object[]} blocks Blocks to flatten.
*
* @return {Object[]} Flat list of blocks.
*/
function flattenBlocks( blocks ) {
return blocks.flatMap( ( block ) => [
block,
...flattenBlocks( block.innerBlocks ?? [] ),
] );
}

// Get the client ids of all blocks in the editor.
const allBlockClientIds = getClientIdsOfDescendants( postContentClientId );
function getLatestHeadings(
blocks,
clientId,
permalink,
{ onlyIncludeCurrentPage, maxLevel }
) {
const allBlocks = flattenBlocks( blocks );
const isPaginated = allBlocks.some(
( block ) => block.name === 'core/nextpage'
);

// 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.
// A Table of Contents placed outside the post content, in a template,
// is not part of the post, so it uses the first page.
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' ) {
const tocIndex = allBlocks.findIndex(
( block ) => block.clientId === clientId
);

for ( const block of allBlocks.slice( 0, Math.max( tocIndex, 0 ) ) ) {
if ( block.name === 'core/nextpage' ) {
tocPage++;
}
}
Expand All @@ -59,17 +55,14 @@ function getLatestHeadings( select, clientId ) {
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' ) {
for ( const block of allBlocks ) {
if ( block.name === 'core/nextpage' ) {
headingPage++;

// If we're only including headings from the current page (of
Expand All @@ -90,8 +83,8 @@ function getLatestHeadings( select, clientId ) {
// 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 );
if ( block.name === 'core/heading' ) {
const headingAttributes = block.attributes;

// Skip headings that are deeper than maxLevel
if ( maxLevel && headingAttributes.level > maxLevel ) {
Expand Down Expand Up @@ -123,38 +116,56 @@ function getLatestHeadings( select, clientId ) {
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;
}
/**
* Returns the headings of the post given by the block context, including
* unsaved edits.
*
* @param {Object} options
* @param {string} options.clientId Table of Contents block client ID.
* @param {number} options.postId Post ID from the block context.
* @param {string} options.postType Post type from the block context.
* @param {boolean} options.onlyIncludeCurrentPage Whether to only include headings from the current page.
* @param {number} options.maxLevel Maximum heading level to include.
*
* @return {Object[]} Heading data.
*/
export function useHeadings( {
clientId,
postId,
postType,
onlyIncludeCurrentPage,
maxLevel,
} ) {
const [ blocks ] = useEntityBlockEditor( 'postType', postType, {
id: postId,
} );
const permalink = useSelect(
( select ) =>
postId
? select( coreStore ).getEditedEntityRecord(
'postType',
postType,
postId
)?.link
: undefined,
[ postType, postId ]
);

return useMemo( () => {
if ( ! postId || ! blocks ) {
return EMPTY_ARRAY;
}

const headings = getLatestHeadings( select, clientId );
if ( ! fastDeepEqual( headings, attributes.headings ) ) {
// Executing the update in a microtask ensures that the non-persistent marker doesn't affect an attribute triggering the change.
window.queueMicrotask( () => {
__unstableMarkNextChangeAsNotPersistent();
updateBlockAttributes( clientId, { headings } );
return getLatestHeadings( blocks, clientId, permalink, {
onlyIncludeCurrentPage,
maxLevel,
} );
}
}

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 ] );
}, [
blocks,
clientId,
permalink,
postId,
onlyIncludeCurrentPage,
maxLevel,
] );
}
54 changes: 1 addition & 53 deletions packages/block-library/src/table-of-contents/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { __ } from '@wordpress/i18n';
import { tableOfContents as icon } from '@wordpress/icons';
import initBlock from '../utils/init-block';
import deprecated from './deprecated';
Expand All @@ -13,58 +12,7 @@ export const settings = {
icon,
deprecated,
edit,
example: {
innerBlocks: [
{
name: 'core/heading',
attributes: {
level: 2,
content: __( 'Heading' ),
},
},
{
name: 'core/heading',
attributes: {
level: 3,
content: __( 'Subheading' ),
},
},
{
name: 'core/heading',
attributes: {
level: 2,
content: __( 'Heading' ),
},
},
{
name: 'core/heading',
attributes: {
level: 3,
content: __( 'Subheading' ),
},
},
],
attributes: {
headings: [
{
content: __( 'Heading' ),
level: 2,
},
{
content: __( 'Subheading' ),
level: 3,
},
{
content: __( 'Heading' ),
level: 2,
},
{
content: __( 'Subheading' ),
level: 3,
},
],
},
},
example: {},
Comment thread
mikachan marked this conversation as resolved.
Resolved
};

export const init = () => initBlock( { name, metadata, settings } );
Loading
Loading