From 5c856bf903385234b7e092fb214f720dc2da74fb Mon Sep 17 00:00:00 2001 From: Jorge Date: Wed, 28 Feb 2018 18:49:57 +0000 Subject: [PATCH] Implemented nesting support in document outline. Before nested headings were totally ignored now we show them with a representation of their nesting path. --- editor/components/document-outline/index.js | 39 +++++++++++---- editor/components/document-outline/item.js | 15 ++++++ .../test/__snapshots__/index.js.snap | 4 ++ .../components/document-outline/test/index.js | 34 +++++++++++++- editor/components/table-of-contents/panel.js | 22 ++++----- editor/store/selectors.js | 26 ++++++++++ editor/store/test/selectors.js | 47 +++++++++++++++++++ 7 files changed, 164 insertions(+), 23 deletions(-) diff --git a/editor/components/document-outline/index.js b/editor/components/document-outline/index.js index bc6455c8abc40..0eaa93a15967b 100644 --- a/editor/components/document-outline/index.js +++ b/editor/components/document-outline/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { countBy, filter, get } from 'lodash'; +import { countBy, flatMap, get } from 'lodash'; /** * WordPress dependencies @@ -55,11 +55,36 @@ const getHeadingLevel = heading => { return 6; } }; +/** + * Returns an array of heading blocks enhanced with the following properties: + * path - An array of blocks that are ancestors of the heading starting from a top-level node. + * Can be an empty array if the heading is a top-level node (is not nested inside another block). + * level - An integer with the heading level. + * isEmpty - Flag indicating if the heading has no content. + * + * @param {?Array} blocks An array of blocks. + * @param {?Array} path An array of blocks that are ancestors of the blocks passed as blocks. + * + * @return {Array} An array of heading blocks enhanced with the properties described above. + */ +const computeOutlineHeadings = ( blocks = [], path = [] ) => { + return flatMap( blocks, ( block = {} ) => { + if ( block.name === 'core/heading' ) { + return { + ...block, + path, + level: getHeadingLevel( block ), + isEmpty: isEmptyHeading( block ), + }; + } + return computeOutlineHeadings( block.innerBlocks, [ ...path, block ] ); + } ); +}; const isEmptyHeading = heading => ! heading.attributes.content || heading.attributes.content.length === 0; export const DocumentOutline = ( { blocks = [], title, onSelect, isTitleSupported } ) => { - const headings = filter( blocks, ( block ) => block.name === 'core/heading' ); + const headings = computeOutlineHeadings( blocks ); if ( headings.length < 1 ) { return null; @@ -79,12 +104,7 @@ export const DocumentOutline = ( { blocks = [], title, onSelect, isTitleSupporte }; const hasTitle = isTitleSupported && title; - const items = headings.map( ( heading ) => ( { - ...heading, - level: getHeadingLevel( heading ), - isEmpty: isEmptyHeading( heading ), - } ) ); - const countByLevel = countBy( items, 'level' ); + const countByLevel = countBy( headings, 'level' ); const hasMultipleH1 = countByLevel[ 1 ] > 1; return ( @@ -99,7 +119,7 @@ export const DocumentOutline = ( { blocks = [], title, onSelect, isTitleSupporte { title } ) } - { items.map( ( item, index ) => { + { headings.map( ( item, index ) => { // Headings remain the same, go up by one, or down by any amount. // Otherwise there are missing levels. const isIncorrectLevel = item.level > prevHeadingLevel + 1; @@ -118,6 +138,7 @@ export const DocumentOutline = ( { blocks = [], title, onSelect, isTitleSupporte level={ `H${ item.level }` } isValid={ isValid } onClick={ () => onSelectHeading( item.uid ) } + path={ item.path } > { item.isEmpty ? emptyHeadingContent : item.attributes.content } { isIncorrectLevel && incorrectLevelContent } diff --git a/editor/components/document-outline/item.js b/editor/components/document-outline/item.js index 5b59614ecca8e..0b041f1740a7c 100644 --- a/editor/components/document-outline/item.js +++ b/editor/components/document-outline/item.js @@ -8,11 +8,17 @@ import classnames from 'classnames'; */ import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import BlockTitle from '../block-title'; + const TableOfContentsItem = ( { children, isValid, level, onClick, + path = [], } ) => (
  • + { + // path is an array of nodes that are ancestors of the heading starting in the top level node. + // This mapping renders each ancestor to make it easier for the user to know where the headings are nested. + path.map( ( { uid }, index ) => ( + + + + ) ) + } { level } diff --git a/editor/components/document-outline/test/__snapshots__/index.js.snap b/editor/components/document-outline/test/__snapshots__/index.js.snap index 7bf777f695044..89d0ebb04d658 100644 --- a/editor/components/document-outline/test/__snapshots__/index.js.snap +++ b/editor/components/document-outline/test/__snapshots__/index.js.snap @@ -10,6 +10,7 @@ exports[`DocumentOutline header blocks present should match snapshot 1`] = ` key="0" level="H2" onClick={[Function]} + path={Array []} > Heading parent @@ -18,6 +19,7 @@ exports[`DocumentOutline header blocks present should match snapshot 1`] = ` key="1" level="H3" onClick={[Function]} + path={Array []} > Heading child @@ -35,6 +37,7 @@ exports[`DocumentOutline header blocks present should render warnings for multip key="0" level="H1" onClick={[Function]} + path={Array []} > Heading 1
    Heading 1
    () => 'Block Title' ); + describe( 'DocumentOutline', () => { registerCoreBlocks(); @@ -30,6 +32,8 @@ describe( 'DocumentOutline', () => { nodeName: 'H3', } ); + const nestedHeading = createBlock( 'core/columns', undefined, [ headingChild ] ); + describe( 'no header blocks present', () => { it( 'should not render when no blocks provided', () => { const wrapper = shallow( ); @@ -74,4 +78,32 @@ describe( 'DocumentOutline', () => { expect( wrapper ).toMatchSnapshot(); } ); } ); + + describe( 'nested headings', () => { + it( 'should render even if the heading is nested', () => { + const tableOfContentItemsSelector = 'TableOfContentsItem'; + const outlineLevelsSelector = '.document-outline__level'; + const outlineItemContentSelector = '.document-outline__item-content'; + + const blocks = [ headingParent, nestedHeading ]; + const wrapper = mount( ); + + //heading parent and nested heading should appear as items + const tableOfContentItems = wrapper.find( tableOfContentItemsSelector ); + expect( tableOfContentItems ).toHaveLength( 2 ); + + //heading parent test + const firstItemLevels = tableOfContentItems.at( 0 ).find( outlineLevelsSelector ); + expect( firstItemLevels ).toHaveLength( 1 ); + expect( firstItemLevels.at( 0 ).text() ).toEqual( 'H2' ); + expect( tableOfContentItems.at( 0 ).find( outlineItemContentSelector ).text() ).toEqual( 'Heading parent' ); + + //nested heading test + const secondItemLevels = tableOfContentItems.at( 1 ).find( outlineLevelsSelector ); + expect( secondItemLevels ).toHaveLength( 2 ); + expect( secondItemLevels.at( 0 ).text() ).toEqual( 'Block Title' ); + expect( secondItemLevels.at( 1 ).text() ).toEqual( 'H3' ); + expect( tableOfContentItems.at( 1 ).find( outlineItemContentSelector ).text() ).toEqual( 'Heading child' ); + } ); + } ); } ); diff --git a/editor/components/table-of-contents/panel.js b/editor/components/table-of-contents/panel.js index 5879695270ca3..e9f2cc188fd4e 100644 --- a/editor/components/table-of-contents/panel.js +++ b/editor/components/table-of-contents/panel.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { countBy } from 'lodash'; - /** * WordPress dependencies */ @@ -16,9 +11,7 @@ import { withSelect } from '@wordpress/data'; import WordCount from '../word-count'; import DocumentOutline from '../document-outline'; -function TableOfContentsPanel( { blocks } ) { - const blockCount = countBy( blocks, 'name' ); - +function TableOfContentsPanel( { headingCount, paragraphCount, numberOfBlocks } ) { return (
    { __( 'Headings' ) } - { blockCount[ 'core/heading' ] || 0 } + { headingCount }
    { __( 'Paragraphs' ) } - { blockCount[ 'core/paragraph' ] || 0 } + { paragraphCount }
    { __( 'Blocks' ) } - { blocks.length } + { numberOfBlocks }
    - { blockCount[ 'core/heading' ] > 0 && ( + { headingCount > 0 && (
    @@ -64,7 +57,10 @@ function TableOfContentsPanel( { blocks } ) { } export default withSelect( ( select ) => { + const { getGlobalBlockCount } = select( 'core/editor' ); return { - blocks: select( 'core/editor' ).getBlocks(), + headingCount: getGlobalBlockCount( 'core/heading' ), + paragraphCount: getGlobalBlockCount( 'core/paragraph' ), + numberOfBlocks: getGlobalBlockCount(), }; } )( TableOfContentsPanel ); diff --git a/editor/store/selectors.js b/editor/store/selectors.js index db99ac723ddbd..ff512bca0a183 100644 --- a/editor/store/selectors.js +++ b/editor/store/selectors.js @@ -8,6 +8,7 @@ import { has, last, reduce, + size, compact, find, unionWith, @@ -475,6 +476,31 @@ export const getBlocks = createSelector( ] ); +/** + * Returns the total number of blocks, or the total number of blocks with a specific name in a post. + * The number returned includes nested blocks. + * + * @param {Object} state Global application state. + * @param {?String} blockName Optional block name, if specified only blocks of that type will be counted. + * + * @return {number} Number of blocks in the post, or number of blocks with name equal to blockName. + */ +export const getGlobalBlockCount = createSelector( + ( state, blockName ) => { + if ( ! blockName ) { + return size( state.editor.present.blocksByUid ); + } + return reduce( + state.editor.present.blocksByUid, + ( count, block ) => block.name === blockName ? count + 1 : count, + 0 + ); + }, + ( state ) => [ + state.editor.present.blocksByUid, + ] +); + export const getBlocksByUID = createSelector( ( state, uids ) => { return map( uids, ( uid ) => getBlock( state, uid ) ); diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js index 020fab2aa4f4e..44814ad4c1d70 100644 --- a/editor/store/test/selectors.js +++ b/editor/store/test/selectors.js @@ -47,6 +47,7 @@ const { getSelectedBlock, getBlockRootUID, getEditedPostAttribute, + getGlobalBlockCount, getMultiSelectedBlockUids, getMultiSelectedBlocks, getMultiSelectedBlocksStartUid, @@ -1486,6 +1487,52 @@ describe( 'selectors', () => { } ); } ); + describe( 'getGlobalBlockCount', () => { + it( 'should return the global number of top-level blocks in the post', () => { + const state = { + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading', attributes: {} }, + 123: { uid: 123, name: 'core/paragraph', attributes: {} }, + }, + }, + }, + }; + + expect( getGlobalBlockCount( state ) ).toBe( 2 ); + } ); + + it( 'should return the global umber of blocks of a given type', () => { + const state = { + editor: { + present: { + blocksByUid: { + 123: { uid: 123, name: 'core/columns', attributes: {} }, + 456: { uid: 456, name: 'core/paragraph', attributes: {} }, + 789: { uid: 789, name: 'core/paragraph', attributes: {} }, + 124: { uid: 123, name: 'core/heading', attributes: {} }, + }, + }, + }, + }; + + expect( getGlobalBlockCount( state, 'core/heading' ) ).toBe( 1 ); + } ); + + it( 'should return 0 if no blocks exist', () => { + const state = { + editor: { + present: { + blocksByUid: { + }, + }, + }, + }; + expect( getGlobalBlockCount( state ) ).toBe( 0 ); + expect( getGlobalBlockCount( state, 'core/heading' ) ).toBe( 0 ); + } ); + } ); describe( 'getSelectedBlock', () => { it( 'should return null if no block is selected', () => { const state = {