From 2a248d23473cf7858f3079c359b25d911bae66e7 Mon Sep 17 00:00:00 2001 From: Sean Leavey Date: Fri, 20 Mar 2020 10:46:54 +0100 Subject: [PATCH] SeanDS's implementation #21040 (https://github.com/WordPress/gutenberg/pull/21040). Copy changes from pull request #15426 (https://github.com/WordPress/gutenberg/pull/15426). Adds Table of Contents block to the editor. Code contributions in this commit entirely made by ashwin-pc, originally based on the "Guidepost" block by sorta brilliant (https://sortabrilliant.com/guidepost/). Apply polish suggestions from code review. Improve variable names. Add comment Get rid of autosync (users should now convert to list if they want to edit the contents) Add ability to transform into list; remove unused ListLevel props Update table-of-contents block test configuration Simplify expression Remove unused function Remove unused styles. Rename TOCEdit to TableOfContentsEdit Apply suggestions from code review Remove non-existent import Make imports explicit Remove unused function Change unsubscribe function to class property Change JSON.stringify comparison to Lodash's isEqual Turns out refresh() is required Remove unnecessary state setting Don't change state on save Change behaviour to only add links if there are anchors specified by the user Newline Replace anchor with explicit key in map since anchor can now sometimes be empty Update test data Update packages/block-library/src/table-of-contents/block.json Rename ListLevel to ListItem for clarity and polish. Co-authored-by: ashwin-pc Co-authored-by: Daniel Richards Co-authored-by: Zebulan Stanphill --- packages/block-library/src/index.js | 2 + .../src/table-of-contents/ListItem.js | 36 ++++++ .../src/table-of-contents/block.json | 17 +++ .../src/table-of-contents/edit.js | 72 ++++++++++++ .../src/table-of-contents/index.js | 28 +++++ .../src/table-of-contents/save.js | 20 ++++ .../src/table-of-contents/transforms.js | 31 +++++ .../src/table-of-contents/utils.js | 111 ++++++++++++++++++ .../e2e-tests/fixtures/block-transforms.js | 4 + .../blocks/core__table-of-contents.html | 3 + .../blocks/core__table-of-contents.json | 32 +++++ .../core__table-of-contents.parsed.json | 11 ++ .../core__table-of-contents.serialized.html | 3 + 13 files changed, 370 insertions(+) create mode 100644 packages/block-library/src/table-of-contents/ListItem.js create mode 100644 packages/block-library/src/table-of-contents/block.json create mode 100644 packages/block-library/src/table-of-contents/edit.js create mode 100644 packages/block-library/src/table-of-contents/index.js create mode 100644 packages/block-library/src/table-of-contents/save.js create mode 100644 packages/block-library/src/table-of-contents/transforms.js create mode 100644 packages/block-library/src/table-of-contents/utils.js create mode 100644 packages/e2e-tests/fixtures/blocks/core__table-of-contents.html create mode 100644 packages/e2e-tests/fixtures/blocks/core__table-of-contents.json create mode 100644 packages/e2e-tests/fixtures/blocks/core__table-of-contents.parsed.json create mode 100644 packages/e2e-tests/fixtures/blocks/core__table-of-contents.serialized.html diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 5ad6a33fd2ac0..be638cfaa3b3b 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -55,6 +55,7 @@ import * as shortcode from './shortcode'; import * as spacer from './spacer'; import * as subhead from './subhead'; import * as table from './table'; +import * as tableOfContents from './table-of-contents'; import * as textColumns from './text-columns'; import * as verse from './verse'; import * as video from './video'; @@ -155,6 +156,7 @@ export const registerCoreBlocks = () => { spacer, subhead, table, + tableOfContents, tagCloud, textColumns, verse, diff --git a/packages/block-library/src/table-of-contents/ListItem.js b/packages/block-library/src/table-of-contents/ListItem.js new file mode 100644 index 0000000000000..e25b6d2ee806a --- /dev/null +++ b/packages/block-library/src/table-of-contents/ListItem.js @@ -0,0 +1,36 @@ +export default function ListItem( { children, noWrapList = false } ) { + if ( children ) { + const childNodes = children.map( function( childNode, index ) { + const { content, anchor, level } = childNode.block; + + const entry = anchor ? ( + + { content } + + ) : ( + + { content } + + ); + + return ( +
  • + { entry } + { childNode.children ? ( + { childNode.children } + ) : null } +
  • + ); + } ); + + // Don't wrap the list elements in
      if converting to a core/list. + return noWrapList ? childNodes :
        { childNodes }
      ; + } +} diff --git a/packages/block-library/src/table-of-contents/block.json b/packages/block-library/src/table-of-contents/block.json new file mode 100644 index 0000000000000..2b503ce44dd7f --- /dev/null +++ b/packages/block-library/src/table-of-contents/block.json @@ -0,0 +1,17 @@ +{ + "name": "core/table-of-contents", + "category": "common", + "attributes": { + "headings": { + "type": "array", + "source": "query", + "selector": ".blocks-table-of-contents-entry", + "default": [], + "query": { + "content": { "source": "text" }, + "anchor": { "source": "attribute", "attribute": "href" }, + "level": { "source": "attribute", "attribute": "data-level" } + } + } + } +} diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js new file mode 100644 index 0000000000000..b302578d8ea8d --- /dev/null +++ b/packages/block-library/src/table-of-contents/edit.js @@ -0,0 +1,72 @@ +/** + * External dependencies + */ + +const { isEqual } = require( 'lodash' ); + +/** + * Internal dependencies + */ +import { getHeadingsList, linearToNestedHeadingList } from './utils'; +import ListItem from './ListItem'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { subscribe } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; + +class TableOfContentsEdit extends Component { + componentDidMount() { + const { attributes, setAttributes } = this.props; + let { headings } = attributes; + + // Update the table of contents when changes are made to other blocks. + this.unsubscribe = subscribe( () => { + this.setState( { headings: getHeadingsList() } ); + } ); + + if ( ! headings ) { + headings = getHeadingsList(); + } + + setAttributes( { headings } ); + } + + componentWillUnmount() { + this.unsubscribe(); + } + + componentDidUpdate( prevProps, prevState ) { + const { setAttributes } = this.props; + const { headings } = this.state; + + if ( prevState && ! isEqual( headings, prevState.headings ) ) { + setAttributes( { headings } ); + } + } + + render() { + const { attributes } = this.props; + const { headings = [] } = attributes; + + if ( headings.length === 0 ) { + return ( +

      + { __( + 'Start adding heading blocks to see a Table of Contents here' + ) } +

      + ); + } + + return ( +
      + { linearToNestedHeadingList( headings ) } +
      + ); + } +} + +export default TableOfContentsEdit; diff --git a/packages/block-library/src/table-of-contents/index.js b/packages/block-library/src/table-of-contents/index.js new file mode 100644 index 0000000000000..3a2ca88ede24e --- /dev/null +++ b/packages/block-library/src/table-of-contents/index.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title: __( 'Table of Contents' ), + description: __( + 'Add a list of internal links allowing your readers to quickly navigate around.' + ), + icon: 'list-view', + category: 'layout', + transforms, + edit, + save, +}; 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..934ea75ab02be --- /dev/null +++ b/packages/block-library/src/table-of-contents/save.js @@ -0,0 +1,20 @@ +/** + * Internal dependencies + */ +import { linearToNestedHeadingList } from './utils'; +import ListItem from './ListItem'; + +export default function save( props ) { + const { attributes } = props; + const { headings } = attributes; + + if ( headings.length === 0 ) { + return null; + } + + return ( + + ); +} diff --git a/packages/block-library/src/table-of-contents/transforms.js b/packages/block-library/src/table-of-contents/transforms.js new file mode 100644 index 0000000000000..80c72ac1a103e --- /dev/null +++ b/packages/block-library/src/table-of-contents/transforms.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { renderToString } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { linearToNestedHeadingList } from './utils'; +import ListItem from './ListItem'; + +const transforms = { + to: [ + { + type: 'block', + blocks: [ 'core/list' ], + transform: ( { headings } ) => { + return createBlock( 'core/list', { + values: renderToString( + + { linearToNestedHeadingList( headings ) } + + ), + } ); + }, + }, + ], +}; + +export default transforms; diff --git a/packages/block-library/src/table-of-contents/utils.js b/packages/block-library/src/table-of-contents/utils.js new file mode 100644 index 0000000000000..010eb17475da3 --- /dev/null +++ b/packages/block-library/src/table-of-contents/utils.js @@ -0,0 +1,111 @@ +/** + * WordPress dependencies + */ +import { select } from '@wordpress/data'; +import { create } from '@wordpress/rich-text'; + +/** + * Takes a flat list of heading parameters and nests them based on each header's + * immediate parent's level. + * + * @param {Array} headingsList The flat list of headings to nest. + * @param {number} index The current list index. + * @return {Array} The nested list of headings. + */ +export function linearToNestedHeadingList( headingsList, index = 0 ) { + const nestedHeadingsList = []; + + headingsList.forEach( function( heading, key ) { + if ( heading.content === undefined ) { + return; + } + + // Make sure we are only working with the same level as the first iteration in our set. + if ( heading.level === headingsList[ 0 ].level ) { + // Check that the next iteration will return a value. + // If it does and the next level is greater than the current level, + // the next iteration becomes a child of the current interation. + if ( + headingsList[ key + 1 ] !== undefined && + headingsList[ key + 1 ].level > heading.level + ) { + // We need to calculate the last index before the next iteration that has the same level (siblings). + // We then use this last index to slice the array for use in recursion. + // This prevents duplicate nodes. + let endOfSlice = headingsList.length; + for ( let i = key + 1; i < headingsList.length; i++ ) { + if ( headingsList[ i ].level === heading.level ) { + endOfSlice = i; + break; + } + } + + // We found a child node: Push a new node onto the return array with children. + nestedHeadingsList.push( { + block: heading, + index: index + key, + children: linearToNestedHeadingList( + headingsList.slice( key + 1, endOfSlice ), + index + key + 1 + ), + } ); + } else { + // No child node: Push a new node onto the return array. + nestedHeadingsList.push( { + block: heading, + index: index + key, + children: null, + } ); + } + } + } ); + + return nestedHeadingsList; +} + +/** + * Gets a list of heading texts, anchors and levels in the current document. + * + * @return {Array} The list of headings. + */ +export function getHeadingsList() { + return convertBlocksToTableOfContents( getHeadingBlocks() ); +} + +/** + * Gets a list of heading blocks in the current document. + * + * @return {Array} The list of heading blocks. + */ +export function getHeadingBlocks() { + const editor = select( 'core/block-editor' ); + return editor + .getBlocks() + .filter( ( block ) => block.name === 'core/heading' ); +} + +/** + * Extracts text, anchor and level from a list of heading blocks. + * + * @param {Array} headingBlocks The list of heading blocks. + * @return {Array} The list of heading parameters. + */ +export function convertBlocksToTableOfContents( headingBlocks ) { + return headingBlocks.map( function( heading ) { + // This is a string so that it can be stored/sourced as an attribute in the table of contents + // block using a data attribute. + const level = heading.attributes.level.toString(); + + const headingContent = heading.attributes.content; + const anchorContent = heading.attributes.anchor; + + // Strip html from heading to use as the table of contents entry. + const content = headingContent + ? create( { html: headingContent } ).text + : ''; + + const anchor = anchorContent ? '#' + anchorContent : ''; + + return { content, anchor, level }; + } ); +} diff --git a/packages/e2e-tests/fixtures/block-transforms.js b/packages/e2e-tests/fixtures/block-transforms.js index c9ebbc3ebe6cc..7cc40740df038 100644 --- a/packages/e2e-tests/fixtures/block-transforms.js +++ b/packages/e2e-tests/fixtures/block-transforms.js @@ -512,6 +512,10 @@ export const EXPECTED_TRANSFORMS = { originalBlock: 'Table', availableTransforms: [ 'Group' ], }, + 'core__table-of-contents': { + originalBlock: 'Table of Contents', + availableTransforms: [ 'Group', 'List' ], + }, 'core__tag-cloud': { originalBlock: 'Tag Cloud', availableTransforms: [ 'Group' ], diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.html b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.html new file mode 100644 index 0000000000000..0648739665c54 --- /dev/null +++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.json b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.json new file mode 100644 index 0000000000000..3769fd2eed875 --- /dev/null +++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.json @@ -0,0 +1,32 @@ +[ + { + "clientId": "_clientId_0", + "name": "core/table-of-contents", + "isValid": true, + "attributes": { + "headings": [ + { + "content": "First Heading", + "anchor": "#0-First-Heading", + "level": "2" + }, + { + "content": "Sub Heading", + "anchor": "#1-Sub-Heading", + "level": "3" + }, + { + "content": "Another Sub Heading", + "anchor": "#2-Another-Sub-Heading", + "level": "3" + }, + { + "content": "A Sub Heading Without Link", + "level": "3" + } + ] + }, + "innerBlocks": [], + "originalContent": "" + } +] diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.parsed.json b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.parsed.json new file mode 100644 index 0000000000000..858fc9f39961a --- /dev/null +++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.parsed.json @@ -0,0 +1,11 @@ +[ + { + "blockName": "core/table-of-contents", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "\n\n", + "innerContent": [ + "\n\n" + ] + } +] diff --git a/packages/e2e-tests/fixtures/blocks/core__table-of-contents.serialized.html b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.serialized.html new file mode 100644 index 0000000000000..9e7a0258eb32e --- /dev/null +++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.serialized.html @@ -0,0 +1,3 @@ + + +