From ed0410aaded8d0cce3c4ea8204d615408873bd71 Mon Sep 17 00:00:00 2001 From: Matthew Beale Date: Tue, 14 Jul 2015 11:36:40 -0400 Subject: [PATCH 1/3] Ignore .env for AWS keys --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9ad0bd2d1..d3dc6f386 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ coverage # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt +.env # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release From 6261c7c02f5e83e38ea52c06d830d0343796603a Mon Sep 17 00:00:00 2001 From: Matthew Beale Date: Tue, 14 Jul 2015 11:36:56 -0400 Subject: [PATCH 2/3] Tweak docs to show booting node server --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 37813df0a..96266bdf9 100644 --- a/README.md +++ b/README.md @@ -125,3 +125,11 @@ export EMBEDLY_KEY=XXXXXX Also, set the `bucketName` in `server/config.json` with the name of your AWS S3 bucket for uploading files. + +Then to boot the server: + +``` +node server/index.js +``` + +And visit http://localhost:5000/dist/demo/index.html From 67c2e0d41049128bda81f8757c25ff7b30e42a84 Mon Sep 17 00:00:00 2001 From: Matthew Beale Date: Mon, 13 Jul 2015 12:20:42 -0400 Subject: [PATCH 3/3] Refactor Image and Card sections to a new renderer --- package.json | 4 +- server/config.json | 6 +- server/index.js | 1 - src/js/commands/image.js | 104 +++++------ src/js/editor/editor.js | 103 ++++++++--- src/js/models/card-node.js | 53 ++++++ src/js/models/image.js | 6 + src/js/models/post.js | 14 -- src/js/models/render-node.js | 75 ++++++++ src/js/models/render-tree.js | 18 ++ src/js/parsers/mobiledoc.js | 18 ++ src/js/renderers/editor-dom.js | 175 +++++++++++++++--- src/js/renderers/mobiledoc.js | 14 +- src/js/utils/array-utils.js | 11 ++ src/js/utils/post-builder.js | 12 ++ tests/unit/editor/card-lifecycle-test.js | 218 +++++++++++++++++++++++ tests/unit/editor/editor-test.js | 4 +- tests/unit/parsers/mobiledoc-test.js | 36 ++++ tests/unit/renderers/editor-dom-test.js | 169 ++++++++++++++++++ tests/unit/renderers/mobiledoc-test.js | 45 +++++ 20 files changed, 958 insertions(+), 128 deletions(-) create mode 100644 src/js/models/card-node.js create mode 100644 src/js/models/image.js create mode 100644 src/js/models/render-node.js create mode 100644 src/js/models/render-tree.js create mode 100644 src/js/utils/array-utils.js create mode 100644 tests/unit/editor/card-lifecycle-test.js create mode 100644 tests/unit/renderers/editor-dom-test.js diff --git a/package.json b/package.json index c6c4c6f64..26d08573c 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,8 @@ "broccoli-test-builder": "^0.1.0", "content-kit-utils": "^0.2.0", "jquery": "^2.1.4", - "mobiledoc-dom-renderer": "^0.1.3", - "mobiledoc-html-renderer": "^0.1.0", + "mobiledoc-dom-renderer": "^0.1.4", + "mobiledoc-html-renderer": "^0.1.1", "testem": "^0.8.4" } } diff --git a/server/config.json b/server/config.json index 2ebd149aa..3c7e6d010 100644 --- a/server/config.json +++ b/server/config.json @@ -1,5 +1,5 @@ -{ +{ "s3" : { - "bucketName" : "content-kit" + "bucketName" : "201-bustle-demo" } -} \ No newline at end of file +} diff --git a/server/index.js b/server/index.js index 4547b4e6e..73e1e5989 100644 --- a/server/index.js +++ b/server/index.js @@ -4,7 +4,6 @@ var EmbedService = require('./services/embed'); // Express app var app = express(); -app.use(express.static('demo')); app.use('/dist', express.static('dist')); // Enable cors diff --git a/src/js/commands/image.js b/src/js/commands/image.js index 1f0a5e553..e3b46abb0 100644 --- a/src/js/commands/image.js +++ b/src/js/commands/image.js @@ -2,37 +2,12 @@ import Command from './base'; import Message from '../views/message'; import { inherit } from 'content-kit-utils'; import { FileUploader } from '../utils/http-utils'; +import { generateBuilder } from '../utils/post-builder'; -function createFileInput(command) { - var fileInput = document.createElement('input'); - fileInput.type = 'file'; - fileInput.accept = 'image/*'; - fileInput.className = 'ck-file-input'; - // FIXME should this listener be torn down when the ImageCommand is not active? - fileInput.addEventListener('change', function(e) { - command.handleFile(e); - }); - return fileInput; -} - -function injectImageBlock(/* src, editor, index */) { - throw new Error('Unimplemented: BlockModel and Type.IMAGE are no longer things'); - /* - var imageModel = BlockModel.createWithType(Type.IMAGE, { attributes: { src: src } }); - editor.replaceBlock(imageModel, index); - */ -} - -function renderFromFile(file, editor, index) { - if (file && window.FileReader) { - var reader = new FileReader(); - reader.onload = function(e) { - var base64Src = e.target.result; - injectImageBlock(base64Src, editor, index); - editor.renderBlockAt(index, true); - }; - reader.readAsDataURL(file); - } +function readFromFile(file, callback) { + var reader = new FileReader(); + reader.onload = ({target}) => callback(target.result); + reader.readAsDataURL(file); } function ImageCommand(options) { @@ -40,44 +15,61 @@ function ImageCommand(options) { name: 'image', button: '' }); - this.uploader = new FileUploader({ url: options.serviceUrl, maxFileSize: 5000000 }); + this.uploader = new FileUploader({ + url: options.serviceUrl, + maxFileSize: 5000000 + }); } inherit(ImageCommand, Command); ImageCommand.prototype = { - exec: function() { + exec() { ImageCommand._super.prototype.exec.call(this); - var fileInput = this.fileInput; - if (!fileInput) { - fileInput = this.fileInput = createFileInput(this); - document.body.appendChild(fileInput); - } + var fileInput = this.getFileInput(); fileInput.dispatchEvent(new MouseEvent('click', { bubbles: false })); }, - handleFile: function(e) { - var fileInput = e.target; - var file = fileInput.files && fileInput.files[0]; - var editor = this.editorContext; - var embedIntent = this.embedIntent; - var currentEditingIndex = editor.getCurrentBlockIndex(); + getFileInput() { + if (this._fileInput) { + return this._fileInput; + } + + var fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/*'; + fileInput.className = 'ck-file-input'; + fileInput.addEventListener('change', e => this.handleFile(e)); + document.body.appendChild(fileInput); + + return fileInput; + }, + handleFile({target: fileInput}) { + let imageSection; + + let file = fileInput.files[0]; + readFromFile(file, (base64Image) => { + imageSection = generateBuilder().generateImageSection(base64Image); + this.editorContext.insertSectionAtCursor(imageSection); + this.editorContext.rerender(); + }); - embedIntent.showLoading(); - renderFromFile(file, editor, currentEditingIndex); // render image immediately client-side this.uploader.upload({ - fileInput: fileInput, - complete: function(response, error) { - embedIntent.hideLoading(); - if (error || !response || !response.url) { - setTimeout(function() { - editor.removeBlockAt(currentEditingIndex); - editor.syncVisual(); - }, 1000); - return new Message().showError(error.message || 'Error uploading image'); + fileInput, + complete: (response, error) => { + if (!imageSection) { + throw new Error('Upload completed before the image was read into memory'); + } + if (!error && response && response.url) { + imageSection.src = response.url; + imageSection.renderNode.markDirty(); + this.editorContext.rerender(); + this.editorContext.trigger('update'); + } else { + this.editorContext.removeSection(imageSection); + new Message().showError(error.message || 'Error uploading image'); } - injectImageBlock(response.url, editor, currentEditingIndex); + this.editorContext.rerender(); } }); - fileInput.value = null; // reset file input } }; diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index 1d175e365..1f422cbda 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -20,7 +20,8 @@ import EventEmitter from '../utils/event-emitter'; import MobiledocParser from "../parsers/mobiledoc"; import DOMParser from "../parsers/dom"; -import EditorDOMRenderer from "../renderers/editor-dom"; +import Renderer from 'content-kit-editor/renderers/editor-dom'; +import RenderTree from 'content-kit-editor/models/render-tree'; import MobiledocRenderer from '../renderers/mobiledoc'; import { toArray, merge, mergeWithOptions } from 'content-kit-utils'; @@ -53,7 +54,9 @@ var defaults = { new UnorderedListCommand(), new OrderedListCommand() ], - cards: {}, + cards: [], + cardOptions: {}, + unknownCardHandler: () => { throw new Error('Unknown card encountered'); }, mobiledoc: null }; @@ -88,7 +91,7 @@ function bindContentEditableTypingListeners(editor) { var sanitizedHTML = pastedHTML && editor._renderer.rerender(pastedHTML); if (sanitizedHTML) { document.execCommand('insertHTML', false, sanitizedHTML); - editor.syncVisual(); + editor.rerender(); } e.preventDefault(); return false; @@ -175,8 +178,8 @@ function Editor(element, options) { // FIXME: This should merge onto this.options mergeWithOptions(this, defaults, options); - this._renderer = new EditorDOMRenderer(window.document, this.cards); this._parser = new DOMParser(); + this._renderer = new Renderer(this.cards, this.unknownCardHandler, this.cardOptions); this.applyClassName(); this.applyPlaceholder(); @@ -193,12 +196,12 @@ function Editor(element, options) { } clearChildNodes(element); - this.syncVisual(); + this.rerender(); bindContentEditableTypingListeners(this); bindAutoTypingListeners(this); bindDragAndDrop(this); - this.addEventListener(element, 'input', () => this.handleInput(...arguments)); + this.addEventListener(element, 'input', () => this.handleInput()); initEmbedCommands(this); this.addView(new TextFormatToolbar({ @@ -227,22 +230,34 @@ merge(Editor.prototype, { loadModel(post) { this.post = post; - this.syncVisual(); + this.rerender(); this.trigger('update'); }, parseModelFromDOM(element) { this.post = this._parser.parse(element); + this._renderTree = new RenderTree(); + let node = this._renderTree.buildRenderNode(this.post); + this._renderTree.node = node; this.trigger('update'); }, parseModelFromMobiledoc(mobiledoc) { this.post = new MobiledocParser().parse(mobiledoc); + this._renderTree = new RenderTree(); + let node = this._renderTree.buildRenderNode(this.post); + this._renderTree.node = node; this.trigger('update'); }, - syncVisual() { - this._renderer.render(this.post, this.element); + rerender() { + let postRenderNode = this.post.renderNode; + if (!postRenderNode.element) { + postRenderNode.element = this.element; + postRenderNode.markDirty(); + } + + this._renderer.render(this._renderTree); }, getCurrentBlockIndex() { @@ -344,21 +359,28 @@ merge(Editor.prototype, { let newSections = []; let previousSection; forEachChildNode(this.element, (node) => { - let section = this.post.getElementSection(node); - if (!section) { - section = this._parser.parseSection( + let sectionRenderNode = this._renderTree.getElementRenderNode(node); + if (!sectionRenderNode) { + let section = this._parser.parseSection( previousSection, node ); - this.post.setSectionElement(section, node); newSections.push(section); + + sectionRenderNode = this._renderTree.buildRenderNode(section); + sectionRenderNode.element = node; + sectionRenderNode.markClean(); + if (previousSection) { this.post.insertSectionAfter(section, previousSection); + this._renderTree.node.insertAfter(sectionRenderNode, previousSection.renderNode); } else { this.post.prependSection(section); + this._renderTree.node.insertAfter(sectionRenderNode, null); } } // may cause duplicates to be included + let section = sectionRenderNode.postNode; sectionsInDOM.push(section); previousSection = section; }); @@ -368,7 +390,11 @@ merge(Editor.prototype, { for (i=this.post.sections.length-1;i>=0;i--) { let section = this.post.sections[i]; if (sectionsInDOM.indexOf(section) === -1) { - this.post.removeSection(section); + if (section.renderNode) { + section.renderNode.scheduleForRemoval(); + } else { + throw new Error('All sections are expected to have a renderNode'); + } } } @@ -388,9 +414,18 @@ merge(Editor.prototype, { this.reparseSection(section); } }); + + this.rerender(); + this.trigger('update'); }, getSectionsWithCursor() { + return this.getRenderNodesWithCursor().map( renderNode => { + return renderNode.postNode; + }); + }, + + getRenderNodesWithCursor() { const selection = document.getSelection(); if (selection.rangeCount === 0) { return null; @@ -400,26 +435,32 @@ merge(Editor.prototype, { let { startContainer:startElement, endContainer:endElement } = range; - let getElementSection = (e) => this.post.getElementSection(e); - let { result:startSection } = detectParentNode(startElement, getElementSection); - let { result:endSection } = detectParentNode(endElement, getElementSection); - - let startIndex = this.post.sections.indexOf(startSection), - endIndex = this.post.sections.indexOf(endSection); + let getElementRenderNode = (e) => { + return this._renderTree.getElementRenderNode(e); + }; + let { result:startRenderNode } = detectParentNode(startElement, getElementRenderNode); + let { result:endRenderNode } = detectParentNode(endElement, getElementRenderNode); + + let nodes = []; + let node = startRenderNode; + while (node && (!endRenderNode.nextSibling || endRenderNode.nextSibling !== node)) { + nodes.push(node); + node = node.nextSibling; + } - return this.post.sections.slice(startIndex, endIndex+1); + return nodes; }, reparseSection(section) { - let sectionElement = this.post.getSectionElement(section); + let sectionRenderNode = section.renderNode; + let sectionElement = sectionRenderNode.element; let previousSection = this.post.getPreviousSection(section); var newSection = this._parser.parseSection( previousSection, sectionElement ); - this.post.replaceSection(section, newSection); - this.post.setSectionElement(newSection, sectionElement); + section.markers = newSection.markers; this.trigger('update'); }, @@ -433,6 +474,20 @@ merge(Editor.prototype, { this._views = []; }, + insertSectionAtCursor(newSection) { + let newRenderNode = this._renderTree.buildRenderNode(newSection); + let renderNodes = this.getRenderNodesWithCursor(); + let lastRenderNode = renderNodes[renderNodes.length-1]; + lastRenderNode.parentNode.insertAfter(newRenderNode, lastRenderNode); + this.post.insertSectionAfter(newSection, lastRenderNode.postNode); + renderNodes.forEach(renderNode => renderNode.scheduleForRemoval()); + this.trigger('update'); + }, + + removeSection(section) { + this.post.removeSection(section); + }, + destroy() { this.removeAllEventListeners(); this.removeAllViews(); diff --git a/src/js/models/card-node.js b/src/js/models/card-node.js new file mode 100644 index 000000000..a5997fd5a --- /dev/null +++ b/src/js/models/card-node.js @@ -0,0 +1,53 @@ +export default class CardNode { + constructor(card, section, element, cardOptions) { + this.card = card; + this.section = section; + this.cardOptions = cardOptions; + this.element = element; + + this.mode = null; + this.setupResult = null; + } + + render(mode) { + if (this.mode === mode) { return; } + + this.teardown(); + + this.mode = mode; + this.setupResult = this.card[mode].setup( + this.element, + this.cardOptions, + this.env, + this.section.payload + ); + } + + get env() { + return { + name: this.card.name, + edit: () => { this.edit(); }, + save: (payload) => { + this.section.payload = payload; + this.display(); + }, + cancel: () => { this.display(); } + }; + } + + display() { + this.render('display'); + } + + edit() { + this.render('edit'); + } + + teardown() { + if (this.mode) { + if (this.card[this.mode].teardown) { + this.card[this.mode].teardown(this.setupResult); + } + } + } +} diff --git a/src/js/models/image.js b/src/js/models/image.js new file mode 100644 index 000000000..b78610114 --- /dev/null +++ b/src/js/models/image.js @@ -0,0 +1,6 @@ +export default class Image { + constructor() { + this.type = 'imageSection'; + this.src = null; + } +} diff --git a/src/js/models/post.js b/src/js/models/post.js index 8638393a3..512ec22a3 100644 --- a/src/js/models/post.js +++ b/src/js/models/post.js @@ -1,11 +1,8 @@ -import ElementMap from "../utils/element-map"; - // FIXME: making sections a linked-list would greatly improve this export default class Post { constructor() { this.type = 'post'; this.sections = []; - this.sectionElementMap = new ElementMap(); } appendSection(section) { this.sections.push(section); @@ -27,18 +24,7 @@ export default class Post { } throw new Error('Previous section was not found in post.sections'); } - setSectionElement(section, element) { - section.element = element; - this.sectionElementMap.set(element, section); - } - getSectionElement(section) { - return section && section.element; - } - getElementSection(element) { - return this.sectionElementMap.get(element); - } removeSection(section) { - this.sectionElementMap.remove(section.element); var i, l; for (i=0,l=this.sections.length;i card.name === section.name); + + const env = { name: section.name }; + renderNode.element = document.createElement('div'); + renderNode.parentNode.element.appendChild(renderNode.element); + + if (card) { + let cardNode = new CardNode(card, section, renderNode.element, this.options); + renderNode.cardNode = cardNode; + cardNode.display(); + } else { + this.unknownCardHandler(renderNode.element, this.options, env, section.payload); + } } - this.cards = cards; } -NewDOMRenderer.prototype.render = function NewDOMRenderer_render(post, target) { - var sections = post.sections; - var i, l, section, node; - for (i=0, l=sections.length;i { + let node = lookupNode(renderTree, parentNode, section, previousNode); + if (node.isDirty) { + nodes.push(node); + } + previousNode = node; + }); + } + let node = nodes.shift(); + while (node) { + removeChildren(node); + visitor[node.postNode.type](node, node.postNode, visit); + node.markClean(); + node = nodes.shift(); + } +} + +export default class Render { + constructor(cards, unknownCardHandler, options) { + this.visitor = new Visitor(cards, unknownCardHandler, options); + } + + render(renderTree) { + renderInternal(renderTree, this.visitor); + } +} diff --git a/src/js/renderers/mobiledoc.js b/src/js/renderers/mobiledoc.js index bac38670a..e967d0526 100644 --- a/src/js/renderers/mobiledoc.js +++ b/src/js/renderers/mobiledoc.js @@ -9,6 +9,12 @@ let visitor = { opcodes.push(['openMarkupSection', node.tagName]); visitArray(visitor, node.markers, opcodes); }, + imageSection(node, opcodes) { + opcodes.push(['openImageSection', node.src]); + }, + card(node, opcodes) { + opcodes.push(['openCardSection', node.name, node.payload]); + }, marker(node, opcodes) { opcodes.push(['openMarker', node.close, node.value]); visitArray(visitor, node.open, opcodes); @@ -24,13 +30,19 @@ let postOpcodeCompiler = { this.markers.push([ this.markupMarkerIds, closeCount, - value + value || '' ]); }, openMarkupSection(tagName) { this.markers = []; this.sections.push([1, tagName, this.markers]); }, + openImageSection(url) { + this.sections.push([2, url]); + }, + openCardSection(name, payload) { + this.sections.push([10, name, payload]); + }, openPost() { this.markerTypes = []; this.sections = []; diff --git a/src/js/utils/array-utils.js b/src/js/utils/array-utils.js new file mode 100644 index 000000000..9cb929864 --- /dev/null +++ b/src/js/utils/array-utils.js @@ -0,0 +1,11 @@ +function detect(array, callback) { + for (let i=0; i { + assert.expect(4); + + const payload = { + foo: 'bar' + }; + const cardOptions = { boo: 'baz' }; + + const card = { + name: 'test-card', + display: { + setup(element, options, env, setupPayload) { + assert.ok(editorElement.contains(element), + 'card element is part of the editor element'); + assert.deepEqual(payload, setupPayload, + 'the payload is passed to the card'); + assert.equal(env.name, 'test-card', + 'env.name is correct'); + assert.deepEqual(options, cardOptions, 'correct cardOptions'); + }, + teardown() { + } + } + }; + + const mobiledoc = [ + [], + [ + [10, 'test-card', payload] + ] + ]; + editor = new Editor(editorElement, { + mobiledoc, + cards: [card], + cardOptions + }); +}); + +test('rendering a mobiledoc for editing calls #unknownCardHandler when it encounters an unknown card', (assert) => { + assert.expect(1); + + const cardName = 'my-card'; + + const unknownCardHandler = (element, options, env /*,setupPayload*/) => { + assert.equal(env.name, cardName, 'includes card name in env'); + }; + + const mobiledoc = [ + [], + [ + [10, cardName, {}] + ] + ]; + + editor = new Editor(editorElement, {mobiledoc, unknownCardHandler}); +}); + +test('rendered card can fire edit hook to enter editing mode', (assert) => { + assert.expect(7); + + const payload = { foo: 'bar' }; + const cardOptions = { boo: 'baz' }; + + let returnedSetupValue = {some: 'object'}; + let span; + const card = { + name: 'test-card', + display: { + setup(element, options, env/*, setupPayload*/) { + span = document.createElement('span'); + span.onclick = function() { + assert.ok(true, 'precond - click occurred'); + env.edit(); + }; + element.appendChild(span); + return returnedSetupValue; + }, + teardown(passedValue) { + assert.ok(true, 'teardown called'); + assert.equal(passedValue, returnedSetupValue, + 'teardown called with return value of setup'); + } + }, + edit: { + setup(element, options, env, setupPayload) { + assert.ok(editorElement.contains(element), + 'card element is part of the editor element'); + assert.deepEqual(payload, setupPayload, + 'the payload is passed to the card'); + assert.equal(env.name, 'test-card', + 'env.name is correct'); + assert.deepEqual(options, cardOptions, 'correct cardOptions'); + } + } + }; + + const mobiledoc = [ + [], + [ + [10, 'test-card', payload] + ] + ]; + editor = new Editor(editorElement, { + mobiledoc, + cards: [card], + cardOptions + }); + + Helpers.dom.triggerEvent(span, 'click'); +}); + +test('rendered card can fire edit hook to enter editing mode, then save', (assert) => { + assert.expect(3); + + let setupPayloads = []; + let newPayload = {some: 'new values'}; + let doEdit, doSave; + const card = { + name: 'test-card', + display: { + setup(element, options, env, setupPayload) { + setupPayloads.push(setupPayload); + doEdit = () => { + env.edit(); + }; + } + }, + edit: { + setup(element, options, env) { + assert.ok(env.save, + 'env exposes save hook'); + doSave = () => { + env.save(newPayload); + }; + } + } + }; + + const payload = { foo: 'bar' }; + const mobiledoc = [ + [], + [ + [10, 'test-card', payload] + ] + ]; + editor = new Editor(editorElement, { + mobiledoc, + cards: [card] + }); + + doEdit(); + doSave(); + let [firstPayload, secondPayload] = setupPayloads; + assert.equal(firstPayload, payload, 'first display with mobiledoc payload'); + assert.equal(secondPayload, newPayload, 'second display with new payload'); +}); + +test('rendered card can fire edit hook to enter editing mode, then cancel', (assert) => { + assert.expect(3); + + let setupPayloads = []; + let doEdit, doCancel; + const card = { + name: 'test-card', + display: { + setup(element, options, env, setupPayload) { + setupPayloads.push(setupPayload); + doEdit = () => { + env.edit(); + }; + } + }, + edit: { + setup(element, options, env) { + assert.ok(env.cancel, 'env exposes cancel hook'); + doCancel = () => { + env.cancel(); + }; + } + } + }; + + const payload = { foo: 'bar' }; + const mobiledoc = [ + [], + [ + [10, 'test-card', payload] + ] + ]; + editor = new Editor(editorElement, { + mobiledoc, + cards: [card] + }); + + doEdit(); + doCancel(); + let [firstPayload, secondPayload] = setupPayloads; + assert.equal(firstPayload, payload, 'first display with mobiledoc payload'); + assert.equal(secondPayload, payload, 'second display with mobiledoc payload'); +}); diff --git a/tests/unit/editor/editor-test.js b/tests/unit/editor/editor-test.js index 86f05e7ca..b6dca0375 100644 --- a/tests/unit/editor/editor-test.js +++ b/tests/unit/editor/editor-test.js @@ -13,7 +13,9 @@ module('Unit: Editor', { fixture.appendChild(editorElement); }, afterEach: function() { - editor.destroy(); + if (editor) { + editor.destroy(); + } fixture.removeChild(editorElement); } }); diff --git a/tests/unit/parsers/mobiledoc-test.js b/tests/unit/parsers/mobiledoc-test.js index d12a479b5..53d1a737b 100644 --- a/tests/unit/parsers/mobiledoc-test.js +++ b/tests/unit/parsers/mobiledoc-test.js @@ -1,6 +1,7 @@ import MobiledocParser from 'content-kit-editor/parsers/mobiledoc'; import { generateBuilder } from 'content-kit-editor/utils/post-builder'; +const DATA_URL = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="; const { module, test } = window.QUnit; let parser, builder, post; @@ -77,3 +78,38 @@ test('#parse doc with marker type', (assert) => { ); }); +test('#parse doc with image section', (assert) => { + const mobiledoc = [ + [], + [ + [2, DATA_URL] + ] + ]; + + const parsed = parser.parse(mobiledoc); + + let section = builder.generateImageSection(DATA_URL); + post.appendSection(section); + assert.deepEqual( + parsed, + post + ); +}); + +test('#parse doc with custom card type', (assert) => { + const mobiledoc = [ + [], + [ + [10, 'custom-card', {}] + ] + ]; + + const parsed = parser.parse(mobiledoc); + + let section = builder.generateCardSection('custom-card'); + post.appendSection(section); + assert.deepEqual( + parsed, + post + ); +}); diff --git a/tests/unit/renderers/editor-dom-test.js b/tests/unit/renderers/editor-dom-test.js new file mode 100644 index 000000000..df0d07507 --- /dev/null +++ b/tests/unit/renderers/editor-dom-test.js @@ -0,0 +1,169 @@ +import { generateBuilder } from 'content-kit-editor/utils/post-builder'; +const { module, test } = window.QUnit; +import Renderer from 'content-kit-editor/renderers/render'; +import RenderNode from 'content-kit-editor/models/render-node'; +import RenderTree from 'content-kit-editor/models/render-tree'; + +const DATA_URL = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="; +let builder; + +function render(renderTree) { + let renderer = new Renderer([]); + return renderer.render(renderTree); +} + +module("Unit: Renderer", { + beforeEach() { + builder = generateBuilder(); + } +}); + +test("It renders a dirty post", (assert) => { + /* + * renderTree is: + * + * renderNode + * + */ + let renderNode = new RenderNode(builder.generatePost()); + let renderTree = new RenderTree(renderNode); + renderNode.renderTree = renderTree; + + render(renderTree); + + assert.ok(renderTree.node.element, 'renderTree renders element for post'); + assert.ok(!renderTree.node.isDirty, 'dirty node becomes clean'); + assert.equal(renderTree.node.element.tagName, 'DIV', 'renderTree renders element for post'); +}); + +test("It renders a dirty post with un-rendered sections", (assert) => { + let post = builder.generatePost(); + let sectionA = builder.generateSection('P'); + post.appendSection(sectionA); + let sectionB = builder.generateSection('P'); + post.appendSection(sectionB); + + let renderNode = new RenderNode(post); + let renderTree = new RenderTree(renderNode); + renderNode.renderTree = renderTree; + + render(renderTree); + + assert.equal(renderTree.node.element.outerHTML, '

