diff --git a/blocks/api/factory.js b/blocks/api/factory.js index a0b490cb44939..e603795eaa858 100644 --- a/blocks/api/factory.js +++ b/blocks/api/factory.js @@ -21,6 +21,7 @@ import { * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { applyFilters } from '@wordpress/hooks'; /** * Internal dependencies @@ -32,10 +33,11 @@ import { getBlockType, getBlockTypes } from './registration'; * * @param {string} name Block name. * @param {Object} blockAttributes Block attributes. + * @param {?Array} innerBlocks Nested blocks. * * @return {Object} Block object. */ -export function createBlock( name, blockAttributes = {} ) { +export function createBlock( name, blockAttributes = {}, innerBlocks = [] ) { // Get the type definition associated with a registered block. const blockType = getBlockType( name ); @@ -59,6 +61,29 @@ export function createBlock( name, blockAttributes = {} ) { name, isValid: true, attributes, + innerBlocks, + }; +} + +/** + * Given a block object, returns a copy of the block object, optionally merging + * new attributes and/or replacing its inner blocks. + * + * @param {Object} block Block object. + * @param {Object} mergeAttributes Block attributes. + * @param {?Array} innerBlocks Nested blocks. + * + * @return {Object} A cloned block. + */ +export function cloneBlock( block, mergeAttributes = {}, innerBlocks = block.innerBlocks ) { + return { + ...block, + uid: uuid(), + attributes: { + ...block.attributes, + ...mergeAttributes, + }, + innerBlocks, }; } @@ -213,12 +238,24 @@ export function switchToBlockType( blocks, name ) { return null; } - return transformationResults.map( ( result, index ) => ( { - ...result, - // The first transformed block whose type matches the "destination" - // type gets to keep the existing UID of the first block. - uid: index === firstSwitchedBlock ? firstBlock.uid : result.uid, - } ) ); + return transformationResults.map( ( result, index ) => { + const transformedBlock = { + ...result, + // The first transformed block whose type matches the "destination" + // type gets to keep the existing UID of the first block. + uid: index === firstSwitchedBlock ? firstBlock.uid : result.uid, + }; + + /** + * Filters an individual transform result from block transformation. + * All of the original blocks are passed, since transformations are + * many-to-many, not one-to-one. + * + * @param {Object} transformedBlock The transformed block. + * @param {Object[]} blocks Original blocks transformed. + */ + return applyFilters( 'blocks.switchToBlockType.transformedBlock', transformedBlock, blocks ); + } ); } /** diff --git a/blocks/api/index.js b/blocks/api/index.js index 7f0653a133c5b..b3bce77afc123 100644 --- a/blocks/api/index.js +++ b/blocks/api/index.js @@ -1,4 +1,10 @@ -export { createBlock, getPossibleBlockTransformations, switchToBlockType, createReusableBlock } from './factory'; +export { + createBlock, + cloneBlock, + getPossibleBlockTransformations, + switchToBlockType, + createReusableBlock, +} from './factory'; export { default as parse, getBlockAttributes } from './parser'; export { default as rawHandler } from './raw-handling'; export { diff --git a/blocks/api/parser.js b/blocks/api/parser.js index 2011a213db583..609e270e91d49 100644 --- a/blocks/api/parser.js +++ b/blocks/api/parser.js @@ -178,13 +178,23 @@ export function getAttributesFromDeprecatedVersion( blockType, innerHTML, attrib /** * Creates a block with fallback to the unknown type handler. * - * @param {?string} name Block type name. - * @param {string} innerHTML Raw block content. - * @param {?Object} attributes Attributes obtained from block delimiters. + * @param {Object} blockNode Parsed block node. * * @return {?Object} An initialized block object (if possible). */ -export function createBlockWithFallback( name, innerHTML, attributes ) { +export function createBlockWithFallback( blockNode ) { + let { + blockName: name, + attrs: attributes, + innerBlocks = [], + innerHTML, + } = blockNode; + + attributes = attributes || {}; + + // Trim content to avoid creation of intermediary freeform segments + innerHTML = innerHTML.trim(); + // Use type from block content, otherwise find unknown handler. name = name || getUnknownTypeHandlerName(); @@ -217,6 +227,9 @@ export function createBlockWithFallback( name, innerHTML, attributes ) { blockType = getBlockType( name ); } + // Coerce inner blocks from parse form to canonical form + innerBlocks = innerBlocks.map( createBlockWithFallback ); + // Include in set only if type were determined. if ( ! blockType || ( ! innerHTML && name === fallbackBlock ) ) { return; @@ -224,7 +237,8 @@ export function createBlockWithFallback( name, innerHTML, attributes ) { const block = createBlock( name, - getBlockAttributes( blockType, innerHTML, attributes ) + getBlockAttributes( blockType, innerHTML, attributes ), + innerBlocks ); // Validate that the parsed block is valid, meaning that if we were to @@ -264,8 +278,7 @@ export function createBlockWithFallback( name, innerHTML, attributes ) { */ export function parseWithGrammar( content ) { return grammarParse( content ).reduce( ( memo, blockNode ) => { - const { blockName, innerHTML, attrs } = blockNode; - const block = createBlockWithFallback( blockName, innerHTML.trim(), attrs ); + const block = createBlockWithFallback( blockNode ); if ( block ) { memo.push( block ); } diff --git a/blocks/api/serializer.js b/blocks/api/serializer.js index d93b189784217..d98132fe20eec 100644 --- a/blocks/api/serializer.js +++ b/blocks/api/serializer.js @@ -15,6 +15,7 @@ import { hasFilter, applyFilters } from '@wordpress/hooks'; * Internal dependencies */ import { getBlockType, getUnknownTypeHandlerName } from './registration'; +import BlockContentProvider from '../block-content-provider'; /** * Returns the block's default classname from its name. @@ -32,12 +33,13 @@ export function getBlockDefaultClassname( blockName ) { * Given a block type containg a save render implementation and attributes, returns the * enhanced element to be saved or string when raw HTML expected. * - * @param {Object} blockType Block type. - * @param {Object} attributes Block attributes. + * @param {Object} blockType Block type. + * @param {Object} attributes Block attributes. + * @param {?Array} innerBlocks Nested blocks. * - * @return {Object|string} Save content. + * @return {Object|string} Save element or raw HTML string. */ -export function getSaveElement( blockType, attributes ) { +export function getSaveElement( blockType, attributes, innerBlocks = [] ) { let { save } = blockType; // Component classes are unsupported for save since serialization must @@ -48,7 +50,7 @@ export function getSaveElement( blockType, attributes ) { save = instance.render.bind( instance ); } - let element = save( { attributes } ); + let element = save( { attributes, innerBlocks } ); if ( isObject( element ) && hasFilter( 'blocks.getSaveContent.extraProps' ) ) { /** @@ -77,20 +79,27 @@ export function getSaveElement( blockType, attributes ) { * @param {WPBlockType} blockType Block type definition. * @param {Object} attributes Block attributes. */ - return applyFilters( 'blocks.getSaveElement', element, blockType, attributes ); + element = applyFilters( 'blocks.getSaveElement', element, blockType, attributes ); + + return ( + + { element } + + ); } /** * Given a block type containg a save render implementation and attributes, returns the * static markup to be saved. * - * @param {Object} blockType Block type. - * @param {Object} attributes Block attributes. + * @param {Object} blockType Block type. + * @param {Object} attributes Block attributes. + * @param {?Array} innerBlocks Nested blocks. * * @return {string} Save content. */ -export function getSaveContent( blockType, attributes ) { - return renderToString( getSaveElement( blockType, attributes ) ); +export function getSaveContent( blockType, attributes, innerBlocks ) { + return renderToString( getSaveElement( blockType, attributes, innerBlocks ) ); } /** @@ -171,11 +180,14 @@ export function getBlockContent( block ) { const blockType = getBlockType( block.name ); // If block was parsed as invalid or encounters an error while generating - // save content, use original content instead to avoid content loss. + // save content, use original content instead to avoid content loss. If a + // block contains nested content, exempt it from this condition because we + // otherwise have no access to its original content and content loss would + // still occur. let saveContent = block.originalContent; - if ( block.isValid ) { + if ( block.isValid || block.innerBlocks.length ) { try { - saveContent = getSaveContent( blockType, block.attributes ); + saveContent = getSaveContent( blockType, block.attributes, block.innerBlocks ); } catch ( error ) {} } diff --git a/blocks/api/test/factory.js b/blocks/api/test/factory.js index 9932007d88f82..e24a0a726a6d0 100644 --- a/blocks/api/test/factory.js +++ b/blocks/api/test/factory.js @@ -1,12 +1,19 @@ /** * External dependencies */ +import deepFreeze from 'deep-freeze'; import { noop } from 'lodash'; /** * Internal dependencies */ -import { createBlock, getPossibleBlockTransformations, switchToBlockType, createReusableBlock } from '../factory'; +import { + createBlock, + cloneBlock, + getPossibleBlockTransformations, + switchToBlockType, + createReusableBlock, +} from '../factory'; import { getBlockTypes, unregisterBlockType, setUnknownTypeHandlerName, registerBlockType } from '../registration'; describe( 'block factory', () => { @@ -34,7 +41,7 @@ describe( 'block factory', () => { } ); describe( 'createBlock()', () => { - it( 'should create a block given its blockType and attributes', () => { + it( 'should create a block given its blockType, attributes, inner blocks', () => { registerBlockType( 'core/test-block', { attributes: { align: { @@ -53,9 +60,11 @@ describe( 'block factory', () => { category: 'common', title: 'test block', } ); - const block = createBlock( 'core/test-block', { - align: 'left', - } ); + const block = createBlock( + 'core/test-block', + { align: 'left' }, + [ createBlock( 'core/test-block' ) ], + ); expect( block.name ).toEqual( 'core/test-block' ); expect( block.attributes ).toEqual( { @@ -64,6 +73,8 @@ describe( 'block factory', () => { align: 'left', } ); expect( block.isValid ).toBe( true ); + expect( block.innerBlocks ).toHaveLength( 1 ); + expect( block.innerBlocks[ 0 ].name ).toBe( 'core/test-block' ); expect( typeof block.uid ).toBe( 'string' ); } ); @@ -103,6 +114,79 @@ describe( 'block factory', () => { } ); } ); + describe( 'cloneBlock()', () => { + it( 'should merge attributes into the existing block', () => { + registerBlockType( 'core/test-block', { + attributes: { + align: { + type: 'string', + }, + isDifferent: { + type: 'boolean', + default: false, + }, + }, + save: noop, + category: 'common', + title: 'test block', + } ); + const block = deepFreeze( + createBlock( + 'core/test-block', + { align: 'left' }, + [ createBlock( 'core/test-block' ) ], + ) + ); + + const clonedBlock = cloneBlock( block, { + isDifferent: true, + } ); + + expect( clonedBlock.name ).toEqual( block.name ); + expect( clonedBlock.attributes ).toEqual( { + align: 'left', + isDifferent: true, + } ); + expect( clonedBlock.innerBlocks ).toHaveLength( 1 ); + expect( typeof clonedBlock.uid ).toBe( 'string' ); + expect( clonedBlock.uid ).not.toBe( block.uid ); + } ); + + it( 'should replace inner blocks of the existing block', () => { + registerBlockType( 'core/test-block', { + attributes: { + align: { + type: 'string', + }, + isDifferent: { + type: 'boolean', + default: false, + }, + }, + save: noop, + category: 'common', + title: 'test block', + } ); + const block = deepFreeze( + createBlock( + 'core/test-block', + { align: 'left' }, + [ + createBlock( 'core/test-block', { align: 'right' } ), + createBlock( 'core/test-block', { align: 'left' } ), + ], + ) + ); + + const clonedBlock = cloneBlock( block, undefined, [ + createBlock( 'core/test-block' ), + ] ); + + expect( clonedBlock.innerBlocks ).toHaveLength( 1 ); + expect( clonedBlock.innerBlocks[ 0 ].attributes ).not.toHaveProperty( 'align' ); + } ); + } ); + describe( 'getPossibleBlockTransformations()', () => { it( 'should should show as available a simple "from" transformation"', () => { registerBlockType( 'core/updated-text-block', { diff --git a/blocks/api/test/parser.js b/blocks/api/test/parser.js index 0213dbf225766..ea08c371f3da8 100644 --- a/blocks/api/test/parser.js +++ b/blocks/api/test/parser.js @@ -23,7 +23,7 @@ describe( 'block parser', () => { type: 'string', }, }, - save: ( { attributes } ) => attributes.fruit, + save: ( { attributes } ) => attributes.fruit || null, category: 'common', title: 'block title', }; @@ -37,7 +37,7 @@ describe( 'block parser', () => { source: 'html', }, }, - save: ( { attributes } ) => attributes.content, + save: ( { attributes } ) => attributes.content || null, }; beforeAll( () => { @@ -231,11 +231,11 @@ describe( 'block parser', () => { it( 'should create the requested block if it exists', () => { registerBlockType( 'core/test-block', defaultBlockSettings ); - const block = createBlockWithFallback( - 'core/test-block', - 'Bananas', - { fruit: 'Bananas' } - ); + const block = createBlockWithFallback( { + blockName: 'core/test-block', + innerHTML: 'Bananas', + attrs: { fruit: 'Bananas' }, + } ); expect( block.name ).toEqual( 'core/test-block' ); expect( block.attributes ).toEqual( { fruit: 'Bananas' } ); } ); @@ -243,7 +243,10 @@ describe( 'block parser', () => { it( 'should create the requested block with no attributes if it exists', () => { registerBlockType( 'core/test-block', defaultBlockSettings ); - const block = createBlockWithFallback( 'core/test-block', '' ); + const block = createBlockWithFallback( { + blockName: 'core/test-block', + innerHTML: '', + } ); expect( block.name ).toEqual( 'core/test-block' ); expect( block.attributes ).toEqual( {} ); } ); @@ -252,11 +255,11 @@ describe( 'block parser', () => { registerBlockType( 'core/unknown-block', unknownBlockSettings ); setUnknownTypeHandlerName( 'core/unknown-block' ); - const block = createBlockWithFallback( - 'core/test-block', - 'Bananas', - { fruit: 'Bananas' } - ); + const block = createBlockWithFallback( { + blockName: 'core/test-block', + innerHTML: 'Bananas', + attrs: { fruit: 'Bananas' }, + } ); expect( block.name ).toBe( 'core/unknown-block' ); expect( block.attributes.content ).toContain( 'wp:test-block' ); } ); @@ -265,13 +268,18 @@ describe( 'block parser', () => { registerBlockType( 'core/unknown-block', unknownBlockSettings ); setUnknownTypeHandlerName( 'core/unknown-block' ); - const block = createBlockWithFallback( null, 'content' ); + const block = createBlockWithFallback( { + innerHTML: 'content', + } ); expect( block.name ).toEqual( 'core/unknown-block' ); expect( block.attributes ).toEqual( { content: '

content

' } ); } ); it( 'should not create a block if no unknown type handler', () => { - const block = createBlockWithFallback( 'core/test-block', '' ); + const block = createBlockWithFallback( { + blockName: 'core/test-block', + innerHTML: '', + } ); expect( block ).toBeUndefined(); } ); @@ -301,11 +309,11 @@ describe( 'block parser', () => { ], } ); - const block = createBlockWithFallback( - 'core/test-block', - 'Bananas', - { fruit: 'Bananas' } - ); + const block = createBlockWithFallback( { + blockName: 'core/test-block', + innerHTML: 'Bananas', + attrs: { fruit: 'Bananas' }, + } ); expect( block.name ).toEqual( 'core/test-block' ); expect( block.attributes ).toEqual( { fruit: 'Big Bananas' } ); expect( block.isValid ).toBe( true ); diff --git a/blocks/api/test/registration.js b/blocks/api/test/registration.js index bebd5ae5bc85d..f8cf2aa8fb8f4 100644 --- a/blocks/api/test/registration.js +++ b/blocks/api/test/registration.js @@ -95,6 +95,9 @@ describe( 'blocks', () => { className: { type: 'string', }, + layout: { + type: 'string', + }, }, } ); } ); @@ -183,6 +186,9 @@ describe( 'blocks', () => { className: { type: 'string', }, + layout: { + type: 'string', + }, }, } ); } ); @@ -202,6 +208,9 @@ describe( 'blocks', () => { className: { type: 'string', }, + layout: { + type: 'string', + }, }, } ); } ); @@ -227,6 +236,9 @@ describe( 'blocks', () => { className: { type: 'string', }, + layout: { + type: 'string', + }, }, }, ] ); @@ -242,6 +254,9 @@ describe( 'blocks', () => { className: { type: 'string', }, + layout: { + type: 'string', + }, }, } ); expect( getBlockTypes() ).toEqual( [] ); @@ -289,6 +304,9 @@ describe( 'blocks', () => { className: { type: 'string', }, + layout: { + type: 'string', + }, }, } ); } ); @@ -307,6 +325,9 @@ describe( 'blocks', () => { className: { type: 'string', }, + layout: { + type: 'string', + }, }, } ); } ); @@ -332,6 +353,9 @@ describe( 'blocks', () => { className: { type: 'string', }, + layout: { + type: 'string', + }, }, }, { @@ -345,6 +369,9 @@ describe( 'blocks', () => { className: { type: 'string', }, + layout: { + type: 'string', + }, }, }, ] ); diff --git a/blocks/api/test/serializer.js b/blocks/api/test/serializer.js index f029d555db6c9..ec2bfde75c36f 100644 --- a/blocks/api/test/serializer.js +++ b/blocks/api/test/serializer.js @@ -16,12 +16,14 @@ import serialize, { getBlockContent, } from '../serializer'; import { + getBlockType, getBlockTypes, registerBlockType, unregisterBlockType, setUnknownTypeHandlerName, } from '../registration'; import { createBlock } from '../'; +import InnerBlocks from '../../inner-blocks'; describe( 'block serializer', () => { beforeAll( () => { @@ -119,6 +121,45 @@ describe( 'block serializer', () => { expect( saved ).toBe( '
Bananas
' ); } ); + + it( 'should return element as string, with inner blocks', () => { + registerBlockType( 'core/fruit', { + category: 'common', + + title: 'fruit', + + attributes: { + fruit: { + type: 'string', + }, + }, + + supports: { + className: false, + }, + + save( { attributes } ) { + return ( +
+ { attributes.fruit } + +
+ ); + }, + } ); + + const saved = getSaveContent( + getBlockType( 'core/fruit' ), + { fruit: 'Bananas' }, + [ createBlock( 'core/fruit', { fruit: 'Apples' } ) ], + ); + + expect( saved ).toBe( + '
Bananas\n' + + '
Apples
\n' + + '
' + ); + } ); } ); } ); @@ -334,7 +375,12 @@ describe( 'block serializer', () => { throw new Error(); } - return

; + return ( +

+ { attributes.content } + +

+ ); }, category: 'common', title: 'block title', @@ -347,7 +393,7 @@ describe( 'block serializer', () => { content: 'Ribs & Chicken', stuff: 'left & right -- but ', } ); - const expectedPostContent = '\n

Ribs & Chicken

\n'; + const expectedPostContent = '\n

Ribs & Chicken

\n'; expect( serialize( [ block ] ) ).toEqual( expectedPostContent ); expect( serialize( block ) ).toEqual( expectedPostContent ); @@ -366,6 +412,27 @@ describe( 'block serializer', () => { ); } ); + it( 'should force serialize for invalid block with inner blocks', () => { + const block = createBlock( + 'core/test-block', + { content: 'Invalid' }, + [ createBlock( 'core/test-block' ) ] + ); + + block.isValid = false; + block.originalContent = 'Original'; + + expect( serialize( block ) ).toEqual( + '\n' + + '

Invalid\n' + + ' \n' + + '

\n' + + ' \n' + + '

