'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
function detect(enumerable, callback) {
if ('detect' in enumerable) {
return enumerable.detect(callback);
}
else {
for (let i = 0; i < enumerable.length; i++) {
if (callback(enumerable[i])) {
return enumerable[i];
}
}
}
}
function any(enumerable, callback) {
if ('any' in enumerable) {
return enumerable.any(callback);
}
for (let i = 0; i < enumerable.length; i++) {
if (callback(enumerable[i])) {
return true;
}
}
return false;
}
/**
* Useful for array-like things that aren't
* actually arrays, like NodeList
* @private
*/
function forEach(enumerable, callback) {
if ('forEach' in enumerable) {
enumerable.forEach(callback);
}
else {
for (let i = 0; i < enumerable.length; i++) {
callback(enumerable[i], i);
}
}
}
function filter(enumerable, conditionFn) {
const filtered = [];
forEach(enumerable, 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;
let 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 = {}) {
this.isShowing = false;
this.isDestroyed = false;
options.tagName = options.tagName || 'div';
options.container = options.container || document.body;
this.element = document.createElement(options.tagName);
this.container = options.container;
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.parentElement;
}
}
function getElementRelativeOffset(element) {
const offset = { left: 0, top: -window.pageYOffset };
const offsetParent = element.offsetParent;
const offsetParentPosition = window.getComputedStyle(offsetParent).position;
let 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) {
const relativeOffset = getElementRelativeOffset(element);
const style = element.style;
const round = Math.round;
let left, top;
topOffset = topOffset || 0;
leftOffset = leftOffset || 0;
left = round(rect.left - relativeOffset.left - leftOffset);
top = round(rect.top + rect.height - relativeOffset.top - topOffset);
style.left = `${left}px`;
style.top = `${top}px`;
return { left: left, top: top };
}
function positionElementHorizontallyCenteredToRect(element, rect, topOffset) {
const horizontalCenter = element.offsetWidth / 2 - rect.width / 2;
return positionElementToRect(element, rect, topOffset, horizontalCenter);
}
function positionElementCenteredBelow(element, belowElement) {
const elementMargin = getElementComputedStyleNumericProp(element, 'marginTop');
return positionElementHorizontallyCenteredToRect(element, belowElement.getBoundingClientRect(), -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) };
}
class Markuperable {
constructor() {
this.markups = [];
this.prev = null;
this.next = null;
this.isAtom = false;
this.isMarker = false;
this.section = null;
this.parent = null;
this.renderNode = null;
}
charAt(offset) {
return this.value.slice(offset, offset + 1);
}
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 MobiledocError extends Error {
}
function assert(message, conditional) {
if (!conditional) {
throw new MobiledocError(message);
}
}
function assertExistsIn(message, key, object) {
assert(message, key in object);
}
function assertNotNull(message, value) {
if (value === null) {
throw new MobiledocError(message);
}
}
function assertType(message, _value, conditional) {
assert(message, conditional);
}
function expect(value, message) {
if (value === null || value === undefined) {
throw new MobiledocError(message);
}
return value;
}
function unwrap(value) {
return expect(value, 'expected value to not be null or undefined');
}
// 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];
class Marker extends Markuperable {
constructor(value = '', markups = []) {
super();
this.type = "marker" /* MARKER */;
this.isMarker = true;
this.markups = [];
this.renderNode = null;
this.value = value;
assert('Marker must have value', value !== undefined && value !== null);
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;
}
/**
* 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];
}
}
function isMarker(postNode) {
return postNode.type === "marker" /* MARKER */;
}
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';
var Direction;
(function (Direction) {
Direction[Direction["FORWARD"] = 1] = "FORWARD";
Direction[Direction["BACKWARD"] = -1] = "BACKWARD";
})(Direction || (Direction = {}));
const MODIFIERS = {
META: 1,
CTRL: 2,
SHIFT: 4,
ALT: 8,
};
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
*/
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;
default:
return Direction.FORWARD;
}
}
/**
* 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(root = window) {
const selection = root.getSelection();
selection && selection.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 && 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) {
assertNotNull('selection anchorNode should not be null', selection.anchorNode);
assertNotNull('selection focusNode should not be null', selection.focusNode);
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 isFullSelection(selection) {
return selection instanceof Selection;
}
function comparePosition(selection) {
assertNotNull('selection anchorNode should not be null', selection.anchorNode);
assertNotNull('selection focusNode should not be null', selection.focusNode);
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]
* @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(headSection, headOffset), new Position(tailSection, tailOffset), direction);
}
static blankRange() {
return new Range(Position.blankPosition(), Position.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;
assertNotNull('expected range section to not be null', headSection);
assertMarkerable(headSection);
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 = unwrap(headMarker).next || head.marker;
}
let headPosition = new Position(headSection, headSection.offsetOfMarker(unwrap(headMarker)));
assertMarkerable(tail.section);
let tailMarker = tail.section.markers.detect(firstNotMatchingDetect, tail.marker);
if (!tailMarker && detectMarker(unwrap(headSection.markers.tail))) {
tailMarker = unwrap(headSection.markers.tail);
}
else {
tailMarker = unwrap(tailMarker).prev || unwrap(tail.marker);
}
let tailPosition = new Position(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;
}
}
function assertMarkerable(section) {
if (!('markers' in section)) {
throw new MobiledocError('Expected position section to be markerable');
}
}
class LinkedItem {
constructor() {
this.next = null;
this.prev = null;
}
}
function isListSection(item) {
return 'items' in item && item.items;
}
class Section extends LinkedItem {
constructor(type) {
super();
this.isSection = true;
this.isMarkerable = false;
this.isNested = false;
this.isListItem = false;
this.isListSection = false;
this.isLeafSection = true;
this.isCardSection = false;
this._parent = null;
assert('Cannot create section without type', !!type);
this.type = type;
}
get parent() {
return expect(this._parent, 'expected section parent to be assigned');
}
get isBlank() {
return false;
}
get length() {
return 0;
}
/**
* @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(this, offset);
}
/**
* @return {Range} A range from this section's head to tail positions
* @public
*/
toRange() {
return this.headPosition().toRange(this.tailPosition());
}
/**
* Markerable sections should override this method
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
splitMarkerAtOffset(_offset) {
let blankEdit = { added: [], removed: [] };
return blankEdit;
}
nextLeafSection() {
const next = this.next;
if (next) {
if (isListSection(next)) {
return next.items.head;
}
else {
return next;
}
}
else {
if (isNested(this)) {
return this.parent.nextLeafSection();
}
}
return null;
}
immediatelyNextMarkerableSection() {
let next = this.nextLeafSection();
while (next && !next.isMarkerable) {
next = next.nextLeafSection();
}
return next;
}
previousLeafSection() {
const prev = this.prev;
if (prev) {
if (isListSection(prev)) {
return prev.items.tail;
}
else {
return prev;
}
}
else {
if (isNested(this)) {
return this.parent.previousLeafSection();
}
}
return null;
}
}
function isNested(section) {
return section.isNested;
}
function shallowCopyObject(object) {
return { ...object };
}
var CardMode;
(function (CardMode) {
CardMode["DISPLAY"] = "display";
CardMode["EDIT"] = "edit";
})(CardMode || (CardMode = {}));
const CARD_LENGTH = 1;
function isCardSection(section) {
return section.isCardSection;
}
class Card extends Section {
constructor(name, payload) {
super("card-section" /* CARD */);
this._initialMode = CardMode.DISPLAY;
this.isCardSection = true;
this.name = name;
this.payload = payload;
this.isCardSection = true;
}
textUntil() {
return '';
}
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;
}
}
const ATOM_LENGTH = 1;
class Atom extends Markuperable {
constructor(name, value, payload, markups = []) {
super();
this.type = "atom" /* ATOM */;
this.isAtom = true;
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" /* ATOM */;
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];
}
}
function isAtom(postNode) {
return postNode.type === "atom" /* ATOM */;
}
const { FORWARD, BACKWARD } = Direction;
// generated via http://xregexp.com/ to cover chars that \w misses
// (new XRegExp('\\p{Alphabetic}|[0-9]|_|:')).toString()
// eslint-disable-next-line no-misleading-character-class
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 = 0) {
let offsetInSection = 0;
let marker = markerable.markers.head;
while (marker) {
assertHasRenderNode(marker.renderNode);
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 assertHasRenderNode(renderNode) {
if (!renderNode) {
throw new Error('expected marker to have render node');
}
}
function findOffsetInSection(section, node, offset) {
if (isMarkerable(section)) {
return findOffsetInMarkerable(section, node, offset);
}
else {
assertIsCard(section);
assertHasRenderNode(section.renderNode);
let wrapperNode = section.renderNode.element;
let endTextNode = wrapperNode.lastChild;
if (node === endTextNode) {
return 1;
}
return 0;
}
}
function assertIsCard(section) {
assert('findOffsetInSection must be called with markerable or card section', section && section.isCardSection);
}
function isMarkerable(section) {
return section.isMarkerable;
}
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 (!elementFromPoint || !containsNode(rootElement, elementFromPoint)) {
return null;
}
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) || null;
}
/**
* 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 if (isElementNode(node)) {
return Position.fromElementNode(renderTree, node, offset);
}
assert('Positions can only be created from text nodes or elements', false);
}
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 = marker.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 = 0) {
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 (isCardSection(section)) {
// 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 && isAtom(postNode)) {
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);
assertType('cannot get markerPosition of a non-markerable', this.section, !!this.section.isMarkerable);
return this.section.markerPositionAtOffset(this.offset);
}
}
class BlankPosition extends Position {
constructor() {
super(null, 0, true);
}
isEqual(other) {
return other && other.isBlank;
}
toRange() {
return Range.blankRange();
}
get leafSectionIndex() {
throw new Error('must implement get leafSectionIndex');
}
get isMarkerable() {
return false;
}
get marker() {
return null;
}
isHeadOfPost() {
return false;
}
isTailOfPost() {
return false;
}
isHead() {
return false;
}
isTail() {
return false;
}
move() {
return this;
}
moveWord() {
return this;
}
get markerPosition() {
return {};
}
}
/**
* @module UI
*/
const defaultShowPrompt = (message, defaultValue, callback) => callback(window.prompt(message, defaultValue));
/**
* @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
*/
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 });
});
}
}
/**
* Exposes the core behavior for editing an existing link, and allows for
* customization of the URL input handler.
* @param {HTMLAnchorElement} target The anchor () DOM element whose URL should be edited.
* @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`.
*
* @public
*/
function editLink(target, editor, showPrompt = defaultShowPrompt) {
showPrompt('Enter a URL', target.href, url => {
if (!url) {
return;
}
const position = Position.fromNode(editor._renderTree, target.firstChild);
const range = new Range(position, new Position(position.section, position.offset + target.textContent.length));
editor.run(post => {
let markup = editor.builder.createMarkup('a', { href: url });
// This is the only way to "update" a markup with new attributes in the
// current API.
post.toggleMarkup(markup, range);
post.toggleMarkup(markup, range);
});
});
}
var ui = {
toggleLink,
editLink,
};
const SHOW_DELAY = 200;
const HIDE_DELAY = 600;
class Tooltip extends View {
constructor(options) {
super({ ...options, classNames: ['__mobiledoc-tooltip'] });
this.elementObserver = null;
this.rootElement = options.rootElement;
this.editor = options.editor;
this.addListeners(options);
}
showLink(linkEl) {
const { editor, element: tooltipEl } = this;
const { tooltipPlugin } = editor;
tooltipPlugin.renderLink(tooltipEl, linkEl, {
editLink: () => {
editLink(linkEl, editor);
this.hide();
},
});
this.show();
positionElementCenteredBelow(this.element, linkEl);
this.elementObserver = whenElementIsNotInDOM(linkEl, () => this.hide());
}
addListeners(options) {
const { rootElement, element: tooltipElement } = this;
let showTimeout, hideTimeout;
const scheduleHide = () => {
clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
this.hide();
}, HIDE_DELAY);
};
this.addEventListener(tooltipElement, 'mouseenter', () => {
clearTimeout(hideTimeout);
});
this.addEventListener(tooltipElement, 'mouseleave', () => {
scheduleHide();
});
this.addEventListener(rootElement, 'mouseover', event => {
let target = getEventTargetMatchingTag(options.showForTag, event.target, rootElement);
if (target && target.isContentEditable) {
clearTimeout(hideTimeout);
showTimeout = setTimeout(() => {
target && this.showLink(target);
}, SHOW_DELAY);
}
});
this.addEventListener(rootElement, 'mouseout', () => {
clearTimeout(showTimeout);
if (this.elementObserver) {
this.elementObserver.cancel();
}
scheduleHide();
});
}
}
const DEFAULT_TOOLTIP_PLUGIN = {
renderLink(tooltipEl, linkEl, { editLink }) {
const { href } = linkEl;
tooltipEl.innerHTML = `${href}`;
const button = document.createElement('button');
button.classList.add('__mobiledoc-tooltip__edit-link');
button.innerText = 'Edit Link';
button.addEventListener('click', editLink);
tooltipEl.append(button);
},
};
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;
}
}
function hasChildSections(section) {
return 'sections' in section;
}
const MARKERABLE = 'markerable';
const NESTED_MARKERABLE = 'nested_markerable';
const NON_MARKERABLE = 'non_markerable';
class Visitor {
constructor({ postEditor, post }, cursorPosition) {
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;
assertType(`Cannot visit node of type ${node.type}`, method, method in this);
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" /* POST */](node) {
let { cursorSection } = this;
if (cursorSection.isBlank && !cursorSection.isNested) {
// replace blank section with entire post
let newSections = node.sections.map(s => s.clone());
this._replaceSection(cursorSection, newSections);
}
else {
node.sections.forEach(section => this.visit(section));
}
}
["markup-section" /* MARKUP_SECTION */](node) {
this[MARKERABLE](node);
}
["list-section" /* LIST_SECTION */](node) {
let hasNext = !!node.next;
node.items.forEach(item => this.visit(item));
if (this._isNested && hasNext) {
this._breakNestedAtCursor();
}
}
["list-item" /* LIST_ITEM */](node) {
this[NESTED_MARKERABLE](node);
}
["card-section" /* CARD */](node) {
this[NON_MARKERABLE](node);
}
["image-section" /* IMAGE_SECTION */](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;
}
let insertedSection = this._isNested ? section : this._wrapNestedSection(section);
this._breakAtCursor();
this._insertLeafSection(insertedSection);
}
// 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);
const list = this.cursorSection.parent;
const position = this.cursorPosition;
const 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() {
const collection = this._post.sections;
const blank = this.builder.createMarkupSection();
const 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', hasChildSections(section.parent));
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) {
assert('Cannot insert into section that does not have parent.sections', hasChildSections(this.cursorSection.parent));
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) {
return rangeLike.toRange();
}
assert(`Incorrect structure for rangeLike: ${rangeLike}`, false);
}
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);
},
isChrome() {
return typeof window !== 'undefined' && 'chrome' in window;
},
};
class Cursor {
constructor(editor) {
this.editor = editor;
this.renderTree = editor._renderTree;
this.post = editor.post;
}
clearSelection() {
clearSelection(this.editor.root);
}
/**
* @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 { renderTree } = this;
let parentNode = unwrap(this.editor.element);
let selection = constrainSelectionTo(this.selection, parentNode);
const { headNode, headOffset, tailNode, tailOffset, direction } = comparePosition(selection);
const headPosition = Position.fromNode(renderTree, headNode, headOffset);
const tailPosition = Position.fromNode(renderTree, tailNode, tailOffset);
return new Range(headPosition, tailPosition, direction);
}
_findNodeForPosition(position) {
let section = unwrap(position.section);
let node, offset;
assertNotNull('expected section to have render node', section.renderNode);
if (isCardSection(section)) {
offset = 0;
if (position.offset === 0) {
node = section.renderNode.element.firstChild;
}
else {
node = section.renderNode.element.lastChild;
}
}
else if (section.isBlank || section.type === "image-section" /* IMAGE_SECTION */) {
node = section.renderNode.cursorElement;
offset = 0;
}
else {
let { marker, offsetInMarker } = position;
assertNotNull('expected position to have marker', marker);
assertNotNull('expected marker to have render node', marker.renderNode);
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 expect(this.editor.root.getSelection(), 'expected editor.root selection to not be null');
}
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 && isFullSelection(this.selection)) {
this.selection.addRange(range);
this.selection.extend(endNode, endOffset);
}
else {
range.setEnd(endNode, endOffset);
this.selection.addRange(range);
}
}
_hasSelection() {
const element = unwrap(this.editor.element);
const { _selectionRange } = this;
// COMPAT: There's a bug in chrome that always returns `true` for
// `isCollapsed` for a Selection that comes from a ShadowRoot.
// (2020/08/08)
// https://bugs.chromium.org/p/chromium/issues/detail?id=447523
let isCollapsed;
const hasShadowRoot = () => {
return !!(window.document.activeElement && window.document.activeElement.shadowRoot);
};
if (_selectionRange) {
if (Browser.isChrome() && hasShadowRoot()) {
isCollapsed =
_selectionRange.anchorNode === _selectionRange.focusNode &&
_selectionRange.anchorOffset === _selectionRange.focusOffset;
}
else {
isCollapsed = _selectionRange.isCollapsed;
}
}
if (!_selectionRange || isCollapsed) {
return false;
}
return (containsNode(element, unwrap(this.selection.anchorNode)) &&
containsNode(element, unwrap(this.selection.focusNode)));
}
_hasCollapsedSelection() {
const { _selectionRange } = this;
if (!_selectionRange) {
return false;
}
const element = this.editor.element;
return containsNode(unwrap(element), unwrap(this.selection.anchorNode));
}
get _selectionRange() {
const { selection } = this;
if (selection.rangeCount === 0) {
return null;
}
return selection.getRangeAt(0);
}
}
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 (!getParent(item)) {
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', getParent(item) === this);
}
}
function getParent(item) {
return item[PARENT_PROP] || null;
}
function tagNameable(Base) {
class TagNameable extends Base {
constructor() {
super(...arguments);
this._tagName = null;
}
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;
}
}
return TagNameable;
}
class Markerable extends tagNameable(Section) {
constructor(type, tagName, markers = []) {
super(type);
this.type = 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];
}
/**
* 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 = null;
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 false;
});
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 = Range.create(this, headOffset, this, 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 };
}
}
function isMarkerable$1(section) {
return section.isMarkerable;
}
const MARKUP_SECTION_TYPE = 'markup-section';
const LIST_SECTION_TYPE = 'list-section';
const MARKUP_TYPE = 'markup';
const LIST_ITEM_TYPE = 'list-item';
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;
}
const VALID_ATTRIBUTES = ['data-md-text-align'];
/*
* A "mixin" to add section attribute support
* to markup and list sections.
*/
function attributable(Base) {
return class extends Base {
constructor() {
super(...arguments);
this.attributes = {};
}
hasAttribute(key) {
return key in this.attributes;
}
setAttribute(key, value) {
if (!contains(VALID_ATTRIBUTES, key)) {
throw new Error(`Invalid attribute "${key}" was passed. Constrain attributes to the spec-compliant whitelist.`);
}
this.attributes[key] = value;
}
removeAttribute(key) {
delete this.attributes[key];
}
getAttribute(key) {
return this.attributes[key];
}
eachAttribute(cb) {
entries(this.attributes).forEach(([k, v]) => cb(k, v));
}
};
}
function getSectionAttributes(section) {
if (isNested(section)) {
return section.parent.attributes || {};
}
return section.attributes || {};
}
const VALID_LIST_SECTION_TAGNAMES = ['ul', 'ol'].map(normalizeTagName);
const DEFAULT_TAG_NAME = VALID_LIST_SECTION_TAGNAMES[0];
class ListSection extends attributable(tagNameable(Section)) {
constructor(tagName = DEFAULT_TAG_NAME, items = [], attributes = {}) {
super(LIST_SECTION_TYPE);
this.isListSection = true;
this.isLeafSection = false;
this.tagName = tagName;
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 (isListSection$1(other)) {
other.items.forEach(i => this.join(i));
}
else if (other.isMarkerable) {
let item = this.builder.createListItem();
item.join(other);
this.items.append(item);
}
}
}
function isListSection$1(section) {
return section.isListSection;
}
const VALID_LIST_ITEM_TAGNAMES = ['li'].map(normalizeTagName);
class ListItem extends Markerable {
constructor(tagName, markers = []) {
super("list-item" /* LIST_ITEM */, tagName, markers);
this.isListItem = true;
this.isNested = true;
this.section = null;
}
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 expect(this.section, 'expected list item to have section').post;
}
}
function isListItem(section) {
return section.isListItem;
}
// 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$1 = VALID_MARKUP_SECTION_TAGNAMES[8];
class MarkupSection extends attributable(Markerable) {
constructor(tagName = DEFAULT_TAG_NAME$1, markers = [], attributes = {}) {
super(MARKUP_SECTION_TYPE, tagName, markers);
this.isMarkupSection = true;
this.isGenerated = false;
this._inferredTagName = false;
entries(attributes).forEach(([k, v]) => this.setAttribute(k, v));
}
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);
}
}
function isMarkupSection(section) {
return section.isMarkupSection;
}
function hasInferredTagName(section) {
return isMarkupSection(section) && section._inferredTagName;
}
const { FORWARD: FORWARD$1, BACKWARD: BACKWARD$1 } = Direction;
function isListSectionTagName(tagName) {
return tagName === 'ul' || tagName === 'ol';
}
function shrinkRange(range) {
const { head, tail } = range;
if (tail.offset === 0 && head.section !== tail.section) {
range.tail = new Position(tail.section.prev, tail.section.prev.length);
}
return range;
}
const CALLBACK_QUEUES = {
BEFORE_COMPLETE: 'beforeComplete',
COMPLETE: 'complete',
AFTER_COMPLETE: 'afterComplete',
};
/**
* 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 {
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(queueName, callback) {
this._callbacks.addCallback(queueName, callback);
}
addCallbackOnce(queueName, callback) {
this._callbacks.addCallbackOnce(queueName, callback);
}
runCallbacks(queueName) {
this._callbacks.runCallbacks(queueName);
}
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 = 2 /* DELETE */;
const { head, tail } = range;
let headSection = head.section;
let tailSection = tail.section;
const { editor } = this;
const { post } = editor;
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 (isMarkerable$1(headSection) && isMarkerable$1(tailSection)) {
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 && tail instanceof Position);
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 (isMarkerable$1(section)) {
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 && isListSection$1(postNode.prev)) ||
(postNode.next && isListSection$1(postNode.next));
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 && isListSection$1(prev) && isListSection$1(section) && 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 && isListItem(currentHead.section) && 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 ('section' in postNode && postNode.section) {
this._markDirty(postNode.section);
}
if (isMarkerable$1(postNode)) {
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: TextUnit.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: TextUnit.CHAR }) {
if (direction === Direction.BACKWARD) {
return this._deleteAtPositionBackward(position, unit);
}
else {
return this._deleteAtPositionForward(position, unit);
}
}
_deleteAtPositionBackward(position, unit) {
if (position.isHead() && isListItem(position.section)) {
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.section;
if (isCardSection(section)) {
return this._splitCardSection(section, position);
}
else if (isListItem(section)) {
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
* @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) {
const section = position.section;
let offset = position.offset;
assert('Cannot insert markers at non-markerable position', section.isMarkerable);
this.editActionTaken = 1 /* 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 (isMarkupSection(nextNewSection) && isListItem(section)) {
// 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 = shrinkRange(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 => {
const cbSection = isListItem(section) ? section.parent : section;
if (cb(cbSection, attribute) === true) {
this._markDirty(section);
}
});
this.setRange(range);
}
_isSameSectionType(section, sectionTagName) {
return isListItem(section) ? 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 (isListItem(section)) {
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) {
assertType('Must pass list item to `_changeSectionFromListItem`', section, isListItem(section));
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 (isListItem(section)) {
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;
assertType('expected section to have child sections', parent, hasChildSections(parent));
this._scheduleForRemoval(section);
parent.sections.remove(section);
if (isListSection$1(parent)) {
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;
},
};
/*
* 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) {
switch (section[0]) {
case 1 /* MARKUP */:
this.parseMarkupSection(section, post);
break;
case 2 /* IMAGE */:
this.parseImageSection(section, post);
break;
case 10 /* CARD */:
this.parseCardSection(section, post);
break;
case 3 /* LIST */:
this.parseListSection(section, post);
break;
default:
assert(`Unexpected section type ${section[0]}`, 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);
}
}
/*
* 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) {
switch (section[0]) {
case 1 /* MARKUP */:
this.parseMarkupSection(section, post);
break;
case 2 /* IMAGE */:
this.parseImageSection(section, post);
break;
case 10 /* CARD */:
this.parseCardSection(section, post);
break;
case 3 /* LIST */:
this.parseListSection(section, post);
break;
default:
assert(`Unexpected section type ${section[0]}`, 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 0 /* MARKUP */:
return this.builder.createMarker(value, this.markups.slice());
case 1 /* ATOM */: {
const [atomName, atomValue, atomPayload] = this.getAtomTypeFromIndex(value);
return this.builder.createAtom(atomName, atomValue, atomPayload, this.markups.slice());
}
default:
assert(`Unexpected marker type ${type}`, false);
}
}
}
/*
* 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) {
switch (section[0]) {
case 1 /* MARKUP */:
this.parseMarkupSection(section, post);
break;
case 2 /* IMAGE */:
this.parseImageSection(section, post);
break;
case 10 /* CARD */:
this.parseCardSection(section, post);
break;
case 3 /* LIST */:
this.parseListSection(section, post);
break;
default:
assert(`Unexpected section type ${section[0]}`, 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 0 /* MARKUP */:
return this.builder.createMarker(value, this.markups.slice());
case 1 /* ATOM */: {
const [atomName, atomValue, atomPayload] = this.getAtomTypeFromIndex(value);
return this.builder.createAtom(atomName, atomValue, atomPayload, this.markups.slice());
}
default:
assert(`Unexpected marker type ${type}`, false);
}
}
}
/*
* 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) {
switch (section[0]) {
case 1 /* MARKUP */:
this.parseMarkupSection(section, post);
break;
case 2 /* IMAGE */:
this.parseImageSection(section, post);
break;
case 10 /* CARD */:
this.parseCardSection(section, post);
break;
case 3 /* LIST */:
this.parseListSection(section, post);
break;
default:
assert(`Unexpected section type ${section[0]}`, 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 0 /* MARKUP */:
return this.builder.createMarker(value, this.markups.slice());
case 1 /* ATOM */: {
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 visit(visitor, node, opcodes) {
const method = node.type;
assertExistsIn(`Cannot visit unknown type ${method}`, method, visitor);
visitor[method](node, opcodes);
}
function compile(compiler, opcodes) {
for (let i = 0, l = opcodes.length; i < l; i++) {
let [method, ...params] = opcodes[i];
compiler[method].apply(compiler, params);
}
}
function visitArray(visitor, nodes, opcodes) {
if (!nodes || nodes.length === 0) {
return;
}
forEach(nodes, node => {
visit(visitor, node, opcodes);
});
}
const MOBILEDOC_VERSION = '0.2.0';
const visitor = {
["post" /* POST */](node, opcodes) {
opcodes.push(['openPost']);
visitArray(visitor, node.sections, opcodes);
},
["markup-section" /* MARKUP_SECTION */](node, opcodes) {
opcodes.push(['openMarkupSection', node.tagName]);
visitArray(visitor, node.markers, opcodes);
},
["list-section" /* LIST_SECTION */](node, opcodes) {
opcodes.push(['openListSection', node.tagName]);
visitArray(visitor, node.items, opcodes);
},
["list-item" /* LIST_ITEM */](node, opcodes) {
opcodes.push(['openListItem']);
visitArray(visitor, node.markers, opcodes);
},
["image-section" /* IMAGE_SECTION */](node, opcodes) {
opcodes.push(['openImageSection', node.src]);
},
["card-section" /* CARD */](node, opcodes) {
opcodes.push(['openCardSection', node.name, node.payload]);
},
["marker" /* MARKER */](node, opcodes) {
opcodes.push(['openMarker', node.closedMarkups.length, node.value]);
visitArray(visitor, node.openedMarkups, opcodes);
},
["markup" /* MARKUP */](node, opcodes) {
opcodes.push(['openMarkup', node.tagName, objectToSortedKVArray(node.attributes)]);
},
};
class PostOpcodeCompiler {
openMarker(closeCount, value) {
this.markupMarkerIds = [];
this.markers.push([this.markupMarkerIds, closeCount, value || '']);
}
openMarkupSection(tagName) {
this.markers = [];
this.sections.push([1 /* MARKUP */, tagName, this.markers]);
}
openListSection(tagName) {
this.items = [];
this.sections.push([3 /* LIST */, tagName, this.items]);
}
openListItem() {
this.markers = [];
this.items.push(this.markers);
}
openImageSection(url) {
this.sections.push([2 /* IMAGE */, url]);
}
openCardSection(name, payload) {
this.sections.push([10 /* CARD */, 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 = new PostOpcodeCompiler();
compile(compiler, opcodes);
return compiler.result;
},
};
const MOBILEDOC_VERSION$1 = '0.3.0';
const visitor$1 = {
["post" /* POST */](node, opcodes) {
opcodes.push(['openPost']);
visitArray(visitor$1, node.sections, opcodes);
},
["markup-section" /* MARKUP_SECTION */](node, opcodes) {
opcodes.push(['openMarkupSection', node.tagName]);
visitArray(visitor$1, node.markers, opcodes);
},
["list-section" /* LIST_SECTION */](node, opcodes) {
opcodes.push(['openListSection', node.tagName]);
visitArray(visitor$1, node.items, opcodes);
},
["list-item" /* LIST_ITEM */](node, opcodes) {
opcodes.push(['openListItem']);
visitArray(visitor$1, node.markers, opcodes);
},
["image-section" /* IMAGE_SECTION */](node, opcodes) {
opcodes.push(['openImageSection', node.src]);
},
["card-section" /* CARD */](node, opcodes) {
opcodes.push(['openCardSection', node.name, node.payload]);
},
["marker" /* MARKER */](node, opcodes) {
opcodes.push(['openMarker', node.closedMarkups.length, node.value]);
visitArray(visitor$1, node.openedMarkups, opcodes);
},
["markup" /* MARKUP */](node, opcodes) {
opcodes.push(['openMarkup', node.tagName, objectToSortedKVArray(node.attributes)]);
},
["atom" /* ATOM */](node, opcodes) {
opcodes.push(['openAtom', node.closedMarkups.length, node.name, node.value, node.payload]);
visitArray(visitor$1, node.openedMarkups, opcodes);
},
};
class PostOpcodeCompiler$1 {
openMarker(closeCount, value) {
this.markupMarkerIds = [];
this.markers.push([0 /* MARKUP */, this.markupMarkerIds, closeCount, value || '']);
}
openAtom(closeCount, name, value, payload) {
const index = this._addAtomTypeIndex(name, value, payload);
this.markupMarkerIds = [];
this.markers.push([1 /* ATOM */, this.markupMarkerIds, closeCount, index]);
}
openMarkupSection(tagName) {
this.markers = [];
this.sections.push([1 /* MARKUP */, tagName, this.markers]);
}
openListSection(tagName) {
this.items = [];
this.sections.push([3 /* LIST */, tagName, this.items]);
}
openListItem() {
this.markers = [];
this.items.push(this.markers);
}
openImageSection(url) {
this.sections.push([2 /* IMAGE */, url]);
}
openCardSection(name, payload) {
const index = this._addCardTypeIndex(name, payload);
this.sections.push([10 /* CARD */, 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 = new PostOpcodeCompiler$1();
compile(compiler, opcodes);
return compiler.result;
},
};
const MOBILEDOC_VERSION$2 = '0.3.1';
const visitor$2 = {
["post" /* POST */](node, opcodes) {
opcodes.push(['openPost']);
visitArray(visitor$2, node.sections, opcodes);
},
["markup-section" /* MARKUP_SECTION */](node, opcodes) {
opcodes.push(['openMarkupSection', node.tagName]);
visitArray(visitor$2, node.markers, opcodes);
},
["list-section" /* LIST_SECTION */](node, opcodes) {
opcodes.push(['openListSection', node.tagName]);
visitArray(visitor$2, node.items, opcodes);
},
["list-item" /* LIST_ITEM */](node, opcodes) {
opcodes.push(['openListItem']);
visitArray(visitor$2, node.markers, opcodes);
},
["image-section" /* IMAGE_SECTION */](node, opcodes) {
opcodes.push(['openImageSection', node.src]);
},
["card-section" /* CARD */](node, opcodes) {
opcodes.push(['openCardSection', node.name, node.payload]);
},
["marker" /* MARKER */](node, opcodes) {
opcodes.push(['openMarker', node.closedMarkups.length, node.value]);
visitArray(visitor$2, node.openedMarkups, opcodes);
},
["markup" /* MARKUP */](node, opcodes) {
opcodes.push(['openMarkup', node.tagName, objectToSortedKVArray(node.attributes)]);
},
["atom" /* ATOM */](node, opcodes) {
opcodes.push(['openAtom', node.closedMarkups.length, node.name, node.value, node.payload]);
visitArray(visitor$2, node.openedMarkups, opcodes);
},
};
class PostOpcodeCompiler$2 {
openMarker(closeCount, value) {
this.markupMarkerIds = [];
this.markers.push([0 /* MARKUP */, this.markupMarkerIds, closeCount, value || '']);
}
openAtom(closeCount, name, value, payload) {
const index = this._addAtomTypeIndex(name, value, payload);
this.markupMarkerIds = [];
this.markers.push([1 /* ATOM */, this.markupMarkerIds, closeCount, index]);
}
openMarkupSection(tagName) {
this.markers = [];
this.sections.push([1 /* MARKUP */, tagName, this.markers]);
}
openListSection(tagName) {
this.items = [];
this.sections.push([3 /* LIST */, tagName, this.items]);
}
openListItem() {
this.markers = [];
this.items.push(this.markers);
}
openImageSection(url) {
this.sections.push([2 /* IMAGE */, url]);
}
openCardSection(name, payload) {
const index = this._addCardTypeIndex(name, payload);
this.sections.push([10 /* CARD */, 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 = new PostOpcodeCompiler$2();
compile(compiler, opcodes);
return compiler.result;
},
};
const MOBILEDOC_VERSION$3 = '0.3.2';
const visitor$3 = {
["post" /* POST */](node, opcodes) {
opcodes.push(['openPost']);
visitArray(visitor$3, node.sections, opcodes);
},
["markup-section" /* MARKUP_SECTION */](node, opcodes) {
opcodes.push(['openMarkupSection', node.tagName, objectToSortedKVArray(node.attributes)]);
visitArray(visitor$3, node.markers, opcodes);
},
["list-section" /* LIST_SECTION */](node, opcodes) {
opcodes.push(['openListSection', node.tagName, objectToSortedKVArray(node.attributes)]);
visitArray(visitor$3, node.items, opcodes);
},
["list-item" /* LIST_ITEM */](node, opcodes) {
opcodes.push(['openListItem']);
visitArray(visitor$3, node.markers, opcodes);
},
["image-section" /* IMAGE_SECTION */](node, opcodes) {
opcodes.push(['openImageSection', node.src]);
},
["card-section" /* CARD */](node, opcodes) {
opcodes.push(['openCardSection', node.name, node.payload]);
},
["marker" /* MARKER */](node, opcodes) {
opcodes.push(['openMarker', node.closedMarkups.length, node.value]);
visitArray(visitor$3, node.openedMarkups, opcodes);
},
["markup" /* MARKUP */](node, opcodes) {
opcodes.push(['openMarkup', node.tagName, objectToSortedKVArray(node.attributes)]);
},
["atom" /* ATOM */](node, opcodes) {
opcodes.push(['openAtom', node.closedMarkups.length, node.name, node.value, node.payload]);
visitArray(visitor$3, node.openedMarkups, opcodes);
},
};
class PostOpcodeCompiler$3 {
openMarker(closeCount, value) {
this.markupMarkerIds = [];
this.markers.push([0 /* MARKUP */, this.markupMarkerIds, closeCount, value || '']);
}
openAtom(closeCount, name, value, payload) {
const index = this._addAtomTypeIndex(name, value, payload);
this.markupMarkerIds = [];
this.markers.push([1 /* ATOM */, this.markupMarkerIds, closeCount, index]);
}
openMarkupSection(tagName, attributes) {
this.markers = [];
if (attributes && attributes.length !== 0) {
this.sections.push([1 /* MARKUP */, tagName, this.markers, attributes]);
}
else {
this.sections.push([1 /* MARKUP */, tagName, this.markers]);
}
}
openListSection(tagName, attributes) {
this.items = [];
if (attributes && attributes.length !== 0) {
this.sections.push([3 /* LIST */, tagName, this.items, attributes]);
}
else {
this.sections.push([3 /* LIST */, tagName, this.items]);
}
}
openListItem() {
this.markers = [];
this.items.push(this.markers);
}
openImageSection(url) {
this.sections.push([2 /* IMAGE */, url]);
}
openCardSection(name, payload) {
const index = this._addCardTypeIndex(name, payload);
this.sections.push([10 /* CARD */, 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 = new PostOpcodeCompiler$3();
compile(compiler, opcodes);
return compiler.result;
},
};
var mobiledocParsers = {
parse(builder, mobiledoc) {
switch (mobiledoc.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: ${mobiledoc.version}`, false);
}
},
};
class CardNode {
constructor(editor, card, section, element, options) {
this._rendered = null;
this._teardownCallback = null;
this._didRenderCallback = null;
this.editor = editor;
this.card = card;
this.section = section;
this.element = element;
this.options = options;
}
render(mode) {
if (this.mode === mode) {
return;
}
this.teardown();
this.mode = mode;
let methodName = mode === 'display' ? 'render' : 'edit';
let method = this.card[methodName];
assert(`Card is missing "${methodName}" (tried to render mode: "${mode}")`, !!method);
let rendered = method({
env: this.env,
options: this.options,
payload: this.section.payload,
}) || null;
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(CardMode.DISPLAY);
}
edit() {
this.render(CardMode.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._teardownCallback = null;
this.editor = editor;
this.atom = atom;
this.model = model;
this.atomOptions = atomOptions;
this.element = element;
}
render() {
if (!this._rendered) {
let { atomOptions: options, env, model: { value, payload }, } = this;
// cache initial render
this._rendered = this.atom.render({ options, env, value, payload }) || null;
}
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);
}
}
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 node (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 = unwrap(renderNode.element);
assertNotNull('expected RenderNode to have a parent', renderNode.parent);
if (originalElement) {
// RenderNode has already rendered
let parentElement = renderNode.parent.element;
parentElement.replaceChild(element, originalElement);
}
else {
// RenderNode has not yet been rendered
let parentElement;
let nextSiblingElement;
if (renderNode.prev) {
let previousElement = unwrap(renderNode.prev.element);
parentElement = unwrap(previousElement.parentNode);
nextSiblingElement = previousElement.nextSibling;
}
else {
parentElement = renderNode.parent.element;
nextSiblingElement = parentElement.firstChild;
}
parentElement.insertBefore(element, nextSiblingElement);
}
}
function removeRenderNodeSectionFromParent(renderNode, section) {
assertNotNull('expected RenderNode to have a parent', renderNode.parent);
assertNotNull('expected parent RenderNode to have a PostNode', renderNode.parent.postNode);
const parent = renderNode.parent.postNode;
assert('expected PostNode to have sections', hasChildSections(parent));
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" /* POST */](renderNode, post, visit) {
if (!renderNode.element) {
renderNode.element = document.createElement('div');
}
let element = renderNode.element;
addClassName(element, EDITOR_ELEMENT_CLASS_NAME);
if (post.hasContent) {
removeClassName(element, EDITOR_HAS_NO_CONTENT_CLASS_NAME);
}
else {
addClassName(element, EDITOR_HAS_NO_CONTENT_CLASS_NAME);
}
visit(renderNode, post.sections);
}
["markup-section" /* MARKUP_SECTION */](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" /* LIST_SECTION */](renderNode, section, visit) {
const originalElement = renderNode.element;
renderNode.element = renderListSection(section);
attachRenderNodeElementToDOM(renderNode, originalElement);
const visitAll = true;
visit(renderNode, section.items, visitAll);
}
["list-item" /* LIST_ITEM */](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" /* MARKER */](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" /* IMAGE_SECTION */](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-section" /* CARD */](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" /* ATOM */](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" /* POST */]( /*renderNode, post*/) {
assert('post destruction is not supported by the renderer', false);
},
["markup-section" /* MARKUP_SECTION */](renderNode, section) {
removeRenderNodeSectionFromParent(renderNode, section);
removeRenderNodeElementFromParent(renderNode);
},
["list-section" /* LIST_SECTION */](renderNode, section) {
removeRenderNodeSectionFromParent(renderNode, section);
removeRenderNodeElementFromParent(renderNode);
},
["list-item" /* LIST_ITEM */](renderNode, li) {
removeRenderNodeSectionFromParent(renderNode, li);
removeRenderNodeElementFromParent(renderNode);
},
["marker" /* MARKER */](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" /* IMAGE_SECTION */](renderNode, section) {
removeRenderNodeSectionFromParent(renderNode, section);
removeRenderNodeElementFromParent(renderNode);
},
["card-section" /* CARD */](renderNode, section) {
if (renderNode.cardNode) {
renderNode.cardNode.teardown();
}
removeRenderNodeSectionFromParent(renderNode, section);
removeRenderNodeElementFromParent(renderNode);
},
["atom" /* ATOM */](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" /* MARKER */](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;
assertExistsIn(`editor-dom cannot destroy "${method}"`, method, destroyHooks);
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.renderTree = null;
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 = unwrap(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;
let postNode;
while (renderNode) {
removeDestroyedChildren(renderNode);
postNode = renderNode.postNode;
method = postNode.type;
assertExistsIn(`EditorDom visitor cannot handle type ${method}`, method, this.visitor);
this.visitor[method](renderNode, postNode, (...args) => this.visit(renderTree, ...args));
renderNode.markClean();
renderNode = this.nodes.shift();
}
}
}
const VALID_MARKUP_TAGNAMES = [
'a',
'b',
'code',
'em',
'i',
's',
'del',
'strong',
'sub',
'sup',
'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.type = MARKUP_TYPE;
assert(`Cannot create markup of tagName ${tagName}`, VALID_MARKUP_TAGNAMES.indexOf(tagName) !== -1);
this.tagName = normalizeTagName(tagName);
assert('Must use attributes object param (not array) for Markup', !Array.isArray(attributes));
this.attributes = filterObject(attributes, VALID_ATTRIBUTES$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 = /\s*\n\s*/g;
function sanitize(text) {
return text.replace(NEWLINES, ' ');
}
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$1(this.state.section) && !this.state.section.text && !this.state.text) {
this.state.section = null;
}
else {
this._closeCurrentSection();
}
this.sections.push(section);
},
addMarkerable: (marker) => {
let { state } = this;
let { section } = state;
// if the first element doesn't create it's own state and it's plugin
// handler uses `addMarkerable` we won't have a section yet
if (!section) {
state.text = '';
state.section = this.builder.createMarkupSection(normalizeTagName('p'));
section = state.section;
}
assertType('Markerables can only be appended to markup sections and list item sections', section, section && section.isMarkerable);
if (state.text) {
this._createMarker();
}
section.markers.append(marker);
},
nodeFinished() {
isNodeFinished = true;
},
};
for (let i = 0; i < this.plugins.length; i++) {
let plugin = this.plugins[i];
plugin(node, this.builder, env);
if (isNodeFinished) {
return true;
}
}
return false;
}
/* eslint-disable complexity */
parseNode(node) {
if (!this.state.section) {
this._updateStateFromElement(node);
}
let nodeFinished = this.runPlugins(node);
if (nodeFinished) {
return;
}
// handle closing the current section and starting a new one if we hit a
// new-section-creating element.
if (this.state.section && isElementNode(node) && node.tagName) {
let tagName = normalizeTagName(node.tagName);
let isListSection = contains(VALID_LIST_SECTION_TAGNAMES, tagName);
let isListItem = contains(VALID_LIST_ITEM_TAGNAMES, tagName);
let isMarkupSection$1 = contains(VALID_MARKUP_SECTION_TAGNAMES, tagName);
let isNestedListSection = isListSection && this.state.section.isListItem;
let lastSection = this.sections[this.sections.length - 1];
// lists can continue after breaking out for a markup section,
// in that situation, start a new list using the same list type
if (isListItem && isMarkupSection(this.state.section)) {
this._closeCurrentSection();
this._updateStateFromElement(node.parentElement);
}
// we can hit a list item after parsing a nested list, when that happens
// and the lists are of different types we need to make sure we switch
// the list type back
if (isListItem && lastSection && isListSection$1(lastSection)) {
let parentElement = expect(node.parentElement, 'expected node to have parent element');
let parentElementTagName = normalizeTagName(parentElement.tagName);
if (parentElementTagName !== lastSection.tagName) {
this._closeCurrentSection();
this._updateStateFromElement(parentElement);
}
}
// if we've broken out of a list due to nested section-level elements we
// can hit the next list item without having a list section in the current
// state. In this instance we find the parent list node and use it to
// re-initialize the state with a new list section
if (isListItem &&
!(this.state.section.isListItem || this.state.section.isListSection) &&
!lastSection.isListSection) {
this._closeCurrentSection();
this._updateStateFromElement(node.parentElement);
}
// if we have consecutive list sections of different types (ul, ol) then
// ensure we close the current section and start a new one
let isNewListSection = lastSection &&
isListSection$1(lastSection) &&
this.state.section.isListItem &&
isListSection &&
tagName !== lastSection.tagName;
if (isNewListSection || (isListSection && !isNestedListSection) || isMarkupSection$1 || isListItem) {
// don't break out of the list for list items that contain a single .
// deals with typical case of
Text
Text
if (this.state.section.isListItem &&
tagName === 'p' &&
!node.nextSibling &&
contains(VALID_LIST_ITEM_TAGNAMES, normalizeTagName(expect(node.parentElement, 'expected node to have parent element').tagName))) {
this.parseElementNode(node);
return;
}
// avoid creating empty paragraphs due to wrapper elements around
// section-creating elements
if (isMarkerable$1(this.state.section) && !this.state.text && this.state.section.markers.length === 0) {
this.state.section = null;
}
else {
this._closeCurrentSection();
}
this._updateStateFromElement(node);
}
if (this.state.section && 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;
assert('expected markups to be non-null', state.markups);
const markups = this._markupsFromElement(element);
if (markups.length && state.text.length && isMarkerable$1(state.section)) {
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) {
if (isCommentNode(element)) {
return;
}
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 (isListItem(state.section) && lastSection && isListSection$1(lastSection)) {
trimSectionText(state.section);
lastSection.items.append(state.section);
}
else {
// avoid creating empty markup sections, especially useful for indented source
if (isMarkerable$1(state.section) &&
!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 && isListSection$1(lastSection) && 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);
assertType('expected section to be markerable', state.section, isMarkerable$1(state.section));
state.section.markers.append(marker);
state.text = '';
}
_getSectionDetails(element) {
let sectionType, tagName, inferredTagName = false;
if (isTextNode(element)) {
tagName = DEFAULT_TAG_NAME$1;
sectionType = MARKUP_SECTION_TYPE;
inferredTagName = true;
}
else {
tagName = normalizeTagName(element.tagName);
// blockquote>p is valid html and should be treated as a blockquote section
// rather than a plain markup section
if (tagName === 'p' &&
element.parentElement &&
normalizeTagName(element.parentElement.tagName) === 'blockquote') {
tagName = 'blockquote';
}
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$1;
inferredTagName = true;
}
}
return { sectionType, tagName, inferredTagName };
}
_createSectionFromElement(element) {
if (isCommentNode(element)) {
return;
}
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 isElementNode(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 (isMarkerable$1(section) && section.markers.length) {
let { head, tail } = section.markers;
head.value = head.value.replace(/^\s+/, '');
tail.value = tail.value.replace(/\s+$/, '');
}
}
function isGoogleDocsContainer(element) {
return (isElementNode(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 ||
(isMarkerable$1(section) && trim(section.text) === '' && !any(section.markers, marker => marker.isAtom))) {
return;
}
let lastSection = post.sections.tail;
if (lastSection &&
hasInferredTagName(lastSection) &&
hasInferredTagName(section) &&
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 (isElementNode(node) && 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 (isMarker(renderNode.postNode)) {
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 (isAtom(renderNode.postNode)) {
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;
// 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;
this._childNodes = null;
this._element = null;
this._cursorElement = null; // blank render nodes need a cursor element
this.postNode = postNode;
this.renderTree = renderTree;
}
isAttached() {
assert('Cannot check if a renderNode is attached without an element.', !!this.element);
return containsNode(unwrap(unwrap(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;
}
get element() {
return this._element;
}
set element(element) {
const currentElement = this._element;
this._element = element;
if (currentElement) {
this.renderTree.removeElementRenderNode(currentElement);
}
if (element) {
this.renderTree.setElementRenderNode(element, this);
}
}
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) {
assertHasUuid(key);
delete this._map[key._uuid];
}
}
function assertHasUuid(key) {
assert('tried to fetch a value for an element not seen before', !!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;
let _element = element;
while (_element) {
renderNode = this.getElementRenderNode(_element);
if (renderNode && conditionFn(renderNode)) {
return renderNode;
}
// continue loop
_element = _element.parentElement;
// 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 = MOBILEDOC_VERSION$3) {
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) {
return Object.assign(original, updates, options);
}
var Environment = {
hasDOM() {
return typeof document !== 'undefined';
},
};
function expectCloneable(section) {
if (!('clone' in section)) {
throw new Error('Expected section to be cloneable');
}
return section;
}
/**
* 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 {
constructor() {
this.type = "post" /* POST */;
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.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.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;
let 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 (isMarkerable$1(section)) {
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 (isListSection$1(next)) {
return next.items.head;
}
else {
assert('Cannot determine next section from non-leaf-section', false);
}
}
else if (isNested(section)) {
// 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);
}
else {
return null;
}
}
/**
* @param {Range} range
* @return {Post} A new post, constrained to {range}
*/
trimTo(range) {
const { builder } = this;
const post = builder.createPost();
const { head, tail } = range;
const tailNotSelected = tail.offset === 0 && head.section !== tail.section;
let sectionParent = post, listParent = null;
this.walkLeafSections(range, section => {
let newSection;
if (isMarkerable$1(section)) {
if (isListItem(section)) {
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;
const tagName = tailNotSelected && tail.section === section ? 'p' : section.tagName;
newSection = builder.createMarkupSection(tagName);
}
let currentRange = range.trimTo(section);
forEach(section.markersFor(currentRange.headSectionOffset, currentRange.tailSectionOffset), m => newSection.markers.append(m));
}
else {
newSection =
tailNotSelected && tail.section === section
? builder.createMarkupSection('p')
: expectCloneable(section).clone();
sectionParent = post;
}
if (sectionParent) {
sectionParent.sections.append(newSection);
}
});
return post;
}
}
class Image extends Section {
constructor() {
super("image-section" /* IMAGE_SECTION */);
this.src = null;
}
clone() {
return this.builder.createImageSection(this.src);
}
canJoin() {
return false;
}
get length() {
return 1;
}
}
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 {
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$1, 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, 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;
}
section.builder = this;
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) {
const { range } = editor;
const { head } = range;
const { section } = head;
// 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);
},
},
];
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) {
if (isCompiledKeyCommand(keyCommand)) {
return keyCommand;
}
assert('[deprecation] Key commands no longer use the `modifier` property', !keyCommand.modifier);
let { str, run, name } = keyCommand;
let [character, ...modifierNames] = str.split('+').reverse();
return {
name,
run,
modifierMask: modifierNamesToMask(modifierNames),
code: characterToCode(character),
};
}
function isCompiledKeyCommand(keyCommand) {
return keyCommand.str === undefined;
}
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;
});
}
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 "characterData" /* CHARACTER_DATA */:
nodes.push(mutation.target);
break;
case "childList" /* 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);
let headPosition = headSection.toPosition(headOffset);
let tailPosition = tailSection.toPosition(tailOffset);
return headPosition.toRange(tailPosition);
}
}
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$1, 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 = isListSection$1(section) &&
this.prevSection &&
isListSection$1(this.prevSection) &&
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;
}
}
}
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 < handlers.length; i++) {
if (handlers[i].name === name) {
handlers.splice(i, 1);
}
}
}
handle(string) {
let { editor } = this;
editor.insertText(string);
let matchedHandler = this._findHandler();
if (matchedHandler) {
let [handler, matches] = matchedHandler;
handler.run(editor, matches);
}
}
handleNewLine() {
let { editor } = this;
let matchedHandler = this._findHandler(ENTER);
if (matchedHandler) {
let [handler, matches] = matchedHandler;
handler.run(editor, matches);
}
}
_findHandler(string = '') {
const { editor } = this;
const { range } = editor;
const { head } = range;
const { section } = head;
let preText = section.textUntil(head) + string;
for (let i = 0; i < this._handlers.length; i++) {
let handler = this._handlers[i];
if ('text' in handler && endsWith(preText, handler.text)) {
return [handler, [handler.text]];
}
else if ('match' in handler && handler.match.test(preText)) {
return [handler, handler.match.exec(preText)];
}
}
}
_validateHandler(handler) {
deprecate('Registered input handlers require a "name" property so that they can be unregistered', !!handler.name);
return (!!handler.run && // has `run`
(!!handler.text || !!handler.match) && // and `text` or `match`
!(!!handler.text && !!handler.match)); // not both `text` and `match`
}
destroy() {
this._handlers = [];
}
}
let instance;
class SelectionChangeObserver {
constructor({ editor }) {
this.started = false;
this.listeners = [];
this.selection = {};
this.editor = editor;
}
static getInstance({ editor } = {}) {
if (!instance) {
instance = new SelectionChangeObserver({ editor });
}
return instance;
}
static addListener(listener) {
SelectionChangeObserver.getInstance({ editor: listener.editor }).addListener(listener);
}
addListener(listener) {
if (this.listeners.indexOf(listener) === -1) {
this.listeners.push(listener);
this.start();
}
}
static removeListener(listener) {
SelectionChangeObserver.getInstance().removeListener(listener);
}
removeListener(listener) {
let index = this.listeners.indexOf(listener);
if (index !== -1) {
this.listeners.splice(index, 1);
if (this.listeners.length === 0) {
this.stop();
}
}
}
start() {
if (this.started) {
return;
}
this.started = true;
this.poll();
}
stop() {
this.started = false;
this.selection = {};
}
notifyListeners(newSelection, prevSelection) {
this.listeners.forEach(listener => {
listener.selectionDidChange(newSelection, prevSelection);
});
}
destroy() {
this.stop();
this.listeners = [];
}
getSelection(root = window) {
let selection = root.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(this.editor.root);
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(curSelection, prevSelection) {
if (this.started) {
this.callback(curSelection, prevSelection);
}
}
}
const ELEMENT_EVENT_TYPES = [
'keydown',
'keyup',
'cut',
'copy',
'paste',
'keypress',
'drop',
'compositionstart',
'compositionend',
];
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;
this._isComposingOnBlankLine = false;
}
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();
}
// Handle carriage returns
if (!key.isEnter() && key.keyCode === 13) {
_textInputHandler.handleNewLine();
editor.handleNewline(event);
return;
}
_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) {
// Ignore keydown events when using an IME
case key.isIME(): {
break;
}
// 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 = TextUnit.CHAR;
if (key.altKey && Browser.isMac()) {
unit = TextUnit.WORD;
}
else if (key.ctrlKey && !Browser.isMac()) {
unit = TextUnit.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 });
}
// The mutation handler interferes with IMEs when composing
// on a blank line. These two event handlers are for suppressing
// mutation handling in this scenario.
compositionstart(_event) {
let { editor } = this;
// Ignore compositionstart if not on a blank line
if (editor.range.headMarker) {
return;
}
this._isComposingOnBlankLine = true;
if (editor.post.isBlank) {
editor._insertEmptyMarkupSectionAtCursor();
}
// Stop listening for mutations on Chrome browsers and suppress
// mutations by prepending a character for other browsers.
// The reason why we treat these separately is because
// of the way each browser processes IME inputs.
if (Browser.isChrome()) {
editor.setPlaceholder('');
editor._mutationHandler.stopObserving();
}
else {
this._textInputHandler.handle(' ');
}
}
compositionend(event) {
const { editor } = this;
// Ignore compositionend if not composing on blank line
if (!this._isComposingOnBlankLine) {
return;
}
this._isComposingOnBlankLine = false;
// Start listening for mutations on Chrome browsers and
// delete the prepended character introduced by compositionstart
// for other browsers.
if (Browser.isChrome()) {
editor.insertText(event.data);
editor.setPlaceholder(editor.placeholder);
editor._mutationHandler.startObserving();
}
else {
let startOfCompositionLine = editor.range.headSection.toPosition(0);
let endOfCompositionLine = editor.range.headSection.toPosition(event.data.length);
editor.run(postEditor => {
postEditor.deleteAtPosition(startOfCompositionLine, 1, { unit: TextUnit.CHAR });
postEditor.setRange(endOfCompositionLine);
});
}
}
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, Format.HTML),
text: editor.serializePost(post, Format.TEXT),
mobiledoc: editor.serializePost(post, Format.MOBILEDOC),
};
editor.runCallbacks('willCopy', [data]);
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.runCallbacks('willPaste', [pastedPost]);
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() {
const { state, prevState } = this;
const { range } = state;
const { range: prevRange } = prevState;
return !prevRange.isEqual(range);
}
/**
* @return {Boolean} Whether the input mode (active markups or active section tag names)
* has changed.
*/
inputModeDidChange() {
const state = this.state;
const prevState = this.prevState;
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) {
const { head, tail } = range;
const { editor } = this;
const { post } = editor;
if (range.isBlank) {
return [];
}
else {
return post.sections.readRange(head.section, tail.section);
}
}
_readActiveMarkups(range) {
const { editor } = this;
const { post } = editor;
return post.markupsInRange(range);
}
_readSectionAttributes(sections) {
return sections.reduce((sectionAttributes, s) => {
let attributes = getSectionAttributes(s);
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 = 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:
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:
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$1 = 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$1:
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$1:
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,
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,
tooltipPlugin: DEFAULT_TOOLTIP_PLUGIN,
};
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',
WILL_COPY: 'willCopy',
WILL_PASTE: 'willPaste',
};
var Format;
(function (Format) {
Format["MOBILEDOC"] = "mobiledoc";
Format["HTML"] = "html";
Format["TEXT"] = "text";
})(Format || (Format = {}));
var TextUnit;
(function (TextUnit) {
TextUnit["CHAR"] = "char";
TextUnit["WORD"] = "word";
})(TextUnit || (TextUnit = {}));
/**
* 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.
* @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: [] };
this._isComposingOnBlankLine = false;
DEFAULT_TEXT_INPUT_HANDLERS.forEach(handler => this.onTextInput(handler));
this.hasRendered = false;
this.isDestroyed = 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(html);
}
else {
let dom = html;
return this._parser.parse(dom);
}
}
else {
return this.builder.createPost([this.builder.createMarkupSection()]);
}
}
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',
editor: this,
}));
}
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: TextUnit.CHAR }) {
const { 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
const editHistory = this._editHistory;
const pendingSnapshot = editHistory._pendingSnapshot;
const range = pendingSnapshot.range;
this.run(() => {
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, Format.MOBILEDOC, { version });
}
serializeTo(format) {
let post = this.post;
return this.serializePost(post, format);
}
serializePost(post, format, options = {}) {
assert(`Unrecognized serialization format ${format}`, contains(Object.values(Format), format));
if (format === Format.MOBILEDOC) {
let version = options.version || MOBILEDOC_VERSION$4;
return mobiledocRenderers.render(post, version);
}
else {
let mobiledoc = this.serializePost(post, Format.MOBILEDOC);
let unknownCardHandler = () => { };
let unknownAtomHandler = () => { };
let rendererOptions = { unknownCardHandler, unknownAtomHandler };
switch (format) {
case Format.HTML: {
if (Environment.hasDOM()) {
const rendered = new RendererFactory(rendererOptions).render(mobiledoc);
return `${serializeHTML(rendered.result)}
`;
}
else {
// Fallback to text serialization
return this.serializePost(post, Format.TEXT, options);
}
}
case Format.TEXT: {
let 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, CardMode.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, CardMode.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);
}
willCopy(callback) {
this.addCallback(CALLBACK_QUEUES$1.WILL_COPY, callback);
}
/**
* @param {Function} callback This callback will be called before pasting.
* @public
*/
willPaste(callback) {
this.addCallback(CALLBACK_QUEUES$1.WILL_PASTE, 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(markupTag, attributes = {}) {
const markup = this.builder.createMarkup(markupTag, attributes);
const { range } = this;
const willAdd = !this.detectMarkupInRange(range, markup.tagName);
const 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() {
const { 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 this.root.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 < keyCommands.length; i++) {
let keyCommand = keyCommands[i];
if (keyCommand.run(this) !== false) {
event.preventDefault();
return true;
}
}
return false;
}
/**
* Inserts the text 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} text
* @public
*/
insertText(text) {
if (!this.hasCursor()) {
return;
}
if (this.post.isBlank) {
this._insertEmptyMarkupSectionAtCursor();
}
let { activeMarkups, range, range: { head: position }, } = this;
this.run(postEditor => {
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 (isNested(section)) {
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.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(queueName, callback) {
this._callbacks.addCallback(queueName, callback);
}
addCallbackOnce(queueName, callback) {
this._callbacks.addCallbackOnce(queueName, callback);
}
runCallbacks(queueName, args) {
if (this.isDestroyed) {
// TODO warn that callback attempted after editor was destroyed
return;
}
this._callbacks.runCallbacks(queueName, 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;
}
}
}
get root() {
const root = this.element.getRootNode();
// COMPAT: Only Chrome implements the DocumentOrShadowRoot mixin for
// ShadowRoot; other browsers still implement it on the Document
// interface. (2020/08/08)
// https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot#Properties
if (root.getSelection === undefined && this.element.ownerDocument !== null) {
return this.element.ownerDocument;
}
return root;
}
}
var version = '##VERSION##';
exports.DOMParser = DOMParser;
exports.Editor = Editor;
exports.Error = MobiledocError;
exports.ImageCard = ImageCard;
exports.MOBILEDOC_VERSION = MOBILEDOC_VERSION$4;
exports.Position = Position;
exports.PostNodeBuilder = PostNodeBuilder;
exports.Range = Range;
exports.Renderer = mobiledocRenderers;
exports.UI = ui;
exports.VERSION = version;
//# sourceMappingURL=mobiledoc.cjs.map