From 67e556a581d98c37ac35a07e9d892f973da5241e Mon Sep 17 00:00:00 2001 From: Richard Livsey Date: Mon, 30 Nov 2015 21:10:27 +0000 Subject: [PATCH] Implement unknownAtomHandler & lifecycle hooks --- src/js/editor/editor.js | 5 +- src/js/models/atom-node.js | 40 ++-- src/js/renderers/editor-dom.js | 64 ++++--- src/js/utils/paste-utils.js | 7 +- tests/helpers/mobiledoc.js | 7 +- tests/unit/editor/atom-lifecycle-test.js | 234 +++++++++++++++++++++++ tests/unit/renderers/editor-dom-test.js | 8 +- 7 files changed, 317 insertions(+), 48 deletions(-) create mode 100644 tests/unit/editor/atom-lifecycle-test.js diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index 6f3fc23c3..052911851 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -56,6 +56,9 @@ const defaults = { unknownCardHandler: ({env}) => { throw new Error(`Unknown card encountered: ${env.name}`); }, + unknownAtomHandler: ({env}) => { + throw new Error(`Unknown atom encountered: ${env.name}`); + }, mobiledoc: null, html: null }; @@ -92,7 +95,7 @@ class Editor { DEFAULT_KEY_COMMANDS.forEach(kc => this.registerKeyCommand(kc)); this._parser = new DOMParser(this.builder); - this._renderer = new Renderer(this, this.cards, this.atoms, this.unknownCardHandler, this.cardOptions); + this._renderer = new Renderer(this, this.cards, this.atoms, this.unknownCardHandler, this.unknownAtomHandler, this.cardOptions); this.post = this.loadPost(); this._renderTree = new RenderTree(this.post); diff --git a/src/js/models/atom-node.js b/src/js/models/atom-node.js index ea20df341..265cc5147 100644 --- a/src/js/models/atom-node.js +++ b/src/js/models/atom-node.js @@ -1,4 +1,4 @@ -import { clearChildNodes } from '../utils/dom-utils'; +import assert from '../utils/assert'; export default class AtomNode { constructor(editor, atom, model, element, atomOptions) { @@ -8,37 +8,53 @@ export default class AtomNode { this.atomOptions = atomOptions; this.element = element; - this._teardown = null; + this._teardownCallback = null; + this._rendered = null; } render() { this.teardown(); - let fragment = document.createDocumentFragment(); - - this._teardown = this.atom.render({ + let rendered = this.atom.render({ options: this.atomOptions, env: this.env, value: this.model.value, - payload: this.model.payload, - fragment + payload: this.model.payload }); - this.element.appendChild(fragment); + this._validateAndAppendRenderResult(rendered); } get env() { return { - name: this.atom.name + name: this.atom.name, + onTeardown: (callback) => this._teardownCallback = callback }; } teardown() { - if (this._teardown) { - this._teardown(); + if (this._teardownCallback) { + this._teardownCallback(); + this._teardownCallback = null; + } + if (this._rendered) { + this.element.removeChild(this._rendered); + this._rendered = null; + } + } + + _validateAndAppendRenderResult(rendered) { + if (!rendered) { + return; } - clearChildNodes(this.element); + let { atom: { name } } = this; + assert( + `Atom "${name}" must render dom (render value was: "${rendered}")`, + !!rendered.nodeType + ); + this.element.appendChild(rendered); + this._rendered = rendered; } } \ No newline at end of file diff --git a/src/js/renderers/editor-dom.js b/src/js/renderers/editor-dom.js index db6b56547..44ab4b1ec 100644 --- a/src/js/renderers/editor-dom.js +++ b/src/js/renderers/editor-dom.js @@ -230,7 +230,7 @@ function validateAtoms(atoms=[]) { atom.type === 'dom' ); assert( - `Card "${atom.name}" must define \`render\` method`, + `Atom "${atom.name}" must define \`render\` method`, !!atom.render ); }); @@ -238,11 +238,12 @@ function validateAtoms(atoms=[]) { } class Visitor { - constructor(editor, cards, atoms, unknownCardHandler, options) { + constructor(editor, cards, atoms, unknownCardHandler, unknownAtomHandler, options) { this.editor = editor; this.cards = validateCards(cards); this.atoms = validateAtoms(atoms); this.unknownCardHandler = unknownCardHandler; + this.unknownAtomHandler = unknownAtomHandler; this.options = options; } @@ -265,6 +266,24 @@ class Visitor { }; } + _findAtom(atomName) { + let atom = detect(this.atoms, atom => atom.name === atomName); + return atom || this._createUnknownAtom(atomName); + } + + _createUnknownAtom(atomName) { + assert( + `Unknown atom "${atomName}" found, but no unknownAtomHandler is defined`, + !!this.unknownAtomHandler + ); + + return { + name: atomName, + type: 'dom', + render: this.unknownAtomHandler + }; + } + [POST_TYPE](renderNode, post, visit) { if (!renderNode.element) { renderNode.element = document.createElement('div'); @@ -378,22 +397,16 @@ class Visitor { const {editor, options} = this; const atomElement = renderAtom(parentElement, renderNode.prev); - const atom = detect(this.atoms, atom => atom.name === atomModel.name); + const atom = this._findAtom(atomModel.name); - if (atom) { - const atomNode = new AtomNode( - editor, atom, atomModel, atomElement, options - ); + const atomNode = new AtomNode( + editor, atom, atomModel, atomElement, options + ); - atomNode.render(); + atomNode.render(); - renderNode.atomNode = atomNode; - renderNode.element = atomElement; - } else { - const env = { name: atomModel.name }; - this.unknownAtomHandler( // TODO - pass this in... - atomElement, options, env, atomModel.payload); - } + renderNode.atomNode = atomNode; + renderNode.element = atomElement; } } @@ -444,15 +457,16 @@ let destroyHooks = { } removeRenderNodeSectionFromParent(renderNode, section); removeRenderNodeElementFromParent(renderNode); - } + }, + + [ATOM_TYPE](renderNode, atom) { + if (renderNode.atomNode) { + renderNode.atomNode.teardown(); + } - // [ATOM_TYPE](renderNode, atom) { - // if (renderNode.atomNode) { - // renderNode.atomNode.teardown(); - // } - // - // // TODO - same/similar logic as markers? - // } + // an atom is a kind of marker so just call its destroy hook vs copying here + destroyHooks[MARKER_TYPE](renderNode, atom); + } }; // removes children from parentNode (a RenderNode) that are scheduled for removal @@ -485,9 +499,9 @@ function lookupNode(renderTree, parentNode, postNode, previousNode) { } export default class Renderer { - constructor(editor, cards, atoms, unknownCardHandler, options) { + constructor(editor, cards, atoms, unknownCardHandler, unknownAtomHandler, options) { this.editor = editor; - this.visitor = new Visitor(editor, cards, atoms, unknownCardHandler, options); + this.visitor = new Visitor(editor, cards, atoms, unknownCardHandler, unknownAtomHandler, options); this.nodes = []; this.hasRendered = false; } diff --git a/src/js/utils/paste-utils.js b/src/js/utils/paste-utils.js index 357a87451..b7e611804 100644 --- a/src/js/utils/paste-utils.js +++ b/src/js/utils/paste-utils.js @@ -42,13 +42,14 @@ export function setClipboardCopyData(copyEvent, editor) { const mobiledoc = post.cloneRange(range); let unknownCardHandler = () => {}; // ignore unknown cards - let {result: innerHTML } = - new HTMLRenderer({unknownCardHandler}).render(mobiledoc); + let unknownAtomHandler = () => {}; // ignore unknown atoms + let {result: innerHTML} = + new HTMLRenderer({unknownCardHandler, unknownAtomHandler}).render(mobiledoc); const html = `
${innerHTML}
`; const {result: plain} = - new TextRenderer({unknownCardHandler}).render(mobiledoc); + new TextRenderer({unknownCardHandler, unknownAtomHandler}).render(mobiledoc); clipboardData.setData(MIME_TEXT_PLAIN, plain); clipboardData.setData(MIME_TEXT_HTML, html); diff --git a/tests/helpers/mobiledoc.js b/tests/helpers/mobiledoc.js index 485ee5558..3c4fd8dd3 100644 --- a/tests/helpers/mobiledoc.js +++ b/tests/helpers/mobiledoc.js @@ -1,6 +1,7 @@ import PostAbstractHelpers from './post-abstract'; import mobiledocRenderers from 'mobiledoc-kit/renderers/mobiledoc'; -import MobiledocRenderer_0_2, { MOBILEDOC_VERSION } from 'mobiledoc-kit/renderers/mobiledoc/0-2'; +import MobiledocRenderer_0_2, { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_2 } from 'mobiledoc-kit/renderers/mobiledoc/0-2'; +import MobiledocRenderer_0_3, { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3 } from 'mobiledoc-kit/renderers/mobiledoc/0-3'; import Editor from 'mobiledoc-kit/editor/editor'; import { mergeWithOptions } from 'mobiledoc-kit/utils/merge'; @@ -17,8 +18,10 @@ import { mergeWithOptions } from 'mobiledoc-kit/utils/merge'; function build(treeFn, version) { let post = PostAbstractHelpers.build(treeFn); switch (version) { - case MOBILEDOC_VERSION: + case MOBILEDOC_VERSION_0_2: return MobiledocRenderer_0_2.render(post); + case MOBILEDOC_VERSION_0_3: + return MobiledocRenderer_0_3.render(post); case undefined: case null: return mobiledocRenderers.render(post); diff --git a/tests/unit/editor/atom-lifecycle-test.js b/tests/unit/editor/atom-lifecycle-test.js new file mode 100644 index 000000000..583dfc20a --- /dev/null +++ b/tests/unit/editor/atom-lifecycle-test.js @@ -0,0 +1,234 @@ +import Helpers from '../../test-helpers'; +import { Editor } from 'mobiledoc-kit'; +let editorElement, editor; + +import { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3 } from 'mobiledoc-kit/renderers/mobiledoc/0-3'; + +const { module, test } = Helpers; + +module('Unit: Editor: Atom Lifecycle', { + beforeEach() { + editorElement = $('#editor')[0]; + }, + afterEach() { + if (editor) { + editor.destroy(); + editor = null; + } + } +}); + + +function makeEl(id) { + let el = document.createElement('span'); + el.id = id; + return el; +} + +// Default version is 0.2 for the moment +function build(fn) { + return Helpers.mobiledoc.build(fn, MOBILEDOC_VERSION_0_3); +} + +function assertRenderArguments(assert, args, expected) { + let {env, options, payload} = args; + + assert.deepEqual(payload, expected.payload, 'correct payload'); + assert.deepEqual(options, expected.options, 'correct options'); + + // basic env + let {name, onTeardown} = env; + assert.equal(name, expected.name, 'correct name'); + assert.ok(!!onTeardown, 'has onTeardown'); +} + +test('rendering a mobiledoc with atom calls atom#render', (assert) => { + const atomPayload = { foo: 'bar' }; + const atomValue = "@bob"; + const cardOptions = { boo: 'baz' }; + const atomName = 'test-atom'; + + let renderArg; + + const atom = { + name: atomName, + type: 'dom', + render(_renderArg) { + renderArg = _renderArg; + } + }; + + const mobiledoc = build(({markupSection, post, atom}) => + post([markupSection('p', [atom(atomName, atomValue, atomPayload)])]) + ); + + editor = new Editor({mobiledoc, atoms: [atom], cardOptions}); + editor.render(editorElement); + + let expected = { + name: atomName, + payload: atomPayload, + options: cardOptions + }; + assertRenderArguments(assert, renderArg, expected); +}); + +test('rendering a mobiledoc with atom appends result of atom#render', (assert) => { + const atomName = 'test-atom'; + + const atom = { + name: atomName, + type: 'dom', + render() { + return makeEl('the-atom'); + } + }; + + const mobiledoc = build(({markupSection, post, atom}) => + post([markupSection('p', [atom(atomName, '@bob', {})])]) + ); + editor = new Editor({mobiledoc, atoms: [atom]}); + assert.hasNoElement('#editor #the-atom', 'precond - atom not rendered'); + editor.render(editorElement); + assert.hasElement('#editor #the-atom'); +}); + +test('returning wrong type from render throws', (assert) => { + const atomName = 'test-atom'; + + const atom = { + name: atomName, + type: 'dom', + render() { + return 'string'; + } + }; + + const mobiledoc = build(({markupSection, post, atom}) => + post([markupSection('p', [atom(atomName, '@bob', {})])]) + ); + editor = new Editor({mobiledoc, atoms: [atom]}); + + assert.throws(() => { + editor.render(editorElement); + }, new RegExp(`Atom "${atomName}" must render dom`)); +}); + +test('returning undefined from render is ok', (assert) => { + const atomName = 'test-atom'; + + const atom = { + name: atomName, + type: 'dom', + render() {} + }; + + const mobiledoc = build(({markupSection, post, atom}) => + post([markupSection('p', [atom(atomName, '@bob', {})])]) + ); + editor = new Editor({mobiledoc, atoms: [atom]}); + editor.render(editorElement); + assert.ok(true, 'no errors are thrown'); +}); + +test('rendering atom with wrong type throws', (assert) => { + const atomName = 'test-atom'; + const atom = { + name: atomName, + type: 'other', + render() {} + }; + const mobiledoc = build(({markupSection, post, atom}) => + post([markupSection('p', [atom(atomName, '@bob', {})])]) + ); + + assert.throws(() => { + editor = new Editor({mobiledoc, atoms: [atom]}); + editor.render(editorElement); + }, new RegExp(`Atom "${atomName}.* must define type`)); +}); + +test('rendering atom without render method throws', (assert) => { + const atomName = 'test-atom'; + const atom = { + name: atomName, + type: 'dom' + }; + const mobiledoc = build(({markupSection, post, atom}) => + post([markupSection('p', [atom(atomName, '@bob', {})])]) + ); + + assert.throws(() => { + editor = new Editor({mobiledoc, atoms: [atom]}); + editor.render(editorElement); + }, new RegExp(`Atom "${atomName}.* must define.*render`)); +}); + +test('rendering unknown atom calls #unknownAtomHandler', (assert) => { + const payload = { foo: 'bar' }; + const cardOptions = { boo: 'baz' }; + const atomName = 'test-atom'; + const atomValue = '@bob'; + + let unknownArg; + const unknownAtomHandler = (_unknownArg) => { + unknownArg = _unknownArg; + }; + + const mobiledoc = build(({markupSection, post, atom}) => + post([markupSection('p', [atom(atomName, atomValue, payload)])]) + ); + + editor = new Editor({mobiledoc, unknownAtomHandler, cardOptions}); + editor.render(editorElement); + + let expected = { + name: atomName, + value: atomValue, + options: cardOptions, + payload + }; + assertRenderArguments(assert, unknownArg, expected); +}); + +test('rendering unknown atom without unknownAtomHandler throws', (assert) => { + const atomName = 'test-atom'; + + const mobiledoc = build(({markupSection, post, atom}) => + post([markupSection('p', [atom(atomName, '@bob', {})])]) + ); + + editor = new Editor({mobiledoc, unknownAtomHandler: undefined}); + + assert.throws(() => { + editor.render(editorElement); + }, new RegExp(`Unknown atom "${atomName}".*no unknownAtomHandler`)); +}); + + + +test('onTeardown hook is called when editor is destroyed', (assert) => { + const atomName = 'test-atom'; + + let teardown; + + const atom = { + name: atomName, + type: 'dom', + render({env}) { + env.onTeardown(() => teardown = true); + } + }; + + const mobiledoc = build(({markupSection, post, atom}) => + post([markupSection('p', [atom(atomName, '@bob', {})])]) + ); + editor = new Editor({mobiledoc, atoms: [atom]}); + editor.render(editorElement); + + assert.ok(!teardown, 'nothing torn down yet'); + + editor.destroy(); + + assert.ok(teardown, 'onTeardown hook called'); +}); diff --git a/tests/unit/renderers/editor-dom-test.js b/tests/unit/renderers/editor-dom-test.js index 8f25f86c1..eaa7ee046 100644 --- a/tests/unit/renderers/editor-dom-test.js +++ b/tests/unit/renderers/editor-dom-test.js @@ -205,9 +205,8 @@ test('renders a post with atom', (assert) => { { name: 'mention', type: 'dom', - render({fragment, value/*, options, env, payload*/}) { - let textNode = document.createTextNode(value); - fragment.appendChild(textNode); + render({value/*, options, env, payload*/}) { + return document.createTextNode(value); } } ]); @@ -234,8 +233,7 @@ test('renders a post with mixed markups and atoms', (assert) => { name: 'mention', type: 'dom', render({fragment, value/*, options, env, payload*/}) { - let textNode = document.createTextNode(value); - fragment.appendChild(textNode); + return document.createTextNode(value); } } ]);