Skip to content

Commit

Permalink
Add EditHistory, wire META+Z to undo last change
Browse files Browse the repository at this point in the history
refs #149

  * Tests for walkAllLeafSections, undo. fixes bug in walkAllLeafSections when
    markup section follows list section
  * add FixedQueue
  * Used FixedQueue in EditHistory, undo and redo stacks
  * Editor uses undoDepth option to configure depth of undo stack
  * Document undoDepth option
  • Loading branch information
bantic authored and mixonic committed Feb 10, 2016
1 parent 00e9388 commit 5e6a3d5
Show file tree
Hide file tree
Showing 13 changed files with 607 additions and 25 deletions.
8 changes: 6 additions & 2 deletions README.md
Expand Up @@ -61,11 +61,15 @@ editor.render(element);
* `placeholder` - [string] default text to show before a user starts typing.
* `spellcheck` - [boolean] whether to enable spellcheck. Defaults to true.
* `autofocus` - [boolean] When true, focuses on the editor when it is rendered.
* `undoDepth` - [number] How many undo levels should be available. Default
value is five. Set this to zero to disable undo/redo.
* `cards` - [array] The list of cards that the editor may render
* `atoms` - [array] The list of atoms that the editor may render
* `cardOptions` - [object] Options passed to cards and atoms
* `unknownCardHandler` - [function] This will be invoked by the editor-renderer whenever it encounters an unknown card
* `unknownAtomHandler` - [function] This will be invoked by the editor-renderer whenever it encounters an unknown atom
* `unknownCardHandler` - [function] This will be invoked by the editor-renderer
whenever it encounters an unknown card
* `unknownAtomHandler` - [function] This will be invoked by the editor-renderer
whenever it encounters an unknown atom

### Editor API

Expand Down
119 changes: 119 additions & 0 deletions src/js/editor/edit-history.js
@@ -0,0 +1,119 @@
import mobiledocParsers from 'mobiledoc-kit/parsers/mobiledoc';
import Range from 'mobiledoc-kit/utils/cursor/range';
import Position from 'mobiledoc-kit/utils/cursor/position';
import FixedQueue from 'mobiledoc-kit/utils/fixed-queue';

function findLeafSectionAtIndex(post, index) {
let section;
post.walkAllLeafSections((_section, _index) => {
if (index === _index) {
section = _section;
}
});
return section;
}

export class Snapshot {
constructor(editor) {
this.mobiledoc = editor.serialize();
this.editor = editor;

this.snapshotRange();
}

snapshotRange() {
let { range, cursor } = this.editor;
if (cursor.hasCursor()) {
let { head, tail } = range;
this.range = {
head: [head.leafSectionIndex, head.offset],
tail: [tail.leafSectionIndex, tail.offset]
};
}
}

getRange(post) {
if (this.range) {
let { head, tail } = this.range;
let [headLeafSectionIndex, headOffset] = head;
let [tailLeafSectionIndex, tailOffset] = tail;
let headSection = findLeafSectionAtIndex(post, headLeafSectionIndex);
let tailSection = findLeafSectionAtIndex(post, tailLeafSectionIndex);

return new Range(new Position(headSection, headOffset),
new Position(tailSection, tailOffset));
}
}
}

