diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index 4835e6761..6f3fc23c3 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -51,6 +51,7 @@ const defaults = { spellcheck: true, autofocus: true, cards: [], + atoms: [], cardOptions: {}, unknownCardHandler: ({env}) => { throw new Error(`Unknown card encountered: ${env.name}`); @@ -91,7 +92,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.unknownCardHandler, this.cardOptions); + this._renderer = new Renderer(this, this.cards, this.atoms, this.unknownCardHandler, 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 new file mode 100644 index 000000000..ea20df341 --- /dev/null +++ b/src/js/models/atom-node.js @@ -0,0 +1,44 @@ +import { clearChildNodes } from '../utils/dom-utils'; + +export default class AtomNode { + constructor(editor, atom, model, element, atomOptions) { + this.editor = editor; + this.atom = atom; + this.model = model; + this.atomOptions = atomOptions; + this.element = element; + + this._teardown = null; + } + + render() { + this.teardown(); + + let fragment = document.createDocumentFragment(); + + this._teardown = this.atom.render({ + options: this.atomOptions, + env: this.env, + value: this.model.value, + payload: this.model.payload, + fragment + }); + + this.element.appendChild(fragment); + } + + get env() { + return { + name: this.atom.name + }; + } + + teardown() { + if (this._teardown) { + this._teardown(); + } + + clearChildNodes(this.element); + } + +} \ No newline at end of file diff --git a/src/js/models/atom.js b/src/js/models/atom.js new file mode 100644 index 000000000..66fa9db4b --- /dev/null +++ b/src/js/models/atom.js @@ -0,0 +1,20 @@ +import { ATOM_TYPE } from './types'; +import mixin from '../utils/mixin'; +import MarkuperableMixin from '../utils/markuperable'; +import LinkedItem from '../utils/linked-item'; + +export default class Atom extends LinkedItem { + constructor(name, value, payload, markups=[]) { + super(); + this.name = name; + this.value = value; + this.payload = payload; + this.type = ATOM_TYPE; + this.length = 1; + + this.markups = []; + markups.forEach(m => this.addMarkup(m)); + } +} + +mixin(Atom, MarkuperableMixin); \ No newline at end of file diff --git a/src/js/models/marker.js b/src/js/models/marker.js index ec374ba4e..7b4fc341c 100644 --- a/src/js/models/marker.js +++ b/src/js/models/marker.js @@ -1,6 +1,6 @@ import { MARKER_TYPE } from './types'; -import { normalizeTagName } from '../utils/dom-utils'; -import { detect, commonItemLength, forEach, filter } from '../utils/array-utils'; +import mixin from '../utils/mixin'; +import MarkuperableMixin from '../utils/markuperable'; import LinkedItem from '../utils/linked-item'; import assert from '../utils/assert'; @@ -41,44 +41,8 @@ const Marker = class Marker extends LinkedItem { return this.value.length; } - clearMarkups() { - this.markups = []; - } - - addMarkup(markup) { - this.markups.push(markup); - } - - removeMarkup(markupOrMarkupCallback) { - let callback; - if (typeof markupOrMarkupCallback === 'function') { - callback = markupOrMarkupCallback; - } else { - let markup = markupOrMarkupCallback; - callback = (_markup) => _markup === markup; - } - - forEach( - filter(this.markups, callback), - m => this._removeMarkup(m) - ); - } - - _removeMarkup(markup) { - const index = this.markups.indexOf(markup); - if (index !== -1) { - this.markups.splice(index, 1); - } - } - - /** - * delete the character at this offset, - * update the value with the new value. - * This method mutates the marker. - * - * @return {Number} the length of the change - * (usually 1 but can be 2 when deleting an emoji, e.g.) - */ + // delete the character at this offset, + // update the value with the new value deleteValueAtOffset(offset) { assert('Cannot delete value at offset outside bounds', offset >= 0 && offset <= this.length); @@ -101,20 +65,6 @@ const Marker = class Marker extends LinkedItem { return width; } - hasMarkup(tagNameOrMarkup) { - return !!this.getMarkup(tagNameOrMarkup); - } - - getMarkup(tagNameOrMarkup) { - if (typeof tagNameOrMarkup === 'string') { - let tagName = normalizeTagName(tagNameOrMarkup); - return detect(this.markups, markup => markup.tagName === tagName); - } else { - let targetMarkup = tagNameOrMarkup; - return detect(this.markups, markup => markup === targetMarkup); - } - } - join(other) { const joined = this.builder.createMarker(this.value + other.value); this.markups.forEach(m => joined.addMarkup(m)); @@ -155,24 +105,8 @@ const Marker = class Marker extends LinkedItem { return [pre, post]; } - get openedMarkups() { - let count = 0; - if (this.prev) { - count = commonItemLength(this.markups, this.prev.markups); - } - - return this.markups.slice(count); - } - - get closedMarkups() { - let count = 0; - if (this.next) { - count = commonItemLength(this.markups, this.next.markups); - } - - return this.markups.slice(count); - } - }; +mixin(Marker, MarkuperableMixin); + export default Marker; diff --git a/src/js/models/post-node-builder.js b/src/js/models/post-node-builder.js index a8a0c93a0..f5708c605 100644 --- a/src/js/models/post-node-builder.js +++ b/src/js/models/post-node-builder.js @@ -1,3 +1,4 @@ +import Atom from '../models/atom'; import Post from '../models/post'; import MarkupSection from '../models/markup-section'; import ListSection from '../models/list-section'; @@ -102,8 +103,8 @@ export default class PostNodeBuilder { return marker; } - createAtom(name, text, payload={}) { - const atom = new Atom(name, text, payload); + createAtom(name, text, payload={}, markups=[]) { + const atom = new Atom(name, text, payload, markups); atom.builder = this; return atom; } diff --git a/src/js/models/types.js b/src/js/models/types.js index 12dd1a07b..aa2f6f0c9 100644 --- a/src/js/models/types.js +++ b/src/js/models/types.js @@ -6,3 +6,4 @@ export const POST_TYPE = 'post'; export const LIST_ITEM_TYPE = 'list-item'; export const CARD_TYPE = 'card-section'; export const IMAGE_SECTION_TYPE = 'image-section'; +export const ATOM_TYPE = 'atom'; \ No newline at end of file diff --git a/src/js/renderers/editor-dom.js b/src/js/renderers/editor-dom.js index 58372eb04..db6b56547 100644 --- a/src/js/renderers/editor-dom.js +++ b/src/js/renderers/editor-dom.js @@ -1,5 +1,6 @@ import CardNode from 'mobiledoc-kit/models/card-node'; import { detect, forEach } from 'mobiledoc-kit/utils/array-utils'; +import AtomNode from 'mobiledoc-kit/models/atom-node'; import { POST_TYPE, MARKUP_SECTION_TYPE, @@ -7,7 +8,8 @@ import { LIST_ITEM_TYPE, MARKER_TYPE, IMAGE_SECTION_TYPE, - CARD_TYPE + CARD_TYPE, + ATOM_TYPE } from '../models/types'; import { startsWith, endsWith } from '../utils/string-utils'; import { addClassName } from '../utils/dom-utils'; @@ -109,6 +111,21 @@ function renderCard() { return { wrapper, cardElement }; } +function renderAtom(element, previousRenderNode) { + let atomElement = document.createElement('span'); + addClassName(atomElement, '-mobiledoc-kit__atom'); + + if (previousRenderNode) { + let previousSibling = previousRenderNode.element; + let previousSiblingPenultimate = penultimateParentOf(previousSibling, element); + element.insertBefore(atomElement, previousSiblingPenultimate.nextSibling); + } else { + element.insertBefore(atomElement, element.firstChild); + } + + return atomElement; +} + function getNextMarkerElement(renderNode) { let element = renderNode.element.parentNode; let marker = renderNode.postNode; @@ -206,10 +223,25 @@ function validateCards(cards=[]) { return cards; } +function validateAtoms(atoms=[]) { + forEach(atoms, atom => { + assert( + `Atom "${atom.name}" must define type "dom", has: "${atom.type}"`, + atom.type === 'dom' + ); + assert( + `Card "${atom.name}" must define \`render\` method`, + !!atom.render + ); + }); + return atoms; +} + class Visitor { - constructor(editor, cards, unknownCardHandler, options) { + constructor(editor, cards, atoms, unknownCardHandler, options) { this.editor = editor; this.cards = validateCards(cards); + this.atoms = validateAtoms(atoms); this.unknownCardHandler = unknownCardHandler; this.options = options; } @@ -334,6 +366,35 @@ class Visitor { const initialMode = section._initialMode; cardNode[initialMode](); } + + [ATOM_TYPE](renderNode, atomModel) { + let parentElement; + + if (renderNode.prev) { + parentElement = getNextMarkerElement(renderNode.prev); + } else { + parentElement = renderNode.parent.element; + } + + const {editor, options} = this; + const atomElement = renderAtom(parentElement, renderNode.prev); + const atom = detect(this.atoms, atom => atom.name === atomModel.name); + + if (atom) { + const atomNode = new AtomNode( + editor, atom, atomModel, atomElement, options + ); + + 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); + } + } } let destroyHooks = { @@ -384,6 +445,14 @@ let destroyHooks = { removeRenderNodeSectionFromParent(renderNode, section); removeRenderNodeElementFromParent(renderNode); } + + // [ATOM_TYPE](renderNode, atom) { + // if (renderNode.atomNode) { + // renderNode.atomNode.teardown(); + // } + // + // // TODO - same/similar logic as markers? + // } }; // removes children from parentNode (a RenderNode) that are scheduled for removal @@ -416,9 +485,9 @@ function lookupNode(renderTree, parentNode, postNode, previousNode) { } export default class Renderer { - constructor(editor, cards, unknownCardHandler, options) { + constructor(editor, cards, atoms, unknownCardHandler, options) { this.editor = editor; - this.visitor = new Visitor(editor, cards, unknownCardHandler, options); + this.visitor = new Visitor(editor, cards, atoms, unknownCardHandler, options); this.nodes = []; this.hasRendered = false; } diff --git a/src/js/utils/markuperable.js b/src/js/utils/markuperable.js new file mode 100644 index 000000000..74088eeab --- /dev/null +++ b/src/js/utils/markuperable.js @@ -0,0 +1,67 @@ +import { normalizeTagName } from '../utils/dom-utils'; +import { detect, commonItemLength, forEach, filter } from '../utils/array-utils'; + +export default class Markerupable { + + clearMarkups() { + this.markups = []; + } + + addMarkup(markup) { + this.markups.push(markup); + } + + removeMarkup(markupOrMarkupCallback) { + let callback; + if (typeof markupOrMarkupCallback === 'function') { + callback = markupOrMarkupCallback; + } else { + let markup = markupOrMarkupCallback; + callback = (_markup) => _markup === markup; + } + + forEach( + filter(this.markups, callback), + m => this._removeMarkup(m) + ); + } + + _removeMarkup(markup) { + const index = this.markups.indexOf(markup); + if (index !== -1) { + this.markups.splice(index, 1); + } + } + + hasMarkup(tagNameOrMarkup) { + return !!this.getMarkup(tagNameOrMarkup); + } + + getMarkup(tagNameOrMarkup) { + if (typeof tagNameOrMarkup === 'string') { + let tagName = normalizeTagName(tagNameOrMarkup); + return detect(this.markups, markup => markup.tagName === tagName); + } else { + let targetMarkup = tagNameOrMarkup; + return detect(this.markups, markup => markup === targetMarkup); + } + } + + get openedMarkups() { + let count = 0; + if (this.prev) { + count = commonItemLength(this.markups, this.prev.markups); + } + + return this.markups.slice(count); + } + + get closedMarkups() { + let count = 0; + if (this.next) { + count = commonItemLength(this.markups, this.next.markups); + } + + return this.markups.slice(count); + } +} diff --git a/tests/helpers/post-abstract.js b/tests/helpers/post-abstract.js index 150714deb..72fc360cb 100644 --- a/tests/helpers/post-abstract.js +++ b/tests/helpers/post-abstract.js @@ -20,7 +20,8 @@ function build(treeFn) { marker : (...args) => builder.createMarker(...args), listSection : (...args) => builder.createListSection(...args), listItem : (...args) => builder.createListItem(...args), - cardSection : (...args) => builder.createCardSection(...args) + cardSection : (...args) => builder.createCardSection(...args), + atom : (...args) => builder.createAtom(...args) }; return treeFn(simpleBuilder); diff --git a/tests/unit/editor/post-test.js b/tests/unit/editor/post-test.js index f31300a72..c388ca259 100644 --- a/tests/unit/editor/post-test.js +++ b/tests/unit/editor/post-test.js @@ -34,7 +34,7 @@ function postEditorWithMobiledoc(treeFn) { function renderBuiltAbstract(post) { mockEditor.post = post; - let renderer = new EditorDomRenderer(mockEditor, [], () => {}, {}); + let renderer = new EditorDomRenderer(mockEditor, [], [], () => {}, {}); let renderTree = new RenderTree(post); renderer.render(renderTree); return mockEditor; diff --git a/tests/unit/models/atom-test.js b/tests/unit/models/atom-test.js new file mode 100644 index 000000000..06b885271 --- /dev/null +++ b/tests/unit/models/atom-test.js @@ -0,0 +1,25 @@ +const {module, test} = QUnit; + +import PostNodeBuilder from 'mobiledoc-kit/models/post-node-builder'; + +let builder; +module('Unit: Atom', { + beforeEach() { + builder = new PostNodeBuilder(); + }, + afterEach() { + builder = null; + } +}); + +test('can create an atom with value and payload', (assert) => { + let payload = {}; + let value = 'atom-value'; + let name = 'atom-name'; + let atom = builder.createAtom(name, value, payload); + assert.ok(!!atom, 'creates atom'); + assert.ok(atom.name === name, 'has name'); + assert.ok(atom.value === value, 'has value'); + assert.ok(atom.payload === payload, 'has payload'); + assert.ok(atom.length === 1, 'has length of 1'); +}); \ No newline at end of file diff --git a/tests/unit/renderers/editor-dom-test.js b/tests/unit/renderers/editor-dom-test.js index 983721466..8f25f86c1 100644 --- a/tests/unit/renderers/editor-dom-test.js +++ b/tests/unit/renderers/editor-dom-test.js @@ -10,9 +10,9 @@ import placeholderImageSrc from 'mobiledoc-kit/utils/placeholder-image-src'; let builder; let renderer; -function render(renderTree, cards=[]) { +function render(renderTree, cards=[], atoms=[]) { let editor = {}; - renderer = new Renderer(editor, cards); + renderer = new Renderer(editor, cards, atoms); return renderer.render(renderTree); } @@ -195,6 +195,54 @@ test('renders a post with image', (assert) => { assert.equal(renderTree.rootElement.innerHTML, ``); }); +test('renders a post with atom', (assert) => { + let post = Helpers.postAbstract.build(({ markupSection, post, atom }) => { + return post([markupSection('p', [atom('mention', '@bob', {})])]); + }); + + const renderTree = new RenderTree(post); + render(renderTree, [], [ + { + name: 'mention', + type: 'dom', + render({fragment, value/*, options, env, payload*/}) { + let textNode = document.createTextNode(value); + fragment.appendChild(textNode); + } + } + ]); + assert.equal(renderTree.rootElement.innerHTML, `

@bob

`); +}); + +test('renders a post with mixed markups and atoms', (assert) => { + let post = Helpers.postAbstract.build(({ markupSection, post, atom, marker, markup }) => { + let b = markup('B'); + let i = markup('I'); + + return post([markupSection('p', [ + marker('bold', [b]), + marker('italic ', [b, i]), + atom('mention', '@bob', {}, [b, i]), + marker(' bold', [b]), + builder.createMarker('text.') + ])]); + }); + + const renderTree = new RenderTree(post); + render(renderTree, [], [ + { + name: 'mention', + type: 'dom', + render({fragment, value/*, options, env, payload*/}) { + let textNode = document.createTextNode(value); + fragment.appendChild(textNode); + } + } + ]); + + assert.equal(renderTree.rootElement.innerHTML, `

bolditalic @bob boldtext.

`); +}); + test('renders a card section', (assert) => { let post = builder.createPost(); let cardSection = builder.createCardSection('my-card'); @@ -238,9 +286,9 @@ test('renders a card section', (assert) => { * section * | * | - * | + * | * marker1 [b] - * | + * | * + */ @@ -431,7 +479,7 @@ test('contiguous markers have overlapping markups', (assert) => { }); test('renders and rerenders list items', (assert) => { - const post = Helpers.postAbstract.build(({post, listSection, listItem, marker}) => + const post = Helpers.postAbstract.build(({post, listSection, listItem, marker}) => post([ listSection('ul', [ listItem([marker('first item')]),