From 3a523bbb62f098cd2a395316cdcee84b025c2b67 Mon Sep 17 00:00:00 2001 From: Zebulan Stanphill Date: Mon, 8 Mar 2021 09:44:13 -0600 Subject: [PATCH] Document Outline: include all heading tags in post. --- .../src/components/document-outline/index.js | 118 +++++++++++------- .../components/document-outline/test/index.js | 55 ++++++-- 2 files changed, 115 insertions(+), 58 deletions(-) diff --git a/packages/editor/src/components/document-outline/index.js b/packages/editor/src/components/document-outline/index.js index abd1c853fb8b8..8a18a2929ddce 100644 --- a/packages/editor/src/components/document-outline/index.js +++ b/packages/editor/src/components/document-outline/index.js @@ -1,19 +1,22 @@ /** * External dependencies */ -import { countBy, flatMap, get } from 'lodash'; +import { countBy, get } from 'lodash'; /** * WordPress dependencies */ +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { getBlockContent } from '@wordpress/blocks'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; -import { compose } from '@wordpress/compose'; -import { withSelect } from '@wordpress/data'; import { create, getTextContent } from '@wordpress/rich-text'; /** * Internal dependencies */ +import { store as editorStore } from '../../store'; import DocumentOutlineItem from './item'; /** @@ -38,38 +41,76 @@ const multipleH1Headings = [ ]; /** - * Returns an array of heading blocks enhanced with the following properties: - * level - An integer with the heading level. - * isEmpty - Flag indicating if the heading has no content. + * Extracts heading elements from an HTML string. * - * @param {?Array} blocks An array of blocks. + * @param {string} html The HTML string to extract heading elements from. * - * @return {Array} An array of heading blocks enhanced with the properties described above. + * @return {HTMLHeadingElement[]} Array of h1-h6 elements. */ -const computeOutlineHeadings = ( blocks = [] ) => { - return flatMap( blocks, ( block = {} ) => { - if ( block.name === 'core/heading' ) { - return { - ...block, - level: block.attributes.level, - isEmpty: isEmptyHeading( block ), - }; - } - return computeOutlineHeadings( block.innerBlocks ); - } ); -}; +function getHeadingElementsFromHTML( html ) { + // Create a temporary container to put the post content into, so we can + // use the DOM to find all the headings. + const tempContainer = document.createElement( 'div' ); + tempContainer.innerHTML = html; + + // Remove template elements so that headings inside them aren't counted. + // This is only needed for IE11, which doesn't recognize the element and + // treats it like a div. + for ( const template of tempContainer.querySelectorAll( 'template' ) ) { + template.remove(); + } -const isEmptyHeading = ( heading ) => - ! heading.attributes.content || heading.attributes.content.length === 0; + return [ ...tempContainer.querySelectorAll( 'h1, h2, h3, h4, h5, h6' ) ]; +} -export const DocumentOutline = ( { - blocks = [], - title, +export default function DocumentOutline( { onSelect, - isTitleSupported, hasOutlineItemsDisabled, -} ) => { - const headings = computeOutlineHeadings( blocks ); +} ) { + const { headings, isTitleSupported, title } = useSelect( ( select ) => { + const { getBlocks } = select( blockEditorStore ); + const { getEditedPostAttribute } = select( editorStore ); + const { getPostType } = select( coreStore ); + + const postType = getPostType( getEditedPostAttribute( 'type' ) ); + const blocks = getBlocks() ?? []; + + const _headings = []; + for ( const block of blocks ) { + if ( block.name === 'core/heading' ) { + _headings.push( { + blockClientId: block.clientId, + content: block.attributes.content, + isEmpty: + ! block.attributes.content || + block.attributes.content.length === 0, + level: block.attributes.level, + } ); + } else { + const headingElements = getHeadingElementsFromHTML( + getBlockContent( block ) + ); + for ( const element of headingElements ) { + _headings.push( { + blockClientId: block.clientId, + content: element.textContent, + isEmpty: element.textContent.length === 0, + // Kinda hacky, but since we know at this point that the tag + // is an H1-H6, we can just grab the 2nd character of the tag + // name and convert it to an integer. Should be faster than + // conditionals. + level: parseInt( element.tagName[ 1 ], 10 ), + } ); + } + } + } + + return { + headings: _headings, + isTitleSupported: get( postType, [ 'supports', 'title' ], false ), + title: getEditedPostAttribute( 'title' ), + }; + }, [] ); if ( headings.length < 1 ) { return null; @@ -116,14 +157,14 @@ export const DocumentOutline = ( { level={ `H${ item.level }` } isValid={ isValid } isDisabled={ hasOutlineItemsDisabled } - href={ `#block-${ item.clientId }` } + href={ `#block-${ item.blockClientId }` } onSelect={ onSelect } > { item.isEmpty ? emptyHeadingContent : getTextContent( create( { - html: item.attributes.content, + html: item.content, } ) ) } { isIncorrectLevel && incorrectLevelContent } @@ -140,19 +181,4 @@ export const DocumentOutline = ( { ); -}; - -export default compose( - withSelect( ( select ) => { - const { getBlocks } = select( 'core/block-editor' ); - const { getEditedPostAttribute } = select( 'core/editor' ); - const { getPostType } = select( 'core' ); - const postType = getPostType( getEditedPostAttribute( 'type' ) ); - - return { - title: getEditedPostAttribute( 'title' ), - blocks: getBlocks(), - isTitleSupported: get( postType, [ 'supports', 'title' ], false ), - }; - } ) -)( DocumentOutline ); +} diff --git a/packages/editor/src/components/document-outline/test/index.js b/packages/editor/src/components/document-outline/test/index.js index b38412b4a258c..a002fb1641ab5 100644 --- a/packages/editor/src/components/document-outline/test/index.js +++ b/packages/editor/src/components/document-outline/test/index.js @@ -11,16 +11,40 @@ import { registerBlockType, unregisterBlockType, } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies */ -import { DocumentOutline } from '../'; +import DocumentOutline from '../'; + +function mockUseSelect( blocks ) { + useSelect.mockImplementation( ( cb ) => { + return cb( () => ( { + getBlocks: () => blocks, + getEditedPostAttribute( attr ) { + if ( attr === 'type' ) { + return 'post'; + } else if ( attr === 'title' ) { + return 'Mocked post title'; + } + return undefined; + }, + getPostType: () => ( { supports: { title: true } } ), + } ) ); + } ); +} jest.mock( '@wordpress/block-editor', () => ( { BlockTitle: () => 'Block Title', } ) ); +jest.mock( '@wordpress/data/src/components/use-select', () => { + // This allows us to tweak the returned value on each test + const mock = jest.fn(); + return mock; +} ); + describe( 'DocumentOutline', () => { let paragraph, headingH1, headingH2, headingH3, nestedHeading; beforeAll( () => { @@ -47,9 +71,9 @@ describe( 'DocumentOutline', () => { save: () => {}, } ); - registerBlockType( 'core/columns', { - category: 'text', - title: 'Paragraph', + registerBlockType( 'core/group', { + category: 'design', + title: 'Group', edit: () => {}, save: () => {}, } ); @@ -67,7 +91,7 @@ describe( 'DocumentOutline', () => { content: 'Heading 3', level: 3, } ); - nestedHeading = createBlock( 'core/columns', undefined, [ headingH3 ] ); + nestedHeading = createBlock( 'core/group', undefined, [ headingH3 ] ); } ); afterAll( () => { @@ -77,6 +101,7 @@ describe( 'DocumentOutline', () => { describe( 'no header blocks present', () => { it( 'should not render when no blocks provided', () => { + mockUseSelect( [] ); const wrapper = shallow( ); expect( wrapper.html() ).toBe( null ); @@ -87,7 +112,8 @@ describe( 'DocumentOutline', () => { // Set client IDs to a predictable value. return { ...block, clientId: `clientId_${ index }` }; } ); - const wrapper = shallow( ); + mockUseSelect( blocks ); + const wrapper = shallow( ); expect( wrapper.html() ).toBe( null ); } ); @@ -99,14 +125,16 @@ describe( 'DocumentOutline', () => { // Set client IDs to a predictable value. return { ...block, clientId: `clientId_${ index }` }; } ); - const wrapper = shallow( ); + mockUseSelect( blocks ); + const wrapper = shallow( ); expect( wrapper ).toMatchSnapshot(); } ); it( 'should render an item when only one heading provided', () => { const blocks = [ headingH2 ]; - const wrapper = shallow( ); + mockUseSelect( blocks ); + const wrapper = shallow( ); expect( wrapper.find( 'TableOfContentsItem' ) ).toHaveLength( 1 ); } ); @@ -119,7 +147,8 @@ describe( 'DocumentOutline', () => { headingH3, paragraph, ]; - const wrapper = shallow( ); + mockUseSelect( blocks ); + const wrapper = shallow( ); expect( wrapper.find( 'TableOfContentsItem' ) ).toHaveLength( 2 ); } ); @@ -131,7 +160,8 @@ describe( 'DocumentOutline', () => { return { ...block, clientId: `clientId_${ index }` }; } ); - const wrapper = shallow( ); + mockUseSelect( blocks ); + const wrapper = shallow( ); expect( wrapper ).toMatchSnapshot(); } ); @@ -145,9 +175,10 @@ describe( 'DocumentOutline', () => { '.document-outline__item-content'; const blocks = [ headingH2, nestedHeading ]; - const wrapper = mount( ); + mockUseSelect( blocks ); + const wrapper = mount( ); - // Unnested heading and nested heading should appear as items + // Unnested heading and nested heading should appear as items. const tableOfContentItems = wrapper.find( tableOfContentItemsSelector );