Skip to content

Commit

Permalink
Groups Undo
Browse files Browse the repository at this point in the history
Refs bustle#502

Basically there are three types of UNDO events, content insert, content delete, and everything else.
content insert and content delete events group together overwriting the last element in the undo queue unless another event occurs which breaks it or a timeout occurs.
So, if I write text then as long as I don't delete text or insert an atom or card, and as long as the timeout doesn't occur, those events are grouped and undo in one go.

A timeout is reset whenever the `run` method is called on the editor, so it doesn't occur on the first occurence of an event in a run but rather the last.
  • Loading branch information
disordinary committed Oct 19, 2016
1 parent 8f48cbb commit 7edaaa7
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 19 deletions.
9 changes: 7 additions & 2 deletions src/js/editor/edit-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,15 @@ export default class EditHistory {
}
}

storeSnapshot() {
storeSnapshot(overwrite) {
// store pending snapshot
if (this._pendingSnapshot) {
this._undoStack.push(this._pendingSnapshot);
// if overwrite === true then this state replaces the last state on the stack
if(overwrite) {
this._undoStack[ this._undoStack.length - 1] = this._pendingSnapshot;
} else {
this._undoStack.push(this._pendingSnapshot);
}
this._redoStack.clear();
}

Expand Down
45 changes: 40 additions & 5 deletions src/js/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ const CALLBACK_QUEUES = {
INPUT_MODE_DID_CHANGE: 'inputModeDidChange'
};

// There are only two events that we're concerned about for Undo, that is inserting text and deleting content.
// These are the only two states that go on a "run" and create a combined undo, everything else has it's own
// deadicated undo.
const EDITOR_RUN_LAST_ACTION_STATE = {
INSERT_TEXT: 1,
DELETE: 2
};

const UNDO_RUN_BREAK_TIMEOUT_LENGTH = 500; // how many ms after the last event before an UNDO run is broken

/**
* The Editor is a core component of mobiledoc-kit. After instantiating
* an editor, use {@link Editor#render} to display the editor on the web page.
Expand Down Expand Up @@ -145,6 +155,9 @@ class Editor {
DEFAULT_TEXT_INPUT_HANDLERS.forEach(handler => this.onTextInput(handler));

this.hasRendered = false;

this._undoRunLastState = null; //RYAN
this._undoRunTimeout = null;
}

/**
Expand Down Expand Up @@ -293,7 +306,7 @@ class Editor {
this.run(postEditor => {
let nextPosition = postEditor.deleteAtPosition(position, direction, {unit});
postEditor.setRange(nextPosition);
});
}, EDITOR_RUN_LAST_ACTION_STATE.DELETE);
}

/**
Expand All @@ -306,7 +319,7 @@ class Editor {
this.run(postEditor => {
let nextPosition = postEditor.deleteRange(range);
postEditor.setRange(nextPosition);
});
}, EDITOR_RUN_LAST_ACTION_STATE.DELETE);
}

/**
Expand Down Expand Up @@ -673,10 +686,32 @@ class Editor {
*
* @param {Function} callback Called with an instance of
* {@link PostEditor} as its argument.
* @param {Number|undefined} actionType provides the type of the current
* action, but only if it's a "runnable"
* type.
* @return {Mixed} The return value of `callback`.
* @public
*/
run(callback) {
run(callback, actionType) {

// Decides whether or not this current action is in a run of previous
// actions which dictates whether or not it overwrites the last item on the
// undo queue.
let isInARun = actionType && this._undoRunLastState === actionType;
this._undoRunLastState = actionType;

if(this._undoRunTimeout) {
clearTimeout(this._undoRunTimeout);
this._undoRunTimeout = null;
}

if(isInARun) {
this._undoRunTimeout = setTimeout(() => {
this._undoRunTimeout = null;
this._undoRunLastState = null;
},
UNDO_RUN_BREAK_TIMEOUT_LENGTH);
}
const postEditor = new PostEditor(this);
postEditor.begin();
this._editHistory.snapshot();
Expand All @@ -688,7 +723,7 @@ class Editor {
if (postEditor._shouldCancelSnapshot) {
this._editHistory._pendingSnapshot = null;
}
this._editHistory.storeSnapshot();
this._editHistory.storeSnapshot(isInARun);

return result;
}
Expand Down Expand Up @@ -940,7 +975,7 @@ class Editor {
}

postEditor.insertTextWithMarkup(position, text, activeMarkups);
});
}, EDITOR_RUN_LAST_ACTION_STATE.INSERT_TEXT);
}

