ContentKit Editor
-A modern, minimalist text editor allowing you to write in a distraction free environment. Simply select any text you would like to format and a toolbar will appear where you can toggle options such as bold and italic, or create a link.
-Create headings by pressing "H1" on the toolbar
-Pressing "H2" will create a subheading, like this one.
-Create block quotes by selecting any text and pressing the "quote" button. Press it again to toggle back to a standard paragraph.-
To create a list, start typing a dash followed by a space ("- ") on a new line and a list will be automatically created.
-To create an ordered list, start typing a one followed by a period and a space ("1. ") and the list will be automatically created.
-Tips & Tricks:
--
-
- Close the formatting toolbar by clicking anywhere, or press ESC -
- Make the toolbar sticky by pressing F5 -
- Double click a word to select it -
- You only have to select a portion of a paragraph if you want to change it to a heading, subheading, or quote -
- To create a soft line break, press shift + enter -
- Press enter twice to exit a list -
Keyboard shortcuts:
--
-
- bold: (cmd+B) -
- italic: (cmd+I) -
- undo: (cmd+z) -
- redo: (cmd+y) -
- select all text: (cmd+a) -
- select letters: (hold shift + arrow keys) -
- close toolbar: (ESC) -
Enjoy focusing on your content and not worrying about formatting!
- +mobiledoc to load
+
+ This mobiledoc will be loaded into the editor.
+ You can change it and see the editor reload with the new contents.
+ (If there is a JSON syntax error it will be ignored; if there is a parser
+ error the editor may stop responding.)
+
+ Select a preloaded mobiledoc here:
+
+
serialized mobiledoc
++ When the editor updates, it prints its serialized mobiledoc here. +
+
+ rendered mobiledoc (dom)
++ This is the output of using the runtime (client-side) + mobiledoc-dom-renderer + on the serialized mobiledoc. +
+
+
+ rendered mobiledoc (html)
++ This is the output of using the server-side + mobiledoc-html-renderer + on the serialized mobiledoc. +
+'; - }; - - var twitter = TwitterRenderer; - - function InstagramRenderer() {} - InstagramRenderer.prototype.render = function(model) { - return '
'; - } - return false; - } - - function getElementRelativeOffset(element) { - var offset = { left: 0, top: -window.pageYOffset }; - var offsetParent = element.offsetParent; - var offsetParentPosition = window.getComputedStyle(offsetParent).position; - var offsetParentRect; - - if (offsetParentPosition === 'relative') { - offsetParentRect = offsetParent.getBoundingClientRect(); - offset.left = offsetParentRect.left; - offset.top = offsetParentRect.top; - } - return offset; - } - - function getElementComputedStyleNumericProp(element, prop) { - return parseFloat(window.getComputedStyle(element)[prop]); - } - - function positionElementToRect(element, rect, topOffset, leftOffset) { - var relativeOffset = getElementRelativeOffset(element); - var style = element.style; - var round = Math.round; - var left, top; - - topOffset = topOffset || 0; - leftOffset = leftOffset || 0; - left = round(rect.left - relativeOffset.left - leftOffset); - top = round(rect.top - relativeOffset.top - topOffset); - style.left = left + 'px'; - style.top = top + 'px'; - return { left: left, top: top }; - } - - function positionElementHorizontallyCenteredToRect(element, rect, topOffset) { - var horizontalCenter = (element.offsetWidth / 2) - (rect.width / 2); - return positionElementToRect(element, rect, topOffset, horizontalCenter); - } - - function positionElementCenteredAbove(element, aboveElement) { - var elementMargin = getElementComputedStyleNumericProp(element, 'marginBottom'); - return positionElementHorizontallyCenteredToRect(element, aboveElement.getBoundingClientRect(), element.offsetHeight + elementMargin); - } - - function positionElementCenteredBelow(element, belowElement) { - var elementMargin = getElementComputedStyleNumericProp(element, 'marginTop'); - return positionElementHorizontallyCenteredToRect(element, belowElement.getBoundingClientRect(), -element.offsetHeight - elementMargin); - } - - function positionElementCenteredIn(element, inElement) { - var verticalCenter = (inElement.offsetHeight / 2) - (element.offsetHeight / 2); - return positionElementHorizontallyCenteredToRect(element, inElement.getBoundingClientRect(), -verticalCenter); - } - - function positionElementToLeftOf(element, leftOfElement) { - var verticalCenter = (leftOfElement.offsetHeight / 2) - (element.offsetHeight / 2); - var elementMargin = getElementComputedStyleNumericProp(element, 'marginRight'); - return positionElementToRect(element, leftOfElement.getBoundingClientRect(), -verticalCenter, element.offsetWidth + elementMargin); - } - - function positionElementToRightOf(element, rightOfElement) { - var verticalCenter = (rightOfElement.offsetHeight / 2) - (element.offsetHeight / 2); - var elementMargin = getElementComputedStyleNumericProp(element, 'marginLeft'); - var rightOfElementRect = rightOfElement.getBoundingClientRect(); - return positionElementToRect(element, rightOfElementRect, -verticalCenter, -rightOfElement.offsetWidth - elementMargin); - } - - var RootTags = [ - types_type.PARAGRAPH.tag, - types_type.HEADING.tag, - types_type.SUBHEADING.tag, - types_type.QUOTE.tag, - types_type.LIST.tag, - types_type.ORDERED_LIST.tag - ]; - - var SelectionDirection = { - LEFT_TO_RIGHT : 1, - RIGHT_TO_LEFT : 2, - SAME_NODE : 3 - }; - - function getDirectionOfSelection(selection) { - var node = selection.anchorNode; - var position = node && node.compareDocumentPosition(selection.focusNode); - if (position & Node.DOCUMENT_POSITION_FOLLOWING) { - return SelectionDirection.LEFT_TO_RIGHT; - } else if (position & Node.DOCUMENT_POSITION_PRECEDING) { - return SelectionDirection.RIGHT_TO_LEFT; - } - return SelectionDirection.SAME_NODE; - } - - function getSelectionElement(selection) { - selection = selection || window.getSelection(); - var node = getDirectionOfSelection(selection) === SelectionDirection.LEFT_TO_RIGHT ? selection.anchorNode : selection.focusNode; - return node && (node.nodeType === 3 ? node.parentNode : node); - } - - function getSelectionBlockElement(selection) { - selection = selection || window.getSelection(); - var element = getSelectionElement(); - var tag = element && element.tagName.toLowerCase(); - while (tag && RootTags.indexOf(tag) === -1) { - if (element.contentEditable === 'true') { - return null; // Stop traversing up dom when hitting an editor element - } - element = element.parentNode; - tag = element.tagName && element.tagName.toLowerCase(); - } - return element; - } - - function getSelectionTagName() { - var element = getSelectionElement(); - return element ? element.tagName.toLowerCase() : null; - } - - function getSelectionBlockTagName() { - var element = getSelectionBlockElement(); - return element ? element.tagName && element.tagName.toLowerCase() : null; - } - - function tagsInSelection(selection) { - var element = getSelectionElement(selection); - var tags = []; - while(element) { - if (element.contentEditable === 'true') { break; } // Stop traversing up dom when hitting an editor element - if (element.tagName) { - tags.push(element.tagName.toLowerCase()); - } - element = element.parentNode; - } - return tags; - } - - function selectionIsInElement(selection, element) { - var node = selection.anchorNode; - return node && nodeIsDescendantOfElement(node, element); - } - - function selectionIsEditable(selection) { - var el = getSelectionBlockElement(selection); - return el && el.isContentEditable; - } - - function restoreRange(range) { - var selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - } - - function selectNode(node) { - var range = document.createRange(); - var selection = window.getSelection(); - range.setStart(node, 0); - range.setEnd(node, node.length); - selection.removeAllRanges(); - selection.addRange(range); - } - - function setCursorIndexInElement(element, index) { - var range = document.createRange(); - var selection = window.getSelection(); - range.setStart(element, index); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - } - - function setCursorToStartOfElement(element) { - setCursorIndexInElement(element, 0); - } - - function getCursorOffsetInElement(element) { - // http://stackoverflow.com/questions/4811822/get-a-ranges-start-and-end-offsets-relative-to-its-parent-container/4812022#4812022 - var caretOffset = 0; - var selection = window.getSelection(); - if (selection.rangeCount > 0) { - var range = selection.getRangeAt(0); - var preCaretRange = range.cloneRange(); - preCaretRange.selectNodeContents(element); - preCaretRange.setEnd(range.endContainer, range.endOffset); - caretOffset = preCaretRange.toString().length; - } - return caretOffset; - } - - var ToolbarDirection = { - TOP : 1, - RIGHT : 2 - }; - - function selectionContainsButtonsTag(selectedTags, buttonsTags) { - return selectedTags.filter(function(tag) { - return buttonsTags.indexOf(tag) > -1; - }).length; - } - - function updateButtonsForSelection(buttons, selection) { - var selectedTags = tagsInSelection(selection); - var len = buttons.length; - var i, button; - - for (i = 0; i < len; i++) { - button = buttons[i]; - if (selectionContainsButtonsTag(selectedTags, button.command.mappedTags)) { - button.setActive(); - } else { - button.setInactive(); - } - } - } - - function Toolbar(options) { - options = options || {}; - var toolbar = this; - var commands = options.commands; - var commandCount = commands && commands.length, i; - options.classNames = ['ck-toolbar']; - views_view.call(toolbar, options); - - toolbar.setSticky(options.sticky || false); - toolbar.setDirection(options.direction || ToolbarDirection.TOP); - toolbar.editor = options.editor || null; - toolbar.embedIntent = options.embedIntent || null; - toolbar.activePrompt = null; - toolbar.buttons = []; - - toolbar.contentElement = createDiv('ck-toolbar-content'); - toolbar.promptContainerElement = createDiv('ck-toolbar-prompt'); - toolbar.buttonContainerElement = createDiv('ck-toolbar-buttons'); - toolbar.contentElement.appendChild(toolbar.promptContainerElement); - toolbar.contentElement.appendChild(toolbar.buttonContainerElement); - toolbar.element.appendChild(toolbar.contentElement); - - for(i = 0; i < commandCount; i++) { - this.addCommand(commands[i]); - } - - // Closes prompt if displayed when changing selection - document.addEventListener('mouseup', function() { - toolbar.dismissPrompt(); - }); - } - inherit(Toolbar, views_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 toolbar_button({ 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(); - }); - 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; - selection = selection || window.getSelection(); - if (toolbar.sticky) { - updateButtonsForSelection(toolbar.buttons, selection); - } else if (!selection.isCollapsed) { - toolbar.positionToContent(selection.getRangeAt(0)); - updateButtonsForSelection(toolbar.buttons, selection); - } - }; - - Toolbar.prototype.positionToContent = function(content) { - var directions = ToolbarDirection; - var positioningMethod, position, sideEdgeOffset; - switch(this.direction) { - case directions.RIGHT: - positioningMethod = positionElementToRightOf; - break; - default: - positioningMethod = positionElementCenteredAbove; - } - position = positioningMethod(this.element, content); - sideEdgeOffset = Math.min(Math.max(10, position.left), document.body.clientWidth - this.element.offsetWidth - 10); - this.contentElement.style.transform = 'translateX(' + (sideEdgeOffset - position.left) + 'px)'; - }; - - Toolbar.prototype.setDirection = function(direction) { - this.direction = direction; - if (direction === ToolbarDirection.RIGHT) { - this.addClass('right'); - } else { - this.removeClass('right'); - } - }; - - Toolbar.prototype.setSticky = function(sticky) { - this.sticky = sticky; - if (sticky) { - this.addClass('sticky'); - this.element.removeAttribute('style'); // clears any prior positioning - this.show(); - } else { - this.removeClass('sticky'); - this.hide(); - } - }; - - Toolbar.prototype.toggleSticky = function() { - this.setSticky(!this.sticky); - }; - - Toolbar.Direction = ToolbarDirection; - - var views_toolbar = Toolbar; - - var Keycodes = { - BKSP : 8, - ENTER : 13, - ESC : 27, - DEL : 46, - M : 77 - }; - - function selectionIsEditableByToolbar(selection, toolbar) { - return selectionIsEditable(selection) && selectionIsInElement(selection, toolbar.rootElement); - } - - function handleTextSelection(toolbar) { - var selection = window.getSelection(); - if (toolbar.sticky) { - toolbar.updateForSelection(selectionIsEditableByToolbar(selection, toolbar) ? selection : null); - } else { - if (selection.isCollapsed || selection.toString().trim() === '' || !selectionIsEditableByToolbar(selection, toolbar)) { - toolbar.hide(); - } else { - toolbar.show(); - toolbar.updateForSelection(selection); - } - } - } - - function TextFormatToolbar(options) { - var toolbar = this; - views_toolbar.call(this, options); - toolbar.rootElement = options.rootElement; - toolbar.rootElement.addEventListener('keyup', function() { handleTextSelection(toolbar); }); - - document.addEventListener('mouseup', function() { - setTimeout(function() { - handleTextSelection(toolbar); - }); - }); - - document.addEventListener('keyup', function(e) { - var key = e.keyCode; - if (key === 116) { //F5 - toolbar.toggleSticky(); - handleTextSelection(toolbar); - } else if (!toolbar.sticky && key === Keycodes.ESC) { - toolbar.hide(); - } - }); - - window.addEventListener('resize', function() { - if(!toolbar.sticky && toolbar.isShowing) { - var activePromptRange = toolbar.activePrompt && toolbar.activePrompt.range; - toolbar.positionToContent(activePromptRange ? activePromptRange : window.getSelection().getRangeAt(0)); - } - }); - } - inherit(TextFormatToolbar, views_toolbar); - - var text_format_toolbar = TextFormatToolbar; - - function Tooltip(options) { - var tooltip = this; - var rootElement = options.rootElement; - var delay = options.delay || 200; - var timeout; - options.classNames = ['ck-tooltip']; - views_view.call(tooltip, options); - - rootElement.addEventListener('mouseover', function(e) { - var target = getEventTargetMatchingTag(options.showForTag, e.target, rootElement); - if (target && target.isContentEditable) { - 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(); - } - }); - } - inherit(Tooltip, views_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); - }; - - var views_tooltip = Tooltip; - - var LayoutStyle = { - GUTTER : 1, - CENTERED : 2 - }; - - function computeLayoutStyle(rootElement) { - if (rootElement.getBoundingClientRect().left > 100) { - return LayoutStyle.GUTTER; - } - return LayoutStyle.CENTERED; - } - - function EmbedIntent(options) { - var embedIntent = this; - var rootElement = embedIntent.rootElement = options.rootElement; - options.classNames = ['ck-embed-intent']; - views_view.call(embedIntent, options); - - embedIntent.isActive = false; - embedIntent.editorContext = options.editorContext; - embedIntent.loadingIndicator = createDiv('ck-embed-loading'); - embedIntent.button = document.createElement('button'); - embedIntent.button.className = 'ck-embed-intent-btn'; - embedIntent.button.title = 'Insert image or embed...'; - embedIntent.element.appendChild(embedIntent.button); - embedIntent.button.addEventListener('mouseup', function(e) { - if (embedIntent.isActive) { - embedIntent.deactivate(); - } else { - embedIntent.activate(); - } - e.stopPropagation(); - }); - - embedIntent.toolbar = new views_toolbar({ - container: embedIntent.element, - embedIntent: embedIntent, - editor: embedIntent.editorContext, - commands: options.commands, - direction: views_toolbar.Direction.RIGHT - }); - - function embedIntentHandler() { - var blockElement = getSelectionBlockElement(); - if (blockElement && elementContentIsEmpty(blockElement)) { - embedIntent.showAt(blockElement); - } else { - embedIntent.hide(); - } - } - - rootElement.addEventListener('keyup', embedIntentHandler); - document.addEventListener('mouseup', function() { - setTimeout(function() { embedIntentHandler(); }); - }); - - document.addEventListener('keyup', function(e) { - if (e.keyCode === Keycodes.ESC) { - embedIntent.hide(); - } - }); - - window.addEventListener('resize', function() { - if(embedIntent.isShowing) { - embedIntent.reposition(); - } - }); - } - inherit(EmbedIntent, views_view); - - EmbedIntent.prototype.hide = function() { - if (EmbedIntent._super.prototype.hide.call(this)) { - this.deactivate(); - } - }; - - EmbedIntent.prototype.showAt = function(node) { - this.atNode = node; - this.show(); - this.deactivate(); - this.reposition(); - }; - - EmbedIntent.prototype.reposition = function() { - if (computeLayoutStyle(this.rootElement) === LayoutStyle.GUTTER) { - positionElementToLeftOf(this.element, this.atNode); - } else { - positionElementCenteredIn(this.element, this.atNode); - } - }; - - EmbedIntent.prototype.activate = function() { - if (!this.isActive) { - this.addClass('activated'); - this.toolbar.show(); - this.isActive = true; - } - }; - - EmbedIntent.prototype.deactivate = function() { - if (this.isActive) { - this.removeClass('activated'); - this.toolbar.hide(); - this.isActive = false; - } - }; - - EmbedIntent.prototype.showLoading = function() { - var embedIntent = this; - var loadingIndicator = embedIntent.loadingIndicator; - embedIntent.hide(); - embedIntent.atNode.appendChild(loadingIndicator); - }; - - EmbedIntent.prototype.hideLoading = function() { - this.atNode.removeChild(this.loadingIndicator); - }; - - var embed_intent = EmbedIntent; - - function Command(options) { - options = options || {}; - var command = this; - var name = options.name; - var prompt = options.prompt; - command.name = name; - command.button = options.button || name; - if (prompt) { command.prompt = prompt; } - } - - Command.prototype.exec = function() {}; - - var base = Command; - - function TextFormatCommand(options) { - options = options || {}; - base.call(this, options); - this.tag = options.tag; - this.mappedTags = options.mappedTags || []; - this.mappedTags.push(this.tag); - this.action = options.action || this.name; - this.removeAction = options.removeAction || this.action; - } - inherit(TextFormatCommand, base); - - TextFormatCommand.prototype = { - exec: function(value) { - document.execCommand(this.action, false, value || null); - }, - unexec: function(value) { - document.execCommand(this.removeAction, false, value || null); - } - }; - - var text_format = TextFormatCommand; - - var RegExpHeadingTag = /^(h1|h2|h3|h4|h5|h6)$/i; - - function BoldCommand() { - text_format.call(this, { - name: 'bold', - tag: types_type.BOLD.tag, - mappedTags: types_type.BOLD.mappedTags, - button: '' - }); - } - inherit(BoldCommand, text_format); - - BoldCommand.prototype.exec = function() { - // Don't allow executing bold command on heading tags - if (!RegExpHeadingTag.test(getSelectionBlockTagName())) { - BoldCommand._super.prototype.exec.call(this); - } - }; - - var bold = BoldCommand; - - function ItalicCommand() { - text_format.call(this, { - name: 'italic', - tag: types_type.ITALIC.tag, - mappedTags: types_type.ITALIC.mappedTags, - button: '' - }); - } - inherit(ItalicCommand, text_format); - - var italic = ItalicCommand; - - var container = document.body; - var hiliter = createDiv('ck-editor-hilite'); - - function positionHiliteRange(range) { - var rect = range.getBoundingClientRect(); - var style = hiliter.style; - style.width = rect.width + 'px'; - style.height = rect.height + 'px'; - positionElementToRect(hiliter, rect); - } - - function Prompt(options) { - var prompt = this; - options.tagName = 'input'; - views_view.call(prompt, options); - - prompt.command = options.command; - prompt.element.placeholder = options.placeholder || ''; - prompt.element.addEventListener('mouseup', function(e) { e.stopPropagation(); }); // prevents closing prompt when clicking input - prompt.element.addEventListener('keyup', function(e) { - var entry = this.value; - if(entry && prompt.range && !e.shiftKey && e.which === Keycodes.ENTER) { - restoreRange(prompt.range); - prompt.command.exec(entry); - if (prompt.onComplete) { prompt.onComplete(); } - } - }); - - window.addEventListener('resize', function() { - var activeHilite = hiliter.parentNode; - var range = prompt.range; - if(activeHilite && range) { - positionHiliteRange(range); - } - }); - } - inherit(Prompt, views_view); - - Prompt.prototype.show = function(callback) { - var prompt = this; - var element = prompt.element; - var selection = window.getSelection(); - var range = selection && selection.rangeCount && selection.getRangeAt(0); - element.value = null; - prompt.range = range || null; - if (range) { - container.appendChild(hiliter); - positionHiliteRange(prompt.range); - setTimeout(function(){ element.focus(); }); // defer focus (disrupts mouseup events) - if (callback) { prompt.onComplete = callback; } - } - }; - - Prompt.prototype.hide = function() { - if (hiliter.parentNode) { - container.removeChild(hiliter); - } - }; - - var views_prompt = Prompt; - - var RegExpHttp = /^https?:\/\//i; - - function LinkCommand() { - text_format.call(this, { - name: 'link', - tag: types_type.LINK.tag, - action: 'createLink', - removeAction: 'unlink', - button: '', - prompt: new views_prompt({ - command: this, - placeholder: 'Enter a url, press return...' - }) - }); - } - inherit(LinkCommand, text_format); - - LinkCommand.prototype.exec = function(url) { - if (!url) { - return LinkCommand._super.prototype.unexec.call(this); - } - - if(this.tag === getSelectionTagName()) { - this.unexec(); - } else { - if (!RegExpHttp.test(url)) { - url = 'http://' + url; - } - LinkCommand._super.prototype.exec.call(this, url); - } - }; - - var commands_link = LinkCommand; - - function FormatBlockCommand(options) { - options = options || {}; - options.action = 'formatBlock'; - text_format.call(this, options); - } - inherit(FormatBlockCommand, text_format); - - FormatBlockCommand.prototype.exec = function() { - var tag = this.tag; - // Brackets neccessary for certain browsers - var value = '<' + tag + '>'; - var blockElement = getSelectionBlockElement(); - // Allow block commands to be toggled back to a text block - if(tag === blockElement.tagName.toLowerCase()) { - value = types_type.PARAGRAPH.tag; - } else { - // Flattens the selection before applying the block format. - // Otherwise, undesirable nested blocks can occur. - // TODO: would love to be able to remove this - var flatNode = document.createTextNode(blockElement.textContent); - blockElement.parentNode.insertBefore(flatNode, blockElement); - blockElement.parentNode.removeChild(blockElement); - selectNode(flatNode); - } - - FormatBlockCommand._super.prototype.exec.call(this, value); - }; - - var format_block = FormatBlockCommand; - - function QuoteCommand() { - format_block.call(this, { - name: 'quote', - tag: types_type.QUOTE.tag, - button: '' - }); - } - inherit(QuoteCommand, format_block); - - var quote = QuoteCommand; - - function HeadingCommand() { - format_block.call(this, { - name: 'heading', - tag: types_type.HEADING.tag, - button: '1' - }); - } - inherit(HeadingCommand, format_block); - - var heading = HeadingCommand; - - function SubheadingCommand() { - format_block.call(this, { - name: 'subheading', - tag: types_type.SUBHEADING.tag, - button: '2' - }); - } - inherit(SubheadingCommand, format_block); - - var subheading = SubheadingCommand; - - function ListCommand(options) { - text_format.call(this, options); - } - inherit(ListCommand, text_format); - - ListCommand.prototype.exec = function() { - ListCommand._super.prototype.exec.call(this); - - // After creation, lists need to be unwrapped - // TODO: eventually can remove this when direct model manipulation is ready - var listElement = getSelectionBlockElement(); - var wrapperNode = listElement.parentNode; - if (wrapperNode.firstChild === listElement) { - var editorNode = wrapperNode.parentNode; - editorNode.insertBefore(listElement, wrapperNode); - editorNode.removeChild(wrapperNode); - selectNode(listElement); - } - }; - - ListCommand.prototype.checkAutoFormat = function(node) { - // Creates unordered lists when node starts with '- ' - // or ordered list if node starts with '1. ' - var regex = this.autoFormatRegex, text; - if (node && regex) { - text = node.textContent; - if (types_type.LIST_ITEM.tag !== getSelectionTagName() && regex.test(text)) { - this.exec(); - window.getSelection().anchorNode.textContent = text.replace(regex, ''); - return true; - } - } - return false; - }; - - var list = ListCommand; - - function UnorderedListCommand() { - list.call(this, { - name: 'list', - tag: types_type.LIST.tag, - action: 'insertUnorderedList' - }); - } - inherit(UnorderedListCommand, list); - - UnorderedListCommand.prototype.autoFormatRegex = /^[-*]\s/; - - var unordered_list = UnorderedListCommand; - - function OrderedListCommand() { - list.call(this, { - name: 'ordered list', - tag: types_type.ORDERED_LIST.tag, - action: 'insertOrderedList' - }); - } - inherit(OrderedListCommand, list); - - OrderedListCommand.prototype.autoFormatRegex = /^1\.\s/; - - var ordered_list = OrderedListCommand; - - var defaultClassNames = ['ck-message']; - - function Message(options) { - options = options || {}; - options.classNames = defaultClassNames; - views_view.call(this, options); - } - inherit(Message, views_view); - - function show(view, message) { - view.element.innerHTML = message; - Message._super.prototype.show.call(view); - setTimeout(function() { - view.hide(); - }, 3200); - } - - Message.prototype.showInfo = function(message) { - this.setClasses(defaultClassNames); - show(this, message); - }; - - Message.prototype.showError = function(message) { - this.addClass('ck-message-error'); - show(this, message); - }; - - var views_message = Message; - - function createXHR(options) { - var xhr = new XMLHttpRequest(); - xhr.open(options.method, options.url); - xhr.onload = function () { - var response = xhr.responseText; - if (xhr.status === 200) { - return options.success.call(this, response); - } - options.error.call(this, response); - }; - xhr.onerror = function (error) { - options.error.call(this, error); - }; - return xhr; - } - - function xhrGet(options) { - options.method = 'GET'; - var xhr = createXHR(options); - try { - xhr.send(); - } catch(error) {} - } - - function xhrPost(options) { - options.method = 'POST'; - var xhr = createXHR(options); - var formData = new FormData(); - formData.append('file', options.data); - try { - xhr.send(formData); - } catch(error) {} - } - - function responseJSON(jsonString) { - if (!jsonString) { return null; } - try { - return JSON.parse(jsonString); - } catch(e) { - return jsonString; - } - } - - // -------------------------------------------- - - function FileUploader(options) { - options = options || {}; - var url = options.url; - var maxFileSize = options.maxFileSize; - if (url) { - this.url = url; - } else { - throw new Error('FileUploader: setting the `url` to an upload service is required'); - } - if (maxFileSize) { - this.maxFileSize = maxFileSize; - } - } - - FileUploader.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 window.File)) { return; } - - if (maxFileSize && file.size > maxFileSize) { - if (callback) { callback.call(this, null, { message: 'max 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 OEmbedder(options) { - options = options || {}; - var url = options.url; - if (url) { - this.url = url; - } else { - throw new Error('OEmbedder: setting the `url` to an embed service is required'); - } - } - - OEmbedder.prototype.fetch = function(options) { - var callback = options.complete; - xhrGet({ - url: this.url + "?url=" + encodeURI(options.url), - success: function(response) { - if (callback) { callback.call(this, responseJSON(response)); } - }, - error: function(error) { - if (callback) { callback.call(this, null, responseJSON(error)); } - } - }); - }; - - function createFileInput(command) { - var fileInput = document.createElement('input'); - fileInput.type = 'file'; - fileInput.accept = 'image/*'; - fileInput.className = 'ck-file-input'; - fileInput.addEventListener('change', function(e) { - command.handleFile(e); - }); - return fileInput; - } - - function injectImageBlock(src, editor, index) { - var imageModel = models_block.createWithType(types_type.IMAGE, { attributes: { src: src } }); - editor.replaceBlock(imageModel, index); - } - - function renderFromFile(file, editor, index) { - if (file && window.FileReader) { - var reader = new FileReader(); - reader.onload = function(e) { - var base64Src = e.target.result; - injectImageBlock(base64Src, editor, index); - editor.renderBlockAt(index, true); - }; - reader.readAsDataURL(file); - } - } - - function ImageCommand(options) { - base.call(this, { - name: 'image', - button: '' - }); - this.uploader = new FileUploader({ url: options.serviceUrl, maxFileSize: 5000000 }); - } - inherit(ImageCommand, base); - - ImageCommand.prototype = { - exec: function() { - ImageCommand._super.prototype.exec.call(this); - var fileInput = this.fileInput; - if (!fileInput) { - fileInput = this.fileInput = createFileInput(this); - document.body.appendChild(fileInput); - } - fileInput.dispatchEvent(new MouseEvent('click', { bubbles: false })); - }, - handleFile: function(e) { - var fileInput = e.target; - var file = fileInput.files && fileInput.files[0]; - var editor = this.editorContext; - var embedIntent = this.embedIntent; - var currentEditingIndex = editor.getCurrentBlockIndex(); - - embedIntent.showLoading(); - renderFromFile(file, editor, currentEditingIndex); // render image immediately client-side - this.uploader.upload({ - fileInput: fileInput, - complete: function(response, error) { - embedIntent.hideLoading(); - if (error || !response || !response.url) { - setTimeout(function() { - editor.removeBlockAt(currentEditingIndex); - editor.syncVisual(); - }, 1000); - return new views_message().showError(error.message || 'Error uploading image'); - } - injectImageBlock(response.url, editor, currentEditingIndex); - } - }); - fileInput.value = null; // reset file input - } - }; - - var image = ImageCommand; - - function loadTwitterWidgets(element) { - if (window.twttr) { - window.twttr.widgets.load(element); - } else { - var script = document.createElement('script'); - script.async = true; - script.src = 'http://platform.twitter.com/widgets.js'; - document.head.appendChild(script); - } - } - - function OEmbedCommand(options) { - base.call(this, { - name: 'embed', - button: '', - prompt: new views_prompt({ - command: this, - placeholder: 'Paste a YouTube or Twitter url...' - }) - }); - - this.embedService = new OEmbedder({ url: options.serviceUrl }); - } - inherit(OEmbedCommand, base); - - OEmbedCommand.prototype.exec = function(url) { - var command = this; - var editorContext = command.editorContext; - var embedIntent = command.embedIntent; - var index = editorContext.getCurrentBlockIndex(); - - embedIntent.showLoading(); - this.embedService.fetch({ - url: url, - complete: function(response, error) { - embedIntent.hideLoading(); - if (error) { - var errorMsg = error; - if (error.target && error.target.status === 0) { - errorMsg = 'Error: could not connect to embed service.'; - } else if (typeof error !== 'string') { - errorMsg = 'Error: unexpected embed error.'; - } - new views_message().showError(errorMsg); - embedIntent.show(); - } else if (response.error_message) { - new views_message().showError(response.error_message); - embedIntent.show(); - } else { - var embedModel = new embed(response); - editorContext.insertBlock(embedModel, index); - editorContext.renderBlockAt(index); - if (embedModel.attributes.provider_name.toLowerCase() === 'twitter') { - loadTwitterWidgets(editorContext.element); - } - } - } - }); - }; - - var oembed = OEmbedCommand; - - // Based on https://github.com/jeromeetienne/microevent.js/blob/master/microevent.js - // See also: https://github.com/allouis/minivents/blob/master/minivents.js - - var EventEmitter = { - on : function(type, handler){ - var events = this.__events = this.__events || {}; - events[type] = events[type] || []; - events[type].push(handler); - }, - off : function(type, handler){ - var events = this.__events = this.__events || {}; - if (type in events) { - events[type].splice(events[type].indexOf(handler), 1); - } - }, - trigger : function(type) { - var events = this.__events = this.__events || {}; - var eventForTypeCount, i; - if (type in events) { - eventForTypeCount = events[type].length; - for(i = 0; i < eventForTypeCount; i++) { - events[type][i].apply(this, Array.prototype.slice.call(arguments, 1)); - } - } - } - }; - - var event_emitter = EventEmitter; - - var defaults = { - placeholder: 'Write here...', - spellcheck: true, - autofocus: true, - model: null, - serverHost: '', - stickyToolbar: !!('ontouchstart' in window), - textFormatCommands: [ - new bold(), - new italic(), - new commands_link(), - new quote(), - new heading(), - new subheading() - ], - embedCommands: [ - new image({ serviceUrl: '/upload' }), - new oembed({ serviceUrl: '/embed' }) - ], - autoTypingCommands: [ - new unordered_list(), - new ordered_list() - ], - compiler: new compiler({ - includeTypeNames: true, // outputs models with type names, i.e. 'BOLD', for easier debugging - renderer: new editor_html_renderer() // subclassed HTML renderer that adds dom structure for additional editor interactivity - }) - }; - - function bindContentEditableTypingListeners(editor) { - - - editor.element.addEventListener('keyup', function(e) { - // Assure there is always a supported block tag, and not empty text nodes or divs. - // On a carrage return, make sure to always generate a 'p' tag - if (!getSelectionBlockElement() || - !editor.element.textContent || - (!e.shiftKey && e.which === Keycodes.ENTER) || (e.ctrlKey && e.which === Keycodes.M)) { - document.execCommand('formatBlock', false, types_type.PARAGRAPH.tag); - } //else if (e.which === Keycodes.BKSP) { - // TODO: Need to rerender when backspacing 2 blocks together - //var cursorIndex = editor.getCursorIndexInCurrentBlock(); - //var currentBlockElement = getSelectionBlockElement(); - //editor.renderBlockAt(editor.getCurrentBlockIndex(), true); - //setCursorIndexInElement(currentBlockElement, cursorIndex); - //} - }); - - // On 'PASTE' sanitize and insert - editor.element.addEventListener('paste', function(e) { - var data = e.clipboardData; - var pastedHTML = data && data.getData && data.getData('text/html'); - var sanitizedHTML = pastedHTML && editor.compiler.rerender(pastedHTML); - if (sanitizedHTML) { - document.execCommand('insertHTML', false, sanitizedHTML); - editor.syncVisual(); - } - e.preventDefault(); - return false; - }); - } - - function bindLiveUpdate(editor) { - editor.element.addEventListener('input', function() { - editor.syncContentEditableBlocks(); - }); - } - - function bindAutoTypingListeners(editor) { - // Watch typing patterns for auto format commands (e.g. lists '- ', '1. ') - editor.element.addEventListener('keyup', function(e) { - var commands = editor.autoTypingCommands; - var count = commands && commands.length; - var selection, i; - - if (count) { - selection = window.getSelection(); - for (i = 0; i < count; i++) { - if (commands[i].checkAutoFormat(selection.anchorNode)) { - e.stopPropagation(); - return; - } - } - } - }); - } - - function bindDragAndDrop() { - // TODO. For now, just prevent redirect when dropping something on the page - window.addEventListener('dragover', function(e) { - e.preventDefault(); // prevents showing cursor where to drop - }); - window.addEventListener('drop', function(e) { - e.preventDefault(); // prevent page from redirecting - }); - } - - function initEmbedCommands(editor) { - var commands = editor.embedCommands; - if(commands) { - return new embed_intent({ - editorContext: editor, - commands: commands, - rootElement: editor.element - }); - } - } - - function applyClassName(editorElement) { - var editorClassName = 'ck-editor'; - var editorClassNameRegExp = new RegExp(editorClassName); - var existingClassName = editorElement.className; - - if (!editorClassNameRegExp.test(existingClassName)) { - existingClassName += (existingClassName ? ' ' : '') + editorClassName; - } - editorElement.className = existingClassName; - } - - function applyPlaceholder(editorElement, placeholder) { - var dataset = editorElement.dataset; - if (placeholder && !dataset.placeholder) { - dataset.placeholder = placeholder; - } - } - - function getNonTextBlocks(blockTypeSet, model) { - var blocks = []; - var len = model.length; - var i, block, type; - for (i = 0; i < len; i++) { - block = model[i]; - type = blockTypeSet.findById(block && block.type); - if (type && !type.isTextType) { - blocks.push(block); - } - } - return blocks; - } - - /** - * @class Editor - * An individual Editor - * @param element `Element` node - * @param options hash of options - */ - function Editor(element, options) { - var editor = this; - mergeWithOptions(editor, defaults, options); - - // Update embed commands by prepending the serverHost - editor.embedCommands = [ - new image({ serviceUrl: editor.serverHost + '/upload' }), - new oembed({ serviceUrl: editor.serverHost + '/embed' }) - ]; - - if (element) { - applyClassName(element); - applyPlaceholder(element, editor.placeholder); - element.spellcheck = editor.spellcheck; - element.setAttribute('contentEditable', true); - editor.element = element; - - if (editor.model) { - editor.loadModel(editor.model); - } else { - editor.sync(); - } - - bindContentEditableTypingListeners(editor); - bindAutoTypingListeners(editor); - bindDragAndDrop(editor); - bindLiveUpdate(editor); - initEmbedCommands(editor); - - editor.textFormatToolbar = new text_format_toolbar({ rootElement: element, commands: editor.textFormatCommands, sticky: editor.stickyToolbar }); - editor.linkTooltips = new views_tooltip({ rootElement: element, showForTag: types_type.LINK.tag }); - - if(editor.autofocus) { element.focus(); } - } - } - - // Add event emitter pub/sub functionality - merge(Editor.prototype, event_emitter); - - Editor.prototype.loadModel = function(model) { - this.model = model; - this.syncVisual(); - this.trigger('update'); - }; - - Editor.prototype.syncModel = function() { - this.model = this.compiler.parse(this.element.innerHTML); - this.trigger('update'); - }; - - Editor.prototype.syncVisual = function() { - this.element.innerHTML = this.compiler.render(this.model); - }; - - Editor.prototype.sync = function() { - this.syncModel(); - this.syncVisual(); - }; - - Editor.prototype.getCurrentBlockIndex = function(element) { - var selectionEl = element || getSelectionBlockElement(); - var blockElements = toArray(this.element.children); - return blockElements.indexOf(selectionEl); - }; - - Editor.prototype.getCursorIndexInCurrentBlock = function() { - var currentBlock = getSelectionBlockElement(); - if (currentBlock) { - return getCursorOffsetInElement(currentBlock); - } - return -1; - }; - - Editor.prototype.insertBlock = function(block, index) { - this.model.splice(index, 0, block); - this.trigger('update'); - }; - - Editor.prototype.removeBlockAt = function(index) { - this.model.splice(index, 1); - this.trigger('update'); - }; - - Editor.prototype.replaceBlock = function(block, index) { - this.model[index] = block; - this.trigger('update'); - }; - - Editor.prototype.renderBlockAt = function(index, replace) { - var modelAtIndex = this.model[index]; - var html = this.compiler.render([modelAtIndex]); - var dom = document.createElement('div'); - dom.innerHTML = html; - var newEl = dom.firstChild; - var sibling = this.element.children[index]; - if (replace) { - this.element.replaceChild(newEl, sibling); - } else { - this.element.insertBefore(newEl, sibling); - } - }; - - Editor.prototype.syncContentEditableBlocks = function() { - var nonTextBlocks = getNonTextBlocks(this.compiler.blockTypes, this.model); - var blockElements = toArray(this.element.children); - var len = blockElements.length; - var updatedModel = []; - var i, blockEl; - for (i = 0; i < len; i++) { - blockEl = blockElements[i]; - if(blockEl.isContentEditable) { - updatedModel.push(this.compiler.parser.serializeBlockNode(blockEl)); - } else { - updatedModel.push(nonTextBlocks.shift()); - } - } - this.model = updatedModel; - this.trigger('update'); - }; - - - var editor_editor = Editor; - - function EditorFactory(element, options) { - var editors = []; - var elements, elementsLen, i; - - if (!element) { - return new editor_editor(element, options); - } - - if (typeof element === 'string') { - elements = document.querySelectorAll(element); - } else if (element && element.length) { - elements = element; - } else if (element) { - elements = [element]; - } - - if (elements) { - elementsLen = elements.length; - for (i = 0; i < elementsLen; i++) { - editors.push(new editor_editor(elements[i], options)); - } - } - - return editors.length > 1 ? editors : editors[0]; - } - - EditorFactory.prototype = editor_editor.prototype; - - var editor_factory = EditorFactory; - - var ContentKit = {}; - ContentKit.Type = types_type; - ContentKit.BlockModel = models_block; - ContentKit.EmbedModel = embed; - ContentKit.Compiler = compiler; - ContentKit.HTMLParser = html_parser; - ContentKit.HTMLRenderer = html_renderer; - ContentKit.Editor = editor_factory; - - window.ContentKit = ContentKit; -}(this, document)); diff --git a/gulpfile.js b/gulpfile.js deleted file mode 100644 index 6911ece67..000000000 --- a/gulpfile.js +++ /dev/null @@ -1,140 +0,0 @@ -var del = require('del'); -var gulp = require('gulp'); -var jshint = require('gulp-jshint'); -var qunit = require('gulp-qunit'); -var less = require('gulp-less'); -var concat = require('gulp-concat'); -var header = require('gulp-header'); -var open = require('gulp-open'); -var uglify = require('gulp-uglify'); -var cssmin = require('gulp-cssmin'); -var file = require('gulp-file'); -var transpile = require('esperanto'); - -// ------------------------------------------- - -var pkg = require('./package.json'); - -var jsSrc = [ - './src/js/**/*.js' -]; - -var jsEntry = './src/js/index.js'; - -var cssSrc = [ - './src/css/variables.less', - './src/css/editor.less', - './src/css/toolbar.less', - './src/css/tooltip.less', - './src/css/embeds.less', - './src/css/message.less', - './src/css/icons.less', - './src/css/animations.less' -]; - -var distDest = './dist/'; -var jsDistName = 'content-kit-editor.js'; -var cssDistName = 'content-kit-editor.css'; -var cssDistPath = distDest + cssDistName; - -var testRunner = './tests/index.html'; -var testScripts = './tests/**/*.js'; -var demo = './demo/index.html'; - -var banner = ['/**', - ' * @overview <%= pkg.name %>: <%= pkg.description %>', - ' * @version <%= pkg.version %>', - ' * @author <%= pkg.author %>', - ' * @license <%= pkg.license %>', - ' */', - ''].join('\n'); - -var iifeHeader = '\n(function(window, document, undefined) {\n\n'; -var iifeFooter = '\n}(this, document));\n'; - -// JSHint javascript code linting -gulp.task('lint', function() { - return gulp.src(jsSrc) - .pipe(jshint('.jshintrc')) - .pipe(jshint.reporter('default')); -}); - -gulp.task('build-js', function() { - return transpile.bundle({ - entry: jsEntry, - resolvePath: function (importee, importer) { - return 'node_modules/' + importee + '.js'; - } - }).then(function(bundle) { - var transpiled = bundle.concat({ - intro: iifeHeader, - outro: iifeFooter - }); - return file(jsDistName, transpiled.code, { src: true }) - .pipe(header(banner, { pkg : pkg } )) - .pipe(gulp.dest(distDest)); - }); -}); - -// Compiles LESS and concatenates css -gulp.task('build-css', function() { - return gulp.src(cssSrc) - .pipe(concat(cssDistName)) - .pipe(less()) - .pipe(gulp.dest(distDest)); -}); - -// Builds the entire suite of js/css -gulp.task('build', ['build-css', 'build-js']); - -// Runs QUnit tests -gulp.task('test', ['build'], function() { - return gulp.src(testRunner).pipe(qunit()); -}); - -// Opens the test runner in your default browser -gulp.task('test-browser', ['build'], function(){ - return gulp.src(testRunner).pipe(open('<% file.path %>')); -}); - -// Opens the demo in your default browser -gulp.task('demo', function(){ - return gulp.src(demo).pipe(open('<% file.path %>')); -}); - -// Removes built output files -gulp.task('clean', function() { - return del([distDest + '*']); -}); - -// Watches when js files change and automatically lints/builds -gulp.task('watch-js', function() { - gulp.watch(jsSrc, ['lint', 'build-js']); -}); - -// Watches test files change and automatically tests -gulp.task('watch-tests', function() { - gulp.watch(testScripts, ['test']); -}); - -// Watches when css files change and automatically builds -gulp.task('watch-css', function() { - gulp.watch(cssSrc, ['build-css']); -}); - -// Watches when any files change and automatically tests/builds -gulp.task('watch', ['watch-js', 'watch-tests', 'watch-css']); - -// Default task -gulp.task('default', ['lint', 'build', 'test']); - -// Deploy task -gulp.task('deploy', ['clean', 'build'], function() { - gulp.src(jsDistPath) - .pipe(uglify()) - .pipe(gulp.dest(distDest)); - - gulp.src(cssDistPath) - .pipe(cssmin()) - .pipe(gulp.dest(distDest)); -}); diff --git a/package.json b/package.json index 876066e13..c0ab01446 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,16 @@ "version": "0.1.3", "description": "A modern, minimalist WYSIWYG editor.", "repository": "https://github.com/bustlelabs/content-kit-editor", - "main": "dist/content-kit-editor.js", + "main": "dist/commonjs/content-kit-editor/index.js", "engines": { "node": "0.10.x" }, "scripts": { - "start": "node server/index.js", - "test": "gulp test" + "start": "broccoli serve", + "test": "testem ci", + "build": "rm -rf dist && broccoli build dist", + "build-website": "./bin/build-website.sh", + "deploy-website": "./bin/deploy-website.sh" }, "keywords": [ "html", @@ -18,6 +21,13 @@ "editor", "contenteditable" ], + "files": [ + "src", + "dist/amd", + "dist/commonjs", + "dist/global", + "dist/css" + ], "author": "Garth Poitras
, etc
+ if (VALID_MARKUP_SECTION_TAGNAMES.indexOf(tagName.toLowerCase()) !== -1) {
+ section = postBuilder.generateMarkupSection(tagName, readAttributes(sectionElement));
+ var node = sectionElement.firstChild;
+ while (node) {
+ parseMarkers(section, postBuilder, node);
+ node = node.nextSibling;
+ }
+ // , etc
+ } else {
+ if (previousSection && previousSection.isGenerated) {
+ section = previousSection;
+ } else {
+ section = postBuilder.generateMarkupSection('P', {}, true);
+ }
+ parseMarkers(section, postBuilder, sectionElement);
+ }
+ break;
+ case TEXT_NODE:
+ if (previousSection && previousSection.isGenerated) {
+ section = previousSection;
+ } else {
+ section = postBuilder.generateMarkupSection('P', {}, true);
+ }
+ parseMarkers(section, postBuilder, sectionElement);
+ break;
+ }
+ return section;
+ },
+ parse: function(postElement) {
+ var post = this.postBuilder.generatePost();
+ var i, l, section, previousSection, sectionElement;
+ // FIXME: Instead of storing isGenerated on sections, and passing
+ // the previous section to the parser, we could instead do a two-pass
+ // parse. The first pass identifies sections and gathers a list of
+ // dom nodes that can be parsed for markers, the second pass parses
+ // for markers.
+ for (i=0, l=postElement.childNodes.length;i this.parseMarkerType(markerType));
+ }
+
+ parseMarkerType([tagName, attributes]) {
+ return this.builder.generateMarkup(tagName, attributes);
+ }
+
+ parseSections(sections, post) {
+ sections.forEach((section) => this.parseSection(section, post));
+ }
+
+ parseSection(section, post) {
+ let [type] = section;
+ switch(type) {
+ case 1: // markup section
+ this.parseMarkupSection(section, post);
+ break;
+ case IMAGE_SECTION_TYPE:
+ this.parseImageSection(section, post);
+ break;
+ case CARD_SECTION_TYPE:
+ this.parseCardSection(section, post);
+ break;
+ default:
+ throw new Error(`Unexpected section type ${type}`);
+ }
+ }
+
+ parseCardSection([type, name, payload], post) {
+ const section = this.builder.generateCardSection(name, payload);
+ post.appendSection(section);
+ }
+
+ parseImageSection([type, src], post) {
+ const section = this.builder.generateImageSection(src);
+ post.appendSection(section);
+ }
+
+ parseMarkupSection([type, tagName, markers], post) {
+ const attributes = null;
+ const isGenerated = false;
+ const section = this.builder.generateMarkupSection(tagName, attributes, isGenerated);
+
+ post.appendSection(section);
+ this.parseMarkers(markers, section);
+ }
+
+ parseMarkers(markers, section) {
+ markers.forEach((marker) => this.parseMarker(marker, section));
+ }
+
+ parseMarker([markerTypeIndexes, closeCount, value], section) {
+ markerTypeIndexes.forEach(index => {
+ this.markups.push(this.markerTypes[index]);
+ });
+ const marker = this.builder.generateMarker(this.markups.slice(), value);
+ section.appendMarker(marker);
+ this.markups = this.markups.slice(0, this.markups.length-closeCount);
+ }
+}
diff --git a/src/js/parsers/post.js b/src/js/parsers/post.js
new file mode 100644
index 000000000..fd124903a
--- /dev/null
+++ b/src/js/parsers/post.js
@@ -0,0 +1,19 @@
+import Post from 'content-kit-editor/models/post';
+import SectionParser from 'content-kit-editor/parsers/section';
+import { forEach } from 'content-kit-editor/utils/array-utils';
+
+export default {
+ parse(element) {
+ const post = new Post();
+
+ forEach(element.childNodes, child => {
+ post.appendSection(SectionParser.parse(child));
+ });
+
+ return post;
+ },
+
+ parseSection(element) {
+ return SectionParser.parse(element);
+ }
+};
diff --git a/src/js/parsers/section.js b/src/js/parsers/section.js
new file mode 100644
index 000000000..adfc9d0b3
--- /dev/null
+++ b/src/js/parsers/section.js
@@ -0,0 +1,111 @@
+const TEXT_NODE = 3;
+const ELEMENT_NODE = 1;
+
+import MarkupSection from 'content-kit-editor/models/markup-section';
+import {
+ DEFAULT_TAG_NAME,
+ VALID_MARKUP_SECTION_TAGNAMES
+} from 'content-kit-editor/models/markup-section';
+
+import Marker from 'content-kit-editor/models/marker';
+import Markup from 'content-kit-editor/models/markup';
+import { VALID_MARKUP_TAGNAMES } from 'content-kit-editor/models/markup';
+import { getAttributes } from 'content-kit-editor/utils/dom-utils';
+import { forEach } from 'content-kit-editor/utils/array-utils';
+
+/**
+ * parses an element into a section, ignoring any non-markup
+ * elements contained within
+ * @return {Section}
+ */
+export default {
+ parse(element) {
+ if (!this.isSectionElement(element)) {
+ element = this.wrapInSectionElement(element);
+ }
+
+ const tagName = this.sectionTagNameFromElement(element);
+ const section = new MarkupSection(tagName);
+ const state = {section, markups:[], text:''};
+
+ forEach(element.childNodes, (el) => {
+ this.parseNode(el, state);
+ });
+
+ // close a trailing text nodes if it exists
+ if (state.text.length) {
+ let marker = new Marker(state.text, state.markups);
+ state.section.appendMarker(marker);
+ }
+
+ return section;
+ },
+
+ wrapInSectionElement(element) {
+ const parent = document.createElement(DEFAULT_TAG_NAME);
+ parent.appendChild(element);
+ return parent;
+ },
+
+ parseNode(node, state) {
+ switch (node.nodeType) {
+ case TEXT_NODE:
+ this.parseTextNode(node, state);
+ break;
+ case ELEMENT_NODE:
+ this.parseElementNode(node, state);
+ break;
+ default:
+ throw new Error(`parseNode got unexpected element type ${node.nodeType} ` + node);
+ }
+ },
+
+ parseElementNode(element, state) {
+ const markup = this.markupFromElement(element);
+ if (markup) {
+ if (state.text.length) {
+ // close previous text marker
+ let marker = new Marker(state.text, state.markups);
+ state.section.appendMarker(marker);
+ state.text = '';
+ }
+
+ state.markups.push(markup);
+ }
+
+ forEach(element.childNodes, (node) => {
+ this.parseNode(node, state);
+ });
+
+ if (markup) {
+ // close the marker started for this node and pop
+ // its markup from the stack
+ let marker = new Marker(state.text, state.markups);
+ state.section.appendMarker(marker);
+ state.markups.pop();
+ state.text = '';
+ }
+ },
+
+ parseTextNode(textNode, state) {
+ state.text += textNode.textContent;
+ },
+
+ isSectionElement(element) {
+ return element.nodeType === ELEMENT_NODE &&
+ VALID_MARKUP_SECTION_TAGNAMES.indexOf(element.tagName.toLowerCase()) !== -1;
+ },
+
+ markupFromElement(element) {
+ const tagName = element.tagName.toLowerCase();
+ if (VALID_MARKUP_TAGNAMES.indexOf(tagName) === -1) { return null; }
+
+ return new Markup(tagName, getAttributes(element));
+ },
+
+ sectionTagNameFromElement(element) {
+ let tagName = element.tagName.toLowerCase();
+ if (VALID_MARKUP_SECTION_TAGNAMES.indexOf(tagName) === -1) { tagName = DEFAULT_TAG_NAME; }
+ return tagName;
+ }
+};
diff --git a/src/js/renderers/editor-dom.js b/src/js/renderers/editor-dom.js
new file mode 100644
index 000000000..2fc884940
--- /dev/null
+++ b/src/js/renderers/editor-dom.js
@@ -0,0 +1,205 @@
+import RenderNode from "content-kit-editor/models/render-node";
+import CardNode from "content-kit-editor/models/card-node";
+import { detect } from 'content-kit-editor/utils/array-utils';
+import { POST_TYPE } from "../models/post";
+import { MARKUP_SECTION_TYPE } from "../models/markup-section";
+import { IMAGE_SECTION_TYPE } from "../models/image";
+
+function createElementFromMarkerType(doc, markerType) {
+ var element = doc.createElement(markerType.tagName);
+ if (markerType.attributes) {
+ for (var i=0, l=markerType.attributes.length;i card.name === section.name);
+
+ const env = { name: section.name };
+ const element = document.createElement('div');
+ element.contentEditable = 'false';
+ renderNode.element = element;
+ renderNode.parentNode.element.appendChild(renderNode.element);
+
+ if (card) {
+ let cardNode = new CardNode(card, section, renderNode.element, this.options);
+ renderNode.cardNode = cardNode;
+ cardNode.display();
+ } else {
+ this.unknownCardHandler(renderNode.element, this.options, env, section.payload);
+ }
+ }
+}
+
+let destroyHooks = {
+ [POST_TYPE](/*renderNode, post*/) {
+ throw new Error('post destruction is not supported by the renderer');
+ },
+ [MARKUP_SECTION_TYPE](renderNode, section) {
+ let post = renderNode.parentNode.postNode;
+ post.removeSection(section);
+ // Some formatting commands remove the element from the DOM during
+ // formatting. Do not error if this is the case.
+ if (renderNode.element.parentNode) {
+ renderNode.element.parentNode.removeChild(renderNode.element);
+ }
+ },
+ [IMAGE_SECTION_TYPE](renderNode, section) {
+ let post = renderNode.parentNode.postNode;
+ post.removeSection(section);
+ renderNode.element.parentNode.removeChild(renderNode.element);
+ },
+ card(renderNode, section) {
+ if (renderNode.cardNode) {
+ renderNode.cardNode.teardown();
+ }
+ let post = renderNode.parentNode.postNode;
+ post.removeSection(section);
+ renderNode.element.parentNode.removeChild(renderNode.element);
+ }
+};
+
+function removeChildren(parentNode) {
+ let child = parentNode.firstChild;
+ while (child) {
+ let nextChild = child.nextSibling;
+ if (child.isRemoved) {
+ destroyHooks[child.postNode.type](child, child.postNode);
+ parentNode.removeChild(child);
+ }
+ child = nextChild;
+ }
+}
+
+function lookupNode(renderTree, parentNode, section, previousNode) {
+ if (section.renderNode) {
+ return section.renderNode;
+ } else {
+ let renderNode = new RenderNode(section);
+ renderNode.renderTree = renderTree;
+ parentNode.insertAfter(renderNode, previousNode);
+ section.renderNode = renderNode;
+ return renderNode;
+ }
+}
+
+function renderInternal(renderTree, visitor) {
+ let nodes = [renderTree.node];
+ function visit(parentNode, sections) {
+ let previousNode;
+ sections.forEach(section => {
+ let node = lookupNode(renderTree, parentNode, section, previousNode);
+ if (node.isDirty) {
+ nodes.push(node);
+ }
+ previousNode = node;
+ });
+ }
+ let node = nodes.shift();
+ while (node) {
+ removeChildren(node);
+ visitor[node.postNode.type](node, node.postNode, visit);
+ node.markClean();
+ node = nodes.shift();
+ }
+}
+
+export default class Renderer {
+ constructor(cards, unknownCardHandler, options) {
+ this.visitor = new Visitor(cards, unknownCardHandler, options);
+ }
+
+ render(renderTree) {
+ renderInternal(renderTree, this.visitor);
+ }
+}
diff --git a/src/js/renderers/editor-html-renderer.js b/src/js/renderers/editor-html-renderer.js
deleted file mode 100644
index 25bd8f4db..000000000
--- a/src/js/renderers/editor-html-renderer.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import HTMLRenderer from 'node_modules/content-kit-compiler/src/renderers/html-renderer';
-import Type from 'node_modules/content-kit-compiler/src/types/type';
-import { inherit } from 'node_modules/content-kit-utils/src/object-utils';
-import YouTubeRenderer from './youtube';
-import TwitterRenderer from './twitter';
-import InstagramRenderer from './instagram';
-import LinkImageRenderer from './link-image-renderer';
-
-/**
- * A dictionary of supported embeds types that we'll custom render
- * for the editor, instead of the default oembed html.
- */
-var embedRenderers = {
- YOUTUBE : new YouTubeRenderer(),
- TWITTER : new TwitterRenderer(),
- INSTAGRAM : new InstagramRenderer(),
- LINK_IMAGE : new LinkImageRenderer()
-};
-
-function embedRenderer(model) {
- var embedAttrs = model.attributes;
- var embedType = embedAttrs.embed_type;
- var isVideo = embedType === 'video';
- var providerName = embedAttrs.provider_name;
- var customRendererId = providerName && providerName.toUpperCase();
- var customRenderer = embedRenderers[customRendererId];
- if (!customRenderer && embedType === 'link' && embedAttrs.thumbnail) {
- customRenderer = embedRenderers.LINK_IMAGE;
- }
- var renderer = customRenderer ? customRenderer : this;
-
- return '
';
-};
-
-export default InstagramRenderer;
diff --git a/src/js/renderers/link-image-renderer.js b/src/js/renderers/link-image-renderer.js
deleted file mode 100644
index 8cf5e8619..000000000
--- a/src/js/renderers/link-image-renderer.js
+++ /dev/null
@@ -1,7 +0,0 @@
-
-function LinkImageRenderer() {}
-LinkImageRenderer.prototype.render = function(model) {
- return '
';
-};
-
-export default LinkImageRenderer;
diff --git a/src/js/renderers/mobiledoc.js b/src/js/renderers/mobiledoc.js
new file mode 100644
index 000000000..259e43d38
--- /dev/null
+++ b/src/js/renderers/mobiledoc.js
@@ -0,0 +1,83 @@
+import {visit, visitArray, compile} from "../utils/compiler";
+import { POST_TYPE } from "../models/post";
+import { MARKUP_SECTION_TYPE } from "../models/markup-section";
+import { IMAGE_SECTION_TYPE } from "../models/image";
+import { MARKER_TYPE } from "../models/marker";
+import { MARKUP_TYPE } from "../models/markup";
+
+let visitor = {
+ [POST_TYPE](node, opcodes) {
+ opcodes.push(['openPost']);
+ visitArray(visitor, node.sections, opcodes);
+ },
+ [MARKUP_SECTION_TYPE](node, opcodes) {
+ opcodes.push(['openMarkupSection', node.tagName]);
+ visitArray(visitor, node.markers, opcodes);
+ },
+ [IMAGE_SECTION_TYPE](node, opcodes) {
+ opcodes.push(['openImageSection', node.src]);
+ },
+ card(node, opcodes) {
+ opcodes.push(['openCardSection', node.name, node.payload]);
+ },
+ [MARKER_TYPE](node, opcodes) {
+ opcodes.push(['openMarker', node.closedMarkups.length, node.value]);
+ visitArray(visitor, node.openedMarkups, opcodes);
+ },
+ [MARKUP_TYPE](node, opcodes) {
+ opcodes.push(['openMarkup', node.tagName, node.attributes]);
+ }
+};
+
+let postOpcodeCompiler = {
+ openMarker(closeCount, value) {
+ this.markupMarkerIds = [];
+ this.markers.push([
+ this.markupMarkerIds,
+ closeCount,
+ value || ''
+ ]);
+ },
+ openMarkupSection(tagName) {
+ this.markers = [];
+ this.sections.push([1, tagName, this.markers]);
+ },
+ openImageSection(url) {
+ this.sections.push([2, url]);
+ },
+ openCardSection(name, payload) {
+ this.sections.push([10, name, payload]);
+ },
+ openPost() {
+ this.markerTypes = [];
+ this.sections = [];
+ this.result = [this.markerTypes, this.sections];
+ },
+ openMarkup(tagName, attributes) {
+ if (!this._seenMarkerTypes) {
+ this._seenMarkerTypes = {};
+ }
+ let index;
+ if (attributes.length) {
+ this.markerTypes.push([tagName, attributes]);
+ index = this.markerTypes.length - 1;
+ } else {
+ index = this._seenMarkerTypes[tagName];
+ if (index === undefined) {
+ this.markerTypes.push([tagName]);
+ this._seenMarkerTypes[tagName] = index = this.markerTypes.length-1;
+ }
+ }
+ this.markupMarkerIds.push(index);
+ }
+};
+
+export default {
+ render(post) {
+ let opcodes = [];
+ visit(visitor, post, opcodes);
+ let compiler = Object.create(postOpcodeCompiler);
+ compile(compiler, opcodes);
+ return compiler.result;
+ }
+};
diff --git a/src/js/renderers/twitter.js b/src/js/renderers/twitter.js
deleted file mode 100644
index 167e09a63..000000000
--- a/src/js/renderers/twitter.js
+++ /dev/null
@@ -1,7 +0,0 @@
-
-function TwitterRenderer() {}
-TwitterRenderer.prototype.render = function(model) {
- return '
';
-};
-
-export default TwitterRenderer;
diff --git a/src/js/renderers/youtube.js b/src/js/renderers/youtube.js
deleted file mode 100644
index 3d9b4bf61..000000000
--- a/src/js/renderers/youtube.js
+++ /dev/null
@@ -1,19 +0,0 @@
-
-var RegExVideoId = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#\&\?]*).*/;
-
-function getVideoIdFromUrl(url) {
- var match = url && 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 '';
-};
-
-export default YouTubeRenderer;
\ No newline at end of file
diff --git a/src/js/utils/array-utils.js b/src/js/utils/array-utils.js
new file mode 100644
index 000000000..cc23389e8
--- /dev/null
+++ b/src/js/utils/array-utils.js
@@ -0,0 +1,22 @@
+function detect(array, callback) {
+ for (let i=0; i parentNode === childNode;
+ const isContainedBy = () => {
+ const position = parentNode.compareDocumentPosition(childNode);
+ return !!(position & Node.DOCUMENT_POSITION_CONTAINED_BY);
+ };
+ return isSame() || isContainedBy();
+}
+
+function forEachChildNode(element, callback) {
+ for (let i=0; i {
+ context.removeEventListener(...args);
+ });
+ }
+}
diff --git a/src/js/utils/http-utils.js b/src/js/utils/http-utils.js
index 2df74388d..7347995b9 100644
--- a/src/js/utils/http-utils.js
+++ b/src/js/utils/http-utils.js
@@ -1,4 +1,3 @@
-
function createXHR(options) {
var xhr = new XMLHttpRequest();
xhr.open(options.method, options.url);
@@ -36,7 +35,7 @@ function xhrPost(options) {
function responseJSON(jsonString) {
if (!jsonString) { return null; }
try {
- return JSON.parse(jsonString);
+ return window.JSON.parse(jsonString);
} catch(e) {
return jsonString;
}
diff --git a/src/js/utils/keycodes.js b/src/js/utils/keycodes.js
index 299b92112..093376771 100644
--- a/src/js/utils/keycodes.js
+++ b/src/js/utils/keycodes.js
@@ -1,4 +1,5 @@
export default {
+ LEFT_ARROW: 37,
BKSP : 8,
ENTER : 13,
ESC : 27,
diff --git a/src/js/utils/mixin.js b/src/js/utils/mixin.js
new file mode 100644
index 000000000..e798ca71e
--- /dev/null
+++ b/src/js/utils/mixin.js
@@ -0,0 +1,15 @@
+const CONSTRUCTOR_FN_NAME = 'constructor';
+
+export default function mixin(target, source) {
+ target = target.prototype;
+ // Fallback to just `source` to allow mixing in a plain object (pojo)
+ source = source.prototype || source;
+
+ Object.getOwnPropertyNames(source).forEach((name) => {
+ if (name !== CONSTRUCTOR_FN_NAME) {
+ const descriptor = Object.getOwnPropertyDescriptor(source, name);
+
+ Object.defineProperty(target, name, descriptor);
+ }
+ });
+}
diff --git a/src/js/utils/post-builder.js b/src/js/utils/post-builder.js
new file mode 100644
index 000000000..131a05a57
--- /dev/null
+++ b/src/js/utils/post-builder.js
@@ -0,0 +1,52 @@
+import Post from "../models/post";
+import MarkupSection from "../models/markup-section";
+import ImageSection from "../models/image";
+import Marker from "../models/marker";
+import Markup from "../models/markup";
+
+var builder = {
+ generatePost() {
+ return new Post();
+ },
+ generateMarkupSection(tagName, attributes, isGenerated) {
+ let section = new MarkupSection(tagName);
+ if (isGenerated) {
+ section.isGenerated = !!isGenerated;
+ }
+ return section;
+ },
+ generateImageSection(url) {
+ let section = new ImageSection();
+ if (url) {
+ section.src = url;
+ }
+ return section;
+ },
+ generateCardSection(name, payload={}) {
+ const type = 'card';
+ return { name, payload, type };
+ },
+ generateMarker: function(markers, value) {
+ return new Marker(value, markers);
+ },
+ generateMarkup: function(tagName, attributes) {
+ if (attributes) {
+ // FIXME: This could also be cached
+ return new Markup(tagName, attributes);
+ }
+ var markerType = this._markerTypeCache[tagName];
+ if (!markerType) {
+ this._markerTypeCache[tagName] = markerType = new Markup(tagName);
+ }
+ return markerType;
+ }
+};
+
+function reset(builder){
+ builder._markerTypeCache = {};
+}
+
+export function generateBuilder(){
+ reset(builder);
+ return builder;
+}
diff --git a/src/js/utils/selection-utils.js b/src/js/utils/selection-utils.js
index bc498fa8f..a0b7f15f2 100644
--- a/src/js/utils/selection-utils.js
+++ b/src/js/utils/selection-utils.js
@@ -1,14 +1,8 @@
-import { nodeIsDescendantOfElement } from './element-utils';
-import Type from 'node_modules/content-kit-compiler/src/types/type';
+import { containsNode } from './dom-utils';
// TODO: remove, pass in Editor's current block set
var RootTags = [
- Type.PARAGRAPH.tag,
- Type.HEADING.tag,
- Type.SUBHEADING.tag,
- Type.QUOTE.tag,
- Type.LIST.tag,
- Type.ORDERED_LIST.tag
+ 'p', 'h2', 'h3', 'blockquote', 'ul', 'ol'
];
var SelectionDirection = {
@@ -17,6 +11,11 @@ var SelectionDirection = {
SAME_NODE : 3
};
+function clearSelection() {
+ // FIXME-IE ensure this works on IE 9. It works on IE10.
+ window.getSelection().removeAllRanges();
+}
+
function getDirectionOfSelection(selection) {
var node = selection.anchorNode;
var position = node && node.compareDocumentPosition(selection.focusNode);
@@ -30,10 +29,28 @@ function getDirectionOfSelection(selection) {
function getSelectionElement(selection) {
selection = selection || window.getSelection();
- var node = getDirectionOfSelection(selection) === SelectionDirection.LEFT_TO_RIGHT ? selection.anchorNode : selection.focusNode;
+ // FIXME it used to return `anchorNode` when selection direction is `LEFT_TO_RIGHT`,
+ // but I think that was a bug. In Safari and Chrome the selection usually had the
+ // same anchorNode and focusNode when selecting text, so it didn't matter.
+ var node = getDirectionOfSelection(selection) === SelectionDirection.LEFT_TO_RIGHT ? selection.focusNode : selection.anchorNode;
return node && (node.nodeType === 3 ? node.parentNode : node);
}
+function isSelectionInElement(element) {
+ const selection = window.getSelection();
+ const { rangeCount, anchorNode, focusNode } = selection;
+
+ const range = (rangeCount > 0) && selection.getRangeAt(0);
+ const hasSelection = range && !range.collapsed;
+
+ if (hasSelection) {
+ return containsNode(element, anchorNode) &&
+ containsNode(element, focusNode);
+ } else {
+ return false;
+ }
+}
+
function getSelectionBlockElement(selection) {
selection = selection || window.getSelection();
var element = getSelectionElement();
@@ -71,44 +88,23 @@ function tagsInSelection(selection) {
return tags;
}
-function selectionIsInElement(selection, element) {
- var node = selection.anchorNode;
- return node && nodeIsDescendantOfElement(node, element);
-}
-
-function selectionIsEditable(selection) {
- var el = getSelectionBlockElement(selection);
- return el && el.isContentEditable;
-}
-
function restoreRange(range) {
+ clearSelection();
var selection = window.getSelection();
- selection.removeAllRanges();
selection.addRange(range);
}
function selectNode(node) {
+ clearSelection();
+
var range = document.createRange();
- var selection = window.getSelection();
range.setStart(node, 0);
range.setEnd(node, node.length);
- selection.removeAllRanges();
- selection.addRange(range);
-}
-function setCursorIndexInElement(element, index) {
- var range = document.createRange();
var selection = window.getSelection();
- range.setStart(element, index);
- range.collapse(true);
- selection.removeAllRanges();
selection.addRange(range);
}
-function setCursorToStartOfElement(element) {
- setCursorIndexInElement(element, 0);
-}
-
function getCursorOffsetInElement(element) {
// http://stackoverflow.com/questions/4811822/get-a-ranges-start-and-end-offsets-relative-to-its-parent-container/4812022#4812022
var caretOffset = 0;
@@ -123,5 +119,16 @@ function getCursorOffsetInElement(element) {
return caretOffset;
}
-export { getDirectionOfSelection, getSelectionElement, getSelectionBlockElement, getSelectionTagName,
- getSelectionBlockTagName, tagsInSelection, selectionIsInElement, selectionIsEditable, restoreRange, selectNode, setCursorToStartOfElement, setCursorIndexInElement, getCursorOffsetInElement };
+export {
+ getDirectionOfSelection,
+ getSelectionElement,
+ getSelectionBlockElement,
+ getSelectionTagName,
+ getSelectionBlockTagName,
+ tagsInSelection,
+ restoreRange,
+ selectNode,
+ getCursorOffsetInElement,
+ clearSelection,
+ isSelectionInElement
+};
diff --git a/src/js/utils/string-utils.js b/src/js/utils/string-utils.js
new file mode 100644
index 000000000..20b2f88b5
--- /dev/null
+++ b/src/js/utils/string-utils.js
@@ -0,0 +1,11 @@
+/*
+ * @param {String} string
+ * @return {String} a dasherized string. 'modelIndex' -> 'model-index', etc
+ */
+export function dasherize(string) {
+ return string.replace(/[A-Z]/g, (match, offset) => {
+ const lower = match.toLowerCase();
+
+ return (offset === 0 ? lower : '-' + lower);
+ });
+}
diff --git a/src/js/views/embed-intent.js b/src/js/views/embed-intent.js
index e22068c83..2344b0576 100644
--- a/src/js/views/embed-intent.js
+++ b/src/js/views/embed-intent.js
@@ -1,6 +1,6 @@
import View from './view';
import Toolbar from './toolbar';
-import { inherit } from 'node_modules/content-kit-utils/src/object-utils';
+import { inherit } from 'content-kit-utils';
import { getSelectionBlockElement } from '../utils/selection-utils';
import { elementContentIsEmpty, positionElementToLeftOf, positionElementCenteredIn } from '../utils/element-utils';
import { createDiv } from '../utils/element-utils';
@@ -31,7 +31,8 @@ function EmbedIntent(options) {
embedIntent.button.className = 'ck-embed-intent-btn';
embedIntent.button.title = 'Insert image or embed...';
embedIntent.element.appendChild(embedIntent.button);
- embedIntent.button.addEventListener('mouseup', function(e) {
+
+ this.addEventListener(embedIntent.button, 'mouseup', (e) => {
if (embedIntent.isActive) {
embedIntent.deactivate();
} else {
@@ -57,18 +58,20 @@ function EmbedIntent(options) {
}
}
- rootElement.addEventListener('keyup', embedIntentHandler);
- document.addEventListener('mouseup', function() {
- setTimeout(function() { embedIntentHandler(); });
+ this.addEventListener(rootElement, 'keyup', embedIntentHandler);
+ this.addEventListener(document, 'mouseup', () => {
+ setTimeout(() => {
+ embedIntentHandler();
+ });
});
- document.addEventListener('keyup', function(e) {
+ this.addEventListener(document, 'keyup', (e) => {
if (e.keyCode === Keycodes.ESC) {
embedIntent.hide();
}
});
- window.addEventListener('resize', function() {
+ this.addEventListener(window, 'resize', () => {
if(embedIntent.isShowing) {
embedIntent.reposition();
}
diff --git a/src/js/views/message.js b/src/js/views/message.js
index 59d72a60f..37844b037 100644
--- a/src/js/views/message.js
+++ b/src/js/views/message.js
@@ -1,5 +1,5 @@
import View from './view';
-import { inherit } from 'node_modules/content-kit-utils/src/object-utils';
+import { inherit } from 'content-kit-utils';
var defaultClassNames = ['ck-message'];
diff --git a/src/js/views/prompt.js b/src/js/views/prompt.js
index ed631cdb2..eb97a935e 100644
--- a/src/js/views/prompt.js
+++ b/src/js/views/prompt.js
@@ -1,5 +1,4 @@
import View from './view';
-import { inherit } from 'node_modules/content-kit-utils/src/object-utils';
import { restoreRange } from '../utils/selection-utils';
import { createDiv, positionElementToRect } from '../utils/element-utils';
import Keycodes from '../utils/keycodes';
@@ -15,52 +14,63 @@ function positionHiliteRange(range) {
positionElementToRect(hiliter, rect);
}
-function Prompt(options) {
- var prompt = this;
- options.tagName = 'input';
- View.call(prompt, options);
+class Prompt extends View {
+ constructor(options) {
+ options.tagName = 'input';
+ super(options);
- prompt.command = options.command;
- prompt.element.placeholder = options.placeholder || '';
- prompt.element.addEventListener('mouseup', function(e) { e.stopPropagation(); }); // prevents closing prompt when clicking input
- prompt.element.addEventListener('keyup', function(e) {
- var entry = this.value;
- if(entry && prompt.range && !e.shiftKey && e.which === Keycodes.ENTER) {
- restoreRange(prompt.range);
- prompt.command.exec(entry);
- if (prompt.onComplete) { prompt.onComplete(); }
- }
- });
+ var prompt = this;
- window.addEventListener('resize', function() {
- var activeHilite = hiliter.parentNode;
- var range = prompt.range;
- if(activeHilite && range) {
- positionHiliteRange(range);
- }
- });
-}
-inherit(Prompt, View);
+ prompt.command = options.command;
+ prompt.element.placeholder = options.placeholder || '';
+ this.addEventListener(prompt.element, 'mouseup', (e) => {
+ // prevents closing prompt when clicking input
+ e.stopPropagation();
+ });
+ this.addEventListener(prompt.element, 'keyup', (e) => {
+ const entry = prompt.element.value;
+
+ if (entry && prompt.range && !e.shiftKey && e.which === Keycodes.ENTER) {
+ restoreRange(prompt.range);
+ this.command.exec(entry);
+ if (this.onComplete) { this.onComplete(); }
+ }
+ });
+
+ this.addEventListener(window, 'resize', () => {
+ var activeHilite = hiliter.parentNode;
+ var range = prompt.range;
+ if(activeHilite && range) {
+ positionHiliteRange(range);
+ }
+ });
+ }
+
+ show(callback) {
+ var element = this.element;
+ var selection = window.getSelection();
+ var range = selection && selection.rangeCount && selection.getRangeAt(0);
+ element.value = null;
+ this.range = range || null;
-Prompt.prototype.show = function(callback) {
- var prompt = this;
- var element = prompt.element;
- var selection = window.getSelection();
- var range = selection && selection.rangeCount && selection.getRangeAt(0);
- element.value = null;
- prompt.range = range || null;
- if (range) {
- container.appendChild(hiliter);
- positionHiliteRange(prompt.range);
- setTimeout(function(){ element.focus(); }); // defer focus (disrupts mouseup events)
- if (callback) { prompt.onComplete = callback; }
+ if (range) {
+ container.appendChild(hiliter);
+ positionHiliteRange(this.range);
+ setTimeout(() => {
+ // defer focus (disrupts mouseup events)
+ element.focus();
+ });
+ if (callback) {
+ this.onComplete = callback;
+ }
+ }
}
-};
-Prompt.prototype.hide = function() {
- if (hiliter.parentNode) {
- container.removeChild(hiliter);
+ hide() {
+ if (hiliter.parentNode) {
+ container.removeChild(hiliter);
+ }
}
-};
+}
export default Prompt;
diff --git a/src/js/views/text-format-toolbar.js b/src/js/views/text-format-toolbar.js
index cd04aba69..8e45e677f 100644
--- a/src/js/views/text-format-toolbar.js
+++ b/src/js/views/text-format-toolbar.js
@@ -1,55 +1,29 @@
import Toolbar from './toolbar';
-import { inherit } from 'node_modules/content-kit-utils/src/object-utils';
-import { selectionIsEditable, selectionIsInElement } from '../utils/selection-utils';
-import Keycodes from '../utils/keycodes';
-function selectionIsEditableByToolbar(selection, toolbar) {
- return selectionIsEditable(selection) && selectionIsInElement(selection, toolbar.rootElement);
-}
+export default class TextFormatToolbar extends Toolbar {
+ constructor(options={}) {
+ super(options);
-function handleTextSelection(toolbar) {
- var selection = window.getSelection();
- if (toolbar.sticky) {
- toolbar.updateForSelection(selectionIsEditableByToolbar(selection, toolbar) ? selection : null);
- } else {
- if (selection.isCollapsed || selection.toString().trim() === '' || !selectionIsEditableByToolbar(selection, toolbar)) {
- toolbar.hide();
- } else {
- toolbar.show();
- toolbar.updateForSelection(selection);
- }
+ this.editor.on('selection', () => this.handleSelection());
+ this.editor.on('selectionUpdated', () => this.handleSelection());
+ this.editor.on('selectionEnded', () => this.handleSelectionEnded());
+ this.editor.on('escapeKey', () => this.editor.cancelSelection());
+ this.addEventListener(window, 'resize', () => this.handleResize());
}
-}
-function TextFormatToolbar(options) {
- var toolbar = this;
- Toolbar.call(this, options);
- toolbar.rootElement = options.rootElement;
- toolbar.rootElement.addEventListener('keyup', function() { handleTextSelection(toolbar); });
-
- document.addEventListener('mouseup', function() {
- setTimeout(function() {
- handleTextSelection(toolbar);
- });
- });
-
- document.addEventListener('keyup', function(e) {
- var key = e.keyCode;
- if (key === 116) { //F5
- toolbar.toggleSticky();
- handleTextSelection(toolbar);
- } else if (!toolbar.sticky && key === Keycodes.ESC) {
- toolbar.hide();
+ handleResize() {
+ if(this.isShowing) {
+ let activePromptRange = this.activePrompt && this.activePrompt.range;
+ this.positionToContent(activePromptRange ? activePromptRange : window.getSelection().getRangeAt(0));
}
- });
+ }
- window.addEventListener('resize', function() {
- if(!toolbar.sticky && toolbar.isShowing) {
- var activePromptRange = toolbar.activePrompt && toolbar.activePrompt.range;
- toolbar.positionToContent(activePromptRange ? activePromptRange : window.getSelection().getRangeAt(0));
- }
- });
-}
-inherit(TextFormatToolbar, Toolbar);
+ handleSelection() {
+ this.show();
+ this.updateForSelection(window.getSelection());
+ }
-export default TextFormatToolbar;
+ handleSelectionEnded() {
+ this.hide();
+ }
+}
diff --git a/src/js/views/toolbar-button.js b/src/js/views/toolbar-button.js
index d2edef4a9..19e7010d5 100644
--- a/src/js/views/toolbar-button.js
+++ b/src/js/views/toolbar-button.js
@@ -1,5 +1,6 @@
-
var buttonClassName = 'ck-toolbar-btn';
+import mixin from '../utils/mixin';
+import EventListenerMixin from '../utils/event-listener';
function ToolbarButton(options) {
var button = this;
@@ -15,7 +16,7 @@ function ToolbarButton(options) {
element.title = command.name;
element.className = buttonClassName;
element.innerHTML = command.button;
- element.addEventListener('mouseup', function(e) {
+ this.addEventListener(element, 'mouseup', (e) => {
if (!button.isActive && prompt) {
toolbar.displayPrompt(prompt);
} else {
@@ -43,4 +44,6 @@ ToolbarButton.prototype = {
}
};
+mixin(ToolbarButton, EventListenerMixin);
+
export default ToolbarButton;
diff --git a/src/js/views/toolbar.js b/src/js/views/toolbar.js
index d63344c2e..20aa53b12 100644
--- a/src/js/views/toolbar.js
+++ b/src/js/views/toolbar.js
@@ -1,6 +1,5 @@
import View from './view';
import ToolbarButton from './toolbar-button';
-import { inherit } from 'node_modules/content-kit-utils/src/object-utils';
import { tagsInSelection } from '../utils/selection-utils';
import { createDiv, swapElements, positionElementToRightOf, positionElementCenteredAbove } from '../utils/element-utils';
@@ -30,127 +29,104 @@ function updateButtonsForSelection(buttons, selection) {
}
}
-function Toolbar(options) {
- options = options || {};
- var toolbar = this;
- var commands = options.commands;
- var commandCount = commands && commands.length, i;
- options.classNames = ['ck-toolbar'];
- View.call(toolbar, options);
-
- toolbar.setSticky(options.sticky || false);
- toolbar.setDirection(options.direction || ToolbarDirection.TOP);
- toolbar.editor = options.editor || null;
- toolbar.embedIntent = options.embedIntent || null;
- toolbar.activePrompt = null;
- toolbar.buttons = [];
-
- toolbar.contentElement = createDiv('ck-toolbar-content');
- toolbar.promptContainerElement = createDiv('ck-toolbar-prompt');
- toolbar.buttonContainerElement = createDiv('ck-toolbar-buttons');
- toolbar.contentElement.appendChild(toolbar.promptContainerElement);
- toolbar.contentElement.appendChild(toolbar.buttonContainerElement);
- toolbar.element.appendChild(toolbar.contentElement);
-
- for(i = 0; i < commandCount; i++) {
- this.addCommand(commands[i]);
- }
+class Toolbar extends View {
+ constructor(options={}) {
+ options.classNames = ['ck-toolbar'];
+ super(options);
+
+ let commands = options.commands;
+ let commandCount = commands && commands.length;
+
+ this.setDirection(options.direction || ToolbarDirection.TOP);
+ this.editor = options.editor || null;
+ this.embedIntent = options.embedIntent || null;
+ this.activePrompt = null;
+ this.buttons = [];
+
+ this.contentElement = createDiv('ck-toolbar-content');
+ this.promptContainerElement = createDiv('ck-toolbar-prompt');
+ this.buttonContainerElement = createDiv('ck-toolbar-buttons');
+ this.contentElement.appendChild(this.promptContainerElement);
+ this.contentElement.appendChild(this.buttonContainerElement);
+ this.element.appendChild(this.contentElement);
+
+ for(let i = 0; i < commandCount; i++) {
+ this.addCommand(commands[i]);
+ }
- // Closes prompt if displayed when changing selection
- document.addEventListener('mouseup', function() {
- toolbar.dismissPrompt();
- });
-}
-inherit(Toolbar, View);
-
-Toolbar.prototype.hide = function() {
- if (Toolbar._super.prototype.hide.call(this)) {
- var style = this.element.style;
- style.left = '';
- style.top = '';
- this.dismissPrompt();
+ // Closes prompt if displayed when changing selection
+ this.addEventListener(document, 'mouseup', () => {
+ 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();
- });
- toolbar.activePrompt = prompt;
-};
+ hide() {
+ if (super.hide()) {
+ let style = this.element.style;
+ style.left = '';
+ style.top = '';
+ this.dismissPrompt();
+ }
+ }
-Toolbar.prototype.dismissPrompt = function() {
- var toolbar = this;
- var activePrompt = toolbar.activePrompt;
- if (activePrompt) {
- activePrompt.hide();
- swapElements(toolbar.buttonContainerElement, toolbar.promptContainerElement);
- toolbar.activePrompt = null;
+ addCommand(command) {
+ command.editorContext = this.editor;
+ command.embedIntent = this.embedIntent;
+ let button = new ToolbarButton({command: command, toolbar: this});
+ this.buttons.push(button);
+ this.buttonContainerElement.appendChild(button.element);
}
-};
-Toolbar.prototype.updateForSelection = function(selection) {
- var toolbar = this;
- selection = selection || window.getSelection();
- if (toolbar.sticky) {
- updateButtonsForSelection(toolbar.buttons, selection);
- } else if (!selection.isCollapsed) {
- toolbar.positionToContent(selection.getRangeAt(0));
- updateButtonsForSelection(toolbar.buttons, selection);
+ displayPrompt(prompt) {
+ swapElements(this.promptContainerElement, this.buttonContainerElement);
+ this.promptContainerElement.appendChild(prompt.element);
+ prompt.show(() => {
+ this.dismissPrompt();
+ this.updateForSelection();
+ });
+ this.activePrompt = prompt;
}
-};
-Toolbar.prototype.positionToContent = function(content) {
- var directions = ToolbarDirection;
- var positioningMethod, position, sideEdgeOffset;
- switch(this.direction) {
- case directions.RIGHT:
- positioningMethod = positionElementToRightOf;
- break;
- default:
- positioningMethod = positionElementCenteredAbove;
+ dismissPrompt() {
+ let activePrompt = this.activePrompt;
+ if (activePrompt) {
+ activePrompt.hide();
+ swapElements(this.buttonContainerElement, this.promptContainerElement);
+ this.activePrompt = null;
+ }
}
- position = positioningMethod(this.element, content);
- sideEdgeOffset = Math.min(Math.max(10, position.left), document.body.clientWidth - this.element.offsetWidth - 10);
- this.contentElement.style.transform = 'translateX(' + (sideEdgeOffset - position.left) + 'px)';
-};
-Toolbar.prototype.setDirection = function(direction) {
- this.direction = direction;
- if (direction === ToolbarDirection.RIGHT) {
- this.addClass('right');
- } else {
- this.removeClass('right');
+ updateForSelection(selection=window.getSelection()) {
+ if (!selection.isCollapsed) {
+ this.positionToContent(selection.getRangeAt(0));
+ updateButtonsForSelection(this.buttons, selection);
+ }
}
-};
-Toolbar.prototype.setSticky = function(sticky) {
- this.sticky = sticky;
- if (sticky) {
- this.addClass('sticky');
- this.element.removeAttribute('style'); // clears any prior positioning
- this.show();
- } else {
- this.removeClass('sticky');
- this.hide();
+ positionToContent(content) {
+ var directions = ToolbarDirection;
+ var positioningMethod, position, sideEdgeOffset;
+ switch(this.direction) {
+ case directions.RIGHT:
+ positioningMethod = positionElementToRightOf;
+ break;
+ default:
+ positioningMethod = positionElementCenteredAbove;
+ }
+ position = positioningMethod(this.element, content);
+ sideEdgeOffset = Math.min(Math.max(10, position.left), document.body.clientWidth - this.element.offsetWidth - 10);
+ this.contentElement.style.transform = 'translateX(' + (sideEdgeOffset - position.left) + 'px)';
}
-};
-Toolbar.prototype.toggleSticky = function() {
- this.setSticky(!this.sticky);
-};
+ setDirection(direction) {
+ this.direction = direction;
+ if (direction === ToolbarDirection.RIGHT) {
+ this.addClass('right');
+ } else {
+ this.removeClass('right');
+ }
+ }
+}
Toolbar.Direction = ToolbarDirection;
diff --git a/src/js/views/tooltip.js b/src/js/views/tooltip.js
index b4f8d4309..9ffc3cb15 100644
--- a/src/js/views/tooltip.js
+++ b/src/js/views/tooltip.js
@@ -1,5 +1,5 @@
import View from './view';
-import { inherit } from 'node_modules/content-kit-utils/src/object-utils';
+import { inherit } from 'content-kit-utils';
import { positionElementCenteredBelow, getEventTargetMatchingTag } from '../utils/element-utils';
function Tooltip(options) {
@@ -10,7 +10,7 @@ function Tooltip(options) {
options.classNames = ['ck-tooltip'];
View.call(tooltip, options);
- rootElement.addEventListener('mouseover', function(e) {
+ this.addEventListener(rootElement, 'mouseover', (e) => {
var target = getEventTargetMatchingTag(options.showForTag, e.target, rootElement);
if (target && target.isContentEditable) {
timeout = setTimeout(function() {
@@ -19,7 +19,7 @@ function Tooltip(options) {
}
});
- rootElement.addEventListener('mouseout', function(e) {
+ this.addEventListener(rootElement, 'mouseout', (e) => {
clearTimeout(timeout);
var toElement = e.toElement || e.relatedTarget;
if (toElement && toElement.className !== tooltip.element.className) {
diff --git a/src/js/views/view.js b/src/js/views/view.js
index 09970877e..8fcf2baf5 100644
--- a/src/js/views/view.js
+++ b/src/js/views/view.js
@@ -1,3 +1,6 @@
+import mixin from '../utils/mixin';
+import EventListenerMixin from '../utils/event-listener';
+
function renderClasses(view) {
var classNames = view.classNames;
if (classNames && classNames.length) {
@@ -7,51 +10,61 @@ function renderClasses(view) {
}
}
-function View(options) {
- options = options || {};
- this.tagName = options.tagName || 'div';
- this.classNames = options.classNames || [];
- this.element = document.createElement(this.tagName);
- this.container = options.container || document.body;
- this.isShowing = false;
- renderClasses(this);
-}
+class View {
+ constructor(options={}) {
+ this.tagName = options.tagName || 'div';
+ this.classNames = options.classNames || [];
+ this.element = document.createElement(this.tagName);
+ this.container = options.container || document.body;
+ this.isShowing = false;
+ renderClasses(this);
+ }
-View.prototype = {
- show: function() {
+ show() {
var view = this;
if(!view.isShowing) {
view.container.appendChild(view.element);
view.isShowing = true;
return true;
}
- },
- hide: function() {
+ }
+
+ hide() {
var view = this;
if(view.isShowing) {
view.container.removeChild(view.element);
view.isShowing = false;
return true;
}
- },
- addClass: function(className) {
+ }
+
+ addClass(className) {
var index = this.classNames && this.classNames.indexOf(className);
if (index === -1) {
this.classNames.push(className);
renderClasses(this);
}
- },
- removeClass: function(className) {
+ }
+
+ removeClass(className) {
var index = this.classNames && this.classNames.indexOf(className);
if (index > -1) {
this.classNames.splice(index, 1);
renderClasses(this);
}
- },
- setClasses: function(classNameArr) {
+ }
+
+ setClasses(classNameArr) {
this.classNames = classNameArr;
renderClasses(this);
}
-};
+
+ destroy() {
+ this.removeAllEventListeners();
+ this.hide();
+ }
+}
+
+mixin(View, EventListenerMixin);
export default View;
diff --git a/testem.json b/testem.json
new file mode 100644
index 000000000..2f920c5f0
--- /dev/null
+++ b/testem.json
@@ -0,0 +1,16 @@
+{
+ "framework": "qunit",
+ "test_page": "dist/tests/index.html",
+ "src_files": [
+ "tests/**/*.js",
+ "src/**/*.js"
+ ],
+ "before_tests": "npm run build",
+ "launch_in_ci": [
+ "PhantomJS"
+ ],
+ "launch_in_dev": [
+ "PhantomJS",
+ "Chrome"
+ ]
+}
diff --git a/tests/acceptance/basic-editor-test.js b/tests/acceptance/basic-editor-test.js
new file mode 100644
index 000000000..a840e7517
--- /dev/null
+++ b/tests/acceptance/basic-editor-test.js
@@ -0,0 +1,44 @@
+import { Editor } from 'content-kit-editor';
+import Helpers from '../test-helpers';
+
+const { test, module } = QUnit;
+
+let fixture, editor, editorElement;
+
+module('Acceptance: basic editor', {
+ beforeEach() {
+ fixture = document.getElementById('qunit-fixture');
+ editorElement = document.createElement('div');
+ editorElement.setAttribute('id', 'editor');
+ fixture.appendChild(editorElement);
+ },
+ afterEach() {
+ editor.destroy();
+ }
+});
+
+test('sets element as contenteditable', (assert) => {
+ let innerHTML = `Hello
`;
+ editorElement.innerHTML = innerHTML;
+ editor = new Editor(document.getElementById('editor'));
+
+ assert.equal(editorElement.getAttribute('contenteditable'),
+ 'true',
+ 'element is contenteditable');
+ assert.equal(editorElement.firstChild.tagName, 'P',
+ `editor element has a P as its first child`);
+});
+
+test('editing element changes editor post model', (assert) => {
+ let innerHTML = `Hello
`;
+ editorElement.innerHTML = innerHTML;
+ editor = new Editor(document.getElementById('editor'));
+
+ let p = editorElement.querySelector('p');
+ let textElement = p.firstChild;
+
+ Helpers.dom.moveCursorTo(textElement, 0);
+
+ document.execCommand('insertText', false, 'A');
+ assert.equal(p.textContent, 'AHello');
+});
diff --git a/tests/acceptance/editor-commands-test.js b/tests/acceptance/editor-commands-test.js
new file mode 100644
index 000000000..b87bc2aac
--- /dev/null
+++ b/tests/acceptance/editor-commands-test.js
@@ -0,0 +1,150 @@
+import { Editor } from 'content-kit-editor';
+import Helpers from '../test-helpers';
+
+const { test, module } = QUnit;
+
+let fixture, editor, editorElement, selectedText;
+
+module('Acceptance: Editor commands', {
+ beforeEach() {
+ fixture = document.getElementById('qunit-fixture');
+ editorElement = document.createElement('div');
+ editorElement.setAttribute('id', 'editor');
+ editorElement.innerHTML = 'THIS IS A TEST';
+ fixture.appendChild(editorElement);
+ editor = new Editor(editorElement);
+
+ selectedText = 'IS A';
+ Helpers.dom.selectText(selectedText, editorElement);
+ Helpers.dom.triggerEvent(document, 'mouseup');
+ },
+
+ afterEach() {
+ editor.destroy();
+ }
+});
+
+function clickToolbarButton(name, assert) {
+ let btnSelector = `.ck-toolbar-btn[title="${name}"]`;
+ let button = assert.hasElement(btnSelector);
+
+ Helpers.dom.triggerEvent(button[0], 'mouseup');
+}
+
+test('when text is highlighted, shows toolbar', (assert) => {
+ let done = assert.async();
+
+ setTimeout(() => {
+ assert.hasElement('.ck-toolbar', 'displays toolbar');
+ assert.hasElement('.ck-toolbar-btn', 'displays toolbar buttons');
+ let boldBtnSelector = '.ck-toolbar-btn[title="bold"]';
+ assert.hasElement(boldBtnSelector, 'has bold button');
+
+ done();
+ });
+});
+
+test('highlight text, click "bold" button bolds text', (assert) => {
+ let done = assert.async();
+
+ setTimeout(() => {
+ clickToolbarButton('bold', assert);
+ assert.hasElement('#editor b:contains(IS A)');
+
+ done();
+ });
+});
+
+test('highlight text, click "italic" button italicizes text', (assert) => {
+ let done = assert.async();
+
+ setTimeout(() => {
+ clickToolbarButton('italic', assert);
+ assert.hasElement('#editor i:contains(IS A)');
+
+ done();
+ });
+});
+
+test('highlight text, click "heading" button turns text into h2 header', (assert) => {
+ const done = assert.async();
+
+ setTimeout(() => {
+ clickToolbarButton('heading', assert);
+ assert.hasElement('#editor h2:contains(THIS IS A TEST)');
+
+ done();
+ });
+});
+
+test('highlight text, click "subheading" button turns text into h3 header', (assert) => {
+ const done = assert.async();
+
+ setTimeout(() => {
+ clickToolbarButton('subheading', assert);
+ assert.hasElement('#editor h3:contains(THIS IS A TEST)');
+
+ done();
+ });
+});
+
+test('highlight text, click "quote" button turns text into blockquote', (assert) => {
+ const done = assert.async();
+
+ setTimeout(() => {
+ clickToolbarButton('quote', assert);
+ assert.hasElement('#editor blockquote:contains(THIS IS A TEST)');
+
+ done();
+ });
+});
+
+// FIXME PhantomJS doesn't create keyboard events properly (they have no keyCode or which)
+// see https://bugs.webkit.org/show_bug.cgi?id=36423
+Helpers.skipInPhantom('highlight text, click "link" button shows input for URL, makes link', (assert) => {
+ const done = assert.async();
+
+ setTimeout(() => {
+ clickToolbarButton('link', assert);
+ let input = assert.hasElement('.ck-toolbar-prompt input');
+ let url = 'http://google.com';
+ $(input).val(url);
+ Helpers.dom.triggerKeyEvent(input[0], 'keyup');
+
+ assert.hasElement(`#editor a[href="${url}"]:contains(${selectedText})`);
+
+ done();
+ });
+});
+
+test('highlighting bold text shows bold button as active', (assert) => {
+ const done = assert.async();
+
+ setTimeout(() => {
+ assert.hasNoElement(`.ck-toolbar-btn.active[title="bold"]`,
+ 'precond - bold button is not active');
+ clickToolbarButton('bold', assert);
+
+ assert.hasElement(`.ck-toolbar-btn.active[title="bold"]`,
+ 'bold button is active after clicking it');
+
+ Helpers.dom.clearSelection();
+ Helpers.dom.triggerEvent(document, 'mouseup');
+
+ setTimeout(() => {
+ assert.hasNoElement('.ck-toolbar', 'toolbar is hidden');
+
+ Helpers.dom.selectText(selectedText, editorElement);
+ Helpers.dom.triggerEvent(document, 'mouseup');
+
+ setTimeout(() => {
+ assert.hasElement('.ck-toolbar', 'toolbar is shown again');
+
+ assert.hasElement(`.ck-toolbar-btn.active[title="bold"]`,
+ 'bold button is active when selecting bold text');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/tests/acceptance/editor-sections-test.js b/tests/acceptance/editor-sections-test.js
new file mode 100644
index 000000000..172ecd0e4
--- /dev/null
+++ b/tests/acceptance/editor-sections-test.js
@@ -0,0 +1,102 @@
+import { Editor } from 'content-kit-editor';
+import Helpers from '../test-helpers';
+
+const { test, module } = QUnit;
+
+const newline = '\r\n';
+
+let fixture, editor, editorElement;
+const mobileDocWith1Section = [
+ [],
+ [
+ [1, "P", [
+ [[], 0, "only section"]
+ ]]
+ ]
+];
+const mobileDocWith2Sections = [
+ [],
+ [
+ [1, "P", [
+ [[], 0, "first section"]
+ ]],
+ [1, "P", [
+ [[], 0, "second section"]
+ ]]
+ ]
+];
+const mobileDocWith3Sections = [
+ [],
+ [
+ [1, "P", [
+ [[], 0, "first section"]
+ ]],
+ [1, "P", [
+ [[], 0, "second section"]
+ ]],
+ [1, "P", [
+ [[], 0, "third section"]
+ ]]
+ ]
+];
+
+module('Acceptance: Editor sections', {
+ beforeEach() {
+ fixture = document.getElementById('qunit-fixture');
+ editorElement = document.createElement('div');
+ editorElement.setAttribute('id', 'editor');
+ fixture.appendChild(editorElement);
+ },
+
+ afterEach() {
+ editor.destroy();
+ }
+});
+
+test('typing inserts section', (assert) => {
+ editor = new Editor(editorElement, {mobiledoc: mobileDocWith1Section});
+ assert.equal($('#editor p').length, 1, 'has 1 paragraph to start');
+
+ const text = 'new section';
+
+ Helpers.dom.moveCursorTo(editorElement);
+ document.execCommand('insertText', false, text + newline);
+
+ assert.equal($('#editor p').length, 2, 'has 2 paragraphs after typing return');
+ assert.hasElement(`#editor p:contains(${text})`, 'has first pargraph with "A"');
+ assert.hasElement('#editor p:contains(only section)', 'has correct second paragraph text');
+});
+
+test('deleting across 0 sections merges them', (assert) => {
+ editor = new Editor(editorElement, {mobiledoc: mobileDocWith2Sections});
+ assert.equal($('#editor p').length, 2, 'precond - has 2 sections to start');
+
+ const p0 = $('#editor p:eq(0)')[0],
+ p1 = $('#editor p:eq(1)')[0];
+
+ Helpers.dom.selectText('tion', p0, 'sec', p1);
+ document.execCommand('delete', false);
+
+ assert.equal($('#editor p').length, 1, 'has only 1 paragraph after deletion');
+ assert.hasElement('#editor p:contains(first second section)',
+ 'remaining paragraph has correct text');
+});
+
+test('deleting across 1 section removes it, joins the 2 boundary sections', (assert) => {
+ editor = new Editor(editorElement, {mobiledoc: mobileDocWith3Sections});
+ assert.equal($('#editor p').length, 3, 'precond - has 3 paragraphs to start');
+
+ const p0 = $('#editor p:eq(0)')[0],
+ p1 = $('#editor p:eq(1)')[0],
+ p2 = $('#editor p:eq(2)')[0];
+ assert.ok(p0 && p1 && p2, 'precond - paragraphs exist');
+
+ Helpers.dom.selectText('section', p0, 'third ', p2);
+
+ document.execCommand('delete', false);
+
+
+ assert.equal($('#editor p').length, 1, 'has only 1 paragraph after deletion');
+ assert.hasElement('#editor p:contains(first section)',
+ 'remaining paragraph has correct text');
+});
diff --git a/tests/helpers/assertions.js b/tests/helpers/assertions.js
new file mode 100644
index 000000000..c2c8a79b7
--- /dev/null
+++ b/tests/helpers/assertions.js
@@ -0,0 +1,15 @@
+/* global QUnit, $ */
+
+export default function registerAssertions() {
+ QUnit.assert.hasElement = function(selector, message=`hasElement "${selector}"`) {
+ let found = $(selector);
+ this.push(found.length > 0, found.length, selector, message);
+ return found;
+ };
+
+ QUnit.assert.hasNoElement = function(selector, message=`hasNoElement "${selector}"`) {
+ let found = $(selector);
+ this.push(found.length === 0, found.length, selector, message);
+ return found;
+ };
+}
diff --git a/tests/helpers/dom.js b/tests/helpers/dom.js
new file mode 100644
index 000000000..9c9b8ce4f
--- /dev/null
+++ b/tests/helpers/dom.js
@@ -0,0 +1,131 @@
+const TEXT_NODE = 3;
+
+import { clearSelection } from 'content-kit-editor/utils/selection-utils';
+import KEY_CODES from 'content-kit-editor/utils/keycodes';
+
+function walkDOMUntil(topNode, conditionFn=() => {}) {
+ if (!topNode) { throw new Error('Cannot call walkDOMUntil without a node'); }
+ let stack = [topNode];
+ let currentElement;
+
+ while (stack.length) {
+ currentElement = stack.pop();
+
+ if (conditionFn(currentElement)) {
+ return currentElement;
+ }
+
+ for (let i=0; i < currentElement.childNodes.length; i++) {
+ stack.push(currentElement.childNodes[i]);
+ }
+ }
+}
+
+function selectRange(startNode, startOffset, endNode, endOffset) {
+ clearSelection();
+
+ const range = document.createRange();
+ range.setStart(startNode, startOffset);
+ range.setEnd(endNode, endOffset);
+
+ const selection = window.getSelection();
+ selection.addRange(range);
+}
+
+function selectText(startText,
+ startContainingElement,
+ endText=startText,
+ endContainingElement=startContainingElement) {
+ const findTextNode = (text) => {
+ return (el) => el.nodeType === TEXT_NODE && el.textContent.indexOf(text) !== -1;
+ };
+ const startTextNode = walkDOMUntil(startContainingElement, findTextNode(startText));
+ const endTextNode = walkDOMUntil(endContainingElement, findTextNode(endText));
+
+ if (!startTextNode) {
+ throw new Error(`Could not find a starting textNode containing "${startText}"`);
+ }
+ if (!endTextNode) {
+ throw new Error(`Could not find an ending textNode containing "${endText}"`);
+ }
+
+ const startOffset = startTextNode.textContent.indexOf(startText),
+ endOffset = endTextNode.textContent.indexOf(endText) + endText.length;
+ selectRange(startTextNode, startOffset, endTextNode, endOffset);
+}
+
+function moveCursorTo(element, offset=0) {
+ selectRange(element, offset, element, offset);
+}
+
+function triggerEvent(node, eventType) {
+ if (!node) { throw new Error(`Attempted to trigger event "${eventType}" on undefined node`); }
+
+ let clickEvent = document.createEvent('MouseEvents');
+ clickEvent.initEvent(eventType, true, true);
+ node.dispatchEvent(clickEvent);
+}
+
+function createKeyEvent(eventType, keyCode) {
+ let oEvent = document.createEvent('KeyboardEvent');
+ if (oEvent.initKeyboardEvent) {
+ oEvent.initKeyboardEvent(eventType, true, true, window, 0, 0, 0, 0, 0, keyCode);
+ } else if (oEvent.initKeyEvent) {
+ oEvent.initKeyEvent(eventType, true, true, window, 0, 0, 0, 0, 0, keyCode);
+ }
+
+ // Hack for Chrome to force keyCode/which value
+ try {
+ Object.defineProperty(oEvent, 'keyCode', {get: function() { return keyCode; }});
+ Object.defineProperty(oEvent, 'which', {get: function() { return keyCode; }});
+ } catch(e) {
+ // FIXME
+ // PhantomJS/webkit will throw an error "ERROR: Attempting to change access mechanism for an unconfigurable property"
+ // see https://bugs.webkit.org/show_bug.cgi?id=36423
+ }
+
+ if (oEvent.keyCode !== keyCode || oEvent.which !== keyCode) {
+ throw new Error(`Failed to create key event with keyCode ${keyCode}. \`keyCode\`: ${oEvent.keyCode}, \`which\`: ${oEvent.which}`);
+ }
+
+ return oEvent;
+}
+
+function triggerKeyEvent(node, eventType, keyCode=KEY_CODES.ENTER) {
+ let oEvent = createKeyEvent(eventType, keyCode);
+ node.dispatchEvent(oEvent);
+}
+
+function _buildDOM(tagName, attributes={}, children=[]) {
+ const el = document.createElement(tagName);
+ Object.keys(attributes).forEach(k => el.setAttribute(k, attributes[k]));
+ children.forEach(child => el.appendChild(child));
+ return el;
+}
+
+_buildDOM.text = (string) => {
+ return document.createTextNode(string);
+};
+
+/**
+ * Usage:
+ * makeDOM(t =>
+ * t('div', attributes={}, children=[
+ * t('b', {}, [
+ * t.text('I am a bold text node')
+ * ])
+ * ])
+ * );
+ */
+function makeDOM(tree) {
+ return tree(_buildDOM);
+}
+
+export default {
+ moveCursorTo,
+ selectText,
+ clearSelection,
+ triggerEvent,
+ triggerKeyEvent,
+ makeDOM
+};
diff --git a/tests/helpers/skip-in-phantom.js b/tests/helpers/skip-in-phantom.js
new file mode 100644
index 000000000..54e41c23c
--- /dev/null
+++ b/tests/helpers/skip-in-phantom.js
@@ -0,0 +1,10 @@
+const { test } = QUnit;
+
+export default function(message, testFn) {
+ const isPhantom = navigator.userAgent.indexOf('PhantomJS') !== -1;
+ if (isPhantom) {
+ message = '[SKIPPED in PhantomJS] ' + message;
+ testFn = (assert) => assert.ok(true);
+ }
+ test(message, testFn);
+}
diff --git a/tests/index.html b/tests/index.html
index 3b36ef372..164b81c8a 100644
--- a/tests/index.html
+++ b/tests/index.html
@@ -2,17 +2,35 @@
- QUnit
-
+ Content-Kit-Editor tests
+
+
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/tests/test-helpers.js b/tests/test-helpers.js
new file mode 100644
index 000000000..e4986cae2
--- /dev/null
+++ b/tests/test-helpers.js
@@ -0,0 +1,10 @@
+import registerAssertions from './helpers/assertions';
+registerAssertions();
+
+import DOMHelpers from './helpers/dom';
+import skipInPhantom from './helpers/skip-in-phantom';
+
+export default {
+ dom: DOMHelpers,
+ skipInPhantom
+};
diff --git a/tests/tests.js b/tests/tests.js
deleted file mode 100644
index d279d4abb..000000000
--- a/tests/tests.js
+++ /dev/null
@@ -1,73 +0,0 @@
-var fixture = document.getElementById('qunit-fixture');
-var editorElement = document.createElement('div');
-editorElement.id = 'editor1';
-editorElement.className = 'editor';
-
-QUnit.module('Editor', {
- setup: function() {
- fixture.appendChild(editorElement);
- },
- teardown: function() {
- fixture.removeChild(editorElement);
- }
-});
-
-test('can create an editor', function() {
- var editor = new ContentKit.Editor();
- ok(editor instanceof ContentKit.Editor);
-});
-
-test('can create an editor via dom node reference', function() {
- var editor = new ContentKit.Editor(editorElement);
- equal(editor.element, editorElement);
-});
-
-test('can create an editor via dom node reference from getElementById', function() {
- var editor = new ContentKit.Editor(document.getElementById('editor1'));
- equal(editor.element, editorElement);
-});
-
-test('can create an editor via id selector', function() {
- var editor = new ContentKit.Editor('#editor1');
- equal(editor.element, editorElement);
-});
-
-test('can create an editor via class selector', function() {
- var editor = new ContentKit.Editor('.editor');
- equal(editor.element, editorElement);
-});
-
-test('can recreate an editor on the same element', function() {
- var editor = new ContentKit.Editor('#editor1');
- ok(editor.element === editorElement);
-
- editor = new ContentKit.Editor('.editor');
- equal(editor.element, editorElement);
- equal(editor.element.className, 'editor ck-editor');
-});
-
-test('creating an editor doesn\'t trash existing class names', function() {
- editorElement.className = 'some-class';
-
- var editor = new ContentKit.Editor('.some-class');
- equal(editor.element.className, 'some-class ck-editor');
-});
-
-test('creating an editor without a class name adds appropriate class', function() {
- editorElement.className = '';
-
- var editor = new ContentKit.Editor(document.getElementById('editor1'));
- equal(editor.element.className, 'ck-editor');
-});
-
-asyncTest('editor fires update event', function() {
- expect(2);
-
- var editor = new ContentKit.Editor();
- editor.on('update', function(data) {
- equal (this, editor);
- equal (data.index, 99);
- start();
- });
- editor.trigger('update', { index: 99 });
-});
diff --git a/tests/unit/editor/card-lifecycle-test.js b/tests/unit/editor/card-lifecycle-test.js
new file mode 100644
index 000000000..087a45d87
--- /dev/null
+++ b/tests/unit/editor/card-lifecycle-test.js
@@ -0,0 +1,219 @@
+const { module, test } = QUnit;
+
+import Helpers from '../../test-helpers';
+import { Editor } from 'content-kit-editor';
+import { containsNode } from 'content-kit-editor/utils/dom-utils';
+let editorElement, editor;
+
+module('Unit: Editor: Card Lifecycle', {
+ beforeEach() {
+ editorElement = document.createElement('div');
+ },
+ afterEach() {
+ if (editor) {
+ editor.destroy();
+ }
+ editor = null;
+ }
+});
+
+test('rendering a mobiledoc for editing calls card#setup', (assert) => {
+ assert.expect(4);
+
+ const payload = {
+ foo: 'bar'
+ };
+ const cardOptions = { boo: 'baz' };
+
+ const card = {
+ name: 'test-card',
+ display: {
+ setup(element, options, env, setupPayload) {
+ assert.ok(containsNode(editorElement, element),
+ 'card element is part of the editor element');
+ assert.deepEqual(payload, setupPayload,
+ 'the payload is passed to the card');
+ assert.equal(env.name, 'test-card',
+ 'env.name is correct');
+ assert.deepEqual(options, cardOptions, 'correct cardOptions');
+ },
+ teardown() {
+ }
+ }
+ };
+
+ const mobiledoc = [
+ [],
+ [
+ [10, 'test-card', payload]
+ ]
+ ];
+ editor = new Editor(editorElement, {
+ mobiledoc,
+ cards: [card],
+ cardOptions
+ });
+});
+
+test('rendering a mobiledoc for editing calls #unknownCardHandler when it encounters an unknown card', (assert) => {
+ assert.expect(1);
+
+ const cardName = 'my-card';
+
+ const unknownCardHandler = (element, options, env /*,setupPayload*/) => {
+ assert.equal(env.name, cardName, 'includes card name in env');
+ };
+
+ const mobiledoc = [
+ [],
+ [
+ [10, cardName, {}]
+ ]
+ ];
+
+ editor = new Editor(editorElement, {mobiledoc, unknownCardHandler});
+});
+
+test('rendered card can fire edit hook to enter editing mode', (assert) => {
+ assert.expect(7);
+
+ const payload = { foo: 'bar' };
+ const cardOptions = { boo: 'baz' };
+
+ let returnedSetupValue = {some: 'object'};
+ let span;
+ const card = {
+ name: 'test-card',
+ display: {
+ setup(element, options, env/*, setupPayload*/) {
+ span = document.createElement('span');
+ span.onclick = function() {
+ assert.ok(true, 'precond - click occurred');
+ env.edit();
+ };
+ element.appendChild(span);
+ return returnedSetupValue;
+ },
+ teardown(passedValue) {
+ assert.ok(true, 'teardown called');
+ assert.equal(passedValue, returnedSetupValue,
+ 'teardown called with return value of setup');
+ }
+ },
+ edit: {
+ setup(element, options, env, setupPayload) {
+ assert.ok(containsNode(editorElement, element),
+ 'card element is part of the editor element');
+ assert.deepEqual(payload, setupPayload,
+ 'the payload is passed to the card');
+ assert.equal(env.name, 'test-card',
+ 'env.name is correct');
+ assert.deepEqual(options, cardOptions, 'correct cardOptions');
+ }
+ }
+ };
+
+ const mobiledoc = [
+ [],
+ [
+ [10, 'test-card', payload]
+ ]
+ ];
+ editor = new Editor(editorElement, {
+ mobiledoc,
+ cards: [card],
+ cardOptions
+ });
+
+ Helpers.dom.triggerEvent(span, 'click');
+});
+
+test('rendered card can fire edit hook to enter editing mode, then save', (assert) => {
+ assert.expect(3);
+
+ let setupPayloads = [];
+ let newPayload = {some: 'new values'};
+ let doEdit, doSave;
+ const card = {
+ name: 'test-card',
+ display: {
+ setup(element, options, env, setupPayload) {
+ setupPayloads.push(setupPayload);
+ doEdit = () => {
+ env.edit();
+ };
+ }
+ },
+ edit: {
+ setup(element, options, env) {
+ assert.ok(env.save,
+ 'env exposes save hook');
+ doSave = () => {
+ env.save(newPayload);
+ };
+ }
+ }
+ };
+
+ const payload = { foo: 'bar' };
+ const mobiledoc = [
+ [],
+ [
+ [10, 'test-card', payload]
+ ]
+ ];
+ editor = new Editor(editorElement, {
+ mobiledoc,
+ cards: [card]
+ });
+
+ doEdit();
+ doSave();
+ let [firstPayload, secondPayload] = setupPayloads;
+ assert.equal(firstPayload, payload, 'first display with mobiledoc payload');
+ assert.equal(secondPayload, newPayload, 'second display with new payload');
+});
+
+test('rendered card can fire edit hook to enter editing mode, then cancel', (assert) => {
+ assert.expect(3);
+
+ let setupPayloads = [];
+ let doEdit, doCancel;
+ const card = {
+ name: 'test-card',
+ display: {
+ setup(element, options, env, setupPayload) {
+ setupPayloads.push(setupPayload);
+ doEdit = () => {
+ env.edit();
+ };
+ }
+ },
+ edit: {
+ setup(element, options, env) {
+ assert.ok(env.cancel, 'env exposes cancel hook');
+ doCancel = () => {
+ env.cancel();
+ };
+ }
+ }
+ };
+
+ const payload = { foo: 'bar' };
+ const mobiledoc = [
+ [],
+ [
+ [10, 'test-card', payload]
+ ]
+ ];
+ editor = new Editor(editorElement, {
+ mobiledoc,
+ cards: [card]
+ });
+
+ doEdit();
+ doCancel();
+ let [firstPayload, secondPayload] = setupPayloads;
+ assert.equal(firstPayload, payload, 'first display with mobiledoc payload');
+ assert.equal(secondPayload, payload, 'second display with mobiledoc payload');
+});
diff --git a/tests/unit/editor/editor-destroy-test.js b/tests/unit/editor/editor-destroy-test.js
new file mode 100644
index 000000000..f671c1a56
--- /dev/null
+++ b/tests/unit/editor/editor-destroy-test.js
@@ -0,0 +1,36 @@
+const { module, test } = window.QUnit;
+import Helpers from '../../test-helpers';
+
+import { Editor } from 'content-kit-editor';
+
+let editor;
+let editorElement;
+
+module('Unit: Editor #destroy', {
+ beforeEach() {
+ let fixture = $('#qunit-fixture')[0];
+ editorElement = document.createElement('div');
+ editorElement.innerHTML = 'HELLO';
+ fixture.appendChild(editorElement);
+ editor = new Editor(editorElement);
+ },
+ afterEach() {
+ if (editor) {
+ editor.destroy();
+ }
+ }
+});
+
+test('removes toolbar from DOM', (assert) => {
+ let done = assert.async();
+
+ Helpers.dom.selectText('HELLO', editorElement);
+ Helpers.dom.triggerEvent(document, 'mouseup');
+
+ setTimeout(() => {
+ assert.hasElement('.ck-toolbar', 'toolbar is shown');
+ editor.destroy();
+ assert.hasNoElement('.ck-toolbar', 'toolbar is removed');
+ done();
+ });
+});
diff --git a/tests/unit/editor/editor-events-test.js b/tests/unit/editor/editor-events-test.js
new file mode 100644
index 000000000..948486d03
--- /dev/null
+++ b/tests/unit/editor/editor-events-test.js
@@ -0,0 +1,99 @@
+const { module, test } = QUnit;
+import Helpers from '../../test-helpers';
+
+import { Editor } from 'content-kit-editor';
+
+let editor, editorElement;
+let triggered = [];
+
+module('Unit: Editor: events', {
+ beforeEach() {
+ editorElement = document.createElement('div');
+ editorElement.innerHTML = 'this is the editor';
+ document.getElementById('qunit-fixture').appendChild(editorElement);
+
+ editor = new Editor(editorElement);
+ editor.trigger = (name) => triggered.push(name);
+ },
+
+ afterEach() {
+ if (editor) {
+ editor.destroy();
+ editor = null;
+ }
+ triggered = [];
+ }
+});
+
+function assertTriggered(name, assert, message=`triggers ${name}`) {
+ assert.ok(triggered.indexOf(name) > -1, message);
+}
+
+function assertNotTriggered(name, assert, message=`does not trigger ${name}`) {
+ assert.ok(triggered.indexOf(name) === -1, message);
+}
+
+test('mouseup when text is selected triggers "selection" event', (assert) => {
+ const done = assert.async();
+
+ Helpers.dom.selectText('the editor', editorElement);
+ Helpers.dom.triggerEvent(document, 'mouseup');
+
+ assertNotTriggered('selection', assert, 'no selection before timeout');
+
+ setTimeout(() => {
+ assertTriggered('selection', assert, 'no selection before timeout');
+
+ done();
+ });
+});
+
+test('multiple mouseups when text is selected trigger "selectionUpdated" event', (assert) => {
+ const done = assert.async();
+
+ Helpers.dom.selectText('the editor', editorElement);
+ Helpers.dom.triggerEvent(document, 'mouseup');
+
+ setTimeout(() => {
+ // mouseup again
+ Helpers.dom.triggerEvent(document, 'mouseup');
+
+ setTimeout(() => {
+ assertTriggered('selectionUpdated', assert);
+
+ done();
+ });
+ });
+});
+
+test('mouseup when no text is selected triggers no events', (assert) => {
+ const done = assert.async();
+
+ Helpers.dom.triggerEvent(document, 'mouseup');
+
+ setTimeout(() => {
+ assertNotTriggered('selection', assert);
+ assertNotTriggered('selectionUpdated', assert);
+ assertNotTriggered('selectionEnded', assert);
+
+ done();
+ });
+});
+
+test('mouseup after text was selected triggers "selectionEnded" event', (assert) => {
+ const done = assert.async();
+
+ Helpers.dom.selectText('the editor', editorElement);
+ Helpers.dom.triggerEvent(document, 'mouseup');
+
+ setTimeout(() => {
+ Helpers.dom.clearSelection();
+ Helpers.dom.triggerEvent(document, 'mouseup');
+
+ setTimeout(() => {
+ assertTriggered('selectionEnded', assert);
+
+ done();
+ });
+ });
+});
diff --git a/tests/unit/editor/editor-test.js b/tests/unit/editor/editor-test.js
new file mode 100644
index 000000000..b6dca0375
--- /dev/null
+++ b/tests/unit/editor/editor-test.js
@@ -0,0 +1,71 @@
+let fixture = document.getElementById('qunit-fixture');
+let editorElement = document.createElement('div');
+let editor;
+editorElement.id = 'editor1';
+editorElement.className = 'editor';
+
+import Editor from 'content-kit-editor/editor/editor';
+
+const { module, test } = window.QUnit;
+
+module('Unit: Editor', {
+ beforeEach: function() {
+ fixture.appendChild(editorElement);
+ },
+ afterEach: function() {
+ if (editor) {
+ editor.destroy();
+ }
+ fixture.removeChild(editorElement);
+ }
+});
+
+test('can create an editor via dom node reference', (assert) => {
+ editor = new Editor(editorElement);
+ assert.equal(editor.element, editorElement);
+});
+
+test('can create an editor via dom node reference from getElementById', (assert) => {
+ editor = new Editor(document.getElementById('editor1'));
+ assert.equal(editor.element, editorElement);
+});
+
+test('creating an editor without a class name adds appropriate class', (assert) => {
+ editorElement.className = '';
+
+ var editor = new Editor(document.getElementById('editor1'));
+ assert.equal(editor.element.className, 'ck-editor');
+});
+
+test('editor fires update event', (assert) => {
+ assert.expect(2);
+ let done = assert.async();
+
+ var editor = new Editor(editorElement);
+ editor.on('update', function(data) {
+ assert.equal(this, editor);
+ assert.equal(data.index, 99);
+ done();
+ });
+ editor.trigger('update', { index: 99 });
+});
+
+test('editor parses and renders mobiledoc format', (assert) => {
+ const mobiledoc = [
+ [],
+ [
+ [1, 'P', [
+ [[], 0, 'hello world']
+ ]]
+ ]
+ ];
+ editorElement.innerHTML = 'something here
';
+ let editor = new Editor(editorElement, {mobiledoc});
+
+ assert.ok(editor.mobiledoc, 'editor has mobiledoc');
+ assert.equal(editorElement.innerHTML,
+ `hello world
`);
+
+ assert.deepEqual(editor.serialize(), mobiledoc,
+ 'serialized editor === mobiledoc');
+});
diff --git a/tests/unit/models/marker-test.js b/tests/unit/models/marker-test.js
new file mode 100644
index 000000000..07e7d781b
--- /dev/null
+++ b/tests/unit/models/marker-test.js
@@ -0,0 +1,88 @@
+const {module, test} = QUnit;
+
+import Marker from 'content-kit-editor/models/marker';
+import Markup from 'content-kit-editor/models/markup';
+
+module('Unit: Marker');
+
+test('Marker exists', (assert) => {
+ assert.ok(Marker);
+});
+
+test('a marker can truncated from an offset', (assert) => {
+ const m1 = new Marker('hi there!');
+
+ const offset = 5;
+ m1.truncateFrom(offset);
+
+ assert.equal(m1.value, 'hi th');
+});
+
+test('a marker can truncated to an offset', (assert) => {
+ const m1 = new Marker('hi there!');
+
+ const offset = 5;
+ m1.truncateTo(offset);
+
+ assert.equal(m1.value, 'ere!');
+});
+
+test('a marker can have a markup applied to it', (assert) => {
+ const m1 = new Marker('hi there!');
+ m1.addMarkup(new Markup('b'));
+
+ assert.ok(m1.hasMarkup('b'));
+});
+
+test('a marker can have the same markup tagName applied twice', (assert) => {
+ const m1 = new Marker('hi there!');
+ m1.addMarkup(new Markup('b'));
+ m1.addMarkup(new Markup('b'));
+
+ assert.equal(m1.markups.length, 2, 'markup only applied once');
+});
+
+test('a marker can have a complex markup applied to it', (assert) => {
+ const m1 = new Marker('hi there!');
+ const markup = new Markup('a', {href:'blah'});
+ m1.addMarkup(markup);
+
+ assert.ok(m1.hasMarkup('a'));
+ assert.equal(m1.getMarkup('a').attributes.href, 'blah');
+});
+
+test('a marker can have the same complex markup tagName applied twice, even with different attributes', (assert) => {
+ const m1 = new Marker('hi there!');
+ const markup1 = new Markup('a', {href:'blah'});
+ const markup2 = new Markup('a', {href:'blah2'});
+ m1.addMarkup(markup1);
+ m1.addMarkup(markup2);
+
+ assert.equal(m1.markups.length, 2, 'only one markup');
+ assert.equal(m1.getMarkup('a').attributes.href, 'blah',
+ 'first markup is applied');
+});
+
+test('a marker can be joined to another', (assert) => {
+ const m1 = new Marker('hi');
+ m1.addMarkup(new Markup('b'));
+ const m2 = new Marker(' there!');
+ m2.addMarkup(new Markup('i'));
+
+ const m3 = m1.join(m2);
+ assert.equal(m3.value, 'hi there!');
+ assert.ok(m3.hasMarkup('b'));
+ assert.ok(m3.hasMarkup('i'));
+});
+
+test('a marker can be split into two', (assert) => {
+ const m1 = new Marker('hi there!');
+ m1.addMarkup(new Markup('b'));
+
+ const [_m1, m2] = m1.split(5);
+ assert.ok(_m1.hasMarkup('b') && m2.hasMarkup('b'),
+ 'both markers get the markup');
+
+ assert.equal(_m1.value, 'hi th');
+ assert.equal(m2.value, 'ere!');
+});
diff --git a/tests/unit/models/section-test.js b/tests/unit/models/section-test.js
new file mode 100644
index 000000000..149f1f60d
--- /dev/null
+++ b/tests/unit/models/section-test.js
@@ -0,0 +1,115 @@
+const {module, test} = QUnit;
+
+import Section from 'content-kit-editor/models/markup-section';
+import Marker from 'content-kit-editor/models/marker';
+import Markup from 'content-kit-editor/models/markup';
+
+module('Unit: Section');
+
+test('Section exists', (assert) => {
+ assert.ok(Section);
+});
+
+test('a section can append a marker', (assert) => {
+ const s1 = new Section();
+ const m1 = new Marker('hello');
+
+ s1.appendMarker(m1);
+ assert.equal(s1.markers.length, 1);
+});
+
+test('#markerContaining finds the marker at the given offset when 1 marker', (assert) => {
+ const m = new Marker('hi there!');
+ const s = new Section('h2',[m]);
+
+ for (let i=0; i {
+ const m1 = new Marker('hi ');
+ const m2 = new Marker('there!');
+ const s = new Section('h2',[m1,m2]);
+
+ assert.equal(s.markerContaining(0), m1,
+ 'first marker is always found at offset 0');
+ assert.equal(s.markerContaining(m1.length + m2.length), m2,
+ 'last marker is always found at offset === length');
+ assert.equal(s.markerContaining(m1.length + m2.length + 1), m2,
+ 'last marker is always found at offset > length');
+
+ for (let i=1; i {
+ const m1 = new Marker('hi ');
+ const m2 = new Marker('there!');
+ const m3 = new Marker(' and more');
+ const markerLength = [m1,m2,m3].reduce((prev, cur) => prev + cur.length, 0);
+ const s = new Section('h2',[m1,m2,m3]);
+
+ assert.equal(s.markerContaining(0), m1,
+ 'first marker is always found at offset 0');
+ assert.equal(s.markerContaining(markerLength), m3,
+ 'last marker is always found at offset === length');
+ assert.equal(s.markerContaining(markerLength + 1), m3,
+ 'last marker is always found at offset > length');
+
+ for (let i=1; i {
+ const m = new Marker('hi there!', [new Markup('b')]);
+ const s = new Section('p', [m]);
+
+ const [s1, s2] = s.split(5);
+ assert.equal(s1.markers.length, 1, 's1 has marker');
+ assert.equal(s2.markers.length, 1, 's2 has marker');
+
+ assert.ok(s1.markers[0].hasMarkup('b'));
+ assert.equal(s1.markers[0].value, 'hi th');
+
+ assert.ok(s2.markers[0].hasMarkup('b'));
+ assert.equal(s2.markers[0].value, 'ere!');
+});
+
+test('a section can be split, splitting its markers when multiple markers', (assert) => {
+ const m1 = new Marker('hi ');
+ const m2 = new Marker('there!');
+ const s = new Section('h2', [m1,m2]);
+
+ const [s1, s2] = s.split(5);
+ assert.equal(s1.markers.length, 2, 's1 has 2 markers');
+ assert.equal(s2.markers.length, 1, 's2 has 1 marker');
+
+ assert.equal(s1.markers[0].value, 'hi ');
+ assert.equal(s1.markers[1].value, 'th');
+ assert.equal(s2.markers[0].value, 'ere!');
+});
+
+// test: a section can parse dom
+
+// test: a section can clear a range:
+// * truncating the markers on the boundaries
+// * removing the intermediate markers
+// * connecting (but not joining) the truncated boundary markers
diff --git a/tests/unit/parsers/dom-test.js b/tests/unit/parsers/dom-test.js
new file mode 100644
index 000000000..f83a752e9
--- /dev/null
+++ b/tests/unit/parsers/dom-test.js
@@ -0,0 +1,519 @@
+import DOMParser from 'content-kit-editor/parsers/dom';
+import { generateBuilder } from 'content-kit-editor/utils/post-builder';
+
+const { module, test } = window.QUnit;
+
+function buildDOM(html) {
+ var div = document.createElement('div');
+ div.innerHTML = html;
+ return div;
+}
+
+let parser, builder, expectedPost;
+
+module('Unit: DOMParser', {
+ beforeEach() {
+ parser = new DOMParser();
+ builder = generateBuilder();
+ expectedPost = builder.generatePost();
+ },
+ afterEach() {
+ parser = null;
+ builder = null;
+ expectedPost = null;
+ }
+});
+
+test('parse empty content', (assert) => {
+ const post = parser.parse(buildDOM(''));
+ assert.deepEqual(post, expectedPost);
+});
+
+test('blank textnodes are ignored', (assert) => {
+ let post = parser.parse(buildDOM('first line
\nsecond line
'));
+
+ let expectedFirst = builder.generateMarkupSection('P');
+ expectedFirst.appendMarker(builder.generateMarker([], 'first line'));
+ expectedPost.appendSection(expectedFirst);
+ let expectedSecond = builder.generateMarkupSection('P');
+ expectedSecond.appendMarker(builder.generateMarker([], 'second line'));
+ expectedPost.appendSection(expectedSecond);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('textnode adjacent to p tag becomes section', (assert) => {
+ const post = parser.parse(buildDOM('first line
second line'));
+
+ let expectedFirst = builder.generateMarkupSection('P');
+ expectedFirst.appendMarker(builder.generateMarker([], 'first line'));
+ expectedPost.appendSection(expectedFirst);
+ let expectedSecond = builder.generateMarkupSection('P', {}, true);
+ expectedSecond.appendMarker(builder.generateMarker([], 'second line'));
+ expectedPost.appendSection(expectedSecond);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('p tag (section markup) should create a block', (assert) => {
+ const post = parser.parse(buildDOM('text
'));
+
+ let expectedFirst = builder.generateMarkupSection('P');
+ expectedFirst.appendMarker(builder.generateMarker([], 'text'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('strong tag (stray markup) without a block should create a block', (assert) => {
+ const post = parser.parse(buildDOM('text'));
+
+ let expectedFirst = builder.generateMarkupSection('P', {}, true);
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('STRONG')
+ ], 'text'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('strong tag with inner em (stray markup) without a block should create a block', (assert) => {
+ const post = parser.parse(buildDOM('stray markup tags.'));
+
+ let expectedFirst = builder.generateMarkupSection('P', {}, true);
+ let strong = builder.generateMarkup('STRONG');
+ expectedFirst.appendMarker(builder.generateMarker([
+ strong,
+ builder.generateMarkup('EM')
+ ], 'stray'));
+ expectedFirst.appendMarker(builder.generateMarker([strong], ' markup tags'));
+ expectedFirst.appendMarker(builder.generateMarker([], '.'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('stray text (stray markup) should create a block', (assert) => {
+ const post = parser.parse(buildDOM('text'));
+
+ let expectedFirst = builder.generateMarkupSection('P', {}, true);
+ expectedFirst.appendMarker(builder.generateMarker([], 'text'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('text node, strong tag, text node (stray markup) without a block should create a block', (assert) => {
+ const post = parser.parse(buildDOM('start bold end'));
+
+ let expectedFirst = builder.generateMarkupSection('P', {}, true);
+ expectedFirst.appendMarker(builder.generateMarker([], 'start '));
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('STRONG')
+ ], 'bold'));
+ expectedFirst.appendMarker(builder.generateMarker([], ' end'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('italic tag (stray markup) without a block should create a block', (assert) => {
+ const post = parser.parse(buildDOM('text'));
+
+ let expectedFirst = builder.generateMarkupSection('P', {}, true);
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('EM')
+ ], 'text'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('u tag (stray markup) without a block should strip U and create a block', (assert) => {
+ const post = parser.parse(buildDOM('text'));
+
+ let expectedFirst = builder.generateMarkupSection('P', {}, true);
+ expectedFirst.appendMarker(builder.generateMarker([], 'text'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('a tag (stray markup) without a block should create a block', (assert) => {
+ var url = "http://test.com";
+ const post = parser.parse(buildDOM('text'));
+
+ let expectedFirst = builder.generateMarkupSection('P', {}, true);
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('A', ['href', url])
+ ], 'text'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+/* FIXME: What should happen with br
+test('markup: break', (assert) => {
+ const post = parser.parse(buildDOM('line
break'));
+
+ let expectedFirst = builder.generateMarkupSection('P', {}, true);
+ expectedFirst.appendMarker(builder.generateMarker([], 0, 'line '));
+ expectedFirst.appendMarker(builder.generateMarker([], 0, 'break'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+*/
+
+test('sub tag (stray markup) without a block should filter SUB and create a block', (assert) => {
+ const post = parser.parse(buildDOM('footnote1'));
+
+ let expectedFirst = builder.generateMarkupSection('P', {}, true);
+ expectedFirst.appendMarker(builder.generateMarker([], 'footnote'));
+ expectedFirst.appendMarker(builder.generateMarker([], '1'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('sup tag (stray markup) without a block should filter SUP and create a block', (assert) => {
+ const post = parser.parse(buildDOM('e=mc2'));
+
+ let expectedFirst = builder.generateMarkupSection('P', {}, true);
+ expectedFirst.appendMarker(builder.generateMarker([], 'e=mc'));
+ expectedFirst.appendMarker(builder.generateMarker([], '2'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('list (stray markup) without a block should create a block', (assert) => {
+ const post = parser.parse(buildDOM('- Item 1
- Item 2
'));
+
+ let expectedFirst = builder.generateMarkupSection('UL');
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('LI')
+ ], 'Item 1'));
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('LI')
+ ], 'Item 2'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('nested tags (section markup) should create a block', (assert) => {
+ const post = parser.parse(buildDOM('Double. Double staggered start. Double staggered end. Double staggered middle.
'));
+
+ let expectedFirst = builder.generateMarkupSection('P');
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('EM'),
+ builder.generateMarkup('STRONG')
+ ], 'Double.'));
+ expectedFirst.appendMarker(builder.generateMarker([], ' '));
+ let firstStrong = builder.generateMarkup('STRONG');
+ expectedFirst.appendMarker(builder.generateMarker([
+ firstStrong,
+ builder.generateMarkup('EM')
+ ], 'Double staggered'));
+ expectedFirst.appendMarker(builder.generateMarker([firstStrong], ' start.'));
+ expectedFirst.appendMarker(builder.generateMarker([], ' '));
+ let secondStrong = builder.generateMarkup('STRONG');
+ expectedFirst.appendMarker(builder.generateMarker([
+ secondStrong
+ ], 'Double '));
+ expectedFirst.appendMarker(builder.generateMarker([
+ secondStrong,
+ builder.generateMarkup('EM')
+ ], 'staggered end.'));
+ expectedFirst.appendMarker(builder.generateMarker([], ' '));
+ let thirdStrong = builder.generateMarkup('STRONG');
+ expectedFirst.appendMarker(builder.generateMarker([
+ thirdStrong
+ ], 'Double '));
+ expectedFirst.appendMarker(builder.generateMarker([
+ thirdStrong,
+ builder.generateMarkup('EM')
+ ], 'staggered'));
+ expectedFirst.appendMarker(builder.generateMarker([thirdStrong], ' middle.'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+ let sectionMarkers = post.sections[0].markers;
+ assert.equal(sectionMarkers[2].markups[0], sectionMarkers[3].markups[0]);
+});
+
+/*
+ * FIXME: Update these tests to use the renderer
+ *
+test('markup: nested/unsupported tags', (assert) => {
+ var parsed = compiler.parse('Test one two three four five six seven
');
+
+ equal ( parsed.length, 1 );
+ equal ( parsed[0].type, Type.PARAGRAPH.id );
+ equal ( parsed[0].value, 'Test one two three four five six seven' );
+ equal ( parsed[0].markup.length, 5 );
+
+ equal ( parsed[0].markup[0].type, Type.BOLD.id );
+ equal ( parsed[0].markup[0].start, 9 );
+ equal ( parsed[0].markup[0].end, 12 );
+
+ equal ( parsed[0].markup[1].type, Type.ITALIC.id );
+ equal ( parsed[0].markup[1].start, 13 );
+ equal ( parsed[0].markup[1].end, 18 );
+
+ equal ( parsed[0].markup[2].type, Type.BOLD.id );
+ equal ( parsed[0].markup[2].start, 13 );
+ equal ( parsed[0].markup[2].end, 18 );
+
+ equal ( parsed[0].markup[3].type, Type.BOLD.id );
+ equal ( parsed[0].markup[3].start, 24 );
+ equal ( parsed[0].markup[3].end, 28 );
+
+ equal ( parsed[0].markup[4].type, Type.BOLD.id );
+ equal ( parsed[0].markup[4].start, 29 );
+ equal ( parsed[0].markup[4].end, 32 );
+});
+
+test('markup: preserves spaces in empty tags', (assert) => {
+ var rendered = compiler.rerender('Testing a space
');
+ equal ( rendered, 'Testing a space
');
+});
+
+test('markup: self-closing tags with nesting', (assert) => {
+ var input = 'Blah
blah
blah
';
+ var parsed = compiler.parse(input);
+
+ equal ( parsed[0].value, 'Blah blah blah' );
+ equal ( parsed[0].markup.length, 3 );
+
+ equal ( parsed[0].markup[0].type, Type.BOLD.id );
+ equal ( parsed[0].markup[0].start, 0 );
+ equal ( parsed[0].markup[0].end, 9 );
+
+ equal ( parsed[0].markup[1].type, Type.BREAK.id );
+ equal ( parsed[0].markup[1].start, 5 );
+ equal ( parsed[0].markup[1].end, 5 );
+
+ equal ( parsed[0].markup[2].type, Type.BREAK.id );
+ equal ( parsed[0].markup[2].start, 10 );
+ equal ( parsed[0].markup[2].end, 10 );
+});
+
+test('markup: whitespace', (assert) => {
+ var parsed = compiler.parse(' ' +
+ '\t - Item 1
\n' +
+ ' - Item 2
\r\n ' +
+ '\t\t- Item 3
\r' +
+ '
');
+ equal ( parsed.length, 1 );
+ equal ( parsed[0].value, 'Item 1 Item 2 Item 3' );
+
+ var markup = parsed[0].markup
+ equal ( markup.length, 6);
+ equal ( markup[0].type, Type.LIST_ITEM.id );
+ equal ( markup[0].start, 0 );
+ equal ( markup[0].end, 6 );
+ equal ( markup[1].type, Type.ITALIC.id );
+ equal ( markup[1].start, 5 );
+ equal ( markup[1].end, 6 );
+ equal ( markup[2].type, Type.LIST_ITEM.id );
+ equal ( markup[2].start, 7 );
+ equal ( markup[2].end, 13 );
+ equal ( markup[3].type, Type.BOLD.id );
+ equal ( markup[3].start, 7 );
+ equal ( markup[3].end, 13 );
+ equal ( markup[4].type, Type.LIST_ITEM.id );
+ equal ( markup[4].start, 14 );
+ equal ( markup[4].end, 20 );
+ equal ( markup[5].type, Type.BOLD.id );
+ equal ( markup[5].start, 14 );
+ equal ( markup[5].end, 18 );
+});
+
+test('markup: consistent order', (assert) => {
+ var correctlyOrdered = compiler.parse('');
+ var incorrectlyOrdered = compiler.parse('');
+
+ equal( compiler.render(correctlyOrdered), compiler.render(incorrectlyOrdered) );
+});
+*/
+
+test('attributes', (assert) => {
+ const href = 'http://google.com';
+ const rel = 'nofollow';
+ const post = parser.parse(buildDOM(``));
+
+ let expectedFirst = builder.generateMarkupSection('P');
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('A', ['href', href, 'rel', rel])
+ ], 'Link to google.com'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('attributes filters out inline styles and classes', (assert) => {
+ const post = parser.parse(buildDOM('test
'));
+
+ let expectedFirst = builder.generateMarkupSection('P');
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('B')
+ ], 'test'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('blocks: paragraph', (assert) => {
+ const post = parser.parse(buildDOM('TEXT
'));
+
+ let expectedFirst = builder.generateMarkupSection('P');
+ expectedFirst.appendMarker(builder.generateMarker([], 'TEXT'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('blocks: heading', (assert) => {
+ const post = parser.parse(buildDOM('TEXT
'));
+
+ let expectedFirst = builder.generateMarkupSection('H2');
+ expectedFirst.appendMarker(builder.generateMarker([], 'TEXT'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('blocks: subheading', (assert) => {
+ const post = parser.parse(buildDOM('TEXT
'));
+
+ let expectedFirst = builder.generateMarkupSection('H3');
+ expectedFirst.appendMarker(builder.generateMarker([], 'TEXT'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+/* FIXME: should not create a markup type section
+test('blocks: image', (assert) => {
+ var url = "http://domain.com/text.png";
+ const post = parser.parse(buildDOM('
'));
+ assert.deepEqual( post, {
+ sections: [{
+ type: MARKUP_SECTION,
+ tagName: 'IMG',
+ attributes: ['src', url],
+ markups: []
+ }]
+ });
+});
+*/
+
+test('blocks: quote', (assert) => {
+ const post = parser.parse(buildDOM('quote
'));
+
+ let expectedFirst = builder.generateMarkupSection('BLOCKQUOTE');
+ expectedFirst.appendMarker(builder.generateMarker([], 'quote'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('blocks: list', (assert) => {
+ const post = parser.parse(buildDOM('- Item 1
- Item 2
'));
+
+ let expectedFirst = builder.generateMarkupSection('UL');
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('LI')
+ ], 'Item 1'));
+ expectedFirst.appendMarker(builder.generateMarker([], ' '));
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('LI')
+ ], 'Item 2'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+test('blocks: ordered list', (assert) => {
+ const post = parser.parse(buildDOM('- Item 1
- Item 2
'));
+
+ let expectedFirst = builder.generateMarkupSection('OL');
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('LI')
+ ], 'Item 1'));
+ expectedFirst.appendMarker(builder.generateMarker([], ' '));
+ expectedFirst.appendMarker(builder.generateMarker([
+ builder.generateMarkup('LI')
+ ], 'Item 2'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
+
+/*
+test('blocks: mixed', (assert) => {
+ var input = 'The Title
The Subtitle
TEXT 1
TEXT 2
TEXT with a link.
Quote
';
+ var parsed = compiler.parse(input);
+
+ equal ( parsed.length, 6 );
+ equal ( parsed[0].type, Type.HEADING.id );
+ equal ( parsed[1].type, Type.SUBHEADING.id );
+ equal ( parsed[2].type, Type.PARAGRAPH.id );
+ equal ( parsed[3].type, Type.PARAGRAPH.id );
+ equal ( parsed[4].type, Type.PARAGRAPH.id );
+ equal ( parsed[5].type, Type.QUOTE.id );
+});
+*/
+
+/* FIXME: needs images, br support
+test('blocks: self-closing', (assert) => {
+ var url = 'http://domain.com/test.png';
+ const post = parser.parse(buildDOM('
Line
break
'));
+
+ assert.deepEqual( post, {
+ sections: [{
+ type: MARKUP_SECTION,
+ tagName: 'IMG',
+ attributes: ['src', url],
+ markups: []
+ }, {
+ type: MARKUP_SECTION,
+ tagName: 'P',
+ markups: [{
+ open: [],
+ close: 0,
+ value: 'Line'
+ }, {
+ open: [{
+ tagName: 'BR'
+ }],
+ close: 1,
+ value: null
+ }, {
+ open: [],
+ close: 0,
+ value: 'break'
+ }]
+ }]
+ });
+});
+*/
+
+test('converts tags to mapped values', (assert) => {
+ // FIXME: Should probably be normalizing b to strong etc
+ const post = parser.parse(buildDOM('Converts tags.
'));
+
+ let expectedFirst = builder.generateMarkupSection('P');
+ let bold = builder.generateMarkup('B');
+ expectedFirst.appendMarker(builder.generateMarker([
+ bold,
+ builder.generateMarkup('I')
+ ], 'Converts'));
+ expectedFirst.appendMarker(builder.generateMarker([bold], ' tags'));
+ expectedFirst.appendMarker(builder.generateMarker([], '.'));
+ expectedPost.appendSection(expectedFirst);
+
+ assert.deepEqual(post, expectedPost);
+});
diff --git a/tests/unit/parsers/mobiledoc-test.js b/tests/unit/parsers/mobiledoc-test.js
new file mode 100644
index 000000000..b19547f2c
--- /dev/null
+++ b/tests/unit/parsers/mobiledoc-test.js
@@ -0,0 +1,115 @@
+import MobiledocParser from 'content-kit-editor/parsers/mobiledoc';
+import { generateBuilder } from 'content-kit-editor/utils/post-builder';
+
+const DATA_URL = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=";
+const { module, test } = window.QUnit;
+
+let parser, builder, post;
+
+module('Unit: Parsers: Mobiledoc', {
+ beforeEach() {
+ parser = new MobiledocParser();
+ builder = generateBuilder();
+ post = builder.generatePost();
+ },
+ afterEach() {
+ parser = null;
+ builder = null;
+ post = null;
+ }
+});
+
+test('#parse empty doc returns an empty post', (assert) => {
+ assert.deepEqual(parser.parse([[], []]),
+ post);
+});
+
+test('#parse doc without marker types', (assert) => {
+ const mobiledoc = [
+ [],
+ [[
+ 1,'P', [[[], 0, 'hello world']]
+ ]]
+ ];
+ const parsed = parser.parse(mobiledoc);
+
+ let section = builder.generateMarkupSection('P', [], false);
+ let marker = builder.generateMarker([], 'hello world');
+ section.appendMarker(marker);
+ post.appendSection(section);
+
+ assert.deepEqual(
+ parsed,
+ post
+ );
+});
+
+test('#parse doc with marker type', (assert) => {
+ const mobiledoc = [
+ [
+ ['B'],
+ ['A', ['href', 'google.com']]
+ ],
+ [[
+ 1,'P', [
+ [[1], 0, 'hello'], // a tag open
+ [[0], 1, 'brave new'], // b tag open/close
+ [[], 1, 'world'] // a tag close
+ ]
+ ]]
+ ];
+ const parsed = parser.parse(mobiledoc);
+
+ let section = builder.generateMarkupSection('P', [], false);
+ let aMarkerType = builder.generateMarkup('A', ['href', 'google.com']);
+ let bMarkerType = builder.generateMarkup('B');
+
+ let markers = [
+ builder.generateMarker([aMarkerType], 'hello'),
+ builder.generateMarker([aMarkerType, bMarkerType], 'brave new'),
+ builder.generateMarker([aMarkerType], 'world')
+ ];
+ markers.forEach(marker => section.appendMarker(marker));
+ post.appendSection(section);
+
+ assert.deepEqual(
+ parsed,
+ post
+ );
+});
+
+test('#parse doc with image section', (assert) => {
+ const mobiledoc = [
+ [],
+ [
+ [2, DATA_URL]
+ ]
+ ];
+
+ const parsed = parser.parse(mobiledoc);
+
+ let section = builder.generateImageSection(DATA_URL);
+ post.appendSection(section);
+ assert.deepEqual(
+ parsed,
+ post
+ );
+});
+
+test('#parse doc with custom card type', (assert) => {
+ const mobiledoc = [
+ [],
+ [
+ [10, 'custom-card', {}]
+ ]
+ ];
+
+ const parsed = parser.parse(mobiledoc);
+
+ let section = builder.generateCardSection('custom-card');
+ post.appendSection(section);
+ assert.deepEqual(
+ parsed,
+ post
+ );
+});
diff --git a/tests/unit/parsers/post-test.js b/tests/unit/parsers/post-test.js
new file mode 100644
index 000000000..0bb8f69c4
--- /dev/null
+++ b/tests/unit/parsers/post-test.js
@@ -0,0 +1,61 @@
+const {module, test} = QUnit;
+
+import PostParser from 'content-kit-editor/parsers/post';
+import Helpers from '../../test-helpers';
+
+module('Unit: Parser: PostParser');
+
+test('#parse can parse a single text node', (assert) => {
+ let element = Helpers.dom.makeDOM(t =>
+ t('div', {}, [t.text('some text')])
+ );
+
+ const post = PostParser.parse(element);
+ assert.ok(post, 'gets post');
+ assert.equal(post.sections.length, 1, 'has 1 section');
+
+ const s1 = post.sections[0];
+ assert.equal(s1.markers.length, 1, 's1 has 1 marker');
+ assert.equal(s1.markers[0].value, 'some text', 'has text');
+});
+
+test('#parse can parse a section element', (assert) => {
+ let element = Helpers.dom.makeDOM(t =>
+ t('div', {}, [
+ t('p', {}, [
+ t.text('some text')
+ ])
+ ])
+ );
+
+ const post = PostParser.parse(element);
+ assert.ok(post, 'gets post');
+ assert.equal(post.sections.length, 1, 'has 1 section');
+
+ const s1 = post.sections[0];
+ assert.equal(s1.markers.length, 1, 's1 has 1 marker');
+ assert.equal(s1.markers[0].value, 'some text', 'has text');
+});
+
+test('#parse can parse multiple elements', (assert) => {
+ let element = Helpers.dom.makeDOM(t =>
+ t('div', {}, [
+ t('p', {}, [
+ t.text('some text')
+ ]),
+ t.text('some other text')
+ ])
+ );
+
+ const post = PostParser.parse(element);
+ assert.ok(post, 'gets post');
+ assert.equal(post.sections.length, 2, 'has 2 sections');
+
+ const [s1, s2] = post.sections;
+ assert.equal(s1.markers.length, 1, 's1 has 1 marker');
+ assert.equal(s1.markers[0].value, 'some text');
+
+ assert.equal(s2.markers.length, 1, 's2 has 1 marker');
+ assert.equal(s2.markers[0].value, 'some other text');
+});
+
diff --git a/tests/unit/parsers/section-test.js b/tests/unit/parsers/section-test.js
new file mode 100644
index 000000000..c1ce21416
--- /dev/null
+++ b/tests/unit/parsers/section-test.js
@@ -0,0 +1,122 @@
+const {module, test} = QUnit;
+
+import SectionParser from 'content-kit-editor/parsers/section';
+import Helpers from '../../test-helpers';
+
+module('Unit: Parser: SectionParser');
+
+test('#parse parses simple dom', (assert) => {
+ let element = Helpers.dom.makeDOM(t =>
+ t('p', {}, [
+ t.text('hello there'),
+ t('b', {}, [
+ t.text('i am bold')
+ ])
+ ])
+ );
+
+ const section = SectionParser.parse(element);
+ assert.equal(section.tagName, 'p');
+ assert.equal(section.markers.length, 2, 'has 2 markers');
+ const [m1, m2] = section.markers;
+
+ assert.equal(m1.value, 'hello there');
+ assert.equal(m2.value, 'i am bold');
+ assert.ok(m2.hasMarkup('b'), 'm2 is bold');
+});
+
+test('#parse parses nested markups', (assert) => {
+ let element = Helpers.dom.makeDOM(t =>
+ t('p', {}, [
+ t('b', {}, [
+ t.text('i am bold'),
+ t('i', {}, [
+ t.text('i am bold and italic')
+ ]),
+ t.text('i am bold again')
+ ])
+ ])
+ );
+
+ const section = SectionParser.parse(element);
+ assert.equal(section.markers.length, 3, 'has 3 markers');
+ const [m1, m2, m3] = section.markers;
+
+ assert.equal(m1.value, 'i am bold');
+ assert.equal(m2.value, 'i am bold and italic');
+ assert.equal(m3.value, 'i am bold again');
+ assert.ok(m1.hasMarkup('b'), 'm1 is bold');
+ assert.ok(m2.hasMarkup('b') && m2.hasMarkup('i'), 'm2 is bold and i');
+ assert.ok(m3.hasMarkup('b'), 'm3 is bold');
+ assert.ok(!m1.hasMarkup('i') && !m3.hasMarkup('i'), 'm1 and m3 are not i');
+});
+
+test('#parse ignores non-markup elements like spans', (assert) => {
+ let element = Helpers.dom.makeDOM(t =>
+ t('p', {}, [
+ t('span', {}, [
+ t.text('i was in span')
+ ])
+ ])
+ );
+
+ const section = SectionParser.parse(element);
+ assert.equal(section.tagName, 'p');
+ assert.equal(section.markers.length, 1, 'has 1 markers');
+ const [m1] = section.markers;
+
+ assert.equal(m1.value, 'i was in span');
+});
+
+test('#parse reads attributes', (assert) => {
+ let element = Helpers.dom.makeDOM(t =>
+ t('p', {}, [
+ t('a', {href: 'google.com'}, [
+ t.text('i am a link')
+ ])
+ ])
+ );
+ const section = SectionParser.parse(element);
+ assert.equal(section.markers.length, 1, 'has 1 markers');
+ const [m1] = section.markers;
+ assert.equal(m1.value, 'i am a link');
+ assert.ok(m1.hasMarkup('a'), 'has "a" markup');
+ assert.equal(m1.getMarkup('a').attributes.href, 'google.com');
+});
+
+test('#parse joins contiguous text nodes separated by non-markup elements', (assert) => {
+ let element = Helpers.dom.makeDOM(t =>
+ t('p', {}, [
+ t('span', {}, [
+ t.text('span 1')
+ ]),
+ t('span', {}, [
+ t.text('span 2')
+ ])
+ ])
+ );
+
+ const section = SectionParser.parse(element);
+ assert.equal(section.tagName, 'p');
+ assert.equal(section.markers.length, 1, 'has 1 markers');
+ const [m1] = section.markers;
+
+ assert.equal(m1.value, 'span 1span 2');
+});
+
+test('#parse parses a single text node', (assert) => {
+ let element = Helpers.dom.makeDOM(h =>
+ h.text('raw text')
+ );
+ const section = SectionParser.parse(element);
+ assert.equal(section.tagName, 'p');
+ assert.equal(section.markers.length, 1, 'has 1 marker');
+ assert.equal(section.markers[0].value, 'raw text');
+});
+
+// test: a section can parse dom
+
+// test: a section can clear a range:
+// * truncating the markers on the boundaries
+// * removing the intermediate markers
+// * connecting (but not joining) the truncated boundary markers
diff --git a/tests/unit/renderers/editor-dom-test.js b/tests/unit/renderers/editor-dom-test.js
new file mode 100644
index 000000000..1ca09af11
--- /dev/null
+++ b/tests/unit/renderers/editor-dom-test.js
@@ -0,0 +1,218 @@
+import { generateBuilder } from 'content-kit-editor/utils/post-builder';
+const { module, test } = window.QUnit;
+import Renderer from 'content-kit-editor/renderers/editor-dom';
+import RenderNode from 'content-kit-editor/models/render-node';
+import RenderTree from 'content-kit-editor/models/render-tree';
+
+const DATA_URL = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=";
+let builder;
+
+function render(renderTree, cards=[]) {
+ let renderer = new Renderer(cards);
+ return renderer.render(renderTree);
+}
+
+module("Unit: Renderer", {
+ beforeEach() {
+ builder = generateBuilder();
+ }
+});
+
+test("It renders a dirty post", (assert) => {
+ /*
+ * renderTree is:
+ *
+ * renderNode
+ *
+ */
+ let renderNode = new RenderNode(builder.generatePost());
+ let renderTree = new RenderTree(renderNode);
+ renderNode.renderTree = renderTree;
+
+ render(renderTree);
+
+ assert.ok(renderTree.node.element, 'renderTree renders element for post');
+ assert.ok(!renderTree.node.isDirty, 'dirty node becomes clean');
+ assert.equal(renderTree.node.element.tagName, 'DIV', 'renderTree renders element for post');
+});
+
+test("It renders a dirty post with un-rendered sections", (assert) => {
+ let post = builder.generatePost();
+ let sectionA = builder.generateMarkupSection('P');
+ post.appendSection(sectionA);
+ let sectionB = builder.generateMarkupSection('P');
+ post.appendSection(sectionB);
+
+ let renderNode = new RenderNode(post);
+ let renderTree = new RenderTree(renderNode);
+ renderNode.renderTree = renderTree;
+
+ render(renderTree);
+
+ assert.equal(renderTree.node.element.outerHTML, '',
+ 'correct HTML is rendered');
+
+ assert.ok(renderTree.node.firstChild,
+ 'sectionA creates a first child');
+ assert.equal(renderTree.node.firstChild.postNode, sectionA,
+ 'sectionA is first renderNode child');
+ assert.ok(!renderTree.node.firstChild.isDirty, 'sectionA node is clean');
+ assert.equal(renderTree.node.lastChild.postNode, sectionB,
+ 'sectionB is second renderNode child');
+ assert.ok(!renderTree.node.lastChild.isDirty, 'sectionB node is clean');
+});
+
+[
+ {
+ name: 'markup',
+ section: (builder) => builder.generateMarkupSection('P')
+ },
+ {
+ name: 'image',
+ section: (builder) => builder.generateImageSection(DATA_URL)
+ },
+ {
+ name: 'card',
+ section: (builder) => builder.generateCardSection('new-card')
+ }
+].forEach((testInfo) => {
+ test(`Remove nodes with ${testInfo.name} section`, (assert) => {
+ let post = builder.generatePost();
+ let section = testInfo.section(builder);
+ post.appendSection(section);
+
+ let postElement = document.createElement('div');
+ let sectionElement = document.createElement('p');
+ postElement.appendChild(sectionElement);
+
+ let postRenderNode = new RenderNode(post);
+
+ let renderTree = new RenderTree(postRenderNode);
+ postRenderNode.renderTree = renderTree;
+ postRenderNode.element = postElement;
+
+ let sectionRenderNode = renderTree.buildRenderNode(section);
+ sectionRenderNode.element = sectionElement;
+ sectionRenderNode.scheduleForRemoval();
+ postRenderNode.appendChild(sectionRenderNode);
+
+ render(renderTree);
+
+ assert.equal(renderTree.node.element, postElement,
+ 'post element remains');
+
+ assert.equal(renderTree.node.element.firstChild, null,
+ 'section element removed');
+
+ assert.equal(renderTree.node.firstChild, null,
+ 'section renderNode is removed');
+ });
+});
+
+test('renders a post with marker', (assert) => {
+ let post = builder.generatePost();
+ let section = builder.generateMarkupSection('P');
+ post.appendSection(section);
+ section.appendMarker(
+ builder.generateMarker([
+ builder.generateMarkup('STRONG')
+ ], 'Hi')
+ );
+
+ let node = new RenderNode(post);
+ let renderTree = new RenderTree(node);
+ node.renderTree = renderTree;
+ render(renderTree);
+ assert.equal(node.element.innerHTML, 'Hi
');
+});
+
+test('renders a post with image', (assert) => {
+ let url = DATA_URL;
+ let post = builder.generatePost();
+ let section = builder.generateImageSection(url);
+ post.appendSection(section);
+
+ let node = new RenderNode(post);
+ let renderTree = new RenderTree(node);
+ node.renderTree = renderTree;
+ render(renderTree);
+ assert.equal(node.element.innerHTML, `
`);
+});
+
+test('renders a card section', (assert) => {
+ let post = builder.generatePost();
+ let cardSection = builder.generateCardSection('my-card');
+ let card = {
+ name: 'my-card',
+ display: {
+ setup(element) {
+ element.innerHTML = 'I am a card';
+ }
+ }
+ };
+ post.appendSection(cardSection);
+
+ let node = new RenderNode(post);
+ let renderTree = new RenderTree(node);
+ node.renderTree = renderTree;
+ render(renderTree, [card]);
+
+ assert.equal(node.element.firstChild.innerHTML, 'I am a card',
+ 'card is rendered');
+});
+
+test('renders a card section into a non-contenteditable element', (assert) => {
+ assert.expect(2);
+
+ let post = builder.generatePost();
+ let cardSection = builder.generateCardSection('my-card');
+ let card = {
+ name: 'my-card',
+ display: {
+ setup(element) {
+ element.setAttribute('id', 'my-card-div');
+ }
+ }
+ };
+ post.appendSection(cardSection);
+
+ let node = new RenderNode(post);
+ let renderTree = new RenderTree(node);
+ node.renderTree = renderTree;
+ render(renderTree, [card]);
+
+ let element = node.element.firstChild;
+ assert.equal(element.getAttribute('id'), 'my-card-div',
+ 'precond - correct element selected');
+ assert.equal(element.contentEditable, 'false', 'is not contenteditable');
+});
+
+
+/*
+test("It renders a renderTree with rendered dirty section", (assert) => {
+ /*
+ * renderTree is:
+ *
+ * post
+ * / \
+ * / \
+ * section section
+ *
+ let post = builder.generatePost
+ let postRenderNode = {
+ element: null,
+ parent: null,
+ isDirty: true,
+ postNode: builder.generatePost()
+ }
+ let renderTree = {
+ node: renderNode
+ }
+
+ render(renderTree);
+
+ assert.ok(renderTree.node.element, 'renderTree renders element for post');
+ assert.ok(!renderTree.node.isDirty, 'dirty node becomes clean');
+ assert.equal(renderTree.node.element.tagName, 'DIV', 'renderTree renders element for post');
+});
+*/
diff --git a/tests/unit/renderers/mobiledoc-test.js b/tests/unit/renderers/mobiledoc-test.js
new file mode 100644
index 000000000..f95bb70b8
--- /dev/null
+++ b/tests/unit/renderers/mobiledoc-test.js
@@ -0,0 +1,115 @@
+import MobiledocRenderer from 'content-kit-editor/renderers/mobiledoc';
+import { generateBuilder } from 'content-kit-editor/utils/post-builder';
+
+const { module, test } = window.QUnit;
+const render = MobiledocRenderer.render;
+let builder;
+
+
+module('Unit: Mobiledoc Renderer', {
+ beforeEach() {
+ builder = generateBuilder();
+ }
+});
+
+test('renders a blank post', (assert) => {
+ let post = builder.generatePost();
+ let mobiledoc = render(post);
+ assert.deepEqual(mobiledoc, [[], []]);
+});
+
+test('renders a post with marker', (assert) => {
+ let post = builder.generatePost();
+ let section = builder.generateMarkupSection('P');
+ post.appendSection(section);
+ section.appendMarker(
+ builder.generateMarker([
+ builder.generateMarkup('STRONG')
+ ], 'Hi')
+ );
+ let mobiledoc = render(post);
+ assert.deepEqual(mobiledoc, [
+ [
+ ['strong']
+ ],
+ [
+ [1, 'P', [
+ [[0], 1, 'Hi']
+ ]]
+ ]
+ ]);
+});
+
+test('renders a post section with markers sharing a markup', (assert) => {
+ let post = builder.generatePost();
+ let section = builder.generateMarkupSection('P');
+ post.appendSection(section);
+ let markup = builder.generateMarkup('STRONG');
+ section.appendMarker(
+ builder.generateMarker([
+ markup
+ ], 'Hi')
+ );
+ section.appendMarker(
+ builder.generateMarker([
+ markup
+ ], ' Guy')
+ );
+ let mobiledoc = render(post);
+ assert.deepEqual(mobiledoc, [
+ [
+ ['strong']
+ ],
+ [
+ [1, 'P', [
+ [[0], 0, 'Hi'],
+ [[], 1, ' Guy']
+ ]]
+ ]
+ ]);
+});
+
+test('renders a post with image', (assert) => {
+ let url = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=";
+ let post = builder.generatePost();
+ let section = builder.generateImageSection(url);
+ post.appendSection(section);
+
+ let mobiledoc = render(post);
+ assert.deepEqual(mobiledoc, [
+ [],
+ [
+ [2, url]
+ ]
+ ]);
+});
+
+test('renders a post with image and null src', (assert) => {
+ let post = builder.generatePost();
+ let section = builder.generateImageSection();
+ post.appendSection(section);
+
+ let mobiledoc = render(post);
+ assert.deepEqual(mobiledoc, [
+ [],
+ [
+ [2, null]
+ ]
+ ]);
+});
+
+test('renders a post with card', (assert) => {
+ let cardName = 'super-card';
+ let payload = { bar: 'baz' };
+ let post = builder.generatePost();
+ let section = builder.generateCardSection(cardName, payload);
+ post.appendSection(section);
+
+ let mobiledoc = render(post);
+ assert.deepEqual(mobiledoc, [
+ [],
+ [
+ [10, cardName, payload]
+ ]
+ ]);
+});
diff --git a/dist/content-kit-editor.css b/website/css/content-kit-editor.css
similarity index 99%
rename from dist/content-kit-editor.css
rename to website/css/content-kit-editor.css
index 2168230fe..c6118cd01 100644
--- a/dist/content-kit-editor.css
+++ b/website/css/content-kit-editor.css
@@ -1,3 +1,144 @@
+/**
+ * Animations
+ */
+@-webkit-keyframes fade-in {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+@keyframes fade-in {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+@-webkit-keyframes spin {
+ to {
+ -webkit-transform: rotate(360deg);
+ }
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@-webkit-keyframes shake {
+ 0%,
+ 100% {
+ -webkit-transform: translateX(0);
+ }
+ 20%,
+ 60% {
+ -webkit-transform: translateX(-10px);
+ }
+ 40%,
+ 80% {
+ -webkit-transform: translateX(10px);
+ }
+}
+@keyframes shake {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+ 20%,
+ 60% {
+ transform: translateX(-10px);
+ }
+ 40%,
+ 80% {
+ transform: translateX(10px);
+ }
+}
+@-webkit-keyframes textGlimmer {
+ 0% {
+ background-position: -288px 0;
+ }
+ 100% {
+ background-position: 288px 0;
+ }
+}
+@-webkit-keyframes pop-out {
+ 0% {
+ opacity: 0.8;
+ -webkit-transform: scale(0.8);
+ }
+ 50% {
+ opacity: 1;
+ -webkit-transform: scale(1.1);
+ }
+}
+@keyframes pop-out {
+ 0% {
+ opacity: 0.8;
+ transform: scale(0.8);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.1);
+ }
+}
+@-webkit-keyframes pop-up {
+ 0% {
+ opacity: 0.8;
+ -webkit-transform: scale(0.9) translateY(14px);
+ }
+ 50% {
+ opacity: 1;
+ -webkit-transform: scale(1.05) translateY(-4px);
+ }
+}
+@keyframes pop-up {
+ 0% {
+ opacity: 0.8;
+ transform: scale(0.9) translateY(14px);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.05) translateY(-4px);
+ }
+}
+@-webkit-keyframes pop-right {
+ 0% {
+ opacity: 0.8;
+ -webkit-transform: scale(0.9) translateX(-14px) translateY(-50%);
+ }
+ 50% {
+ opacity: 1;
+ -webkit-transform: scale(1.05) translateX(4px) translateY(-50%);
+ }
+}
+@keyframes pop-right {
+ 0% {
+ opacity: 0.8;
+ transform: scale(0.9) translateX(-14px);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.05) translateX(4px);
+ }
+}
+@-webkit-keyframes slide-down {
+ 0% {
+ -webkit-transform: translateY(-100%);
+ }
+ 100% {
+ -webkit-transform: none;
+ }
+}
+@keyframes slide-down {
+ 0% {
+ transform: translateY(-100%);
+ }
+ 100% {
+ transform: none;
+ }
+}
/**
* Editor
*/
@@ -52,262 +193,75 @@
max-width: 100%;
}
/**
- * Toolbar
+ * Embeds
*/
-.ck-toolbar {
- text-align: center;
- position: absolute;
- z-index: 1;
- transition: left 0.1s, top 0.1s;
- margin-bottom: 0.45em;
-}
-.ck-toolbar:after {
- content: '';
+.ck-embed-intent {
position: absolute;
- left: 50%;
- width: 0;
- height: 0;
- border-left: 0.45em solid transparent;
- border-right: 0.45em solid transparent;
- border-top: 0.45em solid #2b2b2b;
- bottom: -0.4em;
- margin: 0 0 0 -0.45em;
-}
-.ck-toolbar.right {
- margin: 0 0 0 0.5em;
}
-.ck-toolbar.right:after {
- left: -0.4em;
+.ck-embed-intent .ck-toolbar {
+ min-width: 100px;
+ left: 40px;
top: 50%;
- bottom: auto;
- margin: -0.45em 0 0 0;
- border-top: 0.45em solid transparent;
- border-bottom: 0.45em solid transparent;
- border-right: 0.45em solid #3d3d3d;
- border-left: none;
+ -webkit-transform: translateY(-50%);
+ transform: translateY(-50%);
}
-.ck-toolbar,
-.ck-toolbar-prompt {
- -webkit-animation: pop-up 0.25s;
- animation: pop-up 0.25s;
+.ck-embed-intent-btn {
+ position: relative;
+ display: block;
+ background: none;
+ border: none;
+ outline: none;
+ margin: 0 0.4em 0 0;
+ padding: 0;
+ width: 1em;
+ height: 1em;
+ border: 2px solid #ccc;
+ border-radius: 100%;
+ color: #ccc;
+ text-align: center;
+ font-size: 2em;
+ line-height: 0.7em;
+ cursor: pointer;
+ transition: color 0.1s, border-color 0.1s, transform 0.35s;
+ -webkit-animation: pop-out 0.25s;
+ animation: pop-out 0.25s;
}
-.ck-toolbar.right {
- -webkit-animation: pop-right 0.25s;
- animation: pop-right 0.25s;
+.ck-embed-intent-btn:hover {
+ color: #999;
+ border-color: #999;
}
-.ck-toolbar.sticky {
- position: fixed;
+.ck-embed-intent-btn:active {
+ color: #666;
+ border-color: #666;
+ transition: none;
+}
+.ck-embed-intent-btn:before {
+ content: '+';
+ position: absolute;
top: 0;
left: 0;
right: 0;
- transition: none;
- border-radius: 0;
- margin: 0;
- border-bottom: 1px solid #e0e0e0;
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
- background: linear-gradient(to bottom, rgba(252, 252, 252, 0.92) 0%, rgba(252, 252, 252, 0.97) 100%);
- box-shadow: none;
- -webkit-animation: slide-down 0.25s;
- animation: slide-down 0.25s;
+ bottom: 0;
}
-.ck-toolbar.sticky:after {
- content: none;
+.activated .ck-embed-intent-btn {
+ -webkit-transform: rotate(-135deg);
+ transform: rotate(-135deg);
}
-.sticky .ck-toolbar-btn {
- color: #454545;
- text-shadow: none;
+.ck-embed-loading {
+ position: absolute;
+ top: 0;
+ left: 50%;
+ margin-left: -0.75em;
+ width: 1.5em;
+ height: 1.5em;
+ border-radius: 50%;
+ border: 2px solid #ddd;
+ border-top-color: #3ea3ff;
+ animation: spin .5s linear infinite;
+ -webkit-animation: spin .5s linear infinite;
}
-.sticky .ck-toolbar-btn:hover,
-.sticky .ck-toolbar-btn:active {
- background-color: #eee;
-}
-.ck-toolbar-content {
- border-radius: 3px;
- background: linear-gradient(to bottom, rgba(74, 74, 74, 0.97) 0%, #2b2b2b 100%);
- box-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.8), inset 0 2px 0 rgba(255, 255, 255, 0.12), inset 1px 1px 0 #282828, inset -1px -1px 0 #282828;
-}
-.ck-toolbar-buttons {
- border-radius: 5px;
- overflow: hidden;
- white-space: nowrap;
-}
-.ck-toolbar-btn {
- display: inline-block;
- background-color: transparent;
- border: none;
- outline: none;
- color: #FFF;
- font-size: 18px;
- padding: 0;
- margin: 0;
- width: 48px;
- height: 44px;
- line-height: 42px;
- cursor: pointer;
- transition: background-color 0.1s linear;
- text-shadow: 0 1px 1px rgba(0, 0, 0, 0.65);
- -moz-user-select: none;
- -webkit-user-select: none;
- -ms-user-select: none;
-}
-.ck-toolbar-btn:hover {
- background-color: rgba(43, 43, 43, 0.4);
-}
-.ck-toolbar-btn:active {
- background-color: rgba(43, 43, 43, 0.65);
-}
-.ck-toolbar-btn:active,
-.ck-toolbar-btn.active {
- color: #3ea3ff;
-}
-.ck-toolbar-prompt {
- display: none;
-}
-.ck-toolbar-prompt input {
- background: none;
- border: none;
- color: #f5f5f5;
- font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
- font-size: 14px;
- padding: 0 16px;
- width: 288px;
- height: 44px;
- line-height: 1em;
-}
-.ck-toolbar-prompt input:focus {
- outline: none;
-}
-.ck-toolbar-prompt input::-ms-clear {
- display: none;
-}
-.ck-toolbar-prompt ::-webkit-input-placeholder {
- background-color: #a2a2a2;
- background-image: -webkit-gradient(linear, left top, right top, color-stop(0, #a2a2a2), color-stop(0.4, #a2a2a2), color-stop(0.5, #ffffff), color-stop(0.6, #a2a2a2), color-stop(1, #a2a2a2));
- background-repeat: no-repeat;
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- -webkit-animation: textGlimmer 4s infinite;
-}
-.ck-editor-hilite {
- position: absolute;
- z-index: -1;
- background-color: rgba(62, 163, 255, 0.05);
- border-bottom: 2px dotted #3ea3ff;
-}
-/**
- * Tooltip
- */
-.ck-tooltip {
- font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
- font-size: 0.7em;
- white-space: nowrap;
- position: absolute;
- background-color: rgba(43, 43, 43, 0.9);
- border-radius: 3px;
- line-height: 1em;
- padding: 0.7em 0.9em;
- color: #FFF;
- -webkit-animation: fade-in 0.2s;
- animation: fade-in 0.2s;
-}
-.ck-tooltip:before {
- content: '';
- position: absolute;
- left: 50%;
- width: 0;
- height: 0;
- border-left: 0.4em solid transparent;
- border-right: 0.4em solid transparent;
- border-bottom: 0.4em solid rgba(43, 43, 43, 0.9);
- top: -0.4em;
- margin-left: -0.4em;
-}
-.ck-tooltip:after {
- /* help keeps mouseover state when moving from link to tooltip */
- content: '';
- position: absolute;
- left: 0;
- right: 0;
- top: -0.4em;
- height: 0.4em;
-}
-.ck-tooltip a {
- color: #FFF;
- text-decoration: none;
-}
-.ck-tooltip a:hover {
- text-decoration: underline;
-}
-/**
- * Embeds
- */
-.ck-embed-intent {
- position: absolute;
-}
-.ck-embed-intent .ck-toolbar {
- min-width: 100px;
- left: 40px;
- top: 50%;
- -webkit-transform: translateY(-50%);
- transform: translateY(-50%);
-}
-.ck-embed-intent-btn {
- position: relative;
- display: block;
- background: none;
- border: none;
- outline: none;
- margin: 0 0.4em 0 0;
- padding: 0;
- width: 1em;
- height: 1em;
- border: 2px solid #ccc;
- border-radius: 100%;
- color: #ccc;
- text-align: center;
- font-size: 2em;
- line-height: 0.7em;
- cursor: pointer;
- transition: color 0.1s, border-color 0.1s, transform 0.35s;
- -webkit-animation: pop-out 0.25s;
- animation: pop-out 0.25s;
-}
-.ck-embed-intent-btn:hover {
- color: #999;
- border-color: #999;
-}
-.ck-embed-intent-btn:active {
- color: #666;
- border-color: #666;
- transition: none;
-}
-.ck-embed-intent-btn:before {
- content: '+';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
-}
-.activated .ck-embed-intent-btn {
- -webkit-transform: rotate(-135deg);
- transform: rotate(-135deg);
-}
-.ck-embed-loading {
- position: absolute;
- top: 0;
- left: 50%;
- margin-left: -0.75em;
- width: 1.5em;
- height: 1.5em;
- border-radius: 50%;
- border: 2px solid #ddd;
- border-top-color: #3ea3ff;
- animation: spin .5s linear infinite;
- -webkit-animation: spin .5s linear infinite;
-}
-.ck-file-input {
- display: none;
+.ck-file-input {
+ display: none;
}
.ck-embed {
text-align: center;
@@ -373,49 +327,6 @@
-webkit-animation: fade-in 1s;
animation: fade-in 1s;
}
-.ck-message {
- position: fixed;
- left: 0;
- right: 0;
- height: 3em;
- line-height: 3em;
- top: -3em;
- z-index: 2;
- padding: 0 1em;
- font-weight: 500;
- text-align: center;
- background-color: rgba(103, 182, 255, 0.98);
- border-bottom: 1px solid #4daaff;
- -webkit-animation: messageShowHide 3.2s;
- animation: messageShowHide 3.2s;
-}
-.ck-message-error {
- color: #a33a3a;
- background-color: rgba(233, 106, 106, 0.98);
- border-bottom: 1px solid #e75d5d;
-}
-@-webkit-keyframes messageShowHide {
- 8% {
- top: 0;
- }
- 92% {
- top: 0;
- }
- 100% {
- top: -3em;
- }
-}
-@keyframes messageShowHide {
- 8% {
- top: 0;
- }
- 92% {
- top: 0;
- }
- 100% {
- top: -3em;
- }
-}
/**
* Icons
*/
@@ -463,144 +374,233 @@
.ck-icon-quote:before {
content: "\e606";
}
-/**
- * Animations
- */
-@-webkit-keyframes fade-in {
- 0% {
- opacity: 0;
- }
- 100% {
- opacity: 1;
- }
+.ck-message {
+ position: fixed;
+ left: 0;
+ right: 0;
+ height: 3em;
+ line-height: 3em;
+ top: -3em;
+ z-index: 2;
+ padding: 0 1em;
+ font-weight: 500;
+ text-align: center;
+ background-color: rgba(103, 182, 255, 0.98);
+ border-bottom: 1px solid #4daaff;
+ -webkit-animation: messageShowHide 3.2s;
+ animation: messageShowHide 3.2s;
}
-@keyframes fade-in {
- 0% {
- opacity: 0;
- }
- 100% {
- opacity: 1;
- }
+.ck-message-error {
+ color: #a33a3a;
+ background-color: rgba(233, 106, 106, 0.98);
+ border-bottom: 1px solid #e75d5d;
}
-@-webkit-keyframes spin {
- to {
- -webkit-transform: rotate(360deg);
+@-webkit-keyframes messageShowHide {
+ 8% {
+ top: 0;
}
-}
-@keyframes spin {
- to {
- transform: rotate(360deg);
+ 92% {
+ top: 0;
}
-}
-@-webkit-keyframes shake {
- 0%,
100% {
- -webkit-transform: translateX(0);
- }
- 20%,
- 60% {
- -webkit-transform: translateX(-10px);
- }
- 40%,
- 80% {
- -webkit-transform: translateX(10px);
+ top: -3em;
}
}
-@keyframes shake {
- 0%,
- 100% {
- transform: translateX(0);
- }
- 20%,
- 60% {
- transform: translateX(-10px);
- }
- 40%,
- 80% {
- transform: translateX(10px);
+@keyframes messageShowHide {
+ 8% {
+ top: 0;
}
-}
-@-webkit-keyframes textGlimmer {
- 0% {
- background-position: -288px 0;
+ 92% {
+ top: 0;
}
100% {
- background-position: 288px 0;
+ top: -3em;
}
}
-@-webkit-keyframes pop-out {
- 0% {
- opacity: 0.8;
- -webkit-transform: scale(0.8);
- }
- 50% {
- opacity: 1;
- -webkit-transform: scale(1.1);
- }
+/**
+ * Toolbar
+ */
+.ck-toolbar {
+ text-align: center;
+ position: absolute;
+ z-index: 1;
+ transition: left 0.1s, top 0.1s;
+ margin-bottom: 0.45em;
}
-@keyframes pop-out {
- 0% {
- opacity: 0.8;
- transform: scale(0.8);
- }
- 50% {
- opacity: 1;
- transform: scale(1.1);
- }
+.ck-toolbar:after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ width: 0;
+ height: 0;
+ border-left: 0.45em solid transparent;
+ border-right: 0.45em solid transparent;
+ border-top: 0.45em solid #2b2b2b;
+ bottom: -0.4em;
+ margin: 0 0 0 -0.45em;
}
-@-webkit-keyframes pop-up {
- 0% {
- opacity: 0.8;
- -webkit-transform: scale(0.9) translateY(14px);
- }
- 50% {
- opacity: 1;
- -webkit-transform: scale(1.05) translateY(-4px);
- }
+.ck-toolbar.right {
+ margin: 0 0 0 0.5em;
}
-@keyframes pop-up {
- 0% {
- opacity: 0.8;
- transform: scale(0.9) translateY(14px);
- }
- 50% {
- opacity: 1;
- transform: scale(1.05) translateY(-4px);
- }
+.ck-toolbar.right:after {
+ left: -0.4em;
+ top: 50%;
+ bottom: auto;
+ margin: -0.45em 0 0 0;
+ border-top: 0.45em solid transparent;
+ border-bottom: 0.45em solid transparent;
+ border-right: 0.45em solid #3d3d3d;
+ border-left: none;
}
-@-webkit-keyframes pop-right {
- 0% {
- opacity: 0.8;
- -webkit-transform: scale(0.9) translateX(-14px) translateY(-50%);
- }
- 50% {
- opacity: 1;
- -webkit-transform: scale(1.05) translateX(4px) translateY(-50%);
- }
+.ck-toolbar,
+.ck-toolbar-prompt {
+ -webkit-animation: pop-up 0.25s;
+ animation: pop-up 0.25s;
}
-@keyframes pop-right {
- 0% {
- opacity: 0.8;
- transform: scale(0.9) translateX(-14px);
- }
- 50% {
- opacity: 1;
- transform: scale(1.05) translateX(4px);
- }
+.ck-toolbar.right {
+ -webkit-animation: pop-right 0.25s;
+ animation: pop-right 0.25s;
}
-@-webkit-keyframes slide-down {
- 0% {
- -webkit-transform: translateY(-100%);
- }
- 100% {
- -webkit-transform: none;
- }
+.ck-toolbar.sticky {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ transition: none;
+ border-radius: 0;
+ margin: 0;
+ border-bottom: 1px solid #e0e0e0;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
+ background: linear-gradient(to bottom, rgba(252, 252, 252, 0.92) 0%, rgba(252, 252, 252, 0.97) 100%);
+ box-shadow: none;
+ -webkit-animation: slide-down 0.25s;
+ animation: slide-down 0.25s;
}
-@keyframes slide-down {
- 0% {
- transform: translateY(-100%);
- }
- 100% {
- transform: none;
- }
+.ck-toolbar.sticky:after {
+ content: none;
+}
+.sticky .ck-toolbar-btn {
+ color: #454545;
+ text-shadow: none;
+}
+.sticky .ck-toolbar-btn:hover,
+.sticky .ck-toolbar-btn:active {
+ background-color: #eee;
+}
+.ck-toolbar-content {
+ border-radius: 3px;
+ background: linear-gradient(to bottom, rgba(74, 74, 74, 0.97) 0%, #2b2b2b 100%);
+ box-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.8), inset 0 2px 0 rgba(255, 255, 255, 0.12), inset 1px 1px 0 #282828, inset -1px -1px 0 #282828;
+}
+.ck-toolbar-buttons {
+ border-radius: 5px;
+ overflow: hidden;
+ white-space: nowrap;
+}
+.ck-toolbar-btn {
+ display: inline-block;
+ background-color: transparent;
+ border: none;
+ outline: none;
+ color: #FFF;
+ font-size: 18px;
+ padding: 0;
+ margin: 0;
+ width: 48px;
+ height: 44px;
+ line-height: 42px;
+ cursor: pointer;
+ transition: background-color 0.1s linear;
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.65);
+ -moz-user-select: none;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+}
+.ck-toolbar-btn:hover {
+ background-color: rgba(43, 43, 43, 0.4);
+}
+.ck-toolbar-btn:active {
+ background-color: rgba(43, 43, 43, 0.65);
+}
+.ck-toolbar-btn:active,
+.ck-toolbar-btn.active {
+ color: #3ea3ff;
+}
+.ck-toolbar-prompt {
+ display: none;
+}
+.ck-toolbar-prompt input {
+ background: none;
+ border: none;
+ color: #f5f5f5;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 14px;
+ padding: 0 16px;
+ width: 288px;
+ height: 44px;
+ line-height: 1em;
+}
+.ck-toolbar-prompt input:focus {
+ outline: none;
+}
+.ck-toolbar-prompt input::-ms-clear {
+ display: none;
+}
+.ck-toolbar-prompt ::-webkit-input-placeholder {
+ background-color: #a2a2a2;
+ background-image: -webkit-gradient(linear, left top, right top, color-stop(0, #a2a2a2), color-stop(0.4, #a2a2a2), color-stop(0.5, white), color-stop(0.6, #a2a2a2), color-stop(1, #a2a2a2));
+ background-repeat: no-repeat;
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ -webkit-animation: textGlimmer 4s infinite;
+}
+.ck-editor-hilite {
+ position: absolute;
+ z-index: -1;
+ background-color: rgba(62, 163, 255, 0.05);
+ border-bottom: 2px dotted #3ea3ff;
+}
+/**
+ * Tooltip
+ */
+.ck-tooltip {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 0.7em;
+ white-space: nowrap;
+ position: absolute;
+ background-color: rgba(43, 43, 43, 0.9);
+ border-radius: 3px;
+ line-height: 1em;
+ padding: 0.7em 0.9em;
+ color: #FFF;
+ -webkit-animation: fade-in 0.2s;
+ animation: fade-in 0.2s;
+}
+.ck-tooltip:before {
+ content: '';
+ position: absolute;
+ left: 50%;
+ width: 0;
+ height: 0;
+ border-left: 0.4em solid transparent;
+ border-right: 0.4em solid transparent;
+ border-bottom: 0.4em solid rgba(43, 43, 43, 0.9);
+ top: -0.4em;
+ margin-left: -0.4em;
+}
+.ck-tooltip:after {
+ /* help keeps mouseover state when moving from link to tooltip */
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: -0.4em;
+ height: 0.4em;
+}
+.ck-tooltip a {
+ color: #FFF;
+ text-decoration: none;
+}
+.ck-tooltip a:hover {
+ text-decoration: underline;
}
diff --git a/website/demo/demo.css b/website/demo/demo.css
new file mode 100644
index 000000000..8a448f606
--- /dev/null
+++ b/website/demo/demo.css
@@ -0,0 +1,158 @@
+*,
+*:before,
+*:after {
+ box-sizing: border-box;
+}
+
+body {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ color: #454545;
+ margin: 0;
+ padding: 0;
+ background-color: #EFEFEF;
+}
+@media only screen and (max-width: 767px) {
+ body {
+ font-size: 0.88em;
+ }
+}
+
+.container {
+ display: flex;
+ justify-content: space-around;
+}
+
+.pane {
+ max-width: 20%;
+ padding: 0 1em;
+}
+
+.pane p.desc {
+ height: 150px;
+ overflow: scroll;
+}
+
+.editor-pane {
+ max-width: 50em;
+ margin: 0 auto;
+ padding: 3em 1.45em;
+ width: 100%;
+ position: relative;
+}
+.code-pane-open .editor-pane {
+ width: 50%;
+ margin: 0;
+}
+
+.demo-buttons {
+ position: fixed;
+ top: 0;
+ right: 0;
+ z-index: 2;
+}
+.demo-buttons a {
+ display: block;
+ cursor: pointer;
+ font-size: 24px;
+ padding: 9px 12px;
+ color: #2b303b;
+ text-shadow: 0 1px 2px rgba(252,252,252,0.7), 0 -1px 2px rgba(252,252,252,0.7), 1px 0 2px rgba(252,252,252,0.7), -1px 0 2px rgba(252,252,252,0.7);
+}
+
+.code-panes {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ right: -50%;
+ width: 0;
+ z-index: 1;
+}
+.code-pane-open .code-panes {
+ right: 0;
+ width: 50%;
+}
+#serialized-mobiledoc, #mobiledoc-to-load {
+ overflow: hidden;
+ padding: 0.25em;
+}
+#serialized-mobiledoc {
+ white-space: pre;
+ background-color: #080808;
+}
+#mobiledoc-to-load {
+}
+#mobiledoc-to-load textarea {
+ height: 500px;
+ width: 100%;
+}
+
+.code-pane:first-child {
+ display: block;
+}
+.code-pane code {
+ white-space: pre-wrap;
+ font-family: Consolas, Menlo, Courier, monospace;
+ font-size: 0.75em;
+ line-height: 1.2em;
+ background-color: transparent;
+ color: #c0c5ce;
+ padding: 5em 1em 1em;
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 0;
+ bottom: 0;
+}
+.code-pane label {
+ font-size: 0.8em;
+ color: #c0c5ce;
+ background: rgba(30,40,48,0.92);
+ padding: 0.5em 0.75em;
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ right: 0;
+ border-radius: 0 0 0 3px;
+ border-left: 1px solid rgba(192,197,206,0.25);
+ border-bottom: 1px solid rgba(192,197,206,0.25);
+}
+
+@font-face {
+ font-family: 'demo-icons';
+ src: url(data:application/x-font-ttf;charset=utf-8;base64,AAEAAAALAIAAAwAwT1MvMggi/LYAAAC8AAAAYGNtYXAaVcxXAAABHAAAAExnYXNwAAAAEAAAAWgAAAAIZ2x5ZmOrGnQAAAFwAAABxGhlYWQBePfEAAADNAAAADZoaGVhA5kB5gAAA2wAAAAkaG10eAMAAAAAAAOQAAAAFGxvY2EAKAD2AAADpAAAAAxtYXhwAAgAhgAAA7AAAAAgbmFtZRP8/tEAAAPQAAABYHBvc3QAAwAAAAAFMAAAACAAAwIAAZAABQAAAUwBZgAAAEcBTAFmAAAA9QAZAIQAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAAAAAAAAAAAABAAADmAAHg/+D/4AHgACAAAAABAAAAAAAAAAAAAAAgAAAAAAACAAAAAwAAABQAAwABAAAAFAAEADgAAAAKAAgAAgACAAEAIOYA//3//wAAAAAAIOYA//3//wAB/+MaBAADAAEAAAAAAAAAAAAAAAEAAf//AA8AAQAAAAAAAAAAAAIAADc5AQAAAAABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAgAAAAABtwG3AHIAgwAANzU0NzYzNzY3JicmNTQ3Njc2NxYfATY3Njc2FzcWFxYVFxYXNzY3FhcWFxYVFAcGBwYHFh8BMhcWHQEUBwYnBwYHFhcWFRQHBgcGByYvAQYHBgcGJwcmJyYvASYnBwYHJicmJyY1NDc2NzY3Ji8BIicmNTcUFxYXNjc2NTQnJgcmBwYVAAIDAzUEBwsUAgIIFBUGBAQnDQ0FAwIJPwQDAwgODCkCBQMEJQoCAgUKCgUHBDUDAwICAwM1BQYKFAMCCBUUBgQEJw0NBQQCCD8EAwMBCA4LKQIFBAMkCwICBQoKBQcENQMDApIWFR4fFRYWFR8eFRa8PwQCBAkMDhAYAgUBBQkWEwEBAh8HBCgNCQEBAQIDAzUEByABAQECIw0DAwUCBwwOBg8NCQQCBD8EAgUBCQ8LDRsCBQIEChUTAQEBIAgDKA0JAQEBAQQDNQQHIAEBAQIhDwQCBQIHDA4GDw0JBAIEHh0WFQEBFRYdIBQXAQEXFCAAAAABAAAAAQAA+Wpa1V8PPPUACwIAAAAAANBR24gAAAAA0FHbiAAAAAABtwG3AAAACAACAAAAAAAAAAEAAAHg/+AAAAIAAAAAAAG3AAEAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAQAAAAIAAAAAAAAAAAoAFAAeAOIAAQAAAAUAhAACAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAA4ArgABAAAAAAABABQAAAABAAAAAAACAA4AXAABAAAAAAADABQAKgABAAAAAAAEABQAagABAAAAAAAFABYAFAABAAAAAAAGAAoAPgABAAAAAAAKADQAfgADAAEECQABABQAAAADAAEECQACAA4AXAADAAEECQADABQAKgADAAEECQAEABQAagADAAEECQAFABYAFAADAAEECQAGABQASAADAAEECQAKADQAfgBkAGUAbQBvAC0AaQBjAG8AbgBzAFYAZQByAHMAaQBvAG4AIAAxAC4AMABkAGUAbQBvAC0AaQBjAG8AbgBzZGVtby1pY29ucwBkAGUAbQBvAC0AaQBjAG8AbgBzAFIAZQBnAHUAbABhAHIAZABlAG0AbwAtAGkAYwBvAG4AcwBGAG8AbgB0ACAAZwBlAG4AZQByAGEAdABlAGQAIABiAHkAIABJAGMAbwBNAG8AbwBuAC4AAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==) format('truetype'),
+ url(data:application/font-woff;charset=utf-8;base64,d09GRgABAAAAAAWcAAsAAAAABVAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABPUy8yAAABCAAAAGAAAABgCCL8tmNtYXAAAAFoAAAATAAAAEwaVcxXZ2FzcAAAAbQAAAAIAAAACAAAABBnbHlmAAABvAAAAcQAAAHEY6sadGhlYWQAAAOAAAAANgAAADYBePfEaGhlYQAAA7gAAAAkAAAAJAOZAeZobXR4AAAD3AAAABQAAAAUAwAAAGxvY2EAAAPwAAAADAAAAAwAKAD2bWF4cAAAA/wAAAAgAAAAIAAIAIZuYW1lAAAEHAAAAWAAAAFgE/z+0XBvc3QAAAV8AAAAIAAAACAAAwAAAAMCAAGQAAUAAAFMAWYAAABHAUwBZgAAAPUAGQCEAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAAAAAAAAAAAAAQAAA5gAB4P/g/+AB4AAgAAAAAQAAAAAAAAAAAAAAIAAAAAAAAgAAAAMAAAAUAAMAAQAAABQABAA4AAAACgAIAAIAAgABACDmAP/9//8AAAAAACDmAP/9//8AAf/jGgQAAwABAAAAAAAAAAAAAAABAAH//wAPAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAAAAAAAAAAAAAgAANzkBAAAAAAIAAAAAAbcBtwByAIMAADc1NDc2Mzc2NyYnJjU0NzY3NjcWHwE2NzY3Nhc3FhcWFRcWFzc2NxYXFhcWFRQHBgcGBxYfATIXFh0BFAcGJwcGBxYXFhUUBwYHBgcmLwEGBwYHBicHJicmLwEmJwcGByYnJicmNTQ3Njc2NyYvASInJjU3FBcWFzY3NjU0JyYHJgcGFQACAwM1BAcLFAICCBQVBgQEJw0NBQMCCT8EAwMIDgwpAgUDBCUKAgIFCgoFBwQ1AwMCAgMDNQUGChQDAggVFAYEBCcNDQUEAgg/BAMDAQgOCykCBQQDJAsCAgUKCgUHBDUDAwKSFhUeHxUWFhUfHhUWvD8EAgQJDA4QGAIFAQUJFhMBAQIfBwQoDQkBAQECAwM1BAcgAQEBAiMNAwMFAgcMDgYPDQkEAgQ/BAIFAQkPCw0bAgUCBAoVEwEBASAIAygNCQEBAQEEAzUEByABAQECIQ8EAgUCBwwOBg8NCQQCBB4dFhUBARUWHSAUFwEBFxQgAAAAAQAAAAEAAPlqWtVfDzz1AAsCAAAAAADQUduIAAAAANBR24gAAAAAAbcBtwAAAAgAAgAAAAAAAAABAAAB4P/gAAACAAAAAAABtwABAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAEAAAACAAAAAAAAAAAKABQAHgDiAAEAAAAFAIQAAgAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAOAK4AAQAAAAAAAQAUAAAAAQAAAAAAAgAOAFwAAQAAAAAAAwAUACoAAQAAAAAABAAUAGoAAQAAAAAABQAWABQAAQAAAAAABgAKAD4AAQAAAAAACgA0AH4AAwABBAkAAQAUAAAAAwABBAkAAgAOAFwAAwABBAkAAwAUACoAAwABBAkABAAUAGoAAwABBAkABQAWABQAAwABBAkABgAUAEgAAwABBAkACgA0AH4AZABlAG0AbwAtAGkAYwBvAG4AcwBWAGUAcgBzAGkAbwBuACAAMQAuADAAZABlAG0AbwAtAGkAYwBvAG4Ac2RlbW8taWNvbnMAZABlAG0AbwAtAGkAYwBvAG4AcwBSAGUAZwB1AGwAYQByAGQAZQBtAG8ALQBpAGMAbwBuAHMARgBvAG4AdAAgAGcAZQBuAGUAcgBhAHQAZQBkACAAYgB5ACAASQBjAG8ATQBvAG8AbgAuAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=) format('woff');
+ font-weight: normal;
+ font-style: normal;
+}
+.icon-settings {
+ font-family: 'demo-icons';
+ speak: none;
+ font-style: normal;
+ font-weight: normal;
+ font-variant: normal;
+ text-transform: none;
+ line-height: 1;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+.icon-settings:before {
+ content: "\e600";
+}
+
+.json-key {
+ color: #a8b389;
+}
+.json-number {
+ color: #879db3;
+}
+.json-string {
+ color: #c0c5ce;
+}
+.json-boolean {
+ color: #bf616a;
+}
+.json-null {
+ color: #bf616a;
+}
diff --git a/website/demo/demo.js b/website/demo/demo.js
new file mode 100644
index 000000000..98dede0eb
--- /dev/null
+++ b/website/demo/demo.js
@@ -0,0 +1,173 @@
+(function(exports, document, undefined) {
+
+'use strict';
+
+var ContentKit = exports.ContentKit,
+ $ = exports.$,
+ MobiledocHTMLRenderer = exports.MobiledocHTMLRenderer,
+ MobiledocDOMRenderer = exports.MobiledocDOMRenderer;
+
+var ContentKitDemo = exports.ContentKitDemo = {
+ syncCodePane: function(editor) {
+ var codePaneJSON = document.getElementById('serialized-mobiledoc');
+ var mobiledoc = editor.serialize();
+ codePaneJSON.innerHTML = this.syntaxHighlight(mobiledoc);
+
+ var renderer = new MobiledocDOMRenderer();
+ var rendered = renderer.render(mobiledoc);
+
+ $('#rendered-mobiledoc').empty();
+ $('#rendered-mobiledoc')[0].appendChild(rendered);
+
+ var htmlRenderer = new MobiledocHTMLRenderer();
+ var html = htmlRenderer.render(mobiledoc);
+
+ html = html.replace(/&/g,'&').replace(//g,'>');
+
+ $('#rendered-mobiledoc-html').html(html);
+ },
+
+ syntaxHighlight: function(json) {
+ // http://stackoverflow.com/a/7220510/189440
+ if (typeof json !== 'string') {
+ json = JSON.stringify(json, undefined, 2);
+ }
+ json = json.replace(/&/g, '&').replace(//g, '>');
+ return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
+ var cls = 'json-number';
+ if (/^"/.test(match)) {
+ if (/:$/.test(match)) {
+ cls = 'json-key';
+ } else {
+ cls = 'json-string';
+ }
+ } else if (/true|false/.test(match)) {
+ cls = 'json-boolean';
+ } else if (/null/.test(match)) {
+ cls = 'json-null';
+ }
+ return '' + match + '';
+ });
+ }
+
+};
+
+function bootEditor(element, mobiledoc) {
+ var editor = new ContentKit.Editor(element, {
+ autofocus: false,
+ mobiledoc: mobiledoc,
+ cards: {
+ 'pick-color': function renderPickColor(payload) {
+ return 'PICK A COLOR: '+payload.options.join(', ');
+ }
+ }
+ });
+
+ editor.on('update', function() {
+ ContentKitDemo.syncCodePane(editor);
+ });
+}
+
+function readMobiledoc(string) {
+ return JSON.parse(string);
+}
+
+function isValidJSON(string) {
+ try {
+ window.JSON.parse(string);
+ return true;
+ } catch(e) {
+ return false;
+ }
+}
+
+function attemptEditorReboot(editor, textarea) {
+ var textPayload = $(textarea).val();
+ if (isValidJSON(textPayload)) {
+ var mobiledoc = readMobiledoc(textPayload);
+ if (editor) {
+ editor.destroy();
+ }
+ bootEditor($('#editor')[0], mobiledoc);
+ }
+}
+
+var sampleMobiledocs = {
+ simpleMobiledoc: [
+ [],
+ [
+ [1, "H2", [
+ [[], 0, "headline h2"]
+ ]],
+ [1, "P", [
+ [[], 0, "hello world"]
+ ]]
+ ]
+ ],
+
+ mobileDocWithMarker: [
+ [['B']],
+ [
+ [1, "H2", [
+ [[], 0, "headline h2"]
+ ]],
+ [1, "P", [
+ [[0], 1, "bold world"]
+ ]]
+ ]
+ ],
+
+ mobileDocWithMultipleMarkers: [
+ [['B'], ['I']],
+ [
+ [1, "H2", [
+ [[], 0, "headline h2"]
+ ]],
+ [1, "P", [
+ [[], 0, "hello "],
+ [[0], 1, "bold, "],
+ [[1], 1, "italic "],
+ [[], 0, "world."]
+ ]]
+ ]
+ ],
+
+ mobileDocWithAttributeMarker: [
+ [['A', ['href', 'http://github.com/bustlelabs/content-kit-editor']]],
+ [
+ [1, "H2", [
+ [[], 0, "headline h2"]
+ ]],
+ [1, "P", [
+ [[], 0, "see it "],
+ [[0], 1, "on github."]
+ ]]
+ ]
+ ]
+};
+
+
+$(function() {
+ var editor;
+ var editorEl = $('#editor')[0];
+ var mobiledoc = sampleMobiledocs.simpleMobiledoc;
+
+ var textarea = $('#mobiledoc-to-load textarea');
+ textarea.val(window.JSON.stringify(mobiledoc, false, 2));
+
+ textarea.on('input', function() {
+ attemptEditorReboot(editor, textarea);
+ });
+
+ $('#select-mobiledoc').on('change', function() {
+ var mobiledocName = $(this).val();
+ var mobiledoc = sampleMobiledocs[mobiledocName];
+ textarea.val(window.JSON.stringify(mobiledoc, false, 2));
+ textarea.trigger('input');
+ });
+
+ bootEditor(editorEl, mobiledoc);
+ $(editorEl).focus();
+});
+
+}(this, document));
diff --git a/website/demo/favicon.ico b/website/demo/favicon.ico
new file mode 100644
index 000000000..00930e1a3
Binary files /dev/null and b/website/demo/favicon.ico differ
diff --git a/website/demo/index.html b/website/demo/index.html
new file mode 100644
index 000000000..5c1c38941
--- /dev/null
+++ b/website/demo/index.html
@@ -0,0 +1,93 @@
+
+
+
+
+
+ Content Kit Editor Demo 0.2.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mobiledoc to load
+
+ This mobiledoc will be loaded into the editor.
+ You can change it and see the editor reload with the new contents.
+ (If there is a JSON syntax error it will be ignored; if there is a parser
+ error the editor may stop responding.)
+
+ Select a preloaded mobiledoc here:
+
+
+
+
+
+
+
+
+ editor
+
+ The live-editing surface. Changes here are serialized to mobiledoc
+ format and displayed to the right.
+
+
+
+
+
+
+ serialized mobiledoc
+
+ When the editor updates, it prints its serialized mobiledoc here.
+
+
+
+
+
+
+ rendered mobiledoc (dom)
+
+ This is the output of using the runtime (client-side)
+ mobiledoc-dom-renderer
+ on the serialized mobiledoc.
+
+
+
+
+
+ rendered mobiledoc (html)
+
+ This is the output of using the server-side
+ mobiledoc-html-renderer
+ on the serialized mobiledoc.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/demo/jquery/jquery.js b/website/demo/jquery/jquery.js
new file mode 100644
index 000000000..eed17778c
--- /dev/null
+++ b/website/demo/jquery/jquery.js
@@ -0,0 +1,9210 @@
+/*!
+ * jQuery JavaScript Library v2.1.4
+ * http://jquery.com/
+ *
+ * Includes Sizzle.js
+ * http://sizzlejs.com/
+ *
+ * Copyright 2005, 2014 jQuery Foundation, Inc. and other contributors
+ * Released under the MIT license
+ * http://jquery.org/license
+ *
+ * Date: 2015-04-28T16:01Z
+ */
+
+(function( global, factory ) {
+
+ if ( typeof module === "object" && typeof module.exports === "object" ) {
+ // For CommonJS and CommonJS-like environments where a proper `window`
+ // is present, execute the factory and get jQuery.
+ // For environments that do not have a `window` with a `document`
+ // (such as Node.js), expose a factory as module.exports.
+ // This accentuates the need for the creation of a real `window`.
+ // e.g. var jQuery = require("jquery")(window);
+ // See ticket #14549 for more info.
+ module.exports = global.document ?
+ factory( global, true ) :
+ function( w ) {
+ if ( !w.document ) {
+ throw new Error( "jQuery requires a window with a document" );
+ }
+ return factory( w );
+ };
+ } else {
+ factory( global );
+ }
+
+// Pass this if window is not defined yet
+}(typeof window !== "undefined" ? window : this, function( window, noGlobal ) {
+
+// Support: Firefox 18+
+// Can't be in strict mode, several libs including ASP.NET trace
+// the stack via arguments.caller.callee and Firefox dies if
+// you try to trace through "use strict" call chains. (#13335)
+//
+
+var arr = [];
+
+var slice = arr.slice;
+
+var concat = arr.concat;
+
+var push = arr.push;
+
+var indexOf = arr.indexOf;
+
+var class2type = {};
+
+var toString = class2type.toString;
+
+var hasOwn = class2type.hasOwnProperty;
+
+var support = {};
+
+
+
+var
+ // Use the correct document accordingly with window argument (sandbox)
+ document = window.document,
+
+ version = "2.1.4",
+
+ // Define a local copy of jQuery
+ jQuery = function( selector, context ) {
+ // The jQuery object is actually just the init constructor 'enhanced'
+ // Need init if jQuery is called (just allow error to be thrown if not included)
+ return new jQuery.fn.init( selector, context );
+ },
+
+ // Support: Android<4.1
+ // Make sure we trim BOM and NBSP
+ rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,
+
+ // Matches dashed string for camelizing
+ rmsPrefix = /^-ms-/,
+ rdashAlpha = /-([\da-z])/gi,
+
+ // Used by jQuery.camelCase as callback to replace()
+ fcamelCase = function( all, letter ) {
+ return letter.toUpperCase();
+ };
+
+jQuery.fn = jQuery.prototype = {
+ // The current version of jQuery being used
+ jquery: version,
+
+ constructor: jQuery,
+
+ // Start with an empty selector
+ selector: "",
+
+ // The default length of a jQuery object is 0
+ length: 0,
+
+ toArray: function() {
+ return slice.call( this );
+ },
+
+ // Get the Nth element in the matched element set OR
+ // Get the whole matched element set as a clean array
+ get: function( num ) {
+ return num != null ?
+
+ // Return just the one element from the set
+ ( num < 0 ? this[ num + this.length ] : this[ num ] ) :
+
+ // Return all the elements in a clean array
+ slice.call( this );
+ },
+
+ // Take an array of elements and push it onto the stack
+ // (returning the new matched element set)
+ pushStack: function( elems ) {
+
+ // Build a new jQuery matched element set
+ var ret = jQuery.merge( this.constructor(), elems );
+
+ // Add the old object onto the stack (as a reference)
+ ret.prevObject = this;
+ ret.context = this.context;
+
+ // Return the newly-formed element set
+ return ret;
+ },
+
+ // Execute a callback for every element in the matched set.
+ // (You can seed the arguments with an array of args, but this is
+ // only used internally.)
+ each: function( callback, args ) {
+ return jQuery.each( this, callback, args );
+ },
+
+ map: function( callback ) {
+ return this.pushStack( jQuery.map(this, function( elem, i ) {
+ return callback.call( elem, i, elem );
+ }));
+ },
+
+ slice: function() {
+ return this.pushStack( slice.apply( this, arguments ) );
+ },
+
+ first: function() {
+ return this.eq( 0 );
+ },
+
+ last: function() {
+ return this.eq( -1 );
+ },
+
+ eq: function( i ) {
+ var len = this.length,
+ j = +i + ( i < 0 ? len : 0 );
+ return this.pushStack( j >= 0 && j < len ? [ this[j] ] : [] );
+ },
+
+ end: function() {
+ return this.prevObject || this.constructor(null);
+ },
+
+ // For internal use only.
+ // Behaves like an Array's method, not like a jQuery method.
+ push: push,
+ sort: arr.sort,
+ splice: arr.splice
+};
+
+jQuery.extend = jQuery.fn.extend = function() {
+ var options, name, src, copy, copyIsArray, clone,
+ target = arguments[0] || {},
+ i = 1,
+ length = arguments.length,
+ deep = false;
+
+ // Handle a deep copy situation
+ if ( typeof target === "boolean" ) {
+ deep = target;
+
+ // Skip the boolean and the target
+ target = arguments[ i ] || {};
+ i++;
+ }
+
+ // Handle case when target is a string or something (possible in deep copy)
+ if ( typeof target !== "object" && !jQuery.isFunction(target) ) {
+ target = {};
+ }
+
+ // Extend jQuery itself if only one argument is passed
+ if ( i === length ) {
+ target = this;
+ i--;
+ }
+
+ for ( ; i < length; i++ ) {
+ // Only deal with non-null/undefined values
+ if ( (options = arguments[ i ]) != null ) {
+ // Extend the base object
+ for ( name in options ) {
+ src = target[ name ];
+ copy = options[ name ];
+
+ // Prevent never-ending loop
+ if ( target === copy ) {
+ continue;
+ }
+
+ // Recurse if we're merging plain objects or arrays
+ if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) {
+ if ( copyIsArray ) {
+ copyIsArray = false;
+ clone = src && jQuery.isArray(src) ? src : [];
+
+ } else {
+ clone = src && jQuery.isPlainObject(src) ? src : {};
+ }
+
+ // Never move original objects, clone them
+ target[ name ] = jQuery.extend( deep, clone, copy );
+
+ // Don't bring in undefined values
+ } else if ( copy !== undefined ) {
+ target[ name ] = copy;
+ }
+ }
+ }
+ }
+
+ // Return the modified object
+ return target;
+};
+
+jQuery.extend({
+ // Unique for each copy of jQuery on the page
+ expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ),
+
+ // Assume jQuery is ready without the ready module
+ isReady: true,
+
+ error: function( msg ) {
+ throw new Error( msg );
+ },
+
+ noop: function() {},
+
+ isFunction: function( obj ) {
+ return jQuery.type(obj) === "function";
+ },
+
+ isArray: Array.isArray,
+
+ isWindow: function( obj ) {
+ return obj != null && obj === obj.window;
+ },
+
+ isNumeric: function( obj ) {
+ // parseFloat NaNs numeric-cast false positives (null|true|false|"")
+ // ...but misinterprets leading-number strings, particularly hex literals ("0x...")
+ // subtraction forces infinities to NaN
+ // adding 1 corrects loss of precision from parseFloat (#15100)
+ return !jQuery.isArray( obj ) && (obj - parseFloat( obj ) + 1) >= 0;
+ },
+
+ isPlainObject: function( obj ) {
+ // Not plain objects:
+ // - Any object or value whose internal [[Class]] property is not "[object Object]"
+ // - DOM nodes
+ // - window
+ if ( jQuery.type( obj ) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) {
+ return false;
+ }
+
+ if ( obj.constructor &&
+ !hasOwn.call( obj.constructor.prototype, "isPrototypeOf" ) ) {
+ return false;
+ }
+
+ // If the function hasn't returned already, we're confident that
+ // |obj| is a plain object, created by {} or constructed with new Object
+ return true;
+ },
+
+ isEmptyObject: function( obj ) {
+ var name;
+ for ( name in obj ) {
+ return false;
+ }
+ return true;
+ },
+
+ type: function( obj ) {
+ if ( obj == null ) {
+ return obj + "";
+ }
+ // Support: Android<4.0, iOS<6 (functionish RegExp)
+ return typeof obj === "object" || typeof obj === "function" ?
+ class2type[ toString.call(obj) ] || "object" :
+ typeof obj;
+ },
+
+ // Evaluates a script in a global context
+ globalEval: function( code ) {
+ var script,
+ indirect = eval;
+
+ code = jQuery.trim( code );
+
+ if ( code ) {
+ // If the code includes a valid, prologue position
+ // strict mode pragma, execute code by injecting a
+ // script tag into the document.
+ if ( code.indexOf("use strict") === 1 ) {
+ script = document.createElement("script");
+ script.text = code;
+ document.head.appendChild( script ).parentNode.removeChild( script );
+ } else {
+ // Otherwise, avoid the DOM node creation, insertion
+ // and removal by using an indirect global eval
+ indirect( code );
+ }
+ }
+ },
+
+ // Convert dashed to camelCase; used by the css and data modules
+ // Support: IE9-11+
+ // Microsoft forgot to hump their vendor prefix (#9572)
+ camelCase: function( string ) {
+ return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase );
+ },
+
+ nodeName: function( elem, name ) {
+ return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase();
+ },
+
+ // args is for internal usage only
+ each: function( obj, callback, args ) {
+ var value,
+ i = 0,
+ length = obj.length,
+ isArray = isArraylike( obj );
+
+ if ( args ) {
+ if ( isArray ) {
+ for ( ; i < length; i++ ) {
+ value = callback.apply( obj[ i ], args );
+
+ if ( value === false ) {
+ break;
+ }
+ }
+ } else {
+ for ( i in obj ) {
+ value = callback.apply( obj[ i ], args );
+
+ if ( value === false ) {
+ break;
+ }
+ }
+ }
+
+ // A special, fast, case for the most common use of each
+ } else {
+ if ( isArray ) {
+ for ( ; i < length; i++ ) {
+ value = callback.call( obj[ i ], i, obj[ i ] );
+
+ if ( value === false ) {
+ break;
+ }
+ }
+ } else {
+ for ( i in obj ) {
+ value = callback.call( obj[ i ], i, obj[ i ] );
+
+ if ( value === false ) {
+ break;
+ }
+ }
+ }
+ }
+
+ return obj;
+ },
+
+ // Support: Android<4.1
+ trim: function( text ) {
+ return text == null ?
+ "" :
+ ( text + "" ).replace( rtrim, "" );
+ },
+
+ // results is for internal usage only
+ makeArray: function( arr, results ) {
+ var ret = results || [];
+
+ if ( arr != null ) {
+ if ( isArraylike( Object(arr) ) ) {
+ jQuery.merge( ret,
+ typeof arr === "string" ?
+ [ arr ] : arr
+ );
+ } else {
+ push.call( ret, arr );
+ }
+ }
+
+ return ret;
+ },
+
+ inArray: function( elem, arr, i ) {
+ return arr == null ? -1 : indexOf.call( arr, elem, i );
+ },
+
+ merge: function( first, second ) {
+ var len = +second.length,
+ j = 0,
+ i = first.length;
+
+ for ( ; j < len; j++ ) {
+ first[ i++ ] = second[ j ];
+ }
+
+ first.length = i;
+
+ return first;
+ },
+
+ grep: function( elems, callback, invert ) {
+ var callbackInverse,
+ matches = [],
+ i = 0,
+ length = elems.length,
+ callbackExpect = !invert;
+
+ // Go through the array, only saving the items
+ // that pass the validator function
+ for ( ; i < length; i++ ) {
+ callbackInverse = !callback( elems[ i ], i );
+ if ( callbackInverse !== callbackExpect ) {
+ matches.push( elems[ i ] );
+ }
+ }
+
+ return matches;
+ },
+
+ // arg is for internal usage only
+ map: function( elems, callback, arg ) {
+ var value,
+ i = 0,
+ length = elems.length,
+ isArray = isArraylike( elems ),
+ ret = [];
+
+ // Go through the array, translating each of the items to their new values
+ if ( isArray ) {
+ for ( ; i < length; i++ ) {
+ value = callback( elems[ i ], i, arg );
+
+ if ( value != null ) {
+ ret.push( value );
+ }
+ }
+
+ // Go through every key on the object,
+ } else {
+ for ( i in elems ) {
+ value = callback( elems[ i ], i, arg );
+
+ if ( value != null ) {
+ ret.push( value );
+ }
+ }
+ }
+
+ // Flatten any nested arrays
+ return concat.apply( [], ret );
+ },
+
+ // A global GUID counter for objects
+ guid: 1,
+
+ // Bind a function to a context, optionally partially applying any
+ // arguments.
+ proxy: function( fn, context ) {
+ var tmp, args, proxy;
+
+ if ( typeof context === "string" ) {
+ tmp = fn[ context ];
+ context = fn;
+ fn = tmp;
+ }
+
+ // Quick check to determine if target is callable, in the spec
+ // this throws a TypeError, but we will just return undefined.
+ if ( !jQuery.isFunction( fn ) ) {
+ return undefined;
+ }
+
+ // Simulated bind
+ args = slice.call( arguments, 2 );
+ proxy = function() {
+ return fn.apply( context || this, args.concat( slice.call( arguments ) ) );
+ };
+
+ // Set the guid of unique handler to the same of original handler, so it can be removed
+ proxy.guid = fn.guid = fn.guid || jQuery.guid++;
+
+ return proxy;
+ },
+
+ now: Date.now,
+
+ // jQuery.support is not used in Core but other projects attach their
+ // properties to it so it needs to exist.
+ support: support
+});
+
+// Populate the class2type map
+jQuery.each("Boolean Number String Function Array Date RegExp Object Error".split(" "), function(i, name) {
+ class2type[ "[object " + name + "]" ] = name.toLowerCase();
+});
+
+function isArraylike( obj ) {
+
+ // Support: iOS 8.2 (not reproducible in simulator)
+ // `in` check used to prevent JIT error (gh-2145)
+ // hasOwn isn't used here due to false negatives
+ // regarding Nodelist length in IE
+ var length = "length" in obj && obj.length,
+ type = jQuery.type( obj );
+
+ if ( type === "function" || jQuery.isWindow( obj ) ) {
+ return false;
+ }
+
+ if ( obj.nodeType === 1 && length ) {
+ return true;
+ }
+
+ return type === "array" || length === 0 ||
+ typeof length === "number" && length > 0 && ( length - 1 ) in obj;
+}
+var Sizzle =
+/*!
+ * Sizzle CSS Selector Engine v2.2.0-pre
+ * http://sizzlejs.com/
+ *
+ * Copyright 2008, 2014 jQuery Foundation, Inc. and other contributors
+ * Released under the MIT license
+ * http://jquery.org/license
+ *
+ * Date: 2014-12-16
+ */
+(function( window ) {
+
+var i,
+ support,
+ Expr,
+ getText,
+ isXML,
+ tokenize,
+ compile,
+ select,
+ outermostContext,
+ sortInput,
+ hasDuplicate,
+
+ // Local document vars
+ setDocument,
+ document,
+ docElem,
+ documentIsHTML,
+ rbuggyQSA,
+ rbuggyMatches,
+ matches,
+ contains,
+
+ // Instance-specific data
+ expando = "sizzle" + 1 * new Date(),
+ preferredDoc = window.document,
+ dirruns = 0,
+ done = 0,
+ classCache = createCache(),
+ tokenCache = createCache(),
+ compilerCache = createCache(),
+ sortOrder = function( a, b ) {
+ if ( a === b ) {
+ hasDuplicate = true;
+ }
+ return 0;
+ },
+
+ // General-purpose constants
+ MAX_NEGATIVE = 1 << 31,
+
+ // Instance methods
+ hasOwn = ({}).hasOwnProperty,
+ arr = [],
+ pop = arr.pop,
+ push_native = arr.push,
+ push = arr.push,
+ slice = arr.slice,
+ // Use a stripped-down indexOf as it's faster than native
+ // http://jsperf.com/thor-indexof-vs-for/5
+ indexOf = function( list, elem ) {
+ var i = 0,
+ len = list.length;
+ for ( ; i < len; i++ ) {
+ if ( list[i] === elem ) {
+ return i;
+ }
+ }
+ return -1;
+ },
+
+ booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",
+
+ // Regular expressions
+
+ // Whitespace characters http://www.w3.org/TR/css3-selectors/#whitespace
+ whitespace = "[\\x20\\t\\r\\n\\f]",
+ // http://www.w3.org/TR/css3-syntax/#characters
+ characterEncoding = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",
+
+ // Loosely modeled on CSS identifier characters
+ // An unquoted value should be a CSS identifier http://www.w3.org/TR/css3-selectors/#attribute-selectors
+ // Proper syntax: http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier
+ identifier = characterEncoding.replace( "w", "w#" ),
+
+ // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors
+ attributes = "\\[" + whitespace + "*(" + characterEncoding + ")(?:" + whitespace +
+ // Operator (capture 2)
+ "*([*^$|!~]?=)" + whitespace +
+ // "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]"
+ "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace +
+ "*\\]",
+
+ pseudos = ":(" + characterEncoding + ")(?:\\((" +
+ // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments:
+ // 1. quoted (capture 3; capture 4 or capture 5)
+ "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" +
+ // 2. simple (capture 6)
+ "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" +
+ // 3. anything else (capture 2)
+ ".*" +
+ ")\\)|)",
+
+ // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter
+ rwhitespace = new RegExp( whitespace + "+", "g" ),
+ rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ),
+
+ rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ),
+ rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ),
+
+ rattributeQuotes = new RegExp( "=" + whitespace + "*([^\\]'\"]*?)" + whitespace + "*\\]", "g" ),
+
+ rpseudo = new RegExp( pseudos ),
+ ridentifier = new RegExp( "^" + identifier + "$" ),
+
+ matchExpr = {
+ "ID": new RegExp( "^#(" + characterEncoding + ")" ),
+ "CLASS": new RegExp( "^\\.(" + characterEncoding + ")" ),
+ "TAG": new RegExp( "^(" + characterEncoding.replace( "w", "w*" ) + ")" ),
+ "ATTR": new RegExp( "^" + attributes ),
+ "PSEUDO": new RegExp( "^" + pseudos ),
+ "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace +
+ "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace +
+ "*(\\d+)|))" + whitespace + "*\\)|)", "i" ),
+ "bool": new RegExp( "^(?:" + booleans + ")$", "i" ),
+ // For use in libraries implementing .is()
+ // We use this for POS matching in `select`
+ "needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" +
+ whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" )
+ },
+
+ rinputs = /^(?:input|select|textarea|button)$/i,
+ rheader = /^h\d$/i,
+
+ rnative = /^[^{]+\{\s*\[native \w/,
+
+ // Easily-parseable/retrievable ID or TAG or CLASS selectors
+ rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,
+
+ rsibling = /[+~]/,
+ rescape = /'|\\/g,
+
+ // CSS escapes http://www.w3.org/TR/CSS21/syndata.html#escaped-characters
+ runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ),
+ funescape = function( _, escaped, escapedWhitespace ) {
+ var high = "0x" + escaped - 0x10000;
+ // NaN means non-codepoint
+ // Support: Firefox<24
+ // Workaround erroneous numeric interpretation of +"0x"
+ return high !== high || escapedWhitespace ?
+ escaped :
+ high < 0 ?
+ // BMP codepoint
+ String.fromCharCode( high + 0x10000 ) :
+ // Supplemental Plane codepoint (surrogate pair)
+ String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 );
+ },
+
+ // Used for iframes
+ // See setDocument()
+ // Removing the function wrapper causes a "Permission Denied"
+ // error in IE
+ unloadHandler = function() {
+ setDocument();
+ };
+
+// Optimize for push.apply( _, NodeList )
+try {
+ push.apply(
+ (arr = slice.call( preferredDoc.childNodes )),
+ preferredDoc.childNodes
+ );
+ // Support: Android<4.0
+ // Detect silently failing push.apply
+ arr[ preferredDoc.childNodes.length ].nodeType;
+} catch ( e ) {
+ push = { apply: arr.length ?
+
+ // Leverage slice if possible
+ function( target, els ) {
+ push_native.apply( target, slice.call(els) );
+ } :
+
+ // Support: IE<9
+ // Otherwise append directly
+ function( target, els ) {
+ var j = target.length,
+ i = 0;
+ // Can't trust NodeList.length
+ while ( (target[j++] = els[i++]) ) {}
+ target.length = j - 1;
+ }
+ };
+}
+
+function Sizzle( selector, context, results, seed ) {
+ var match, elem, m, nodeType,
+ // QSA vars
+ i, groups, old, nid, newContext, newSelector;
+
+ if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) {
+ setDocument( context );
+ }
+
+ context = context || document;
+ results = results || [];
+ nodeType = context.nodeType;
+
+ if ( typeof selector !== "string" || !selector ||
+ nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) {
+
+ return results;
+ }
+
+ if ( !seed && documentIsHTML ) {
+
+ // Try to shortcut find operations when possible (e.g., not under DocumentFragment)
+ if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) {
+ // Speed-up: Sizzle("#ID")
+ if ( (m = match[1]) ) {
+ if ( nodeType === 9 ) {
+ elem = context.getElementById( m );
+ // Check parentNode to catch when Blackberry 4.6 returns
+ // nodes that are no longer in the document (jQuery #6963)
+ if ( elem && elem.parentNode ) {
+ // Handle the case where IE, Opera, and Webkit return items
+ // by name instead of ID
+ if ( elem.id === m ) {
+ results.push( elem );
+ return results;
+ }
+ } else {
+ return results;
+ }
+ } else {
+ // Context is not a document
+ if ( context.ownerDocument && (elem = context.ownerDocument.getElementById( m )) &&
+ contains( context, elem ) && elem.id === m ) {
+ results.push( elem );
+ return results;
+ }
+ }
+
+ // Speed-up: Sizzle("TAG")
+ } else if ( match[2] ) {
+ push.apply( results, context.getElementsByTagName( selector ) );
+ return results;
+
+ // Speed-up: Sizzle(".CLASS")
+ } else if ( (m = match[3]) && support.getElementsByClassName ) {
+ push.apply( results, context.getElementsByClassName( m ) );
+ return results;
+ }
+ }
+
+ // QSA path
+ if ( support.qsa && (!rbuggyQSA || !rbuggyQSA.test( selector )) ) {
+ nid = old = expando;
+ newContext = context;
+ newSelector = nodeType !== 1 && selector;
+
+ // qSA works strangely on Element-rooted queries
+ // We can work around this by specifying an extra ID on the root
+ // and working up from there (Thanks to Andrew Dupont for the technique)
+ // IE 8 doesn't work on object elements
+ if ( nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) {
+ groups = tokenize( selector );
+
+ if ( (old = context.getAttribute("id")) ) {
+ nid = old.replace( rescape, "\\$&" );
+ } else {
+ context.setAttribute( "id", nid );
+ }
+ nid = "[id='" + nid + "'] ";
+
+ i = groups.length;
+ while ( i-- ) {
+ groups[i] = nid + toSelector( groups[i] );
+ }
+ newContext = rsibling.test( selector ) && testContext( context.parentNode ) || context;
+ newSelector = groups.join(",");
+ }
+
+ if ( newSelector ) {
+ try {
+ push.apply( results,
+ newContext.querySelectorAll( newSelector )
+ );
+ return results;
+ } catch(qsaError) {
+ } finally {
+ if ( !old ) {
+ context.removeAttribute("id");
+ }
+ }
+ }
+ }
+ }
+
+ // All others
+ return select( selector.replace( rtrim, "$1" ), context, results, seed );
+}
+
+/**
+ * Create key-value caches of limited size
+ * @returns {Function(string, Object)} Returns the Object data after storing it on itself with
+ * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength)
+ * deleting the oldest entry
+ */
+function createCache() {
+ var keys = [];
+
+ function cache( key, value ) {
+ // Use (key + " ") to avoid collision with native prototype properties (see Issue #157)
+ if ( keys.push( key + " " ) > Expr.cacheLength ) {
+ // Only keep the most recent entries
+ delete cache[ keys.shift() ];
+ }
+ return (cache[ key + " " ] = value);
+ }
+ return cache;
+}
+
+/**
+ * Mark a function for special use by Sizzle
+ * @param {Function} fn The function to mark
+ */
+function markFunction( fn ) {
+ fn[ expando ] = true;
+ return fn;
+}
+
+/**
+ * Support testing using an element
+ * @param {Function} fn Passed the created div and expects a boolean result
+ */
+function assert( fn ) {
+ var div = document.createElement("div");
+
+ try {
+ return !!fn( div );
+ } catch (e) {
+ return false;
+ } finally {
+ // Remove from its parent by default
+ if ( div.parentNode ) {
+ div.parentNode.removeChild( div );
+ }
+ // release memory in IE
+ div = null;
+ }
+}
+
+/**
+ * Adds the same handler for all of the specified attrs
+ * @param {String} attrs Pipe-separated list of attributes
+ * @param {Function} handler The method that will be applied
+ */
+function addHandle( attrs, handler ) {
+ var arr = attrs.split("|"),
+ i = attrs.length;
+
+ while ( i-- ) {
+ Expr.attrHandle[ arr[i] ] = handler;
+ }
+}
+
+/**
+ * Checks document order of two siblings
+ * @param {Element} a
+ * @param {Element} b
+ * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b
+ */
+function siblingCheck( a, b ) {
+ var cur = b && a,
+ diff = cur && a.nodeType === 1 && b.nodeType === 1 &&
+ ( ~b.sourceIndex || MAX_NEGATIVE ) -
+ ( ~a.sourceIndex || MAX_NEGATIVE );
+
+ // Use IE sourceIndex if available on both nodes
+ if ( diff ) {
+ return diff;
+ }
+
+ // Check if b follows a
+ if ( cur ) {
+ while ( (cur = cur.nextSibling) ) {
+ if ( cur === b ) {
+ return -1;
+ }
+ }
+ }
+
+ return a ? 1 : -1;
+}
+
+/**
+ * Returns a function to use in pseudos for input types
+ * @param {String} type
+ */
+function createInputPseudo( type ) {
+ return function( elem ) {
+ var name = elem.nodeName.toLowerCase();
+ return name === "input" && elem.type === type;
+ };
+}
+
+/**
+ * Returns a function to use in pseudos for buttons
+ * @param {String} type
+ */
+function createButtonPseudo( type ) {
+ return function( elem ) {
+ var name = elem.nodeName.toLowerCase();
+ return (name === "input" || name === "button") && elem.type === type;
+ };
+}
+
+/**
+ * Returns a function to use in pseudos for positionals
+ * @param {Function} fn
+ */
+function createPositionalPseudo( fn ) {
+ return markFunction(function( argument ) {
+ argument = +argument;
+ return markFunction(function( seed, matches ) {
+ var j,
+ matchIndexes = fn( [], seed.length, argument ),
+ i = matchIndexes.length;
+
+ // Match elements found at the specified indexes
+ while ( i-- ) {
+ if ( seed[ (j = matchIndexes[i]) ] ) {
+ seed[j] = !(matches[j] = seed[j]);
+ }
+ }
+ });
+ });
+}
+
+/**
+ * Checks a node for validity as a Sizzle context
+ * @param {Element|Object=} context
+ * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value
+ */
+function testContext( context ) {
+ return context && typeof context.getElementsByTagName !== "undefined" && context;
+}
+
+// Expose support vars for convenience
+support = Sizzle.support = {};
+
+/**
+ * Detects XML nodes
+ * @param {Element|Object} elem An element or a document
+ * @returns {Boolean} True iff elem is a non-HTML XML node
+ */
+isXML = Sizzle.isXML = function( elem ) {
+ // documentElement is verified for cases where it doesn't yet exist
+ // (such as loading iframes in IE - #4833)
+ var documentElement = elem && (elem.ownerDocument || elem).documentElement;
+ return documentElement ? documentElement.nodeName !== "HTML" : false;
+};
+
+/**
+ * Sets document-related variables once based on the current document
+ * @param {Element|Object} [doc] An element or document object to use to set the document
+ * @returns {Object} Returns the current document
+ */
+setDocument = Sizzle.setDocument = function( node ) {
+ var hasCompare, parent,
+ doc = node ? node.ownerDocument || node : preferredDoc;
+
+ // If no document and documentElement is available, return
+ if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) {
+ return document;
+ }
+
+ // Set our document
+ document = doc;
+ docElem = doc.documentElement;
+ parent = doc.defaultView;
+
+ // Support: IE>8
+ // If iframe document is assigned to "document" variable and if iframe has been reloaded,
+ // IE will throw "permission denied" error when accessing "document" variable, see jQuery #13936
+ // IE6-8 do not support the defaultView property so parent will be undefined
+ if ( parent && parent !== parent.top ) {
+ // IE11 does not have attachEvent, so all must suffer
+ if ( parent.addEventListener ) {
+ parent.addEventListener( "unload", unloadHandler, false );
+ } else if ( parent.attachEvent ) {
+ parent.attachEvent( "onunload", unloadHandler );
+ }
+ }
+
+ /* Support tests
+ ---------------------------------------------------------------------- */
+ documentIsHTML = !isXML( doc );
+
+ /* Attributes
+ ---------------------------------------------------------------------- */
+
+ // Support: IE<8
+ // Verify that getAttribute really returns attributes and not properties
+ // (excepting IE8 booleans)
+ support.attributes = assert(function( div ) {
+ div.className = "i";
+ return !div.getAttribute("className");
+ });
+
+ /* getElement(s)By*
+ ---------------------------------------------------------------------- */
+
+ // Check if getElementsByTagName("*") returns only elements
+ support.getElementsByTagName = assert(function( div ) {
+ div.appendChild( doc.createComment("") );
+ return !div.getElementsByTagName("*").length;
+ });
+
+ // Support: IE<9
+ support.getElementsByClassName = rnative.test( doc.getElementsByClassName );
+
+ // Support: IE<10
+ // Check if getElementById returns elements by name
+ // The broken getElementById methods don't pick up programatically-set names,
+ // so use a roundabout getElementsByName test
+ support.getById = assert(function( div ) {
+ docElem.appendChild( div ).id = expando;
+ return !doc.getElementsByName || !doc.getElementsByName( expando ).length;
+ });
+
+ // ID find and filter
+ if ( support.getById ) {
+ Expr.find["ID"] = function( id, context ) {
+ if ( typeof context.getElementById !== "undefined" && documentIsHTML ) {
+ var m = context.getElementById( id );
+ // Check parentNode to catch when Blackberry 4.6 returns
+ // nodes that are no longer in the document #6963
+ return m && m.parentNode ? [ m ] : [];
+ }
+ };
+ Expr.filter["ID"] = function( id ) {
+ var attrId = id.replace( runescape, funescape );
+ return function( elem ) {
+ return elem.getAttribute("id") === attrId;
+ };
+ };
+ } else {
+ // Support: IE6/7
+ // getElementById is not reliable as a find shortcut
+ delete Expr.find["ID"];
+
+ Expr.filter["ID"] = function( id ) {
+ var attrId = id.replace( runescape, funescape );
+ return function( elem ) {
+ var node = typeof elem.getAttributeNode !== "undefined" && elem.getAttributeNode("id");
+ return node && node.value === attrId;
+ };
+ };
+ }
+
+ // Tag
+ Expr.find["TAG"] = support.getElementsByTagName ?
+ function( tag, context ) {
+ if ( typeof context.getElementsByTagName !== "undefined" ) {
+ return context.getElementsByTagName( tag );
+
+ // DocumentFragment nodes don't have gEBTN
+ } else if ( support.qsa ) {
+ return context.querySelectorAll( tag );
+ }
+ } :
+
+ function( tag, context ) {
+ var elem,
+ tmp = [],
+ i = 0,
+ // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too
+ results = context.getElementsByTagName( tag );
+
+ // Filter out possible comments
+ if ( tag === "*" ) {
+ while ( (elem = results[i++]) ) {
+ if ( elem.nodeType === 1 ) {
+ tmp.push( elem );
+ }
+ }
+
+ return tmp;
+ }
+ return results;
+ };
+
+ // Class
+ Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) {
+ if ( documentIsHTML ) {
+ return context.getElementsByClassName( className );
+ }
+ };
+
+ /* QSA/matchesSelector
+ ---------------------------------------------------------------------- */
+
+ // QSA and matchesSelector support
+
+ // matchesSelector(:active) reports false when true (IE9/Opera 11.5)
+ rbuggyMatches = [];
+
+ // qSa(:focus) reports false when true (Chrome 21)
+ // We allow this because of a bug in IE8/9 that throws an error
+ // whenever `document.activeElement` is accessed on an iframe
+ // So, we allow :focus to pass through QSA all the time to avoid the IE error
+ // See http://bugs.jquery.com/ticket/13378
+ rbuggyQSA = [];
+
+ if ( (support.qsa = rnative.test( doc.querySelectorAll )) ) {
+ // Build QSA regex
+ // Regex strategy adopted from Diego Perini
+ assert(function( div ) {
+ // Select is set to empty string on purpose
+ // This is to test IE's treatment of not explicitly
+ // setting a boolean content attribute,
+ // since its presence should be enough
+ // http://bugs.jquery.com/ticket/12359
+ docElem.appendChild( div ).innerHTML = "" +
+ "";
+
+ // Support: IE8, Opera 11-12.16
+ // Nothing should be selected when empty strings follow ^= or $= or *=
+ // The test attribute must be unknown in Opera but "safe" for WinRT
+ // http://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section
+ if ( div.querySelectorAll("[msallowcapture^='']").length ) {
+ rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" );
+ }
+
+ // Support: IE8
+ // Boolean attributes and "value" are not treated correctly
+ if ( !div.querySelectorAll("[selected]").length ) {
+ rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" );
+ }
+
+ // Support: Chrome<29, Android<4.2+, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.7+
+ if ( !div.querySelectorAll( "[id~=" + expando + "-]" ).length ) {
+ rbuggyQSA.push("~=");
+ }
+
+ // Webkit/Opera - :checked should return selected option elements
+ // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked
+ // IE8 throws error here and will not see later tests
+ if ( !div.querySelectorAll(":checked").length ) {
+ rbuggyQSA.push(":checked");
+ }
+
+ // Support: Safari 8+, iOS 8+
+ // https://bugs.webkit.org/show_bug.cgi?id=136851
+ // In-page `selector#id sibing-combinator selector` fails
+ if ( !div.querySelectorAll( "a#" + expando + "+*" ).length ) {
+ rbuggyQSA.push(".#.+[+~]");
+ }
+ });
+
+ assert(function( div ) {
+ // Support: Windows 8 Native Apps
+ // The type and name attributes are restricted during .innerHTML assignment
+ var input = doc.createElement("input");
+ input.setAttribute( "type", "hidden" );
+ div.appendChild( input ).setAttribute( "name", "D" );
+
+ // Support: IE8
+ // Enforce case-sensitivity of name attribute
+ if ( div.querySelectorAll("[name=d]").length ) {
+ rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" );
+ }
+
+ // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled)
+ // IE8 throws error here and will not see later tests
+ if ( !div.querySelectorAll(":enabled").length ) {
+ rbuggyQSA.push( ":enabled", ":disabled" );
+ }
+
+ // Opera 10-11 does not throw on post-comma invalid pseudos
+ div.querySelectorAll("*,:x");
+ rbuggyQSA.push(",.*:");
+ });
+ }
+
+ if ( (support.matchesSelector = rnative.test( (matches = docElem.matches ||
+ docElem.webkitMatchesSelector ||
+ docElem.mozMatchesSelector ||
+ docElem.oMatchesSelector ||
+ docElem.msMatchesSelector) )) ) {
+
+ assert(function( div ) {
+ // Check to see if it's possible to do matchesSelector
+ // on a disconnected node (IE 9)
+ support.disconnectedMatch = matches.call( div, "div" );
+
+ // This should fail with an exception
+ // Gecko does not error, returns false instead
+ matches.call( div, "[s!='']:x" );
+ rbuggyMatches.push( "!=", pseudos );
+ });
+ }
+
+ rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") );
+ rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") );
+
+ /* Contains
+ ---------------------------------------------------------------------- */
+ hasCompare = rnative.test( docElem.compareDocumentPosition );
+
+ // Element contains another
+ // Purposefully does not implement inclusive descendent
+ // As in, an element does not contain itself
+ contains = hasCompare || rnative.test( docElem.contains ) ?
+ function( a, b ) {
+ var adown = a.nodeType === 9 ? a.documentElement : a,
+ bup = b && b.parentNode;
+ return a === bup || !!( bup && bup.nodeType === 1 && (
+ adown.contains ?
+ adown.contains( bup ) :
+ a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16
+ ));
+ } :
+ function( a, b ) {
+ if ( b ) {
+ while ( (b = b.parentNode) ) {
+ if ( b === a ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ };
+
+ /* Sorting
+ ---------------------------------------------------------------------- */
+
+ // Document order sorting
+ sortOrder = hasCompare ?
+ function( a, b ) {
+
+ // Flag for duplicate removal
+ if ( a === b ) {
+ hasDuplicate = true;
+ return 0;
+ }
+
+ // Sort on method existence if only one input has compareDocumentPosition
+ var compare = !a.compareDocumentPosition - !b.compareDocumentPosition;
+ if ( compare ) {
+ return compare;
+ }
+
+ // Calculate position if both inputs belong to the same document
+ compare = ( a.ownerDocument || a ) === ( b.ownerDocument || b ) ?
+ a.compareDocumentPosition( b ) :
+
+ // Otherwise we know they are disconnected
+ 1;
+
+ // Disconnected nodes
+ if ( compare & 1 ||
+ (!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) {
+
+ // Choose the first element that is related to our preferred document
+ if ( a === doc || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) {
+ return -1;
+ }
+ if ( b === doc || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) {
+ return 1;
+ }
+
+ // Maintain original order
+ return sortInput ?
+ ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) :
+ 0;
+ }
+
+ return compare & 4 ? -1 : 1;
+ } :
+ function( a, b ) {
+ // Exit early if the nodes are identical
+ if ( a === b ) {
+ hasDuplicate = true;
+ return 0;
+ }
+
+ var cur,
+ i = 0,
+ aup = a.parentNode,
+ bup = b.parentNode,
+ ap = [ a ],
+ bp = [ b ];
+
+ // Parentless nodes are either documents or disconnected
+ if ( !aup || !bup ) {
+ return a === doc ? -1 :
+ b === doc ? 1 :
+ aup ? -1 :
+ bup ? 1 :
+ sortInput ?
+ ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) :
+ 0;
+
+ // If the nodes are siblings, we can do a quick check
+ } else if ( aup === bup ) {
+ return siblingCheck( a, b );
+ }
+
+ // Otherwise we need full lists of their ancestors for comparison
+ cur = a;
+ while ( (cur = cur.parentNode) ) {
+ ap.unshift( cur );
+ }
+ cur = b;
+ while ( (cur = cur.parentNode) ) {
+ bp.unshift( cur );
+ }
+
+ // Walk down the tree looking for a discrepancy
+ while ( ap[i] === bp[i] ) {
+ i++;
+ }
+
+ return i ?
+ // Do a sibling check if the nodes have a common ancestor
+ siblingCheck( ap[i], bp[i] ) :
+
+ // Otherwise nodes in our document sort first
+ ap[i] === preferredDoc ? -1 :
+ bp[i] === preferredDoc ? 1 :
+ 0;
+ };
+
+ return doc;
+};
+
+Sizzle.matches = function( expr, elements ) {
+ return Sizzle( expr, null, null, elements );
+};
+
+Sizzle.matchesSelector = function( elem, expr ) {
+ // Set document vars if needed
+ if ( ( elem.ownerDocument || elem ) !== document ) {
+ setDocument( elem );
+ }
+
+ // Make sure that attribute selectors are quoted
+ expr = expr.replace( rattributeQuotes, "='$1']" );
+
+ if ( support.matchesSelector && documentIsHTML &&
+ ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) &&
+ ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) {
+
+ try {
+ var ret = matches.call( elem, expr );
+
+ // IE 9's matchesSelector returns false on disconnected nodes
+ if ( ret || support.disconnectedMatch ||
+ // As well, disconnected nodes are said to be in a document
+ // fragment in IE 9
+ elem.document && elem.document.nodeType !== 11 ) {
+ return ret;
+ }
+ } catch (e) {}
+ }
+
+ return Sizzle( expr, document, null, [ elem ] ).length > 0;
+};
+
+Sizzle.contains = function( context, elem ) {
+ // Set document vars if needed
+ if ( ( context.ownerDocument || context ) !== document ) {
+ setDocument( context );
+ }
+ return contains( context, elem );
+};
+
+Sizzle.attr = function( elem, name ) {
+ // Set document vars if needed
+ if ( ( elem.ownerDocument || elem ) !== document ) {
+ setDocument( elem );
+ }
+
+ var fn = Expr.attrHandle[ name.toLowerCase() ],
+ // Don't get fooled by Object.prototype properties (jQuery #13807)
+ val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ?
+ fn( elem, name, !documentIsHTML ) :
+ undefined;
+
+ return val !== undefined ?
+ val :
+ support.attributes || !documentIsHTML ?
+ elem.getAttribute( name ) :
+ (val = elem.getAttributeNode(name)) && val.specified ?
+ val.value :
+ null;
+};
+
+Sizzle.error = function( msg ) {
+ throw new Error( "Syntax error, unrecognized expression: " + msg );
+};
+
+/**
+ * Document sorting and removing duplicates
+ * @param {ArrayLike} results
+ */
+Sizzle.uniqueSort = function( results ) {
+ var elem,
+ duplicates = [],
+ j = 0,
+ i = 0;
+
+ // Unless we *know* we can detect duplicates, assume their presence
+ hasDuplicate = !support.detectDuplicates;
+ sortInput = !support.sortStable && results.slice( 0 );
+ results.sort( sortOrder );
+
+ if ( hasDuplicate ) {
+ while ( (elem = results[i++]) ) {
+ if ( elem === results[ i ] ) {
+ j = duplicates.push( i );
+ }
+ }
+ while ( j-- ) {
+ results.splice( duplicates[ j ], 1 );
+ }
+ }
+
+ // Clear input after sorting to release objects
+ // See https://github.com/jquery/sizzle/pull/225
+ sortInput = null;
+
+ return results;
+};
+
+/**
+ * Utility function for retrieving the text value of an array of DOM nodes
+ * @param {Array|Element} elem
+ */
+getText = Sizzle.getText = function( elem ) {
+ var node,
+ ret = "",
+ i = 0,
+ nodeType = elem.nodeType;
+
+ if ( !nodeType ) {
+ // If no nodeType, this is expected to be an array
+ while ( (node = elem[i++]) ) {
+ // Do not traverse comment nodes
+ ret += getText( node );
+ }
+ } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) {
+ // Use textContent for elements
+ // innerText usage removed for consistency of new lines (jQuery #11153)
+ if ( typeof elem.textContent === "string" ) {
+ return elem.textContent;
+ } else {
+ // Traverse its children
+ for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) {
+ ret += getText( elem );
+ }
+ }
+ } else if ( nodeType === 3 || nodeType === 4 ) {
+ return elem.nodeValue;
+ }
+ // Do not include comment or processing instruction nodes
+
+ return ret;
+};
+
+Expr = Sizzle.selectors = {
+
+ // Can be adjusted by the user
+ cacheLength: 50,
+
+ createPseudo: markFunction,
+
+ match: matchExpr,
+
+ attrHandle: {},
+
+ find: {},
+
+ relative: {
+ ">": { dir: "parentNode", first: true },
+ " ": { dir: "parentNode" },
+ "+": { dir: "previousSibling", first: true },
+ "~": { dir: "previousSibling" }
+ },
+
+ preFilter: {
+ "ATTR": function( match ) {
+ match[1] = match[1].replace( runescape, funescape );
+
+ // Move the given value to match[3] whether quoted or unquoted
+ match[3] = ( match[3] || match[4] || match[5] || "" ).replace( runescape, funescape );
+
+ if ( match[2] === "~=" ) {
+ match[3] = " " + match[3] + " ";
+ }
+
+ return match.slice( 0, 4 );
+ },
+
+ "CHILD": function( match ) {
+ /* matches from matchExpr["CHILD"]
+ 1 type (only|nth|...)
+ 2 what (child|of-type)
+ 3 argument (even|odd|\d*|\d*n([+-]\d+)?|...)
+ 4 xn-component of xn+y argument ([+-]?\d*n|)
+ 5 sign of xn-component
+ 6 x of xn-component
+ 7 sign of y-component
+ 8 y of y-component
+ */
+ match[1] = match[1].toLowerCase();
+
+ if ( match[1].slice( 0, 3 ) === "nth" ) {
+ // nth-* requires argument
+ if ( !match[3] ) {
+ Sizzle.error( match[0] );
+ }
+
+ // numeric x and y parameters for Expr.filter.CHILD
+ // remember that false/true cast respectively to 0/1
+ match[4] = +( match[4] ? match[5] + (match[6] || 1) : 2 * ( match[3] === "even" || match[3] === "odd" ) );
+ match[5] = +( ( match[7] + match[8] ) || match[3] === "odd" );
+
+ // other types prohibit arguments
+ } else if ( match[3] ) {
+ Sizzle.error( match[0] );
+ }
+
+ return match;
+ },
+
+ "PSEUDO": function( match ) {
+ var excess,
+ unquoted = !match[6] && match[2];
+
+ if ( matchExpr["CHILD"].test( match[0] ) ) {
+ return null;
+ }
+
+ // Accept quoted arguments as-is
+ if ( match[3] ) {
+ match[2] = match[4] || match[5] || "";
+
+ // Strip excess characters from unquoted arguments
+ } else if ( unquoted && rpseudo.test( unquoted ) &&
+ // Get excess from tokenize (recursively)
+ (excess = tokenize( unquoted, true )) &&
+ // advance to the next closing parenthesis
+ (excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) {
+
+ // excess is a negative index
+ match[0] = match[0].slice( 0, excess );
+ match[2] = unquoted.slice( 0, excess );
+ }
+
+ // Return only captures needed by the pseudo filter method (type and argument)
+ return match.slice( 0, 3 );
+ }
+ },
+
+ filter: {
+
+ "TAG": function( nodeNameSelector ) {
+ var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase();
+ return nodeNameSelector === "*" ?
+ function() { return true; } :
+ function( elem ) {
+ return elem.nodeName && elem.nodeName.toLowerCase() === nodeName;
+ };
+ },
+
+ "CLASS": function( className ) {
+ var pattern = classCache[ className + " " ];
+
+ return pattern ||
+ (pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) &&
+ classCache( className, function( elem ) {
+ return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== "undefined" && elem.getAttribute("class") || "" );
+ });
+ },
+
+ "ATTR": function( name, operator, check ) {
+ return function( elem ) {
+ var result = Sizzle.attr( elem, name );
+
+ if ( result == null ) {
+ return operator === "!=";
+ }
+ if ( !operator ) {
+ return true;
+ }
+
+ result += "";
+
+ return operator === "=" ? result === check :
+ operator === "!=" ? result !== check :
+ operator === "^=" ? check && result.indexOf( check ) === 0 :
+ operator === "*=" ? check && result.indexOf( check ) > -1 :
+ operator === "$=" ? check && result.slice( -check.length ) === check :
+ operator === "~=" ? ( " " + result.replace( rwhitespace, " " ) + " " ).indexOf( check ) > -1 :
+ operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" :
+ false;
+ };
+ },
+
+ "CHILD": function( type, what, argument, first, last ) {
+ var simple = type.slice( 0, 3 ) !== "nth",
+ forward = type.slice( -4 ) !== "last",
+ ofType = what === "of-type";
+
+ return first === 1 && last === 0 ?
+
+ // Shortcut for :nth-*(n)
+ function( elem ) {
+ return !!elem.parentNode;
+ } :
+
+ function( elem, context, xml ) {
+ var cache, outerCache, node, diff, nodeIndex, start,
+ dir = simple !== forward ? "nextSibling" : "previousSibling",
+ parent = elem.parentNode,
+ name = ofType && elem.nodeName.toLowerCase(),
+ useCache = !xml && !ofType;
+
+ if ( parent ) {
+
+ // :(first|last|only)-(child|of-type)
+ if ( simple ) {
+ while ( dir ) {
+ node = elem;
+ while ( (node = node[ dir ]) ) {
+ if ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) {
+ return false;
+ }
+ }
+ // Reverse direction for :only-* (if we haven't yet done so)
+ start = dir = type === "only" && !start && "nextSibling";
+ }
+ return true;
+ }
+
+ start = [ forward ? parent.firstChild : parent.lastChild ];
+
+ // non-xml :nth-child(...) stores cache data on `parent`
+ if ( forward && useCache ) {
+ // Seek `elem` from a previously-cached index
+ outerCache = parent[ expando ] || (parent[ expando ] = {});
+ cache = outerCache[ type ] || [];
+ nodeIndex = cache[0] === dirruns && cache[1];
+ diff = cache[0] === dirruns && cache[2];
+ node = nodeIndex && parent.childNodes[ nodeIndex ];
+
+ while ( (node = ++nodeIndex && node && node[ dir ] ||
+
+ // Fallback to seeking `elem` from the start
+ (diff = nodeIndex = 0) || start.pop()) ) {
+
+ // When found, cache indexes on `parent` and break
+ if ( node.nodeType === 1 && ++diff && node === elem ) {
+ outerCache[ type ] = [ dirruns, nodeIndex, diff ];
+ break;
+ }
+ }
+
+ // Use previously-cached element index if available
+ } else if ( useCache && (cache = (elem[ expando ] || (elem[ expando ] = {}))[ type ]) && cache[0] === dirruns ) {
+ diff = cache[1];
+
+ // xml :nth-child(...) or :nth-last-child(...) or :nth(-last)?-of-type(...)
+ } else {
+ // Use the same loop as above to seek `elem` from the start
+ while ( (node = ++nodeIndex && node && node[ dir ] ||
+ (diff = nodeIndex = 0) || start.pop()) ) {
+
+ if ( ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) && ++diff ) {
+ // Cache the index of each encountered element
+ if ( useCache ) {
+ (node[ expando ] || (node[ expando ] = {}))[ type ] = [ dirruns, diff ];
+ }
+
+ if ( node === elem ) {
+ break;
+ }
+ }
+ }
+ }
+
+ // Incorporate the offset, then check against cycle size
+ diff -= last;
+ return diff === first || ( diff % first === 0 && diff / first >= 0 );
+ }
+ };
+ },
+
+ "PSEUDO": function( pseudo, argument ) {
+ // pseudo-class names are case-insensitive
+ // http://www.w3.org/TR/selectors/#pseudo-classes
+ // Prioritize by case sensitivity in case custom pseudos are added with uppercase letters
+ // Remember that setFilters inherits from pseudos
+ var args,
+ fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] ||
+ Sizzle.error( "unsupported pseudo: " + pseudo );
+
+ // The user may use createPseudo to indicate that
+ // arguments are needed to create the filter function
+ // just as Sizzle does
+ if ( fn[ expando ] ) {
+ return fn( argument );
+ }
+
+ // But maintain support for old signatures
+ if ( fn.length > 1 ) {
+ args = [ pseudo, pseudo, "", argument ];
+ return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ?
+ markFunction(function( seed, matches ) {
+ var idx,
+ matched = fn( seed, argument ),
+ i = matched.length;
+ while ( i-- ) {
+ idx = indexOf( seed, matched[i] );
+ seed[ idx ] = !( matches[ idx ] = matched[i] );
+ }
+ }) :
+ function( elem ) {
+ return fn( elem, 0, args );
+ };
+ }
+
+ return fn;
+ }
+ },
+
+ pseudos: {
+ // Potentially complex pseudos
+ "not": markFunction(function( selector ) {
+ // Trim the selector passed to compile
+ // to avoid treating leading and trailing
+ // spaces as combinators
+ var input = [],
+ results = [],
+ matcher = compile( selector.replace( rtrim, "$1" ) );
+
+ return matcher[ expando ] ?
+ markFunction(function( seed, matches, context, xml ) {
+ var elem,
+ unmatched = matcher( seed, null, xml, [] ),
+ i = seed.length;
+
+ // Match elements unmatched by `matcher`
+ while ( i-- ) {
+ if ( (elem = unmatched[i]) ) {
+ seed[i] = !(matches[i] = elem);
+ }
+ }
+ }) :
+ function( elem, context, xml ) {
+ input[0] = elem;
+ matcher( input, null, xml, results );
+ // Don't keep the element (issue #299)
+ input[0] = null;
+ return !results.pop();
+ };
+ }),
+
+ "has": markFunction(function( selector ) {
+ return function( elem ) {
+ return Sizzle( selector, elem ).length > 0;
+ };
+ }),
+
+ "contains": markFunction(function( text ) {
+ text = text.replace( runescape, funescape );
+ return function( elem ) {
+ return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1;
+ };
+ }),
+
+ // "Whether an element is represented by a :lang() selector
+ // is based solely on the element's language value
+ // being equal to the identifier C,
+ // or beginning with the identifier C immediately followed by "-".
+ // The matching of C against the element's language value is performed case-insensitively.
+ // The identifier C does not have to be a valid language name."
+ // http://www.w3.org/TR/selectors/#lang-pseudo
+ "lang": markFunction( function( lang ) {
+ // lang value must be a valid identifier
+ if ( !ridentifier.test(lang || "") ) {
+ Sizzle.error( "unsupported lang: " + lang );
+ }
+ lang = lang.replace( runescape, funescape ).toLowerCase();
+ return function( elem ) {
+ var elemLang;
+ do {
+ if ( (elemLang = documentIsHTML ?
+ elem.lang :
+ elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) {
+
+ elemLang = elemLang.toLowerCase();
+ return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0;
+ }
+ } while ( (elem = elem.parentNode) && elem.nodeType === 1 );
+ return false;
+ };
+ }),
+
+ // Miscellaneous
+ "target": function( elem ) {
+ var hash = window.location && window.location.hash;
+ return hash && hash.slice( 1 ) === elem.id;
+ },
+
+ "root": function( elem ) {
+ return elem === docElem;
+ },
+
+ "focus": function( elem ) {
+ return elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex);
+ },
+
+ // Boolean properties
+ "enabled": function( elem ) {
+ return elem.disabled === false;
+ },
+
+ "disabled": function( elem ) {
+ return elem.disabled === true;
+ },
+
+ "checked": function( elem ) {
+ // In CSS3, :checked should return both checked and selected elements
+ // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked
+ var nodeName = elem.nodeName.toLowerCase();
+ return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected);
+ },
+
+ "selected": function( elem ) {
+ // Accessing this property makes selected-by-default
+ // options in Safari work properly
+ if ( elem.parentNode ) {
+ elem.parentNode.selectedIndex;
+ }
+
+ return elem.selected === true;
+ },
+
+ // Contents
+ "empty": function( elem ) {
+ // http://www.w3.org/TR/selectors/#empty-pseudo
+ // :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5),
+ // but not by others (comment: 8; processing instruction: 7; etc.)
+ // nodeType < 6 works because attributes (2) do not appear as children
+ for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) {
+ if ( elem.nodeType < 6 ) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ "parent": function( elem ) {
+ return !Expr.pseudos["empty"]( elem );
+ },
+
+ // Element/input types
+ "header": function( elem ) {
+ return rheader.test( elem.nodeName );
+ },
+
+ "input": function( elem ) {
+ return rinputs.test( elem.nodeName );
+ },
+
+ "button": function( elem ) {
+ var name = elem.nodeName.toLowerCase();
+ return name === "input" && elem.type === "button" || name === "button";
+ },
+
+ "text": function( elem ) {
+ var attr;
+ return elem.nodeName.toLowerCase() === "input" &&
+ elem.type === "text" &&
+
+ // Support: IE<8
+ // New HTML5 attribute values (e.g., "search") appear with elem.type === "text"
+ ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === "text" );
+ },
+
+ // Position-in-collection
+ "first": createPositionalPseudo(function() {
+ return [ 0 ];
+ }),
+
+ "last": createPositionalPseudo(function( matchIndexes, length ) {
+ return [ length - 1 ];
+ }),
+
+ "eq": createPositionalPseudo(function( matchIndexes, length, argument ) {
+ return [ argument < 0 ? argument + length : argument ];
+ }),
+
+ "even": createPositionalPseudo(function( matchIndexes, length ) {
+ var i = 0;
+ for ( ; i < length; i += 2 ) {
+ matchIndexes.push( i );
+ }
+ return matchIndexes;
+ }),
+
+ "odd": createPositionalPseudo(function( matchIndexes, length ) {
+ var i = 1;
+ for ( ; i < length; i += 2 ) {
+ matchIndexes.push( i );
+ }
+ return matchIndexes;
+ }),
+
+ "lt": createPositionalPseudo(function( matchIndexes, length, argument ) {
+ var i = argument < 0 ? argument + length : argument;
+ for ( ; --i >= 0; ) {
+ matchIndexes.push( i );
+ }
+ return matchIndexes;
+ }),
+
+ "gt": createPositionalPseudo(function( matchIndexes, length, argument ) {
+ var i = argument < 0 ? argument + length : argument;
+ for ( ; ++i < length; ) {
+ matchIndexes.push( i );
+ }
+ return matchIndexes;
+ })
+ }
+};
+
+Expr.pseudos["nth"] = Expr.pseudos["eq"];
+
+// Add button/input type pseudos
+for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) {
+ Expr.pseudos[ i ] = createInputPseudo( i );
+}
+for ( i in { submit: true, reset: true } ) {
+ Expr.pseudos[ i ] = createButtonPseudo( i );
+}
+
+// Easy API for creating new setFilters
+function setFilters() {}
+setFilters.prototype = Expr.filters = Expr.pseudos;
+Expr.setFilters = new setFilters();
+
+tokenize = Sizzle.tokenize = function( selector, parseOnly ) {
+ var matched, match, tokens, type,
+ soFar, groups, preFilters,
+ cached = tokenCache[ selector + " " ];
+
+ if ( cached ) {
+ return parseOnly ? 0 : cached.slice( 0 );
+ }
+
+ soFar = selector;
+ groups = [];
+ preFilters = Expr.preFilter;
+
+ while ( soFar ) {
+
+ // Comma and first run
+ if ( !matched || (match = rcomma.exec( soFar )) ) {
+ if ( match ) {
+ // Don't consume trailing commas as valid
+ soFar = soFar.slice( match[0].length ) || soFar;
+ }
+ groups.push( (tokens = []) );
+ }
+
+ matched = false;
+
+ // Combinators
+ if ( (match = rcombinators.exec( soFar )) ) {
+ matched = match.shift();
+ tokens.push({
+ value: matched,
+ // Cast descendant combinators to space
+ type: match[0].replace( rtrim, " " )
+ });
+ soFar = soFar.slice( matched.length );
+ }
+
+ // Filters
+ for ( type in Expr.filter ) {
+ if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] ||
+ (match = preFilters[ type ]( match ))) ) {
+ matched = match.shift();
+ tokens.push({
+ value: matched,
+ type: type,
+ matches: match
+ });
+ soFar = soFar.slice( matched.length );
+ }
+ }
+
+ if ( !matched ) {
+ break;
+ }
+ }
+
+ // Return the length of the invalid excess
+ // if we're just parsing
+ // Otherwise, throw an error or return tokens
+ return parseOnly ?
+ soFar.length :
+ soFar ?
+ Sizzle.error( selector ) :
+ // Cache the tokens
+ tokenCache( selector, groups ).slice( 0 );
+};
+
+function toSelector( tokens ) {
+ var i = 0,
+ len = tokens.length,
+ selector = "";
+ for ( ; i < len; i++ ) {
+ selector += tokens[i].value;
+ }
+ return selector;
+}
+
+function addCombinator( matcher, combinator, base ) {
+ var dir = combinator.dir,
+ checkNonElements = base && dir === "parentNode",
+ doneName = done++;
+
+ return combinator.first ?
+ // Check against closest ancestor/preceding element
+ function( elem, context, xml ) {
+ while ( (elem = elem[ dir ]) ) {
+ if ( elem.nodeType === 1 || checkNonElements ) {
+ return matcher( elem, context, xml );
+ }
+ }
+ } :
+
+ // Check against all ancestor/preceding elements
+ function( elem, context, xml ) {
+ var oldCache, outerCache,
+ newCache = [ dirruns, doneName ];
+
+ // We can't set arbitrary data on XML nodes, so they don't benefit from dir caching
+ if ( xml ) {
+ while ( (elem = elem[ dir ]) ) {
+ if ( elem.nodeType === 1 || checkNonElements ) {
+ if ( matcher( elem, context, xml ) ) {
+ return true;
+ }
+ }
+ }
+ } else {
+ while ( (elem = elem[ dir ]) ) {
+ if ( elem.nodeType === 1 || checkNonElements ) {
+ outerCache = elem[ expando ] || (elem[ expando ] = {});
+ if ( (oldCache = outerCache[ dir ]) &&
+ oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) {
+
+ // Assign to newCache so results back-propagate to previous elements
+ return (newCache[ 2 ] = oldCache[ 2 ]);
+ } else {
+ // Reuse newcache so results back-propagate to previous elements
+ outerCache[ dir ] = newCache;
+
+ // A match means we're done; a fail means we have to keep checking
+ if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ };
+}
+
+function elementMatcher( matchers ) {
+ return matchers.length > 1 ?
+ function( elem, context, xml ) {
+ var i = matchers.length;
+ while ( i-- ) {
+ if ( !matchers[i]( elem, context, xml ) ) {
+ return false;
+ }
+ }
+ return true;
+ } :
+ matchers[0];
+}
+
+function multipleContexts( selector, contexts, results ) {
+ var i = 0,
+ len = contexts.length;
+ for ( ; i < len; i++ ) {
+ Sizzle( selector, contexts[i], results );
+ }
+ return results;
+}
+
+function condense( unmatched, map, filter, context, xml ) {
+ var elem,
+ newUnmatched = [],
+ i = 0,
+ len = unmatched.length,
+ mapped = map != null;
+
+ for ( ; i < len; i++ ) {
+ if ( (elem = unmatched[i]) ) {
+ if ( !filter || filter( elem, context, xml ) ) {
+ newUnmatched.push( elem );
+ if ( mapped ) {
+ map.push( i );
+ }
+ }
+ }
+ }
+
+ return newUnmatched;
+}
+
+function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) {
+ if ( postFilter && !postFilter[ expando ] ) {
+ postFilter = setMatcher( postFilter );
+ }
+ if ( postFinder && !postFinder[ expando ] ) {
+ postFinder = setMatcher( postFinder, postSelector );
+ }
+ return markFunction(function( seed, results, context, xml ) {
+ var temp, i, elem,
+ preMap = [],
+ postMap = [],
+ preexisting = results.length,
+
+ // Get initial elements from seed or context
+ elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ),
+
+ // Prefilter to get matcher input, preserving a map for seed-results synchronization
+ matcherIn = preFilter && ( seed || !selector ) ?
+ condense( elems, preMap, preFilter, context, xml ) :
+ elems,
+
+ matcherOut = matcher ?
+ // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results,
+ postFinder || ( seed ? preFilter : preexisting || postFilter ) ?
+
+ // ...intermediate processing is necessary
+ [] :
+
+ // ...otherwise use results directly
+ results :
+ matcherIn;
+
+ // Find primary matches
+ if ( matcher ) {
+ matcher( matcherIn, matcherOut, context, xml );
+ }
+
+ // Apply postFilter
+ if ( postFilter ) {
+ temp = condense( matcherOut, postMap );
+ postFilter( temp, [], context, xml );
+
+ // Un-match failing elements by moving them back to matcherIn
+ i = temp.length;
+ while ( i-- ) {
+ if ( (elem = temp[i]) ) {
+ matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem);
+ }
+ }
+ }
+
+ if ( seed ) {
+ if ( postFinder || preFilter ) {
+ if ( postFinder ) {
+ // Get the final matcherOut by condensing this intermediate into postFinder contexts
+ temp = [];
+ i = matcherOut.length;
+ while ( i-- ) {
+ if ( (elem = matcherOut[i]) ) {
+ // Restore matcherIn since elem is not yet a final match
+ temp.push( (matcherIn[i] = elem) );
+ }
+ }
+ postFinder( null, (matcherOut = []), temp, xml );
+ }
+
+ // Move matched elements from seed to results to keep them synchronized
+ i = matcherOut.length;
+ while ( i-- ) {
+ if ( (elem = matcherOut[i]) &&
+ (temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) {
+
+ seed[temp] = !(results[temp] = elem);
+ }
+ }
+ }
+
+ // Add elements to results, through postFinder if defined
+ } else {
+ matcherOut = condense(
+ matcherOut === results ?
+ matcherOut.splice( preexisting, matcherOut.length ) :
+ matcherOut
+ );
+ if ( postFinder ) {
+ postFinder( null, results, matcherOut, xml );
+ } else {
+ push.apply( results, matcherOut );
+ }
+ }
+ });
+}
+
+function matcherFromTokens( tokens ) {
+ var checkContext, matcher, j,
+ len = tokens.length,
+ leadingRelative = Expr.relative[ tokens[0].type ],
+ implicitRelative = leadingRelative || Expr.relative[" "],
+ i = leadingRelative ? 1 : 0,
+
+ // The foundational matcher ensures that elements are reachable from top-level context(s)
+ matchContext = addCombinator( function( elem ) {
+ return elem === checkContext;
+ }, implicitRelative, true ),
+ matchAnyContext = addCombinator( function( elem ) {
+ return indexOf( checkContext, elem ) > -1;
+ }, implicitRelative, true ),
+ matchers = [ function( elem, context, xml ) {
+ var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || (
+ (checkContext = context).nodeType ?
+ matchContext( elem, context, xml ) :
+ matchAnyContext( elem, context, xml ) );
+ // Avoid hanging onto element (issue #299)
+ checkContext = null;
+ return ret;
+ } ];
+
+ for ( ; i < len; i++ ) {
+ if ( (matcher = Expr.relative[ tokens[i].type ]) ) {
+ matchers = [ addCombinator(elementMatcher( matchers ), matcher) ];
+ } else {
+ matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches );
+
+ // Return special upon seeing a positional matcher
+ if ( matcher[ expando ] ) {
+ // Find the next relative operator (if any) for proper handling
+ j = ++i;
+ for ( ; j < len; j++ ) {
+ if ( Expr.relative[ tokens[j].type ] ) {
+ break;
+ }
+ }
+ return setMatcher(
+ i > 1 && elementMatcher( matchers ),
+ i > 1 && toSelector(
+ // If the preceding token was a descendant combinator, insert an implicit any-element `*`
+ tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" })
+ ).replace( rtrim, "$1" ),
+ matcher,
+ i < j && matcherFromTokens( tokens.slice( i, j ) ),
+ j < len && matcherFromTokens( (tokens = tokens.slice( j )) ),
+ j < len && toSelector( tokens )
+ );
+ }
+ matchers.push( matcher );
+ }
+ }
+
+ return elementMatcher( matchers );
+}
+
+function matcherFromGroupMatchers( elementMatchers, setMatchers ) {
+ var bySet = setMatchers.length > 0,
+ byElement = elementMatchers.length > 0,
+ superMatcher = function( seed, context, xml, results, outermost ) {
+ var elem, j, matcher,
+ matchedCount = 0,
+ i = "0",
+ unmatched = seed && [],
+ setMatched = [],
+ contextBackup = outermostContext,
+ // We must always have either seed elements or outermost context
+ elems = seed || byElement && Expr.find["TAG"]( "*", outermost ),
+ // Use integer dirruns iff this is the outermost matcher
+ dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1),
+ len = elems.length;
+
+ if ( outermost ) {
+ outermostContext = context !== document && context;
+ }
+
+ // Add elements passing elementMatchers directly to results
+ // Keep `i` a string if there are no elements so `matchedCount` will be "00" below
+ // Support: IE<9, Safari
+ // Tolerate NodeList properties (IE: "length"; Safari: ) matching elements by id
+ for ( ; i !== len && (elem = elems[i]) != null; i++ ) {
+ if ( byElement && elem ) {
+ j = 0;
+ while ( (matcher = elementMatchers[j++]) ) {
+ if ( matcher( elem, context, xml ) ) {
+ results.push( elem );
+ break;
+ }
+ }
+ if ( outermost ) {
+ dirruns = dirrunsUnique;
+ }
+ }
+
+ // Track unmatched elements for set filters
+ if ( bySet ) {
+ // They will have gone through all possible matchers
+ if ( (elem = !matcher && elem) ) {
+ matchedCount--;
+ }
+
+ // Lengthen the array for every element, matched or not
+ if ( seed ) {
+ unmatched.push( elem );
+ }
+ }
+ }
+
+ // Apply set filters to unmatched elements
+ matchedCount += i;
+ if ( bySet && i !== matchedCount ) {
+ j = 0;
+ while ( (matcher = setMatchers[j++]) ) {
+ matcher( unmatched, setMatched, context, xml );
+ }
+
+ if ( seed ) {
+ // Reintegrate element matches to eliminate the need for sorting
+ if ( matchedCount > 0 ) {
+ while ( i-- ) {
+ if ( !(unmatched[i] || setMatched[i]) ) {
+ setMatched[i] = pop.call( results );
+ }
+ }
+ }
+
+ // Discard index placeholder values to get only actual matches
+ setMatched = condense( setMatched );
+ }
+
+ // Add matches to results
+ push.apply( results, setMatched );
+
+ // Seedless set matches succeeding multiple successful matchers stipulate sorting
+ if ( outermost && !seed && setMatched.length > 0 &&
+ ( matchedCount + setMatchers.length ) > 1 ) {
+
+ Sizzle.uniqueSort( results );
+ }
+ }
+
+ // Override manipulation of globals by nested matchers
+ if ( outermost ) {
+ dirruns = dirrunsUnique;
+ outermostContext = contextBackup;
+ }
+
+ return unmatched;
+ };
+
+ return bySet ?
+ markFunction( superMatcher ) :
+ superMatcher;
+}
+
+compile = Sizzle.compile = function( selector, match /* Internal Use Only */ ) {
+ var i,
+ setMatchers = [],
+ elementMatchers = [],
+ cached = compilerCache[ selector + " " ];
+
+ if ( !cached ) {
+ // Generate a function of recursive functions that can be used to check each element
+ if ( !match ) {
+ match = tokenize( selector );
+ }
+ i = match.length;
+ while ( i-- ) {
+ cached = matcherFromTokens( match[i] );
+ if ( cached[ expando ] ) {
+ setMatchers.push( cached );
+ } else {
+ elementMatchers.push( cached );
+ }
+ }
+
+ // Cache the compiled function
+ cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) );
+
+ // Save selector and tokenization
+ cached.selector = selector;
+ }
+ return cached;
+};
+
+/**
+ * A low-level selection function that works with Sizzle's compiled
+ * selector functions
+ * @param {String|Function} selector A selector or a pre-compiled
+ * selector function built with Sizzle.compile
+ * @param {Element} context
+ * @param {Array} [results]
+ * @param {Array} [seed] A set of elements to match against
+ */
+select = Sizzle.select = function( selector, context, results, seed ) {
+ var i, tokens, token, type, find,
+ compiled = typeof selector === "function" && selector,
+ match = !seed && tokenize( (selector = compiled.selector || selector) );
+
+ results = results || [];
+
+ // Try to minimize operations if there is no seed and only one group
+ if ( match.length === 1 ) {
+
+ // Take a shortcut and set the context if the root selector is an ID
+ tokens = match[0] = match[0].slice( 0 );
+ if ( tokens.length > 2 && (token = tokens[0]).type === "ID" &&
+ support.getById && context.nodeType === 9 && documentIsHTML &&
+ Expr.relative[ tokens[1].type ] ) {
+
+ context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0];
+ if ( !context ) {
+ return results;
+
+ // Precompiled matchers will still verify ancestry, so step up a level
+ } else if ( compiled ) {
+ context = context.parentNode;
+ }
+
+ selector = selector.slice( tokens.shift().value.length );
+ }
+
+ // Fetch a seed set for right-to-left matching
+ i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length;
+ while ( i-- ) {
+ token = tokens[i];
+
+ // Abort if we hit a combinator
+ if ( Expr.relative[ (type = token.type) ] ) {
+ break;
+ }
+ if ( (find = Expr.find[ type ]) ) {
+ // Search, expanding context for leading sibling combinators
+ if ( (seed = find(
+ token.matches[0].replace( runescape, funescape ),
+ rsibling.test( tokens[0].type ) && testContext( context.parentNode ) || context
+ )) ) {
+
+ // If seed is empty or no tokens remain, we can return early
+ tokens.splice( i, 1 );
+ selector = seed.length && toSelector( tokens );
+ if ( !selector ) {
+ push.apply( results, seed );
+ return results;
+ }
+
+ break;
+ }
+ }
+ }
+ }
+
+ // Compile and execute a filtering function if one is not provided
+ // Provide `match` to avoid retokenization if we modified the selector above
+ ( compiled || compile( selector, match ) )(
+ seed,
+ context,
+ !documentIsHTML,
+ results,
+ rsibling.test( selector ) && testContext( context.parentNode ) || context
+ );
+ return results;
+};
+
+// One-time assignments
+
+// Sort stability
+support.sortStable = expando.split("").sort( sortOrder ).join("") === expando;
+
+// Support: Chrome 14-35+
+// Always assume duplicates if they aren't passed to the comparison function
+support.detectDuplicates = !!hasDuplicate;
+
+// Initialize against the default document
+setDocument();
+
+// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27)
+// Detached nodes confoundingly follow *each other*
+support.sortDetached = assert(function( div1 ) {
+ // Should return 1, but returns 4 (following)
+ return div1.compareDocumentPosition( document.createElement("div") ) & 1;
+});
+
+// Support: IE<8
+// Prevent attribute/property "interpolation"
+// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx
+if ( !assert(function( div ) {
+ div.innerHTML = "";
+ return div.firstChild.getAttribute("href") === "#" ;
+}) ) {
+ addHandle( "type|href|height|width", function( elem, name, isXML ) {
+ if ( !isXML ) {
+ return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 );
+ }
+ });
+}
+
+// Support: IE<9
+// Use defaultValue in place of getAttribute("value")
+if ( !support.attributes || !assert(function( div ) {
+ div.innerHTML = "";
+ div.firstChild.setAttribute( "value", "" );
+ return div.firstChild.getAttribute( "value" ) === "";
+}) ) {
+ addHandle( "value", function( elem, name, isXML ) {
+ if ( !isXML && elem.nodeName.toLowerCase() === "input" ) {
+ return elem.defaultValue;
+ }
+ });
+}
+
+// Support: IE<9
+// Use getAttributeNode to fetch booleans when getAttribute lies
+if ( !assert(function( div ) {
+ return div.getAttribute("disabled") == null;
+}) ) {
+ addHandle( booleans, function( elem, name, isXML ) {
+ var val;
+ if ( !isXML ) {
+ return elem[ name ] === true ? name.toLowerCase() :
+ (val = elem.getAttributeNode( name )) && val.specified ?
+ val.value :
+ null;
+ }
+ });
+}
+
+return Sizzle;
+
+})( window );
+
+
+
+jQuery.find = Sizzle;
+jQuery.expr = Sizzle.selectors;
+jQuery.expr[":"] = jQuery.expr.pseudos;
+jQuery.unique = Sizzle.uniqueSort;
+jQuery.text = Sizzle.getText;
+jQuery.isXMLDoc = Sizzle.isXML;
+jQuery.contains = Sizzle.contains;
+
+
+
+var rneedsContext = jQuery.expr.match.needsContext;
+
+var rsingleTag = (/^<(\w+)\s*\/?>(?:<\/\1>|)$/);
+
+
+
+var risSimple = /^.[^:#\[\.,]*$/;
+
+// Implement the identical functionality for filter and not
+function winnow( elements, qualifier, not ) {
+ if ( jQuery.isFunction( qualifier ) ) {
+ return jQuery.grep( elements, function( elem, i ) {
+ /* jshint -W018 */
+ return !!qualifier.call( elem, i, elem ) !== not;
+ });
+
+ }
+
+ if ( qualifier.nodeType ) {
+ return jQuery.grep( elements, function( elem ) {
+ return ( elem === qualifier ) !== not;
+ });
+
+ }
+
+ if ( typeof qualifier === "string" ) {
+ if ( risSimple.test( qualifier ) ) {
+ return jQuery.filter( qualifier, elements, not );
+ }
+
+ qualifier = jQuery.filter( qualifier, elements );
+ }
+
+ return jQuery.grep( elements, function( elem ) {
+ return ( indexOf.call( qualifier, elem ) >= 0 ) !== not;
+ });
+}
+
+jQuery.filter = function( expr, elems, not ) {
+ var elem = elems[ 0 ];
+
+ if ( not ) {
+ expr = ":not(" + expr + ")";
+ }
+
+ return elems.length === 1 && elem.nodeType === 1 ?
+ jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [] :
+ jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) {
+ return elem.nodeType === 1;
+ }));
+};
+
+jQuery.fn.extend({
+ find: function( selector ) {
+ var i,
+ len = this.length,
+ ret = [],
+ self = this;
+
+ if ( typeof selector !== "string" ) {
+ return this.pushStack( jQuery( selector ).filter(function() {
+ for ( i = 0; i < len; i++ ) {
+ if ( jQuery.contains( self[ i ], this ) ) {
+ return true;
+ }
+ }
+ }) );
+ }
+
+ for ( i = 0; i < len; i++ ) {
+ jQuery.find( selector, self[ i ], ret );
+ }
+
+ // Needed because $( selector, context ) becomes $( context ).find( selector )
+ ret = this.pushStack( len > 1 ? jQuery.unique( ret ) : ret );
+ ret.selector = this.selector ? this.selector + " " + selector : selector;
+ return ret;
+ },
+ filter: function( selector ) {
+ return this.pushStack( winnow(this, selector || [], false) );
+ },
+ not: function( selector ) {
+ return this.pushStack( winnow(this, selector || [], true) );
+ },
+ is: function( selector ) {
+ return !!winnow(
+ this,
+
+ // If this is a positional/relative selector, check membership in the returned set
+ // so $("p:first").is("p:last") won't return true for a doc with two "p".
+ typeof selector === "string" && rneedsContext.test( selector ) ?
+ jQuery( selector ) :
+ selector || [],
+ false
+ ).length;
+ }
+});
+
+
+// Initialize a jQuery object
+
+
+// A central reference to the root jQuery(document)
+var rootjQuery,
+
+ // A simple way to check for HTML strings
+ // Prioritize #id over to avoid XSS via location.hash (#9521)
+ // Strict HTML recognition (#11290: must start with <)
+ rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,
+
+ init = jQuery.fn.init = function( selector, context ) {
+ var match, elem;
+
+ // HANDLE: $(""), $(null), $(undefined), $(false)
+ if ( !selector ) {
+ return this;
+ }
+
+ // Handle HTML strings
+ if ( typeof selector === "string" ) {
+ if ( selector[0] === "<" && selector[ selector.length - 1 ] === ">" && selector.length >= 3 ) {
+ // Assume that strings that start and end with <> are HTML and skip the regex check
+ match = [ null, selector, null ];
+
+ } else {
+ match = rquickExpr.exec( selector );
+ }
+
+ // Match html or make sure no context is specified for #id
+ if ( match && (match[1] || !context) ) {
+
+ // HANDLE: $(html) -> $(array)
+ if ( match[1] ) {
+ context = context instanceof jQuery ? context[0] : context;
+
+ // Option to run scripts is true for back-compat
+ // Intentionally let the error be thrown if parseHTML is not present
+ jQuery.merge( this, jQuery.parseHTML(
+ match[1],
+ context && context.nodeType ? context.ownerDocument || context : document,
+ true
+ ) );
+
+ // HANDLE: $(html, props)
+ if ( rsingleTag.test( match[1] ) && jQuery.isPlainObject( context ) ) {
+ for ( match in context ) {
+ // Properties of context are called as methods if possible
+ if ( jQuery.isFunction( this[ match ] ) ) {
+ this[ match ]( context[ match ] );
+
+ // ...and otherwise set as attributes
+ } else {
+ this.attr( match, context[ match ] );
+ }
+ }
+ }
+
+ return this;
+
+ // HANDLE: $(#id)
+ } else {
+ elem = document.getElementById( match[2] );
+
+ // Support: Blackberry 4.6
+ // gEBID returns nodes no longer in the document (#6963)
+ if ( elem && elem.parentNode ) {
+ // Inject the element directly into the jQuery object
+ this.length = 1;
+ this[0] = elem;
+ }
+
+ this.context = document;
+ this.selector = selector;
+ return this;
+ }
+
+ // HANDLE: $(expr, $(...))
+ } else if ( !context || context.jquery ) {
+ return ( context || rootjQuery ).find( selector );
+
+ // HANDLE: $(expr, context)
+ // (which is just equivalent to: $(context).find(expr)
+ } else {
+ return this.constructor( context ).find( selector );
+ }
+
+ // HANDLE: $(DOMElement)
+ } else if ( selector.nodeType ) {
+ this.context = this[0] = selector;
+ this.length = 1;
+ return this;
+
+ // HANDLE: $(function)
+ // Shortcut for document ready
+ } else if ( jQuery.isFunction( selector ) ) {
+ return typeof rootjQuery.ready !== "undefined" ?
+ rootjQuery.ready( selector ) :
+ // Execute immediately if ready is not present
+ selector( jQuery );
+ }
+
+ if ( selector.selector !== undefined ) {
+ this.selector = selector.selector;
+ this.context = selector.context;
+ }
+
+ return jQuery.makeArray( selector, this );
+ };
+
+// Give the init function the jQuery prototype for later instantiation
+init.prototype = jQuery.fn;
+
+// Initialize central reference
+rootjQuery = jQuery( document );
+
+
+var rparentsprev = /^(?:parents|prev(?:Until|All))/,
+ // Methods guaranteed to produce a unique set when starting from a unique set
+ guaranteedUnique = {
+ children: true,
+ contents: true,
+ next: true,
+ prev: true
+ };
+
+jQuery.extend({
+ dir: function( elem, dir, until ) {
+ var matched = [],
+ truncate = until !== undefined;
+
+ while ( (elem = elem[ dir ]) && elem.nodeType !== 9 ) {
+ if ( elem.nodeType === 1 ) {
+ if ( truncate && jQuery( elem ).is( until ) ) {
+ break;
+ }
+ matched.push( elem );
+ }
+ }
+ return matched;
+ },
+
+ sibling: function( n, elem ) {
+ var matched = [];
+
+ for ( ; n; n = n.nextSibling ) {
+ if ( n.nodeType === 1 && n !== elem ) {
+ matched.push( n );
+ }
+ }
+
+ return matched;
+ }
+});
+
+jQuery.fn.extend({
+ has: function( target ) {
+ var targets = jQuery( target, this ),
+ l = targets.length;
+
+ return this.filter(function() {
+ var i = 0;
+ for ( ; i < l; i++ ) {
+ if ( jQuery.contains( this, targets[i] ) ) {
+ return true;
+ }
+ }
+ });
+ },
+
+ closest: function( selectors, context ) {
+ var cur,
+ i = 0,
+ l = this.length,
+ matched = [],
+ pos = rneedsContext.test( selectors ) || typeof selectors !== "string" ?
+ jQuery( selectors, context || this.context ) :
+ 0;
+
+ for ( ; i < l; i++ ) {
+ for ( cur = this[i]; cur && cur !== context; cur = cur.parentNode ) {
+ // Always skip document fragments
+ if ( cur.nodeType < 11 && (pos ?
+ pos.index(cur) > -1 :
+
+ // Don't pass non-elements to Sizzle
+ cur.nodeType === 1 &&
+ jQuery.find.matchesSelector(cur, selectors)) ) {
+
+ matched.push( cur );
+ break;
+ }
+ }
+ }
+
+ return this.pushStack( matched.length > 1 ? jQuery.unique( matched ) : matched );
+ },
+
+ // Determine the position of an element within the set
+ index: function( elem ) {
+
+ // No argument, return index in parent
+ if ( !elem ) {
+ return ( this[ 0 ] && this[ 0 ].parentNode ) ? this.first().prevAll().length : -1;
+ }
+
+ // Index in selector
+ if ( typeof elem === "string" ) {
+ return indexOf.call( jQuery( elem ), this[ 0 ] );
+ }
+
+ // Locate the position of the desired element
+ return indexOf.call( this,
+
+ // If it receives a jQuery object, the first element is used
+ elem.jquery ? elem[ 0 ] : elem
+ );
+ },
+
+ add: function( selector, context ) {
+ return this.pushStack(
+ jQuery.unique(
+ jQuery.merge( this.get(), jQuery( selector, context ) )
+ )
+ );
+ },
+
+ addBack: function( selector ) {
+ return this.add( selector == null ?
+ this.prevObject : this.prevObject.filter(selector)
+ );
+ }
+});
+
+function sibling( cur, dir ) {
+ while ( (cur = cur[dir]) && cur.nodeType !== 1 ) {}
+ return cur;
+}
+
+jQuery.each({
+ parent: function( elem ) {
+ var parent = elem.parentNode;
+ return parent && parent.nodeType !== 11 ? parent : null;
+ },
+ parents: function( elem ) {
+ return jQuery.dir( elem, "parentNode" );
+ },
+ parentsUntil: function( elem, i, until ) {
+ return jQuery.dir( elem, "parentNode", until );
+ },
+ next: function( elem ) {
+ return sibling( elem, "nextSibling" );
+ },
+ prev: function( elem ) {
+ return sibling( elem, "previousSibling" );
+ },
+ nextAll: function( elem ) {
+ return jQuery.dir( elem, "nextSibling" );
+ },
+ prevAll: function( elem ) {
+ return jQuery.dir( elem, "previousSibling" );
+ },
+ nextUntil: function( elem, i, until ) {
+ return jQuery.dir( elem, "nextSibling", until );
+ },
+ prevUntil: function( elem, i, until ) {
+ return jQuery.dir( elem, "previousSibling", until );
+ },
+ siblings: function( elem ) {
+ return jQuery.sibling( ( elem.parentNode || {} ).firstChild, elem );
+ },
+ children: function( elem ) {
+ return jQuery.sibling( elem.firstChild );
+ },
+ contents: function( elem ) {
+ return elem.contentDocument || jQuery.merge( [], elem.childNodes );
+ }
+}, function( name, fn ) {
+ jQuery.fn[ name ] = function( until, selector ) {
+ var matched = jQuery.map( this, fn, until );
+
+ if ( name.slice( -5 ) !== "Until" ) {
+ selector = until;
+ }
+
+ if ( selector && typeof selector === "string" ) {
+ matched = jQuery.filter( selector, matched );
+ }
+
+ if ( this.length > 1 ) {
+ // Remove duplicates
+ if ( !guaranteedUnique[ name ] ) {
+ jQuery.unique( matched );
+ }
+
+ // Reverse order for parents* and prev-derivatives
+ if ( rparentsprev.test( name ) ) {
+ matched.reverse();
+ }
+ }
+
+ return this.pushStack( matched );
+ };
+});
+var rnotwhite = (/\S+/g);
+
+
+
+// String to Object options format cache
+var optionsCache = {};
+
+// Convert String-formatted options into Object-formatted ones and store in cache
+function createOptions( options ) {
+ var object = optionsCache[ options ] = {};
+ jQuery.each( options.match( rnotwhite ) || [], function( _, flag ) {
+ object[ flag ] = true;
+ });
+ return object;
+}
+
+/*
+ * Create a callback list using the following parameters:
+ *
+ * options: an optional list of space-separated options that will change how
+ * the callback list behaves or a more traditional option object
+ *
+ * By default a callback list will act like an event callback list and can be
+ * "fired" multiple times.
+ *
+ * Possible options:
+ *
+ * once: will ensure the callback list can only be fired once (like a Deferred)
+ *
+ * memory: will keep track of previous values and will call any callback added
+ * after the list has been fired right away with the latest "memorized"
+ * values (like a Deferred)
+ *
+ * unique: will ensure a callback can only be added once (no duplicate in the list)
+ *
+ * stopOnFalse: interrupt callings when a callback returns false
+ *
+ */
+jQuery.Callbacks = function( options ) {
+
+ // Convert options from String-formatted to Object-formatted if needed
+ // (we check in cache first)
+ options = typeof options === "string" ?
+ ( optionsCache[ options ] || createOptions( options ) ) :
+ jQuery.extend( {}, options );
+
+ var // Last fire value (for non-forgettable lists)
+ memory,
+ // Flag to know if list was already fired
+ fired,
+ // Flag to know if list is currently firing
+ firing,
+ // First callback to fire (used internally by add and fireWith)
+ firingStart,
+ // End of the loop when firing
+ firingLength,
+ // Index of currently firing callback (modified by remove if needed)
+ firingIndex,
+ // Actual callback list
+ list = [],
+ // Stack of fire calls for repeatable lists
+ stack = !options.once && [],
+ // Fire callbacks
+ fire = function( data ) {
+ memory = options.memory && data;
+ fired = true;
+ firingIndex = firingStart || 0;
+ firingStart = 0;
+ firingLength = list.length;
+ firing = true;
+ for ( ; list && firingIndex < firingLength; firingIndex++ ) {
+ if ( list[ firingIndex ].apply( data[ 0 ], data[ 1 ] ) === false && options.stopOnFalse ) {
+ memory = false; // To prevent further calls using add
+ break;
+ }
+ }
+ firing = false;
+ if ( list ) {
+ if ( stack ) {
+ if ( stack.length ) {
+ fire( stack.shift() );
+ }
+ } else if ( memory ) {
+ list = [];
+ } else {
+ self.disable();
+ }
+ }
+ },
+ // Actual Callbacks object
+ self = {
+ // Add a callback or a collection of callbacks to the list
+ add: function() {
+ if ( list ) {
+ // First, we save the current length
+ var start = list.length;
+ (function add( args ) {
+ jQuery.each( args, function( _, arg ) {
+ var type = jQuery.type( arg );
+ if ( type === "function" ) {
+ if ( !options.unique || !self.has( arg ) ) {
+ list.push( arg );
+ }
+ } else if ( arg && arg.length && type !== "string" ) {
+ // Inspect recursively
+ add( arg );
+ }
+ });
+ })( arguments );
+ // Do we need to add the callbacks to the
+ // current firing batch?
+ if ( firing ) {
+ firingLength = list.length;
+ // With memory, if we're not firing then
+ // we should call right away
+ } else if ( memory ) {
+ firingStart = start;
+ fire( memory );
+ }
+ }
+ return this;
+ },
+ // Remove a callback from the list
+ remove: function() {
+ if ( list ) {
+ jQuery.each( arguments, function( _, arg ) {
+ var index;
+ while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) {
+ list.splice( index, 1 );
+ // Handle firing indexes
+ if ( firing ) {
+ if ( index <= firingLength ) {
+ firingLength--;
+ }
+ if ( index <= firingIndex ) {
+ firingIndex--;
+ }
+ }
+ }
+ });
+ }
+ return this;
+ },
+ // Check if a given callback is in the list.
+ // If no argument is given, return whether or not list has callbacks attached.
+ has: function( fn ) {
+ return fn ? jQuery.inArray( fn, list ) > -1 : !!( list && list.length );
+ },
+ // Remove all callbacks from the list
+ empty: function() {
+ list = [];
+ firingLength = 0;
+ return this;
+ },
+ // Have the list do nothing anymore
+ disable: function() {
+ list = stack = memory = undefined;
+ return this;
+ },
+ // Is it disabled?
+ disabled: function() {
+ return !list;
+ },
+ // Lock the list in its current state
+ lock: function() {
+ stack = undefined;
+ if ( !memory ) {
+ self.disable();
+ }
+ return this;
+ },
+ // Is it locked?
+ locked: function() {
+ return !stack;
+ },
+ // Call all callbacks with the given context and arguments
+ fireWith: function( context, args ) {
+ if ( list && ( !fired || stack ) ) {
+ args = args || [];
+ args = [ context, args.slice ? args.slice() : args ];
+ if ( firing ) {
+ stack.push( args );
+ } else {
+ fire( args );
+ }
+ }
+ return this;
+ },
+ // Call all the callbacks with the given arguments
+ fire: function() {
+ self.fireWith( this, arguments );
+ return this;
+ },
+ // To know if the callbacks have already been called at least once
+ fired: function() {
+ return !!fired;
+ }
+ };
+
+ return self;
+};
+
+
+jQuery.extend({
+
+ Deferred: function( func ) {
+ var tuples = [
+ // action, add listener, listener list, final state
+ [ "resolve", "done", jQuery.Callbacks("once memory"), "resolved" ],
+ [ "reject", "fail", jQuery.Callbacks("once memory"), "rejected" ],
+ [ "notify", "progress", jQuery.Callbacks("memory") ]
+ ],
+ state = "pending",
+ promise = {
+ state: function() {
+ return state;
+ },
+ always: function() {
+ deferred.done( arguments ).fail( arguments );
+ return this;
+ },
+ then: function( /* fnDone, fnFail, fnProgress */ ) {
+ var fns = arguments;
+ return jQuery.Deferred(function( newDefer ) {
+ jQuery.each( tuples, function( i, tuple ) {
+ var fn = jQuery.isFunction( fns[ i ] ) && fns[ i ];
+ // deferred[ done | fail | progress ] for forwarding actions to newDefer
+ deferred[ tuple[1] ](function() {
+ var returned = fn && fn.apply( this, arguments );
+ if ( returned && jQuery.isFunction( returned.promise ) ) {
+ returned.promise()
+ .done( newDefer.resolve )
+ .fail( newDefer.reject )
+ .progress( newDefer.notify );
+ } else {
+ newDefer[ tuple[ 0 ] + "With" ]( this === promise ? newDefer.promise() : this, fn ? [ returned ] : arguments );
+ }
+ });
+ });
+ fns = null;
+ }).promise();
+ },
+ // Get a promise for this deferred
+ // If obj is provided, the promise aspect is added to the object
+ promise: function( obj ) {
+ return obj != null ? jQuery.extend( obj, promise ) : promise;
+ }
+ },
+ deferred = {};
+
+ // Keep pipe for back-compat
+ promise.pipe = promise.then;
+
+ // Add list-specific methods
+ jQuery.each( tuples, function( i, tuple ) {
+ var list = tuple[ 2 ],
+ stateString = tuple[ 3 ];
+
+ // promise[ done | fail | progress ] = list.add
+ promise[ tuple[1] ] = list.add;
+
+ // Handle state
+ if ( stateString ) {
+ list.add(function() {
+ // state = [ resolved | rejected ]
+ state = stateString;
+
+ // [ reject_list | resolve_list ].disable; progress_list.lock
+ }, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock );
+ }
+
+ // deferred[ resolve | reject | notify ]
+ deferred[ tuple[0] ] = function() {
+ deferred[ tuple[0] + "With" ]( this === deferred ? promise : this, arguments );
+ return this;
+ };
+ deferred[ tuple[0] + "With" ] = list.fireWith;
+ });
+
+ // Make the deferred a promise
+ promise.promise( deferred );
+
+ // Call given func if any
+ if ( func ) {
+ func.call( deferred, deferred );
+ }
+
+ // All done!
+ return deferred;
+ },
+
+ // Deferred helper
+ when: function( subordinate /* , ..., subordinateN */ ) {
+ var i = 0,
+ resolveValues = slice.call( arguments ),
+ length = resolveValues.length,
+
+ // the count of uncompleted subordinates
+ remaining = length !== 1 || ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0,
+
+ // the master Deferred. If resolveValues consist of only a single Deferred, just use that.
+ deferred = remaining === 1 ? subordinate : jQuery.Deferred(),
+
+ // Update function for both resolve and progress values
+ updateFunc = function( i, contexts, values ) {
+ return function( value ) {
+ contexts[ i ] = this;
+ values[ i ] = arguments.length > 1 ? slice.call( arguments ) : value;
+ if ( values === progressValues ) {
+ deferred.notifyWith( contexts, values );
+ } else if ( !( --remaining ) ) {
+ deferred.resolveWith( contexts, values );
+ }
+ };
+ },
+
+ progressValues, progressContexts, resolveContexts;
+
+ // Add listeners to Deferred subordinates; treat others as resolved
+ if ( length > 1 ) {
+ progressValues = new Array( length );
+ progressContexts = new Array( length );
+ resolveContexts = new Array( length );
+ for ( ; i < length; i++ ) {
+ if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) {
+ resolveValues[ i ].promise()
+ .done( updateFunc( i, resolveContexts, resolveValues ) )
+ .fail( deferred.reject )
+ .progress( updateFunc( i, progressContexts, progressValues ) );
+ } else {
+ --remaining;
+ }
+ }
+ }
+
+ // If we're not waiting on anything, resolve the master
+ if ( !remaining ) {
+ deferred.resolveWith( resolveContexts, resolveValues );
+ }
+
+ return deferred.promise();
+ }
+});
+
+
+// The deferred used on DOM ready
+var readyList;
+
+jQuery.fn.ready = function( fn ) {
+ // Add the callback
+ jQuery.ready.promise().done( fn );
+
+ return this;
+};
+
+jQuery.extend({
+ // Is the DOM ready to be used? Set to true once it occurs.
+ isReady: false,
+
+ // A counter to track how many items to wait for before
+ // the ready event fires. See #6781
+ readyWait: 1,
+
+ // Hold (or release) the ready event
+ holdReady: function( hold ) {
+ if ( hold ) {
+ jQuery.readyWait++;
+ } else {
+ jQuery.ready( true );
+ }
+ },
+
+ // Handle when the DOM is ready
+ ready: function( wait ) {
+
+ // Abort if there are pending holds or we're already ready
+ if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) {
+ return;
+ }
+
+ // Remember that the DOM is ready
+ jQuery.isReady = true;
+
+ // If a normal DOM Ready event fired, decrement, and wait if need be
+ if ( wait !== true && --jQuery.readyWait > 0 ) {
+ return;
+ }
+
+ // If there are functions bound, to execute
+ readyList.resolveWith( document, [ jQuery ] );
+
+ // Trigger any bound ready events
+ if ( jQuery.fn.triggerHandler ) {
+ jQuery( document ).triggerHandler( "ready" );
+ jQuery( document ).off( "ready" );
+ }
+ }
+});
+
+/**
+ * The ready event handler and self cleanup method
+ */
+function completed() {
+ document.removeEventListener( "DOMContentLoaded", completed, false );
+ window.removeEventListener( "load", completed, false );
+ jQuery.ready();
+}
+
+jQuery.ready.promise = function( obj ) {
+ if ( !readyList ) {
+
+ readyList = jQuery.Deferred();
+
+ // Catch cases where $(document).ready() is called after the browser event has already occurred.
+ // We once tried to use readyState "interactive" here, but it caused issues like the one
+ // discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15
+ if ( document.readyState === "complete" ) {
+ // Handle it asynchronously to allow scripts the opportunity to delay ready
+ setTimeout( jQuery.ready );
+
+ } else {
+
+ // Use the handy event callback
+ document.addEventListener( "DOMContentLoaded", completed, false );
+
+ // A fallback to window.onload, that will always work
+ window.addEventListener( "load", completed, false );
+ }
+ }
+ return readyList.promise( obj );
+};
+
+// Kick off the DOM ready check even if the user does not
+jQuery.ready.promise();
+
+
+
+
+// Multifunctional method to get and set values of a collection
+// The value/s can optionally be executed if it's a function
+var access = jQuery.access = function( elems, fn, key, value, chainable, emptyGet, raw ) {
+ var i = 0,
+ len = elems.length,
+ bulk = key == null;
+
+ // Sets many values
+ if ( jQuery.type( key ) === "object" ) {
+ chainable = true;
+ for ( i in key ) {
+ jQuery.access( elems, fn, i, key[i], true, emptyGet, raw );
+ }
+
+ // Sets one value
+ } else if ( value !== undefined ) {
+ chainable = true;
+
+ if ( !jQuery.isFunction( value ) ) {
+ raw = true;
+ }
+
+ if ( bulk ) {
+ // Bulk operations run against the entire set
+ if ( raw ) {
+ fn.call( elems, value );
+ fn = null;
+
+ // ...except when executing function values
+ } else {
+ bulk = fn;
+ fn = function( elem, key, value ) {
+ return bulk.call( jQuery( elem ), value );
+ };
+ }
+ }
+
+ if ( fn ) {
+ for ( ; i < len; i++ ) {
+ fn( elems[i], key, raw ? value : value.call( elems[i], i, fn( elems[i], key ) ) );
+ }
+ }
+ }
+
+ return chainable ?
+ elems :
+
+ // Gets
+ bulk ?
+ fn.call( elems ) :
+ len ? fn( elems[0], key ) : emptyGet;
+};
+
+
+/**
+ * Determines whether an object can have data
+ */
+jQuery.acceptData = function( owner ) {
+ // Accepts only:
+ // - Node
+ // - Node.ELEMENT_NODE
+ // - Node.DOCUMENT_NODE
+ // - Object
+ // - Any
+ /* jshint -W018 */
+ return owner.nodeType === 1 || owner.nodeType === 9 || !( +owner.nodeType );
+};
+
+
+function Data() {
+ // Support: Android<4,
+ // Old WebKit does not have Object.preventExtensions/freeze method,
+ // return new empty object instead with no [[set]] accessor
+ Object.defineProperty( this.cache = {}, 0, {
+ get: function() {
+ return {};
+ }
+ });
+
+ this.expando = jQuery.expando + Data.uid++;
+}
+
+Data.uid = 1;
+Data.accepts = jQuery.acceptData;
+
+Data.prototype = {
+ key: function( owner ) {
+ // We can accept data for non-element nodes in modern browsers,
+ // but we should not, see #8335.
+ // Always return the key for a frozen object.
+ if ( !Data.accepts( owner ) ) {
+ return 0;
+ }
+
+ var descriptor = {},
+ // Check if the owner object already has a cache key
+ unlock = owner[ this.expando ];
+
+ // If not, create one
+ if ( !unlock ) {
+ unlock = Data.uid++;
+
+ // Secure it in a non-enumerable, non-writable property
+ try {
+ descriptor[ this.expando ] = { value: unlock };
+ Object.defineProperties( owner, descriptor );
+
+ // Support: Android<4
+ // Fallback to a less secure definition
+ } catch ( e ) {
+ descriptor[ this.expando ] = unlock;
+ jQuery.extend( owner, descriptor );
+ }
+ }
+
+ // Ensure the cache object
+ if ( !this.cache[ unlock ] ) {
+ this.cache[ unlock ] = {};
+ }
+
+ return unlock;
+ },
+ set: function( owner, data, value ) {
+ var prop,
+ // There may be an unlock assigned to this node,
+ // if there is no entry for this "owner", create one inline
+ // and set the unlock as though an owner entry had always existed
+ unlock = this.key( owner ),
+ cache = this.cache[ unlock ];
+
+ // Handle: [ owner, key, value ] args
+ if ( typeof data === "string" ) {
+ cache[ data ] = value;
+
+ // Handle: [ owner, { properties } ] args
+ } else {
+ // Fresh assignments by object are shallow copied
+ if ( jQuery.isEmptyObject( cache ) ) {
+ jQuery.extend( this.cache[ unlock ], data );
+ // Otherwise, copy the properties one-by-one to the cache object
+ } else {
+ for ( prop in data ) {
+ cache[ prop ] = data[ prop ];
+ }
+ }
+ }
+ return cache;
+ },
+ get: function( owner, key ) {
+ // Either a valid cache is found, or will be created.
+ // New caches will be created and the unlock returned,
+ // allowing direct access to the newly created
+ // empty data object. A valid owner object must be provided.
+ var cache = this.cache[ this.key( owner ) ];
+
+ return key === undefined ?
+ cache : cache[ key ];
+ },
+ access: function( owner, key, value ) {
+ var stored;
+ // In cases where either:
+ //
+ // 1. No key was specified
+ // 2. A string key was specified, but no value provided
+ //
+ // Take the "read" path and allow the get method to determine
+ // which value to return, respectively either:
+ //
+ // 1. The entire cache object
+ // 2. The data stored at the key
+ //
+ if ( key === undefined ||
+ ((key && typeof key === "string") && value === undefined) ) {
+
+ stored = this.get( owner, key );
+
+ return stored !== undefined ?
+ stored : this.get( owner, jQuery.camelCase(key) );
+ }
+
+ // [*]When the key is not a string, or both a key and value
+ // are specified, set or extend (existing objects) with either:
+ //
+ // 1. An object of properties
+ // 2. A key and value
+ //
+ this.set( owner, key, value );
+
+ // Since the "set" path can have two possible entry points
+ // return the expected data based on which path was taken[*]
+ return value !== undefined ? value : key;
+ },
+ remove: function( owner, key ) {
+ var i, name, camel,
+ unlock = this.key( owner ),
+ cache = this.cache[ unlock ];
+
+ if ( key === undefined ) {
+ this.cache[ unlock ] = {};
+
+ } else {
+ // Support array or space separated string of keys
+ if ( jQuery.isArray( key ) ) {
+ // If "name" is an array of keys...
+ // When data is initially created, via ("key", "val") signature,
+ // keys will be converted to camelCase.
+ // Since there is no way to tell _how_ a key was added, remove
+ // both plain key and camelCase key. #12786
+ // This will only penalize the array argument path.
+ name = key.concat( key.map( jQuery.camelCase ) );
+ } else {
+ camel = jQuery.camelCase( key );
+ // Try the string as a key before any manipulation
+ if ( key in cache ) {
+ name = [ key, camel ];
+ } else {
+ // If a key with the spaces exists, use it.
+ // Otherwise, create an array by matching non-whitespace
+ name = camel;
+ name = name in cache ?
+ [ name ] : ( name.match( rnotwhite ) || [] );
+ }
+ }
+
+ i = name.length;
+ while ( i-- ) {
+ delete cache[ name[ i ] ];
+ }
+ }
+ },
+ hasData: function( owner ) {
+ return !jQuery.isEmptyObject(
+ this.cache[ owner[ this.expando ] ] || {}
+ );
+ },
+ discard: function( owner ) {
+ if ( owner[ this.expando ] ) {
+ delete this.cache[ owner[ this.expando ] ];
+ }
+ }
+};
+var data_priv = new Data();
+
+var data_user = new Data();
+
+
+
+// Implementation Summary
+//
+// 1. Enforce API surface and semantic compatibility with 1.9.x branch
+// 2. Improve the module's maintainability by reducing the storage
+// paths to a single mechanism.
+// 3. Use the same single mechanism to support "private" and "user" data.
+// 4. _Never_ expose "private" data to user code (TODO: Drop _data, _removeData)
+// 5. Avoid exposing implementation details on user objects (eg. expando properties)
+// 6. Provide a clear path for implementation upgrade to WeakMap in 2014
+
+var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,
+ rmultiDash = /([A-Z])/g;
+
+function dataAttr( elem, key, data ) {
+ var name;
+
+ // If nothing was found internally, try to fetch any
+ // data from the HTML5 data-* attribute
+ if ( data === undefined && elem.nodeType === 1 ) {
+ name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase();
+ data = elem.getAttribute( name );
+
+ if ( typeof data === "string" ) {
+ try {
+ data = data === "true" ? true :
+ data === "false" ? false :
+ data === "null" ? null :
+ // Only convert to a number if it doesn't change the string
+ +data + "" === data ? +data :
+ rbrace.test( data ) ? jQuery.parseJSON( data ) :
+ data;
+ } catch( e ) {}
+
+ // Make sure we set the data so it isn't changed later
+ data_user.set( elem, key, data );
+ } else {
+ data = undefined;
+ }
+ }
+ return data;
+}
+
+jQuery.extend({
+ hasData: function( elem ) {
+ return data_user.hasData( elem ) || data_priv.hasData( elem );
+ },
+
+ data: function( elem, name, data ) {
+ return data_user.access( elem, name, data );
+ },
+
+ removeData: function( elem, name ) {
+ data_user.remove( elem, name );
+ },
+
+ // TODO: Now that all calls to _data and _removeData have been replaced
+ // with direct calls to data_priv methods, these can be deprecated.
+ _data: function( elem, name, data ) {
+ return data_priv.access( elem, name, data );
+ },
+
+ _removeData: function( elem, name ) {
+ data_priv.remove( elem, name );
+ }
+});
+
+jQuery.fn.extend({
+ data: function( key, value ) {
+ var i, name, data,
+ elem = this[ 0 ],
+ attrs = elem && elem.attributes;
+
+ // Gets all values
+ if ( key === undefined ) {
+ if ( this.length ) {
+ data = data_user.get( elem );
+
+ if ( elem.nodeType === 1 && !data_priv.get( elem, "hasDataAttrs" ) ) {
+ i = attrs.length;
+ while ( i-- ) {
+
+ // Support: IE11+
+ // The attrs elements can be null (#14894)
+ if ( attrs[ i ] ) {
+ name = attrs[ i ].name;
+ if ( name.indexOf( "data-" ) === 0 ) {
+ name = jQuery.camelCase( name.slice(5) );
+ dataAttr( elem, name, data[ name ] );
+ }
+ }
+ }
+ data_priv.set( elem, "hasDataAttrs", true );
+ }
+ }
+
+ return data;
+ }
+
+ // Sets multiple values
+ if ( typeof key === "object" ) {
+ return this.each(function() {
+ data_user.set( this, key );
+ });
+ }
+
+ return access( this, function( value ) {
+ var data,
+ camelKey = jQuery.camelCase( key );
+
+ // The calling jQuery object (element matches) is not empty
+ // (and therefore has an element appears at this[ 0 ]) and the
+ // `value` parameter was not undefined. An empty jQuery object
+ // will result in `undefined` for elem = this[ 0 ] which will
+ // throw an exception if an attempt to read a data cache is made.
+ if ( elem && value === undefined ) {
+ // Attempt to get data from the cache
+ // with the key as-is
+ data = data_user.get( elem, key );
+ if ( data !== undefined ) {
+ return data;
+ }
+
+ // Attempt to get data from the cache
+ // with the key camelized
+ data = data_user.get( elem, camelKey );
+ if ( data !== undefined ) {
+ return data;
+ }
+
+ // Attempt to "discover" the data in
+ // HTML5 custom data-* attrs
+ data = dataAttr( elem, camelKey, undefined );
+ if ( data !== undefined ) {
+ return data;
+ }
+
+ // We tried really hard, but the data doesn't exist.
+ return;
+ }
+
+ // Set the data...
+ this.each(function() {
+ // First, attempt to store a copy or reference of any
+ // data that might've been store with a camelCased key.
+ var data = data_user.get( this, camelKey );
+
+ // For HTML5 data-* attribute interop, we have to
+ // store property names with dashes in a camelCase form.
+ // This might not apply to all properties...*
+ data_user.set( this, camelKey, value );
+
+ // *... In the case of properties that might _actually_
+ // have dashes, we need to also store a copy of that
+ // unchanged property.
+ if ( key.indexOf("-") !== -1 && data !== undefined ) {
+ data_user.set( this, key, value );
+ }
+ });
+ }, null, value, arguments.length > 1, null, true );
+ },
+
+ removeData: function( key ) {
+ return this.each(function() {
+ data_user.remove( this, key );
+ });
+ }
+});
+
+
+jQuery.extend({
+ queue: function( elem, type, data ) {
+ var queue;
+
+ if ( elem ) {
+ type = ( type || "fx" ) + "queue";
+ queue = data_priv.get( elem, type );
+
+ // Speed up dequeue by getting out quickly if this is just a lookup
+ if ( data ) {
+ if ( !queue || jQuery.isArray( data ) ) {
+ queue = data_priv.access( elem, type, jQuery.makeArray(data) );
+ } else {
+ queue.push( data );
+ }
+ }
+ return queue || [];
+ }
+ },
+
+ dequeue: function( elem, type ) {
+ type = type || "fx";
+
+ var queue = jQuery.queue( elem, type ),
+ startLength = queue.length,
+ fn = queue.shift(),
+ hooks = jQuery._queueHooks( elem, type ),
+ next = function() {
+ jQuery.dequeue( elem, type );
+ };
+
+ // If the fx queue is dequeued, always remove the progress sentinel
+ if ( fn === "inprogress" ) {
+ fn = queue.shift();
+ startLength--;
+ }
+
+ if ( fn ) {
+
+ // Add a progress sentinel to prevent the fx queue from being
+ // automatically dequeued
+ if ( type === "fx" ) {
+ queue.unshift( "inprogress" );
+ }
+
+ // Clear up the last queue stop function
+ delete hooks.stop;
+ fn.call( elem, next, hooks );
+ }
+
+ if ( !startLength && hooks ) {
+ hooks.empty.fire();
+ }
+ },
+
+ // Not public - generate a queueHooks object, or return the current one
+ _queueHooks: function( elem, type ) {
+ var key = type + "queueHooks";
+ return data_priv.get( elem, key ) || data_priv.access( elem, key, {
+ empty: jQuery.Callbacks("once memory").add(function() {
+ data_priv.remove( elem, [ type + "queue", key ] );
+ })
+ });
+ }
+});
+
+jQuery.fn.extend({
+ queue: function( type, data ) {
+ var setter = 2;
+
+ if ( typeof type !== "string" ) {
+ data = type;
+ type = "fx";
+ setter--;
+ }
+
+ if ( arguments.length < setter ) {
+ return jQuery.queue( this[0], type );
+ }
+
+ return data === undefined ?
+ this :
+ this.each(function() {
+ var queue = jQuery.queue( this, type, data );
+
+ // Ensure a hooks for this queue
+ jQuery._queueHooks( this, type );
+
+ if ( type === "fx" && queue[0] !== "inprogress" ) {
+ jQuery.dequeue( this, type );
+ }
+ });
+ },
+ dequeue: function( type ) {
+ return this.each(function() {
+ jQuery.dequeue( this, type );
+ });
+ },
+ clearQueue: function( type ) {
+ return this.queue( type || "fx", [] );
+ },
+ // Get a promise resolved when queues of a certain type
+ // are emptied (fx is the type by default)
+ promise: function( type, obj ) {
+ var tmp,
+ count = 1,
+ defer = jQuery.Deferred(),
+ elements = this,
+ i = this.length,
+ resolve = function() {
+ if ( !( --count ) ) {
+ defer.resolveWith( elements, [ elements ] );
+ }
+ };
+
+ if ( typeof type !== "string" ) {
+ obj = type;
+ type = undefined;
+ }
+ type = type || "fx";
+
+ while ( i-- ) {
+ tmp = data_priv.get( elements[ i ], type + "queueHooks" );
+ if ( tmp && tmp.empty ) {
+ count++;
+ tmp.empty.add( resolve );
+ }
+ }
+ resolve();
+ return defer.promise( obj );
+ }
+});
+var pnum = (/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/).source;
+
+var cssExpand = [ "Top", "Right", "Bottom", "Left" ];
+
+var isHidden = function( elem, el ) {
+ // isHidden might be called from jQuery#filter function;
+ // in that case, element will be second argument
+ elem = el || elem;
+ return jQuery.css( elem, "display" ) === "none" || !jQuery.contains( elem.ownerDocument, elem );
+ };
+
+var rcheckableType = (/^(?:checkbox|radio)$/i);
+
+
+
+(function() {
+ var fragment = document.createDocumentFragment(),
+ div = fragment.appendChild( document.createElement( "div" ) ),
+ input = document.createElement( "input" );
+
+ // Support: Safari<=5.1
+ // Check state lost if the name is set (#11217)
+ // Support: Windows Web Apps (WWA)
+ // `name` and `type` must use .setAttribute for WWA (#14901)
+ input.setAttribute( "type", "radio" );
+ input.setAttribute( "checked", "checked" );
+ input.setAttribute( "name", "t" );
+
+ div.appendChild( input );
+
+ // Support: Safari<=5.1, Android<4.2
+ // Older WebKit doesn't clone checked state correctly in fragments
+ support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked;
+
+ // Support: IE<=11+
+ // Make sure textarea (and checkbox) defaultValue is properly cloned
+ div.innerHTML = "";
+ support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue;
+})();
+var strundefined = typeof undefined;
+
+
+
+support.focusinBubbles = "onfocusin" in window;
+
+
+var
+ rkeyEvent = /^key/,
+ rmouseEvent = /^(?:mouse|pointer|contextmenu)|click/,
+ rfocusMorph = /^(?:focusinfocus|focusoutblur)$/,
+ rtypenamespace = /^([^.]*)(?:\.(.+)|)$/;
+
+function returnTrue() {
+ return true;
+}
+
+function returnFalse() {
+ return false;
+}
+
+function safeActiveElement() {
+ try {
+ return document.activeElement;
+ } catch ( err ) { }
+}
+
+/*
+ * Helper functions for managing events -- not part of the public interface.
+ * Props to Dean Edwards' addEvent library for many of the ideas.
+ */
+jQuery.event = {
+
+ global: {},
+
+ add: function( elem, types, handler, data, selector ) {
+
+ var handleObjIn, eventHandle, tmp,
+ events, t, handleObj,
+ special, handlers, type, namespaces, origType,
+ elemData = data_priv.get( elem );
+
+ // Don't attach events to noData or text/comment nodes (but allow plain objects)
+ if ( !elemData ) {
+ return;
+ }
+
+ // Caller can pass in an object of custom data in lieu of the handler
+ if ( handler.handler ) {
+ handleObjIn = handler;
+ handler = handleObjIn.handler;
+ selector = handleObjIn.selector;
+ }
+
+ // Make sure that the handler has a unique ID, used to find/remove it later
+ if ( !handler.guid ) {
+ handler.guid = jQuery.guid++;
+ }
+
+ // Init the element's event structure and main handler, if this is the first
+ if ( !(events = elemData.events) ) {
+ events = elemData.events = {};
+ }
+ if ( !(eventHandle = elemData.handle) ) {
+ eventHandle = elemData.handle = function( e ) {
+ // Discard the second event of a jQuery.event.trigger() and
+ // when an event is called after a page has unloaded
+ return typeof jQuery !== strundefined && jQuery.event.triggered !== e.type ?
+ jQuery.event.dispatch.apply( elem, arguments ) : undefined;
+ };
+ }
+
+ // Handle multiple events separated by a space
+ types = ( types || "" ).match( rnotwhite ) || [ "" ];
+ t = types.length;
+ while ( t-- ) {
+ tmp = rtypenamespace.exec( types[t] ) || [];
+ type = origType = tmp[1];
+ namespaces = ( tmp[2] || "" ).split( "." ).sort();
+
+ // There *must* be a type, no attaching namespace-only handlers
+ if ( !type ) {
+ continue;
+ }
+
+ // If event changes its type, use the special event handlers for the changed type
+ special = jQuery.event.special[ type ] || {};
+
+ // If selector defined, determine special event api type, otherwise given type
+ type = ( selector ? special.delegateType : special.bindType ) || type;
+
+ // Update special based on newly reset type
+ special = jQuery.event.special[ type ] || {};
+
+ // handleObj is passed to all event handlers
+ handleObj = jQuery.extend({
+ type: type,
+ origType: origType,
+ data: data,
+ handler: handler,
+ guid: handler.guid,
+ selector: selector,
+ needsContext: selector && jQuery.expr.match.needsContext.test( selector ),
+ namespace: namespaces.join(".")
+ }, handleObjIn );
+
+ // Init the event handler queue if we're the first
+ if ( !(handlers = events[ type ]) ) {
+ handlers = events[ type ] = [];
+ handlers.delegateCount = 0;
+
+ // Only use addEventListener if the special events handler returns false
+ if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) {
+ if ( elem.addEventListener ) {
+ elem.addEventListener( type, eventHandle, false );
+ }
+ }
+ }
+
+ if ( special.add ) {
+ special.add.call( elem, handleObj );
+
+ if ( !handleObj.handler.guid ) {
+ handleObj.handler.guid = handler.guid;
+ }
+ }
+
+ // Add to the element's handler list, delegates in front
+ if ( selector ) {
+ handlers.splice( handlers.delegateCount++, 0, handleObj );
+ } else {
+ handlers.push( handleObj );
+ }
+
+ // Keep track of which events have ever been used, for event optimization
+ jQuery.event.global[ type ] = true;
+ }
+
+ },
+
+ // Detach an event or set of events from an element
+ remove: function( elem, types, handler, selector, mappedTypes ) {
+
+ var j, origCount, tmp,
+ events, t, handleObj,
+ special, handlers, type, namespaces, origType,
+ elemData = data_priv.hasData( elem ) && data_priv.get( elem );
+
+ if ( !elemData || !(events = elemData.events) ) {
+ return;
+ }
+
+ // Once for each type.namespace in types; type may be omitted
+ types = ( types || "" ).match( rnotwhite ) || [ "" ];
+ t = types.length;
+ while ( t-- ) {
+ tmp = rtypenamespace.exec( types[t] ) || [];
+ type = origType = tmp[1];
+ namespaces = ( tmp[2] || "" ).split( "." ).sort();
+
+ // Unbind all events (on this namespace, if provided) for the element
+ if ( !type ) {
+ for ( type in events ) {
+ jQuery.event.remove( elem, type + types[ t ], handler, selector, true );
+ }
+ continue;
+ }
+
+ special = jQuery.event.special[ type ] || {};
+ type = ( selector ? special.delegateType : special.bindType ) || type;
+ handlers = events[ type ] || [];
+ tmp = tmp[2] && new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" );
+
+ // Remove matching events
+ origCount = j = handlers.length;
+ while ( j-- ) {
+ handleObj = handlers[ j ];
+
+ if ( ( mappedTypes || origType === handleObj.origType ) &&
+ ( !handler || handler.guid === handleObj.guid ) &&
+ ( !tmp || tmp.test( handleObj.namespace ) ) &&
+ ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) {
+ handlers.splice( j, 1 );
+
+ if ( handleObj.selector ) {
+ handlers.delegateCount--;
+ }
+ if ( special.remove ) {
+ special.remove.call( elem, handleObj );
+ }
+ }
+ }
+
+ // Remove generic event handler if we removed something and no more handlers exist
+ // (avoids potential for endless recursion during removal of special event handlers)
+ if ( origCount && !handlers.length ) {
+ if ( !special.teardown || special.teardown.call( elem, namespaces, elemData.handle ) === false ) {
+ jQuery.removeEvent( elem, type, elemData.handle );
+ }
+
+ delete events[ type ];
+ }
+ }
+
+ // Remove the expando if it's no longer used
+ if ( jQuery.isEmptyObject( events ) ) {
+ delete elemData.handle;
+ data_priv.remove( elem, "events" );
+ }
+ },
+
+ trigger: function( event, data, elem, onlyHandlers ) {
+
+ var i, cur, tmp, bubbleType, ontype, handle, special,
+ eventPath = [ elem || document ],
+ type = hasOwn.call( event, "type" ) ? event.type : event,
+ namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split(".") : [];
+
+ cur = tmp = elem = elem || document;
+
+ // Don't do events on text and comment nodes
+ if ( elem.nodeType === 3 || elem.nodeType === 8 ) {
+ return;
+ }
+
+ // focus/blur morphs to focusin/out; ensure we're not firing them right now
+ if ( rfocusMorph.test( type + jQuery.event.triggered ) ) {
+ return;
+ }
+
+ if ( type.indexOf(".") >= 0 ) {
+ // Namespaced trigger; create a regexp to match event type in handle()
+ namespaces = type.split(".");
+ type = namespaces.shift();
+ namespaces.sort();
+ }
+ ontype = type.indexOf(":") < 0 && "on" + type;
+
+ // Caller can pass in a jQuery.Event object, Object, or just an event type string
+ event = event[ jQuery.expando ] ?
+ event :
+ new jQuery.Event( type, typeof event === "object" && event );
+
+ // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true)
+ event.isTrigger = onlyHandlers ? 2 : 3;
+ event.namespace = namespaces.join(".");
+ event.namespace_re = event.namespace ?
+ new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ) :
+ null;
+
+ // Clean up the event in case it is being reused
+ event.result = undefined;
+ if ( !event.target ) {
+ event.target = elem;
+ }
+
+ // Clone any incoming data and prepend the event, creating the handler arg list
+ data = data == null ?
+ [ event ] :
+ jQuery.makeArray( data, [ event ] );
+
+ // Allow special events to draw outside the lines
+ special = jQuery.event.special[ type ] || {};
+ if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) {
+ return;
+ }
+
+ // Determine event propagation path in advance, per W3C events spec (#9951)
+ // Bubble up to document, then to window; watch for a global ownerDocument var (#9724)
+ if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) {
+
+ bubbleType = special.delegateType || type;
+ if ( !rfocusMorph.test( bubbleType + type ) ) {
+ cur = cur.parentNode;
+ }
+ for ( ; cur; cur = cur.parentNode ) {
+ eventPath.push( cur );
+ tmp = cur;
+ }
+
+ // Only add window if we got to document (e.g., not plain obj or detached DOM)
+ if ( tmp === (elem.ownerDocument || document) ) {
+ eventPath.push( tmp.defaultView || tmp.parentWindow || window );
+ }
+ }
+
+ // Fire handlers on the event path
+ i = 0;
+ while ( (cur = eventPath[i++]) && !event.isPropagationStopped() ) {
+
+ event.type = i > 1 ?
+ bubbleType :
+ special.bindType || type;
+
+ // jQuery handler
+ handle = ( data_priv.get( cur, "events" ) || {} )[ event.type ] && data_priv.get( cur, "handle" );
+ if ( handle ) {
+ handle.apply( cur, data );
+ }
+
+ // Native handler
+ handle = ontype && cur[ ontype ];
+ if ( handle && handle.apply && jQuery.acceptData( cur ) ) {
+ event.result = handle.apply( cur, data );
+ if ( event.result === false ) {
+ event.preventDefault();
+ }
+ }
+ }
+ event.type = type;
+
+ // If nobody prevented the default action, do it now
+ if ( !onlyHandlers && !event.isDefaultPrevented() ) {
+
+ if ( (!special._default || special._default.apply( eventPath.pop(), data ) === false) &&
+ jQuery.acceptData( elem ) ) {
+
+ // Call a native DOM method on the target with the same name name as the event.
+ // Don't do default actions on window, that's where global variables be (#6170)
+ if ( ontype && jQuery.isFunction( elem[ type ] ) && !jQuery.isWindow( elem ) ) {
+
+ // Don't re-trigger an onFOO event when we call its FOO() method
+ tmp = elem[ ontype ];
+
+ if ( tmp ) {
+ elem[ ontype ] = null;
+ }
+
+ // Prevent re-triggering of the same event, since we already bubbled it above
+ jQuery.event.triggered = type;
+ elem[ type ]();
+ jQuery.event.triggered = undefined;
+
+ if ( tmp ) {
+ elem[ ontype ] = tmp;
+ }
+ }
+ }
+ }
+
+ return event.result;
+ },
+
+ dispatch: function( event ) {
+
+ // Make a writable jQuery.Event from the native event object
+ event = jQuery.event.fix( event );
+
+ var i, j, ret, matched, handleObj,
+ handlerQueue = [],
+ args = slice.call( arguments ),
+ handlers = ( data_priv.get( this, "events" ) || {} )[ event.type ] || [],
+ special = jQuery.event.special[ event.type ] || {};
+
+ // Use the fix-ed jQuery.Event rather than the (read-only) native event
+ args[0] = event;
+ event.delegateTarget = this;
+
+ // Call the preDispatch hook for the mapped type, and let it bail if desired
+ if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) {
+ return;
+ }
+
+ // Determine handlers
+ handlerQueue = jQuery.event.handlers.call( this, event, handlers );
+
+ // Run delegates first; they may want to stop propagation beneath us
+ i = 0;
+ while ( (matched = handlerQueue[ i++ ]) && !event.isPropagationStopped() ) {
+ event.currentTarget = matched.elem;
+
+ j = 0;
+ while ( (handleObj = matched.handlers[ j++ ]) && !event.isImmediatePropagationStopped() ) {
+
+ // Triggered event must either 1) have no namespace, or 2) have namespace(s)
+ // a subset or equal to those in the bound event (both can have no namespace).
+ if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) {
+
+ event.handleObj = handleObj;
+ event.data = handleObj.data;
+
+ ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler )
+ .apply( matched.elem, args );
+
+ if ( ret !== undefined ) {
+ if ( (event.result = ret) === false ) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ }
+ }
+ }
+
+ // Call the postDispatch hook for the mapped type
+ if ( special.postDispatch ) {
+ special.postDispatch.call( this, event );
+ }
+
+ return event.result;
+ },
+
+ handlers: function( event, handlers ) {
+ var i, matches, sel, handleObj,
+ handlerQueue = [],
+ delegateCount = handlers.delegateCount,
+ cur = event.target;
+
+ // Find delegate handlers
+ // Black-hole SVG ';
-}
-
-function imageRenderer(model) {
- return ' ';
-}
-
-var typeRenderers = {};
-typeRenderers[Type.EMBED.id] = embedRenderer;
-typeRenderers[Type.IMAGE.id] = imageRenderer;
-
-/**
- * @class EditorHTMLRenderer
- * @constructor
- * Subclass of HTMLRenderer specifically for the Editor
- * Wraps interactive elements to add functionality
- */
-function EditorHTMLRenderer() {
- HTMLRenderer.call(this, {
- typeRenderers: typeRenderers
- });
-}
-inherit(EditorHTMLRenderer, HTMLRenderer);
-
-export default EditorHTMLRenderer;
diff --git a/src/js/renderers/instagram.js b/src/js/renderers/instagram.js
deleted file mode 100644
index b92005bb3..000000000
--- a/src/js/renderers/instagram.js
+++ /dev/null
@@ -1,7 +0,0 @@
-
-function InstagramRenderer() {}
-InstagramRenderer.prototype.render = function(model) {
- return '
'; -}; - -export default TwitterRenderer; diff --git a/src/js/renderers/youtube.js b/src/js/renderers/youtube.js deleted file mode 100644 index 3d9b4bf61..000000000 --- a/src/js/renderers/youtube.js +++ /dev/null @@ -1,19 +0,0 @@ - -var RegExVideoId = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#\&\?]*).*/; - -function getVideoIdFromUrl(url) { - var match = url && 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 ''; -}; - -export default YouTubeRenderer; \ No newline at end of file diff --git a/src/js/utils/array-utils.js b/src/js/utils/array-utils.js new file mode 100644 index 000000000..cc23389e8 --- /dev/null +++ b/src/js/utils/array-utils.js @@ -0,0 +1,22 @@ +function detect(array, callback) { + for (let i=0; i
Hello
`; + editorElement.innerHTML = innerHTML; + editor = new Editor(document.getElementById('editor')); + + assert.equal(editorElement.getAttribute('contenteditable'), + 'true', + 'element is contenteditable'); + assert.equal(editorElement.firstChild.tagName, 'P', + `editor element has a P as its first child`); +}); + +test('editing element changes editor post model', (assert) => { + let innerHTML = `Hello
`; + editorElement.innerHTML = innerHTML; + editor = new Editor(document.getElementById('editor')); + + let p = editorElement.querySelector('p'); + let textElement = p.firstChild; + + Helpers.dom.moveCursorTo(textElement, 0); + + document.execCommand('insertText', false, 'A'); + assert.equal(p.textContent, 'AHello'); +}); diff --git a/tests/acceptance/editor-commands-test.js b/tests/acceptance/editor-commands-test.js new file mode 100644 index 000000000..b87bc2aac --- /dev/null +++ b/tests/acceptance/editor-commands-test.js @@ -0,0 +1,150 @@ +import { Editor } from 'content-kit-editor'; +import Helpers from '../test-helpers'; + +const { test, module } = QUnit; + +let fixture, editor, editorElement, selectedText; + +module('Acceptance: Editor commands', { + beforeEach() { + fixture = document.getElementById('qunit-fixture'); + editorElement = document.createElement('div'); + editorElement.setAttribute('id', 'editor'); + editorElement.innerHTML = 'THIS IS A TEST'; + fixture.appendChild(editorElement); + editor = new Editor(editorElement); + + selectedText = 'IS A'; + Helpers.dom.selectText(selectedText, editorElement); + Helpers.dom.triggerEvent(document, 'mouseup'); + }, + + afterEach() { + editor.destroy(); + } +}); + +function clickToolbarButton(name, assert) { + let btnSelector = `.ck-toolbar-btn[title="${name}"]`; + let button = assert.hasElement(btnSelector); + + Helpers.dom.triggerEvent(button[0], 'mouseup'); +} + +test('when text is highlighted, shows toolbar', (assert) => { + let done = assert.async(); + + setTimeout(() => { + assert.hasElement('.ck-toolbar', 'displays toolbar'); + assert.hasElement('.ck-toolbar-btn', 'displays toolbar buttons'); + let boldBtnSelector = '.ck-toolbar-btn[title="bold"]'; + assert.hasElement(boldBtnSelector, 'has bold button'); + + done(); + }); +}); + +test('highlight text, click "bold" button bolds text', (assert) => { + let done = assert.async(); + + setTimeout(() => { + clickToolbarButton('bold', assert); + assert.hasElement('#editor b:contains(IS A)'); + + done(); + }); +}); + +test('highlight text, click "italic" button italicizes text', (assert) => { + let done = assert.async(); + + setTimeout(() => { + clickToolbarButton('italic', assert); + assert.hasElement('#editor i:contains(IS A)'); + + done(); + }); +}); + +test('highlight text, click "heading" button turns text into h2 header', (assert) => { + const done = assert.async(); + + setTimeout(() => { + clickToolbarButton('heading', assert); + assert.hasElement('#editor h2:contains(THIS IS A TEST)'); + + done(); + }); +}); + +test('highlight text, click "subheading" button turns text into h3 header', (assert) => { + const done = assert.async(); + + setTimeout(() => { + clickToolbarButton('subheading', assert); + assert.hasElement('#editor h3:contains(THIS IS A TEST)'); + + done(); + }); +}); + +test('highlight text, click "quote" button turns text into blockquote', (assert) => { + const done = assert.async(); + + setTimeout(() => { + clickToolbarButton('quote', assert); + assert.hasElement('#editor blockquote:contains(THIS IS A TEST)'); + + done(); + }); +}); + +// FIXME PhantomJS doesn't create keyboard events properly (they have no keyCode or which) +// see https://bugs.webkit.org/show_bug.cgi?id=36423 +Helpers.skipInPhantom('highlight text, click "link" button shows input for URL, makes link', (assert) => { + const done = assert.async(); + + setTimeout(() => { + clickToolbarButton('link', assert); + let input = assert.hasElement('.ck-toolbar-prompt input'); + let url = 'http://google.com'; + $(input).val(url); + Helpers.dom.triggerKeyEvent(input[0], 'keyup'); + + assert.hasElement(`#editor a[href="${url}"]:contains(${selectedText})`); + + done(); + }); +}); + +test('highlighting bold text shows bold button as active', (assert) => { + const done = assert.async(); + + setTimeout(() => { + assert.hasNoElement(`.ck-toolbar-btn.active[title="bold"]`, + 'precond - bold button is not active'); + clickToolbarButton('bold', assert); + + assert.hasElement(`.ck-toolbar-btn.active[title="bold"]`, + 'bold button is active after clicking it'); + + Helpers.dom.clearSelection(); + Helpers.dom.triggerEvent(document, 'mouseup'); + + setTimeout(() => { + assert.hasNoElement('.ck-toolbar', 'toolbar is hidden'); + + Helpers.dom.selectText(selectedText, editorElement); + Helpers.dom.triggerEvent(document, 'mouseup'); + + setTimeout(() => { + assert.hasElement('.ck-toolbar', 'toolbar is shown again'); + + assert.hasElement(`.ck-toolbar-btn.active[title="bold"]`, + 'bold button is active when selecting bold text'); + + done(); + }); + }); + }); +}); diff --git a/tests/acceptance/editor-sections-test.js b/tests/acceptance/editor-sections-test.js new file mode 100644 index 000000000..172ecd0e4 --- /dev/null +++ b/tests/acceptance/editor-sections-test.js @@ -0,0 +1,102 @@ +import { Editor } from 'content-kit-editor'; +import Helpers from '../test-helpers'; + +const { test, module } = QUnit; + +const newline = '\r\n'; + +let fixture, editor, editorElement; +const mobileDocWith1Section = [ + [], + [ + [1, "P", [ + [[], 0, "only section"] + ]] + ] +]; +const mobileDocWith2Sections = [ + [], + [ + [1, "P", [ + [[], 0, "first section"] + ]], + [1, "P", [ + [[], 0, "second section"] + ]] + ] +]; +const mobileDocWith3Sections = [ + [], + [ + [1, "P", [ + [[], 0, "first section"] + ]], + [1, "P", [ + [[], 0, "second section"] + ]], + [1, "P", [ + [[], 0, "third section"] + ]] + ] +]; + +module('Acceptance: Editor sections', { + beforeEach() { + fixture = document.getElementById('qunit-fixture'); + editorElement = document.createElement('div'); + editorElement.setAttribute('id', 'editor'); + fixture.appendChild(editorElement); + }, + + afterEach() { + editor.destroy(); + } +}); + +test('typing inserts section', (assert) => { + editor = new Editor(editorElement, {mobiledoc: mobileDocWith1Section}); + assert.equal($('#editor p').length, 1, 'has 1 paragraph to start'); + + const text = 'new section'; + + Helpers.dom.moveCursorTo(editorElement); + document.execCommand('insertText', false, text + newline); + + assert.equal($('#editor p').length, 2, 'has 2 paragraphs after typing return'); + assert.hasElement(`#editor p:contains(${text})`, 'has first pargraph with "A"'); + assert.hasElement('#editor p:contains(only section)', 'has correct second paragraph text'); +}); + +test('deleting across 0 sections merges them', (assert) => { + editor = new Editor(editorElement, {mobiledoc: mobileDocWith2Sections}); + assert.equal($('#editor p').length, 2, 'precond - has 2 sections to start'); + + const p0 = $('#editor p:eq(0)')[0], + p1 = $('#editor p:eq(1)')[0]; + + Helpers.dom.selectText('tion', p0, 'sec', p1); + document.execCommand('delete', false); + + assert.equal($('#editor p').length, 1, 'has only 1 paragraph after deletion'); + assert.hasElement('#editor p:contains(first second section)', + 'remaining paragraph has correct text'); +}); + +test('deleting across 1 section removes it, joins the 2 boundary sections', (assert) => { + editor = new Editor(editorElement, {mobiledoc: mobileDocWith3Sections}); + assert.equal($('#editor p').length, 3, 'precond - has 3 paragraphs to start'); + + const p0 = $('#editor p:eq(0)')[0], + p1 = $('#editor p:eq(1)')[0], + p2 = $('#editor p:eq(2)')[0]; + assert.ok(p0 && p1 && p2, 'precond - paragraphs exist'); + + Helpers.dom.selectText('section', p0, 'third ', p2); + + document.execCommand('delete', false); + + + assert.equal($('#editor p').length, 1, 'has only 1 paragraph after deletion'); + assert.hasElement('#editor p:contains(first section)', + 'remaining paragraph has correct text'); +}); diff --git a/tests/helpers/assertions.js b/tests/helpers/assertions.js new file mode 100644 index 000000000..c2c8a79b7 --- /dev/null +++ b/tests/helpers/assertions.js @@ -0,0 +1,15 @@ +/* global QUnit, $ */ + +export default function registerAssertions() { + QUnit.assert.hasElement = function(selector, message=`hasElement "${selector}"`) { + let found = $(selector); + this.push(found.length > 0, found.length, selector, message); + return found; + }; + + QUnit.assert.hasNoElement = function(selector, message=`hasNoElement "${selector}"`) { + let found = $(selector); + this.push(found.length === 0, found.length, selector, message); + return found; + }; +} diff --git a/tests/helpers/dom.js b/tests/helpers/dom.js new file mode 100644 index 000000000..9c9b8ce4f --- /dev/null +++ b/tests/helpers/dom.js @@ -0,0 +1,131 @@ +const TEXT_NODE = 3; + +import { clearSelection } from 'content-kit-editor/utils/selection-utils'; +import KEY_CODES from 'content-kit-editor/utils/keycodes'; + +function walkDOMUntil(topNode, conditionFn=() => {}) { + if (!topNode) { throw new Error('Cannot call walkDOMUntil without a node'); } + let stack = [topNode]; + let currentElement; + + while (stack.length) { + currentElement = stack.pop(); + + if (conditionFn(currentElement)) { + return currentElement; + } + + for (let i=0; i < currentElement.childNodes.length; i++) { + stack.push(currentElement.childNodes[i]); + } + } +} + +function selectRange(startNode, startOffset, endNode, endOffset) { + clearSelection(); + + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + + const selection = window.getSelection(); + selection.addRange(range); +} + +function selectText(startText, + startContainingElement, + endText=startText, + endContainingElement=startContainingElement) { + const findTextNode = (text) => { + return (el) => el.nodeType === TEXT_NODE && el.textContent.indexOf(text) !== -1; + }; + const startTextNode = walkDOMUntil(startContainingElement, findTextNode(startText)); + const endTextNode = walkDOMUntil(endContainingElement, findTextNode(endText)); + + if (!startTextNode) { + throw new Error(`Could not find a starting textNode containing "${startText}"`); + } + if (!endTextNode) { + throw new Error(`Could not find an ending textNode containing "${endText}"`); + } + + const startOffset = startTextNode.textContent.indexOf(startText), + endOffset = endTextNode.textContent.indexOf(endText) + endText.length; + selectRange(startTextNode, startOffset, endTextNode, endOffset); +} + +function moveCursorTo(element, offset=0) { + selectRange(element, offset, element, offset); +} + +function triggerEvent(node, eventType) { + if (!node) { throw new Error(`Attempted to trigger event "${eventType}" on undefined node`); } + + let clickEvent = document.createEvent('MouseEvents'); + clickEvent.initEvent(eventType, true, true); + node.dispatchEvent(clickEvent); +} + +function createKeyEvent(eventType, keyCode) { + let oEvent = document.createEvent('KeyboardEvent'); + if (oEvent.initKeyboardEvent) { + oEvent.initKeyboardEvent(eventType, true, true, window, 0, 0, 0, 0, 0, keyCode); + } else if (oEvent.initKeyEvent) { + oEvent.initKeyEvent(eventType, true, true, window, 0, 0, 0, 0, 0, keyCode); + } + + // Hack for Chrome to force keyCode/which value + try { + Object.defineProperty(oEvent, 'keyCode', {get: function() { return keyCode; }}); + Object.defineProperty(oEvent, 'which', {get: function() { return keyCode; }}); + } catch(e) { + // FIXME + // PhantomJS/webkit will throw an error "ERROR: Attempting to change access mechanism for an unconfigurable property" + // see https://bugs.webkit.org/show_bug.cgi?id=36423 + } + + if (oEvent.keyCode !== keyCode || oEvent.which !== keyCode) { + throw new Error(`Failed to create key event with keyCode ${keyCode}. \`keyCode\`: ${oEvent.keyCode}, \`which\`: ${oEvent.which}`); + } + + return oEvent; +} + +function triggerKeyEvent(node, eventType, keyCode=KEY_CODES.ENTER) { + let oEvent = createKeyEvent(eventType, keyCode); + node.dispatchEvent(oEvent); +} + +function _buildDOM(tagName, attributes={}, children=[]) { + const el = document.createElement(tagName); + Object.keys(attributes).forEach(k => el.setAttribute(k, attributes[k])); + children.forEach(child => el.appendChild(child)); + return el; +} + +_buildDOM.text = (string) => { + return document.createTextNode(string); +}; + +/** + * Usage: + * makeDOM(t => + * t('div', attributes={}, children=[ + * t('b', {}, [ + * t.text('I am a bold text node') + * ]) + * ]) + * ); + */ +function makeDOM(tree) { + return tree(_buildDOM); +} + +export default { + moveCursorTo, + selectText, + clearSelection, + triggerEvent, + triggerKeyEvent, + makeDOM +}; diff --git a/tests/helpers/skip-in-phantom.js b/tests/helpers/skip-in-phantom.js new file mode 100644 index 000000000..54e41c23c --- /dev/null +++ b/tests/helpers/skip-in-phantom.js @@ -0,0 +1,10 @@ +const { test } = QUnit; + +export default function(message, testFn) { + const isPhantom = navigator.userAgent.indexOf('PhantomJS') !== -1; + if (isPhantom) { + message = '[SKIPPED in PhantomJS] ' + message; + testFn = (assert) => assert.ok(true); + } + test(message, testFn); +} diff --git a/tests/index.html b/tests/index.html index 3b36ef372..164b81c8a 100644 --- a/tests/index.html +++ b/tests/index.html @@ -2,17 +2,35 @@ -something here
'; + let editor = new Editor(editorElement, {mobiledoc}); + + assert.ok(editor.mobiledoc, 'editor has mobiledoc'); + assert.equal(editorElement.innerHTML, + `hello world
`); + + assert.deepEqual(editor.serialize(), mobiledoc, + 'serialized editor === mobiledoc'); +}); diff --git a/tests/unit/models/marker-test.js b/tests/unit/models/marker-test.js new file mode 100644 index 000000000..07e7d781b --- /dev/null +++ b/tests/unit/models/marker-test.js @@ -0,0 +1,88 @@ +const {module, test} = QUnit; + +import Marker from 'content-kit-editor/models/marker'; +import Markup from 'content-kit-editor/models/markup'; + +module('Unit: Marker'); + +test('Marker exists', (assert) => { + assert.ok(Marker); +}); + +test('a marker can truncated from an offset', (assert) => { + const m1 = new Marker('hi there!'); + + const offset = 5; + m1.truncateFrom(offset); + + assert.equal(m1.value, 'hi th'); +}); + +test('a marker can truncated to an offset', (assert) => { + const m1 = new Marker('hi there!'); + + const offset = 5; + m1.truncateTo(offset); + + assert.equal(m1.value, 'ere!'); +}); + +test('a marker can have a markup applied to it', (assert) => { + const m1 = new Marker('hi there!'); + m1.addMarkup(new Markup('b')); + + assert.ok(m1.hasMarkup('b')); +}); + +test('a marker can have the same markup tagName applied twice', (assert) => { + const m1 = new Marker('hi there!'); + m1.addMarkup(new Markup('b')); + m1.addMarkup(new Markup('b')); + + assert.equal(m1.markups.length, 2, 'markup only applied once'); +}); + +test('a marker can have a complex markup applied to it', (assert) => { + const m1 = new Marker('hi there!'); + const markup = new Markup('a', {href:'blah'}); + m1.addMarkup(markup); + + assert.ok(m1.hasMarkup('a')); + assert.equal(m1.getMarkup('a').attributes.href, 'blah'); +}); + +test('a marker can have the same complex markup tagName applied twice, even with different attributes', (assert) => { + const m1 = new Marker('hi there!'); + const markup1 = new Markup('a', {href:'blah'}); + const markup2 = new Markup('a', {href:'blah2'}); + m1.addMarkup(markup1); + m1.addMarkup(markup2); + + assert.equal(m1.markups.length, 2, 'only one markup'); + assert.equal(m1.getMarkup('a').attributes.href, 'blah', + 'first markup is applied'); +}); + +test('a marker can be joined to another', (assert) => { + const m1 = new Marker('hi'); + m1.addMarkup(new Markup('b')); + const m2 = new Marker(' there!'); + m2.addMarkup(new Markup('i')); + + const m3 = m1.join(m2); + assert.equal(m3.value, 'hi there!'); + assert.ok(m3.hasMarkup('b')); + assert.ok(m3.hasMarkup('i')); +}); + +test('a marker can be split into two', (assert) => { + const m1 = new Marker('hi there!'); + m1.addMarkup(new Markup('b')); + + const [_m1, m2] = m1.split(5); + assert.ok(_m1.hasMarkup('b') && m2.hasMarkup('b'), + 'both markers get the markup'); + + assert.equal(_m1.value, 'hi th'); + assert.equal(m2.value, 'ere!'); +}); diff --git a/tests/unit/models/section-test.js b/tests/unit/models/section-test.js new file mode 100644 index 000000000..149f1f60d --- /dev/null +++ b/tests/unit/models/section-test.js @@ -0,0 +1,115 @@ +const {module, test} = QUnit; + +import Section from 'content-kit-editor/models/markup-section'; +import Marker from 'content-kit-editor/models/marker'; +import Markup from 'content-kit-editor/models/markup'; + +module('Unit: Section'); + +test('Section exists', (assert) => { + assert.ok(Section); +}); + +test('a section can append a marker', (assert) => { + const s1 = new Section(); + const m1 = new Marker('hello'); + + s1.appendMarker(m1); + assert.equal(s1.markers.length, 1); +}); + +test('#markerContaining finds the marker at the given offset when 1 marker', (assert) => { + const m = new Marker('hi there!'); + const s = new Section('h2',[m]); + + for (let i=0; ifirst line
\nsecond line
')); + + let expectedFirst = builder.generateMarkupSection('P'); + expectedFirst.appendMarker(builder.generateMarker([], 'first line')); + expectedPost.appendSection(expectedFirst); + let expectedSecond = builder.generateMarkupSection('P'); + expectedSecond.appendMarker(builder.generateMarker([], 'second line')); + expectedPost.appendSection(expectedSecond); + + assert.deepEqual(post, expectedPost); +}); + +test('textnode adjacent to p tag becomes section', (assert) => { + const post = parser.parse(buildDOM('first line
second line')); + + let expectedFirst = builder.generateMarkupSection('P'); + expectedFirst.appendMarker(builder.generateMarker([], 'first line')); + expectedPost.appendSection(expectedFirst); + let expectedSecond = builder.generateMarkupSection('P', {}, true); + expectedSecond.appendMarker(builder.generateMarker([], 'second line')); + expectedPost.appendSection(expectedSecond); + + assert.deepEqual(post, expectedPost); +}); + +test('p tag (section markup) should create a block', (assert) => { + const post = parser.parse(buildDOM('text
')); + + let expectedFirst = builder.generateMarkupSection('P'); + expectedFirst.appendMarker(builder.generateMarker([], 'text')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('strong tag (stray markup) without a block should create a block', (assert) => { + const post = parser.parse(buildDOM('text')); + + let expectedFirst = builder.generateMarkupSection('P', {}, true); + expectedFirst.appendMarker(builder.generateMarker([ + builder.generateMarkup('STRONG') + ], 'text')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('strong tag with inner em (stray markup) without a block should create a block', (assert) => { + const post = parser.parse(buildDOM('stray markup tags.')); + + let expectedFirst = builder.generateMarkupSection('P', {}, true); + let strong = builder.generateMarkup('STRONG'); + expectedFirst.appendMarker(builder.generateMarker([ + strong, + builder.generateMarkup('EM') + ], 'stray')); + expectedFirst.appendMarker(builder.generateMarker([strong], ' markup tags')); + expectedFirst.appendMarker(builder.generateMarker([], '.')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('stray text (stray markup) should create a block', (assert) => { + const post = parser.parse(buildDOM('text')); + + let expectedFirst = builder.generateMarkupSection('P', {}, true); + expectedFirst.appendMarker(builder.generateMarker([], 'text')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('text node, strong tag, text node (stray markup) without a block should create a block', (assert) => { + const post = parser.parse(buildDOM('start bold end')); + + let expectedFirst = builder.generateMarkupSection('P', {}, true); + expectedFirst.appendMarker(builder.generateMarker([], 'start ')); + expectedFirst.appendMarker(builder.generateMarker([ + builder.generateMarkup('STRONG') + ], 'bold')); + expectedFirst.appendMarker(builder.generateMarker([], ' end')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('italic tag (stray markup) without a block should create a block', (assert) => { + const post = parser.parse(buildDOM('text')); + + let expectedFirst = builder.generateMarkupSection('P', {}, true); + expectedFirst.appendMarker(builder.generateMarker([ + builder.generateMarkup('EM') + ], 'text')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('u tag (stray markup) without a block should strip U and create a block', (assert) => { + const post = parser.parse(buildDOM('text')); + + let expectedFirst = builder.generateMarkupSection('P', {}, true); + expectedFirst.appendMarker(builder.generateMarker([], 'text')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('a tag (stray markup) without a block should create a block', (assert) => { + var url = "http://test.com"; + const post = parser.parse(buildDOM('text')); + + let expectedFirst = builder.generateMarkupSection('P', {}, true); + expectedFirst.appendMarker(builder.generateMarker([ + builder.generateMarkup('A', ['href', url]) + ], 'text')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +/* FIXME: What should happen with br +test('markup: break', (assert) => { + const post = parser.parse(buildDOM('linebreak')); + + let expectedFirst = builder.generateMarkupSection('P', {}, true); + expectedFirst.appendMarker(builder.generateMarker([], 0, 'line ')); + expectedFirst.appendMarker(builder.generateMarker([], 0, 'break')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); +*/ + +test('sub tag (stray markup) without a block should filter SUB and create a block', (assert) => { + const post = parser.parse(buildDOM('footnote1')); + + let expectedFirst = builder.generateMarkupSection('P', {}, true); + expectedFirst.appendMarker(builder.generateMarker([], 'footnote')); + expectedFirst.appendMarker(builder.generateMarker([], '1')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('sup tag (stray markup) without a block should filter SUP and create a block', (assert) => { + const post = parser.parse(buildDOM('e=mc2')); + + let expectedFirst = builder.generateMarkupSection('P', {}, true); + expectedFirst.appendMarker(builder.generateMarker([], 'e=mc')); + expectedFirst.appendMarker(builder.generateMarker([], '2')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('list (stray markup) without a block should create a block', (assert) => { + const post = parser.parse(buildDOM('
- Item 1
- Item 2
Double. Double staggered start. Double staggered end. Double staggered middle.
')); + + let expectedFirst = builder.generateMarkupSection('P'); + expectedFirst.appendMarker(builder.generateMarker([ + builder.generateMarkup('EM'), + builder.generateMarkup('STRONG') + ], 'Double.')); + expectedFirst.appendMarker(builder.generateMarker([], ' ')); + let firstStrong = builder.generateMarkup('STRONG'); + expectedFirst.appendMarker(builder.generateMarker([ + firstStrong, + builder.generateMarkup('EM') + ], 'Double staggered')); + expectedFirst.appendMarker(builder.generateMarker([firstStrong], ' start.')); + expectedFirst.appendMarker(builder.generateMarker([], ' ')); + let secondStrong = builder.generateMarkup('STRONG'); + expectedFirst.appendMarker(builder.generateMarker([ + secondStrong + ], 'Double ')); + expectedFirst.appendMarker(builder.generateMarker([ + secondStrong, + builder.generateMarkup('EM') + ], 'staggered end.')); + expectedFirst.appendMarker(builder.generateMarker([], ' ')); + let thirdStrong = builder.generateMarkup('STRONG'); + expectedFirst.appendMarker(builder.generateMarker([ + thirdStrong + ], 'Double ')); + expectedFirst.appendMarker(builder.generateMarker([ + thirdStrong, + builder.generateMarkup('EM') + ], 'staggered')); + expectedFirst.appendMarker(builder.generateMarker([thirdStrong], ' middle.')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); + let sectionMarkers = post.sections[0].markers; + assert.equal(sectionMarkers[2].markups[0], sectionMarkers[3].markups[0]); +}); + +/* + * FIXME: Update these tests to use the renderer + * +test('markup: nested/unsupported tags', (assert) => { + var parsed = compiler.parse('Test one two three four five six seven
'); + + equal ( parsed.length, 1 ); + equal ( parsed[0].type, Type.PARAGRAPH.id ); + equal ( parsed[0].value, 'Test one two three four five six seven' ); + equal ( parsed[0].markup.length, 5 ); + + equal ( parsed[0].markup[0].type, Type.BOLD.id ); + equal ( parsed[0].markup[0].start, 9 ); + equal ( parsed[0].markup[0].end, 12 ); + + equal ( parsed[0].markup[1].type, Type.ITALIC.id ); + equal ( parsed[0].markup[1].start, 13 ); + equal ( parsed[0].markup[1].end, 18 ); + + equal ( parsed[0].markup[2].type, Type.BOLD.id ); + equal ( parsed[0].markup[2].start, 13 ); + equal ( parsed[0].markup[2].end, 18 ); + + equal ( parsed[0].markup[3].type, Type.BOLD.id ); + equal ( parsed[0].markup[3].start, 24 ); + equal ( parsed[0].markup[3].end, 28 ); + + equal ( parsed[0].markup[4].type, Type.BOLD.id ); + equal ( parsed[0].markup[4].start, 29 ); + equal ( parsed[0].markup[4].end, 32 ); +}); + +test('markup: preserves spaces in empty tags', (assert) => { + var rendered = compiler.rerender('Testing a space
'); + equal ( rendered, 'Testing a space
'); +}); + +test('markup: self-closing tags with nesting', (assert) => { + var input = 'Blah
blah
blah
- ' +
+ '\t
- Item 1 \n' + + '
- Item 2 \r\n ' + + '\t\t
- Item 3 \r' + + '
test
')); + + let expectedFirst = builder.generateMarkupSection('P'); + expectedFirst.appendMarker(builder.generateMarker([ + builder.generateMarkup('B') + ], 'test')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('blocks: paragraph', (assert) => { + const post = parser.parse(buildDOM('TEXT
')); + + let expectedFirst = builder.generateMarkupSection('P'); + expectedFirst.appendMarker(builder.generateMarker([], 'TEXT')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('blocks: heading', (assert) => { + const post = parser.parse(buildDOM('TEXT
')); + + let expectedFirst = builder.generateMarkupSection('H2'); + expectedFirst.appendMarker(builder.generateMarker([], 'TEXT')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('blocks: subheading', (assert) => { + const post = parser.parse(buildDOM('TEXT
')); + + let expectedFirst = builder.generateMarkupSection('H3'); + expectedFirst.appendMarker(builder.generateMarker([], 'TEXT')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +/* FIXME: should not create a markup type section +test('blocks: image', (assert) => { + var url = "http://domain.com/text.png"; + const post = parser.parse(buildDOM('quote')); + + let expectedFirst = builder.generateMarkupSection('BLOCKQUOTE'); + expectedFirst.appendMarker(builder.generateMarker([], 'quote')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); + +test('blocks: list', (assert) => { + const post = parser.parse(buildDOM('
- Item 1
- Item 2
- Item 1
- Item 2
The Title
The Subtitle
TEXT 1
TEXT 2
TEXT with a link.
Quote'; + var parsed = compiler.parse(input); + + equal ( parsed.length, 6 ); + equal ( parsed[0].type, Type.HEADING.id ); + equal ( parsed[1].type, Type.SUBHEADING.id ); + equal ( parsed[2].type, Type.PARAGRAPH.id ); + equal ( parsed[3].type, Type.PARAGRAPH.id ); + equal ( parsed[4].type, Type.PARAGRAPH.id ); + equal ( parsed[5].type, Type.QUOTE.id ); +}); +*/ + +/* FIXME: needs images, br support +test('blocks: self-closing', (assert) => { + var url = 'http://domain.com/test.png'; + const post = parser.parse(buildDOM('
Line
break
Converts tags.
')); + + let expectedFirst = builder.generateMarkupSection('P'); + let bold = builder.generateMarkup('B'); + expectedFirst.appendMarker(builder.generateMarker([ + bold, + builder.generateMarkup('I') + ], 'Converts')); + expectedFirst.appendMarker(builder.generateMarker([bold], ' tags')); + expectedFirst.appendMarker(builder.generateMarker([], '.')); + expectedPost.appendSection(expectedFirst); + + assert.deepEqual(post, expectedPost); +}); diff --git a/tests/unit/parsers/mobiledoc-test.js b/tests/unit/parsers/mobiledoc-test.js new file mode 100644 index 000000000..b19547f2c --- /dev/null +++ b/tests/unit/parsers/mobiledoc-test.js @@ -0,0 +1,115 @@ +import MobiledocParser from 'content-kit-editor/parsers/mobiledoc'; +import { generateBuilder } from 'content-kit-editor/utils/post-builder'; + +const DATA_URL = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="; +const { module, test } = window.QUnit; + +let parser, builder, post; + +module('Unit: Parsers: Mobiledoc', { + beforeEach() { + parser = new MobiledocParser(); + builder = generateBuilder(); + post = builder.generatePost(); + }, + afterEach() { + parser = null; + builder = null; + post = null; + } +}); + +test('#parse empty doc returns an empty post', (assert) => { + assert.deepEqual(parser.parse([[], []]), + post); +}); + +test('#parse doc without marker types', (assert) => { + const mobiledoc = [ + [], + [[ + 1,'P', [[[], 0, 'hello world']] + ]] + ]; + const parsed = parser.parse(mobiledoc); + + let section = builder.generateMarkupSection('P', [], false); + let marker = builder.generateMarker([], 'hello world'); + section.appendMarker(marker); + post.appendSection(section); + + assert.deepEqual( + parsed, + post + ); +}); + +test('#parse doc with marker type', (assert) => { + const mobiledoc = [ + [ + ['B'], + ['A', ['href', 'google.com']] + ], + [[ + 1,'P', [ + [[1], 0, 'hello'], // a tag open + [[0], 1, 'brave new'], // b tag open/close + [[], 1, 'world'] // a tag close + ] + ]] + ]; + const parsed = parser.parse(mobiledoc); + + let section = builder.generateMarkupSection('P', [], false); + let aMarkerType = builder.generateMarkup('A', ['href', 'google.com']); + let bMarkerType = builder.generateMarkup('B'); + + let markers = [ + builder.generateMarker([aMarkerType], 'hello'), + builder.generateMarker([aMarkerType, bMarkerType], 'brave new'), + builder.generateMarker([aMarkerType], 'world') + ]; + markers.forEach(marker => section.appendMarker(marker)); + post.appendSection(section); + + assert.deepEqual( + parsed, + post + ); +}); + +test('#parse doc with image section', (assert) => { + const mobiledoc = [ + [], + [ + [2, DATA_URL] + ] + ]; + + const parsed = parser.parse(mobiledoc); + + let section = builder.generateImageSection(DATA_URL); + post.appendSection(section); + assert.deepEqual( + parsed, + post + ); +}); + +test('#parse doc with custom card type', (assert) => { + const mobiledoc = [ + [], + [ + [10, 'custom-card', {}] + ] + ]; + + const parsed = parser.parse(mobiledoc); + + let section = builder.generateCardSection('custom-card'); + post.appendSection(section); + assert.deepEqual( + parsed, + post + ); +}); diff --git a/tests/unit/parsers/post-test.js b/tests/unit/parsers/post-test.js new file mode 100644 index 000000000..0bb8f69c4 --- /dev/null +++ b/tests/unit/parsers/post-test.js @@ -0,0 +1,61 @@ +const {module, test} = QUnit; + +import PostParser from 'content-kit-editor/parsers/post'; +import Helpers from '../../test-helpers'; + +module('Unit: Parser: PostParser'); + +test('#parse can parse a single text node', (assert) => { + let element = Helpers.dom.makeDOM(t => + t('div', {}, [t.text('some text')]) + ); + + const post = PostParser.parse(element); + assert.ok(post, 'gets post'); + assert.equal(post.sections.length, 1, 'has 1 section'); + + const s1 = post.sections[0]; + assert.equal(s1.markers.length, 1, 's1 has 1 marker'); + assert.equal(s1.markers[0].value, 'some text', 'has text'); +}); + +test('#parse can parse a section element', (assert) => { + let element = Helpers.dom.makeDOM(t => + t('div', {}, [ + t('p', {}, [ + t.text('some text') + ]) + ]) + ); + + const post = PostParser.parse(element); + assert.ok(post, 'gets post'); + assert.equal(post.sections.length, 1, 'has 1 section'); + + const s1 = post.sections[0]; + assert.equal(s1.markers.length, 1, 's1 has 1 marker'); + assert.equal(s1.markers[0].value, 'some text', 'has text'); +}); + +test('#parse can parse multiple elements', (assert) => { + let element = Helpers.dom.makeDOM(t => + t('div', {}, [ + t('p', {}, [ + t.text('some text') + ]), + t.text('some other text') + ]) + ); + + const post = PostParser.parse(element); + assert.ok(post, 'gets post'); + assert.equal(post.sections.length, 2, 'has 2 sections'); + + const [s1, s2] = post.sections; + assert.equal(s1.markers.length, 1, 's1 has 1 marker'); + assert.equal(s1.markers[0].value, 'some text'); + + assert.equal(s2.markers.length, 1, 's2 has 1 marker'); + assert.equal(s2.markers[0].value, 'some other text'); +}); + diff --git a/tests/unit/parsers/section-test.js b/tests/unit/parsers/section-test.js new file mode 100644 index 000000000..c1ce21416 --- /dev/null +++ b/tests/unit/parsers/section-test.js @@ -0,0 +1,122 @@ +const {module, test} = QUnit; + +import SectionParser from 'content-kit-editor/parsers/section'; +import Helpers from '../../test-helpers'; + +module('Unit: Parser: SectionParser'); + +test('#parse parses simple dom', (assert) => { + let element = Helpers.dom.makeDOM(t => + t('p', {}, [ + t.text('hello there'), + t('b', {}, [ + t.text('i am bold') + ]) + ]) + ); + + const section = SectionParser.parse(element); + assert.equal(section.tagName, 'p'); + assert.equal(section.markers.length, 2, 'has 2 markers'); + const [m1, m2] = section.markers; + + assert.equal(m1.value, 'hello there'); + assert.equal(m2.value, 'i am bold'); + assert.ok(m2.hasMarkup('b'), 'm2 is bold'); +}); + +test('#parse parses nested markups', (assert) => { + let element = Helpers.dom.makeDOM(t => + t('p', {}, [ + t('b', {}, [ + t.text('i am bold'), + t('i', {}, [ + t.text('i am bold and italic') + ]), + t.text('i am bold again') + ]) + ]) + ); + + const section = SectionParser.parse(element); + assert.equal(section.markers.length, 3, 'has 3 markers'); + const [m1, m2, m3] = section.markers; + + assert.equal(m1.value, 'i am bold'); + assert.equal(m2.value, 'i am bold and italic'); + assert.equal(m3.value, 'i am bold again'); + assert.ok(m1.hasMarkup('b'), 'm1 is bold'); + assert.ok(m2.hasMarkup('b') && m2.hasMarkup('i'), 'm2 is bold and i'); + assert.ok(m3.hasMarkup('b'), 'm3 is bold'); + assert.ok(!m1.hasMarkup('i') && !m3.hasMarkup('i'), 'm1 and m3 are not i'); +}); + +test('#parse ignores non-markup elements like spans', (assert) => { + let element = Helpers.dom.makeDOM(t => + t('p', {}, [ + t('span', {}, [ + t.text('i was in span') + ]) + ]) + ); + + const section = SectionParser.parse(element); + assert.equal(section.tagName, 'p'); + assert.equal(section.markers.length, 1, 'has 1 markers'); + const [m1] = section.markers; + + assert.equal(m1.value, 'i was in span'); +}); + +test('#parse reads attributes', (assert) => { + let element = Helpers.dom.makeDOM(t => + t('p', {}, [ + t('a', {href: 'google.com'}, [ + t.text('i am a link') + ]) + ]) + ); + const section = SectionParser.parse(element); + assert.equal(section.markers.length, 1, 'has 1 markers'); + const [m1] = section.markers; + assert.equal(m1.value, 'i am a link'); + assert.ok(m1.hasMarkup('a'), 'has "a" markup'); + assert.equal(m1.getMarkup('a').attributes.href, 'google.com'); +}); + +test('#parse joins contiguous text nodes separated by non-markup elements', (assert) => { + let element = Helpers.dom.makeDOM(t => + t('p', {}, [ + t('span', {}, [ + t.text('span 1') + ]), + t('span', {}, [ + t.text('span 2') + ]) + ]) + ); + + const section = SectionParser.parse(element); + assert.equal(section.tagName, 'p'); + assert.equal(section.markers.length, 1, 'has 1 markers'); + const [m1] = section.markers; + + assert.equal(m1.value, 'span 1span 2'); +}); + +test('#parse parses a single text node', (assert) => { + let element = Helpers.dom.makeDOM(h => + h.text('raw text') + ); + const section = SectionParser.parse(element); + assert.equal(section.tagName, 'p'); + assert.equal(section.markers.length, 1, 'has 1 marker'); + assert.equal(section.markers[0].value, 'raw text'); +}); + +// test: a section can parse dom + +// test: a section can clear a range: +// * truncating the markers on the boundaries +// * removing the intermediate markers +// * connecting (but not joining) the truncated boundary markers diff --git a/tests/unit/renderers/editor-dom-test.js b/tests/unit/renderers/editor-dom-test.js new file mode 100644 index 000000000..1ca09af11 --- /dev/null +++ b/tests/unit/renderers/editor-dom-test.js @@ -0,0 +1,218 @@ +import { generateBuilder } from 'content-kit-editor/utils/post-builder'; +const { module, test } = window.QUnit; +import Renderer from 'content-kit-editor/renderers/editor-dom'; +import RenderNode from 'content-kit-editor/models/render-node'; +import RenderTree from 'content-kit-editor/models/render-tree'; + +const DATA_URL = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="; +let builder; + +function render(renderTree, cards=[]) { + let renderer = new Renderer(cards); + return renderer.render(renderTree); +} + +module("Unit: Renderer", { + beforeEach() { + builder = generateBuilder(); + } +}); + +test("It renders a dirty post", (assert) => { + /* + * renderTree is: + * + * renderNode + * + */ + let renderNode = new RenderNode(builder.generatePost()); + let renderTree = new RenderTree(renderNode); + renderNode.renderTree = renderTree; + + render(renderTree); + + assert.ok(renderTree.node.element, 'renderTree renders element for post'); + assert.ok(!renderTree.node.isDirty, 'dirty node becomes clean'); + assert.equal(renderTree.node.element.tagName, 'DIV', 'renderTree renders element for post'); +}); + +test("It renders a dirty post with un-rendered sections", (assert) => { + let post = builder.generatePost(); + let sectionA = builder.generateMarkupSection('P'); + post.appendSection(sectionA); + let sectionB = builder.generateMarkupSection('P'); + post.appendSection(sectionB); + + let renderNode = new RenderNode(post); + let renderTree = new RenderTree(renderNode); + renderNode.renderTree = renderTree; + + render(renderTree); + + assert.equal(renderTree.node.element.outerHTML, 'Hi
'); +}); + +test('renders a post with image', (assert) => { + let url = DATA_URL; + let post = builder.generatePost(); + let section = builder.generateImageSection(url); + post.appendSection(section); + + let node = new RenderNode(post); + let renderTree = new RenderTree(node); + node.renderTree = renderTree; + render(renderTree); + assert.equal(node.element.innerHTML, `mobiledoc to load
+
+ This mobiledoc will be loaded into the editor.
+ You can change it and see the editor reload with the new contents.
+ (If there is a JSON syntax error it will be ignored; if there is a parser
+ error the editor may stop responding.)
+
+ Select a preloaded mobiledoc here:
+
+
editor
++ The live-editing surface. Changes here are serialized to mobiledoc + format and displayed to the right. +
+serialized mobiledoc
++ When the editor updates, it prints its serialized mobiledoc here. +
+rendered mobiledoc (dom)
++ This is the output of using the runtime (client-side) + mobiledoc-dom-renderer + on the serialized mobiledoc. +
+ +rendered mobiledoc (html)
++ This is the output of using the server-side + mobiledoc-html-renderer + on the serialized mobiledoc. +
+ +