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 @@
+
+
+