/**
Expand Down
57 changes: 45 additions & 12 deletions tests/acceptance/editor-undo-redo-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,11 @@ test('undo/redo the insertion of a character', (assert) => {
// when typing characters
test('undo/redo the insertion of multiple characters', (assert) => {
let done = assert.async();
let beforeUndo, afterUndo1, afterUndo2;
let beforeUndo, afterUndo;
editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => {
beforeUndo = post([markupSection('p', [marker('abcDE')])]);
afterUndo1 = post([markupSection('p', [marker('abcD')])]);
afterUndo2 = post([markupSection('p', [marker('abc')])]);
return afterUndo2;
afterUndo = post([markupSection('p', [marker('abc')])]);
return afterUndo;
});

let textNode = Helpers.dom.findTextNode(editorElement, 'abc');
Expand All @@ -84,13 +83,7 @@ test('undo/redo the insertion of multiple characters', (assert) => {
assert.postIsSimilar(editor.post, beforeUndo); // precond

undo(editor);
assert.postIsSimilar(editor.post, afterUndo1);

undo(editor);
assert.postIsSimilar(editor.post, afterUndo2);

redo(editor);
assert.postIsSimilar(editor.post, afterUndo1);
assert.postIsSimilar(editor.post, afterUndo);

redo(editor);
assert.postIsSimilar(editor.post, beforeUndo);
Expand All @@ -99,6 +92,46 @@ test('undo/redo the insertion of multiple characters', (assert) => {
});
});

test('adding and deleting characters break the undo group/run', (assert) => {
let beforeUndo, afterUndo1, afterUndo2;
let done = assert.async();
editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => {
beforeUndo = post([markupSection('p', [marker('abcXY')])]);
afterUndo1 = post([markupSection('p', [marker('abc')])]);
afterUndo2 = post([markupSection('p', [marker('abcDE')])]);
return afterUndo2;
});

let textNode = Helpers.dom.findTextNode(editorElement, 'abcDE');
Helpers.dom.moveCursorTo(editor, textNode, 'abcDE'.length);

Helpers.dom.triggerDelete(editor);
Helpers.dom.triggerDelete(editor);

Helpers.dom.insertText(editor, 'X');

Helpers.wait(() => {
Helpers.dom.insertText(editor, 'Y');

Helpers.wait(() => {
assert.postIsSimilar(editor.post, beforeUndo); // precond

undo(editor);
assert.postIsSimilar(editor.post, afterUndo1);

undo(editor);
assert.postIsSimilar(editor.post, afterUndo2);

redo(editor);
assert.postIsSimilar(editor.post, afterUndo1);

redo(editor);
assert.postIsSimilar(editor.post, beforeUndo);
done();
});
});
});

test('undo the deletion of a character', (assert) => {
let expectedBeforeUndo, expectedAfterUndo;
editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => {
Expand Down Expand Up @@ -206,7 +239,7 @@ test('undo stack length can be configured (depth 1)', (assert) => {
let beforeUndo, afterUndo;
editor = Helpers.mobiledoc.renderIntoAndFocusTail(editorElement, ({post, markupSection, marker}) => {
beforeUndo = post([markupSection('p', [marker('abcDE')])]);
afterUndo = post([markupSection('p', [marker('abcD')])]);
afterUndo = post([markupSection('p', [marker('abc')])]);
return post([markupSection('p', [marker('abc')])]);
}, editorOptions);

Expand Down

0 comments on commit 7edaaa7

Please sign in to comment.