Skip to content

Commit

Permalink
Merge pull request #1943 from WordPress/update/627-undo-keyboard
Browse files Browse the repository at this point in the history
Handle undo / redo keyboard events
  • Loading branch information
aduth committed Jul 28, 2017
2 parents 1f970d5 + 3eaed9a commit c9a619d
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 7 deletions.
35 changes: 34 additions & 1 deletion blocks/editable/index.js
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 ] );
Expand Down Expand Up @@ -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 ) =>
Expand Down Expand Up @@ -514,3 +543,7 @@ export default class Editable extends Component {
);
}
}

Editable.contextTypes = {
onUndo: noop,
};
34 changes: 34 additions & 0 deletions 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;
1 change: 1 addition & 0 deletions blocks/index.js
Expand Up @@ -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';
19 changes: 19 additions & 0 deletions editor/actions.js
Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions 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';
Expand All @@ -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';

Expand All @@ -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 );
Expand Down Expand Up @@ -86,7 +88,13 @@ export function createEditorInstance( id, post ) {
render(
<ReduxProvider store={ store }>
<SlotFillProvider>
<Layout />
<EditableProvider {
...bindActionCreators( {
onUndo: undo,
}, store.dispatch ) }
>
<Layout />
</EditableProvider>
</SlotFillProvider>
</ReduxProvider>,
document.getElementById( id )
Expand Down
24 changes: 20 additions & 4 deletions editor/modes/visual-editor/index.js
Expand Up @@ -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() {
Expand All @@ -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() {
Expand All @@ -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() {
Expand All @@ -72,6 +84,8 @@ class VisualEditor extends Component {
>
<KeyboardShortcuts shortcuts={ {
'mod+a': this.selectAll,
'mod+z': this.undoOrRedo,
'mod+shift+z': this.undoOrRedo,
} } />
<PostTitle />
<VisualEditorBlockList ref={ this.bindBlocksContainer } />
Expand All @@ -89,6 +103,8 @@ export default connect(
},
{
clearSelectedBlock,
multiSelect,
onMultiSelect: multiSelect,
onRedo: redo,
onUndo: undo,
}
)( VisualEditor );
30 changes: 30 additions & 0 deletions editor/utils/test/undoable-reducer.js
Expand Up @@ -49,13 +49,43 @@ 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 );

let state;
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( {
Expand Down
10 changes: 10 additions & 0 deletions editor/utils/undoable-reducer.js
Expand Up @@ -25,13 +25,23 @@ 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 ],
future: [ present, ...future ],
};

case 'REDO':
// Can't redo if no future
if ( ! future.length ) {
break;
}

return {
past: [ ...past, present ],
present: future[ 0 ],
Expand Down

0 comments on commit c9a619d

Please sign in to comment.