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( + '
  • %1$s%2$s
  • ', + $entry, + $child_node['children'] + ? block_core_table_of_contents_render_list( $child_node['children'] ) + : null + ); + }, + $nested_heading_list + ); + + return ''; +} + +/** + * Renders the `core/table-of-contents` block on server. + * + * @access private + * + * @param array $attributes Block attributes. + * @param string $content Block default content. + * @param WP_Block $block Block instance. + * + * @return string Rendered block HTML. + */ +function render_block_core_table_of_contents( $attributes, $content, $block ) { + if ( ! isset( $block->context['postId'] ) ) { + return ''; + } + + $headings = block_core_table_of_contents_get_headings( + $block->context['postId'], + $attributes['onlyIncludeCurrentPage'] + ); + + // If there are no headings. + if ( count( $headings ) === 0 ) { + return ''; + } + + return sprintf( + '', + get_block_wrapper_attributes(), + block_core_table_of_contents_render_list( + block_core_table_of_contents_linear_to_nested_heading_list( $headings ) + ) + ); +} + +/** + * Registers the `core/table-of-contents` block on server. + * + * @access private + * + * @uses render_block_core_table_of_contents() + * + * @throws WP_Error An exception parsing the block definition. + */ +function register_block_core_table_of_contents() { + register_block_type_from_metadata( + __DIR__ . '/table-of-contents', + array( + 'render_callback' => 'render_block_core_table_of_contents', + ) + ); +} +add_action( 'init', 'register_block_core_table_of_contents' ); diff --git a/packages/block-library/src/table-of-contents/list.js b/packages/block-library/src/table-of-contents/list.js new file mode 100644 index 0000000000000..3d583c151e144 --- /dev/null +++ b/packages/block-library/src/table-of-contents/list.js @@ -0,0 +1,28 @@ +const ENTRY_CLASS_NAME = 'wp-block-table-of-contents__entry'; + +export default function TableOfContentsList( { nestedHeadingList } ) { + return nestedHeadingList.map( ( childNode, index ) => { + const { anchor, content } = childNode.heading; + + const entry = anchor ? ( + + { content } + + ) : ( + { content } + ); + + return ( +
  • + { entry } + { childNode.children ? ( + + ) : null } +
  • + ); + } ); +} 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 @@ +