Skip to content

Commit

Permalink
Document Outline: include all heading tags in post.
Browse files Browse the repository at this point in the history
  • Loading branch information
ZebulanStanphill committed Mar 8, 2021
1 parent 912eb9e commit 3a523bb
Show file tree
Hide file tree
Showing 2 changed files with 115 additions and 58 deletions.
118 changes: 72 additions & 46 deletions packages/editor/src/components/document-outline/index.js
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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 }
Expand All @@ -140,19 +181,4 @@ export const DocumentOutline = ( {
</ul>
</div>
);
};

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 );
}
55 changes: 43 additions & 12 deletions packages/editor/src/components/document-outline/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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( () => {
Expand All @@ -47,9 +71,9 @@ describe( 'DocumentOutline', () => {
save: () => {},
} );

registerBlockType( 'core/columns', {
category: 'text',
title: 'Paragraph',
registerBlockType( 'core/group', {
category: 'design',
title: 'Group',
edit: () => {},
save: () => {},
} );
Expand All @@ -67,7 +91,7 @@ describe( 'DocumentOutline', () => {
content: 'Heading 3',
level: 3,
} );
nestedHeading = createBlock( 'core/columns', undefined, [ headingH3 ] );
nestedHeading = createBlock( 'core/group', undefined, [ headingH3 ] );
} );

afterAll( () => {
Expand All @@ -77,6 +101,7 @@ describe( 'DocumentOutline', () => {

describe( 'no header blocks present', () => {
it( 'should not render when no blocks provided', () => {
mockUseSelect( [] );
const wrapper = shallow( <DocumentOutline /> );

expect( wrapper.html() ).toBe( null );
Expand All @@ -87,7 +112,8 @@ describe( 'DocumentOutline', () => {
// Set client IDs to a predictable value.
return { ...block, clientId: `clientId_${ index }` };
} );
const wrapper = shallow( <DocumentOutline blocks={ blocks } /> );
mockUseSelect( blocks );
const wrapper = shallow( <DocumentOutline /> );

expect( wrapper.html() ).toBe( null );
} );
Expand All @@ -99,14 +125,16 @@ describe( 'DocumentOutline', () => {
// Set client IDs to a predictable value.
return { ...block, clientId: `clientId_${ index }` };
} );
const wrapper = shallow( <DocumentOutline blocks={ blocks } /> );
mockUseSelect( blocks );
const wrapper = shallow( <DocumentOutline /> );

expect( wrapper ).toMatchSnapshot();
} );

it( 'should render an item when only one heading provided', () => {
const blocks = [ headingH2 ];
const wrapper = shallow( <DocumentOutline blocks={ blocks } /> );
mockUseSelect( blocks );
const wrapper = shallow( <DocumentOutline /> );

expect( wrapper.find( 'TableOfContentsItem' ) ).toHaveLength( 1 );
} );
Expand All @@ -119,7 +147,8 @@ describe( 'DocumentOutline', () => {
headingH3,
paragraph,
];
const wrapper = shallow( <DocumentOutline blocks={ blocks } /> );
mockUseSelect( blocks );
const wrapper = shallow( <DocumentOutline /> );

expect( wrapper.find( 'TableOfContentsItem' ) ).toHaveLength( 2 );
} );
Expand All @@ -131,7 +160,8 @@ describe( 'DocumentOutline', () => {
return { ...block, clientId: `clientId_${ index }` };
}
);
const wrapper = shallow( <DocumentOutline blocks={ blocks } /> );
mockUseSelect( blocks );
const wrapper = shallow( <DocumentOutline /> );

expect( wrapper ).toMatchSnapshot();
} );
Expand All @@ -145,9 +175,10 @@ describe( 'DocumentOutline', () => {
'.document-outline__item-content';

const blocks = [ headingH2, nestedHeading ];
const wrapper = mount( <DocumentOutline blocks={ blocks } /> );
mockUseSelect( blocks );
const wrapper = mount( <DocumentOutline /> );

// Unnested heading and nested heading should appear as items
// Unnested heading and nested heading should appear as items.
const tableOfContentItems = wrapper.find(
tableOfContentItemsSelector
);
Expand Down

0 comments on commit 3a523bb

Please sign in to comment.