', + 'correct HTML is rendered'); + + assert.ok(renderTree.node.firstChild, + 'sectionA creates a first child'); + assert.equal(renderTree.node.firstChild.postNode, sectionA, + 'sectionA is first renderNode child'); + assert.ok(!renderTree.node.firstChild.isDirty, 'sectionA node is clean'); + assert.equal(renderTree.node.lastChild.postNode, sectionB, + 'sectionB is second renderNode child'); + assert.ok(!renderTree.node.lastChild.isDirty, 'sectionB node is clean'); +}); + +[ + { + name: 'markup', + section: (builder) => builder.generateSection('P') + }, + { + name: 'image', + section: (builder) => builder.generateImageSection(DATA_URL) + }, + { + name: 'card', + section: (builder) => builder.generateCardSection('new-card') + } +].forEach((testInfo) => { + test(`Remove nodes with ${testInfo.name} section`, (assert) => { + let post = builder.generatePost(); + let section = testInfo.section(builder); + post.appendSection(section); + + let postElement = document.createElement('div'); + let sectionElement = document.createElement('p'); + postElement.appendChild(sectionElement); + + let postRenderNode = new RenderNode(post); + + let renderTree = new RenderTree(postRenderNode); + postRenderNode.renderTree = renderTree; + postRenderNode.element = postElement; + + let sectionRenderNode = renderTree.buildRenderNode(section); + sectionRenderNode.element = sectionElement; + sectionRenderNode.scheduleForRemoval(); + postRenderNode.appendChild(sectionRenderNode); + + render(renderTree); + + assert.equal(renderTree.node.element, postElement, + 'post element remains'); + + assert.equal(renderTree.node.element.firstChild, null, + 'section element removed'); + + assert.equal(renderTree.node.firstChild, null, + 'section renderNode is removed'); + }); +}); + +test('renders a post with marker', (assert) => { + let post = builder.generatePost(); + let section = builder.generateSection('P'); + post.appendSection(section); + section.markers.push( + builder.generateMarker([ + builder.generateMarkerType('STRONG') + ], 1, 'Hi') + ); + + let node = new RenderNode(post); + let renderTree = new RenderTree(node); + node.renderTree = renderTree; + render(renderTree); + assert.equal(node.element.innerHTML, '

