From 2ff05908fbe03ff48f3da16523825bb2c73383aa Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Wed, 29 Jun 2016 17:33:50 -0400 Subject: [PATCH] Add SelectionChangeObserver, use it for editor.range updates SelectionChangeObserver maintains a singleton instance that polls the window using `requestAnimationFrame` for changes to the selection. Editors' EventManager instances use a SelectionManager to listen for `selectionchanged` events, and update the editor's `range` if necessary. Removes code that would use some keyup events (when `key.isMovement()`) and `mouseup` events to detect when the range could have changed. The range-detection code was previously spread out over `EventManager`, `Editor`, `Cursor` and `EditState`. This code change consolidates most of the responsibility for knowing/reading/updating the editor's `range` o its `EditState` instance. The editor now delegates `range` to the edit state instance, and the edit state instance is responsible for knowing when the editor's inputMode or range has changed. Some tests assumed selection changes would be picked up synchronously; for those tests a `Helpers.wait` helper is added that schedules a callback with `requestAnimationFrame`. Also: * use "hidepassed" for sauce tests to improve test failure debugging * remove unused `editor.selectSections` * remove now-unnecessary key commands for meta+arrow on mac --- src/js/editor/edit-state.js | 47 +++-- src/js/editor/editor.js | 103 +++------- src/js/editor/event-manager.js | 62 +++--- src/js/editor/key-commands.js | 14 -- src/js/editor/post.js | 4 +- src/js/editor/selection-change-observer.js | 99 ++++++++++ src/js/editor/selection-manager.js | 31 +++ src/js/utils/key.js | 12 +- src/js/views/view.js | 2 +- testem-ci.json | 2 +- tests/acceptance/basic-editor-test.js | 36 ++-- tests/acceptance/editor-atoms-test.js | 46 +++-- tests/acceptance/editor-cards-test.js | 8 +- tests/acceptance/editor-drag-drop-test.js | 10 + tests/acceptance/editor-key-commands-test.js | 128 ++++--------- tests/acceptance/editor-list-test.js | 4 +- tests/acceptance/editor-post-editor-test.js | 8 +- tests/acceptance/editor-reparse-test.js | 24 +-- tests/acceptance/editor-sections-test.js | 8 +- tests/acceptance/editor-selections-test.js | 50 ++--- tests/acceptance/editor-undo-redo-test.js | 24 +-- tests/helpers/dom.js | 4 +- tests/helpers/mobiledoc.js | 1 + tests/helpers/wait.js | 5 + tests/test-helpers.js | 4 +- tests/unit/editor/atom-lifecycle-test.js | 6 +- tests/unit/editor/card-lifecycle-test.js | 2 +- tests/unit/editor/editor-events-test.js | 191 +++++++++---------- tests/unit/editor/editor-test.js | 21 +- tests/unit/editor/post-test.js | 64 ++++--- tests/unit/editor/post/insert-post-test.js | 2 +- tests/unit/parsers/dom-test.js | 10 +- tests/unit/utils/cursor-position-test.js | 2 +- tests/unit/utils/key-test.js | 20 -- 34 files changed, 565 insertions(+), 489 deletions(-) create mode 100644 src/js/editor/selection-change-observer.js create mode 100644 src/js/editor/selection-manager.js create mode 100644 tests/helpers/wait.js diff --git a/src/js/editor/edit-state.js b/src/js/editor/edit-state.js index 047a9ae05..fd3b44b82 100644 --- a/src/js/editor/edit-state.js +++ b/src/js/editor/edit-state.js @@ -1,4 +1,5 @@ import { contains, isArrayEqual } from 'mobiledoc-kit/utils/array-utils'; +import Range from 'mobiledoc-kit/utils/cursor/range'; /** * Used by {@link Editor} to manage its current state (cursor, active markups @@ -8,15 +9,34 @@ import { contains, isArrayEqual } from 'mobiledoc-kit/utils/array-utils'; class EditState { constructor(editor) { this.editor = editor; - this.prevState = this.state = this._readState(); + + let defaultState = { + range: Range.blankRange(), + activeMarkups: [], + activeSections: [], + activeSectionTagNames: [] + }; + + this.prevState = this.state = defaultState; + } + + updateRange(newRange) { + this.prevState = this.state; + this.state = this._readState(newRange); + } + + destroy() { + this.editor = null; + this.prevState = this.state = null; } /** - * Cache the last state, force a reread of current state + * @return {Boolean} */ - reset() { - this.prevState = this.state; - this.state = this._readState(); + rangeDidChange() { + let { state: { range } , prevState: {range: prevRange} } = this; + + return !prevRange.isEqual(range); } /** @@ -29,14 +49,6 @@ class EditState { !isArrayEqual(state.activeSectionTagNames, prevState.activeSectionTagNames)); } - /** - * @return {Boolean} Whether the range has changed. - */ - rangeDidChange() { - let { state, prevState } = this; - return !state.range.isEqual(prevState.range); - } - /** * @return {Range} */ @@ -72,10 +84,9 @@ class EditState { } } - _readState() { - let range = this._readRange(); + _readState(range) { let state = { - range: range, + range, activeMarkups: this._readActiveMarkups(range), activeSections: this._readActiveSections(range) }; @@ -89,10 +100,6 @@ class EditState { return state; } - _readRange() { - return this.editor.range; - } - _readActiveSections(range) { let { head, tail } = range; let { editor: { post } } = this; diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index 63bfa4b0d..c1695eea7 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -247,11 +247,12 @@ class Editor { this.hasRendered = true; this.rerender(); - if (this.autofocus) { - this.element.focus(); - } this._mutationHandler.init(); this._eventManager.init(); + + if (this.autofocus) { + this.selectRange(new Range(this.post.headPosition())); + } } _addTooltip() { @@ -350,18 +351,6 @@ class Editor { this.runCallbacks(CALLBACK_QUEUES.POST_DID_CHANGE); } - selectSections(sections=[]) { - if (sections.length) { - let headSection = sections[0], - tailSection = sections[sections.length - 1]; - this.selectRange(new Range(headSection.headPosition(), - tailSection.tailPosition())); - } else { - this.cursor.clearSelection(); - } - this._reportSelectionState(); - } - /** * Selects the given range. If range is collapsed, this positions the cursor * at the range's position, otherwise a selection is created in the editor @@ -369,15 +358,8 @@ class Editor { * @param {Range} */ selectRange(range) { - this.renderRange(range); - } - - /** - * @private - */ - renderRange(range) { this.cursor.selectRange(range); - this._notifyRangeChange(); + this.range = range; } get cursor() { @@ -386,44 +368,29 @@ class Editor { /** * Return the current range for the editor (may be cached). - * The #_resetRange method forces a re-read of - * the range from DOM. * @return {Range} */ get range() { - if (this._range) { - return this._range; - } - let range = this.cursor.offsets; - if (!range.isBlank) { // do not cache blank ranges - this._range = range; - } - return range; + return this._editState.range; } - /** - * Used to notify the editor that the range (or state) may - * have changed (e.g. in response to a mouseup or keyup) and - * that the editor should re-read values from DOM and fire the - * necessary callbacks - * @private - */ - _notifyRangeChange() { - if (this.isEditable) { - this._resetRange(); - this._editState.reset(); + set range(newRange) { + this._editState.updateRange(newRange); - if (this._editState.rangeDidChange()) { - this._rangeDidChange(); - } - if (this._editState.inputModeDidChange()) { - this._inputModeDidChange(); - } + if (this._editState.rangeDidChange()) { + this._rangeDidChange(); + } + + if (this._editState.inputModeDidChange()) { + this._inputModeDidChange(); } } - _resetRange() { - delete this._range; + _readRangeFromDOM() { + if (!this.isEditable) { + return; + } + this.range = this.cursor.offsets; } setPlaceholder(placeholder) { @@ -610,7 +577,7 @@ class Editor { * @public */ destroy() { - this._isDestroyed = true; + this.isDestroyed = true; if (this.hasCursor()) { this.cursor.clearSelection(); this.element.blur(); // FIXME This doesn't blur the element on IE11 @@ -619,6 +586,7 @@ class Editor { this._eventManager.destroy(); this.removeAllViews(); this._renderer.destroy(); + this._editState.destroy(); } /** @@ -628,10 +596,13 @@ class Editor { * @public */ disableEditing() { + if (this.isEditable === false) { return; } + this.isEditable = false; - if (this.element) { + if (this.hasRendered) { this.element.setAttribute('contentEditable', false); this.setPlaceholder(''); + this.selectRange(Range.blankRange()); } } @@ -705,11 +676,12 @@ class Editor { const result = callback(postEditor); this.runCallbacks(CALLBACK_QUEUES.DID_UPDATE, [postEditor]); postEditor.complete(); + this._readRangeFromDOM(); + if (postEditor._shouldCancelSnapshot) { this._editHistory._pendingSnapshot = null; } this._editHistory.storeSnapshot(); - this._notifyRangeChange(); return result; } @@ -784,24 +756,7 @@ class Editor { this.addCallback(CALLBACK_QUEUES.CURSOR_DID_CHANGE, callback); } - /* - The following events/sequences can create a selection and are handled: - * mouseup -- can happen anywhere in document, must wait until next tick to read selection - * keyup when key is a movement key and shift is pressed -- in editor element - * keyup when key combo was cmd-A (alt-A) aka "select all" - * keyup when key combo was cmd-Z (browser may restore selection) - These cases can create a selection and are not handled: - * ctrl-click -> context menu -> click "select all" - */ - _reportSelectionState() { - this._cursorDidChange(); - } - _rangeDidChange() { - this._cursorDidChange(); - } - - _cursorDidChange() { if (this.hasRendered) { this.runCallbacks(CALLBACK_QUEUES.CURSOR_DID_CHANGE); } @@ -830,7 +785,7 @@ class Editor { * @see PostEditor#toggleMarkup */ toggleMarkup(markup) { - markup = this.post.builder.createMarkup(markup); + markup = this.builder.createMarkup(markup); let { range } = this; if (range.isCollapsed) { this._editState.toggleMarkupState(markup); @@ -1021,7 +976,7 @@ class Editor { } runCallbacks(...args) { - if (this._isDestroyed) { + if (this.isDestroyed) { // TODO warn that callback attempted after editor was destroyed return; } diff --git a/src/js/editor/event-manager.js b/src/js/editor/event-manager.js index 97d474219..d5ed3c06e 100644 --- a/src/js/editor/event-manager.js +++ b/src/js/editor/event-manager.js @@ -5,15 +5,15 @@ import { parsePostFromDrop } from 'mobiledoc-kit/utils/parse-utils'; import Range from 'mobiledoc-kit/utils/cursor/range'; -import { filter, forEach, contains } from 'mobiledoc-kit/utils/array-utils'; +import { filter, forEach } from 'mobiledoc-kit/utils/array-utils'; import Key from 'mobiledoc-kit/utils/key'; import { TAB } from 'mobiledoc-kit/utils/characters'; import TextInputHandler from 'mobiledoc-kit/editor/text-input-handler'; +import SelectionManager from 'mobiledoc-kit/editor/selection-manager'; const ELEMENT_EVENT_TYPES = [ 'keydown', 'keyup', 'cut', 'copy', 'paste', 'keypress', 'drop' ]; -const DOCUMENT_EVENT_TYPES = ['mouseup']; export default class EventManager { constructor(editor) { @@ -22,6 +22,9 @@ export default class EventManager { this._textInputHandler = new TextInputHandler(editor); this._listeners = []; this.isShift = false; + + this._selectionManager = new SelectionManager( + this.editor, this.selectionDidChange.bind(this)); } init() { @@ -32,9 +35,7 @@ export default class EventManager { this._addListener(element, type); }); - DOCUMENT_EVENT_TYPES.forEach(type => { - this._addListener(document, type); - }); + this._selectionManager.start(); } registerInputHandler(inputHandler) { @@ -53,6 +54,7 @@ export default class EventManager { this._listeners.forEach(([context, type, listener]) => { context.removeEventListener(type, listener); }); + this._listeners = []; } // This is primarily useful for programmatically simulating events on the @@ -70,22 +72,43 @@ export default class EventManager { destroy() { this._textInputHandler.destroy(); + this._selectionManager.destroy(); this._removeListeners(); - this._listeners = []; } _handleEvent(type, event) { - let { editor } = this; + let {target: element} = event; + if (!this.isElementAddressable(element)) { + // abort handling this event + return true; + } + + this[type](event); + } - if (contains(ELEMENT_EVENT_TYPES, type)) { - let {target: element} = event; - if (!editor.cursor.isAddressable(element)) { - // abort handling this event - return true; + isElementAddressable(element) { + return this.editor.cursor.isAddressable(element); + } + + selectionDidChange(selection /*, prevSelection */) { + let shouldNotify = true; + let { anchorNode } = selection; + if (!this.isElementAddressable(anchorNode)) { + if (!this.editor.range.isBlank) { + // Selection changed from something addressable to something + // not-addressable -- e.g., blur event, user clicked outside editor, + // etc + shouldNotify = true; + } else { + // selection changes wholly outside the editor should not trigger + // change notifications + shouldNotify = false; } } - this[type](event); + if (shouldNotify) { + this.editor._readRangeFromDOM(); + } } keypress(event) { @@ -121,7 +144,8 @@ export default class EventManager { let range = editor.range; switch(true) { - case key.isHorizontalArrow(): + // FIXME This should be restricted to only card/atom boundaries + case key.isHorizontalArrowWithoutModifiersOtherThanShift(): let newRange; if (key.isShift()) { newRange = range.extend(key.direction * 1); @@ -153,11 +177,6 @@ export default class EventManager { if (key.isShiftKey()) { this.isShift = false; } - - // Only movement-related keys require re-checking the active range - if (key.isMovement()) { - setTimeout(() => this.editor._notifyRangeChange()); - } } cut(event) { @@ -201,11 +220,6 @@ export default class EventManager { }); } - mouseup(/* event */) { - // mouseup does not correctly report a selection until the next tick - setTimeout(() => this.editor._notifyRangeChange()); - } - drop(event) { event.preventDefault(); diff --git a/src/js/editor/key-commands.js b/src/js/editor/key-commands.js index ddc6dff06..eba4cf1b1 100644 --- a/src/js/editor/key-commands.js +++ b/src/js/editor/key-commands.js @@ -68,13 +68,6 @@ export const DEFAULT_KEY_COMMANDS = [{ selectAll(editor); } } -}, { - str: 'META+LEFT', - run(editor) { - if (Browser.isMac) { - gotoStartOfLine(editor); - } - } }, { str: 'META+A', run(editor) { @@ -89,13 +82,6 @@ export const DEFAULT_KEY_COMMANDS = [{ gotoEndOfLine(editor); } } -}, { - str: 'META+RIGHT', - run(editor) { - if (Browser.isMac) { - gotoEndOfLine(editor); - } - } }, { str: 'META+K', run(editor) { diff --git a/src/js/editor/post.js b/src/js/editor/post.js index fde4299b3..e5c3e6106 100644 --- a/src/js/editor/post.js +++ b/src/js/editor/post.js @@ -41,7 +41,7 @@ class PostEditor { this._didComplete = false; - this._renderRange = () => this.editor.renderRange(this._range); + this._renderRange = () => this.editor.selectRange(this._range); this._postDidChange = () => this.editor._postDidChange(); this._rerender = () => this.editor.rerender(); } @@ -1344,8 +1344,6 @@ class PostEditor { this._didComplete = true; this.runCallbacks(CALLBACK_QUEUES.COMPLETE); this.runCallbacks(CALLBACK_QUEUES.AFTER_COMPLETE); - - this.editor._notifyRangeChange(); } undoLastChange() { diff --git a/src/js/editor/selection-change-observer.js b/src/js/editor/selection-change-observer.js new file mode 100644 index 000000000..8c37411f4 --- /dev/null +++ b/src/js/editor/selection-change-observer.js @@ -0,0 +1,99 @@ +let instance; + +class SelectionChangeObserver { + constructor() { + this.started = false; + this.listeners = []; + this.selection = {}; + } + + static getInstance() { + if (!instance) { + instance = new SelectionChangeObserver(); + } + return instance; + } + + static addListener(listener) { + SelectionChangeObserver.getInstance().addListener(listener); + } + + addListener(listener) { + if (this.listeners.indexOf(listener) === -1) { + this.listeners.push(listener); + this.start(); + } + } + + static removeListener(listener) { + SelectionChangeObserver.getInstance().removeListener(listener); + } + + removeListener(listener) { + let index = this.listeners.indexOf(listener); + if (index !== -1) { + this.listeners.splice(index, 1); + if (this.listeners.length === 0) { + this.stop(); + } + } + } + + start() { + if (this.started) { return; } + this.started = true; + + this.poll(); + } + + stop() { + this.started = false; + this.selection = {}; + } + + notifyListeners(/* newSelection, prevSelection */) { + this.listeners.forEach(listener => { + listener.selectionDidChange(...arguments); + }); + } + + destroy() { + this.stop(); + this.listeners = []; + } + + getSelection() { + let selection = window.getSelection(); + let { anchorNode, focusNode, anchorOffset, focusOffset } = selection; + return { anchorNode, focusNode, anchorOffset, focusOffset }; + } + + poll() { + if (this.started) { + this.update(); + this.runNext(() => this.poll()); + } + } + + runNext(fn) { + window.requestAnimationFrame(fn); + } + + update() { + let prevSelection = this.selection; + let curSelection = this.getSelection(); + if (!this.selectionIsEqual(prevSelection, curSelection)) { + this.selection = curSelection; + this.notifyListeners(curSelection, prevSelection); + } + } + + selectionIsEqual(s1, s2) { + return s1.anchorNode === s2.anchorNode && + s1.anchorOffset === s2.anchorOffset && + s1.focusNode === s2.focusNode && + s1.focusOffset === s2.focusOffset; + } +} + +export default SelectionChangeObserver; diff --git a/src/js/editor/selection-manager.js b/src/js/editor/selection-manager.js new file mode 100644 index 000000000..72a8beb2d --- /dev/null +++ b/src/js/editor/selection-manager.js @@ -0,0 +1,31 @@ +import SelectionChangeObserver from 'mobiledoc-kit/editor/selection-change-observer'; + +export default class SelectionManager { + constructor(editor, callback) { + this.editor = editor; + this.callback = callback; + this.started = false; + } + + start() { + if (this.started) { return; } + + SelectionChangeObserver.addListener(this); + this.started = true; + } + + stop() { + this.started = false; + SelectionChangeObserver.removeListener(this); + } + + destroy() { + this.stop(); + } + + selectionDidChange() { + if (this.started) { + this.callback(...arguments); + } + } +} diff --git a/src/js/utils/key.js b/src/js/utils/key.js index 01844a050..3b692e797 100644 --- a/src/js/utils/key.js +++ b/src/js/utils/key.js @@ -90,17 +90,18 @@ const Key = class Key { return this.keyCode === Keycodes.DELETE; } - isMovement() { - return this.isArrow() || this.isHome() || this.isEnd(); - } - isArrow() { return this.isHorizontalArrow() || this.isVerticalArrow(); } isHorizontalArrow() { return this.keyCode === Keycodes.LEFT || - this.keyCode === Keycodes.RIGHT; + this.keyCode === Keycodes.RIGHT; + } + + isHorizontalArrowWithoutModifiersOtherThanShift() { + return this.isHorizontalArrow() && + !(this.ctrlKey || this.metaKey || this.altKey); } isVerticalArrow() { @@ -209,6 +210,7 @@ const Key = class Key { } return ( + code !== 0 || this.toString().length > 0 || (code >= Keycodes['0'] && code <= Keycodes['9']) || // number keys this.isSpace() || diff --git a/src/js/views/view.js b/src/js/views/view.js index 7248dd960..a356095b1 100644 --- a/src/js/views/view.js +++ b/src/js/views/view.js @@ -44,7 +44,7 @@ class View { destroy() { this.removeAllEventListeners(); this.hide(); - this._isDestroyed = true; + this.isDestroyed = true; } } diff --git a/testem-ci.json b/testem-ci.json index c915491a2..ac2ed76de 100644 --- a/testem-ci.json +++ b/testem-ci.json @@ -1,7 +1,7 @@ { "framework": "qunit", "parallel": 6, - "test_page": "dist/tests/index.html", + "test_page": "dist/tests/index.html?hidepassed", "on_start": "./sauce_labs/saucie-connect.js", "on_exit": "./sauce_labs/saucie-disconnect.js", "port": 8080, diff --git a/tests/acceptance/basic-editor-test.js b/tests/acceptance/basic-editor-test.js index 29edf5695..66f0ba343 100644 --- a/tests/acceptance/basic-editor-test.js +++ b/tests/acceptance/basic-editor-test.js @@ -87,7 +87,7 @@ test('clicking outside the editor does not raise an error', (assert) => { Helpers.dom.triggerEvent(editorElement, 'click'); - setTimeout(() => { + Helpers.wait(() => { assert.ok(true, 'can click external item without error'); secondEditor.destroy(); document.body.removeChild(secondEditorElement); @@ -161,7 +161,7 @@ test('typing tab enters a tab character', (assert) => { Helpers.dom.moveCursorTo(editor, $('#editor')[0]); Helpers.dom.insertText(editor, TAB); Helpers.dom.insertText(editor, 'Y'); - window.setTimeout(() => { + Helpers.wait(() => { let expectedPost = Helpers.postAbstract.build(({post, markupSection, marker}) => { return post([ markupSection('p', [ @@ -191,13 +191,17 @@ test('select-all and type text works ok', (assert) => { assert.selectedText('abc', 'precond - abc is selected'); assert.hasElement('#editor p:contains(abc)', 'precond - renders p'); + + Helpers.wait(() => { + Helpers.dom.insertText(editor, 'X'); + + Helpers.wait(function() { + assert.hasNoElement('#editor p:contains(abc)', 'replaces existing text'); + assert.hasElement('#editor p:contains(X)', 'inserts text'); + done(); + }); + }); - Helpers.dom.insertText(editor, 'X'); - setTimeout(function() { - assert.hasNoElement('#editor p:contains(abc)', 'replaces existing text'); - assert.hasElement('#editor p:contains(X)', 'inserts text'); - done(); - }, 0); }); test('typing enter splits lines, sets cursor', (assert) => { @@ -214,7 +218,7 @@ test('typing enter splits lines, sets cursor', (assert) => { Helpers.dom.moveCursorTo(editor, $('#editor p')[0].firstChild, 2); Helpers.dom.insertText(editor, ENTER); - window.setTimeout(() => { + Helpers.wait(() => { let expectedPost = Helpers.postAbstract.build(({post, markupSection, marker}) => { return post([ markupSection('p', [ @@ -229,7 +233,7 @@ test('typing enter splits lines, sets cursor', (assert) => { let expectedRange = new Range(new Position(editor.post.sections.tail, 0)); assert.ok(expectedRange.isEqual(editor.range), 'range is at start of new section'); done(); - }, 0); + }); }); // see https://github.com/bustlelabs/mobiledoc-kit/issues/306 @@ -264,6 +268,7 @@ test('adding/removing bold text between two bold markers works', (assert) => { }); test('keypress events when the editor does not have selection are ignored', (assert) => { + let done = assert.async(); let expected; editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { expected = post([markupSection('p', [marker('abc')])]); @@ -274,10 +279,11 @@ test('keypress events when the editor does not have selection are ignored', (ass Helpers.dom.clearSelection(); - assert.ok(document.activeElement === editorElement, 'precond - editor is focused'); - assert.equal(window.getSelection().rangeCount, 0, 'nothing selected'); - - Helpers.dom.insertText(editor, 'v'); + Helpers.wait(() => { + assert.ok(!editor.hasCursor(), 'precond - editor does not have cursor'); + Helpers.dom.insertText(editor, 'v'); - assert.postIsSimilar(editor.post, expected, 'post is not changed'); + assert.postIsSimilar(editor.post, expected, 'post is not changed'); + done(); + }); }); diff --git a/tests/acceptance/editor-atoms-test.js b/tests/acceptance/editor-atoms-test.js index 884c866e0..a9df3e31c 100644 --- a/tests/acceptance/editor-atoms-test.js +++ b/tests/acceptance/editor-atoms-test.js @@ -59,7 +59,7 @@ test('keystroke of character before starting atom inserts character', (assert) = editor.selectRange(new Range(editor.post.headPosition())); Helpers.dom.insertText(editor, 'A'); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); assert.renderTreeIsEqual(editor._renderTree, expected); done(); @@ -78,7 +78,7 @@ test('keystroke of character before mid-text atom inserts character', (assert) = editor.selectRange(Range.create(editor.post.sections.head, 'AB'.length)); Helpers.dom.insertText(editor, 'C'); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); assert.renderTreeIsEqual(editor._renderTree, expected); done(); @@ -97,7 +97,7 @@ test('keystroke of character after mid-text atom inserts character', (assert) => editor.selectRange(Range.create(editor.post.sections.head, 1)); Helpers.dom.insertText(editor, 'A'); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); assert.renderTreeIsEqual(editor._renderTree, expected); done(); @@ -116,7 +116,7 @@ test('keystroke of character after end-text atom inserts character', (assert) => editor.selectRange(Range.create(editor.post.sections.head, 1)); Helpers.dom.insertText(editor, 'A'); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); assert.renderTreeIsEqual(editor._renderTree, expected); done(); @@ -291,26 +291,34 @@ test('keystroke of enter at list item head before atom creates new section', (as }); test('marking atom with markup adds markup', (assert) => { + assert.expect(1); + let done = assert.async(); + editor = new Editor({mobiledoc: mobiledocWithAtom, atoms: [simpleAtom]}); editor.render(editorElement); let pNode = $('#editor p')[0]; Helpers.dom.selectRange(pNode.firstChild, 16, pNode.lastChild, 0); - editor.run(postEditor => { - let markup = editor.builder.createMarkup('strong'); - postEditor.addMarkupToRange(editor.range, markup); - }); - assert.postIsSimilar(editor.post, Helpers.postAbstract.build( - ({post, markupSection, atom, marker, markup}) => { - return post([ - markupSection('p', [ - marker('text before atom'), - atom('simple-atom', 'Bob', {}, [markup('strong')]), - marker('text after atom') - ]) - ]); - })); + Helpers.wait(() => { + editor.run(postEditor => { + let markup = editor.builder.createMarkup('strong'); + postEditor.addMarkupToRange(editor.range, markup); + }); + + assert.postIsSimilar(editor.post, Helpers.postAbstract.build( + ({post, markupSection, atom, marker, markup}) => { + return post([ + markupSection('p', [ + marker('text before atom'), + atom('simple-atom', 'Bob', {}, [markup('strong')]), + marker('text after atom') + ]) + ]); + })); + + done(); + }); }); test('typing between two atoms inserts character', (assert) => { @@ -335,7 +343,7 @@ test('typing between two atoms inserts character', (assert) => { Helpers.dom.insertText(editor, 'A'); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); assert.renderTreeIsEqual(editor._renderTree, expected); done(); diff --git a/tests/acceptance/editor-cards-test.js b/tests/acceptance/editor-cards-test.js index f8e063843..b09672e3d 100644 --- a/tests/acceptance/editor-cards-test.js +++ b/tests/acceptance/editor-cards-test.js @@ -87,7 +87,7 @@ test('editor listeners are quieted for card actions', (assert) => { Helpers.dom.selectText(editor ,cardText, editorElement); Helpers.dom.triggerEvent(document, 'mouseup'); - setTimeout(() => { + Helpers.wait(() => { // FIXME should have a better assertion here assert.ok(true, 'made it here with no javascript errors'); done(); @@ -113,7 +113,7 @@ test('removing last card from mobiledoc allows additional editing', (assert) => button.click(); - setTimeout(() => { + Helpers.wait(() => { assert.hasNoElement('#editor button:contains(Click me)', 'button is removed'); assert.hasNoElement('#editor p'); Helpers.dom.moveCursorTo(editor, $('#editor')[0]); @@ -213,7 +213,9 @@ test('selecting a card and deleting deletes the card', (assert) => { assert.hasElement('#my-simple-card', 'precond - renders card'); assert.hasNoElement('#editor p', 'precond - has no markup section'); - editor.selectSections([editor.post.sections.head]); + let range = new Range(editor.post.sections.head.headPosition(), + editor.post.sections.head.tailPosition()); + editor.selectRange(range); Helpers.dom.triggerDelete(editor); assert.hasNoElement('#my-simple-card', 'has no card after delete'); diff --git a/tests/acceptance/editor-drag-drop-test.js b/tests/acceptance/editor-drag-drop-test.js index 26f61ce3e..08d56386c 100644 --- a/tests/acceptance/editor-drag-drop-test.js +++ b/tests/acceptance/editor-drag-drop-test.js @@ -20,6 +20,16 @@ function findCenterPointOfTextNode(node) { module('Acceptance: editor: drag-drop', { beforeEach() { editorElement = $('#editor')[0]; + + /** + * `document.elementFromPoint` return `null` if the element is outside the + * viewpor, so force the editor element to in the viewport for this test suite + */ + $(editorElement).css({ + position: 'fixed', + top: '100px', + left: '100px' + }); }, afterEach() { if (editor) { diff --git a/tests/acceptance/editor-key-commands-test.js b/tests/acceptance/editor-key-commands-test.js index e89528a23..601512592 100644 --- a/tests/acceptance/editor-key-commands-test.js +++ b/tests/acceptance/editor-key-commands-test.js @@ -1,6 +1,5 @@ import { MODIFIERS } from 'mobiledoc-kit/utils/key'; import Keycodes from 'mobiledoc-kit/utils/keycodes'; -import Browser from 'mobiledoc-kit/utils/browser'; import Helpers from '../test-helpers'; import Range from 'mobiledoc-kit/utils/cursor/range'; @@ -49,7 +48,7 @@ function testStatefulCommand({modifierName, key, command, markupName}) { Helpers.dom.triggerKeyCommand(editor, key, modifier); Helpers.dom.triggerKeyEvent(editor, 'keyup', {charCode: 0, keyCode: modifierKeyCode}); - setTimeout(() => { + Helpers.wait(() => { assert.hasElement(`#editor ${markupName}:contains(${initialText})`, `text wrapped in ${markupName}`); done(); @@ -74,47 +73,52 @@ function testStatefulCommand({modifierName, key, command, markupName}) { Helpers.dom.moveCursorTo(editor, editor.post.sections.head.markers.head.renderNode.element, initialText.length); - Helpers.dom.triggerKeyCommand(editor, key, modifier); - // simulate meta/ctrl keyup - Helpers.dom.triggerKeyEvent(editor, 'keyup', { charCode: 0, keyCode: modifierKeyCode}); - - setTimeout(() => { - Helpers.dom.insertText(editor, 'z'); - - let expected1 = Helpers.postAbstract.build(({post, markupSection, marker, markup}) => { - return post([ - markupSection('p', [ - marker(initialText), - marker('z', [markup(markupName)]) - ]) - ]); - }); - let expected2 = Helpers.postAbstract.build(({post, markupSection, marker, markup}) => { - return post([ - markupSection('p', [ - marker(initialText), - marker('z', [markup(markupName)]), - marker('x') - ]) - ]); - }); - assert.postIsSimilar(editor.post, expected1); - assert.renderTreeIsEqual(editor._renderTree, expected1); - assert.positionIsEqual(editor.range.head, editor.post.tailPosition()); - - // un-toggles markup + Helpers.wait(() => { Helpers.dom.triggerKeyCommand(editor, key, modifier); - Helpers.dom.triggerKeyEvent(editor, 'keyup', {charCode: 0, keyCode: modifierKeyCode}); + // simulate meta/ctrl keyup + Helpers.dom.triggerKeyEvent(editor, 'keyup', { charCode: 0, keyCode: modifierKeyCode}); + + Helpers.wait(() => { + Helpers.dom.insertText(editor, 'z'); + + let expected1 = Helpers.postAbstract.build(({post, markupSection, marker, markup}) => { + return post([ + markupSection('p', [ + marker(initialText), + marker('z', [markup(markupName)]) + ]) + ]); + }); + let expected2 = Helpers.postAbstract.build(({post, markupSection, marker, markup}) => { + return post([ + markupSection('p', [ + marker(initialText), + marker('z', [markup(markupName)]), + marker('x') + ]) + ]); + }); + + assert.postIsSimilar(editor.post, expected1); + assert.renderTreeIsEqual(editor._renderTree, expected1); + assert.positionIsEqual(editor.range.head, editor.post.tailPosition()); - setTimeout(() => { - Helpers.dom.insertText(editor, 'x'); + Helpers.wait(() => { + // un-toggles markup + Helpers.dom.triggerKeyCommand(editor, key, modifier); + Helpers.dom.triggerKeyEvent(editor, 'keyup', {charCode: 0, keyCode: modifierKeyCode}); - assert.postIsSimilar(editor.post, expected2); - assert.renderTreeIsEqual(editor._renderTree, expected2); - assert.positionIsEqual(editor.range.head, editor.post.tailPosition()); + Helpers.wait(() => { + Helpers.dom.insertText(editor, 'x'); + + assert.postIsSimilar(editor.post, expected2); + assert.renderTreeIsEqual(editor._renderTree, expected2); + assert.positionIsEqual(editor.range.head, editor.post.tailPosition()); - done(); + done(); + }); + }); }); }); }); @@ -148,54 +152,6 @@ testStatefulCommand({ markupName: 'em' }); -test(`cmd-left goes to the beginning of a line (MacOS only)`, (assert) => { - let initialText = 'something'; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ - markupSection('p', [marker(initialText)]) - ])); - - assert.ok(editor.hasCursor(), 'has cursor'); - - let textElement = editor.post.sections.head.markers.head.renderNode.element; - - Helpers.dom.moveCursorTo(editor, textElement, 4); - let originalCursorPosition = Helpers.dom.getCursorPosition(); - Helpers.dom.triggerKeyCommand(editor, 'LEFT', MODIFIERS.META); - - let changedCursorPosition = Helpers.dom.getCursorPosition(); - let expectedCursorPosition = 0; // beginning of text - - if (Browser.isMac) { - assert.equal(changedCursorPosition.offset, expectedCursorPosition, 'cursor moved to the beginning of the line on MacOS'); - } else { - assert.equal(changedCursorPosition.offset, originalCursorPosition.offset, 'cursor not moved to the end of the line (non-MacOS)'); - } -}); - -test(`cmd-right goes to the end of a line (MacOS only)`, (assert) => { - let initialText = 'something'; - editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ - markupSection('p', [marker(initialText)]) - ])); - - assert.ok(editor.hasCursor(), 'has cursor'); - - let textElement = editor.post.sections.head.markers.head.renderNode.element; - - Helpers.dom.moveCursorTo(editor, textElement, 4); - let originalCursorPosition = Helpers.dom.getCursorPosition(); - Helpers.dom.triggerKeyCommand(editor, 'RIGHT', MODIFIERS.META); - - let changedCursorPosition = Helpers.dom.getCursorPosition(); - let expectedCursorPosition = initialText.length; // end of text - - if (Browser.isMac) { - assert.equal(changedCursorPosition.offset, expectedCursorPosition, 'cursor moved to the end of the line on MacOS'); - } else { - assert.equal(changedCursorPosition.offset, originalCursorPosition.offset, 'cursor not moved to the end of the line (non-MacOS)'); - } -}); - test(`ctrl-k clears to the end of a line`, (assert) => { let initialText = 'something'; editor = renderIntoAndFocusTail(({post, markupSection, marker}) => post([ diff --git a/tests/acceptance/editor-list-test.js b/tests/acceptance/editor-list-test.js index 85823f38c..73a9c8a8e 100644 --- a/tests/acceptance/editor-list-test.js +++ b/tests/acceptance/editor-list-test.js @@ -149,7 +149,7 @@ test('can hit enter at end of list item to add new item', (assert) => { assert.equal(newLi.text(), '', 'new li has no text'); Helpers.dom.insertText(editor, 'X'); - setTimeout(() => { + Helpers.wait(() => { assert.hasElement('#editor li:contains(X)', 'text goes in right spot'); const liCount = $('#editor li').length; @@ -410,7 +410,7 @@ test('selecting empty list items does not cause error', (assert) => { Helpers.dom.moveCursorTo(editor, $('#editor li:eq(1)')[0], 0, $('#editor li:eq(2)')[0], 0); Helpers.dom.triggerEvent(editor.element, 'click'); - setTimeout(() => { + Helpers.wait(() => { assert.ok(true, 'no error'); Helpers.dom.insertText(editor, 'X'); diff --git a/tests/acceptance/editor-post-editor-test.js b/tests/acceptance/editor-post-editor-test.js index 7c56e1bd6..7538c112c 100644 --- a/tests/acceptance/editor-post-editor-test.js +++ b/tests/acceptance/editor-post-editor-test.js @@ -60,17 +60,17 @@ test('#insertSection inserts after the cursor active section', (assert) => { test('#insertSection inserts at end when no active cursor section', (assert) => { let newSection; - const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => { + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { newSection = markupSection('p', [marker('123')]); return post([ markupSection('p', [marker('abc')]), markupSection('p', [marker('def')]) ]); - }); - editor = new Editor({mobiledoc}); - editor.render(editorElement); + }, {autofocus: false}); //precond + assert.ok(!editor.hasCursor(), 'editor has no cursor'); + assert.ok(editor.range.isBlank, 'editor has no cursor'); assert.hasElement('#editor p:eq(0):contains(abc)'); assert.hasElement('#editor p:eq(1):contains(def)'); assert.hasNoElement('#editor p:contains(123)'); diff --git a/tests/acceptance/editor-reparse-test.js b/tests/acceptance/editor-reparse-test.js index cc0474949..e63639c7f 100644 --- a/tests/acceptance/editor-reparse-test.js +++ b/tests/acceptance/editor-reparse-test.js @@ -42,7 +42,7 @@ test('changing text node content causes reparse of section', (assert) => { node.textContent = 'def'; - setTimeout(() => { + Helpers.wait(() => { assert.equal(section.text, 'def', 'section reparsed correctly'); assert.postIsSimilar(editor.post, expected); done(); @@ -66,7 +66,7 @@ test('removing text node causes reparse of section', (assert) => { node.parentNode.removeChild(node); - setTimeout(() => { + Helpers.wait(() => { assert.equal(section.text, 'def', 'section reparsed correctly'); assert.postIsSimilar(editor.post, expected); done(); @@ -90,7 +90,7 @@ test('removing section node causes reparse of post', (assert) => { node.parentNode.removeChild(node); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); done(); }); @@ -115,7 +115,7 @@ test('inserting styled span in section causes section reparse', (assert) => { span.appendChild(document.createTextNode('def')); node.appendChild(span); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); done(); }); @@ -137,7 +137,7 @@ test('inserting new top-level node causes reparse of post', (assert) => { span.appendChild(document.createTextNode('123')); editorElement.appendChild(span); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); done(); }); @@ -156,7 +156,7 @@ test('inserting node into blank post causes reparse', (assert) => { span.appendChild(document.createTextNode('123')); editorElement.appendChild(span); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); done(); }); @@ -183,7 +183,7 @@ test('after reparsing post, mutations still handled properly', (assert) => { span.appendChild(document.createTextNode('123')); editorElement.appendChild(span); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected1); let node = editorElement.firstChild.firstChild; @@ -191,7 +191,7 @@ test('after reparsing post, mutations still handled properly', (assert) => { node.textContent = 'def'; - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected2); done(); @@ -221,7 +221,7 @@ test('inserting text into text node on left/right of atom is reparsed correctly' 'precond - correct right cursor node'); rightCursorNode.textContent = 'Z'; - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected1); assert.renderTreeIsEqual(editor._renderTree, expected1); @@ -230,7 +230,7 @@ test('inserting text into text node on left/right of atom is reparsed correctly' 'precond - correct left cursor node'); leftCursorNode.textContent = 'A'; - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected2); assert.renderTreeIsEqual(editor._renderTree, expected2); @@ -266,8 +266,8 @@ test('mutation inside card element does not cause reparse', (assert) => { textNode.textContent = 'adios'; // Allow the mutation observer to fire then... - setTimeout(function() { + Helpers.wait(function() { assert.equal(0, parseCount); done(); - }, 0); + }); }); diff --git a/tests/acceptance/editor-sections-test.js b/tests/acceptance/editor-sections-test.js index 627b63a7b..2387215b0 100644 --- a/tests/acceptance/editor-sections-test.js +++ b/tests/acceptance/editor-sections-test.js @@ -433,7 +433,7 @@ test('when selection incorrectly contains P end tag, editor reports correct sele secondSectionTextNode, 0); Helpers.dom.triggerEvent(document, 'mouseup'); - setTimeout(() => { + Helpers.wait(() => { assert.ok(true, 'No error should occur'); let { @@ -471,7 +471,7 @@ test('when selection incorrectly contains P start tag, editor reports correct se secondSectionPNode, 0); Helpers.dom.triggerEvent(document, 'mouseup'); - setTimeout(() => { + Helpers.wait(() => { assert.ok(true, 'No error should occur'); let { @@ -519,7 +519,7 @@ test('deleting when after deletion there is a trailing space positions cursor at let text = 'e'; Helpers.dom.insertText(editor, text); - setTimeout(() => { + Helpers.wait(() => { assert.equal(editor.post.sections.head.text, `first ${text}`, 'character is placed after space'); done(); @@ -539,7 +539,7 @@ test('deleting when after deletion there is a leading space positions cursor at let text = 'e'; Helpers.dom.insertText(editor, text); - setTimeout(() => { + Helpers.wait(() => { assert.equal(editor.post.sections.tail.text, `${text} section`, 'correct text after insertion'); done(); }); diff --git a/tests/acceptance/editor-selections-test.js b/tests/acceptance/editor-selections-test.js index 92836d742..ec835f0ba 100644 --- a/tests/acceptance/editor-selections-test.js +++ b/tests/acceptance/editor-selections-test.js @@ -58,23 +58,29 @@ test('selecting across sections is possible', (assert) => { }); test('when editing is disabled, the selection detection code is disabled', (assert) => { - $('#qunit-fixture').append('

outside section

'); + let done = assert.async(); + $('#qunit-fixture').append('

outside section 1

'); + $('#qunit-fixture').append('

outside section 2

'); editor = new Editor({mobiledoc: mobileDocWithSection}); editor.render(editorElement); editor.disableEditing(); - const firstSection = $('p:contains(one trick pony)')[0]; - const outsideSection = $('p:contains(outside section)')[0]; + const outside1 = $('p:contains(outside section 1)')[0]; + const outside2 = $('p:contains(outside section 2)')[0]; - Helpers.dom.selectText(editor ,'trick', firstSection, - 'outside', outsideSection); + Helpers.wait(() => { + Helpers.dom.selectText(editor ,'outside', outside1, 'section 2', outside2); - Helpers.dom.triggerEvent(document, 'mouseup'); + Helpers.wait(() => { + assert.equal(editor.activeSections.length, 0, 'no selection inside the editor'); + const selectedText = Helpers.dom.getSelectedText(); + assert.ok(selectedText.indexOf('outside section 1') !== -1 && + selectedText.indexOf('outside section 2') !== -1, 'selects the text'); - assert.equal(editor.activeSections.length, 0, 'no selection inside the editor'); - const selectedText = Helpers.dom.getSelectedText(); - assert.ok(selectedText.indexOf('trick pony') !== -1 && selectedText.indexOf('outside') !== -1, 'selects the text'); + done(); + }); + }); }); test('selecting an entire section and deleting removes it', (assert) => { @@ -300,17 +306,17 @@ test('selecting text bounded by space and typing replaces it', (assert) => { Helpers.dom.selectText(editor ,'trick', editorElement); Helpers.dom.insertText(editor, 'X'); - window.setTimeout(() => { + Helpers.wait(() => { assert.equal(editor.post.sections.head.text, 'one X pony', 'new text present'); Helpers.dom.insertText(editor, 'Y'); - window.setTimeout(() => { + Helpers.wait(() => { assert.equal(editor.post.sections.head.text, 'one XY pony', 'further new text present'); done(); - }, 0); - }, 0); + }); + }); }); test('selecting all text across sections and hitting enter deletes and moves cursor to empty section', (assert) => { @@ -529,24 +535,6 @@ test('selecting text that includes an empty section and applying markup to it', assert.hasElement('#editor p strong:contains(abc)', 'bold is applied to text'); }); -// see https://github.com/bustlelabs/mobiledoc-kit/issues/155 -test('editor#selectSections works when given an empty array', (assert) => { - const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => { - return post([markupSection('p', [marker('abc')])]); - }); - editor = new Editor({mobiledoc}); - editor.render(editorElement); - - assert.selectedText('', 'precond - no text selected'); - - const section = editor.post.sections.head; - editor.selectSections([section]); - - assert.selectedText('abc', 'section is selected'); - editor.selectSections([]); - assert.selectedText(null, 'no text selected after selecting no sections'); -}); - test('placing cursor inside a strong section should cause markupsInSelection to contain "strong"', (assert) => { const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker, markup}) => { const b = markup('strong'); diff --git a/tests/acceptance/editor-undo-redo-test.js b/tests/acceptance/editor-undo-redo-test.js index 55682ea33..7ceac3393 100644 --- a/tests/acceptance/editor-undo-redo-test.js +++ b/tests/acceptance/editor-undo-redo-test.js @@ -51,7 +51,7 @@ test('undo/redo the insertion of a character', (assert) => { Helpers.dom.insertText(editor, 'D'); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expectedBeforeUndo); // precond undo(editor); assert.postIsSimilar(editor.post, expectedAfterUndo); @@ -89,10 +89,10 @@ test('undo/redo the insertion of multiple characters', (assert) => { Helpers.dom.insertText(editor, 'D'); - setTimeout(() => { + Helpers.wait(() => { Helpers.dom.insertText(editor, 'E'); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, beforeUndo); // precond undo(editor); @@ -187,7 +187,7 @@ test('undo insertion of character to a list item', (assert) => { Helpers.dom.moveCursorTo(editor, textNode, 'abc'.length); Helpers.dom.insertText(editor, 'D'); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expectedBeforeUndo); // precond undo(editor); @@ -226,10 +226,10 @@ test('undo stack length can be configured (depth 1)', (assert) => { Helpers.dom.moveCursorTo(editor, textNode, 'abc'.length); Helpers.dom.insertText(editor, 'D'); - setTimeout(() => { + Helpers.wait(() => { Helpers.dom.insertText(editor, 'E'); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, beforeUndo); // precond undo(editor); @@ -243,7 +243,7 @@ test('undo stack length can be configured (depth 1)', (assert) => { assert.positionIsEqual(editor.range.head, editor.post.sections.head.tailPosition()); done(); - }, 0); + }); }); }); @@ -261,10 +261,10 @@ test('undo stack length can be configured (depth 0)', (assert) => { Helpers.dom.moveCursorTo(editor, textNode, 'abc'.length); Helpers.dom.insertText(editor, 'D'); - setTimeout(() => { + Helpers.wait(() => { Helpers.dom.insertText(editor, 'E'); - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, beforeUndo); // precond undo(editor); @@ -273,7 +273,7 @@ test('undo stack length can be configured (depth 0)', (assert) => { assert.positionIsEqual(editor.range.head, editor.post.sections.head.tailPosition()); done(); - }, 0); + }); }); }); @@ -309,12 +309,12 @@ test('take and undo a snapshot based on drag/dropping of text', (assert) => { textNode.textContent = text; // Allow the mutation observer to fire, then... - setTimeout(function() { + Helpers.wait(function() { assert.postIsSimilar(editor.post, beforeUndo, 'precond - text is added'); undo(editor); assert.postIsSimilar(editor.post, afterUndo, 'text is removed'); done(); - }, 0); + }); }); test('take and undo a snapshot when adding a card', (assert) => { diff --git a/tests/helpers/dom.js b/tests/helpers/dom.js index adad44381..72337b512 100644 --- a/tests/helpers/dom.js +++ b/tests/helpers/dom.js @@ -73,7 +73,7 @@ function selectText(editor, const startOffset = startTextNode.textContent.indexOf(startText), endOffset = endTextNode.textContent.indexOf(endText) + endText.length; selectRange(startTextNode, startOffset, endTextNode, endOffset); - editor._notifyRangeChange(); + editor._readRangeFromDOM(); } function moveCursorWithoutNotifyingEditorTo(editor, node, offset=0, endNode=node, endOffset=offset) { @@ -84,7 +84,7 @@ function moveCursorTo(editor, node, offset=0, endNode=node, endOffset=offset) { assertEditor(editor); if (!node) { throw new Error('Cannot moveCursorTo node without node'); } moveCursorWithoutNotifyingEditorTo(editor, node, offset, endNode, endOffset); - editor._notifyRangeChange(); + editor._readRangeFromDOM(); } function triggerEvent(node, eventType) { diff --git a/tests/helpers/mobiledoc.js b/tests/helpers/mobiledoc.js index 3c4fd8dd3..c5f24b586 100644 --- a/tests/helpers/mobiledoc.js +++ b/tests/helpers/mobiledoc.js @@ -14,6 +14,7 @@ import { mergeWithOptions } from 'mobiledoc-kit/utils/merge'; * ]) * }) * ) + * @return Mobiledoc */ function build(treeFn, version) { let post = PostAbstractHelpers.build(treeFn); diff --git a/tests/helpers/wait.js b/tests/helpers/wait.js new file mode 100644 index 000000000..37b86fd99 --- /dev/null +++ b/tests/helpers/wait.js @@ -0,0 +1,5 @@ +let wait = (callback) => { + window.requestAnimationFrame(callback); +}; + +export default wait; diff --git a/tests/test-helpers.js b/tests/test-helpers.js index 338310878..639928f86 100644 --- a/tests/test-helpers.js +++ b/tests/test-helpers.js @@ -5,6 +5,7 @@ import DOMHelpers from './helpers/dom'; import MobiledocHelpers from './helpers/mobiledoc'; import PostAbstract from './helpers/post-abstract'; import { detectIE11 } from './helpers/browsers'; +import wait from './helpers/wait'; const { test:qunitTest, module, skip } = QUnit; @@ -43,5 +44,6 @@ export default { postAbstract: PostAbstract, test, module, - skipInIE11 + skipInIE11, + wait }; diff --git a/tests/unit/editor/atom-lifecycle-test.js b/tests/unit/editor/atom-lifecycle-test.js index 74e2013a2..e3c83593c 100644 --- a/tests/unit/editor/atom-lifecycle-test.js +++ b/tests/unit/editor/atom-lifecycle-test.js @@ -11,7 +11,7 @@ module('Unit: Editor: Atom Lifecycle', { editorElement = $('#editor')[0]; }, afterEach() { - if (editor) { + if (editor && !editor.isDestroyed) { editor.destroy(); editor = null; } @@ -258,8 +258,8 @@ test('mutating the content of an atom does not trigger an update', (assert) => { $("#the-atom").html("updated"); // ensure the mutations have had time to trigger - setTimeout(function(){ + Helpers.wait(function(){ assert.ok(!updateTriggered); done(); - }, 10); + }); }); diff --git a/tests/unit/editor/card-lifecycle-test.js b/tests/unit/editor/card-lifecycle-test.js index 6370b16a8..85c53d77c 100644 --- a/tests/unit/editor/card-lifecycle-test.js +++ b/tests/unit/editor/card-lifecycle-test.js @@ -9,7 +9,7 @@ module('Unit: Editor: Card Lifecycle', { editorElement = $('#editor')[0]; }, afterEach() { - if (editor) { + if (editor && !editor.isDestroyed) { editor.destroy(); editor = null; } diff --git a/tests/unit/editor/editor-events-test.js b/tests/unit/editor/editor-events-test.js index 8a7342396..7e87156d2 100644 --- a/tests/unit/editor/editor-events-test.js +++ b/tests/unit/editor/editor-events-test.js @@ -10,7 +10,6 @@ const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => { return post([markupSection('p', [marker('this is the editor')])]); }); - module('Unit: Editor: events and lifecycle callbacks', { beforeEach() { editorElement = $('#editor')[0]; @@ -23,125 +22,112 @@ module('Unit: Editor: events and lifecycle callbacks', { }, afterEach() { - if (editor) { + if (editor && !editor.isDestroyed) { editor.destroy(); editor = null; } } }); -test('cursorDidChange callback fired after mouseup', (assert) => { - assert.expect(2); +test('cursorDidChange callback does not fire when selection is set to the same value', (assert) => { + assert.expect(1); let done = assert.async(); let cursorChanged = 0; editor.cursorDidChange(() => cursorChanged++); let node = Helpers.dom.findTextNode(editorElement, 'this is the editor'); - Helpers.dom.moveCursorWithoutNotifyingEditorTo(editor, node, 0); + Helpers.dom.selectRange(node, 0, node, 0); - assert.equal(cursorChanged, 0, 'precond - no cursor change yet'); + Helpers.wait(() => { + cursorChanged = 0; - Helpers.dom.triggerEvent(document, 'mouseup'); + Helpers.dom.selectRange(node, 0, node, 0); - setTimeout(() => { - assert.equal(cursorChanged, 1, 'cursor did change'); - cursorChanged = 0; + Helpers.wait(() => { + assert.equal(cursorChanged, 0, 'cursor did not change when selection is set to same value'); - done(); + done(); + }); }); }); -test('cursorDidChange callback not fired after mouseup when selection is unchanged', (assert) => { - assert.expect(2); +test('cursorDidChange callback fires when editor loses focus', (assert) => { + assert.expect(1); let done = assert.async(); - let cursorChanged = 0; - editor.cursorDidChange(() => cursorChanged++); + Helpers.wait(() => { + // Tests in FF can fail if the window is not front-most and + // we don't explicitly render the range + let node = Helpers.dom.findTextNode(editor.element, 'this is the editor'); + Helpers.dom.selectRange(node, 0, node, 0); - let node = Helpers.dom.findTextNode(editorElement, 'this is the editor'); - Helpers.dom.moveCursorWithoutNotifyingEditorTo(editor, node, 0); - Helpers.dom.triggerEvent(document, 'mouseup'); + Helpers.wait(() => { + let cursorChanged = 0; + editor.cursorDidChange(() => cursorChanged++); - setTimeout(() => { - assert.equal(cursorChanged, 1, 'cursor did change'); - cursorChanged = 0; + Helpers.dom.clearSelection(); - Helpers.dom.triggerEvent(document, 'mouseup'); - setTimeout(() => { - assert.equal(cursorChanged, 0, 'cursor did not change after mouseup when selection is unchanged'); + Helpers.wait(() => { + assert.equal(cursorChanged, 1, 'cursor changed after clearing selection'); - done(); + done(); + }); }); }); }); -test('cursorDidChange callback fired after mouseup when editor loses focus', (assert) => { +test('cursorDidChange callback not fired if editor is destroyed', (assert) => { assert.expect(2); let done = assert.async(); - // Tests in FF can fail if the window is not front-most and - // we don't explicitly render the range - let node = Helpers.dom.findTextNode(editor.element, 'this is the editor'); - Helpers.dom.moveCursorWithoutNotifyingEditorTo(editor, node); - let cursorChanged = 0; editor.cursorDidChange(() => cursorChanged++); - Helpers.dom.triggerEvent(document, 'mouseup'); - setTimeout(() => { - assert.equal(cursorChanged, 1, 'precond - trigger cursor change'); + Helpers.dom.clearSelection(); + + Helpers.wait(() => { cursorChanged = 0; + let node = Helpers.dom.findTextNode(editor.element, 'this is the editor'); + Helpers.dom.selectRange(node, 0, node, 0); - Helpers.dom.clearSelection(); - Helpers.dom.triggerEvent(document, 'mouseup'); + Helpers.wait(() => { + assert.equal(cursorChanged, 1, 'precond - cursor change fires'); - setTimeout(() => { - assert.equal(cursorChanged, 1, 'cursor changed when mouseup and no selection'); + cursorChanged = 0; + editor.destroy(); + Helpers.dom.clearSelection(); - done(); + Helpers.wait(() => { + assert.equal(cursorChanged, 0, 'callback not fired'); + + done(); + }); }); }); }); -test('cursorDidChange callback fired after keypress', (assert) => { - let done = assert.async(); +test('cursorChanged callback fired after editor.run sets range', (assert) => { assert.expect(2); - - let cursorChanged = 0; - editor.cursorDidChange(() => cursorChanged++); - - let node = Helpers.dom.findTextNode(editorElement, 'this is the editor'); - Helpers.dom.moveCursorTo(editor, node, 0); - - assert.equal(cursorChanged, 1, 'precond - cursor changed by move'); - cursorChanged = 0; - - Helpers.dom.moveCursorWithoutNotifyingEditorTo(editor, node, 'this is the editor'.length); - Helpers.dom.triggerRightArrowKey(editor); - - setTimeout(() => { - assert.equal(cursorChanged, 1, 'cursor changed after key up'); - done(); - }); -}); - -test('cursorDidChange callback not fired if editor is destroyed', (assert) => { - assert.expect(1); let done = assert.async(); let cursorChanged = 0; editor.cursorDidChange(() => cursorChanged++); - Helpers.dom.clearSelection(); - Helpers.dom.triggerEvent(document, 'mouseup'); - editor.destroy(); - editor = null; + Helpers.wait(() => { + assert.equal(cursorChanged, 0, 'precond - no cursor change'); - setTimeout(() => { - assert.equal(cursorChanged, 0, 'callback not fired'); + editor.run(postEditor => { + let position = editor.post.headPosition(); + postEditor.insertText(position, 'blah'); + postEditor.setRange(new Range(editor.post.tailPosition())); + }); - done(); + Helpers.wait(() => { + assert.equal(cursorChanged, 1, 'cursor changes after editor.run sets position'); + + done(); + }); }); }); @@ -215,13 +201,16 @@ test('inputModeDidChange callback fired when markup is toggled and there is a se Helpers.dom.selectText(editor, "this is the editor", editorElement); - let inputChanged = 0; - editor.inputModeDidChange(() => inputChanged++); + Helpers.wait(() => { + let inputChanged = 0; + editor.inputModeDidChange(() => inputChanged++); - editor.toggleMarkup('b'); - setTimeout(() => { - assert.equal(inputChanged, 1, 'inputModeDidChange fired once'); - done(); + editor.toggleMarkup('b'); + + Helpers.wait(() => { + assert.equal(inputChanged, 1, 'inputModeDidChange fired once'); + done(); + }); }); }); @@ -231,14 +220,16 @@ test('inputModeDidChange callback fired when markup is toggled and there is no s editor.selectRange(new Range(editor.post.headPosition())); - let inputChanged = 0; - editor.inputModeDidChange(() => inputChanged++); + Helpers.wait(() => { + let inputChanged = 0; + editor.inputModeDidChange(() => inputChanged++); - editor.toggleMarkup('b'); + editor.toggleMarkup('b'); - setTimeout(() => { - assert.equal(inputChanged, 1, 'inputModeDidChange fired once'); - done(); + Helpers.wait(() => { + assert.equal(inputChanged, 1, 'inputModeDidChange fired once'); + done(); + }); }); }); @@ -251,14 +242,16 @@ test('inputModeDidChange callback fired when moving cursor into markup', (assert editor.toggleMarkup('b'); editor.selectRange(Range.create(editor.post.sections.head, 'this is'.length)); - let inputChanged = 0; - editor.inputModeDidChange(() => inputChanged++); + Helpers.wait(() => { + let inputChanged = 0; + editor.inputModeDidChange(() => inputChanged++); - Helpers.dom.triggerRightArrowKey(editor); + editor.selectRange(editor.range.move(1)); - setTimeout(() => { - assert.equal(inputChanged, 1, 'inputModeDidChange fired once'); - done(); + Helpers.wait(() => { + assert.equal(inputChanged, 1, 'inputModeDidChange fired once'); + done(); + }); }); }); @@ -273,7 +266,7 @@ test('inputModeDidChange callback fired when toggling section', (assert) => { editor.toggleSection('h2'); - setTimeout(() => { + Helpers.wait(() => { assert.equal(inputChanged, 1, 'inputModeDidChange fired once'); done(); }); @@ -290,7 +283,7 @@ test('inputModeDidChange callback not fired when toggle is no-op', (assert) => { editor.toggleSection('p'); // toggling to same section is no-op - setTimeout(() => { + Helpers.wait(() => { assert.equal(inputChanged, 0, 'inputModeDidChange not fired'); done(); }); @@ -306,17 +299,23 @@ test('inputModeDidChange callback fired when moving cursor into section', (asser postEditor.insertSectionAtEnd(newSection); }); + let inputChanged = 0; + editor.inputModeDidChange(() => { + inputChanged++; + }); + assert.hasElement('h2:contains(abc)', 'precond - inserted h2'); editor.selectRange(new Range(editor.post.sections.tail.headPosition())); - let inputChanged = 0; - editor.inputModeDidChange(() => inputChanged++); + Helpers.wait(() => { + inputChanged = 0; - Helpers.dom.triggerLeftArrowKey(editor); + editor.selectRange(new Range(editor.post.sections.head.tailPosition())); - setTimeout(() => { - assert.equal(inputChanged, 1, 'inputModeDidChange fired once'); - done(); + Helpers.wait(() => { + assert.equal(inputChanged, 1, 'inputModeDidChange fired once'); + done(); + }); }); }); @@ -338,7 +337,7 @@ test('inputModeDidChange callback not fired when moving cursor into same section Helpers.dom.triggerLeftArrowKey(editor); - setTimeout(() => { + Helpers.wait(() => { assert.equal(inputChanged, 0, 'inputModeDidChange not fired'); done(); }); diff --git a/tests/unit/editor/editor-test.js b/tests/unit/editor/editor-test.js index a6c87e27d..f87a01cfd 100644 --- a/tests/unit/editor/editor-test.js +++ b/tests/unit/editor/editor-test.js @@ -15,8 +15,9 @@ module('Unit: Editor', { }, afterEach() { - if (editor) { + if (editor && !editor.isDestroyed) { editor.destroy(); + editor = null; } } }); @@ -28,6 +29,24 @@ test('can render an editor via dom node reference', (assert) => { assert.ok(editor.post); }); +test('autofocused editor hasCursor and has non-blank range after rendering', (assert) => { + let done = assert.async(); + let mobiledoc = Helpers.mobiledoc.build(({post, markupSection}) => { + return post([markupSection('p')]); + }); + editor = new Editor({autofocus: true, mobiledoc}); + assert.ok(!editor.hasCursor(), 'precond - editor has no cursor'); + assert.ok(editor.range.isBlank, 'precond - editor has blank range'); + + editor.render(editorElement); + + Helpers.wait(() => { + assert.ok(editor.hasCursor(), 'editor has cursor'); + assert.ok(!editor.range.isBlank, 'editor has non-blank range'); + done(); + }); +}); + test('creating an editor with DOM node throws', (assert) => { assert.throws(function() { editor = new Editor(document.createElement('div')); diff --git a/tests/unit/editor/post-test.js b/tests/unit/editor/post-test.js index c8f080014..486902c9d 100644 --- a/tests/unit/editor/post-test.js +++ b/tests/unit/editor/post-test.js @@ -7,7 +7,6 @@ import { DIRECTION } from 'mobiledoc-kit/utils/key'; import PostNodeBuilder from 'mobiledoc-kit/models/post-node-builder'; import Range from 'mobiledoc-kit/utils/cursor/range'; import Position from 'mobiledoc-kit/utils/cursor/position'; -import { clearSelection } from 'mobiledoc-kit/utils/selection-utils'; const { FORWARD } = DIRECTION; @@ -44,13 +43,15 @@ function renderBuiltAbstract(post) { } let renderedRange; -function buildEditorWithMobiledoc(builderFn) { +function buildEditorWithMobiledoc(builderFn, autofocus=true) { let mobiledoc = Helpers.mobiledoc.build(builderFn); let unknownCardHandler = () => {}; let unknownAtomHandler = () => {}; - editor = new Editor({mobiledoc, unknownCardHandler, unknownAtomHandler}); + editor = new Editor({mobiledoc, unknownCardHandler, unknownAtomHandler, autofocus}); editor.render(editorElement); - editor.renderRange = function(range) { + let selectRange = editor.selectRange; + editor.selectRange = function(range) { + selectRange.call(editor, range); renderedRange = range; }; return editor; @@ -189,10 +190,10 @@ class MockEditor { } rerender() {} _postDidChange() {} - renderRange(range) { + selectRange(range) { renderedRange = range; } - _notifyRangeChange() {} + _readRangeFromDOM() {} } @@ -1251,28 +1252,33 @@ test('#toggleSection when cursor is in non-markerable section changes nothing', }); test('#toggleSection when editor has no cursor does nothing', (assert) => { + assert.expect(6); + let done = assert.async(); + editor = buildEditorWithMobiledoc( ({post, markupSection, marker}) => { return post([markupSection('p', [marker('abc')])]); - }); + }, false); let expected = Helpers.postAbstract.build( ({post, markupSection, marker}) => { return post([markupSection('p', [marker('abc')])]); }); - Helpers.dom.blur(); - clearSelection(); - - assert.equal(window.getSelection().rangeCount, 0, 'precond - nothing selected'); - assert.ok(document.activeElement !== editorElement, 'precond - no activeElement'); assert.ok(!editor.hasCursor(), 'editor has no cursor'); + assert.ok(editor.range.isBlank, 'editor has blank range'); + renderedRange = null; editor.run(postEditor => postEditor.toggleSection('blockquote')); - assert.postIsSimilar(editor.post, expected); - assert.ok(document.activeElement !== editorElement, 'editor element is not active'); - assert.ok(renderedRange.isBlank, 'rendered range is blank'); - assert.equal(window.getSelection().rangeCount, 0, 'nothing selected'); + Helpers.wait(() => { + assert.postIsSimilar(editor.post, expected); + assert.ok(document.activeElement !== editorElement, + 'editor element is not active'); + assert.ok(renderedRange && renderedRange.isBlank, 'rendered range is blank'); + assert.equal(window.getSelection().rangeCount, 0, 'nothing selected'); + + done(); + }); }); test('#toggleSection toggle single p -> list item', (assert) => { @@ -1790,28 +1796,30 @@ test('#toggleMarkup when range does not have the markup adds it', (assert) => { }); test('#toggleMarkup when the editor has no cursor', (assert) => { + let done = assert.async(); + editor = buildEditorWithMobiledoc( ({post, markupSection, marker}) => { return post([markupSection('p', [marker('abc')])]); - }); + }, false); let expected = Helpers.postAbstract.build( ({post, markupSection, marker}) => { return post([markupSection('p', [marker('abc')])]); }); - Helpers.dom.blur(); - clearSelection(); + renderedRange = null; + editor.run(postEditor => postEditor.toggleMarkup('b')); - editor.run(postEditor => { - postEditor.toggleMarkup('b'); - }); + Helpers.wait(() => { + assert.postIsSimilar(editor.post, expected); + assert.equal(window.getSelection().rangeCount, 0, + 'nothing is selected'); + assert.ok(document.activeElement !== editorElement, + 'active element is not editor element'); + assert.ok(renderedRange && renderedRange.isBlank, 'rendered range is blank'); - assert.postIsSimilar(editor.post, expected); - assert.equal(window.getSelection().rangeCount, 0, - 'nothing is selected'); - assert.ok(document.activeElement !== editorElement, - 'active element is not editor element'); - assert.ok(renderedRange.isBlank, 'rendered range is blank'); + done(); + }); }); test('#insertMarkers inserts an atom', (assert) => { diff --git a/tests/unit/editor/post/insert-post-test.js b/tests/unit/editor/post/insert-post-test.js index cfd91317b..856e5e622 100644 --- a/tests/unit/editor/post/insert-post-test.js +++ b/tests/unit/editor/post/insert-post-test.js @@ -25,7 +25,7 @@ function buildEditorWithMobiledoc(builderFn) { let unknownCardHandler = () => {}; editor = new Editor({mobiledoc, unknownCardHandler}); editor.render(editorElement); - editor.renderRange = function(range) { + editor.selectRange = function(range) { renderedRange = range; }; return editor; diff --git a/tests/unit/parsers/dom-test.js b/tests/unit/parsers/dom-test.js index 5b3308c77..3fa636f39 100644 --- a/tests/unit/parsers/dom-test.js +++ b/tests/unit/parsers/dom-test.js @@ -101,7 +101,7 @@ test('editor#parse fixes text in atom headTextNode when atom is at start of sect assert.ok(!!headTextNode, 'precond - headTextNode'); headTextNode.textContent = ZWNJ + 'X'; - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); assert.renderTreeIsEqual(editor._renderTree, expected); @@ -142,7 +142,7 @@ test('editor#parse fixes text in atom headTextNode when atom has marker before i assert.ok(!!headTextNode, 'precond - headTextNode'); headTextNode.textContent = ZWNJ + 'X'; - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); assert.renderTreeIsEqual(editor._renderTree, expected); done(); @@ -163,7 +163,7 @@ test('editor#parse fixes text in atom tailTextNode when atom is at end of sectio assert.ok(!!tailTextNode, 'precond - tailTextNode'); tailTextNode.textContent = ZWNJ + 'X'; - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); assert.renderTreeIsEqual(editor._renderTree, expected); done(); @@ -184,7 +184,7 @@ test('editor#parse fixes text in atom tailTextNode when atom has atom after it', assert.ok(!!tailTextNode, 'precond - tailTextNode'); tailTextNode.textContent = ZWNJ + 'X'; - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); assert.renderTreeIsEqual(editor._renderTree, expected); done(); @@ -206,7 +206,7 @@ test('editor#parse fixes text in atom tailTextNode when atom has marker after it assert.ok(!!tailTextNode, 'precond - tailTextNode'); tailTextNode.textContent = ZWNJ + 'X'; - setTimeout(() => { + Helpers.wait(() => { assert.postIsSimilar(editor.post, expected); assert.renderTreeIsEqual(editor._renderTree, expected); done(); diff --git a/tests/unit/utils/cursor-position-test.js b/tests/unit/utils/cursor-position-test.js index f4146db83..f1b1d67bc 100644 --- a/tests/unit/utils/cursor-position-test.js +++ b/tests/unit/utils/cursor-position-test.js @@ -13,6 +13,7 @@ module('Unit: Utils: Position', { afterEach() { if (editor) { editor.destroy(); + editor = null; } } }); @@ -317,4 +318,3 @@ test('Position cannot be on list section', (assert) => { position = new Position(listItem, 0); assert.ok(position, 'position with list item is ok'); }); - diff --git a/tests/unit/utils/key-test.js b/tests/unit/utils/key-test.js index b509dc6f7..3fbf4163a 100644 --- a/tests/unit/utils/key-test.js +++ b/tests/unit/utils/key-test.js @@ -54,23 +54,3 @@ test('firefox arrow keypress is not printable', (assert) => { let key = Key.fromEvent(event); assert.ok(!key.isPrintable()); }); - -test('HOME key is movement', (assert) => { - let element = $('#qunit-fixture')[0]; - let event = Helpers.dom.createMockEvent('keypress', element, { - keyCode: Keycodes.HOME, - charCode: 0 - }); - let key = Key.fromEvent(event); - assert.ok(key.isMovement()); -}); - -test('END key is movement', (assert) => { - let element = $('#qunit-fixture')[0]; - let event = Helpers.dom.createMockEvent('keypress', element, { - keyCode: Keycodes.END, - charCode: 0 - }); - let key = Key.fromEvent(event); - assert.ok(key.isMovement()); -});