-
Notifications
You must be signed in to change notification settings - Fork 4.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Table of Contents block #21040
Add Table of Contents block #21040
Changes from all commits
e5bea61
ba40205
ba5c43a
8507968
50df5b5
4c650b5
371a901
d2e929b
c0f2e1a
4fce9a1
cb3e495
bcfc383
f6aeb36
366d7bd
0648776
29e3c05
5cfade2
b89368d
b82a9e5
7e4a236
10d49be
32a376a
e49f28d
becb1a9
e7f3dae
8cbe8fc
36adc5b
45ee5f7
04e0df2
afacffc
796900a
d31534c
6d1d82c
a96a5dc
00255a0
af0f900
9c73d7b
99bbe0b
d9037e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,43 @@ | ||||||||||||||||
/** | ||||||||||||||||
* WordPress dependencies | ||||||||||||||||
*/ | ||||||||||||||||
|
||||||||||||||||
export default function ListItem( props ) { | ||||||||||||||||
const { children, noWrapList = false } = props; | ||||||||||||||||
Comment on lines
+5
to
+6
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||
let childNodes = null; | ||||||||||||||||
|
||||||||||||||||
if ( children ) { | ||||||||||||||||
childNodes = children.map( function( childNode, index ) { | ||||||||||||||||
Comment on lines
+7
to
+10
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||
const { content, anchor, level } = childNode.block; | ||||||||||||||||
|
||||||||||||||||
const entry = anchor ? ( | ||||||||||||||||
<a | ||||||||||||||||
className="blocks-table-of-contents-entry" | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This class name should follow the BEM-ish style used elsewhere in Gutenberg:
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (The tests will have to be updated.) |
||||||||||||||||
href={ anchor } | ||||||||||||||||
data-level={ level } | ||||||||||||||||
> | ||||||||||||||||
{ content } | ||||||||||||||||
</a> | ||||||||||||||||
) : ( | ||||||||||||||||
<span | ||||||||||||||||
className="wp-block-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>; | ||||||||||||||||
} | ||||||||||||||||
} |
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" } | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,72 @@ | ||||||
/** | ||||||
* External dependencies | ||||||
*/ | ||||||
|
||||||
Comment on lines
+1
to
+4
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This newline should be removed. |
||||||
const { isEqual } = require( 'lodash' ); | ||||||
|
||||||
/** | ||||||
* Internal dependencies | ||||||
*/ | ||||||
import { getHeadingsList, linearToNestedHeadingList } from './utils'; | ||||||
import ListItem from './ListItem'; | ||||||
Comment on lines
+7
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These should come after the WordPress dependencies, not before. |
||||||
|
||||||
/** | ||||||
* 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 } ); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a bit unclear what this does. It seems to set the headings attribute to the same value it already is. Might be possible to delete it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I had to keep that there to make the block work when newly created, but I haven't checked again since changing a bunch of other things. I can experiment removing it to see if it still behaves. |
||||||
} | ||||||
|
||||||
componentWillUnmount() { | ||||||
this.unsubscribe(); | ||||||
} | ||||||
|
||||||
componentDidUpdate( prevProps, prevState ) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not completely sure about the need to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is no longer updating state so (I think?) doesn't trigger a re-render. It might still be possible to merge into |
||||||
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' | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
) } | ||||||
</p> | ||||||
); | ||||||
} | ||||||
|
||||||
return ( | ||||||
<div className={ this.props.className }> | ||||||
<ListItem>{ linearToNestedHeadingList( headings ) }</ListItem> | ||||||
</div> | ||||||
); | ||||||
} | ||||||
} | ||||||
|
||||||
export default TableOfContentsEdit; |
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.' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this description needs to be updated since the block no longer automatically adds anchors to the headings. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. True. Maybe "Add a list of headers allowing your readers to quickly navigate through your post." instead, and somewhere else make it obvious to users how to make them links (maybe with popovers or similar)? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd say "headings" rather than "headers". Also, the contributor guide recommends avoiding describing features as "allowing" something. How's this?
You might be able to leave off "automatically". |
||
), | ||
icon: 'list-view', | ||
category: 'layout', | ||
transforms, | ||
edit, | ||
save, | ||
}; |
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> | ||
); | ||
} |
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; |
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(); | ||
SeanDS marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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 }; | ||
} ); | ||
} |
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 --> |
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>" | ||
} | ||
] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment should be removed.