From bce86b21c58e21bf98ec9a2a0f001b152b46424e Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 19 Jul 2017 10:17:28 -0400 Subject: [PATCH 1/3] Handle undo / redo global keyboard events --- editor/actions.js | 19 +++++++++++++++++++ editor/modes/visual-editor/index.js | 24 ++++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/editor/actions.js b/editor/actions.js index e4d0fa0b241f79..73ca75e43f3565 100644 --- a/editor/actions.js +++ b/editor/actions.js @@ -133,6 +133,25 @@ export function queueAutosave() { }; } +/** + * Returns an action object used in signalling that undo history should + * restore last popped state. + * + * @return {Object} Action object + */ +export function redo() { + return { type: 'REDO' }; +} + +/** + * Returns an action object used in signalling that undo history should pop. + * + * @return {Object} Action object + */ +export function undo() { + return { type: 'UNDO' }; +} + /** * Returns an action object used in signalling that the blocks * corresponding to the specified UID set are to be removed. diff --git a/editor/modes/visual-editor/index.js b/editor/modes/visual-editor/index.js index 8feb5ddf30d6b0..5a2a5841535688 100644 --- a/editor/modes/visual-editor/index.js +++ b/editor/modes/visual-editor/index.js @@ -18,7 +18,7 @@ import './style.scss'; import VisualEditorBlockList from './block-list'; import PostTitle from '../../post-title'; import { getBlockUids } from '../../selectors'; -import { clearSelectedBlock, multiSelect } from '../../actions'; +import { clearSelectedBlock, multiSelect, redo, undo } from '../../actions'; class VisualEditor extends Component { constructor() { @@ -27,6 +27,7 @@ class VisualEditor extends Component { this.bindBlocksContainer = this.bindBlocksContainer.bind( this ); this.onClick = this.onClick.bind( this ); this.selectAll = this.selectAll.bind( this ); + this.undoOrRedo = this.undoOrRedo.bind( this ); } componentDidMount() { @@ -52,9 +53,20 @@ class VisualEditor extends Component { } selectAll( event ) { - const { uids } = this.props; + const { uids, onMultiSelect } = this.props; + event.preventDefault(); + onMultiSelect( first( uids ), last( uids ) ); + } + + undoOrRedo( event ) { + const { onRedo, onUndo } = this.props; + if ( event.shiftKey ) { + onRedo(); + } else { + onUndo(); + } + event.preventDefault(); - this.props.multiSelect( first( uids ), last( uids ) ); } render() { @@ -72,6 +84,8 @@ class VisualEditor extends Component { > @@ -89,6 +103,8 @@ export default connect( }, { clearSelectedBlock, - multiSelect, + onMultiSelect: multiSelect, + onRedo: redo, + onUndo: undo, } )( VisualEditor ); From 3c68e0f7dbbf768a55c75033a17dedf1ecc134e5 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 19 Jul 2017 10:17:56 -0400 Subject: [PATCH 2/3] Propagate undo to context for empty TinyMCE history --- blocks/editable/index.js | 35 ++++++++++++++++++++++++++++++++++- blocks/editable/provider.js | 34 ++++++++++++++++++++++++++++++++++ blocks/index.js | 1 + editor/index.js | 12 ++++++++++-- 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 blocks/editable/provider.js diff --git a/blocks/editable/index.js b/blocks/editable/index.js index ed23f2d8931cc5..38a3f769e58f6c 100644 --- a/blocks/editable/index.js +++ b/blocks/editable/index.js @@ -3,7 +3,17 @@ */ import tinymce from 'tinymce'; import classnames from 'classnames'; -import { last, isEqual, omitBy, forEach, merge, identity, find } from 'lodash'; +import { + last, + isEqual, + omitBy, + forEach, + merge, + identity, + find, + defer, + noop, +} from 'lodash'; import { nodeListToReact } from 'dom-react'; import { Fill } from 'react-slot-fill'; import 'element-closest'; @@ -54,6 +64,7 @@ export default class Editable extends Component { this.onKeyUp = this.onKeyUp.bind( this ); this.changeFormats = this.changeFormats.bind( this ); this.onSelectionChange = this.onSelectionChange.bind( this ); + this.maybePropagateUndo = this.maybePropagateUndo.bind( this ); this.onPastePostProcess = this.onPastePostProcess.bind( this ); this.state = { @@ -80,6 +91,7 @@ export default class Editable extends Component { editor.on( 'keydown', this.onKeyDown ); editor.on( 'keyup', this.onKeyUp ); editor.on( 'selectionChange', this.onSelectionChange ); + editor.on( 'BeforeExecCommand', this.maybePropagateUndo ); editor.on( 'PastePostProcess', this.onPastePostProcess ); patterns.apply( this, [ editor ] ); @@ -129,6 +141,23 @@ export default class Editable extends Component { } } + maybePropagateUndo( event ) { + const { onUndo } = this.context; + if ( onUndo && event.command === 'Undo' && ! this.editor.undoManager.hasUndo() ) { + // When user attempts Undo when empty Undo stack, propagate undo + // action to context handler. The compromise here is that: TinyMCE + // handles Undo until change, at which point `editor.save` resets + // history. If no history exists, let context handler have a turn. + // Defer in case an immediate undo causes TinyMCE to be destroyed, + // if other undo behaviors test presence of an input field. + defer( onUndo ); + + // We could return false here to stop other TinyMCE event handlers + // from running, but we assume TinyMCE won't do anything on an + // empty undo stack anyways. + } + } + onPastePostProcess( event ) { const childNodes = Array.from( event.node.childNodes ); const isBlockDelimiter = ( node ) => @@ -514,3 +543,7 @@ export default class Editable extends Component { ); } } + +Editable.contextTypes = { + onUndo: noop, +}; diff --git a/blocks/editable/provider.js b/blocks/editable/provider.js new file mode 100644 index 00000000000000..38aca7fdeaaeb5 --- /dev/null +++ b/blocks/editable/provider.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { pick, noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from 'element'; + +/** + * The Editable Provider allows a rendering context to define global behaviors + * without requiring intermediate props to be passed through to the Editable. + * The provider accepts as props its `childContextTypes` which are passed to + * any Editable instance. + */ +class EditableProvider extends Component { + getChildContext() { + return pick( + this.props, + Object.keys( this.constructor.childContextTypes ) + ); + } + + render() { + return this.props.children; + } +} + +EditableProvider.childContextTypes = { + onUndo: noop, +}; + +export default EditableProvider; diff --git a/blocks/index.js b/blocks/index.js index 9f011251f3daad..da426de0b15095 100644 --- a/blocks/index.js +++ b/blocks/index.js @@ -18,5 +18,6 @@ export { default as BlockControls } from './block-controls'; export { default as BlockDescription } from './block-description'; export { default as BlockIcon } from './block-icon'; export { default as Editable } from './editable'; +export { default as EditableProvider } from './editable/provider'; export { default as InspectorControls } from './inspector-controls'; export { default as MediaUploadButton } from './media-upload-button'; diff --git a/editor/index.js b/editor/index.js index 13961ff05cffae..8f1bac0f8d09f8 100644 --- a/editor/index.js +++ b/editor/index.js @@ -1,6 +1,7 @@ /** * External dependencies */ +import { bindActionCreators } from 'redux'; import { Provider as ReduxProvider } from 'react-redux'; import { Provider as SlotFillProvider } from 'react-slot-fill'; import moment from 'moment-timezone'; @@ -9,7 +10,7 @@ import 'moment-timezone/moment-timezone-utils'; /** * WordPress dependencies */ -import { parse } from 'blocks'; +import { EditableProvider, parse } from 'blocks'; import { render } from 'element'; import { settings } from 'date'; @@ -19,6 +20,7 @@ import { settings } from 'date'; import './assets/stylesheets/main.scss'; import Layout from './layout'; import { createReduxStore } from './state'; +import { undo } from './actions'; // Configure moment globally moment.locale( settings.l10n.locale ); @@ -86,7 +88,13 @@ export function createEditorInstance( id, post ) { render( - + + + , document.getElementById( id ) From 3eaed9a1a4a511bb4a5c1cab6cead9a4970c266b Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 19 Jul 2017 10:21:27 -0400 Subject: [PATCH 3/3] Prevent undo / redo if no past / future respectively --- editor/utils/test/undoable-reducer.js | 30 +++++++++++++++++++++++++++ editor/utils/undoable-reducer.js | 10 +++++++++ 2 files changed, 40 insertions(+) diff --git a/editor/utils/test/undoable-reducer.js b/editor/utils/test/undoable-reducer.js index 1c7a020651d0a2..9423cd8fe4f3ba 100644 --- a/editor/utils/test/undoable-reducer.js +++ b/editor/utils/test/undoable-reducer.js @@ -49,6 +49,21 @@ describe( 'undoableReducer', () => { } ); } ); + it( 'should not perform undo on empty past', () => { + const reducer = undoable( counter ); + + let state; + state = reducer( undefined, {} ); + state = reducer( state, { type: 'INCREMENT' } ); + state = reducer( state, { type: 'UNDO' } ); + + expect( state ).toEqual( { + past: [], + present: 0, + future: [ 1 ], + } ); + } ); + it( 'should perform redo', () => { const reducer = undoable( counter ); @@ -56,6 +71,21 @@ describe( 'undoableReducer', () => { state = reducer( undefined, {} ); state = reducer( state, { type: 'INCREMENT' } ); state = reducer( state, { type: 'UNDO' } ); + state = reducer( state, { type: 'UNDO' } ); + + expect( state ).toEqual( { + past: [], + present: 0, + future: [ 1 ], + } ); + } ); + + it( 'should not perform redo on empty future', () => { + const reducer = undoable( counter ); + + let state; + state = reducer( undefined, {} ); + state = reducer( state, { type: 'INCREMENT' } ); state = reducer( state, { type: 'REDO' } ); expect( state ).toEqual( { diff --git a/editor/utils/undoable-reducer.js b/editor/utils/undoable-reducer.js index 95415ff02828ce..a7d2e97681df9b 100644 --- a/editor/utils/undoable-reducer.js +++ b/editor/utils/undoable-reducer.js @@ -25,6 +25,11 @@ export function undoable( reducer, options = {} ) { switch ( action.type ) { case 'UNDO': + // Can't undo if no past + if ( ! past.length ) { + break; + } + return { past: past.slice( 0, past.length - 1 ), present: past[ past.length - 1 ], @@ -32,6 +37,11 @@ export function undoable( reducer, options = {} ) { }; case 'REDO': + // Can't redo if no future + if ( ! future.length ) { + break; + } + return { past: [ ...past, present ], present: future[ 0 ],