Skip to content

Commit

Permalink
SeanDS's implementation #21040 (#21040).
Browse files Browse the repository at this point in the history
Copy changes from pull request #15426 (#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 <[email protected]>
Co-authored-by: Daniel Richards <[email protected]>
Co-authored-by: Zebulan Stanphill <[email protected]>
  • Loading branch information
4 people committed Jun 29, 2020
1 parent 6e80f44 commit 2a248d2
Show file tree
Hide file tree
Showing 13 changed files with 370 additions and 0 deletions.
2 changes: 2 additions & 0 deletions packages/block-library/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -155,6 +156,7 @@ export const registerCoreBlocks = () => {
spacer,
subhead,
table,
tableOfContents,
tagCloud,
textColumns,
verse,
Expand Down
36 changes: 36 additions & 0 deletions packages/block-library/src/table-of-contents/ListItem.js
Original file line number Diff line number Diff line change
@@ -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 ? (
<a
className="blocks-table-of-contents-entry"
href={ anchor }
data-level={ level }
>
{ content }
</a>
) : (
<span
className="blocks-table-of-contents-entry"
data-level={ level }
>
{ content }
</span>
);

return (
<li key={ index }>
{ entry }
{ childNode.children ? (
<ListItem>{ childNode.children }</ListItem>
) : null }
</li>
);
} );

// Don't wrap the list elements in <ul> if converting to a core/list.
return noWrapList ? childNodes : <ul>{ childNodes }</ul>;
}
}
17 changes: 17 additions & 0 deletions packages/block-library/src/table-of-contents/block.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
}
72 changes: 72 additions & 0 deletions packages/block-library/src/table-of-contents/edit.js
Original file line number Diff line number Diff line change
@@ -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 (
<p>
{ __(
'Start adding heading blocks to see a Table of Contents here'
) }
</p>
);
}

return (
<div className={ this.props.className }>
<ListItem>{ linearToNestedHeadingList( headings ) }</ListItem>
</div>
);
}
}

export default TableOfContentsEdit;
28 changes: 28 additions & 0 deletions packages/block-library/src/table-of-contents/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
20 changes: 20 additions & 0 deletions packages/block-library/src/table-of-contents/save.js
Original file line number Diff line number Diff line change
@@ -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 (
<nav className={ props.className }>
<ListItem>{ linearToNestedHeadingList( headings ) }</ListItem>
</nav>
);
}
31 changes: 31 additions & 0 deletions packages/block-library/src/table-of-contents/transforms.js
Original file line number Diff line number Diff line change
@@ -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(
<ListItem noWrapList>
{ linearToNestedHeadingList( headings ) }
</ListItem>
),
} );
},
},
],
};

export default transforms;
111 changes: 111 additions & 0 deletions packages/block-library/src/table-of-contents/utils.js
Original file line number Diff line number Diff line change
@@ -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 };
} );
}
4 changes: 4 additions & 0 deletions packages/e2e-tests/fixtures/block-transforms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' ],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<!-- wp:core/table-of-contents -->
<nav class="wp-block-table-of-contents"><ul><li><a class="blocks-table-of-contents-entry" href="#0-First-Heading" data-level="2">First Heading</a><ul><li><a class="blocks-table-of-contents-entry" href="#1-Sub-Heading" data-level="3">Sub Heading</a></li><li><a class="blocks-table-of-contents-entry" href="#2-Another-Sub-Heading" data-level="3">Another Sub Heading</a></li><li><span class="blocks-table-of-contents-entry" data-level="3">A Sub Heading Without Link</span></li></ul></li></ul></nav>
<!-- /wp:core/table-of-contents -->
32 changes: 32 additions & 0 deletions packages/e2e-tests/fixtures/blocks/core__table-of-contents.json
Original file line number Diff line number Diff line change
@@ -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": "<nav class=\"wp-block-table-of-contents\"><ul><li><a class=\"blocks-table-of-contents-entry\" href=\"#0-First-Heading\" data-level=\"2\">First Heading</a><ul><li><a class=\"blocks-table-of-contents-entry\" href=\"#1-Sub-Heading\" data-level=\"3\">Sub Heading</a></li><li><a class=\"blocks-table-of-contents-entry\" href=\"#2-Another-Sub-Heading\" data-level=\"3\">Another Sub Heading</a></li><li><span class=\"blocks-table-of-contents-entry\" data-level=\"3\">A Sub Heading Without Link</span></li></ul></li></ul></nav>"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[
{
"blockName": "core/table-of-contents",
"attrs": {},
"innerBlocks": [],
"innerHTML": "\n<nav class=\"wp-block-table-of-contents\"><ul><li><a class=\"blocks-table-of-contents-entry\" href=\"#0-First-Heading\" data-level=\"2\">First Heading</a><ul><li><a class=\"blocks-table-of-contents-entry\" href=\"#1-Sub-Heading\" data-level=\"3\">Sub Heading</a></li><li><a class=\"blocks-table-of-contents-entry\" href=\"#2-Another-Sub-Heading\" data-level=\"3\">Another Sub Heading</a></li><li><span class=\"blocks-table-of-contents-entry\" data-level=\"3\">A Sub Heading Without Link</span></li></ul></li></ul></nav>\n",
"innerContent": [
"\n<nav class=\"wp-block-table-of-contents\"><ul><li><a class=\"blocks-table-of-contents-entry\" href=\"#0-First-Heading\" data-level=\"2\">First Heading</a><ul><li><a class=\"blocks-table-of-contents-entry\" href=\"#1-Sub-Heading\" data-level=\"3\">Sub Heading</a></li><li><a class=\"blocks-table-of-contents-entry\" href=\"#2-Another-Sub-Heading\" data-level=\"3\">Another Sub Heading</a></li><li><span class=\"blocks-table-of-contents-entry\" data-level=\"3\">A Sub Heading Without Link</span></li></ul></li></ul></nav>\n"
]
}
]
Loading

0 comments on commit 2a248d2

Please sign in to comment.