Skip to content

Commit

Permalink
IE11 Support
Browse files Browse the repository at this point in the history
* Replace the `input` event with mutation observer
* Only use `Selection#extend` if needed and present
* Use range/paste based text insertion instead of unsupported
  execCommand('insertText' for tests
* Also fix keyCodes in tests (keyCodes are not charCodes)
  • Loading branch information
mixonic committed Dec 1, 2015
1 parent 38736a0 commit 3eeb2ba
Show file tree
Hide file tree
Showing 14 changed files with 399 additions and 137 deletions.
34 changes: 28 additions & 6 deletions src/js/editor/editor.js
Expand Up @@ -39,7 +39,7 @@ import { DIRECTION } from 'mobiledoc-kit/utils/key';

export const EDITOR_ELEMENT_CLASS_NAME = '__mobiledoc-editor';

const ELEMENT_EVENTS = ['keydown', 'keyup', 'input', 'cut', 'copy', 'paste'];
const ELEMENT_EVENTS = ['keydown', 'keyup', 'cut', 'copy', 'paste'];
const DOCUMENT_EVENTS= ['mouseup'];

const defaults = {
Expand All @@ -59,7 +59,8 @@ const CALLBACK_QUEUES = {
DID_UPDATE: 'didUpdate',
WILL_RENDER: 'willRender',
DID_RENDER: 'didRender',
CURSOR_DID_CHANGE: 'cursorDidChange'
CURSOR_DID_CHANGE: 'cursorDidChange',
DID_REPARSE: 'didReparse'
};

/**
Expand Down Expand Up @@ -89,6 +90,10 @@ class Editor {
this._parser = new DOMParser(this.builder);
this._renderer = new Renderer(this, this.cards, this.unknownCardHandler, this.cardOptions);

this._handleLastKeydownExpansion = () => {
this.handleExpansion(this._lastKeydownEvent);
};

this.post = this.loadPost();
this._renderTree = new RenderTree(this.post);
}
Expand Down Expand Up @@ -157,7 +162,9 @@ class Editor {
this.run(() => {});
this.rerender();

if (this.autofocus) { this.element.focus(); }
if (this.autofocus) {
this.element.focus();
}
}

_addTooltip() {
Expand Down Expand Up @@ -305,7 +312,6 @@ class Editor {
*/
handleInput() {
this.reparse();
this.didUpdate();
}

reparse() {
Expand All @@ -316,6 +322,7 @@ class Editor {
// postEditor.
this.run(() => {});
this.rerender();
this.runCallbacks(CALLBACK_QUEUES.DID_REPARSE);
this.didUpdate();
}

Expand Down Expand Up @@ -362,7 +369,9 @@ class Editor {

_reparseCurrentSection() {
const {headSection:currentSection } = this.cursor.offsets;
this._parser.reparseSection(currentSection, this._renderTree);
if (currentSection) {
this._parser.reparseSection(currentSection, this._renderTree);
}
}

serialize() {
Expand All @@ -376,6 +385,9 @@ class Editor {

destroy() {
this._isDestroyed = true;
if (this.mutationObserver) {
this.mutationObserver.disconnect();
}
this.removeAllEventListeners();
this.removeAllViews();
this._renderer.destroy();
Expand Down Expand Up @@ -510,6 +522,15 @@ class Editor {
}

_setupListeners() {
this.mutationObserver = new MutationObserver(() => {
this.handleInput();
});
this.mutationObserver.observe(this.element, {
characterData: true,
childList: true,
subtree: true
});

ELEMENT_EVENTS.forEach(eventName => {
this.addEventListener(this.element, eventName,
(...args) => this.handleEvent(eventName, ...args)
Expand Down Expand Up @@ -619,7 +640,8 @@ class Editor {
break;
}

this.handleExpansion(event);
this._lastKeydownEvent = event;
this.addCallbackOnce(CALLBACK_QUEUES.DID_REPARSE, this._handleLastKeydownExpansion);
}

/**
Expand Down
6 changes: 5 additions & 1 deletion src/js/editor/text-expansions.js
Expand Up @@ -88,5 +88,9 @@ export function findExpansion(expansions, keyEvent, editor) {
const _text = section.textUntil(offset);
return detect(
expansions,
({trigger, text}) => key.keyCode === trigger && _text === text);
({trigger, text}) => {
return key.keyCode === trigger &&
_text === (text + String.fromCharCode(trigger));
}
);
}
9 changes: 7 additions & 2 deletions src/js/utils/cursor.js
Expand Up @@ -147,8 +147,13 @@ const Cursor = class Cursor {

const range = document.createRange();
range.setStart(node, offset);
this.selection.addRange(range);
this.selection.extend(endNode, endOffset);
if (direction === DIRECTION.BACKWARD && !!this.selection.extend) {
this.selection.addRange(range);
this.selection.extend(endNode, endOffset);
} else {
range.setEnd(endNode, endOffset);
this.selection.addRange(range);
}
}

_hasSelection() {
Expand Down
11 changes: 11 additions & 0 deletions src/js/utils/lifecycle-callbacks.js
Expand Up @@ -17,4 +17,15 @@ export default class LifecycleCallbacksMixin {
}
this.callbackQueues[queueName].push(callback);
}
addCallbackOnce(queueName, callback) {
if (!queueName) { throw new Error('Must pass queue name to addCallbackOnce'); }
let queue = this.callbackQueues[queueName];
if (!queue) {
this.callbackQueues[queueName] = queue = [];
}
let index = queue.indexOf(callback);
if (index === -1) {
queue.push(callback);
}
}
}
49 changes: 43 additions & 6 deletions src/js/utils/selection-utils.js
Expand Up @@ -11,7 +11,36 @@ function comparePosition(selection) {

const position = anchorNode.compareDocumentPosition(focusNode);

if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
// IE may select return focus and anchor nodes far up the DOM tree instead of
// picking the deepest, most specific possible node. For example in
//
// <div><span>abc</span><span>def</span></div>
//
// with a cursor between c and d, IE might say the focusNode is <div> with
// an offset of 1. However the anchorNode for a selection might still be
// <span> 2 if there was a selection.
//
// This code walks down the DOM tree until a good comparison of position can be
// made.
//
if (position & Node.DOCUMENT_POSITION_CONTAINS) {
return comparePosition({
focusNode: focusNode.childNodes[focusOffset],
focusOffset: 0,
anchorNode, anchorOffset
});
} else if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
let offset = anchorOffset - 1;
if (offset < 0) {
offset = 0;
}
return comparePosition({
anchorNode: anchorNode.childNodes[offset],
anchorOffset: 0,
focusNode, focusOffset
});
// The meat of translating anchor and focus nodes to head and tail nodes
} else if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
headNode = anchorNode; tailNode = focusNode;
headOffset = anchorOffset; tailOffset = focusOffset;
direction = DIRECTION.FORWARD;
Expand All @@ -20,11 +49,19 @@ function comparePosition(selection) {
headOffset = focusOffset; tailOffset = anchorOffset;
direction = DIRECTION.BACKWARD;
} else { // same node
headNode = anchorNode;
tailNode = focusNode;
headOffset = Math.min(anchorOffset, focusOffset);
tailOffset = Math.max(anchorOffset, focusOffset);
direction = null;
headNode = tailNode = anchorNode;
headOffset = anchorOffset;
tailOffset = focusOffset;
if (tailOffset < headOffset) {
// Swap the offset order
headOffset = focusOffset;
tailOffset = anchorOffset;
direction = DIRECTION.BACKWARD;
} else if (headOffset < tailOffset) {
direction = DIRECTION.FORWARD;
} else {
direction = null;
}
}

return {headNode, headOffset, tailNode, tailOffset, direction};
Expand Down
11 changes: 7 additions & 4 deletions tests/acceptance/basic-editor-test.js
Expand Up @@ -104,7 +104,7 @@ test('typing in empty post correctly adds a section to it', (assert) => {
assert.hasElement('#editor');
assert.hasNoElement('#editor p');

Helpers.dom.moveCursorTo($('#editor')[0]);
Helpers.dom.moveCursorTo(editorElement);
Helpers.dom.insertText(editor, 'X');
assert.hasElement('#editor p:contains(X)');
Helpers.dom.insertText(editor, 'Y');
Expand Down Expand Up @@ -149,6 +149,7 @@ test('typing when on the start of a card is blocked', (assert) => {

// see https://github.com/bustlelabs/mobiledoc-kit/issues/215
test('select-all and type text works ok', (assert) => {
let done = assert.async();
const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => {
return post([
markupSection('p', [marker('abc')])
Expand All @@ -164,7 +165,9 @@ test('select-all and type text works ok', (assert) => {
assert.hasElement('#editor p:contains(abc)', 'precond - renders p');

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

assert.hasNoElement('#editor p:contains(abc)', 'replaces existing text');
assert.hasElement('#editor p:contains(X)', 'inserts text');
setTimeout(function() {
assert.hasNoElement('#editor p:contains(abc)', 'replaces existing text');
assert.hasElement('#editor p:contains(X)', 'inserts text');
done();
}, 0);
});
106 changes: 56 additions & 50 deletions tests/acceptance/cursor-movement-test.js
@@ -1,6 +1,7 @@
import { Editor } from 'mobiledoc-kit';
import Helpers from '../test-helpers';
import { MODIFIERS } from 'mobiledoc-kit/utils/key';
import { supportsSelectionExtend } from '../helpers/browsers';

const { test, module } = Helpers;

Expand Down Expand Up @@ -169,57 +170,62 @@ module('Acceptance: Cursor Movement w/ shift', {
}
});

test('left arrow when at the end of a card moves the cursor across the card', assert => {
let mobiledoc = Helpers.mobiledoc.build(({post, cardSection}) => {
return post([
cardSection('my-card')
]);
if (supportsSelectionExtend()) {
// FIXME: Older versions of IE do not support `extends` on selection
// objects, and thus cannot support highlighting left until we implement
// selections without native APIs.
test('left arrow when at the end of a card moves the selection across the card', assert => {
let mobiledoc = Helpers.mobiledoc.build(({post, cardSection}) => {
return post([
cardSection('my-card')
]);
});
editor = new Editor({mobiledoc, cards});
editor.render(editorElement);

// Before zwnj
Helpers.dom.moveCursorTo(editorElement.firstChild.lastChild, 0);
Helpers.dom.triggerLeftArrowKey(editor, MODIFIERS.SHIFT);
let { offsets } = editor.cursor;

assert.ok(offsets.head.section === editor.post.sections.head,
'selection head is positioned on first section');
assert.ok(offsets.tail.section === editor.post.sections.head,
'selection tail is positioned on first section');
assert.equal(offsets.head.offset, 0,
'selection head is positioned at offset 0');
assert.equal(offsets.tail.offset, 1,
'selection tail is positioned at offset 1');

// After zwnj
Helpers.dom.moveCursorTo(editorElement.firstChild.lastChild, 1);
Helpers.dom.triggerLeftArrowKey(editor, MODIFIERS.SHIFT);
offsets = editor.cursor.offsets;

assert.ok(offsets.head.section === editor.post.sections.head,
'selection head is positioned on first section');
assert.ok(offsets.tail.section === editor.post.sections.head,
'selection tail is positioned on first section');
assert.equal(offsets.head.offset, 0,
'selection head is positioned at offset 0');
assert.equal(offsets.tail.offset, 1,
'selection tail is positioned at offset 1');

// On wrapper
Helpers.dom.moveCursorTo(editorElement.firstChild, 2);
Helpers.dom.triggerLeftArrowKey(editor, MODIFIERS.SHIFT);
offsets = editor.cursor.offsets;

assert.ok(offsets.head.section === editor.post.sections.head,
'selection head is positioned on first section');
assert.ok(offsets.tail.section === editor.post.sections.head,
'selection tail is positioned on first section');
assert.equal(offsets.head.offset, 0,
'selection head is positioned at offset 0');
assert.equal(offsets.tail.offset, 1,
'selection tail is positioned at offset 1');
});
editor = new Editor({mobiledoc, cards});
editor.render(editorElement);

// Before zwnj
Helpers.dom.moveCursorTo(editorElement.firstChild.lastChild, 0);
Helpers.dom.triggerLeftArrowKey(editor, MODIFIERS.SHIFT);
let { offsets } = editor.cursor;

assert.ok(offsets.head.section === editor.post.sections.head,
'selection head is positioned on first section');
assert.ok(offsets.tail.section === editor.post.sections.head,
'selection tail is positioned on first section');
assert.equal(offsets.head.offset, 0,
'selection head is positioned at offset 0');
assert.equal(offsets.tail.offset, 1,
'selection tail is positioned at offset 1');

// After zwnj
Helpers.dom.moveCursorTo(editorElement.firstChild.lastChild, 1);
Helpers.dom.triggerLeftArrowKey(editor, MODIFIERS.SHIFT);
offsets = editor.cursor.offsets;

assert.ok(offsets.head.section === editor.post.sections.head,
'selection head is positioned on first section');
assert.ok(offsets.tail.section === editor.post.sections.head,
'selection tail is positioned on first section');
assert.equal(offsets.head.offset, 0,
'selection head is positioned at offset 0');
assert.equal(offsets.tail.offset, 1,
'selection tail is positioned at offset 1');

// On wrapper
Helpers.dom.moveCursorTo(editorElement.firstChild, 2);
Helpers.dom.triggerLeftArrowKey(editor, MODIFIERS.SHIFT);
offsets = editor.cursor.offsets;

assert.ok(offsets.head.section === editor.post.sections.head,
'selection head is positioned on first section');
assert.ok(offsets.tail.section === editor.post.sections.head,
'selection tail is positioned on first section');
assert.equal(offsets.head.offset, 0,
'selection head is positioned at offset 0');
assert.equal(offsets.tail.offset, 1,
'selection tail is positioned at offset 1');
});
}

test('right arrow moves the cursor across the card', assert => {
let mobiledoc = Helpers.mobiledoc.build(({post, cardSection}) => {
Expand Down
10 changes: 5 additions & 5 deletions tests/acceptance/editor-cards-test.js
Expand Up @@ -347,17 +347,17 @@ test('editor ignores events when focus is inside a card', (assert) => {
assert.hasElement('#simple-card-input', 'precond - renders card');

let inputEvents = 0;
editor.handleInput = () => inputEvents++;
editor.handleKeyup = () => inputEvents++;

let input = $('#simple-card-input')[0];
Helpers.dom.triggerEvent(input, 'input');
Helpers.dom.triggerEvent(input, 'keyup');

assert.equal(inputEvents, 0, 'editor does not handle input event when in card');
assert.equal(inputEvents, 0, 'editor does not handle keyup event when in card');

let p = $('#editor p')[0];
Helpers.dom.triggerEvent(p, 'input');
Helpers.dom.triggerEvent(p, 'keyup');

assert.equal(inputEvents, 1, 'editor handles input event outside of card');
assert.equal(inputEvents, 1, 'editor handles keyup event outside of card');
});

test('a moved card retains its inital editing mode', (assert) => {
Expand Down

0 comments on commit 3eeb2ba

Please sign in to comment.