diff --git a/blocks/api/factory.js b/blocks/api/factory.js index bb27df7e56d09..c5514147f121c 100644 --- a/blocks/api/factory.js +++ b/blocks/api/factory.js @@ -2,11 +2,17 @@ * External dependencies */ import uuid from 'uuid/v4'; +import { get } from 'lodash'; + +/** + * Internal dependencies + */ +import { getBlockSettings } from './registration'; /** * Returns a block object given its type and attributes * - * @param {Object} blockType BlockType + * @param {String} blockType BlockType * @param {Object} attributes Block attributes * @return {Object} Block object */ @@ -17,3 +23,31 @@ export function createBlock( blockType, attributes = {} ) { attributes }; } + +/** + * Switch Block Type and returns the updated block + * + * @param {Object} block Block object + * @param {string} blockType BlockType + * @return {Object?} Block object + */ +export function switchToBlockType( block, blockType ) { + // Find the right transformation by giving priority to the "to" transformation + const destinationSettings = getBlockSettings( blockType ); + const sourceSettings = getBlockSettings( block.blockType ); + const transformationsFrom = get( destinationSettings, 'transforms.from', [] ); + const transformationsTo = get( sourceSettings, 'transforms.to', [] ); + const transformation = + transformationsTo.find( t => t.blocks.indexOf( blockType ) !== -1 ) || + transformationsFrom.find( t => t.blocks.indexOf( block.blockType ) !== -1 ); + + if ( ! transformation ) { + return null; + } + + return Object.assign( { + uid: block.uid, + attributes: transformation.transform( block.attributes ), + blockType + } ); +} diff --git a/blocks/api/index.js b/blocks/api/index.js index 2d43c110f7069..be8351e5ca643 100644 --- a/blocks/api/index.js +++ b/blocks/api/index.js @@ -4,7 +4,7 @@ import * as query from 'hpq'; export { query }; -export { createBlock } from './factory'; +export { createBlock, switchToBlockType } from './factory'; export { default as parse } from './parser'; export { default as serialize } from './serializer'; export { getCategories } from './categories'; diff --git a/blocks/api/test/factory.js b/blocks/api/test/factory.js index d8c5accc5fcbe..cb1a0265738d5 100644 --- a/blocks/api/test/factory.js +++ b/blocks/api/test/factory.js @@ -6,9 +6,17 @@ import { expect } from 'chai'; /** * Internal dependencies */ -import { createBlock } from '../factory'; +import { createBlock, switchToBlockType } from '../factory'; +import { getBlocks, unregisterBlock, setUnknownTypeHandler, registerBlock } from '../registration'; describe( 'block factory', () => { + afterEach( () => { + setUnknownTypeHandler( undefined ); + getBlocks().forEach( ( block ) => { + unregisterBlock( block.slug ); + } ); + } ); + describe( 'createBlock()', () => { it( 'should create a block given its blockType and attributes', () => { const block = createBlock( 'core/test-block', { @@ -22,4 +30,91 @@ describe( 'block factory', () => { expect( block.uid ).to.be.a( 'string' ); } ); } ); + + describe( 'switchBlockType()', () => { + it( 'should switch the blockType of a block using the "transform form"', () => { + registerBlock( 'core/updated-text-block', { + transforms: { + from: [ { + blocks: [ 'core/text-block' ], + transform: ( { value } ) => { + return { + value: 'chicken ' + value + }; + } + } ] + } + } ); + registerBlock( 'core/text-block', {} ); + + const block = { + uid: 1, + blockType: 'core/text-block', + attributes: { + value: 'ribs' + } + }; + + const updateBlock = switchToBlockType( block, 'core/updated-text-block' ); + + expect( updateBlock ).to.eql( { + uid: 1, + blockType: 'core/updated-text-block', + attributes: { + value: 'chicken ribs' + } + } ); + } ); + + it( 'should switch the blockType of a block using the "transform to"', () => { + registerBlock( 'core/updated-text-block', {} ); + registerBlock( 'core/text-block', { + transforms: { + to: [ { + blocks: [ 'core/updated-text-block' ], + transform: ( { value } ) => { + return { + value: 'chicken ' + value + }; + } + } ] + } + } ); + + const block = { + uid: 1, + blockType: 'core/text-block', + attributes: { + value: 'ribs' + } + }; + + const updateBlock = switchToBlockType( block, 'core/updated-text-block' ); + + expect( updateBlock ).to.eql( { + uid: 1, + blockType: 'core/updated-text-block', + attributes: { + value: 'chicken ribs' + } + } ); + } ); + + it( 'should return null if no transformation is found', () => { + registerBlock( 'core/updated-text-block', {} ); + registerBlock( 'core/text-block', {} ); + + const block = { + uid: 1, + blockType: 'core/text-block', + attributes: { + value: 'ribs' + } + }; + + const updateBlock = switchToBlockType( block, 'core/updated-text-block' ); + + expect( updateBlock ).to.be.null(); + } ); + } ); } ); diff --git a/blocks/library/heading/index.js b/blocks/library/heading/index.js index 512c197e0c999..dd9d3137d540a 100644 --- a/blocks/library/heading/index.js +++ b/blocks/library/heading/index.js @@ -52,5 +52,38 @@ registerBlock( 'core/heading', { style={ align ? { textAlign: align } : null } dangerouslySetInnerHTML={ { __html: content } } /> ); + }, + + transforms: { + from: [ + { + type: 'block', + blocks: [ 'core/text' ], + transform: ( { content, align } ) => { + if ( Array.isArray( content ) ) { + // TODO this appears to always be true? + // TODO reject the switch if more than one paragraph + content = content[ 0 ]; + } + return { + tag: 'H2', + content, + align + }; + } + } + ], + to: [ + { + type: 'block', + blocks: [ 'core/text' ], + transform: ( { content, align } ) => { + return { + content, + align + }; + } + } + ] } } ); diff --git a/blocks/library/list/index.js b/blocks/library/list/index.js index 8caae01983ddd..7b8112444a4f0 100644 --- a/blocks/library/list/index.js +++ b/blocks/library/list/index.js @@ -2,10 +2,10 @@ * Internal dependencies */ import './style.scss'; -import { registerBlock, query } from 'api'; +import { registerBlock, query as hpq } from 'api'; import Editable from 'components/editable'; -const { html, prop } = query; +const { html, prop, query } = hpq; registerBlock( 'core/list', { title: wp.i18n.__( 'List' ), @@ -14,7 +14,7 @@ registerBlock( 'core/list', { attributes: { listType: prop( 'ol,ul', 'nodeName' ), - items: query.query( 'li', { + items: query( 'li', { value: html() } ) }, diff --git a/editor/components/block-switcher/index.js b/editor/components/block-switcher/index.js new file mode 100644 index 0000000000000..e93df85dfd589 --- /dev/null +++ b/editor/components/block-switcher/index.js @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { connect } from 'react-redux'; +import { uniq, get, reduce } from 'lodash'; + +/** + * Internal dependencies + */ +import './style.scss'; +import IconButton from 'components/icon-button'; + +class BlockSwitcher extends wp.element.Component { + constructor() { + super( ...arguments ); + this.toggleMenu = this.toggleMenu.bind( this ); + this.state = { + open: false + }; + } + + toggleMenu() { + this.setState( { + open: ! this.state.open + } ); + } + + switchBlockType( blockType ) { + return () => { + this.setState( { + open: false + } ); + this.props.onTransform( this.props.block, blockType ); + }; + } + + render() { + const blockSettings = wp.blocks.getBlockSettings( this.props.block.blockType ); + const blocksToBeTransformedFrom = reduce( wp.blocks.getBlocks(), ( memo, block ) => { + const transformFrom = get( block, 'transforms.from', [] ); + const transformation = transformFrom.find( t => t.blocks.indexOf( this.props.block.blockType ) !== -1 ); + return transformation ? memo.concat( [ block.slug ] ) : memo; + }, [] ); + const blocksToBeTransformedTo = get( blockSettings, 'transforms.to', [] ) + .reduce( ( memo, transformation ) => memo.concat( transformation.blocks ), [] ); + const allowedBlocks = uniq( blocksToBeTransformedFrom.concat( blocksToBeTransformedTo ) ) + .reduce( ( memo, blockType ) => { + const block = wp.blocks.getBlockSettings( blockType ); + return !! block ? memo.concat( block ) : memo; + }, [] ); + + if ( ! allowedBlocks.length ) { + return null; + } + + return ( +
+ +
+ + { this.state.open && +
+
+ { allowedBlocks.map( ( { slug, title, icon } ) => ( + + { title } + + ) ) } +
+ } +
+ ); + } +} + +export default connect( + ( state, ownProps ) => ( { + block: state.blocks.byUid[ ownProps.uid ] + } ), + ( dispatch, ownProps ) => ( { + onTransform( block, blockType ) { + dispatch( { + type: 'SWITCH_BLOCK_TYPE', + uid: ownProps.uid, + block: wp.blocks.switchToBlockType( block, blockType ) + } ); + } + } ) +)( BlockSwitcher ); diff --git a/editor/components/block-switcher/style.scss b/editor/components/block-switcher/style.scss new file mode 100644 index 0000000000000..30356ee470cad --- /dev/null +++ b/editor/components/block-switcher/style.scss @@ -0,0 +1,89 @@ +.editor-block-switcher { + border: 1px solid $light-gray-500; + box-shadow: 0px 3px 20px rgba( 18, 24, 30, .1 ), 0px 1px 3px rgba( 18, 24, 30, .1 ); + background-color: $white; + margin-right: 10px; + font-family: $default-font; + font-size: $default-font-size; + line-height: $default-line-height; +} + +.editor-block-switcher__toggle { + width: auto; + margin: 3px; +} + +.editor-block-switcher__arrow { + display: inline-flex; + border: 6px dashed $dark-gray-500; + margin-left: 5px; + height: 0; + line-height: 0; + width: 0; + z-index: 1; + border-top-style: solid; + border-bottom: none; + border-left-color: transparent; + border-right-color: transparent; +} + +.editor-block-switcher__menu { + position: absolute; + top: 50px; + box-shadow: 0px 3px 20px rgba( 18, 24, 30, .1 ), 0px 1px 3px rgba( 18, 24, 30, .1 ); + border: 1px solid #e0e5e9; + background: #fff; + z-index: 1; + + input { + font-size: 13px; + } +} + +.editor-block-switcher__menu-arrow { + border: 10px dashed $light-gray-500; + height: 0; + line-height: 0; + position: absolute; + width: 0; + z-index: 1; + top: -10px; + left: 50%; + margin-left: -10px; + border-bottom-style: solid; + border-top: none; + border-left-color: transparent; + border-right-color: transparent; + + &:before { + top: 2px; + border: 10px solid $white; + content: " "; + position: absolute; + left: 50%; + margin-left: -10px; + border-bottom-style: solid; + border-top: none; + border-left-color: transparent; + border-right-color: transparent; + } +} + +.editor-block-switcher__menu-item { + width: auto; + margin: 3px; + padding: 6px; + background: none; + border: 1px solid transparent; + outline: none; + color: $dark-gray-500; + cursor: pointer; + + &:hover { + border-color: $dark-gray-500; + } + + .dashicon { + margin-right: 5px; + } +} diff --git a/editor/modes/visual-editor/block.js b/editor/modes/visual-editor/block.js index bf8b1bf8e105e..226efa62b2f32 100644 --- a/editor/modes/visual-editor/block.js +++ b/editor/modes/visual-editor/block.js @@ -9,6 +9,7 @@ import classnames from 'classnames'; */ import Toolbar from 'components/toolbar'; import BlockMover from 'components/block-mover'; +import BlockSwitcher from 'components/block-switcher'; function VisualEditorBlock( props ) { const { block } = props; @@ -64,14 +65,17 @@ function VisualEditorBlock( props ) { className={ className } > { ( isSelected || isHovered ) && } - { isSelected && settings.controls ? ( - ( { - ...control, - onClick: () => control.onClick( block.attributes, setAttributes ), - isActive: () => control.isActive( block.attributes ) - } ) ) } /> - ) : null } +
+ { isSelected && } + { isSelected && settings.controls ? ( + ( { + ...control, + onClick: () => control.onClick( block.attributes, setAttributes ), + isActive: () => control.isActive( block.attributes ) + } ) ) } /> + ) : null } +
{ expect( state.order ).to.eql( [ 'chicken', 'ribs' ] ); } ); + it( 'should switch the block', () => { + const original = blocks( undefined, { + type: 'REPLACE_BLOCKS', + blockNodes: [ { + uid: 'chicken', + blockType: 'core/test-block', + attributes: {} + } ] + } ); + const state = blocks( original, { + type: 'SWITCH_BLOCK_TYPE', + uid: 'chicken', + block: { + uid: 'chicken', + blockType: 'core/freeform' + } + } ); + + expect( Object.keys( state.byUid ) ).to.have.lengthOf( 1 ); + expect( values( state.byUid )[ 0 ].blockType ).to.equal( 'core/freeform' ); + } ); + it( 'should move the block up', () => { const original = blocks( undefined, { type: 'REPLACE_BLOCKS',