From c4c31735c1ef100117b5224996f2d0c3fae6bffe Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Fri, 6 May 2022 06:37:26 -0500 Subject: [PATCH] ToC block: re-enable + use static markup and only support core Heading and Page Break blocks (#29739) Re-enables the Table of Contents block, which was first merged over a year ago in #21234. The block is using static markup and only supports the core Heading and Page Break blocks, using a much cleaner and performant implementation. It is also using
    for the lists. Co-authored-by: Miguel Fonseca --- .github/CODEOWNERS | 1 + docs/reference-guides/core-blocks.md | 2 +- lib/blocks.php | 3 +- packages/block-library/src/heading/edit.js | 7 +- packages/block-library/src/index.js | 3 +- packages/block-library/src/list/transforms.js | 7 + .../src/table-of-contents/block.json | 10 +- .../src/table-of-contents/edit.js | 235 ++++++++---- .../src/table-of-contents/index.js | 2 + .../src/table-of-contents/index.php | 346 ------------------ .../src/table-of-contents/list.js | 28 -- .../src/table-of-contents/list.tsx | 46 +++ .../src/table-of-contents/save.js | 25 ++ .../src/table-of-contents/utils.js | 126 ------- .../src/table-of-contents/utils.ts | 71 ++++ packages/block-library/tsconfig.json | 11 + .../editor/various/block-switcher.test.js | 3 + .../editor/various/inserting-blocks.test.js | 2 +- .../blocks/core__table-of-contents.html | 3 + .../blocks/core__table-of-contents.json | 22 ++ .../core__table-of-contents.parsed.json | 24 ++ .../core__table-of-contents.serialized.html | 3 + .../core__table-of-contents__empty.html | 1 + .../core__table-of-contents__empty.json | 10 + ...core__table-of-contents__empty.parsed.json | 9 + ...__table-of-contents__empty.serialized.html | 1 + 26 files changed, 416 insertions(+), 585 deletions(-) delete mode 100644 packages/block-library/src/table-of-contents/index.php delete mode 100644 packages/block-library/src/table-of-contents/list.js create mode 100644 packages/block-library/src/table-of-contents/list.tsx create mode 100644 packages/block-library/src/table-of-contents/save.js delete mode 100644 packages/block-library/src/table-of-contents/utils.js create mode 100644 packages/block-library/src/table-of-contents/utils.ts create mode 100644 packages/block-library/tsconfig.json create mode 100644 test/integration/fixtures/blocks/core__table-of-contents.html create mode 100644 test/integration/fixtures/blocks/core__table-of-contents.json create mode 100644 test/integration/fixtures/blocks/core__table-of-contents.parsed.json create mode 100644 test/integration/fixtures/blocks/core__table-of-contents.serialized.html create mode 100644 test/integration/fixtures/blocks/core__table-of-contents__empty.html create mode 100644 test/integration/fixtures/blocks/core__table-of-contents__empty.json create mode 100644 test/integration/fixtures/blocks/core__table-of-contents__empty.parsed.json create mode 100644 test/integration/fixtures/blocks/core__table-of-contents__empty.serialized.html diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fa0d4fa4dc24e3..8bc716803f1067 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -20,6 +20,7 @@ /packages/block-library/src/page-list @tellthemachines /packages/block-library/src/comment-template @michalczaplinski /packages/block-library/src/comments @michalczaplinski +/packages/block-library/src/table-of-contents @ZebulanStanphill # Duotone /lib/block-supports/duotone.php @ajlende diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 5ef3e04c65f204..7e4dc0d7e05be7 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -798,7 +798,7 @@ Summarize your post with a list of headings. Add HTML anchors to Heading blocks - **Name:** core/table-of-contents - **Category:** layout - **Supports:** ~~html~~ -- **Attributes:** onlyIncludeCurrentPage +- **Attributes:** headings, onlyIncludeCurrentPage ## Tag Cloud diff --git a/lib/blocks.php b/lib/blocks.php index 10c052954ad7d4..3d673f07522c2d 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -38,7 +38,7 @@ function gutenberg_reregister_core_block_types() { 'social-links', 'spacer', 'table', - // 'table-of-contents', + 'table-of-contents', 'text-columns', 'verse', 'video', @@ -107,7 +107,6 @@ 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', 'tag-cloud.php' => 'core/tag-cloud', 'template-part.php' => 'core/template-part', 'term-description.php' => 'core/term-description', diff --git a/packages/block-library/src/heading/edit.js b/packages/block-library/src/heading/edit.js index f4b9cb46198c30..50eb46cdcf7848 100644 --- a/packages/block-library/src/heading/edit.js +++ b/packages/block-library/src/heading/edit.js @@ -42,10 +42,13 @@ function HeadingEdit( { } ); const { canGenerateAnchors } = useSelect( ( select ) => { - const settings = select( blockEditorStore ).getSettings(); + const { getGlobalBlockCount, getSettings } = select( blockEditorStore ); + const settings = getSettings(); return { - canGenerateAnchors: !! settings.generateAnchors, + canGenerateAnchors: + !! settings.generateAnchors || + getGlobalBlockCount( 'core/table-of-contents' ) > 0, }; }, [] ); diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index b4ef7507f368a3..55d5aa9a94915d 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -99,6 +99,7 @@ import * as socialLink from './social-link'; import * as socialLinks from './social-links'; import * as spacer from './spacer'; import * as table from './table'; +import * as tableOfContents from './table-of-contents'; import * as tagCloud from './tag-cloud'; import * as templatePart from './template-part'; import * as termDescription from './term-description'; @@ -175,7 +176,6 @@ export const __experimentalGetCoreBlocks = () => [ socialLinks, spacer, table, - // tableOfContents, tagCloud, textColumns, verse, @@ -269,6 +269,7 @@ export const __experimentalRegisterExperimentalCoreBlocks = process.env [ // Experimental blocks. postAuthorName, + tableOfContents, ...( window.__experimentalEnableListBlockV2 ? [ listItem ] : [] ), diff --git a/packages/block-library/src/list/transforms.js b/packages/block-library/src/list/transforms.js index 0d0cca5f648684..5d67e6cee0c67d 100644 --- a/packages/block-library/src/list/transforms.js +++ b/packages/block-library/src/list/transforms.js @@ -208,6 +208,13 @@ const transforms = { } ); }, }, + { + type: 'block', + blocks: [ 'core/table-of-contents' ], + transform: () => { + return createBlock( 'core/table-of-contents' ); + }, + }, ], }; diff --git a/packages/block-library/src/table-of-contents/block.json b/packages/block-library/src/table-of-contents/block.json index 18456f70ef1205..f0ec1707fe0e7e 100644 --- a/packages/block-library/src/table-of-contents/block.json +++ b/packages/block-library/src/table-of-contents/block.json @@ -8,13 +8,19 @@ "keywords": [ "document outline", "summary" ], "textdomain": "default", "attributes": { + "headings": { + "type": "array", + "items": { + "type": "object" + } + }, "onlyIncludeCurrentPage": { "type": "boolean", "default": false } }, - "usesContext": [ "postId" ], "supports": { "html": false - } + }, + "example": {} } diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 44cd67303c7456..1ab1dbaef694a2 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -15,6 +15,7 @@ import { } from '@wordpress/block-editor'; import { createBlock, store as blocksStore } from '@wordpress/blocks'; import { + Disabled, PanelBody, Placeholder, ToggleControl, @@ -22,120 +23,197 @@ import { ToolbarGroup, } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; -import { renderToString, useEffect, useState } from '@wordpress/element'; +import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { renderToString, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ +import icon from './icon'; import TableOfContentsList from './list'; -import { getHeadingsFromContent, linearToNestedHeadingList } from './utils'; +import { linearToNestedHeadingList } from './utils'; + +/** @typedef {import('./utils').HeadingData} HeadingData */ /** * 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 {HeadingData[]} props.attributes.headings A list of data for each heading in the post. + * @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 }, + attributes: { headings = [], 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' - ), - // FIXME: @wordpress/block-library should not depend on @wordpress/editor. - // Blocks can be loaded into a *non-post* block editor. - // eslint-disable-next-line @wordpress/data-no-store-string-literals - postContent: select( 'core/editor' ).getEditedPostContent(), - } ), + const listBlockExists = useSelect( + ( select ) => !! select( blocksStore ).getBlockType( 'core/list' ), [] ); - // 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 { + __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, - getBlockIndex, getBlockName, - getBlockOrder, + getClientIdsWithDescendants, + __experimentalGetGlobalBlocksByName: getGlobalBlocksByName, } = 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 ] ); + // 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' ) { - 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; + 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( { + content: stripHTML( headingAttributes.content ), + level: headingAttributes.level, + link: canBeLinked + ? `${ headingPageLink }#${ headingAttributes.anchor }` + : null, + } ); } } } - return page; + if ( isEqual( headings, _latestHeadings ) ) { + return null; + } + return _latestHeadings; }, - [ clientId, onlyIncludeCurrentPage ] + [ clientId, onlyIncludeCurrentPage, headings ] ); 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 ) ); + if ( latestHeadings !== null ) { + // This is required to keep undo working and not create 2 undo steps + // for each heading change. + __unstableMarkNextChangeAsNotPersistent(); + setAttributes( { headings: latestHeadings } ); } - }, [ pageIndex, postContent, onlyIncludeCurrentPage ] ); + }, [ latestHeadings ] ); - const { replaceBlocks } = useDispatch( blockEditorStore ); + const headingTree = linearToNestedHeadingList( headings ); const toolbarControls = listBlockExists && ( @@ -145,6 +223,7 @@ export default function TableOfContentsEdit( { replaceBlocks( clientId, createBlock( 'core/list', { + ordered: true, values: renderToString( - +
    } + icon={ } label="Table of Contents" instructions={ __( 'Start adding Heading blocks to create a table of contents. Headings with HTML anchors will be linked here.' @@ -206,9 +285,13 @@ export default function TableOfContentsEdit( { return ( <> { toolbarControls } { inspectorControls } diff --git a/packages/block-library/src/table-of-contents/index.js b/packages/block-library/src/table-of-contents/index.js index b9639508217db0..62bb5e89953a1d 100644 --- a/packages/block-library/src/table-of-contents/index.js +++ b/packages/block-library/src/table-of-contents/index.js @@ -4,6 +4,7 @@ import metadata from './block.json'; import edit from './edit'; import icon from './icon'; +import save from './save'; const { name } = metadata; @@ -12,4 +13,5 @@ export { metadata, name }; export const settings = { icon, edit, + save, }; diff --git a/packages/block-library/src/table-of-contents/index.php b/packages/block-library/src/table-of-contents/index.php deleted file mode 100644 index 6408bb53c0175d..00000000000000 --- a/packages/block-library/src/table-of-contents/index.php +++ /dev/null @@ -1,346 +0,0 @@ -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 ) { - $id = null; - - if ( isset( $heading->attributes ) ) { - $id_attribute = $heading->attributes->getNamedItem( 'id' ); - - if ( null !== $id_attribute && '' !== $id_attribute->nodeValue ) { - $id = $id_attribute->nodeValue; - } - } - - 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], - 'id' => $id, - 'page' => $headings_page, - '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 ) { - return block_core_table_of_contents_get_headings_from_content( - $page_content, - $page_index + 1 - ); - }, - $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 iteration. - 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. - * @param string $page_url URL of the page the block belongs to. - * - * @return string The heading list rendered as HTML. - */ -function block_core_table_of_contents_render_list( - $nested_heading_list, - $page_url -) { - $entry_class = 'wp-block-table-of-contents__entry'; - - $child_nodes = array_map( - function ( $child_node ) use ( $entry_class, $page_url ) { - global $multipage; - - $id = $child_node['heading']['id']; - $content = $child_node['heading']['content']; - - if ( isset( $id ) ) { - if ( $multipage ) { - $href = add_query_arg( - 'page', - (string) $child_node['heading']['page'], - remove_query_arg( 'page', $page_url ) - ) . '#' . $id; - } else { - $href = $page_url . '#' . $id; - } - - $entry = sprintf( - '%3$s', - esc_attr( $entry_class ), - esc_url( $href ), - esc_html( $content ) - ); - } else { - $entry = sprintf( - '%2$s', - esc_attr( $entry_class ), - esc_html( $content ) - ); - } - - return sprintf( - '
  1. %1$s%2$s
  2. ', - $entry, - $child_node['children'] - ? block_core_table_of_contents_render_list( - $child_node['children'], - esc_url( $page_url ) - ) - : null - ); - }, - $nested_heading_list - ); - - return '
      ' . implode( $child_nodes ) . '
    '; -} - -/** - * 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 ), - get_permalink( $block->context['postId'] ) - ) - ); -} - -/** - * 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 deleted file mode 100644 index 3d583c151e1447..00000000000000 --- a/packages/block-library/src/table-of-contents/list.js +++ /dev/null @@ -1,28 +0,0 @@ -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 ( -
  3. - { entry } - { childNode.children ? ( -
      - -
    - ) : null } -
  4. - ); - } ); -} diff --git a/packages/block-library/src/table-of-contents/list.tsx b/packages/block-library/src/table-of-contents/list.tsx new file mode 100644 index 00000000000000..e327f8dfe2e86b --- /dev/null +++ b/packages/block-library/src/table-of-contents/list.tsx @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +import type { WPElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { NestedHeadingData } from './utils'; + +const ENTRY_CLASS_NAME = 'wp-block-table-of-contents__entry'; + +export default function TableOfContentsList( { + nestedHeadingList, +}: { + nestedHeadingList: NestedHeadingData[]; +} ): WPElement { + return ( + <> + { nestedHeadingList.map( ( node, index ) => { + const { content, link } = node.heading; + + const entry = link ? ( + + { content } + + ) : ( + { content } + ); + + return ( +
  5. + { entry } + { node.children ? ( +
      + +
    + ) : null } +
  6. + ); + } ) } + + ); +} diff --git a/packages/block-library/src/table-of-contents/save.js b/packages/block-library/src/table-of-contents/save.js new file mode 100644 index 00000000000000..7b9556aca33ffd --- /dev/null +++ b/packages/block-library/src/table-of-contents/save.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { useBlockProps } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import TableOfContentsList from './list'; +import { linearToNestedHeadingList } from './utils'; + +export default function save( { attributes: { headings = [] } } ) { + if ( headings.length === 0 ) { + return null; + } + return ( + + ); +} diff --git a/packages/block-library/src/table-of-contents/utils.js b/packages/block-library/src/table-of-contents/utils.js deleted file mode 100644 index 3327544fa4ba01..00000000000000 --- a/packages/block-library/src/table-of-contents/utils.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @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 iteration. - 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/block-library/src/table-of-contents/utils.ts b/packages/block-library/src/table-of-contents/utils.ts new file mode 100644 index 00000000000000..37050b43ce0ab2 --- /dev/null +++ b/packages/block-library/src/table-of-contents/utils.ts @@ -0,0 +1,71 @@ +export interface HeadingData { + /** The plain text content of the heading. */ + content: string; + /** The heading level. */ + level: number; + /** Link to the heading. */ + link: string; +} + +export interface NestedHeadingData { + /** The heading content, level, and link. */ + heading: HeadingData; + /** The sub-headings of this heading, if any. */ + children: NestedHeadingData[] | null; +} + +/** + * Takes a flat list of heading parameters and nests them based on each header's + * immediate parent's level. + * + * @param headingList The flat list of headings to nest. + * + * @return The nested list of headings. + */ +export function linearToNestedHeadingList( + headingList: HeadingData[] +): NestedHeadingData[] { + const nestedHeadingList: NestedHeadingData[] = []; + + 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 iteration. + if ( headingList[ key + 1 ]?.level > heading.level ) { + // We must calculate the last index before the next iteration that + // has the same level (siblings). We then use this 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, + children: linearToNestedHeadingList( + headingList.slice( key + 1, endOfSlice ) + ), + } ); + } else { + // No child node: Push a new node onto the return array. + nestedHeadingList.push( { + heading, + children: null, + } ); + } + } + } ); + + return nestedHeadingList; +} diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json new file mode 100644 index 00000000000000..a63ae328408191 --- /dev/null +++ b/packages/block-library/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types", + "types": [ "gutenberg-env" ], + "strictNullChecks": true + }, + "references": [ { "path": "../element" } ], + "include": [ "src/**/*.ts", "src/**/*.tsx" ] +} diff --git a/packages/e2e-tests/specs/editor/various/block-switcher.test.js b/packages/e2e-tests/specs/editor/various/block-switcher.test.js index f0a472ec2f48c6..945cd212528876 100644 --- a/packages/e2e-tests/specs/editor/various/block-switcher.test.js +++ b/packages/e2e-tests/specs/editor/various/block-switcher.test.js @@ -32,6 +32,7 @@ describe( 'Block Switcher', () => { 'Heading', 'Pullquote', 'Columns', + 'Table of Contents', ] ) ); } ); @@ -57,6 +58,7 @@ describe( 'Block Switcher', () => { 'Paragraph', 'Pullquote', 'Heading', + 'Table of Contents', ] ) ); } ); @@ -71,6 +73,7 @@ describe( 'Block Switcher', () => { 'core/group', 'core/heading', 'core/columns', + 'core/table-of-contents', ].map( ( block ) => wp.blocks.unregisterBlockType( block ) ); } ); diff --git a/packages/e2e-tests/specs/editor/various/inserting-blocks.test.js b/packages/e2e-tests/specs/editor/various/inserting-blocks.test.js index 5207771ad548bd..f03bd390641969 100644 --- a/packages/e2e-tests/specs/editor/various/inserting-blocks.test.js +++ b/packages/e2e-tests/specs/editor/various/inserting-blocks.test.js @@ -299,7 +299,7 @@ describe( 'Inserting blocks', () => { await page.waitForSelector( INSERTER_SEARCH_SELECTOR ); await page.focus( INSERTER_SEARCH_SELECTOR ); await pressKeyWithModifier( 'primary', 'a' ); - const searchTerm = 'Heading'; + const searchTerm = 'Verse'; await page.keyboard.type( searchTerm ); const browseAll = await page.waitForXPath( '//button[text()="Browse all"]' diff --git a/test/integration/fixtures/blocks/core__table-of-contents.html b/test/integration/fixtures/blocks/core__table-of-contents.html new file mode 100644 index 00000000000000..6c8ef7aee27d75 --- /dev/null +++ b/test/integration/fixtures/blocks/core__table-of-contents.html @@ -0,0 +1,3 @@ + + + diff --git a/test/integration/fixtures/blocks/core__table-of-contents.json b/test/integration/fixtures/blocks/core__table-of-contents.json new file mode 100644 index 00000000000000..4cb3510b6b3862 --- /dev/null +++ b/test/integration/fixtures/blocks/core__table-of-contents.json @@ -0,0 +1,22 @@ +[ + { + "name": "core/table-of-contents", + "isValid": true, + "attributes": { + "headings": [ + { + "content": "Heading text", + "level": 2, + "link": "#heading-id-1" + }, + { + "content": "A sub-heading", + "level": 3, + "link": "#heading-id-2" + } + ], + "onlyIncludeCurrentPage": false + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__table-of-contents.parsed.json b/test/integration/fixtures/blocks/core__table-of-contents.parsed.json new file mode 100644 index 00000000000000..cb65acd4c18494 --- /dev/null +++ b/test/integration/fixtures/blocks/core__table-of-contents.parsed.json @@ -0,0 +1,24 @@ +[ + { + "blockName": "core/table-of-contents", + "attrs": { + "headings": [ + { + "content": "Heading text", + "level": 2, + "link": "#heading-id-1" + }, + { + "content": "A sub-heading", + "level": 3, + "link": "#heading-id-2" + } + ] + }, + "innerBlocks": [], + "innerHTML": "\n\n", + "innerContent": [ + "\n\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__table-of-contents.serialized.html b/test/integration/fixtures/blocks/core__table-of-contents.serialized.html new file mode 100644 index 00000000000000..6c8ef7aee27d75 --- /dev/null +++ b/test/integration/fixtures/blocks/core__table-of-contents.serialized.html @@ -0,0 +1,3 @@ + + + diff --git a/test/integration/fixtures/blocks/core__table-of-contents__empty.html b/test/integration/fixtures/blocks/core__table-of-contents__empty.html new file mode 100644 index 00000000000000..cd71582269d837 --- /dev/null +++ b/test/integration/fixtures/blocks/core__table-of-contents__empty.html @@ -0,0 +1 @@ + diff --git a/test/integration/fixtures/blocks/core__table-of-contents__empty.json b/test/integration/fixtures/blocks/core__table-of-contents__empty.json new file mode 100644 index 00000000000000..396280b2892af8 --- /dev/null +++ b/test/integration/fixtures/blocks/core__table-of-contents__empty.json @@ -0,0 +1,10 @@ +[ + { + "name": "core/table-of-contents", + "isValid": true, + "attributes": { + "onlyIncludeCurrentPage": false + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__table-of-contents__empty.parsed.json b/test/integration/fixtures/blocks/core__table-of-contents__empty.parsed.json new file mode 100644 index 00000000000000..b7f38c61dfe062 --- /dev/null +++ b/test/integration/fixtures/blocks/core__table-of-contents__empty.parsed.json @@ -0,0 +1,9 @@ +[ + { + "blockName": "core/table-of-contents", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } +] diff --git a/test/integration/fixtures/blocks/core__table-of-contents__empty.serialized.html b/test/integration/fixtures/blocks/core__table-of-contents__empty.serialized.html new file mode 100644 index 00000000000000..cd71582269d837 --- /dev/null +++ b/test/integration/fixtures/blocks/core__table-of-contents__empty.serialized.html @@ -0,0 +1 @@ +