From 4a48469506f03f876b72a8f13c5fccbb87e40af6 Mon Sep 17 00:00:00 2001 From: gdub22 Date: Sun, 10 Aug 2014 19:36:35 -0400 Subject: [PATCH] image uploading --- demo/demo.css | 6 +- demo/index.html | 33 +- dist/content-kit-editor.css | 22 +- dist/content-kit-editor.js | 2320 ++++++++++++++------------ ext/xhr-file-uploader.js | 78 + gulpfile.js | 18 +- src/css/embeds.less | 8 + src/css/toolbar.less | 2 +- src/css/variables.less | 2 +- src/js/commands.js | 91 +- src/js/editor.js | 26 +- src/js/ext/content-kit-compiler.js | 769 +++++---- src/js/utils/element-utils.js | 5 + src/js/utils/http-utils.js | 2 +- src/js/{ => views}/embed-intent.js | 14 + src/js/{ => views}/message.js | 0 src/js/{ => views}/prompt.js | 0 src/js/{ => views}/toolbar-button.js | 0 src/js/{ => views}/toolbar.js | 0 src/js/{ => views}/tooltip.js | 0 src/js/{ => views}/view.js | 0 21 files changed, 1879 insertions(+), 1517 deletions(-) create mode 100644 ext/xhr-file-uploader.js rename src/js/{ => views}/embed-intent.js (87%) rename src/js/{ => views}/message.js (100%) rename src/js/{ => views}/prompt.js (100%) rename src/js/{ => views}/toolbar-button.js (100%) rename src/js/{ => views}/toolbar.js (100%) rename src/js/{ => views}/tooltip.js (100%) rename src/js/{ => views}/view.js (100%) diff --git a/demo/demo.css b/demo/demo.css index 80af9b1ea..89ec43202 100644 --- a/demo/demo.css +++ b/demo/demo.css @@ -50,9 +50,9 @@ header { } .mode-buttons button { background-color: transparent; - border: 1px solid #007aff; + border: 1px solid #52a3ff; outline: none; - color: #007aff; + color: #52a3ff; border-radius: 5px; padding: 0.6em 1em; margin: 0; @@ -66,7 +66,7 @@ header { background-color: rgba(0,122,255,0.15); } .mode-buttons button:active { - background-color: #007aff; + background-color: #52a3ff; color: #FFF; transition: none; } diff --git a/demo/index.html b/demo/index.html index 1265a1952..1a8e1a883 100644 --- a/demo/index.html +++ b/demo/index.html @@ -4,7 +4,7 @@ ContentKit Editor - + @@ -66,20 +66,20 @@

Keyboard shortcuts:

+ + - - - + + + diff --git a/dist/content-kit-editor.css b/dist/content-kit-editor.css index ab4ab1219..cdf0673aa 100755 --- a/dist/content-kit-editor.css +++ b/dist/content-kit-editor.css @@ -21,13 +21,13 @@ color: #bbb; } .ck-editor a { - color: #2ac845; + color: #0b8bff; } .ck-editor p { min-height: 1.6em; } .ck-editor blockquote { - border-left: 4px solid #2ac845; + border-left: 4px solid #0b8bff; margin: 0 0 0 -1.2em; padding-left: 1.05em; color: #a0a0a0; @@ -120,7 +120,7 @@ } .ck-toolbar-btn:active, .ck-toolbar-btn.active { - color: #4cd964; + color: #3ea3ff; } .ck-toolbar-prompt { display: none; @@ -153,8 +153,8 @@ .ck-editor-hilite { position: absolute; z-index: -1; - background-color: rgba(76, 217, 100, 0.08); - border-bottom: 2px dotted #4cd964; + background-color: rgba(62, 163, 255, 0.05); + border-bottom: 2px dotted #3ea3ff; } /** * Tooltip @@ -245,6 +245,9 @@ -webkit-transform: rotate(-135deg); transform: rotate(-135deg); } +.ck-embed-loading { + position: absolute; +} .ck-file-input { display: none; } @@ -255,7 +258,10 @@ border-radius: 2px; } .ck-embed.selected { - border-color: #4cd964; + border-color: #3ea3ff; +} +.ck-embed iframe { + margin: 0 auto !important; } .ck-embed figure { position: relative; @@ -305,8 +311,8 @@ left: 50%; z-index: 10; padding: 0.5em 1em; - background-color: #a0ebad; - border: 1px solid #219e37; + background-color: #a4d4ff; + border: 1px solid #0071d7; color: #333; text-shadow: 0px 1px 0px rgba(255, 255, 255, 0.5); border-radius: 2px; diff --git a/dist/content-kit-editor.js b/dist/content-kit-editor.js index 3f4e10819..fe05cc666 100755 --- a/dist/content-kit-editor.js +++ b/dist/content-kit-editor.js @@ -3,7 +3,7 @@ * @version 0.1.0 * @author Garth Poitras (http://garthpoitras.com/) * @license MIT - * Last modified: Aug 7, 2014 + * Last modified: Aug 10, 2014 */ (function(exports, document) { @@ -58,28 +58,6 @@ var Tags = { var RootTags = [ Tags.PARAGRAPH, Tags.HEADING, Tags.SUBHEADING, Tags.QUOTE, Tags.FIGURE, Tags.LIST, Tags.ORDERED_LIST ]; -function merge(object, updates) { - updates = updates || {}; - for(var o in updates) { - if (updates.hasOwnProperty(o)) { - object[o] = updates[o]; - } - } - return object; -} - -function inherits(Subclass, Superclass) { - Subclass._super = Superclass; - Subclass.prototype = Object.create(Superclass.prototype, { - constructor: { - value: Subclass, - enumerable: false, - writable: true, - configurable: true - } - }); -} - /** * Converts an array-like object (i.e. NodeList) to Array */ @@ -178,6 +156,11 @@ function positionElementCenteredBelow(element, belowElement) { positionElementHorizontallyCenteredToRect(element, belowElement.getBoundingClientRect(), -element.offsetHeight - elementMargin); } +function positionElementCenteredIn(element, inElement) { + var verticalCenter = (inElement.offsetHeight / 2) - (element.offsetHeight / 2); + positionElementHorizontallyCenteredToRect(element, inElement.getBoundingClientRect(), -verticalCenter); +} + function positionElementToLeftOf(element, leftOfElement) { var verticalCenter = (leftOfElement.offsetHeight / 2) - (element.offsetHeight / 2); var elementMargin = getElementComputedStyleNumericProp(element, 'marginRight'); @@ -191,6 +174,62 @@ function positionElementToRightOf(element, rightOfElement) { positionElementToRect(element, rightOfElementRect, -verticalCenter, -rightOfElement.offsetWidth - elementMargin); } +var HTTP = (function() { + + var head = document.head; + var uuid = 0; + + return { + get: function(url, callback) { + var request = new XMLHttpRequest(); + request.onload = function() { + callback(this.responseText); + }; + request.onerror = function(error) { + callback(null, error); + }; + request.open('GET', url); + request.send(); + }, + + jsonp: function(url, callback) { + var script = document.createElement('script'); + var name = '_jsonp_' + uuid++; + url += ( url.match(/\?/) ? '&' : '?' ) + 'callback=' + name; + script.src = url; + exports[name] = function(response) { + callback(JSON.parse(response)); + head.removeChild(script); + delete exports[name]; + }; + head.appendChild(script); + } + }; + +}()); + +function merge(object, updates) { + updates = updates || {}; + for(var o in updates) { + if (updates.hasOwnProperty(o)) { + object[o] = updates[o]; + } + } + return object; +} + +function inherits(Subclass, Superclass) { + Subclass._super = Superclass; + Subclass.prototype = Object.create(Superclass.prototype, { + constructor: { + value: Subclass, + enumerable: false, + writable: true, + configurable: true + } + }); +} + function getDirectionOfSelection(selection) { var node = selection.anchorNode; var position = node && node.compareDocumentPosition(selection.focusNode); @@ -302,40 +341,6 @@ function selectNode(node) { selection.addRange(range); } -var HTTP = (function() { - - var head = document.head; - var uuid = 0; - - return { - get: function(url, callback) { - var request = new XMLHttpRequest(); - request.onload = function() { - callback(this.responseText); - }; - request.onerror = function(error) { - callback(this.responseText, error); - }; - request.open('GET', url); - request.send(); - }, - - jsonp: function(url, callback) { - var script = document.createElement('script'); - var name = '_jsonp_' + uuid++; - url += ( url.match(/\?/) ? '&' : '?' ) + 'callback=' + name; - script.src = url; - exports[name] = function(response) { - callback(JSON.parse(response)); - head.removeChild(script); - delete exports[name]; - }; - head.appendChild(script); - } - }; - -}()); - function View(options) { this.tagName = options.tagName || 'div'; this.classNames = options.classNames || []; @@ -375,6 +380,129 @@ View.prototype = { } }; +var EmbedIntent = (function() { + + function EmbedIntent(options) { + var embedIntent = this; + var rootElement = options.rootElement; + options.tagName = 'button'; + options.classNames = ['ck-embed-intent-btn']; + View.call(embedIntent, options); + + embedIntent.editorContext = options.editorContext; + embedIntent.element.title = 'Insert image or embed...'; + embedIntent.element.addEventListener('mouseup', function(e) { + if (embedIntent.isActive) { + embedIntent.deactivate(); + } else { + embedIntent.activate(); + } + e.stopPropagation(); + }); + + embedIntent.toolbar = new Toolbar({ embedIntent: embedIntent, editor: embedIntent.editorContext, commands: options.commands, direction: ToolbarDirection.RIGHT }); + embedIntent.isActive = false; + + function embedIntentHandler() { + var blockElement = getSelectionBlockElement(); + var blockElementContent = blockElement && blockElement.innerHTML; + if (blockElementContent === '' || blockElementContent === '
') { + embedIntent.showAt(blockElement); + } else { + embedIntent.hide(); + } + } + + rootElement.addEventListener('keyup', embedIntentHandler); + + document.addEventListener('mouseup', function(e) { + setTimeout(function() { + if (!nodeIsDescendantOfElement(e.target, embedIntent.toolbar.element)) { + embedIntentHandler(); + } + }); + }); + + document.addEventListener('keyup', function(e) { + if (e.keyCode === Keycodes.ESC) { + embedIntent.hide(); + } + }); + + window.addEventListener('resize', function() { + if(embedIntent.isShowing) { + positionElementToLeftOf(embedIntent.element, embedIntent.atNode); + if (embedIntent.toolbar.isShowing) { + embedIntent.toolbar.positionToContent(embedIntent.element); + } + } + }); + } + inherits(EmbedIntent, View); + + EmbedIntent.prototype.hide = function() { + if (EmbedIntent._super.prototype.hide.call(this)) { + this.deactivate(); + } + }; + + EmbedIntent.prototype.showAt = function(node) { + this.show(); + this.deactivate(); + this.atNode = node; + positionElementToLeftOf(this.element, node); + }; + + EmbedIntent.prototype.activate = function() { + if (!this.isActive) { + this.addClass('activated'); + this.toolbar.show(); + this.toolbar.positionToContent(this.element); + this.isActive = true; + } + }; + + EmbedIntent.prototype.deactivate = function() { + if (this.isActive) { + this.removeClass('activated'); + this.toolbar.hide(); + this.isActive = false; + } + }; + + var loading = createDiv('div'); + loading.className = 'ck-embed-loading'; + loading.innerHTML = 'LOADING'; + EmbedIntent.prototype.showLoading = function() { + this.hide(); + document.body.appendChild(loading); + positionElementCenteredIn(loading, this.atNode); + }; + + EmbedIntent.prototype.hideLoading = function() { + document.body.removeChild(loading); + }; + + + return EmbedIntent; +}()); + +function Message(options) { + options = options || {}; + options.classNames = ['ck-message']; + View.call(this, options); +} +inherits(Message, View); + +Message.prototype.show = function(message) { + var messageView = this; + messageView.element.innerHTML = message; + Message._super.prototype.show.call(messageView); + setTimeout(function() { + messageView.hide(); + }, 3000); +}; + var Prompt = (function() { var container = document.body; @@ -439,36 +567,284 @@ var Prompt = (function() { return Prompt; }()); -function createCommandIndex(commands) { - var index = {}; - var len = commands.length, i, command; - for(i = 0; i < len; i++) { - command = commands[i]; - index[command.name] = command; - } - return index; -} - -function Command(options) { - var command = this; - var name = options.name; - var prompt = options.prompt; - command.name = name; - command.button = options.button || name; - command.editorContext = null; - if (prompt) { command.prompt = prompt; } -} -Command.prototype.exec = function(){}; +var ToolbarButton = (function() { -function TextFormatCommand(options) { - Command.call(this, options); - this.tag = options.tag.toUpperCase(); - this.action = options.action || this.name; - this.removeAction = options.removeAction || this.action; -} -inherits(TextFormatCommand, Command); + var buttonClassName = 'ck-toolbar-btn'; -TextFormatCommand.prototype = { + function ToolbarButton(options) { + var button = this; + var toolbar = options.toolbar; + var command = options.command; + var prompt = command.prompt; + var element = document.createElement('button'); + + if(typeof command === 'string') { + command = Command.index[command]; + } + + button.element = element; + button.command = command; + button.isActive = false; + + element.title = command.name; + element.className = buttonClassName; + element.innerHTML = command.button; + element.addEventListener('click', function(e) { + if (!button.isActive && prompt) { + toolbar.displayPrompt(prompt); + } else { + command.exec(); + } + }); + } + + ToolbarButton.prototype = { + setActive: function() { + var button = this; + if (!button.isActive) { + button.element.className = buttonClassName + ' active'; + button.isActive = true; + } + }, + setInactive: function() { + var button = this; + if (button.isActive) { + button.element.className = buttonClassName; + button.isActive = false; + } + } + }; + + return ToolbarButton; +}()); + +var Toolbar = (function() { + + function Toolbar(options) { + var toolbar = this; + var commands = options.commands; + var commandCount = commands && commands.length; + var i, button, command; + toolbar.editor = options.editor || null; + toolbar.embedIntent = options.embedIntent || null; + toolbar.direction = options.direction || ToolbarDirection.TOP; + options.classNames = ['ck-toolbar']; + if (toolbar.direction === ToolbarDirection.RIGHT) { + options.classNames.push('right'); + } + + View.call(toolbar, options); + + toolbar.activePrompt = null; + toolbar.buttons = []; + + toolbar.promptContainerElement = createDiv('ck-toolbar-prompt'); + toolbar.buttonContainerElement = createDiv('ck-toolbar-buttons'); + toolbar.element.appendChild(toolbar.promptContainerElement); + toolbar.element.appendChild(toolbar.buttonContainerElement); + + for(i = 0; i < commandCount; i++) { + this.addCommand(commands[i]); + } + + // Closes prompt if displayed when changing selection + document.addEventListener('mouseup', function() { + toolbar.dismissPrompt(); + }); + } + inherits(Toolbar, View); + + Toolbar.prototype.hide = function() { + if (Toolbar._super.prototype.hide.call(this)) { + var style = this.element.style; + style.left = ''; + style.top = ''; + this.dismissPrompt(); + } + }; + + Toolbar.prototype.addCommand = function(command) { + command.editorContext = this.editor; + command.embedIntent = this.embedIntent; + var button = new ToolbarButton({ command: command, toolbar: this }); + this.buttons.push(button); + this.buttonContainerElement.appendChild(button.element); + }; + + Toolbar.prototype.displayPrompt = function(prompt) { + var toolbar = this; + swapElements(toolbar.promptContainerElement, toolbar.buttonContainerElement); + toolbar.promptContainerElement.appendChild(prompt.element); + prompt.show(function() { + toolbar.dismissPrompt(); + toolbar.updateForSelection(window.getSelection()); + }); + toolbar.activePrompt = prompt; + }; + + Toolbar.prototype.dismissPrompt = function() { + var toolbar = this; + var activePrompt = toolbar.activePrompt; + if (activePrompt) { + activePrompt.hide(); + swapElements(toolbar.buttonContainerElement, toolbar.promptContainerElement); + toolbar.activePrompt = null; + } + }; + + Toolbar.prototype.updateForSelection = function(selection) { + var toolbar = this; + if (selection.isCollapsed) { + toolbar.hide(); + } else { + toolbar.show(); + toolbar.positionToContent(selection.getRangeAt(0)); + updateButtonsForSelection(toolbar.buttons, selection); + } + }; + + Toolbar.prototype.positionToContent = function(content) { + var directions = ToolbarDirection; + var positioningMethod; + switch(this.direction) { + case directions.RIGHT: + positioningMethod = positionElementToRightOf; + break; + default: + positioningMethod = positionElementCenteredAbove; + } + positioningMethod(this.element, content); + }; + + function updateButtonsForSelection(buttons, selection) { + var selectedTags = tagsInSelection(selection), + len = buttons.length, + i, button; + + for (i = 0; i < len; i++) { + button = buttons[i]; + if (selectedTags.indexOf(button.command.tag) > -1) { + button.setActive(); + } else { + button.setInactive(); + } + } + } + + return Toolbar; +}()); + + +var TextFormatToolbar = (function() { + + function TextFormatToolbar(options) { + var toolbar = this; + Toolbar.call(this, options); + toolbar.rootElement = options.rootElement; + toolbar.rootElement.addEventListener('keyup', function() { toolbar.handleTextSelection(); }); + + document.addEventListener('keyup', function(e) { + if (e.keyCode === Keycodes.ESC) { + toolbar.hide(); + } + }); + + document.addEventListener('mouseup', function() { + setTimeout(function() { toolbar.handleTextSelection(); }); + }); + + window.addEventListener('resize', function() { + if(toolbar.isShowing) { + var activePromptRange = toolbar.activePrompt && toolbar.activePrompt.range; + toolbar.positionToContent(activePromptRange ? activePromptRange : window.getSelection().getRangeAt(0)); + } + }); + } + inherits(TextFormatToolbar, Toolbar); + + TextFormatToolbar.prototype.handleTextSelection = function() { + var toolbar = this; + var selection = window.getSelection(); + if (selection.isCollapsed || !selectionIsEditable(selection) || selection.toString().trim() === '' || !selectionIsInElement(selection, toolbar.rootElement)) { + toolbar.hide(); + } else { + toolbar.updateForSelection(selection); + } + }; + + return TextFormatToolbar; +}()); + +function Tooltip(options) { + var tooltip = this; + var rootElement = options.rootElement; + var delay = options.delay || 200; + var timeout; + options.classNames = ['ck-tooltip']; + View.call(tooltip, options); + + rootElement.addEventListener('mouseover', function(e) { + var target = getEventTargetMatchingTag(options.showForTag, e.target, rootElement); + if (target) { + timeout = setTimeout(function() { + tooltip.showLink(target.href, target); + }, delay); + } + }); + + rootElement.addEventListener('mouseout', function(e) { + clearTimeout(timeout); + var toElement = e.toElement || e.relatedTarget; + if (toElement && toElement.className !== tooltip.element.className) { + tooltip.hide(); + } + }); +} +inherits(Tooltip, View); + +Tooltip.prototype.showMessage = function(message, element) { + var tooltip = this; + var tooltipElement = tooltip.element; + tooltipElement.innerHTML = message; + tooltip.show(); + positionElementCenteredBelow(tooltipElement, element); +}; + +Tooltip.prototype.showLink = function(link, element) { + var message = '' + link + ''; + this.showMessage(message, element); +}; + +function createCommandIndex(commands) { + var index = {}; + var len = commands.length, i, command; + for(i = 0; i < len; i++) { + command = commands[i]; + index[command.name] = command; + } + return index; +} + +function Command(options) { + var command = this; + var name = options.name; + var prompt = options.prompt; + command.name = name; + command.button = options.button || name; + command.editorContext = null; + if (prompt) { command.prompt = prompt; } +} +Command.prototype.exec = function(){}; + +function TextFormatCommand(options) { + Command.call(this, options); + this.tag = options.tag.toUpperCase(); + this.action = options.action || this.name; + this.removeAction = options.removeAction || this.action; +} +inherits(TextFormatCommand, Command); + +TextFormatCommand.prototype = { exec: function(value) { document.execCommand(this.action, false, value || null); }, @@ -636,6 +1012,9 @@ function ImageEmbedCommand(options) { name: 'image', button: '' }); + if (window.XHRFileUploader) { + this.uploader = new XHRFileUploader({ url: '/upload', maxFileSize: 5000000 }); + } } inherits(ImageEmbedCommand, EmbedCommand); @@ -643,39 +1022,40 @@ ImageEmbedCommand.prototype = { exec: function() { ImageEmbedCommand._super.prototype.exec.call(this); var clickEvent = new MouseEvent('click', { bubbles: false }); - if (!this.fileBrowser) { + if (!this.fileInput) { var command = this; - var fileBrowser = this.fileBrowser = document.createElement('input'); - fileBrowser.type = 'file'; - fileBrowser.accept = 'image/*'; - fileBrowser.className = 'ck-file-input'; - fileBrowser.addEventListener('change', function(e) { + var fileInput = this.fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/*'; + fileInput.className = 'ck-file-input'; + fileInput.addEventListener('change', function(e) { command.handleFile(e); }); - document.body.appendChild(fileBrowser); + document.body.appendChild(fileInput); } - this.fileBrowser.dispatchEvent(clickEvent); + this.fileInput.dispatchEvent(clickEvent); }, handleFile: function(e) { - var command = this; - var target = e.target; - var file = target && target.files[0]; - var reader = new FileReader(); - reader.onload = function(event) { - var base64File = event.target.result; - var blockElement = getSelectionBlockElement(); - var editorNode = blockElement.parentNode; - var image = document.createElement('img'); - image.src = base64File; - - // image needs to be placed outside of the current empty paragraph - editorNode.insertBefore(image, blockElement); - editorNode.removeChild(blockElement); - - command.embedIntent.hide(); - }; - reader.readAsDataURL(file); - target.value = null; // reset + var fileInput = e.target; + var editor = this.editorContext; + var embedIntent = this.embedIntent; + + embedIntent.showLoading(); + this.uploader.upload({ + fileInput: fileInput, + complete: function(response, error) { + embedIntent.hideLoading(); + if (error || !response || !response.url) { + return new Message().show(error.message || 'Error uploading image'); + } + var imageModel = new ContentKit.ImageModel({ src: response.url }); + var index = editor.getCurrentBlockIndex(); + editor.insertBlockAt(imageModel, index); + editor.syncVisualAt(index); + } + }); + fileInput.value = null; // reset file input + // TODO: client-side render while uploading } }; @@ -685,7 +1065,7 @@ function OEmbedCommand(options) { button: '', prompt: new Prompt({ command: this, - placeholder: 'Paste a YouTube, Twitter, or any url...' + placeholder: 'Paste a YouTube or Twitter url...' }) }); } @@ -695,22 +1075,29 @@ OEmbedCommand.prototype.exec = function(url) { var command = this; var editorContext = command.editorContext; var index = editorContext.getCurrentBlockIndex(); - command.embedIntent.hide(); - HTTP.get('http://noembed.com/embed?url=' + url, function(responseText, error) { + var oEmbedEndpoint = 'http://noembed.com/embed?url='; + + command.embedIntent.showLoading(); + if (!Regex.HTTP_PROTOCOL.test(url)) { + url = 'http://' + url; + } + + HTTP.get(oEmbedEndpoint + url, function(responseText, error) { + command.embedIntent.hideLoading(); if (error) { - new Message().show('Embed error: ' + error); - return; - } - var json = JSON.parse(responseText); - if (json.error) { - new Message().show('Embed error: ' + json.error); + new Message().show('Embed error: status code ' + error.currentTarget.status); } else { - var embedModel = new ContentKit.EmbedModel(json); - if (!embedModel.attributes.provider_id) { - new Message().show('Embed error: "' + embedModel.attributes.provider_name + '" embeds are not supported at this time'); + var json = JSON.parse(responseText); + if (json.error) { + new Message().show('Embed error: ' + json.error); } else { - editorContext.insertBlockAt(embedModel, index); - editorContext.syncVisualAt(index); + var embedModel = new ContentKit.EmbedModel(json); + //if (!embedModel.attributes.provider_id) { + // new Message().show('Embed error: "' + embedModel.attributes.provider_name + '" embeds are not supported at this time'); + //} else { + editorContext.insertBlockAt(embedModel, index); + editorContext.syncVisualAt(index); + //} } } }); @@ -811,6 +1198,11 @@ ContentKit.Editor = (function() { commands: editor.embedCommands, rootElement: element }); + + if (editor.imageServiceUrl) { + // TODO: lookup by name + editor.embedCommands[0].uploader.url = editor.imageServiceUrl; + } } if(editor.autofocus) { element.focus(); } @@ -828,10 +1220,12 @@ ContentKit.Editor = (function() { }; Editor.prototype.syncVisualAt = function(index) { - var block = this.model[index]; - var html = this.compiler.render([block]); + var blockModel = this.model[index]; + var html = this.compiler.render([blockModel]); var blockElements = toArray(this.element.children); - blockElements[index].innerHTML = html; + var element = blockElements[index]; + element.innerHTML = html; + runAfterRenderHooks(element, blockModel); }; Editor.prototype.getCurrentBlockIndex = function() { @@ -849,777 +1243,737 @@ ContentKit.Editor = (function() { this.model.splice(index, 0, model); }; - Editor.prototype.addTextFormat = function(opts) { - var command = new TextFormatCommand(opts); - this.compiler.registerMarkupType(new ContentKit.Type({ - name : opts.name, - tag : opts.tag || opts.name - })); - this.textFormatCommands.push(command); - this.textFormatToolbar.addCommand(command); - }; - - Editor.prototype.willRenderType = function(type, renderer) { - this.compiler.renderer.willRenderType(type, renderer); - }; - - function bindTypingEvents(editor) { - var editorEl = editor.element; - - // Breaks out of blockquotes when pressing enter. - editorEl.addEventListener('keyup', function(e) { - if(!e.shiftKey && e.which === Keycodes.ENTER) { - if(Tags.QUOTE === getSelectionBlockTagName()) { - document.execCommand('formatBlock', false, editor.defaultFormatter); - e.stopPropagation(); - } - } - }); - - // Creates unordered list when block starts with '- ', or ordered if starts with '1. ' - editorEl.addEventListener('keyup', function(e) { - var selectedText = window.getSelection().anchorNode.textContent, - selection, selectionNode, command, replaceRegex; - - if (Tags.LIST_ITEM !== getSelectionTagName()) { - if (Regex.UL_START.test(selectedText)) { - command = new UnorderedListCommand(); - replaceRegex = Regex.UL_START; - } else if (Regex.OL_START.test(selectedText)) { - command = new OrderedListCommand(); - replaceRegex = Regex.OL_START; - } - - if (command) { - command.exec(); - selection = window.getSelection(); - selectionNode = selection.anchorNode; - selectionNode.textContent = selectedText.replace(replaceRegex, ''); - moveCursorToBeginningOfSelection(selection); - e.stopPropagation(); - } - } - }); - - // Assure there is always a supported root tag, and not empty text nodes or divs. - editorEl.addEventListener('keyup', function() { - if (this.innerHTML.length && RootTags.indexOf(getSelectionBlockTagName()) === -1) { - document.execCommand('formatBlock', false, editor.defaultFormatter); - } - }); - - // Experimental: Live update - sync model with textual content as you type - editorEl.addEventListener('keyup', function(e) { - if (editor.model && editor.model.length) { - var index = editor.getCurrentBlockIndex(); - if (editor.model[index].type === 1) { - editor.syncModelAt(index); - } - } - }); - } - - function bindPasteEvents(editor) { - editor.element.addEventListener('paste', function(e) { - var data = e.clipboardData, plainText; - e.preventDefault(); - if(data && data.getData) { - plainText = data.getData('text/plain'); - var formattedContent = plainTextToBlocks(plainText, editor.defaultFormatter); - document.execCommand('insertHTML', false, formattedContent); - } - }); - } - - function plainTextToBlocks(plainText, blockTag) { - var blocks = plainText.split(Regex.NEWLINE), - len = blocks.length, - block, openTag, closeTag, content, i; - if(len < 2) { - return plainText; - } else { - content = ''; - openTag = '<' + blockTag + '>'; - closeTag = ''; - for(i=0; i -1) { - button.setActive(); - } else { - button.setInactive(); + // Breaks out of blockquotes when pressing enter. + editorEl.addEventListener('keyup', function(e) { + if(!e.shiftKey && e.which === Keycodes.ENTER) { + if(Tags.QUOTE === getSelectionBlockTagName()) { + document.execCommand('formatBlock', false, editor.defaultFormatter); + e.stopPropagation(); + } } - } - } - - return Toolbar; -}()); - + }); -var TextFormatToolbar = (function() { + // Creates unordered list when block starts with '- ', or ordered if starts with '1. ' + editorEl.addEventListener('keyup', function(e) { + var selectedText = window.getSelection().anchorNode.textContent, + selection, selectionNode, command, replaceRegex; - function TextFormatToolbar(options) { - var toolbar = this; - Toolbar.call(this, options); - toolbar.rootElement = options.rootElement; - toolbar.rootElement.addEventListener('keyup', function() { toolbar.handleTextSelection(); }); + if (Tags.LIST_ITEM !== getSelectionTagName()) { + if (Regex.UL_START.test(selectedText)) { + command = new UnorderedListCommand(); + replaceRegex = Regex.UL_START; + } else if (Regex.OL_START.test(selectedText)) { + command = new OrderedListCommand(); + replaceRegex = Regex.OL_START; + } - document.addEventListener('keyup', function(e) { - if (e.keyCode === Keycodes.ESC) { - toolbar.hide(); + if (command) { + command.exec(); + selection = window.getSelection(); + selectionNode = selection.anchorNode; + selectionNode.textContent = selectedText.replace(replaceRegex, ''); + moveCursorToBeginningOfSelection(selection); + e.stopPropagation(); + } } }); - document.addEventListener('mouseup', function() { - setTimeout(function() { toolbar.handleTextSelection(); }); + // Assure there is always a supported root tag, and not empty text nodes or divs. + editorEl.addEventListener('keyup', function() { + if (this.innerHTML.length && RootTags.indexOf(getSelectionBlockTagName()) === -1) { + document.execCommand('formatBlock', false, editor.defaultFormatter); + } }); - window.addEventListener('resize', function() { - if(toolbar.isShowing) { - var activePromptRange = toolbar.activePrompt && toolbar.activePrompt.range; - toolbar.positionToContent(activePromptRange ? activePromptRange : window.getSelection().getRangeAt(0)); + // Experimental: Live update - sync model with textual content as you type + editorEl.addEventListener('keyup', function(e) { + if (editor.model && editor.model.length) { + var index = editor.getCurrentBlockIndex(); + if (editor.model[index].type === 1) { + editor.syncModelAt(index); + } } }); } - inherits(TextFormatToolbar, Toolbar); - TextFormatToolbar.prototype.handleTextSelection = function() { - var toolbar = this; - var selection = window.getSelection(); - if (selection.isCollapsed || !selectionIsEditable(selection) || selection.toString().trim() === '' || !selectionIsInElement(selection, toolbar.rootElement)) { - toolbar.hide(); - } else { - toolbar.updateForSelection(selection); + var afterRenderHooks = []; + Editor.prototype.afterRender = function(callback) { + if ('function' === typeof callback) { + afterRenderHooks.push(callback); } }; - return TextFormatToolbar; -}()); - -var ToolbarButton = (function() { - - var buttonClassName = 'ck-toolbar-btn'; - - function ToolbarButton(options) { - var button = this; - var toolbar = options.toolbar; - var command = options.command; - var prompt = command.prompt; - var element = document.createElement('button'); - - if(typeof command === 'string') { - command = Command.index[command]; + function runAfterRenderHooks(element, blockModel) { + for (var i = 0, len = afterRenderHooks.length; i < len; i++) { + afterRenderHooks[i].call(null, element, blockModel); } + } - button.element = element; - button.command = command; - button.isActive = false; - - element.title = command.name; - element.className = buttonClassName; - element.innerHTML = command.button; - element.addEventListener('click', function(e) { - if (!button.isActive && prompt) { - toolbar.displayPrompt(prompt); - } else { - command.exec(); + function bindPasteEvents(editor) { + editor.element.addEventListener('paste', function(e) { + var data = e.clipboardData, plainText; + e.preventDefault(); + if(data && data.getData) { + plainText = data.getData('text/plain'); + var formattedContent = plainTextToBlocks(plainText, editor.defaultFormatter); + document.execCommand('insertHTML', false, formattedContent); } }); } - ToolbarButton.prototype = { - setActive: function() { - var button = this; - if (!button.isActive) { - button.element.className = buttonClassName + ' active'; - button.isActive = true; - } - }, - setInactive: function() { - var button = this; - if (button.isActive) { - button.element.className = buttonClassName; - button.isActive = false; + function plainTextToBlocks(plainText, blockTag) { + var blocks = plainText.split(Regex.NEWLINE), + len = blocks.length, + block, openTag, closeTag, content, i; + if(len < 2) { + return plainText; + } else { + content = ''; + openTag = '<' + blockTag + '>'; + closeTag = ''; + for(i=0; i (http://garthpoitras.com/) + * @license MIT + * Last modified: Aug 9, 2014 + */ + +(function(window, document, define, undefined) { + +define("content-kit", + ["./types/type","./models/block","./models/text","./models/image","./models/embed","./compiler","./parsers/html-parser","./renderers/html-renderer","exports"], + function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__, __dependency6__, __dependency7__, __dependency8__, __exports__) { + "use strict"; + var Type = __dependency1__["default"]; + var BlockModel = __dependency2__["default"]; + var TextModel = __dependency3__["default"]; + var ImageModel = __dependency4__["default"]; + var EmbedModel = __dependency5__["default"]; + var Compiler = __dependency6__["default"]; + var HTMLParser = __dependency7__["default"]; + var HTMLRenderer = __dependency8__["default"]; + + /** + * @namespace ContentKit + * Merge public modules into the common ContentKit namespace. + * Handy for working in the browser with globals. + */ + var ContentKit = window.ContentKit || {}; + ContentKit.Type = Type; + ContentKit.BlockModel = BlockModel; + ContentKit.TextModel = TextModel; + ContentKit.ImageModel = ImageModel; + ContentKit.EmbedModel = EmbedModel; + ContentKit.Compiler = Compiler; + ContentKit.HTMLParser = HTMLParser; + ContentKit.HTMLRenderer = HTMLRenderer; + + __exports__["default"] = ContentKit; }); -} -inherits(Tooltip, View); +define("compiler", + ["./parsers/html-parser","./renderers/html-renderer","./types/type","./types/default-types","../utils/object-utils","exports"], + function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__, __exports__) { + "use strict"; + var HTMLParser = __dependency1__["default"]; + var HTMLRenderer = __dependency2__["default"]; + var Type = __dependency3__["default"]; + var DefaultBlockTypeSet = __dependency4__.DefaultBlockTypeSet; + var DefaultMarkupTypeSet = __dependency4__.DefaultMarkupTypeSet; + var merge = __dependency5__.merge; -Tooltip.prototype.showMessage = function(message, element) { - var tooltip = this; - var tooltipElement = tooltip.element; - tooltipElement.innerHTML = message; - tooltip.show(); - positionElementCenteredBelow(tooltipElement, element); -}; + /** + * @class Compiler + * @constructor + * @param options + */ + function Compiler(options) { + var parser = new HTMLParser(); + var renderer = new HTMLRenderer(); + var defaults = { + parser : parser, + renderer : renderer, + blockTypes : DefaultBlockTypeSet, + markupTypes : DefaultMarkupTypeSet, + includeTypeNames : false // true will output type_name: 'TEXT' etc. when parsing for easier debugging + }; + merge(this, defaults, options); -Tooltip.prototype.showLink = function(link, element) { - var message = '' + link + ''; - this.showMessage(message, element); -}; + // Reference the compiler settings + parser.blockTypes = renderer.blockTypes = this.blockTypes; + parser.markupTypes = renderer.markupTypes = this.markupTypes; + parser.includeTypeNames = this.includeTypeNames; + } -var EmbedIntent = (function() { + /** + * @method parse + * @param input + * @return Object + */ + Compiler.prototype.parse = function(input) { + return this.parser.parse(input); + }; - function EmbedIntent(options) { - var embedIntent = this; - var rootElement = options.rootElement; - options.tagName = 'button'; - options.classNames = ['ck-embed-intent-btn']; - View.call(embedIntent, options); + /** + * @method render + * @param data + * @return Object + */ + Compiler.prototype.render = function(data) { + return this.renderer.render(data); + }; - embedIntent.editorContext = options.editorContext; - embedIntent.element.title = 'Insert image or embed...'; - embedIntent.element.addEventListener('mouseup', function(e) { - if (embedIntent.isActive) { - embedIntent.deactivate(); - } else { - embedIntent.activate(); + /** + * @method registerBlockType + * @param {Type} type + */ + Compiler.prototype.registerBlockType = function(type) { + if (type instanceof Type) { + return this.blockTypes.addType(type); } - e.stopPropagation(); - }); - - embedIntent.toolbar = new Toolbar({ embedIntent: embedIntent, editor: embedIntent.editorContext, commands: options.commands, direction: ToolbarDirection.RIGHT }); - embedIntent.isActive = false; + }; - function embedIntentHandler() { - var blockElement = getSelectionBlockElement(); - var blockElementContent = blockElement && blockElement.innerHTML; - if (blockElementContent === '' || blockElementContent === '
') { - embedIntent.showAt(blockElement); - } else { - embedIntent.hide(); + /** + * @method registerMarkupType + * @param {Type} type + */ + Compiler.prototype.registerMarkupType = function(type) { + if (type instanceof Type) { + return this.markupTypes.addType(type); } - } + }; - rootElement.addEventListener('keyup', embedIntentHandler); + __exports__["default"] = Compiler; + }); +define("models/block", + ["./model","../utils/object-utils","exports"], + function(__dependency1__, __dependency2__, __exports__) { + "use strict"; + var Model = __dependency1__["default"]; + var inherit = __dependency2__.inherit; - document.addEventListener('mouseup', function(e) { - setTimeout(function() { - if (!nodeIsDescendantOfElement(e.target, embedIntent.toolbar.element)) { - embedIntentHandler(); + /** + * Ensures block markups at the same index are always in a specific order. + * For example, so all bold links are consistently marked up + * as text instead of text + */ + function sortBlockMarkups(markups) { + return markups.sort(function(a, b) { + if (a.start === b.start && a.end === b.end) { + return b.type - a.type; } + return 0; }); - }); + } - document.addEventListener('keyup', function(e) { - if (e.keyCode === Keycodes.ESC) { - embedIntent.hide(); - } - }); + /** + * @class BlockModel + * @constructor + * @extends Model + */ + function BlockModel(options) { + options = options || {}; + Model.call(this, options); + this.value = options.value || ''; + this.markup = sortBlockMarkups(options.markup || []); + } + inherit(BlockModel, Model); - window.addEventListener('resize', function() { - if(embedIntent.isShowing) { - positionElementToLeftOf(embedIntent.element, embedIntent.atNode); - if (embedIntent.toolbar.isShowing) { - embedIntent.toolbar.positionToContent(embedIntent.element); - } - } - }); - } - inherits(EmbedIntent, View); + __exports__["default"] = BlockModel; + }); +define("models/embed", + ["../utils/object-utils","../models/model","../types/type","exports"], + function(__dependency1__, __dependency2__, __dependency3__, __exports__) { + "use strict"; + var inherit = __dependency1__.inherit; + var Model = __dependency2__["default"]; + var Type = __dependency3__["default"]; - EmbedIntent.prototype.hide = function() { - if (EmbedIntent._super.prototype.hide.call(this)) { - this.deactivate(); - } - }; + /** + * @class EmbedModel + * @constructor + * @extends Model + * Massages data from an oEmbed response into an EmbedModel + */ + function EmbedModel(options) { + if (!options) { return null; } - EmbedIntent.prototype.showAt = function(node) { - this.show(); - this.deactivate(); - this.atNode = node; - positionElementToLeftOf(this.element, node); - }; + Model.call(this, { + type: Type.EMBED.id, + type_name: Type.EMBED.name, + attributes: {} + }); - EmbedIntent.prototype.activate = function() { - if (!this.isActive) { - this.addClass('activated'); - this.toolbar.show(); - this.toolbar.positionToContent(this.element); - this.isActive = true; - } - }; + var attributes = this.attributes; + var embedType = options.type; + var providerName = options.provider_name; + var embedUrl = options.url; + var embedTitle = options.title; + var embedThumbnail = options.thumbnail_url; + var embedHtml = options.html; - EmbedIntent.prototype.deactivate = function() { - if (this.isActive) { - this.removeClass('activated'); - this.toolbar.hide(); - this.isActive = false; + if (embedType) { attributes.embed_type = embedType; } + if (providerName) { attributes.provider_name = providerName; } + if (embedUrl) { attributes.url = embedUrl; } + if (embedTitle) { attributes.title = embedTitle; } + + if (embedType === 'photo') { + attributes.thumbnail = options.media_url || embedUrl; + } else if (embedThumbnail) { + attributes.thumbnail = embedThumbnail; + } + + if (embedHtml && embedType === 'rich') { + attributes.html = embedHtml; + } } - }; + inherit(Model, EmbedModel); - return EmbedIntent; -}()); + __exports__["default"] = EmbedModel; + }); +define("models/image", + ["./block","../types/type","../utils/object-utils","exports"], + function(__dependency1__, __dependency2__, __dependency3__, __exports__) { + "use strict"; + var BlockModel = __dependency1__["default"]; + var Type = __dependency2__["default"]; + var inherit = __dependency3__.inherit; -function Message(options) { - options = options || {}; - options.classNames = ['ck-message']; - View.call(this, options); -} -inherits(Message, View); + /** + * @class ImageModel + * @constructor + * @extends BlockModel + * A simple BlockModel subclass representing an image + */ + function ImageModel(options) { + options = options || {}; + options.type = Type.IMAGE.id; + options.type_name = Type.IMAGE.name; + if (options.src) { + options.attributes = { src: options.src }; + } + BlockModel.call(this, options); + } + inherit(ImageModel, BlockModel); -Message.prototype.show = function(message) { - var messageView = this; - messageView.element.innerHTML = message; - Message._super.prototype.show.call(messageView); - setTimeout(function() { - messageView.hide(); - }, 3000); -}; + __exports__["default"] = ImageModel; + }); +define("models/markup", + ["./model","../utils/object-utils","exports"], + function(__dependency1__, __dependency2__, __exports__) { + "use strict"; + var Model = __dependency1__["default"]; + var inherit = __dependency2__.inherit; -}(this, document)); + /** + * @class MarkupModel + * @constructor + * @extends Model + */ + function MarkupModel(options) { + options = options || {}; + Model.call(this, options); + this.start = options.start || 0; + this.end = options.end || 0; + } + inherit(MarkupModel, Model); -/*! - * @overview ContentKit-Compiler: Parses HTML to ContentKit's JSON schema and renders back to HTML. - * @version 0.1.0 - * @author Garth Poitras (http://garthpoitras.com/) - * @license MIT - * Last modified: Aug 7, 2014 - */ + __exports__["default"] = MarkupModel; + }); +define("models/model", + ["exports"], + function(__exports__) { + "use strict"; + /** + * @class Model + * @constructor + * @private + */ + function Model(options) { + options = options || {}; + var type_name = options.type_name; + var attributes = options.attributes; -(function(window, document, define, undefined) { + this.type = options.type || null; + if (type_name) { + this.type_name = type_name; + } + if (attributes) { + this.attributes = attributes; + } + } -define("content-kit", - ["./types/type","./models/block","./models/text","./models/embed","./compiler","./parsers/html-parser","./renderers/html-renderer","exports"], - function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__, __dependency6__, __dependency7__, __exports__) { + __exports__["default"] = Model; + }); +define("models/text", + ["./block","../types/type","../utils/object-utils","exports"], + function(__dependency1__, __dependency2__, __dependency3__, __exports__) { "use strict"; - var Type = __dependency1__["default"]; - var BlockModel = __dependency2__["default"]; - var TextModel = __dependency3__["default"]; - var EmbedModel = __dependency4__["default"]; - var Compiler = __dependency5__["default"]; - var HTMLParser = __dependency6__["default"]; - var HTMLRenderer = __dependency7__["default"]; + var BlockModel = __dependency1__["default"]; + var Type = __dependency2__["default"]; + var inherit = __dependency3__.inherit; /** - * @namespace ContentKit - * Merge public modules into the common ContentKit namespace. - * Handy for working in the browser with globals. + * @class TextModel + * @constructor + * @extends BlockModel + * A simple BlockModel subclass representing a paragraph of text */ - var ContentKit = window.ContentKit || {}; - ContentKit.Type = Type; - ContentKit.BlockModel = BlockModel; - ContentKit.TextModel = TextModel; - ContentKit.EmbedModel = EmbedModel; - ContentKit.Compiler = Compiler; - ContentKit.HTMLParser = HTMLParser; - ContentKit.HTMLRenderer = HTMLRenderer; + function TextModel(options) { + options = options || {}; + options.type = Type.TEXT.id; + options.type_name = Type.TEXT.name; + BlockModel.call(this, options); + } + inherit(TextModel, BlockModel); - __exports__["default"] = ContentKit; + __exports__["default"] = TextModel; }); -define("compiler", - ["./parsers/html-parser","./renderers/html-renderer","./types/type","./types/default-types","../utils/object-utils","exports"], - function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__, __exports__) { +define("parsers/html-parser", + ["../models/block","../models/markup","../types/default-types","../utils/object-utils","../utils/array-utils","../utils/string-utils","../utils/node-utils","exports"], + function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__, __dependency6__, __dependency7__, __exports__) { "use strict"; - var HTMLParser = __dependency1__["default"]; - var HTMLRenderer = __dependency2__["default"]; - var Type = __dependency3__["default"]; - var DefaultBlockTypeSet = __dependency4__.DefaultBlockTypeSet; - var DefaultMarkupTypeSet = __dependency4__.DefaultMarkupTypeSet; - var merge = __dependency5__.merge; + var BlockModel = __dependency1__["default"]; + var MarkupModel = __dependency2__["default"]; + var DefaultBlockTypeSet = __dependency3__.DefaultBlockTypeSet; + var DefaultMarkupTypeSet = __dependency3__.DefaultMarkupTypeSet; + var merge = __dependency4__.merge; + var toArray = __dependency5__.toArray; + var trim = __dependency6__.trim; + var trimLeft = __dependency6__.trimLeft; + var sanitizeWhitespace = __dependency6__.sanitizeWhitespace; + var createElement = __dependency7__.createElement; + var DOMParsingNode = __dependency7__.DOMParsingNode; + var textOfNode = __dependency7__.textOfNode; + var unwrapNode = __dependency7__.unwrapNode; + var attributesForNode = __dependency7__.attributesForNode; /** - * @class Compiler + * Gets the last block in the set or creates and return a default block if none exist yet. + */ + function getLastBlockOrCreate(parser, blocks) { + var block; + if (blocks.length) { + block = blocks[blocks.length - 1]; + } else { + block = parser.parseBlock(createElement(DefaultBlockTypeSet.TEXT.tag)); + blocks.push(block); + } + return block; + } + + /** + * Helper to retain stray elements at the root of the html that aren't blocks + */ + function handleNonBlockElementAtRoot(parser, elementNode, blocks) { + var block = getLastBlockOrCreate(parser, blocks), + markup = parser.parseElementMarkup(elementNode, block.value.length); + if (markup) { + block.markup.push(markup); + } + block.value += textOfNode(elementNode); + } + + /** + * @class HTMLParser * @constructor - * @param options */ - function Compiler(options) { - var parser = new HTMLParser(); - var renderer = new HTMLRenderer(); + function HTMLParser(options) { var defaults = { - parser : parser, - renderer : renderer, blockTypes : DefaultBlockTypeSet, markupTypes : DefaultMarkupTypeSet, - includeTypeNames : false // true will output type_name: 'TEXT' etc. when parsing for easier debugging + includeTypeNames : false }; merge(this, defaults, options); - - // Reference the compiler settings - parser.blockTypes = renderer.blockTypes = this.blockTypes; - parser.markupTypes = renderer.markupTypes = this.markupTypes; - parser.includeTypeNames = this.includeTypeNames; } /** * @method parse - * @param input - * @return Object + * @param html String of HTML content + * @return Array Parsed JSON content array */ - Compiler.prototype.parse = function(input) { - return this.parser.parse(input); - }; + HTMLParser.prototype.parse = function(html) { + DOMParsingNode.innerHTML = sanitizeWhitespace(html); - /** - * @method render - * @param data - * @return Object - */ - Compiler.prototype.render = function(data) { - return this.renderer.render(data); - }; + var children = toArray(DOMParsingNode.childNodes), + len = children.length, + blocks = [], + i, currentNode, block, text; - /** - * @method registerBlockType - * @param {Type} type - */ - Compiler.prototype.registerBlockType = function(type) { - if (type instanceof Type) { - return this.blockTypes.addType(type); + for (i = 0; i < len; i++) { + currentNode = children[i]; + // All top level nodes *should be* `Element` nodes and supported block types. + // We'll handle some cases if it isn't so we don't lose any content when parsing. + // Parser assumes sane input (such as from the ContentKit Editor) and is not intended to be a full html sanitizer. + if (currentNode.nodeType === 1) { + block = this.parseBlock(currentNode); + if (block) { + blocks.push(block); + } else { + handleNonBlockElementAtRoot(this, currentNode, blocks); + } + } else if (currentNode.nodeType === 3) { + text = currentNode.nodeValue; + if (trim(text)) { + block = getLastBlockOrCreate(this, blocks); + block.value += text; + } + } } + + return blocks; }; /** - * @method registerMarkupType - * @param {Type} type + * @method parseBlock + * @param node DOM node to parse + * @return {BlockModel} parsed block model + * Parses a single block type node into a model */ - Compiler.prototype.registerMarkupType = function(type) { - if (type instanceof Type) { - return this.markupTypes.addType(type); + HTMLParser.prototype.parseBlock = function(node) { + var type = this.blockTypes.findByNode(node); + if (type) { + return new BlockModel({ + type : type.id, + type_name : this.includeTypeNames && type.name, + value : trim(textOfNode(node)), + attributes : attributesForNode(node), + markup : this.parseBlockMarkup(node) + }); } }; - __exports__["default"] = Compiler; - }); -define("models/block", - ["./model","../utils/object-utils","exports"], - function(__dependency1__, __dependency2__, __exports__) { - "use strict"; - var Model = __dependency1__["default"]; - var inherit = __dependency2__.inherit; - /** - * Ensures block markups at the same index are always in a specific order. - * For example, so all bold links are consistently marked up - * as text instead of text + * @method parseBlockMarkup + * @param node DOM node to parse + * @return {Array} parsed markups + * Parses a single block type node's markup */ - function sortBlockMarkups(markups) { - return markups.sort(function(a, b) { - if (a.start === b.start && a.end === b.end) { - return b.type - a.type; - } - return 0; - }); - } + HTMLParser.prototype.parseBlockMarkup = function(node) { + var processedText = '', + markups = [], + index = 0, + currentNode, markup; - /** - * @class BlockModel - * @constructor - * @extends Model - */ - function BlockModel(options) { - options = options || {}; - Model.call(this, options); - this.value = options.value || ''; - this.markup = sortBlockMarkups(options.markup || []); - } - inherit(BlockModel, Model); + // Clone the node since it will be recursively torn down + node = node.cloneNode(true); - __exports__["default"] = BlockModel; - }); -define("models/embed", - ["../utils/object-utils","./model","../types/type","exports"], - function(__dependency1__, __dependency2__, __dependency3__, __exports__) { - "use strict"; - var inherit = __dependency1__.inherit; - var Model = __dependency2__["default"]; - var Type = __dependency3__["default"]; + while (node.hasChildNodes()) { + currentNode = node.firstChild; + if (currentNode.nodeType === 1) { + markup = this.parseElementMarkup(currentNode, processedText.length); + if (markup) { + markups.push(markup); + } + // unwrap the element so we can process any children + if (currentNode.hasChildNodes()) { + unwrapNode(currentNode); + } + } else if (currentNode.nodeType === 3) { + var text = sanitizeWhitespace(currentNode.nodeValue); + if (index === 0) { text = trimLeft(text); } + if (text) { processedText += text; } + } + + // node has been processed, remove it + currentNode.parentNode.removeChild(currentNode); + index++; + } - /** - * Whitelist of supported services by provider name - */ - var supportedServices = { - YOUTUBE : 1, - TWITTER : 2, - INSTAGRAM : 3 + return markups; }; /** - * Returns the id of a supported service from a provider name - */ - function serviceFor(provider) { - provider = provider && provider.toUpperCase(); - return provider && supportedServices[provider] || null; - } - - /** - * @class EmbedModel - * @constructor - * @extends Model - * Massages data from an oEmbed response into an EmbedModel + * @method parseElementMarkup + * @param node DOM node to parse + * @param startIndex DOM node to parse + * @return {MarkupModel} parsed markup model + * Parses markup of a single html element node */ - function EmbedModel(options) { - if (!options) { return null; } - - Model.call(this, { - type: Type.EMBED.id, - type_name: Type.EMBED.name, - attributes: {} - }); - - var attributes = this.attributes; - var embedType = options.type; - var providerName = options.provider_name; - var providerId = serviceFor(providerName); - var embedUrl = options.url; - var embedTitle = options.title; - var embedThumbnail = options.thumbnail_url; + HTMLParser.prototype.parseElementMarkup = function(node, startIndex) { + var type = this.markupTypes.findByNode(node), + selfClosing, endIndex; - if (embedType) { attributes.embed_type = embedType; } - if (providerName) { attributes.provider_name = providerName; } - if (providerId) { attributes.provider_id = providerId; } - if (embedUrl) { attributes.url = embedUrl; } - if (embedTitle) { attributes.title = embedTitle; } + if (type) { + selfClosing = type.selfClosing; + if (!selfClosing && !node.hasChildNodes()) { return; } // check for empty nodes - if (embedType === 'photo') { - attributes.thumbnail = options.media_url || embedUrl; - } else if (embedThumbnail) { - attributes.thumbnail = embedThumbnail; + endIndex = startIndex + (selfClosing ? 0 : textOfNode(node).length); + if (endIndex > startIndex || (selfClosing && endIndex === startIndex)) { // check for empty nodes + return new MarkupModel({ + type : type.id, + type_name : this.includeTypeNames && type.name, + start : startIndex, + end : endIndex, + attributes : attributesForNode(node) + }); + } } - } - inherit(Model, EmbedModel); + }; - __exports__["default"] = EmbedModel; + __exports__["default"] = HTMLParser; }); -define("models/markup", - ["./model","../utils/object-utils","exports"], +define("renderers/html-element-renderer", + ["../utils/string-utils","../utils/array-utils","exports"], function(__dependency1__, __dependency2__, __exports__) { "use strict"; - var Model = __dependency1__["default"]; - var inherit = __dependency2__.inherit; + var injectIntoString = __dependency1__.injectIntoString; + var sumSparseArray = __dependency2__.sumSparseArray; /** - * @class MarkupModel - * @constructor - * @extends Model + * Builds an opening html tag. i.e. ''; + }; + + __exports__["default"] = TwitterRenderer; + }); +define("renderers/embeds/youtube", + ["exports"], + function(__exports__) { + "use strict"; + + var RegExVideoId = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#\&\?]*).*/; + + function getVideoIdFromUrl(url) { + var match = url.match(RegExVideoId); + if (match && match[1].length === 11){ + return match[1]; + } + return null; + } + + function YouTubeRenderer() {} + YouTubeRenderer.prototype.render = function(model) { + var videoId = getVideoIdFromUrl(model.attributes.url); + var embedUrl = 'http://www.youtube.com/embed/' + videoId + '?controls=2&showinfo=0&color=white&theme=light'; + return ''; + }; + + __exports__["default"] = YouTubeRenderer; + }); }(this, document, define)); diff --git a/ext/xhr-file-uploader.js b/ext/xhr-file-uploader.js new file mode 100644 index 000000000..05bb08843 --- /dev/null +++ b/ext/xhr-file-uploader.js @@ -0,0 +1,78 @@ +/** + * A simple ajax file uploader for the browser + */ + +(function(exports) { + + function XHRFileUploader(options) { + options = options || {}; + var url = options.url; + var maxFileSize = options.maxFileSize; + if (url) { + this.url = url; + } else { + throw new Error('XHRFileUploader: setting the `url` to an upload service is required'); + } + if (maxFileSize) { + this.maxFileSize = maxFileSize; + } + } + + exports.XHRFileUploader = XHRFileUploader; + + XHRFileUploader.prototype.upload = function(options) { + if (!options) { return; } + + var fileInput = options.fileInput; + var file = options.file || (fileInput && fileInput.files && fileInput.files[0]); + var callback = options.complete; + var maxFileSize = this.maxFileSize; + if (!file || !(file instanceof exports.File)) { return; } + + if (maxFileSize && file.size > maxFileSize) { + if (callback) { callback.call(this, null, { message: 'maximum file size is ' + maxFileSize + ' bytes' }); } + return; + } + + xhrPost({ + url: this.url, + data: file, + success: function(response) { + if (callback) { callback.call(this, responseJSON(response)); } + }, + error: function(error) { + if (callback) { callback.call(this, null, responseJSON(error)); } + } + }); + }; + + function xhrPost(options) { + var request = new XMLHttpRequest(); + request.open('POST', options.url, true); + + request.onload = function () { + var response = request.responseText; + if (request.status === 200) { + return options.success.call(this, response); + } + options.error.call(this, response); + }; + request.onerror = function (error) { + options.error.call(this, error); + }; + + var formData = new FormData(); + formData.append('file', options.data); + request.send(formData); + } + + function responseJSON(jsonString) { + if (!jsonString) { return null; } + try { + return JSON.parse(jsonString); + } catch(e) { + return jsonString; + } + } + +}(this)); \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js index d38ad00ba..8e84dd238 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -17,25 +17,17 @@ var pkg = require('./package.json'); var jsSrc = [ './src/js/index.js', './src/js/constants.js', - './src/js/utils/object-utils.js', - './src/js/utils/array-utils.js', - './src/js/utils/element-utils.js', - './src/js/utils/selection-utils.js', - './src/js/utils/http-utils.js', - './src/js/view.js', - './src/js/prompt.js', + './src/js/utils/*.js', + './src/js/views/view.js', + './src/js/views/*.js', './src/js/commands.js', - './src/js/editor.js', - './src/js/toolbar.js', - './src/js/toolbar-button.js', - './src/js/tooltip.js', - './src/js/embed-intent.js', - './src/js/message.js' + './src/js/editor.js' ]; var jsExtSrc = './src/js/ext/*'; var cssSrc = [ + './src/css/variables.less', './src/css/editor.less', './src/css/toolbar.less', './src/css/tooltip.less', diff --git a/src/css/embeds.less b/src/css/embeds.less index 758a133c2..64270ed71 100644 --- a/src/css/embeds.less +++ b/src/css/embeds.less @@ -45,6 +45,10 @@ transform: rotate(-135deg); } +.ck-embed-loading { + position: absolute; +} + .ck-file-input { display:none; } @@ -59,6 +63,10 @@ border-color: @themeColor; } +.ck-embed iframe { + margin: 0 auto !important; +} + .ck-embed figure { position: relative; margin: 0; diff --git a/src/css/toolbar.less b/src/css/toolbar.less index f3d942106..5ce6813ef 100644 --- a/src/css/toolbar.less +++ b/src/css/toolbar.less @@ -117,6 +117,6 @@ .ck-editor-hilite { position: absolute; z-index: -1; - background-color: rgba(76,217,100,0.08); + background-color: fadeout(@themeColor, 95%); border-bottom: 2px dotted @themeColor; } diff --git a/src/css/variables.less b/src/css/variables.less index f0f3580d2..7443e218d 100644 --- a/src/css/variables.less +++ b/src/css/variables.less @@ -1,6 +1,6 @@ // LESS Variables -@themeColor : rgb(76, 217, 100); +@themeColor : rgb(62, 163, 255); //rgb(76, 217, 100); @themeColorText : darken(@themeColor, 10%); @elementMoveSpeed : 0.1s; diff --git a/src/js/commands.js b/src/js/commands.js index 234c346d1..62e679c95 100644 --- a/src/js/commands.js +++ b/src/js/commands.js @@ -195,6 +195,9 @@ function ImageEmbedCommand(options) { name: 'image', button: '' }); + if (window.XHRFileUploader) { + this.uploader = new XHRFileUploader({ url: '/upload', maxFileSize: 5000000 }); + } } inherits(ImageEmbedCommand, EmbedCommand); @@ -202,39 +205,40 @@ ImageEmbedCommand.prototype = { exec: function() { ImageEmbedCommand._super.prototype.exec.call(this); var clickEvent = new MouseEvent('click', { bubbles: false }); - if (!this.fileBrowser) { + if (!this.fileInput) { var command = this; - var fileBrowser = this.fileBrowser = document.createElement('input'); - fileBrowser.type = 'file'; - fileBrowser.accept = 'image/*'; - fileBrowser.className = 'ck-file-input'; - fileBrowser.addEventListener('change', function(e) { + var fileInput = this.fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/*'; + fileInput.className = 'ck-file-input'; + fileInput.addEventListener('change', function(e) { command.handleFile(e); }); - document.body.appendChild(fileBrowser); + document.body.appendChild(fileInput); } - this.fileBrowser.dispatchEvent(clickEvent); + this.fileInput.dispatchEvent(clickEvent); }, handleFile: function(e) { - var command = this; - var target = e.target; - var file = target && target.files[0]; - var reader = new FileReader(); - reader.onload = function(event) { - var base64File = event.target.result; - var blockElement = getSelectionBlockElement(); - var editorNode = blockElement.parentNode; - var image = document.createElement('img'); - image.src = base64File; - - // image needs to be placed outside of the current empty paragraph - editorNode.insertBefore(image, blockElement); - editorNode.removeChild(blockElement); + var fileInput = e.target; + var editor = this.editorContext; + var embedIntent = this.embedIntent; - command.embedIntent.hide(); - }; - reader.readAsDataURL(file); - target.value = null; // reset + embedIntent.showLoading(); + this.uploader.upload({ + fileInput: fileInput, + complete: function(response, error) { + embedIntent.hideLoading(); + if (error || !response || !response.url) { + return new Message().show(error.message || 'Error uploading image'); + } + var imageModel = new ContentKit.ImageModel({ src: response.url }); + var index = editor.getCurrentBlockIndex(); + editor.insertBlockAt(imageModel, index); + editor.syncVisualAt(index); + } + }); + fileInput.value = null; // reset file input + // TODO: client-side render while uploading } }; @@ -244,7 +248,7 @@ function OEmbedCommand(options) { button: '', prompt: new Prompt({ command: this, - placeholder: 'Paste a YouTube, Twitter, or any url...' + placeholder: 'Paste a YouTube or Twitter url...' }) }); } @@ -254,22 +258,29 @@ OEmbedCommand.prototype.exec = function(url) { var command = this; var editorContext = command.editorContext; var index = editorContext.getCurrentBlockIndex(); - command.embedIntent.hide(); - HTTP.get('http://noembed.com/embed?url=' + url, function(responseText, error) { + var oEmbedEndpoint = 'http://noembed.com/embed?url='; + + command.embedIntent.showLoading(); + if (!Regex.HTTP_PROTOCOL.test(url)) { + url = 'http://' + url; + } + + HTTP.get(oEmbedEndpoint + url, function(responseText, error) { + command.embedIntent.hideLoading(); if (error) { - new Message().show('Embed error: ' + error); - return; - } - var json = JSON.parse(responseText); - if (json.error) { - new Message().show('Embed error: ' + json.error); + new Message().show('Embed error: status code ' + error.currentTarget.status); } else { - var embedModel = new ContentKit.EmbedModel(json); - if (!embedModel.attributes.provider_id) { - new Message().show('Embed error: "' + embedModel.attributes.provider_name + '" embeds are not supported at this time'); + var json = JSON.parse(responseText); + if (json.error) { + new Message().show('Embed error: ' + json.error); } else { - editorContext.insertBlockAt(embedModel, index); - editorContext.syncVisualAt(index); + var embedModel = new ContentKit.EmbedModel(json); + //if (!embedModel.attributes.provider_id) { + // new Message().show('Embed error: "' + embedModel.attributes.provider_name + '" embeds are not supported at this time'); + //} else { + editorContext.insertBlockAt(embedModel, index); + editorContext.syncVisualAt(index); + //} } } }); diff --git a/src/js/editor.js b/src/js/editor.js index 86867ee7f..633616d8d 100644 --- a/src/js/editor.js +++ b/src/js/editor.js @@ -86,6 +86,11 @@ ContentKit.Editor = (function() { commands: editor.embedCommands, rootElement: element }); + + if (editor.imageServiceUrl) { + // TODO: lookup by name + editor.embedCommands[0].uploader.url = editor.imageServiceUrl; + } } if(editor.autofocus) { element.focus(); } @@ -103,10 +108,12 @@ ContentKit.Editor = (function() { }; Editor.prototype.syncVisualAt = function(index) { - var block = this.model[index]; - var html = this.compiler.render([block]); + var blockModel = this.model[index]; + var html = this.compiler.render([blockModel]); var blockElements = toArray(this.element.children); - blockElements[index].innerHTML = html; + var element = blockElements[index]; + element.innerHTML = html; + runAfterRenderHooks(element, blockModel); }; Editor.prototype.getCurrentBlockIndex = function() { @@ -194,6 +201,19 @@ ContentKit.Editor = (function() { }); } + var afterRenderHooks = []; + Editor.prototype.afterRender = function(callback) { + if ('function' === typeof callback) { + afterRenderHooks.push(callback); + } + }; + + function runAfterRenderHooks(element, blockModel) { + for (var i = 0, len = afterRenderHooks.length; i < len; i++) { + afterRenderHooks[i].call(null, element, blockModel); + } + } + function bindPasteEvents(editor) { editor.element.addEventListener('paste', function(e) { var data = e.clipboardData, plainText; diff --git a/src/js/ext/content-kit-compiler.js b/src/js/ext/content-kit-compiler.js index 0f3d37285..9d7337ce7 100755 --- a/src/js/ext/content-kit-compiler.js +++ b/src/js/ext/content-kit-compiler.js @@ -3,22 +3,23 @@ * @version 0.1.0 * @author Garth Poitras (http://garthpoitras.com/) * @license MIT - * Last modified: Aug 7, 2014 + * Last modified: Aug 10, 2014 */ (function(window, document, define, undefined) { define("content-kit", - ["./types/type","./models/block","./models/text","./models/embed","./compiler","./parsers/html-parser","./renderers/html-renderer","exports"], - function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__, __dependency6__, __dependency7__, __exports__) { + ["./types/type","./models/block","./models/text","./models/image","./models/embed","./compiler","./parsers/html-parser","./renderers/html-renderer","exports"], + function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__, __dependency6__, __dependency7__, __dependency8__, __exports__) { "use strict"; var Type = __dependency1__["default"]; var BlockModel = __dependency2__["default"]; var TextModel = __dependency3__["default"]; - var EmbedModel = __dependency4__["default"]; - var Compiler = __dependency5__["default"]; - var HTMLParser = __dependency6__["default"]; - var HTMLRenderer = __dependency7__["default"]; + var ImageModel = __dependency4__["default"]; + var EmbedModel = __dependency5__["default"]; + var Compiler = __dependency6__["default"]; + var HTMLParser = __dependency7__["default"]; + var HTMLRenderer = __dependency8__["default"]; /** * @namespace ContentKit @@ -29,6 +30,7 @@ define("content-kit", ContentKit.Type = Type; ContentKit.BlockModel = BlockModel; ContentKit.TextModel = TextModel; + ContentKit.ImageModel = ImageModel; ContentKit.EmbedModel = EmbedModel; ContentKit.Compiler = Compiler; ContentKit.HTMLParser = HTMLParser; @@ -147,30 +149,13 @@ define("models/block", __exports__["default"] = BlockModel; }); define("models/embed", - ["../utils/object-utils","./model","../types/type","exports"], + ["../utils/object-utils","../models/model","../types/type","exports"], function(__dependency1__, __dependency2__, __dependency3__, __exports__) { "use strict"; var inherit = __dependency1__.inherit; var Model = __dependency2__["default"]; var Type = __dependency3__["default"]; - /** - * Whitelist of supported services by provider name - */ - var supportedServices = { - YOUTUBE : 1, - TWITTER : 2, - INSTAGRAM : 3 - }; - - /** - * Returns the id of a supported service from a provider name - */ - function serviceFor(provider) { - provider = provider && provider.toUpperCase(); - return provider && supportedServices[provider] || null; - } - /** * @class EmbedModel * @constructor @@ -189,14 +174,13 @@ define("models/embed", var attributes = this.attributes; var embedType = options.type; var providerName = options.provider_name; - var providerId = serviceFor(providerName); var embedUrl = options.url; var embedTitle = options.title; var embedThumbnail = options.thumbnail_url; + var embedHtml = options.html; if (embedType) { attributes.embed_type = embedType; } if (providerName) { attributes.provider_name = providerName; } - if (providerId) { attributes.provider_id = providerId; } if (embedUrl) { attributes.url = embedUrl; } if (embedTitle) { attributes.title = embedTitle; } @@ -205,11 +189,42 @@ define("models/embed", } else if (embedThumbnail) { attributes.thumbnail = embedThumbnail; } + + if (embedHtml && embedType === 'rich') { + attributes.html = embedHtml; + } } inherit(Model, EmbedModel); __exports__["default"] = EmbedModel; }); +define("models/image", + ["./block","../types/type","../utils/object-utils","exports"], + function(__dependency1__, __dependency2__, __dependency3__, __exports__) { + "use strict"; + var BlockModel = __dependency1__["default"]; + var Type = __dependency2__["default"]; + var inherit = __dependency3__.inherit; + + /** + * @class ImageModel + * @constructor + * @extends BlockModel + * A simple BlockModel subclass representing an image + */ + function ImageModel(options) { + options = options || {}; + options.type = Type.IMAGE.id; + options.type_name = Type.IMAGE.name; + if (options.src) { + options.attributes = { src: options.src }; + } + BlockModel.call(this, options); + } + inherit(ImageModel, BlockModel); + + __exports__["default"] = ImageModel; + }); define("models/markup", ["./model","../utils/object-utils","exports"], function(__dependency1__, __dependency2__, __exports__) { @@ -262,7 +277,7 @@ define("models/text", function(__dependency1__, __dependency2__, __dependency3__, __exports__) { "use strict"; var BlockModel = __dependency1__["default"]; - var Type = __dependency2__.Type; + var Type = __dependency2__["default"]; var inherit = __dependency3__.inherit; /** @@ -281,69 +296,196 @@ define("models/text", __exports__["default"] = TextModel; }); -define("renderers/embed-renderer", - ["exports"], - function(__exports__) { +define("parsers/html-parser", + ["../models/block","../models/markup","../types/default-types","../utils/object-utils","../utils/array-utils","../utils/string-utils","../utils/node-utils","exports"], + function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__, __dependency6__, __dependency7__, __exports__) { "use strict"; + var BlockModel = __dependency1__["default"]; + var MarkupModel = __dependency2__["default"]; + var DefaultBlockTypeSet = __dependency3__.DefaultBlockTypeSet; + var DefaultMarkupTypeSet = __dependency3__.DefaultMarkupTypeSet; + var merge = __dependency4__.merge; + var toArray = __dependency5__.toArray; + var trim = __dependency6__.trim; + var trimLeft = __dependency6__.trimLeft; + var sanitizeWhitespace = __dependency6__.sanitizeWhitespace; + var createElement = __dependency7__.createElement; + var DOMParsingNode = __dependency7__.DOMParsingNode; + var textOfNode = __dependency7__.textOfNode; + var unwrapNode = __dependency7__.unwrapNode; + var attributesForNode = __dependency7__.attributesForNode; + /** - * Embed Service Adapters + * Gets the last block in the set or creates and return a default block if none exist yet. */ - var RegExVideoId = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#\&\?]*).*/; - function getVideoIdFromUrl(url) { - var match = url.match(RegExVideoId); - if (match && match[1].length === 11){ - return match[1]; + function getLastBlockOrCreate(parser, blocks) { + var block; + if (blocks.length) { + block = blocks[blocks.length - 1]; + } else { + block = parser.parseBlock(createElement(DefaultBlockTypeSet.TEXT.tag)); + blocks.push(block); } - return null; + return block; } - function YoutubeAdapter() {} - YoutubeAdapter.prototype.render = function(model) { - var videoId = getVideoIdFromUrl(model.attributes.url); - var embedUrl = 'http://www.youtube.com/embed/' + videoId + '?controls=2&color=white&theme=light'; - return ''; - }; - + /** + * Helper to retain stray elements at the root of the html that aren't blocks + */ + function handleNonBlockElementAtRoot(parser, elementNode, blocks) { + var block = getLastBlockOrCreate(parser, blocks), + markup = parser.parseElementMarkup(elementNode, block.value.length); + if (markup) { + block.markup.push(markup); + } + block.value += textOfNode(elementNode); + } /** - * @class EmbedRenderer + * @class HTMLParser * @constructor */ - function EmbedRenderer() {} + function HTMLParser(options) { + var defaults = { + blockTypes : DefaultBlockTypeSet, + markupTypes : DefaultMarkupTypeSet, + includeTypeNames : false + }; + merge(this, defaults, options); + } /** - * @method render - * @param model - * @return String html + * @method parse + * @param html String of HTML content + * @return Array Parsed JSON content array */ - EmbedRenderer.prototype.render = function(model) { - var adapter = this.adatperFor(model); - if (adapter) { - return adapter.render(model); + HTMLParser.prototype.parse = function(html) { + DOMParsingNode.innerHTML = sanitizeWhitespace(html); + + var children = toArray(DOMParsingNode.childNodes), + len = children.length, + blocks = [], + i, currentNode, block, text; + + for (i = 0; i < len; i++) { + currentNode = children[i]; + // All top level nodes *should be* `Element` nodes and supported block types. + // We'll handle some cases if it isn't so we don't lose any content when parsing. + // Parser assumes sane input (such as from the ContentKit Editor) and is not intended to be a full html sanitizer. + if (currentNode.nodeType === 1) { + block = this.parseBlock(currentNode); + if (block) { + blocks.push(block); + } else { + handleNonBlockElementAtRoot(this, currentNode, blocks); + } + } else if (currentNode.nodeType === 3) { + text = currentNode.nodeValue; + if (trim(text)) { + block = getLastBlockOrCreate(this, blocks); + block.value += text; + } + } + } + + return blocks; + }; + + /** + * @method parseBlock + * @param node DOM node to parse + * @return {BlockModel} parsed block model + * Parses a single block type node into a model + */ + HTMLParser.prototype.parseBlock = function(node) { + var type = this.blockTypes.findByNode(node); + if (type) { + return new BlockModel({ + type : type.id, + type_name : this.includeTypeNames && type.name, + value : trim(textOfNode(node)), + attributes : attributesForNode(node), + markup : this.parseBlockMarkup(node) + }); + } + }; + + /** + * @method parseBlockMarkup + * @param node DOM node to parse + * @return {Array} parsed markups + * Parses a single block type node's markup + */ + HTMLParser.prototype.parseBlockMarkup = function(node) { + var processedText = '', + markups = [], + index = 0, + currentNode, markup; + + // Clone the node since it will be recursively torn down + node = node.cloneNode(true); + + while (node.hasChildNodes()) { + currentNode = node.firstChild; + if (currentNode.nodeType === 1) { + markup = this.parseElementMarkup(currentNode, processedText.length); + if (markup) { + markups.push(markup); + } + // unwrap the element so we can process any children + if (currentNode.hasChildNodes()) { + unwrapNode(currentNode); + } + } else if (currentNode.nodeType === 3) { + var text = sanitizeWhitespace(currentNode.nodeValue); + if (index === 0) { text = trimLeft(text); } + if (text) { processedText += text; } + } + + // node has been processed, remove it + currentNode.parentNode.removeChild(currentNode); + index++; } - return model.attributes.provider_name + ': ' + model.attributes.title + ''; + return markups; }; - EmbedRenderer.prototype.adatperFor = function(model) { - var providerId = model.attributes.provider_id; - switch(providerId) { - case 1: - return new YoutubeAdapter(); + /** + * @method parseElementMarkup + * @param node DOM node to parse + * @param startIndex DOM node to parse + * @return {MarkupModel} parsed markup model + * Parses markup of a single html element node + */ + HTMLParser.prototype.parseElementMarkup = function(node, startIndex) { + var type = this.markupTypes.findByNode(node), + selfClosing, endIndex; + + if (type) { + selfClosing = type.selfClosing; + if (!selfClosing && !node.hasChildNodes()) { return; } // check for empty nodes + + endIndex = startIndex + (selfClosing ? 0 : textOfNode(node).length); + if (endIndex > startIndex || (selfClosing && endIndex === startIndex)) { // check for empty nodes + return new MarkupModel({ + type : type.id, + type_name : this.includeTypeNames && type.name, + start : startIndex, + end : endIndex, + attributes : attributesForNode(node) + }); + } } }; - __exports__["default"] = EmbedRenderer; + __exports__["default"] = HTMLParser; }); -define("renderers/html-renderer", - ["../types/default-types","../utils/object-utils","../utils/string-utils","../utils/array-utils","exports"], - function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __exports__) { +define("renderers/html-element-renderer", + ["../utils/string-utils","../utils/array-utils","exports"], + function(__dependency1__, __dependency2__, __exports__) { "use strict"; - var DefaultBlockTypeSet = __dependency1__.DefaultBlockTypeSet; - var DefaultMarkupTypeSet = __dependency1__.DefaultMarkupTypeSet; - var merge = __dependency2__.merge; - var injectIntoString = __dependency3__.injectIntoString; - var sumSparseArray = __dependency4__.sumSparseArray; + var injectIntoString = __dependency1__.injectIntoString; + var sumSparseArray = __dependency2__.sumSparseArray; /** * Builds an opening html tag. i.e. ''; + }; + + __exports__["default"] = TwitterRenderer; + }); +define("renderers/embeds/youtube", + ["exports"], + function(__exports__) { + "use strict"; + + var RegExVideoId = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#\&\?]*).*/; + + function getVideoIdFromUrl(url) { + var match = url.match(RegExVideoId); + if (match && match[1].length === 11){ + return match[1]; + } + return null; + } + + function YouTubeRenderer() {} + YouTubeRenderer.prototype.render = function(model) { + var videoId = getVideoIdFromUrl(model.attributes.url); + var embedUrl = 'http://www.youtube.com/embed/' + videoId + '?controls=2&showinfo=0&color=white&theme=light'; + return ''; + }; + + __exports__["default"] = YouTubeRenderer; + }); }(this, document, define)); diff --git a/src/js/utils/element-utils.js b/src/js/utils/element-utils.js index 0aa3a4279..cb5abfd4d 100644 --- a/src/js/utils/element-utils.js +++ b/src/js/utils/element-utils.js @@ -84,6 +84,11 @@ function positionElementCenteredBelow(element, belowElement) { positionElementHorizontallyCenteredToRect(element, belowElement.getBoundingClientRect(), -element.offsetHeight - elementMargin); } +function positionElementCenteredIn(element, inElement) { + var verticalCenter = (inElement.offsetHeight / 2) - (element.offsetHeight / 2); + positionElementHorizontallyCenteredToRect(element, inElement.getBoundingClientRect(), -verticalCenter); +} + function positionElementToLeftOf(element, leftOfElement) { var verticalCenter = (leftOfElement.offsetHeight / 2) - (element.offsetHeight / 2); var elementMargin = getElementComputedStyleNumericProp(element, 'marginRight'); diff --git a/src/js/utils/http-utils.js b/src/js/utils/http-utils.js index 69411e0c8..caadef1ac 100644 --- a/src/js/utils/http-utils.js +++ b/src/js/utils/http-utils.js @@ -10,7 +10,7 @@ var HTTP = (function() { callback(this.responseText); }; request.onerror = function(error) { - callback(this.responseText, error); + callback(null, error); }; request.open('GET', url); request.send(); diff --git a/src/js/embed-intent.js b/src/js/views/embed-intent.js similarity index 87% rename from src/js/embed-intent.js rename to src/js/views/embed-intent.js index ba5ce984f..46e761ac8 100644 --- a/src/js/embed-intent.js +++ b/src/js/views/embed-intent.js @@ -88,5 +88,19 @@ var EmbedIntent = (function() { } }; + var loading = createDiv('div'); + loading.className = 'ck-embed-loading'; + loading.innerHTML = 'LOADING'; + EmbedIntent.prototype.showLoading = function() { + this.hide(); + document.body.appendChild(loading); + positionElementCenteredIn(loading, this.atNode); + }; + + EmbedIntent.prototype.hideLoading = function() { + document.body.removeChild(loading); + }; + + return EmbedIntent; }()); diff --git a/src/js/message.js b/src/js/views/message.js similarity index 100% rename from src/js/message.js rename to src/js/views/message.js diff --git a/src/js/prompt.js b/src/js/views/prompt.js similarity index 100% rename from src/js/prompt.js rename to src/js/views/prompt.js diff --git a/src/js/toolbar-button.js b/src/js/views/toolbar-button.js similarity index 100% rename from src/js/toolbar-button.js rename to src/js/views/toolbar-button.js diff --git a/src/js/toolbar.js b/src/js/views/toolbar.js similarity index 100% rename from src/js/toolbar.js rename to src/js/views/toolbar.js diff --git a/src/js/tooltip.js b/src/js/views/tooltip.js similarity index 100% rename from src/js/tooltip.js rename to src/js/views/tooltip.js diff --git a/src/js/view.js b/src/js/views/view.js similarity index 100% rename from src/js/view.js rename to src/js/views/view.js