Table of Contents

Table of contents from headings with customizable options Table of Contents 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…

Table of contents from headings with customizable options

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;