diff --git a/demo/demo.js b/demo/demo.js index 55e536039..86ea8a31a 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -182,7 +182,8 @@ var ContentKitDemo = exports.ContentKitDemo = { 'simple-card': simpleCard, 'edit-card': cardWithEditMode, 'input-card': cardWithInput, - 'selfie-card': selfieCard + 'selfie-card': selfieCard, + 'image': ContentKit.ImageCard }; var renderer = new MobiledocDOMRenderer(); var rendered = renderer.render(mobiledoc, document.createElement('div'), cards); @@ -262,7 +263,12 @@ function bootEditor(element, mobiledoc) { editor = new ContentKit.Editor(element, { autofocus: false, mobiledoc: mobiledoc, - cards: [simpleCard, cardWithEditMode, cardWithInput, selfieCard] + cards: [simpleCard, cardWithEditMode, cardWithInput, selfieCard], + cardOptions: { + image: { + uploadUrl: 'http://localhost:5000/upload' + } + } }); function sync() { diff --git a/package.json b/package.json index 23fcb6685..bcf94a9db 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,8 @@ "broccoli-test-builder": "^0.1.0", "content-kit-utils": "^0.2.0", "jquery": "^2.1.4", - "mobiledoc-dom-renderer": "^0.1.8", - "mobiledoc-html-renderer": "^0.1.5", + "mobiledoc-dom-renderer": "^0.1.10", + "mobiledoc-html-renderer": "^0.1.6", "testem": "^0.8.4" } } diff --git a/src/js/cards/image.js b/src/js/cards/image.js new file mode 100644 index 000000000..b99595a33 --- /dev/null +++ b/src/js/cards/image.js @@ -0,0 +1,96 @@ +import placeholderImage from './placeholder-image'; +import { FileUploader } from '../utils/http-utils'; + +function buildFileInput() { + let input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.classList.add('ck-file-input'); + document.body.appendChild(input); + return input; +} + +function buildButton(text) { + let button = document.createElement('button'); + button.innerHTML = text; + return button; +} + +function upload(imageOptions, fileInput, success, failure) { + let uploader = new FileUploader({ + url: imageOptions.uploadUrl, + maxFileSize: 5000000 + }); + uploader.upload({ + fileInput, + complete: (response, error) => { + if (!error && response && response.url) { + success({ + src: response.url + }); + } else { + window.alert('There was a problem uploading the image: '+error); + failure(); + } + } + }); +} + +export default { + name: 'image', + + display: { + setup(element, options, {edit}, payload) { + var img = document.createElement('img'); + img.src = payload.src || placeholderImage; + if (edit) { + img.onclick = edit; + } + element.appendChild(img); + return img; + }, + teardown(element) { + element.parentNode.removeChild(element); + } + }, + + edit: { + setup(element, options, {save, cancel}) { + let uploadButton = buildButton('Upload'); + let cancelButton = buildButton('Cancel'); + cancelButton.onclick = cancel; + + let {image: imageOptions} = options; + if (!imageOptions || (imageOptions && !imageOptions.uploadUrl)) { + window.alert('Image card must have `image.uploadUrl` included in cardOptions'); + cancel(); + return; + } + + + let fileInput = buildFileInput(); + uploadButton.onclick = () => { + fileInput.dispatchEvent(new MouseEvent('click', { bubbles: false })); + }; + element.appendChild(uploadButton); + element.appendChild(cancelButton); + + fileInput.onchange = () => { + try { + if (fileInput.files.length === 0) { + cancel(); + } + upload(imageOptions, fileInput, save, cancel); + } catch(error) { + window.alert('There was a starting the upload: '+error); + cancel(); + } + }; + return [uploadButton, cancelButton, fileInput]; + }, + teardown(elements) { + elements.forEach(element => element.parentNode.removeChild(element)); + } + } + +}; diff --git a/src/js/cards/placeholder-image.js b/src/js/cards/placeholder-image.js new file mode 100644 index 000000000..1a984a943 --- /dev/null +++ b/src/js/cards/placeholder-image.js @@ -0,0 +1,3 @@ +const placeholderImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAOEAAADhCAMAAAAJbSJIAAAAMFBMVEXp7vG6vsHm6+63u77Hy868wMPe4+bO09bh5unr8fTR1djAxMfM0NPX3N/c4eTBxcjXRf5TAAACh0lEQVR4nO3b6ZKqMBSFUSQMYZL3f9tbBq/NEEDiqUqOfusn1ZXKbjcQlGQZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACC6RkbsGHuabChEtHmiGYfS3EQYM+Sxw/gMQvmcNnYaj6oTDHi73WPn2eqnj9B8zo3TJXcq5uNjXmVff86VwSR3JtryMa1BYqi7S1hJDCVpSigyLcGhJJEwzlCSNtPKrbVhVwsdCfOhH7uuaG3ARV9DwsaOzxt3N1yPqCHhvXytTUz92VDpmE/LLhZwl++R6Sds6sUa/PL6K/2E2fIhw1xdRKefsFolrPc+xNx/N0k/4fpBsdhL2HfeiN+TsDCms8dDpeRyS3P3QDl6Iqaf8L0rTf+80m6Lmn7Ct+4Wxf+/2RY1/YRv3PHz/u+fsCmqgoTnq7Z+8SGviqoh4dnKu1ieqauiakh4/PQ0r6ivqDoSHj0B97eNRVG1JNxV+L4bnxdVecJtRTdFVZ7QU9F1UXUn9FZ0VVRlCav5ob2KLouqKmFjy676u2HsVnRRVFUJq3J+8KCi86IqSthMvyl209Hjijqm3RsqAZ5pNfa5PJ2KelJRjQmr1/r7cfy0ouoSNvOfvbvhvKLaEr4qOin9kTQnrN7LpDZhE/Zmhp6Eq4p+YcKgiipKGFhRRQkDK6ooYfgLbiSMioQkJGF8P5XwHv4O+7AaKiXzaeXh1kMl5AffTUxiKEm/krD94BR8Gdxl1fceSlR58ZhXKbEpyD2amNiBtmrJLTMHL1LF8/rpXkSZXEmz8K8uvAFFNm6Iq0aBLUFOmeCuJ6exrcCmoLpN7kYx891bSAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgh/wDdr8peyRHLogAAAAASUVORK5CYII="; + +export default placeholderImage; diff --git a/src/js/commands/image.js b/src/js/commands/image.js index deae4cb39..bc32b6ce8 100644 --- a/src/js/commands/image.js +++ b/src/js/commands/image.js @@ -1,74 +1,24 @@ import Command from './base'; -import Message from '../views/message'; -import { FileUploader } from '../utils/http-utils'; import { generateBuilder } from '../utils/post-builder'; -function readFromFile(file, callback) { - var reader = new FileReader(); - reader.onload = ({target}) => callback(target.result); - reader.readAsDataURL(file); -} - export default class ImageCommand extends Command { - constructor(options={}) { + constructor() { super({ name: 'image', button: '' }); - this.uploader = new FileUploader({ - url: options.serviceUrl, - maxFileSize: 5000000 - }); + this.builder = generateBuilder(); } exec() { - super.exec(); - var fileInput = this.getFileInput(); - fileInput.dispatchEvent(new MouseEvent('click', { bubbles: false })); - } - - 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.editor.insertSectionAtCursor(imageSection); - this.editor.rerender(); - }); - - this.uploader.upload({ - 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.editor.rerender(); - this.editor.trigger('update'); - } else { - this.editor.removeSection(imageSection); - new Message().showError(error.message || 'Error uploading image'); - } - this.editor.rerender(); - } - }); + let {post} = this.editor; + let sections = this.editor.activeSections; + let lastSection = sections[sections.length - 1]; + let section = this.builder.generateCardSection('image'); + post.insertSectionAfter(section, lastSection); + sections.forEach(section => section.renderNode.scheduleForRemoval()); + + this.editor.rerender(); + this.editor.trigger('update'); } } diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index 802e6bc91..bf81a4d90 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -15,6 +15,8 @@ import ImageCommand from '../commands/image'; import OEmbedCommand from '../commands/oembed'; import CardCommand from '../commands/card'; +import ImageCard from '../cards/image'; + import Keycodes from '../utils/keycodes'; import { getSelectionBlockElement @@ -60,7 +62,7 @@ const defaults = { new LinkCommand() ], embedCommands: [ - new ImageCommand({ serviceUrl: '/upload' }), + new ImageCommand(), new OEmbedCommand({ serviceUrl: '/embed' }), new CardCommand() ], @@ -211,6 +213,8 @@ class Editor { // FIXME: This should merge onto this.options mergeWithOptions(this, defaults, options); + this.cards.push(ImageCard); + this._parser = PostParser; this._renderer = new Renderer(this, this.cards, this.unknownCardHandler, this.cardOptions); diff --git a/src/js/index.js b/src/js/index.js index 4211501ce..bfb49581e 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -1,7 +1,9 @@ import Editor from './editor/editor'; +import ImageCard from './cards/image'; const ContentKit = { - Editor + Editor, + ImageCard }; export function registerGlobal(global) { diff --git a/src/js/renderers/editor-dom.js b/src/js/renderers/editor-dom.js index 6011a71ba..5452ff7d6 100644 --- a/src/js/renderers/editor-dom.js +++ b/src/js/renderers/editor-dom.js @@ -178,7 +178,16 @@ class Visitor { const element = document.createElement('div'); element.contentEditable = 'false'; renderNode.element = element; - renderNode.parentNode.element.appendChild(renderNode.element); + if (renderNode.previousSibling) { + let previousElement = renderNode.previousSibling.element; + let nextElement = previousElement.nextSibling; + if (nextElement) { + nextElement.parentNode.insertBefore(element, nextElement); + } + } + if (!element.parentNode) { + renderNode.parentNode.element.appendChild(element); + } if (card) { let cardNode = new CardNode(editor, card, section, renderNode.element, options); diff --git a/src/js/views/embed-intent.js b/src/js/views/embed-intent.js index 2344b0576..f1f00cd61 100644 --- a/src/js/views/embed-intent.js +++ b/src/js/views/embed-intent.js @@ -32,7 +32,7 @@ function EmbedIntent(options) { embedIntent.button.title = 'Insert image or embed...'; embedIntent.element.appendChild(embedIntent.button); - this.addEventListener(embedIntent.button, 'mouseup', (e) => { + this.addEventListener(embedIntent.button, 'click', (e) => { if (embedIntent.isActive) { embedIntent.deactivate(); } else { @@ -59,7 +59,7 @@ function EmbedIntent(options) { } this.addEventListener(rootElement, 'keyup', embedIntentHandler); - this.addEventListener(document, 'mouseup', () => { + this.addEventListener(document, 'click', () => { setTimeout(() => { embedIntentHandler(); }); diff --git a/src/js/views/prompt.js b/src/js/views/prompt.js index eb97a935e..f98b55c6b 100644 --- a/src/js/views/prompt.js +++ b/src/js/views/prompt.js @@ -23,7 +23,7 @@ class Prompt extends View { prompt.command = options.command; prompt.element.placeholder = options.placeholder || ''; - this.addEventListener(prompt.element, 'mouseup', (e) => { + this.addEventListener(prompt.element, 'click', (e) => { // prevents closing prompt when clicking input e.stopPropagation(); }); diff --git a/src/js/views/reversible-toolbar-button.js b/src/js/views/reversible-toolbar-button.js index e4c4f7898..e757a87e0 100644 --- a/src/js/views/reversible-toolbar-button.js +++ b/src/js/views/reversible-toolbar-button.js @@ -11,7 +11,7 @@ class ReversibleToolbarButton { this.element = this.createElement(); this.active = false; - this.addEventListener(this.element, 'mouseup', e => this.handleClick(e)); + this.addEventListener(this.element, 'click', e => this.handleClick(e)); this.editor.on('selection', () => this.updateActiveState()); this.editor.on('selectionUpdated', () => this.updateActiveState()); this.editor.on('selectionEnded', () => this.updateActiveState()); diff --git a/src/js/views/toolbar-button.js b/src/js/views/toolbar-button.js index 19e7010d5..0e8db3afb 100644 --- a/src/js/views/toolbar-button.js +++ b/src/js/views/toolbar-button.js @@ -16,12 +16,15 @@ function ToolbarButton(options) { element.title = command.name; element.className = buttonClassName; element.innerHTML = command.button; - this.addEventListener(element, 'mouseup', (e) => { + this.addEventListener(element, 'click', (e) => { if (!button.isActive && prompt) { toolbar.displayPrompt(prompt); } else { command.exec(); toolbar.updateForSelection(); + if (toolbar.embedIntent) { + toolbar.embedIntent.hide(); + } } e.stopPropagation(); }); diff --git a/src/js/views/toolbar.js b/src/js/views/toolbar.js index 8e8e3ddff..013847909 100644 --- a/src/js/views/toolbar.js +++ b/src/js/views/toolbar.js @@ -51,7 +51,7 @@ class Toolbar extends View { (options.commands || []).forEach(c => this.addCommand(c)); // Closes prompt if displayed when changing selection - this.addEventListener(document, 'mouseup', () => { + this.addEventListener(document, 'click', () => { this.dismissPrompt(); }); } diff --git a/tests/acceptance/editor-commands-test.js b/tests/acceptance/editor-commands-test.js index 96c6df2a4..1c8d3f60e 100644 --- a/tests/acceptance/editor-commands-test.js +++ b/tests/acceptance/editor-commands-test.js @@ -49,7 +49,7 @@ function assertToolbarHidden(assert) { function clickToolbarButton(assert, name) { const button = getToolbarButton(assert, name); - Helpers.dom.triggerEvent(button[0], 'mouseup'); + Helpers.dom.triggerEvent(button[0], 'click'); } function assertActiveToolbarButton(assert, buttonTitle) { diff --git a/tests/acceptance/embed-intent-test.js b/tests/acceptance/embed-intent-test.js index 42fabc446..55147d490 100644 --- a/tests/acceptance/embed-intent-test.js +++ b/tests/acceptance/embed-intent-test.js @@ -16,6 +16,23 @@ const mobileDocWith1Section = { ] ] }; +const mobileDocWith3Sections = { + version: MOBILEDOC_VERSION, + sections: [ + [], + [ + [1, "P", [ + [[], 0, "first section"] + ]], + [1, "P", [ + [[], 0, ""] + ]], + [1, "P", [ + [[], 0, "third section"] + ]] + ] + ] +}; module('Acceptance: Embed intent', { beforeEach() { @@ -35,6 +52,7 @@ module('Acceptance: Embed intent', { Helpers.skipInPhantom('typing inserts section', (assert) => { editor = new Editor(editorElement, {mobiledoc: mobileDocWith1Section}); assert.equal($('#editor p').length, 1, 'has 1 paragraph to start'); + assert.hasNoElement('.ck-embed-intent', 'embed intent is hidden'); Helpers.dom.moveCursorTo(editorElement.childNodes[0].childNodes[0], 12); Helpers.dom.triggerKeyEvent(editorElement, 'keydown', Helpers.dom.KEY_CODES.ENTER); @@ -43,3 +61,25 @@ Helpers.skipInPhantom('typing inserts section', (assert) => { assert.ok($('.ck-embed-intent').is(':visible'), 'embed intent appears'); }); +Helpers.skipInPhantom('add card between sections', (assert) => { + editor = new Editor(editorElement, {mobiledoc: mobileDocWith3Sections}); + assert.equal(editorElement.childNodes.length, 3, 'has 3 paragraphs to start'); + + Helpers.dom.moveCursorTo(editorElement.childNodes[1].firstChild, 0); + Helpers.dom.triggerEvent(editorElement.childNodes[1].firstChild, 'click'); + + let done = assert.async(); + setTimeout(() => { // delay due to internal async + assert.ok($('.ck-embed-intent').is(':visible'), 'embed intent appears'); + + Helpers.dom.triggerEvent($('.ck-embed-intent-btn')[0], 'click'); + Helpers.dom.triggerEvent($('button[title=image]')[0], 'click'); + + assert.hasNoElement('.ck-embed-intent', 'embed intent is hidden'); + assert.equal(editorElement.childNodes.length, 3, 'has 3 sections after card insertion'); + assert.equal(editor.element.childNodes[0].tagName, 'P'); + assert.equal(editor.element.childNodes[1].tagName, 'DIV'); + assert.equal(editor.element.childNodes[2].tagName, 'P'); + done(); + }, 3); +});