From 75e7dea7dfb66a697886a49e879e50b2a646c407 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Fri, 10 Mar 2017 12:34:31 +0100 Subject: [PATCH] TinyMCE per block: Extract the "reducer" logic (#233) --- .../src/blocks/embed-block/form.js | 4 - .../src/blocks/embed-block/index.js | 4 +- .../src/blocks/heading-block/form.js | 1 - .../src/blocks/heading-block/index.js | 15 +- .../src/blocks/html-block/form.js | 15 -- .../src/blocks/html-block/index.js | 22 +- .../src/blocks/image-block/form.js | 5 - .../src/blocks/image-block/index.js | 4 +- .../src/blocks/inline-text-block/form.js | 24 -- .../src/blocks/quote-block/form.js | 28 +- .../src/blocks/quote-block/index.js | 12 +- .../src/blocks/text-block/form.js | 1 - .../src/blocks/text-block/index.js | 7 +- .../src/external/wp-blocks/editable/index.js | 7 +- .../src/renderers/block/block.js | 14 - .../src/renderers/block/commands.js | 2 +- .../src/renderers/block/index.js | 254 ++++-------------- .../src/renderers/block/updater.js | 218 +++++++++++++++ tinymce-per-block/src/utils/state.js | 34 +++ 19 files changed, 370 insertions(+), 301 deletions(-) create mode 100644 tinymce-per-block/src/renderers/block/updater.js create mode 100644 tinymce-per-block/src/utils/state.js diff --git a/tinymce-per-block/src/blocks/embed-block/form.js b/tinymce-per-block/src/blocks/embed-block/form.js index a86ea13881b9b..ce0dad757dab2 100644 --- a/tinymce-per-block/src/blocks/embed-block/form.js +++ b/tinymce-per-block/src/blocks/embed-block/form.js @@ -14,10 +14,6 @@ import { getEmbedHtmlFromUrl } from '../../utils/embed'; import FigureAlignmentToolbar from 'controls/figure-alignment-toolbar'; export default class EmbedBlockForm extends Component { - merge = () => { - this.props.api.remove(); - }; - setAlignment = ( id ) => { this.props.api.change( { align: id } ); }; diff --git a/tinymce-per-block/src/blocks/embed-block/index.js b/tinymce-per-block/src/blocks/embed-block/index.js index 80895d9fa3ba2..e7be4e9926168 100644 --- a/tinymce-per-block/src/blocks/embed-block/index.js +++ b/tinymce-per-block/src/blocks/embed-block/index.js @@ -10,6 +10,7 @@ import { VideoAlt3Icon } from 'dashicons'; import form from './form'; import { getEmbedHtmlFromUrl } from 'utils/embed'; import { getFigureAlignmentStyles } from 'utils/figure-alignment'; +import { removeBlockFromState } from 'utils/state'; registerBlock( 'embed', { title: 'Embed', @@ -52,5 +53,6 @@ registerBlock( 'embed', { align: 'no-align', caption: '' }; - } + }, + merge: removeBlockFromState } ); diff --git a/tinymce-per-block/src/blocks/heading-block/form.js b/tinymce-per-block/src/blocks/heading-block/form.js index 470d7d7e22c0f..f135688e26f93 100644 --- a/tinymce-per-block/src/blocks/heading-block/form.js +++ b/tinymce-per-block/src/blocks/heading-block/form.js @@ -13,7 +13,6 @@ import TransformBlockToolbar from 'controls/transform-block-toolbar'; export default class HeadingBlockForm extends Component { bindForm = ( ref ) => { this.form = ref; - this.merge = ( ...args ) => this.form.merge( ...args ); }; bindFormatToolbar = ( ref ) => { diff --git a/tinymce-per-block/src/blocks/heading-block/index.js b/tinymce-per-block/src/blocks/heading-block/index.js index e29019cdb8520..cde5c042ac516 100644 --- a/tinymce-per-block/src/blocks/heading-block/index.js +++ b/tinymce-per-block/src/blocks/heading-block/index.js @@ -8,6 +8,7 @@ import { EditorHeadingIcon } from 'dashicons'; * Internal dependencies */ import form from './form'; +import { mergeInlineTextBlocks } from 'utils/state'; const createHeadingBlockWithContent = ( content = '' ) => { return { @@ -49,10 +50,12 @@ registerBlock( 'heading', { }; }, create: createHeadingBlockWithContent, - transformations: [ - { - blocks: [ 'text', 'quote' ], - transform: ( block ) => createHeadingBlockWithContent( block.content ) - } - ] + transformations: [ { + blocks: [ 'text', 'quote' ], + transform: ( block ) => createHeadingBlockWithContent( block.content ) + } ], + merge: [ { + blocks: [ 'text', 'quote', 'heading' ], + merge: mergeInlineTextBlocks + } ] } ); diff --git a/tinymce-per-block/src/blocks/html-block/form.js b/tinymce-per-block/src/blocks/html-block/form.js index e9e9a98fcd6ee..e4de8fe07810f 100644 --- a/tinymce-per-block/src/blocks/html-block/form.js +++ b/tinymce-per-block/src/blocks/html-block/form.js @@ -9,21 +9,6 @@ import EditableFormatToolbar from 'controls/editable-format-toolbar'; import BlockArrangement from 'controls/block-arrangement'; export default class HtmlBlockForm extends Component { - merge = ( block ) => { - const acceptedBlockTypes = [ 'html', 'quote', 'text', 'heading' ]; - if ( acceptedBlockTypes.indexOf( block.blockType ) === -1 ) { - return; - } - - const { api, block: { content, externalChange = 0 } } = this.props; - api.focus( { end: true } ); - api.remove( block.uid ); - api.change( { - content: content + block.content, - externalChange: externalChange + 1 - } ); - } - bindEditable = ( ref ) => { this.editable = ref; } diff --git a/tinymce-per-block/src/blocks/html-block/index.js b/tinymce-per-block/src/blocks/html-block/index.js index 792c1361cf25b..f2790bf9387c7 100644 --- a/tinymce-per-block/src/blocks/html-block/index.js +++ b/tinymce-per-block/src/blocks/html-block/index.js @@ -36,5 +36,25 @@ registerBlock( 'html', { align: 'no-align', caption: '' }; - } + }, + merge: [ { + blocks: [ 'html', 'quote', 'text', 'heading' ], + merge: ( state, index ) => { + const currentBlock = state.blocks[ index ]; + const blockToMerge = state.blocks[ index + 1 ]; + const newBlock = Object.assign( {}, currentBlock, { + content: currentBlock.content + blockToMerge.content, + externalChange: ( currentBlock.externalChange || 0 ) + 1 + } ); + const newBlocks = [ + ...state.blocks.slice( 0, index ), + newBlock, + ...state.blocks.slice( index + 2 ) + ]; + return Object.assign( {}, state, { + blocks: newBlocks, + focus: { uid: newBlock.uid, config: { end: true } } + } ); + } + } ] } ); diff --git a/tinymce-per-block/src/blocks/image-block/form.js b/tinymce-per-block/src/blocks/image-block/form.js index 599f9a9beec87..6da81a144d7c2 100644 --- a/tinymce-per-block/src/blocks/image-block/form.js +++ b/tinymce-per-block/src/blocks/image-block/form.js @@ -9,11 +9,6 @@ import BlockArrangement from 'controls/block-arrangement'; import FigureAlignmentToolbar from 'controls/figure-alignment-toolbar'; export default class ImageBlockForm extends Component { - - merge() { - this.props.api.remove(); - } - setAlignment = ( id ) => { this.props.api.change( { align: id } ); }; diff --git a/tinymce-per-block/src/blocks/image-block/index.js b/tinymce-per-block/src/blocks/image-block/index.js index 0ebae2139eca3..9e917a13f472a 100644 --- a/tinymce-per-block/src/blocks/image-block/index.js +++ b/tinymce-per-block/src/blocks/image-block/index.js @@ -9,6 +9,7 @@ import { FormatImageIcon } from 'dashicons'; */ import form from './form'; import { getFigureAlignmentStyles } from 'utils/figure-alignment'; +import { removeBlockFromState } from 'utils/state'; registerBlock( 'image', { title: 'Image', @@ -69,5 +70,6 @@ registerBlock( 'image', { caption: '', align: 'no-align' }; - } + }, + merge: removeBlockFromState } ); diff --git a/tinymce-per-block/src/blocks/inline-text-block/form.js b/tinymce-per-block/src/blocks/inline-text-block/form.js index d88a897e74836..3392bea8f9e4b 100644 --- a/tinymce-per-block/src/blocks/inline-text-block/form.js +++ b/tinymce-per-block/src/blocks/inline-text-block/form.js @@ -5,30 +5,6 @@ import { createElement, Component } from 'wp-elements'; import { EditableComponent } from 'wp-blocks'; export default class InlineTextBlockForm extends Component { - merge = ( block ) => { - const acceptedBlockTypes = [ 'quote', 'text', 'heading' ]; - if ( acceptedBlockTypes.indexOf( block.blockType ) === -1 ) { - return; - } - - const getLeaves = html => { - const div = document.createElement( 'div' ); - div.innerHTML = html; - if ( div.childNodes.length === 1 && div.firstChild.nodeName === 'P' ) { - return getLeaves( div.firstChild.innerHTML ); - } - return html; - }; - - const { api, block: { content, externalChange = 0 } } = this.props; - api.focus( { end: true } ); - api.remove( block.uid ); - api.change( { - content: getLeaves( content ) + getLeaves( block.content ), - externalChange: externalChange + 1 - } ); - } - bindEditable = ( ref ) => { this.editable = ref; } diff --git a/tinymce-per-block/src/blocks/quote-block/form.js b/tinymce-per-block/src/blocks/quote-block/form.js index 3f349c001f8c3..41f3c28432f6b 100644 --- a/tinymce-per-block/src/blocks/quote-block/form.js +++ b/tinymce-per-block/src/blocks/quote-block/form.js @@ -22,30 +22,6 @@ export default class QuoteBlockForm extends Component { this.cite = ref; }; - merge = ( block ) => { - const acceptedBlockTypes = [ 'quote', 'text', 'heading' ]; - if ( acceptedBlockTypes.indexOf( block.blockType ) === -1 ) { - return; - } - - const getLeaves = html => { - const div = document.createElement( 'div' ); - div.innerHTML = html; - if ( div.childNodes.length === 1 && div.firstChild.nodeName === 'P' ) { - return getLeaves( div.firstChild.innerHTML ); - } - return html; - }; - - const { api, block: { content, externalChange = 0 } } = this.props; - api.focus( { input: 'content', end: true } ); - api.remove( block.uid ); - api.change( { - content: getLeaves( content ) + getLeaves( block.content ), - externalChange: externalChange + 1, - } ); - } - moveToCite = () => { this.props.api.focus( { input: 'cite', start: true } ); }; @@ -71,7 +47,7 @@ export default class QuoteBlockForm extends Component { const splitValue = ( left, right ) => { api.change( { cite: left, - externalChange: ( block.externalChange || 0 ) + 1 + citeExternalChange: ( block.citeExternalChange || 0 ) + 1 } ); api.appendBlock( { blockType: 'text', @@ -146,7 +122,7 @@ export default class QuoteBlockForm extends Component { mergeWithPrevious={ this.moveToContent } remove={ this.moveToContent } content={ block.cite } - externalChange={ block.externalChange } + externalChange={ block.citeExternalChange } splitValue={ splitValue } onChange={ ( value ) => api.change( { cite: value } ) } setToolbarState={ focusInput === 'cite' ? this.setToolbarState : undefined } diff --git a/tinymce-per-block/src/blocks/quote-block/index.js b/tinymce-per-block/src/blocks/quote-block/index.js index 3006bb9c6673a..bebd2e0b578b1 100644 --- a/tinymce-per-block/src/blocks/quote-block/index.js +++ b/tinymce-per-block/src/blocks/quote-block/index.js @@ -10,6 +10,7 @@ import { * Internal dependencies */ import form from './form'; +import { mergeInlineTextBlocks } from 'utils/state'; const createQuoteBlockWithContent = ( content = '' ) => { return { @@ -76,5 +77,14 @@ registerBlock( 'quote', { blocks: [ 'text', 'heading' ], transform: ( block ) => createQuoteBlockWithContent( block.content ) } - ] + ], + merge: [ { + blocks: [ 'quote', 'text', 'heading' ], + merge: ( state, index ) => { + const mergedState = mergeInlineTextBlocks( state, index ); + return Object.assign( {}, mergedState, { + focus: { uid: state.blocks[ index ].uid, config: { input: 'content', end: true } } + } ); + } + } ] } ); diff --git a/tinymce-per-block/src/blocks/text-block/form.js b/tinymce-per-block/src/blocks/text-block/form.js index dbd8566eacaf8..c4fd6b700eb58 100644 --- a/tinymce-per-block/src/blocks/text-block/form.js +++ b/tinymce-per-block/src/blocks/text-block/form.js @@ -13,7 +13,6 @@ import InserterButton from 'inserter/button'; export default class TextBlockForm extends Component { bindForm = ( ref ) => { this.form = ref; - this.merge = ( ...args ) => this.form.merge( ...args ); }; bindFormatToolbar = ( ref ) => { diff --git a/tinymce-per-block/src/blocks/text-block/index.js b/tinymce-per-block/src/blocks/text-block/index.js index 82a41f93c8c6b..d0d1fb4e48fa5 100644 --- a/tinymce-per-block/src/blocks/text-block/index.js +++ b/tinymce-per-block/src/blocks/text-block/index.js @@ -8,6 +8,7 @@ import { EditorParagraphIcon } from 'dashicons'; * Internal dependencies */ import form from './form'; +import { mergeInlineTextBlocks } from 'utils/state'; const createTextBlockWithContent = ( content = '' ) => { return { @@ -58,5 +59,9 @@ registerBlock( 'text', { blocks: [ 'heading', 'quote' ], transform: ( block ) => createTextBlockWithContent( block.content ) } - ] + ], + merge: [ { + blocks: [ 'text', 'quote', 'heading' ], + merge: mergeInlineTextBlocks + } ] } ); diff --git a/tinymce-per-block/src/external/wp-blocks/editable/index.js b/tinymce-per-block/src/external/wp-blocks/editable/index.js index 193e39b146b1f..844403f72d9bf 100644 --- a/tinymce-per-block/src/external/wp-blocks/editable/index.js +++ b/tinymce-per-block/src/external/wp-blocks/editable/index.js @@ -78,9 +78,13 @@ export default class EditableComponent extends Component { } focus() { - this.editor.focus(); + if ( this.props.focusConfig.bookmark ) { + return; + } const { start = false, end = false, bookmark = false } = this.props.focusConfig; + this.editor.focus(); if ( start ) { + this.editor.focus(); this.editor.selection.setCursorLocation( undefined, 0 ); } else if ( end ) { this.editor.selection.select( this.editor.getBody(), true ); @@ -140,7 +144,6 @@ export default class EditableComponent extends Component { const hasAfter = !! childNodes.slice( splitIndex ) .reduce( ( memo, node ) => memo + node.textContent, '' ); if ( before ) { - this.editor.setContent( before ); this.props.splitValue( before, hasAfter ? after : '' ); } } ); diff --git a/tinymce-per-block/src/renderers/block/block.js b/tinymce-per-block/src/renderers/block/block.js index 52763e3742a11..c8389eb9f4d03 100644 --- a/tinymce-per-block/src/renderers/block/block.js +++ b/tinymce-per-block/src/renderers/block/block.js @@ -11,18 +11,6 @@ import { getBlock } from 'wp-blocks'; import * as commands from './commands'; export default class BlockListBlock extends Component { - setRef = ( blockNode ) => { - this.blockNode = blockNode; - }; - - bindForm = ( form ) => { - this.form = form; - } - - merge = ( block, index ) => { - this.form.merge && this.form.merge( block, index ); - } - render() { const { block, isSelected, focusConfig, first, last } = this.props; const blockDefinition = getBlock( block.blockType ); @@ -47,13 +35,11 @@ export default class BlockListBlock extends Component { return (
( { export const remove = ( uid ) => ( { type: 'remove', - uid + removedUID: uid } ); export const mergeWithPrevious = () => ( { diff --git a/tinymce-per-block/src/renderers/block/index.js b/tinymce-per-block/src/renderers/block/index.js index 74a5d2fe210c2..33e0e1237bdfd 100644 --- a/tinymce-per-block/src/renderers/block/index.js +++ b/tinymce-per-block/src/renderers/block/index.js @@ -2,247 +2,107 @@ * External dependencies */ import { createElement, Component } from 'wp-elements'; -import { assign, map, uniqueId, findIndex } from 'lodash'; +import { map, debounce } from 'lodash'; import { findDOMNode } from 'react-dom'; -import { getBlock } from 'wp-blocks'; /** * Internal dependencies */ import BlockListBlock from './block'; import InserterButtonComponent from 'inserter/button'; +import stateUpdater from './updater'; class BlockList extends Component { state = { - selectedUID: null, - focusedUID: null, - focusConfig: {}, + selected: null, + focus: { uid: null }, + blocks: [], }; blockNodes = []; - content = []; + commands = []; componentDidMount() { - this.content = this.props.content; + this.setState( { blocks: this.props.content } ); } componentDidUpdate() { - this.content = this.props.content; + if ( this.props.content !== this.state.blocks ) { + this.setState( { blocks: this.props.content } ); + } } - focus = ( uid, config = {} ) => { - this.setState( { - focusedUID: uid, - focusConfig: config - } ); - }; - - select = ( uid ) => { - this.setState( { - selectedUID: uid - } ); - }; - bindBlock = ( uid ) => ( ref ) => { this.blockNodes[ uid ] = ref; }; - onChange = ( content ) => { - this.content = content; - this.props.onChange( content ); + executeCommand = ( command ) => { + this.commands.push( command ); + this.runBatchedCommands(); }; - addBlock = ( id ) => { - const newBlockUid = uniqueId(); - const blockDefinition = getBlock( id ); - const newBlock = Object.assign( { uid: newBlockUid }, blockDefinition.create() ); - const newBlocks = [ - ...this.content, - newBlock - ]; - this.onChange( newBlocks ); - this.focus( newBlockUid ); - this.select( newBlockUid ); - }; + runBatchedCommands = debounce( () => { + if ( ! this.commands.length ) { + return; + } - executeCommand = ( uid, command ) => { - const { content } = this; - const index = findIndex( content, b => b.uid === uid ); + const previousState = this.state; + const newState = this.commands.reduce( ( memo, command ) => { + return stateUpdater( memo, command ); + }, previousState ); + + // Middlewares ( Handling scroll position when moving blocks ) + let previousOffset; + const moveCommand = this.commands.reverse() + .find( command => [ 'moveBlockDown', 'moveBlockUp' ].indexOf( command.type ) !== -1 ); + const shouldUpdateScroll = !! moveCommand; + if ( shouldUpdateScroll ) { + const movedBlockNode = findDOMNode( this.blockNodes[ moveCommand.uid ] ); + previousOffset = movedBlockNode.getBoundingClientRect().top; + } - // Ignore commands for removed blocks - if ( index === -1 ) { - return; + this.setState( newState ); + + if ( shouldUpdateScroll ) { + // Restaure scrolling after moving the block + setTimeout( () => { + const destinationBlock = findDOMNode( this.blockNodes[ moveCommand.uid ] ); + window.scrollTo( + window.scrollX, + window.scrollY + destinationBlock.getBoundingClientRect().top - previousOffset + ); + } ); } - // Updating blocks - const commandHandlers = { - change: ( { changes } ) => { - const newBlocks = [ ...content ]; - newBlocks[ index ] = assign( {}, content[ index ], changes ); - this.onChange( newBlocks ); - }, - append: ( { block: commandBlock } ) => { - const createdBlock = commandBlock - ? commandBlock - : { blockType: 'text', content: ' ' }; - const appenedBlockId = uniqueId(); - this.onChange( [ - ...content.slice( 0, index + 1 ), - Object.assign( {}, createdBlock, { uid: appenedBlockId } ), - ...content.slice( index + 1 ) - ] ); - this.focus( appenedBlockId, { start: true } ); - this.select( null ); - }, - remove: ( { uid: commandUID } ) => { - const uidToRemove = commandUID === undefined ? uid : commandUID; - const indexToRemove = findIndex( content, b => b.uid === uidToRemove ); - if ( ! commandUID && indexToRemove === 0 ) { - return; - } - this.onChange( [ - ...content.slice( 0, indexToRemove ), - ...content.slice( indexToRemove + 1 ), - ] ); - if ( indexToRemove && this.state.focusedUID === uidToRemove ) { - const previousBlock = content[ indexToRemove - 1 ]; - this.focus( previousBlock.uid, { end: true } ); - } - this.select( null ); - }, - mergeWithPrevious: () => { - const previousBlock = this.content[ index - 1 ]; - if ( ! previousBlock ) { - return; - } - const previousBlockNode = this.blockNodes[ previousBlock.uid ]; - previousBlockNode.merge( content[ index ] ); - this.select( null ); - }, - focus: ( { config } ) => { - this.focus( uid, config ); - }, - moveCursorUp: () => { - const previousBlock = this.content[ index - 1 ]; - if ( previousBlock ) { - this.focus( previousBlock.uid, { end: true } ); - } - this.select( null ); - }, - moveCursorDown: () => { - const nextBlock = this.content[ index + 1 ]; - if ( nextBlock ) { - this.focus( nextBlock.uid, { start: true } ); - } - this.select( null ); - }, - select: () => { - this.select( uid ); - }, - unselect: () => { - this.select( null ); - }, - moveBlockUp: () => { - if ( index === 0 ) { - return; - } - const movedBlockNode = findDOMNode( this.blockNodes[ content[ index ].uid ] ); - const previousOffset = movedBlockNode.getBoundingClientRect().top; - const newBlocks = [ - ...content.slice( 0, index - 1 ), - content[ index ], - content[ index - 1 ], - ...content.slice( index + 1 ) - ]; - this.onChange( newBlocks ); - this.select( uid ); - // Restaure scrolling after moving the block - setTimeout( () => { - const destinationBlock = findDOMNode( this.blockNodes[ content[ index ].uid ] ); - window.scrollTo( - window.scrollX, - window.scrollY + destinationBlock.getBoundingClientRect().top - previousOffset - ); - } ); - }, - moveBlockDown: () => { - if ( index === content.length - 1 ) { - return; - } - const movedBlockNode = findDOMNode( this.blockNodes[ content[ index ].uid ] ); - const previousOffset = movedBlockNode.getBoundingClientRect().top; - const newBlocks = [ - ...content.slice( 0, index ), - content[ index + 1 ], - content[ index ], - ...content.slice( index + 2 ) - ]; - this.onChange( newBlocks ); - this.select( uid ); - // Restaure scrolling after moving the block - setTimeout( () => { - const destinationBlock = findDOMNode( this.blockNodes[ content[ index ].uid ] ); - window.scrollTo( - window.scrollX, - window.scrollY + destinationBlock.getBoundingClientRect().top - previousOffset - ); - } ); - }, - replace: ( { id } ) => { - const newBlockUid = uniqueId(); - const blockDefinition = getBlock( id ); - const newBlock = Object.assign( { uid: newBlockUid }, blockDefinition.create() ); - const newBlocks = [ - ...this.content.slice( 0, index ), - newBlock, - ...this.content.slice( index + 1 ) - ]; - this.onChange( newBlocks ); - this.focus( newBlockUid ); - }, - transform: ( { id } ) => { - const newBlockUid = uniqueId(); - const currentBlockType = content[ index ].blockType; - const blockDefinition = getBlock( id ); - const transformation = blockDefinition.transformations - .find( t => t.blocks.indexOf( currentBlockType ) !== -1 ); - if ( ! transformation ) { - return; - } - const newBlock = Object.assign( { uid: newBlockUid }, transformation.transform( content[ index ] ) ); - const newBlocks = [ - ...this.content.slice( 0, index ), - newBlock, - ...this.content.slice( index + 1 ) - ]; - this.onChange( newBlocks ); - this.focus( newBlockUid ); - } - }; + this.props.onChange( newState.blocks ); + this.commands = []; + } ); - commandHandlers[ command.type ] && commandHandlers[ command.type ]( command ); - }; + addBlock = ( id ) => { + this.executeCommand( { type: 'addBlock', id } ); + } render() { - const { content } = this.props; - const { focusedUID, focusConfig, selectedUID } = this.state; + const { blocks, focus, selected } = this.state; return (
- { map( content, ( block, index ) => { - const isFocused = block.uid === focusedUID; + { map( blocks, ( block, index ) => { + const isFocused = block.uid === focus.uid; return ( this.executeCommand( block.uid, command ) } + isSelected={ selected === block.uid } + focusConfig={ isFocused ? focus.config : null } + executeCommand={ ( command ) => + this.executeCommand( Object.assign( { uid: block.uid }, command ) ) + } block={ block } first={ index === 0 } - last={ index === content.length - 1 } + last={ index === blocks.length - 1 } /> ); } ) } diff --git a/tinymce-per-block/src/renderers/block/updater.js b/tinymce-per-block/src/renderers/block/updater.js new file mode 100644 index 0000000000000..3249bc0b5f2a8 --- /dev/null +++ b/tinymce-per-block/src/renderers/block/updater.js @@ -0,0 +1,218 @@ +/** + * External dependencies + */ +import { findIndex, uniqueId, isArray } from 'lodash'; +import { getBlock } from 'wp-blocks'; + +const blockLevelUpdater = ( state, command ) => { + const currentUID = command.uid; + const currentIndex = findIndex( state.blocks, b => b.uid === currentUID ); + // Ignore commands for removed blocks + if ( currentIndex === -1 ) { + return state; + } + const currentBlock = state.blocks[ currentIndex ]; + const mergeStates = newState => Object.assign( {}, state, newState ); + + const blockCommandHandlers = { + change: ( { changes } ) => { + const newBlocks = [ ...state.blocks ]; + newBlocks[ currentIndex ] = Object.assign( {}, currentBlock, changes ); + return mergeStates( { blocks: newBlocks } ); + }, + + append: ( { block: commandBlock } ) => { + const createdBlock = commandBlock ? commandBlock : { blockType: 'text', content: ' ' }; + const appenedBlockId = uniqueId(); + const newBlocks = [ + ...state.blocks.slice( 0, currentIndex + 1 ), + Object.assign( {}, createdBlock, { uid: appenedBlockId } ), + ...state.blocks.slice( currentIndex + 1 ) + ]; + const focus = { uid: appenedBlockId, config: { start: true } }; + const selected = null; + return mergeStates( { + blocks: newBlocks, + selected, + focus + } ); + }, + + remove: ( { removedUID: commandUID } ) => { + const uidToRemove = commandUID === undefined ? currentUID : commandUID; + const indexToRemove = findIndex( state.blocks, b => b.uid === uidToRemove ); + if ( ! commandUID && indexToRemove === 0 ) { + return state; + } + const newBlocks = [ + ...state.blocks.slice( 0, indexToRemove ), + ...state.blocks.slice( indexToRemove + 1 ), + ]; + const focus = indexToRemove && state.focus.uid === uidToRemove + ? { uid: state.blocks[ indexToRemove - 1 ].uid, config: { end: true } } + : state.focus; + const selected = null; + return mergeStates( { + blocks: newBlocks, + selected, + focus + } ); + }, + + mergeWithPrevious: () => { + const previousBlock = state.blocks[ currentIndex - 1 ]; + const previousBlockDefinition = getBlock( previousBlock.blockType ); + if ( ! previousBlock || ! previousBlockDefinition.merge ) { + return state; + } + const mergeDefinition = isArray( previousBlockDefinition.merge ) + ? previousBlockDefinition.merge.find( def => def.blocks.indexOf( currentBlock.blockType ) !== -1 ) + : previousBlockDefinition.merge; + if ( ! mergeDefinition ) { + return state; + } + const mergedState = mergeStates( mergeDefinition.merge( state, currentIndex - 1 ) ); + return Object.assign( mergedState, { selected: null } ); + }, + + focus: ( { config } ) => { + return mergeStates( { + focus: { uid: currentUID, config } + } ); + }, + + moveCursorUp: () => { + const previousBlock = state.blocks[ currentIndex - 1 ]; + return mergeStates( { + focus: previousBlock ? { uid: previousBlock.uid, config: { end: true } } : state.focus, + selected: null + } ); + }, + + moveCursorDown: () => { + const nextBlock = state.blocks[ currentIndex + 1 ]; + return mergeStates( { + focus: nextBlock ? { uid: nextBlock.uid, config: { end: true } } : state.focus, + selected: null + } ); + }, + + select: () => { + return mergeStates( { + selected: currentUID + } ); + }, + + unselect: () => { + return mergeStates( { + selected: null + } ); + }, + + moveBlockUp: () => { + if ( currentIndex === 0 ) { + return state; + } + const newBlocks = [ + ...state.blocks.slice( 0, currentIndex - 1 ), + state.blocks[ currentIndex ], + state.blocks[ currentIndex - 1 ], + ...state.blocks.slice( currentIndex + 1 ) + ]; + return mergeStates( { + blocks: newBlocks, + selected: currentUID + } ); + }, + + moveBlockDown: () => { + if ( currentIndex === state.blocks.length - 1 ) { + return state; + } + const newBlocks = [ + ...state.blocks.slice( 0, currentIndex ), + state.blocks[ currentIndex + 1 ], + state.blocks[ currentIndex ], + ...state.blocks.slice( currentIndex + 2 ) + ]; + return mergeStates( { + blocks: newBlocks, + selected: currentUID + } ); + }, + + replace: ( { id } ) => { + const newBlockUid = uniqueId(); + const blockDefinition = getBlock( id ); + const newBlock = Object.assign( { uid: newBlockUid }, blockDefinition.create() ); + const newBlocks = [ + ...state.blocks.slice( 0, currentIndex ), + newBlock, + ...state.blocks.slice( currentIndex + 1 ) + ]; + return mergeStates( { + blocks: newBlocks, + focus: { uid: newBlockUid, config: {} } + } ); + }, + + transform: ( { id } ) => { + const newBlockUid = uniqueId(); + const blockDefinition = getBlock( id ); + const transformation = blockDefinition.transformations + .find( t => t.blocks.indexOf( currentBlock.blockType ) !== -1 ); + if ( ! transformation ) { + return state; + } + const newBlock = Object.assign( { uid: newBlockUid }, transformation.transform( currentBlock ) ); + const newBlocks = [ + ...state.blocks.slice( 0, currentIndex ), + newBlock, + ...state.blocks.slice( currentIndex + 1 ) + ]; + return mergeStates( { + blocks: newBlocks, + focus: { uid: newBlockUid, config: {} } + } ); + } + }; + + if ( blockCommandHandlers[ command.type ] ) { + return blockCommandHandlers[ command.type ]( command ); + } + + return state; +}; + +const globalLevelUpdater = ( state, command ) => { + const commandHandlers = { + addBlock: ( { id } ) => { + const newBlockUID = uniqueId(); + const blockDefinition = getBlock( id ); + const newBlock = Object.assign( { uid: newBlockUID }, blockDefinition.create() ); + const newBlocks = [ + ...state.blocks, + newBlock + ]; + return Object.assign( {}, state, { + blocks: newBlocks, + focus: { uid: newBlockUID, config: {} }, + selected: newBlockUID + } ); + } + }; + + if ( commandHandlers[ command.type ] ) { + return commandHandlers[ command.type ]( command ); + } + + return state; +}; + +export default ( state, command ) => { + if ( command.uid ) { + return blockLevelUpdater( state, command ); + } + + return globalLevelUpdater( state, command ); +}; diff --git a/tinymce-per-block/src/utils/state.js b/tinymce-per-block/src/utils/state.js new file mode 100644 index 0000000000000..5c2d26d0bb2a0 --- /dev/null +++ b/tinymce-per-block/src/utils/state.js @@ -0,0 +1,34 @@ +export const removeBlockFromState = ( state, index ) => { + return Object.assign( {}, state, { + blocks: [ + ...state.blocks.slice( 0, index ), + ...state.blocks.slice( index + 1 ), + ] + } ); +}; + +export const mergeInlineTextBlocks = ( state, index ) => { + const getLeaves = html => { + const div = document.createElement( 'div' ); + div.innerHTML = html; + if ( div.childNodes.length === 1 && div.firstChild.nodeName === 'P' ) { + return getLeaves( div.firstChild.innerHTML ); + } + return html; + }; + const currentBlock = state.blocks[ index ]; + const blockToMerge = state.blocks[ index + 1 ]; + const newBlock = Object.assign( {}, currentBlock, { + content: getLeaves( currentBlock.content ) + getLeaves( blockToMerge.content ), + externalChange: ( currentBlock.externalChange || 0 ) + 1 + } ); + const newBlocks = [ + ...state.blocks.slice( 0, index ), + newBlock, + ...state.blocks.slice( index + 2 ) + ]; + return Object.assign( {}, state, { + blocks: newBlocks, + focus: { uid: newBlock.uid, config: { end: true } } + } ); +};