Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle undo / redo keyboard events #1943

Merged
merged 3 commits into from
Jul 28, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion blocks/editable/index.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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();
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of the prevent default here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of the prevent default here?

It ensures that the browser default undo behavior is overridden by our custom implementation.

It's likely browser specific, but at least in Chrome I notice some differences in how undo stacks are generated with contenteditable vs. our save intervals with Editable changes.

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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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