\n' + + '' + ); + } ); + it( 'should preserve content for crashing block', () => { const block = createBlock( 'core/test-block', { content: 'Incorrect', diff --git a/blocks/block-content-provider/README.md b/blocks/block-content-provider/README.md new file mode 100644 index 0000000000000..45ad712a49e8b --- /dev/null +++ b/blocks/block-content-provider/README.md @@ -0,0 +1,12 @@ +BlockContentProvider +==================== + +An internal block component used in block content serialization to inject nested block content within the `save` implementation of the ancestor component in which it is nested. The component provides a pre-bound `BlockContent` component via context, which is used by the developer-facing `InnerBlocks.Content` component to render block content. + +## Usage + +```jsx + + { blockSaveElement } + +``` diff --git a/blocks/block-content-provider/index.js b/blocks/block-content-provider/index.js new file mode 100644 index 0000000000000..6829078682858 --- /dev/null +++ b/blocks/block-content-provider/index.js @@ -0,0 +1,50 @@ +/** + * WordPress dependencies + */ +import { Component, RawHTML } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { serialize } from '../api'; + +/** + * An internal block component used in block content serialization to inject + * nested block content within the `save` implementation of the ancestor + * component in which it is nested. The component provides a pre-bound + * `BlockContent` component via context, which is used by the developer-facing + * `InnerBlocks.Content` component to render block content. + * + * @example + * + * ```jsx + * + * { blockSaveElement } + * + * ``` + */ +class BlockContentProvider extends Component { + getChildContext() { + const { innerBlocks } = this.props; + + return { + BlockContent() { + // Value is an array of blocks, so defer to block serializer + const html = serialize( innerBlocks ); + + // Use special-cased raw HTML tag to avoid default escaping + return { html }; + }, + }; + } + + render() { + return this.props.children; + } +} + +BlockContentProvider.childContextTypes = { + BlockContent: () => {}, +}; + +export default BlockContentProvider; diff --git a/blocks/hooks/index.js b/blocks/hooks/index.js index 33489dd8e4315..4bc4cebbf3119 100644 --- a/blocks/hooks/index.js +++ b/blocks/hooks/index.js @@ -5,4 +5,5 @@ import './anchor'; import './custom-class-name'; import './deprecated'; import './generated-class-name'; +import './layout'; import './matchers'; diff --git a/blocks/hooks/layout.js b/blocks/hooks/layout.js new file mode 100644 index 0000000000000..a313c6ee35f18 --- /dev/null +++ b/blocks/hooks/layout.js @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import { assign, compact, get } from 'lodash'; + +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; + +/** + * Filters registered block settings, extending attributes with layout. + * + * @param {Object} settings Original block settings. + * + * @return {Object} Filtered block settings. + */ +export function addAttribute( settings ) { + // Use Lodash's assign to gracefully handle if attributes are undefined + settings.attributes = assign( settings.attributes, { + layout: { + type: 'string', + }, + } ); + + return settings; +} + +/** + * Override props assigned to save component to inject layout class. This is + * only applied if the block's save result is an element and not a markup + * string. + * + * @param {Object} extraProps Additional props applied to save element. + * @param {Object} blockType Block type. + * @param {Object} attributes Current block attributes. + * + * @return {Object} Filtered props applied to save element. + */ +export function addSaveProps( extraProps, blockType, attributes ) { + const { layout } = attributes; + if ( layout ) { + extraProps.className = compact( [ + extraProps.className, + 'layout-' + layout, + ] ).join( ' ' ); + } + + return extraProps; +} + +/** + * Given a transformed block, assigns the layout from the original block. Since + * layout is a "global" attribute implemented via hooks, the individual block + * transforms are not expected to handle this themselves, and a transform would + * otherwise lose assigned layout. + * + * @param {Object} transformedBlock Original transformed block. + * @param {Object} blocks Blocks on which transform was applied. + * + * @return {Object} Modified transformed block, with layout preserved. + */ +function preserveLayoutAttribute( transformedBlock, blocks ) { + // Since block transforms are many-to-many, use the layout attribute from + // the first of the source blocks. + const layout = get( blocks, [ 0, 'attributes', 'layout' ] ); + + transformedBlock.attributes.layout = layout; + + return transformedBlock; +} + +addFilter( 'blocks.registerBlockType', 'core/layout/attribute', addAttribute ); +addFilter( 'blocks.getSaveContent.extraProps', 'core/layout/save-props', addSaveProps ); +addFilter( 'blocks.switchToBlockType.transformedBlock', 'core/layout/preserve-layout', preserveLayoutAttribute ); diff --git a/blocks/hooks/test/layout.js b/blocks/hooks/test/layout.js new file mode 100644 index 0000000000000..24ac163077807 --- /dev/null +++ b/blocks/hooks/test/layout.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import '../layout'; + +describe( 'layout', () => { + const blockSettings = { + save: noop, + category: 'common', + title: 'block title', + }; + + describe( 'addAttribute()', () => { + const registerBlockType = applyFilters.bind( null, 'blocks.registerBlockType' ); + + it( 'should assign a new layout attribute', () => { + const settings = registerBlockType( blockSettings ); + + expect( settings.attributes ).toHaveProperty( 'layout' ); + } ); + } ); + + describe( 'addSaveProps', () => { + const getSaveContentExtraProps = applyFilters.bind( null, 'blocks.getSaveContent.extraProps' ); + + it( 'should merge layout class name', () => { + const attributes = { layout: 'wide' }; + const extraProps = getSaveContentExtraProps( { + className: 'wizard', + }, blockSettings, attributes ); + + expect( extraProps.className ).toBe( 'wizard layout-wide' ); + } ); + } ); + + describe( 'preserveLayoutAttribute', () => { + const transformBlock = applyFilters.bind( null, 'blocks.switchToBlockType.transformedBlock' ); + + it( 'should preserve layout attribute', () => { + const blocks = [ { attributes: { layout: 'wide' } } ]; + const transformedBlock = transformBlock( { attributes: {} }, blocks ); + + expect( transformedBlock.attributes.layout ).toBe( 'wide' ); + } ); + } ); +} ); diff --git a/blocks/index.js b/blocks/index.js index 4ef35d2d54ab4..6d30e99988796 100644 --- a/blocks/index.js +++ b/blocks/index.js @@ -22,6 +22,7 @@ export { default as BlockEdit } from './block-edit'; export { default as BlockIcon } from './block-icon'; export { default as ColorPalette } from './color-palette'; export { default as Editable } from './rich-text/editable'; +export { default as InnerBlocks } from './inner-blocks'; export { default as InspectorControls } from './inspector-controls'; export { default as PlainText } from './plain-text'; export { default as MediaUpload } from './media-upload'; diff --git a/blocks/inner-blocks/README.md b/blocks/inner-blocks/README.md new file mode 100644 index 0000000000000..5d6b39b4295e8 --- /dev/null +++ b/blocks/inner-blocks/README.md @@ -0,0 +1,147 @@ +InnerBlocks +=========== + +InnerBlocks exports a pair of components which can be used in block implementations to enable nested block content. + +Refer to the [implementation of the Columns block](https://github.com/WordPress/gutenberg/tree/master/blocks/library/columns) as an example resource. + +## Usage + +In a block's `edit` implementation, simply render `InnerBlocks`, optionally with `layouts` of available nest areas: + +Then, in the `save` implementation, render `InnerBlocks.Content`. This will be replaced automatically with the content of the nested blocks. + +```jsx +import { registerBlockType, InnerBlocks } from '@wordpress/blocks'; + +registerBlockType( 'my-plugin/my-block', { + // ... + + edit( { className } ) { + return ( +
+ +
+ ); + }, + + save() { + return ( +
+ +
+ ); + } +} ); +``` + +_Note:_ A block can render at most a single `InnerBlocks` and `InnerBlocks.Content` element in `edit` and `save` respectively. To create distinct arrangements of nested blocks, refer to the `layouts` prop documented below. + +_Note:_ Since the save step will automatically apply props to the element returned by `save`, it is important to include the wrapping `div` in the above simple example even though we are applying no props of our own. In a real-world example, you may have your own attributes to apply to the saved markup, or sibling content adjacent to the rendered nested blocks. + +## Props + +### `InnerBlocks` + +#### `layouts` + +* **Type:** `Array|Object` + +To achieve distinct arrangements of nested blocks, you may assign layout as an array of objects, or an object. When assigned, a user will be provided with the option to move blocks between layouts, and the rendered output will assign a layout-specific class which can be used in your block stylesheet to effect the visual arrangement of nested blocks. + +Because `InnerBlocks.Content` will generate a single continuous flow of block markup for nested content, it may be advisable to use [CSS Grid Layout](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Grid_Layout) to assign layout positions. Be aware that CSS grid is [not suported in legacy browsers](https://caniuse.com/#feat=css-grid), and therefore you should consider how this might impact your block's appearance when viewed on the front end of a site in such browsers. + +Layouts can be assigned either either as an object (ungrouped layouts) or an array of objects (grouped layouts). These are documented below. + +In both cases, each layout consists of: + +- Name: A slug to use in generating the layout class applied to nested blocks +- Icon (`icon: string`): The slug of the Dashicon to use in controls presented to the user in moving between layouts + - Reference: https://developer.wordpress.org/resource/dashicons/ +- Label (`label: string`): The text to display in the controls presented to the user in moving between layouts + +_Ungrouped Layouts:_ + +If you do not depend on a particular order of markup for your nested content and need merely to assign a layout class to each nested block, you should assign `layouts` as an object, where each key is the name of a layout: + +```jsx + +``` + +_Grouped Layouts:_ + +If your nested content depends on having each layout grouped in its markup, you should assign `layouts` as an array of layout objects, where the name of the layout is set as a property of the object: + +```jsx + +``` + +Consider a Columns block. When the user changes the layout of a block from one column to another, it is not sufficient to simply reassign the class name of the block to the new layout, as the user may then proceed to attempt to move the block up or down within the new column. The expected behavior here requires that the markup of the block itself be moved in relation to blocks already present in the new layout. + +```html + +
+ +

First Paragraph

+ + + +

Second Paragraph

+ + + +

Third Paragraph

+ +
+ +``` + +In the above example markup, if the user moved the first nested paragraph block to the second column, we must ensure that if they then proceed to move the block down, that the block would be the last item in the markup, otherwise it would not appear to move because it would still exist in markup prior to the third paragraph. + +_Bad:_ + +```html + +
+ +

First Paragraph

+ + + +

Second Paragraph

+ + + +

Third Paragraph

+ +
+ +``` + +We achieve this by ensuring that the markup of each layout is kept grouped together. + +_Good:_ + +```html + +
+ +

Second Paragraph

+ + + +

First Paragraph

+ + + +

Third Paragraph