Hi

'); +}); + +test('renders a post with image', (assert) => { + let url = DATA_URL; + let post = builder.generatePost(); + let section = builder.generateImageSection(url); + post.appendSection(section); + + let node = new RenderNode(post); + let renderTree = new RenderTree(node); + node.renderTree = renderTree; + render(renderTree); + assert.equal(node.element.innerHTML, ``); +}); + +/* +test("It renders a renderTree with rendered dirty section", (assert) => { + /* + * renderTree is: + * + * post + * / \ + * / \ + * section section + * + let post = builder.generatePost + let postRenderNode = { + element: null, + parent: null, + isDirty: true, + postNode: builder.generatePost() + } + let renderTree = { + node: renderNode + } + + render(renderTree); + + assert.ok(renderTree.node.element, 'renderTree renders element for post'); + assert.ok(!renderTree.node.isDirty, 'dirty node becomes clean'); + assert.equal(renderTree.node.element.tagName, 'DIV', 'renderTree renders element for post'); +}); +*/ diff --git a/tests/unit/renderers/mobiledoc-test.js b/tests/unit/renderers/mobiledoc-test.js index 12145c7f2..f31da6c27 100644 --- a/tests/unit/renderers/mobiledoc-test.js +++ b/tests/unit/renderers/mobiledoc-test.js @@ -39,3 +39,48 @@ test('renders a post with marker', (assert) => { ] ]); }); + +test('renders a post with image', (assert) => { + let url = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="; + let post = builder.generatePost(); + let section = builder.generateImageSection(url); + post.appendSection(section); + + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, [ + [], + [ + [2, url] + ] + ]); +}); + +test('renders a post with image and null src', (assert) => { + let post = builder.generatePost(); + let section = builder.generateImageSection(); + post.appendSection(section); + + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, [ + [], + [ + [2, null] + ] + ]); +}); + +test('renders a post with card', (assert) => { + let cardName = 'super-card'; + let payload = { bar: 'baz' }; + let post = builder.generatePost(); + let section = builder.generateCardSection(cardName, payload); + post.appendSection(section); + + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, [ + [], + [ + [10, cardName, payload] + ] + ]); +});