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`] = ` " -

4

+

4

" `; exports[`Heading can be created by prefixing number sign and a space 1`] = ` " -

3

+

3

" `; exports[`Heading should correctly apply custom colors 1`] = ` " -

Heading

+

Heading

" `; exports[`Heading should correctly apply named colors 1`] = ` " -

Heading

+

Heading

" `; @@ -30,13 +30,13 @@ exports[`Heading should create a paragraph block above when pressing enter at th -

a

+

a

" `; exports[`Heading should create a paragraph block below when pressing enter at the end 1`] = ` " -

a

+

a

@@ -46,12 +46,12 @@ exports[`Heading should create a paragraph block below when pressing enter at th exports[`Heading should not work with the list input rule 1`] = ` " -

1. H

+

1. H

" `; exports[`Heading should work with the format input rules 1`] = ` " -

code

+

code

" `; diff --git a/packages/e2e-tests/specs/editor/blocks/__snapshots__/quote.test.js.snap b/packages/e2e-tests/specs/editor/blocks/__snapshots__/quote.test.js.snap index 57fcc6c61dbdd0..6345efae5ed728 100644 --- a/packages/e2e-tests/specs/editor/blocks/__snapshots__/quote.test.js.snap +++ b/packages/e2e-tests/specs/editor/blocks/__snapshots__/quote.test.js.snap @@ -40,7 +40,7 @@ exports[`Quote can be converted to paragraphs and renders only one paragraph for exports[`Quote can be created by converting a heading 1`] = ` " -

test

+

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`] = ` " -

one

+

one

@@ -154,7 +154,7 @@ exports[`Quote is transformed to a heading and a quote if the quote contains a c exports[`Quote is transformed to a heading and a quote if the quote contains multiple paragraphs 1`] = ` " -

one

+

one

@@ -164,7 +164,7 @@ exports[`Quote is transformed to a heading and a quote if the quote contains mul exports[`Quote is transformed to a heading if the quote just contains one paragraph 1`] = ` " -

one

+

one

" `; @@ -176,7 +176,7 @@ exports[`Quote is transformed to an empty heading if the quote is empty 1`] = ` exports[`Quote the resuling quote after transforming to a heading can be transformed again 1`] = ` " -

one

+

one

@@ -186,11 +186,11 @@ exports[`Quote the resuling quote after transforming to a heading can be transfo exports[`Quote the resuling quote after transforming to a heading can be transformed again 2`] = ` " -

one

+

one

-

two

+

two

@@ -200,14 +200,14 @@ exports[`Quote the resuling quote after transforming to a heading can be transfo exports[`Quote the resuling quote after transforming to a heading can be transformed again 3`] = ` " -

one

+

one

-

two

+

two

-

cite

+

cite

" `; diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/block-grouping.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/block-grouping.test.js.snap index f3c24c200b971a..aeaafed4ea8a7a 100644 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/block-grouping.test.js.snap +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/block-grouping.test.js.snap @@ -3,7 +3,7 @@ exports[`Block Grouping Group creation creates a group from multiple blocks of different types via block transforms 1`] = ` "
-

Group Heading

+

Group Heading

@@ -51,7 +51,7 @@ exports[`Block Grouping Group creation creates a group from multiple blocks of t exports[`Block Grouping Group creation groups and ungroups multiple blocks of different types via options toolbar 1`] = ` "
-

Group Heading

+

Group Heading

@@ -66,7 +66,7 @@ exports[`Block Grouping Group creation groups and ungroups multiple blocks of di exports[`Block Grouping Group creation groups and ungroups multiple blocks of different types via options toolbar 2`] = ` " -

Group Heading

+

Group Heading

@@ -81,7 +81,7 @@ exports[`Block Grouping Group creation groups and ungroups multiple blocks of di exports[`Block Grouping Preserving selected blocks attributes preserves width alignment settings of selected blocks 1`] = ` "
-

Group Heading

+

Group Heading

diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/inserting-blocks.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/inserting-blocks.test.js.snap index ec465386aaf05d..b97b80ddf59aea 100644 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/inserting-blocks.test.js.snap +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/inserting-blocks.test.js.snap @@ -71,7 +71,7 @@ exports[`Inserting blocks inserts a block in proper place after having clicked \ -

Heading

+

Heading

@@ -93,7 +93,7 @@ exports[`Inserting blocks inserts a block in proper place after having clicked \ -

Heading

+

Heading

diff --git a/packages/e2e-tests/specs/widgets/adding-widgets.test.js b/packages/e2e-tests/specs/widgets/adding-widgets.test.js index d01a52eddfe9c6..ac907b26eafe0d 100644 --- a/packages/e2e-tests/specs/widgets/adding-widgets.test.js +++ b/packages/e2e-tests/specs/widgets/adding-widgets.test.js @@ -347,7 +347,7 @@ describe( 'Widgets screen', () => {

First Paragraph

-

My Heading

+

My Heading

Second Paragraph