+ +
+ +``` diff --git a/blocks/inner-blocks/index.js b/blocks/inner-blocks/index.js new file mode 100644 index 0000000000000..37ae97e13c71a --- /dev/null +++ b/blocks/inner-blocks/index.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { withContext } from '@wordpress/components'; + +function InnerBlocks( { BlockList, layouts } ) { + return ; +} + +InnerBlocks = withContext( 'BlockList' )()( InnerBlocks ); + +InnerBlocks.Content = ( { BlockContent } ) => { + return ; +}; + +InnerBlocks.Content = withContext( 'BlockContent' )()( InnerBlocks.Content ); + +export default InnerBlocks; diff --git a/blocks/library/columns/index.js b/blocks/library/columns/index.js new file mode 100644 index 0000000000000..5331df92262a9 --- /dev/null +++ b/blocks/library/columns/index.js @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import { times } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import './style.scss'; +import RangeControl from '../../inspector-controls/range-control'; +import InspectorControls from '../../inspector-controls'; +import BlockControls from '../../block-controls'; +import BlockAlignmentToolbar from '../../block-alignment-toolbar'; +import InnerBlocks from '../../inner-blocks'; + +export const name = 'core/columns'; + +export const settings = { + title: sprintf( + /* translators: Block title modifier */ + __( '%1$s (%2$s)' ), + __( 'Columns' ), + __( 'Experimental' ) + ), + + icon: 'columns', + + category: 'layout', + + attributes: { + columns: { + type: 'number', + default: 2, + }, + align: { + type: 'string', + }, + }, + + description: __( 'A multi-column layout of content.' ), + + getEditWrapperProps( attributes ) { + const { align } = attributes; + + return { 'data-align': align }; + }, + + edit( { attributes, setAttributes, className, focus } ) { + const { align, columns } = attributes; + const classes = classnames( className, `has-${ columns }-columns` ); + + // Define columns as a set of layouts within the inner block list. This + // will enable the user to move blocks between columns, and will apply + // a layout-specific class name to the rendered output which can be + // styled by the columns wrapper to visually place the columns. + const layouts = times( columns, ( n ) => ( { + name: `column-${ n + 1 }`, + label: sprintf( __( 'Column %d' ), n + 1 ), + icon: 'columns', + } ) ); + + return [ + ...focus ? [ + + { + setAttributes( { align: nextAlign } ); + } } + /> + , + + { + setAttributes( { + columns: nextColumns, + } ); + } } + min={ 2 } + max={ 6 } + /> + , + ] : [], +
+ +
, + ]; + }, + + save( { attributes } ) { + const { columns } = attributes; + + return ( +
+ +
+ ); + }, +}; diff --git a/blocks/library/columns/style.scss b/blocks/library/columns/style.scss new file mode 100644 index 0000000000000..9ec2e09c5c875 --- /dev/null +++ b/blocks/library/columns/style.scss @@ -0,0 +1,16 @@ +.wp-block-columns { + display: grid; + grid-auto-flow: dense; + + @for $i from 2 through 6 { + &.has-#{ $i }-columns { + grid-auto-columns: #{ 100% / $i }; + } + } + + @for $i from 1 through 6 { + .layout-column-#{ $i } { + grid-column: #{ $i }; + } + } +} diff --git a/blocks/library/index.js b/blocks/library/index.js index 17e7534aa35cd..b55e5b2025cf5 100644 --- a/blocks/library/index.js +++ b/blocks/library/index.js @@ -10,6 +10,7 @@ import * as audio from './audio'; import * as button from './button'; import * as categories from './categories'; import * as code from './code'; +import * as columns from './columns'; import * as coverImage from './cover-image'; import * as embed from './embed'; import * as freeform from './freeform'; @@ -59,6 +60,7 @@ export const registerCoreBlocks = () => { button, categories, code, + columns, coverImage, embed, ...embed.common, diff --git a/blocks/library/latest-posts/index.php b/blocks/library/latest-posts/index.php index 5358c2aa44546..4ea93fa513050 100644 --- a/blocks/library/latest-posts/index.php +++ b/blocks/library/latest-posts/index.php @@ -48,11 +48,11 @@ function gutenberg_render_block_core_latest_posts( $attributes ) { } $class = "wp-block-latest-posts align{$attributes['align']}"; - if ( isset( $attributes['layout'] ) && 'grid' === $attributes['layout'] ) { + if ( isset( $attributes['postLayout'] ) && 'grid' === $attributes['postLayout'] ) { $class .= ' is-grid'; } - if ( isset( $attributes['columns'] ) && 'grid' === $attributes['layout'] ) { + if ( isset( $attributes['columns'] ) && 'grid' === $attributes['postLayout'] ) { $class .= ' columns-' . $attributes['columns']; } @@ -78,7 +78,7 @@ function gutenberg_render_block_core_latest_posts( $attributes ) { 'type' => 'boolean', 'default' => false, ), - 'layout' => array( + 'postLayout' => array( 'type' => 'string', 'default' => 'list', ), diff --git a/blocks/test/fixtures/core-embed__animoto.json b/blocks/test/fixtures/core-embed__animoto.json index 9ffca1aefa663..973bfe9bd485f 100644 --- a/blocks/test/fixtures/core-embed__animoto.json +++ b/blocks/test/fixtures/core-embed__animoto.json @@ -9,6 +9,7 @@ "Embedded content from animoto" ] }, + "innerBlocks": [], "originalContent": "
\n https://animoto.com/\n
Embedded content from animoto
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__cloudup.json b/blocks/test/fixtures/core-embed__cloudup.json index 5b093bf14ab04..111dc5d58921d 100644 --- a/blocks/test/fixtures/core-embed__cloudup.json +++ b/blocks/test/fixtures/core-embed__cloudup.json @@ -9,6 +9,7 @@ "Embedded content from cloudup" ] }, + "innerBlocks": [], "originalContent": "
\n https://cloudup.com/\n
Embedded content from cloudup
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__collegehumor.json b/blocks/test/fixtures/core-embed__collegehumor.json index 7aa5dab8f4ecc..cccdbc2ba161c 100644 --- a/blocks/test/fixtures/core-embed__collegehumor.json +++ b/blocks/test/fixtures/core-embed__collegehumor.json @@ -9,6 +9,7 @@ "Embedded content from collegehumor" ] }, + "innerBlocks": [], "originalContent": "
\n https://collegehumor.com/\n
Embedded content from collegehumor
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__dailymotion.json b/blocks/test/fixtures/core-embed__dailymotion.json index 644af7bf6e028..d47c9e592c7f6 100644 --- a/blocks/test/fixtures/core-embed__dailymotion.json +++ b/blocks/test/fixtures/core-embed__dailymotion.json @@ -9,6 +9,7 @@ "Embedded content from dailymotion" ] }, + "innerBlocks": [], "originalContent": "
\n https://dailymotion.com/\n
Embedded content from dailymotion
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__facebook.json b/blocks/test/fixtures/core-embed__facebook.json index 23050a60bbe44..15e57c9103b72 100644 --- a/blocks/test/fixtures/core-embed__facebook.json +++ b/blocks/test/fixtures/core-embed__facebook.json @@ -9,6 +9,7 @@ "Embedded content from facebook" ] }, + "innerBlocks": [], "originalContent": "
\n https://facebook.com/\n
Embedded content from facebook
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__flickr.json b/blocks/test/fixtures/core-embed__flickr.json index 98e4d671330dc..3d202ece36f59 100644 --- a/blocks/test/fixtures/core-embed__flickr.json +++ b/blocks/test/fixtures/core-embed__flickr.json @@ -9,6 +9,7 @@ "Embedded content from flickr" ] }, + "innerBlocks": [], "originalContent": "
\n https://flickr.com/\n
Embedded content from flickr
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__funnyordie.json b/blocks/test/fixtures/core-embed__funnyordie.json index 707a5eac89db5..8cef138ad02cd 100644 --- a/blocks/test/fixtures/core-embed__funnyordie.json +++ b/blocks/test/fixtures/core-embed__funnyordie.json @@ -9,6 +9,7 @@ "Embedded content from funnyordie" ] }, + "innerBlocks": [], "originalContent": "
\n https://funnyordie.com/\n
Embedded content from funnyordie
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__hulu.json b/blocks/test/fixtures/core-embed__hulu.json index 8a3af46b91cf3..0fb42064add73 100644 --- a/blocks/test/fixtures/core-embed__hulu.json +++ b/blocks/test/fixtures/core-embed__hulu.json @@ -9,6 +9,7 @@ "Embedded content from hulu" ] }, + "innerBlocks": [], "originalContent": "
\n https://hulu.com/\n
Embedded content from hulu
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__imgur.json b/blocks/test/fixtures/core-embed__imgur.json index e37ea33dac37b..b1d5ca44fc301 100644 --- a/blocks/test/fixtures/core-embed__imgur.json +++ b/blocks/test/fixtures/core-embed__imgur.json @@ -9,6 +9,7 @@ "Embedded content from imgur" ] }, + "innerBlocks": [], "originalContent": "
\n https://imgur.com/\n
Embedded content from imgur
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__instagram.json b/blocks/test/fixtures/core-embed__instagram.json index 9756e0b3268e6..ebaf856850fdb 100644 --- a/blocks/test/fixtures/core-embed__instagram.json +++ b/blocks/test/fixtures/core-embed__instagram.json @@ -9,6 +9,7 @@ "Embedded content from instagram" ] }, + "innerBlocks": [], "originalContent": "
\n https://instagram.com/\n
Embedded content from instagram
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__issuu.json b/blocks/test/fixtures/core-embed__issuu.json index 8b37c67162c71..406b16d852d60 100644 --- a/blocks/test/fixtures/core-embed__issuu.json +++ b/blocks/test/fixtures/core-embed__issuu.json @@ -9,6 +9,7 @@ "Embedded content from issuu" ] }, + "innerBlocks": [], "originalContent": "
\n https://issuu.com/\n
Embedded content from issuu
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__kickstarter.json b/blocks/test/fixtures/core-embed__kickstarter.json index 044df72425bee..a31b8342ab392 100644 --- a/blocks/test/fixtures/core-embed__kickstarter.json +++ b/blocks/test/fixtures/core-embed__kickstarter.json @@ -9,6 +9,7 @@ "Embedded content from kickstarter" ] }, + "innerBlocks": [], "originalContent": "
\n https://kickstarter.com/\n
Embedded content from kickstarter
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__meetup-com.json b/blocks/test/fixtures/core-embed__meetup-com.json index b5bfda1c2154d..574330da51c36 100644 --- a/blocks/test/fixtures/core-embed__meetup-com.json +++ b/blocks/test/fixtures/core-embed__meetup-com.json @@ -9,6 +9,7 @@ "Embedded content from meetup-com" ] }, + "innerBlocks": [], "originalContent": "
\n https://meetup.com/\n
Embedded content from meetup-com
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__mixcloud.json b/blocks/test/fixtures/core-embed__mixcloud.json index afa52ccab22ab..c234164d20ee4 100644 --- a/blocks/test/fixtures/core-embed__mixcloud.json +++ b/blocks/test/fixtures/core-embed__mixcloud.json @@ -9,6 +9,7 @@ "Embedded content from mixcloud" ] }, + "innerBlocks": [], "originalContent": "
\n https://mixcloud.com/\n
Embedded content from mixcloud
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__photobucket.json b/blocks/test/fixtures/core-embed__photobucket.json index 4a9e74cd575f4..76b33d9373e52 100644 --- a/blocks/test/fixtures/core-embed__photobucket.json +++ b/blocks/test/fixtures/core-embed__photobucket.json @@ -9,6 +9,7 @@ "Embedded content from photobucket" ] }, + "innerBlocks": [], "originalContent": "
\n https://photobucket.com/\n
Embedded content from photobucket
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__polldaddy.json b/blocks/test/fixtures/core-embed__polldaddy.json index d5d6e007d7c8e..f355eb0f8e41e 100644 --- a/blocks/test/fixtures/core-embed__polldaddy.json +++ b/blocks/test/fixtures/core-embed__polldaddy.json @@ -9,6 +9,7 @@ "Embedded content from polldaddy" ] }, + "innerBlocks": [], "originalContent": "
\n https://polldaddy.com/\n
Embedded content from polldaddy
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__reddit.json b/blocks/test/fixtures/core-embed__reddit.json index 1b9ca92d2ffd3..516155b3c50d1 100644 --- a/blocks/test/fixtures/core-embed__reddit.json +++ b/blocks/test/fixtures/core-embed__reddit.json @@ -9,6 +9,7 @@ "Embedded content from reddit" ] }, + "innerBlocks": [], "originalContent": "
\n https://reddit.com/\n
Embedded content from reddit
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__reverbnation.json b/blocks/test/fixtures/core-embed__reverbnation.json index e7345026cb11e..f76235a112012 100644 --- a/blocks/test/fixtures/core-embed__reverbnation.json +++ b/blocks/test/fixtures/core-embed__reverbnation.json @@ -9,6 +9,7 @@ "Embedded content from reverbnation" ] }, + "innerBlocks": [], "originalContent": "
\n https://reverbnation.com/\n
Embedded content from reverbnation
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__screencast.json b/blocks/test/fixtures/core-embed__screencast.json index 4ff4aa953f000..8fe863a7dcc65 100644 --- a/blocks/test/fixtures/core-embed__screencast.json +++ b/blocks/test/fixtures/core-embed__screencast.json @@ -9,6 +9,7 @@ "Embedded content from screencast" ] }, + "innerBlocks": [], "originalContent": "
\n https://screencast.com/\n
Embedded content from screencast
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__scribd.json b/blocks/test/fixtures/core-embed__scribd.json index 6a49c821012e9..ce090cf5d7a51 100644 --- a/blocks/test/fixtures/core-embed__scribd.json +++ b/blocks/test/fixtures/core-embed__scribd.json @@ -9,6 +9,7 @@ "Embedded content from scribd" ] }, + "innerBlocks": [], "originalContent": "
\n https://scribd.com/\n
Embedded content from scribd
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__slideshare.json b/blocks/test/fixtures/core-embed__slideshare.json index 05e785b0dbaa3..097f06411708f 100644 --- a/blocks/test/fixtures/core-embed__slideshare.json +++ b/blocks/test/fixtures/core-embed__slideshare.json @@ -9,6 +9,7 @@ "Embedded content from slideshare" ] }, + "innerBlocks": [], "originalContent": "
\n https://slideshare.com/\n
Embedded content from slideshare
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__smugmug.json b/blocks/test/fixtures/core-embed__smugmug.json index a7d05bbffe401..72d3442ae9210 100644 --- a/blocks/test/fixtures/core-embed__smugmug.json +++ b/blocks/test/fixtures/core-embed__smugmug.json @@ -9,6 +9,7 @@ "Embedded content from smugmug" ] }, + "innerBlocks": [], "originalContent": "
\n https://smugmug.com/\n
Embedded content from smugmug
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__soundcloud.json b/blocks/test/fixtures/core-embed__soundcloud.json index 7b2ab83295483..1fdfe140b3ffe 100644 --- a/blocks/test/fixtures/core-embed__soundcloud.json +++ b/blocks/test/fixtures/core-embed__soundcloud.json @@ -9,6 +9,7 @@ "Embedded content from soundcloud" ] }, + "innerBlocks": [], "originalContent": "
\n https://soundcloud.com/\n
Embedded content from soundcloud
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__speaker.json b/blocks/test/fixtures/core-embed__speaker.json index 27b7dcc291deb..1998640c39583 100644 --- a/blocks/test/fixtures/core-embed__speaker.json +++ b/blocks/test/fixtures/core-embed__speaker.json @@ -9,6 +9,7 @@ "Embedded content from speaker" ] }, + "innerBlocks": [], "originalContent": "
\n https://speaker.com/\n
Embedded content from speaker
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__spotify.json b/blocks/test/fixtures/core-embed__spotify.json index f769e12a5b1cc..ec59ab05cdfec 100644 --- a/blocks/test/fixtures/core-embed__spotify.json +++ b/blocks/test/fixtures/core-embed__spotify.json @@ -9,6 +9,7 @@ "Embedded content from spotify" ] }, + "innerBlocks": [], "originalContent": "
\n https://spotify.com/\n
Embedded content from spotify
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__ted.json b/blocks/test/fixtures/core-embed__ted.json index 8160876659851..306e48d7b9484 100644 --- a/blocks/test/fixtures/core-embed__ted.json +++ b/blocks/test/fixtures/core-embed__ted.json @@ -9,6 +9,7 @@ "Embedded content from ted" ] }, + "innerBlocks": [], "originalContent": "
\n https://ted.com/\n
Embedded content from ted
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__tumblr.json b/blocks/test/fixtures/core-embed__tumblr.json index 7b69e5746c6ec..7ff6190c97151 100644 --- a/blocks/test/fixtures/core-embed__tumblr.json +++ b/blocks/test/fixtures/core-embed__tumblr.json @@ -9,6 +9,7 @@ "Embedded content from tumblr" ] }, + "innerBlocks": [], "originalContent": "
\n https://tumblr.com/\n
Embedded content from tumblr
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__twitter.json b/blocks/test/fixtures/core-embed__twitter.json index 85953b791641f..458d69398354f 100644 --- a/blocks/test/fixtures/core-embed__twitter.json +++ b/blocks/test/fixtures/core-embed__twitter.json @@ -9,6 +9,7 @@ "We are Automattic" ] }, + "innerBlocks": [], "originalContent": "
\n https://twitter.com/automattic\n
We are Automattic
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__videopress.json b/blocks/test/fixtures/core-embed__videopress.json index 2547accbabf84..81f8b7bed2ddd 100644 --- a/blocks/test/fixtures/core-embed__videopress.json +++ b/blocks/test/fixtures/core-embed__videopress.json @@ -9,6 +9,7 @@ "Embedded content from videopress" ] }, + "innerBlocks": [], "originalContent": "
\n https://videopress.com/\n
Embedded content from videopress
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__vimeo.json b/blocks/test/fixtures/core-embed__vimeo.json index 459909bfb9afb..930291ae2d150 100644 --- a/blocks/test/fixtures/core-embed__vimeo.json +++ b/blocks/test/fixtures/core-embed__vimeo.json @@ -9,6 +9,7 @@ "Embedded content from vimeo" ] }, + "innerBlocks": [], "originalContent": "
\n https://vimeo.com/\n
Embedded content from vimeo
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__wordpress-tv.json b/blocks/test/fixtures/core-embed__wordpress-tv.json index 74bd05f431c65..9e55cc69e9fff 100644 --- a/blocks/test/fixtures/core-embed__wordpress-tv.json +++ b/blocks/test/fixtures/core-embed__wordpress-tv.json @@ -9,6 +9,7 @@ "Embedded content from wordpress-tv" ] }, + "innerBlocks": [], "originalContent": "
\n https://wordpress.tv/\n
Embedded content from wordpress-tv
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__wordpress.json b/blocks/test/fixtures/core-embed__wordpress.json index 45dd54a275f30..9386dc743d3cf 100644 --- a/blocks/test/fixtures/core-embed__wordpress.json +++ b/blocks/test/fixtures/core-embed__wordpress.json @@ -9,6 +9,7 @@ "Embedded content from WordPress" ] }, + "innerBlocks": [], "originalContent": "
\n https://wordpress.com/\n
Embedded content from WordPress
\n
" } ] diff --git a/blocks/test/fixtures/core-embed__youtube.json b/blocks/test/fixtures/core-embed__youtube.json index f4f42cb78de85..73e380d4dfa27 100644 --- a/blocks/test/fixtures/core-embed__youtube.json +++ b/blocks/test/fixtures/core-embed__youtube.json @@ -9,6 +9,7 @@ "Embedded content from youtube" ] }, + "innerBlocks": [], "originalContent": "
\n https://youtube.com/\n
Embedded content from youtube
\n
" } ] diff --git a/blocks/test/fixtures/core__4-invalid-starting-letter.json b/blocks/test/fixtures/core__4-invalid-starting-letter.json index 5515c7e9eade7..792d06fd6ce23 100644 --- a/blocks/test/fixtures/core__4-invalid-starting-letter.json +++ b/blocks/test/fixtures/core__4-invalid-starting-letter.json @@ -6,6 +6,7 @@ "attributes": { "content": "

" }, + "innerBlocks": [], "originalContent": "

" } ] diff --git a/blocks/test/fixtures/core__audio.json b/blocks/test/fixtures/core__audio.json index 99b43e37d313d..b1ff7c032168e 100644 --- a/blocks/test/fixtures/core__audio.json +++ b/blocks/test/fixtures/core__audio.json @@ -8,6 +8,7 @@ "align": "right", "caption": [] }, + "innerBlocks": [], "originalContent": "
\n \n
" } ] diff --git a/blocks/test/fixtures/core__block.json b/blocks/test/fixtures/core__block.json index 46433ea6c4ee4..02490abb1936a 100644 --- a/blocks/test/fixtures/core__block.json +++ b/blocks/test/fixtures/core__block.json @@ -6,6 +6,7 @@ "attributes": { "ref": 123 }, + "innerBlocks": [], "originalContent": "" } ] diff --git a/blocks/test/fixtures/core__button__center.json b/blocks/test/fixtures/core__button__center.json index 0ef63ab867160..5965af4df12e8 100644 --- a/blocks/test/fixtures/core__button__center.json +++ b/blocks/test/fixtures/core__button__center.json @@ -10,6 +10,7 @@ ], "align": "center" }, + "innerBlocks": [], "originalContent": "" } ] diff --git a/blocks/test/fixtures/core__categories.json b/blocks/test/fixtures/core__categories.json index 0c72a917e9e5e..96f2240e6b788 100644 --- a/blocks/test/fixtures/core__categories.json +++ b/blocks/test/fixtures/core__categories.json @@ -8,6 +8,7 @@ "displayAsDropdown": false, "showHierarchy": false }, + "innerBlocks": [], "originalContent": "" } ] diff --git a/blocks/test/fixtures/core__code.json b/blocks/test/fixtures/core__code.json index eabc18c2a24ec..01009fbb64042 100644 --- a/blocks/test/fixtures/core__code.json +++ b/blocks/test/fixtures/core__code.json @@ -6,6 +6,7 @@ "attributes": { "content": "export default function MyButton() {\n\treturn ;\n}" }, + "innerBlocks": [], "originalContent": "
export default function MyButton() {\n\treturn <Button>Click Me!</Button>;\n}
" } ] diff --git a/blocks/test/fixtures/core__columns.html b/blocks/test/fixtures/core__columns.html new file mode 100644 index 0000000000000..ae96a2e33b146 --- /dev/null +++ b/blocks/test/fixtures/core__columns.html @@ -0,0 +1,16 @@ + +
+ +

Column One, Paragraph One

+ + +

Column One, Paragraph Two

+ + +

Column Two, Paragraph One

+ + +

Column Three, Paragraph One

+ +
+ diff --git a/blocks/test/fixtures/core__columns.json b/blocks/test/fixtures/core__columns.json new file mode 100644 index 0000000000000..baf28cfd7f291 --- /dev/null +++ b/blocks/test/fixtures/core__columns.json @@ -0,0 +1,69 @@ +[ + { + "uid": "_uid_0", + "name": "core/columns", + "isValid": true, + "attributes": { + "columns": 3 + }, + "innerBlocks": [ + { + "uid": "_uid_0", + "name": "core/paragraph", + "isValid": true, + "attributes": { + "content": [ + "Column One, Paragraph One" + ], + "dropCap": false, + "layout": "column-1" + }, + "innerBlocks": [], + "originalContent": "

Column One, Paragraph One

" + }, + { + "uid": "_uid_1", + "name": "core/paragraph", + "isValid": true, + "attributes": { + "content": [ + "Column One, Paragraph Two" + ], + "dropCap": false, + "layout": "column-1" + }, + "innerBlocks": [], + "originalContent": "

Column One, Paragraph Two

" + }, + { + "uid": "_uid_2", + "name": "core/paragraph", + "isValid": true, + "attributes": { + "content": [ + "Column Two, Paragraph One" + ], + "dropCap": false, + "layout": "column-2" + }, + "innerBlocks": [], + "originalContent": "

Column Two, Paragraph One

" + }, + { + "uid": "_uid_3", + "name": "core/paragraph", + "isValid": true, + "attributes": { + "content": [ + "Column Three, Paragraph One" + ], + "dropCap": false, + "layout": "column-3" + }, + "innerBlocks": [], + "originalContent": "

Column Three, Paragraph One

" + } + ], + "originalContent": "
\n\t\n\t\n\t\n\t\n
" + } +] diff --git a/blocks/test/fixtures/core__columns.parsed.json b/blocks/test/fixtures/core__columns.parsed.json new file mode 100644 index 0000000000000..80b670bf1e849 --- /dev/null +++ b/blocks/test/fixtures/core__columns.parsed.json @@ -0,0 +1,47 @@ +[ + { + "blockName": "core/columns", + "attrs": { + "columns": 3 + }, + "innerBlocks": [ + { + "blockName": "core/paragraph", + "attrs": { + "layout": "column-1" + }, + "innerBlocks": [], + "innerHTML": "\n\t

Column One, Paragraph One

\n\t" + }, + { + "blockName": "core/paragraph", + "attrs": { + "layout": "column-1" + }, + "innerBlocks": [], + "innerHTML": "\n\t

Column One, Paragraph Two

\n\t" + }, + { + "blockName": "core/paragraph", + "attrs": { + "layout": "column-2" + }, + "innerBlocks": [], + "innerHTML": "\n\t

Column Two, Paragraph One

\n\t" + }, + { + "blockName": "core/paragraph", + "attrs": { + "layout": "column-3" + }, + "innerBlocks": [], + "innerHTML": "\n\t

Column Three, Paragraph One

\n\t" + } + ], + "innerHTML": "\n
\n\t\n\t\n\t\n\t\n
\n" + }, + { + "attrs": {}, + "innerHTML": "\n" + } +] diff --git a/blocks/test/fixtures/core__columns.serialized.html b/blocks/test/fixtures/core__columns.serialized.html new file mode 100644 index 0000000000000..0296c04a5ad00 --- /dev/null +++ b/blocks/test/fixtures/core__columns.serialized.html @@ -0,0 +1,19 @@ + +
+ +

Column One, Paragraph One

+ + + +

Column One, Paragraph Two

+ + + +

Column Two, Paragraph One

+ + + +

Column Three, Paragraph One

+ +
+ diff --git a/blocks/test/fixtures/core__cover-image.json b/blocks/test/fixtures/core__cover-image.json index 8fa8eccc0308b..f001fe32e4ef4 100644 --- a/blocks/test/fixtures/core__cover-image.json +++ b/blocks/test/fixtures/core__cover-image.json @@ -12,6 +12,7 @@ "hasParallax": false, "dimRatio": 40 }, + "innerBlocks": [], "originalContent": "
\n

Guten Berg!

\n
" } ] diff --git a/blocks/test/fixtures/core__embed.json b/blocks/test/fixtures/core__embed.json index 7f105a115092c..69b11621eaac1 100644 --- a/blocks/test/fixtures/core__embed.json +++ b/blocks/test/fixtures/core__embed.json @@ -9,6 +9,7 @@ "Embedded content from an example URL" ] }, + "innerBlocks": [], "originalContent": "
\n https://example.com/\n
Embedded content from an example URL
\n
" } ] diff --git a/blocks/test/fixtures/core__freeform.json b/blocks/test/fixtures/core__freeform.json index cdeb5e8293fa2..e71a19e0decae 100644 --- a/blocks/test/fixtures/core__freeform.json +++ b/blocks/test/fixtures/core__freeform.json @@ -6,6 +6,7 @@ "attributes": { "content": "

Testing freeform block with some\n

\n\tHTML content\n
" }, + "innerBlocks": [], "originalContent": "

Testing freeform block with some\n

\n\tHTML content\n
" } ] diff --git a/blocks/test/fixtures/core__freeform__undelimited.json b/blocks/test/fixtures/core__freeform__undelimited.json index cdeb5e8293fa2..e71a19e0decae 100644 --- a/blocks/test/fixtures/core__freeform__undelimited.json +++ b/blocks/test/fixtures/core__freeform__undelimited.json @@ -6,6 +6,7 @@ "attributes": { "content": "

Testing freeform block with some\n

\n\tHTML content\n
" }, + "innerBlocks": [], "originalContent": "

Testing freeform block with some\n

\n\tHTML content\n
" } ] diff --git a/blocks/test/fixtures/core__gallery.json b/blocks/test/fixtures/core__gallery.json index 1f1f20b45b79e..c8aebc9301e4b 100644 --- a/blocks/test/fixtures/core__gallery.json +++ b/blocks/test/fixtures/core__gallery.json @@ -20,6 +20,7 @@ "imageCrop": true, "linkTo": "none" }, + "innerBlocks": [], "originalContent": "" } ] diff --git a/blocks/test/fixtures/core__gallery__columns.json b/blocks/test/fixtures/core__gallery__columns.json index c6fbbd5ef0cbe..41614c3387ecb 100644 --- a/blocks/test/fixtures/core__gallery__columns.json +++ b/blocks/test/fixtures/core__gallery__columns.json @@ -21,6 +21,7 @@ "imageCrop": true, "linkTo": "none" }, + "innerBlocks": [], "originalContent": "" } ] diff --git a/blocks/test/fixtures/core__heading__h2-em.json b/blocks/test/fixtures/core__heading__h2-em.json index 39674931b4429..213be79cfa972 100644 --- a/blocks/test/fixtures/core__heading__h2-em.json +++ b/blocks/test/fixtures/core__heading__h2-em.json @@ -14,6 +14,7 @@ ], "nodeName": "H2" }, + "innerBlocks": [], "originalContent": "

The Inserter Tool

" } ] diff --git a/blocks/test/fixtures/core__heading__h2.json b/blocks/test/fixtures/core__heading__h2.json index ad97334855f74..f07a75b91f5c6 100644 --- a/blocks/test/fixtures/core__heading__h2.json +++ b/blocks/test/fixtures/core__heading__h2.json @@ -9,6 +9,7 @@ ], "nodeName": "H2" }, + "innerBlocks": [], "originalContent": "

A picture is worth a thousand words, or so the saying goes

" } ] diff --git a/blocks/test/fixtures/core__html.json b/blocks/test/fixtures/core__html.json index d96020b083d2e..1ce1fdf0b9180 100644 --- a/blocks/test/fixtures/core__html.json +++ b/blocks/test/fixtures/core__html.json @@ -6,6 +6,7 @@ "attributes": { "content": "

Some HTML code

\nThis text will scroll from right to left" }, + "innerBlocks": [], "originalContent": "

Some HTML code

\nThis text will scroll from right to left" } ] diff --git a/blocks/test/fixtures/core__image.json b/blocks/test/fixtures/core__image.json index 71d725f76d910..2ac6b609a3a8c 100644 --- a/blocks/test/fixtures/core__image.json +++ b/blocks/test/fixtures/core__image.json @@ -8,6 +8,7 @@ "alt": "", "caption": [] }, + "innerBlocks": [], "originalContent": "
\"\"
" } ] diff --git a/blocks/test/fixtures/core__image__center-caption.json b/blocks/test/fixtures/core__image__center-caption.json index 1b6164a90508a..22ccf40cd5655 100644 --- a/blocks/test/fixtures/core__image__center-caption.json +++ b/blocks/test/fixtures/core__image__center-caption.json @@ -11,6 +11,7 @@ ], "align": "center" }, + "innerBlocks": [], "originalContent": "
\"\"
Give it a try. Press the "really wide" button on the image toolbar.
" } ] diff --git a/blocks/test/fixtures/core__invalid-Capitals.json b/blocks/test/fixtures/core__invalid-Capitals.json index 8a320995a2224..a5711c158ceaf 100644 --- a/blocks/test/fixtures/core__invalid-Capitals.json +++ b/blocks/test/fixtures/core__invalid-Capitals.json @@ -6,6 +6,7 @@ "attributes": { "content": "

" }, + "innerBlocks": [], "originalContent": "

" } ] diff --git a/blocks/test/fixtures/core__invalid-special.json b/blocks/test/fixtures/core__invalid-special.json index 08728b7a4d8e7..f950a3e0272f7 100644 --- a/blocks/test/fixtures/core__invalid-special.json +++ b/blocks/test/fixtures/core__invalid-special.json @@ -6,6 +6,7 @@ "attributes": { "content": "

" }, + "innerBlocks": [], "originalContent": "

" } ] diff --git a/blocks/test/fixtures/core__latest-posts.json b/blocks/test/fixtures/core__latest-posts.json index 0ef356b6788d5..f3dd4953876cb 100644 --- a/blocks/test/fixtures/core__latest-posts.json +++ b/blocks/test/fixtures/core__latest-posts.json @@ -6,12 +6,12 @@ "attributes": { "postsToShow": 5, "displayPostDate": false, - "layout": "list", "columns": 3, "align": "center", "order": "desc", "orderBy": "date" }, + "innerBlocks": [], "originalContent": "" } ] diff --git a/blocks/test/fixtures/core__latest-posts__displayPostDate.json b/blocks/test/fixtures/core__latest-posts__displayPostDate.json index c0e38f52cf828..be9c487c2f0a8 100644 --- a/blocks/test/fixtures/core__latest-posts__displayPostDate.json +++ b/blocks/test/fixtures/core__latest-posts__displayPostDate.json @@ -6,12 +6,12 @@ "attributes": { "postsToShow": 5, "displayPostDate": true, - "layout": "list", "columns": 3, "align": "center", "order": "desc", "orderBy": "date" }, + "innerBlocks": [], "originalContent": "" } ] diff --git a/blocks/test/fixtures/core__list__ul.json b/blocks/test/fixtures/core__list__ul.json index 15bc029cf8de1..4f86b13d9acd4 100644 --- a/blocks/test/fixtures/core__list__ul.json +++ b/blocks/test/fixtures/core__list__ul.json @@ -39,6 +39,7 @@ } ] }, + "innerBlocks": [], "originalContent": "
  • Text & Headings
  • Images & Videos
  • Galleries
  • Embeds, like YouTube, Tweets, or other WordPress posts.
  • Layout blocks, like Buttons, Hero Images, Separators, etc.
  • And Lists like this one of course :)
" } ] diff --git a/blocks/test/fixtures/core__more.json b/blocks/test/fixtures/core__more.json index 14cf7688d9edd..4ba7ca6254470 100644 --- a/blocks/test/fixtures/core__more.json +++ b/blocks/test/fixtures/core__more.json @@ -6,6 +6,7 @@ "attributes": { "noTeaser": false }, + "innerBlocks": [], "originalContent": "" } ] diff --git a/blocks/test/fixtures/core__more__custom-text-teaser.json b/blocks/test/fixtures/core__more__custom-text-teaser.json index 1632474c51fff..c37da83a834bd 100644 --- a/blocks/test/fixtures/core__more__custom-text-teaser.json +++ b/blocks/test/fixtures/core__more__custom-text-teaser.json @@ -7,6 +7,7 @@ "customText": "Continue Reading", "noTeaser": true }, + "innerBlocks": [], "originalContent": "" } ] diff --git a/blocks/test/fixtures/core__paragraph__align-right.json b/blocks/test/fixtures/core__paragraph__align-right.json index ee501720801af..731657d06705f 100644 --- a/blocks/test/fixtures/core__paragraph__align-right.json +++ b/blocks/test/fixtures/core__paragraph__align-right.json @@ -10,6 +10,7 @@ "align": "right", "dropCap": false }, + "innerBlocks": [], "originalContent": "

... like this one, which is separate from the above and right aligned.

" } ] diff --git a/blocks/test/fixtures/core__preformatted.json b/blocks/test/fixtures/core__preformatted.json index 37ca66e8c95ad..d8e03462e4c68 100644 --- a/blocks/test/fixtures/core__preformatted.json +++ b/blocks/test/fixtures/core__preformatted.json @@ -17,6 +17,7 @@ "And more!" ] }, + "innerBlocks": [], "originalContent": "
Some preformatted text...
And more!
" } ] diff --git a/blocks/test/fixtures/core__pullquote.json b/blocks/test/fixtures/core__pullquote.json index 5ebe0b5564fb2..e044ae447f65f 100644 --- a/blocks/test/fixtures/core__pullquote.json +++ b/blocks/test/fixtures/core__pullquote.json @@ -23,6 +23,7 @@ ], "align": "none" }, + "innerBlocks": [], "originalContent": "
\n

Testing pullquote block...

...with a caption\n
" } ] diff --git a/blocks/test/fixtures/core__pullquote__multi-paragraph.json b/blocks/test/fixtures/core__pullquote__multi-paragraph.json index 2105a8afb0aec..c2a6c0d770de7 100644 --- a/blocks/test/fixtures/core__pullquote__multi-paragraph.json +++ b/blocks/test/fixtures/core__pullquote__multi-paragraph.json @@ -15,7 +15,7 @@ "Paragraph ", { "type": "strong", - "key": "_domReact67", + "key": "_domReact71", "ref": null, "props": { "children": "one" @@ -47,6 +47,7 @@ ], "align": "none" }, + "innerBlocks": [], "originalContent": "
\n

Paragraph one

\n

Paragraph two

\n by whomever\n
" } ] diff --git a/blocks/test/fixtures/core__quote__style-1.json b/blocks/test/fixtures/core__quote__style-1.json index bea152f89f32c..1a1f57668d6bb 100644 --- a/blocks/test/fixtures/core__quote__style-1.json +++ b/blocks/test/fixtures/core__quote__style-1.json @@ -23,6 +23,7 @@ ], "style": 1 }, + "innerBlocks": [], "originalContent": "

The editor will endeavour to create a new page and post building experience that makes writing rich posts effortless, and has “blocks” to make it easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery.

Matt Mullenweg, 2017
" } ] diff --git a/blocks/test/fixtures/core__quote__style-2.json b/blocks/test/fixtures/core__quote__style-2.json index ea48f03aef42f..2462f8aa0a8fc 100644 --- a/blocks/test/fixtures/core__quote__style-2.json +++ b/blocks/test/fixtures/core__quote__style-2.json @@ -23,6 +23,7 @@ ], "style": 2 }, + "innerBlocks": [], "originalContent": "

There is no greater agony than bearing an untold story inside you.

Maya Angelou
" } ] diff --git a/blocks/test/fixtures/core__separator.json b/blocks/test/fixtures/core__separator.json index 4311954603380..7060deba1c063 100644 --- a/blocks/test/fixtures/core__separator.json +++ b/blocks/test/fixtures/core__separator.json @@ -4,6 +4,7 @@ "name": "core/separator", "isValid": true, "attributes": {}, + "innerBlocks": [], "originalContent": "
" } ] diff --git a/blocks/test/fixtures/core__shortcode.json b/blocks/test/fixtures/core__shortcode.json index ded5113b30e69..37471b9d68f37 100644 --- a/blocks/test/fixtures/core__shortcode.json +++ b/blocks/test/fixtures/core__shortcode.json @@ -6,6 +6,7 @@ "attributes": { "text": "[gallery ids=\"238,338\"]" }, + "innerBlocks": [], "originalContent": "[gallery ids=\"238,338\"]" } ] diff --git a/blocks/test/fixtures/core__subhead.json b/blocks/test/fixtures/core__subhead.json index 20e5037001563..c2373ee2781c0 100644 --- a/blocks/test/fixtures/core__subhead.json +++ b/blocks/test/fixtures/core__subhead.json @@ -13,6 +13,7 @@ "." ] }, + "innerBlocks": [], "originalContent": "

This is a subhead.

" } ] diff --git a/blocks/test/fixtures/core__table.json b/blocks/test/fixtures/core__table.json index 2ecdee94dd749..532566a4908cd 100644 --- a/blocks/test/fixtures/core__table.json +++ b/blocks/test/fixtures/core__table.json @@ -196,6 +196,7 @@ } ] }, + "innerBlocks": [], "originalContent": "
VersionMusicianDate
.70No musician chosen.May 27, 2003
1.0Miles DavisJanuary 3, 2004
Lots of versions skipped, see the full list
4.4Clifford BrownDecember 8, 2015
4.5Coleman HawkinsApril 12, 2016
4.6Pepper AdamsAugust 16, 2016
4.7Sarah VaughanDecember 6, 2016
" } ] diff --git a/blocks/test/fixtures/core__text-columns.json b/blocks/test/fixtures/core__text-columns.json index f0caa26a89003..44d2a980a68bd 100644 --- a/blocks/test/fixtures/core__text-columns.json +++ b/blocks/test/fixtures/core__text-columns.json @@ -19,6 +19,7 @@ "columns": 2, "width": "center" }, + "innerBlocks": [], "originalContent": "
\n
\n

One

\n
\n
\n

Two

\n
\n
" } ] diff --git a/blocks/test/fixtures/core__text__converts-to-paragraph.json b/blocks/test/fixtures/core__text__converts-to-paragraph.json index 0efed063a93be..f9ff727423d80 100644 --- a/blocks/test/fixtures/core__text__converts-to-paragraph.json +++ b/blocks/test/fixtures/core__text__converts-to-paragraph.json @@ -14,6 +14,7 @@ ], "dropCap": false }, + "innerBlocks": [], "originalContent": "

This is an old-style text block. Changed to paragraph in #2135.

" } ] diff --git a/blocks/test/fixtures/core__verse.json b/blocks/test/fixtures/core__verse.json index 0baa254be043a..0f78462328135 100644 --- a/blocks/test/fixtures/core__verse.json +++ b/blocks/test/fixtures/core__verse.json @@ -17,6 +17,7 @@ "And more!" ] }, + "innerBlocks": [], "originalContent": "
A verse
And more!
" } ] diff --git a/blocks/test/fixtures/core__video.json b/blocks/test/fixtures/core__video.json index fd7b9da559965..6905f4d7b9ccb 100644 --- a/blocks/test/fixtures/core__video.json +++ b/blocks/test/fixtures/core__video.json @@ -7,6 +7,7 @@ "src": "https://awesome-fake.video/file.mp4", "caption": [] }, + "innerBlocks": [], "originalContent": "
" } ] diff --git a/blocks/test/full-content.js b/blocks/test/full-content.js index be355e6d242a3..a1fd277048fa7 100644 --- a/blocks/test/full-content.js +++ b/blocks/test/full-content.js @@ -77,13 +77,19 @@ function normalizeParsedBlocks( blocks ) { // Clone and remove React-instance-specific stuff; also, attribute // values that equal `undefined` will be removed block = JSON.parse( JSON.stringify( block ) ); + // Change unique UIDs to a predictable value block.uid = '_uid_' + index; + // Walk each attribute and get a more concise representation of any // React elements for ( const k in block.attributes ) { block.attributes[ k ] = normalizeReactTree( block.attributes[ k ] ); } + + // Recurse to normalize inner blocks + block.innerBlocks = normalizeParsedBlocks( block.innerBlocks ); + return block; } ); } diff --git a/edit-post/components/modes/visual-editor/index.js b/edit-post/components/modes/visual-editor/index.js index 6bf5cb77a42b3..13a828437a94b 100644 --- a/edit-post/components/modes/visual-editor/index.js +++ b/edit-post/components/modes/visual-editor/index.js @@ -10,7 +10,6 @@ import { BlockList, PostTitle, WritingFlow, - DefaultBlockAppender, EditorGlobalKeyboardShortcuts, BlockSelectionClearer, } from '@wordpress/editor'; @@ -38,7 +37,6 @@ function VisualEditor( props ) { ) } /> - ); diff --git a/editor/components/block-drop-zone/index.js b/editor/components/block-drop-zone/index.js index 29f76029940b1..1654048f20436 100644 --- a/editor/components/block-drop-zone/index.js +++ b/editor/components/block-drop-zone/index.js @@ -2,13 +2,13 @@ * External Dependencies */ import { connect } from 'react-redux'; -import { reduce, get, find } from 'lodash'; +import { reduce, get, find, castArray } from 'lodash'; /** * WordPress dependencies */ import { DropZone, withContext } from '@wordpress/components'; -import { getBlockTypes, rawHandler } from '@wordpress/blocks'; +import { getBlockTypes, rawHandler, cloneBlock } from '@wordpress/blocks'; import { compose } from '@wordpress/element'; /** @@ -21,7 +21,7 @@ function BlockDropZone( { index, isLocked, ...props } ) { return null; } - const getInsertPosition = ( position ) => { + const getInsertIndex = ( position ) => { if ( index !== undefined ) { return position.y === 'top' ? index : index + 1; } @@ -39,9 +39,9 @@ function BlockDropZone( { index, isLocked, ...props } ) { }, false ); if ( transformation ) { - const insertPosition = getInsertPosition( position ); + const insertIndex = getInsertIndex( position ); const blocks = transformation.transform( files, props.updateBlockAttributes ); - props.insertBlocks( blocks, insertPosition ); + props.insertBlocks( blocks, insertIndex ); } }; @@ -49,7 +49,7 @@ function BlockDropZone( { index, isLocked, ...props } ) { const blocks = rawHandler( { HTML, mode: 'BLOCKS' } ); if ( blocks.length ) { - props.insertBlocks( blocks, getInsertPosition( position ) ); + props.insertBlocks( blocks, getInsertIndex( position ) ); } }; @@ -64,7 +64,28 @@ function BlockDropZone( { index, isLocked, ...props } ) { export default compose( connect( undefined, - { insertBlocks, updateBlockAttributes } + ( dispatch, ownProps ) => { + return { + insertBlocks( blocks, insertIndex ) { + const { rootUID, layout } = ownProps; + + if ( layout ) { + // A block's transform function may return a single + // transformed block or an array of blocks, so ensure + // to first coerce to an array before mapping to inject + // the layout attribute. + blocks = castArray( blocks ).map( ( block ) => ( + cloneBlock( block, { layout } ) + ) ); + } + + dispatch( insertBlocks( blocks, insertIndex, rootUID ) ); + }, + updateBlockAttributes( ...args ) { + dispatch( updateBlockAttributes( ...args ) ); + }, + }; + } ), withContext( 'editor' )( ( settings ) => { const { templateLock } = settings; diff --git a/editor/components/block-list/block.js b/editor/components/block-list/block.js index 973ca34de52de..63ef3da3ffc9f 100644 --- a/editor/components/block-list/block.js +++ b/editor/components/block-list/block.js @@ -3,16 +3,17 @@ */ import { connect } from 'react-redux'; import classnames from 'classnames'; -import { get, partial, reduce, size } from 'lodash'; +import { get, partial, reduce, size, castArray, noop } from 'lodash'; /** * WordPress dependencies */ -import { Component, compose } from '@wordpress/element'; +import { Component, findDOMNode, compose } from '@wordpress/element'; import { keycodes } from '@wordpress/utils'; import { BlockEdit, createBlock, + cloneBlock, getBlockType, getSaveElement, isReusableBlock, @@ -35,6 +36,8 @@ import BlockContextualToolbar from './block-contextual-toolbar'; import BlockMultiControls from './multi-controls'; import BlockMobileToolbar from './block-mobile-toolbar'; import BlockInsertionPoint from './insertion-point'; +import IgnoreNestedEvents from './ignore-nested-events'; +import { createInnerBlockList } from './utils'; import { clearSelectedBlock, editPost, @@ -122,6 +125,22 @@ export class BlockListBlock extends Component { }; } + getChildContext() { + const { + uid, + renderBlockMenu, + showContextualToolbar, + } = this.props; + + return { + BlockList: createInnerBlockList( + uid, + renderBlockMenu, + showContextualToolbar + ), + }; + } + componentDidMount() { if ( this.props.focus ) { this.node.focus(); @@ -178,11 +197,23 @@ export class BlockListBlock extends Component { } setBlockListRef( node ) { + // Disable reason: The root return element uses a component to manage + // event nesting, but the parent block list layout needs the raw DOM + // node to track multi-selection. + // + // eslint-disable-next-line react/no-find-dom-node + node = findDOMNode( node ); + this.props.blockRef( node, this.props.uid ); } bindBlockNode( node ) { - this.node = node; + // Disable reason: The block element uses a component to manage event + // nesting, but we rely on a raw DOM node for focusing and preserving + // scroll offset on move. + // + // eslint-disable-next-line react/no-find-dom-node + this.node = findDOMNode( node ); } setAttributes( attributes ) { @@ -216,6 +247,16 @@ export class BlockListBlock extends Component { this.hadTouchStart = false; } + /** + * A mouseover event handler to apply hover effect when a pointer device is + * placed within the bounds of the block. The mouseover event is preferred + * over mouseenter because it may be the case that a previous mouseenter + * event was blocked from being handled by a IgnoreNestedEvents component, + * therefore transitioning out of a nested block to the bounds of the block + * would otherwise not trigger a hover effect. + * + * @see https://developer.mozilla.org/en-US/docs/Web/Events/mouseenter + */ maybeHover() { const { isHovered, isSelected, isMultiSelected, onHover } = this.props; @@ -397,7 +438,18 @@ export class BlockListBlock extends Component { } render() { - const { block, order, mode, showContextualToolbar, isLocked, renderBlockMenu } = this.props; + const { + block, + order, + mode, + showContextualToolbar, + isLocked, + isFirst, + isLast, + rootUID, + layout, + renderBlockMenu, + } = this.props; const { name: blockName, isValid } = block; const blockType = getBlockType( blockName ); // translators: %s: Type of block (i.e. Text, Image etc) @@ -425,27 +477,63 @@ export class BlockListBlock extends Component { wrapperProps = blockType.getEditWrapperProps( block.attributes ); } - // Disable reason: Each block can be selected by clicking on it - /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ + // Disable reasons: + // + // jsx-a11y/mouse-events-have-key-events: + // - onMouseOver is explicitly handling hover effects + // + // jsx-a11y/no-static-element-interactions: + // - Each block can be selected by clicking on it + + /* eslint-disable jsx-a11y/mouse-events-have-key-events, jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ return ( -
- - { ( showUI || isHovered ) && } - { ( showUI || isHovered ) && } - { ( showUI || isHovered ) && } + + { ( showUI || isHovered ) && ( + + ) } + { ( showUI || isHovered ) && ( + + ) } + { ( showUI || isHovered ) && ( + + ) } { showUI && isValid && showContextualToolbar && } - { isFirstMultiSelected && } -
} + { showUI && } -
+ { !! error && } - -
+ + ); /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ } } -const mapStateToProps = ( state, { uid } ) => ( { +const mapStateToProps = ( state, { uid, rootUID } ) => ( { previousBlock: getPreviousBlock( state, uid ), nextBlock: getNextBlock( state, uid ), block: getBlock( state, uid ), @@ -505,7 +597,7 @@ const mapStateToProps = ( state, { uid } ) => ( { isHovered: isBlockHovered( state, uid ) && ! isMultiSelecting( state ), focus: getBlockFocus( state, uid ), isTyping: isTyping( state ), - order: getBlockIndex( state, uid ), + order: getBlockIndex( state, uid, rootUID ), meta: getEditedPostAttribute( state, 'meta' ), mode: getBlockMode( state, uid ), isSelectionEnabled: isSelectionEnabled( state ), @@ -546,8 +638,12 @@ const mapDispatchToProps = ( dispatch, ownProps ) => ( { } ); }, - onInsertBlocks( blocks, position ) { - dispatch( insertBlocks( blocks, position ) ); + onInsertBlocks( blocks, index ) { + const { rootUID, layout } = ownProps; + + blocks = blocks.map( ( block ) => cloneBlock( block, { layout } ) ); + + dispatch( insertBlocks( blocks, index, rootUID ) ); }, onFocus( ...args ) { @@ -563,6 +659,12 @@ const mapDispatchToProps = ( dispatch, ownProps ) => ( { }, onReplace( blocks ) { + const { layout } = ownProps; + + blocks = castArray( blocks ).map( ( block ) => ( + cloneBlock( block, { layout } ) + ) ); + dispatch( replaceBlocks( [ ownProps.uid ], blocks ) ); }, @@ -577,6 +679,10 @@ const mapDispatchToProps = ( dispatch, ownProps ) => ( { BlockListBlock.className = 'editor-block-list__block-edit'; +BlockListBlock.childContextTypes = { + BlockList: noop, +}; + export default compose( connect( mapStateToProps, mapDispatchToProps ), withContext( 'editor' )( ( settings ) => { diff --git a/editor/components/block-list/ignore-nested-events.js b/editor/components/block-list/ignore-nested-events.js new file mode 100644 index 0000000000000..fe13f37df13b8 --- /dev/null +++ b/editor/components/block-list/ignore-nested-events.js @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import { reduce } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Component which renders a div with passed props applied except the optional + * `childHandledEvents` prop. Event prop handlers are replaced with a proxying + * event handler to capture and prevent events from being handled by ancestor + * `IgnoreNestedEvents` elements by testing the presence of a private property + * assigned on the event object. + * + * Optionally accepts an `childHandledEvents` prop array, which can be used in + * instances where an inner `IgnoreNestedEvents` element exists and the outer + * element should stop propagation but not invoke a callback handler, since it + * would be assumed these are invoked by the child element. + * + * @type {Component} + */ +class IgnoreNestedEvents extends Component { + constructor() { + super( ...arguments ); + + this.proxyEvent = this.proxyEvent.bind( this ); + + // The event map is responsible for tracking an event type to a React + // component prop name, since it is easy to determine event type from + // a React prop name, but not the other way around. + this.eventMap = {}; + } + + /** + * General event handler which only calls to its original props callback if + * it has not already been handled by a descendant IgnoreNestedEvents. + * + * @param {Event} event Event object. + * + * @return {void} + */ + proxyEvent( event ) { + // Skip if already handled (i.e. assume nested block) + if ( event.nativeEvent._blockHandled ) { + return; + } + + // Assign into the native event, since React will reuse their synthetic + // event objects and this property assignment could otherwise leak. + // + // See: https://reactjs.org/docs/events.html#event-pooling + event.nativeEvent._blockHandled = true; + + // Invoke original prop handler + const propKey = this.eventMap[ event.type ]; + if ( this.props[ propKey ] ) { + this.props[ propKey ]( event ); + } + } + + render() { + const { childHandledEvents = [], ...props } = this.props; + + const eventHandlers = reduce( [ + ...childHandledEvents, + ...Object.keys( props ), + ], ( result, key ) => { + // Try to match prop key as event handler + const match = key.match( /^on([A-Z][a-zA-Z]+)$/ ); + if ( match ) { + // Re-map the prop to the local proxy handler to check whether + // the event has already been handled. + result[ key ] = this.proxyEvent; + + // Assign event -> propName into an instance variable, so as to + // avoid re-renders which could be incurred either by setState + // or in mapping values to a newly created function. + this.eventMap[ match[ 1 ].toLowerCase() ] = key; + } + + return result; + }, {} ); + + return
; + } +} + +export default IgnoreNestedEvents; diff --git a/editor/components/block-list/index.js b/editor/components/block-list/index.js index 532d36a855dfe..bb2311d7b9211 100644 --- a/editor/components/block-list/index.js +++ b/editor/components/block-list/index.js @@ -3,294 +3,60 @@ */ import { connect } from 'react-redux'; import { - findLast, + filter, + get, map, - invert, - isEqual, - mapValues, - sortBy, - throttle, } from 'lodash'; -import scrollIntoView from 'dom-scroll-into-view'; -import 'element-closest'; - -/** - * WordPress dependencies - */ -import { Component } from '@wordpress/element'; -import { serialize } from '@wordpress/blocks'; - /** * Internal dependencies */ import './style.scss'; -import BlockListBlock from './block'; -import BlockSelectionClearer from '../block-selection-clearer'; -import { - getBlockUids, - getMultiSelectedBlocksStartUid, - getMultiSelectedBlocksEndUid, - getMultiSelectedBlocks, - getMultiSelectedBlockUids, - getSelectedBlock, - isSelectionEnabled, - isMultiSelecting, -} from '../../store/selectors'; -import { startMultiSelect, stopMultiSelect, multiSelect, selectBlock } from '../../store/actions'; -import { documentHasSelection } from '../../utils/dom'; - -class BlockList extends Component { - constructor( props ) { - super( props ); - - this.onSelectionStart = this.onSelectionStart.bind( this ); - this.onSelectionEnd = this.onSelectionEnd.bind( this ); - this.onShiftSelection = this.onShiftSelection.bind( this ); - this.onCopy = this.onCopy.bind( this ); - this.onCut = this.onCut.bind( this ); - this.setBlockRef = this.setBlockRef.bind( this ); - this.setLastClientY = this.setLastClientY.bind( this ); - this.onPointerMove = throttle( this.onPointerMove.bind( this ), 100 ); - // Browser does not fire `*move` event when the pointer position changes - // relative to the document, so fire it with the last known position. - this.onScroll = () => this.onPointerMove( { clientY: this.lastClientY } ); - - this.lastClientY = 0; - this.nodes = {}; - } - - componentDidMount() { - document.addEventListener( 'copy', this.onCopy ); - document.addEventListener( 'cut', this.onCut ); - window.addEventListener( 'mousemove', this.setLastClientY ); - } - - componentWillUnmount() { - document.removeEventListener( 'copy', this.onCopy ); - document.removeEventListener( 'cut', this.onCut ); - window.removeEventListener( 'mousemove', this.setLastClientY ); - } - - componentWillReceiveProps( nextProps ) { - if ( isEqual( this.props.multiSelectedBlockUids, nextProps.multiSelectedBlockUids ) ) { - return; - } - - if ( nextProps.multiSelectedBlockUids && nextProps.multiSelectedBlockUids.length > 0 ) { - const extent = this.nodes[ nextProps.selectionEnd ]; - if ( extent ) { - scrollIntoView( extent, extent.closest( '.edit-post-layout__content' ), { - onlyScrollIfNeeded: true, - } ); - } - } - } - - setLastClientY( { clientY } ) { - this.lastClientY = clientY; - } - - setBlockRef( node, uid ) { - if ( node === null ) { - delete this.nodes[ uid ]; - } else { - this.nodes = { - ...this.nodes, - [ uid ]: node, - }; - } - } - - /** - * Handles a pointer move event to update the extent of the current cursor - * multi-selection. - * - * @param {MouseEvent} event A mousemove event object. - * - * @return {void} - */ - onPointerMove( { clientY } ) { - // We don't start multi-selection until the mouse starts moving, so as - // to avoid dispatching multi-selection actions on an in-place click. - if ( ! this.props.isMultiSelecting ) { - this.props.onStartMultiSelect(); - } - - const boundaries = this.nodes[ this.selectionAtStart ].getBoundingClientRect(); - const y = clientY - boundaries.top; - const key = findLast( this.coordMapKeys, ( coordY ) => coordY < y ); - - this.onSelectionChange( this.coordMap[ key ] ); - } - - onCopy( event ) { - const { multiSelectedBlocks, selectedBlock } = this.props; - - if ( ! multiSelectedBlocks.length && ! selectedBlock ) { - return; - } - - // Let native copy behaviour take over in input fields. - if ( selectedBlock && documentHasSelection() ) { - return; - } - - const serialized = serialize( selectedBlock || multiSelectedBlocks ); - - event.clipboardData.setData( 'text/plain', serialized ); - event.clipboardData.setData( 'text/html', serialized ); - - event.preventDefault(); - } - - onCut( event ) { - const { multiSelectedBlockUids } = this.props; - - this.onCopy( event ); - - if ( multiSelectedBlockUids.length ) { - this.props.onRemove( multiSelectedBlockUids ); - } - } - - /** - * Binds event handlers to the document for tracking a pending multi-select - * in response to a mousedown event occurring in a rendered block. - * - * @param {string} uid UID of the block where mousedown occurred. - * - * @return {void} - */ - onSelectionStart( uid ) { - if ( ! this.props.isSelectionEnabled ) { - return; - } - - const boundaries = this.nodes[ uid ].getBoundingClientRect(); - - // Create a uid to Y coördinate map. - const uidToCoordMap = mapValues( this.nodes, ( node ) => - node.getBoundingClientRect().top - boundaries.top ); - - // Cache a Y coördinate to uid map for use in `onPointerMove`. - this.coordMap = invert( uidToCoordMap ); - // Cache an array of the Y coördinates for use in `onPointerMove`. - // Sort the coördinates, as `this.nodes` will not necessarily reflect - // the current block sequence. - this.coordMapKeys = sortBy( Object.values( uidToCoordMap ) ); - this.selectionAtStart = uid; - - window.addEventListener( 'mousemove', this.onPointerMove ); - // Capture scroll on all elements. - window.addEventListener( 'scroll', this.onScroll, true ); - window.addEventListener( 'mouseup', this.onSelectionEnd ); - } - - onSelectionChange( uid ) { - const { onMultiSelect, selectionStart, selectionEnd } = this.props; - const { selectionAtStart } = this; - const isAtStart = selectionAtStart === uid; - - if ( ! selectionAtStart || ! this.props.isSelectionEnabled ) { - return; - } - - if ( isAtStart && selectionStart ) { - onMultiSelect( null, null ); - } - - if ( ! isAtStart && selectionEnd !== uid ) { - onMultiSelect( selectionAtStart, uid ); - } - } - - /** - * Handles a mouseup event to end the current cursor multi-selection. - * - * @return {void} - */ - onSelectionEnd() { - // Cancel throttled calls. - this.onPointerMove.cancel(); - - delete this.coordMap; - delete this.coordMapKeys; - delete this.selectionAtStart; - - window.removeEventListener( 'mousemove', this.onPointerMove ); - window.removeEventListener( 'scroll', this.onScroll, true ); - window.removeEventListener( 'mouseup', this.onSelectionEnd ); - - // We may or may not be in a multi-selection when mouseup occurs (e.g. - // an in-place mouse click), so only trigger stop if multi-selecting. - if ( this.props.isMultiSelecting ) { - this.props.onStopMultiSelect(); - } - } - - onShiftSelection( uid ) { - if ( ! this.props.isSelectionEnabled ) { - return; - } - - const { selectedBlock, selectionStart, onMultiSelect, onSelect } = this.props; - - if ( selectedBlock ) { - onMultiSelect( selectedBlock.uid, uid ); - } else if ( selectionStart ) { - onMultiSelect( selectionStart, uid ); - } else { - onSelect( uid ); - } - } - - render() { - const { blocks, showContextualToolbar, renderBlockMenu } = this.props; +import BlockListLayout from './layout'; +import { getBlocks } from '../../store/selectors'; + +function BlockList( { + blocks, + renderBlockMenu, + layouts = {}, + rootUID, + showContextualToolbar, +} ) { + // BlockList can be provided with a layouts configuration, either grouped + // (blocks adjacent in markup) or ungrouped. This is inferred by the shape + // of the layouts configuration passed (grouped layout as array). + const isGroupedByLayout = Array.isArray( layouts ); + + // In case of ungrouped layout, we still emulate a layout merely for the + // purposes of normalizing layout rendering, even though there will only + // be a single layout, and no filtering applied. + if ( ! isGroupedByLayout ) { + layouts = [ { name: 'default' } ]; + } + + return map( layouts, ( layout ) => { + // When rendering grouped layouts, filter to blocks assigned to layout. + const layoutBlocks = isGroupedByLayout ? + filter( blocks, ( block ) => ( + get( block, [ 'attributes', 'layout' ] ) === layout.name + ) ) : + blocks; return ( - - { map( blocks, ( uid ) => ( - - ) ) } - + ); - } + } ); } export default connect( - ( state ) => ( { - blocks: getBlockUids( state ), - selectionStart: getMultiSelectedBlocksStartUid( state ), - selectionEnd: getMultiSelectedBlocksEndUid( state ), - multiSelectedBlocks: getMultiSelectedBlocks( state ), - multiSelectedBlockUids: getMultiSelectedBlockUids( state ), - selectedBlock: getSelectedBlock( state ), - isSelectionEnabled: isSelectionEnabled( state ), - isMultiSelecting: isMultiSelecting( state ), + ( state, ownProps ) => ( { + blocks: getBlocks( state, ownProps.rootUID ), } ), - ( dispatch ) => ( { - onStartMultiSelect() { - dispatch( startMultiSelect() ); - }, - onStopMultiSelect() { - dispatch( stopMultiSelect() ); - }, - onMultiSelect( start, end ) { - dispatch( multiSelect( start, end ) ); - }, - onSelect( uid ) { - dispatch( selectBlock( uid ) ); - }, - onRemove( uids ) { - dispatch( { type: 'REMOVE_BLOCKS', uids } ); - }, - } ) )( BlockList ); diff --git a/editor/components/block-list/insertion-point.js b/editor/components/block-list/insertion-point.js index f95c6335a310c..919df2c499d4a 100644 --- a/editor/components/block-list/insertion-point.js +++ b/editor/components/block-list/insertion-point.js @@ -7,7 +7,7 @@ import { connect } from 'react-redux'; * Internal dependencies */ import { - getBlockUids, + getBlockIndex, getBlockInsertionPoint, isBlockInsertionPointVisible, } from '../../store/selectors'; @@ -21,14 +21,14 @@ function BlockInsertionPoint( { showInsertionPoint } ) { } export default connect( - ( state, { uid } ) => { - const blockIndex = uid ? getBlockUids( state ).indexOf( uid ) : -1; + ( state, { uid, rootUID, layout } ) => { + const blockIndex = uid ? getBlockIndex( state, uid, rootUID ) : -1; const insertIndex = blockIndex > -1 ? blockIndex + 1 : 0; return { showInsertionPoint: ( - isBlockInsertionPointVisible( state ) && - getBlockInsertionPoint( state ) === insertIndex + isBlockInsertionPointVisible( state, rootUID, layout ) && + getBlockInsertionPoint( state, rootUID ) === insertIndex ), }; }, diff --git a/editor/components/block-list/layout.js b/editor/components/block-list/layout.js new file mode 100644 index 0000000000000..b0cedfe918e23 --- /dev/null +++ b/editor/components/block-list/layout.js @@ -0,0 +1,323 @@ +/** + * External dependencies + */ +import { connect } from 'react-redux'; +import { + findLast, + map, + invert, + isEqual, + mapValues, + sortBy, + throttle, + get, + last, +} from 'lodash'; +import scrollIntoView from 'dom-scroll-into-view'; +import 'element-closest'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { serialize, getDefaultBlockName } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import './style.scss'; +import BlockListBlock from './block'; +import BlockSelectionClearer from '../block-selection-clearer'; +import DefaultBlockAppender from '../default-block-appender'; +import { + getMultiSelectedBlocksStartUid, + getMultiSelectedBlocksEndUid, + getMultiSelectedBlocks, + getMultiSelectedBlockUids, + getSelectedBlock, + isSelectionEnabled, + isMultiSelecting, +} from '../../store/selectors'; +import { startMultiSelect, stopMultiSelect, multiSelect, selectBlock } from '../../store/actions'; +import { documentHasSelection } from '../../utils/dom'; + +class BlockListLayout extends Component { + constructor( props ) { + super( props ); + + this.onSelectionStart = this.onSelectionStart.bind( this ); + this.onSelectionEnd = this.onSelectionEnd.bind( this ); + this.onShiftSelection = this.onShiftSelection.bind( this ); + this.onCopy = this.onCopy.bind( this ); + this.onCut = this.onCut.bind( this ); + this.setBlockRef = this.setBlockRef.bind( this ); + this.setLastClientY = this.setLastClientY.bind( this ); + this.onPointerMove = throttle( this.onPointerMove.bind( this ), 100 ); + // Browser does not fire `*move` event when the pointer position changes + // relative to the document, so fire it with the last known position. + this.onScroll = () => this.onPointerMove( { clientY: this.lastClientY } ); + + this.lastClientY = 0; + this.nodes = {}; + } + + componentDidMount() { + document.addEventListener( 'copy', this.onCopy ); + document.addEventListener( 'cut', this.onCut ); + window.addEventListener( 'mousemove', this.setLastClientY ); + } + + componentWillUnmount() { + document.removeEventListener( 'copy', this.onCopy ); + document.removeEventListener( 'cut', this.onCut ); + window.removeEventListener( 'mousemove', this.setLastClientY ); + } + + componentWillReceiveProps( nextProps ) { + if ( isEqual( this.props.multiSelectedBlockUids, nextProps.multiSelectedBlockUids ) ) { + return; + } + + if ( nextProps.multiSelectedBlockUids && nextProps.multiSelectedBlockUids.length > 0 ) { + const extent = this.nodes[ nextProps.selectionEnd ]; + if ( extent ) { + scrollIntoView( extent, extent.closest( '.edit-post-layout__content' ), { + onlyScrollIfNeeded: true, + } ); + } + } + } + + setLastClientY( { clientY } ) { + this.lastClientY = clientY; + } + + setBlockRef( node, uid ) { + if ( node === null ) { + delete this.nodes[ uid ]; + } else { + this.nodes = { + ...this.nodes, + [ uid ]: node, + }; + } + } + + /** + * Handles a pointer move event to update the extent of the current cursor + * multi-selection. + * + * @param {MouseEvent} event A mousemove event object. + * + * @return {void} + */ + onPointerMove( { clientY } ) { + // We don't start multi-selection until the mouse starts moving, so as + // to avoid dispatching multi-selection actions on an in-place click. + if ( ! this.props.isMultiSelecting ) { + this.props.onStartMultiSelect(); + } + + const boundaries = this.nodes[ this.selectionAtStart ].getBoundingClientRect(); + const y = clientY - boundaries.top; + const key = findLast( this.coordMapKeys, ( coordY ) => coordY < y ); + + this.onSelectionChange( this.coordMap[ key ] ); + } + + onCopy( event ) { + const { multiSelectedBlocks, selectedBlock } = this.props; + + if ( ! multiSelectedBlocks.length && ! selectedBlock ) { + return; + } + + // Let native copy behaviour take over in input fields. + if ( selectedBlock && documentHasSelection() ) { + return; + } + + const serialized = serialize( selectedBlock || multiSelectedBlocks ); + + event.clipboardData.setData( 'text/plain', serialized ); + event.clipboardData.setData( 'text/html', serialized ); + + event.preventDefault(); + } + + onCut( event ) { + const { multiSelectedBlockUids } = this.props; + + this.onCopy( event ); + + if ( multiSelectedBlockUids.length ) { + this.props.onRemove( multiSelectedBlockUids ); + } + } + + /** + * Binds event handlers to the document for tracking a pending multi-select + * in response to a mousedown event occurring in a rendered block. + * + * @param {string} uid UID of the block where mousedown occurred. + * + * @return {void} + */ + onSelectionStart( uid ) { + if ( ! this.props.isSelectionEnabled ) { + return; + } + + const boundaries = this.nodes[ uid ].getBoundingClientRect(); + + // Create a uid to Y coördinate map. + const uidToCoordMap = mapValues( this.nodes, ( node ) => + node.getBoundingClientRect().top - boundaries.top ); + + // Cache a Y coördinate to uid map for use in `onPointerMove`. + this.coordMap = invert( uidToCoordMap ); + // Cache an array of the Y coördinates for use in `onPointerMove`. + // Sort the coördinates, as `this.nodes` will not necessarily reflect + // the current block sequence. + this.coordMapKeys = sortBy( Object.values( uidToCoordMap ) ); + this.selectionAtStart = uid; + + window.addEventListener( 'mousemove', this.onPointerMove ); + // Capture scroll on all elements. + window.addEventListener( 'scroll', this.onScroll, true ); + window.addEventListener( 'mouseup', this.onSelectionEnd ); + } + + onSelectionChange( uid ) { + const { onMultiSelect, selectionStart, selectionEnd } = this.props; + const { selectionAtStart } = this; + const isAtStart = selectionAtStart === uid; + + if ( ! selectionAtStart || ! this.props.isSelectionEnabled ) { + return; + } + + if ( isAtStart && selectionStart ) { + onMultiSelect( null, null ); + } + + if ( ! isAtStart && selectionEnd !== uid ) { + onMultiSelect( selectionAtStart, uid ); + } + } + + /** + * Handles a mouseup event to end the current cursor multi-selection. + * + * @return {void} + */ + onSelectionEnd() { + // Cancel throttled calls. + this.onPointerMove.cancel(); + + delete this.coordMap; + delete this.coordMapKeys; + delete this.selectionAtStart; + + window.removeEventListener( 'mousemove', this.onPointerMove ); + window.removeEventListener( 'scroll', this.onScroll, true ); + window.removeEventListener( 'mouseup', this.onSelectionEnd ); + + // We may or may not be in a multi-selection when mouseup occurs (e.g. + // an in-place mouse click), so only trigger stop if multi-selecting. + if ( this.props.isMultiSelecting ) { + this.props.onStopMultiSelect(); + } + } + + onShiftSelection( uid ) { + if ( ! this.props.isSelectionEnabled ) { + return; + } + + const { selectedBlock, selectionStart, onMultiSelect, onSelect } = this.props; + + if ( selectedBlock ) { + onMultiSelect( selectedBlock.uid, uid ); + } else if ( selectionStart ) { + onMultiSelect( selectionStart, uid ); + } else { + onSelect( uid ); + } + } + + render() { + const { + blocks, + showContextualToolbar, + layout, + isGroupedByLayout, + rootUID, + renderBlockMenu, + } = this.props; + + let defaultLayout; + if ( isGroupedByLayout ) { + defaultLayout = layout; + } + + const isLastBlockDefault = get( last( blocks ), 'name' ) === getDefaultBlockName(); + + return ( + + { map( blocks, ( block, blockIndex ) => ( + + ) ) } + { ( ! blocks.length || ! isLastBlockDefault ) && ( + + ) } + + ); + } +} + +export default connect( + ( state ) => ( { + selectionStart: getMultiSelectedBlocksStartUid( state ), + selectionEnd: getMultiSelectedBlocksEndUid( state ), + multiSelectedBlocks: getMultiSelectedBlocks( state ), + multiSelectedBlockUids: getMultiSelectedBlockUids( state ), + selectedBlock: getSelectedBlock( state ), + isSelectionEnabled: isSelectionEnabled( state ), + isMultiSelecting: isMultiSelecting( state ), + } ), + ( dispatch ) => ( { + onStartMultiSelect() { + dispatch( startMultiSelect() ); + }, + onStopMultiSelect() { + dispatch( stopMultiSelect() ); + }, + onMultiSelect( start, end ) { + dispatch( multiSelect( start, end ) ); + }, + onSelect( uid ) { + dispatch( selectBlock( uid ) ); + }, + onRemove( uids ) { + dispatch( { type: 'REMOVE_BLOCKS', uids } ); + }, + } ) +)( BlockListLayout ); diff --git a/editor/components/block-list/multi-controls.js b/editor/components/block-list/multi-controls.js index b6e4970f457c9..d1dcffa7b38a4 100644 --- a/editor/components/block-list/multi-controls.js +++ b/editor/components/block-list/multi-controls.js @@ -13,7 +13,7 @@ import { isMultiSelecting, } from '../../store/selectors'; -function BlockListMultiControls( { multiSelectedBlockUids, isSelecting } ) { +function BlockListMultiControls( { multiSelectedBlockUids, rootUID, isSelecting } ) { if ( isSelecting ) { return null; } @@ -21,6 +21,7 @@ function BlockListMultiControls( { multiSelectedBlockUids, isSelecting } ) { return [ , { + it( 'passes props to its rendered div', () => { + const wrapper = shallow( + + ); + + expect( wrapper.type() ).toBe( 'div' ); + expect( wrapper.prop( 'className' ) ).toBe( 'foo' ); + } ); + + it( 'stops propagation of events to ancestor IgnoreNestedEvents', () => { + const spyOuter = jest.fn(); + const spyInner = jest.fn(); + const wrapper = shallow( + + + + ); + + wrapper.childAt( 0 ).simulate( 'click' ); + + expect( spyInner ).toHaveBeenCalled(); + expect( spyOuter ).not.toHaveBeenCalled(); + } ); + + it( 'stops propagation of child handled events', () => { + const spyOuter = jest.fn(); + const spyInner = jest.fn(); + const wrapper = shallow( + + +
+ + + + ); + + const div = wrapper.childAt( 0 ).childAt( 0 ); + div.simulate( 'click' ); + + expect( spyInner ).not.toHaveBeenCalled(); + expect( spyOuter ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/editor/components/block-list/utils.js b/editor/components/block-list/utils.js new file mode 100644 index 0000000000000..67a1969753739 --- /dev/null +++ b/editor/components/block-list/utils.js @@ -0,0 +1,67 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import BlockList from './'; + +/** + * An object of cached BlockList components + * + * @type {Object} + */ +const INNER_BLOCK_LIST_CACHE = {}; + +/** + * Returns a BlockList component which is already pre-bound to render with a + * given UID as its rootUID prop. It is necessary to cache these components + * because otherwise the rendering of a nested BlockList will cause ancestor + * blocks to re-mount, leading to an endless cycle of remounting inner blocks. + * + * @param {string} uid Block UID to use as root UID of + * BlockList component. + * @param {Function} renderBlockMenu Render function for block menu of + * nested BlockList. + * @param {boolean} showContextualToolbar Whether contextual toolbar is to be + * used. + * + * @return {Component} Pre-bound BlockList component + */ +export function createInnerBlockList( uid, renderBlockMenu, showContextualToolbar ) { + if ( ! INNER_BLOCK_LIST_CACHE[ uid ] ) { + INNER_BLOCK_LIST_CACHE[ uid ] = [ + // The component class: + class extends Component { + componentWillMount() { + INNER_BLOCK_LIST_CACHE[ uid ][ 1 ]++; + } + + componentWillUnmount() { + // If, after decrementing the tracking count, there are no + // remaining instances of the component, remove from cache. + if ( ! INNER_BLOCK_LIST_CACHE[ uid ][ 1 ]-- ) { + delete INNER_BLOCK_LIST_CACHE[ uid ]; + } + } + + render() { + return ( + + ); + } + }, + + // A counter tracking active mounted instances: + 0, + ]; + } + + return INNER_BLOCK_LIST_CACHE[ uid ][ 0 ]; +} diff --git a/editor/components/block-mover/index.js b/editor/components/block-mover/index.js index 3110a34ef0e73..013414f02bd53 100644 --- a/editor/components/block-mover/index.js +++ b/editor/components/block-mover/index.js @@ -2,7 +2,7 @@ * External dependencies */ import { connect } from 'react-redux'; -import { first, last } from 'lodash'; +import { first } from 'lodash'; /** * WordPress dependencies @@ -17,7 +17,7 @@ import { compose } from '@wordpress/element'; */ import './style.scss'; import { getBlockMoverLabel } from './mover-label'; -import { isFirstBlock, isLastBlock, getBlockIndex, getBlock } from '../../store/selectors'; +import { getBlockIndex, getBlock } from '../../store/selectors'; import { selectBlock } from '../../store/actions'; export function BlockMover( { onMoveUp, onMoveDown, isFirst, isLast, uids, blockType, firstIndex, isLocked } ) { @@ -65,39 +65,42 @@ export function BlockMover( { onMoveUp, onMoveDown, isFirst, isLast, uids, block ); } +/** + * Action creator creator which, given the action type to dispatch and the + * arguments of mapDispatchToProps, creates a prop dispatcher callback for + * managing block movement. + * + * @param {string} type Action type to dispatch. + * @param {Function} dispatch Store dispatch. + * @param {Object} ownProps The wrapped component's own props. + * + * @return {Function} Prop dispatcher callback. + */ +function createOnMove( type, dispatch, ownProps ) { + return () => { + const { uids, rootUID } = ownProps; + if ( uids.length === 1 ) { + dispatch( selectBlock( first( uids ) ) ); + } + + dispatch( { type, uids, rootUID } ); + }; +} + export default compose( connect( ( state, ownProps ) => { - const block = getBlock( state, first( ownProps.uids ) ); + const { uids, rootUID } = ownProps; + const block = getBlock( state, first( uids ) ); return ( { - isFirst: isFirstBlock( state, first( ownProps.uids ) ), - isLast: isLastBlock( state, last( ownProps.uids ) ), - firstIndex: getBlockIndex( state, first( ownProps.uids ) ), + firstIndex: getBlockIndex( state, first( uids ), rootUID ), blockType: block ? getBlockType( block.name ) : null, } ); }, - ( dispatch, ownProps ) => ( { - onMoveDown() { - if ( ownProps.uids.length === 1 ) { - dispatch( selectBlock( first( ownProps.uids ) ) ); - } - - dispatch( { - type: 'MOVE_BLOCKS_DOWN', - uids: ownProps.uids, - } ); - }, - onMoveUp() { - if ( ownProps.uids.length === 1 ) { - dispatch( selectBlock( first( ownProps.uids ) ) ); - } - - dispatch( { - type: 'MOVE_BLOCKS_UP', - uids: ownProps.uids, - } ); - }, + ( ...args ) => ( { + onMoveDown: createOnMove( 'MOVE_BLOCKS_DOWN', ...args ), + onMoveUp: createOnMove( 'MOVE_BLOCKS_UP', ...args ), } ) ), withContext( 'editor' )( ( settings ) => { diff --git a/editor/components/default-block-appender/index.js b/editor/components/default-block-appender/index.js index a7cf5a6d2ffee..bd96ab0537510 100644 --- a/editor/components/default-block-appender/index.js +++ b/editor/components/default-block-appender/index.js @@ -2,14 +2,11 @@ * External dependencies */ import { connect } from 'react-redux'; -import { last } from 'lodash'; /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Component } from '@wordpress/element'; -import { getDefaultBlockName } from '@wordpress/blocks'; /** * Internal dependencies @@ -17,48 +14,36 @@ import { getDefaultBlockName } from '@wordpress/blocks'; import './style.scss'; import BlockDropZone from '../block-drop-zone'; import { appendDefaultBlock } from '../../store/actions'; -import { getBlockCount, getBlocks } from '../../store/selectors'; -export class DefaultBlockAppender extends Component { - render() { - const { count, blocks } = this.props; - const lastBlock = last( blocks ); - const showAppender = lastBlock && lastBlock.name !== getDefaultBlockName(); - - return ( -
- { ( count === 0 || showAppender ) && } - { count === 0 && - - } - { count !== 0 && showAppender && - - } -
- ); - } +export function DefaultBlockAppender( { onAppend, showPrompt = true } ) { + return ( +
+ + +
+ ); } export default connect( - ( state ) => ( { - count: getBlockCount( state ), - blocks: getBlocks( state ), + null, + ( dispatch, ownProps ) => ( { + onAppend() { + const { layout, rootUID } = ownProps; + + let attributes; + if ( layout ) { + attributes = { layout }; + } + + dispatch( appendDefaultBlock( attributes, rootUID ) ); + }, } ), - { appendDefaultBlock } )( DefaultBlockAppender ); diff --git a/editor/components/default-block-appender/test/__snapshots__/index.js.snap b/editor/components/default-block-appender/test/__snapshots__/index.js.snap index 4b68fc5e90559..a8cafd88e0773 100644 --- a/editor/components/default-block-appender/test/__snapshots__/index.js.snap +++ b/editor/components/default-block-appender/test/__snapshots__/index.js.snap @@ -1,12 +1,41 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`DefaultBlockAppender blocks present should match snapshot 1`] = ` +exports[`DefaultBlockAppender should append a default block when input focused 1`] = `
+> + + +
`; -exports[`DefaultBlockAppender no block present should match snapshot 1`] = ` +exports[`DefaultBlockAppender should match snapshot 1`] = `
@@ -22,3 +51,20 @@ exports[`DefaultBlockAppender no block present should match snapshot 1`] = ` />
`; + +exports[`DefaultBlockAppender should optionally show without prompt 1`] = ` +
+ + +
+`; diff --git a/editor/components/default-block-appender/test/index.js b/editor/components/default-block-appender/test/index.js index cc681b935ac22..02a512388c0bd 100644 --- a/editor/components/default-block-appender/test/index.js +++ b/editor/components/default-block-appender/test/index.js @@ -9,52 +9,47 @@ import { shallow } from 'enzyme'; import { DefaultBlockAppender } from '../'; describe( 'DefaultBlockAppender', () => { - const expectAppendDefaultBlockCalled = ( appendDefaultBlock ) => { - expect( appendDefaultBlock ).toHaveBeenCalledTimes( 1 ); - expect( appendDefaultBlock ).toHaveBeenCalledWith(); + const expectOnAppendCalled = ( onAppend ) => { + expect( onAppend ).toHaveBeenCalledTimes( 1 ); + expect( onAppend ).toHaveBeenCalledWith(); }; - describe( 'no block present', () => { - it( 'should match snapshot', () => { - const appendDefaultBlock = jest.fn(); - const wrapper = shallow( ); + it( 'should match snapshot', () => { + const onAppend = jest.fn(); + const wrapper = shallow( ); - expect( wrapper ).toMatchSnapshot(); - } ); + expect( wrapper ).toMatchSnapshot(); + } ); - it( 'should append a default block when input clicked', () => { - const appendDefaultBlock = jest.fn(); - const wrapper = shallow( ); + it( 'should append a default block when input clicked', () => { + const onAppend = jest.fn(); + const wrapper = shallow( ); + const input = wrapper.find( 'input.editor-default-block-appender__content' ); - wrapper.find( 'input.editor-default-block-appender__content' ).simulate( 'click' ); + expect( input.prop( 'value' ) ).toEqual( 'Write your story' ); + input.simulate( 'click' ); - expectAppendDefaultBlockCalled( appendDefaultBlock ); - } ); + expectOnAppendCalled( onAppend ); + } ); - it( 'should append a default block when input focused', () => { - const appendDefaultBlock = jest.fn(); - const wrapper = shallow( ); + it( 'should append a default block when input focused', () => { + const onAppend = jest.fn(); + const wrapper = shallow( ); - wrapper.find( 'input.editor-default-block-appender__content' ).simulate( 'focus' ); + wrapper.find( 'input.editor-default-block-appender__content' ).simulate( 'focus' ); - expectAppendDefaultBlockCalled( appendDefaultBlock ); - } ); - } ); + expect( wrapper ).toMatchSnapshot(); - describe( 'blocks present', () => { - it( 'should match snapshot', () => { - const wrapper = shallow( ); - - expect( wrapper ).toMatchSnapshot(); - } ); + expectOnAppendCalled( onAppend ); + } ); - it( 'should append a default block when button clicked', () => { - const insertBlock = jest.fn(); - const wrapper = shallow( ); + it( 'should optionally show without prompt', () => { + const onAppend = jest.fn(); + const wrapper = shallow( ); + const input = wrapper.find( 'input.editor-default-block-appender__content' ); - wrapper.find( 'input.editor-default-block-appender__content' ).simulate( 'click' ); + expect( input.prop( 'value' ) ).toEqual( '' ); - expectAppendDefaultBlockCalled( insertBlock ); - } ); + expect( wrapper ).toMatchSnapshot(); } ); } ); diff --git a/editor/components/editor-global-keyboard-shortcuts/index.js b/editor/components/editor-global-keyboard-shortcuts/index.js index 493136635cd01..cb591b7b1104f 100644 --- a/editor/components/editor-global-keyboard-shortcuts/index.js +++ b/editor/components/editor-global-keyboard-shortcuts/index.js @@ -13,7 +13,7 @@ import { KeyboardShortcuts, withContext } from '@wordpress/components'; /** * Internal dependencies */ -import { getBlockUids, getMultiSelectedBlockUids } from '../../store/selectors'; +import { getBlockOrder, getMultiSelectedBlockUids } from '../../store/selectors'; import { clearSelectedBlock, multiSelect, redo, undo, removeBlocks } from '../../store/actions'; class EditorGlobalKeyboardShortcuts extends Component { @@ -69,7 +69,7 @@ export default compose( connect( ( state ) => { return { - uids: getBlockUids( state ), + uids: getBlockOrder( state ), multiSelectedBlockUids: getMultiSelectedBlockUids( state ), }; }, diff --git a/editor/components/inserter/index.js b/editor/components/inserter/index.js index e7063ff88c811..e06a2ce5f0a17 100644 --- a/editor/components/inserter/index.js +++ b/editor/components/inserter/index.js @@ -81,6 +81,7 @@ class Inserter extends Component { renderContent={ ( { onClose } ) => { const onSelect = ( item ) => { onInsertBlock( item, insertionPoint ); + onClose(); }; @@ -93,16 +94,19 @@ class Inserter extends Component { export default compose( [ connect( - ( state ) => { + ( state, ownProps ) => { return { - insertionPoint: getBlockInsertionPoint( state ), + insertionPoint: getBlockInsertionPoint( state, ownProps.rootUID ), }; }, - ( dispatch ) => ( { - onInsertBlock( item, position ) { + ( dispatch, ownProps ) => ( { + onInsertBlock( item, index ) { + const { rootUID, layout } = ownProps; + const { name, initialAttributes } = item; dispatch( insertBlock( - createBlock( item.name, item.initialAttributes ), - position + createBlock( name, { ...initialAttributes, layout } ), + index, + rootUID, ) ); }, ...bindActionCreators( { diff --git a/editor/components/writing-flow/index.js b/editor/components/writing-flow/index.js index 157a2a21d02ab..f4f0ceeaee8a1 100644 --- a/editor/components/writing-flow/index.js +++ b/editor/components/writing-flow/index.js @@ -3,7 +3,7 @@ */ import { connect } from 'react-redux'; import 'element-closest'; -import { find, last, reverse, clamp } from 'lodash'; +import { find, last, reverse } from 'lodash'; /** * WordPress dependencies */ @@ -22,7 +22,8 @@ import { placeCaretAtVerticalEdge, } from '../../utils/dom'; import { - getBlockUids, + getPreviousBlock, + getNextBlock, getMultiSelectedBlocksStartUid, getMultiSelectedBlocksEndUid, getMultiSelectedBlocks, @@ -134,16 +135,22 @@ class WritingFlow extends Component { blockEl.contains( el ) && isElementNonEmpty( el ) ); } - expandSelection( blocks, currentStartUid, currentEndUid, delta ) { - const lastIndex = blocks.indexOf( currentEndUid ); - const nextIndex = clamp( lastIndex + delta, 0, blocks.length - 1 ); - this.props.onMultiSelect( currentStartUid, blocks[ nextIndex ] ); + expandSelection( currentStartUid, isReverse ) { + const { previousBlock, nextBlock } = this.props; + + const expandedBlock = isReverse ? previousBlock : nextBlock; + if ( expandedBlock ) { + this.props.onMultiSelect( currentStartUid, expandedBlock.uid ); + } } - moveSelection( blocks, currentUid, delta ) { - const currentIndex = blocks.indexOf( currentUid ); - const nextIndex = clamp( currentIndex + delta, 0, blocks.length - 1 ); - this.props.onFocusBlock( blocks[ nextIndex ] ); + moveSelection( currentUid, isReverse ) { + const { previousBlock, nextBlock } = this.props; + + const focusedBlock = isReverse ? previousBlock : nextBlock; + if ( focusedBlock ) { + this.props.onFocusBlock( focusedBlock.uid ); + } } isEditableEdge( moveUp, target ) { @@ -154,7 +161,7 @@ class WritingFlow extends Component { } onKeyDown( event ) { - const { selectedBlock, selectionStart, selectionEnd, blocks, hasMultiSelection } = this.props; + const { selectedBlock, selectionStart, selectionEnd, hasMultiSelection } = this.props; const { keyCode, target } = event; const isUp = keyCode === UP; @@ -178,15 +185,15 @@ class WritingFlow extends Component { if ( isNav && isShift && hasMultiSelection ) { // Shift key is down and existing block selection event.preventDefault(); - this.expandSelection( blocks, selectionStart, selectionEnd, isReverse ? -1 : +1 ); + this.expandSelection( selectionStart, isReverse ); } else if ( isNav && isShift && this.isEditableEdge( isReverse, target ) && isNavEdge( target, isReverse, true ) ) { // Shift key is down, but no existing block selection event.preventDefault(); - this.expandSelection( blocks, selectedBlock.uid, selectedBlock.uid, isReverse ? -1 : +1 ); + this.expandSelection( selectedBlock.uid, isReverse ); } else if ( isNav && hasMultiSelection ) { // Moving from multi block selection to single block selection event.preventDefault(); - this.moveSelection( blocks, selectionEnd, isReverse ? -1 : +1 ); + this.moveSelection( selectionEnd, isReverse ); } else if ( isVertical && isVerticalEdge( target, isReverse, isShift ) ) { const closestTabbable = this.getClosestTabbable( target, isReverse ); placeCaretAtVerticalEdge( closestTabbable, isReverse, this.verticalRect ); @@ -226,7 +233,8 @@ class WritingFlow extends Component { export default connect( ( state ) => ( { - blocks: getBlockUids( state ), + previousBlock: getPreviousBlock( state ), + nextBlock: getNextBlock( state ), selectionStart: getMultiSelectedBlocksStartUid( state ), selectionEnd: getMultiSelectedBlocksEndUid( state ), hasMultiSelection: getMultiSelectedBlocks( state ).length > 1, diff --git a/editor/store/actions.js b/editor/store/actions.js index 8240520008572..9727be15ac05a 100644 --- a/editor/store/actions.js +++ b/editor/store/actions.js @@ -187,15 +187,36 @@ export function replaceBlock( uid, block ) { return replaceBlocks( uid, block ); } -export function insertBlock( block, position ) { - return insertBlocks( [ block ], position ); +/** + * Returns an action object used in signalling that a single block should be + * inserted, optionally at a specific index respective a root block list. + * + * @param {Object} block Block object to insert. + * @param {?number} index Index at which block should be inserted. + * @param {?string} rootUID Optional root UID of block list to insert. + * + * @return {Object} Action object. + */ +export function insertBlock( block, index, rootUID ) { + return insertBlocks( [ block ], index, rootUID ); } -export function insertBlocks( blocks, position ) { +/** + * Returns an action object used in signalling that an array of blocks should + * be inserted, optionally at a specific index respective a root block list. + * + * @param {Object[]} blocks Block objects to insert. + * @param {?number} index Index at which block should be inserted. + * @param {?string} rootUID Optional root UID of block list to insert. + * + * @return {Object} Action object. + */ +export function insertBlocks( blocks, index, rootUID ) { return { type: 'INSERT_BLOCKS', blocks: castArray( blocks ), - position, + index, + rootUID, }; } @@ -541,9 +562,19 @@ export function convertBlockToReusable( uid ) { uid, }; } - -export function appendDefaultBlock() { +/** + * Returns an action object used in signalling that a new block of the default + * type should be appended to the block list. + * + * @param {?Object} attributes Optional attributes of the block to assign. + * @param {?string} rootUID Optional root UID of block list to append. + * + * @return {Object} Action object + */ +export function appendDefaultBlock( attributes, rootUID ) { return { type: 'APPEND_DEFAULT_BLOCK', + attributes, + rootUID, }; } diff --git a/editor/store/effects.js b/editor/store/effects.js index 646d317b5dee7..5a6c69c2af330 100644 --- a/editor/store/effects.js +++ b/editor/store/effects.js @@ -458,13 +458,19 @@ export default { const oldBlock = getBlock( getState(), action.uid ); const reusableBlock = createReusableBlock( oldBlock.name, oldBlock.attributes ); - const newBlock = createBlock( 'core/block', { ref: reusableBlock.id } ); + const newBlock = createBlock( 'core/block', { + ref: reusableBlock.id, + layout: oldBlock.attributes.layout, + } ); dispatch( updateReusableBlock( reusableBlock.id, reusableBlock ) ); dispatch( saveReusableBlock( reusableBlock.id ) ); dispatch( replaceBlocks( [ oldBlock.uid ], [ newBlock ] ) ); }, - APPEND_DEFAULT_BLOCK() { - return insertBlock( createBlock( getDefaultBlockName() ) ); + APPEND_DEFAULT_BLOCK( action ) { + const { attributes, rootUID } = action; + const block = createBlock( getDefaultBlockName(), attributes ); + + return insertBlock( block, undefined, rootUID ); }, CREATE_NOTICE( { notice: { content, spokenMessage } } ) { const message = spokenMessage || content; diff --git a/editor/store/reducer.js b/editor/store/reducer.js index 00c9679437309..861c5b1defe0b 100644 --- a/editor/store/reducer.js +++ b/editor/store/reducer.js @@ -7,7 +7,6 @@ import { flow, partialRight, reduce, - keyBy, first, last, omit, @@ -45,6 +44,56 @@ export function getPostRawValue( value ) { return value; } +/** + * Given an array of blocks, returns an object where each key is a nesting + * context, the value of which is an array of block UIDs existing within that + * nesting context. + * + * @param {Array} blocks Blocks to map. + * @param {?string} rootUID Assumed root UID. + * + * @return {Object} Block order map object. + */ +function mapBlockOrder( blocks, rootUID = '' ) { + const result = { [ rootUID ]: [] }; + + blocks.forEach( ( block ) => { + const { uid, innerBlocks } = block; + + result[ rootUID ].push( uid ); + + Object.assign( result, mapBlockOrder( innerBlocks, uid ) ); + } ); + + return result; +} + +/** + * Given an array of blocks, returns an object containing all blocks, recursing + * into inner blocks. Keys correspond to the block UID, the value of which is + * the block object. + * + * @param {Array} blocks Blocks to flatten. + * + * @return {Object} Flattened blocks object. + */ +function getFlattenedBlocks( blocks ) { + const flattenedBlocks = {}; + + const stack = [ ...blocks ]; + while ( stack.length ) { + // `innerBlocks` is redundant data which can fall out of sync, since + // this is reflected in `blockOrder`, so exclude from appended block. + const { innerBlocks, ...block } = stack.shift(); + + stack.push( ...innerBlocks ); + + flattenedBlocks[ block.uid ] = block; + } + + return flattenedBlocks; +} + /** * Undoable reducer returning the editor post state, including blocks parsed * from current HTML markup. @@ -53,7 +102,8 @@ export function getPostRawValue( value ) { * - edits: an object describing changes to be made to the current post, in * the format accepted by the WP REST API * - blocksByUid: post content blocks keyed by UID - * - blockOrder: list of block UIDs in order + * - blockOrder: object where each key is a UID, its value an array of uids + * representing the order of its inner blocks * * @param {Object} state Current state. * @param {Object} action Dispatched action. @@ -117,7 +167,7 @@ export const editor = flow( [ blocksByUid( state = {}, action ) { switch ( action.type ) { case 'RESET_BLOCKS': - return keyBy( action.blocks, 'uid' ); + return getFlattenedBlocks( action.blocks ); case 'UPDATE_BLOCK_ATTRIBUTES': // Ignore updates if block isn't known @@ -171,19 +221,18 @@ export const editor = flow( [ case 'INSERT_BLOCKS': return { ...state, - ...keyBy( action.blocks, 'uid' ), + ...getFlattenedBlocks( action.blocks ), }; case 'REPLACE_BLOCKS': if ( ! action.blocks ) { return state; } - return action.blocks.reduce( ( memo, block ) => { - return { - ...memo, - [ block.uid ]: block, - }; - }, omit( state, action.uids ) ); + + return { + ...omit( state, action.uids ), + ...getFlattenedBlocks( action.blocks ), + }; case 'REMOVE_BLOCKS': return omit( state, action.uids ); @@ -218,80 +267,127 @@ export const editor = flow( [ return state; }, - blockOrder( state = [], action ) { + blockOrder( state = {}, action ) { switch ( action.type ) { case 'RESET_BLOCKS': - return action.blocks.map( ( { uid } ) => uid ); + return mapBlockOrder( action.blocks ); case 'INSERT_BLOCKS': { - const position = action.position !== undefined ? action.position : state.length; - return [ - ...state.slice( 0, position ), - ...action.blocks.map( block => block.uid ), - ...state.slice( position ), - ]; + const { rootUID = '', blocks } = action; + + const subState = state[ rootUID ] || []; + const mappedBlocks = mapBlockOrder( blocks, rootUID ); + + const { index = subState.length } = action; + + return { + ...state, + ...mappedBlocks, + [ rootUID ]: [ + ...subState.slice( 0, index ), + ...mappedBlocks[ rootUID ], + ...subState.slice( index ), + ], + }; } case 'MOVE_BLOCKS_UP': { - const firstUid = first( action.uids ); - const lastUid = last( action.uids ); + const { uids, rootUID = '' } = action; + const firstUid = first( uids ); + const lastUid = last( uids ); + const subState = state[ rootUID ]; - if ( ! state.length || firstUid === first( state ) ) { + if ( ! subState.length || firstUid === first( subState ) ) { return state; } - const firstIndex = state.indexOf( firstUid ); - const lastIndex = state.indexOf( lastUid ); - const swappedUid = state[ firstIndex - 1 ]; + const firstIndex = subState.indexOf( firstUid ); + const lastIndex = subState.indexOf( lastUid ); + const swappedUid = subState[ firstIndex - 1 ]; - return [ - ...state.slice( 0, firstIndex - 1 ), - ...action.uids, - swappedUid, - ...state.slice( lastIndex + 1 ), - ]; + return { + ...state, + [ rootUID ]: [ + ...subState.slice( 0, firstIndex - 1 ), + ...uids, + swappedUid, + ...subState.slice( lastIndex + 1 ), + ], + }; } case 'MOVE_BLOCKS_DOWN': { - const firstUid = first( action.uids ); - const lastUid = last( action.uids ); + const { uids, rootUID = '' } = action; + const firstUid = first( uids ); + const lastUid = last( uids ); + const subState = state[ rootUID ]; - if ( ! state.length || lastUid === last( state ) ) { + if ( ! subState.length || lastUid === last( subState ) ) { return state; } - const firstIndex = state.indexOf( firstUid ); - const lastIndex = state.indexOf( lastUid ); - const swappedUid = state[ lastIndex + 1 ]; + const firstIndex = subState.indexOf( firstUid ); + const lastIndex = subState.indexOf( lastUid ); + const swappedUid = subState[ lastIndex + 1 ]; - return [ - ...state.slice( 0, firstIndex ), - swappedUid, - ...action.uids, - ...state.slice( lastIndex + 2 ), - ]; + return { + ...state, + [ rootUID ]: [ + ...subState.slice( 0, firstIndex ), + swappedUid, + ...uids, + ...subState.slice( lastIndex + 2 ), + ], + }; } - case 'REPLACE_BLOCKS': - if ( ! action.blocks ) { + case 'REPLACE_BLOCKS': { + const { blocks, uids } = action; + if ( ! blocks ) { return state; } - return state.reduce( ( memo, uid ) => { - if ( uid === action.uids[ 0 ] ) { - return memo.concat( action.blocks.map( ( block ) => block.uid ) ); - } - if ( action.uids.indexOf( uid ) === -1 ) { - memo.push( uid ); - } - return memo; - }, [] ); + const mappedBlocks = mapBlockOrder( blocks ); + + return flow( [ + ( nextState ) => omit( nextState, uids ), + ( nextState ) => mapValues( nextState, ( subState ) => ( + reduce( subState, ( result, uid ) => { + if ( uid === uids[ 0 ] ) { + return [ + ...result, + ...mappedBlocks[ '' ], + ]; + } + + if ( uids.indexOf( uid ) === -1 ) { + result.push( uid ); + } + + return result; + }, [] ) + ) ), + ] )( { + ...state, + ...omit( mappedBlocks, '' ), + } ); + } case 'REMOVE_BLOCKS': - return without( state, ...action.uids ); - - case 'REMOVE_REUSABLE_BLOCK': - return without( state, ...action.associatedBlockUids ); + case 'REMOVE_REUSABLE_BLOCK': { + const { type, uids, associatedBlockUids } = action; + const uidsToRemove = type === 'REMOVE_BLOCKS' ? uids : associatedBlockUids; + + return flow( [ + // Remove inner block ordering for removed blocks + ( nextState ) => omit( nextState, uidsToRemove ), + + // Remove deleted blocks from other blocks' orderings + ( nextState ) => mapValues( nextState, ( subState ) => ( + without( subState, ...uidsToRemove ) + ) ), + ] )( state ); + } } return state; diff --git a/editor/store/selectors.js b/editor/store/selectors.js index ffa12d7954917..80bd6ccb88ee8 100644 --- a/editor/store/selectors.js +++ b/editor/store/selectors.js @@ -3,6 +3,7 @@ */ import moment from 'moment'; import { + map, first, get, has, @@ -12,6 +13,7 @@ import { find, some, unionWith, + includes, } from 'lodash'; import createSelector from 'rememo'; @@ -28,6 +30,15 @@ import { addQueryArgs } from '@wordpress/url'; const MAX_RECENT_BLOCKS = 8; export const POST_UPDATE_TRANSACTION_ID = 'post-update'; +/** + * Shared reference to an empty array used as the default block order return + * value when the state value is not explicitly assigned, since we want to + * avoid returning a new array reference on every invocation. + * + * @type {Array} + */ +const DEFAULT_BLOCK_ORDER = []; + /** * Returns the state of legacy meta boxes. * @@ -387,7 +398,7 @@ export function getEditedPostPreviewLink( state ) { * @param {Object} state Global application state. * @param {string} uid Block unique ID. * - * @returns {Object} Parsed block object. + * @return {Object} Parsed block object. */ export const getBlock = createSelector( ( state, uid ) => { @@ -439,13 +450,17 @@ function getPostMeta( state, key ) { * the order they appear in the post. * Note: It's important to memoize this selector to avoid return a new instance on each call * - * @param {Object} state Global application state. + * @param {Object} state Global application state. + * @param {?String} rootUID Optional root UID of block list. * - * @returns {Object[]} Post blocks. + * @return {Object[]} Post blocks. */ export const getBlocks = createSelector( - ( state ) => { - return state.editor.present.blockOrder.map( ( uid ) => getBlock( state, uid ) ); + ( state, rootUID ) => { + return map( getBlockOrder( state, rootUID ), ( uid ) => ( { + ...getBlock( state, uid ), + innerBlocks: getBlocks( state, uid ), + } ) ); }, ( state ) => [ state.editor.present.blockOrder, @@ -456,12 +471,13 @@ export const getBlocks = createSelector( /** * Returns the number of blocks currently present in the post. * - * @param {Object} state Global application state. + * @param {Object} state Global application state. + * @param {?string} rootUID Optional root UID of block list. * * @return {number} Number of blocks in the post. */ -export function getBlockCount( state ) { - return getBlockUids( state ).length; +export function getBlockCount( state, rootUID ) { + return getBlockOrder( state, rootUID ).length; } /** @@ -497,22 +513,134 @@ export function getSelectedBlock( state ) { return getBlock( state, start ); } +/** + * Given a block UID, returns the root block from which the block is nested, an + * empty string for top-level blocks, or null if the block does not exist. + * + * @param {Object} state Global application state. + * @param {string} uid Block from which to find root UID. + * + * @return {?string} Root UID, if exists + */ +export function getBlockRootUID( state, uid ) { + const { blockOrder } = state.editor.present; + + for ( const rootUID in blockOrder ) { + if ( includes( blockOrder[ rootUID ], uid ) ) { + return rootUID; + } + } + + return null; +} + +/** + * Returns the block adjacent one at the given reference startUID and modifier + * directionality. Defaults start UID to the selected block, and direction as + * next block. Returns null if there is no adjacent block. + * + * @param {Object} state Global application state. + * @param {?string} startUID Optional UID of block from which to search. + * @param {?number} modifier Directionality multiplier (1 next, -1 previous). + * + * @return {?Object} Adjacent block object, or null if none exists. + */ +export function getAdjacentBlock( state, startUID, modifier = 1 ) { + // Default to selected block. + if ( startUID === undefined ) { + startUID = get( getSelectedBlock( state ), 'uid' ); + } + + // Try multi-selection starting at extent based on modifier. + if ( startUID === undefined ) { + if ( modifier < 0 ) { + startUID = getFirstMultiSelectedBlockUid( state ); + } else { + startUID = getLastMultiSelectedBlockUid( state ); + } + } + + // Validate working start UID. + if ( ! startUID ) { + return null; + } + + // Retrieve start block root UID, being careful to allow the falsey empty + // string top-level root UID by explicitly testing against null. + const rootUID = getBlockRootUID( state, startUID ); + if ( rootUID === null ) { + return null; + } + + const { blockOrder } = state.editor.present; + const orderSet = blockOrder[ rootUID ]; + const index = orderSet.indexOf( startUID ); + const nextIndex = ( index + ( 1 * modifier ) ); + + // Block was first in set and we're attempting to get previous. + if ( nextIndex < 0 ) { + return null; + } + + // Block was last in set and we're attempting to get next. + if ( nextIndex === orderSet.length ) { + return null; + } + + // Assume incremented index is within the set. + return getBlock( state, orderSet[ nextIndex ] ); +} + +/** + * Returns the previous block from the given reference startUID. Defaults start + * UID to the selected block. Returns null if there is no previous block. + * + * @param {Object} state Global application state. + * @param {?string} startUID Optional UID of block from which to search. + * + * @return {?Object} Adjacent block object, or null if none exists. + */ +export function getPreviousBlock( state, startUID ) { + return getAdjacentBlock( state, startUID, -1 ); +} + +/** + * Returns the next block from the given reference startUID. Defaults start UID + * to the selected block. Returns null if there is no next block. + * + * @param {Object} state Global application state. + * @param {?string} startUID Optional UID of block from which to search. + * + * @return {?Object} Adjacent block object, or null if none exists. + */ +export function getNextBlock( state, startUID ) { + return getAdjacentBlock( state, startUID, 1 ); +} + /** * Returns the current multi-selection set of blocks unique IDs, or an empty * array if there is no multi-selection. * * @param {Object} state Global application state. * - * @returns {Array} Multi-selected block unique IDs. + * @return {Array} Multi-selected block unique IDs. */ export const getMultiSelectedBlockUids = createSelector( ( state ) => { - const { blockOrder } = state.editor.present; const { start, end } = state.blockSelection; if ( start === end ) { return []; } + // Retrieve root UID to aid in retrieving relevant nested block order, + // being careful to allow the falsey empty string top-level root UID by + // explicitly testing against null. + const rootUID = getBlockRootUID( state, start ); + if ( rootUID === null ) { + return []; + } + + const blockOrder = getBlockOrder( state, rootUID ); const startIndex = blockOrder.indexOf( start ); const endIndex = blockOrder.indexOf( end ); @@ -535,7 +663,7 @@ export const getMultiSelectedBlockUids = createSelector( * * @param {Object} state Global application state. * - * @returns {Array} Multi-selected block objects. + * @return {Array} Multi-selected block objects. */ export const getMultiSelectedBlocks = createSelector( ( state ) => getMultiSelectedBlockUids( state ).map( ( uid ) => getBlock( state, uid ) ), @@ -640,81 +768,31 @@ export function getMultiSelectedBlocksEndUid( state ) { /** * Returns an array containing all block unique IDs of the post being edited, - * in the order they appear in the post. + * in the order they appear in the post. Optionally accepts a root UID of the + * block list for which the order should be returned, defaulting to the top- + * level block order. * - * @param {Object} state Global application state. + * @param {Object} state Global application state. + * @param {?string} rootUID Optional root UID of block list. * * @return {Array} Ordered unique IDs of post blocks. */ -export function getBlockUids( state ) { - return state.editor.present.blockOrder; +export function getBlockOrder( state, rootUID ) { + return state.editor.present.blockOrder[ rootUID || '' ] || DEFAULT_BLOCK_ORDER; } /** * Returns the index at which the block corresponding to the specified unique ID * occurs within the post block order, or `-1` if the block does not exist. * - * @param {Object} state Global application state. - * @param {string} uid Block unique ID. + * @param {Object} state Global application state. + * @param {string} uid Block unique ID. + * @param {?string} rootUID Optional root UID of block list. * * @return {number} Index at which block exists in order. */ -export function getBlockIndex( state, uid ) { - return state.editor.present.blockOrder.indexOf( uid ); -} - -/** - * Returns true if the block corresponding to the specified unique ID is the - * first block of the post, or false otherwise. - * - * @param {Object} state Global application state. - * @param {string} uid Block unique ID. - * - * @return {boolean} Whether block is first in post. - */ -export function isFirstBlock( state, uid ) { - return first( state.editor.present.blockOrder ) === uid; -} - -/** - * Returns true if the block corresponding to the specified unique ID is the - * last block of the post, or false otherwise. - * - * @param {Object} state Global application state. - * @param {string} uid Block unique ID. - * - * @return {boolean} Whether block is last in post. - */ -export function isLastBlock( state, uid ) { - return last( state.editor.present.blockOrder ) === uid; -} - -/** - * Returns the block object occurring before the one corresponding to the - * specified unique ID. - * - * @param {Object} state Global application state. - * @param {string} uid Block unique ID. - * - * @return {Object} Block occurring before specified unique ID. - */ -export function getPreviousBlock( state, uid ) { - const order = getBlockIndex( state, uid ); - return state.editor.present.blocksByUid[ state.editor.present.blockOrder[ order - 1 ] ] || null; -} - -/** - * Returns the block object occurring after the one corresponding to the - * specified unique ID. - * - * @param {Object} state Global application state. - * @param {string} uid Block unique ID. - * - * @return {Object} Block occurring after specified unique ID. - */ -export function getNextBlock( state, uid ) { - const order = getBlockIndex( state, uid ); - return state.editor.present.blocksByUid[ state.editor.present.blockOrder[ order + 1 ] ] || null; +export function getBlockIndex( state, uid, rootUID ) { + return getBlockOrder( state, rootUID ).indexOf( uid ); } /** @@ -837,24 +915,25 @@ export function isTyping( state ) { /** * Returns the insertion point, the index at which the new inserted block would - * be placed. Defaults to the last position. + * be placed. Defaults to the last index. * - * @param {Object} state Global application state. + * @param {Object} state Global application state. + * @param {?string} rootUID Optional root UID of block list. * * @return {?string} Unique ID after which insertion will occur. */ -export function getBlockInsertionPoint( state ) { +export function getBlockInsertionPoint( state, rootUID ) { const lastMultiSelectedBlock = getLastMultiSelectedBlockUid( state ); if ( lastMultiSelectedBlock ) { - return getBlockIndex( state, lastMultiSelectedBlock ) + 1; + return getBlockIndex( state, lastMultiSelectedBlock, rootUID ) + 1; } const selectedBlock = getSelectedBlock( state ); if ( selectedBlock ) { - return getBlockIndex( state, selectedBlock.uid ) + 1; + return getBlockIndex( state, selectedBlock.uid, rootUID ) + 1; } - return state.editor.present.blockOrder.length; + return getBlockOrder( state, rootUID ).length; } /** @@ -913,20 +992,20 @@ export function didPostSaveRequestFail( state ) { * @return {?string} Suggested post format. */ export function getSuggestedPostFormat( state ) { - const blocks = state.editor.present.blockOrder; + const blocks = getBlockOrder( state ); let name; // If there is only one block in the content of the post grab its name // so we can derive a suitable post format from it. if ( blocks.length === 1 ) { - name = state.editor.present.blocksByUid[ blocks[ 0 ] ].name; + name = getBlock( state, blocks[ 0 ] ).name; } // If there are two blocks in the content and the last one is a text blocks // grab the name of the first one to also suggest a post format from it. if ( blocks.length === 2 ) { - if ( state.editor.present.blocksByUid[ blocks[ 1 ] ].name === 'core/paragraph' ) { - name = state.editor.present.blocksByUid[ blocks[ 0 ] ].name; + if ( getBlock( state, blocks[ 1 ] ).name === 'core/paragraph' ) { + name = getBlock( state, blocks[ 0 ] ).name; } } @@ -958,7 +1037,7 @@ export function getSuggestedPostFormat( state ) { * * @param {Object} state Global application state. * - * @returns {string} Post content. + * @return {string} Post content. */ export const getEditedPostContent = createSelector( ( state ) => { diff --git a/editor/store/test/actions.js b/editor/store/test/actions.js index 83d0a1a504d5c..404f65b7261aa 100644 --- a/editor/store/test/actions.js +++ b/editor/store/test/actions.js @@ -215,11 +215,11 @@ describe( 'actions', () => { const block = { uid: 'ribs', }; - const position = 5; - expect( insertBlock( block, position ) ).toEqual( { + const index = 5; + expect( insertBlock( block, index ) ).toEqual( { type: 'INSERT_BLOCKS', blocks: [ block ], - position, + index, } ); } ); } ); @@ -229,11 +229,11 @@ describe( 'actions', () => { const blocks = [ { uid: 'ribs', } ]; - const position = 3; - expect( insertBlocks( blocks, position ) ).toEqual( { + const index = 3; + expect( insertBlocks( blocks, index ) ).toEqual( { type: 'INSERT_BLOCKS', blocks, - position, + index, } ); } ); } ); diff --git a/editor/store/test/reducer.js b/editor/store/test/reducer.js index fd53c7a4a5795..a0d75132f1d27 100644 --- a/editor/store/test/reducer.js +++ b/editor/store/test/reducer.js @@ -11,6 +11,7 @@ import { registerCoreBlocks, registerBlockType, unregisterBlockType, + createBlock, } from '@wordpress/blocks'; /** @@ -75,20 +76,41 @@ describe( 'state', () => { expect( state.future ).toEqual( [] ); expect( state.present.edits ).toEqual( {} ); expect( state.present.blocksByUid ).toEqual( {} ); - expect( state.present.blockOrder ).toEqual( [] ); + expect( state.present.blockOrder ).toEqual( {} ); expect( state.isDirty ).toBe( false ); } ); - it( 'should key by replaced blocks uid', () => { + it( 'should key by reset blocks uid', () => { const original = editor( undefined, {} ); const state = editor( original, { type: 'RESET_BLOCKS', - blocks: [ { uid: 'bananas' } ], + blocks: [ { uid: 'bananas', innerBlocks: [] } ], } ); expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 1 ); expect( values( state.present.blocksByUid )[ 0 ].uid ).toBe( 'bananas' ); - expect( state.present.blockOrder ).toEqual( [ 'bananas' ] ); + expect( state.present.blockOrder ).toEqual( { + '': [ 'bananas' ], + bananas: [], + } ); + } ); + + it( 'should key by reset blocks uid, including inner blocks', () => { + const original = editor( undefined, {} ); + const state = editor( original, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'bananas', + innerBlocks: [ { uid: 'apples', innerBlocks: [] } ], + } ], + } ); + + expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 2 ); + expect( state.present.blockOrder ).toEqual( { + '': [ 'bananas' ], + apples: [], + bananas: [ 'apples' ], + } ); } ); it( 'should insert block', () => { @@ -98,6 +120,7 @@ describe( 'state', () => { uid: 'chicken', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { @@ -105,12 +128,17 @@ describe( 'state', () => { blocks: [ { uid: 'ribs', name: 'core/freeform', + innerBlocks: [], } ], } ); expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 2 ); expect( values( state.present.blocksByUid )[ 1 ].uid ).toBe( 'ribs' ); - expect( state.present.blockOrder ).toEqual( [ 'chicken', 'ribs' ] ); + expect( state.present.blockOrder ).toEqual( { + '': [ 'chicken', 'ribs' ], + chicken: [], + ribs: [], + } ); } ); it( 'should replace the block', () => { @@ -120,6 +148,7 @@ describe( 'state', () => { uid: 'chicken', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { @@ -128,13 +157,39 @@ describe( 'state', () => { blocks: [ { uid: 'wings', name: 'core/freeform', + innerBlocks: [], } ], } ); expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 1 ); expect( values( state.present.blocksByUid )[ 0 ].name ).toBe( 'core/freeform' ); expect( values( state.present.blocksByUid )[ 0 ].uid ).toBe( 'wings' ); - expect( state.present.blockOrder ).toEqual( [ 'wings' ] ); + expect( state.present.blockOrder ).toEqual( { + '': [ 'wings' ], + wings: [], + } ); + } ); + + it( 'should replace the nested block', () => { + const nestedBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ nestedBlock ] ); + const replacementBlock = createBlock( 'core/test-block' ); + const original = editor( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + + const state = editor( original, { + type: 'REPLACE_BLOCKS', + uids: [ nestedBlock.uid ], + blocks: [ replacementBlock ], + } ); + + expect( state.present.blockOrder ).toEqual( { + '': [ wrapperBlock.uid ], + [ wrapperBlock.uid ]: [ replacementBlock.uid ], + [ replacementBlock.uid ]: [], + } ); } ); it( 'should update the block', () => { @@ -145,6 +200,7 @@ describe( 'state', () => { name: 'core/test-block', attributes: {}, isValid: false, + innerBlocks: [], } ], } ); const state = editor( deepFreeze( original ), { @@ -174,6 +230,7 @@ describe( 'state', () => { ref: 'random-uid', }, isValid: false, + innerBlocks: [], } ], } ); @@ -200,10 +257,12 @@ describe( 'state', () => { uid: 'chicken', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'ribs', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { @@ -211,7 +270,29 @@ describe( 'state', () => { uids: [ 'ribs' ], } ); - expect( state.present.blockOrder ).toEqual( [ 'ribs', 'chicken' ] ); + expect( state.present.blockOrder[ '' ] ).toEqual( [ 'ribs', 'chicken' ] ); + } ); + + it( 'should move the nested block up', () => { + const movedBlock = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ siblingBlock, movedBlock ] ); + const original = editor( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = editor( original, { + type: 'MOVE_BLOCKS_UP', + uids: [ movedBlock.uid ], + rootUID: wrapperBlock.uid, + } ); + + expect( state.present.blockOrder ).toEqual( { + '': [ wrapperBlock.uid ], + [ wrapperBlock.uid ]: [ movedBlock.uid, siblingBlock.uid ], + [ movedBlock.uid ]: [], + [ siblingBlock.uid ]: [], + } ); } ); it( 'should move multiple blocks up', () => { @@ -221,14 +302,17 @@ describe( 'state', () => { uid: 'chicken', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'ribs', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'veggies', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { @@ -236,7 +320,31 @@ describe( 'state', () => { uids: [ 'ribs', 'veggies' ], } ); - expect( state.present.blockOrder ).toEqual( [ 'ribs', 'veggies', 'chicken' ] ); + expect( state.present.blockOrder[ '' ] ).toEqual( [ 'ribs', 'veggies', 'chicken' ] ); + } ); + + it( 'should move multiple nested blocks up', () => { + const movedBlockA = createBlock( 'core/test-block' ); + const movedBlockB = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ siblingBlock, movedBlockA, movedBlockB ] ); + const original = editor( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = editor( original, { + type: 'MOVE_BLOCKS_UP', + uids: [ movedBlockA.uid, movedBlockB.uid ], + rootUID: wrapperBlock.uid, + } ); + + expect( state.present.blockOrder ).toEqual( { + '': [ wrapperBlock.uid ], + [ wrapperBlock.uid ]: [ movedBlockA.uid, movedBlockB.uid, siblingBlock.uid ], + [ movedBlockA.uid ]: [], + [ movedBlockB.uid ]: [], + [ siblingBlock.uid ]: [], + } ); } ); it( 'should not move the first block up', () => { @@ -246,10 +354,12 @@ describe( 'state', () => { uid: 'chicken', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'ribs', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { @@ -267,10 +377,12 @@ describe( 'state', () => { uid: 'chicken', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'ribs', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { @@ -278,7 +390,29 @@ describe( 'state', () => { uids: [ 'chicken' ], } ); - expect( state.present.blockOrder ).toEqual( [ 'ribs', 'chicken' ] ); + expect( state.present.blockOrder[ '' ] ).toEqual( [ 'ribs', 'chicken' ] ); + } ); + + it( 'should move the nested block down', () => { + const movedBlock = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ movedBlock, siblingBlock ] ); + const original = editor( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = editor( original, { + type: 'MOVE_BLOCKS_DOWN', + uids: [ movedBlock.uid ], + rootUID: wrapperBlock.uid, + } ); + + expect( state.present.blockOrder ).toEqual( { + '': [ wrapperBlock.uid ], + [ wrapperBlock.uid ]: [ siblingBlock.uid, movedBlock.uid ], + [ movedBlock.uid ]: [], + [ siblingBlock.uid ]: [], + } ); } ); it( 'should move multiple blocks down', () => { @@ -288,14 +422,17 @@ describe( 'state', () => { uid: 'chicken', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'ribs', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'veggies', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { @@ -303,7 +440,31 @@ describe( 'state', () => { uids: [ 'chicken', 'ribs' ], } ); - expect( state.present.blockOrder ).toEqual( [ 'veggies', 'chicken', 'ribs' ] ); + expect( state.present.blockOrder[ '' ] ).toEqual( [ 'veggies', 'chicken', 'ribs' ] ); + } ); + + it( 'should move multiple nested blocks down', () => { + const movedBlockA = createBlock( 'core/test-block' ); + const movedBlockB = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ movedBlockA, movedBlockB, siblingBlock ] ); + const original = editor( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = editor( original, { + type: 'MOVE_BLOCKS_DOWN', + uids: [ movedBlockA.uid, movedBlockB.uid ], + rootUID: wrapperBlock.uid, + } ); + + expect( state.present.blockOrder ).toEqual( { + '': [ wrapperBlock.uid ], + [ wrapperBlock.uid ]: [ siblingBlock.uid, movedBlockA.uid, movedBlockB.uid ], + [ movedBlockA.uid ]: [], + [ movedBlockB.uid ]: [], + [ siblingBlock.uid ]: [], + } ); } ); it( 'should not move the last block down', () => { @@ -313,10 +474,12 @@ describe( 'state', () => { uid: 'chicken', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'ribs', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { @@ -334,10 +497,12 @@ describe( 'state', () => { uid: 'chicken', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'ribs', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { @@ -345,7 +510,8 @@ describe( 'state', () => { uids: [ 'chicken' ], } ); - expect( state.present.blockOrder ).toEqual( [ 'ribs' ] ); + expect( state.present.blockOrder[ '' ] ).toEqual( [ 'ribs' ] ); + expect( state.present.blockOrder ).not.toHaveProperty( 'chicken' ); expect( state.present.blocksByUid ).toEqual( { ribs: { uid: 'ribs', @@ -362,14 +528,17 @@ describe( 'state', () => { uid: 'chicken', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'ribs', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'veggies', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { @@ -377,7 +546,9 @@ describe( 'state', () => { uids: [ 'chicken', 'veggies' ], } ); - expect( state.present.blockOrder ).toEqual( [ 'ribs' ] ); + expect( state.present.blockOrder[ '' ] ).toEqual( [ 'ribs' ] ); + expect( state.present.blockOrder ).not.toHaveProperty( 'chicken' ); + expect( state.present.blockOrder ).not.toHaveProperty( 'veggies' ); expect( state.present.blocksByUid ).toEqual( { ribs: { uid: 'ribs', @@ -387,31 +558,34 @@ describe( 'state', () => { } ); } ); - it( 'should insert at the specified position', () => { + it( 'should insert at the specified index', () => { const original = editor( undefined, { type: 'RESET_BLOCKS', blocks: [ { uid: 'kumquat', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'loquat', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { type: 'INSERT_BLOCKS', - position: 1, + index: 1, blocks: [ { uid: 'persimmon', name: 'core/freeform', + innerBlocks: [], } ], } ); expect( Object.keys( state.present.blocksByUid ) ).toHaveLength( 3 ); - expect( state.present.blockOrder ).toEqual( [ 'kumquat', 'persimmon', 'loquat' ] ); + expect( state.present.blockOrder[ '' ] ).toEqual( [ 'kumquat', 'persimmon', 'loquat' ] ); } ); it( 'should remove associated blocks when deleting a reusable block', () => { @@ -421,10 +595,12 @@ describe( 'state', () => { uid: 'chicken', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'ribs', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); const state = editor( original, { @@ -433,7 +609,7 @@ describe( 'state', () => { associatedBlockUids: [ 'chicken', 'veggies' ], } ); - expect( state.present.blockOrder ).toEqual( [ 'ribs' ] ); + expect( state.present.blockOrder[ '' ] ).toEqual( [ 'ribs' ] ); expect( state.present.blocksByUid ).toEqual( { ribs: { uid: 'ribs', @@ -546,10 +722,12 @@ describe( 'state', () => { uid: 'kumquat', name: 'core/test-block', attributes: {}, + innerBlocks: [], }, { uid: 'loquat', name: 'core/test-block', attributes: {}, + innerBlocks: [], } ], } ); @@ -564,6 +742,7 @@ describe( 'state', () => { blocks: [ { uid: 'kumquat', attributes: {}, + innerBlocks: [], } ], } ) ); const state = editor( original, { @@ -585,6 +764,7 @@ describe( 'state', () => { attributes: { updated: true, }, + innerBlocks: [], } ], } ) ); const state = editor( original, { @@ -625,6 +805,7 @@ describe( 'state', () => { attributes: { updated: true, }, + innerBlocks: [], } ], } ) ); const state = editor( original, { @@ -700,6 +881,7 @@ describe( 'state', () => { blocks: [ { uid: 'wings', name: 'core/freeform', + innerBlocks: [], } ], } ); @@ -713,6 +895,7 @@ describe( 'state', () => { blocks: [ { uid: 'wings', name: 'core/freeform', + innerBlocks: [], } ], } ); diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js index cebb550803b33..0f15fe911b078 100644 --- a/editor/store/test/selectors.js +++ b/editor/store/test/selectors.js @@ -39,15 +39,14 @@ import { getBlocks, getBlockCount, getSelectedBlock, + getBlockRootUID, getEditedPostContent, getMultiSelectedBlockUids, getMultiSelectedBlocks, getMultiSelectedBlocksStartUid, getMultiSelectedBlocksEndUid, - getBlockUids, + getBlockOrder, getBlockIndex, - isFirstBlock, - isLastBlock, getPreviousBlock, getNextBlock, isBlockSelected, @@ -515,7 +514,7 @@ describe( 'selectors', () => { present: { edits: {}, blocksByUid: {}, - blockOrder: [], + blockOrder: {}, }, isDirty: false, }, @@ -555,7 +554,7 @@ describe( 'selectors', () => { present: { edits: {}, blocksByUid: {}, - blockOrder: [], + blockOrder: {}, }, isDirty: false, }, @@ -576,7 +575,7 @@ describe( 'selectors', () => { present: { edits: {}, blocksByUid: {}, - blockOrder: [], + blockOrder: {}, }, isDirty: true, }, @@ -837,7 +836,7 @@ describe( 'selectors', () => { editor: { present: { blocksByUid: {}, - blockOrder: [], + blockOrder: {}, edits: {}, }, }, @@ -852,7 +851,7 @@ describe( 'selectors', () => { editor: { present: { blocksByUid: {}, - blockOrder: [], + blockOrder: {}, edits: {}, }, }, @@ -869,7 +868,7 @@ describe( 'selectors', () => { editor: { present: { blocksByUid: {}, - blockOrder: [], + blockOrder: {}, edits: {}, }, }, @@ -894,7 +893,9 @@ describe( 'selectors', () => { }, }, }, - blockOrder: [ 123 ], + blockOrder: { + '': [ 123 ], + }, edits: {}, }, }, @@ -1032,21 +1033,23 @@ describe( 'selectors', () => { 23: { uid: 23, name: 'core/heading' }, 123: { uid: 123, name: 'core/paragraph' }, }, - blockOrder: [ 123, 23 ], + blockOrder: { + '': [ 123, 23 ], + }, edits: {}, }, }, }; expect( getBlocks( state ) ).toEqual( [ - { uid: 123, name: 'core/paragraph' }, - { uid: 23, name: 'core/heading' }, + { uid: 123, name: 'core/paragraph', innerBlocks: [] }, + { uid: 23, name: 'core/heading', innerBlocks: [] }, ] ); } ); } ); describe( 'getBlockCount', () => { - it( 'should return the number of blocks in the post', () => { + it( 'should return the number of top-level blocks in the post', () => { const state = { editor: { present: { @@ -1054,13 +1057,35 @@ describe( 'selectors', () => { 23: { uid: 23, name: 'core/heading' }, 123: { uid: 123, name: 'core/paragraph' }, }, - blockOrder: [ 123, 23 ], + blockOrder: { + '': [ 123, 23 ], + }, }, }, }; expect( getBlockCount( state ) ).toBe( 2 ); } ); + + it( 'should return the number of blocks in a nested context', () => { + const state = { + editor: { + present: { + blocksByUid: { + 123: { uid: 123, name: 'core/columns' }, + 456: { uid: 456, name: 'core/paragraph' }, + 789: { uid: 789, name: 'core/paragraph' }, + }, + blockOrder: { + '': [ 123 ], + 123: [ 456, 789 ], + }, + }, + }, + }; + + expect( getBlockCount( state, '123' ) ).toBe( 2 ); + } ); } ); describe( 'getSelectedBlock', () => { @@ -1115,12 +1140,43 @@ describe( 'selectors', () => { } ); } ); + describe( 'getBlockRootUID', () => { + it( 'should return null if the block does not exist', () => { + const state = { + editor: { + present: { + blockOrder: {}, + }, + }, + }; + + expect( getBlockRootUID( state, 56 ) ).toBeNull(); + } ); + + it( 'should return root UID relative the block UID', () => { + const state = { + editor: { + present: { + blockOrder: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }, + }; + + expect( getBlockRootUID( state, 56 ) ).toBe( '123' ); + } ); + } ); + describe( 'getMultiSelectedBlockUids', () => { it( 'should return empty if there is no multi selection', () => { const state = { editor: { present: { - blockOrder: [ 123, 23 ], + blockOrder: { + '': [ 123, 23 ], + }, }, }, blockSelection: { start: null, end: null }, @@ -1133,7 +1189,9 @@ describe( 'selectors', () => { const state = { editor: { present: { - blockOrder: [ 5, 4, 3, 2, 1 ], + blockOrder: { + '': [ 5, 4, 3, 2, 1 ], + }, }, }, blockSelection: { start: 2, end: 4 }, @@ -1141,16 +1199,27 @@ describe( 'selectors', () => { expect( getMultiSelectedBlockUids( state ) ).toEqual( [ 4, 3, 2 ] ); } ); - } ); - describe( 'getMultiSelectedBlocksStartUid', () => { - it( 'returns null if there is no multi selection', () => { + it( 'should return selected block uids if there is multi selection (nested context)', () => { const state = { editor: { present: { - blockOrder: [ 123, 23 ], + blockOrder: { + '': [ 5, 4, 3, 2, 1 ], + 4: [ 9, 8, 7, 6 ], + }, }, }, + blockSelection: { start: 7, end: 9 }, + }; + + expect( getMultiSelectedBlockUids( state ) ).toEqual( [ 9, 8, 7 ] ); + } ); + } ); + + describe( 'getMultiSelectedBlocksStartUid', () => { + it( 'returns null if there is no multi selection', () => { + const state = { blockSelection: { start: null, end: null }, }; @@ -1159,11 +1228,6 @@ describe( 'selectors', () => { it( 'returns multi selection start', () => { const state = { - editor: { - present: { - blockOrder: [ 5, 4, 3, 2, 1 ], - }, - }, blockSelection: { start: 2, end: 4 }, }; @@ -1174,11 +1238,6 @@ describe( 'selectors', () => { describe( 'getMultiSelectedBlocksEndUid', () => { it( 'returns null if there is no multi selection', () => { const state = { - editor: { - present: { - blockOrder: [ 123, 23 ], - }, - }, blockSelection: { start: null, end: null }, }; @@ -1187,11 +1246,6 @@ describe( 'selectors', () => { it( 'returns multi selection end', () => { const state = { - editor: { - present: { - blockOrder: [ 5, 4, 3, 2, 1 ], - }, - }, blockSelection: { start: 2, end: 4 }, }; @@ -1199,88 +1253,109 @@ describe( 'selectors', () => { } ); } ); - describe( 'getBlockUids', () => { - it( 'should return the ordered block UIDs', () => { + describe( 'getBlockOrder', () => { + it( 'should return the ordered block UIDs of top-level blocks by default', () => { const state = { editor: { present: { - blockOrder: [ 123, 23 ], + blockOrder: { + '': [ 123, 23 ], + }, }, }, }; - expect( getBlockUids( state ) ).toEqual( [ 123, 23 ] ); + expect( getBlockOrder( state ) ).toEqual( [ 123, 23 ] ); } ); - } ); - describe( 'getBlockIndex', () => { - it( 'should return the block order', () => { + it( 'should return the ordered block UIDs at a specified rootUID', () => { const state = { editor: { present: { - blockOrder: [ 123, 23 ], + blockOrder: { + '': [ 123, 23 ], + 123: [ 456 ], + }, }, }, }; - expect( getBlockIndex( state, 23 ) ).toBe( 1 ); + expect( getBlockOrder( state, '123' ) ).toEqual( [ 456 ] ); } ); } ); - describe( 'isFirstBlock', () => { - it( 'should return true when the block is first', () => { + describe( 'getBlockIndex', () => { + it( 'should return the block order', () => { const state = { editor: { present: { - blockOrder: [ 123, 23 ], + blockOrder: { + '': [ 123, 23 ], + }, }, }, }; - expect( isFirstBlock( state, 123 ) ).toBe( true ); + expect( getBlockIndex( state, 23 ) ).toBe( 1 ); } ); - it( 'should return false when the block is not first', () => { + it( 'should return the block order (nested context)', () => { const state = { editor: { present: { - blockOrder: [ 123, 23 ], + blockOrder: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, }, }, }; - expect( isFirstBlock( state, 23 ) ).toBe( false ); + expect( getBlockIndex( state, 56, '123' ) ).toBe( 1 ); } ); } ); - describe( 'isLastBlock', () => { - it( 'should return true when the block is last', () => { + describe( 'getPreviousBlock', () => { + it( 'should return the previous block', () => { const state = { editor: { present: { - blockOrder: [ 123, 23 ], + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + }, + blockOrder: { + '': [ 123, 23 ], + }, }, }, }; - expect( isLastBlock( state, 23 ) ).toBe( true ); + expect( getPreviousBlock( state, 23 ) ).toEqual( { uid: 123, name: 'core/paragraph' } ); } ); - it( 'should return false when the block is not last', () => { + it( 'should return the previous block (nested context)', () => { const state = { editor: { present: { - blockOrder: [ 123, 23 ], + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + 56: { uid: 56, name: 'core/heading' }, + 456: { uid: 456, name: 'core/paragraph' }, + }, + blockOrder: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, }, }, }; - expect( isLastBlock( state, 123 ) ).toBe( false ); + expect( getPreviousBlock( state, 56, '123' ) ).toEqual( { uid: 456, name: 'core/paragraph' } ); } ); - } ); - describe( 'getPreviousBlock', () => { - it( 'should return the previous block', () => { + it( 'should return null for the first block', () => { const state = { editor: { present: { @@ -1288,28 +1363,35 @@ describe( 'selectors', () => { 23: { uid: 23, name: 'core/heading' }, 123: { uid: 123, name: 'core/paragraph' }, }, - blockOrder: [ 123, 23 ], + blockOrder: { + '': [ 123, 23 ], + }, }, }, }; - expect( getPreviousBlock( state, 23 ) ).toEqual( { uid: 123, name: 'core/paragraph' } ); + expect( getPreviousBlock( state, 123 ) ).toBeNull(); } ); - it( 'should return null for the first block', () => { + it( 'should return null for the first block (nested context)', () => { const state = { editor: { present: { blocksByUid: { 23: { uid: 23, name: 'core/heading' }, 123: { uid: 123, name: 'core/paragraph' }, + 56: { uid: 56, name: 'core/heading' }, + 456: { uid: 456, name: 'core/paragraph' }, + }, + blockOrder: { + '': [ 123, 23 ], + 123: [ 456, 56 ], }, - blockOrder: [ 123, 23 ], }, }, }; - expect( getPreviousBlock( state, 123 ) ).toBeNull(); + expect( getPreviousBlock( state, 456, '123' ) ).toBeNull(); } ); } ); @@ -1322,7 +1404,9 @@ describe( 'selectors', () => { 23: { uid: 23, name: 'core/heading' }, 123: { uid: 123, name: 'core/paragraph' }, }, - blockOrder: [ 123, 23 ], + blockOrder: { + '': [ 123, 23 ], + }, }, }, }; @@ -1330,6 +1414,27 @@ describe( 'selectors', () => { expect( getNextBlock( state, 123 ) ).toEqual( { uid: 23, name: 'core/heading' } ); } ); + it( 'should return the following block (nested context)', () => { + const state = { + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + 56: { uid: 56, name: 'core/heading' }, + 456: { uid: 456, name: 'core/paragraph' }, + }, + blockOrder: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }, + }; + + expect( getNextBlock( state, 456, '123' ) ).toEqual( { uid: 56, name: 'core/heading' } ); + } ); + it( 'should return null for the last block', () => { const state = { editor: { @@ -1338,13 +1443,36 @@ describe( 'selectors', () => { 23: { uid: 23, name: 'core/heading' }, 123: { uid: 123, name: 'core/paragraph' }, }, - blockOrder: [ 123, 23 ], + blockOrder: { + '': [ 123, 23 ], + }, }, }, }; expect( getNextBlock( state, 23 ) ).toBeNull(); } ); + + it( 'should return null for the last block (nested context)', () => { + const state = { + editor: { + present: { + blocksByUid: { + 23: { uid: 23, name: 'core/heading' }, + 123: { uid: 123, name: 'core/paragraph' }, + 56: { uid: 56, name: 'core/heading' }, + 456: { uid: 456, name: 'core/paragraph' }, + }, + blockOrder: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }, + }; + + expect( getNextBlock( state, 56, '123' ) ).toBeNull(); + } ); } ); describe( 'isBlockSelected', () => { @@ -1379,7 +1507,9 @@ describe( 'selectors', () => { blockSelection: { start: 5, end: 3 }, editor: { present: { - blockOrder: [ 5, 4, 3, 2, 1 ], + blockOrder: { + '': [ 5, 4, 3, 2, 1 ], + }, }, }, }; @@ -1392,7 +1522,9 @@ describe( 'selectors', () => { blockSelection: { start: 5, end: 3 }, editor: { present: { - blockOrder: [ 5, 4, 3, 2, 1 ], + blockOrder: { + '': [ 5, 4, 3, 2, 1 ], + }, }, }, }; @@ -1405,7 +1537,9 @@ describe( 'selectors', () => { blockSelection: { start: 5, end: 3 }, editor: { present: { - blockOrder: [ 5, 4, 3, 2, 1 ], + blockOrder: { + '': [ 5, 4, 3, 2, 1 ], + }, }, }, }; @@ -1418,7 +1552,9 @@ describe( 'selectors', () => { blockSelection: {}, editor: { present: { - blockOrder: [ 5, 4, 3, 2, 1 ], + blockOrder: { + '': [ 5, 4, 3, 2, 1 ], + }, }, }, }; @@ -1431,7 +1567,9 @@ describe( 'selectors', () => { const state = { editor: { present: { - blockOrder: [ 5, 4, 3, 2, 1 ], + blockOrder: { + '': [ 5, 4, 3, 2, 1 ], + }, }, }, blockSelection: { start: 2, end: 4 }, @@ -1450,7 +1588,9 @@ describe( 'selectors', () => { const state = { editor: { present: { - blockOrder: [ 5, 4, 3, 2, 1 ], + blockOrder: { + '': [ 5, 4, 3, 2, 1 ], + }, }, }, blockSelection: { start: 2, end: 4 }, @@ -1607,7 +1747,9 @@ describe( 'selectors', () => { blocksByUid: { 2: { uid: 2 }, }, - blockOrder: [ 1, 2, 3 ], + blockOrder: { + '': [ 1, 2, 3 ], + }, edits: {}, }, }, @@ -1626,7 +1768,9 @@ describe( 'selectors', () => { }, editor: { present: { - blockOrder: [ 1, 2, 3 ], + blockOrder: { + '': [ 1, 2, 3 ], + }, }, }, isInsertionPointVisible: false, @@ -1641,7 +1785,9 @@ describe( 'selectors', () => { blockSelection: { start: null, end: null }, editor: { present: { - blockOrder: [ 1, 2, 3 ], + blockOrder: { + '': [ 1, 2, 3 ], + }, }, }, isInsertionPointVisible: false, @@ -1745,7 +1891,7 @@ describe( 'selectors', () => { const state = { editor: { present: { - blockOrder: [], + blockOrder: {}, blocksByUid: {}, }, }, @@ -1758,7 +1904,9 @@ describe( 'selectors', () => { const state = { editor: { present: { - blockOrder: [ 123, 456 ], + blockOrder: { + '': [ 123, 456 ], + }, blocksByUid: { 123: { uid: 123, name: 'core/image' }, 456: { uid: 456, name: 'core/quote' }, @@ -1774,7 +1922,9 @@ describe( 'selectors', () => { const state = { editor: { present: { - blockOrder: [ 123 ], + blockOrder: { + '': [ 123 ], + }, blocksByUid: { 123: { uid: 123, name: 'core/image' }, }, @@ -1789,7 +1939,9 @@ describe( 'selectors', () => { const state = { editor: { present: { - blockOrder: [ 456 ], + blockOrder: { + '': [ 456 ], + }, blocksByUid: { 456: { uid: 456, name: 'core/quote' }, }, @@ -1804,7 +1956,9 @@ describe( 'selectors', () => { const state = { editor: { present: { - blockOrder: [ 567 ], + blockOrder: { + '': [ 567 ], + }, blocksByUid: { 567: { uid: 567, name: 'core-embed/youtube' }, }, @@ -1819,7 +1973,9 @@ describe( 'selectors', () => { const state = { editor: { present: { - blockOrder: [ 456, 789 ], + blockOrder: { + '': [ 456, 789 ], + }, blocksByUid: { 456: { uid: 456, name: 'core/quote' }, 789: { uid: 789, name: 'core/paragraph' }, @@ -1851,7 +2007,7 @@ describe( 'selectors', () => { editor: { present: { blocksByUid: {}, - blockOrder: [], + blockOrder: {}, }, }, reusableBlocks: { @@ -1868,7 +2024,7 @@ describe( 'selectors', () => { editor: { present: { blocksByUid: {}, - blockOrder: [], + blockOrder: {}, }, }, reusableBlocks: { @@ -1897,7 +2053,9 @@ describe( 'selectors', () => { blocksByUid: { 1: { uid: 1, name: 'core/test-block', attributes: {} }, }, - blockOrder: [ 1 ], + blockOrder: { + '': [ 1 ], + }, }, }, reusableBlocks: { @@ -1914,7 +2072,7 @@ describe( 'selectors', () => { editor: { present: { blocksByUid: {}, - blockOrder: [], + blockOrder: {}, }, }, reusableBlocks: {