diff --git a/README.md b/README.md index 81282b69f..67c6681da 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/js/editor/edit-history.js b/src/js/editor/edit-history.js new file mode 100644 index 000000000..aed17e6fa --- /dev/null +++ b/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); + } + } +} diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index b97d11a69..9c1d3cb9d 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -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'; @@ -51,6 +52,7 @@ const defaults = { placeholder: 'Write here...', spellcheck: true, autofocus: true, + undoDepth: 5, cards: [], atoms: [], cardOptions: {}, @@ -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) { @@ -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); }); } } @@ -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); @@ -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; } @@ -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, () => { @@ -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(); diff --git a/src/js/editor/key-commands.js b/src/js/editor/key-commands.js index e1702abd5..6fb77b272 100644 --- a/src/js/editor/key-commands.js +++ b/src/js/editor/key-commands.js @@ -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) { diff --git a/src/js/editor/post.js b/src/js/editor/post.js index c0fd6d1df..3b0c7748b 100644 --- a/src/js/editor/post.js +++ b/src/js/editor/post.js @@ -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); diff --git a/src/js/models/list-item.js b/src/js/models/list-item.js index a68183cfd..7a145f107 100644 --- a/src/js/models/list-item.js +++ b/src/js/models/list-item.js @@ -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; + } } diff --git a/src/js/models/post.js b/src/js/models/post.js index 6968f8b02..b01a78633 100644 --- a/src/js/models/post.js +++ b/src/js/models/post.js @@ -126,6 +126,7 @@ export default class Post { walkLeafSections(range, callback) { const { head, tail } = range; + let index = 0; let nextSection, shouldStop; let currentSection = head.section; @@ -133,7 +134,8 @@ export default class Post { nextSection = this._nextLeafSection(currentSection); shouldStop = currentSection === tail.section; - callback(currentSection); + callback(currentSection, index); + index++; if (shouldStop) { break; @@ -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; @@ -196,9 +198,8 @@ 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 @@ -206,13 +207,11 @@ export default class Post { } 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); } } diff --git a/src/js/utils/cursor/position.js b/src/js/utils/cursor/position.js index b62ce735e..95c2ed331 100644 --- a/src/js/utils/cursor/position.js +++ b/src/js/utils/cursor/position.js @@ -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; } diff --git a/src/js/utils/fixed-queue.js b/src/js/utils/fixed-queue.js new file mode 100644 index 000000000..287bd6577 --- /dev/null +++ b/src/js/utils/fixed-queue.js @@ -0,0 +1,29 @@ +export default class FixedQueue { + constructor(length=0) { + this._maxLength = length; + this._items = []; + } + + get length() { + return this._items.length; + } + + pop() { + return this._items.pop(); + } + + push(item) { + this._items.push(item); + if (this.length > this._maxLength) { + this._items.shift(); + } + } + + clear() { + this._items = []; + } + + toArray() { + return this._items; + } +} diff --git a/tests/acceptance/editor-undo-redo-test.js b/tests/acceptance/editor-undo-redo-test.js new file mode 100644 index 000000000..331a66dfb --- /dev/null +++ b/tests/acceptance/editor-undo-redo-test.js @@ -0,0 +1,290 @@ +import { MODIFIERS } from 'mobiledoc-kit/utils/key'; +import Helpers from '../test-helpers'; +import Position from 'mobiledoc-kit/utils/cursor/position'; + +const { module, test } = Helpers; + +let editor, editorElement; + +function undo(editor) { + Helpers.dom.triggerKeyCommand(editor, 'Z', [MODIFIERS.META]); +} + +function redo(editor) { + Helpers.dom.triggerKeyCommand(editor, 'Z', [MODIFIERS.META, MODIFIERS.SHIFT]); +} + +module('Acceptance: Editor: Undo/Redo', { + beforeEach() { + editorElement = $('#editor')[0]; + }, + afterEach() { + if (editor) { + editor.destroy(); + editor = null; + } + } +}); + +test('undo/redo the insertion of a character', (assert) => { + let done = assert.async(); + let expectedBeforeUndo, expectedAfterUndo; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expectedBeforeUndo = post([markupSection('p', [marker('abcD')])]); + expectedAfterUndo = post([markupSection('p', [marker('abc')])]); + return expectedAfterUndo; + }); + + let textNode = Helpers.dom.findTextNode(editorElement, 'abc'); + Helpers.dom.moveCursorTo(textNode, 'abc'.length); + + Helpers.dom.insertText(editor, 'D'); + + setTimeout(() => { + assert.postIsSimilar(editor.post, expectedBeforeUndo); // precond + undo(editor); + assert.postIsSimilar(editor.post, expectedAfterUndo); + assert.renderTreeIsEqual(editor._renderTree, expectedAfterUndo); + + let position = editor.range.head; + assert.positionIsEqual(position, editor.post.sections.head.tailPosition()); + + redo(editor); + + assert.postIsSimilar(editor.post, expectedBeforeUndo); + assert.renderTreeIsEqual(editor._renderTree, expectedBeforeUndo); + + position = editor.range.head; + assert.positionIsEqual(position, editor.post.sections.head.tailPosition()); + + done(); + }); +}); + +// Test to ensure that we don't push empty snapshots on the undo stack +// when typing characters +test('undo/redo the insertion of multiple characters', (assert) => { + let done = assert.async(); + let beforeUndo, afterUndo1, afterUndo2; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + beforeUndo = post([markupSection('p', [marker('abcDE')])]); + afterUndo1 = post([markupSection('p', [marker('abcD')])]); + afterUndo2 = post([markupSection('p', [marker('abc')])]); + return afterUndo2; + }); + + let textNode = Helpers.dom.findTextNode(editorElement, 'abc'); + Helpers.dom.moveCursorTo(textNode, 'abc'.length); + + Helpers.dom.insertText(editor, 'D'); + + setTimeout(() => { + Helpers.dom.insertText(editor, 'E'); + + setTimeout(() => { + 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.renderInto(editorElement, ({post, markupSection, marker}) => { + expectedBeforeUndo = post([markupSection('p', [marker('abc')])]); + expectedAfterUndo = post([markupSection('p', [marker('abcD')])]); + return expectedAfterUndo; + }); + + let textNode = Helpers.dom.findTextNode(editorElement, 'abcD'); + Helpers.dom.moveCursorTo(textNode, 'abcD'.length); + + Helpers.dom.triggerDelete(editor); + + assert.postIsSimilar(editor.post, expectedBeforeUndo); // precond + + undo(editor); + assert.postIsSimilar(editor.post, expectedAfterUndo); + assert.renderTreeIsEqual(editor._renderTree, expectedAfterUndo); + let position = editor.range.head; + assert.positionIsEqual(position, editor.post.sections.head.tailPosition()); + + redo(editor); + assert.postIsSimilar(editor.post, expectedBeforeUndo); + assert.renderTreeIsEqual(editor._renderTree, expectedBeforeUndo); + position = editor.range.head; + assert.positionIsEqual(position, editor.post.sections.head.tailPosition()); +}); + +test('undo the deletion of a range', (assert) => { + let expectedBeforeUndo, expectedAfterUndo; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expectedBeforeUndo = post([markupSection('p', [marker('ad')])]); + expectedAfterUndo = post([markupSection('p', [marker('abcd')])]); + return expectedAfterUndo; + }); + + Helpers.dom.selectText('bc', editorElement); + Helpers.dom.triggerDelete(editor); + + assert.postIsSimilar(editor.post, expectedBeforeUndo); // precond + + undo(editor); + assert.postIsSimilar(editor.post, expectedAfterUndo); + assert.renderTreeIsEqual(editor._renderTree, expectedAfterUndo); + let { head, tail } = editor.range; + let section = editor.post.sections.head; + assert.positionIsEqual(head, new Position(section, 'a'.length)); + assert.positionIsEqual(tail, new Position(section, 'abc'.length)); + + redo(editor); + assert.postIsSimilar(editor.post, expectedBeforeUndo); + assert.renderTreeIsEqual(editor._renderTree, expectedBeforeUndo); + head = editor.range.head; + tail = editor.range.tail; + section = editor.post.sections.head; + assert.positionIsEqual(head, new Position(section, 'a'.length)); + assert.positionIsEqual(tail, new Position(section, 'a'.length)); +}); + +test('undo insertion of character to a list item', (assert) => { + let done = assert.async(); + let expectedBeforeUndo, expectedAfterUndo; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, listSection, listItem, marker}) => { + expectedBeforeUndo = post([ + listSection('ul', [listItem([marker('abcD')])]) + ]); + expectedAfterUndo = post([ + listSection('ul', [listItem([marker('abc')])]) + ]); + return expectedAfterUndo; + }); + + let textNode = Helpers.dom.findTextNode(editorElement, 'abc'); + Helpers.dom.moveCursorTo(textNode, 'abc'.length); + Helpers.dom.insertText(editor, 'D'); + + setTimeout(() => { + assert.postIsSimilar(editor.post, expectedBeforeUndo); // precond + + undo(editor); + assert.postIsSimilar(editor.post, expectedAfterUndo); + assert.renderTreeIsEqual(editor._renderTree, expectedAfterUndo); + let { head, tail } = editor.range; + let section = editor.post.sections.head.items.head; + assert.positionIsEqual(head, new Position(section, 'abc'.length)); + assert.positionIsEqual(tail, new Position(section, 'abc'.length)); + + redo(editor); + assert.postIsSimilar(editor.post, expectedBeforeUndo); + assert.renderTreeIsEqual(editor._renderTree, expectedBeforeUndo); + head = editor.range.head; + tail = editor.range.tail; + section = editor.post.sections.head.items.head; + assert.positionIsEqual(head, new Position(section, 'abcD'.length)); + assert.positionIsEqual(tail, new Position(section, 'abcD'.length)); + + done(); + }); +}); + +test('undo stack length can be configured', (assert) => { + let done = assert.async(); + let editorOptions = { undoDepth: 1 }; + + let beforeUndo, afterUndo; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + beforeUndo = post([markupSection('p', [marker('abcDE')])]); + afterUndo = post([markupSection('p', [marker('abcD')])]); + return post([markupSection('p', [marker('abc')])]); + }, editorOptions); + + let textNode = Helpers.dom.findTextNode(editorElement, 'abc'); + Helpers.dom.moveCursorTo(textNode, 'abc'.length); + Helpers.dom.insertText(editor, 'D'); + + setTimeout(() => { + Helpers.dom.insertText(editor, 'E'); + + setTimeout(() => { + assert.postIsSimilar(editor.post, beforeUndo); // precond + + undo(editor); + assert.postIsSimilar(editor.post, afterUndo); + assert.renderTreeIsEqual(editor._renderTree, afterUndo); + assert.positionIsEqual(editor.range.head, editor.post.sections.head.tailPosition()); + + undo(editor); + assert.postIsSimilar(editor.post, afterUndo, 'second undo does not change post'); + assert.renderTreeIsEqual(editor._renderTree, afterUndo); + assert.positionIsEqual(editor.range.head, editor.post.sections.head.tailPosition()); + + done(); + }); + }); +}); + +test('undo stack length can be configured', (assert) => { + let done = assert.async(); + let editorOptions = { undoDepth: 0 }; + + let beforeUndo; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + beforeUndo = post([markupSection('p', [marker('abcDE')])]); + return post([markupSection('p', [marker('abc')])]); + }, editorOptions); + + let textNode = Helpers.dom.findTextNode(editorElement, 'abc'); + Helpers.dom.moveCursorTo(textNode, 'abc'.length); + Helpers.dom.insertText(editor, 'D'); + + setTimeout(() => { + Helpers.dom.insertText(editor, 'E'); + + setTimeout(() => { + assert.postIsSimilar(editor.post, beforeUndo); // precond + + undo(editor); + assert.postIsSimilar(editor.post, beforeUndo, 'nothing is undone'); + assert.renderTreeIsEqual(editor._renderTree, beforeUndo); + assert.positionIsEqual(editor.range.head, editor.post.sections.head.tailPosition()); + + done(); + }); + }); + +}); + +test('taking and restoring a snapshot with no cursor', (assert) => { + let beforeUndo, afterUndo; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + beforeUndo = post([markupSection('p', [marker('abc')])]); + afterUndo = post([markupSection('p', [])]); + return afterUndo; + }, {autofocus: false}); + + assert.ok(!editor.cursor.hasCursor(), 'precond - no cursor'); + editor.run(postEditor => { + postEditor.insertText(editor.post.headPosition(), 'abc'); + }); + assert.postIsSimilar(editor.post, beforeUndo, 'precond - text is added'); + + undo(editor); + assert.postIsSimilar(editor.post, afterUndo, 'text is removed'); +}); + +// FIXME test that the queue length is respected (only 5 undoes or redoes) +// FIXME test that making a change clears the redo queue +// FIXME test undoing, redoing, undoing again diff --git a/tests/helpers/dom.js b/tests/helpers/dom.js index c18cfc881..f345f6ad7 100644 --- a/tests/helpers/dom.js +++ b/tests/helpers/dom.js @@ -1,5 +1,5 @@ import { clearSelection } from 'mobiledoc-kit/utils/selection-utils'; -import { forEach } from 'mobiledoc-kit/utils/array-utils'; +import { forEach, contains } from 'mobiledoc-kit/utils/array-utils'; import KEY_CODES from 'mobiledoc-kit/utils/keycodes'; import { DIRECTION, MODIFIERS } from 'mobiledoc-kit/utils/key'; import { isTextNode } from 'mobiledoc-kit/utils/dom-utils'; @@ -226,11 +226,15 @@ function insertText(editor, string) { // triggers a key sequence like cmd-B on the editor, to test out // registered keyCommands -function triggerKeyCommand(editor, string, modifier) { +function triggerKeyCommand(editor, string, modifiers=[]) { + if (typeof modifiers === "number") { + modifiers = [modifiers]; // convert singular to array + } let keyEvent = createMockEvent('keydown', editor.element, { keyCode: string.toUpperCase().charCodeAt(0), - metaKey: modifier === MODIFIERS.META, - ctrlKey: modifier === MODIFIERS.CTRL + shiftKey: contains(modifiers, MODIFIERS.SHIFT), + metaKey: contains(modifiers, MODIFIERS.META), + ctrlKey: contains(modifiers, MODIFIERS.CTRL) }); editor.triggerEvent(editor.element, 'keydown', keyEvent); } @@ -313,6 +317,12 @@ function fromHTML(html) { return div; } +function findTextNode(parentElement, text) { + return walkDOMUntil(parentElement, node => { + return isTextNode(node) && node.textContent.indexOf(text) !== -1; + }); +} + const DOMHelper = { moveCursorTo, selectRange, @@ -337,7 +347,8 @@ const DOMHelper = { getCopyData, setCopyData, clearCopyData, - createMockEvent + createMockEvent, + findTextNode }; export { triggerEvent }; diff --git a/tests/unit/models/post-test.js b/tests/unit/models/post-test.js index 4208784f7..fb8e30e2c 100644 --- a/tests/unit/models/post-test.js +++ b/tests/unit/models/post-test.js @@ -114,6 +114,28 @@ test('#walkMarkerableSections skips non-markerable sections', (assert) => { }); +test('#walkAllLeafSections returns markup section that follows a list section', (assert) => { + let post = Helpers.postAbstract.build(({post, markupSection, marker, listSection, listItem}) => { + return post([ + markupSection('p', [marker('abc')]), + markupSection('p', [marker('def')]), + listSection('ul', [ + listItem([marker('123')]) + ]), + markupSection('p') + ]); + }); + + let sections = []; + post.walkAllLeafSections(s => sections.push(s)); + + assert.equal(sections.length, 4); + assert.ok(sections[0] === post.sections.head, 'section 0'); + assert.ok(sections[1] === post.sections.objectAt(1), 'section 1'); + assert.ok(sections[2] === post.sections.objectAt(2).items.head, 'section 2'); + assert.ok(sections[3] === post.sections.tail, 'section 3'); +}); + test('#markupsInRange returns all markups', (assert) => { let b, i, a1, a2, found; const post = Helpers.postAbstract.build(builder => { diff --git a/tests/unit/utils/fixed-queue-test.js b/tests/unit/utils/fixed-queue-test.js new file mode 100644 index 000000000..4032fe7df --- /dev/null +++ b/tests/unit/utils/fixed-queue-test.js @@ -0,0 +1,40 @@ +import Helpers from '../../test-helpers'; +import FixedQueue from 'mobiledoc-kit/utils/fixed-queue'; + +const {module, test} = Helpers; + +module('Unit: Utils: FixedQueue'); + +test('basic implementation', (assert) => { + let queue = new FixedQueue(3); + for (let i=0; i < 3; i++) { + queue.push(i); + } + + assert.equal(queue.length, 3); + + let popped = []; + while (queue.length) { + popped.push(queue.pop()); + } + + assert.deepEqual(popped, [2,1,0]); +}); + +test('empty queue', (assert) => { + let queue = new FixedQueue(0); + assert.equal(queue.length, 0); + assert.equal(queue.pop(), undefined); + queue.push(1); + + assert.equal(queue.length, 0); + assert.deepEqual(queue.toArray(), []); +}); + +test('push onto full queue ejects first item', (assert) => { + let queue = new FixedQueue(1); + queue.push(0); + queue.push(1); + + assert.deepEqual(queue.toArray(), [1]); +});