From c53df83b12e4206ea7540042603b52c33c04b2b2 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Sun, 27 Jun 2021 17:46:54 -0500 Subject: [PATCH 01/25] ToC block: use static markup and only support core Heading and Page Break blocks. --- docs/reference-guides/core-blocks.md | 2 +- lib/blocks.php | 3 +- packages/block-library/src/index.js | 3 +- .../src/table-of-contents/block.json | 10 +- .../src/table-of-contents/edit.js | 177 +++++---- .../src/table-of-contents/index.js | 2 + .../src/table-of-contents/index.php | 346 ------------------ .../src/table-of-contents/list.js | 12 +- .../src/table-of-contents/save.js | 25 ++ .../src/table-of-contents/utils.js | 73 +--- 10 files changed, 168 insertions(+), 485 deletions(-) delete mode 100644 packages/block-library/src/table-of-contents/index.php create mode 100644 packages/block-library/src/table-of-contents/save.js diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 045bfc7b401eb..f2fd5bf5369b1 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 10c052954ad7d..3d673f07522c2 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/index.js b/packages/block-library/src/index.js index b4ef7507f368a..94bc058ac090e 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,7 @@ export const __experimentalGetCoreBlocks = () => [ socialLinks, spacer, table, - // tableOfContents, + 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 index 18456f70ef120..f0ec1707fe0e7 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 44cd67303c745..c34fbe4a31d63 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -22,120 +22,163 @@ import { ToolbarGroup, } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; -import { renderToString, useEffect, useState } from '@wordpress/element'; +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 HeadingData + * + * @property {string} content The plain text content of the heading. + * @property {number} level The heading level. + * @property {string} link Link to the heading. + */ /** * 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 ); + const latestHeadings = useSelect( + ( select ) => { const { getBlockAttributes, getBlockIndex, getBlockName, getBlockOrder, + getClientIdsOfDescendants, + getGlobalBlockCount, } = select( blockEditorStore ); + // 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 + const { getPermalink } = select( 'core/editor' ); + + const isPaginated = getGlobalBlockCount( 'core/nextpage' ) !== 0; 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 ] ); + + // Get the top-level block client ids, and add them and the client ids of their children to an ordered list. We don't use getClientIdsWithDescendants because it returns ids in the wrong order. + const allBlockClientIds = []; + for ( const blockClientId of getBlockOrder() ) { + allBlockClientIds.push( + blockClientId, + ...getClientIdsOfDescendants( [ blockClientId ] ) + ); + } + + const _latestHeadings = []; + + // Calculate the page (of a paginated post) the Table of Contents block is part of. + let tocPage = 1; + for ( const [ i, blockClientId ] of allBlockClientIds.entries() ) { + if ( i > blockIndex ) { + break; + } + if ( getBlockName( blockClientId ) === 'core/nextpage' ) { + tocPage++; + } + } + + // The page (of a paginated post) a heading will be part of. + let headingPage = 1; + + // Link to post including pagination query if necessary. + const permalink = getPermalink(); + + let 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; + } + + 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 hasAnchor = + typeof headingAttributes.anchor === 'string' && + headingAttributes.anchor !== ''; + + _latestHeadings.push( { + content: headingAttributes.content, + level: headingAttributes.level, + link: hasAnchor + ? `${ headingPageLink }#${ headingAttributes.anchor }` + : null, + } ); } } } - return page; + return _latestHeadings; }, [ 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 ) ); + // This is required to keep undo working and not create 2 undo steps for each heading change. + __unstableMarkNextChangeAsNotPersistent(); + setAttributes( { headings: latestHeadings } ); } - }, [ pageIndex, postContent, onlyIncludeCurrentPage ] ); + } ); - const { replaceBlocks } = useDispatch( blockEditorStore ); + const headingTree = linearToNestedHeadingList( headings ); const toolbarControls = listBlockExists && ( @@ -191,7 +234,7 @@ export default function TableOfContentsEdit( { <>
} + icon={ } label="Table of Contents" instructions={ __( 'Start adding Heading blocks to create a table of contents. Headings with HTML anchors will be linked here.' diff --git a/packages/block-library/src/table-of-contents/index.js b/packages/block-library/src/table-of-contents/index.js index b9639508217db..62bb5e89953a1 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 6408bb53c0175..0000000000000 --- 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$s%2$s
  • ', - $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 index 3d583c151e144..3f4019c04c36d 100644 --- a/packages/block-library/src/table-of-contents/list.js +++ b/packages/block-library/src/table-of-contents/list.js @@ -1,11 +1,11 @@ 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; + return nestedHeadingList.map( ( node, index ) => { + const { content, link } = node.heading; - const entry = anchor ? ( - + const entry = link ? ( + { content } ) : ( @@ -15,10 +15,10 @@ export default function TableOfContentsList( { nestedHeadingList } ) { return (
  • { entry } - { childNode.children ? ( + { node.children ? (
    ) : null } 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 0000000000000..c16e6c9882d46 --- /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 index 3327544fa4ba0..dff22ecd5fb48 100644 --- a/packages/block-library/src/table-of-contents/utils.js +++ b/packages/block-library/src/table-of-contents/utils.js @@ -1,78 +1,31 @@ /** - * @typedef WPHeadingData + * @typedef HeadingData * - * @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. + * @property {string} link Link to the heading. */ /** - * Extracts text, anchor, and level from a list of heading elements. + * @typedef NestedHeadingData * - * @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. + * @property {HeadingData} heading The heading content, level, + * and link. + * @property {number} index The index of this heading + * node in the entire nested + * list of heading data. + * @property {NestedHeadingData[]|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. + * @param {HeadingData[]} headingList The flat list of headings to nest. + * @param {number} index The current list index. * - * @return {WPNestedHeadingData[]} The nested list of headings. + * @return {NestedHeadingData[]} The nested list of headings. */ export function linearToNestedHeadingList( headingList, index = 0 ) { const nestedHeadingList = []; From afadefa3268ab331f0a06ceed394b395cbdb51a1 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Sun, 27 Jun 2021 17:47:15 -0500 Subject: [PATCH 02/25] Remove unused index prop from NestedHeadingData. --- packages/block-library/src/table-of-contents/utils.js | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/block-library/src/table-of-contents/utils.js b/packages/block-library/src/table-of-contents/utils.js index dff22ecd5fb48..c1c923fef50b8 100644 --- a/packages/block-library/src/table-of-contents/utils.js +++ b/packages/block-library/src/table-of-contents/utils.js @@ -11,9 +11,6 @@ * * @property {HeadingData} heading The heading content, level, * and link. - * @property {number} index The index of this heading - * node in the entire nested - * list of heading data. * @property {NestedHeadingData[]|null} children The sub-headings of this * heading, if any. */ @@ -23,11 +20,10 @@ * immediate parent's level. * * @param {HeadingData[]} headingList The flat list of headings to nest. - * @param {number} index The current list index. * * @return {NestedHeadingData[]} The nested list of headings. */ -export function linearToNestedHeadingList( headingList, index = 0 ) { +export function linearToNestedHeadingList( headingList ) { const nestedHeadingList = []; headingList.forEach( ( heading, key ) => { @@ -58,17 +54,14 @@ export function linearToNestedHeadingList( headingList, index = 0 ) { // 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 + headingList.slice( key + 1, endOfSlice ) ), } ); } else { // No child node: Push a new node onto the return array. nestedHeadingList.push( { heading, - index: index + key, children: null, } ); } From 2796c70381d40edb78b1aed5dcd6d8564fe0f92a Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Fri, 15 Oct 2021 17:26:13 -0500 Subject: [PATCH 03/25] Add unit test fixtures. --- .../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 + 8 files changed, 73 insertions(+) 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/test/integration/fixtures/blocks/core__table-of-contents.html b/test/integration/fixtures/blocks/core__table-of-contents.html new file mode 100644 index 0000000000000..df4f8ba8a1c6a --- /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 0000000000000..4cb3510b6b386 --- /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 0000000000000..6792f6253a7a2 --- /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 0000000000000..df4f8ba8a1c6a --- /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 0000000000000..cd71582269d83 --- /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 0000000000000..396280b2892af --- /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 0000000000000..b7f38c61dfe06 --- /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 0000000000000..cd71582269d83 --- /dev/null +++ b/test/integration/fixtures/blocks/core__table-of-contents__empty.serialized.html @@ -0,0 +1 @@ + From f5646672857f3a98f35476e3477d1cec97625170 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Tue, 2 Nov 2021 12:09:50 -0500 Subject: [PATCH 04/25] Update test. Because the ToC block has "Heading" in its description, it was changing the availableBlocks length to 2. --- .../e2e-tests/specs/editor/various/inserting-blocks.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5207771ad548b..f03bd39064196 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"]' From 4b7bbc6c2541ba0d11a7a3ed94cc5605f2aa312f Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Thu, 11 Nov 2021 15:02:27 -0600 Subject: [PATCH 05/25] Use some TypeScript. --- .../src/table-of-contents/edit.js | 8 +--- .../src/table-of-contents/list.js | 28 ----------- .../src/table-of-contents/list.tsx | 46 +++++++++++++++++++ .../table-of-contents/{utils.js => utils.ts} | 39 ++++++++-------- packages/block-library/tsconfig.json | 14 ++++++ 5 files changed, 81 insertions(+), 54 deletions(-) delete mode 100644 packages/block-library/src/table-of-contents/list.js create mode 100644 packages/block-library/src/table-of-contents/list.tsx rename packages/block-library/src/table-of-contents/{utils.js => utils.ts} (66%) create mode 100644 packages/block-library/tsconfig.json diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index c34fbe4a31d63..e63adf6c619c8 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -33,13 +33,7 @@ import icon from './icon'; import TableOfContentsList from './list'; import { linearToNestedHeadingList } from './utils'; -/** - * @typedef HeadingData - * - * @property {string} content The plain text content of the heading. - * @property {number} level The heading level. - * @property {string} link Link to the heading. - */ +/** @typedef {import('./utils').HeadingData} HeadingData */ /** * Table of Contents block edit component. 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 3f4019c04c36d..0000000000000 --- 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( ( node, index ) => { - const { content, link } = node.heading; - - const entry = link ? ( - - { content } - - ) : ( - { content } - ); - - return ( -
  • - { entry } - { node.children ? ( -
      - -
    - ) : null } -
  • - ); - } ); -} 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 0000000000000..f013a9e6b32cf --- /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 ( +
  • + { entry } + { node.children ? ( +
      + +
    + ) : null } +
  • + ); + } ) } + + ); +} diff --git a/packages/block-library/src/table-of-contents/utils.js b/packages/block-library/src/table-of-contents/utils.ts similarity index 66% rename from packages/block-library/src/table-of-contents/utils.js rename to packages/block-library/src/table-of-contents/utils.ts index c1c923fef50b8..81084e4b51fd8 100644 --- a/packages/block-library/src/table-of-contents/utils.js +++ b/packages/block-library/src/table-of-contents/utils.ts @@ -1,30 +1,31 @@ -/** - * @typedef HeadingData - * - * @property {string} content The plain text content of the heading. - * @property {number} level The heading level. - * @property {string} link Link to the heading. - */ +export interface HeadingData { + /** The plain text content of the heading. */ + content: string; + /** The heading level. */ + level: number; + /** Link to the heading. */ + link: string; +} -/** - * @typedef NestedHeadingData - * - * @property {HeadingData} heading The heading content, level, - * and link. - * @property {NestedHeadingData[]|null} children The sub-headings of this - * heading, if any. - */ +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 {HeadingData[]} headingList The flat list of headings to nest. + * @param headingList The flat list of headings to nest. * - * @return {NestedHeadingData[]} The nested list of headings. + * @return The nested list of headings. */ -export function linearToNestedHeadingList( headingList ) { - const nestedHeadingList = []; +export function linearToNestedHeadingList( + headingList: HeadingData[] +): NestedHeadingData[] { + const nestedHeadingList: NestedHeadingData[] = []; headingList.forEach( ( heading, key ) => { if ( heading.content === '' ) { diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json new file mode 100644 index 0000000000000..d170f7d5df181 --- /dev/null +++ b/packages/block-library/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types", + "types": [ "gutenberg-env" ], + "strictNullChecks": true + }, + "references": [ { "path": "../element" } ], + "include": [ + "src/table-of-contents/list.tsx", + "src/table-of-contents/utils.ts" + ] +} From 0ff623d7eafc542c6132cc7ead699254a1bca5d2 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Tue, 1 Mar 2022 22:39:32 -0600 Subject: [PATCH 06/25] Disable permalink support when core/editor store is unavailable. --- .../src/table-of-contents/edit.js | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index e63adf6c619c8..aa82edce3641b 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -74,10 +74,10 @@ export default function TableOfContentsEdit( { getClientIdsOfDescendants, getGlobalBlockCount, } = select( blockEditorStore ); - // FIXME: @wordpress/block-library should not depend on @wordpress/editor. - // Blocks can be loaded into a *non-post* block editor. + + // Disable reason: 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 { getPermalink } = select( 'core/editor' ); + const editorSelectors = select( 'core/editor' ); const isPaginated = getGlobalBlockCount( 'core/nextpage' ) !== 0; @@ -105,15 +105,20 @@ export default function TableOfContentsEdit( { } } - // The page (of a paginated post) a heading will be part of. + /** The page (of a paginated post) a heading will be part of. */ let headingPage = 1; - // Link to post including pagination query if necessary. - const permalink = getPermalink(); + /** If the core/editor store is available, we can add permalinks to the generated table of contents. This variable will be a link to current post including pagination query if necessary. */ + const permalink = editorSelectors?.getPermalink() ?? null; + + let headingPageLink = null; - let headingPageLink = isPaginated - ? addQueryArgs( permalink, { page: headingPage } ) - : permalink; + // 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 ); @@ -127,10 +132,12 @@ export default function TableOfContentsEdit( { break; } - headingPageLink = addQueryArgs( - removeQueryArgs( permalink, [ 'page' ] ), - { page: headingPage } - ); + 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 @@ -144,14 +151,15 @@ export default function TableOfContentsEdit( { blockClientId ); - const hasAnchor = + const canBeLinked = + typeof headingPageLink === 'string' && typeof headingAttributes.anchor === 'string' && headingAttributes.anchor !== ''; _latestHeadings.push( { content: headingAttributes.content, level: headingAttributes.level, - link: hasAnchor + link: canBeLinked ? `${ headingPageLink }#${ headingAttributes.anchor }` : null, } ); From 8ba556a025d51b1edf213b0da984f3caefea6b0d Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Wed, 2 Mar 2022 00:22:38 -0600 Subject: [PATCH 07/25] Add Zebulan as code owner for Table of Contents. --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fa0d4fa4dc24e..8bc716803f106 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 From dc2730a0dce959426ecb0c91a7853fec12df7fdc Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Mon, 14 Mar 2022 17:04:33 -0500 Subject: [PATCH 08/25] Switch from ul to ol for list element. See https://stackoverflow.com/a/58733251. --- packages/block-library/src/table-of-contents/edit.js | 5 +++-- packages/block-library/src/table-of-contents/list.tsx | 4 ++-- packages/block-library/src/table-of-contents/save.js | 4 ++-- .../integration/fixtures/blocks/core__table-of-contents.html | 2 +- .../fixtures/blocks/core__table-of-contents.parsed.json | 4 ++-- .../fixtures/blocks/core__table-of-contents.serialized.html | 2 +- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index aa82edce3641b..fcc5f1da8b010 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -190,6 +190,7 @@ export default function TableOfContentsEdit( { replaceBlocks( clientId, createBlock( 'core/list', { + ordered: true, values: renderToString( { toolbarControls } { inspectorControls } diff --git a/packages/block-library/src/table-of-contents/list.tsx b/packages/block-library/src/table-of-contents/list.tsx index f013a9e6b32cf..e327f8dfe2e86 100644 --- a/packages/block-library/src/table-of-contents/list.tsx +++ b/packages/block-library/src/table-of-contents/list.tsx @@ -32,11 +32,11 @@ export default function TableOfContentsList( {
  • { entry } { node.children ? ( -
      +
        -
    + ) : null }
  • ); diff --git a/packages/block-library/src/table-of-contents/save.js b/packages/block-library/src/table-of-contents/save.js index c16e6c9882d46..7b9556aca33ff 100644 --- a/packages/block-library/src/table-of-contents/save.js +++ b/packages/block-library/src/table-of-contents/save.js @@ -15,11 +15,11 @@ export default function save( { attributes: { headings = [] } } ) { } return ( ); } diff --git a/test/integration/fixtures/blocks/core__table-of-contents.html b/test/integration/fixtures/blocks/core__table-of-contents.html index df4f8ba8a1c6a..6c8ef7aee27d7 100644 --- a/test/integration/fixtures/blocks/core__table-of-contents.html +++ b/test/integration/fixtures/blocks/core__table-of-contents.html @@ -1,3 +1,3 @@ - + diff --git a/test/integration/fixtures/blocks/core__table-of-contents.parsed.json b/test/integration/fixtures/blocks/core__table-of-contents.parsed.json index 6792f6253a7a2..cb65acd4c1849 100644 --- a/test/integration/fixtures/blocks/core__table-of-contents.parsed.json +++ b/test/integration/fixtures/blocks/core__table-of-contents.parsed.json @@ -16,9 +16,9 @@ ] }, "innerBlocks": [], - "innerHTML": "\n\n", + "innerHTML": "\n\n", "innerContent": [ - "\n\n" + "\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 index df4f8ba8a1c6a..6c8ef7aee27d7 100644 --- a/test/integration/fixtures/blocks/core__table-of-contents.serialized.html +++ b/test/integration/fixtures/blocks/core__table-of-contents.serialized.html @@ -1,3 +1,3 @@ - + From 8f7db2da3f7ceaf14af5be2d157a821036e87481 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Wed, 16 Mar 2022 10:17:00 -0500 Subject: [PATCH 09/25] Optimize re-renders caused by shallow comparisons of useSelect return value. --- .../block-library/src/table-of-contents/edit.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index fcc5f1da8b010..542739f18c8c4 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -64,6 +64,13 @@ export default function TableOfContentsEdit( { 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 { @@ -167,18 +174,21 @@ export default function TableOfContentsEdit( { } } + if ( isEqual( headings, _latestHeadings ) ) { + return null; + } return _latestHeadings; }, - [ clientId, onlyIncludeCurrentPage ] + [ clientId, onlyIncludeCurrentPage, headings ] ); useEffect( () => { - if ( ! isEqual( headings, 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 } ); } - } ); + }, [ latestHeadings ] ); const headingTree = linearToNestedHeadingList( headings ); From 4282578335fcc72f91561705f1ff8cbc6a94285b Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Mon, 21 Mar 2022 11:44:17 -0500 Subject: [PATCH 10/25] Optimize calculating which page the block is on. --- .../src/table-of-contents/edit.js | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 542739f18c8c4..a232f11300816 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -79,16 +79,26 @@ export default function TableOfContentsEdit( { getBlockName, getBlockOrder, getClientIdsOfDescendants, - getGlobalBlockCount, + __experimentalGetGlobalBlocksByName: getGlobalBlocksByName, } = select( blockEditorStore ); // Disable reason: 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 isPaginated = getGlobalBlockCount( 'core/nextpage' ) !== 0; + const pageBreakClientIds = getGlobalBlocksByName( 'core/nextpage' ); - const blockIndex = getBlockIndex( clientId ); + const isPaginated = pageBreakClientIds.length !== 0; + + const tocIndex = getBlockIndex( clientId ); + + // Calculate the page (of a paginated post) this Table of Contents block is part of. Note that pageBreakClientIds may not be in the order they appear on the page, so we have to iterate over all of them. + let tocPage = 1; + for ( const pageBreakClientId of pageBreakClientIds ) { + if ( tocIndex > getBlockIndex( pageBreakClientId ) ) { + tocPage++; + } + } // Get the top-level block client ids, and add them and the client ids of their children to an ordered list. We don't use getClientIdsWithDescendants because it returns ids in the wrong order. const allBlockClientIds = []; @@ -101,17 +111,6 @@ export default function TableOfContentsEdit( { const _latestHeadings = []; - // Calculate the page (of a paginated post) the Table of Contents block is part of. - let tocPage = 1; - for ( const [ i, blockClientId ] of allBlockClientIds.entries() ) { - if ( i > blockIndex ) { - break; - } - if ( getBlockName( blockClientId ) === 'core/nextpage' ) { - tocPage++; - } - } - /** The page (of a paginated post) a heading will be part of. */ let headingPage = 1; From 308008726497026bb75a76e051c16e692dc95afe Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Tue, 22 Mar 2022 10:25:55 -0500 Subject: [PATCH 11/25] Enable Heading anchor auto-generation when a Table of Contents is in same post. --- packages/block-library/src/heading/edit.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/heading/edit.js b/packages/block-library/src/heading/edit.js index f4b9cb46198c3..50eb46cdcf784 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, }; }, [] ); From 803eddc6087c3b08f20fc818be4ab49f81810352 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Wed, 30 Mar 2022 11:18:44 -0500 Subject: [PATCH 12/25] Add List -> Table of Contents block transform. --- packages/block-library/src/list/transforms.js | 7 +++++++ .../e2e-tests/specs/editor/various/block-switcher.test.js | 3 +++ 2 files changed, 10 insertions(+) diff --git a/packages/block-library/src/list/transforms.js b/packages/block-library/src/list/transforms.js index 0d0cca5f64868..5d67e6cee0c67 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/e2e-tests/specs/editor/various/block-switcher.test.js b/packages/e2e-tests/specs/editor/various/block-switcher.test.js index f0a472ec2f48c..945cd21252887 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 ) ); } ); From 6a724bf8b9d489a110e12fe84b0f8f42b53366c2 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Wed, 30 Mar 2022 13:12:34 -0500 Subject: [PATCH 13/25] Prevent links from being clicked in the editor since they don't work there. Heading blocks in the editor don't even use their "anchor" block attribute in the rendered HTML "id" attribute of their editor form. --- packages/block-library/src/editor.scss | 1 + packages/block-library/src/table-of-contents/block.json | 3 ++- packages/block-library/src/table-of-contents/editor.scss | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 packages/block-library/src/table-of-contents/editor.scss diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index ba031d6cbdeaa..7f2ce67f410ab 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -41,6 +41,7 @@ @import "./social-links/editor.scss"; @import "./spacer/editor.scss"; @import "./table/editor.scss"; +@import "./table-of-contents/editor.scss"; @import "./template-part/editor.scss"; @import "./text-columns/editor.scss"; @import "./video/editor.scss"; diff --git a/packages/block-library/src/table-of-contents/block.json b/packages/block-library/src/table-of-contents/block.json index f0ec1707fe0e7..416e56d35ba5f 100644 --- a/packages/block-library/src/table-of-contents/block.json +++ b/packages/block-library/src/table-of-contents/block.json @@ -22,5 +22,6 @@ "supports": { "html": false }, - "example": {} + "example": {}, + "editorStyle": "wp-block-table-of-contents-editor" } diff --git a/packages/block-library/src/table-of-contents/editor.scss b/packages/block-library/src/table-of-contents/editor.scss new file mode 100644 index 0000000000000..c0feabe3a1228 --- /dev/null +++ b/packages/block-library/src/table-of-contents/editor.scss @@ -0,0 +1,4 @@ +// The links in the block don't work in the editor, so we prevent them from being clicked. +.wp-block-table-of-contents a { + pointer-events: none; +} From 1eb8d2281a4c3c82cf0904ce2a0e997a4e15928e Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Wed, 30 Mar 2022 22:40:16 -0500 Subject: [PATCH 14/25] Strip HTML tags from listed headings. --- packages/block-library/src/table-of-contents/edit.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index a232f11300816..99e1d0812fd60 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -22,6 +22,7 @@ import { ToolbarGroup, } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; +import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; import { renderToString, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; @@ -163,7 +164,7 @@ export default function TableOfContentsEdit( { headingAttributes.anchor !== ''; _latestHeadings.push( { - content: headingAttributes.content, + content: stripHTML( headingAttributes.content ), level: headingAttributes.level, link: canBeLinked ? `${ headingPageLink }#${ headingAttributes.anchor }` From e7e866aba3c3394c9dc16ae10d5d224acdb356bb Mon Sep 17 00:00:00 2001 From: Miguel Fonseca Date: Thu, 31 Mar 2022 16:23:29 +0100 Subject: [PATCH 15/25] Prefer over pointer-events rule --- packages/block-library/src/editor.scss | 1 - .../block-library/src/table-of-contents/block.json | 3 +-- packages/block-library/src/table-of-contents/edit.js | 11 ++++++++--- .../block-library/src/table-of-contents/editor.scss | 4 ---- 4 files changed, 9 insertions(+), 10 deletions(-) delete mode 100644 packages/block-library/src/table-of-contents/editor.scss diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index 7f2ce67f410ab..ba031d6cbdeaa 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -41,7 +41,6 @@ @import "./social-links/editor.scss"; @import "./spacer/editor.scss"; @import "./table/editor.scss"; -@import "./table-of-contents/editor.scss"; @import "./template-part/editor.scss"; @import "./text-columns/editor.scss"; @import "./video/editor.scss"; diff --git a/packages/block-library/src/table-of-contents/block.json b/packages/block-library/src/table-of-contents/block.json index 416e56d35ba5f..f0ec1707fe0e7 100644 --- a/packages/block-library/src/table-of-contents/block.json +++ b/packages/block-library/src/table-of-contents/block.json @@ -22,6 +22,5 @@ "supports": { "html": false }, - "example": {}, - "editorStyle": "wp-block-table-of-contents-editor" + "example": {} } diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 99e1d0812fd60..4aedf052aa2ef 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, @@ -262,9 +263,13 @@ export default function TableOfContentsEdit( { return ( <> { toolbarControls } { inspectorControls } diff --git a/packages/block-library/src/table-of-contents/editor.scss b/packages/block-library/src/table-of-contents/editor.scss deleted file mode 100644 index c0feabe3a1228..0000000000000 --- a/packages/block-library/src/table-of-contents/editor.scss +++ /dev/null @@ -1,4 +0,0 @@ -// The links in the block don't work in the editor, so we prevent them from being clicked. -.wp-block-table-of-contents a { - pointer-events: none; -} From 6221796add7467922ae71fb11aa22180881a91b7 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Thu, 31 Mar 2022 13:31:07 -0500 Subject: [PATCH 16/25] Make comment formatting consistent. --- .../src/table-of-contents/edit.js | 40 ++++++++++++++----- .../src/table-of-contents/utils.ts | 9 +++-- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 4aedf052aa2ef..bcd9ae15f0806 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -67,11 +67,19 @@ export default function TableOfContentsEdit( { } = useDispatch( blockEditorStore ); /** - * The latest heading data, or null if the new data deeply equals the saved headings attribute. + * 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. + * 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. + * 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 ) => { @@ -84,7 +92,11 @@ export default function TableOfContentsEdit( { __experimentalGetGlobalBlocksByName: getGlobalBlocksByName, } = select( blockEditorStore ); - // Disable reason: 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. + // Disable reason: 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' ); @@ -94,7 +106,9 @@ export default function TableOfContentsEdit( { const tocIndex = getBlockIndex( clientId ); - // Calculate the page (of a paginated post) this Table of Contents block is part of. Note that pageBreakClientIds may not be in the order they appear on the page, so we have to iterate over all of them. + // Calculate the page (of a paginated post) this block is part of. + // Note that pageBreakClientIds may not be in the order they appear on + // the page, so we have to iterate over all of them. let tocPage = 1; for ( const pageBreakClientId of pageBreakClientIds ) { if ( tocIndex > getBlockIndex( pageBreakClientId ) ) { @@ -102,7 +116,9 @@ export default function TableOfContentsEdit( { } } - // Get the top-level block client ids, and add them and the client ids of their children to an ordered list. We don't use getClientIdsWithDescendants because it returns ids in the wrong order. + // Get the top-level block client ids, and add them and the client ids + // of their children to an ordered list. We don't use + // getClientIdsWithDescendants since it returns ids in the wrong order. const allBlockClientIds = []; for ( const blockClientId of getBlockOrder() ) { allBlockClientIds.push( @@ -116,12 +132,17 @@ export default function TableOfContentsEdit( { /** The page (of a paginated post) a heading will be part of. */ let headingPage = 1; - /** If the core/editor store is available, we can add permalinks to the generated table of contents. This variable will be a link to current post including pagination query if necessary. */ + /** + * If the core/editor store is available, we can add permalinks to the + * generated table of contents. This variable will be a link to + * current post including pagination query if necessary. + */ 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 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 } ) @@ -185,7 +206,8 @@ export default function TableOfContentsEdit( { useEffect( () => { if ( latestHeadings !== null ) { - // This is required to keep undo working and not create 2 undo steps for each heading change. + // This is required to keep undo working and not create 2 undo steps + // for each heading change. __unstableMarkNextChangeAsNotPersistent(); setAttributes( { headings: latestHeadings } ); } diff --git a/packages/block-library/src/table-of-contents/utils.ts b/packages/block-library/src/table-of-contents/utils.ts index 81084e4b51fd8..0e4a26c3f298a 100644 --- a/packages/block-library/src/table-of-contents/utils.ts +++ b/packages/block-library/src/table-of-contents/utils.ts @@ -41,9 +41,9 @@ export function linearToNestedHeadingList( 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. + // 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 ) { @@ -52,7 +52,8 @@ export function linearToNestedHeadingList( } } - // We found a child node: Push a new node onto the return array with children. + // We found a child node: Push a new node onto the return array + // with children. nestedHeadingList.push( { heading, children: linearToNestedHeadingList( From 38bb61233b2a0a002f898c01cb270026b0981819 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Thu, 31 Mar 2022 13:32:35 -0500 Subject: [PATCH 17/25] Simplify TypeScript config. --- packages/block-library/tsconfig.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json index d170f7d5df181..a63ae32840819 100644 --- a/packages/block-library/tsconfig.json +++ b/packages/block-library/tsconfig.json @@ -7,8 +7,5 @@ "strictNullChecks": true }, "references": [ { "path": "../element" } ], - "include": [ - "src/table-of-contents/list.tsx", - "src/table-of-contents/utils.ts" - ] + "include": [ "src/**/*.ts", "src/**/*.tsx" ] } From a8a1cbda383ef62be86aafec365ff39cdfbb0121 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Thu, 31 Mar 2022 13:34:46 -0500 Subject: [PATCH 18/25] Simplify condition using optional chaining in utils.ts. Co-authored-by: Miguel Fonseca --- packages/block-library/src/table-of-contents/utils.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/block-library/src/table-of-contents/utils.ts b/packages/block-library/src/table-of-contents/utils.ts index 0e4a26c3f298a..37050b43ce0ab 100644 --- a/packages/block-library/src/table-of-contents/utils.ts +++ b/packages/block-library/src/table-of-contents/utils.ts @@ -37,10 +37,7 @@ export function linearToNestedHeadingList( // 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 - ) { + 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. From 6d9c653fbbf91a04d4e00e24d96a6646125d956f Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Thu, 31 Mar 2022 14:52:24 -0500 Subject: [PATCH 19/25] Fix page index calculation breaking when ToC or Page Break is nested. Massive props to @mcsf for figuring this out. Co-authored-by: Miguel Fonseca --- .../src/table-of-contents/edit.js | 29 ++++++++++++++++-- .../src/table-of-contents/utils.ts | 30 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index bcd9ae15f0806..609a91ed6f965 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -33,7 +33,7 @@ import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; */ import icon from './icon'; import TableOfContentsList from './list'; -import { linearToNestedHeadingList } from './utils'; +import { comparePathAToB, linearToNestedHeadingList } from './utils'; /** @typedef {import('./utils').HeadingData} HeadingData */ @@ -88,6 +88,7 @@ export default function TableOfContentsEdit( { getBlockIndex, getBlockName, getBlockOrder, + getBlockParents, getClientIdsOfDescendants, __experimentalGetGlobalBlocksByName: getGlobalBlocksByName, } = select( blockEditorStore ); @@ -104,14 +105,36 @@ export default function TableOfContentsEdit( { const isPaginated = pageBreakClientIds.length !== 0; - const tocIndex = getBlockIndex( clientId ); + /** + * Get the relative indices of the block from top to bottom nesting + * level. + * + * @param {string} blockClientId + * + * @return {number[]} The path of indices to the block. + */ + function getBlockPath( blockClientId ) { + const indices = getBlockParents( + blockClientId + ).map( ( ancestorId ) => getBlockIndex( ancestorId ) ); + indices.push( getBlockIndex( blockClientId ) ); + return indices; + } + + // We can't use just getBlockIndex because it only returns the index relative to sibling blocks, so we have to get all the indices from top to bottom. + const tocPath = getBlockPath( clientId ); // Calculate the page (of a paginated post) this block is part of. // Note that pageBreakClientIds may not be in the order they appear on // the page, so we have to iterate over all of them. let tocPage = 1; for ( const pageBreakClientId of pageBreakClientIds ) { - if ( tocIndex > getBlockIndex( pageBreakClientId ) ) { + if ( + comparePathAToB( + tocPath, + getBlockPath( pageBreakClientId ) + ) < 0 + ) { tocPage++; } } diff --git a/packages/block-library/src/table-of-contents/utils.ts b/packages/block-library/src/table-of-contents/utils.ts index 37050b43ce0ab..2110694cfa0c5 100644 --- a/packages/block-library/src/table-of-contents/utils.ts +++ b/packages/block-library/src/table-of-contents/utils.ts @@ -1,3 +1,33 @@ +/** + * Determines if the first path of indices leads to an earlier spot + * than the second path. + * + * @param pathA + * @param pathB + * @return Negative: A is before B; positive: A is after B; zero: the paths are identical. + */ +export function comparePathAToB( pathA: number[], pathB: number[] ): number { + let a: number | undefined = 0; + let b: number | undefined = 0; + + // To avoid modifying the arrays passed into the function. + const clonedPathA = [ ...pathA ]; + const clonedPathB = [ ...pathB ]; + + do { + a = clonedPathA.shift(); + b = clonedPathB.shift(); + } while ( a === b && a !== undefined && b !== undefined ); + + // Defaulting to -1 ensures that if a path terminates before the other, it + // is considered as leading to an earlier global index. This ensures that + // parent blocks are considered as coming before their first child. + // Technically, this isn't needed for the Table of Contents use-case, since + // neither it nor Page Break blocks support children, but it's good to play + // it safe in case this code gets reused elsewhere. + return ( a ?? -1 ) - ( b ?? -1 ); +} + export interface HeadingData { /** The plain text content of the heading. */ content: string; From 21adec540cd8aa3b5861487924d4701b08930fce Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Fri, 1 Apr 2022 15:46:00 -0500 Subject: [PATCH 20/25] Make comment about editor store dependency consistent with other places in the codebase. --- packages/block-library/src/table-of-contents/edit.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 609a91ed6f965..e72970d44d679 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -93,11 +93,11 @@ export default function TableOfContentsEdit( { __experimentalGetGlobalBlocksByName: getGlobalBlocksByName, } = select( blockEditorStore ); - // Disable reason: 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. + // 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' ); From fa6cc7da4674be480ca9a831a0bb76c25b93b627 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Mon, 4 Apr 2022 13:27:04 -0500 Subject: [PATCH 21/25] Simplify logic thanks to changes made in #39985. --- .../src/table-of-contents/edit.js | 64 ++++++------------- .../src/table-of-contents/utils.ts | 30 --------- 2 files changed, 21 insertions(+), 73 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index e72970d44d679..95fc6bc044f15 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -33,7 +33,7 @@ import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; */ import icon from './icon'; import TableOfContentsList from './list'; -import { comparePathAToB, linearToNestedHeadingList } from './utils'; +import { linearToNestedHeadingList } from './utils'; /** @typedef {import('./utils').HeadingData} HeadingData */ @@ -85,11 +85,8 @@ export default function TableOfContentsEdit( { ( select ) => { const { getBlockAttributes, - getBlockIndex, getBlockName, - getBlockOrder, - getBlockParents, - getClientIdsOfDescendants, + getClientIdsWithDescendants, __experimentalGetGlobalBlocksByName: getGlobalBlocksByName, } = select( blockEditorStore ); @@ -105,49 +102,30 @@ export default function TableOfContentsEdit( { const isPaginated = pageBreakClientIds.length !== 0; - /** - * Get the relative indices of the block from top to bottom nesting - * level. - * - * @param {string} blockClientId - * - * @return {number[]} The path of indices to the block. - */ - function getBlockPath( blockClientId ) { - const indices = getBlockParents( - blockClientId - ).map( ( ancestorId ) => getBlockIndex( ancestorId ) ); - indices.push( getBlockIndex( blockClientId ) ); - return indices; - } - - // We can't use just getBlockIndex because it only returns the index relative to sibling blocks, so we have to get all the indices from top to bottom. - const tocPath = getBlockPath( clientId ); + // Get the client ids of all blocks in the editor. + const allBlockClientIds = getClientIdsWithDescendants(); // Calculate the page (of a paginated post) this block is part of. - // Note that pageBreakClientIds may not be in the order they appear on - // the page, so we have to iterate over all of them. let tocPage = 1; - for ( const pageBreakClientId of pageBreakClientIds ) { - if ( - comparePathAToB( - tocPath, - getBlockPath( pageBreakClientId ) - ) < 0 - ) { - tocPage++; - } - } - // Get the top-level block client ids, and add them and the client ids - // of their children to an ordered list. We don't use - // getClientIdsWithDescendants since it returns ids in the wrong order. - const allBlockClientIds = []; - for ( const blockClientId of getBlockOrder() ) { - allBlockClientIds.push( + if ( isPaginated ) { + // We can't use getBlockIndex because it only returns the index + // relative to sibling blocks. + const tocIndex = allBlockClientIds.indexOf( clientId ); + + for ( const [ + blockIndex, blockClientId, - ...getClientIdsOfDescendants( [ 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 = []; diff --git a/packages/block-library/src/table-of-contents/utils.ts b/packages/block-library/src/table-of-contents/utils.ts index 2110694cfa0c5..37050b43ce0ab 100644 --- a/packages/block-library/src/table-of-contents/utils.ts +++ b/packages/block-library/src/table-of-contents/utils.ts @@ -1,33 +1,3 @@ -/** - * Determines if the first path of indices leads to an earlier spot - * than the second path. - * - * @param pathA - * @param pathB - * @return Negative: A is before B; positive: A is after B; zero: the paths are identical. - */ -export function comparePathAToB( pathA: number[], pathB: number[] ): number { - let a: number | undefined = 0; - let b: number | undefined = 0; - - // To avoid modifying the arrays passed into the function. - const clonedPathA = [ ...pathA ]; - const clonedPathB = [ ...pathB ]; - - do { - a = clonedPathA.shift(); - b = clonedPathB.shift(); - } while ( a === b && a !== undefined && b !== undefined ); - - // Defaulting to -1 ensures that if a path terminates before the other, it - // is considered as leading to an earlier global index. This ensures that - // parent blocks are considered as coming before their first child. - // Technically, this isn't needed for the Table of Contents use-case, since - // neither it nor Page Break blocks support children, but it's good to play - // it safe in case this code gets reused elsewhere. - return ( a ?? -1 ) - ( b ?? -1 ); -} - export interface HeadingData { /** The plain text content of the heading. */ content: string; From 2ed8a4a11d0eced02026e01c64acc0b0a5b4fc12 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Thu, 7 Apr 2022 23:05:16 -0500 Subject: [PATCH 22/25] Optimize logic when onlyIncludeCurrentPage is false. --- packages/block-library/src/table-of-contents/edit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 95fc6bc044f15..0dcbb7c1a88ab 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -105,10 +105,10 @@ export default function TableOfContentsEdit( { // Get the client ids of all blocks in the editor. const allBlockClientIds = getClientIdsWithDescendants(); - // Calculate the page (of a paginated post) this block is part of. + // 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 ) { + if ( isPaginated && onlyIncludeCurrentPage ) { // We can't use getBlockIndex because it only returns the index // relative to sibling blocks. const tocIndex = allBlockClientIds.indexOf( clientId ); From c439c5ba49b5f135509e2b57e138d10a7a25020c Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Thu, 7 Apr 2022 23:13:35 -0500 Subject: [PATCH 23/25] Revise JSDoc for permalink const. --- packages/block-library/src/table-of-contents/edit.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 0dcbb7c1a88ab..fde27c4bec1ea 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -134,9 +134,8 @@ export default function TableOfContentsEdit( { let headingPage = 1; /** - * If the core/editor store is available, we can add permalinks to the - * generated table of contents. This variable will be a link to - * current post including pagination query if necessary. + * A permalink to the current post. If the core/editor store is + * unavailable, this variable will be null. */ const permalink = editorSelectors?.getPermalink() ?? null; From 425109fe37ab2eb03bd76e3fb623bdeaf3d2b440 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Thu, 14 Apr 2022 13:32:40 -0500 Subject: [PATCH 24/25] Update inspector panel title as per #40275. --- packages/block-library/src/table-of-contents/edit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index fde27c4bec1ea..1ab1dbaef694a 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -241,7 +241,7 @@ export default function TableOfContentsEdit( { const inspectorControls = ( - + Date: Wed, 27 Apr 2022 16:14:33 -0500 Subject: [PATCH 25/25] Make block experimental. --- packages/block-library/src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 94bc058ac090e..55d5aa9a94915 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -176,7 +176,6 @@ export const __experimentalGetCoreBlocks = () => [ socialLinks, spacer, table, - tableOfContents, tagCloud, textColumns, verse, @@ -270,6 +269,7 @@ export const __experimentalRegisterExperimentalCoreBlocks = process.env [ // Experimental blocks. postAuthorName, + tableOfContents, ...( window.__experimentalEnableListBlockV2 ? [ listItem ] : [] ),