diff --git a/lib/blocks.php b/lib/blocks.php
index 03109903da02b..8fbc900d7ecb1 100644
--- a/lib/blocks.php
+++ b/lib/blocks.php
@@ -42,6 +42,7 @@ function gutenberg_reregister_core_block_types() {
'spacer',
'subhead',
'table',
+ 'table-of-contents',
'text-columns',
'verse',
'video',
@@ -89,6 +90,7 @@ function gutenberg_reregister_core_block_types() {
'site-logo.php' => 'core/site-logo',
'site-tagline.php' => 'core/site-tagline',
'site-title.php' => 'core/site-title',
+ 'table-of-contents.php' => 'core/table-of-contents',
'template-part.php' => 'core/template-part',
)
),
diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js
index 643730e17c93c..37cafb85f8410 100644
--- a/packages/block-library/src/index.js
+++ b/packages/block-library/src/index.js
@@ -54,6 +54,7 @@ import * as shortcode from './shortcode';
import * as spacer from './spacer';
import * as subhead from './subhead';
import * as table from './table';
+import * as tableOfContents from './table-of-contents';
import * as textColumns from './text-columns';
import * as verse from './verse';
import * as video from './video';
@@ -162,6 +163,7 @@ export const __experimentalGetCoreBlocks = () => [
spacer,
subhead,
table,
+ tableOfContents,
tagCloud,
textColumns,
verse,
diff --git a/packages/block-library/src/table-of-contents/block.json b/packages/block-library/src/table-of-contents/block.json
new file mode 100644
index 0000000000000..1008a886df890
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/block.json
@@ -0,0 +1,15 @@
+{
+ "apiVersion": 2,
+ "name": "core/table-of-contents",
+ "category": "layout",
+ "attributes": {
+ "onlyIncludeCurrentPage": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "usesContext": [ "postId" ],
+ "supports": {
+ "html": false
+ }
+}
diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js
new file mode 100644
index 0000000000000..095a35800da8b
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/edit.js
@@ -0,0 +1,215 @@
+/**
+ * External dependencies
+ */
+import { isEqual } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ BlockControls,
+ BlockIcon,
+ InspectorControls,
+ store as blockEditorStore,
+ useBlockProps,
+} from '@wordpress/block-editor';
+import { createBlock, store as blocksStore } from '@wordpress/blocks';
+import {
+ PanelBody,
+ Placeholder,
+ ToggleControl,
+ ToolbarButton,
+ ToolbarGroup,
+} from '@wordpress/components';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { store as editorStore } from '@wordpress/editor';
+import { renderToString, useEffect, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import TableOfContentsList from './list';
+import { getHeadingsFromContent, linearToNestedHeadingList } from './utils';
+
+/**
+ * Table of Contents block edit component.
+ *
+ * @param {Object} props The props.
+ * @param {Object} props.attributes The block attributes.
+ * @param {boolean} props.attributes.onlyIncludeCurrentPage
+ * Whether to only include headings from the current page (if the post is
+ * paginated).
+ * @param {string} props.clientId
+ * @param {(attributes: Object) => void} props.setAttributes
+ *
+ * @return {WPComponent} The component.
+ */
+export default function TableOfContentsEdit( {
+ attributes: { onlyIncludeCurrentPage },
+ clientId,
+ setAttributes,
+} ) {
+ const blockProps = useBlockProps();
+
+ // Local state; not saved to block attributes. The saved block is dynamic and uses PHP to generate its content.
+ const [ headings, setHeadings ] = useState( [] );
+ const [ headingTree, setHeadingTree ] = useState( [] );
+
+ const { listBlockExists, postContent } = useSelect(
+ ( select ) => ( {
+ listBlockExists: !! select( blocksStore ).getBlockType(
+ 'core/list'
+ ),
+ postContent: select( editorStore ).getEditedPostContent(),
+ } ),
+ []
+ );
+
+ // The page this block would be part of on the front-end. For performance
+ // reasons, this is only calculated when onlyIncludeCurrentPage is true.
+ const pageIndex = useSelect(
+ ( select ) => {
+ if ( ! onlyIncludeCurrentPage ) {
+ return null;
+ }
+
+ const {
+ getBlockAttributes,
+ getBlockIndex,
+ getBlockName,
+ getBlockOrder,
+ } = select( blockEditorStore );
+
+ const blockIndex = getBlockIndex( clientId );
+ const blockOrder = getBlockOrder();
+
+ // Calculate which page the block will appear in on the front-end by
+ // counting how many tags precede it.
+ // Unfortunately, this implementation only accounts for Page Break and
+ // Classic blocks, so if there are any tags in any
+ // other block, they won't be counted. This will result in the table
+ // of contents showing headings from the wrong page if
+ // onlyIncludeCurrentPage === true. Thankfully, this issue only
+ // affects the editor implementation.
+ let page = 1;
+ for ( let i = 0; i < blockIndex; i++ ) {
+ const blockName = getBlockName( blockOrder[ i ] );
+ if ( blockName === 'core/nextpage' ) {
+ page++;
+ } else if ( blockName === 'core/freeform' ) {
+ // Count the page breaks inside the Classic block.
+ const pageBreaks = getBlockAttributes(
+ blockOrder[ i ]
+ ).content?.match( //g );
+
+ if ( pageBreaks !== null && pageBreaks !== undefined ) {
+ page += pageBreaks.length;
+ }
+ }
+ }
+
+ return page;
+ },
+ [ clientId, onlyIncludeCurrentPage ]
+ );
+
+ useEffect( () => {
+ let latestHeadings;
+
+ if ( onlyIncludeCurrentPage ) {
+ const pagesOfContent = postContent.split( '' );
+
+ latestHeadings = getHeadingsFromContent(
+ pagesOfContent[ pageIndex - 1 ]
+ );
+ } else {
+ latestHeadings = getHeadingsFromContent( postContent );
+ }
+
+ if ( ! isEqual( headings, latestHeadings ) ) {
+ setHeadings( latestHeadings );
+ setHeadingTree( linearToNestedHeadingList( latestHeadings ) );
+ }
+ }, [ pageIndex, postContent, onlyIncludeCurrentPage ] );
+
+ const { replaceBlocks } = useDispatch( blockEditorStore );
+
+ const toolbarControls = listBlockExists && (
+
+
+
+ replaceBlocks(
+ clientId,
+ createBlock( 'core/list', {
+ values: renderToString(
+
+ ),
+ } )
+ )
+ }
+ >
+ { __( 'Convert to static list' ) }
+
+
+
+ );
+
+ const inspectorControls = (
+
+
+
+ setAttributes( { onlyIncludeCurrentPage: value } )
+ }
+ help={
+ onlyIncludeCurrentPage
+ ? __(
+ 'Only including headings from the current page (if the post is paginated).'
+ )
+ : __(
+ 'Toggle to only include headings from the current page (if the post is paginated).'
+ )
+ }
+ />
+
+
+ );
+
+ // If there are no headings or the only heading is empty.
+ // Note that the toolbar controls are intentionally omitted since the
+ // "Convert to static list" option is useless to the placeholder state.
+ if ( headings.length === 0 ) {
+ return (
+ <>
+
+ }
+ label="Table of Contents"
+ instructions={ __(
+ 'Start adding Heading blocks to create a table of contents. Headings with HTML anchors will be linked here.'
+ ) }
+ />
+
+ { inspectorControls }
+ >
+ );
+ }
+
+ return (
+ <>
+
+ { toolbarControls }
+ { inspectorControls }
+ >
+ );
+}
diff --git a/packages/block-library/src/table-of-contents/icon.js b/packages/block-library/src/table-of-contents/icon.js
new file mode 100644
index 0000000000000..02b642ea5e923
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/icon.js
@@ -0,0 +1,18 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/components';
+
+export default (
+
+);
diff --git a/packages/block-library/src/table-of-contents/index.js b/packages/block-library/src/table-of-contents/index.js
new file mode 100644
index 0000000000000..fc6149a0b0072
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/index.js
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import edit from './edit';
+import icon from './icon';
+
+const { name } = metadata;
+
+export { metadata, name };
+
+export const settings = {
+ title: __( 'Table of Contents' ),
+ description: __(
+ 'Summarize your post with a list of headings. Add HTML anchors to Heading blocks to link them here.'
+ ),
+ icon,
+ keywords: [ __( 'document outline' ), __( 'summary' ) ],
+ edit,
+};
diff --git a/packages/block-library/src/table-of-contents/index.php b/packages/block-library/src/table-of-contents/index.php
new file mode 100644
index 0000000000000..8cf550a4bab51
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/index.php
@@ -0,0 +1,337 @@
+loadHTML(
+ // loadHTML expects ISO-8859-1, so we need to convert the post content to
+ // that format. We use htmlentities to encode Unicode characters not
+ // supported by ISO-8859-1 as HTML entities. However, this function also
+ // converts all special characters like < or > to HTML entities, so we use
+ // htmlspecialchars_decode to decode them.
+ htmlspecialchars_decode(
+ utf8_decode(
+ htmlentities(
+ '' . $content . '',
+ ENT_COMPAT,
+ 'UTF-8',
+ false
+ )
+ ),
+ ENT_COMPAT
+ )
+ );
+
+ // We're done parsing, so we can disable user error handling. This also
+ // clears any existing errors, which helps avoid a memory leak.
+ libxml_use_internal_errors( false );
+
+ // IE11 treats template elements like divs, so to avoid extracting heading
+ // elements from them, we first have to remove them.
+ // We can't use foreach directly on the $templates DOMNodeList because it's a
+ // dynamic list, and removing nodes confuses the foreach iterator. So
+ // instead, we convert the iterator to an array and then iterate over that.
+ $templates = iterator_to_array(
+ $doc->documentElement->getElementsByTagName( 'template' )
+ );
+
+ foreach ( $templates as $template ) {
+ $template->parentNode->removeChild( $template );
+ }
+
+ $xpath = new DOMXPath( $doc );
+
+ // Get all non-empty heading elements in the post content.
+ $headings = iterator_to_array(
+ $xpath->query(
+ '//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][text()!=""]'
+ )
+ );
+
+ return array_map(
+ function ( $heading ) use ( $headings_page, $current_page ) {
+ $anchor = '';
+
+ if ( isset( $heading->attributes ) ) {
+ $id_attribute = $heading->attributes->getNamedItem( 'id' );
+
+ if ( null !== $id_attribute ) {
+ $id = $id_attribute->nodeValue;
+ if ( $headings_page === $current_page ) {
+ $anchor = '#' . $id;
+ } elseif ( 1 !== $headings_page && 1 === $current_page ) {
+ $anchor = './' . $headings_page . '/#' . $id;
+ } elseif ( 1 === $headings_page && 1 !== $current_page ) {
+ $anchor = '../#' . $id;
+ } else {
+ $anchor = '../' . $headings_page . '/#' . $id;
+ }
+ }
+ }
+
+ return array(
+ // A little hacky, but since we know at this point that the tag will
+ // be an h1-h6, we can just grab the 2nd character of the tag name
+ // and convert it to an integer. Should be faster than conditionals.
+ 'level' => (int) $heading->nodeName[1],
+ 'anchor' => $anchor,
+ 'content' => $heading->textContent,
+ );
+ },
+ $headings
+ );
+ /* phpcs:enable */
+}
+
+/**
+ * Gets the content, anchor, level, and page of headings from a post. Returns
+ * data from all headings in a paginated post if $current_page_only is false;
+ * otherwise, returns only data from headings on the current page being
+ * rendered.
+ *
+ * @access private
+ *
+ * @param int $post_id Id of the post to extract headings from.
+ * @param bool $current_page_only Whether to include headings from the entire
+ * post, or just those from the current page (if
+ * the post is paginated).
+ *
+ * @return array The list of headings.
+ */
+function block_core_table_of_contents_get_headings(
+ $post_id,
+ $current_page_only
+) {
+ global $multipage, $page, $pages;
+
+ if ( $multipage ) {
+ // Creates a list of heading lists, one list per page.
+ $pages_of_headings = array_map(
+ function( $page_content, $page_index ) use ( $page ) {
+ return block_core_table_of_contents_get_headings_from_content(
+ $page_content,
+ $page_index + 1,
+ $page
+ );
+ },
+ $pages,
+ array_keys( $pages )
+ );
+
+ if ( $current_page_only ) {
+ // Return the headings from the current page.
+ return $pages_of_headings[ $page - 1 ];
+ } else {
+ // Concatenate the heading lists into a single array and return it.
+ return array_merge( ...$pages_of_headings );
+ }
+ } else {
+ // Only one page, so return headings from entire post_content.
+ return block_core_table_of_contents_get_headings_from_content(
+ get_post( $post_id )->post_content
+ );
+ }
+}
+
+/**
+ * Converts a flat list of heading parameters to a hierarchical nested list
+ * based on each header's immediate parent's level.
+ *
+ * @access private
+ *
+ * @param array $heading_list Flat list of heading parameters to nest.
+ * @param int $index The current list index.
+ *
+ * @return array A hierarchical nested list of heading parameters.
+ */
+function block_core_table_of_contents_linear_to_nested_heading_list(
+ $heading_list,
+ $index = 0
+) {
+ $nested_heading_list = array();
+
+ foreach ( $heading_list as $key => $heading ) {
+ // Make sure we are only working with the same level as the first
+ // iteration in our set.
+ if ( $heading['level'] === $heading_list[0]['level'] ) {
+ // Check that the next iteration will return a value.
+ // If it does and the next level is greater than the current level,
+ // the next iteration becomes a child of the current interation.
+ if (
+ isset( $heading_list[ $key + 1 ] ) &&
+ $heading_list[ $key + 1 ]['level'] > $heading['level']
+ ) {
+ // We need to calculate the last index before the next iteration
+ // that has the same level (siblings). We then use this last index
+ // to slice the array for use in recursion. This prevents duplicate
+ // nodes.
+ $heading_list_length = count( $heading_list );
+ $end_of_slice = $heading_list_length;
+ for ( $i = $key + 1; $i < $heading_list_length; $i++ ) {
+ if ( $heading_list[ $i ]['level'] === $heading['level'] ) {
+ $end_of_slice = $i;
+ break;
+ }
+ }
+
+ // Found a child node: Push a new node onto the return array with
+ // children.
+ $nested_heading_list[] = array(
+ 'heading' => $heading,
+ 'index' => $index + $key,
+ 'children' => block_core_table_of_contents_linear_to_nested_heading_list(
+ array_slice(
+ $heading_list,
+ $key + 1,
+ $end_of_slice - ( $key + 1 )
+ ),
+ $index + $key + 1
+ ),
+ );
+ } else {
+ // No child node: Push a new node onto the return array.
+ $nested_heading_list[] = array(
+ 'heading' => $heading,
+ 'index' => $index + $key,
+ 'children' => null,
+ );
+ }
+ }
+ }
+
+ return $nested_heading_list;
+}
+
+/**
+ * Renders the heading list of the `core/table-of-contents` block on server.
+ *
+ * @access private
+ *
+ * @param array $nested_heading_list Nested list of heading data.
+ *
+ * @return string The heading list rendered as HTML.
+ */
+function block_core_table_of_contents_render_list( $nested_heading_list ) {
+ $entry_class = 'wp-block-table-of-contents__entry';
+
+ $child_nodes = array_map(
+ function ( $child_node ) use ( $entry_class ) {
+ $anchor = $child_node['heading']['anchor'];
+ $content = $child_node['heading']['content'];
+
+ if ( isset( $anchor ) && '' !== $anchor ) {
+ $entry = sprintf(
+ '%3$s',
+ $entry_class,
+ esc_attr( $anchor ),
+ esc_html( $content )
+ );
+ } else {
+ $entry = sprintf(
+ '%2$s',
+ $entry_class,
+ esc_html( $content )
+ );
+ }
+
+ return sprintf(
+ '
+ );
+ } );
+}
diff --git a/packages/block-library/src/table-of-contents/utils.js b/packages/block-library/src/table-of-contents/utils.js
new file mode 100644
index 0000000000000..6523d1662cc85
--- /dev/null
+++ b/packages/block-library/src/table-of-contents/utils.js
@@ -0,0 +1,126 @@
+/**
+ * @typedef WPHeadingData
+ *
+ * @property {string} anchor The anchor link to the heading, or '' if none.
+ * @property {string} content The plain text content of the heading.
+ * @property {number} level The heading level.
+ */
+
+/**
+ * Extracts text, anchor, and level from a list of heading elements.
+ *
+ * @param {NodeList} headingElements The list of heading elements.
+ *
+ * @return {WPHeadingData[]} The list of heading parameters.
+ */
+export function getHeadingsFromHeadingElements( headingElements ) {
+ return [ ...headingElements ].map( ( heading ) => ( {
+ // A little hacky, but since we know at this point that the tag will
+ // be an H1-H6, we can just grab the 2nd character of the tag name and
+ // convert it to an integer. Should be faster than conditionals.
+ level: parseInt( heading.tagName[ 1 ], 10 ),
+ anchor: heading.hasAttribute( 'id' ) ? `#${ heading.id }` : '',
+ content: heading.textContent,
+ } ) );
+}
+
+/**
+ * Extracts heading data from the provided content.
+ *
+ * @param {string} content The content to extract heading data from.
+ *
+ * @return {WPHeadingData[]} The list of heading parameters.
+ */
+export function getHeadingsFromContent( content ) {
+ // Create a temporary container to put the post content into, so we can
+ // use the DOM to find all the headings.
+ const tempPostContentDOM = document.createElement( 'div' );
+ tempPostContentDOM.innerHTML = content;
+
+ // Remove template elements so that headings inside them aren't counted.
+ // This is only needed for IE11, which doesn't recognize the element and
+ // treats it like a div.
+ for ( const template of tempPostContentDOM.querySelectorAll(
+ 'template'
+ ) ) {
+ template.remove();
+ }
+
+ const headingElements = tempPostContentDOM.querySelectorAll(
+ 'h1:not(:empty), h2:not(:empty), h3:not(:empty), h4:not(:empty), h5:not(:empty), h6:not(:empty)'
+ );
+
+ return getHeadingsFromHeadingElements( headingElements );
+}
+
+/**
+ * @typedef WPNestedHeadingData
+ *
+ * @property {WPHeadingData} heading The heading content, anchor,
+ * and level.
+ * @property {number} index The index of this heading
+ * node in the entire nested
+ * list of heading data.
+ * @property {WPNestedHeadingData[]|null} children The sub-headings of this
+ * heading, if any.
+ */
+
+/**
+ * Takes a flat list of heading parameters and nests them based on each header's
+ * immediate parent's level.
+ *
+ * @param {WPHeadingData[]} headingList The flat list of headings to nest.
+ * @param {number} index The current list index.
+ *
+ * @return {WPNestedHeadingData[]} The nested list of headings.
+ */
+export function linearToNestedHeadingList( headingList, index = 0 ) {
+ const nestedHeadingList = [];
+
+ headingList.forEach( ( heading, key ) => {
+ if ( heading.content === '' ) {
+ return;
+ }
+
+ // Make sure we are only working with the same level as the first iteration in our set.
+ if ( heading.level === headingList[ 0 ].level ) {
+ // Check that the next iteration will return a value.
+ // If it does and the next level is greater than the current level,
+ // the next iteration becomes a child of the current interation.
+ if (
+ headingList[ key + 1 ] !== undefined &&
+ headingList[ key + 1 ].level > heading.level
+ ) {
+ // We need to calculate the last index before the next iteration that has the same level (siblings).
+ // We then use this last index to slice the array for use in recursion.
+ // This prevents duplicate nodes.
+ let endOfSlice = headingList.length;
+ for ( let i = key + 1; i < headingList.length; i++ ) {
+ if ( headingList[ i ].level === heading.level ) {
+ endOfSlice = i;
+ break;
+ }
+ }
+
+ // We found a child node: Push a new node onto the return array with children.
+ nestedHeadingList.push( {
+ heading,
+ index: index + key,
+ children: linearToNestedHeadingList(
+ headingList.slice( key + 1, endOfSlice ),
+ index + key + 1
+ ),
+ } );
+ } else {
+ // No child node: Push a new node onto the return array.
+ nestedHeadingList.push( {
+ heading,
+ index: index + key,
+ children: null,
+ } );
+ }
+ }
+ } );
+
+ return nestedHeadingList;
+}
diff --git a/packages/e2e-tests/fixtures/block-transforms.js b/packages/e2e-tests/fixtures/block-transforms.js
index 35404b9dec474..c0ee14457c713 100644
--- a/packages/e2e-tests/fixtures/block-transforms.js
+++ b/packages/e2e-tests/fixtures/block-transforms.js
@@ -516,6 +516,10 @@ export const EXPECTED_TRANSFORMS = {
originalBlock: 'Table',
availableTransforms: [ 'Group' ],
},
+ 'core__table-of-contents': {
+ originalBlock: 'Table of Contents',
+ availableTransforms: [ 'Group' ],
+ },
'core__tag-cloud': {
originalBlock: 'Tag Cloud',
availableTransforms: [ 'Group' ],
diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.html b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.html
new file mode 100644
index 0000000000000..c07afd290aa83
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.html
@@ -0,0 +1 @@
+
diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.json b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.json
new file mode 100644
index 0000000000000..f270e47a5262c
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.json
@@ -0,0 +1,12 @@
+[
+ {
+ "clientId": "_clientId_0",
+ "name": "core/table-of-contents",
+ "isValid": true,
+ "attributes": {
+ "onlyIncludeCurrentPage": false
+ },
+ "innerBlocks": [],
+ "originalContent": ""
+ }
+]
diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.parsed.json b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.parsed.json
new file mode 100644
index 0000000000000..f6ea98b753764
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.parsed.json
@@ -0,0 +1,16 @@
+[
+ {
+ "blockName": "core/table-of-contents",
+ "attrs": {},
+ "innerBlocks": [],
+ "innerHTML": "",
+ "innerContent": []
+ },
+ {
+ "blockName": null,
+ "attrs": {},
+ "innerBlocks": [],
+ "innerHTML": "\n",
+ "innerContent": [ "\n" ]
+ }
+]
diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.serialized.html b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.serialized.html
new file mode 100644
index 0000000000000..cd71582269d83
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.serialized.html
@@ -0,0 +1 @@
+