export default class EditHistory {
constructor(editor, queueLength) {
this.editor = editor;
this._undoStack = new FixedQueue(queueLength);
this._redoStack = new FixedQueue(queueLength);

this._pendingSnapshot = null;
}

snapshot() {
// update the current snapshot with the range read from DOM
if (this._pendingSnapshot) {
this._pendingSnapshot.snapshotRange();
}
}

storeSnapshot() {
// store pending snapshot
if (this._pendingSnapshot) {
this._undoStack.push(this._pendingSnapshot);
this._redoStack.clear();
}

// take new pending snapshot to store next time `storeSnapshot` is called
this._pendingSnapshot = new Snapshot(this.editor);
}

stepBackward(postEditor) {
// Throw away the pending snapshot
this._pendingSnapshot = null;

let snapshot = this._undoStack.pop();
if (snapshot) {
this._redoStack.push(new Snapshot(this.editor));
this._restoreFromSnapshot(snapshot, postEditor);
}
}

stepForward(postEditor) {
let snapshot = this._redoStack.pop();
if (snapshot) {
this._undoStack.push(new Snapshot(this.editor));
this._restoreFromSnapshot(snapshot, postEditor);
}
postEditor.cancelSnapshot();
}

_restoreFromSnapshot(snapshot, postEditor) {
let { mobiledoc } = snapshot;
let { editor } = this;
let { builder, post } = editor;
let restoredPost = mobiledocParsers.parse(builder, mobiledoc);

// remove existing sections
post.sections.toArray().forEach(section => {
postEditor.removeSection(section);
});

// append restored sections
restoredPost.sections.toArray().forEach(section => {
restoredPost.sections.remove(section);
postEditor.insertSectionBefore(post.sections, section, null);
});

// resurrect snapshotted range if it exists
let newRange = snapshot.getRange(post);
if (newRange) {
postEditor.setRange(newRange);
}
}
}
39 changes: 33 additions & 6 deletions src/js/editor/editor.js
Expand Up @@ -41,6 +41,7 @@ import { TAB, SPACE } from 'mobiledoc-kit/utils/characters';
import assert from '../utils/assert';
import MutationHandler from 'mobiledoc-kit/editor/mutation-handler';
import { MOBILEDOC_VERSION } from 'mobiledoc-kit/renderers/mobiledoc';
import EditHistory from 'mobiledoc-kit/editor/edit-history';

export const EDITOR_ELEMENT_CLASS_NAME = '__mobiledoc-editor';

