From 4ec45cf902dc01359a2106252cc289d03767c61e Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Wed, 10 Mar 2021 14:29:44 -0600 Subject: [PATCH] ToC block: use static markup and only support core Heading and Page Break blocks. --- lib/blocks.php | 4 +- packages/block-library/src/index.js | 4 +- .../src/table-of-contents/block.json | 10 +- .../src/table-of-contents/edit.js | 154 ++++---- .../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 | 63 +--- 9 files changed, 145 insertions(+), 475 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/lib/blocks.php b/lib/blocks.php index f902cef724119..4bc842a4b1b18 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -40,7 +40,7 @@ function gutenberg_reregister_core_block_types() { 'social-links', 'spacer', 'table', - // 'table-of-contents', + 'table-of-contents', 'text-columns', 'verse', 'video', @@ -92,7 +92,7 @@ function gutenberg_reregister_core_block_types() { 'site-logo.php' => 'core/site-logo', 'site-tagline.php' => 'core/site-tagline', 'site-title.php' => 'core/site-title', - // 'table-of-contents.php' => 'core/table-of-contents', + 'table-of-contents.php' => 'core/table-of-contents', '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 e1b6d279acaad..482946556cbd5 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -55,7 +55,7 @@ import * as separator from './separator'; import * as shortcode from './shortcode'; import * as spacer from './spacer'; import * as table from './table'; -// import * as tableOfContents from './table-of-contents'; +import * as tableOfContents from './table-of-contents'; import * as textColumns from './text-columns'; import * as verse from './verse'; import * as video from './video'; @@ -166,7 +166,7 @@ export const __experimentalGetCoreBlocks = () => [ socialLink, 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 1008a886df890..657a1b77639f8 100644 --- a/packages/block-library/src/table-of-contents/block.json +++ b/packages/block-library/src/table-of-contents/block.json @@ -3,13 +3,19 @@ "name": "core/table-of-contents", "category": "layout", "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 095a35800da8b..37be031391719 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -23,20 +23,32 @@ import { } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; -import { renderToString, useEffect, useState } from '@wordpress/element'; +import { renderToString, useState } 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 {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). @@ -46,94 +58,112 @@ import { getHeadingsFromContent, linearToNestedHeadingList } from './utils'; * @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' - ), - postContent: select( editorStore ).getEditedPostContent(), - } ), + const listBlockExists = useSelect( + ( select ) => !! select( blocksStore ).getBlockType( 'core/list' ), [] ); + const { + // __unstableMarkNextChangeAsNotPersistent, + replaceBlocks, + } = useDispatch( blockEditorStore ); + // 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( + useSelect( ( select ) => { - if ( ! onlyIncludeCurrentPage ) { - return null; - } - const { getBlockAttributes, getBlockIndex, getBlockName, getBlockOrder, + getGlobalBlockCount, } = select( blockEditorStore ); + const { getPermalink } = select( editorStore ); + + 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 ] ); - 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; - } - } - } + const latestHeadings = []; - return page; - }, - [ clientId, onlyIncludeCurrentPage ] - ); + // The page (of a paginated post) the Table of Contents block will be + // part of. + let tocPage = 1; - useEffect( () => { - let latestHeadings; + // The page (of a paginated post) a heading will be part of. + let headingPage = 1; - if ( onlyIncludeCurrentPage ) { - const pagesOfContent = postContent.split( '' ); + // Link to post including pagination query if necessary. + const permalink = getPermalink(); - latestHeadings = getHeadingsFromContent( - pagesOfContent[ pageIndex - 1 ] - ); - } else { - latestHeadings = getHeadingsFromContent( postContent ); - } + let headingPageLink = isPaginated + ? addQueryArgs( permalink, { page: headingPage } ) + : permalink; - if ( ! isEqual( headings, latestHeadings ) ) { - setHeadings( latestHeadings ); - setHeadingTree( linearToNestedHeadingList( latestHeadings ) ); - } - }, [ pageIndex, postContent, onlyIncludeCurrentPage ] ); + for ( const [ i, blockClientId ] of blockOrder.entries() ) { + const blockName = getBlockName( blockClientId ); + if ( blockName === 'core/nextpage' ) { + headingPage++; + headingPageLink = addQueryArgs( + removeQueryArgs( permalink, [ 'page' ] ), + { page: headingPage } + ); + if ( i < blockIndex ) { + tocPage++; + } + } else if ( blockName === 'core/heading' ) { + // If we're only including headings from the current page (of a + // paginated post), then exit the loop if we've reached headings + // on the pages after the one with the Table of Contents block. + if ( onlyIncludeCurrentPage && headingPage > tocPage ) { + break; + } + // 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. + if ( ! onlyIncludeCurrentPage || headingPage === tocPage ) { + 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, + // page: headingPage, + } ); + } + } + } - const { replaceBlocks } = useDispatch( blockEditorStore ); + if ( ! isEqual( headings, latestHeadings ) ) { + // __unstableMarkNextChangeAsNotPersistent(); + setAttributes( { headings: latestHeadings } ); + setHeadingTree( linearToNestedHeadingList( latestHeadings ) ); + } + } + // ,[ + // clientId, + // onlyIncludeCurrentPage, + // // __unstableMarkNextChangeAsNotPersistent, + // ] + ); const toolbarControls = listBlockExists && ( @@ -189,7 +219,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 fc6149a0b0072..847e22755ee5a 100644 --- a/packages/block-library/src/table-of-contents/index.js +++ b/packages/block-library/src/table-of-contents/index.js @@ -9,6 +9,7 @@ import { __ } from '@wordpress/i18n'; import metadata from './block.json'; import edit from './edit'; import icon from './icon'; +import save from './save'; const { name } = metadata; @@ -22,4 +23,5 @@ export const settings = { icon, keywords: [ __( 'document outline' ), __( 'summary' ) ], 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 207995b8abf41..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', - $entry_class, - esc_url( $href ), - esc_html( $content ) - ); - } else { - $entry = sprintf( - '%2$s', - $entry_class, - esc_html( $content ) - ); - } - - return sprintf( - '
  • %1$s%2$s
  • ', - $entry, - $child_node['children'] - ? block_core_table_of_contents_render_list( - $child_node['children'], - $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..1606c46889ecb 100644 --- a/packages/block-library/src/table-of-contents/utils.js +++ b/packages/block-library/src/table-of-contents/utils.js @@ -1,67 +1,20 @@ /** - * @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 {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 {WPNestedHeadingData[]|null} children The sub-headings of this + * @property {NestedHeadingData[]|null} children The sub-headings of this * heading, if any. */ @@ -69,10 +22,10 @@ export function getHeadingsFromContent( content ) { * 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 {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 = [];