diff --git a/components/button/index.js b/components/button/index.js index fcdb3bf145526b..3ebbddc9170683 100644 --- a/components/button/index.js +++ b/components/button/index.js @@ -4,22 +4,43 @@ import './style.scss'; import classnames from 'classnames'; -function Button( { href, target, isPrimary, isLarge, isToggled, className, disabled, ...additionalProps } ) { - const classes = classnames( 'components-button', className, { - button: ( isPrimary || isLarge ), - 'button-primary': isPrimary, - 'button-large': isLarge, - 'is-toggled': isToggled, - } ); - - const tag = href !== undefined && ! disabled ? 'a' : 'button'; - const tagProps = tag === 'a' ? { href, target } : { type: 'button', disabled }; - - return wp.element.createElement( tag, { - ...tagProps, - ...additionalProps, - className: classes, - } ); +class Button extends wp.element.Component { + constructor( props ) { + super( props ); + this.setRef = this.setRef.bind( this ); + } + + componentDidMount() { + if ( this.props.focus ) { + this.ref.focus(); + } + } + + setRef( ref ) { + this.ref = ref; + } + + render() { + const { href, target, isPrimary, isLarge, isToggled, className, disabled, ...additionalProps } = this.props; + const classes = classnames( 'components-button', className, { + button: ( isPrimary || isLarge ), + 'button-primary': isPrimary, + 'button-large': isLarge, + 'is-toggled': isToggled, + } ); + + const tag = href !== undefined && ! disabled ? 'a' : 'button'; + const tagProps = tag === 'a' ? { href, target } : { type: 'button', disabled }; + + delete additionalProps.focus; + + return wp.element.createElement( tag, { + ...tagProps, + ...additionalProps, + className: classes, + ref: this.setRef, + } ); + } } export default Button; diff --git a/components/icon-button/index.js b/components/icon-button/index.js index e4856ec8de2c6d..48ea3f49277f23 100644 --- a/components/icon-button/index.js +++ b/components/icon-button/index.js @@ -10,11 +10,11 @@ import './style.scss'; import Button from '../button'; import Dashicon from '../dashicon'; -function IconButton( { icon, children, label, className, ...additionalProps } ) { +function IconButton( { icon, children, label, className, focus, ...additionalProps } ) { const classes = classnames( 'components-icon-button', className ); return ( - diff --git a/components/toolbar/index.js b/components/toolbar/index.js index 7ec91b9579f101..687fd359a4016b 100644 --- a/components/toolbar/index.js +++ b/components/toolbar/index.js @@ -30,6 +30,7 @@ function Toolbar( { controls } ) { 'is-active': control.isActive, } ) } aria-pressed={ control.isActive } + focus={ focus && ! index } /> ) ) } diff --git a/editor/block-mover/index.js b/editor/block-mover/index.js index 3e3cb24ba83494..7f3b634eca7a7e 100644 --- a/editor/block-mover/index.js +++ b/editor/block-mover/index.js @@ -2,6 +2,7 @@ * External dependencies */ import { connect } from 'react-redux'; +import { first, last } from 'lodash'; /** * WordPress dependencies @@ -40,20 +41,20 @@ function BlockMover( { onMoveUp, onMoveDown, isFirst, isLast } ) { export default connect( ( state, ownProps ) => ( { - isFirst: isFirstBlock( state, ownProps.uid ), - isLast: isLastBlock( state, ownProps.uid ), + isFirst: isFirstBlock( state, first( ownProps.uids ) ), + isLast: isLastBlock( state, last( ownProps.uids ) ), } ), ( dispatch, ownProps ) => ( { onMoveDown() { dispatch( { - type: 'MOVE_BLOCK_DOWN', - uid: ownProps.uid, + type: 'MOVE_BLOCKS_DOWN', + uids: ownProps.uids, } ); }, onMoveUp() { dispatch( { - type: 'MOVE_BLOCK_UP', - uid: ownProps.uid, + type: 'MOVE_BLOCKS_UP', + uids: ownProps.uids, } ); }, } ) diff --git a/editor/modes/visual-editor/block-list.js b/editor/modes/visual-editor/block-list.js new file mode 100644 index 00000000000000..37b99b5b872349 --- /dev/null +++ b/editor/modes/visual-editor/block-list.js @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import { connect } from 'react-redux'; +import clickOutside from 'react-click-outside'; + +/** + * Internal dependencies + */ +import VisualEditorBlock from './block'; +import { + getBlockUids, + getBlockInsertionPoint, + getBlockSelectionStart, + getBlockSelectionEnd, +} from '../../selectors'; + +const INSERTION_POINT_PLACEHOLDER = '[[insertion-point]]'; + +class VisualEditorBlockList extends wp.element.Component { + constructor( props ) { + super( props ); + + this.onSelectionStart = this.onSelectionStart.bind( this ); + this.onSelectionChange = this.onSelectionChange.bind( this ); + this.onSelectionEnd = this.onSelectionEnd.bind( this ); + + this.state = { + selectionAtStart: null, + }; + } + + onSelectionStart( uid ) { + this.setState( { selectionAtStart: uid } ); + } + + onSelectionChange( uid ) { + const { onMultiSelect, selectionStart, selectionEnd } = this.props; + const { selectionAtStart } = this.state; + const isAtStart = selectionAtStart === uid; + + if ( ! selectionAtStart ) { + return; + } + + if ( isAtStart && selectionStart ) { + onMultiSelect( { start: null, end: null } ); + } + + if ( ! isAtStart && selectionEnd !== uid ) { + onMultiSelect( { start: selectionAtStart, end: uid } ); + } + } + + onSelectionEnd() { + this.setState( { selectionAtStart: null } ); + } + + handleClickOutside() { + this.props.clearSelectedBlock(); + } + + render() { + const { blocks, insertionPoint } = this.props; + const insertionPointIndex = blocks.indexOf( insertionPoint ); + const blocksWithInsertionPoint = insertionPoint + ? [ + ...blocks.slice( 0, insertionPointIndex + 1 ), + INSERTION_POINT_PLACEHOLDER, + ...blocks.slice( insertionPointIndex + 1 ), + ] + : blocks; + + return ( +
+ { blocksWithInsertionPoint.map( ( uid ) => { + if ( uid === INSERTION_POINT_PLACEHOLDER ) { + return ( +
+ ); + } + + return ( + this.onSelectionStart( uid ) } + onSelectionChange={ () => this.onSelectionChange( uid ) } + onSelectionEnd={ this.onSelectionEnd } + /> + ); + } ) } +
+ ); + } +} + +export default connect( + ( state ) => ( { + blocks: getBlockUids( state ), + insertionPoint: getBlockInsertionPoint( state ), + selectionStart: getBlockSelectionStart( state ), + selectionEnd: getBlockSelectionEnd( state ), + } ), + ( dispatch ) => ( { + clearSelectedBlock: () => dispatch( { type: 'CLEAR_SELECTED_BLOCK' } ), + onMultiSelect( { start, end } ) { + dispatch( { type: 'MULTI_SELECT', start, end } ); + }, + } ) +)( clickOutside( VisualEditorBlockList ) ); diff --git a/editor/modes/visual-editor/block.js b/editor/modes/visual-editor/block.js index 491e35a486140e..ddc72f48be7c07 100644 --- a/editor/modes/visual-editor/block.js +++ b/editor/modes/visual-editor/block.js @@ -19,7 +19,6 @@ import Toolbar from 'components/toolbar'; import BlockMover from '../../block-mover'; import BlockSwitcher from '../../block-switcher'; import { - deselectBlock, focusBlock, mergeBlocks, insertBlock, @@ -32,6 +31,9 @@ import { getBlockOrder, isBlockHovered, isBlockSelected, + isBlockMultiSelected, + isFirstSelectedBlock, + getSelectedBlocks, isTypingInBlock, } from '../../selectors'; @@ -60,8 +62,8 @@ class VisualEditorBlock extends wp.element.Component { componentWillReceiveProps( newProps ) { if ( this.props.order !== newProps.order && - this.props.isSelected && - newProps.isSelected + ( ( this.props.isSelected && newProps.isSelected ) || + ( this.props.isFirstSelected && newProps.isFirstSelected ) ) ) { this.previousOffset = this.node.getBoundingClientRect().top; } @@ -78,10 +80,13 @@ class VisualEditorBlock extends wp.element.Component { } maybeHover() { - const { isTyping, isHovered, onHover } = this.props; - if ( isTyping && ! isHovered ) { - onHover(); + const { isHovered, isSelected, isMultiSelected, onHover } = this.props; + + if ( isHovered || isSelected || isMultiSelected ) { + return; } + + onHover(); } maybeStartTyping() { @@ -96,20 +101,34 @@ class VisualEditorBlock extends wp.element.Component { } } - removeOrDeselect( event ) { - const { keyCode, target } = event; + removeOrDeselect( { keyCode, target } ) { + const { + uid, + selectedBlocks, + previousBlock, + onRemove, + onFocus, + onDeselect, + } = this.props; // Remove block on backspace - if ( 8 /* Backspace */ === keyCode && target === this.node ) { - this.props.onRemove( this.props.uid ); - if ( this.props.previousBlock ) { - this.props.onFocus( this.props.previousBlock.uid, { offset: -1 } ); + if ( 8 /* Backspace */ === keyCode ) { + if ( target === this.node ) { + onRemove( [ uid ] ); + + if ( previousBlock ) { + onFocus( previousBlock.uid, { offset: -1 } ); + } + } + + if ( selectedBlocks.length ) { + onRemove( selectedBlocks ); } } // Deselect on escape - if ( 27 /* Escape */ === event.keyCode ) { - this.props.onDeselect(); + if ( 27 /* Escape */ === keyCode ) { + onDeselect(); } } @@ -161,7 +180,7 @@ class VisualEditorBlock extends wp.element.Component { } render() { - const { block } = this.props; + const { block, selectedBlocks } = this.props; const settings = wp.blocks.getBlockSettings( block.blockType ); let BlockEdit; @@ -173,14 +192,15 @@ class VisualEditorBlock extends wp.element.Component { return null; } - const { isHovered, isSelected, isTyping, focus } = this.props; + const { isHovered, isSelected, isMultiSelected, isFirstSelected, isTyping, focus } = this.props; const showUI = isSelected && ( ! isTyping || ! focus.collapsed ); const className = classnames( 'editor-visual-editor__block', { 'is-selected': showUI, + 'is-multi-selected': isMultiSelected, 'is-hovered': isHovered, } ); - const { onSelect, onHover, onMouseLeave, onFocus, onInsertAfter } = this.props; + const { onSelect, onMouseLeave, onFocus, onInsertAfter } = this.props; // Determine whether the block has props to apply to the wrapper let wrapperProps; @@ -194,18 +214,24 @@ class VisualEditorBlock extends wp.element.Component { return (
{ + this.props.onSelectionChange(); + this.maybeHover(); + } } + onTouchMove={ this.props.onSelectionChange } + onMouseUp={ this.props.onSelectionEnd } + onTouchEnd={ this.props.onSelectionEnd } + onMouseEnter={ this.maybeHover } onMouseLeave={ onMouseLeave } className={ className } data-type={ block.blockType } tabIndex="0" { ...wrapperProps } > - { ( showUI || isHovered ) && } + { ( showUI || isHovered ) && } { showUI && } -
+ { isFirstSelected && ( + + ) } + { isFirstSelected && ( +
+ this.props.onRemove( selectedBlocks ), + isActive: false, + } ] } + focus={ true } + /> +
+ ) } +
+ className="editor-visual-editor" + > - { blocksWithInsertionPoint.map( ( uid ) => { - if ( uid === INSERTION_POINT_PLACEHOLDER ) { - return
; - } - return ; - } ) } +
); - /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ } - -export default connect( - ( state ) => ( { - blocks: getBlockUids( state ), - insertionPoint: getBlockInsertionPoint( state ), - } ), - ( dispatch ) => ( { - clearSelectedBlock: () => dispatch( { type: 'CLEAR_SELECTED_BLOCK' } ), - } ) )( VisualEditor ); diff --git a/editor/modes/visual-editor/style.scss b/editor/modes/visual-editor/style.scss index e0f579cb2a37fb..5a25c55cb53df2 100644 --- a/editor/modes/visual-editor/style.scss +++ b/editor/modes/visual-editor/style.scss @@ -52,6 +52,15 @@ border-color: $light-gray-500; } + &.is-multi-selected *::selection { + background: transparent; + } + + &.is-multi-selected:before { + background: $blue-medium-100; + border: 2px solid $blue-medium-200; + } + .iframe-overlay { position: relative; } diff --git a/editor/selectors.js b/editor/selectors.js index 796a76deff91f3..74bc8a260ee564 100644 --- a/editor/selectors.js +++ b/editor/selectors.js @@ -94,10 +94,40 @@ export function getBlocks( state ) { } export function getSelectedBlock( state ) { - if ( ! state.selectedBlock.uid ) { + const { uid } = state.selectedBlock; + const { start, end } = state.multiSelectedBlocks; + + if ( start || end || ! uid ) { return null; } - return state.editor.blocksByUid[ state.selectedBlock.uid ]; + + return state.editor.blocksByUid[ uid ]; +} + +export function getSelectedBlocks( state ) { + const { blockOrder } = state.editor; + const { start, end } = state.multiSelectedBlocks; + + if ( ! start || ! end ) { + return []; + } + + const startIndex = blockOrder.indexOf( start ); + const endIndex = blockOrder.indexOf( end ); + + if ( startIndex > endIndex ) { + return blockOrder.slice( endIndex, startIndex + 1 ); + } + + return blockOrder.slice( startIndex, endIndex + 1 ); +} + +export function getBlockSelectionStart( state ) { + return state.multiSelectedBlocks.start; +} + +export function getBlockSelectionEnd( state ) { + return state.multiSelectedBlocks.end; } export function getBlockUids( state ) { @@ -112,6 +142,10 @@ export function isFirstBlock( state, uid ) { return first( state.editor.blockOrder ) === uid; } +export function isFirstSelectedBlock( state, uid ) { + return first( getSelectedBlocks( state ) ) === uid; +} + export function isLastBlock( state, uid ) { return last( state.editor.blockOrder ) === uid; } @@ -127,19 +161,37 @@ export function getNextBlock( state, uid ) { } export function isBlockSelected( state, uid ) { + const { start, end } = state.multiSelectedBlocks; + + if ( start || end ) { + return null; + } + return state.selectedBlock.uid === uid; } +export function isBlockMultiSelected( state, uid ) { + return getSelectedBlocks( state ).indexOf( uid ) !== -1; +} + export function isBlockHovered( state, uid ) { return state.hoveredBlock === uid; } export function getBlockFocus( state, uid ) { - return state.selectedBlock.uid === uid ? state.selectedBlock.focus : null; + if ( ! isBlockSelected( state, uid ) ) { + return null; + } + + return state.selectedBlock.focus; } export function isTypingInBlock( state, uid ) { - return state.selectedBlock.uid === uid ? state.selectedBlock.typing : false; + if ( ! isBlockSelected( state, uid ) ) { + return false; + } + + return state.selectedBlock.typing; } export function getBlockInsertionPoint( state ) { diff --git a/editor/state.js b/editor/state.js index 57b2234aed8cb4..f99a19e829f2aa 100644 --- a/editor/state.js +++ b/editor/state.js @@ -3,7 +3,7 @@ */ import { combineReducers, applyMiddleware, createStore } from 'redux'; import refx from 'refx'; -import { keyBy, last, omit, without, flowRight } from 'lodash'; +import { keyBy, first, last, omit, without, flowRight } from 'lodash'; /** * Internal dependencies @@ -47,10 +47,10 @@ export const editor = combineUndoableReducers( { case 'UPDATE_BLOCK': case 'INSERT_BLOCK': - case 'MOVE_BLOCK_DOWN': - case 'MOVE_BLOCK_UP': + case 'MOVE_BLOCKS_DOWN': + case 'MOVE_BLOCKS_UP': case 'REPLACE_BLOCKS': - case 'REMOVE_BLOCK': + case 'REMOVE_BLOCKS': case 'EDIT_POST': return true; } @@ -89,59 +89,72 @@ export const editor = combineUndoableReducers( { }; }, omit( state, action.uids ) ); - case 'REMOVE_BLOCK': - return omit( state, action.uid ); + case 'REMOVE_BLOCKS': + return omit( state, action.uids ); } return state; }, blockOrder( state = [], action ) { - let index; - let swappedUid; switch ( action.type ) { case 'RESET_BLOCKS': return action.blocks.map( ( { uid } ) => uid ); - case 'INSERT_BLOCK': + case 'INSERT_BLOCK': { const position = action.after ? state.indexOf( action.after ) + 1 : state.length; return [ ...state.slice( 0, position ), action.block.uid, ...state.slice( position ), ]; + } - case 'MOVE_BLOCK_UP': - if ( action.uid === state[ 0 ] ) { + case 'MOVE_BLOCKS_UP': { + const firstUid = first( action.uids ); + const lastUid = last( action.uids ); + + if ( ! state.length || firstUid === first( state ) ) { return state; } - index = state.indexOf( action.uid ); - swappedUid = state[ index - 1 ]; + + const firstIndex = state.indexOf( firstUid ); + const lastIndex = state.indexOf( lastUid ); + const swappedUid = state[ firstIndex - 1 ]; + return [ - ...state.slice( 0, index - 1 ), - action.uid, + ...state.slice( 0, firstIndex - 1 ), + ...action.uids, swappedUid, - ...state.slice( index + 1 ), + ...state.slice( lastIndex + 1 ), ]; + } - case 'MOVE_BLOCK_DOWN': - if ( action.uid === last( state ) ) { + case 'MOVE_BLOCKS_DOWN': { + const firstUid = first( action.uids ); + const lastUid = last( action.uids ); + + if ( ! state.length || lastUid === last( state ) ) { return state; } - index = state.indexOf( action.uid ); - swappedUid = state[ index + 1 ]; + + const firstIndex = state.indexOf( firstUid ); + const lastIndex = state.indexOf( lastUid ); + const swappedUid = state[ lastIndex + 1 ]; + return [ - ...state.slice( 0, index ), + ...state.slice( 0, firstIndex ), swappedUid, - action.uid, - ...state.slice( index + 2 ), + ...action.uids, + ...state.slice( lastIndex + 2 ), ]; + } case 'REPLACE_BLOCKS': if ( ! action.blocks ) { return state; } - index = state.indexOf( action.uids[ 0 ] ); + return state.reduce( ( memo, uid ) => { if ( uid === action.uids[ 0 ] ) { return memo.concat( action.blocks.map( ( block ) => block.uid ) ); @@ -152,8 +165,8 @@ export const editor = combineUndoableReducers( { return memo; }, [] ); - case 'REMOVE_BLOCK': - return without( state, action.uid ); + case 'REMOVE_BLOCKS': + return without( state, ...action.uids ); } return state; @@ -204,11 +217,13 @@ export function selectedBlock( state = {}, action ) { case 'CLEAR_SELECTED_BLOCK': return {}; - case 'MOVE_BLOCK_UP': - case 'MOVE_BLOCK_DOWN': - return action.uid === state.uid + case 'MOVE_BLOCKS_UP': + case 'MOVE_BLOCKS_DOWN': { + const firstUid = first( action.uids ); + return firstUid === state.uid ? state - : { uid: action.uid, typing: false, focus: {} }; + : { uid: firstUid, typing: false, focus: {} }; + } case 'INSERT_BLOCK': return { @@ -253,6 +268,31 @@ export function selectedBlock( state = {}, action ) { return state; } +/** + * Reducer returning multi selected block state. + * + * @param {Object} state Current state + * @param {Object} action Dispatched action + * @return {Object} Updated state + */ +export function multiSelectedBlocks( state = { start: null, end: null }, action ) { + switch ( action.type ) { + case 'CLEAR_SELECTED_BLOCK': + case 'TOGGLE_BLOCK_SELECTED': + return { + start: null, + end: null, + }; + case 'MULTI_SELECT': + return { + start: action.start, + end: action.end, + }; + } + + return state; +} + /** * Reducer returning hovered block state. * @@ -272,6 +312,7 @@ export function hoveredBlock( state = null, action ) { break; case 'START_TYPING': + case 'MULTI_SELECT': return null; case 'REPLACE_BLOCKS': @@ -381,6 +422,7 @@ export function createReduxStore() { editor, currentPost, selectedBlock, + multiSelectedBlocks, hoveredBlock, insertionPoint, mode, diff --git a/editor/test/selectors.js b/editor/test/selectors.js index 2bf7623a5b9b2d..de6427a00cf600 100644 --- a/editor/test/selectors.js +++ b/editor/test/selectors.js @@ -23,6 +23,7 @@ import { getBlock, getBlocks, getSelectedBlock, + getSelectedBlocks, getBlockUids, getBlockOrder, isFirstBlock, @@ -30,6 +31,8 @@ import { getPreviousBlock, getNextBlock, isBlockSelected, + isBlockMultiSelected, + isFirstSelectedBlock, isBlockHovered, getBlockFocus, isTypingInBlock, @@ -395,6 +398,22 @@ describe( 'selectors', () => { }, }, selectedBlock: { uid: null }, + multiSelectedBlocks: {}, + }; + + expect( getSelectedBlock( state ) ).to.equal( null ); + } ); + + it( 'should return null if there is multi selection', () => { + const state = { + editor: { + blocksByUid: { + 23: { uid: 23, blockType: 'core/heading' }, + 123: { uid: 123, blockType: 'core/text' }, + }, + }, + selectedBlock: { uid: 23 }, + multiSelectedBlocks: { start: 23, end: 123 }, }; expect( getSelectedBlock( state ) ).to.equal( null ); @@ -409,12 +428,37 @@ describe( 'selectors', () => { }, }, selectedBlock: { uid: 23 }, + multiSelectedBlocks: {}, }; expect( getSelectedBlock( state ) ).to.equal( state.editor.blocksByUid[ 23 ] ); } ); } ); + describe( 'getSelectedBlocks', () => { + it( 'should return empty if there is no multi selection', () => { + const state = { + editor: { + blockOrder: [ 123, 23 ], + }, + multiSelectedBlocks: { start: null, end: null }, + }; + + expect( getSelectedBlocks( state ) ).to.eql( [] ); + } ); + + it( 'should return empty if there is no multi selection', () => { + const state = { + editor: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + multiSelectedBlocks: { start: 2, end: 4 }, + }; + + expect( getSelectedBlocks( state ) ).to.eql( [ 4, 3, 2 ] ); + } ); + } ); + describe( 'getBlockUids', () => { it( 'should return the ordered block UIDs', () => { const state = { @@ -551,6 +595,7 @@ describe( 'selectors', () => { it( 'should return true if the block is selected', () => { const state = { selectedBlock: { uid: 123 }, + multiSelectedBlocks: {}, }; expect( isBlockSelected( state, 123 ) ).to.be.true(); @@ -559,12 +604,47 @@ describe( 'selectors', () => { it( 'should return false if the block is not selected', () => { const state = { selectedBlock: { uid: 123 }, + multiSelectedBlocks: {}, }; expect( isBlockSelected( state, 23 ) ).to.be.false(); } ); } ); + describe( 'isBlockMultiSelected', () => { + const state = { + editor: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + multiSelectedBlocks: { start: 2, end: 4 }, + }; + + it( 'should return true if the block is multi selected', () => { + expect( isBlockMultiSelected( state, 3 ) ).to.be.true(); + } ); + + it( 'should return false if the block is not multi selected', () => { + expect( isBlockMultiSelected( state, 5 ) ).to.be.false(); + } ); + } ); + + describe( 'isFirstSelectedBlock', () => { + const state = { + editor: { + blockOrder: [ 5, 4, 3, 2, 1 ], + }, + multiSelectedBlocks: { start: 2, end: 4 }, + }; + + it( 'should return true if the block is first in multi selection', () => { + expect( isFirstSelectedBlock( state, 4 ) ).to.be.true(); + } ); + + it( 'should return false if the block is not first in multi selection', () => { + expect( isFirstSelectedBlock( state, 3 ) ).to.be.false(); + } ); + } ); + describe( 'isBlockHovered', () => { it( 'should return true if the block is hovered', () => { const state = { @@ -590,6 +670,7 @@ describe( 'selectors', () => { uid: 123, focus: { editable: 'cite' }, }, + multiSelectedBlocks: {}, }; expect( getBlockFocus( state, 123 ) ).to.be.eql( { editable: 'cite' } ); @@ -601,6 +682,7 @@ describe( 'selectors', () => { uid: 123, focus: { editable: 'cite' }, }, + multiSelectedBlocks: {}, }; expect( getBlockFocus( state, 23 ) ).to.be.eql( null ); @@ -614,6 +696,7 @@ describe( 'selectors', () => { uid: 123, typing: true, }, + multiSelectedBlocks: {}, }; expect( isTypingInBlock( state, 123 ) ).to.be.true(); @@ -625,6 +708,7 @@ describe( 'selectors', () => { uid: 123, typing: true, }, + multiSelectedBlocks: {}, }; expect( isTypingInBlock( state, 23 ) ).to.be.false(); diff --git a/editor/test/state.js b/editor/test/state.js index da6859330d8827..77c9671459c6f7 100644 --- a/editor/test/state.js +++ b/editor/test/state.js @@ -13,6 +13,7 @@ import { currentPost, hoveredBlock, selectedBlock, + multiSelectedBlocks, mode, isSidebarOpened, saving, @@ -131,13 +132,38 @@ describe( 'state', () => { } ], } ); const state = editor( original, { - type: 'MOVE_BLOCK_UP', - uid: 'ribs', + type: 'MOVE_BLOCKS_UP', + uids: [ 'ribs' ], } ); expect( state.blockOrder ).to.eql( [ 'ribs', 'chicken' ] ); } ); + it( 'should move multiple blocks up', () => { + const original = editor( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + blockType: 'core/test-block', + attributes: {}, + }, { + uid: 'ribs', + blockType: 'core/test-block', + attributes: {}, + }, { + uid: 'veggies', + blockType: 'core/test-block', + attributes: {}, + } ], + } ); + const state = editor( original, { + type: 'MOVE_BLOCKS_UP', + uids: [ 'ribs', 'veggies' ], + } ); + + expect( state.blockOrder ).to.eql( [ 'ribs', 'veggies', 'chicken' ] ); + } ); + it( 'should not move the first block up', () => { const original = editor( undefined, { type: 'RESET_BLOCKS', @@ -152,8 +178,8 @@ describe( 'state', () => { } ], } ); const state = editor( original, { - type: 'MOVE_BLOCK_UP', - uid: 'chicken', + type: 'MOVE_BLOCKS_UP', + uids: [ 'chicken' ], } ); expect( state.blockOrder ).to.equal( original.blockOrder ); @@ -173,13 +199,38 @@ describe( 'state', () => { } ], } ); const state = editor( original, { - type: 'MOVE_BLOCK_DOWN', - uid: 'chicken', + type: 'MOVE_BLOCKS_DOWN', + uids: [ 'chicken' ], } ); expect( state.blockOrder ).to.eql( [ 'ribs', 'chicken' ] ); } ); + it( 'should move multiple blocks down', () => { + const original = editor( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + blockType: 'core/test-block', + attributes: {}, + }, { + uid: 'ribs', + blockType: 'core/test-block', + attributes: {}, + }, { + uid: 'veggies', + blockType: 'core/test-block', + attributes: {}, + } ], + } ); + const state = editor( original, { + type: 'MOVE_BLOCKS_DOWN', + uids: [ 'chicken', 'ribs' ], + } ); + + expect( state.blockOrder ).to.eql( [ 'veggies', 'chicken', 'ribs' ] ); + } ); + it( 'should not move the last block down', () => { const original = editor( undefined, { type: 'RESET_BLOCKS', @@ -194,8 +245,8 @@ describe( 'state', () => { } ], } ); const state = editor( original, { - type: 'MOVE_BLOCK_DOWN', - uid: 'ribs', + type: 'MOVE_BLOCKS_DOWN', + uids: [ 'ribs' ], } ); expect( state.blockOrder ).to.equal( original.blockOrder ); @@ -215,8 +266,40 @@ describe( 'state', () => { } ], } ); const state = editor( original, { - type: 'REMOVE_BLOCK', - uid: 'chicken', + type: 'REMOVE_BLOCKS', + uids: [ 'chicken' ], + } ); + + expect( state.blockOrder ).to.eql( [ 'ribs' ] ); + expect( state.blocksByUid ).to.eql( { + ribs: { + uid: 'ribs', + blockType: 'core/test-block', + attributes: {}, + }, + } ); + } ); + + it( 'should remove multiple blocks', () => { + const original = editor( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + blockType: 'core/test-block', + attributes: {}, + }, { + uid: 'ribs', + blockType: 'core/test-block', + attributes: {}, + }, { + uid: 'veggies', + blockType: 'core/test-block', + attributes: {}, + } ], + } ); + const state = editor( original, { + type: 'REMOVE_BLOCKS', + uids: [ 'chicken', 'veggies' ], } ); expect( state.blockOrder ).to.eql( [ 'ribs' ] ); @@ -583,8 +666,8 @@ describe( 'state', () => { it( 'should return with block moved up', () => { const state = selectedBlock( undefined, { - type: 'MOVE_BLOCK_UP', - uid: 'ribs', + type: 'MOVE_BLOCKS_UP', + uids: [ 'ribs' ], } ); expect( state ).to.eql( { uid: 'ribs', typing: false, focus: {} } ); @@ -592,8 +675,8 @@ describe( 'state', () => { it( 'should return with block moved down', () => { const state = selectedBlock( undefined, { - type: 'MOVE_BLOCK_DOWN', - uid: 'chicken', + type: 'MOVE_BLOCKS_DOWN', + uids: [ 'chicken' ], } ); expect( state ).to.eql( { uid: 'chicken', typing: false, focus: {} } ); @@ -602,8 +685,8 @@ describe( 'state', () => { it( 'should not update the state if the block moved is already selected', () => { const original = deepFreeze( { uid: 'ribs', typing: true, focus: {} } ); const state = selectedBlock( original, { - type: 'MOVE_BLOCK_UP', - uid: 'ribs', + type: 'MOVE_BLOCKS_UP', + uids: [ 'ribs' ], } ); expect( state ).to.equal( original ); @@ -678,6 +761,34 @@ describe( 'state', () => { } ); } ); + describe( 'multiSelectedBlocks()', () => { + it( 'should set multi selection', () => { + const state = multiSelectedBlocks( undefined, { + type: 'MULTI_SELECT', + start: 'ribs', + end: 'chicken', + } ); + + expect( state ).to.eql( { start: 'ribs', end: 'chicken' } ); + } ); + + it( 'should unset multi selection', () => { + const original = deepFreeze( { start: 'ribs', end: 'chicken' } ); + + const state1 = multiSelectedBlocks( original, { + type: 'CLEAR_SELECTED_BLOCK', + } ); + + expect( state1 ).to.eql( { start: null, end: null } ); + + const state2 = multiSelectedBlocks( original, { + type: 'TOGGLE_BLOCK_SELECTED', + } ); + + expect( state2 ).to.eql( { start: null, end: null } ); + } ); + } ); + describe( 'mode()', () => { it( 'should return "visual" by default', () => { const state = mode( undefined, {} ); @@ -775,6 +886,7 @@ describe( 'state', () => { 'editor', 'currentPost', 'selectedBlock', + 'multiSelectedBlocks', 'hoveredBlock', 'mode', 'isSidebarOpened',