Table of contents from headings with customizable options
Table of Contents
- Lorem ipsum
- lorem 3
-
different heading title
- Lorem 3.2
- Lorem 3.3
Lorem ipsum
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt molestie imperdiet. Nullam vestibulum leo magna, nec dictum dolor aliquet sed. Praesent dictum aliquet condimentum.
lorem 2
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt molestie imperdiet. Nullam vestibulum leo magna, nec dictum dolor aliquet sed. Praesent dictum aliquet condimentum.
Lorem 2.1
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt molestie imperdiet. Nullam vestibulum leo magna, nec dictum dolor aliquet sed. Praesent dictum aliquet condimentum.
Lorem 2.2
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt molestie imperdiet. Nullam vestibulum leo magna, nec dictum dolor aliquet sed. Praesent dictum aliquet condimentum.
Lorem 2.2.1
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt molestie imperdiet. Nullam vestibulum leo magna, nec dictum dolor aliquet sed. Praesent dictum aliquet condimentum.
Lorem 2.2.2
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt molestie imperdiet. Nullam vestibulum leo magna, nec dictum dolor aliquet sed. Praesent dictum aliquet condimentum.
Lorem 2.3
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt molestie imperdiet. Nullam vestibulum leo magna, nec dictum dolor aliquet sed. Praesent dictum aliquet condimentum.
lorem 3
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt molestie imperdiet. Nullam vestibulum leo magna, nec dictum dolor aliquet sed. Praesent dictum aliquet condimentum.
Lorem 3.1
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt molestie imperdiet. Nullam vestibulum leo magna, nec dictum dolor aliquet sed. Praesent dictum aliquet condimentum.
Lorem 3.2
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt molestie imperdiet. Nullam vestibulum leo magna, nec dictum dolor aliquet sed. Praesent dictum aliquet condimentum.
Lorem 3.3
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt molestie imperdiet. Nullam vestibulum leo magna, nec dictum dolor aliquet sed. Praesent dictum aliquet condimentum.
block.json
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "wlc/table-of-contents",
"title": "Table of Contents",
"category": "theme",
"icon": "list-view",
"description": "Table of contents",
"keywords": [ "list", "contents", "widget" ],
"version": "1.0.0",
"textdomain": "WLC",
"supports": {
"renaming": true
},
"attributes": {
"headings": {
"type": "array",
"default": []
},
"settings": {
"type": "object",
"default": {}
}
},
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
"render": "file:./render.php"
}
components.js
import { ToggleControl, TextControl, Button, Flex, FlexItem, Dropdown, Dashicon, Tooltip} from '@wordpress/components';
import { dispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
const TableOfContentsItemTitle = ( { title, anchor, active } ) => {
if ( !!anchor ) {
const url = new URL( location.href );
url.hash = `#${anchor}`;
return (
<span style={{opacity: active ? 1 : 0.5}}>
<a href={url.href}>{title}</a>
</span>
)
}
return (
<span style={{opacity: active ? 1 : 0.5 }}>{title}</span>
)
}
const TableOfContentsItem = ( { level, settings, parentActive, heading, atts } ) => {
const { heads, setAttributes } = atts;
const { id, title, anchor, isActive, customTitle, children } = heading;
const updateHeads = ( heads, id, prop, value ) => {
heads.forEach( head => {
if ( head.children.length > 0 ) {
updateHeads( head.children, id, prop, value );
}
if ( head.id == id ) {
head[prop] = value;
}
} )
}
const updateAnchor = value => {
dispatch('core/block-editor').updateBlockAttributes( id, { anchor: value } );
updateHeads(heads, id, 'anchor', value )
setAttributes( { headings: heads } )
}
const updateIsActive = value => {
dispatch('core/block-editor').updateBlockAttributes( id, { wlcTocActive: value } );
updateHeads( heads, id, 'isActive', value )
setAttributes( { headings: heads } )
}
const updateCustomTitle = value => {
dispatch('core/block-editor').updateBlockAttributes( id, { wlcTocCustomTitle: value } );
updateHeads( heads, id, 'customTitle', value )
setAttributes( { headings: heads } )
}
let active = isActive && parentActive;
let displayTitle = !!customTitle && customTitle != title ? customTitle : title;
return (
<li className="wlc-toc-list-item" data-level={level}>
<Flex>
<FlexItem>
<TableOfContentsItemTitle anchor={anchor} title={displayTitle} active={active}/>
</FlexItem>
<FlexItem>
<Flex className="options">
<FlexItem>
<Dropdown
popoverProps={ { placement: 'bottom-start' } }
renderToggle={ ( { isOpen, onToggle } ) => (
<Button
variant="tertiary"
size="link"
onClick={ onToggle }
aria-expanded={ isOpen }
label={ __( 'Link/Unlink to the connected heading', 'WLC') }
showTooltip={true}
>
{ !anchor && (
<Dashicon icon="admin-links" />
) }
{ !!anchor && (
<Dashicon icon="editor-unlink" />
) }
</Button>
) }
renderContent={ () => (
<TextControl
className="anchor-text"
label={ __('Anchor', 'WLC') }
help={ __('Enter slug used for the anchor', 'WLC') }
value={ anchor }
onChange={ value => updateAnchor( value ) }
/>
) }
/>
</FlexItem>
<FlexItem>
<Dropdown
popoverProps={ { placement: 'bottom-start' } }
renderToggle={ ( { isOpen, onToggle } ) => (
<Button
variant="tertiary"
size="link"
onClick={ onToggle }
aria-expanded={ isOpen }
label={ __( 'Set Custom Title for Heading in Table of Contents', 'WLC') }
showTooltip={true}
>
<Dashicon icon="edit" />
</Button>
) }
renderContent={ () => (
<TextControl
className="anchor-text"
label={ __('Custom Title', 'WLC') }
help={ __('Enter Custom Title for Heading in Table of Contents', 'WLC') }
value={ customTitle }
onChange={ value => updateCustomTitle( value ) }
/>
) }
/>
</FlexItem>
<FlexItem>
<Tooltip delay={500} text={ __( 'Show/hide on the frontend', 'WLC') }>
<div>
<ToggleControl
checked={ active }
onChange={ value => updateIsActive( value ) }
/>
</div>
</Tooltip>
</FlexItem>
</Flex>
</FlexItem>
</Flex>
{ children.length > 0 && (
<TableOfContentsList
level={ level+1 }
settings={settings }
headings={children}
parentActive={active}
atts={atts}
/>
) }
</li>
)
}
export const TableOfContentsList = ( { level, settings, headings, parentActive, atts } ) => {
const ordered = settings[level]?.ordered || false;
const markers = settings[level]?.markers || false;
const listType = markers ? settings[level]?.type : 'none';
const Tag = ordered ? 'ol' :'ul';
return (
<Tag className={'wlc-toc-list' + ( !markers ? ' no-markers': '')} data-level={level} style={ {'--wlc-toc-list-type': listType } }>
{ headings.map( heading => {
return (
<TableOfContentsItem
level={level}
settings={settings}
parentActive={parentActive}
heading={heading}
atts={atts}
key={ heading.id }/>
)
} ) }
</Tag>
)
}
edit.js
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import {
PanelBody,
BaseControl,
ButtonGroup,
Button,
ToggleControl,
__experimentalRadio as Radio,
__experimentalRadioGroup as RadioGroup
} from '@wordpress/components';
import { useSelect, select, subscribe } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { getBlockContent } from '@wordpress/blocks'
import { __ } from '@wordpress/i18n'
import { TableOfContentsList } from './components'
import { debounce } from '@wordpress/compose'
const edit = ( { attributes, setAttributes, clientId } ) => {
const { headings, settings } = attributes;
const blockProps = useBlockProps( { className: 'wlc-table-of-contents' } );
const headingInnerText = html => {
const el = document.createElement('div');
el.innerHTML = html;
return el.textContent;
}
let maxLevel = 1;
const filterHeadings = blocks => blocks.filter( block => block.name == 'core/heading' )
.map( block => ( {
id: block.clientId,
title: headingInnerText( getBlockContent( block )),
level: block.attributes.level-1,
anchor: block.attributes.anchor,
isActive: block.attributes.wlcTocActive,
customTitle: block.attributes.wlcTocCustomTitle
}) )
.reduce( ( result, { level, ...rest } ) => {
const value = { ...rest, level, children: [] }
result[level] = value.children;
result[level-1]?.push(value)
maxLevel = level;
return result;
}, [[]] )
.shift()
let heads = useSelect( select => {
let blocks = select('core/block-editor').getBlocks();
let headings = filterHeadings( blocks );
return headings;
}, [] );
const debouncedSetAttributes = debounce( setAttributes, 200 );
const unsubscribe = subscribe(()=>{
if ( select('core/block-editor').isTyping() ) {
debouncedSetAttributes ( { headings: heads } );
unsubscribe()
}
}, 'core/block-editor' )
const [ level, setLevel ] = useState(1);
const ListLevelSettings = () => {
const orderedOptions = {
'decimal': '1.',
'decimal-leading-zero': '01.',
'upper-roman': 'I.',
'lower-roman': 'i.',
'upper-alpha': 'A.',
'lower-alpha': 'a.'
}
const unorderedOptions = {
'disc': __( 'Disc', 'WLC'),
'circle': __( 'Circle', 'WLC'),
'square': __( 'Square', 'WLC')
}
let selectedType = settings[level]?.ordered ? orderedOptions : unorderedOptions;
const setOrdered = (level, value) => {
const selectedType = value == true ? orderedOptions : unorderedOptions;
setAttributes( {
settings: {
...settings,
[level]: { ...settings[level], ordered: value, type: Object.keys(selectedType)[0] }
}
} )
}
const setMarkers = (level, value) => setAttributes( {
settings: {
...settings,
[level]: { ...settings[level], markers: value }
}
} )
const setType = (level, value) => setAttributes( {
settings: {
...settings,
[level]: { ...settings[level], type: value }
}
} )
return (
<>
<BaseControl
label={ __( 'Select level to edit', 'WLC' ) }
>
<ButtonGroup>
{
[ ...Array(6).keys()].map( key => {
return(
<Button
variant={ key+1 == level ? 'primary' : 'secondary'}
onClick={ () => setLevel( key+1 ) } key={key}>{ key+1 }</Button>
)
})
}
</ButtonGroup>
</BaseControl>
<BaseControl
label={ `${__( 'List level', 'WLC' )} ${level}` }
>
<ToggleControl
label={ __('Ordered', 'WLC')}
checked={ settings[level]?.ordered || false}
onChange={ value => setOrdered( level, value ) }
/>
</BaseControl>
<BaseControl
label={ __( 'Selected order Type', 'WLC' ) }
>
<ToggleControl
label={ __('With markers', 'WLC')}
checked={ settings[level]?.markers || false }
onChange={ value => setMarkers( level, value ) }
/>
</BaseControl>
{ !!settings[level]?.markers && (
<BaseControl
label={ __( 'Selected marker Type', 'WLC' ) }
>
<RadioGroup
checked={ settings[level]?.type || Object.values(selectedType)[0] }
onChange={ value => setType( level, value ) }
>
{ Object.entries(selectedType).map( ([ key, value]) => (
<Radio value={key} key={key}>{value}</Radio>
) ) }
</RadioGroup>
</BaseControl>
) }
</>
);
}
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Table of Contents Settings', 'WLC' ) }>
<ListLevelSettings />
</PanelBody>
</InspectorControls>
<div { ...blockProps } >
<TableOfContentsList level={1} parentActive={true} settings={settings} headings={heads} parentId={clientId} atts={{heads,setAttributes}}/>
</div>
</>
)
}
export default edit;includes.php
<?php
/**
* Functions to display table of contens
*
* @package wlc/table-of-contents
*/
/**
* Renders The html list lelement
*
* @param array $settings settings for the ToC.
* @param int $level current level of the ToC.
* @param array $children list of items of the current level.
*/
function headings_list( $settings, $level, $children ) {
$ordered = ! empty( $settings[ $level ]['ordered'] ) ? $settings[ $level ]['ordered'] : false;
$markers = ! empty( $settings[ $level ]['markers'] ) ? $settings[ $level ]['markers'] : false;
$tag = $ordered ? 'ol' : 'ul';
$type = $markers && ! empty( $settings[ $level ]['type'] ) ? $settings[ $level ]['type'] : 'none';
?>
<<?php echo wp_kses_data( $tag ); ?> class="wlc-toc-list<?php echo esc_attr( ! $markers ? 'no-markers' : '' ); ?>"
data-level="<?php echo esc_attr( $level ); ?>"
style="--wlc-toc-list-type: <?php echo esc_attr( $type ); ?>"
>
<?php
headings_items( $children, $settings, $level );
?>
</<?php echo wp_kses_data( $tag ); ?>>
<?php
}
/**
* Renders The html list-item element
*
* @param array $items list ofitems of the current level.
* @param array $settings settings of the ToC.
* @param int $level current level of the ToC.
*/
function headings_items( $items, $settings, $level ) {
foreach ( $items as $item ) :
$has_custom_title = ! empty( $item['customTitle'] ) && $item['customTitle'] !== $item['title'];
$display_title = $has_custom_title ? $item['customTitle'] : $item['title'];
if ( $item['isActive'] ) :
?>
<li class="wlc-toc-list-item" data-level="<?php echo esc_attr( $level ); ?>">
<?php
if ( ! empty( $item['anchor'] ) ) :
?>
<a href="<?php echo esc_url( '#' . $item['anchor'] ); ?>">
<?php echo esc_html( $display_title ); ?>
</a>
<?php
else :
echo esc_html( $display_title );
endif;
if ( ! empty( $item['children'] ) ) {
headings_list( $settings, $level + 1, $item['children'] );
}
?>
</li>
<?php
endif;
endforeach;
}
index.js
import { registerBlockType } from "@wordpress/blocks";
import { addFilter } from "@wordpress/hooks";
import metadata from './block.json';
import save from "./save";
import edit from './edit';
import './css/style.css';
import './css/editor.css';
registerBlockType( metadata, {
edit: edit,
save: save,
});
const addHeadingBlockAttr = ( props, name ) => {
if ( name == 'core/heading' ) {
return {
...props,
attributes: {
...props.attributes,
wlcTocActive: {
type: 'boolean',
default: true
},
wlcTocCustomTitle: {
type: 'string',
default: ''
}
}
}
}
return props;
}
addFilter( 'blocks.registerBlockType', 'wlc/table-of-contents/addHeadingBlockAttr', addHeadingBlockAttr, 99 );
render.php
<?php
/**
* Renders the html of table of contents block
*
* @package wlc/table-of-contents
*/
/**
* Requires
*/
require_once __DIR__ . '/includes.php';
$classes = array( 'class' => 'wlc-table-of-contents' );
$atts = get_block_wrapper_attributes( $classes );
$headings = $attributes['headings'];
$settings = $attributes['settings'];
?>
<?php
if ( ! empty( $headings ) && ! empty( $settings ) ) :
?>
<div <?php echo wp_kses_data( $atts ); ?>>
<?php
headings_list( $settings, 1, $headings )
?>
</div>
<?php
endif;
save.js
const save = () => {
return null;
}
export default save;