Expand All @@ -51,6 +52,7 @@ const defaults = {
placeholder: 'Write here...',
spellcheck: true,
autofocus: true,
undoDepth: 5,
cards: [],
atoms: [],
cardOptions: {},
Expand Down Expand Up @@ -100,6 +102,8 @@ class Editor {

this.post = this.loadPost();
this._renderTree = new RenderTree(this.post);

this._editHistory = new EditHistory(this, this.undoDepth);
}

addView(view) {
Expand Down Expand Up @@ -237,7 +241,8 @@ class Editor {
let key = Key.fromEvent(event);
this.run(postEditor => {
let nextPosition = postEditor.deleteFrom(range.head, key.direction);
postEditor.setRange(new Range(nextPosition));
let newRange = new Range(nextPosition);
postEditor.setRange(newRange);
});
}
}
Expand Down Expand Up @@ -342,7 +347,13 @@ class Editor {
currentRange = this.range;
}

this.run(() => {});
// force the current snapshot's range to remain the same rather than
// rereading it from DOM after the new character is applied and the browser
// updates the cursor position
let range = this._editHistory._pendingSnapshot.range;
this.run(() => {
this._editHistory._pendingSnapshot.range = range;
});
this.rerender();
if (currentRange) {
this.selectRange(currentRange);
Expand Down Expand Up @@ -499,9 +510,14 @@ class Editor {
run(callback) {
const postEditor = new PostEditor(this);
postEditor.begin();
this._editHistory.snapshot();
const result = callback(postEditor);
this.runCallbacks(CALLBACK_QUEUES.DID_UPDATE, [postEditor]);
postEditor.complete();
if (postEditor._shouldCancelSnapshot) {
this._editHistory._pendingSnapshot = null;
}
this._editHistory.storeSnapshot();
return result;
}

Expand Down Expand Up @@ -659,21 +675,26 @@ class Editor {
}

let shouldPreventDefault = isCollapsed && range.head.section.isCardSection;

let didEdit = false;
let isMarkerable = range.head.section.isMarkerable;
let isVisibleWhitespace = isMarkerable && (key.isTab() || key.isSpace());

this.run(postEditor => {
if (!isCollapsed) {
nextPosition = postEditor.deleteRange(range);
didEdit = true;
}

let isMarkerable = range.head.section.isMarkerable;
if (isMarkerable &&
(key.isTab() || key.isSpace())
) {
if (isVisibleWhitespace) {
let toInsert = key.isTab() ? TAB : SPACE;
shouldPreventDefault = true;
didEdit = true;
nextPosition = postEditor.insertText(nextPosition, toInsert);
}

if (nextPosition.marker && nextPosition.marker.isAtom) {
didEdit = true;
// ensure that the cursor is properly repositioned one character forward
// after typing on either side of an atom
this.addCallbackOnce(CALLBACK_QUEUES.DID_REPARSE, () => {
Expand All @@ -684,8 +705,14 @@ class Editor {
});
}
if (nextPosition && nextPosition !== range.head) {
didEdit = true;
postEditor.setRange(new Range(nextPosition));
}

if (!didEdit) {
// this ensures we don't push an empty snapshot onto the undo stack
postEditor.cancelSnapshot();
}
});
if (shouldPreventDefault) {
event.preventDefault();
Expand Down
14 changes: 14 additions & 0 deletions src/js/editor/key-commands.js
Expand Up @@ -104,6 +104,20 @@ export const DEFAULT_KEY_COMMANDS = [{
});
}
}
}, {
str: 'META+Z',
run(editor) {
editor.run(postEditor => {
postEditor.undoLastChange();
});
}
}, {
str: 'META+SHIFT+Z',
run(editor) {
editor.run(postEditor => {
postEditor.redoLastChange();
});
}
}];

function modifierNamesToMask(modiferNames) {
Expand Down
12 changes: 12 additions & 0 deletions src/js/editor/post.js
Expand Up @@ -1261,6 +1261,18 @@ class PostEditor {
// will read the dom
this.editor.range = null;
}

undoLastChange() {
this.editor._editHistory.stepBackward(this);
}

redoLastChange() {
this.editor._editHistory.stepForward(this);
}

cancelSnapshot() {
this._shouldCancelSnapshot = true;
}
}

mixin(PostEditor, LifecycleCallbacksMixin);
Expand Down
4 changes: 4 additions & 0 deletions src/js/models/list-item.js
Expand Up @@ -41,4 +41,8 @@ export default class ListItem extends Markerable {
this.markers.forEach(m => item.markers.append(m.clone()));
return item;
}

get post() {
return this.section.post;
}
}
23 changes: 11 additions & 12 deletions src/js/models/post.js
Expand Up @@ -126,14 +126,16 @@ export default class Post {
walkLeafSections(range, callback) {
const { head, tail } = range;

let index = 0;
let nextSection, shouldStop;
let currentSection = head.section;

while (currentSection) {
nextSection = this._nextLeafSection(currentSection);
shouldStop = currentSection === tail.section;

callback(currentSection);
callback(currentSection, index);
index++;

if (shouldStop) {
break;
Expand Down Expand Up @@ -164,8 +166,8 @@ export default class Post {
}
};

const headTopLevelSection = findParent(head.section, s => !!s.post);
const tailTopLevelSection = findParent(tail.section, s => !!s.post);
const headTopLevelSection = findParent(head.section, s => s.parent === s.post);
const tailTopLevelSection = findParent(tail.section, s => s.parent === s.post);

if (headTopLevelSection === tailTopLevelSection) {
return containedSections;
Expand Down Expand Up @@ -196,23 +198,20 @@ export default class Post {
if (!section) { return null; }
const hasChildren = s => !!s.items;
const firstChild = s => s.items.head;
const isChild = s => s.parent && !s.post;
const parent = s => s.parent;

// FIXME this can be refactored to use `isLeafSection`
const next = section.next;
if (next) {
if (hasChildren(next)) { // e.g. a ListSection
return firstChild(next);
} else {
return next;
}
} else {
if (isChild(section)) {
// if there is no section after this, but this section is a child
// (e.g. a ListItem inside a ListSection), check for a markerable
// section after its parent
return this._nextLeafSection(parent(section));
}
} else if (section.isNested) {
// if there is no section after this, but this section is a child
// (e.g. a ListItem inside a ListSection), check for a markerable
// section after its parent
return this._nextLeafSection(section.parent);
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/js/utils/cursor/position.js
Expand Up @@ -83,6 +83,17 @@ const Position = class Position {
return new Position(this.section, this.offset);
}

get leafSectionIndex() {
let post = this.section.post;
let leafSectionIndex;
post.walkAllLeafSections((section, index) => {
if (section === this.section) {
leafSectionIndex = index;
}
});
return leafSectionIndex;
}

get isMarkerable() {
return this.section && this.section.isMarkerable;
}
Expand Down

0 comments on commit 5e6a3d5

Please sign in to comment.