Skip to content

Commit eeb4dda

Browse files
SeanDSashwin-pctalldanZebulanStanphill
committed
SeanDS's implementation #21040 (#21040).
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 <ashwinpc1993@gmail.com> Co-authored-by: Daniel Richards <daniel.p.richards@gmail.com> Co-authored-by: Zebulan Stanphill <zebulanstanphill@protonmail.com>
1 parent aa0abb9 commit eeb4dda

13 files changed

Lines changed: 370 additions & 0 deletions

packages/block-library/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import * as shortcode from './shortcode';
5454
import * as spacer from './spacer';
5555
import * as subhead from './subhead';
5656
import * as table from './table';
57+
import * as tableOfContents from './table-of-contents';
5758
import * as textColumns from './text-columns';
5859
import * as verse from './verse';
5960
import * as video from './video';
@@ -159,6 +160,7 @@ export const registerCoreBlocks = () => {
159160
spacer,
160161
subhead,
161162
table,
163+
tableOfContents,
162164
tagCloud,
163165
textColumns,
164166
verse,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export default function ListItem( { children, noWrapList = false } ) {
2+
if ( children ) {
3+
const childNodes = children.map( function( childNode, index ) {
4+
const { content, anchor, level } = childNode.block;
5+
6+
const entry = anchor ? (
7+
<a
8+
className="blocks-table-of-contents-entry"
9+
href={ anchor }
10+
data-level={ level }
11+
>
12+
{ content }
13+
</a>
14+
) : (
15+
<span
16+
className="blocks-table-of-contents-entry"
17+
data-level={ level }
18+
>
19+
{ content }
20+
</span>
21+
);
22+
23+
return (
24+
<li key={ index }>
25+
{ entry }
26+
{ childNode.children ? (
27+
<ListItem>{ childNode.children }</ListItem>
28+
) : null }
29+
</li>
30+
);
31+
} );
32+
33+
// Don't wrap the list elements in <ul> if converting to a core/list.
34+
return noWrapList ? childNodes : <ul>{ childNodes }</ul>;
35+
}
36+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "core/table-of-contents",
3+
"category": "common",
4+
"attributes": {
5+
"headings": {
6+
"type": "array",
7+
"source": "query",
8+
"selector": ".blocks-table-of-contents-entry",
9+
"default": [],
10+
"query": {
11+
"content": { "source": "text" },
12+
"anchor": { "source": "attribute", "attribute": "href" },
13+
"level": { "source": "attribute", "attribute": "data-level" }
14+
}
15+
}
16+
}
17+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* External dependencies
3+
*/
4+
5+
const { isEqual } = require( 'lodash' );
6+
7+
/**
8+
* Internal dependencies
9+
*/
10+
import { getHeadingsList, linearToNestedHeadingList } from './utils';
11+
import ListItem from './ListItem';
12+
13+
/**
14+
* WordPress dependencies
15+
*/
16+
import { Component } from '@wordpress/element';
17+
import { subscribe } from '@wordpress/data';
18+
import { __ } from '@wordpress/i18n';
19+
20+
class TableOfContentsEdit extends Component {
21+
componentDidMount() {
22+
const { attributes, setAttributes } = this.props;
23+
let { headings } = attributes;
24+
25+
// Update the table of contents when changes are made to other blocks.
26+
this.unsubscribe = subscribe( () => {
27+
this.setState( { headings: getHeadingsList() } );
28+
} );
29+
30+
if ( ! headings ) {
31+
headings = getHeadingsList();
32+
}
33+
34+
setAttributes( { headings } );
35+
}
36+
37+
componentWillUnmount() {
38+
this.unsubscribe();
39+
}
40+
41+
componentDidUpdate( prevProps, prevState ) {
42+
const { setAttributes } = this.props;
43+
const { headings } = this.state;
44+
45+
if ( prevState && ! isEqual( headings, prevState.headings ) ) {
46+
setAttributes( { headings } );
47+
}
48+
}
49+
50+
render() {
51+
const { attributes } = this.props;
52+
const { headings = [] } = attributes;
53+
54+
if ( headings.length === 0 ) {
55+
return (
56+
<p>
57+
{ __(
58+
'Start adding heading blocks to see a Table of Contents here'
59+
) }
60+
</p>
61+
);
62+
}
63+
64+
return (
65+
<div className={ this.props.className }>
66+
<ListItem>{ linearToNestedHeadingList( headings ) }</ListItem>
67+
</div>
68+
);
69+
}
70+
}
71+
72+
export default TableOfContentsEdit;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { __ } from '@wordpress/i18n';
5+
6+
/**
7+
* Internal dependencies
8+
*/
9+
import edit from './edit';
10+
import metadata from './block.json';
11+
import save from './save';
12+
import transforms from './transforms';
13+
14+
const { name } = metadata;
15+
16+
export { metadata, name };
17+
18+
export const settings = {
19+
title: __( 'Table of Contents' ),
20+
description: __(
21+
'Add a list of internal links allowing your readers to quickly navigate around.'
22+
),
23+
icon: 'list-view',
24+
category: 'layout',
25+
transforms,
26+
edit,
27+
save,
28+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Internal dependencies
3+
*/
4+
import { linearToNestedHeadingList } from './utils';
5+
import ListItem from './ListItem';
6+
7+
export default function save( props ) {
8+
const { attributes } = props;
9+
const { headings } = attributes;
10+
11+
if ( headings.length === 0 ) {
12+
return null;
13+
}
14+
15+
return (
16+
<nav className={ props.className }>
17+
<ListItem>{ linearToNestedHeadingList( headings ) }</ListItem>
18+
</nav>
19+
);
20+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { createBlock } from '@wordpress/blocks';
5+
import { renderToString } from '@wordpress/element';
6+
7+
/**
8+
* Internal dependencies
9+
*/
10+
import { linearToNestedHeadingList } from './utils';
11+
import ListItem from './ListItem';
12+
13+
const transforms = {
14+
to: [
15+
{
16+
type: 'block',
17+
blocks: [ 'core/list' ],
18+
transform: ( { headings } ) => {
19+
return createBlock( 'core/list', {
20+
values: renderToString(
21+
<ListItem noWrapList>
22+
{ linearToNestedHeadingList( headings ) }
23+
</ListItem>
24+
),
25+
} );
26+
},
27+
},
28+
],
29+
};
30+
31+
export default transforms;
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { select } from '@wordpress/data';
5+
import { create } from '@wordpress/rich-text';
6+
7+
/**
8+
* Takes a flat list of heading parameters and nests them based on each header's
9+
* immediate parent's level.
10+
*
11+
* @param {Array} headingsList The flat list of headings to nest.
12+
* @param {number} index The current list index.
13+
* @return {Array} The nested list of headings.
14+
*/
15+
export function linearToNestedHeadingList( headingsList, index = 0 ) {
16+
const nestedHeadingsList = [];
17+
18+
headingsList.forEach( function( heading, key ) {
19+
if ( heading.content === undefined ) {
20+
return;
21+
}
22+
23+
// Make sure we are only working with the same level as the first iteration in our set.
24+
if ( heading.level === headingsList[ 0 ].level ) {
25+
// Check that the next iteration will return a value.
26+
// If it does and the next level is greater than the current level,
27+
// the next iteration becomes a child of the current interation.
28+
if (
29+
headingsList[ key + 1 ] !== undefined &&
30+
headingsList[ key + 1 ].level > heading.level
31+
) {
32+
// We need to calculate the last index before the next iteration that has the same level (siblings).
33+
// We then use this last index to slice the array for use in recursion.
34+
// This prevents duplicate nodes.
35+
let endOfSlice = headingsList.length;
36+
for ( let i = key + 1; i < headingsList.length; i++ ) {
37+
if ( headingsList[ i ].level === heading.level ) {
38+
endOfSlice = i;
39+
break;
40+
}
41+
}
42+
43+
// We found a child node: Push a new node onto the return array with children.
44+
nestedHeadingsList.push( {
45+
block: heading,
46+
index: index + key,
47+
children: linearToNestedHeadingList(
48+
headingsList.slice( key + 1, endOfSlice ),
49+
index + key + 1
50+
),
51+
} );
52+
} else {
53+
// No child node: Push a new node onto the return array.
54+
nestedHeadingsList.push( {
55+
block: heading,
56+
index: index + key,
57+
children: null,
58+
} );
59+
}
60+
}
61+
} );
62+
63+
return nestedHeadingsList;
64+
}
65+
66+
/**
67+
* Gets a list of heading texts, anchors and levels in the current document.
68+
*
69+
* @return {Array} The list of headings.
70+
*/
71+
export function getHeadingsList() {
72+
return convertBlocksToTableOfContents( getHeadingBlocks() );
73+
}
74+
75+
/**
76+
* Gets a list of heading blocks in the current document.
77+
*
78+
* @return {Array} The list of heading blocks.
79+
*/
80+
export function getHeadingBlocks() {
81+
const editor = select( 'core/block-editor' );
82+
return editor
83+
.getBlocks()
84+
.filter( ( block ) => block.name === 'core/heading' );
85+
}
86+
87+
/**
88+
* Extracts text, anchor and level from a list of heading blocks.
89+
*
90+
* @param {Array} headingBlocks The list of heading blocks.
91+
* @return {Array} The list of heading parameters.
92+
*/
93+
export function convertBlocksToTableOfContents( headingBlocks ) {
94+
return headingBlocks.map( function( heading ) {
95+
// This is a string so that it can be stored/sourced as an attribute in the table of contents
96+
// block using a data attribute.
97+
const level = heading.attributes.level.toString();
98+
99+
const headingContent = heading.attributes.content;
100+
const anchorContent = heading.attributes.anchor;
101+
102+
// Strip html from heading to use as the table of contents entry.
103+
const content = headingContent
104+
? create( { html: headingContent } ).text
105+
: '';
106+
107+
const anchor = anchorContent ? '#' + anchorContent : '';
108+
109+
return { content, anchor, level };
110+
} );
111+
}

packages/e2e-tests/fixtures/block-transforms.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,10 @@ export const EXPECTED_TRANSFORMS = {
516516
originalBlock: 'Table',
517517
availableTransforms: [ 'Group' ],
518518
},
519+
'core__table-of-contents': {
520+
originalBlock: 'Table of Contents',
521+
availableTransforms: [ 'Group', 'List' ],
522+
},
519523
'core__tag-cloud': {
520524
originalBlock: 'Tag Cloud',
521525
availableTransforms: [ 'Group' ],
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<!-- wp:core/table-of-contents -->
2+
<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>
3+
<!-- /wp:core/table-of-contents -->

0 commit comments

Comments
 (0)