diff --git a/assets/demo/demo.js b/assets/demo/demo.js index 2e17a1798..c29506cc8 100644 --- a/assets/demo/demo.js +++ b/assets/demo/demo.js @@ -1,16 +1,26 @@ -/* global Mobiledoc */ -'use strict'; +import { Editor } from './mobiledoc.js'; -$(() => { - bootstrapEditor(); - bootstrapSimpleDemo(); - bootstrapToolbarEditor(); - bootstrapCardEditor(); -}); +function bootstrapSimpleDemo() { + let el = $('#editor-basic')[0]; + let editor = new Editor({ + placeholder: 'Welcome to Mobiledoc' + }); + editor.render(el); +} -let bootstrapEditor = () => { +function activateButtons(parentSelector, editor) { + $(`${parentSelector} button`).click(function () { + let button = $(this); + let action = button.data('action'); + let args = button.data('args').split(','); + + editor[action](...args); + }); +} + +function bootstrapEditor() { let el = $('#editor')[0]; - let editor = new Mobiledoc.Editor({ + let editor = new Editor({ placeholder: 'Type here', autofocus: true }); @@ -23,35 +33,17 @@ let bootstrapEditor = () => { }; editor.postDidChange(displayMobiledoc); displayMobiledoc(); -}; - -let bootstrapSimpleDemo = () => { - let el = $('#editor-basic')[0]; - let editor = new Mobiledoc.Editor({ - placeholder: 'Welcome to Mobiledoc', - }); - editor.render(el); -}; - -let activateButtons = (parentSelector, editor) => { - $(`${parentSelector} button`).click(function() { - let button = $(this); - let action = button.data('action'); - let args = button.data('args').split(','); - - editor[action](...args); - }); -}; +} let bootstrapToolbarEditor = () => { let el = $('#editor-toolbar')[0]; - let editor = new Mobiledoc.Editor({ + let editor = new Editor({ placeholder: 'Editor with toolbar' }); editor.render(el); activateButtons('#editor-toolbar-wrapper', editor); -} +}; let bootstrapCardEditor = () => { let card = { @@ -73,7 +65,7 @@ let bootstrapCardEditor = () => { } }; let el = $('#editor-card')[0]; - let editor = new Mobiledoc.Editor({ + let editor = new Editor({ placeholder: 'Editor with card', cards: [card], atoms: [atom] @@ -81,3 +73,10 @@ let bootstrapCardEditor = () => { editor.render(el); activateButtons('#editor-card-wrapper', editor); }; + +$(() => { + bootstrapEditor(); + bootstrapSimpleDemo(); + bootstrapToolbarEditor(); + bootstrapCardEditor(); +}); diff --git a/assets/demo/index.html b/assets/demo/index.html index a242cfd91..8c270d148 100644 --- a/assets/demo/index.html +++ b/assets/demo/index.html @@ -6,9 +6,8 @@ - - + diff --git a/assets/demo/mobiledoc.js b/assets/demo/mobiledoc.js new file mode 100644 index 000000000..e0340b38c --- /dev/null +++ b/assets/demo/mobiledoc.js @@ -0,0 +1,12148 @@ +function detect(enumerable, callback) { + if (enumerable.detect) { + return enumerable.detect(callback); + } else { + for (let i=0; i { + if (conditionFn(i)) { filtered.push(i); } + }); + return filtered; +} + +/** + * @return {Integer} the number of items that are the same, starting from the 0th index, in a and b + * @private + */ +function commonItemLength(listA, listB) { + let offset = 0; + while (offset < listA.length && offset < listB.length) { + if (listA[offset] !== listB[offset]) { + break; + } + offset++; + } + return offset; +} + +/** + * @return {Array} the items that are the same, starting from the 0th index, in a and b + * @private + */ +function commonItems(listA, listB) { + let offset = 0; + while (offset < listA.length && offset < listB.length) { + if (listA[offset] !== listB[offset]) { + break; + } + offset++; + } + return listA.slice(0, offset); +} + +function reduce(enumerable, callback, initialValue) { + let previousValue = initialValue; + forEach(enumerable, (val, index) => { + previousValue = callback(previousValue, val, index); + }); + return previousValue; +} + +/** + * @param {Array} array of key1,value1,key2,value2,... + * @return {Object} {key1:value1, key2:value2, ...} + * @private + */ +function kvArrayToObject(array) { + const obj = {}; + for (let i = 0; i < array.length; i+=2) { + let [key, value] = [array[i], array[i+1]]; + obj[key] = value; + } + return obj; +} + +function objectToSortedKVArray(obj) { + const keys = Object.keys(obj).sort(); + const result = []; + keys.forEach(k => { + result.push(k); + result.push(obj[k]); + }); + return result; +} + +// check shallow equality of two non-nested arrays +function isArrayEqual(arr1, arr2) { + let l1 = arr1.length, l2 = arr2.length; + if (l1 !== l2) { return false; } + + for (let i=0; i < l1; i++) { + if (arr1[i] !== arr2[i]) { return false; } + } + return true; +} + +// return an object with only the valid keys +function filterObject(object, validKeys=[]) { + let result = {}; + forEach( + filter(Object.keys(object), key => validKeys.indexOf(key) !== -1), + key => result[key] = object[key] + ); + return result; +} + +function contains(array, item) { + return array.indexOf(item) !== -1; +} + +function values(object) { + return Object.keys(object).map(key => object[key]); +} + +const NODE_TYPES = { + ELEMENT: 1, + TEXT: 3, + COMMENT: 8 +}; + +function isTextNode(node) { + return node.nodeType === NODE_TYPES.TEXT; +} + +function isCommentNode(node) { + return node.nodeType === NODE_TYPES.COMMENT; +} + +function isElementNode(node) { + return node.nodeType === NODE_TYPES.ELEMENT; +} + + +function clearChildNodes(element) { + while (element.childNodes.length) { + element.removeChild(element.childNodes[0]); + } +} + +/** + * @return {Boolean} true when the child node is contained or the same as + * (e.g., inclusive containment) the parent node + * see https://github.com/webmodules/node-contains/blob/master/index.js + * Mimics the behavior of `Node.contains`, which is broken in IE 10 + * @private + */ +function containsNode(parentNode, childNode) { + if (parentNode === childNode) { + return true; + } + const position = parentNode.compareDocumentPosition(childNode); + return !!(position & Node.DOCUMENT_POSITION_CONTAINED_BY); +} + +/** + * converts the element's NamedNodeMap of attrs into + * an object with key-value pairs + * @param {DOMNode} element + * @return {Object} key-value pairs + * @private + */ +function getAttributes(element) { + const result = {}; + if (element.hasAttributes()) { + forEach(element.attributes, ({name,value}) => { + result[name] = value; + }); + } + return result; +} + +function addClassName(element, className) { + element.classList.add(className); +} + +function removeClassName(element, className) { + element.classList.remove(className); +} + +function normalizeTagName(tagName) { + return tagName.toLowerCase(); +} + +function parseHTML(html) { + const div = document.createElement('div'); + div.innerHTML = html; + return div; +} + +function serializeHTML(node) { + const div = document.createElement('div'); + div.appendChild(node); + return div.innerHTML; +} + +class View { + constructor(options={}) { + options.tagName = options.tagName || 'div'; + options.container = options.container || document.body; + + this.element = document.createElement(options.tagName); + this.container = options.container; + this.isShowing = false; + + let classNames = options.classNames || []; + classNames.forEach(name => addClassName(this.element, name)); + this._eventListeners = []; + } + + addEventListener(element, type, listener) { + element.addEventListener(type, listener); + this._eventListeners.push([element, type, listener]); + } + + removeAllEventListeners() { + this._eventListeners.forEach(([element, type, listener]) => { + element.removeEventListener(type, listener); + }); + } + + show() { + if(!this.isShowing) { + this.container.appendChild(this.element); + this.isShowing = true; + return true; + } + } + + hide() { + if (this.isShowing) { + this.container.removeChild(this.element); + this.isShowing = false; + return true; + } + } + + destroy() { + this.removeAllEventListeners(); + this.hide(); + this.isDestroyed = true; + } +} + +/* + * @param {String} string + * @return {String} a dasherized string. 'modelIndex' -> 'model-index', etc + */ +function dasherize(string) { + return string.replace(/[A-Z]/g, (match, offset) => { + const lower = match.toLowerCase(); + + return (offset === 0 ? lower : '-' + lower); + }); +} + +function startsWith(string, character) { + return string.charAt(0) === character; +} + +function endsWith(string, endString) { + let index = string.lastIndexOf(endString); + return index !== -1 && index === string.length - endString.length; +} + +function getEventTargetMatchingTag(tagName, target, container) { + tagName = normalizeTagName(tagName); + // Traverses up DOM from an event target to find the node matching specifed tag + while (target && target !== container) { + if (normalizeTagName(target.tagName) === tagName) { + return target; + } + target = target.parentNode; + } +} + +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 positionElementCenteredBelow(element, belowElement) { + var elementMargin = getElementComputedStyleNumericProp(element, 'marginTop'); + return positionElementHorizontallyCenteredToRect(element, belowElement.getBoundingClientRect(), -element.offsetHeight - elementMargin); +} + +function setData(element, name, value) { + if (element.dataset) { + element.dataset[name] = value; + } else { + const dataName = dasherize(name); + return element.setAttribute(dataName, value); + } +} + +function whenElementIsNotInDOM(element, callback) { + let isCanceled = false; + const observerFn = () => { + if (isCanceled) { return; } + if (!element.parentNode) { + callback(); + } else { + window.requestAnimationFrame(observerFn); + } + }; + observerFn(); + return { cancel: () => isCanceled = true }; +} + +const DELAY = 200; + +class Tooltip extends View { + constructor(options) { + let { rootElement } = options; + let timeout; + options.classNames = ['__mobiledoc-tooltip']; + super(options); + + this.addEventListener(rootElement, 'mouseover', (e) => { + let target = getEventTargetMatchingTag(options.showForTag, e.target, rootElement); + if (target && target.isContentEditable) { + timeout = setTimeout(() => { + this.showLink(target.href, target); + }, DELAY); + } + }); + + this.addEventListener(rootElement, 'mouseout', (e) => { + clearTimeout(timeout); + if (this.elementObserver) { this.elementObserver.cancel(); } + let toElement = e.toElement || e.relatedTarget; + if (toElement && toElement.className !== this.element.className) { + this.hide(); + } + }); + } + + showMessage(message, element) { + let tooltipElement = this.element; + tooltipElement.innerHTML = message; + this.show(); + positionElementCenteredBelow(tooltipElement, element); + } + + showLink(link, element) { + let message = `${link}`; + this.showMessage(message, element); + this.elementObserver = whenElementIsNotInDOM(element, () => this.hide()); + } +} + +var errorProps = [ + 'description', + 'fileName', + 'lineNumber', + 'message', + 'name', + 'number', + 'stack' +]; + +function MobiledocError() { + let tmp = Error.apply(this, arguments); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + // Unfortunately errors are not enumerable in Chrome (at least), so `for prop in tmp` doesn't work. + for (let idx = 0; idx < errorProps.length; idx++) { + this[errorProps[idx]] = tmp[errorProps[idx]]; + } +} + +MobiledocError.prototype = Object.create(Error.prototype); + +function assert(message, conditional) { + if (!conditional) { + throw new MobiledocError(message); + } +} + +const MARKUP_SECTION_TYPE = 'markup-section'; +const LIST_SECTION_TYPE = 'list-section'; +const MARKUP_TYPE = 'markup'; +const MARKER_TYPE = 'marker'; +const POST_TYPE = 'post'; +const LIST_ITEM_TYPE = 'list-item'; +const CARD_TYPE = 'card-section'; +const IMAGE_SECTION_TYPE = 'image-section'; +const ATOM_TYPE = 'atom'; + +const CONSTRUCTOR_FN_NAME = 'constructor'; + +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); + } + }); +} + +class Markerupable { + + clearMarkups() { + this.markups = []; + } + + addMarkup(markup) { + this.markups.push(markup); + } + + addMarkupAtIndex(markup, index) { + this.markups.splice(index, 0, markup); + } + + removeMarkup(markupOrMarkupCallback) { + let callback; + if (typeof markupOrMarkupCallback === 'function') { + callback = markupOrMarkupCallback; + } else { + let markup = markupOrMarkupCallback; + callback = (_markup) => _markup === markup; + } + + forEach( + filter(this.markups, callback), + m => this._removeMarkup(m) + ); + } + + _removeMarkup(markup) { + const index = this.markups.indexOf(markup); + if (index !== -1) { + this.markups.splice(index, 1); + } + } + + hasMarkup(tagNameOrMarkup) { + return !!this.getMarkup(tagNameOrMarkup); + } + + getMarkup(tagNameOrMarkup) { + if (typeof tagNameOrMarkup === 'string') { + let tagName = normalizeTagName(tagNameOrMarkup); + return detect(this.markups, markup => markup.tagName === tagName); + } else { + let targetMarkup = tagNameOrMarkup; + return detect(this.markups, markup => markup === targetMarkup); + } + } + + get openedMarkups() { + let count = 0; + if (this.prev) { + count = commonItemLength(this.markups, this.prev.markups); + } + + return this.markups.slice(count); + } + + get closedMarkups() { + let count = 0; + if (this.next) { + count = commonItemLength(this.markups, this.next.markups); + } + + return this.markups.slice(count); + } +} + +class LinkedItem { + constructor() { + this.next = null; + this.prev = null; + } +} + +// Unicode uses a pair of "surrogate" characters" (a high- and low-surrogate) +// to encode characters outside the basic multilingual plane (like emoji and +// some languages). +// These values are the unicode code points for the start and end of the +// high- and low-surrogate characters. +// See "high surrogate" and "low surrogate" on +// https://en.wikipedia.org/wiki/Unicode_block +const HIGH_SURROGATE_RANGE = [0xD800, 0xDBFF]; +const LOW_SURROGATE_RANGE = [0xDC00, 0xDFFF]; + +const Marker = class Marker extends LinkedItem { + constructor(value='', markups=[]) { + super(); + this.value = value; + assert('Marker must have value', value !== undefined && value !== null); + this.markups = []; + this.type = MARKER_TYPE; + this.isMarker = true; + this.isAtom = false; + markups.forEach(m => this.addMarkup(m)); + } + + clone() { + const clonedMarkups = this.markups.slice(); + return this.builder.createMarker(this.value, clonedMarkups); + } + + get isEmpty() { + return this.isBlank; + } + + get isBlank() { + return this.length === 0; + } + + charAt(offset) { + return this.value.slice(offset, offset+1); + } + + /** + * A marker's text is equal to its value. + * Compare with an Atom which distinguishes between text and value + */ + get text() { + return this.value; + } + + get length() { + return this.value.length; + } + + // delete the character at this offset, + // update the value with the new value + deleteValueAtOffset(offset) { + assert('Cannot delete value at offset outside bounds', + offset >= 0 && offset <= this.length); + + let width = 1; + let code = this.value.charCodeAt(offset); + if (code >= HIGH_SURROGATE_RANGE[0] && code <= HIGH_SURROGATE_RANGE[1]) { + width = 2; + } else if (code >= LOW_SURROGATE_RANGE[0] && code <= LOW_SURROGATE_RANGE[1]) { + width = 2; + offset = offset - 1; + } + + const [ left, right ] = [ + this.value.slice(0, offset), + this.value.slice(offset+width) + ]; + this.value = left + right; + + return width; + } + + canJoin(other) { + return other && other.isMarker && isArrayEqual(this.markups, other.markups); + } + + textUntil(offset) { + return this.value.slice(0, offset); + } + + split(offset=0, endOffset=this.length) { + let markers = [ + this.builder.createMarker(this.value.substring(0, offset)), + this.builder.createMarker(this.value.substring(offset, endOffset)), + this.builder.createMarker(this.value.substring(endOffset)) + ]; + + this.markups.forEach(mu => markers.forEach(m => m.addMarkup(mu))); + return markers; + } + + /** + * @return {Array} 2 markers either or both of which could be blank + */ + splitAtOffset(offset) { + assert('Cannot split a marker at an offset > its length', + offset <= this.length); + let { value, builder } = this; + + let pre = builder.createMarker(value.substring(0, offset)); + let post = builder.createMarker(value.substring(offset)); + + this.markups.forEach(markup => { + pre.addMarkup(markup); + post.addMarkup(markup); + }); + + return [pre, post]; + } + +}; + +mixin(Marker, Markerupable); + +var Keycodes = { + BACKSPACE: 8, + SPACE: 32, + ENTER: 13, + SHIFT: 16, + ESC: 27, + DELETE: 46, + '0': 48, + '9': 57, + A: 65, + Z: 90, + a: 97, + z: 122, + 'NUMPAD_0': 186, + 'NUMPAD_9': 111, + ';': 186, + '.': 190, + '`': 192, + '[': 219, + '"': 222, + + // Input Method Editor uses multiple keystrokes to display characters. + // Example on mac: press option-i then i. This fires 2 key events in Chrome + // with keyCode 229 and displays ˆ and then î. + // See http://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html#fixed-virtual-key-codes + IME: 229, + + TAB: 9, + CLEAR: 12, + PAUSE: 19, + PAGEUP: 33, + PAGEDOWN: 34, + END: 35, + HOME: 36, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + INS: 45, + META: 91, + ALT: 18, + CTRL: 17 +}; + +var Keys = { + BACKSPACE: 'Backspace', + SPACE: ' ', + ENTER: 'Enter', + SHIFT: 'Shift', + ESC: 'Escape', + DELETE: 'Delete', + INS: 'Insert', + HOME: 'Home', + END: 'End', + PAGEUP: 'PageUp', + PAGEDOWN: 'PageDown', + CLEAR: 'Clear', + PAUSE: 'Pause', + TAB: 'Tab', + ALT: 'Alt', + CTRL: 'Control', + + LEFT: 'ArrowLeft', + RIGHT: 'ArrowRight', + UP: 'ArrowUp', + DOWN: 'ArrowDown' +}; + +const TAB = '\t'; +const ENTER = '\n'; + +/** + * @typedef Direction + * @enum {number} + * @property {number} FORWARD + * @property {number} BACKWARD + */ +const DIRECTION = { + FORWARD: 1, + BACKWARD: -1 +}; + +const MODIFIERS = { + META: 1, // also called "command" on OS X + CTRL: 2, + SHIFT: 4, + ALT: 8 // also called "option" on OS X +}; + +function modifierMask(event) { + let { + metaKey, shiftKey, ctrlKey, altKey + } = event; + let modVal = (val, modifier) => { + return (val && modifier) || 0; + }; + return modVal(metaKey, MODIFIERS.META) + + modVal(shiftKey, MODIFIERS.SHIFT) + + modVal(ctrlKey, MODIFIERS.CTRL) + + modVal(altKey, MODIFIERS.ALT); +} + +const SPECIAL_KEYS = { + BACKSPACE: Keycodes.BACKSPACE, + TAB: Keycodes.TAB, + ENTER: Keycodes.ENTER, + ESC: Keycodes.ESC, + SPACE: Keycodes.SPACE, + PAGEUP: Keycodes.PAGEUP, + PAGEDOWN: Keycodes.PAGEDOWN, + END: Keycodes.END, + HOME: Keycodes.HOME, + LEFT: Keycodes.LEFT, + UP: Keycodes.UP, + RIGHT: Keycodes.RIGHT, + DOWN: Keycodes.DOWN, + INS: Keycodes.INS, + DEL: Keycodes.DELETE +}; + +function specialCharacterToCode(specialCharacter) { + return SPECIAL_KEYS[specialCharacter]; +} + +// heuristic for determining if `event` is a key event +function isKeyEvent(event) { + return /^key/.test(event.type); +} + +/** + * An abstraction around a KeyEvent + * that key listeners in the editor can use + * to determine what sort of key was pressed + */ +const Key = class Key { + constructor(event) { + this.key = event.key; + this.keyCode = event.keyCode; + this.charCode = event.charCode; + this.event = event; + this.modifierMask = modifierMask(event); + } + + static fromEvent(event) { + assert('Must pass a Key event to Key.fromEvent', + event && isKeyEvent(event)); + return new Key(event); + } + + toString() { + if (this.isTab()) { return TAB; } + return String.fromCharCode(this.charCode); + } + + // See https://caniuse.com/#feat=keyboardevent-key for browser support. + isKeySupported() { + return this.key; + } + + isKey(identifier) { + if (this.isKeySupported()) { + assert(`Must define Keys.${identifier}.`, Keys[identifier]); + return this.key === Keys[identifier]; + } else { + assert(`Must define Keycodes.${identifier}.`, Keycodes[identifier]); + return this.keyCode === Keycodes[identifier]; + } + } + + isEscape() { + return this.isKey('ESC'); + } + + isDelete() { + return this.isKey('BACKSPACE') || this.isForwardDelete(); + } + + isForwardDelete() { + return this.isKey('DELETE'); + } + + isArrow() { + return this.isHorizontalArrow() || this.isVerticalArrow(); + } + + isHorizontalArrow() { + return this.isLeftArrow() || this.isRightArrow(); + } + + isHorizontalArrowWithoutModifiersOtherThanShift() { + return this.isHorizontalArrow() && + !(this.ctrlKey || this.metaKey || this.altKey); + } + + isVerticalArrow() { + return this.isKey('UP') || this.isKey('DOWN'); + } + + isLeftArrow() { + return this.isKey('LEFT'); + } + + isRightArrow() { + return this.isKey('RIGHT'); + } + + isHome() { + return this.isKey('HOME'); + } + + isEnd() { + return this.isKey('END'); + } + + isPageUp() { + return this.isKey('PAGEUP'); + } + + isPageDown() { + return this.isKey('PAGEDOWN'); + } + + isInsert() { + return this.isKey('INS'); + } + + isClear() { + return this.isKey('CLEAR'); + } + + isPause() { + return this.isKey('PAUSE'); + } + + isSpace() { + return this.isKey('SPACE'); + } + + // In Firefox, pressing ctrl-TAB will switch to another open browser tab, but + // it will also fire a keydown event for the tab+modifier (ctrl). This causes + // Mobiledoc to erroneously insert a tab character before FF switches to the + // new browser tab. Chrome doesn't fire this event so the issue doesn't + // arise there. Fix this by returning false when the TAB key event includes a + // modifier. + // See: https://github.com/bustle/mobiledoc-kit/issues/565 + isTab() { + return !this.hasAnyModifier() && this.isKey('TAB'); + } + + isEnter() { + return this.isKey('ENTER'); + } + + /* + * If the key is the actual shift key. This is false when the shift key + * is held down and the source `event` is not the shift key. + * @see {isShift} + * @return {bool} + */ + isShiftKey() { + return this.isKey('SHIFT'); + } + + /* + * If the key is the actual alt key (aka "option" on mac). This is false when the alt key + * is held down and the source `event` is not the alt key. + * @return {bool} + */ + isAltKey() { + return this.isKey('ALT'); + } + + /* + * If the key is the actual ctrl key. This is false when the ctrl key + * is held down and the source `event` is not the ctrl key. + * @return {bool} + */ + isCtrlKey() { + return this.isKey('CTRL'); + } + + isIME() { + // FIXME the IME action seems to get lost when we issue an + // `editor.deleteSelection` before it (in Chrome) + return this.keyCode === Keycodes.IME; + } + + get direction() { + switch (true) { + case this.isDelete(): + return this.isForwardDelete() ? DIRECTION.FORWARD : DIRECTION.BACKWARD; + case this.isHorizontalArrow(): + return this.isRightArrow() ? DIRECTION.FORWARD : DIRECTION.BACKWARD; + } + } + + /** + * If the shift key is depressed. + * For example, while holding down meta+shift, pressing the "v" + * key would result in an event whose `Key` had `isShift()` with a truthy value, + * because the shift key is down when pressing the "v". + * @see {isShiftKey} which checks if the key is actually the shift key itself. + * @return {bool} + */ + isShift() { + return this.shiftKey; + } + + hasModifier(modifier) { + return modifier & this.modifierMask; + } + + hasAnyModifier() { + return !!this.modifierMask; + } + + get ctrlKey() { + return MODIFIERS.CTRL & this.modifierMask; + } + + get metaKey() { + return MODIFIERS.META & this.modifierMask; + } + + get shiftKey() { + return MODIFIERS.SHIFT & this.modifierMask; + } + + get altKey() { + return MODIFIERS.ALT & this.modifierMask; + } + + isPrintableKey() { + return !( + this.isArrow() || + this.isHome() || this.isEnd() || + this.isPageUp() || this.isPageDown() || + this.isInsert() || this.isClear() || this.isPause() || + this.isEscape() + ); + } + + isNumberKey() { + if (this.isKeySupported()) { + return this.key >= '0' && this.key <= '9'; + } else { + const code = this.keyCode; + return (code >= Keycodes['0'] && code <= Keycodes['9']) || + (code >= Keycodes.NUMPAD_0 && code <= Keycodes.NUMPAD_9); // numpad keys + } + } + + isLetterKey() { + if (this.isKeySupported()) { + const key = this.key; + return (key >= 'a' && key <= 'z') || + (key >= 'A' && key <= 'Z'); + } else { + const code = this.keyCode; + return (code >= Keycodes.A && code <= Keycodes.Z) || + (code >= Keycodes.a && code <= Keycodes.z); + } + } + + isPunctuation() { + if (this.isKeySupported()) { + const key = this.key; + return (key >= ';' && key <= '`') || + (key >= '[' && key <= '"'); + } else { + const code = this.keyCode; + return (code >= Keycodes[';'] && code <= Keycodes['`']) || + (code >= Keycodes['['] && code <= Keycodes['"']); + } + } + + /** + * See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode#Printable_keys_in_standard_position + * and http://stackoverflow.com/a/12467610/137784 + */ + isPrintable() { + if (this.ctrlKey || this.metaKey) { + return false; + } + + // Firefox calls keypress events for some keys that should not be printable + if (!this.isPrintableKey()) { + return false; + } + + return ( + this.keyCode !== 0 || + this.toString().length > 0 || + this.isNumberKey() || + this.isSpace() || + this.isTab() || + this.isEnter() || + this.isLetterKey() || + this.isPunctuation() || + this.isIME() + ); + } +}; + +function clearSelection() { + window.getSelection().removeAllRanges(); +} + +function textNodeRects(node) { + let range = document.createRange(); + range.setEnd(node, node.nodeValue.length); + range.setStart(node, 0); + return range.getClientRects(); +} + +function findOffsetInTextNode(node, coords) { + let len = node.nodeValue.length; + let range = document.createRange(); + for (let i = 0; i < len; i++) { + range.setEnd(node, i + 1); + range.setStart(node, i); + let rect = range.getBoundingClientRect(); + if (rect.top === rect.bottom) { + continue; + } + if (rect.left <= coords.left && rect.right >= coords.left && + rect.top <= coords.top && rect.bottom >= coords.top) { + return {node, offset: i + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0)}; + } + } + return {node, offset: 0}; +} + +/* + * @param {Object} coords with `top` and `left` + * @see https://github.com/ProseMirror/prosemirror/blob/4c22e3fe97d87a355a0534e25d65aaf0c0d83e57/src/edit/dompos.js + * @return {Object} {node, offset} + */ +/* eslint-disable complexity */ +function findOffsetInNode(node, coords) { + let closest, dyClosest = 1e8, coordsClosest, offset = 0; + for (let child = node.firstChild; child; child = child.nextSibling) { + let rects; + if (isElementNode(child)) { + rects = child.getClientRects(); + } else if (isTextNode(child)) { + rects = textNodeRects(child); + } else { + continue; + } + + for (let i = 0; i < rects.length; i++) { + let rect = rects[i]; + if (rect.left <= coords.left && rect.right >= coords.left) { + let dy = rect.top > coords.top ? rect.top - coords.top + : rect.bottom < coords.top ? coords.top - rect.bottom : 0; + if (dy < dyClosest) { + closest = child; + dyClosest = dy; + coordsClosest = dy ? {left: coords.left, top: rect.top} : coords; + if (isElementNode(child) && !child.firstChild) { + offset = i + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0); + } + continue; + } + } + if (!closest && + (coords.top >= rect.bottom || coords.top >= rect.top && coords.left >= rect.right)) { + offset = i + 1; + } + } + } + if (!closest) { + return {node, offset}; + } + if (isTextNode(closest)) { + return findOffsetInTextNode(closest, coordsClosest); + } + if (closest.firstChild) { + return findOffsetInNode(closest, coordsClosest); + } + return {node, offset}; +} +/* eslint-enable complexity */ + +function constrainNodeTo(node, parentNode, existingOffset) { + let compare = parentNode.compareDocumentPosition(node); + if (compare & Node.DOCUMENT_POSITION_CONTAINED_BY) { + // the node is inside parentNode, do nothing + return { node, offset: existingOffset}; + } else if (compare & Node.DOCUMENT_POSITION_CONTAINS) { + // the node contains parentNode. This shouldn't happen. + return { node, offset: existingOffset}; + } else if (compare & Node.DOCUMENT_POSITION_PRECEDING) { + // node is before parentNode. return start of deepest first child + let child = parentNode.firstChild; + while (child.firstChild) { + child = child.firstChild; + } + return { node: child, offset: 0}; + } else if (compare & Node.DOCUMENT_POSITION_FOLLOWING) { + // node is after parentNode. return end of deepest last child + let child = parentNode.lastChild; + while (child.lastChild) { + child = child.lastChild; + } + + let offset = isTextNode(child) ? child.textContent.length : 1; + return {node: child, offset}; + } else { + return { node, offset: existingOffset}; + } +} + +/* + * Returns a new selection that is constrained within parentNode. + * If the anchorNode or focusNode are outside the parentNode, they are replaced with the beginning + * or end of the parentNode's children + */ +function constrainSelectionTo(selection, parentNode) { + let { + node: anchorNode, + offset: anchorOffset + } = constrainNodeTo(selection.anchorNode, parentNode, selection.anchorOffset); + let { + node: focusNode, + offset: focusOffset + } = constrainNodeTo(selection.focusNode, parentNode, selection.focusOffset); + + return { anchorNode, anchorOffset, focusNode, focusOffset }; +} + +function comparePosition(selection) { + let { anchorNode, focusNode, anchorOffset, focusOffset } = selection; + let headNode, tailNode, headOffset, tailOffset, direction; + + const position = anchorNode.compareDocumentPosition(focusNode); + + // IE may select return focus and anchor nodes far up the DOM tree instead of + // picking the deepest, most specific possible node. For example in + // + //
abcdef
+ // + // with a cursor between c and d, IE might say the focusNode is
with + // an offset of 1. However the anchorNode for a selection might still be + // 2 if there was a selection. + // + // This code walks down the DOM tree until a good comparison of position can be + // made. + // + if (position & Node.DOCUMENT_POSITION_CONTAINS) { + if (focusOffset < focusNode.childNodes.length) { + focusNode = focusNode.childNodes[focusOffset]; + focusOffset = 0; + } else { + // This situation happens on IE when triple-clicking to select. + // Set the focus to the very last character inside the node. + while (focusNode.lastChild) { + focusNode = focusNode.lastChild; + } + focusOffset = focusNode.textContent.length; + } + + return comparePosition({ + focusNode, + focusOffset, + anchorNode, anchorOffset + }); + } else if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) { + let offset = anchorOffset - 1; + if (offset < 0) { + offset = 0; + } + return comparePosition({ + anchorNode: anchorNode.childNodes[offset], + anchorOffset: 0, + focusNode, focusOffset + }); + // The meat of translating anchor and focus nodes to head and tail nodes + } else if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + headNode = anchorNode; tailNode = focusNode; + headOffset = anchorOffset; tailOffset = focusOffset; + direction = DIRECTION.FORWARD; + } else if (position & Node.DOCUMENT_POSITION_PRECEDING) { + headNode = focusNode; tailNode = anchorNode; + headOffset = focusOffset; tailOffset = anchorOffset; + direction = DIRECTION.BACKWARD; + } else { // same node + headNode = tailNode = anchorNode; + headOffset = anchorOffset; + tailOffset = focusOffset; + if (tailOffset < headOffset) { + // Swap the offset order + headOffset = focusOffset; + tailOffset = anchorOffset; + direction = DIRECTION.BACKWARD; + } else if (headOffset < tailOffset) { + direction = DIRECTION.FORWARD; + } else { + direction = null; + } + } + + return {headNode, headOffset, tailNode, tailOffset, direction}; +} + +/** + * A logical range of a {@link Post}. + * Usually an instance of Range will be read from the {@link Editor#range} property, + * but it may be useful to instantiate a range directly when programmatically modifying a Post. + */ +class Range { + /** + * @param {Position} head + * @param {Position} [tail=head] + * @param {Direction} [direction=null] + * @return {Range} + * @private + */ + constructor(head, tail=head, direction=null) { + /** @property {Position} head */ + this.head = head; + + /** @property {Position} tail */ + this.tail = tail; + + /** @property {Direction} direction */ + this.direction = direction; + } + + /** + * Shorthand to create a new range from a section(s) and offset(s). + * When given only a head section and offset, creates a collapsed range. + * @param {Section} headSection + * @param {number} headOffset + * @param {Section} [tailSection=headSection] + * @param {number} [tailOffset=headOffset] + * @param {Direction} [direction=null] + * @return {Range} + */ + static create(headSection, headOffset, tailSection=headSection, tailOffset=headOffset, direction=null) { + return new Range( + new Position$1(headSection, headOffset), + new Position$1(tailSection, tailOffset), + direction + ); + } + + static blankRange() { + return new Range(Position$1.blankPosition(), Position$1.blankPosition()); + } + + /** + * @param {Markerable} section + * @return {Range} A range that is constrained to only the part that + * includes the section. + * FIXME -- if the section isn't the head or tail, it's assumed to be + * wholly contained. It's possible to call `trimTo` with a selection that is + * outside of the range, though, which would invalidate that assumption. + * There's no efficient way to determine if a section is within a range, yet. + * @private + */ + trimTo(section) { + const length = section.length; + + let headOffset = section === this.head.section ? + Math.min(this.head.offset, length) : 0; + let tailOffset = section === this.tail.section ? + Math.min(this.tail.offset, length) : length; + + return Range.create(section, headOffset, section, tailOffset); + } + + /** + * Expands the range 1 unit in the given direction + * If the range is expandable in the given direction, always returns a + * non-collapsed range. + * @param {Number} units If units is > 0, the range is extended to the right, + * otherwise range is extended to the left. + * @return {Range} + * @public + */ + extend(units) { + assert(`Must pass integer to Range#extend`, typeof units === 'number'); + + if (units === 0) { return this; } + + let { head, tail, direction: currentDirection } = this; + switch (currentDirection) { + case DIRECTION.FORWARD: + return new Range(head, tail.move(units), currentDirection); + case DIRECTION.BACKWARD: + return new Range(head.move(units), tail, currentDirection); + default: { + let newDirection = units > 0 ? DIRECTION.FORWARD : DIRECTION.BACKWARD; + return new Range(head, tail, newDirection).extend(units); + } + } + } + + /** + * Moves this range 1 unit in the given direction. + * If the range is collapsed, returns a collapsed range shifted by 1 unit, + * otherwise collapses this range to the position at the `direction` end of the range. + * Always returns a collapsed range. + * @param {Direction} direction + * @return {Range} + * @public + */ + move(direction) { + assert(`Must pass DIRECTION.FORWARD (${DIRECTION.FORWARD}) or DIRECTION.BACKWARD (${DIRECTION.BACKWARD}) to Range#move`, + direction === DIRECTION.FORWARD || direction === DIRECTION.BACKWARD); + + let { focusedPosition, isCollapsed } = this; + + if (isCollapsed) { + return new Range(focusedPosition.move(direction)); + } else { + return this._collapse(direction); + } + } + + /** + * expand a range to all markers matching a given check + * + * @param {Function} detectMarker + * @return {Range} The expanded range + * + * @public + */ + expandByMarker(detectMarker) { + let { + head, + tail, + direction + } = this; + let {section: headSection} = head; + if (headSection !== tail.section) { + throw new Error('#expandByMarker does not work across sections. Perhaps you should confirm the range is collapsed'); + } + + let firstNotMatchingDetect = i => { + return !detectMarker(i); + }; + + let headMarker = headSection.markers.detect(firstNotMatchingDetect, head.marker, true); + if (!headMarker && detectMarker(headSection.markers.head)) { + headMarker = headSection.markers.head; + } else { + headMarker = headMarker.next || head.marker; + } + let headPosition = new Position$1(headSection, headSection.offsetOfMarker(headMarker)); + + let tailMarker = tail.section.markers.detect(firstNotMatchingDetect, tail.marker); + if (!tailMarker && detectMarker(headSection.markers.tail)) { + tailMarker = headSection.markers.tail; + } else { + tailMarker = tailMarker.prev || tail.marker; + } + let tailPosition = new Position$1(tail.section, tail.section.offsetOfMarker(tailMarker) + tailMarker.length); + + return headPosition.toRange(tailPosition, direction); + } + + _collapse(direction) { + return new Range(direction === DIRECTION.BACKWARD ? this.head : this.tail); + } + + get focusedPosition() { + return this.direction === DIRECTION.BACKWARD ? this.head : this.tail; + } + + isEqual(other) { + return other && + this.head.isEqual(other.head) && + this.tail.isEqual(other.tail); + } + + get isBlank() { + return this.head.isBlank && this.tail.isBlank; + } + + // "legacy" APIs + get headSection() { + return this.head.section; + } + get tailSection() { + return this.tail.section; + } + get headSectionOffset() { + return this.head.offset; + } + get tailSectionOffset() { + return this.tail.offset; + } + get isCollapsed() { + return this.head.isEqual(this.tail); + } + get headMarker() { + return this.head.marker; + } + get tailMarker() { + return this.tail.marker; + } + get headMarkerOffset() { + return this.head.offsetInMarker; + } + get tailMarkerOffset() { + return this.tail.offsetInMarker; + } +} + +const { FORWARD, BACKWARD } = DIRECTION; + +// generated via http://xregexp.com/ to cover chars that \w misses +// (new XRegExp('\\p{Alphabetic}|[0-9]|_|:')).toString() +const WORD_CHAR_REGEX = /[A-Za-zªµºÀ-ÖØ-öø-ˁˆ-ˑˠ-ˤˬˮͅͰ-ʹͶͷͺ-ͽͿΆΈ-ΊΌΎ-ΡΣ-ϵϷ-ҁҊ-ԯԱ-Ֆՙա-ևְ-ׇֽֿׁׂׅׄא-תװ-ײؐ-ؚؠ-ٗٙ-ٟٮ-ۓە-ۜۡ-ۭۨ-ۯۺ-ۼۿܐ-ܿݍ-ޱߊ-ߪߴߵߺࠀ-ࠗࠚ-ࠬࡀ-ࡘࢠ-ࢴࣣ-ࣰࣩ-ऻऽ-ौॎ-ॐॕ-ॣॱ-ঃঅ-ঌএঐও-নপ-রলশ-হঽ-ৄেৈোৌৎৗড়ঢ়য়-ৣৰৱਁ-ਃਅ-ਊਏਐਓ-ਨਪ-ਰਲਲ਼ਵਸ਼ਸਹਾ-ੂੇੈੋੌੑਖ਼-ੜਫ਼ੰ-ੵઁ-ઃઅ-ઍએ-ઑઓ-નપ-રલળવ-હઽ-ૅે-ૉોૌૐૠ-ૣૹଁ-ଃଅ-ଌଏଐଓ-ନପ-ରଲଳଵ-ହଽ-ୄେୈୋୌୖୗଡ଼ଢ଼ୟ-ୣୱஂஃஅ-ஊஎ-ஐஒ-கஙசஜஞடணதந-பம-ஹா-ூெ-ைொ-ௌௐௗఀ-ఃఅ-ఌఎ-ఐఒ-నప-హఽ-ౄె-ైొ-ౌౕౖౘ-ౚౠ-ౣಁ-ಃಅ-ಌಎ-ಐಒ-ನಪ-ಳವ-ಹಽ-ೄೆ-ೈೊ-ೌೕೖೞೠ-ೣೱೲഁ-ഃഅ-ഌഎ-ഐഒ-ഺഽ-ൄെ-ൈൊ-ൌൎൗൟ-ൣൺ-ൿංඃඅ-ඖක-නඳ-රලව-ෆා-ුූෘ-ෟෲෳก-ฺเ-ๆํກຂຄງຈຊຍດ-ທນ-ຟມ-ຣລວສຫອ-ູົ-ຽເ-ໄໆໍໜ-ໟༀཀ-ཇཉ-ཬཱ-ཱྀྈ-ྗྙ-ྼက-ံးျ-ဿၐ-ၢၥ-ၨၮ-ႆႎႜႝႠ-ჅჇჍა-ჺჼ-ቈቊ-ቍቐ-ቖቘቚ-ቝበ-ኈኊ-ኍነ-ኰኲ-ኵኸ-ኾዀዂ-ዅወ-ዖዘ-ጐጒ-ጕጘ-ፚ፟ᎀ-ᎏᎠ-Ᏽᏸ-ᏽᐁ-ᙬᙯ-ᙿᚁ-ᚚᚠ-ᛪᛮ-ᛸᜀ-ᜌᜎ-ᜓᜠ-ᜳᝀ-ᝓᝠ-ᝬᝮ-ᝰᝲᝳក-ឳា-ៈៗៜᠠ-ᡷᢀ-ᢪᢰ-ᣵᤀ-ᤞᤠ-ᤫᤰ-ᤸᥐ-ᥭᥰ-ᥴᦀ-ᦫᦰ-ᧉᨀ-ᨛᨠ-ᩞᩡ-ᩴᪧᬀ-ᬳᬵ-ᭃᭅ-ᭋᮀ-ᮩᮬ-ᮯᮺ-ᯥᯧ-ᯱᰀ-ᰵᱍ-ᱏᱚ-ᱽᳩ-ᳬᳮ-ᳳᳵᳶᴀ-ᶿᷧ-ᷴḀ-ἕἘ-Ἕἠ-ὅὈ-Ὅὐ-ὗὙὛὝὟ-ώᾀ-ᾴᾶ-ᾼιῂ-ῄῆ-ῌῐ-ΐῖ-Ίῠ-Ῥῲ-ῴῶ-ῼⁱⁿₐ-ₜℂℇℊ-ℓℕℙ-ℝℤΩℨK-ℭℯ-ℹℼ-ℿⅅ-ⅉⅎⅠ-ↈⒶ-ⓩⰀ-Ⱞⰰ-ⱞⱠ-ⳤⳫ-ⳮⳲⳳⴀ-ⴥⴧⴭⴰ-ⵧⵯⶀ-ⶖⶠ-ⶦⶨ-ⶮⶰ-ⶶⶸ-ⶾⷀ-ⷆⷈ-ⷎⷐ-ⷖⷘ-ⷞⷠ-ⷿⸯ々-〇〡-〩〱-〵〸-〼ぁ-ゖゝ-ゟァ-ヺー-ヿㄅ-ㄭㄱ-ㆎㆠ-ㆺㇰ-ㇿ㐀-䶵一-鿕ꀀ-ꒌꓐ-ꓽꔀ-ꘌꘐ-ꘟꘪꘫꙀ-ꙮꙴ-ꙻꙿ-ꛯꜗ-ꜟꜢ-ꞈꞋ-ꞭꞰ-ꞷꟷ-ꠁꠃ-ꠅꠇ-ꠊꠌ-ꠧꡀ-ꡳꢀ-ꣃꣲ-ꣷꣻꣽꤊ-ꤪꤰ-ꥒꥠ-ꥼꦀ-ꦲꦴ-ꦿꧏꧠ-ꧤꧦ-ꧯꧺ-ꧾꨀ-ꨶꩀ-ꩍꩠ-ꩶꩺꩾ-ꪾꫀꫂꫛ-ꫝꫠ-ꫯꫲ-ꫵꬁ-ꬆꬉ-ꬎꬑ-ꬖꬠ-ꬦꬨ-ꬮꬰ-ꭚꭜ-ꭥꭰ-ꯪ가-힣ힰ-ퟆퟋ-ퟻ豈-舘並-龎ff-stﬓ-ﬗיִ-ﬨשׁ-זּטּ-לּמּנּסּףּפּצּ-ﮱﯓ-ﴽﵐ-ﶏﶒ-ﷇﷰ-ﷻﹰ-ﹴﹶ-ﻼA-Za-zヲ-하-ᅦᅧ-ᅬᅭ-ᅲᅳ-ᅵ]|[0-9]|_|:/; + +function findParentSectionFromNode(renderTree, node) { + let renderNode = renderTree.findRenderNodeFromElement( + node, + (renderNode) => renderNode.postNode.isSection + ); + + return renderNode && renderNode.postNode; +} + +function findOffsetInMarkerable(markerable, node, offset) { + let offsetInSection = 0; + let marker = markerable.markers.head; + while (marker) { + let markerNode = marker.renderNode.element; + if (markerNode === node) { + return offsetInSection + offset; + } else if (marker.isAtom) { + if (marker.renderNode.headTextNode === node) { + return offsetInSection; + } else if (marker.renderNode.tailTextNode === node) { + return offsetInSection + 1; + } + } + + offsetInSection += marker.length; + marker = marker.next; + } + + return offsetInSection; +} + +function findOffsetInSection(section, node, offset) { + if (section.isMarkerable) { + return findOffsetInMarkerable(section, node, offset); + } else { + assert('findOffsetInSection must be called with markerable or card section', + section.isCardSection); + + let wrapperNode = section.renderNode.element; + let endTextNode = wrapperNode.lastChild; + if (node === endTextNode) { + return 1; + } + return 0; + } +} + +let Position, BlankPosition; + +Position = class Position { + /** + * A position is a logical location (zero-width, or "collapsed") in a post, + * typically between two characters in a section. + * Two positions (a head and a tail) make up a {@link Range}. + * @constructor + */ + constructor(section, offset=0, isBlank=false) { + if (!isBlank) { + assert('Position must have a section that is addressable by the cursor', + (section && section.isLeafSection)); + assert('Position must have numeric offset', + (typeof offset === 'number')); + } + + this.section = section; + this.offset = offset; + this.isBlank = isBlank; + } + + /** + * @param {integer} x x-position in current viewport + * @param {integer} y y-position in current viewport + * @param {Editor} editor + * @return {Position|null} + */ + static atPoint(x, y, editor) { + let { _renderTree, element: rootElement } = editor; + let elementFromPoint = document.elementFromPoint(x, y); + if (!containsNode(rootElement, elementFromPoint)) { + return; + } + + let { node, offset } = findOffsetInNode(elementFromPoint, {left: x, top: y}); + return Position.fromNode(_renderTree, node, offset); + } + + static blankPosition() { + return new BlankPosition(); + } + + /** + * Returns a range from this position to the given tail. If no explicit + * tail is given this returns a collapsed range focused on this position. + * @param {Position} [tail=this] The ending position + * @return {Range} + * @public + */ + toRange(tail=this, direction=null) { + return new Range(this, tail, direction); + } + + get leafSectionIndex() { + let post = this.section.post; + let leafSectionIndex; + post.walkAllLeafSections((section, index) => { + if (section === this.section) { + leafSectionIndex = index; + } + }); + return leafSectionIndex; + } + + get isMarkerable() { + return this.section && this.section.isMarkerable; + } + + /** + * Returns the marker at this position, in the backward direction + * (i.e., the marker to the left of the cursor if the cursor is on a marker boundary and text is left-to-right) + * @return {Marker|undefined} + */ + get marker() { + return this.isMarkerable && this.markerPosition.marker; + } + + /** + * Returns the marker in `direction` from this position. + * If the position is in the middle of a marker, the direction is irrelevant. + * Otherwise, if the position is at a boundary between two markers, returns the + * marker to the left if `direction` === BACKWARD and the marker to the right + * if `direction` === FORWARD (assuming left-to-right text direction). + * @param {Direction} + * @return {Marker|undefined} + */ + markerIn(direction) { + if (!this.isMarkerable) { return; } + + let { marker, offsetInMarker } = this; + if (!marker) { return; } + + if (offsetInMarker > 0 && offsetInMarker < marker.length) { + return marker; + } else if (offsetInMarker === 0) { + return direction === BACKWARD ? marker : marker.prev; + } else if (offsetInMarker === marker.length) { + return direction === FORWARD ? marker.next : marker; + } + } + + get offsetInMarker() { + return this.markerPosition.offset; + } + + isEqual(position) { + return this.section === position.section && + this.offset === position.offset; + } + + /** + * @return {Boolean} If this position is at the head of the post + */ + isHeadOfPost() { + return this.move(BACKWARD).isEqual(this); + } + + /** + * @return {Boolean} If this position is at the tail of the post + */ + isTailOfPost() { + return this.move(FORWARD).isEqual(this); + } + + /** + * @return {Boolean} If this position is at the head of its section + */ + isHead() { + return this.isEqual(this.section.headPosition()); + } + + /** + * @return {Boolean} If this position is at the tail of its section + */ + isTail() { + return this.isEqual(this.section.tailPosition()); + } + + /** + * Move the position 1 unit in `direction`. + * + * @param {Number} units to move. > 0 moves right, < 0 moves left + * @return {Position} Return a new position one unit in the given + * direction. If the position is moving left and at the beginning of the post, + * the same position will be returned. Same if the position is moving right and + * at the end of the post. + */ + move(units) { + assert('Must pass integer to Position#move', typeof units === 'number'); + + if (units < 0) { + return this.moveLeft().move(++units); + } else if (units > 0) { + return this.moveRight().move(--units); + } else { + return this; + } + } + + /** + * @param {Number} direction (FORWARD or BACKWARD) + * @return {Position} The result of moving 1 "word" unit in `direction` + */ + moveWord(direction) { + let isPostBoundary = direction === BACKWARD ? this.isHeadOfPost() : this.isTailOfPost(); + if (isPostBoundary) { + return this; + } + + if (!this.isMarkerable) { + return this.move(direction); + } + + let pos = this; + + // Helper fn to check if the pos is at the `dir` boundary of its section + let isBoundary = (pos, dir) => { + return dir === BACKWARD ? pos.isHead() : pos.isTail(); + }; + // Get the char at this position (looking forward/right) + let getChar = (pos) => { + let { marker, offsetInMarker } = pos; + return marker.charAt(offsetInMarker); + }; + // Get the char in `dir` at this position + let peekChar = (pos, dir) => { + return dir === BACKWARD ? getChar(pos.move(BACKWARD)) : getChar(pos); + }; + // Whether there is an atom in `dir` from this position + let isAtom = (pos, dir) => { + // Special case when position is at end, the marker associated with it is + // the marker to its left. Normally `pos#marker` is the marker to the right of the pos's offset. + if (dir === BACKWARD && pos.isTail() && pos.marker.isAtom) { + return true; + } + return dir === BACKWARD ? pos.move(BACKWARD).marker.isAtom : pos.marker.isAtom; + }; + + if (isBoundary(pos, direction)) { + // extend movement into prev/next section + return pos.move(direction).moveWord(direction); + } + + let seekWord = (pos) => { + return !isBoundary(pos, direction) && + !isAtom(pos, direction) && + !WORD_CHAR_REGEX.test(peekChar(pos, direction)); + }; + + // move(dir) while we are seeking the first word char + while (seekWord(pos)) { + pos = pos.move(direction); + } + + if (isAtom(pos, direction)) { + return pos.move(direction); + } + + let seekBoundary = (pos) => { + return !isBoundary(pos, direction) && + !isAtom(pos, direction) && + WORD_CHAR_REGEX.test(peekChar(pos, direction)); + }; + + // move(dir) while we are seeking the first boundary position + while (seekBoundary(pos)) { + pos = pos.move(direction); + } + + return pos; + } + + /** + * The position to the left of this position. + * If this position is the post's headPosition it returns itself. + * @return {Position} + * @private + */ + moveLeft() { + if (this.isHead()) { + let prev = this.section.previousLeafSection(); + return prev ? prev.tailPosition() : this; + } else { + let offset = this.offset - 1; + if (this.isMarkerable && this.marker) { + let code = this.marker.value.charCodeAt(offset); + if (code >= LOW_SURROGATE_RANGE[0] && code <= LOW_SURROGATE_RANGE[1]) { + offset = offset - 1; + } + } + return new Position(this.section, offset); + } + } + + /** + * The position to the right of this position. + * If this position is the post's tailPosition it returns itself. + * @return {Position} + * @private + */ + moveRight() { + if (this.isTail()) { + let next = this.section.nextLeafSection(); + return next ? next.headPosition() : this; + } else { + let offset = this.offset + 1; + if (this.isMarkerable && this.marker) { + let code = this.marker.value.charCodeAt(offset - 1); + if (code >= HIGH_SURROGATE_RANGE[0] && code <= HIGH_SURROGATE_RANGE[1]) { + offset = offset + 1; + } + } + return new Position(this.section, offset); + } + } + + static fromNode(renderTree, node, offset) { + if (isTextNode(node)) { + return Position.fromTextNode(renderTree, node, offset); + } else { + return Position.fromElementNode(renderTree, node, offset); + } + } + + static fromTextNode(renderTree, textNode, offsetInNode) { + const renderNode = renderTree.getElementRenderNode(textNode); + let section, offsetInSection; + + if (renderNode) { + const marker = renderNode.postNode; + section = marker.section; + + assert(`Could not find parent section for mapped text node "${textNode.textContent}"`, + !!section); + offsetInSection = section.offsetOfMarker(marker, offsetInNode); + } else { + // all text nodes should be rendered by markers except: + // * text nodes inside cards + // * text nodes created by the browser during text input + // both of these should have rendered parent sections, though + section = findParentSectionFromNode(renderTree, textNode); + assert(`Could not find parent section for un-mapped text node "${textNode.textContent}"`, + !!section); + + offsetInSection = findOffsetInSection(section, textNode, offsetInNode); + } + + return new Position(section, offsetInSection); + } + + static fromElementNode(renderTree, elementNode, offset) { + let position; + + // The browser may change the reported selection to equal the editor's root + // element if the user clicks an element that is immediately removed, + // which can happen when clicking to remove a card. + if (elementNode === renderTree.rootElement) { + let post = renderTree.rootNode.postNode; + position = offset === 0 ? post.headPosition() : post.tailPosition(); + } else { + let section = findParentSectionFromNode(renderTree, elementNode); + assert('Could not find parent section from element node', !!section); + + if (section.isCardSection) { + // Selections in cards are usually made on a text node + // containing a ‌ on one side or the other of the card but + // some scenarios (Firefox) will result in selecting the + // card's wrapper div. If the offset is 2 we've selected + // the final zwnj and should consider the cursor at the + // end of the card (offset 1). Otherwise, the cursor is at + // the start of the card + position = offset < 2 ? section.headPosition() : section.tailPosition(); + } else { + + // In Firefox it is possible for the cursor to be on an atom's wrapper + // element. (In Chrome/Safari, the browser corrects this to be on + // one of the text nodes surrounding the wrapper). + // This code corrects for when the browser reports the cursor position + // to be on the wrapper element itself + let renderNode = renderTree.getElementRenderNode(elementNode); + let postNode = renderNode && renderNode.postNode; + if (postNode && postNode.isAtom) { + let sectionOffset = section.offsetOfMarker(postNode); + if (offset > 1) { + // we are on the tail side of the atom + sectionOffset += postNode.length; + } + position = new Position(section, sectionOffset); + } else if (offset >= elementNode.childNodes.length) { + + // This is to deal with how Firefox handles triple-click selections. + // See https://stackoverflow.com/a/21234837/1269194 for an + // explanation. + position = section.tailPosition(); + } else { + // The offset is 0 if the cursor is on a non-atom-wrapper element node + // (e.g., a
tag in a blank markup section) + position = section.headPosition(); + } + } + } + + return position; + } + + /** + * @private + */ + get markerPosition() { + assert('Cannot get markerPosition without a section', !!this.section); + assert('cannot get markerPosition of a non-markerable', !!this.section.isMarkerable); + return this.section.markerPositionAtOffset(this.offset); + } +}; + +BlankPosition = class BlankPosition extends Position { + constructor() { + super(null, 0, true); + } + + isEqual(other) { + return other && other.isBlank; + } + + toRange() { return Range.blankRange(); } + get leafSectionIndex() { assert('must implement get leafSectionIndex', false); } + + get isMarkerable() { return false; } + get marker() { return false; } + isHeadOfPost() { return false; } + isTailOfPost() { return false; } + isHead() { return false; } + isTail() { return false; } + move() { return this; } + moveWord() { return this; } + + get markerPosition() { return {}; } +}; + +var Position$1 = Position; + +class LifecycleCallbacks { + constructor(queueNames=[]) { + this.callbackQueues = {}; + this.removalQueues = {}; + + queueNames.forEach(name => { + this.callbackQueues[name] = []; + this.removalQueues[name] = []; + }); + } + + runCallbacks(queueName, args=[]) { + let queue = this._getQueue(queueName); + queue.forEach(cb => cb(...args)); + + let toRemove = this.removalQueues[queueName]; + toRemove.forEach(cb => { + let index = queue.indexOf(cb); + if (index !== -1) { + queue.splice(index, 1); + } + }); + + this.removalQueues[queueName] = []; + } + + addCallback(queueName, callback) { + this._getQueue(queueName).push(callback); + } + + _scheduleCallbackForRemoval(queueName, callback) { + this.removalQueues[queueName].push(callback); + } + + addCallbackOnce(queueName, callback) { + let queue = this._getQueue(queueName); + if (queue.indexOf(callback) === -1) { + queue.push(callback); + this._scheduleCallbackForRemoval(queueName, callback); + } + } + + _getQueue(queueName) { + let queue = this.callbackQueues[queueName]; + assert(`No queue found for "${queueName}"`, !!queue); + return queue; + } +} + +const MARKERABLE = 'markerable', + NESTED_MARKERABLE = 'nested_markerable', + NON_MARKERABLE = 'non_markerable'; + +class Visitor { + constructor(inserter, cursorPosition) { + let { postEditor, post } = inserter; + this.postEditor = postEditor; + this._post = post; + this.cursorPosition = cursorPosition; + this.builder = this.postEditor.builder; + + this._hasInsertedFirstLeafSection = false; + } + + get cursorPosition() { + return this._cursorPosition; + } + + set cursorPosition(position) { + this._cursorPosition = position; + this.postEditor.setRange(position); + } + + visit(node) { + let method = node.type; + assert(`Cannot visit node of type ${node.type}`, !!this[method]); + this[method](node); + } + + _canMergeSection(section) { + if (this._hasInsertedFirstLeafSection) { + return false; + } else { + return this._isMarkerable && section.isMarkerable; + } + } + + get _isMarkerable() { + return this.cursorSection.isMarkerable; + } + + get cursorSection() { + return this.cursorPosition.section; + } + + get cursorOffset() { + return this.cursorPosition.offset; + } + + get _isNested() { + return this.cursorSection.isNested; + } + + [POST_TYPE](node) { + if (this.cursorSection.isBlank && !this._isNested) { + // replace blank section with entire post + let newSections = node.sections.map(s => s.clone()); + this._replaceSection(this.cursorSection, newSections); + } else { + node.sections.forEach(section => this.visit(section)); + } + } + + [MARKUP_SECTION_TYPE](node) { + this[MARKERABLE](node); + } + + [LIST_SECTION_TYPE](node) { + let hasNext = !!node.next; + node.items.forEach(item => this.visit(item)); + + if (this._isNested && hasNext) { + this._breakNestedAtCursor(); + } + } + + [LIST_ITEM_TYPE](node) { + this[NESTED_MARKERABLE](node); + } + + [CARD_TYPE](node) { + this[NON_MARKERABLE](node); + } + + [IMAGE_SECTION_TYPE](node) { + this[NON_MARKERABLE](node); + } + + [NON_MARKERABLE](section) { + if (this._isNested) { + this._breakNestedAtCursor(); + } else if (!this.cursorSection.isBlank) { + this._breakAtCursor(); + } + + this._insertLeafSection(section); + } + + [MARKERABLE](section) { + if (this._canMergeSection(section)) { + this._mergeSection(section); + } else if (this._isNested && this._isMarkerable) { + // If we are attaching a markerable section to a list item, + // insert a linebreak then merge the section onto the resulting blank list item + this._breakAtCursor(); + + // Advance the cursor to the head of the blank list item + let nextPosition = this.cursorSection.next.headPosition(); + this.cursorPosition = nextPosition; + + // Merge this section onto the list item + this._mergeSection(section); + } else { + this._breakAtCursor(); + this._insertLeafSection(section); + } + } + + [NESTED_MARKERABLE](section) { + if (this._canMergeSection(section)) { + this._mergeSection(section); + return; + } + + section = this._isNested ? section : this._wrapNestedSection(section); + this._breakAtCursor(); + this._insertLeafSection(section); + } + + // break out of a nested cursor position + _breakNestedAtCursor() { + assert('Cannot call _breakNestedAtCursor if not nested', this._isNested); + + let parent = this.cursorSection.parent; + let cursorAtEndOfList = this.cursorPosition.isEqual(parent.tailPosition()); + + if (cursorAtEndOfList) { + let blank = this.builder.createMarkupSection(); + this._insertSectionAfter(blank, parent); + } else { + let [, blank,] = this._breakListAtCursor(); + this.cursorPosition = blank.tailPosition(); + } + } + + _breakListAtCursor() { + assert('Cannot _splitParentSection if cursor position is not nested', + this._isNested); + + let list = this.cursorSection.parent, + position = this.cursorPosition, + blank = this.builder.createMarkupSection(); + let [pre, post] = this.postEditor._splitListAtPosition(list, position); + + let collection = this._post.sections, + reference = post; + this.postEditor.insertSectionBefore(collection, blank, reference); + return [pre, blank, post]; + } + + _wrapNestedSection(section) { + let tagName = section.parent.tagName; + let parent = this.builder.createListSection(tagName); + parent.items.append(section.clone()); + return parent; + } + + _mergeSection(section) { + assert('Can only merge markerable sections', + this._isMarkerable && section.isMarkerable); + this._hasInsertedFirstLeafSection = true; + + let markers = section.markers.map(m => m.clone()); + let position = this.postEditor.insertMarkers(this.cursorPosition, markers); + + this.cursorPosition = position; + } + + // Can be called to add a line break when in a nested section or a parent + // section. + _breakAtCursor() { + if (this.cursorSection.isBlank) { + return; + } else if (this._isMarkerable) { + this._breakMarkerableAtCursor(); + } else { + this._breakNonMarkerableAtCursor(); + } + } + + // Inserts a blank section before/after the cursor, + // depending on cursor position. + _breakNonMarkerableAtCursor() { + let collection = this._post.sections, + blank = this.builder.createMarkupSection(), + reference = this.cursorPosition.isHead() ? this.cursorSection : + this.cursorSection.next; + this.postEditor.insertSectionBefore(collection, blank, reference); + this.cursorPosition = blank.tailPosition(); + } + + _breakMarkerableAtCursor() { + let [pre,] = + this.postEditor.splitSection(this.cursorPosition); + + this.cursorPosition = pre.tailPosition(); + } + + _replaceSection(section, newSections) { + assert('Cannot replace section that does not have parent.sections', + section.parent && section.parent.sections); + assert('Must pass enumerable to _replaceSection', !!newSections.forEach); + + let collection = section.parent.sections; + let reference = section.next; + this.postEditor.removeSection(section); + newSections.forEach(section => { + this.postEditor.insertSectionBefore(collection, section, reference); + }); + let lastSection = newSections[newSections.length - 1]; + + this.cursorPosition = lastSection.tailPosition(); + } + + _insertSectionBefore(section, reference) { + let collection = this.cursorSection.parent.sections; + this.postEditor.insertSectionBefore(collection, section, reference); + + this.cursorPosition = section.tailPosition(); + } + + // Insert a section after the parent section. + // E.g., add a markup section after a list section + _insertSectionAfter(section, parent) { + assert('Cannot _insertSectionAfter nested section', !parent.isNested); + let reference = parent.next; + let collection = this._post.sections; + this.postEditor.insertSectionBefore(collection, section, reference); + this.cursorPosition = section.tailPosition(); + } + + _insertLeafSection(section) { + assert('Can only _insertLeafSection when cursor is at end of section', + this.cursorPosition.isTail()); + + this._hasInsertedFirstLeafSection = true; + section = section.clone(); + + if (this.cursorSection.isBlank) { + assert('Cannot insert leaf non-markerable section when cursor is nested', + !(section.isMarkerable && this._isNested)); + this._replaceSection(this.cursorSection, [section]); + } else if (this.cursorSection.next && this.cursorSection.next.isBlank) { + this._replaceSection(this.cursorSection.next, [section]); + } else { + let reference = this.cursorSection.next; + this._insertSectionBefore(section, reference); + } + } +} + +class Inserter { + constructor(postEditor, post) { + this.postEditor = postEditor; + this.post = post; + } + + insert(cursorPosition, newPost) { + let visitor = new Visitor(this, cursorPosition); + if (!newPost.isBlank) { + visitor.visit(newPost); + } + return visitor.cursorPosition; + } +} + +/** + * Usage: + * Without a conditional, always prints deprecate message: + * `deprecate('This is deprecated')` + * + * Conditional deprecation, works similarly to `assert`, prints deprecation if + * conditional is false: + * `deprecate('Deprecated only if foo !== bar', foo === bar)` + */ +function deprecate(message, conditional=false) { + if (!conditional) { + // eslint-disable-next-line no-console + console.log(`[mobiledoc-kit] [DEPRECATED]: ${message}`); + } +} + +function toRange(rangeLike) { + assert(`Must pass non-blank object to "toRange"`, !!rangeLike); + + if (rangeLike instanceof Range) { + return rangeLike; + } else if (rangeLike instanceof Position$1) { + return rangeLike.toRange(); + } + + assert(`Incorrect structure for rangeLike: ${rangeLike}`, false); +} + +const { FORWARD: FORWARD$1, BACKWARD: BACKWARD$1 } = DIRECTION; + +function isListSectionTagName(tagName) { + return tagName === 'ul' || tagName === 'ol'; +} + +const CALLBACK_QUEUES = { + BEFORE_COMPLETE: 'beforeComplete', + COMPLETE: 'complete', + AFTER_COMPLETE: 'afterComplete' +}; + +// There are only two events that we're concerned about for Undo, that is inserting text and deleting content. +// These are the only two states that go on a "run" and create a combined undo, everything else has it's own +// deadicated undo. +const EDIT_ACTIONS = { + INSERT_TEXT: 1, + DELETE: 2 +}; + + +/** + * The PostEditor is used to modify a post. It should not be instantiated directly. + * Instead, a new instance of a PostEditor is created by the editor and passed + * as the argument to the callback in {@link Editor#run}. + * + * Usage: + * ``` + * editor.run((postEditor) => { + * // postEditor is an instance of PostEditor that can operate on the + * // editor's post + * }); + * ``` + */ +class PostEditor { + /** + * @private + */ + constructor(editor) { + this.editor = editor; + this.builder = this.editor.builder; + this._callbacks = new LifecycleCallbacks(values(CALLBACK_QUEUES)); + + this._didComplete = false; + this.editActionTaken = null; + + this._renderRange = () => this.editor.selectRange(this._range); + this._postDidChange = () => this.editor._postDidChange(); + this._rerender = () => this.editor.rerender(); + } + + addCallback(...args) { + this._callbacks.addCallback(...args); + } + + addCallbackOnce(...args) { + this._callbacks.addCallbackOnce(...args); + } + + runCallbacks(...args) { + this._callbacks.runCallbacks(...args); + } + + begin() { + // cache the editor's range + this._range = this.editor.range; + } + + /** + * Schedules to select the given range on the editor after the postEditor + * has completed its work. This also updates the postEditor's active range + * (so that multiple calls to range-changing methods on the postEditor will + * update the correct range). + * + * Usage: + * let range = editor.range; + * editor.run(postEditor => { + * let nextPosition = postEditor.deleteRange(range); + * + * // Will position the editor's cursor at `nextPosition` after + * // the postEditor finishes work and the editor rerenders. + * postEditor.setRange(nextPosition); + * }); + * @param {Range|Position} range + * @public + */ + setRange(range) { + range = toRange(range); + + // TODO validate that the range is valid + // (does not contain marked-for-removal head or tail sections?) + this._range = range; + this.scheduleAfterRender(this._renderRange, true); + } + + /** + * Delete a range from the post + * + * Usage: + * ``` + * let { range } = editor; + * editor.run((postEditor) => { + * let nextPosition = postEditor.deleteRange(range); + * postEditor.setRange(nextPosition); + * }); + * ``` + * @param {Range} range Cursor Range object with head and tail Positions + * @return {Position} The position where the cursor would go after deletion + * @public + */ + deleteRange(range) { + assert("Must pass MobiledocKit Range to `deleteRange`", range instanceof Range); + + this.editActionTaken = EDIT_ACTIONS.DELETE; + + let { + head, head: {section: headSection}, + tail, tail: {section: tailSection} + } = range; + + let { editor: { post } } = this; + + if (headSection === tailSection) { + return this.cutSection(headSection, head, tail); + } + + let nextSection = headSection.nextLeafSection(); + + let nextPos = this.cutSection(headSection, head, headSection.tailPosition()); + // cutSection can replace the section, so re-read headSection here + headSection = nextPos.section; + + // Remove sections in the middle of the range + while (nextSection !== tailSection) { + let tmp = nextSection; + nextSection = nextSection.nextLeafSection(); + this.removeSection(tmp); + } + + let tailPos = this.cutSection(tailSection, tailSection.headPosition(), tail); + // cutSection can replace the section, so re-read tailSection here + tailSection = tailPos.section; + + if (tailSection.isBlank) { + this.removeSection(tailSection); + } else { + // If head and tail sections are markerable, join them + // Note: They may not be the same section type. E.g. this may join + // a tail section that was a list item onto a markup section, or vice versa. + // (This is the desired behavior.) + if (headSection.isMarkerable && tailSection.isMarkerable) { + headSection.join(tailSection); + this._markDirty(headSection); + this.removeSection(tailSection); + } else if (headSection.isBlank) { + this.removeSection(headSection); + nextPos = tailPos; + } + } + + if (post.isBlank) { + post.sections.append(this.builder.createMarkupSection('p')); + nextPos = post.headPosition(); + } + + return nextPos; + } + + /** + * Note: This method may replace `section` with a different section. + * + * "Cut" out the part of the section inside `headOffset` and `tailOffset`. + * If section is markerable this splits markers that straddle the head or tail (if necessary), + * and removes markers that are wholly inside the offsets. + * If section is a card, this may replace it with a blank markup section if the + * positions contain the entire card. + * + * @param {Section} section + * @param {Position} head + * @param {Position} tail + * @return {Position} + * @private + */ + cutSection(section, head, tail) { + assert('Must pass head position and tail position to `cutSection`', + head instanceof Position$1 && tail instanceof Position$1); + assert('Must pass positions within same section to `cutSection`', + head.section === tail.section); + + if (section.isBlank || head.isEqual(tail)) { + return head; + } + if (section.isCardSection) { + if (head.isHead() && tail.isTail()) { + let newSection = this.builder.createMarkupSection(); + this.replaceSection(section, newSection); + return newSection.headPosition(); + } else { + return tail; + } + } + + let range = head.toRange(tail); + this.splitMarkers(range).forEach(m => this.removeMarker(m)); + + return head; + } + + _coalesceMarkers(section) { + if (section.isMarkerable) { + this._removeBlankMarkers(section); + this._joinSimilarMarkers(section); + } + } + + _removeBlankMarkers(section) { + forEach( + filter(section.markers, m => m.isBlank), + m => this.removeMarker(m) + ); + } + + // joins markers that have identical markups + _joinSimilarMarkers(section) { + let marker = section.markers.head; + let nextMarker; + while (marker && marker.next) { + nextMarker = marker.next; + + if (marker.canJoin(nextMarker)) { + nextMarker.value = marker.value + nextMarker.value; + this._markDirty(nextMarker); + this.removeMarker(marker); + } + + marker = nextMarker; + } + } + + removeMarker(marker) { + this._scheduleForRemoval(marker); + if (marker.section) { + this._markDirty(marker.section); + marker.section.markers.remove(marker); + } + } + + _scheduleForRemoval(postNode) { + if (postNode.renderNode) { + postNode.renderNode.scheduleForRemoval(); + + this.scheduleRerender(); + this.scheduleDidUpdate(); + } + let removedAdjacentToList = (postNode.prev && postNode.prev.isListSection) || + (postNode.next && postNode.next.isListSection); + if (removedAdjacentToList) { + this.addCallback( + CALLBACK_QUEUES.BEFORE_COMPLETE, + () => this._joinContiguousListSections() + ); + } + } + + _joinContiguousListSections() { + let { post } = this.editor; + let range = this._range; + let prev; + let groups = []; + let currentGroup; + + // FIXME do we need to force a re-render of the range if changed sections + // are contained within the range? + let updatedHead = null; + forEach(post.sections, section => { + if (prev && + prev.isListSection && + section.isListSection && + prev.tagName === section.tagName) { + + currentGroup = currentGroup || [prev]; + currentGroup.push(section); + } else { + if (currentGroup) { + groups.push(currentGroup); + } + currentGroup = null; + } + prev = section; + }); + + if (currentGroup) { + groups.push(currentGroup); + } + + forEach(groups, group => { + let list = group[0]; + forEach(group, listSection => { + if (listSection === list) { + return; + } + + let currentHead = range.head; + let prevPosition; + + // FIXME is there a currentHead if there is no range? + // is the current head a list item in the section + if (!range.isBlank && currentHead.section.isListItem && + currentHead.section.parent === listSection) { + prevPosition = list.tailPosition(); + } + this._joinListSections(list, listSection); + if (prevPosition) { + updatedHead = prevPosition.move(FORWARD$1); + } + }); + }); + + if (updatedHead) { + this.setRange(updatedHead); + } + } + + _joinListSections(baseList, nextList) { + baseList.join(nextList); + this._markDirty(baseList); + this.removeSection(nextList); + } + + _markDirty(postNode) { + if (postNode.renderNode) { + postNode.renderNode.markDirty(); + + this.scheduleRerender(); + this.scheduleDidUpdate(); + } + if (postNode.section) { + this._markDirty(postNode.section); + } + if (postNode.isMarkerable) { + this.addCallback( + CALLBACK_QUEUES.BEFORE_COMPLETE, () => this._coalesceMarkers(postNode)); + } + } + + /** + * @param {Position} position object with {section, offset} the marker and offset to delete from + * @param {Number} direction The direction to delete in (default is BACKWARD) + * @return {Position} for positioning the cursor + * @public + * @deprecated after v0.10.3 + */ + deleteFrom(position, direction=DIRECTION.BACKWARD) { + deprecate("`postEditor#deleteFrom is deprecated. Use `deleteAtPosition(position, direction=BACKWARD, {unit}={unit: 'char'})` instead"); + return this.deleteAtPosition(position, direction, {unit: 'char'}); + } + + /** + * Delete 1 `unit` (can be 'char' or 'word') in the given `direction` at the given + * `position`. In almost all cases this will be equivalent to deleting the range formed + * by expanding the position 1 unit in the given direction. The exception is when deleting + * backward from the beginning of a list item, which reverts the list item into a markup section + * instead of joining it with its previous list item (if any). + * + * Usage: + * + * let position = section.tailPosition(); + * // Section has text of "Howdy!" + * editor.run((postEditor) => { + * postEditor.deleteAtPosition(position); + * }); + * // section has text of "Howdy" + * + * @param {Position} position The position to delete at + * @param {Direction} [direction=DIRECTION.BACKWARD] direction The direction to delete in + * @param {Object} [options] + * @param {String} [options.unit="char"] The unit of deletion ("word" or "char") + * @return {Position} + */ + deleteAtPosition(position, direction=DIRECTION.BACKWARD, {unit}={unit: 'char'}) { + if (direction === DIRECTION.BACKWARD) { + return this._deleteAtPositionBackward(position, unit); + } else { + return this._deleteAtPositionForward(position, unit); + } + } + + _deleteAtPositionBackward(position, unit) { + if (position.isHead() && position.section.isListItem) { + this.toggleSection('p', position); + return this._range.head; + } else { + let prevPosition = unit === 'word' ? position.moveWord(BACKWARD$1) : position.move(BACKWARD$1); + let range = prevPosition.toRange(position); + return this.deleteRange(range); + } + } + + _deleteAtPositionForward(position, unit) { + let nextPosition = unit === 'word' ? position.moveWord(FORWARD$1) : position.move(FORWARD$1); + let range = position.toRange(nextPosition); + return this.deleteRange(range); + } + + /** + * Split markers at two positions, once at the head, and if necessary once + * at the tail. + * + * Usage: + * ``` + * let range = editor.range; + * editor.run((postEditor) => { + * postEditor.splitMarkers(range); + * }); + * ``` + * The return value will be marker object completely inside the offsets + * provided. Markers outside of the split may also have been modified. + * + * @param {Range} markerRange + * @return {Array} of markers that are inside the split + * @private + */ + splitMarkers(range) { + const { post } = this.editor; + const { head, tail } = range; + + this.splitSectionMarkerAtOffset(head.section, head.offset); + this.splitSectionMarkerAtOffset(tail.section, tail.offset); + + return post.markersContainedByRange(range); + } + + splitSectionMarkerAtOffset(section, offset) { + const edit = section.splitMarkerAtOffset(offset); + edit.removed.forEach(m => this.removeMarker(m)); + } + + /** + * Split the section at the position. + * + * Usage: + * ``` + * let position = editor.cursor.offsets.head; + * editor.run((postEditor) => { + * postEditor.splitSection(position); + * }); + * // Will result in the creation of two new sections + * // replacing the old one at the cursor position + * ``` + * The return value will be the two new sections. One or both of these + * sections can be blank (contain only a blank marker), for example if the + * headMarkerOffset is 0. + * + * @param {Position} position + * @return {Array} new sections, one for the first half and one for the second (either one can be null) + * @public + */ + splitSection(position) { + const { section } = position; + + if (section.isCardSection) { + return this._splitCardSection(section, position); + } else if (section.isListItem) { + let isLastAndBlank = section.isBlank && !section.next; + if (isLastAndBlank) { + // if is last, replace the item with a blank markup section + let parent = section.parent; + let collection = this.editor.post.sections; + let blank = this.builder.createMarkupSection(); + this.removeSection(section); + this.insertSectionBefore(collection, blank, parent.next); + + return [null, blank]; + } else { + let [pre, post] = this._splitListItem(section, position); + return [pre, post]; + } + } else { + let splitSections = section.splitAtPosition(position); + splitSections.forEach(s => this._coalesceMarkers(s)); + this._replaceSection(section, splitSections); + + return splitSections; + } + } + + /** + * @param {Section} cardSection + * @param {Position} position to split at + * @return {Section[]} 2-item array of pre and post-split sections + * @private + */ + _splitCardSection(cardSection, position) { + let { offset } = position; + assert('Cards section must be split at offset 0 or 1', + offset === 0 || offset === 1); + + let newSection = this.builder.createMarkupSection(); + let nextSection; + let surroundingSections; + + if (offset === 0) { + nextSection = cardSection; + surroundingSections = [newSection, cardSection]; + } else { + nextSection = cardSection.next; + surroundingSections = [cardSection, newSection]; + } + + let collection = this.editor.post.sections; + this.insertSectionBefore(collection, newSection, nextSection); + + return surroundingSections; + } + + /** + * @param {Section} section + * @param {Section} newSection + * @return null + * @public + */ + replaceSection(section, newSection) { + if (!section) { + // FIXME should a falsy section be a valid argument? + this.insertSectionBefore(this.editor.post.sections, newSection, null); + } else { + this._replaceSection(section, [newSection]); + } + } + + moveSectionBefore(collection, renderedSection, beforeSection) { + const newSection = renderedSection.clone(); + this.removeSection(renderedSection); + this.insertSectionBefore(collection, newSection, beforeSection); + return newSection; + } + + /** + * @param {Section} section A section that is already in DOM + * @public + */ + moveSectionUp(renderedSection) { + const isFirst = !renderedSection.prev; + if (isFirst) { + return renderedSection; + } + + const collection = renderedSection.parent.sections; + const beforeSection = renderedSection.prev; + return this.moveSectionBefore(collection, renderedSection, beforeSection); + } + + /** + * @param {Section} section A section that is already in DOM + * @public + */ + moveSectionDown(renderedSection) { + const isLast = !renderedSection.next; + if (isLast) { + return renderedSection; + } + + const beforeSection = renderedSection.next.next; + const collection = renderedSection.parent.sections; + return this.moveSectionBefore(collection, renderedSection, beforeSection); + } + + /** + * Insert an array of markers at the given position. If the position is in + * a non-markerable section (like a card section), this method throws an error. + * + * @param {Position} position + * @param {Marker[]} markers + * @return {Position} The position that represents the end of the inserted markers. + * @public + */ + insertMarkers(position, markers) { + let { section, offset } = position; + assert('Cannot insert markers at non-markerable position', + section.isMarkerable); + + this.editActionTaken = EDIT_ACTIONS.INSERT_TEXT; + + let edit = section.splitMarkerAtOffset(offset); + edit.removed.forEach(marker => this._scheduleForRemoval(marker)); + + let prevMarker = section.markerBeforeOffset(offset); + markers.forEach(marker => { + section.markers.insertAfter(marker, prevMarker); + offset += marker.length; + prevMarker = marker; + }); + + this._coalesceMarkers(section); + this._markDirty(section); + + let nextPosition = section.toPosition(offset); + this.setRange(nextPosition); + return nextPosition; + } + + /** + * Inserts text with the given markups, ignoring the existing markups at + * the position, if any. + * + * @param {Position} position + * @param {String} text + * @param {Markup[]} markups + * @return {Position} position at the end of the inserted text + */ + insertTextWithMarkup(position, text, markups=[]) { + let { section } = position; + if (!section.isMarkerable) { return; } + let marker = this.builder.createMarker(text, markups); + return this.insertMarkers(position, [marker]); + } + + /** + * Insert the text at the given position + * Inherits the markups already at that position, if any. + * + * @param {Position} position + * @param {String} text + * @return {Position} position at the end of the inserted text. + */ + insertText(position, text) { + let { section } = position; + if (!section.isMarkerable) { return; } + let markups = position.marker && position.marker.markups; + markups = markups || []; + return this.insertTextWithMarkup(position, text, markups); + } + + _replaceSection(section, newSections) { + let nextSection = section.next; + let collection = section.parent.sections; + + let nextNewSection = newSections[0]; + if (nextNewSection.isMarkupSection && section.isListItem) { + // put the new section after the ListSection (section.parent) + // instead of after the ListItem + collection = section.parent.parent.sections; + nextSection = section.parent.next; + } + + newSections.forEach(s => this.insertSectionBefore(collection, s, nextSection)); + this.removeSection(section); + } + + /** + * Given a markerRange (for example `editor.range`) mark all markers + * inside it as a given markup. The markup must be provided as a post + * abstract node. + * + * Usage: + * + * let range = editor.range; + * let strongMarkup = editor.builder.createMarkup('strong'); + * editor.run((postEditor) => { + * postEditor.addMarkupToRange(range, strongMarkup); + * }); + * // Will result some markers possibly being split, and the markup + * // being applied to all markers between the split. + * + * @param {Range} range + * @param {Markup} markup A markup post abstract node + * @public + */ + addMarkupToRange(range, markup) { + if (range.isCollapsed) { return; } + + let markers = this.splitMarkers(range); + if (markers.length) { + // We insert the new markup at a consistent index across the range. + // If we just push on the end of the list, it can end up in different positions + // of the markup stack. This results in unnecessary closing and re-opening of + // the markup each time it changes position. + // If we just push it at the beginning of the list, this causes unnecessary closing + // and re-opening of surrounding tags. + // So, we look for any tags open across the whole range, and push into the stack + // at the end of those. + // Prompted by https://github.com/bustle/mobiledoc-kit/issues/360 + + let markupsOpenAcrossRange = reduce(markers, function (soFar, marker) { + return commonItems(soFar, marker.markups); + }, markers[0].markups); + let indexToInsert = markupsOpenAcrossRange.length; + + markers.forEach(marker => { + marker.addMarkupAtIndex(markup, indexToInsert); + this._markDirty(marker); + }); + } + } + + /** + * Given a markerRange (for example `editor.range`) remove the given + * markup from all contained markers. + * + * Usage: + * ``` + * let { range } = editor; + * let markup = markerRange.headMarker.markups[0]; + * editor.run(postEditor => { + * postEditor.removeMarkupFromRange(range, markup); + * }); + * // Will result in some markers possibly being split, and the markup + * // being removed from all markers between the split. + * ``` + * @param {Range} range Object with offsets + * @param {Markup|Function} markupOrCallback A markup post abstract node or + * a function that returns true when passed a markup that should be removed + * @private + */ + removeMarkupFromRange(range, markupOrMarkupCallback) { + if (range.isCollapsed) { return; } + + this.splitMarkers(range).forEach(marker => { + marker.removeMarkup(markupOrMarkupCallback); + this._markDirty(marker); + }); + } + + /** + * Toggle the given markup in the given range (or at the position given). If the range/position + * has the markup, the markup will be removed. If nothing in the range/position + * has the markup, the markup will be added to everything in the range/position. + * + * Usage: + * ``` + * // Remove any 'strong' markup if it exists in the selection, otherwise + * // make it all 'strong' + * editor.run(postEditor => postEditor.toggleMarkup('strong')); + * + * // add/remove a link to 'bustle.com' to the selection + * editor.run(postEditor => { + * const linkMarkup = postEditor.builder.createMarkup('a', {href: 'http://bustle.com'}); + * postEditor.toggleMarkup(linkMarkup); + * }); + * ``` + * @param {Markup|String} markupOrString Either a markup object created using + * the builder (useful when adding a markup with attributes, like an 'a' markup), + * or, if a string, the tag name of the markup (e.g. 'strong', 'em') to toggle. + * @param {Range|Position} range in which to toggle. Defaults to current editor range. + * @public + */ + toggleMarkup(markupOrMarkupString, range=this._range) { + range = toRange(range); + const markup = typeof markupOrMarkupString === 'string' ? + this.builder.createMarkup(markupOrMarkupString) : + markupOrMarkupString; + + const hasMarkup = this.editor.detectMarkupInRange(range, markup.tagName); + // FIXME: This implies only a single markup in a range. This may not be + // true for links (which are not the same object instance like multiple + // strong tags would be). + if (hasMarkup) { + this.removeMarkupFromRange(range, hasMarkup); + } else { + this.addMarkupToRange(range, markup); + } + + this.setRange(range); + } + + /** + * Toggles the tagName of the active section or sections in the given range/position. + * If every section has the tag name, they will all be reset to default sections. + * Otherwise, every section will be changed to the requested type + * + * @param {String} sectionTagName A valid markup section or + * list section tag name (e.g. 'blockquote', 'h2', 'ul') + * @param {Range|Position} range The range over which to toggle. + * Defaults to the current editor range. + * @public + */ + toggleSection(sectionTagName, range=this._range) { + range = toRange(range); + + sectionTagName = normalizeTagName(sectionTagName); + let { post } = this.editor; + + let everySectionHasTagName = true; + post.walkMarkerableSections(range, section => { + if (!this._isSameSectionType(section, sectionTagName)) { + everySectionHasTagName = false; + } + }); + + let tagName = everySectionHasTagName ? 'p' : sectionTagName; + let sectionTransformations = []; + post.walkMarkerableSections(range, section => { + let changedSection = this.changeSectionTagName(section, tagName); + sectionTransformations.push({ + from: section, + to: changedSection + }); + }); + + let nextRange = this._determineNextRangeAfterToggleSection(range, sectionTransformations); + this.setRange(nextRange); + } + + _determineNextRangeAfterToggleSection(range, sectionTransformations) { + if (sectionTransformations.length) { + let changedHeadSection = detect(sectionTransformations, ({ from }) => { + return from === range.headSection; + }).to; + let changedTailSection = detect(sectionTransformations, ({ from }) => { + return from === range.tailSection; + }).to; + + if (changedHeadSection.isListSection || changedTailSection.isListSection) { + // We don't know to which ListItem's the original sections point at, so + // we don't have enough information to reconstruct the range when + // dealing with lists. + return sectionTransformations[0].to.headPosition().toRange(); + } else { + return Range.create( + changedHeadSection, + range.headSectionOffset, + changedTailSection, + range.tailSectionOffset, + range.direction + ); + } + } else { + return range; + } + } + + setAttribute(key, value, range=this._range) { + this._mutateAttribute(key, range, (section, attribute) => { + if (section.getAttribute(attribute) !== value) { + section.setAttribute(attribute, value); + return true; + } + }); + } + + removeAttribute(key, range=this._range) { + this._mutateAttribute(key, range, (section, attribute) => { + if (section.hasAttribute(attribute)) { + section.removeAttribute(attribute); + return true; + } + }); + } + + _mutateAttribute(key, range, cb) { + range = toRange(range); + let { post } = this.editor; + let attribute = `data-md-${key}`; + + post.walkMarkerableSections(range, section => { + if (section.isListItem) { + section = section.parent; + } + + if (cb(section, attribute) === true) { + this._markDirty(section); + } + }); + + this.setRange(range); + } + + _isSameSectionType(section, sectionTagName) { + return section.isListItem ? + section.parent.tagName === sectionTagName : + section.tagName === sectionTagName; + } + + /** + * @param {Markerable} section + * @private + */ + changeSectionTagName(section, newTagName) { + assert('Cannot pass non-markerable section to `changeSectionTagName`', + section.isMarkerable); + + if (isListSectionTagName(newTagName)) { + return this._changeSectionToListItem(section, newTagName); + } else if (section.isListItem) { + return this._changeSectionFromListItem(section, newTagName); + } else { + section.tagName = newTagName; + this._markDirty(section); + return section; + } + } + + /** + * Splits the item at the position given. + * If the position is at the start or end of the item, the pre- or post-item + * will contain a single empty ("") marker. + * @param {ListItem} item + * @param {Position} position + * @return {Array} the pre-item and post-item on either side of the split + * @private + */ + _splitListItem(item, position) { + let { section, offset } = position; + assert('Cannot split list item at position that does not include item', + item === section); + + item.splitMarkerAtOffset(offset); + let prevMarker = item.markerBeforeOffset(offset); + let preItem = this.builder.createListItem(), + postItem = this.builder.createListItem(); + + let currentItem = preItem; + item.markers.forEach(marker => { + currentItem.markers.append(marker.clone()); + if (marker === prevMarker) { + currentItem = postItem; + } + }); + this._replaceSection(item, [preItem, postItem]); + return [preItem, postItem]; + } + + /** + * Splits the list at the position given. + * @return {Array} pre-split list and post-split list, either of which could + * be blank (0-item list) if the position is at the start or end of the list. + * + * Note: Contiguous list sections will be joined in the before_complete queue + * of the postEditor. + * + * @private + */ + _splitListAtPosition(list, position) { + assert('Cannot split list at position not in list', + position.section.parent === list); + + let positionIsMiddle = !position.isHead() && !position.isTail(); + if (positionIsMiddle) { + let item = position.section; + let [pre,] = + this._splitListItem(item, position); + position = pre.tailPosition(); + } + + let preList = this.builder.createListSection(list.tagName); + let postList = this.builder.createListSection(list.tagName); + + let preItem = position.section; + let currentList = preList; + list.items.forEach(item => { + // If this item matches the start item and the position is at its start, + // it should be appended to the postList instead of the preList + if (item === preItem && position.isEqual(item.headPosition())) { + currentList = postList; + } + currentList.items.append(item.clone()); + // If we just appended the preItem, append the remaining items to the postList + if (item === preItem) { + currentList = postList; + } + }); + + this._replaceSection(list, [preList, postList]); + return [preList, postList]; + } + + /** + * @return Array of [prev, mid, next] lists. `prev` and `next` can + * be blank, depending on the position of `item`. `mid` will always + * be a 1-item list containing `item`. `prev` and `next` will be + * removed in the before_complete queue if they are blank + * (and still attached). + * + * @private + */ + _splitListAtItem(list, item) { + let next = list; + let prev = this.builder.createListSection(next.tagName, [], next.attributes); + let mid = this.builder.createListSection(next.tagName); + + let addToPrev = true; + // must turn the LinkedList into an array so that we can remove items + // as we iterate through it + let items = next.items.toArray(); + items.forEach(i => { + let listToAppend; + if (i === item) { + addToPrev = false; + listToAppend = mid; + } else if (addToPrev) { + listToAppend = prev; + } else { + return; // break after iterating prev and mid parts of the list + } + listToAppend.join(i); + this.removeSection(i); + }); + let found = !addToPrev; + assert('Cannot split list at item that is not present in the list', found); + + let collection = this.editor.post.sections; + this.insertSectionBefore(collection, mid, next); + this.insertSectionBefore(collection, prev, mid); + + // Remove possibly blank prev/next lists + this.addCallback(CALLBACK_QUEUES.BEFORE_COMPLETE, () => { + [prev, next].forEach(_list => { + let isAttached = !!_list.parent; + if (_list.isBlank && isAttached) { + this.removeSection(_list); + } + }); + }); + + return [prev, mid, next]; + } + + _changeSectionFromListItem(section, newTagName) { + assert('Must pass list item to `_changeSectionFromListItem`', + section.isListItem); + + let listSection = section.parent; + let markupSection = this.builder.createMarkupSection(newTagName); + markupSection.join(section); + + let [, mid,] = this._splitListAtItem(listSection, section); + this.replaceSection(mid, markupSection); + return markupSection; + } + + _changeSectionToListItem(section, newTagName) { + let isAlreadyCorrectListItem = section.isListItem && + section.parent.tagName === newTagName; + + if (isAlreadyCorrectListItem) { + return section; + } + + let listSection = this.builder.createListSection(newTagName); + listSection.join(section); + + let sectionToReplace; + if (section.isListItem) { + let [, mid,] = this._splitListAtItem(section.parent, section); + sectionToReplace = mid; + } else { + sectionToReplace = section; + } + this.replaceSection(sectionToReplace, listSection); + return listSection; + } + + /** + * Insert a given section before another one, updating the post abstract + * and the rendered UI. + * + * Usage: + * ``` + * let markerRange = editor.range; + * let sectionWithCursor = markerRange.headMarker.section; + * let section = editor.builder.createCardSection('my-image'); + * let collection = sectionWithCursor.parent.sections; + * editor.run((postEditor) => { + * postEditor.insertSectionBefore(collection, section, sectionWithCursor); + * }); + * ``` + * @param {LinkedList} collection The list of sections to insert into + * @param {Object} section The new section + * @param {Object} beforeSection Optional The section "before" is relative to, + * if falsy the new section will be appended to the collection + * @public + */ + insertSectionBefore(collection, section, beforeSection) { + collection.insertBefore(section, beforeSection); + this._markDirty(section.parent); + } + + /** + * Insert the given section after the current active section, or, if no + * section is active, at the end of the document. + * @param {Section} section + * @public + */ + insertSection(section) { + const activeSection = this.editor.activeSection; + const nextSection = activeSection && activeSection.next; + + const collection = this.editor.post.sections; + this.insertSectionBefore(collection, section, nextSection); + } + + /** + * Insert the given section at the end of the document. + * @param {Section} section + * @public + */ + insertSectionAtEnd(section) { + this.insertSectionBefore(this.editor.post.sections, section, null); + } + + /** + * Insert the `post` at the given position in the editor's post. + * @param {Position} position + * @param {Post} post + * @private + */ + insertPost(position, newPost) { + let post = this.editor.post; + let inserter = new Inserter(this, post); + let nextPosition = inserter.insert(position, newPost); + return nextPosition; + } + + /** + * Remove a given section from the post abstract and the rendered UI. + * + * Usage: + * ``` + * let { range } = editor; + * let sectionWithCursor = range.head.section; + * editor.run((postEditor) => { + * postEditor.removeSection(sectionWithCursor); + * }); + * ``` + * @param {Object} section The section to remove + * @public + */ + removeSection(section) { + let parent = section.parent; + this._scheduleForRemoval(section); + parent.sections.remove(section); + + if (parent.isListSection) { + this._scheduleListRemovalIfEmpty(parent); + } + } + + removeAllSections() { + this.editor.post.sections.toArray().forEach(section => { + this.removeSection(section); + }); + } + + migrateSectionsFromPost(post) { + post.sections.toArray().forEach(section => { + post.sections.remove(section); + this.insertSectionBefore(this.editor.post.sections, section, null); + }); + } + + _scheduleListRemovalIfEmpty(listSection) { + this.addCallback(CALLBACK_QUEUES.BEFORE_COMPLETE, () => { + // if the list is attached and blank after we do other rendering stuff, + // remove it + let isAttached = !!listSection.parent; + if (isAttached && listSection.isBlank) { + this.removeSection(listSection); + } + }); + } + + /** + * A method for adding work the deferred queue + * + * @param {Function} callback to run during completion + * @param {Boolean} [once=false] Whether to only schedule the callback once. + * @public + */ + schedule(callback, once=false) { + assert('Work can only be scheduled before a post edit has completed', + !this._didComplete); + if (once) { + this.addCallbackOnce(CALLBACK_QUEUES.COMPLETE, callback); + } else { + this.addCallback(CALLBACK_QUEUES.COMPLETE, callback); + } + } + + /** + * A method for adding work the deferred queue. The callback will only + * be added to the queue once, even if `scheduleOnce` is called multiple times. + * The function cannot be an anonymous function. + * + * @param {Function} callback to run during completion + * @public + */ + scheduleOnce(callback) { + this.schedule(callback, true); + } + + /** + * Add a rerender job to the queue + * + * @public + */ + scheduleRerender() { + this.scheduleOnce(this._rerender); + } + + /** + * Schedule a notification that the post has been changed. + * The notification will result in the editor firing its `postDidChange` + * hook after the postEditor completes its work (at the end of {@link Editor#run}). + * + * @public + */ + scheduleDidUpdate() { + this.scheduleOnce(this._postDidChange); + } + + scheduleAfterRender(callback, once=false) { + if (once) { + this.addCallbackOnce(CALLBACK_QUEUES.AFTER_COMPLETE, callback); + } else { + this.addCallback(CALLBACK_QUEUES.AFTER_COMPLETE, callback); + } + } + + /** + * Flush any work on the queue. {@link Editor#run} calls this method; it + * should not be called directly. + * + * @private + */ + complete() { + assert('Post editing can only be completed once', !this._didComplete); + + this.runCallbacks(CALLBACK_QUEUES.BEFORE_COMPLETE); + this._didComplete = true; + this.runCallbacks(CALLBACK_QUEUES.COMPLETE); + this.runCallbacks(CALLBACK_QUEUES.AFTER_COMPLETE); + } + + undoLastChange() { + this.editor._editHistory.stepBackward(this); + } + + redoLastChange() { + this.editor._editHistory.stepForward(this); + } + + cancelSnapshot() { + this._shouldCancelSnapshot = true; + } +} + +const placeholderImageSrc = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAOEAAADhCAMAAAAJbSJIAAAAMFBMVEXp7vG6vsHm6+63u77Hy868wMPe4+bO09bh5unr8fTR1djAxMfM0NPX3N/c4eTBxcjXRf5TAAACh0lEQVR4nO3b6ZKqMBSFUSQMYZL3f9tbBq/NEEDiqUqOfusn1ZXKbjcQlGQZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACC6RkbsGHuabChEtHmiGYfS3EQYM+Sxw/gMQvmcNnYaj6oTDHi73WPn2eqnj9B8zo3TJXcq5uNjXmVff86VwSR3JtryMa1BYqi7S1hJDCVpSigyLcGhJJEwzlCSNtPKrbVhVwsdCfOhH7uuaG3ARV9DwsaOzxt3N1yPqCHhvXytTUz92VDpmE/LLhZwl++R6Sds6sUa/PL6K/2E2fIhw1xdRKefsFolrPc+xNx/N0k/4fpBsdhL2HfeiN+TsDCms8dDpeRyS3P3QDl6Iqaf8L0rTf+80m6Lmn7Ct+4Wxf+/2RY1/YRv3PHz/u+fsCmqgoTnq7Z+8SGviqoh4dnKu1ieqauiakh4/PQ0r6ivqDoSHj0B97eNRVG1JNxV+L4bnxdVecJtRTdFVZ7QU9F1UXUn9FZ0VVRlCav5ob2KLouqKmFjy676u2HsVnRRVFUJq3J+8KCi86IqSthMvyl209Hjijqm3RsqAZ5pNfa5PJ2KelJRjQmr1/r7cfy0ouoSNvOfvbvhvKLaEr4qOin9kTQnrN7LpDZhE/Zmhp6Eq4p+YcKgiipKGFhRRQkDK6ooYfgLbiSMioQkJGF8P5XwHv4O+7AaKiXzaeXh1kMl5AffTUxiKEm/krD94BR8Gdxl1fceSlR58ZhXKbEpyD2amNiBtmrJLTMHL1LF8/rpXkSZXEmz8K8uvAFFNm6Iq0aBLUFOmeCuJ6exrcCmoLpN7kYx891bSAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgh/wDdr8peyRHLogAAAAASUVORK5CYII="; + +var ImageCard = { + name: 'image', + type: 'dom', + + render({payload}) { + let img = document.createElement('img'); + img.src = payload.src || placeholderImageSrc; + return img; + } +}; + +function visit(visitor, node, opcodes) { + const method = node.type; + assert(`Cannot visit unknown type ${method}`, !!visitor[method]); + visitor[method](node, opcodes); +} + +function compile(compiler, opcodes) { + for (var i=0, l=opcodes.length; i { + visit(visitor, node, opcodes); + }); +} + +const MOBILEDOC_VERSION = '0.2.0'; +const MOBILEDOC_MARKUP_SECTION_TYPE = 1; +const MOBILEDOC_IMAGE_SECTION_TYPE = 2; +const MOBILEDOC_LIST_SECTION_TYPE = 3; +const MOBILEDOC_CARD_SECTION_TYPE = 10; + +const 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); + }, + [LIST_SECTION_TYPE](node, opcodes) { + opcodes.push(['openListSection', node.tagName]); + visitArray(visitor, node.items, opcodes); + }, + [LIST_ITEM_TYPE](node, opcodes) { + opcodes.push(['openListItem']); + visitArray(visitor, node.markers, opcodes); + }, + [IMAGE_SECTION_TYPE](node, opcodes) { + opcodes.push(['openImageSection', node.src]); + }, + [CARD_TYPE](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, objectToSortedKVArray(node.attributes)]); + } +}; + +const postOpcodeCompiler = { + openMarker(closeCount, value) { + this.markupMarkerIds = []; + this.markers.push([ + this.markupMarkerIds, + closeCount, + value || '' + ]); + }, + openMarkupSection(tagName) { + this.markers = []; + this.sections.push([MOBILEDOC_MARKUP_SECTION_TYPE, tagName, this.markers]); + }, + openListSection(tagName) { + this.items = []; + this.sections.push([MOBILEDOC_LIST_SECTION_TYPE, tagName, this.items]); + }, + openListItem() { + this.markers = []; + this.items.push(this.markers); + }, + openImageSection(url) { + this.sections.push([MOBILEDOC_IMAGE_SECTION_TYPE, url]); + }, + openCardSection(name, payload) { + this.sections.push([MOBILEDOC_CARD_SECTION_TYPE, name, payload]); + }, + openPost() { + this.markerTypes = []; + this.sections = []; + this.result = { + version: MOBILEDOC_VERSION, + sections: [this.markerTypes, this.sections] + }; + }, + openMarkup(tagName, attributes) { + const index = this._findOrAddMarkerTypeIndex(tagName, attributes); + this.markupMarkerIds.push(index); + }, + _findOrAddMarkerTypeIndex(tagName, attributesArray) { + if (!this._markerTypeCache) { this._markerTypeCache = {}; } + const key = `${tagName}-${attributesArray.join('-')}`; + + let index = this._markerTypeCache[key]; + if (index === undefined) { + let markerType = [tagName]; + if (attributesArray.length) { markerType.push(attributesArray); } + this.markerTypes.push(markerType); + + index = this.markerTypes.length - 1; + this._markerTypeCache[key] = index; + } + + return index; + } +}; + +/** + * Render from post -> mobiledoc + */ +var MobiledocRenderer_0_2 = { + /** + * @param {Post} + * @return {Mobiledoc} + */ + render(post) { + let opcodes = []; + visit(visitor, post, opcodes); + let compiler = Object.create(postOpcodeCompiler); + compile(compiler, opcodes); + return compiler.result; + } +}; + +/* + * Parses from mobiledoc -> post + */ +class MobiledocParser { + constructor(builder) { + this.builder = builder; + } + + /** + * @param {Mobiledoc} + * @return {Post} + */ + parse({sections: sectionData}) { + try { + const markerTypes = sectionData[0]; + const sections = sectionData[1]; + + const post = this.builder.createPost(); + + this.markups = []; + this.markerTypes = this.parseMarkerTypes(markerTypes); + this.parseSections(sections, post); + + return post; + } catch (e) { + assert(`Unable to parse mobiledoc: ${e.message}`, false); + } + } + + parseMarkerTypes(markerTypes) { + return markerTypes.map((markerType) => this.parseMarkerType(markerType)); + } + + parseMarkerType([tagName, attributesArray]) { + const attributesObject = kvArrayToObject(attributesArray || []); + return this.builder.createMarkup(tagName, attributesObject); + } + + parseSections(sections, post) { + sections.forEach((section) => this.parseSection(section, post)); + } + + parseSection(section, post) { + let [type] = section; + switch(type) { + case MOBILEDOC_MARKUP_SECTION_TYPE: + this.parseMarkupSection(section, post); + break; + case MOBILEDOC_IMAGE_SECTION_TYPE: + this.parseImageSection(section, post); + break; + case MOBILEDOC_CARD_SECTION_TYPE: + this.parseCardSection(section, post); + break; + case MOBILEDOC_LIST_SECTION_TYPE: + this.parseListSection(section, post); + break; + default: + assert(`Unexpected section type ${type}`, false); + } + } + + parseCardSection([, name, payload], post) { + const section = this.builder.createCardSection(name, payload); + post.sections.append(section); + } + + parseImageSection([, src], post) { + const section = this.builder.createImageSection(src); + post.sections.append(section); + } + + parseMarkupSection([, tagName, markers], post) { + const section = this.builder.createMarkupSection(tagName.toLowerCase() === 'pull-quote' ? 'aside' : tagName); + post.sections.append(section); + this.parseMarkers(markers, section); + // Strip blank markers after they have been created. This ensures any + // markup they include has been correctly populated. + filter(section.markers, m => m.isBlank).forEach(m => { + section.markers.remove(m); + }); + } + + parseListSection([, tagName, items], post) { + const section = this.builder.createListSection(tagName); + post.sections.append(section); + this.parseListItems(items, section); + } + + parseListItems(items, section) { + items.forEach(i => this.parseListItem(i, section)); + } + + parseListItem(markers, section) { + const item = this.builder.createListItem(); + this.parseMarkers(markers, item); + section.items.append(item); + } + + parseMarkers(markers, parent) { + markers.forEach(m => this.parseMarker(m, parent)); + } + + parseMarker([markerTypeIndexes, closeCount, value], parent) { + markerTypeIndexes.forEach(index => { + this.markups.push(this.markerTypes[index]); + }); + const marker = this.builder.createMarker(value, this.markups.slice()); + parent.markers.append(marker); + this.markups = this.markups.slice(0, this.markups.length-closeCount); + } +} + +const MOBILEDOC_VERSION$1 = '0.3.0'; +const MOBILEDOC_MARKUP_SECTION_TYPE$1 = 1; +const MOBILEDOC_IMAGE_SECTION_TYPE$1 = 2; +const MOBILEDOC_LIST_SECTION_TYPE$1 = 3; +const MOBILEDOC_CARD_SECTION_TYPE$1 = 10; + +const MOBILEDOC_MARKUP_MARKER_TYPE = 0; +const MOBILEDOC_ATOM_MARKER_TYPE = 1; + +const visitor$1 = { + [POST_TYPE](node, opcodes) { + opcodes.push(['openPost']); + visitArray(visitor$1, node.sections, opcodes); + }, + [MARKUP_SECTION_TYPE](node, opcodes) { + opcodes.push(['openMarkupSection', node.tagName]); + visitArray(visitor$1, node.markers, opcodes); + }, + [LIST_SECTION_TYPE](node, opcodes) { + opcodes.push(['openListSection', node.tagName]); + visitArray(visitor$1, node.items, opcodes); + }, + [LIST_ITEM_TYPE](node, opcodes) { + opcodes.push(['openListItem']); + visitArray(visitor$1, node.markers, opcodes); + }, + [IMAGE_SECTION_TYPE](node, opcodes) { + opcodes.push(['openImageSection', node.src]); + }, + [CARD_TYPE](node, opcodes) { + opcodes.push(['openCardSection', node.name, node.payload]); + }, + [MARKER_TYPE](node, opcodes) { + opcodes.push(['openMarker', node.closedMarkups.length, node.value]); + visitArray(visitor$1, node.openedMarkups, opcodes); + }, + [MARKUP_TYPE](node, opcodes) { + opcodes.push(['openMarkup', node.tagName, objectToSortedKVArray(node.attributes)]); + }, + [ATOM_TYPE](node, opcodes) { + opcodes.push(['openAtom', node.closedMarkups.length, node.name, node.value, node.payload]); + visitArray(visitor$1, node.openedMarkups, opcodes); + } +}; + +const postOpcodeCompiler$1 = { + openMarker(closeCount, value) { + this.markupMarkerIds = []; + this.markers.push([ + MOBILEDOC_MARKUP_MARKER_TYPE, + this.markupMarkerIds, + closeCount, + value || '' + ]); + }, + openMarkupSection(tagName) { + this.markers = []; + this.sections.push([MOBILEDOC_MARKUP_SECTION_TYPE$1, tagName, this.markers]); + }, + openListSection(tagName) { + this.items = []; + this.sections.push([MOBILEDOC_LIST_SECTION_TYPE$1, tagName, this.items]); + }, + openListItem() { + this.markers = []; + this.items.push(this.markers); + }, + openImageSection(url) { + this.sections.push([MOBILEDOC_IMAGE_SECTION_TYPE$1, url]); + }, + openCardSection(name, payload) { + const index = this._addCardTypeIndex(name, payload); + this.sections.push([MOBILEDOC_CARD_SECTION_TYPE$1, index]); + }, + openAtom(closeCount, name, value, payload) { + const index = this._addAtomTypeIndex(name, value, payload); + this.markupMarkerIds = []; + this.markers.push([ + MOBILEDOC_ATOM_MARKER_TYPE, + this.markupMarkerIds, + closeCount, + index + ]); + }, + openPost() { + this.atomTypes = []; + this.cardTypes = []; + this.markerTypes = []; + this.sections = []; + this.result = { + version: MOBILEDOC_VERSION$1, + atoms: this.atomTypes, + cards: this.cardTypes, + markups: this.markerTypes, + sections: this.sections + }; + }, + openMarkup(tagName, attributes) { + const index = this._findOrAddMarkerTypeIndex(tagName, attributes); + this.markupMarkerIds.push(index); + }, + _addCardTypeIndex(cardName, payload) { + let cardType = [cardName, payload]; + this.cardTypes.push(cardType); + return this.cardTypes.length - 1; + }, + _addAtomTypeIndex(atomName, atomValue, payload) { + let atomType = [atomName, atomValue, payload]; + this.atomTypes.push(atomType); + return this.atomTypes.length - 1; + }, + _findOrAddMarkerTypeIndex(tagName, attributesArray) { + if (!this._markerTypeCache) { this._markerTypeCache = {}; } + const key = `${tagName}-${attributesArray.join('-')}`; + + let index = this._markerTypeCache[key]; + if (index === undefined) { + let markerType = [tagName]; + if (attributesArray.length) { markerType.push(attributesArray); } + this.markerTypes.push(markerType); + + index = this.markerTypes.length - 1; + this._markerTypeCache[key] = index; + } + + return index; + } +}; + +/** + * Render from post -> mobiledoc + */ +var MobiledocRenderer_0_3 = { + /** + * @param {Post} + * @return {Mobiledoc} + */ + render(post) { + let opcodes = []; + visit(visitor$1, post, opcodes); + let compiler = Object.create(postOpcodeCompiler$1); + compile(compiler, opcodes); + return compiler.result; + } +}; + +/* + * Parses from mobiledoc -> post + */ +class MobiledocParser$1 { + constructor(builder) { + this.builder = builder; + } + + /** + * @param {Mobiledoc} + * @return {Post} + */ + parse({ sections, markups: markerTypes, cards: cardTypes, atoms: atomTypes }) { + try { + const post = this.builder.createPost(); + + this.markups = []; + this.markerTypes = this.parseMarkerTypes(markerTypes); + this.cardTypes = this.parseCardTypes(cardTypes); + this.atomTypes = this.parseAtomTypes(atomTypes); + this.parseSections(sections, post); + + return post; + } catch (e) { + assert(`Unable to parse mobiledoc: ${e.message}`, false); + } + } + + parseMarkerTypes(markerTypes) { + return markerTypes.map((markerType) => this.parseMarkerType(markerType)); + } + + parseMarkerType([tagName, attributesArray]) { + const attributesObject = kvArrayToObject(attributesArray || []); + return this.builder.createMarkup(tagName, attributesObject); + } + + parseCardTypes(cardTypes) { + return cardTypes.map((cardType) => this.parseCardType(cardType)); + } + + parseCardType([cardName, cardPayload]) { + return [cardName, cardPayload]; + } + + parseAtomTypes(atomTypes) { + return atomTypes.map((atomType) => this.parseAtomType(atomType)); + } + + parseAtomType([atomName, atomValue, atomPayload]) { + return [atomName, atomValue, atomPayload]; + } + + parseSections(sections, post) { + sections.forEach((section) => this.parseSection(section, post)); + } + + parseSection(section, post) { + let [type] = section; + switch(type) { + case MOBILEDOC_MARKUP_SECTION_TYPE$1: + this.parseMarkupSection(section, post); + break; + case MOBILEDOC_IMAGE_SECTION_TYPE$1: + this.parseImageSection(section, post); + break; + case MOBILEDOC_CARD_SECTION_TYPE$1: + this.parseCardSection(section, post); + break; + case MOBILEDOC_LIST_SECTION_TYPE$1: + this.parseListSection(section, post); + break; + default: + assert('Unexpected section type ${type}', false); + } + } + + getAtomTypeFromIndex(index) { + const atomType = this.atomTypes[index]; + assert(`No atom definition found at index ${index}`, !!atomType); + return atomType; + } + + getCardTypeFromIndex(index) { + const cardType = this.cardTypes[index]; + assert(`No card definition found at index ${index}`, !!cardType); + return cardType; + } + + parseCardSection([, cardIndex], post) { + const [name, payload] = this.getCardTypeFromIndex(cardIndex); + const section = this.builder.createCardSection(name, payload); + post.sections.append(section); + } + + parseImageSection([, src], post) { + const section = this.builder.createImageSection(src); + post.sections.append(section); + } + + parseMarkupSection([, tagName, markers], post) { + const section = this.builder.createMarkupSection(tagName.toLowerCase() === 'pull-quote' ? 'aside' : tagName); + post.sections.append(section); + this.parseMarkers(markers, section); + // Strip blank markers after they have been created. This ensures any + // markup they include has been correctly populated. + filter(section.markers, m => m.isBlank).forEach(m => { + section.markers.remove(m); + }); + } + + parseListSection([, tagName, items], post) { + const section = this.builder.createListSection(tagName); + post.sections.append(section); + this.parseListItems(items, section); + } + + parseListItems(items, section) { + items.forEach(i => this.parseListItem(i, section)); + } + + parseListItem(markers, section) { + const item = this.builder.createListItem(); + this.parseMarkers(markers, item); + section.items.append(item); + } + + parseMarkers(markers, parent) { + markers.forEach(m => this.parseMarker(m, parent)); + } + + parseMarker([type, markerTypeIndexes, closeCount, value], parent) { + markerTypeIndexes.forEach(index => { + this.markups.push(this.markerTypes[index]); + }); + + const marker = this.buildMarkerType(type, value); + parent.markers.append(marker); + + this.markups = this.markups.slice(0, this.markups.length-closeCount); + } + + buildMarkerType(type, value) { + switch (type) { + case MOBILEDOC_MARKUP_MARKER_TYPE: + return this.builder.createMarker(value, this.markups.slice()); + case MOBILEDOC_ATOM_MARKER_TYPE: { + const [atomName, atomValue, atomPayload] = this.getAtomTypeFromIndex(value); + return this.builder.createAtom(atomName, atomValue, atomPayload, this.markups.slice()); + } + default: + assert(`Unexpected marker type ${type}`, false); + } + } +} + +const MOBILEDOC_VERSION$2 = '0.3.1'; +const MOBILEDOC_MARKUP_SECTION_TYPE$2 = 1; +const MOBILEDOC_IMAGE_SECTION_TYPE$2 = 2; +const MOBILEDOC_LIST_SECTION_TYPE$2 = 3; +const MOBILEDOC_CARD_SECTION_TYPE$2 = 10; + +const MOBILEDOC_MARKUP_MARKER_TYPE$1 = 0; +const MOBILEDOC_ATOM_MARKER_TYPE$1 = 1; + +const visitor$2 = { + [POST_TYPE](node, opcodes) { + opcodes.push(['openPost']); + visitArray(visitor$2, node.sections, opcodes); + }, + [MARKUP_SECTION_TYPE](node, opcodes) { + opcodes.push(['openMarkupSection', node.tagName]); + visitArray(visitor$2, node.markers, opcodes); + }, + [LIST_SECTION_TYPE](node, opcodes) { + opcodes.push(['openListSection', node.tagName]); + visitArray(visitor$2, node.items, opcodes); + }, + [LIST_ITEM_TYPE](node, opcodes) { + opcodes.push(['openListItem']); + visitArray(visitor$2, node.markers, opcodes); + }, + [IMAGE_SECTION_TYPE](node, opcodes) { + opcodes.push(['openImageSection', node.src]); + }, + [CARD_TYPE](node, opcodes) { + opcodes.push(['openCardSection', node.name, node.payload]); + }, + [MARKER_TYPE](node, opcodes) { + opcodes.push(['openMarker', node.closedMarkups.length, node.value]); + visitArray(visitor$2, node.openedMarkups, opcodes); + }, + [MARKUP_TYPE](node, opcodes) { + opcodes.push(['openMarkup', node.tagName, objectToSortedKVArray(node.attributes)]); + }, + [ATOM_TYPE](node, opcodes) { + opcodes.push(['openAtom', node.closedMarkups.length, node.name, node.value, node.payload]); + visitArray(visitor$2, node.openedMarkups, opcodes); + } +}; + +const postOpcodeCompiler$2 = { + openMarker(closeCount, value) { + this.markupMarkerIds = []; + this.markers.push([ + MOBILEDOC_MARKUP_MARKER_TYPE$1, + this.markupMarkerIds, + closeCount, + value || '' + ]); + }, + openMarkupSection(tagName) { + this.markers = []; + this.sections.push([MOBILEDOC_MARKUP_SECTION_TYPE$2, tagName, this.markers]); + }, + openListSection(tagName) { + this.items = []; + this.sections.push([MOBILEDOC_LIST_SECTION_TYPE$2, tagName, this.items]); + }, + openListItem() { + this.markers = []; + this.items.push(this.markers); + }, + openImageSection(url) { + this.sections.push([MOBILEDOC_IMAGE_SECTION_TYPE$2, url]); + }, + openCardSection(name, payload) { + const index = this._addCardTypeIndex(name, payload); + this.sections.push([MOBILEDOC_CARD_SECTION_TYPE$2, index]); + }, + openAtom(closeCount, name, value, payload) { + const index = this._addAtomTypeIndex(name, value, payload); + this.markupMarkerIds = []; + this.markers.push([ + MOBILEDOC_ATOM_MARKER_TYPE$1, + this.markupMarkerIds, + closeCount, + index + ]); + }, + openPost() { + this.atomTypes = []; + this.cardTypes = []; + this.markerTypes = []; + this.sections = []; + this.result = { + version: MOBILEDOC_VERSION$2, + atoms: this.atomTypes, + cards: this.cardTypes, + markups: this.markerTypes, + sections: this.sections + }; + }, + openMarkup(tagName, attributes) { + const index = this._findOrAddMarkerTypeIndex(tagName, attributes); + this.markupMarkerIds.push(index); + }, + _addCardTypeIndex(cardName, payload) { + let cardType = [cardName, payload]; + this.cardTypes.push(cardType); + return this.cardTypes.length - 1; + }, + _addAtomTypeIndex(atomName, atomValue, payload) { + let atomType = [atomName, atomValue, payload]; + this.atomTypes.push(atomType); + return this.atomTypes.length - 1; + }, + _findOrAddMarkerTypeIndex(tagName, attributesArray) { + if (!this._markerTypeCache) { this._markerTypeCache = {}; } + const key = `${tagName}-${attributesArray.join('-')}`; + + let index = this._markerTypeCache[key]; + if (index === undefined) { + let markerType = [tagName]; + if (attributesArray.length) { markerType.push(attributesArray); } + this.markerTypes.push(markerType); + + index = this.markerTypes.length - 1; + this._markerTypeCache[key] = index; + } + + return index; + } +}; + +/** + * Render from post -> mobiledoc + */ +var MobiledocRenderer_0_3_1 = { + /** + * @param {Post} + * @return {Mobiledoc} + */ + render(post) { + let opcodes = []; + visit(visitor$2, post, opcodes); + let compiler = Object.create(postOpcodeCompiler$2); + compile(compiler, opcodes); + return compiler.result; + } +}; + +/* + * Parses from mobiledoc -> post + */ +class MobiledocParser$2 { + constructor(builder) { + this.builder = builder; + } + + /** + * @param {Mobiledoc} + * @return {Post} + */ + parse({ sections, markups: markerTypes, cards: cardTypes, atoms: atomTypes }) { + try { + const post = this.builder.createPost(); + + this.markups = []; + this.markerTypes = this.parseMarkerTypes(markerTypes); + this.cardTypes = this.parseCardTypes(cardTypes); + this.atomTypes = this.parseAtomTypes(atomTypes); + this.parseSections(sections, post); + + return post; + } catch (e) { + assert(`Unable to parse mobiledoc: ${e.message}`, false); + } + } + + parseMarkerTypes(markerTypes) { + return markerTypes.map((markerType) => this.parseMarkerType(markerType)); + } + + parseMarkerType([tagName, attributesArray]) { + const attributesObject = kvArrayToObject(attributesArray || []); + return this.builder.createMarkup(tagName, attributesObject); + } + + parseCardTypes(cardTypes) { + return cardTypes.map((cardType) => this.parseCardType(cardType)); + } + + parseCardType([cardName, cardPayload]) { + return [cardName, cardPayload]; + } + + parseAtomTypes(atomTypes) { + return atomTypes.map((atomType) => this.parseAtomType(atomType)); + } + + parseAtomType([atomName, atomValue, atomPayload]) { + return [atomName, atomValue, atomPayload]; + } + + parseSections(sections, post) { + sections.forEach((section) => this.parseSection(section, post)); + } + + parseSection(section, post) { + let [type] = section; + switch(type) { + case MOBILEDOC_MARKUP_SECTION_TYPE$2: + this.parseMarkupSection(section, post); + break; + case MOBILEDOC_IMAGE_SECTION_TYPE$2: + this.parseImageSection(section, post); + break; + case MOBILEDOC_CARD_SECTION_TYPE$2: + this.parseCardSection(section, post); + break; + case MOBILEDOC_LIST_SECTION_TYPE$2: + this.parseListSection(section, post); + break; + default: + assert('Unexpected section type ${type}', false); + } + } + + getAtomTypeFromIndex(index) { + const atomType = this.atomTypes[index]; + assert(`No atom definition found at index ${index}`, !!atomType); + return atomType; + } + + getCardTypeFromIndex(index) { + const cardType = this.cardTypes[index]; + assert(`No card definition found at index ${index}`, !!cardType); + return cardType; + } + + parseCardSection([, cardIndex], post) { + const [name, payload] = this.getCardTypeFromIndex(cardIndex); + const section = this.builder.createCardSection(name, payload); + post.sections.append(section); + } + + parseImageSection([, src], post) { + const section = this.builder.createImageSection(src); + post.sections.append(section); + } + + parseMarkupSection([, tagName, markers], post) { + const section = this.builder.createMarkupSection(tagName); + post.sections.append(section); + this.parseMarkers(markers, section); + // Strip blank markers after they have been created. This ensures any + // markup they include has been correctly populated. + filter(section.markers, m => m.isBlank).forEach(m => { + section.markers.remove(m); + }); + } + + parseListSection([, tagName, items], post) { + const section = this.builder.createListSection(tagName); + post.sections.append(section); + this.parseListItems(items, section); + } + + parseListItems(items, section) { + items.forEach(i => this.parseListItem(i, section)); + } + + parseListItem(markers, section) { + const item = this.builder.createListItem(); + this.parseMarkers(markers, item); + section.items.append(item); + } + + parseMarkers(markers, parent) { + markers.forEach(m => this.parseMarker(m, parent)); + } + + parseMarker([type, markerTypeIndexes, closeCount, value], parent) { + markerTypeIndexes.forEach(index => { + this.markups.push(this.markerTypes[index]); + }); + + const marker = this.buildMarkerType(type, value); + parent.markers.append(marker); + + this.markups = this.markups.slice(0, this.markups.length-closeCount); + } + + buildMarkerType(type, value) { + switch (type) { + case MOBILEDOC_MARKUP_MARKER_TYPE$1: + return this.builder.createMarker(value, this.markups.slice()); + case MOBILEDOC_ATOM_MARKER_TYPE$1: { + const [atomName, atomValue, atomPayload] = this.getAtomTypeFromIndex(value); + return this.builder.createAtom(atomName, atomValue, atomPayload, this.markups.slice()); + } + default: + assert(`Unexpected marker type ${type}`, false); + } + } +} + +const MOBILEDOC_VERSION$3 = '0.3.2'; +const MOBILEDOC_MARKUP_SECTION_TYPE$3 = 1; +const MOBILEDOC_IMAGE_SECTION_TYPE$3 = 2; +const MOBILEDOC_LIST_SECTION_TYPE$3 = 3; +const MOBILEDOC_CARD_SECTION_TYPE$3 = 10; + +const MOBILEDOC_MARKUP_MARKER_TYPE$2 = 0; +const MOBILEDOC_ATOM_MARKER_TYPE$2 = 1; + +const visitor$3 = { + [POST_TYPE](node, opcodes) { + opcodes.push(['openPost']); + visitArray(visitor$3, node.sections, opcodes); + }, + [MARKUP_SECTION_TYPE](node, opcodes) { + opcodes.push(['openMarkupSection', node.tagName, objectToSortedKVArray(node.attributes)]); + visitArray(visitor$3, node.markers, opcodes); + }, + [LIST_SECTION_TYPE](node, opcodes) { + opcodes.push(['openListSection', node.tagName, objectToSortedKVArray(node.attributes)]); + visitArray(visitor$3, node.items, opcodes); + }, + [LIST_ITEM_TYPE](node, opcodes) { + opcodes.push(['openListItem']); + visitArray(visitor$3, node.markers, opcodes); + }, + [IMAGE_SECTION_TYPE](node, opcodes) { + opcodes.push(['openImageSection', node.src]); + }, + [CARD_TYPE](node, opcodes) { + opcodes.push(['openCardSection', node.name, node.payload]); + }, + [MARKER_TYPE](node, opcodes) { + opcodes.push(['openMarker', node.closedMarkups.length, node.value]); + visitArray(visitor$3, node.openedMarkups, opcodes); + }, + [MARKUP_TYPE](node, opcodes) { + opcodes.push(['openMarkup', node.tagName, objectToSortedKVArray(node.attributes)]); + }, + [ATOM_TYPE](node, opcodes) { + opcodes.push(['openAtom', node.closedMarkups.length, node.name, node.value, node.payload]); + visitArray(visitor$3, node.openedMarkups, opcodes); + } +}; + +const postOpcodeCompiler$3 = { + openMarker(closeCount, value) { + this.markupMarkerIds = []; + this.markers.push([ + MOBILEDOC_MARKUP_MARKER_TYPE$2, + this.markupMarkerIds, + closeCount, + value || '' + ]); + }, + openMarkupSection(tagName, attributes) { + this.markers = []; + if (attributes && attributes.length !== 0) { + this.sections.push([MOBILEDOC_MARKUP_SECTION_TYPE$3, tagName, this.markers, attributes]); + } else { + this.sections.push([MOBILEDOC_MARKUP_SECTION_TYPE$3, tagName, this.markers]); + } + }, + openListSection(tagName, attributes) { + this.items = []; + if (attributes && attributes.length !== 0) { + this.sections.push([MOBILEDOC_LIST_SECTION_TYPE$3, tagName, this.items, attributes]); + } else { + this.sections.push([MOBILEDOC_LIST_SECTION_TYPE$3, tagName, this.items]); + } + }, + openListItem() { + this.markers = []; + this.items.push(this.markers); + }, + openImageSection(url) { + this.sections.push([MOBILEDOC_IMAGE_SECTION_TYPE$3, url]); + }, + openCardSection(name, payload) { + const index = this._addCardTypeIndex(name, payload); + this.sections.push([MOBILEDOC_CARD_SECTION_TYPE$3, index]); + }, + openAtom(closeCount, name, value, payload) { + const index = this._addAtomTypeIndex(name, value, payload); + this.markupMarkerIds = []; + this.markers.push([ + MOBILEDOC_ATOM_MARKER_TYPE$2, + this.markupMarkerIds, + closeCount, + index + ]); + }, + openPost() { + this.atomTypes = []; + this.cardTypes = []; + this.markerTypes = []; + this.sections = []; + this.result = { + version: MOBILEDOC_VERSION$3, + atoms: this.atomTypes, + cards: this.cardTypes, + markups: this.markerTypes, + sections: this.sections + }; + }, + openMarkup(tagName, attributes) { + const index = this._findOrAddMarkerTypeIndex(tagName, attributes); + this.markupMarkerIds.push(index); + }, + _addCardTypeIndex(cardName, payload) { + let cardType = [cardName, payload]; + this.cardTypes.push(cardType); + return this.cardTypes.length - 1; + }, + _addAtomTypeIndex(atomName, atomValue, payload) { + let atomType = [atomName, atomValue, payload]; + this.atomTypes.push(atomType); + return this.atomTypes.length - 1; + }, + _findOrAddMarkerTypeIndex(tagName, attributesArray) { + if (!this._markerTypeCache) { this._markerTypeCache = {}; } + const key = `${tagName}-${attributesArray.join('-')}`; + + let index = this._markerTypeCache[key]; + if (index === undefined) { + let markerType = [tagName]; + if (attributesArray.length) { markerType.push(attributesArray); } + this.markerTypes.push(markerType); + + index = this.markerTypes.length - 1; + this._markerTypeCache[key] = index; + } + + return index; + } +}; + +/** + * Render from post -> mobiledoc + */ +var MobiledocRenderer_0_3_2 = { + /** + * @param {Post} + * @return {Mobiledoc} + */ + render(post) { + let opcodes = []; + visit(visitor$3, post, opcodes); + let compiler = Object.create(postOpcodeCompiler$3); + compile(compiler, opcodes); + return compiler.result; + } +}; + +function entries(obj) { + const ownProps = Object.keys(obj); + let i = ownProps.length; + const resArray = new Array(i); + + while (i--) { + resArray[i] = [ownProps[i], obj[ownProps[i]]]; + } + + return resArray; +} + +/* + * Parses from mobiledoc -> post + */ +class MobiledocParser$3 { + constructor(builder) { + this.builder = builder; + } + + /** + * @param {Mobiledoc} + * @return {Post} + */ + parse({ sections, markups: markerTypes, cards: cardTypes, atoms: atomTypes }) { + try { + const post = this.builder.createPost(); + + this.markups = []; + this.markerTypes = this.parseMarkerTypes(markerTypes); + this.cardTypes = this.parseCardTypes(cardTypes); + this.atomTypes = this.parseAtomTypes(atomTypes); + this.parseSections(sections, post); + + return post; + } catch (e) { + assert(`Unable to parse mobiledoc: ${e.message}`, false); + } + } + + parseMarkerTypes(markerTypes) { + return markerTypes.map((markerType) => this.parseMarkerType(markerType)); + } + + parseMarkerType([tagName, attributesArray]) { + const attributesObject = kvArrayToObject(attributesArray || []); + return this.builder.createMarkup(tagName, attributesObject); + } + + parseCardTypes(cardTypes) { + return cardTypes.map((cardType) => this.parseCardType(cardType)); + } + + parseCardType([cardName, cardPayload]) { + return [cardName, cardPayload]; + } + + parseAtomTypes(atomTypes) { + return atomTypes.map((atomType) => this.parseAtomType(atomType)); + } + + parseAtomType([atomName, atomValue, atomPayload]) { + return [atomName, atomValue, atomPayload]; + } + + parseSections(sections, post) { + sections.forEach((section) => this.parseSection(section, post)); + } + + parseSection(section, post) { + let [type] = section; + switch(type) { + case MOBILEDOC_MARKUP_SECTION_TYPE$3: + this.parseMarkupSection(section, post); + break; + case MOBILEDOC_IMAGE_SECTION_TYPE$3: + this.parseImageSection(section, post); + break; + case MOBILEDOC_CARD_SECTION_TYPE$3: + this.parseCardSection(section, post); + break; + case MOBILEDOC_LIST_SECTION_TYPE$3: + this.parseListSection(section, post); + break; + default: + assert('Unexpected section type ${type}', false); + } + } + + getAtomTypeFromIndex(index) { + const atomType = this.atomTypes[index]; + assert(`No atom definition found at index ${index}`, !!atomType); + return atomType; + } + + getCardTypeFromIndex(index) { + const cardType = this.cardTypes[index]; + assert(`No card definition found at index ${index}`, !!cardType); + return cardType; + } + + parseCardSection([, cardIndex], post) { + const [name, payload] = this.getCardTypeFromIndex(cardIndex); + const section = this.builder.createCardSection(name, payload); + post.sections.append(section); + } + + parseImageSection([, src], post) { + const section = this.builder.createImageSection(src); + post.sections.append(section); + } + + parseMarkupSection([, tagName, markers, attributesArray], post) { + const section = this.builder.createMarkupSection(tagName); + post.sections.append(section); + if (attributesArray) { + entries(kvArrayToObject(attributesArray)).forEach(([key, value]) => { + section.setAttribute(key, value); + }); + } + this.parseMarkers(markers, section); + // Strip blank markers after they have been created. This ensures any + // markup they include has been correctly populated. + filter(section.markers, m => m.isBlank).forEach(m => { + section.markers.remove(m); + }); + } + + parseListSection([, tagName, items, attributesArray], post) { + const section = this.builder.createListSection(tagName); + post.sections.append(section); + if (attributesArray) { + entries(kvArrayToObject(attributesArray)).forEach(([key, value]) => { + section.setAttribute(key, value); + }); + } + this.parseListItems(items, section); + } + + parseListItems(items, section) { + items.forEach(i => this.parseListItem(i, section)); + } + + parseListItem(markers, section) { + const item = this.builder.createListItem(); + this.parseMarkers(markers, item); + section.items.append(item); + } + + parseMarkers(markers, parent) { + markers.forEach(m => this.parseMarker(m, parent)); + } + + parseMarker([type, markerTypeIndexes, closeCount, value], parent) { + markerTypeIndexes.forEach(index => { + this.markups.push(this.markerTypes[index]); + }); + + const marker = this.buildMarkerType(type, value); + parent.markers.append(marker); + + this.markups = this.markups.slice(0, this.markups.length-closeCount); + } + + buildMarkerType(type, value) { + switch (type) { + case MOBILEDOC_MARKUP_MARKER_TYPE$2: + return this.builder.createMarker(value, this.markups.slice()); + case MOBILEDOC_ATOM_MARKER_TYPE$2: { + const [atomName, atomValue, atomPayload] = this.getAtomTypeFromIndex(value); + return this.builder.createAtom(atomName, atomValue, atomPayload, this.markups.slice()); + } + default: + assert(`Unexpected marker type ${type}`, false); + } + } +} + +function parseVersion(mobiledoc) { + return mobiledoc.version; +} + +var mobiledocParsers = { + parse(builder, mobiledoc) { + let version = parseVersion(mobiledoc); + switch (version) { + case MOBILEDOC_VERSION: + return new MobiledocParser(builder).parse(mobiledoc); + case MOBILEDOC_VERSION$1: + return new MobiledocParser$1(builder).parse(mobiledoc); + case MOBILEDOC_VERSION$2: + return new MobiledocParser$2(builder).parse(mobiledoc); + case MOBILEDOC_VERSION$3: + return new MobiledocParser$3(builder).parse(mobiledoc); + default: + assert(`Unknown version of mobiledoc parser requested: ${version}`, + false); + } + } +}; + +class CardNode { + constructor(editor, card, section, element, options) { + this.editor = editor; + this.card = card; + this.section = section; + this.element = element; + this.options = options; + + this.mode = null; + + this._teardownCallback = null; + this._rendered = null; + } + + render(mode) { + if (this.mode === mode) { return; } + + this.teardown(); + + this.mode = mode; + + let method = mode === 'display' ? 'render' : 'edit'; + method = this.card[method]; + + assert(`Card is missing "${method}" (tried to render mode: "${mode}")`, + !!method); + let rendered = method({ + env: this.env, + options: this.options, + payload: this.section.payload + }); + + this._validateAndAppendRenderResult(rendered); + } + + teardown() { + if (this._teardownCallback) { + this._teardownCallback(); + this._teardownCallback = null; + } + if (this._rendered) { + this.element.removeChild(this._rendered); + this._rendered = null; + } + } + + didRender() { + if (this._didRenderCallback) { + this._didRenderCallback(); + } + } + + get env() { + return { + name: this.card.name, + isInEditor: true, + onTeardown: (callback) => this._teardownCallback = callback, + didRender: (callback) => this._didRenderCallback = callback, + edit: () => this.edit(), + save: (payload, transition=true) => { + this.section.payload = payload; + + this.editor._postDidChange(); + if (transition) { + this.display(); + } + }, + cancel: () => this.display(), + remove: () => this.remove(), + postModel: this.section + }; + } + + display() { + this.render('display'); + } + + edit() { + this.render('edit'); + } + + remove() { + this.editor.run(postEditor => postEditor.removeSection(this.section)); + } + + _validateAndAppendRenderResult(rendered) { + if (!rendered) { + return; + } + + let { card: { name } } = this; + assert( + `Card "${name}" must render dom (render value was: "${rendered}")`, + !!rendered.nodeType + ); + this.element.appendChild(rendered); + this._rendered = rendered; + this.didRender(); + } +} + +class AtomNode { + constructor(editor, atom, model, element, atomOptions) { + this.editor = editor; + this.atom = atom; + this.model = model; + this.atomOptions = atomOptions; + this.element = element; + + this._teardownCallback = null; + this._rendered = null; + } + + render() { + if (!this._rendered) { + let {atomOptions: options, env, model: { value, payload } } = this; + // cache initial render + this._rendered = this.atom.render({options, env, value, payload}); + } + + this._validateAndAppendRenderResult(this._rendered); + } + + get env() { + return { + name: this.atom.name, + onTeardown: (callback) => this._teardownCallback = callback, + save: (value, payload={}) => { + this.model.value = value; + this.model.payload = payload; + + this.editor._postDidChange(); + this.teardown(); + this.render(); + } + }; + } + + teardown() { + if (this._teardownCallback) { + this._teardownCallback(); + this._teardownCallback = null; + } + if (this._rendered) { + this.element.removeChild(this._rendered); + this._rendered = null; + } + } + + _validateAndAppendRenderResult(rendered) { + if (!rendered) { + return; + } + + let { atom: { name } } = this; + assert( + `Atom "${name}" must return a DOM node (returned value was: "${rendered}")`, + !!rendered.nodeType + ); + this.element.appendChild(rendered); + } +} + +class Set { + constructor(items=[]) { + this.items = []; + items.forEach(i => this.add(i)); + } + + add(item) { + if (!this.has(item)) { + this.items.push(item); + } + } + + get length() { + return this.items.length; + } + + has(item) { + return this.items.indexOf(item) !== -1; + } + + toArray() { + return this.items; + } +} + +const PARENT_PROP = '__parent'; + +class LinkedList { + constructor(options) { + this.head = null; + this.tail = null; + this.length = 0; + + if (options) { + const {adoptItem, freeItem} = options; + this._adoptItem = adoptItem; + this._freeItem = freeItem; + } + } + adoptItem(item) { + item[PARENT_PROP]= this; + this.length++; + if (this._adoptItem) { this._adoptItem(item); } + } + freeItem(item) { + item[PARENT_PROP] = null; + this.length--; + if (this._freeItem) { this._freeItem(item); } + } + get isEmpty() { + return this.length === 0; + } + prepend(item) { + this.insertBefore(item, this.head); + } + append(item) { + this.insertBefore(item, null); + } + insertAfter(item, prevItem) { + let nextItem = prevItem ? prevItem.next : this.head; + this.insertBefore(item, nextItem); + } + _ensureItemIsNotAlreadyInList(item){ + assert( + 'Cannot insert an item into a list if it is already in a list', + !item.next && !item.prev && this.head !== item + ); + } + insertBefore(item, nextItem) { + this._ensureItemIsNotInList(item); + this.adoptItem(item); + + let insertPos; + if (nextItem && nextItem.prev) { + insertPos = 'middle'; + } else if (nextItem) { + insertPos = 'start'; + } else { + insertPos = 'end'; + } + + switch (insertPos) { + case 'start': + if (this.head) { + item.next = this.head; + this.head.prev = item; + } + this.head = item; + + break; + case 'middle': { + let prevItem = nextItem.prev; + item.next = nextItem; + item.prev = prevItem; + nextItem.prev = item; + prevItem.next = item; + + break; + } + case 'end': { + let tail = this.tail; + item.prev = tail; + + if (tail) { + tail.next = item; + } else { + this.head = item; + } + this.tail = item; + + break; + } + } + } + remove(item) { + if (!item[PARENT_PROP]) { + return; + } + this._ensureItemIsInThisList(item); + this.freeItem(item); + + let [prev, next] = [item.prev, item.next]; + item.prev = null; + item.next = null; + + if (prev) { + prev.next = next; + } else { + this.head = next; + } + + if (next) { + next.prev = prev; + } else { + this.tail = prev; + } + } + forEach(callback) { + let item = this.head; + let index = 0; + while (item) { + callback(item, index++); + item = item.next; + } + } + map(callback) { + let result = []; + this.forEach(i => result.push(callback(i))); + return result; + } + walk(startItem, endItem, callback) { + let item = startItem || this.head; + while (item) { + callback(item); + if (item === endItem) { + break; + } + item = item.next; + } + } + readRange(startItem, endItem) { + let items = []; + this.walk(startItem, endItem, (item) => { + items.push(item); + }); + return items; + } + toArray() { + return this.readRange(); + } + detect(callback, item=this.head, reverse=false) { + while (item) { + if (callback(item)) { + return item; + } + item = reverse ? item.prev : item.next; + } + } + any(callback) { + return !!this.detect(callback); + } + every(callback) { + let item = this.head; + while (item) { + if (!callback(item)) { + return false; + } + item = item.next; + } + return true; + } + objectAt(targetIndex) { + let index = -1; + return this.detect(() => { + index++; + return (targetIndex === index); + }); + } + splice(targetItem, removalCount, newItems) { + let item = targetItem; + let nextItem = item.next; + let count = 0; + while (item && count < removalCount) { + count++; + nextItem = item.next; + this.remove(item); + item = nextItem; + } + newItems.forEach((newItem) => { + this.insertBefore(newItem, nextItem); + }); + } + removeBy(conditionFn) { + let item = this.head; + while (item) { + let nextItem = item.next; + + if (conditionFn(item)) { + this.remove(item); + } + + item = nextItem; + } + } + _ensureItemIsNotInList(item) { + assert('Cannot insert an item into a list if it is already in a list', + !item[PARENT_PROP]); + } + _ensureItemIsInThisList(item) { + assert('Cannot remove item that is in another list', + item[PARENT_PROP] === this); + } +} + +function unimplementedMethod(methodName, me) { + assert(`\`${methodName}()\` must be implemented by ${me.constructor.name}`, + false); +} + +class Section extends LinkedItem { + constructor(type) { + super(); + assert('Cannot create section without type', !!type); + this.type = type; + this.isSection = true; + this.isMarkerable = false; + this.isNested = false; + this.isSection = true; + this.isLeafSection = true; + } + + set tagName(val) { + let normalizedTagName = normalizeTagName(val); + assert(`Cannot set section tagName to ${val}`, + this.isValidTagName(normalizedTagName)); + this._tagName = normalizedTagName; + } + + get tagName() { + return this._tagName; + } + + isValidTagName(/* normalizedTagName */) { + unimplementedMethod('isValidTagName', this); + } + + get length() { + return 0; + } + + get isBlank() { + unimplementedMethod('isBlank', this); + } + + clone() { + unimplementedMethod('clone', this); + } + + canJoin(/* otherSection */) { + unimplementedMethod('canJoin', this); + } + + /** + * @return {Position} The position at the start of this section + * @public + */ + headPosition() { + return this.toPosition(0); + } + + /** + * @return {Position} The position at the end of this section + * @public + */ + tailPosition() { + return this.toPosition(this.length); + } + + /** + * @param {Number} offset + * @return {Position} The position in this section at the given offset + * @public + */ + toPosition(offset) { + assert("Must pass number to `toPosition`", typeof offset === 'number'); + assert("Cannot call `toPosition` with offset > length", offset <= this.length); + + return new Position$1(this, offset); + } + + /** + * @return {Range} A range from this section's head to tail positions + * @public + */ + toRange() { + return this.headPosition().toRange(this.tailPosition()); + } + + join() { + unimplementedMethod('join', this); + } + + textUntil(/* position */) { + return ''; + } + + /** + * Markerable sections should override this method + */ + splitMarkerAtOffset() { + let blankEdit = { added: [], removed: [] }; + return blankEdit; + } + + nextLeafSection() { + const next = this.next; + if (next) { + if (next.items) { + return next.items.head; + } else { + return next; + } + } else { + if (this.isNested) { + return this.parent.nextLeafSection(); + } + } + } + + immediatelyNextMarkerableSection() { + let next = this.nextLeafSection(); + while (next && !next.isMarkerable) { + next = next.nextLeafSection(); + } + return next; + } + + previousLeafSection() { + const prev = this.prev; + + if (prev) { + if (prev.items) { + return prev.items.tail; + } else { + return prev; + } + } else { + if (this.isNested) { + return this.parent.previousLeafSection(); + } + } + } +} + +class Markerable extends Section { + constructor(type, tagName, markers=[]) { + super(type); + this.isMarkerable = true; + this.tagName = tagName; + this.markers = new LinkedList({ + adoptItem: m => { + assert(`Can only insert markers and atoms into markerable (was: ${m.type})`, + m.isMarker || m.isAtom); + m.section = m.parent = this; + }, + freeItem: m => m.section = m.parent = null + }); + + markers.forEach(m => this.markers.append(m)); + } + + canJoin(other) { + return other.isMarkerable && + other.type === this.type && + other.tagName === this.tagName; + } + + clone() { + const newMarkers = this.markers.map(m => m.clone()); + return this.builder.createMarkerableSection( + this.type, this.tagName, newMarkers); + } + + get isBlank() { + if (!this.markers.length) { + return true; + } + return this.markers.every(m => m.isBlank); + } + + textUntil(position) { + assert(`Cannot get textUntil for a position not in this section`, position.section === this); + let {marker, offsetInMarker} = position; + let text = ''; + let currentMarker = this.markers.head; + while (currentMarker) { + if (currentMarker === marker) { + text += currentMarker.textUntil(offsetInMarker); + break; + } else { + text += currentMarker.text; + currentMarker = currentMarker.next; + } + } + return text; + } + + /** + * @param {Marker} + * @param {Number} markerOffset The offset relative to the start of the marker + * + * @return {Number} The offset relative to the start of this section + */ + offsetOfMarker(marker, markerOffset=0) { + assert(`Cannot get offsetOfMarker for marker that is not child of this`, + marker.section === this); + + // FIXME it is possible, when we get a cursor position before having finished reparsing, + // for markerOffset to be > marker.length. We shouldn't rely on this functionality. + + let offset = 0; + let currentMarker = this.markers.head; + while (currentMarker && currentMarker !== marker.next) { + let length = currentMarker === marker ? markerOffset : + currentMarker.length; + offset += length; + currentMarker = currentMarker.next; + } + + return offset; + } + + // puts clones of this.markers into beforeSection and afterSection, + // all markers before the marker/offset split go in beforeSection, and all + // after the marker/offset split go in afterSection + // @return {Array} [beforeSection, afterSection], two new sections + _redistributeMarkers(beforeSection, afterSection, marker, offset=0) { + let currentSection = beforeSection; + forEach(this.markers, m => { + if (m === marker) { + const [beforeMarker, ...afterMarkers] = marker.split(offset); + beforeSection.markers.append(beforeMarker); + forEach(afterMarkers, _m => afterSection.markers.append(_m)); + currentSection = afterSection; + } else { + currentSection.markers.append(m.clone()); + } + }); + + return [beforeSection, afterSection]; + } + + splitAtMarker(/*marker, offset=0*/) { + assert('splitAtMarker must be implemented by sub-class', false); + } + + /** + * Split this section's marker (if any) at the given offset, so that + * there is now a marker boundary at that offset (useful for later applying + * a markup to a range) + * @param {Number} sectionOffset The offset relative to start of this section + * @return {EditObject} An edit object with 'removed' and 'added' keys with arrays of Markers. The added markers may be blank. + * After calling `splitMarkerAtOffset(offset)`, there will always be a valid + * result returned from `markerBeforeOffset(offset)`. + */ + splitMarkerAtOffset(sectionOffset) { + assert('Cannot splitMarkerAtOffset when offset is > length', + sectionOffset <= this.length); + let markerOffset; + let len = 0; + let currentMarker = this.markers.head; + let edit = {added: [], removed: []}; + + if (!currentMarker) { + let blankMarker = this.builder.createMarker(); + this.markers.prepend(blankMarker); + edit.added.push(blankMarker); + } else { + while (currentMarker) { + len += currentMarker.length; + if (len === sectionOffset) { + // nothing to do, there is a gap at the requested offset + break; + } else if (len > sectionOffset) { + markerOffset = currentMarker.length - (len - sectionOffset); + let newMarkers = currentMarker.splitAtOffset(markerOffset); + edit.added.push(...newMarkers); + edit.removed.push(currentMarker); + this.markers.splice(currentMarker, 1, newMarkers); + break; + } else { + currentMarker = currentMarker.next; + } + } + } + + return edit; + } + + splitAtPosition(position) { + const {marker, offsetInMarker} = position; + return this.splitAtMarker(marker, offsetInMarker); + } + + // returns the marker just before this offset. + // It is an error to call this method with an offset that is in the middle + // of a marker. + markerBeforeOffset(sectionOffset) { + let len = 0; + let currentMarker = this.markers.head; + + while (currentMarker) { + len += currentMarker.length; + if (len === sectionOffset) { + return currentMarker; + } else { + assert('markerBeforeOffset called with sectionOffset not between markers', + len < sectionOffset); + currentMarker = currentMarker.next; + } + } + } + + markerPositionAtOffset(offset) { + let currentOffset = 0; + let currentMarker; + let remaining = offset; + this.markers.detect((marker) => { + currentOffset = Math.min(remaining, marker.length); + remaining -= currentOffset; + if (remaining === 0) { + currentMarker = marker; + return true; // break out of detect + } + }); + + return {marker:currentMarker, offset:currentOffset}; + } + + get text() { + return reduce(this.markers, (prev, m) => prev + m.value, ''); + } + + get length() { + return reduce(this.markers, (prev, m) => prev + m.length, 0); + } + + /** + * @return {Array} New markers that match the boundaries of the + * range. Does not change the existing markers in this section. + */ + markersFor(headOffset, tailOffset) { + const range = {head: {section:this, offset:headOffset}, + tail: {section:this, offset:tailOffset}}; + + let markers = []; + this._markersInRange(range, (marker, {markerHead, markerTail, isContained}) => { + const cloned = marker.clone(); + if (!isContained) { + // cannot do marker.value.slice if the marker is an atom -- this breaks the atom's "atomic" value + // If a marker is an atom `isContained` should always be true so + // we shouldn't hit this code path. FIXME add tests + cloned.value = marker.value.slice(markerHead, markerTail); + } + markers.push(cloned); + }); + return markers; + } + + markupsInRange(range) { + const markups = new Set(); + this._markersInRange(range, marker => { + marker.markups.forEach(m => markups.add(m)); + }); + return markups.toArray(); + } + + // calls the callback with (marker, {markerHead, markerTail, isContained}) + // for each marker that is wholly or partially contained in the range. + _markersInRange(range, callback) { + const { head, tail } = range; + assert('Cannot call #_markersInRange if range expands beyond this section', + head.section === this && tail.section === this); + const {offset:headOffset} = head, {offset:tailOffset} = tail; + + let currentHead = 0, currentTail = 0, currentMarker = this.markers.head; + + while (currentMarker) { + currentTail += currentMarker.length; + + if (currentTail > headOffset && currentHead < tailOffset) { + let markerHead = Math.max(headOffset - currentHead, 0); + let markerTail = currentMarker.length - + Math.max(currentTail - tailOffset, 0); + let isContained = markerHead === 0 && markerTail === currentMarker.length; + + callback(currentMarker, {markerHead, markerTail, isContained}); + } + + currentHead += currentMarker.length; + currentMarker = currentMarker.next; + + if (currentHead > tailOffset) { break; } + } + } + + // mutates this by appending the other section's (cloned) markers to it + join(otherSection) { + let beforeMarker = this.markers.tail; + let afterMarker = null; + + otherSection.markers.forEach(m => { + if (!m.isBlank) { + m = m.clone(); + this.markers.append(m); + if (!afterMarker) { + afterMarker = m; + } + } + }); + + return { beforeMarker, afterMarker }; + } +} + +const VALID_ATTRIBUTES = [ + 'data-md-text-align' +]; + +/* + * A "mixin" to add section attribute support + * to markup and list sections. + */ +function attributable(ctx) { + ctx.attributes = {}; + + ctx.hasAttribute = key => key in ctx.attributes; + + ctx.setAttribute = (key, value) => { + if (!contains(VALID_ATTRIBUTES, key)) { + throw new Error(`Invalid attribute "${key}" was passed. Constrain attributes to the spec-compliant whitelist.`); + } + ctx.attributes[key] = value; + }; + ctx.removeAttribute = key => { + delete ctx.attributes[key]; + }; + ctx.getAttribute = key => ctx.attributes[key]; + ctx.eachAttribute = cb => { + entries(ctx.attributes).forEach(([k,v]) => cb(k,v)); + }; +} + +// valid values of `tagName` for a MarkupSection +const VALID_MARKUP_SECTION_TAGNAMES = [ + 'aside', + 'blockquote', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p' +].map(normalizeTagName); + +// valid element names for a MarkupSection. A MarkupSection with a tagName +// not in this will be rendered as a div with a className matching the +// tagName +const MARKUP_SECTION_ELEMENT_NAMES = [ + 'aside', + 'blockquote', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p' +].map(normalizeTagName); +const DEFAULT_TAG_NAME = VALID_MARKUP_SECTION_TAGNAMES[8]; + +const MarkupSection = class MarkupSection extends Markerable { + constructor(tagName=DEFAULT_TAG_NAME, markers=[], attributes={}) { + super(MARKUP_SECTION_TYPE, tagName, markers); + + attributable(this); + entries(attributes).forEach(([k,v]) => this.setAttribute(k, v)); + + this.isMarkupSection = true; + } + + isValidTagName(normalizedTagName) { + return contains(VALID_MARKUP_SECTION_TAGNAMES, normalizedTagName); + } + + splitAtMarker(marker, offset=0) { + let [beforeSection, afterSection] = [ + this.builder.createMarkupSection(this.tagName, [], false, this.attributes), + this.builder.createMarkupSection() + ]; + + return this._redistributeMarkers(beforeSection, afterSection, marker, offset); + } +}; + +const CARD_ELEMENT_CLASS_NAME = '__mobiledoc-card'; +const NO_BREAK_SPACE = '\u00A0'; +const TAB_CHARACTER = '\u2003'; +const SPACE = ' '; +const ZWNJ = '\u200c'; +const ATOM_CLASS_NAME = '-mobiledoc-kit__atom'; +const EDITOR_HAS_NO_CONTENT_CLASS_NAME = '__has-no-content'; +const EDITOR_ELEMENT_CLASS_NAME = '__mobiledoc-editor'; + +function createElementFromMarkup(doc, markup) { + let element = doc.createElement(markup.tagName); + Object.keys(markup.attributes).forEach(k => { + element.setAttribute(k, markup.attributes[k]); + }); + return element; +} + +const TWO_SPACES = `${SPACE}${SPACE}`; +const SPACE_AND_NO_BREAK = `${SPACE}${NO_BREAK_SPACE}`; +const SPACES_REGEX = new RegExp(TWO_SPACES, 'g'); +const TAB_REGEX = new RegExp(TAB, 'g'); +const endsWithSpace = function(text) { + return endsWith(text, SPACE); +}; +const startsWithSpace = function(text) { + return startsWith(text, SPACE); +}; + +// FIXME: This can be done more efficiently with a single pass +// building a correct string based on the original. +function renderHTMLText(marker) { + let text = marker.value; + text = text.replace(SPACES_REGEX, SPACE_AND_NO_BREAK) + .replace(TAB_REGEX, TAB_CHARACTER); + + // If the first marker has a leading space or the last marker has a + // trailing space, the browser will collapse the space when we position + // the cursor. + // See https://github.com/bustle/mobiledoc-kit/issues/68 + // and https://github.com/bustle/mobiledoc-kit/issues/75 + if (marker.isMarker && endsWithSpace(text) && !marker.next) { + text = text.substr(0, text.length - 1) + NO_BREAK_SPACE; + } + if (marker.isMarker && startsWithSpace(text) && + (!marker.prev || (marker.prev.isMarker && endsWithSpace(marker.prev.value)))) { + text = NO_BREAK_SPACE + text.substr(1); + } + return text; +} + +// ascends from element upward, returning the last parent node that is not +// parentElement +function penultimateParentOf(element, parentElement) { + while (parentElement && + element.parentNode !== parentElement && + element.parentNode !== document.body // ensure the while loop stops + ) { + element = element.parentNode; + } + return element; +} + +function setSectionAttributesOnElement(section, element) { + section.eachAttribute((key, value) => { + element.setAttribute(key, value); + }); +} + +function renderMarkupSection(section) { + let element; + if (MARKUP_SECTION_ELEMENT_NAMES.indexOf(section.tagName) !== -1) { + element = document.createElement(section.tagName); + } else { + element = document.createElement('div'); + addClassName(element, section.tagName); + } + + setSectionAttributesOnElement(section, element); + + return element; +} + +function renderListSection(section) { + let element = document.createElement(section.tagName); + + setSectionAttributesOnElement(section, element); + + return element; +} + +function renderListItem() { + return document.createElement('li'); +} + +function renderCursorPlaceholder() { + return document.createElement('br'); +} + +function renderInlineCursorPlaceholder() { + return document.createTextNode(ZWNJ); +} + +function renderCard() { + let wrapper = document.createElement('div'); + let cardElement = document.createElement('div'); + cardElement.contentEditable = false; + addClassName(cardElement, CARD_ELEMENT_CLASS_NAME); + wrapper.appendChild(renderInlineCursorPlaceholder()); + wrapper.appendChild(cardElement); + wrapper.appendChild(renderInlineCursorPlaceholder()); + return { wrapper, cardElement }; +} + +/** + * Wrap the element in all of the opened markups + * @return {DOMElement} the wrapped element + * @private + */ +function wrapElement(element, openedMarkups) { + let wrappedElement = element; + + for (let i=openedMarkups.length - 1; i>=0; i--) { + let markup = openedMarkups[i]; + let openedElement = createElementFromMarkup(document, markup); + openedElement.appendChild(wrappedElement); + wrappedElement = openedElement; + } + + return wrappedElement; +} + +// Attach the element to its parent element at the correct position based on the +// previousRenderNode +function attachElementToParent(element, parentElement, previousRenderNode=null) { + if (previousRenderNode) { + let previousSibling = previousRenderNode.element; + let previousSiblingPenultimate = penultimateParentOf(previousSibling, + parentElement); + parentElement.insertBefore(element, previousSiblingPenultimate.nextSibling); + } else { + parentElement.insertBefore(element, parentElement.firstChild); + } +} + +function renderAtom(atom, element, previousRenderNode) { + let atomElement = document.createElement('span'); + atomElement.contentEditable = false; + + let wrapper = document.createElement('span'); + addClassName(wrapper, ATOM_CLASS_NAME); + let headTextNode = renderInlineCursorPlaceholder(); + let tailTextNode = renderInlineCursorPlaceholder(); + + wrapper.appendChild(headTextNode); + wrapper.appendChild(atomElement); + wrapper.appendChild(tailTextNode); + + let wrappedElement = wrapElement(wrapper, atom.openedMarkups); + attachElementToParent(wrappedElement, element, previousRenderNode); + + return { + markupElement: wrappedElement, + wrapper, + atomElement, + headTextNode, + tailTextNode + }; +} + +function getNextMarkerElement(renderNode) { + let element = renderNode.element.parentNode; + let marker = renderNode.postNode; + let closedCount = marker.closedMarkups.length; + + while (closedCount--) { + element = element.parentNode; + } + return element; +} + +/** + * Render the marker + * @param {Marker} marker the marker to render + * @param {DOMNode} element the element to attach the rendered marker to + * @param {RenderNode} [previousRenderNode] The render node before this one, which + * affects the determination of where to insert this rendered marker. + * @return {Object} With properties `element` and `markupElement`. + * The element (textNode) that has the text for + * this marker, and the outermost rendered element. If the marker has no + * markups, element and markupElement will be the same textNode + * @private + */ +function renderMarker(marker, parentElement, previousRenderNode) { + let text = renderHTMLText(marker); + + let element = document.createTextNode(text); + let markupElement = wrapElement(element, marker.openedMarkups); + attachElementToParent(markupElement, parentElement, previousRenderNode); + + return { element, markupElement }; +} + +// Attach the render node's element to the DOM, +// replacing the originalElement if it exists +function attachRenderNodeElementToDOM(renderNode, originalElement=null) { + const element = renderNode.element; + const hasRendered = !!originalElement; + + if (hasRendered) { + let parentElement = renderNode.parent.element; + parentElement.replaceChild(element, originalElement); + } else { + let parentElement, nextSiblingElement; + if (renderNode.prev) { + let previousElement = renderNode.prev.element; + parentElement = previousElement.parentNode; + nextSiblingElement = previousElement.nextSibling; + } else { + parentElement = renderNode.parent.element; + nextSiblingElement = parentElement.firstChild; + } + parentElement.insertBefore(element, nextSiblingElement); + } +} + +function removeRenderNodeSectionFromParent(renderNode, section) { + const parent = renderNode.parent.postNode; + parent.sections.remove(section); +} + +function removeRenderNodeElementFromParent(renderNode) { + if (renderNode.element && renderNode.element.parentNode) { + renderNode.element.parentNode.removeChild(renderNode.element); + } +} + +function validateCards(cards=[]) { + forEach(cards, card => { + assert( + `Card "${card.name}" must define type "dom", has: "${card.type}"`, + card.type === 'dom' + ); + assert( + `Card "${card.name}" must define \`render\` method`, + !!card.render + ); + }); + return cards; +} + +function validateAtoms(atoms=[]) { + forEach(atoms, atom => { + assert( + `Atom "${atom.name}" must define type "dom", has: "${atom.type}"`, + atom.type === 'dom' + ); + assert( + `Atom "${atom.name}" must define \`render\` method`, + !!atom.render + ); + }); + return atoms; +} + +class Visitor$1 { + constructor(editor, cards, atoms, unknownCardHandler, unknownAtomHandler, options) { + this.editor = editor; + this.cards = validateCards(cards); + this.atoms = validateAtoms(atoms); + this.unknownCardHandler = unknownCardHandler; + this.unknownAtomHandler = unknownAtomHandler; + this.options = options; + } + + _findCard(cardName) { + let card = detect(this.cards, card => card.name === cardName); + return card || this._createUnknownCard(cardName); + } + + _createUnknownCard(cardName) { + assert( + `Unknown card "${cardName}" found, but no unknownCardHandler is defined`, + !!this.unknownCardHandler + ); + + return { + name: cardName, + type: 'dom', + render: this.unknownCardHandler, + edit: this.unknownCardHandler + }; + } + + _findAtom(atomName) { + let atom = detect(this.atoms, atom => atom.name === atomName); + return atom || this._createUnknownAtom(atomName); + } + + _createUnknownAtom(atomName) { + assert( + `Unknown atom "${atomName}" found, but no unknownAtomHandler is defined`, + !!this.unknownAtomHandler + ); + + return { + name: atomName, + type: 'dom', + render: this.unknownAtomHandler + }; + } + + [POST_TYPE](renderNode, post, visit) { + if (!renderNode.element) { + renderNode.element = document.createElement('div'); + } + addClassName(renderNode.element, EDITOR_ELEMENT_CLASS_NAME); + if (post.hasContent) { + removeClassName(renderNode.element, EDITOR_HAS_NO_CONTENT_CLASS_NAME); + } else { + addClassName(renderNode.element, EDITOR_HAS_NO_CONTENT_CLASS_NAME); + } + visit(renderNode, post.sections); + } + + [MARKUP_SECTION_TYPE](renderNode, section, visit) { + const originalElement = renderNode.element; + + // Always rerender the section -- its tag name or attributes may have changed. + // TODO make this smarter, only rerendering and replacing the element when necessary + renderNode.element = renderMarkupSection(section); + renderNode.cursorElement = null; + attachRenderNodeElementToDOM(renderNode, originalElement); + + if (section.isBlank) { + let cursorPlaceholder = renderCursorPlaceholder(); + renderNode.element.appendChild(cursorPlaceholder); + renderNode.cursorElement = cursorPlaceholder; + } else { + const visitAll = true; + visit(renderNode, section.markers, visitAll); + } + } + + [LIST_SECTION_TYPE](renderNode, section, visit) { + const originalElement = renderNode.element; + + renderNode.element = renderListSection(section); + attachRenderNodeElementToDOM(renderNode, originalElement); + + const visitAll = true; + visit(renderNode, section.items, visitAll); + } + + [LIST_ITEM_TYPE](renderNode, item, visit) { + // FIXME do we need to do anything special for rerenders? + renderNode.element = renderListItem(); + renderNode.cursorElement = null; + attachRenderNodeElementToDOM(renderNode, null); + + if (item.isBlank) { + let cursorPlaceholder = renderCursorPlaceholder(); + renderNode.element.appendChild(cursorPlaceholder); + renderNode.cursorElement = cursorPlaceholder; + } else { + const visitAll = true; + visit(renderNode, item.markers, visitAll); + } + } + + [MARKER_TYPE](renderNode, marker) { + let parentElement; + + if (renderNode.prev) { + parentElement = getNextMarkerElement(renderNode.prev); + } else { + parentElement = renderNode.parent.element; + } + + let { element, markupElement } = + renderMarker(marker, parentElement, renderNode.prev); + + renderNode.element = element; + renderNode.markupElement = markupElement; + } + + [IMAGE_SECTION_TYPE](renderNode, section) { + if (renderNode.element) { + if (renderNode.element.src !== section.src) { + renderNode.element.src = section.src; + } + } else { + let element = document.createElement('img'); + element.src = section.src; + if (renderNode.prev) { + let previousElement = renderNode.prev.element; + let nextElement = previousElement.nextSibling; + if (nextElement) { + nextElement.parentNode.insertBefore(element, nextElement); + } + } + if (!element.parentNode) { + renderNode.parent.element.appendChild(element); + } + renderNode.element = element; + } + } + + [CARD_TYPE](renderNode, section) { + const originalElement = renderNode.element; + const {editor, options} = this; + + const card = this._findCard(section.name); + + let { wrapper, cardElement } = renderCard(); + renderNode.element = wrapper; + attachRenderNodeElementToDOM(renderNode, originalElement); + + const cardNode = new CardNode( + editor, card, section, cardElement, options); + renderNode.cardNode = cardNode; + + const initialMode = section._initialMode; + cardNode[initialMode](); + } + + [ATOM_TYPE](renderNode, atomModel) { + let parentElement; + + if (renderNode.prev) { + parentElement = getNextMarkerElement(renderNode.prev); + } else { + parentElement = renderNode.parent.element; + } + + const { editor, options } = this; + const { + wrapper, + markupElement, + atomElement, + headTextNode, + tailTextNode + } = renderAtom(atomModel, parentElement, renderNode.prev); + const atom = this._findAtom(atomModel.name); + + let atomNode = renderNode.atomNode; + if (!atomNode) { + // create new AtomNode + atomNode = new AtomNode(editor, atom, atomModel, atomElement, options); + } else { + // retarget atomNode to new atom element + atomNode.element = atomElement; + } + + atomNode.render(); + + renderNode.atomNode = atomNode; + renderNode.element = wrapper; + renderNode.headTextNode = headTextNode; + renderNode.tailTextNode = tailTextNode; + renderNode.markupElement = markupElement; + } +} + +let destroyHooks = { + [POST_TYPE](/*renderNode, post*/) { + assert('post destruction is not supported by the renderer', false); + }, + + [MARKUP_SECTION_TYPE](renderNode, section) { + removeRenderNodeSectionFromParent(renderNode, section); + removeRenderNodeElementFromParent(renderNode); + }, + + [LIST_SECTION_TYPE](renderNode, section) { + removeRenderNodeSectionFromParent(renderNode, section); + removeRenderNodeElementFromParent(renderNode); + }, + + [LIST_ITEM_TYPE](renderNode, li) { + removeRenderNodeSectionFromParent(renderNode, li); + removeRenderNodeElementFromParent(renderNode); + }, + + [MARKER_TYPE](renderNode, marker) { + // FIXME before we render marker, should delete previous renderNode's element + // and up until the next marker element + + // If an atom throws during render we may end up later destroying a renderNode + // that has not rendered yet, so exit early here if so. + if (!renderNode.isRendered) { + return; + } + let { markupElement } = renderNode; + + if (marker.section) { + marker.section.markers.remove(marker); + } + + if (markupElement.parentNode) { + // if no parentNode, the browser already removed this element + markupElement.parentNode.removeChild(markupElement); + } + }, + + [IMAGE_SECTION_TYPE](renderNode, section) { + removeRenderNodeSectionFromParent(renderNode, section); + removeRenderNodeElementFromParent(renderNode); + }, + + [CARD_TYPE](renderNode, section) { + if (renderNode.cardNode) { + renderNode.cardNode.teardown(); + } + removeRenderNodeSectionFromParent(renderNode, section); + removeRenderNodeElementFromParent(renderNode); + }, + + [ATOM_TYPE](renderNode, atom) { + if (renderNode.atomNode) { + renderNode.atomNode.teardown(); + } + + // an atom is a kind of marker so just call its destroy hook vs copying here + destroyHooks[MARKER_TYPE](renderNode, atom); + } +}; + +// removes children from parentNode (a RenderNode) that are scheduled for removal +function removeDestroyedChildren(parentNode, forceRemoval=false) { + let child = parentNode.childNodes.head; + let nextChild, method; + while (child) { + nextChild = child.next; + if (child.isRemoved || forceRemoval) { + removeDestroyedChildren(child, true); + method = child.postNode.type; + assert(`editor-dom cannot destroy "${method}"`, !!destroyHooks[method]); + destroyHooks[method](child, child.postNode); + parentNode.childNodes.remove(child); + } + child = nextChild; + } +} + +// Find an existing render node for the given postNode, or +// create one, insert it into the tree, and return it +function lookupNode(renderTree, parentNode, postNode, previousNode) { + if (postNode.renderNode) { + return postNode.renderNode; + } else { + const renderNode = renderTree.buildRenderNode(postNode); + parentNode.childNodes.insertAfter(renderNode, previousNode); + return renderNode; + } +} + +class Renderer { + constructor(editor, cards, atoms, unknownCardHandler, unknownAtomHandler, options) { + this.editor = editor; + this.visitor = new Visitor$1(editor, cards, atoms, unknownCardHandler, unknownAtomHandler, options); + this.nodes = []; + this.hasRendered = false; + } + + destroy() { + if (!this.hasRendered) { + return; + } + let renderNode = this.renderTree.rootNode; + let force = true; + removeDestroyedChildren(renderNode, force); + } + + visit(renderTree, parentNode, postNodes, visitAll=false) { + let previousNode; + postNodes.forEach(postNode => { + let node = lookupNode(renderTree, parentNode, postNode, previousNode); + if (node.isDirty || visitAll) { + this.nodes.push(node); + } + previousNode = node; + }); + } + + render(renderTree) { + this.hasRendered = true; + this.renderTree = renderTree; + let renderNode = renderTree.rootNode; + let method, postNode; + + while (renderNode) { + removeDestroyedChildren(renderNode); + postNode = renderNode.postNode; + + method = postNode.type; + assert(`EditorDom visitor cannot handle type ${method}`, !!this.visitor[method]); + this.visitor[method](renderNode, postNode, + (...args) => this.visit(renderTree, ...args)); + renderNode.markClean(); + renderNode = this.nodes.shift(); + } + } +} + +const VALID_LIST_SECTION_TAGNAMES = [ + 'ul', 'ol' +].map(normalizeTagName); + +const DEFAULT_TAG_NAME$1 = VALID_LIST_SECTION_TAGNAMES[0]; + +class ListSection extends Section { + constructor(tagName=DEFAULT_TAG_NAME$1, items=[], attributes={}) { + super(LIST_SECTION_TYPE); + this.tagName = tagName; + this.isListSection = true; + this.isLeafSection = false; + + attributable(this); + entries(attributes).forEach(([k,v]) => this.setAttribute(k, v)); + + this.items = new LinkedList({ + adoptItem: i => { + assert(`Cannot insert non-list-item to list (is: ${i.type})`, + i.isListItem); + i.section = i.parent = this; + }, + freeItem: i => i.section = i.parent = null + }); + this.sections = this.items; + + items.forEach(i => this.items.append(i)); + } + + canJoin() { + return false; + } + + isValidTagName(normalizedTagName) { + return contains(VALID_LIST_SECTION_TAGNAMES, normalizedTagName); + } + + headPosition() { + return this.items.head.headPosition(); + } + + tailPosition() { + return this.items.tail.tailPosition(); + } + + get isBlank() { + return this.items.isEmpty; + } + + clone() { + let newSection = this.builder.createListSection(this.tagName); + forEach(this.items, i => newSection.items.append(i.clone())); + return newSection; + } + + /** + * Mutates this list + * @param {ListSection|Markerable} + * @return null + */ + join(other) { + if (other.isListSection) { + other.items.forEach(i => this.join(i)); + } else if (other.isMarkerable) { + let item = this.builder.createListItem(); + item.join(other); + this.items.append(item); + } + } +} + +const VALID_LIST_ITEM_TAGNAMES = [ + 'li' +].map(normalizeTagName); + +class ListItem extends Markerable { + constructor(tagName, markers=[]) { + super(LIST_ITEM_TYPE, tagName, markers); + this.isListItem = true; + this.isNested = true; + } + + isValidTagName(normalizedTagName) { + return contains(VALID_LIST_ITEM_TAGNAMES, normalizedTagName); + } + + splitAtMarker(marker, offset=0) { + // FIXME need to check if we are going to split into two list items + // or a list item and a new markup section: + const isLastItem = !this.next; + const createNewSection = (!marker && offset === 0 && isLastItem); + + let [beforeSection, afterSection] = [ + this.builder.createListItem(), + createNewSection ? this.builder.createMarkupSection() : + this.builder.createListItem() + ]; + + return this._redistributeMarkers( + beforeSection, afterSection, marker, offset); + } + + get post() { + return this.section.post; + } +} + +const VALID_MARKUP_TAGNAMES = [ + 'a', + 'b', + 'code', + 'em', + 'i', + 's', // strikethrough + 'strong', + 'sub', // subscript + 'sup', // superscript + 'u' +].map(normalizeTagName); + +const VALID_ATTRIBUTES$1 = [ + 'href', + 'rel' +]; + +/** + * A Markup is similar with an inline HTML tag that might be added to + * text to modify its meaning and/or display. Examples of types of markup + * that could be added are bold ('b'), italic ('i'), strikethrough ('s'), and `a` tags (links). + * @property {String} tagName + */ +class Markup { + /* + * @param {Object} attributes key-values + */ + constructor(tagName, attributes={}) { + this.tagName = normalizeTagName(tagName); + + assert('Must use attributes object param (not array) for Markup', + !Array.isArray(attributes)); + + this.attributes = filterObject(attributes, VALID_ATTRIBUTES$1); + this.type = MARKUP_TYPE; + + assert(`Cannot create markup of tagName ${tagName}`, + VALID_MARKUP_TAGNAMES.indexOf(this.tagName) !== -1); + } + + /** + * Whether text in the forward direction of the cursor (i.e. to the right in ltr text) + * should be considered to have this markup applied to it. + * @private + */ + isForwardInclusive() { + return this.tagName === normalizeTagName("a") ? false : true; + } + + isBackwardInclusive() { + return false; + } + + hasTag(tagName) { + return this.tagName === normalizeTagName(tagName); + } + + /** + * Returns the attribute value + * @param {String} name, e.g. "href" + */ + getAttribute(name) { + return this.attributes[name]; + } + + static isValidElement(element) { + const tagName = normalizeTagName(element.tagName); + return VALID_MARKUP_TAGNAMES.indexOf(tagName) !== -1; + } +} + +const SKIPPABLE_ELEMENT_TAG_NAMES = [ + 'style', 'head', 'title', 'meta' +].map(normalizeTagName); + +const NEWLINES = /\n/g; +function sanitize(text) { + return text.replace(NEWLINES, ' '); +} + +/** + * parses an element into a section, ignoring any non-markup + * elements contained within + * @private + */ +class SectionParser { + constructor(builder, options={}) { + this.builder = builder; + this.plugins = options.plugins || []; + } + + parse(element) { + if (this._isSkippable(element)) { + return []; + } + this.sections = []; + this.state = {}; + + this._updateStateFromElement(element); + + let finished = false; + + // top-level text nodes will be run through parseNode later so avoid running + // the node through parserPlugins twice + if (!isTextNode(element)) { + finished = this.runPlugins(element); + } + + if (!finished) { + let childNodes = isTextNode(element) ? [element] : element.childNodes; + + forEach(childNodes, el => { + this.parseNode(el); + }); + } + + this._closeCurrentSection(); + + return this.sections; + } + + runPlugins(node) { + let isNodeFinished = false; + let env = { + addSection: (section) => { + // avoid creating empty paragraphs due to wrapper elements around + // parser-plugin-handled elements + if (this.state.section.isMarkerable && !this.state.text && !this.state.section.text) { + this.state.section = null; + } else { + this._closeCurrentSection(); + } + this.sections.push(section); + }, + addMarkerable: (marker) => { + let { state } = this; + let { section } = state; + assert( + 'Markerables can only be appended to markup sections and list item sections', + section && section.isMarkerable + ); + if (state.text) { + this._createMarker(); + } + section.markers.append(marker); + }, + nodeFinished() { + isNodeFinished = true; + } + }; + for (let i=0; i. + // deals with typical case of
  • Text

  • Text

  • + if ( + this.state.section.isListItem && + tagName === 'p' && + !node.nextSibling && + contains(VALID_LIST_ITEM_TAGNAMES, normalizeTagName(node.parentElement.tagName)) + ) { + this.parseElementNode(node); + return; + } + + // avoid creating empty paragraphs due to wrapper elements around + // section-creating elements + if (this.state.section.isMarkerable && !this.state.text && this.state.section.markers.length === 0) { + this.state.section = null; + } else { + this._closeCurrentSection(); + } + + this._updateStateFromElement(node); + } + + if (this.state.section.isListSection) { + // ensure the list section is closed and added to the sections list. + // _closeCurrentSection handles pushing list items onto the list section + this._closeCurrentSection(); + + forEach(node.childNodes, (node) => { + this.parseNode(node); + }); + return; + } + } + + switch (node.nodeType) { + case NODE_TYPES.TEXT: + this.parseTextNode(node); + break; + case NODE_TYPES.ELEMENT: + this.parseElementNode(node); + break; + } + } + + parseElementNode(element) { + let { state } = this; + + const markups = this._markupsFromElement(element); + if (markups.length && state.text.length && state.section.isMarkerable) { + this._createMarker(); + } + state.markups.push(...markups); + + forEach(element.childNodes, (node) => { + this.parseNode(node); + }); + + if (markups.length && state.text.length && state.section.isMarkerable) { + // create the marker started for this node + this._createMarker(); + } + + // pop the current markups from the stack + state.markups.splice(-markups.length, markups.length); + } + + parseTextNode(textNode) { + let { state } = this; + state.text += sanitize(textNode.textContent); + } + + _updateStateFromElement(element) { + let { state } = this; + state.section = this._createSectionFromElement(element); + state.markups = this._markupsFromElement(element); + state.text = ''; + } + + _closeCurrentSection() { + let { sections, state } = this; + let lastSection = sections[sections.length - 1]; + + if (!state.section) { + return; + } + + // close a trailing text node if it exists + if (state.text.length && state.section.isMarkerable) { + this._createMarker(); + } + + // push listItems onto the listSection or add a new section + if (state.section.isListItem && lastSection && lastSection.isListSection) { + trimSectionText(state.section); + lastSection.items.append(state.section); + } else { + // avoid creating empty markup sections, especially useful for indented source + if ( + state.section.isMarkerable && + !state.section.text.trim() && + !any(state.section.markers, marker => marker.isAtom) + ) { + state.section = null; + state.text = ''; + return; + } + + // remove empty list sections before creating a new section + if (lastSection && lastSection.isListSection && lastSection.items.length === 0) { + sections.pop(); + } + + sections.push(state.section); + } + + state.section = null; + state.text = ''; + } + + _markupsFromElement(element) { + let { builder } = this; + let markups = []; + if (isTextNode(element)) { + return markups; + } + + const tagName = normalizeTagName(element.tagName); + if (this._isValidMarkupForElement(tagName, element)) { + markups.push(builder.createMarkup(tagName, getAttributes(element))); + } + + this._markupsFromElementStyle(element).forEach( + markup => markups.push(markup) + ); + + return markups; + } + + _isValidMarkupForElement(tagName, element) { + if (VALID_MARKUP_TAGNAMES.indexOf(tagName) === -1) { + return false; + } else if (tagName === 'b') { + // google docs add a that should not + // create a "b" markup + return element.style.fontWeight !== 'normal'; + } + return true; + } + + _markupsFromElementStyle(element) { + let { builder } = this; + let markups = []; + let { fontStyle, fontWeight } = element.style; + if (fontStyle === 'italic') { + markups.push(builder.createMarkup('em')); + } + if (fontWeight === 'bold' || fontWeight === '700') { + markups.push(builder.createMarkup('strong')); + } + return markups; + } + + _createMarker() { + let { state } = this; + let text = transformHTMLText(state.text); + let marker = this.builder.createMarker(text, state.markups); + state.section.markers.append(marker); + state.text = ''; + } + + _getSectionDetails(element) { + let sectionType, + tagName, + inferredTagName = false; + if (isTextNode(element)) { + tagName = DEFAULT_TAG_NAME; + sectionType = MARKUP_SECTION_TYPE; + inferredTagName = true; + } else { + tagName = normalizeTagName(element.tagName); + + if (contains(VALID_LIST_SECTION_TAGNAMES, tagName)) { + sectionType = LIST_SECTION_TYPE; + } else if (contains(VALID_LIST_ITEM_TAGNAMES, tagName)) { + sectionType = LIST_ITEM_TYPE; + } else if (contains(VALID_MARKUP_SECTION_TAGNAMES, tagName)) { + sectionType = MARKUP_SECTION_TYPE; + } else { + sectionType = MARKUP_SECTION_TYPE; + tagName = DEFAULT_TAG_NAME; + inferredTagName = true; + } + } + + return {sectionType, tagName, inferredTagName}; + } + + _createSectionFromElement(element) { + let { builder } = this; + + let section; + let {tagName, sectionType, inferredTagName} = + this._getSectionDetails(element); + + switch (sectionType) { + case LIST_SECTION_TYPE: + section = builder.createListSection(tagName); + break; + case LIST_ITEM_TYPE: + section = builder.createListItem(); + break; + case MARKUP_SECTION_TYPE: + section = builder.createMarkupSection(tagName); + section._inferredTagName = inferredTagName; + break; + default: + assert('Cannot parse section from element', false); + } + + return section; + } + + _isSkippable(element) { + return isCommentNode(element) || + (element.nodeType === NODE_TYPES.ELEMENT && + contains(SKIPPABLE_ELEMENT_TAG_NAMES, + normalizeTagName(element.tagName))); + } +} + +const GOOGLE_DOCS_CONTAINER_ID_REGEX = /^docs\-internal\-guid/; + +const NO_BREAK_SPACE_REGEX = new RegExp(NO_BREAK_SPACE, 'g'); +const TAB_CHARACTER_REGEX = new RegExp(TAB_CHARACTER, 'g'); +function transformHTMLText(textContent) { + let text = textContent; + text = text.replace(NO_BREAK_SPACE_REGEX, ' '); + text = text.replace(TAB_CHARACTER_REGEX, TAB); + return text; +} + +function trimSectionText(section) { + if (section.isMarkerable && section.markers.length) { + let { head, tail } = section.markers; + head.value = head.value.replace(/^\s+/, ''); + tail.value = tail.value.replace(/\s+$/, ''); + } +} + +function isGoogleDocsContainer(element) { + return !isTextNode(element) && + !isCommentNode(element) && + normalizeTagName(element.tagName) === normalizeTagName('b') && + GOOGLE_DOCS_CONTAINER_ID_REGEX.test(element.id); +} + +function detectRootElement(element) { + let childNodes = element.childNodes || []; + let googleDocsContainer = detect(childNodes, isGoogleDocsContainer); + + if (googleDocsContainer) { + return googleDocsContainer; + } else { + return element; + } +} + +const TAG_REMAPPING = { + 'b': 'strong', + 'i': 'em' +}; + +function remapTagName(tagName) { + let normalized = normalizeTagName(tagName); + let remapped = TAG_REMAPPING[normalized]; + return remapped || normalized; +} + +function trim(str) { + return str.replace(/^\s+/, '').replace(/\s+$/, ''); +} + +function walkMarkerableNodes(parent, callback) { + let currentNode = parent; + + if ( + isTextNode(currentNode) || + ( + isElementNode(currentNode) && + currentNode.classList.contains(ATOM_CLASS_NAME) + ) + ) { + callback(currentNode); + } else { + currentNode = currentNode.firstChild; + while (currentNode) { + walkMarkerableNodes(currentNode, callback); + currentNode = currentNode.nextSibling; + } + } +} + +/** + * Parses DOM element -> Post + * @private + */ +class DOMParser { + constructor(builder, options={}) { + this.builder = builder; + this.sectionParser = new SectionParser(this.builder, options); + } + + parse(element) { + const post = this.builder.createPost(); + let rootElement = detectRootElement(element); + + this._eachChildNode(rootElement, child => { + let sections = this.parseSections(child); + this.appendSections(post, sections); + }); + + // trim leading/trailing whitespace of markerable sections to avoid + // unnessary whitespace from indented HTML input + forEach(post.sections, section => trimSectionText(section)); + + return post; + } + + appendSections(post, sections) { + forEach(sections, section => this.appendSection(post, section)); + } + + appendSection(post, section) { + if ( + section.isBlank || + (section.isMarkerable && + trim(section.text) === "" && + !any(section.markers, marker => marker.isAtom)) + ) { + return; + } + + let lastSection = post.sections.tail; + if (lastSection && + lastSection._inferredTagName && + section._inferredTagName && + lastSection.tagName === section.tagName) { + lastSection.join(section); + } else { + post.sections.append(section); + } + } + + _eachChildNode(element, callback) { + let nodes = isTextNode(element) ? [element] : element.childNodes; + forEach(nodes, node => callback(node)); + } + + parseSections(element) { + return this.sectionParser.parse(element); + } + + // walk up from the textNode until the rootNode, converting each + // parentNode into a markup + collectMarkups(textNode, rootNode) { + let markups = []; + let currentNode = textNode.parentNode; + while (currentNode && currentNode !== rootNode) { + let markup = this.markupFromNode(currentNode); + if (markup) { + markups.push(markup); + } + + currentNode = currentNode.parentNode; + } + return markups; + } + + // Turn an element node into a markup + markupFromNode(node) { + if (Markup.isValidElement(node)) { + let tagName = remapTagName(node.tagName); + let attributes = getAttributes(node); + return this.builder.createMarkup(tagName, attributes); + } + } + + // FIXME should move to the section parser? + // FIXME the `collectMarkups` logic could simplify the section parser? + reparseSection(section, renderTree) { + switch (section.type) { + case LIST_SECTION_TYPE: + return this.reparseListSection(section, renderTree); + case LIST_ITEM_TYPE: + return this.reparseListItem(section, renderTree); + case MARKUP_SECTION_TYPE: + return this.reparseMarkupSection(section, renderTree); + default: + return; // can only parse the above types + } + } + + reparseMarkupSection(section, renderTree) { + return this._reparseSectionContainingMarkers(section, renderTree); + } + + reparseListItem(listItem, renderTree) { + return this._reparseSectionContainingMarkers(listItem, renderTree); + } + + reparseListSection(listSection, renderTree) { + listSection.items.forEach(li => this.reparseListItem(li, renderTree)); + } + + _reparseSectionContainingMarkers(section, renderTree) { + let element = section.renderNode.element; + let seenRenderNodes = []; + let previousMarker; + + walkMarkerableNodes(element, (node) => { + let marker; + let renderNode = renderTree.getElementRenderNode(node); + if (renderNode) { + if (renderNode.postNode.isMarker) { + let text = transformHTMLText(node.textContent); + let markups = this.collectMarkups(node, element); + if (text.length) { + marker = renderNode.postNode; + marker.value = text; + marker.markups = markups; + } else { + renderNode.scheduleForRemoval(); + } + } else if (renderNode.postNode.isAtom) { + let { headTextNode, tailTextNode } = renderNode; + if (headTextNode.textContent !== ZWNJ) { + let value = headTextNode.textContent.replace(new RegExp(ZWNJ, 'g'), ''); + headTextNode.textContent = ZWNJ; + if (previousMarker && previousMarker.isMarker) { + previousMarker.value += value; + if (previousMarker.renderNode) { + previousMarker.renderNode.markDirty(); + } + } else { + let postNode = renderNode.postNode; + let newMarkups = postNode.markups.slice(); + let newPreviousMarker = this.builder.createMarker(value, newMarkups); + section.markers.insertBefore(newPreviousMarker, postNode); + + let newPreviousRenderNode = renderTree.buildRenderNode(newPreviousMarker); + newPreviousRenderNode.markDirty(); + section.renderNode.markDirty(); + + seenRenderNodes.push(newPreviousRenderNode); + section.renderNode.childNodes.insertBefore(newPreviousRenderNode, + renderNode); + } + } + if (tailTextNode.textContent !== ZWNJ) { + let value = tailTextNode.textContent.replace(new RegExp(ZWNJ, 'g'), ''); + tailTextNode.textContent = ZWNJ; + + if (renderNode.postNode.next && renderNode.postNode.next.isMarker) { + let nextMarker = renderNode.postNode.next; + + if (nextMarker.renderNode) { + let nextValue = nextMarker.renderNode.element.textContent; + nextMarker.renderNode.element.textContent = value + nextValue; + } else { + let nextValue = value + nextMarker.value; + nextMarker.value = nextValue; + } + } else { + let postNode = renderNode.postNode; + let newMarkups = postNode.markups.slice(); + let newMarker = this.builder.createMarker(value, newMarkups); + + section.markers.insertAfter(newMarker, postNode); + + let newRenderNode = renderTree.buildRenderNode(newMarker); + seenRenderNodes.push(newRenderNode); + + newRenderNode.markDirty(); + section.renderNode.markDirty(); + + section.renderNode.childNodes.insertAfter(newRenderNode, renderNode); + } + } + if (renderNode) { + marker = renderNode.postNode; + } + } + } else if (isTextNode(node)) { + let text = transformHTMLText(node.textContent); + let markups = this.collectMarkups(node, element); + marker = this.builder.createMarker(text, markups); + + renderNode = renderTree.buildRenderNode(marker); + renderNode.element = node; + renderNode.markClean(); + section.renderNode.markDirty(); + + let previousRenderNode = previousMarker && previousMarker.renderNode; + section.markers.insertAfter(marker, previousMarker); + section.renderNode.childNodes.insertAfter(renderNode, previousRenderNode); + } + + if (renderNode) { + seenRenderNodes.push(renderNode); + } + previousMarker = marker; + }); + + let renderNode = section.renderNode.childNodes.head; + while (renderNode) { + if (seenRenderNodes.indexOf(renderNode) === -1) { + renderNode.scheduleForRemoval(); + } + renderNode = renderNode.next; + } + } +} + +class HTMLParser { + constructor(builder, options={}) { + assert('Must pass builder to HTMLParser', builder); + this.builder = builder; + this.options = options; + } + + /** + * @param {String} html to parse + * @return {Post} A post abstract + */ + parse(html) { + let dom = parseHTML(html); + let parser = new DOMParser(this.builder, this.options); + return parser.parse(dom); + } +} + +class RenderNode extends LinkedItem { + constructor(postNode, renderTree) { + super(); + this.parent = null; + this.isDirty = true; + this.isRemoved = false; + this.postNode = postNode; + this._childNodes = null; + this._element = null; + this._cursorElement = null; // blank render nodes need a cursor element + this.renderTree = renderTree; + + // RenderNodes for Markers keep track of their markupElement + this.markupElement = null; + + // RenderNodes for Atoms use these properties + this.headTextNode = null; + this.tailTextNode = null; + this.atomNode = null; + + // RenderNodes for cards use this property + this.cardNode = null; + } + isAttached() { + assert('Cannot check if a renderNode is attached without an element.', + !!this.element); + return containsNode(this.renderTree.rootElement, this.element); + } + get childNodes() { + if (!this._childNodes) { + this._childNodes = new LinkedList({ + adoptItem: item => item.parent = this, + freeItem: item => item.destroy() + }); + } + return this._childNodes; + } + scheduleForRemoval() { + this.isRemoved = true; + if (this.parent) { this.parent.markDirty(); } + } + markDirty() { + this.isDirty = true; + if (this.parent) { this.parent.markDirty(); } + } + get isRendered() { + return !!this.element; + } + markClean() { + this.isDirty = false; + } + set element(element) { + const currentElement = this._element; + this._element = element; + + if (currentElement) { + this.renderTree.removeElementRenderNode(currentElement); + } + + if (element) { + this.renderTree.setElementRenderNode(element, this); + } + } + get element() { + return this._element; + } + set cursorElement(cursorElement) { + this._cursorElement = cursorElement; + } + get cursorElement() { + return this._cursorElement || this.element; + } + destroy() { + this.element = null; + this.parent = null; + this.postNode = null; + this.renderTree = null; + } + reparsesMutationOfChildNode(node) { + if (this.postNode.isCardSection) { + return !containsNode(this.cardNode.element, node); + } else if (this.postNode.isAtom) { + return !containsNode(this.atomNode.element, node); + } + return true; + } +} + +// start at one to make the falsy semantics easier +let uuidGenerator = 1; + +class ElementMap { + constructor() { + this._map = {}; + } + set(key, value) { + let uuid = key._uuid; + if (!uuid) { + key._uuid = uuid = '' + uuidGenerator++; + } + this._map[uuid] = value; + } + get(key) { + if (key._uuid) { + return this._map[key._uuid]; + } + return null; + } + remove(key) { + assert('tried to fetch a value for an element not seen before', !!key._uuid); + delete this._map[key._uuid]; + } + +} + +class RenderTree { + constructor(rootPostNode) { + this._rootNode = this.buildRenderNode(rootPostNode); + this._elements = new ElementMap(); + } + /* + * @return {RenderNode} The root render node in this tree + */ + get rootNode() { + return this._rootNode; + } + /** + * @return {Boolean} + */ + get isDirty() { + return this.rootNode && this.rootNode.isDirty; + } + /* + * @return {DOMNode} The root DOM element in this tree + */ + get rootElement() { + return this.rootNode.element; + } + /* + * @param {DOMNode} element + * @return {RenderNode} The renderNode for this element, if any + */ + getElementRenderNode(element) { + return this._elements.get(element); + } + setElementRenderNode(element, renderNode) { + this._elements.set(element, renderNode); + } + removeElementRenderNode(element) { + this._elements.remove(element); + } + /** + * @param {DOMNode} element + * Walk up from the dom element until we find a renderNode element + */ + findRenderNodeFromElement(element, conditionFn=()=>true) { + let renderNode; + while (element) { + renderNode = this.getElementRenderNode(element); + if (renderNode && conditionFn(renderNode)) { + return renderNode; + } + + // continue loop + element = element.parentNode; + + // stop if we are at the root element + if (element === this.rootElement) { + if (conditionFn(this.rootNode)) { + return this.rootNode; + } else { + return; + } + } + } + } + buildRenderNode(postNode) { + const renderNode = new RenderNode(postNode, this); + postNode.renderNode = renderNode; + return renderNode; + } +} + +const MOBILEDOC_VERSION$4 = MOBILEDOC_VERSION$3; + +var mobiledocRenderers = { + render(post, version) { + switch (version) { + case MOBILEDOC_VERSION: + return MobiledocRenderer_0_2.render(post); + case MOBILEDOC_VERSION$1: + return MobiledocRenderer_0_3.render(post); + case MOBILEDOC_VERSION$2: + return MobiledocRenderer_0_3_1.render(post); + case undefined: + case null: + case MOBILEDOC_VERSION$3: + return MobiledocRenderer_0_3_2.render(post); + default: + assert(`Unknown version of mobiledoc renderer requested: ${version}`, false); + } + } +}; + +function mergeWithOptions(original, updates, options) { + options = options || {}; + for(var prop in updates) { + if (options.hasOwnProperty(prop)) { + original[prop] = options[prop]; + } else if (updates.hasOwnProperty(prop)) { + original[prop] = updates[prop]; + } + } + return original; +} + +const Cursor = class Cursor { + constructor(editor) { + this.editor = editor; + this.renderTree = editor._renderTree; + this.post = editor.post; + } + + clearSelection() { + clearSelection(); + } + + /** + * @return {Boolean} true when there is either a collapsed cursor in the + * editor's element or a selection that is contained in the editor's element + */ + hasCursor() { + return this.editor.hasRendered && + (this._hasCollapsedSelection() || this._hasSelection()); + } + + hasSelection() { + return this.editor.hasRendered && + this._hasSelection(); + } + + /** + * @return {Boolean} Can the cursor be on this element? + */ + isAddressable(element) { + let { renderTree } = this; + let renderNode = renderTree.findRenderNodeFromElement(element); + if (renderNode && renderNode.postNode.isCardSection) { + let renderedElement = renderNode.element; + + // card sections have addressable text nodes containing ‌ + // as their first and last child + if (element !== renderedElement && + element !== renderedElement.firstChild && + element !== renderedElement.lastChild) { + return false; + } + } + + return !!renderNode; + } + + /* + * @return {Range} Cursor#Range object + */ + get offsets() { + if (!this.hasCursor()) { return Range.blankRange(); } + + let { selection, renderTree } = this; + let parentNode = this.editor.element; + selection = constrainSelectionTo(selection, parentNode); + + const { + headNode, headOffset, tailNode, tailOffset, direction + } = comparePosition(selection); + + const headPosition = Position$1.fromNode(renderTree, headNode, headOffset); + const tailPosition = Position$1.fromNode(renderTree, tailNode, tailOffset); + + return new Range(headPosition, tailPosition, direction); + } + + _findNodeForPosition(position) { + let { section } = position; + let node, offset; + if (section.isCardSection) { + offset = 0; + if (position.offset === 0) { + node = section.renderNode.element.firstChild; + } else { + node = section.renderNode.element.lastChild; + } + } else if (section.isBlank) { + node = section.renderNode.cursorElement; + offset = 0; + } else { + let {marker, offsetInMarker} = position; + if (marker.isAtom) { + if (offsetInMarker > 0) { + // FIXME -- if there is a next marker, focus on it? + offset = 0; + node = marker.renderNode.tailTextNode; + } else { + offset = 0; + node = marker.renderNode.headTextNode; + } + } else { + node = marker.renderNode.element; + offset = offsetInMarker; + } + } + + return {node, offset}; + } + + selectRange(range) { + if (range.isBlank) { + this.clearSelection(); + return; + } + + const { head, tail, direction } = range; + const { node:headNode, offset:headOffset } = this._findNodeForPosition(head), + { node:tailNode, offset:tailOffset } = this._findNodeForPosition(tail); + this._moveToNode(headNode, headOffset, tailNode, tailOffset, direction); + + // Firefox sometimes doesn't keep focus in the editor after adding a card + this.editor._ensureFocus(); + } + + get selection() { + return window.getSelection(); + } + + selectedText() { + // FIXME remove this + return this.selection.toString(); + } + + /** + * @param {textNode} node + * @param {integer} offset + * @param {textNode} endNode + * @param {integer} endOffset + * @param {integer} direction forward or backward, default forward + * @private + */ + _moveToNode(node, offset, endNode, endOffset, direction=DIRECTION.FORWARD) { + this.clearSelection(); + + if (direction === DIRECTION.BACKWARD) { + [node, offset, endNode, endOffset] = [ endNode, endOffset, node, offset ]; + } + + const range = document.createRange(); + range.setStart(node, offset); + if (direction === DIRECTION.BACKWARD && !!this.selection.extend) { + this.selection.addRange(range); + this.selection.extend(endNode, endOffset); + } else { + range.setEnd(endNode, endOffset); + this.selection.addRange(range); + } + } + + _hasSelection() { + const element = this.editor.element; + const { _selectionRange } = this; + if (!_selectionRange || _selectionRange.collapsed) { return false; } + + return containsNode(element, this.selection.anchorNode) && + containsNode(element, this.selection.focusNode); + } + + _hasCollapsedSelection() { + const { _selectionRange } = this; + if (!_selectionRange) { return false; } + + const element = this.editor.element; + return containsNode(element, this.selection.anchorNode); + } + + get _selectionRange() { + const { selection } = this; + if (selection.rangeCount === 0) { return null; } + return selection.getRangeAt(0); + } +}; + +var Environment = { + hasDOM() { + return typeof document !== 'undefined'; + } +}; + +const ATOM_LENGTH = 1; + +class Atom extends LinkedItem { + constructor(name, value, payload, markups=[]) { + super(); + this.name = name; + this.value = value; + this.text = ''; // An atom never has text, but it does have a value + assert('Atom must have value', value !== undefined && value !== null); + this.payload = payload; + this.type = ATOM_TYPE; + this.isMarker = false; + this.isAtom = true; + + this.markups = []; + markups.forEach(m => this.addMarkup(m)); + } + + clone() { + let clonedMarkups = this.markups.slice(); + return this.builder.createAtom( + this.name, this.value, this.payload, clonedMarkups + ); + } + + get isBlank() { + return false; + } + + get length() { + return ATOM_LENGTH; + } + + canJoin(/* other */) { + return false; + } + + textUntil(/* offset */) { + return ''; + } + + split(offset=0, endOffset=offset) { + let markers = []; + + if (endOffset === 0) { + markers.push(this.builder.createMarker('', this.markups.slice())); + } + + markers.push(this.clone()); + + if (offset === ATOM_LENGTH) { + markers.push(this.builder.createMarker('', this.markups.slice())); + } + + return markers; + } + + splitAtOffset(offset) { + assert('Cannot split a marker at an offset > its length', + offset <= this.length); + + let { builder } = this; + let clone = this.clone(); + let blankMarker = builder.createMarker(''); + let pre, post; + + if (offset === 0) { + ([pre, post] = [blankMarker, clone]); + } else if (offset === ATOM_LENGTH) { + ([pre, post] = [clone, blankMarker]); + } else { + assert(`Invalid offset given to Atom#splitAtOffset: "${offset}"`, false); + } + + this.markups.forEach(markup => { + pre.addMarkup(markup); + post.addMarkup(markup); + }); + return [pre, post]; + } +} + +mixin(Atom, Markerupable); + +/** + * The Post is an in-memory representation of an editor's document. + * An editor always has a single post. The post is organized into a list of + * sections. Each section may be markerable (contains "markers", aka editable + * text) or non-markerable (e.g., a card). + * When persisting a post, it must first be serialized (loss-lessly) into + * mobiledoc using {@link Editor#serialize}. + */ +class Post { + /** + * @private + */ + constructor() { + this.type = POST_TYPE; + this.sections = new LinkedList({ + adoptItem: s => s.post = s.parent = this, + freeItem: s => s.post = s.parent = null + }); + } + + /** + * @return {Position} The position at the start of the post (will be a {@link BlankPosition} + * if the post is blank) + * @public + */ + headPosition() { + if (this.isBlank) { + return Position$1.blankPosition(); + } else { + return this.sections.head.headPosition(); + } + } + + /** + * @return {Position} The position at the end of the post (will be a {@link BlankPosition} + * if the post is blank) + * @public + */ + tailPosition() { + if (this.isBlank) { + return Position$1.blankPosition(); + } else { + return this.sections.tail.tailPosition(); + } + } + + /** + * @return {Range} A range encompassing the entire post + * @public + */ + toRange() { + return this.headPosition().toRange(this.tailPosition()); + } + + get isBlank() { + return this.sections.isEmpty; + } + + /** + * If the post has no sections, or only has one, blank section, then it does + * not have content and this method returns false. Otherwise it is true. + * @return {Boolean} + * @public + */ + get hasContent() { + if ((this.sections.length > 1) || + (this.sections.length === 1 && !this.sections.head.isBlank)) { + return true; + } else { + return false; + } + } + + /** + * @param {Range} range + * @return {Array} markers that are completely contained by the range + */ + markersContainedByRange(range) { + const markers = []; + + this.walkMarkerableSections(range, section => { + section._markersInRange( + range.trimTo(section), + (m, {isContained}) => { if (isContained) { markers.push(m); } } + ); + }); + + return markers; + } + + markupsInRange(range) { + const markups = new Set(); + + if (range.isCollapsed) { + let pos = range.head; + if (pos.isMarkerable) { + let [back, forward] = [pos.markerIn(-1), pos.markerIn(1)]; + if (back && forward && back === forward) { + back.markups.forEach(m => markups.add(m)); + } else { + (back && back.markups || []).forEach(m => { + if (m.isForwardInclusive()) { + markups.add(m); + } + }); + (forward && forward.markups || []).forEach(m => { + if (m.isBackwardInclusive()) { + markups.add(m); + } + }); + } + } + } else { + this.walkMarkerableSections(range, (section) => { + forEach( + section.markupsInRange(range.trimTo(section)), + m => markups.add(m) + ); + }); + } + + return markups.toArray(); + } + + walkAllLeafSections(callback) { + let range = this.headPosition().toRange(this.tailPosition()); + return this.walkLeafSections(range, callback); + } + + walkLeafSections(range, callback) { + const { head, tail } = range; + + let index = 0; + let nextSection, shouldStop; + let currentSection = head.section; + + while (currentSection) { + nextSection = this._nextLeafSection(currentSection); + shouldStop = currentSection === tail.section; + + callback(currentSection, index); + index++; + + if (shouldStop) { + break; + } else { + currentSection = nextSection; + } + } + } + + walkMarkerableSections(range, callback) { + this.walkLeafSections(range, section => { + if (section.isMarkerable) { + callback(section); + } + }); + } + + // return the next section that has markers after this one, + // possibly skipping non-markerable sections + _nextLeafSection(section) { + if (!section) { return null; } + + const next = section.next; + if (next) { + if (next.isLeafSection) { + return next; + } else if (next.items) { + return next.items.head; + } else { + assert('Cannot determine next section from non-leaf-section', false); + } + } else if (section.isNested) { + // if there is no section after this, but this section is a child + // (e.g. a ListItem inside a ListSection), check for a markerable + // section after its parent + return this._nextLeafSection(section.parent); + } + } + + /** + * @param {Range} range + * @return {Post} A new post, constrained to {range} + */ + trimTo(range) { + const post = this.builder.createPost(); + const { builder } = this; + + let sectionParent = post, + listParent = null; + this.walkLeafSections(range, section => { + let newSection; + if (section.isMarkerable) { + if (section.isListItem) { + if (listParent) { + sectionParent = null; + } else { + listParent = builder.createListSection(section.parent.tagName); + post.sections.append(listParent); + sectionParent = null; + } + newSection = builder.createListItem(); + listParent.items.append(newSection); + } else { + listParent = null; + sectionParent = post; + newSection = builder.createMarkupSection(section.tagName); + } + + let currentRange = range.trimTo(section); + forEach( + section.markersFor(currentRange.headSectionOffset, currentRange.tailSectionOffset), + m => newSection.markers.append(m) + ); + } else { + newSection = section.clone(); + sectionParent = post; + } + if (sectionParent) { + sectionParent.sections.append(newSection); + } + }); + return post; + } +} + +class Image extends Section { + constructor() { + super(IMAGE_SECTION_TYPE); + this.src = null; + } + + canJoin() { + return false; + } + + get isBlank() { + return false; + } + + get length() { + return 1; + } +} + +function shallowCopyObject(object) { + let copy = {}; + Object.keys(object).forEach(key => { + copy[key] = object[key]; + }); + return copy; +} + +const CARD_MODES = { + DISPLAY: 'display', + EDIT: 'edit' +}; + +const CARD_LENGTH = 1; + +const DEFAULT_INITIAL_MODE = CARD_MODES.DISPLAY; + +class Card extends Section { + constructor(name, payload) { + super(CARD_TYPE); + this.name = name; + this.payload = payload; + this.setInitialMode(DEFAULT_INITIAL_MODE); + this.isCardSection = true; + } + + get isBlank() { + return false; + } + + canJoin() { + return false; + } + + get length() { + return CARD_LENGTH; + } + + clone() { + let payload = shallowCopyObject(this.payload); + let card = this.builder.createCardSection(this.name, payload); + // If this card is currently rendered, clone the mode it is + // currently in as the default mode of the new card. + let mode = this._initialMode; + if (this.renderNode && this.renderNode.cardNode) { + mode = this.renderNode.cardNode.mode; + } + card.setInitialMode(mode); + return card; + } + + /** + * set the mode that this will be rendered into initially + * @private + */ + setInitialMode(initialMode) { + // TODO validate initialMode + this._initialMode = initialMode; + } +} + +function cacheKey(tagName, attributes) { + return `${normalizeTagName(tagName)}-${objectToSortedKVArray(attributes).join('-')}`; +} + +function addMarkupToCache(cache, markup) { + cache[cacheKey(markup.tagName, markup.attributes)] = markup; +} + +function findMarkupInCache(cache, tagName, attributes) { + const key = cacheKey(tagName, attributes); + return cache[key]; +} + +/** + * The PostNodeBuilder is used to create new {@link Post} primitives, such + * as a MarkupSection, a CardSection, a Markup, etc. Every instance of an + * {@link Editor} has its own builder instance. The builder can be used + * inside an {@link Editor#run} callback to programmatically create new + * Post primitives to insert into the document. + * A PostNodeBuilder should be read from the Editor, *not* instantiated on its own. + */ +class PostNodeBuilder { + /** + * @private + */ + constructor() { + this.markupCache = {}; + } + + /** + * @return {Post} A new, blank post + */ + createPost(sections=[]) { + const post = new Post(); + post.builder = this; + + sections.forEach(s => post.sections.append(s)); + + return post; + } + + createMarkerableSection(type, tagName, markers=[]) { + switch (type) { + case LIST_ITEM_TYPE: + return this.createListItem(markers); + case MARKUP_SECTION_TYPE: + return this.createMarkupSection(tagName, markers); + default: + assert(`Cannot create markerable section of type ${type}`, false); + } + } + + /** + * @param {tagName} [tagName='P'] + * @param {Marker[]} [markers=[]] + * @return {MarkupSection} + */ + createMarkupSection(tagName=DEFAULT_TAG_NAME, markers=[], isGenerated=false, attributes={}) { + tagName = normalizeTagName(tagName); + const section = new MarkupSection(tagName, markers, attributes); + if (isGenerated) { + section.isGenerated = true; + } + section.builder = this; + return section; + } + + createListSection(tagName=DEFAULT_TAG_NAME$1, items=[], attributes={}) { + tagName = normalizeTagName(tagName); + const section = new ListSection(tagName, items, attributes); + section.builder = this; + return section; + } + + createListItem(markers=[]) { + const tagName = normalizeTagName('li'); + const item = new ListItem(tagName, markers); + item.builder = this; + return item; + } + + createImageSection(url) { + let section = new Image(); + if (url) { + section.src = url; + } + return section; + } + + /** + * @param {String} name + * @param {Object} [payload={}] + * @return {CardSection} + */ + createCardSection(name, payload={}) { + const card = new Card(name, payload); + card.builder = this; + return card; + } + + /** + * @param {String} value + * @param {Markup[]} [markups=[]] + * @return {Marker} + */ + createMarker(value, markups=[]) { + const marker = new Marker(value, markups); + marker.builder = this; + return marker; + } + + /** + * @param {String} name + * @param {String} [value=''] + * @param {Object} [payload={}] + * @param {Markup[]} [markups=[]] + * @return {Atom} + */ + createAtom(name, value='', payload={}, markups=[]) { + const atom = new Atom(name, value, payload, markups); + atom.builder = this; + return atom; + } + + /** + * @param {String} tagName + * @param {Object} attributes Key-value pairs of attributes for the markup + * @return {Markup} + */ + createMarkup(tagName, attributes={}) { + tagName = normalizeTagName(tagName); + + let markup = findMarkupInCache(this.markupCache, tagName, attributes); + if (!markup) { + markup = new Markup(tagName, attributes); + markup.builder = this; + addMarkupToCache(this.markupCache, markup); + } + + return markup; + } +} + +/** + * Convert section at the editor's cursor position into a list. + * Does nothing if the cursor position is not at the start of the section, + * or if the section is already a list item. + * + * @param {Editor} editor + * @param {String} listTagName ("ul" or "ol") + * @public + */ +function replaceWithListSection(editor, listTagName) { + let { range: { head, head: { section } } } = editor; + // Skip if cursor is not at end of section + if (!head.isTail()) { + return; + } + + if (section.isListItem) { + return; + } + + editor.run(postEditor => { + let { builder } = postEditor; + let item = builder.createListItem(); + let listSection = builder.createListSection(listTagName, [item]); + + postEditor.replaceSection(section, listSection); + postEditor.setRange(listSection.headPosition()); + }); +} + +/** + * Convert section at the editor's cursor position into a header section. + * Does nothing if the cursor position is not at the start of the section. + * + * @param {Editor} editor + * @param {String} headingTagName ('h1', 'h2', 'h3', 'h4', 'h5', 'h6') + * @public + */ +function replaceWithHeaderSection(editor, headingTagName) { + let { range: { head, head: { section } } } = editor; + // Skip if cursor is not at end of section + if (!head.isTail()) { + return; + } + + editor.run(postEditor => { + let { builder } = postEditor; + let newSection = builder.createMarkupSection(headingTagName); + postEditor.replaceSection(section, newSection); + postEditor.setRange(newSection.headPosition()); + }); +} + +const DEFAULT_TEXT_INPUT_HANDLERS = [ + { + name: 'ul', + // "* " -> ul + match: /^\* $/, + run(editor) { + replaceWithListSection(editor, 'ul'); + } + }, + { + name: 'ol', + // "1" -> ol, "1." -> ol + match: /^1\.? $/, + run(editor) { + replaceWithListSection(editor, 'ol'); + } + }, + { + name: 'heading', + /* + * "# " -> h1 + * "## " -> h2 + * "### " -> h3 + * "#### " -> h4 + * "##### " -> h5 + * "###### " -> h6 + */ + match: /^(#{1,6}) $/, + run(editor, matches) { + let capture = matches[1]; + let headingTag = 'h' + capture.length; + replaceWithHeaderSection(editor, headingTag); + } + } +]; + +var Browser = { + isMac() { + return (typeof window !== 'undefined') && window.navigator && /Mac/.test(window.navigator.platform); + }, + isWin() { + return (typeof window !== 'undefined') && window.navigator && /Win/.test(window.navigator.platform); + } +}; + +/** + * @module UI + */ + +/** + * @callback promptCallback + * @param {String} url The URL to pass back to the editor for linking + * to the selected text. + */ + +/** + * @callback showPrompt + * @param {String} message The text of the prompt. + * @param {String} defaultValue The initial URL to display in the prompt. + * @param {module:UI~promptCallback} callback Once your handler has accepted a URL, + * it should pass it to `callback` so that the editor may link the + * selected text. + */ + +/** + * Exposes the core behavior for linking and unlinking text, and allows for + * customization of the URL input handler. + * @param {Editor} editor An editor instance to operate on. If a range is selected, + * either prompt for a URL and add a link or un-link the + * currently linked text. + * @param {module:UI~showPrompt} [showPrompt] An optional custom input handler. Defaults + * to using `window.prompt`. + * @example + * let myPrompt = (message, defaultURL, promptCallback) => { + * let url = window.prompt("Overriding the defaults", "http://placekitten.com"); + * promptCallback(url); + * }; + * + * editor.registerKeyCommand({ + * str: "META+K", + * run(editor) { + * toggleLink(editor, myPrompt); + * } + * }); + * @public + */ + +let defaultShowPrompt = (message, defaultValue, callback) => callback(window.prompt(message, defaultValue)); + +function toggleLink(editor, showPrompt=defaultShowPrompt) { + if (editor.range.isCollapsed) { + return; + } + + let selectedText = editor.cursor.selectedText(); + let defaultUrl = ''; + if (selectedText.indexOf('http') !== -1) { defaultUrl = selectedText; } + + let {range} = editor; + let hasLink = editor.detectMarkupInRange(range, 'a'); + + if (hasLink) { + editor.toggleMarkup('a'); + } else { + showPrompt('Enter a URL', defaultUrl, url => { + if (!url) { return; } + + editor.toggleMarkup('a', {href: url}); + }); + } +} + +var UI = /*#__PURE__*/Object.freeze({ + __proto__: null, + toggleLink: toggleLink +}); + +function selectAll(editor) { + let { post } = editor; + editor.selectRange(post.toRange()); +} + +function gotoStartOfLine(editor) { + let {range} = editor; + let {tail: {section}} = range; + editor.run(postEditor => { + postEditor.setRange(section.headPosition()); + }); +} + +function gotoEndOfLine(editor) { + let {range} = editor; + let {tail: {section}} = range; + editor.run(postEditor => { + postEditor.setRange(section.tailPosition()); + }); +} + +function deleteToEndOfSection(editor) { + let { range } = editor; + if (range.isCollapsed) { + let { head, head: { section } } = range; + range = head.toRange(section.tailPosition()); + } + editor.run(postEditor => { + let nextPosition = postEditor.deleteRange(range); + postEditor.setRange(nextPosition); + }); +} + +const DEFAULT_KEY_COMMANDS = [{ + str: 'META+B', + run(editor) { + editor.toggleMarkup('strong'); + } +}, { + str: 'CTRL+B', + run(editor) { + editor.toggleMarkup('strong'); + } +}, { + str: 'META+I', + run(editor) { + editor.toggleMarkup('em'); + } +}, { + str: 'CTRL+I', + run(editor) { + editor.toggleMarkup('em'); + } +}, { + str: 'META+U', + run(editor) { + editor.toggleMarkup('u'); + } +}, { + str: 'CTRL+U', + run(editor) { + editor.toggleMarkup('u'); + } +}, { + str: 'CTRL+K', + run(editor) { + if (Browser.isMac()) { + return deleteToEndOfSection(editor); + } else if (Browser.isWin()) { + return toggleLink(editor); + } + } +}, { + str: 'CTRL+A', + run(editor) { + if (Browser.isMac()) { + gotoStartOfLine(editor); + } else { + selectAll(editor); + } + } +}, { + str: 'META+A', + run(editor) { + if (Browser.isMac()) { + selectAll(editor); + } + } +}, { + str: 'CTRL+E', + run(editor) { + if (Browser.isMac()) { + gotoEndOfLine(editor); + } + } +}, { + str: 'META+K', + run(editor) { + return toggleLink(editor); + }, + +}, { + str: 'META+Z', + run(editor) { + editor.run(postEditor => { + postEditor.undoLastChange(); + }); + } +}, { + str: 'META+SHIFT+Z', + run(editor) { + editor.run(postEditor => { + postEditor.redoLastChange(); + }); + } +}, { + str: 'CTRL+Z', + run(editor) { + if (Browser.isMac()) { return false; } + editor.run(postEditor => postEditor.undoLastChange()); + } +}, { + str: 'CTRL+SHIFT+Z', + run(editor) { + if (Browser.isMac()) { return false; } + editor.run(postEditor => postEditor.redoLastChange()); + } +}]; + +function modifierNamesToMask(modiferNames) { + let defaultVal = 0; + return reduce(modiferNames, + (sum, name) => { + let modifier = MODIFIERS[name.toUpperCase()]; + assert(`No modifier named "${name}" found`, !!modifier); + return sum + modifier; + }, + defaultVal); +} + +function characterToCode(character) { + const upperCharacter = character.toUpperCase(); + const special = specialCharacterToCode(upperCharacter); + if (special) { + return special; + } else { + assert(`Only 1 character can be used in a key command str (got "${character}")`, + character.length === 1); + return upperCharacter.charCodeAt(0); + } +} + +function buildKeyCommand(keyCommand) { + let { str } = keyCommand; + + if (!str) { + return keyCommand; + } + assert('[deprecation] Key commands no longer use the `modifier` property', + !keyCommand.modifier); + + let [character, ...modifierNames] = str.split('+').reverse(); + + keyCommand.modifierMask = modifierNamesToMask(modifierNames); + keyCommand.code = characterToCode(character); + + return keyCommand; +} + +function validateKeyCommand(keyCommand) { + return !!keyCommand.code && !!keyCommand.run; +} + +function findKeyCommands(keyCommands, keyEvent) { + const key = Key.fromEvent(keyEvent); + + return filter(keyCommands, ({modifierMask, code}) => { + return key.keyCode === code && key.modifierMask === modifierMask; + }); +} + +const MUTATION = { + NODES_CHANGED: 'childList', + CHARACTER_DATA: 'characterData' +}; + +class MutationHandler { + constructor(editor) { + this.editor = editor; + this.logger = editor.loggerFor('mutation-handler'); + this.renderTree = null; + this._isObserving = false; + + this._observer = new MutationObserver((mutations) => { + this._handleMutations(mutations); + }); + } + + init() { + this.startObserving(); + } + + destroy() { + this.stopObserving(); + this._observer = null; + } + + suspendObservation(callback) { + this.stopObserving(); + callback(); + this.startObserving(); + } + + stopObserving() { + if (this._isObserving) { + this._isObserving = false; + this._observer.disconnect(); + } + } + + startObserving() { + if (!this._isObserving) { + let { editor } = this; + assert('Cannot observe un-rendered editor', editor.hasRendered); + + this._isObserving = true; + this.renderTree = editor._renderTree; + + this._observer.observe(editor.element, { + characterData: true, + childList: true, + subtree: true + }); + } + } + + reparsePost() { + this.editor._reparsePost(); + } + + reparseSections(sections) { + this.editor._reparseSections(sections); + } + + /** + * for each mutation: + * * find the target nodes: + * * if nodes changed, target nodes are: + * * added nodes + * * the target from which removed nodes were removed + * * if character data changed + * * target node is the mutation event's target (text node) + * * filter out nodes that are no longer attached (parentNode is null) + * * for each remaining node: + * * find its section, add to sections-to-reparse + * * if no section, reparse all (and break) + */ + _handleMutations(mutations) { + let reparsePost = false; + let sections = new Set(); + + for (let i = 0; i < mutations.length; i++) { + if (reparsePost) { + break; + } + + let nodes = this._findTargetNodes(mutations[i]); + + for (let j=0; j < nodes.length; j++) { + let node = nodes[j]; + let renderNode = this._findRenderNodeFromNode(node); + if (renderNode) { + if (renderNode.reparsesMutationOfChildNode(node)) { + let section = this._findSectionFromRenderNode(renderNode); + if (section) { + sections.add(section); + } else { + reparsePost = true; + } + } + } else { + reparsePost = true; + break; + } + } + } + + if (reparsePost) { + this.logger.log(`reparsePost (${mutations.length} mutations)`); + this.reparsePost(); + } else if (sections.length) { + this.logger.log(`reparse ${sections.length} sections (${mutations.length} mutations)`); + this.reparseSections(sections.toArray()); + } + } + + _findTargetNodes(mutation) { + let nodes = []; + + switch (mutation.type) { + case MUTATION.CHARACTER_DATA: + nodes.push(mutation.target); + break; + case MUTATION.NODES_CHANGED: + forEach(mutation.addedNodes, n => nodes.push(n)); + if (mutation.removedNodes.length) { + nodes.push(mutation.target); + } + break; + } + + let element = this.editor.element; + let attachedNodes = filter(nodes, node => containsNode(element, node)); + return attachedNodes; + } + + _findSectionRenderNodeFromNode(node) { + return this.renderTree.findRenderNodeFromElement(node, (rn) => { + return rn.postNode.isSection; + }); + } + + _findRenderNodeFromNode(node) { + return this.renderTree.findRenderNodeFromElement(node); + } + + _findSectionFromRenderNode(renderNode) { + let sectionRenderNode = this._findSectionRenderNodeFromNode(renderNode.element); + return sectionRenderNode && sectionRenderNode.postNode; + } + +} + +class FixedQueue { + constructor(length=0) { + this._maxLength = length; + this._items = []; + } + + get length() { + return this._items.length; + } + + pop() { + return this._items.pop(); + } + + push(item) { + this._items.push(item); + if (this.length > this._maxLength) { + this._items.shift(); + } + } + + clear() { + this._items = []; + } + + toArray() { + return this._items; + } +} + +function findLeafSectionAtIndex(post, index) { + let section; + post.walkAllLeafSections((_section, _index) => { + if (index === _index) { + section = _section; + } + }); + return section; +} + +class Snapshot { + constructor(takenAt, editor, editAction=null) { + this.mobiledoc = editor.serialize(); + this.editor = editor; + this.editAction = editAction; + this.takenAt = takenAt; + + this.snapshotRange(); + } + + snapshotRange() { + let { range, cursor } = this.editor; + if (cursor.hasCursor() && !range.isBlank) { + let { head, tail } = range; + this.range = { + head: [head.leafSectionIndex, head.offset], + tail: [tail.leafSectionIndex, tail.offset] + }; + } + } + + getRange(post) { + if (this.range) { + let { head, tail } = this.range; + let [headLeafSectionIndex, headOffset] = head; + let [tailLeafSectionIndex, tailOffset] = tail; + let headSection = findLeafSectionAtIndex(post, headLeafSectionIndex); + let tailSection = findLeafSectionAtIndex(post, tailLeafSectionIndex); + + head = headSection.toPosition(headOffset); + tail = tailSection.toPosition(tailOffset); + + return head.toRange(tail); + } + } + + groupsWith(groupingTimeout, editAction, takenAt) { + return ( + editAction !== null && + this.editAction === editAction && + this.takenAt + groupingTimeout > takenAt + ); + } +} + +class EditHistory { + constructor(editor, queueLength, groupingTimeout) { + this.editor = editor; + this._undoStack = new FixedQueue(queueLength); + this._redoStack = new FixedQueue(queueLength); + + this._pendingSnapshot = null; + this._groupingTimeout = groupingTimeout; + } + + snapshot() { + // update the current snapshot with the range read from DOM + if (this._pendingSnapshot) { + this._pendingSnapshot.snapshotRange(); + } + } + + storeSnapshot(editAction=null) { + let now = Date.now(); + // store pending snapshot + let pendingSnapshot = this._pendingSnapshot; + if (pendingSnapshot) { + if (!pendingSnapshot.groupsWith(this._groupingTimeout, editAction, now)) { + this._undoStack.push(pendingSnapshot); + } + this._redoStack.clear(); + } + + // take new pending snapshot to store next time `storeSnapshot` is called + this._pendingSnapshot = new Snapshot(now, this.editor, editAction); + } + + stepBackward(postEditor) { + // Throw away the pending snapshot + this._pendingSnapshot = null; + + let snapshot = this._undoStack.pop(); + if (snapshot) { + this._redoStack.push(new Snapshot(Date.now(), this.editor)); + this._restoreFromSnapshot(snapshot, postEditor); + } + } + + stepForward(postEditor) { + let snapshot = this._redoStack.pop(); + if (snapshot) { + this._undoStack.push(new Snapshot(Date.now(), this.editor)); + this._restoreFromSnapshot(snapshot, postEditor); + } + postEditor.cancelSnapshot(); + } + + _restoreFromSnapshot(snapshot, postEditor) { + let { mobiledoc } = snapshot; + let { editor } = this; + let { builder, post } = editor; + let restoredPost = mobiledocParsers.parse(builder, mobiledoc); + + postEditor.removeAllSections(); + postEditor.migrateSectionsFromPost(restoredPost); + + // resurrect snapshotted range if it exists + let newRange = snapshot.getRange(post); + if (newRange) { + postEditor.setRange(newRange); + } + } +} + +const UL_LI_REGEX = /^\* (.*)$/; +const OL_LI_REGEX = /^\d\.? (.*)$/; +const CR = '\r'; +const LF = '\n'; +const CR_REGEX = new RegExp(CR, 'g'); +const CR_LF_REGEX = new RegExp(CR+LF, 'g'); + +const SECTION_BREAK = LF; + +function normalizeLineEndings(text) { + return text.replace(CR_LF_REGEX, LF) + .replace(CR_REGEX, LF); +} + +class TextParser { + constructor(builder, options) { + this.builder = builder; + this.options = options; + + this.post = this.builder.createPost(); + this.prevSection = null; + } + + /** + * @param {String} text to parse + * @return {Post} a post abstract + */ + parse(text) { + text = normalizeLineEndings(text); + text.split(SECTION_BREAK).forEach(text => { + let section = this._parseSection(text); + this._appendSection(section); + }); + + return this.post; + } + + _parseSection(text) { + let tagName = DEFAULT_TAG_NAME, + type = MARKUP_SECTION_TYPE, + section; + + if (UL_LI_REGEX.test(text)) { + tagName = 'ul'; + type = LIST_SECTION_TYPE; + text = text.match(UL_LI_REGEX)[1]; + } else if (OL_LI_REGEX.test(text)) { + tagName = 'ol'; + type = LIST_SECTION_TYPE; + text = text.match(OL_LI_REGEX)[1]; + } + + let markers = [this.builder.createMarker(text)]; + + switch (type) { + case LIST_SECTION_TYPE: { + let item = this.builder.createListItem(markers); + let list = this.builder.createListSection(tagName, [item]); + section = list; + break; + } + case MARKUP_SECTION_TYPE: + section = this.builder.createMarkupSection(tagName, markers); + break; + default: + assert(`Unknown type encountered ${type}`, false); + } + + return section; + } + + _appendSection(section) { + let isSameListSection = + section.isListSection && + this.prevSection && this.prevSection.isListSection && + this.prevSection.tagName === section.tagName; + + if (isSameListSection) { + section.items.forEach(item => { + this.prevSection.items.append(item.clone()); + }); + } else { + this.post.sections.insertAfter(section, this.prevSection); + this.prevSection = section; + } + } +} + +/* global JSON */ + +const MIME_TEXT_PLAIN = 'text/plain'; +const MIME_TEXT_HTML = 'text/html'; +const NONSTANDARD_IE_TEXT_TYPE = 'Text'; + +const MOBILEDOC_REGEX = new RegExp(/data\-mobiledoc='(.*?)'>/); + +/** + * @return {Post} + * @private + */ +function parsePostFromHTML(html, builder, plugins) { + let post; + + if (MOBILEDOC_REGEX.test(html)) { + let mobiledocString = html.match(MOBILEDOC_REGEX)[1]; + let mobiledoc = JSON.parse(mobiledocString); + post = mobiledocParsers.parse(builder, mobiledoc); + } else { + post = new HTMLParser(builder, {plugins}).parse(html); + } + + return post; +} + +/** + * @return {Post} + * @private + */ +function parsePostFromText(text, builder, plugins) { + let parser = new TextParser(builder, {plugins}); + let post = parser.parse(text); + return post; +} + +/** + * @return {{html: String, text: String}} + * @private + */ +function getContentFromPasteEvent(event, window) { + let html = '', text = ''; + + let { clipboardData } = event; + + if (clipboardData && clipboardData.getData) { + html = clipboardData.getData(MIME_TEXT_HTML); + text = clipboardData.getData(MIME_TEXT_PLAIN); + } else if (window.clipboardData && window.clipboardData.getData) { // IE + // The Internet Explorers (including Edge) have a non-standard way of interacting with the + // Clipboard API (see http://caniuse.com/#feat=clipboard). In short, they expose a global window.clipboardData + // object instead of the per-event event.clipboardData object on the other browsers. + html = window.clipboardData.getData(NONSTANDARD_IE_TEXT_TYPE); + } + + return { html, text }; +} + +/** + * @return {{html: String, text: String}} + * @private + */ +function getContentFromDropEvent(event, logger) { + let html = '', text = ''; + + try { + html = event.dataTransfer.getData(MIME_TEXT_HTML); + text = event.dataTransfer.getData(MIME_TEXT_PLAIN); + } catch (e) { + // FIXME IE11 does not include any data in the 'text/html' or 'text/plain' + // mimetypes. It throws an error 'Invalid argument' when attempting to read + // these properties. + if (logger) { + logger.log('Error getting drop data: ', e); + } + } + + return { html, text }; +} + +/** + * @param {CopyEvent|CutEvent} + * @param {Editor} + * @param {Window} + * @private + */ +function setClipboardData(event, {mobiledoc, html, text}, window) { + if (mobiledoc && html) { + html = `
    ${html}
    `; + } + + let { clipboardData } = event; + let { clipboardData: nonstandardClipboardData } = window; + + if (clipboardData && clipboardData.setData) { + clipboardData.setData(MIME_TEXT_HTML, html); + clipboardData.setData(MIME_TEXT_PLAIN, text); + } else if (nonstandardClipboardData && nonstandardClipboardData.setData) { + // The Internet Explorers (including Edge) have a non-standard way of interacting with the + // Clipboard API (see http://caniuse.com/#feat=clipboard). In short, they expose a global window.clipboardData + // object instead of the per-event event.clipboardData object on the other browsers. + nonstandardClipboardData.setData(NONSTANDARD_IE_TEXT_TYPE, html); + } +} + +/** + * @param {PasteEvent} + * @param {{builder: Builder, _parserPlugins: Array}} options + * @return {Post} + * @private + */ +function parsePostFromPaste(pasteEvent, {builder, _parserPlugins: plugins}, {targetFormat}={targetFormat:'html'}) { + let { html, text } = getContentFromPasteEvent(pasteEvent, window); + + if (targetFormat === 'html' && html && html.length) { + return parsePostFromHTML(html, builder, plugins); + } else if (text && text.length) { + return parsePostFromText(text, builder, plugins); + } +} + +/** + * @param {DropEvent} + * @param {Editor} editor + * @param {Object} [options={}] Can pass a logger + * @return {Post} + * @private + */ +function parsePostFromDrop(dropEvent, editor, {logger}={}) { + let { builder, _parserPlugins: plugins } = editor; + let { html, text } = getContentFromDropEvent(dropEvent, logger); + + if (html && html.length) { + return parsePostFromHTML(html, builder, plugins); + } else if (text && text.length) { + return parsePostFromText(text, builder, plugins); + } +} + +class TextInputHandler { + constructor(editor) { + this.editor = editor; + this._handlers = []; + } + + register(handler) { + assert(`Input Handler is not valid`, this._validateHandler(handler)); + this._handlers.push(handler); + } + + unregister(name) { + let handlers = this._handlers; + for (let i=0; i { + listener.selectionDidChange(...arguments); + }); + } + + destroy() { + this.stop(); + this.listeners = []; + } + + getSelection() { + let selection = window.getSelection(); + let { anchorNode, focusNode, anchorOffset, focusOffset } = selection; + return { anchorNode, focusNode, anchorOffset, focusOffset }; + } + + poll() { + if (this.started) { + this.update(); + this.runNext(() => this.poll()); + } + } + + runNext(fn) { + window.requestAnimationFrame(fn); + } + + update() { + let prevSelection = this.selection; + let curSelection = this.getSelection(); + if (!this.selectionIsEqual(prevSelection, curSelection)) { + this.selection = curSelection; + this.notifyListeners(curSelection, prevSelection); + } + } + + selectionIsEqual(s1, s2) { + return s1.anchorNode === s2.anchorNode && + s1.anchorOffset === s2.anchorOffset && + s1.focusNode === s2.focusNode && + s1.focusOffset === s2.focusOffset; + } +} + +class SelectionManager { + constructor(editor, callback) { + this.editor = editor; + this.callback = callback; + this.started = false; + } + + start() { + if (this.started) { return; } + + SelectionChangeObserver.addListener(this); + this.started = true; + } + + stop() { + this.started = false; + SelectionChangeObserver.removeListener(this); + } + + destroy() { + this.stop(); + } + + selectionDidChange() { + if (this.started) { + this.callback(...arguments); + } + } +} + +const ELEMENT_EVENT_TYPES = [ + 'keydown', 'keyup', 'cut', 'copy', 'paste', 'keypress', 'drop' +]; + +class EventManager { + constructor(editor) { + this.editor = editor; + this.logger = editor.loggerFor('event-manager'); + this._textInputHandler = new TextInputHandler(editor); + this._listeners = []; + this.modifierKeys = { + shift: false + }; + + this._selectionManager = new SelectionManager( + this.editor, this.selectionDidChange.bind(this)); + this.started = true; + } + + init() { + let { editor: { element } } = this; + assert(`Cannot init EventManager without element`, !!element); + + ELEMENT_EVENT_TYPES.forEach(type => { + this._addListener(element, type); + }); + + this._selectionManager.start(); + } + + start() { + this.started = true; + } + + stop() { + this.started = false; + } + + registerInputHandler(inputHandler) { + this._textInputHandler.register(inputHandler); + } + + unregisterInputHandler(name) { + this._textInputHandler.unregister(name); + } + + unregisterAllTextInputHandlers() { + this._textInputHandler.destroy(); + this._textInputHandler = new TextInputHandler(this.editor); + } + + _addListener(context, type) { + assert(`Missing listener for ${type}`, !!this[type]); + + let listener = (event) => this._handleEvent(type, event); + context.addEventListener(type, listener); + this._listeners.push([context, type, listener]); + } + + _removeListeners() { + this._listeners.forEach(([context, type, listener]) => { + context.removeEventListener(type, listener); + }); + this._listeners = []; + } + + // This is primarily useful for programmatically simulating events on the + // editor from the tests. + _trigger(context, type, event) { + forEach( + filter(this._listeners, ([_context, _type]) => { + return _context === context && _type === type; + }), + ([context,, listener]) => { + listener.call(context, event); + } + ); + } + + destroy() { + this._textInputHandler.destroy(); + this._selectionManager.destroy(); + this._removeListeners(); + } + + _handleEvent(type, event) { + let {target: element} = event; + if (!this.started) { + // abort handling this event + return true; + } + + if (!this.isElementAddressable(element)) { + // abort handling this event + return true; + } + + this[type](event); + } + + isElementAddressable(element) { + return this.editor.cursor.isAddressable(element); + } + + selectionDidChange(selection /*, prevSelection */) { + let shouldNotify = true; + let { anchorNode } = selection; + if (!this.isElementAddressable(anchorNode)) { + if (!this.editor.range.isBlank) { + // Selection changed from something addressable to something + // not-addressable -- e.g., blur event, user clicked outside editor, + // etc + shouldNotify = true; + } else { + // selection changes wholly outside the editor should not trigger + // change notifications + shouldNotify = false; + } + } + + if (shouldNotify) { + this.editor._readRangeFromDOM(); + } + } + + keypress(event) { + let { editor, _textInputHandler } = this; + if (!editor.hasCursor()) { return; } + + let key = Key.fromEvent(event); + if (!key.isPrintable()) { + return; + } else { + event.preventDefault(); + } + + _textInputHandler.handle(key.toString()); + } + + keydown(event) { + let { editor } = this; + if (!editor.hasCursor()) { return; } + if (!editor.isEditable) { return; } + + let key = Key.fromEvent(event); + this._updateModifiersFromKey(key, {isDown:true}); + + if (editor.handleKeyCommand(event)) { return; } + + if (editor.post.isBlank) { + editor._insertEmptyMarkupSectionAtCursor(); + } + + let range = editor.range; + + switch(true) { + // FIXME This should be restricted to only card/atom boundaries + case key.isHorizontalArrowWithoutModifiersOtherThanShift(): { + let newRange; + if (key.isShift()) { + newRange = range.extend(key.direction * 1); + } else { + newRange = range.move(key.direction); + } + + editor.selectRange(newRange); + event.preventDefault(); + break; + } + case key.isDelete(): { + let { direction } = key; + let unit = 'char'; + if (key.altKey && Browser.isMac()) { + unit = 'word'; + } else if (key.ctrlKey && !Browser.isMac()) { + unit = 'word'; + } + editor.performDelete({direction, unit}); + event.preventDefault(); + break; + } + case key.isEnter(): + this._textInputHandler.handleNewLine(); + editor.handleNewline(event); + break; + case key.isTab(): + // Handle tab here because it does not fire a `keypress` event + event.preventDefault(); + this._textInputHandler.handle(key.toString()); + break; + } + } + + keyup(event) { + let { editor } = this; + if (!editor.hasCursor()) { return; } + let key = Key.fromEvent(event); + this._updateModifiersFromKey(key, {isDown:false}); + } + + cut(event) { + event.preventDefault(); + + this.copy(event); + this.editor.performDelete(); + } + + copy(event) { + event.preventDefault(); + + let { editor, editor: { range, post } } = this; + post = post.trimTo(range); + + let data = { + html: editor.serializePost(post, 'html'), + text: editor.serializePost(post, 'text'), + mobiledoc: editor.serializePost(post, 'mobiledoc') + }; + + setClipboardData(event, data, window); + } + + paste(event) { + event.preventDefault(); + + let { editor } = this; + let range = editor.range; + + if (!range.isCollapsed) { + editor.performDelete(); + } + + if (editor.post.isBlank) { + editor._insertEmptyMarkupSectionAtCursor(); + } + + let position = editor.range.head; + let targetFormat = this.modifierKeys.shift ? 'text' : 'html'; + let pastedPost = parsePostFromPaste(event, editor, {targetFormat}); + + editor.run(postEditor => { + let nextPosition = postEditor.insertPost(position, pastedPost); + postEditor.setRange(nextPosition); + }); + } + + drop(event) { + event.preventDefault(); + + let { clientX: x, clientY: y } = event; + let { editor } = this; + + let position = editor.positionAtPoint(x, y); + if (!position) { + this.logger.log('Could not find drop position'); + return; + } + + let post = parsePostFromDrop(event, editor, {logger: this.logger}); + if (!post) { + this.logger.log('Could not determine post from drop event'); + return; + } + + editor.run(postEditor => { + let nextPosition = postEditor.insertPost(position, post); + postEditor.setRange(nextPosition); + }); + } + + _updateModifiersFromKey(key, {isDown}) { + if (key.isShiftKey()) { + this.modifierKeys.shift = isDown; + } + } + +} + +/** + * Used by {@link Editor} to manage its current state (cursor, active markups + * and active sections). + * @private + */ +class EditState { + constructor(editor) { + this.editor = editor; + + let defaultState = { + range: Range.blankRange(), + activeMarkups: [], + activeSections: [], + activeSectionTagNames: [], + activeSectionAttributes: {} + }; + + this.prevState = this.state = defaultState; + } + + updateRange(newRange) { + this.prevState = this.state; + this.state = this._readState(newRange); + } + + destroy() { + this.editor = null; + this.prevState = this.state = null; + } + + /** + * @return {Boolean} + */ + rangeDidChange() { + let { state: { range } , prevState: {range: prevRange} } = this; + + return !prevRange.isEqual(range); + } + + /** + * @return {Boolean} Whether the input mode (active markups or active section tag names) + * has changed. + */ + inputModeDidChange() { + let { state, prevState } = this; + return (!isArrayEqual(state.activeMarkups, prevState.activeMarkups) || + !isArrayEqual(state.activeSectionTagNames, prevState.activeSectionTagNames) || + !isArrayEqual(objectToSortedKVArray(state.activeSectionAttributes), objectToSortedKVArray(prevState.activeSectionAttributes))); + } + + /** + * @return {Range} + */ + get range() { + return this.state.range; + } + + /** + * @return {Section[]} + */ + get activeSections() { + return this.state.activeSections; + } + + + /** + * @return {Object} + */ + get activeSectionAttributes() { + return this.state.activeSectionAttributes; + } + + /** + * @return {Markup[]} + */ + get activeMarkups() { + return this.state.activeMarkups; + } + + /** + * Update the editor's markup state. This is used when, e.g., + * a user types meta+B when the editor has a cursor but no selected text; + * in this case the editor needs to track that it has an active "b" markup + * and apply it to the next text the user types. + */ + toggleMarkupState(markup) { + if (contains(this.activeMarkups, markup)) { + this._removeActiveMarkup(markup); + } else { + this._addActiveMarkup(markup); + } + } + + _readState(range) { + let state = { + range, + activeMarkups: this._readActiveMarkups(range), + activeSections: this._readActiveSections(range) + }; + // Section objects are 'live', so to check that they changed, we + // need to map their tagNames now (and compare to mapped tagNames later). + // In addition, to catch changes from ul -> ol, we keep track of the + // un-nested tag names (otherwise we'd only see li -> li change) + state.activeSectionTagNames = state.activeSections.map(s => { + return s.isNested ? s.parent.tagName : s.tagName; + }); + state.activeSectionAttributes = this._readSectionAttributes(state.activeSections); + return state; + } + + _readActiveSections(range) { + let { head, tail } = range; + let { editor: { post } } = this; + if (range.isBlank) { + return []; + } else { + return post.sections.readRange(head.section, tail.section); + } + } + + _readActiveMarkups(range) { + let { editor: { post } } = this; + return post.markupsInRange(range); + } + + _readSectionAttributes(sections) { + return sections.reduce((sectionAttributes, s) => { + let attributes = s.isNested ? s.parent.attributes : s.attributes; + Object.keys(attributes || {}).forEach(attrName => { + let camelizedAttrName = attrName.replace(/^data-md-/, ''); + let attrValue = attributes[attrName]; + sectionAttributes[camelizedAttrName] = sectionAttributes[camelizedAttrName] || []; + if (!contains(sectionAttributes[camelizedAttrName], attrValue)) { + sectionAttributes[camelizedAttrName].push(attrValue); + } + }); + return sectionAttributes; + }, {}); + } + + _removeActiveMarkup(markup) { + let index = this.state.activeMarkups.indexOf(markup); + this.state.activeMarkups.splice(index, 1); + } + + _addActiveMarkup(markup) { + this.state.activeMarkups.push(markup); + } +} + +function addHTMLSpaces(text) { + let nbsp = '\u00A0'; + return text.replace(/ /g, ' ' + nbsp); +} + +function createTextNode(dom, text) { + return dom.createTextNode(addHTMLSpaces(text)); +} + +function normalizeTagName$1(tagName) { + return tagName.toLowerCase(); +} + +var RENDER_TYPE = 'dom'; + +var ImageCard$1 = { + name: 'image', + type: RENDER_TYPE, + render({payload, env: {dom}}) { + let img = dom.createElement('img'); + img.src = payload.src; + return img; + } +}; + +const MARKUP_SECTION_TYPE$1 = 1; +const IMAGE_SECTION_TYPE$1 = 2; +const LIST_SECTION_TYPE$1 = 3; +const CARD_SECTION_TYPE = 10; + +const MARKUP_SECTION_TAG_NAMES = [ + 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pull-quote', 'aside' +].map(normalizeTagName$1); + +const MARKUP_SECTION_ELEMENT_NAMES$1 = [ + 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'aside' +].map(normalizeTagName$1); + +const LIST_SECTION_TAG_NAMES = [ + 'ul', 'ol' +].map(normalizeTagName$1); + +const MARKUP_TYPES = [ + 'b', 'i', 'strong', 'em', 'a', 'u', 'sub', 'sup', 's', 'code' +].map(normalizeTagName$1); + +function contains$1(array, item) { + return array.indexOf(item) !== -1; +} + +function isValidSectionTagName(tagName, sectionType) { + tagName = normalizeTagName$1(tagName); + + switch (sectionType) { + case MARKUP_SECTION_TYPE$1: + return contains$1(MARKUP_SECTION_TAG_NAMES, tagName); + case LIST_SECTION_TYPE$1: + return contains$1(LIST_SECTION_TAG_NAMES, tagName); + default: + throw new Error(`Cannot validate tagName for unknown section type "${sectionType}"`); + } +} + +function isMarkupSectionElementName(tagName) { + tagName = normalizeTagName$1(tagName); + return contains$1(MARKUP_SECTION_ELEMENT_NAMES$1, tagName); +} + +function isValidMarkerType(type) { + type = normalizeTagName$1(type); + return contains$1(MARKUP_TYPES, type); +} + +function includes(array, detectValue) { + for (let i=0;i < array.length;i++) { + let value = array[i]; + if (value === detectValue) { + return true; + } + } + return false; +} + +const PROTOCOL_REGEXP = /^([a-z0-9.+-]+:)/i; + +const badProtocols = [ + 'javascript:', // jshint ignore:line + 'vbscript:' // jshint ignore:line +]; + +function getProtocol(url) { + let matches = url && url.match(PROTOCOL_REGEXP); + let protocol = (matches && matches[0]) || ':'; + return protocol; +} + +function sanitizeHref(url) { + let protocol = getProtocol(url).toLowerCase(); + if (includes(badProtocols, protocol)) { + return `unsafe:${url}`; + } + return url; +} + +/** + * @param attributes array + * @return obj with normalized attribute names (lowercased) + */ +function reduceAttributes(attributes) { + let obj = {}; + for (let i = 0; i < attributes.length; i += 2) { + let key = attributes[i]; + let val = attributes[i+1]; + obj[key.toLowerCase()] = val; + } + return obj; +} + +const VALID_ATTRIBUTES$2 = [ + 'data-md-text-align' +]; + +function _isValidAttribute(attr) { + return VALID_ATTRIBUTES$2.indexOf(attr) !== -1; +} + +function handleMarkupSectionAttribute(element, attributeKey, attributeValue) { + if (!_isValidAttribute(attributeKey)) { + throw new Error(`Cannot use attribute: ${attributeKey}`); + } + + element.setAttribute(attributeKey, attributeValue); +} + +function defaultSectionElementRenderer(tagName, dom, attrsObj = {}) { + let element; + if (isMarkupSectionElementName(tagName)) { + element = dom.createElement(tagName); + + Object.keys(attrsObj).forEach(k => { + handleMarkupSectionAttribute(element, k, attrsObj[k]); + }); + } else { + element = dom.createElement('div'); + element.setAttribute('class', tagName); + } + + return element; +} + +function sanitizeAttribute(tagName, attrName, attrValue) { + if (tagName === 'a' && attrName === 'href') { + return sanitizeHref(attrValue); + } else { + return attrValue; + } +} + +function defaultMarkupElementRenderer(tagName, dom, attrsObj) { + let element = dom.createElement(tagName); + Object.keys(attrsObj).forEach(attrName => { + let attrValue = attrsObj[attrName]; + attrValue = sanitizeAttribute(tagName, attrName, attrValue); + element.setAttribute(attrName, attrValue); + }); + return element; +} + +const MOBILEDOC_VERSION$5 = '0.2.0'; + +const IMAGE_SECTION_TAG_NAME = 'img'; + +function validateVersion(version) { + if (version !== MOBILEDOC_VERSION$5) { + throw new Error(`Unexpected Mobiledoc version "${version}"`); + } +} + +class Renderer$1 { + constructor(mobiledoc, options) { + let { + cards, + cardOptions, + unknownCardHandler, + markupElementRenderer, + sectionElementRenderer, + dom + } = options; + let { + version, + sections: sectionData + } = mobiledoc; + validateVersion(version); + + const [markerTypes, sections] = sectionData; + + this.dom = dom; + this.root = dom.createDocumentFragment(); + this.markerTypes = markerTypes; + this.sections = sections; + this.cards = cards; + this.cardOptions = cardOptions; + this.unknownCardHandler = unknownCardHandler || this._defaultUnknownCardHandler; + + this.sectionElementRenderer = { + '__default__': defaultSectionElementRenderer + }; + Object.keys(sectionElementRenderer).forEach(key => { + this.sectionElementRenderer[key.toLowerCase()] = sectionElementRenderer[key]; + }); + + this.markupElementRenderer = { + '__default__': defaultMarkupElementRenderer + }; + Object.keys(markupElementRenderer).forEach(key => { + this.markupElementRenderer[key.toLowerCase()] = markupElementRenderer[key]; + }); + + this._renderCallbacks = []; + this._teardownCallbacks = []; + this._renderedChildNodes = []; + } + + get _defaultUnknownCardHandler() { + return ({env: {name}}) => { + throw new Error(`Card "${name}" not found but no unknownCardHandler was registered`); + }; + } + + render() { + this.sections.forEach(section => { + let rendered = this.renderSection(section); + if (rendered) { + this.root.appendChild(rendered); + } + }); + for (let i = 0; i < this._renderCallbacks.length; i++) { + this._renderCallbacks[i](); + } + // maintain a reference to child nodes so they can be cleaned up later by teardown + this._renderedChildNodes = []; + let node = this.root.firstChild; + while (node) { + this._renderedChildNodes.push(node); + node = node.nextSibling; + } + return { result: this.root, teardown: () => this.teardown() }; + } + + teardown() { + for (let i=0; i < this._teardownCallbacks.length; i++) { + this._teardownCallbacks[i](); + } + for (let i=0; i < this._renderedChildNodes.length; i++) { + let node = this._renderedChildNodes[i]; + if (node.parentNode) { + node.parentNode.removeChild(node); + } + } + } + + renderSection(section) { + const [type] = section; + switch (type) { + case MARKUP_SECTION_TYPE$1: + return this.renderMarkupSection(section); + case IMAGE_SECTION_TYPE$1: + return this.renderImageSection(section); + case LIST_SECTION_TYPE$1: + return this.renderListSection(section); + case CARD_SECTION_TYPE: + return this.renderCardSection(section); + default: + throw new Error(`Cannot render mobiledoc section of type "${type}"`); + } + } + + renderMarkersOnElement(element, markers) { + let elements = [element]; + let currentElement = element; + + let pushElement = (openedElement) => { + currentElement.appendChild(openedElement); + elements.push(openedElement); + currentElement = openedElement; + }; + + for (let i=0, l=markers.length; i { + element.appendChild(this.renderListItem(li)); + }); + return element; + } + + renderImageSection([type, src]) { + let element = this.dom.createElement(IMAGE_SECTION_TAG_NAME); + element.src = src; + return element; + } + + findCard(name) { + for (let i=0; i < this.cards.length; i++) { + if (this.cards[i].name === name) { + return this.cards[i]; + } + } + if (name === ImageCard$1.name) { + return ImageCard$1; + } + return this._createUnknownCard(name); + } + + _createUnknownCard(name) { + return { + name, + type: RENDER_TYPE, + render: this.unknownCardHandler + }; + } + + _createCardArgument(card, payload={}) { + let env = { + name: card.name, + isInEditor: false, + dom: this.dom, + didRender: (callback) => this._registerRenderCallback(callback), + onTeardown: (callback) => this._registerTeardownCallback(callback) + }; + + let options = this.cardOptions; + + return { env, options, payload }; + } + + _registerRenderCallback(callback) { + this._renderCallbacks.push(callback); + } + + _registerTeardownCallback(callback) { + this._teardownCallbacks.push(callback); + } + + renderCardSection([type, name, payload]) { + let card = this.findCard(name); + + let cardArg = this._createCardArgument(card, payload); + let rendered = card.render(cardArg); + + this._validateCardRender(rendered, card.name); + + return rendered; + } + + _validateCardRender(rendered, cardName) { + if (!rendered) { + return; + } + + if (typeof rendered !== 'object') { + throw new Error(`Card "${cardName}" must render ${RENDER_TYPE}, but result was "${rendered}"`); + } + } + + renderMarkupSection([type, tagName, markers]) { + tagName = tagName.toLowerCase(); + if (!isValidSectionTagName(tagName, MARKUP_SECTION_TYPE$1)) { + return; + } + + let renderer = this.sectionElementRendererFor(tagName); + let element = renderer(tagName, this.dom); + + this.renderMarkersOnElement(element, markers); + return element; + } + + sectionElementRendererFor(tagName) { + return this.sectionElementRenderer[tagName] || + this.sectionElementRenderer.__default__; + } +} + +const MARKUP_MARKER_TYPE = 0; +const ATOM_MARKER_TYPE = 1; + +const MOBILEDOC_VERSION_0_3_0 = '0.3.0'; +const MOBILEDOC_VERSION_0_3_1 = '0.3.1'; +const MOBILEDOC_VERSION_0_3_2 = '0.3.2'; + +const IMAGE_SECTION_TAG_NAME$1 = 'img'; + +function validateVersion$1(version) { + switch (version) { + case MOBILEDOC_VERSION_0_3_0: + case MOBILEDOC_VERSION_0_3_1: + case MOBILEDOC_VERSION_0_3_2: + return; + default: + throw new Error(`Unexpected Mobiledoc version "${version}"`); + } +} + +class Renderer$2 { + constructor(mobiledoc, state) { + + let { + cards, + cardOptions, + atoms, + unknownCardHandler, + unknownAtomHandler, + markupElementRenderer, + sectionElementRenderer, + dom + } = state; + let { + version, + sections, + atoms: atomTypes, + cards: cardTypes, + markups: markerTypes + } = mobiledoc; + validateVersion$1(version); + + this.dom = dom; + this.root = this.dom.createDocumentFragment(); + this.sections = sections; + this.atomTypes = atomTypes; + this.cardTypes = cardTypes; + this.markerTypes = markerTypes; + this.cards = cards; + this.atoms = atoms; + this.cardOptions = cardOptions; + this.unknownCardHandler = unknownCardHandler || this._defaultUnknownCardHandler; + this.unknownAtomHandler = unknownAtomHandler || this._defaultUnknownAtomHandler; + + this.sectionElementRenderer = { + '__default__': defaultSectionElementRenderer + }; + Object.keys(sectionElementRenderer).forEach(key => { + this.sectionElementRenderer[key.toLowerCase()] = sectionElementRenderer[key]; + }); + + this.markupElementRenderer = { + '__default__': defaultMarkupElementRenderer + }; + Object.keys(markupElementRenderer).forEach(key => { + this.markupElementRenderer[key.toLowerCase()] = markupElementRenderer[key]; + }); + + this._renderCallbacks = []; + this._teardownCallbacks = []; + } + + get _defaultUnknownCardHandler() { + return ({env: {name}}) => { + throw new Error(`Card "${name}" not found but no unknownCardHandler was registered`); + }; + } + + get _defaultUnknownAtomHandler() { + return ({env: {name}}) => { + throw new Error(`Atom "${name}" not found but no unknownAtomHandler was registered`); + }; + } + + render() { + this.sections.forEach(section => { + let rendered = this.renderSection(section); + if (rendered) { + this.root.appendChild(rendered); + } + }); + for (let i=0; i < this._renderCallbacks.length; i++) { + this._renderCallbacks[i](); + } + // maintain a reference to child nodes so they can be cleaned up later by teardown + this._renderedChildNodes = Array.prototype.slice.call(this.root.childNodes); + return { result: this.root, teardown: () => this.teardown() }; + } + + teardown() { + for (let i=0; i < this._teardownCallbacks.length; i++) { + this._teardownCallbacks[i](); + } + for (let i=0; i < this._renderedChildNodes.length; i++) { + let node = this._renderedChildNodes[i]; + if (node.parentNode) { + node.parentNode.removeChild(node); + } + } + } + + renderSection(section) { + const [type] = section; + switch (type) { + case MARKUP_SECTION_TYPE$1: + return this.renderMarkupSection(section); + case IMAGE_SECTION_TYPE$1: + return this.renderImageSection(section); + case LIST_SECTION_TYPE$1: + return this.renderListSection(section); + case CARD_SECTION_TYPE: + return this.renderCardSection(section); + default: + throw new Error(`Cannot render mobiledoc section of type "${type}"`); + } + } + + renderMarkersOnElement(element, markers) { + let elements = [element]; + let currentElement = element; + + let pushElement = (openedElement) => { + currentElement.appendChild(openedElement); + elements.push(openedElement); + currentElement = openedElement; + }; + + for (let i=0, l=markers.length; i { + element.appendChild(this.renderListItem(li)); + }); + return element; + } + + renderImageSection([type, src]) { + let element = this.dom.createElement(IMAGE_SECTION_TAG_NAME$1); + element.src = src; + return element; + } + + findCard(name) { + for (let i=0; i < this.cards.length; i++) { + if (this.cards[i].name === name) { + return this.cards[i]; + } + } + if (name === ImageCard$1.name) { + return ImageCard$1; + } + return this._createUnknownCard(name); + } + + _findCardByIndex(index) { + let cardType = this.cardTypes[index]; + if (!cardType) { + throw new Error(`No card definition found at index ${index}`); + } + + let [ name, payload ] = cardType; + let card = this.findCard(name); + + return { + card, + payload + }; + } + + _createUnknownCard(name) { + return { + name, + type: RENDER_TYPE, + render: this.unknownCardHandler + }; + } + + _createCardArgument(card, payload={}) { + let env = { + name: card.name, + isInEditor: false, + dom: this.dom, + didRender: (callback) => this._registerRenderCallback(callback), + onTeardown: (callback) => this._registerTeardownCallback(callback) + }; + + let options = this.cardOptions; + + return { env, options, payload }; + } + + _registerTeardownCallback(callback) { + this._teardownCallbacks.push(callback); + } + + _registerRenderCallback(callback) { + this._renderCallbacks.push(callback); + } + + renderCardSection([type, index]) { + let { card, payload } = this._findCardByIndex(index); + + let cardArg = this._createCardArgument(card, payload); + let rendered = card.render(cardArg); + + this._validateCardRender(rendered, card.name); + + return rendered; + } + + _validateCardRender(rendered, cardName) { + if (!rendered) { + return; + } + + if (typeof rendered !== 'object') { + throw new Error(`Card "${cardName}" must render ${RENDER_TYPE}, but result was "${rendered}"`); + } + } + + findAtom(name) { + for (let i=0; i < this.atoms.length; i++) { + if (this.atoms[i].name === name) { + return this.atoms[i]; + } + } + return this._createUnknownAtom(name); + } + + _createUnknownAtom(name) { + return { + name, + type: RENDER_TYPE, + render: this.unknownAtomHandler + }; + } + + _createAtomArgument(atom, value, payload) { + let env = { + name: atom.name, + isInEditor: false, + dom: this.dom, + onTeardown: (callback) => this._registerTeardownCallback(callback) + }; + + let options = this.cardOptions; + + return { env, options, value, payload }; + } + + _validateAtomRender(rendered, atomName) { + if (!rendered) { + return; + } + + if (typeof rendered !== 'object') { + throw new Error(`Atom "${atomName}" must render ${RENDER_TYPE}, but result was "${rendered}"`); + } + } + + _findAtomByIndex(index) { + let atomType = this.atomTypes[index]; + if (!atomType) { + throw new Error(`No atom definition found at index ${index}`); + } + + let [ name, value, payload ] = atomType; + let atom = this.findAtom(name); + + return { + atom, + value, + payload + }; + } + + _renderAtom(index) { + let { atom, value, payload } = this._findAtomByIndex(index); + + let atomArg = this._createAtomArgument(atom, value, payload); + let rendered = atom.render(atomArg); + + this._validateAtomRender(rendered, atom.name); + + return rendered || createTextNode(this.dom, ''); + } + + renderMarkupSection([type, tagName, markers, attributes = []]) { + tagName = tagName.toLowerCase(); + if (!isValidSectionTagName(tagName, MARKUP_SECTION_TYPE$1)) { + return; + } + + let attrsObj = reduceAttributes(attributes); + let renderer = this.sectionElementRendererFor(tagName); + let element = renderer(tagName, this.dom, attrsObj); + + this.renderMarkersOnElement(element, markers); + return element; + } + + sectionElementRendererFor(tagName) { + return this.sectionElementRenderer[tagName] || + this.sectionElementRenderer.__default__; + } +} + +/** + * runtime DOM renderer + * renders a mobiledoc to DOM + * + * input: mobiledoc + * output: DOM + */ + + function validateCards$1(cards) { + if (!Array.isArray(cards)) { + throw new Error('`cards` must be passed as an array'); + } + for (let i=0; i < cards.length; i++) { + let card = cards[i]; + if (card.type !== RENDER_TYPE) { + throw new Error(`Card "${card.name}" must be of type "${RENDER_TYPE}", was "${card.type}"`); + } + if (!card.render) { + throw new Error(`Card "${card.name}" must define \`render\``); + } + } + } + + function validateAtoms$1(atoms) { + if (!Array.isArray(atoms)) { + throw new Error('`atoms` must be passed as an array'); + } + for (let i=0; i < atoms.length; i++) { + let atom = atoms[i]; + if (atom.type !== RENDER_TYPE) { + throw new Error(`Atom "${atom.name}" must be type "${RENDER_TYPE}", was "${atom.type}"`); + } + if (!atom.render) { + throw new Error(`Atom "${atom.name}" must define \`render\``); + } + } + } + + class RendererFactory { + constructor({ + cards=[], + atoms=[], + cardOptions={}, + unknownCardHandler, + unknownAtomHandler, + markupElementRenderer={}, + sectionElementRenderer={}, + dom, + markupSanitizer=null + }={}) { + validateCards$1(cards); + validateAtoms$1(atoms); + + if (!dom) { + if (typeof window === 'undefined') { + throw new Error('A `dom` option must be provided to the renderer when running without window.document'); + } + dom = window.document; + } + + this.options = { + cards, + atoms, + cardOptions, + unknownCardHandler, + unknownAtomHandler, + markupElementRenderer, + sectionElementRenderer, + dom, + markupSanitizer + }; + } + + render(mobiledoc) { + let { version } = mobiledoc; + switch (version) { + case MOBILEDOC_VERSION$5: + case undefined: + case null: + return new Renderer$1(mobiledoc, this.options).render(); + case MOBILEDOC_VERSION_0_3_0: + case MOBILEDOC_VERSION_0_3_1: + case MOBILEDOC_VERSION_0_3_2: + return new Renderer$2(mobiledoc, this.options).render(); + default: + throw new Error(`Unexpected Mobiledoc version "${version}"`); + } + } + } + +var ImageCard$2 = { + name: 'image-card', + type: 'text', + render() {} +}; + +var RENDER_TYPE$1 = 'text'; + +const MARKUP_SECTION_TYPE$2 = 1; +const IMAGE_SECTION_TYPE$2 = 2; +const LIST_SECTION_TYPE$2 = 3; +const CARD_SECTION_TYPE$1 = 10; + +/** + * runtime Text renderer + * renders a mobiledoc to Text + * + * input: mobiledoc + * output: Text (string) + */ + +const LINE_BREAK = '\n'; + +const MOBILEDOC_VERSION$6 = '0.2.0'; + +function validateVersion$2(version) { + if (version !== MOBILEDOC_VERSION$6) { + throw new Error(`Unexpected Mobiledoc version "${version}"`); + } +} + +class Renderer$3 { + constructor(mobiledoc, state) { + let { cards, cardOptions, atoms, unknownCardHandler } = state; + let { version, sections: sectionData } = mobiledoc; + validateVersion$2(version); + + let [, sections] = sectionData; + + this.root = []; + this.sections = sections; + this.cards = cards; + this.atoms = atoms; + this.cardOptions = cardOptions; + this.unknownCardHandler = unknownCardHandler || this._defaultUnknownCardHandler; + + this._teardownCallbacks = []; + } + + render() { + this.sections.forEach(section => { + this.root.push(this.renderSection(section)); + }); + + let result = this.root.join(LINE_BREAK); + return { result, teardown: () => this.teardown() }; + } + + teardown() { + for (let i=0; i < this._teardownCallbacks.length; i++) { + this._teardownCallbacks[i](); + } + } + + get _defaultUnknownCardHandler() { + return () => { + // for the text renderer, a missing card is a no-op + }; + } + + renderSection(section) { + const [type] = section; + switch (type) { + case MARKUP_SECTION_TYPE$2: + return this.renderMarkupSection(section); + case IMAGE_SECTION_TYPE$2: + return this.renderImageSection(section); + case LIST_SECTION_TYPE$2: + return this.renderListSection(section); + case CARD_SECTION_TYPE$1: + return this.renderCardSection(section); + default: + throw new Error('Unimplemented renderer for type ' + type); + } + } + + renderImageSection() { + return ''; + } + + renderListSection([type, tagName, items]) { + return items.map( + li => this.renderListItem(li) + ).join(LINE_BREAK); + } + + renderListItem(markers) { + return this.renderMarkers(markers); + } + + findCard(name) { + for (let i=0; i < this.cards.length; i++) { + if (this.cards[i].name === name) { + return this.cards[i]; + } + } + if (name === ImageCard$2.name) { + return ImageCard$2; + } + return this._createUnknownCard(name); + } + + _createUnknownCard(name) { + return { + name, + type: RENDER_TYPE$1, + render: this.unknownCardHandler + }; + } + + renderCardSection([type, name, payload]) { + let card = this.findCard(name); + + let cardArg = this._createCardArgument(card, payload); + let rendered = card.render(cardArg); + + this._validateCardRender(rendered, card.name); + + return rendered || ''; + } + + _validateCardRender(rendered, cardName) { + if (!rendered) { + return; + } + + if (typeof rendered !== 'string') { + throw new Error(`Card "${cardName}" must render ${RENDER_TYPE$1}, but result was ${typeof rendered}"`); + } + } + + _registerTeardownCallback(callback) { + this._teardownCallbacks.push(callback); + } + + _createCardArgument(card, payload={}) { + let env = { + name: card.name, + isInEditor: false, + onTeardown: (callback) => this._registerTeardownCallback(callback) + }; + + let options = this.cardOptions; + + return { env, options, payload }; + } + + renderMarkupSection([type, tagName, markers]) { + return this.renderMarkers(markers); + } + + renderMarkers(markers) { + let str = ''; + markers.forEach(m => { + let [, , text] = m; + str += text; + }); + return str; + } +} + +const MARKUP_MARKER_TYPE$1 = 0; +const ATOM_MARKER_TYPE$1 = 1; + +/** + * runtime Text renderer + * renders a mobiledoc to Text + * + * input: mobiledoc + * output: Text (string) + */ + +const LINE_BREAK$1 = '\n'; + +const MOBILEDOC_VERSION_0_3 = '0.3.0'; +const MOBILEDOC_VERSION_0_3_1$1 = '0.3.1'; +const MOBILEDOC_VERSION_0_3_2$1 = '0.3.2'; + +function validateVersion$3(version) { + if ( + version !== MOBILEDOC_VERSION_0_3 && + version !== MOBILEDOC_VERSION_0_3_1$1 && + version !== MOBILEDOC_VERSION_0_3_2$1 + ) { + throw new Error(`Unexpected Mobiledoc version "${version}"`); + } +} + +class Renderer$4 { + constructor(mobiledoc, state) { + + let { cards, cardOptions, atoms, unknownCardHandler, unknownAtomHandler } = state; + let { version, sections, atoms: atomTypes, cards: cardTypes } = mobiledoc; + validateVersion$3(version); + + this.root = []; + this.sections = sections; + this.atomTypes = atomTypes; + this.cardTypes = cardTypes; + this.cards = cards; + this.atoms = atoms; + this.cardOptions = cardOptions; + this.unknownCardHandler = unknownCardHandler || this._defaultUnknownCardHandler; + this.unknownAtomHandler = unknownAtomHandler || this._defaultUnknownAtomHandler; + + this._teardownCallbacks = []; + } + + render() { + this.sections.forEach(section => { + this.root.push(this.renderSection(section)); + }); + + let result = this.root.join(LINE_BREAK$1); + return { result, teardown: () => this.teardown() }; + } + + teardown() { + for (let i=0; i < this._teardownCallbacks.length; i++) { + this._teardownCallbacks[i](); + } + } + + get _defaultUnknownCardHandler() { + return () => { + // for the text renderer, a missing card is a no-op + }; + } + + get _defaultUnknownAtomHandler() { + return ({ value }) => { + return value || ''; + }; + } + + renderSection(section) { + const [type] = section; + switch (type) { + case MARKUP_SECTION_TYPE$2: + return this.renderMarkupSection(section); + case IMAGE_SECTION_TYPE$2: + return this.renderImageSection(section); + case LIST_SECTION_TYPE$2: + return this.renderListSection(section); + case CARD_SECTION_TYPE$1: + return this.renderCardSection(section); + default: + throw new Error('Unimplemented renderer for type ' + type); + } + } + + renderImageSection() { + return ''; + } + + renderListSection([type, tagName, items]) { + return items.map( + li => this.renderListItem(li) + ).join(LINE_BREAK$1); + } + + renderListItem(markers) { + return this.renderMarkers(markers); + } + + findCard(name) { + for (let i=0; i < this.cards.length; i++) { + if (this.cards[i].name === name) { + return this.cards[i]; + } + } + if (name === ImageCard$2.name) { + return ImageCard$2; + } + return this._createUnknownCard(name); + } + + _findCardByIndex(index) { + let cardType = this.cardTypes[index]; + if (!cardType) { + throw new Error(`No card definition found at index ${index}`); + } + + let [ name, payload ] = cardType; + let card = this.findCard(name); + + return { + card, + payload + }; + } + + _createUnknownCard(name) { + return { + name, + type: RENDER_TYPE$1, + render: this.unknownCardHandler + }; + } + + renderCardSection([type, index]) { + let { card, payload } = this._findCardByIndex(index); + + let cardArg = this._createCardArgument(card, payload); + let rendered = card.render(cardArg); + + this._validateCardRender(rendered, card.name); + + return rendered || ''; + } + + _validateCardRender(rendered, cardName) { + if (!rendered) { + return; + } + + if (typeof rendered !== 'string') { + throw new Error(`Card "${cardName}" must render ${RENDER_TYPE$1}, but result was ${typeof rendered}"`); + } + } + + _registerTeardownCallback(callback) { + this._teardownCallbacks.push(callback); + } + + _createCardArgument(card, payload={}) { + let env = { + name: card.name, + isInEditor: false, + onTeardown: (callback) => this._registerTeardownCallback(callback) + }; + + let options = this.cardOptions; + + return { env, options, payload }; + } + + renderMarkupSection([type, tagName, markers]) { + return this.renderMarkers(markers); + } + + findAtom(name) { + for (let i=0; i < this.atoms.length; i++) { + if (this.atoms[i].name === name) { + return this.atoms[i]; + } + } + return this._createUnknownAtom(name); + } + + _createUnknownAtom(name) { + return { + name, + type: RENDER_TYPE$1, + render: this.unknownAtomHandler + }; + } + + _createAtomArgument(atom, value, payload) { + let env = { + name: atom.name, + onTeardown: (callback) => this._registerTeardownCallback(callback) + }; + + let options = this.cardOptions; + + return { env, options, value, payload }; + } + + _validateAtomRender(rendered, atomName) { + if (!rendered) { + return; + } + + if (typeof rendered !== 'string') { + throw new Error(`Atom "${atomName}" must render ${RENDER_TYPE$1}, but result was ${typeof rendered}"`); + } + } + + _findAtomByIndex(index) { + let atomType = this.atomTypes[index]; + if (!atomType) { + throw new Error(`No atom definition found at index ${index}`); + } + + let [ name, value, payload ] = atomType; + let atom = this.findAtom(name); + + return { + atom, + value, + payload + }; + } + + _renderAtom(index) { + let { atom, value, payload } = this._findAtomByIndex(index); + + let atomArg = this._createAtomArgument(atom, value, payload); + let rendered = atom.render(atomArg); + + this._validateAtomRender(rendered, atom.name); + + return rendered || ''; + } + + renderMarkers(markers) { + let str = ''; + markers.forEach(m => { + let [type, , , value] = m; + switch (type) { + case MARKUP_MARKER_TYPE$1: + str += value; + break; + case ATOM_MARKER_TYPE$1: + str += this._renderAtom(value); + break; + default: + throw new Error(`Unknown markup type (${type})`); + } + }); + return str; + } +} + +/** + * runtime Text renderer + * renders a mobiledoc to Text + * + * input: mobiledoc + * output: Text (string) + */ + +function validateCards$2(cards) { + if (!Array.isArray(cards)) { + throw new Error('`cards` must be passed as an array'); + } + for (let i=0; i < cards.length; i++) { + let card = cards[i]; + if (card.type !== RENDER_TYPE$1) { + throw new Error(`Card "${card.name}" must be type "${RENDER_TYPE$1}", was "${card.type}"`); + } + if (!card.render) { + throw new Error(`Card "${card.name}" must define \`render\``); + } + } +} + +function validateAtoms$2(atoms) { + if (!Array.isArray(atoms)) { + throw new Error('`atoms` must be passed as an array'); + } + for (let i=0; i < atoms.length; i++) { + let atom = atoms[i]; + if (atom.type !== RENDER_TYPE$1) { + throw new Error(`Atom "${atom.name}" must be type "${RENDER_TYPE$1}", was "${atom.type}"`); + } + if (!atom.render) { + throw new Error(`Atom "${atom.name}" must define \`render\``); + } + } +} + +class RendererFactory$1 { + constructor({cards, atoms, cardOptions, unknownCardHandler, unknownAtomHandler}={}) { + cards = cards || []; + validateCards$2(cards); + atoms = atoms || []; + validateAtoms$2(atoms); + cardOptions = cardOptions || {}; + + this.state = {cards, atoms, cardOptions, unknownCardHandler, unknownAtomHandler}; + } + + render(mobiledoc) { + let { version } = mobiledoc; + switch (version) { + case MOBILEDOC_VERSION$6: + return new Renderer$3(mobiledoc, this.state).render(); + case undefined: + case null: + case MOBILEDOC_VERSION_0_3: + case MOBILEDOC_VERSION_0_3_1$1: + case MOBILEDOC_VERSION_0_3_2$1: + return new Renderer$4(mobiledoc, this.state).render(); + default: + throw new Error(`Unexpected Mobiledoc version "${version}"`); + } + } +} + +class Logger { + constructor(type, manager) { + this.type = type; + this.manager = manager; + } + + isEnabled() { + return this.manager.isEnabled(this.type); + } + + log(...args) { + args.unshift(`[${this.type}]`); + if (this.isEnabled()) { + window.console.log(...args); + } + } +} + +class LogManager { + constructor() { + this.enabledTypes = []; + this.allEnabled = false; + } + + for(type) { + return new Logger(type, this); + } + + enableAll() { + this.allEnabled = true; + } + + enableTypes(types) { + this.enabledTypes = this.enabledTypes.concat(types); + } + + disable() { + this.enabledTypes = []; + this.allEnabled = false; + } + + isEnabled(type) { + return this.allEnabled || this.enabledTypes.indexOf(type) !== -1; + } +} + +const defaults = { + placeholder: 'Write here...', + spellcheck: true, + autofocus: true, + showLinkTooltips: true, + undoDepth: 5, + undoBlockTimeout: 5000, // ms for an undo event + cards: [], + atoms: [], + cardOptions: {}, + unknownCardHandler: ({env}) => { + throw new MobiledocError(`Unknown card encountered: ${env.name}`); + }, + unknownAtomHandler: ({env}) => { + throw new MobiledocError(`Unknown atom encountered: ${env.name}`); + }, + mobiledoc: null, + html: null +}; + +const CALLBACK_QUEUES$1 = { + DID_UPDATE: 'didUpdate', + WILL_RENDER: 'willRender', + DID_RENDER: 'didRender', + WILL_DELETE: 'willDelete', + DID_DELETE: 'didDelete', + WILL_HANDLE_NEWLINE: 'willHandleNewline', + CURSOR_DID_CHANGE: 'cursorDidChange', + DID_REPARSE: 'didReparse', + POST_DID_CHANGE: 'postDidChange', + INPUT_MODE_DID_CHANGE: 'inputModeDidChange' +}; + +/** + * The Editor is a core component of mobiledoc-kit. After instantiating + * an editor, use {@link Editor#render} to display the editor on the web page. + * + * An editor uses a {@link Post} internally to represent the displayed document. + * The post can be serialized as mobiledoc using {@link Editor#serialize}. Mobiledoc + * is the transportable "over-the-wire" format (JSON) that is suited for persisting + * and sharing between editors and renderers (for display, e.g.), whereas the Post + * model is better suited for programmatic editing. + * + * The editor will call registered callbacks for certain state changes. These are: + * * {@link Editor#cursorDidChange} -- The cursor position or selection changed. + * * {@link Editor#postDidChange} -- The contents of the post changed due to user input or + * programmatic editing. This hook can be used with {@link Editor#serialize} + * to auto-save a post as it is being edited. + * * {@link Editor#inputModeDidChange} -- The active section(s) or markup(s) at the current cursor + * position or selection have changed. This hook can be used with + * {@link Editor#activeMarkups} and {@link Editor#activeSections} to implement + * a custom toolbar. + * * {@link Editor#onTextInput} -- Register callbacks when the user enters text + * that matches a given string or regex. + * * {@link Editor#beforeToggleMarkup} -- Register callbacks that will be run before + * applying changes from {@link Editor#toggleMarkup} + */ +class Editor { + /** + * @param {Object} [options] + * @param {Object} [options.mobiledoc] The mobiledoc to load into the editor. + * Supersedes `options.html`. + * @param {String|DOM} [options.html] The html (as a string or DOM fragment) + * to parse and load into the editor. + * Will be ignored if `options.mobiledoc` is also passed. + * @param {Array} [options.parserPlugins=[]] + * @param {Array} [options.cards=[]] The cards that the editor may render. + * @param {Array} [options.atoms=[]] The atoms that the editor may render. + * @param {Function} [options.unknownCardHandler] Invoked by the editor's renderer + * whenever it encounters an unknown card. + * @param {Function} [options.unknownAtomHandler] Invoked by the editor's renderer + * whenever it encounters an unknown atom. + * @param {String} [options.placeholder] Default text to show before user starts typing. + * @param {Boolean} [options.spellcheck=true] Whether to enable spellcheck + * @param {Boolean} [options.autofocus=true] Whether to focus the editor when it is first rendered. + * @param {Boolean} [options.showLinkTooltips=true] Whether to show the url tooltip for links + * @param {number} [options.undoDepth=5] How many undo levels will be available. + * Set to 0 to disable undo/redo functionality. + * @return {Editor} + * @public + */ + constructor(options={}) { + assert('editor create accepts an options object. For legacy usage passing an element for the first argument, consider the `html` option for loading DOM or HTML posts. For other cases call `editor.render(domNode)` after editor creation', + (options && !options.nodeType)); + this._views = []; + this.isEditable = true; + this._parserPlugins = options.parserPlugins || []; + + // FIXME: This should merge onto this.options + mergeWithOptions(this, defaults, options); + this.cards.push(ImageCard); + + DEFAULT_KEY_COMMANDS.forEach(kc => this.registerKeyCommand(kc)); + + this._logManager = new LogManager(); + this._parser = new DOMParser(this.builder); + let {cards, atoms, unknownCardHandler, unknownAtomHandler, cardOptions} = this; + this._renderer = new Renderer(this, cards, atoms, unknownCardHandler, unknownAtomHandler, cardOptions); + + this.post = this.loadPost(); + this._renderTree = new RenderTree(this.post); + + this._editHistory = new EditHistory(this, this.undoDepth, this.undoBlockTimeout); + this._eventManager = new EventManager(this); + this._mutationHandler = new MutationHandler(this); + this._editState = new EditState(this); + this._callbacks = new LifecycleCallbacks(values(CALLBACK_QUEUES$1)); + this._beforeHooks = { toggleMarkup: [] }; + + DEFAULT_TEXT_INPUT_HANDLERS.forEach(handler => this.onTextInput(handler)); + + this.hasRendered = false; + } + + /** + * Turns on verbose logging for the editor. + * @param {Array} [logTypes=[]] If present, only the given log types will be logged. + * @public + */ + enableLogging(logTypes=[]) { + if (logTypes.length === 0) { + this._logManager.enableAll(); + } else { + this._logManager.enableTypes(logTypes); + } + } + + /** + * Disable all logging + * @public + */ + disableLogging() { + this._logManager.disable(); + } + + /** + * @private + */ + loggerFor(type) { + return this._logManager.for(type); + } + + /** + * The editor's instance of a post node builder. + * @type {PostNodeBuilder} + */ + get builder() { + if (!this._builder) { this._builder = new PostNodeBuilder(); } + return this._builder; + } + + loadPost() { + let {mobiledoc, html} = this; + if (mobiledoc) { + return mobiledocParsers.parse(this.builder, mobiledoc); + } else if (html) { + if (typeof html === 'string') { + let options = {plugins: this._parserPlugins}; + return new HTMLParser(this.builder, options).parse(this.html); + } else { + let dom = html; + return this._parser.parse(dom); + } + } else { + return this.builder.createPost(); + } + } + + rerender() { + let postRenderNode = this.post.renderNode; + + // if we haven't rendered this post's renderNode before, mark it dirty + if (!postRenderNode.element) { + assert('Must call `render` before `rerender` can be called', + this.hasRendered); + postRenderNode.element = this.element; + postRenderNode.markDirty(); + } + + this.runCallbacks(CALLBACK_QUEUES$1.WILL_RENDER); + this._mutationHandler.suspendObservation(() => { + this._renderer.render(this._renderTree); + }); + this.runCallbacks(CALLBACK_QUEUES$1.DID_RENDER); + } + + /** + * @param {Element} element The DOM element to render into. + * Its contents will be replaced by the editor's rendered post. + * @public + */ + render(element) { + assert('Cannot render an editor twice. Use `rerender` to update the ' + + 'rendering of an existing editor instance.', + !this.hasRendered); + + element.spellcheck = this.spellcheck; + + clearChildNodes(element); + + this.element = element; + + if (this.showLinkTooltips) { + this._addTooltip(); + } + + // A call to `run` will trigger the didUpdatePostCallbacks hooks with a + // postEditor. + this.run(() => {}); + + // Only set `hasRendered` to true after calling `run` to ensure that + // no cursorDidChange or other callbacks get fired before the editor is + // done rendering + this.hasRendered = true; + this.rerender(); + + this._mutationHandler.init(); + this._eventManager.init(); + + if (this.isEditable === false) { + this.disableEditing(); + } else { + this.enableEditing(); + } + + if (this.autofocus) { + this.selectRange(this.post.headPosition()); + } + } + + _addTooltip() { + this.addView(new Tooltip({ + rootElement: this.element, + showForTag: 'a' + })); + } + + get keyCommands() { + if (!this._keyCommands) { this._keyCommands = []; } + return this._keyCommands; + } + + /** + * @param {Object} keyCommand The key command to register. It must specify a + * modifier key (meta, ctrl, etc), a string representing the ascii key, and + * a `run` method that will be passed the editor instance when the key command + * is invoked + * @public + */ + registerKeyCommand(rawKeyCommand) { + const keyCommand = buildKeyCommand(rawKeyCommand); + assert('Key Command is not valid', validateKeyCommand(keyCommand)); + this.keyCommands.unshift(keyCommand); + } + + /** + * @param {String} name If the keyCommand event has a name attribute it can be removed. + * @public + */ + unregisterKeyCommands(name) { + for(let i = this.keyCommands.length-1; i > -1; i--) { + let keyCommand = this.keyCommands[i]; + + if(keyCommand.name === name) { + this.keyCommands.splice(i,1); + } + } + } + + /** + * Convenience for {@link PostEditor#deleteAtPosition}. Deletes and puts the + * cursor in the new position. + * @public + */ + deleteAtPosition(position, direction, {unit}) { + this.run(postEditor => { + let nextPosition = postEditor.deleteAtPosition(position, direction, {unit}); + postEditor.setRange(nextPosition); + }); + } + + /** + * Convenience for {@link PostEditor#deleteRange}. Deletes and puts the + * cursor in the new position. + * @param {Range} range + * @public + */ + deleteRange(range) { + this.run(postEditor => { + let nextPosition = postEditor.deleteRange(range); + postEditor.setRange(nextPosition); + }); + } + + /** + * @private + */ + performDelete({direction, unit}={direction: DIRECTION.BACKWARD, unit: 'char'}) { + let { range } = this; + + this.runCallbacks(CALLBACK_QUEUES$1.WILL_DELETE, [range, direction, unit]); + if (range.isCollapsed) { + this.deleteAtPosition(range.head, direction, {unit}); + } else { + this.deleteRange(range); + } + this.runCallbacks(CALLBACK_QUEUES$1.DID_DELETE, [range, direction, unit]); + } + + handleNewline(event) { + if (!this.hasCursor()) { return; } + + event.preventDefault(); + + let { range } = this; + this.run(postEditor => { + let cursorSection; + if (!range.isCollapsed) { + let nextPosition = postEditor.deleteRange(range); + cursorSection = nextPosition.section; + if (cursorSection && cursorSection.isBlank) { + postEditor.setRange(cursorSection.headPosition()); + return; + } + } + + // Above logic might delete redundant range, so callback must run after it. + let defaultPrevented = false; + const event = { preventDefault() { defaultPrevented = true; } }; + this.runCallbacks(CALLBACK_QUEUES$1.WILL_HANDLE_NEWLINE, [event]); + if (defaultPrevented) { return; } + + cursorSection = postEditor.splitSection(range.head)[1]; + postEditor.setRange(cursorSection.headPosition()); + }); + } + + /** + * Notify the editor that the post did change, and run associated + * callbacks. + * @private + */ + _postDidChange() { + this.runCallbacks(CALLBACK_QUEUES$1.POST_DID_CHANGE); + } + + /** + * Selects the given range or position. If given a collapsed range or a position, this positions the cursor + * at the range's position. Otherwise a selection is created in the editor + * surface encompassing the range. + * @param {Range|Position} range + */ + selectRange(range) { + range = toRange(range); + + this.cursor.selectRange(range); + this.range = range; + } + + get cursor() { + return new Cursor(this); + } + + /** + * Return the current range for the editor (may be cached). + * @return {Range} + */ + get range() { + return this._editState.range; + } + + set range(newRange) { + this._editState.updateRange(newRange); + + if (this._editState.rangeDidChange()) { + this._rangeDidChange(); + } + + if (this._editState.inputModeDidChange()) { + this._inputModeDidChange(); + } + } + + _readRangeFromDOM() { + this.range = this.cursor.offsets; + } + + setPlaceholder(placeholder) { + setData(this.element, 'placeholder', placeholder); + } + + _reparsePost() { + let post = this._parser.parse(this.element); + this.run(postEditor => { + postEditor.removeAllSections(); + postEditor.migrateSectionsFromPost(post); + postEditor.setRange(Range.blankRange()); + }); + + this.runCallbacks(CALLBACK_QUEUES$1.DID_REPARSE); + this._postDidChange(); + } + + _reparseSections(sections=[]) { + let currentRange; + sections.forEach(section => { + this._parser.reparseSection(section, this._renderTree); + }); + this._removeDetachedSections(); + + if (this._renderTree.isDirty) { + currentRange = this.range; + } + + // force the current snapshot's range to remain the same rather than + // rereading it from DOM after the new character is applied and the browser + // updates the cursor position + let range = this._editHistory._pendingSnapshot.range; + this.run(() => { + this._editHistory._pendingSnapshot.range = range; + }); + this.rerender(); + if (currentRange) { + this.selectRange(currentRange); + } + + this.runCallbacks(CALLBACK_QUEUES$1.DID_REPARSE); + this._postDidChange(); + } + + // FIXME this should be able to be removed now -- if any sections are detached, + // it's due to a bug in the code. + _removeDetachedSections() { + forEach( + filter(this.post.sections, s => !s.renderNode.isAttached()), + s => s.renderNode.scheduleForRemoval() + ); + } + + /** + * The sections from the cursor's selection start to the selection end + * @type {Section[]} + */ + get activeSections() { + return this._editState.activeSections; + } + + get activeSection() { + const { activeSections } = this; + return activeSections[activeSections.length - 1]; + } + + get activeSectionAttributes() { + return this._editState.activeSectionAttributes; + } + + detectMarkupInRange(range, markupTagName) { + let markups = this.post.markupsInRange(range); + return detect(markups, markup => { + return markup.hasTag(markupTagName); + }); + } + + /** + * @type {Markup[]} + * @public + */ + get activeMarkups() { + return this._editState.activeMarkups; + } + + /** + * @param {Markup|String} markup A markup instance, or a string (e.g. "b") + * @return {boolean} + */ + hasActiveMarkup(markup) { + let matchesFn; + if (typeof markup === 'string') { + let tagName = normalizeTagName(markup); + matchesFn = (m) => m.tagName === tagName; + } else { + matchesFn = (m) => m === markup; + } + + return !!detect(this.activeMarkups, matchesFn); + } + + /** + * @param {String} version The mobiledoc version to serialize to. + * @return {Mobiledoc} Serialized mobiledoc + * @public + */ + serialize(version=MOBILEDOC_VERSION$4) { + return this.serializePost(this.post, 'mobiledoc', {version}); + } + + /** + * Serialize the editor's post to the requested format. + * Note that only mobiledoc format is lossless. If cards or atoms are present + * in the post, the html and text formats will omit them in output because + * the editor does not have access to the html and text versions of the + * cards/atoms. + * @param {string} format The format to serialize ('mobiledoc', 'text', 'html') + * @return {Object|String} The editor's post, serialized to {format} + * @public + */ + serializeTo(format) { + let post = this.post; + return this.serializePost(post, format); + } + + /** + * @param {Post} + * @param {String} format Same as {serializeTo} + * @param {Object} [options] + * @param {String} [options.version=MOBILEDOC_VERSION] version to serialize to + * @return {Object|String} + * @private + */ + serializePost(post, format, options={}) { + const validFormats = ['mobiledoc', 'html', 'text']; + assert(`Unrecognized serialization format ${format}`, + contains(validFormats, format)); + + if (format === 'mobiledoc') { + let version = options.version || MOBILEDOC_VERSION$4; + return mobiledocRenderers.render(post, version); + } else { + let rendered; + let mobiledoc = this.serializePost(post, 'mobiledoc'); + let unknownCardHandler = () => {}; + let unknownAtomHandler = () => {}; + let rendererOptions = { unknownCardHandler, unknownAtomHandler }; + + switch (format) { + case 'html': { + let result; + if (Environment.hasDOM()) { + rendered = new RendererFactory(rendererOptions).render(mobiledoc); + result = `
    ${serializeHTML(rendered.result)}
    `; + } else { + // Fallback to text serialization + result = this.serializePost(post, 'text', options); + } + return result; + } + case 'text': + rendered = new RendererFactory$1(rendererOptions).render(mobiledoc); + return rendered.result; + } + } + } + + addView(view) { + this._views.push(view); + } + + removeAllViews() { + this._views.forEach((v) => v.destroy()); + this._views = []; + } + + /** + * Whether the editor has a cursor (or a selected range). + * It is possible for the editor to be focused but not have a selection. + * In this case, key events will fire but the editor will not be able to + * determine a cursor position, so they will be ignored. + * @return {boolean} + * @public + */ + hasCursor() { + return this.cursor.hasCursor(); + } + + /** + * Tears down the editor's attached event listeners and views. + * @public + */ + destroy() { + this.isDestroyed = true; + if (this._hasSelection()) { + this.cursor.clearSelection(); + } + if (this._hasFocus()) { + this.element.blur(); // FIXME This doesn't blur the element on IE11 + } + this._mutationHandler.destroy(); + this._eventManager.destroy(); + this.removeAllViews(); + this._renderer.destroy(); + this._editState.destroy(); + } + + /** + * Keep the user from directly editing the post using the keyboard and mouse. + * Modification via the programmatic API is still permitted. + * @see Editor#enableEditing + * @public + */ + disableEditing() { + this.isEditable = false; + if (this.hasRendered) { + this._eventManager.stop(); + this.element.setAttribute('contentEditable', false); + this.setPlaceholder(''); + this.selectRange(Range.blankRange()); + } + } + + /** + * Allow the user to directly interact with editing a post via keyboard and mouse input. + * Editor instances are editable by default. Use this method to re-enable + * editing after disabling it. + * @see Editor#disableEditing + * @public + */ + enableEditing() { + this.isEditable = true; + if (this.hasRendered) { + this._eventManager.start(); + this.element.setAttribute('contentEditable', true); + this.setPlaceholder(this.placeholder); + } + } + + /** + * Change a cardSection into edit mode + * If called before the card has been rendered, it will be marked so that + * it is rendered in edit mode when it gets rendered. + * @param {CardSection} cardSection + * @public + */ + editCard(cardSection) { + this._setCardMode(cardSection, CARD_MODES.EDIT); + } + + /** + * Change a cardSection into display mode + * If called before the card has been rendered, it will be marked so that + * it is rendered in display mode when it gets rendered. + * @param {CardSection} cardSection + * @return undefined + * @public + */ + displayCard(cardSection) { + this._setCardMode(cardSection, CARD_MODES.DISPLAY); + } + + /** + * Run a new post editing session. Yields a block with a new {@link PostEditor} + * instance. This instance can be used to interact with the post abstract. + * Rendering will be deferred until after the callback is completed. + * + * Usage: + * ``` + * let markerRange = this.range; + * editor.run((postEditor) => { + * postEditor.deleteRange(markerRange); + * // editing surface not updated yet + * postEditor.schedule(() => { + * console.log('logs during rerender flush'); + * }); + * // logging not yet flushed + * }); + * // editing surface now updated. + * // logging now flushed + * ``` + * + * @param {Function} callback Called with an instance of + * {@link PostEditor} as its argument. + * @return {Mixed} The return value of `callback`. + * @public + */ + run(callback) { + const postEditor = new PostEditor(this); + postEditor.begin(); + this._editHistory.snapshot(); + const result = callback(postEditor); + this.runCallbacks(CALLBACK_QUEUES$1.DID_UPDATE, [postEditor]); + postEditor.complete(); + this._readRangeFromDOM(); + + if (postEditor._shouldCancelSnapshot) { + this._editHistory._pendingSnapshot = null; + } + this._editHistory.storeSnapshot(postEditor.editActionTaken); + + return result; + } + + /** + * @param {Function} callback Called with `postEditor` as its argument. + * @public + */ + didUpdatePost(callback) { + this.addCallback(CALLBACK_QUEUES$1.DID_UPDATE, callback); + } + + /** + * @param {Function} callback Called when the post has changed, either via + * user input or programmatically. Use with {@link Editor#serialize} to + * retrieve the post in portable mobiledoc format. + */ + postDidChange(callback) { + this.addCallback(CALLBACK_QUEUES$1.POST_DID_CHANGE, callback); + } + + /** + * Register a handler that will be invoked by the editor after the user enters + * matching text. + * @param {Object} inputHandler + * @param {String} inputHandler.name Required. Used by identifying handlers. + * @param {String} [inputHandler.text] Required if `match` is not provided + * @param {RegExp} [inputHandler.match] Required if `text` is not provided + * @param {Function} inputHandler.run This callback is invoked with the {@link Editor} + * instance and an array of matches. If `text` was provided, + * the matches array will equal [`text`], and if a `match` + * regex was provided the matches array will be the result of + * `match.exec` on the matching text. The callback is called + * after the matching text has been inserted. + * @public + */ + onTextInput(inputHandler) { + this._eventManager.registerInputHandler(inputHandler); + } + + /** + * Unregister all text input handlers + * + * @public + */ + unregisterAllTextInputHandlers() { + this._eventManager.unregisterAllTextInputHandlers(); + } + + /** + * Unregister text input handler by name + * @param {String} name The name of handler to be removed + * + * @public + */ + unregisterTextInputHandler(name) { + this._eventManager.unregisterInputHandler(name); + } + + /** + * @param {Function} callback Called when the editor's state (active markups or + * active sections) has changed, either via user input or programmatically + */ + inputModeDidChange(callback) { + this.addCallback(CALLBACK_QUEUES$1.INPUT_MODE_DID_CHANGE, callback); + } + + /** + * @param {Function} callback This callback will be called before the editor + * is rendered. + * @public + */ + willRender(callback) { + this.addCallback(CALLBACK_QUEUES$1.WILL_RENDER, callback); + } + + /** + * @param {Function} callback This callback will be called after the editor + * is rendered. + * @public + */ + didRender(callback) { + this.addCallback(CALLBACK_QUEUES$1.DID_RENDER, callback); + } + + /** + * @param {Function} callback This callback will be called before deleting. + * @public + */ + willDelete(callback) { + this.addCallback(CALLBACK_QUEUES$1.WILL_DELETE, callback); + } + + /** + * @param {Function} callback This callback will be called after deleting. + * @public + */ + didDelete(callback) { + this.addCallback(CALLBACK_QUEUES$1.DID_DELETE, callback); + } + + /** + * @param {Function} callback This callback will be called before handling new line. + * @public + */ + willHandleNewline(callback) { + this.addCallback(CALLBACK_QUEUES$1.WILL_HANDLE_NEWLINE, callback); + } + + /** + * @param {Function} callback This callback will be called every time the cursor + * position (or selection) changes. + * @public + */ + cursorDidChange(callback) { + this.addCallback(CALLBACK_QUEUES$1.CURSOR_DID_CHANGE, callback); + } + + _rangeDidChange() { + if (this.hasRendered) { + this.runCallbacks(CALLBACK_QUEUES$1.CURSOR_DID_CHANGE); + } + } + + _inputModeDidChange() { + this.runCallbacks(CALLBACK_QUEUES$1.INPUT_MODE_DID_CHANGE); + } + + _insertEmptyMarkupSectionAtCursor() { + this.run(postEditor => { + const section = postEditor.builder.createMarkupSection('p'); + postEditor.insertSectionBefore(this.post.sections, section); + postEditor.setRange(section.toRange()); + }); + } + + /** + * @callback editorBeforeCallback + * @param { Object } details + * @param { Markup } details.markup + * @param { Range } details.range + * @param { boolean } details.willAdd Whether the markup will be applied + */ + + /** + * Register a callback that will be run before {@link Editor#toggleMarkup} is applied. + * If any callback returns literal `false`, the toggling of markup will be canceled. + * Note this only applies to calling `editor#toggleMarkup`. Using `editor.run` and + * modifying markup with the `postEditor` will skip any `beforeToggleMarkup` callbacks. + * @param {editorBeforeCallback} + */ + beforeToggleMarkup(callback) { + this._beforeHooks.toggleMarkup.push(callback); + } + + /** + * Toggles the given markup at the editor's current {@link Range}. + * If the range is collapsed this changes the editor's state so that the + * next characters typed will be affected. If there is text selected + * (aka a non-collapsed range), the selections' markup will be toggled. + * If the editor is not focused and has no active range, nothing happens. + * Hooks added using #beforeToggleMarkup will be run before toggling, + * and if any of them returns literal false, toggling the markup will be canceled + * and no change will be applied. + * @param {String} markup E.g. "b", "em", "a" + * @param {Object} [attributes={}] E.g. {href: "http://bustle.com"} + * @public + * @see PostEditor#toggleMarkup + */ + toggleMarkup(markup, attributes={}) { + markup = this.builder.createMarkup(markup, attributes); + let { range } = this; + let willAdd = !this.detectMarkupInRange(range, markup.tagName); + let shouldCancel = this._runBeforeHooks('toggleMarkup', {markup, range, willAdd}); + if (shouldCancel) { return; } + + if (range.isCollapsed) { + this._editState.toggleMarkupState(markup); + this._inputModeDidChange(); + + // when clicking a button to toggle markup, the button can end up being focused, + // so ensure the editor is focused + this._ensureFocus(); + } else { + this.run(postEditor => postEditor.toggleMarkup(markup, range)); + } + } + + // If the editor has a selection but is not focused, focus it + _ensureFocus() { + if (this._hasSelection() && !this._hasFocus()) { + this.focus(); + } + } + + focus() { + this.element.focus(); + } + + /** + * Whether there is a selection inside the editor's element. + * It's possible to have a selection but not have focus. + * @see #_hasFocus + * @return {Boolean} + */ + _hasSelection() { + let { cursor } = this; + return this.hasRendered && (cursor._hasCollapsedSelection() || cursor._hasSelection()); + } + + /** + * Whether the editor's element is focused + * It's possible to be focused but have no selection + * @see #_hasSelection + * @return {Boolean} + */ + _hasFocus() { + return document.activeElement === this.element; + } + + /** + * Toggles the tagName for the current active section(s). This will skip + * non-markerable sections. E.g. if the editor's range includes a "P" MarkupSection + * and a CardSection, only the MarkupSection will be toggled. + * @param {String} tagName The new tagname to change to. + * @public + * @see PostEditor#toggleSection + */ + toggleSection(tagName) { + this.run(postEditor => postEditor.toggleSection(tagName, this.range)); + } + + /** + * Sets an attribute for the current active section(s). + * + * @param {String} key The attribute. The only valid attribute is 'text-align'. + * @param {String} value The value of the attribute. + * @public + * @see PostEditor#setAttribute + */ + setAttribute(key, value) { + this.run(postEditor => postEditor.setAttribute(key, value, this.range)); + } + + /** + * Removes an attribute from the current active section(s). + * + * @param {String} key The attribute. The only valid attribute is 'text-align'. + * @public + * @see PostEditor#removeAttribute + */ + removeAttribute(key) { + this.run(postEditor => postEditor.removeAttribute(key, this.range)); + } + + /** + * Finds and runs the first matching key command for the event + * + * If multiple commands are bound to a key combination, the + * first matching one is run. + * + * If a command returns `false` then the next matching command + * is run instead. + * + * @param {Event} event The keyboard event triggered by the user + * @return {Boolean} true when a command was successfully run + * @private + */ + handleKeyCommand(event) { + const keyCommands = findKeyCommands(this.keyCommands, event); + for (let i=0; i { + if (!range.isCollapsed) { + position = postEditor.deleteRange(range); + } + + postEditor.insertTextWithMarkup(position, text, activeMarkups); + }); + } + + /** + * Inserts an atom at the current cursor position. If the editor has + * no current cursor position, nothing will be inserted. If the editor's + * range is not collapsed, it will be deleted before insertion. + * @param {String} atomName + * @param {String} [atomText=''] + * @param {Object} [atomPayload={}] + * @return {Atom} The inserted atom. + * @public + */ + insertAtom(atomName, atomText='', atomPayload={}) { + if (!this.hasCursor()) { return; } + if (this.post.isBlank) { + this._insertEmptyMarkupSectionAtCursor(); + } + + let atom; + let { range } = this; + this.run(postEditor => { + let position = range.head; + + atom = postEditor.builder.createAtom(atomName, atomText, atomPayload); + if (!range.isCollapsed) { + position = postEditor.deleteRange(range); + } + + postEditor.insertMarkers(position, [atom]); + }); + return atom; + } + + /** + * Inserts a card at the section after the current cursor position. If the editor has + * no current cursor position, nothing will be inserted. If the editor's + * range is not collapsed, it will be deleted before insertion. If the cursor is in + * a blank section, it will be replaced with a card section. + * The editor's cursor will be placed at the end of the inserted card. + * @param {String} cardName + * @param {Object} [cardPayload={}] + * @param {Boolean} [inEditMode=false] Whether the card should be inserted in edit mode. + * @return {Card} The inserted Card section. + * @public + */ + insertCard(cardName, cardPayload={}, inEditMode=false) { + if (!this.hasCursor()) { return; } + if (this.post.isBlank) { + this._insertEmptyMarkupSectionAtCursor(); + } + + let card; + let { range } = this; + this.run(postEditor => { + let position = range.tail; + card = postEditor.builder.createCardSection(cardName, cardPayload); + if (inEditMode) { + this.editCard(card); + } + + if (!range.isCollapsed) { + position = postEditor.deleteRange(range); + } + + let section = position.section; + if (section.isNested) { section = section.parent; } + + if (section.isBlank) { + postEditor.replaceSection(section, card); + } else { + let collection = this.post.sections; + postEditor.insertSectionBefore(collection, card, section.next); + } + + // It is important to explicitly set the range to the end of the card. + // Otherwise it is possible to create an inconsistent state in the + // browser. For instance, if the user clicked a button that + // called `editor.insertCard`, the editor surface may retain + // the selection but lose focus, and the next keystroke by the user + // will cause an unexpected DOM mutation (which can wipe out the + // card). + // See: https://github.com/bustle/mobiledoc-kit/issues/286 + postEditor.setRange(card.tailPosition()); + }); + return card; + } + + /** + * @param {integer} x x-position in viewport + * @param {integer} y y-position in viewport + * @return {Position|null} + */ + positionAtPoint(x, y) { + return Position$1.atPoint(x, y, this); + } + + /** + * @private + */ + _setCardMode(cardSection, mode) { + const renderNode = cardSection.renderNode; + if (renderNode && renderNode.isRendered) { + const cardNode = renderNode.cardNode; + cardNode[mode](); + } else { + cardSection.setInitialMode(mode); + } + } + + triggerEvent(context, eventName, event) { + this._eventManager._trigger(context, eventName, event); + } + + addCallback(...args) { + this._callbacks.addCallback(...args); + } + + addCallbackOnce(...args) { + this._callbacks.addCallbackOnce(...args); + } + + runCallbacks(...args) { + if (this.isDestroyed) { + // TODO warn that callback attempted after editor was destroyed + return; + } + this._callbacks.runCallbacks(...args); + } + + /** + * Runs each callback for the given hookName. + * Only the hookName 'toggleMarkup' is currently supported + * @return {Boolean} shouldCancel Whether the action in `hookName` should be canceled + * @private + */ + _runBeforeHooks(hookName, ...args) { + let hooks = this._beforeHooks[hookName] || []; + for (let i = 0; i < hooks.length; i++) { + if (hooks[i](...args) === false) { + return true; + } + } + } +} + +var VERSION = '##VERSION##'; + +const Mobiledoc = { + Editor, + UI, + ImageCard, + Range, + Position: Position$1, + Error: MobiledocError, + VERSION, + MOBILEDOC_VERSION: MOBILEDOC_VERSION$4 +}; + +function registerGlobal(global) { + global.Mobiledoc = Mobiledoc; +} + +export default Mobiledoc; +export { Editor, MOBILEDOC_VERSION$4 as MOBILEDOC_VERSION, Position$1 as Position, Range, UI, registerGlobal }; +//# sourceMappingURL=index.js.map diff --git a/package.json b/package.json index 5f9028551..7f0a0dec4 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,15 @@ "module": "dist/index.js", "scripts": { "start": "rollup --config --watch", - "test:ci": "npm run build:docs && npm run build && testem ci -f testem-ci.js", + "test:ci": "yarn build:docs && yarn build && testem ci -f testem-ci.js", "test": "rollup --config --tests && testem ci -f testem.js", "build": "rollup --config", "build:docs": "jsdoc -c ./.jsdoc", - "build:website": "npm run build && npm run build:docs && ./bin/build-website.sh", + "build:website": "yarn build && yarn build:docs && ./bin/build-website.sh", "deploy:website": "./bin/deploy-website.sh", "update-changelog": "conventional-changelog -i CHANGELOG.md -r 0 -s", - "version": "npm run update-changelog && git add CHANGELOG.md", - "prepublish": "npm run build" + "version": "yarn update-changelog && git add CHANGELOG.md", + "prepublish": "yarn build" }, "keywords": [ "html", diff --git a/rollup.config.js b/rollup.config.js index beb6d442c..ff7656b29 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -4,49 +4,48 @@ import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import path from 'path'; -export default args => { - if (args.tests) { - return { - input: 'tests/index.js', - plugins: [ - resolve(), - commonjs(), - alias({ - entries: [ - { - find: 'mobiledoc-kit', - replacement: path.join(__dirname, 'src/js') - } - ] - }), - globImport() - ], - output: { - file: 'dist/tests.js', - format: 'es', - sourcemap: true - } - }; - } else { - return { - input: 'src/js/index.js', - plugins: [ - resolve(), - commonjs(), - alias({ - entries: [ - { - find: 'mobiledoc-kit', - replacement: path.join(__dirname, 'src/js') - } - ] - }), - ], - output: { - file: 'dist/index.js', - format: 'es', - sourcemap: true - } - }; +export default [ + { + input: 'tests/index.js', + plugins: [ + resolve(), + commonjs(), + alias({ + entries: [ + { + find: 'mobiledoc-kit', + // eslint-disable-next-line no-undef + replacement: path.join(__dirname, 'src/js') + } + ] + }), + globImport() + ], + output: { + file: 'dist/tests.js', + format: 'es', + sourcemap: true + } + }, + { + input: 'src/js/index.js', + plugins: [ + resolve(), + commonjs(), + alias({ + entries: [ + { + find: 'mobiledoc-kit', + // eslint-disable-next-line no-undef + replacement: path.join(__dirname, 'src/js') + } + ] + }) + ], + output: { + file: 'dist/index.js', + format: 'es', + sourcemap: true + } } -}; +]; diff --git a/src/js/index.js b/src/js/index.js index c8512d809..7c377ffb6 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -5,7 +5,7 @@ import Range from './utils/cursor/range'; import Position from './utils/cursor/position'; import Error from './utils/mobiledoc-error'; import VERSION from './version'; -import { MOBILEDOC_VERSION } from 'mobiledoc-kit/renderers/mobiledoc'; +import { MOBILEDOC_VERSION } from './renderers/mobiledoc'; const Mobiledoc = { Editor, diff --git a/website/demo/demo.js b/website/demo/demo.js index 7274acfa0..127e1b0ce 100644 --- a/website/demo/demo.js +++ b/website/demo/demo.js @@ -1,16 +1,26 @@ -/* global Mobiledoc */ -'use strict'; +import { Editor } from '../../dist/index.js'; -$(() => { - bootstrapEditor(); - bootstrapSimpleDemo(); - bootstrapToolbarEditor(); - bootstrapCardEditor(); -}); +function bootstrapSimpleDemo() { + let el = $('#editor-basic')[0]; + let editor = new Editor({ + placeholder: 'Welcome to Mobiledoc' + }); + editor.render(el); +} -let bootstrapEditor = () => { +function activateButtons(parentSelector, editor) { + $(`${parentSelector} button`).click(function () { + let button = $(this); + let action = button.data('action'); + let args = button.data('args').split(','); + + editor[action](...args); + }); +} + +function bootstrapEditor() { let el = $('#editor')[0]; - let editor = new Mobiledoc.Editor({ + let editor = new Editor({ placeholder: 'Type here', autofocus: true }); @@ -23,29 +33,11 @@ let bootstrapEditor = () => { }; editor.postDidChange(displayMobiledoc); displayMobiledoc(); -}; - -let bootstrapSimpleDemo = () => { - let el = $('#editor-basic')[0]; - let editor = new Mobiledoc.Editor({ - placeholder: 'Welcome to Mobiledoc' - }); - editor.render(el); -}; - -let activateButtons = (parentSelector, editor) => { - $(`${parentSelector} button`).click(function () { - let button = $(this); - let action = button.data('action'); - let args = button.data('args').split(','); - - editor[action](...args); - }); -}; +} let bootstrapToolbarEditor = () => { let el = $('#editor-toolbar')[0]; - let editor = new Mobiledoc.Editor({ + let editor = new Editor({ placeholder: 'Editor with toolbar' }); editor.render(el); @@ -73,11 +65,18 @@ let bootstrapCardEditor = () => { } }; let el = $('#editor-card')[0]; - let editor = new Mobiledoc.Editor({ + let editor = new Editor({ placeholder: 'Editor with card', cards: [card], atoms: [atom] }); editor.render(el); activateButtons('#editor-card-wrapper', editor); -}; \ No newline at end of file +}; + +$(() => { + bootstrapEditor(); + bootstrapSimpleDemo(); + bootstrapToolbarEditor(); + bootstrapCardEditor(); +}); diff --git a/website/demo/index.html b/website/demo/index.html index a242cfd91..18cd81616 100644 --- a/website/demo/index.html +++ b/website/demo/index.html @@ -8,7 +8,7 @@ - +