UNPKG

@wordpress/block-library

Version:
307 lines (274 loc) 9.36 kB
/** * External dependencies */ import { isEqual } from 'lodash'; /** * WordPress dependencies */ import { BlockControls, BlockIcon, InspectorControls, store as blockEditorStore, useBlockProps, } from '@wordpress/block-editor'; import { createBlock } from '@wordpress/blocks'; import { PanelBody, Placeholder, ToggleControl, ToolbarButton, ToolbarGroup, } from '@wordpress/components'; import { useDisabled } from '@wordpress/compose'; import { useDispatch, useSelect } from '@wordpress/data'; import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; import { renderToString, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ import icon from './icon'; import TableOfContentsList from './list'; import { linearToNestedHeadingList } from './utils'; /** @typedef {import('./utils').HeadingData} HeadingData */ /** * Table of Contents block edit component. * * @param {Object} props The props. * @param {Object} props.attributes The block attributes. * @param {HeadingData[]} props.attributes.headings A list of data for each heading in the post. * @param {boolean} props.attributes.onlyIncludeCurrentPage Whether to only include headings from the current page (if the post is paginated). * @param {string} props.clientId * @param {(attributes: Object) => void} props.setAttributes * * @return {WPComponent} The component. */ export default function TableOfContentsEdit( { attributes: { headings = [], onlyIncludeCurrentPage }, clientId, setAttributes, } ) { const blockProps = useBlockProps(); const disabledRef = useDisabled(); const canInsertList = useSelect( ( select ) => { const { getBlockRootClientId, canInsertBlockType } = select( blockEditorStore ); const rootClientId = getBlockRootClientId( clientId ); return canInsertBlockType( 'core/list', rootClientId ); }, [ clientId ] ); const { __unstableMarkNextChangeAsNotPersistent, replaceBlocks } = useDispatch( blockEditorStore ); /** * The latest heading data, or null if the new data deeply equals the saved * headings attribute. * * Since useSelect forces a re-render when its return value is shallowly * inequal to its prior call, we would be re-rendering this block every time * the stores change, even if the latest headings were deeply equal to the * ones saved in the block attributes. * * By returning null when they're equal, we reduce that to 2 renders: one * when there are new latest headings (and so it returns them), and one when * they haven't changed (so it returns null). As long as the latest heading * data remains the same, further calls of the useSelect callback will * continue to return null, thus preventing any forced re-renders. */ const latestHeadings = useSelect( ( select ) => { const { getBlockAttributes, getBlockName, getClientIdsWithDescendants, __experimentalGetGlobalBlocksByName: getGlobalBlocksByName, } = select( blockEditorStore ); // FIXME: @wordpress/block-library should not depend on @wordpress/editor. // Blocks can be loaded into a *non-post* block editor, so to avoid // declaring @wordpress/editor as a dependency, we must access its // store by string. When the store is not available, editorSelectors // will be null, and the block's saved markup will lack permalinks. // eslint-disable-next-line @wordpress/data-no-store-string-literals const editorSelectors = select( 'core/editor' ); const pageBreakClientIds = getGlobalBlocksByName( 'core/nextpage' ); const isPaginated = pageBreakClientIds.length !== 0; // Get the client ids of all blocks in the editor. const allBlockClientIds = getClientIdsWithDescendants(); // If onlyIncludeCurrentPage is true, calculate the page (of a paginated post) this block is part of, so we know which headings to include; otherwise, skip the calculation. let tocPage = 1; if ( isPaginated && onlyIncludeCurrentPage ) { // We can't use getBlockIndex because it only returns the index // relative to sibling blocks. const tocIndex = allBlockClientIds.indexOf( clientId ); for ( const [ blockIndex, blockClientId, ] of allBlockClientIds.entries() ) { // If we've reached blocks after the Table of Contents, we've // finished calculating which page the block is on. if ( blockIndex >= tocIndex ) { break; } if ( getBlockName( blockClientId ) === 'core/nextpage' ) { tocPage++; } } } const _latestHeadings = []; /** The page (of a paginated post) a heading will be part of. */ let headingPage = 1; /** * A permalink to the current post. If the core/editor store is * unavailable, this variable will be null. */ const permalink = editorSelectors?.getPermalink() ?? null; let headingPageLink = null; // If the core/editor store is available, we can add permalinks to the // generated table of contents. if ( typeof permalink === 'string' ) { headingPageLink = isPaginated ? addQueryArgs( permalink, { page: headingPage } ) : permalink; } for ( const blockClientId of allBlockClientIds ) { const blockName = getBlockName( blockClientId ); if ( blockName === 'core/nextpage' ) { headingPage++; // If we're only including headings from the current page (of // a paginated post), then exit the loop if we've reached the // pages after the one with the Table of Contents block. if ( onlyIncludeCurrentPage && headingPage > tocPage ) { break; } if ( typeof permalink === 'string' ) { headingPageLink = addQueryArgs( removeQueryArgs( permalink, [ 'page' ] ), { page: headingPage } ); } } // If we're including all headings or we've reached headings on // the same page as the Table of Contents block, add them to the // list. else if ( ! onlyIncludeCurrentPage || headingPage === tocPage ) { if ( blockName === 'core/heading' ) { const headingAttributes = getBlockAttributes( blockClientId ); const canBeLinked = typeof headingPageLink === 'string' && typeof headingAttributes.anchor === 'string' && headingAttributes.anchor !== ''; _latestHeadings.push( { // Convert line breaks to spaces, and get rid of HTML tags in the headings. content: stripHTML( headingAttributes.content.replace( /(<br *\/?>)+/g, ' ' ) ), level: headingAttributes.level, link: canBeLinked ? `${ headingPageLink }#${ headingAttributes.anchor }` : null, } ); } } } if ( isEqual( headings, _latestHeadings ) ) { return null; } return _latestHeadings; }, [ clientId, onlyIncludeCurrentPage, headings ] ); useEffect( () => { if ( latestHeadings !== null ) { // This is required to keep undo working and not create 2 undo steps // for each heading change. __unstableMarkNextChangeAsNotPersistent(); setAttributes( { headings: latestHeadings } ); } }, [ latestHeadings ] ); const headingTree = linearToNestedHeadingList( headings ); const toolbarControls = canInsertList && ( <BlockControls> <ToolbarGroup> <ToolbarButton onClick={ () => replaceBlocks( clientId, createBlock( 'core/list', { ordered: true, values: renderToString( <TableOfContentsList nestedHeadingList={ headingTree } /> ), } ) ) } > { __( 'Convert to static list' ) } </ToolbarButton> </ToolbarGroup> </BlockControls> ); const inspectorControls = ( <InspectorControls> <PanelBody title={ __( 'Settings' ) }> <ToggleControl label={ __( 'Only include current page' ) } checked={ onlyIncludeCurrentPage } onChange={ ( value ) => setAttributes( { onlyIncludeCurrentPage: value } ) } help={ onlyIncludeCurrentPage ? __( 'Only including headings from the current page (if the post is paginated).' ) : __( 'Toggle to only include headings from the current page (if the post is paginated).' ) } /> </PanelBody> </InspectorControls> ); // If there are no headings or the only heading is empty. // Note that the toolbar controls are intentionally omitted since the // "Convert to static list" option is useless to the placeholder state. if ( headings.length === 0 ) { return ( <> <div { ...blockProps }> <Placeholder icon={ <BlockIcon icon={ icon } /> } label="Table of Contents" instructions={ __( 'Start adding Heading blocks to create a table of contents. Headings with HTML anchors will be linked here.' ) } /> </div> { inspectorControls } </> ); } return ( <> <nav { ...blockProps }> <ol ref={ disabledRef }> <TableOfContentsList nestedHeadingList={ headingTree } /> </ol> </nav> { toolbarControls } { inspectorControls } </> ); }