diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index 784850095f97d2..e1f7040e88fb49 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -31,6 +31,7 @@ @import "./spacer/editor.scss"; @import "./subhead/editor.scss"; @import "./table/editor.scss"; +@import "./table-of-contents/editor.scss"; @import "./tag-cloud/editor.scss"; @import "./text-columns/editor.scss"; @import "./verse/editor.scss"; diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index a9a9b78f34ca92..1d95150a6f808b 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -54,6 +54,7 @@ import * as shortcode from './shortcode'; import * as spacer from './spacer'; import * as subhead from './subhead'; import * as table from './table'; +import * as tableOfContents from './table-of-contents'; import * as template from './template'; import * as textColumns from './text-columns'; import * as verse from './verse'; @@ -119,6 +120,7 @@ export const registerCoreBlocks = () => { spacer, subhead, table, + tableOfContents, tagCloud, template, textColumns, diff --git a/packages/block-library/src/table-of-contents/ListLevel.js b/packages/block-library/src/table-of-contents/ListLevel.js new file mode 100644 index 00000000000000..cdbc56d871e5fe --- /dev/null +++ b/packages/block-library/src/table-of-contents/ListLevel.js @@ -0,0 +1,69 @@ +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/editor'; + +export default function ListLevel( props ) { + const { edit, attributes, setAttributes } = props; + let childnodes = null; + + if ( props.children ) { + childnodes = props.children.map( function( childnode ) { + const link = getLinkElement( childnode, props ); + + return ( +
  • + { link } + { childnode.children ? + { childnode.children } + : null } +
  • + ); + } ); + + return ( + + ); + } +} + +function getLinkElement( childnode, props ) { + const { edit, attributes, setAttributes } = props; + const { headings, autosync } = attributes; + + const updateHeading = ( content ) => { + headings[ childnode.index ].content = content; + setAttributes( { headings } ); + }; + + if ( autosync ) { + return { childnode.block.content }; + } + + if ( edit ) { + return ( + updateHeading( content ) } + value={ childnode.block.content } + /> + ); + } + + return ( + + ); +} 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 00000000000000..947c5aa078ba42 --- /dev/null +++ b/packages/block-library/src/table-of-contents/block.json @@ -0,0 +1,4 @@ +{ + "name": "core/table-of-contents", + "category": "common" +} \ No newline at end of file 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 00000000000000..3dbe16ac380f0b --- /dev/null +++ b/packages/block-library/src/table-of-contents/edit.js @@ -0,0 +1,128 @@ +/** + * Internal dependencies + */ +import * as Utils from './utils'; +import ListLevel from './ListLevel'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { subscribe } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { + IconButton, + Toolbar, + PanelBody, + ToggleControl, +} from '@wordpress/components'; +import { + BlockControls, + InspectorControls, +} from '@wordpress/editor'; + +class TOCEdit extends Component { + constructor() { + super( ...arguments ); + + this.state = { + wpDataUnsubscribe: null, + }; + + this.toggleAttribute = this.toggleAttribute.bind( this ); + this.refresh = this.refresh.bind( this ); + } + + toggleAttribute( propName ) { + const value = this.props.attributes[ propName ]; + const { setAttributes } = this.props; + + setAttributes( { [ propName ]: ! value } ); + } + + refresh() { + const { setAttributes } = this.props; + const headings = Utils.getPageHeadings(); + setAttributes( { headings } ); + } + + componentDidMount() { + const { attributes, setAttributes } = this.props; + const headings = attributes.headings || []; + const wpDataUnsubscribe = subscribe( () => { + const pageHeadings = Utils.getPageHeadings(); + this.setState( { pageHeadings } ); + } ); + + setAttributes( { headings } ); + this.setState( { wpDataUnsubscribe } ); + } + + componentWillUnmount() { + this.state.wpDataUnsubscribe(); + } + + componentDidUpdate( prevProps, prevState ) { + const { attributes, setAttributes } = this.props; + const pageHeadings = Utils.getPageHeadings(); + if ( JSON.stringify( pageHeadings ) !== JSON.stringify( prevState.pageHeadings ) ) { + this.setState( { pageHeadings } ); + if ( attributes.autosync ) { + setAttributes( { headings: pageHeadings } ); // this is displayed on the page + } + } + } + + render() { + const { attributes, setAttributes } = this.props; + const { autosync } = attributes; + const headings = attributes.headings || []; + if ( headings.length === 0 ) { + return (

    { __( 'Start adding headings to generate Table of Contents' ) }

    ); + } + + Utils.updateHeadingBlockAnchors(); + + return ( +
    + { ! autosync && + + + + + + } + { + + + { + if ( ! autosync ) { + this.refresh(); + } + this.toggleAttribute( 'autosync' ); + } } + /> + + + } + + { Utils.linearToNestedList( headings ) } + +
    + ); + } +} + +export default TOCEdit; 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 00000000000000..5ecf514b4c9c53 --- /dev/null +++ b/packages/block-library/src/table-of-contents/editor.scss @@ -0,0 +1,21 @@ +.wp-block-table-of-contents { + .editor-block-list__block & ul { + padding-left: 1.3em; + + a { + display: block; + + &:focus { + box-shadow: none; + } + } + + ul { + margin-bottom: 0; + } + } + + p { + opacity: 0.5; + } +} 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 00000000000000..2a4e169af43ca6 --- /dev/null +++ b/packages/block-library/src/table-of-contents/index.js @@ -0,0 +1,39 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import metadata from './block.json'; +import save from './save'; + +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', + attributes: { + headings: { + source: 'query', + selector: 'a', + query: { + content: { source: 'text' }, + anchor: { source: 'attribute', attribute: 'href' }, + level: { source: 'attribute', attribute: 'data-level' }, + }, + }, + autosync: { + type: 'boolean', + default: true, + }, + }, + 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 00000000000000..60c17b1ab4560d --- /dev/null +++ b/packages/block-library/src/table-of-contents/save.js @@ -0,0 +1,27 @@ +/** + * Internal dependencies + */ +import * as Utils from './utils'; +import ListLevel from './ListLevel'; + +export default function save( props ) { + const { attributes, setAttributes } = props; + const headings = attributes.headings; + + if ( headings.length === 0 ) { + return null; + } + + Utils.updateHeadingBlockAnchors(); + return ( + + ); +} 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 00000000000000..9aff56cd8bac82 --- /dev/null +++ b/packages/block-library/src/table-of-contents/utils.js @@ -0,0 +1,118 @@ +/** + * WordPress dependencies + */ +import { select } from '@wordpress/data'; +import { cleanForSlug } from '@wordpress/editor'; + +export function linearToNestedList( array, index = 0 ) { + const returnValue = []; + + array.forEach( function( heading, key ) { + if ( typeof heading.content === 'undefined' ) { + return; + } + + // Make sure we are only working with the same level as the first iteration in our set. + if ( heading.level === array[ 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 ( + ( typeof array[ key + 1 ] !== 'undefined' ) && + ( array[ 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 = array.length; + for ( let i = ( key + 1 ); i < array.length; i++ ) { + if ( array[ i ].level === heading.level ) { + endOfSlice = i; + break; + } + } + + // We found a child node: Push a new node onto the return array with children. + returnValue.push( { + block: heading, + index: index + key, + children: linearToNestedList( array.slice( key + 1, endOfSlice ), index + key + 1 ), + } ); + } else { + // No child node: Push a new node onto the return array. + returnValue.push( { + block: heading, + index: index + key, + children: null, + } ); + } + } + } ); + + return returnValue; +} + +export function getPageHeadings() { + return convertHeadingBlocksToAttributes( getHeadingBlocks() ); +} + +export function getHeadingBlocks() { + const editor = select( 'core/block-editor' ); + return editor.getBlocks().filter( ( block ) => block.name === 'core/heading' ); +} + +export function convertHeadingBlocksToAttributes( headingBlocks ) { + return headingBlocks.map( function( heading ) { + const level = heading.attributes.level.toString(); + + const headingContent = heading.attributes.content || ''; + const anchorContent = heading.attributes.anchor || ''; + + // strip html from heading and attribute content + const contentDiv = document.createElement( 'div' ); + + contentDiv.innerHTML = headingContent; + const content = contentDiv.textContent || contentDiv.innerText || ''; + + contentDiv.innerHTML = anchorContent; + let anchor = contentDiv.textContent || contentDiv.innerText || ''; + + if ( anchor !== '' && anchor.indexOf( '#' ) === -1 ) { + anchor = '#' + cleanForSlug( anchor ); + } + + return { content, anchor, level }; + } ); +} + +export function updateHeadingBlockAnchors() { + // Add anchors to any headings that don't have one. + getHeadingBlocks().forEach( function( heading, key ) { + const headingAnchorEmpty = ( typeof heading.attributes.anchor === 'undefined' || heading.attributes.anchor === '' ); + const headingContentEmpty = ( typeof heading.attributes.content === 'undefined' || heading.attributes.content === '' ); + const headingDefaultAnchor = ( ! headingAnchorEmpty && heading.attributes.anchor.indexOf( key + '-' ) === 0 ); + + if ( ! headingContentEmpty && ( headingAnchorEmpty || headingDefaultAnchor ) ) { + heading.attributes.anchor = key + '-' + cleanForSlug( heading.attributes.content.toString() ); + } + } ); +} + +export function haveHeadingsChanged( oldHeadings, newHeadings ) { + if ( oldHeadings.length !== newHeadings.length ) { + return true; + } + + const changedHeadings = oldHeadings.filter( ( heading, index ) => { + const newHeading = newHeadings[ index ]; + + return ( + heading.content !== newHeading.content || + heading.anchor !== newHeading.anchor || + heading.level !== newHeading.level + ); + } ); + + // Return boolean value from length. + return ! ! +changedHeadings.length; +} diff --git a/packages/e2e-tests/fixtures/block-transforms.js b/packages/e2e-tests/fixtures/block-transforms.js index 7391c1bc7adb90..3e75b883210449 100644 --- a/packages/e2e-tests/fixtures/block-transforms.js +++ b/packages/e2e-tests/fixtures/block-transforms.js @@ -416,6 +416,12 @@ export const EXPECTED_TRANSFORMS = { 'Group', ], }, + 'core__table-of-contents': { + originalBlock: 'Table of Contents', + availableTransforms: [ + 'Group', + ], + }, 'core__tag-cloud': { originalBlock: 'Tag Cloud', availableTransforms: [ 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 00000000000000..d16d4106710de1 --- /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 00000000000000..861783a7f749dd --- /dev/null +++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.json @@ -0,0 +1,29 @@ +[ + { + "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" + } + ], + "autosync": true + }, + "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 00000000000000..589603df59a0ff --- /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 00000000000000..06fbdee15a85ee --- /dev/null +++ b/packages/e2e-tests/fixtures/blocks/core__table-of-contents.serialized.html @@ -0,0 +1,3 @@ + + +