From 34ab629c861292ee06fe70ac668e20a5717f2300 Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Thu, 28 Jan 2016 13:49:27 -0500 Subject: [PATCH] Add MutationHandler, reparse entire post when new nodes appear * adds editor#_reparseSections and editor#_reparsePost * adds RenderTree#isDirty, use it to determine whether to rerender the cursor position after reparsing * tests for changing text and element nodes in the editor dom * Remove editor#reparse and #_reparseCurrentSection fixes #300 --- src/js/editor/editor.js | 99 +++++------ src/js/editor/mutation-handler.js | 129 ++++++++++++++ src/js/models/_section.js | 1 + src/js/models/render-tree.js | 6 + src/js/parsers/dom.js | 1 + src/js/utils/set.js | 4 + tests/acceptance/editor-reparse-test.js | 217 ++++++++++++++++++++++++ tests/unit/parsers/dom-test.js | 73 -------- 8 files changed, 399 insertions(+), 131 deletions(-) create mode 100644 src/js/editor/mutation-handler.js create mode 100644 tests/acceptance/editor-reparse-test.js diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index a41a46242..ade94ec8b 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -39,6 +39,7 @@ import { import { DIRECTION } from 'mobiledoc-kit/utils/key'; import { TAB, SPACE } from 'mobiledoc-kit/utils/characters'; import assert from '../utils/assert'; +import MutationHandler from 'mobiledoc-kit/editor/mutation-handler'; export const EDITOR_ELEMENT_CLASS_NAME = '__mobiledoc-editor'; @@ -89,10 +90,6 @@ class Editor { DEFAULT_TEXT_EXPANSIONS.forEach(e => this.registerExpansion(e)); DEFAULT_KEY_COMMANDS.forEach(kc => this.registerKeyCommand(kc)); - this._mutationObserver = new MutationObserver(() => { - this.handleInput(); - }); - this._isMutationObserved = false; this._parser = new DOMParser(this.builder); this._renderer = new Renderer(this, this.cards, this.unknownCardHandler, this.cardOptions); @@ -137,9 +134,9 @@ class Editor { } this.runCallbacks(CALLBACK_QUEUES.WILL_RENDER); - this.removeMutationObserver(); - this._renderer.render(this._renderTree); - this.ensureMutationObserver(); + this._mutationHandler.suspendObservation(() => { + this._renderer.render(this._renderTree); + }); this.runCallbacks(CALLBACK_QUEUES.DID_RENDER); } @@ -154,6 +151,8 @@ class Editor { clearChildNodes(element); this.element = element; + this._mutationHandler = new MutationHandler(this); + this._mutationHandler.startObserving(); if (this.isEditable === null) { this.enableEditing(); @@ -317,33 +316,33 @@ class Editor { setData(this.element, 'placeholder', placeholder); } - /** - * types of input to handle: - * * delete from beginning of section - * joins 2 sections - * * delete when multiple sections selected - * removes wholly-selected sections, - * joins the partially-selected sections - * * hit enter (handled by capturing 'keydown' for enter key and `handleNewline`) - * if anything is selected, delete it first, then - * split the current marker at the cursor position, - * schedule removal of every marker after the split, - * create new section, append it to post - * append the after-split markers onto the new section - * rerender -- this should render the new section at the appropriate spot - */ - handleInput() { - this.reparse(); + _reparsePost() { + this.post = this._parser.parse(this.element); + this._renderTree = new RenderTree(this.post); + clearChildNodes(this.element); + this.rerender(); + + this.runCallbacks(CALLBACK_QUEUES.DID_REPARSE); + this.didUpdate(); } - reparse() { - this._reparseCurrentSection(); + _reparseSections(sections=[]) { + let currentRange; + sections.forEach(section => { + this._parser.reparseSection(section, this._renderTree); + }); this._removeDetachedSections(); - // A call to `run` will trigger the didUpdatePostCallbacks hooks with a - // postEditor. + if (this._renderTree.isDirty) { + currentRange = this.range; + } + this.run(() => {}); this.rerender(); + if (currentRange) { + this.selectRange(currentRange); + } + this.runCallbacks(CALLBACK_QUEUES.DID_REPARSE); this.didUpdate(); } @@ -389,13 +388,6 @@ class Editor { } } - _reparseCurrentSection() { - const {headSection:currentSection } = this.cursor.offsets; - if (currentSection) { - this._parser.reparseSection(currentSection, this._renderTree); - } - } - serialize() { return mobiledocRenderers.render(this.post); } @@ -405,32 +397,15 @@ class Editor { this._views = []; } - ensureMutationObserver() { - if (!this._isMutationObserved) { - this._mutationObserver.observe(this.element, { - characterData: true, - childList: true, - subtree: true - }); - this._isMutationObserved = true; - } - } - - removeMutationObserver() { - if (this._isMutationObserved) { - this._mutationObserver.disconnect(); - this._isMutationObserved = false; - } - } - destroy() { this._isDestroyed = true; if (this.cursor.hasCursor()) { this.cursor.clearSelection(); this.element.blur(); } - this.removeMutationObserver(); - this._mutationObserver = null; + if (this._mutationHandler) { + this._mutationHandler.destroy(); + } this.removeAllEventListeners(); this.removeAllViews(); this._renderer.destroy(); @@ -565,6 +540,16 @@ class Editor { this.addCallback(CALLBACK_QUEUES.CURSOR_DID_CHANGE, callback); } + /** + * @method didReparse + * @param {Function} callback This callback is called after any part of the + * post is reparsed + * @public + */ + didReparse(callback) { + this.addCallback(CALLBACK_QUEUES.DID_REPARSE, callback); + } + _setupListeners() { ELEMENT_EVENTS.forEach(eventName => { this.addEventListener(this.element, eventName, @@ -665,8 +650,7 @@ class Editor { this.handleNewline(event); break; case key.isPrintable(): - { - let { offsets: range } = this.cursor; + let { range } = this; let { isCollapsed } = range; let nextPosition = range.head; @@ -696,7 +680,6 @@ class Editor { event.preventDefault(); } break; - } } } diff --git a/src/js/editor/mutation-handler.js b/src/js/editor/mutation-handler.js new file mode 100644 index 000000000..be91bd84f --- /dev/null +++ b/src/js/editor/mutation-handler.js @@ -0,0 +1,129 @@ +import Set from 'mobiledoc-kit/utils/set'; +import { forEach, filter } from 'mobiledoc-kit/utils/array-utils'; +import assert from 'mobiledoc-kit/utils/assert'; + +const MUTATION = { + NODES_CHANGED: 'childList', + CHARACTER_DATA: 'characterData' +}; + +export default class MutationHandler { + constructor(editor) { + this.editor = editor; + this.renderTree = null; + this._isObserving = false; + + this._observer = new MutationObserver((mutations) => { + this._handleMutations(mutations); + }); + } + + destroy() { + this.stopObserving(); + this._observer = null; + } + + suspendObservation(callback) { + this.stopObserving(); + callback(); + this.startObserving(); + } + + stopObserving() { + if (this._isObserving) { + this._isObserving = false; + this._observer.disconnect(); + } + } + + startObserving() { + if (!this._isObserving) { + let { editor } = this; + assert('Cannot observe un-rendered editor', editor.hasRendered); + + this._isObserving = true; + this.renderTree = editor._renderTree; + + this._observer.observe(editor.element, { + characterData: true, + childList: true, + subtree: true + }); + } + } + + reparsePost() { + this.editor._reparsePost(); + } + + reparseSections(sections) { + this.editor._reparseSections(sections); + } + + /** + * for each mutation: + * * find the target nodes: + * * if nodes changed, target nodes are: + * * added nodes + * * the target from which removed nodes were removed + * * if character data changed + * * target node is the mutation event's target (text node) + * * filter out nodes that are no longer attached (parentNode is null) + * * for each remaining node: + * * find its section, add to sections-to-reparse + * * if no section, reparse all (and break) + */ + _handleMutations(mutations) { + let reparsePost = false; + let sections = new Set(); + + for (let i = 0; i < mutations.length; i++) { + if (reparsePost) { + break; + } + + let nodes = this._findTargetNodes(mutations[i]); + + for (let j=0; j < nodes.length; j++) { + let section = this._findSectionFromNode(nodes[j]); + if (section) { + sections.add(section); + } else { + reparsePost = true; + break; + } + } + } + + if (reparsePost) { + this.reparsePost(); + } else if (sections.length) { + this.reparseSections(sections.toArray()); + } + } + + _findTargetNodes(mutation) { + let nodes = []; + switch (mutation.type) { + case MUTATION.CHARACTER_DATA: + nodes.push(mutation.target); + break; + case MUTATION.NODES_CHANGED: + forEach(mutation.addedNodes, n => nodes.push(n)); + if (mutation.removedNodes.length) { + nodes.push(mutation.target); + } + break; + } + + let attachedNodes = filter(nodes, node => !!node.parentNode); + return attachedNodes; + } + + _findSectionFromNode(node) { + let rn = this.renderTree.findRenderNodeFromElement(node, (rn) => { + return rn.postNode.isSection; + }); + return rn && rn.postNode; + } +} diff --git a/src/js/models/_section.js b/src/js/models/_section.js index aa057bc90..5b1a50253 100644 --- a/src/js/models/_section.js +++ b/src/js/models/_section.js @@ -12,6 +12,7 @@ export default class Section extends LinkedItem { super(); assert('Cannot create section without type', !!type); this.type = type; + this.isSection = true; this.isMarkerable = false; this.isNested = false; this.isSection = true; diff --git a/src/js/models/render-tree.js b/src/js/models/render-tree.js index 924461005..990574a39 100644 --- a/src/js/models/render-tree.js +++ b/src/js/models/render-tree.js @@ -12,6 +12,12 @@ export default class RenderTree { get rootNode() { return this._rootNode; } + /** + * @return {Boolean} + */ + get isDirty() { + return this.rootNode && this.rootNode.isDirty; + } /* * @return {DOMNode} The root DOM element in this tree */ diff --git a/src/js/parsers/dom.js b/src/js/parsers/dom.js index 11be0e72f..79b5f3f16 100644 --- a/src/js/parsers/dom.js +++ b/src/js/parsers/dom.js @@ -194,6 +194,7 @@ export default class DOMParser { renderNode = renderTree.buildRenderNode(marker); renderNode.element = textNode; renderNode.markClean(); + section.renderNode.markDirty(); let previousRenderNode = previousMarker && previousMarker.renderNode; section.markers.insertAfter(marker, previousMarker); diff --git a/src/js/utils/set.js b/src/js/utils/set.js index d796a872b..599673fae 100644 --- a/src/js/utils/set.js +++ b/src/js/utils/set.js @@ -10,6 +10,10 @@ export default class Set { } } + get length() { + return this.items.length; + } + has(item) { return this.items.indexOf(item) !== -1; } diff --git a/tests/acceptance/editor-reparse-test.js b/tests/acceptance/editor-reparse-test.js new file mode 100644 index 000000000..b04193275 --- /dev/null +++ b/tests/acceptance/editor-reparse-test.js @@ -0,0 +1,217 @@ +import Helpers from '../test-helpers'; +const { test, module } = Helpers; + +let editor, editorElement; + +module('Acceptance: editor: reparsing', { + beforeEach() { + editorElement = $('#editor')[0]; + }, + afterEach() { + if (editor) { editor.destroy(); } + } +}); + +test('changing text node content causes reparse of section', (assert) => { + let done = assert.async(); + let expected; + let editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expected = post([markupSection('p', [marker('def')])]); + + return post([markupSection('p', [marker('abc')])]); + }); + + let reparsed = false; + editor.didReparse(() => reparsed = true); + + let section = editor.post.sections.head; + let node = section.markers.head.renderNode.element; + + assert.equal(node.textContent, 'abc', 'precond - correct text node'); + assert.equal(section.text, 'abc', 'precond - correct section'); + + node.textContent = 'def'; + + setTimeout(() => { + assert.equal(section.text, 'def', 'section reparsed correctly'); + assert.postIsSimilar(editor.post, expected); + assert.ok(reparsed, 'did reparse'); + done(); + }); +}); + +test('removing text node causes reparse of section', (assert) => { + let done = assert.async(); + let expected; + let editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expected = post([markupSection('p', [marker('def')])]); + + return post([markupSection('p', [marker('abc'), marker('def')])]); + }); + + let reparsed = false; + editor.didReparse(() => reparsed = true); + + let section = editor.post.sections.head; + let node = section.markers.head.renderNode.element; + + assert.equal(node.textContent, 'abc', 'precond - correct text node'); + assert.equal(section.text, 'abcdef', 'precond - correct section'); + + node.parentNode.removeChild(node); + + setTimeout(() => { + assert.equal(section.text, 'def', 'section reparsed correctly'); + assert.postIsSimilar(editor.post, expected); + assert.ok(reparsed, 'did reparse'); + done(); + }); +}); + +test('removing section node causes reparse of post', (assert) => { + let done = assert.async(); + let expected; + let editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expected = post([markupSection('p', [marker('123')])]); + + return post([ + markupSection('p', [marker('abc')]), + markupSection('p', [marker('123')]) + ]); + }); + + let reparsed = false; + editor.didReparse(() => reparsed = true); + + let node = editor.post.sections.head.renderNode.element; + assert.equal(node.innerHTML, 'abc', 'precond - correct node'); + + node.parentNode.removeChild(node); + + setTimeout(() => { + assert.postIsSimilar(editor.post, expected); + assert.ok(reparsed, 'did reparse'); + done(); + }); +}); + +test('inserting styled span in section causes section reparse', (assert) => { + let done = assert.async(); + let expected; + let editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expected = post([markupSection('p', [marker('abc'), marker('def')])]); + + return post([ + markupSection('p', [marker('abc')]) + ]); + }); + + let reparsed = false; + editor.didReparse(() => reparsed = true); + + let node = editor.post.sections.head.renderNode.element; + assert.equal(node.innerHTML, 'abc', 'precond - correct node'); + + let span = document.createElement('span'); + span.setAttribute('style','font-size: 24px; font-color: blue'); + span.appendChild(document.createTextNode('def')); + node.appendChild(span); + + setTimeout(() => { + assert.postIsSimilar(editor.post, expected); + assert.ok(reparsed, 'did reparse'); + done(); + }); +}); + +test('inserting new top-level node causes reparse of post', (assert) => { + let done = assert.async(); + let expected; + let editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expected = post([ + markupSection('p', [marker('abc')]), + markupSection('p', [marker('123')]) + ]); + + return post([markupSection('p', [marker('abc')])]); + }); + + let reparsed = false; + editor.didReparse(() => reparsed = true); + + let span = document.createElement('span'); + span.appendChild(document.createTextNode('123')); + editorElement.appendChild(span); + + setTimeout(() => { + assert.postIsSimilar(editor.post, expected); + assert.ok(reparsed, 'did reparse'); + done(); + }); +}); + +test('inserting node into blank post causes reparse', (assert) => { + let done = assert.async(); + let expected; + + let editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expected = post([markupSection('p', [marker('123')])]); + return post(); + }); + + let reparsed = false; + editor.didReparse(() => reparsed = true); + + let span = document.createElement('span'); + span.appendChild(document.createTextNode('123')); + editorElement.appendChild(span); + + setTimeout(() => { + assert.postIsSimilar(editor.post, expected); + assert.ok(reparsed, 'did reparse'); + done(); + }); +}); + +test('after reparsing post, mutations still handled properly', (assert) => { + let done = assert.async(); + let expected1, expected2; + let editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expected1 = post([ + markupSection('p', [marker('abc')]), + markupSection('p', [marker('123')]) + ]); + + expected2 = post([ + markupSection('p', [marker('def')]), + markupSection('p', [marker('123')]) + ]); + + return post([markupSection('p', [marker('abc')])]); + }); + + let reparsed = false; + editor.didReparse(() => reparsed = true); + + let span = document.createElement('span'); + span.appendChild(document.createTextNode('123')); + editorElement.appendChild(span); + + setTimeout(() => { + assert.postIsSimilar(editor.post, expected1); + assert.ok(reparsed, 'did reparse'); + reparsed = false; + + let node = editorElement.firstChild.firstChild; + assert.equal(node.textContent, 'abc', 'precond - correct node'); + + node.textContent = 'def'; + + setTimeout(() => { + assert.ok(reparsed, 'reparsed again'); + assert.postIsSimilar(editor.post, expected2); + + done(); + }); + }); +}); diff --git a/tests/unit/parsers/dom-test.js b/tests/unit/parsers/dom-test.js index 66e222699..c2bf7d9e1 100644 --- a/tests/unit/parsers/dom-test.js +++ b/tests/unit/parsers/dom-test.js @@ -1,8 +1,6 @@ import DOMParser from 'mobiledoc-kit/parsers/dom'; import PostNodeBuilder from 'mobiledoc-kit/models/post-node-builder'; import Helpers from '../../test-helpers'; -import { Editor } from 'mobiledoc-kit'; -import { NO_BREAK_SPACE } from 'mobiledoc-kit/renderers/editor-dom'; import { TAB } from 'mobiledoc-kit/utils/characters'; const {module, test} = Helpers; @@ -70,77 +68,6 @@ test('#parse can parse tabs', (assert) => { assert.equal(s1.markers.head.value, `a${TAB}b`); }); -test('editor#reparse catches changes to section', (assert) => { - const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => - post([ - markupSection('p', [marker('the marker')]) - ]) - ); - editor = new Editor({mobiledoc}); - editor.render(editorElement); - - assert.hasElement('#editor p:contains(the marker)', 'precond - rendered correctly'); - - const p = $('#editor p:eq(0)')[0]; - p.childNodes[0].textContent = 'the NEW marker'; - - // In Firefox, changing the text content changes the selection, so re-set it - Helpers.dom.moveCursorTo(p.childNodes[0]); - - editor.reparse(); - - const section = editor.post.sections.head; - assert.equal(section.text, 'the NEW marker'); -}); - -test('editor#reparse parses spaces and breaking spaces', (assert) => { - const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => - post([ - markupSection('p', [marker('the marker')]) - ]) - ); - editor = new Editor({mobiledoc}); - editor.render(editorElement); - - assert.hasElement('#editor p:contains(the marker)', 'precond - rendered correctly'); - - const p = $('#editor p:eq(0)')[0]; - p.childNodes[0].textContent = `some ${NO_BREAK_SPACE}text ${NO_BREAK_SPACE}${NO_BREAK_SPACE}for ${NO_BREAK_SPACE} ${NO_BREAK_SPACE}you`; - - // In Firefox, changing the text content changes the selection, so re-set it - Helpers.dom.moveCursorTo(p.childNodes[0]); - - editor.reparse(); - - const section = editor.post.sections.head; - assert.equal(section.text, 'some text for you'); -}); - -test('editor#reparse catches changes to list section', (assert) => { - const mobiledoc = Helpers.mobiledoc.build(({post, listSection, listItem, marker}) => - post([ - listSection('ul', [ - listItem([marker('the list item')]) - ]) - ]) - ); - editor = new Editor({mobiledoc}); - editor.render(editorElement); - - assert.hasElement('#editor li:contains(list item)', 'precond - rendered correctly'); - - const li = $('#editor li:eq(0)')[0]; - li.childNodes[0].textContent = 'the NEW list item'; - - // In Firefox, changing the text content changes the selection, so re-set it - Helpers.dom.moveCursorTo(li.childNodes[0]); - - editor.reparse(); - - const listItem = editor.post.sections.head.items.head; - assert.equal(listItem.text, 'the NEW list item'); -}); - test('parse empty content', (assert) => { let element = buildDOM(''); const post = parser.parse(element);