Skip to content

Commit

Permalink
Own undo history
Browse files Browse the repository at this point in the history
  • Loading branch information
ellatrix committed Nov 13, 2018
1 parent 41eb7e2 commit 1cfd664
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 65 deletions.
95 changes: 32 additions & 63 deletions packages/editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
*/
import classnames from 'classnames';
import {
defer,
find,
isNil,
isEqual,
Expand All @@ -21,7 +20,7 @@ import {
getScrollContainer,
} from '@wordpress/dom';
import { createBlobURL } from '@wordpress/blob';
import { BACKSPACE, DELETE, ENTER, rawShortcut } from '@wordpress/keycodes';
import { BACKSPACE, DELETE, ENTER, LEFT, RIGHT, UP, DOWN } from '@wordpress/keycodes';
import { withDispatch, withSelect } from '@wordpress/data';
import { pasteHandler, children, getBlockTransforms, findTransform } from '@wordpress/blocks';
import { withInstanceId, withSafeTimeout, compose } from '@wordpress/compose';
Expand Down Expand Up @@ -59,6 +58,7 @@ import TinyMCE, { TINYMCE_ZWSP } from './tinymce';
import { pickAriaProps } from './aria';
import { getPatterns } from './patterns';
import { withBlockEditContext } from '../block-edit/context';
import { RemoveBrowserShortcuts } from './remove-browser-shortcuts';

/**
* Browser dependencies
Expand All @@ -78,7 +78,6 @@ export class RichText extends Component {
this.multilineWrapperTags = [ 'ul', 'ol' ];
}

this.onInit = this.onInit.bind( this );
this.getSettings = this.getSettings.bind( this );
this.onSetup = this.onSetup.bind( this );
this.onFocus = this.onFocus.bind( this );
Expand All @@ -87,7 +86,6 @@ export class RichText extends Component {
this.onDeleteKeyDown = this.onDeleteKeyDown.bind( this );
this.onKeyDown = this.onKeyDown.bind( this );
this.onKeyUp = this.onKeyUp.bind( this );
this.onPropagateUndo = this.onPropagateUndo.bind( this );
this.onPaste = this.onPaste.bind( this );
this.onCreateUndoLevel = this.onCreateUndoLevel.bind( this );
this.setFocusedElement = this.setFocusedElement.bind( this );
Expand Down Expand Up @@ -116,14 +114,7 @@ export class RichText extends Component {
this.state = {};

this.usedDeprecatedChildrenSource = Array.isArray( value );
}

componentDidMount() {
document.addEventListener( 'selectionchange', this.onSelectionChange );
}

componentWillUnmount() {
document.removeEventListener( 'selectionchange', this.onSelectionChange );
this.lastHistoryValue = value;
}

setRef( node ) {
Expand Down Expand Up @@ -170,11 +161,7 @@ export class RichText extends Component {
onSetup( editor ) {
this.editor = editor;

editor.on( 'init', this.onInit );
editor.on( 'nodechange', this.onNodeChange );
editor.on( 'BeforeExecCommand', this.onPropagateUndo );
// The change event in TinyMCE fires every time an undo level is added.
editor.on( 'change', this.onCreateUndoLevel );

const { unstableOnSetup } = this.props;
if ( unstableOnSetup ) {
Expand All @@ -188,35 +175,6 @@ export class RichText extends Component {
}
}

onInit() {
this.editor.shortcuts.add( rawShortcut.primary( 'z' ), '', 'Undo' );
this.editor.shortcuts.add( rawShortcut.primaryShift( 'z' ), '', 'Redo' );

// Remove TinyMCE Core shortcut for consistency with global editor
// shortcuts. Also clashes with Mac browsers.
this.editor.shortcuts.remove( 'meta+y', '', 'Redo' );
}

/**
* Handles an undo event from TinyMCE.
*
* @param {UndoEvent} event The undo event as triggered by TinyMCE.
*/
onPropagateUndo( event ) {
const { onUndo, onRedo } = this.props;
const { command } = event;

if ( command === 'Undo' && onUndo ) {
defer( onUndo );
event.preventDefault();
}

if ( command === 'Redo' && onRedo ) {
defer( onRedo );
event.preventDefault();
}
}

/**
* Get the current record (value and selection) from props and state.
*
Expand Down Expand Up @@ -418,7 +376,9 @@ export class RichText extends Component {
const record = this.createRecord();
const transformed = this.patterns.reduce( ( accumlator, transform ) => transform( accumlator ), record );

this.onChange( transformed );
this.onChange( transformed, {
withoutHistory: true,
} );
}

/**
Expand Down Expand Up @@ -452,7 +412,7 @@ export class RichText extends Component {
* @param {boolean} _withoutApply If true, the record won't be applied to
* the live DOM.
*/
onChange( record, _withoutApply ) {
onChange( record, { withoutHistory, _withoutApply } = {} ) {
if ( ! _withoutApply ) {
this.applyRecord( record );
}
Expand All @@ -462,28 +422,20 @@ export class RichText extends Component {
this.savedContent = this.valueToFormat( record );
this.props.onChange( this.savedContent );
this.setState( { start, end } );
}

onCreateUndoLevel( event ) {
// TinyMCE fires a `change` event when the first letter in an instance
// is typed. This should not create a history record in Gutenberg.
// https://github.com/tinymce/tinymce/blob/4.7.11/src/core/main/ts/api/UndoManager.ts#L116-L125
// In other cases TinyMCE won't fire a `change` with at least a previous
// record present, so this is a reliable check.
// https://github.com/tinymce/tinymce/blob/4.7.11/src/core/main/ts/api/UndoManager.ts#L272-L275
if ( event && event.lastLevel === null ) {
return;
if ( ! withoutHistory ) {
this.onCreateUndoLevel();
}
}

// Always ensure the content is up-to-date. This is needed because e.g.
// making something bold will trigger a TinyMCE change event but no
// input event. Avoid dispatching an action if the original event is
// blur because the content will already be up-to-date.
if ( ! event || ! event.originalEvent || event.originalEvent.type !== 'blur' ) {
this.onChange( this.createRecord(), true );
onCreateUndoLevel() {
// If the content is the same, no level needs to be created.
if ( this.lastHistoryValue === this.savedContent ) {
return;
}

this.props.onCreateUndoLevel();
this.lastHistoryValue = this.savedContent;
}

/**
Expand Down Expand Up @@ -630,6 +582,10 @@ export class RichText extends Component {
this.splitContent();
}
}

if ( [ LEFT, RIGHT, UP, DOWN ].indexOf( keyCode ) >= 0 ) {
this.onCreateUndoLevel();
}
}

/**
Expand Down Expand Up @@ -810,6 +766,18 @@ export class RichText extends Component {
const record = this.formatToValue( value );
this.applyRecord( record );
}

if ( isSelected && ! prevProps.isSelected ) {
document.addEventListener( 'selectionchange', this.onSelectionChange );
window.addEventListener( 'mousedown', this.onCreateUndoLevel );
window.addEventListener( 'touchstart', this.onCreateUndoLevel );
}

if ( ! isSelected && prevProps.isSelected ) {
document.removeEventListener( 'selectionchange', this.onSelectionChange );
window.removeEventListener( 'mousedown', this.onCreateUndoLevel );
window.removeEventListener( 'touchstart', this.onCreateUndoLevel );
}
}

formatToValue( value ) {
Expand Down Expand Up @@ -957,6 +925,7 @@ export class RichText extends Component {
</Fragment>
) }
</Autocomplete>
{ isSelected && <RemoveBrowserShortcuts /> }
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* External dependencies
*/
import { fromPairs } from 'lodash';

/**
* WordPress dependencies
*/
import { rawShortcut } from '@wordpress/keycodes';
import { KeyboardShortcuts } from '@wordpress/components';

/**
* Set of keyboard shortcuts handled internally by RichText.
*
* @type {Array}
*/
const HANDLED_SHORTCUTS = [
rawShortcut.primary( 'z' ),
rawShortcut.primaryShift( 'z' ),
rawShortcut.primary( 'y' ),
];

/**
* An instance of a KeyboardShortcuts element pre-bound for the handled
* shortcuts. Since shortcuts never change, the element can be considered
* static, and can be skipped in reconciliation.
*
* @type {WPElement}
*/
const SHORTCUTS_ELEMENT = (
<KeyboardShortcuts
bindGlobal
shortcuts={ fromPairs( HANDLED_SHORTCUTS.map( ( shortcut ) => {
return [ shortcut, ( event ) => event.preventDefault() ];
} ) ) }
/>
);

/**
* Component which registered keyboard event handlers to prevent default
* behaviors for key combinations otherwise handled internally by RichText.
*
* @return {WPElement} WordPress element.
*/
export const RemoveBrowserShortcuts = () => SHORTCUTS_ELEMENT;
14 changes: 13 additions & 1 deletion packages/editor/src/components/rich-text/tinymce.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,14 +211,26 @@ export default class TinyMCE extends Component {
} );

editor.on( 'init', () => {
// See https://github.com/tinymce/tinymce/blob/master/src/core/main/ts/keyboard/FormatShortcuts.ts
// History is handled internally by RichText.
//
// See: https://github.com/tinymce/tinymce/blob/master/src/core/main/ts/api/UndoManager.ts
[ 'z', 'y' ].forEach( ( character ) => {
editor.shortcuts.remove( `meta+${ character }` );
} );
editor.shortcuts.remove( 'meta+shift+z' );

// Reset TinyMCE's default formatting shortcuts, since
// RichText supports only registered formats.
//
// See: https://github.com/tinymce/tinymce/blob/master/src/core/main/ts/keyboard/FormatShortcuts.ts
[ 'b', 'i', 'u' ].forEach( ( character ) => {
editor.shortcuts.remove( `meta+${ character }` );
} );
[ 1, 2, 3, 4, 5, 6, 7, 8, 9 ].forEach( ( number ) => {
editor.shortcuts.remove( `access+${ number }` );
} );

// Restore the original `setHTML` once initialized.
editor.dom.setHTML = setHTML;
} );

Expand Down
36 changes: 36 additions & 0 deletions test/e2e/specs/__snapshots__/undo.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,39 @@ exports[`undo Should undo to expected level intervals 1`] = `
<p>test</p>
<!-- /wp:paragraph -->"
`;

exports[`undo should undo typing after arrow navigation 1`] = `
"<!-- wp:paragraph -->
<p>before keyboar after keyboardd</p>
<!-- /wp:paragraph -->"
`;

exports[`undo should undo typing after arrow navigation 2`] = `
"<!-- wp:paragraph -->
<p>before keyboard</p>
<!-- /wp:paragraph -->"
`;

exports[`undo should undo typing after mouse move 1`] = `
"<!-- wp:paragraph -->
<p>before move after move</p>
<!-- /wp:paragraph -->"
`;

exports[`undo should undo typing after mouse move 2`] = `
"<!-- wp:paragraph -->
<p>before move</p>
<!-- /wp:paragraph -->"
`;

exports[`undo should undo typing after non input change 1`] = `
"<!-- wp:paragraph -->
<p>before keyboard <strong>after keyboard</strong></p>
<!-- /wp:paragraph -->"
`;

exports[`undo should undo typing after non input change 2`] = `
"<!-- wp:paragraph -->
<p>before keyboard </p>
<!-- /wp:paragraph -->"
`;
44 changes: 43 additions & 1 deletion test/e2e/specs/undo.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,52 @@ import {
} from '../support/utils';

describe( 'undo', () => {
beforeAll( async () => {
beforeEach( async () => {
await newPost();
} );

it( 'should undo typing after mouse move', async () => {
await clickBlockAppender();

await page.keyboard.type( 'before move' );
await page.mouse.down();
await page.keyboard.type( ' after move' );

expect( await getEditedPostContent() ).toMatchSnapshot();

await pressWithModifier( META_KEY, 'z' );

expect( await getEditedPostContent() ).toMatchSnapshot();
} );

it( 'should undo typing after non input change', async () => {
await clickBlockAppender();

await page.keyboard.type( 'before keyboard ' );
await pressWithModifier( META_KEY, 'b' );
await page.keyboard.type( 'after keyboard' );

expect( await getEditedPostContent() ).toMatchSnapshot();

await pressWithModifier( META_KEY, 'z' );

expect( await getEditedPostContent() ).toMatchSnapshot();
} );

it( 'should undo typing after arrow navigation', async () => {
await clickBlockAppender();

await page.keyboard.type( 'before keyboard' );
await page.keyboard.press( 'ArrowLeft' );
await page.keyboard.type( ' after keyboard' );

expect( await getEditedPostContent() ).toMatchSnapshot();

await pressWithModifier( META_KEY, 'z' );

expect( await getEditedPostContent() ).toMatchSnapshot();
} );

it( 'Should undo to expected level intervals', async () => {
await clickBlockAppender();

Expand Down

0 comments on commit 1cfd664

Please sign in to comment.