diff --git a/packages/block-library/src/heading/autogenerate-anchors.js b/packages/block-library/src/heading/autogenerate-anchors.js new file mode 100644 index 00000000000000..fda20fe44dcee4 --- /dev/null +++ b/packages/block-library/src/heading/autogenerate-anchors.js @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import { deburr, trim } from 'lodash'; + +/** + * WordPress dependencies + */ +import { cleanForSlug } from '@wordpress/url'; +import { useSelect } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * Runs a callback over all blocks, including nested blocks. + * + * @param {Object[]} blocks The blocks. + * @param {Function} callback The callback. + * + * @return {void} + */ +const recurseOverBlocks = ( blocks, callback ) => { + for ( const block of blocks ) { + // eslint-disable-next-line callback-return + callback( block ); + if ( block.innerBlocks ) { + recurseOverBlocks( block.innerBlocks, callback ); + } + } +}; + +/** + * Returns the text without markup. + * + * @param {string} text The text. + * + * @return {string} The text without markup. + */ +const getTextWithoutMarkup = ( text ) => { + const dummyElement = document.createElement( 'div' ); + dummyElement.innerHTML = text; + return dummyElement.innerText; +}; + +/** + * Get all heading anchors. + * + * @param {Object} blockList An object containing all blocks. + * @param {string} excludeId A block ID we want to exclude. + * + * @return {string[]} Return an array of anchors. + */ +const getAllHeadingAnchors = ( blockList, excludeId ) => { + const anchors = []; + + recurseOverBlocks( blockList, ( block ) => { + if ( + block.name === 'core/heading' && + ( ! excludeId || block.clientId !== excludeId ) && + block.attributes.anchor + ) { + anchors.push( block.attributes.anchor ); + } + } ); + + return anchors; +}; + +/** + * Get the slug from the content. + * + * @param {string} content The block content. + * + * @return {string} Returns the slug. + */ +const getSlug = ( content ) => { + content = getTextWithoutMarkup( content ); + + // Get the slug. + let slug = cleanForSlug( content ); + + // If slug is empty, then there is no content, or content is using non-latin characters. + // Try non-latin first. + if ( '' === slug ) { + slug = trim( + deburr( content ) + .replace( /[\s\./]+/g, '-' ) + .toLowerCase(), + '-' + ); + } + + return slug; +}; + +/** + * Generate the anchor for a heading. + * + * @param {string} anchor The heading anchor. + * @param {string} content The block content. + * @param {string[]} allHeadingAnchors An array containing all headings anchors. + * + * @return {string|null} Return the heading anchor. + */ +const generateAnchor = ( anchor, content, allHeadingAnchors ) => { + const slug = getSlug( content ); + // If slug is empty, then return null. + // Returning null instead of an empty string allows us to check again when the content changes. + if ( '' === slug ) { + return null; + } + + const baseAnchor = slug; + anchor = baseAnchor; + let i = 0; + + // If the anchor already exists in another heading, append -i. + while ( allHeadingAnchors.includes( anchor ) ) { + i += 1; + anchor = baseAnchor + '-' + i; + } + + return anchor; +}; + +/** + * Updates the anchor if required. + * + * @param {string} clientId The block's client-ID. + * @param {string} anchor The heading anchor. + * @param {string} content The block content. + * + * @return {string} The anchor. + */ +export default function useGeneratedAnchor( clientId, anchor, content ) { + const allHeadingAnchors = useSelect( + ( select ) => { + const allBlocks = select( blockEditorStore ).getBlocks(); + return getAllHeadingAnchors( allBlocks, clientId ); + }, + [ clientId ] + ); + return generateAnchor( anchor, content, allHeadingAnchors ); +} diff --git a/packages/block-library/src/heading/edit.js b/packages/block-library/src/heading/edit.js index 0f8d900b62c4cf..b15204d4d60bc5 100644 --- a/packages/block-library/src/heading/edit.js +++ b/packages/block-library/src/heading/edit.js @@ -14,11 +14,14 @@ import { RichText, useBlockProps, } from '@wordpress/block-editor'; +import { useEffect, useState } from '@wordpress/element'; +import { usePrevious } from '@wordpress/compose'; /** * Internal dependencies */ import HeadingLevelDropdown from './heading-level-dropdown'; +import useGeneratedAnchor from './autogenerate-anchors'; function HeadingEdit( { attributes, @@ -28,7 +31,7 @@ function HeadingEdit( { mergedStyle, clientId, } ) { - const { textAlign, content, level, placeholder } = attributes; + const { textAlign, content, level, placeholder, anchor } = attributes; const tagName = 'h' + level; const blockProps = useBlockProps( { className: classnames( { @@ -36,6 +39,31 @@ function HeadingEdit( { } ), style: mergedStyle, } ); + const updateAnchor = ( newAnchor ) => + setAttributes( { anchor: newAnchor } ); + + const generatedAnchor = useGeneratedAnchor( + clientId, + attributes.anchor, + content + ); + const [ autoGen, setAutoGen ] = useState( + ! anchor || generatedAnchor === anchor + ); + const prevAnchor = usePrevious( anchor ); + + useEffect( () => { + // Here the anchor has been changed from the sidebar input. + setAutoGen( anchor === prevAnchor ); + }, [ anchor, prevAnchor ] ); + + useEffect( () => { + // No generated so do nothing. + if ( ! generatedAnchor ) return; + if ( autoGen && anchor !== generatedAnchor ) { + updateAnchor( generatedAnchor ); + } + }, [ autoGen, anchor, generatedAnchor ] ); return ( <> diff --git a/packages/e2e-tests/specs/editor/blocks/__snapshots__/heading.test.js.snap b/packages/e2e-tests/specs/editor/blocks/__snapshots__/heading.test.js.snap index c340a475f354a1..290019372feeb3 100644 --- a/packages/e2e-tests/specs/editor/blocks/__snapshots__/heading.test.js.snap +++ b/packages/e2e-tests/specs/editor/blocks/__snapshots__/heading.test.js.snap @@ -2,25 +2,25 @@ exports[`Heading can be created by prefixing existing content with number signs and a space 1`] = ` " -
codecode+test
" `; @@ -144,7 +144,7 @@ exports[`Quote can be split in the middle and merged back 4`] = ` exports[`Quote is transformed to a heading and a quote if the quote contains a citation 1`] = ` " -test
First Paragraph