mirror of
https://github.com/the-djmaze/snappymail.git
synced 2025-01-07 07:18:39 +08:00
3718 lines
104 KiB
JavaScript
3718 lines
104 KiB
JavaScript
/* Copyright © 2011-2015 by Neil Jenkins. MIT Licensed. */
|
||
/* eslint max-len: 0 */
|
||
|
||
/**
|
||
TODO: modifyBlocks function doesn't work very good.
|
||
For example you have: UL > LI > [cursor here in text]
|
||
Then create blockquote at cursor, the result is: BLOCKQUOTE > UL > LI
|
||
not UL > LI > BLOCKQUOTE
|
||
*/
|
||
|
||
(doc => {
|
||
|
||
const
|
||
blockTag = 'DIV',
|
||
DOCUMENT_POSITION_PRECEDING = 2, // Node.DOCUMENT_POSITION_PRECEDING
|
||
ELEMENT_NODE = 1, // Node.ELEMENT_NODE,
|
||
TEXT_NODE = 3, // Node.TEXT_NODE,
|
||
DOCUMENT_FRAGMENT_NODE = 11, // Node.DOCUMENT_FRAGMENT_NODE,
|
||
SHOW_ELEMENT = 1, // NodeFilter.SHOW_ELEMENT,
|
||
SHOW_TEXT = 4, // NodeFilter.SHOW_TEXT,
|
||
SHOW_ELEMENT_OR_TEXT = 5,
|
||
|
||
START_TO_START = 0, // Range.START_TO_START
|
||
START_TO_END = 1, // Range.START_TO_END
|
||
END_TO_END = 2, // Range.END_TO_END
|
||
END_TO_START = 3, // Range.END_TO_START
|
||
|
||
ZWS = '\u200B',
|
||
NBSP = '\u00A0',
|
||
|
||
win = doc.defaultView,
|
||
|
||
ua = navigator.userAgent,
|
||
|
||
isMac = /Mac OS X/.test(ua),
|
||
isIOS = /iP(?:ad|hone|od)/.test(ua) || (isMac && !!navigator.maxTouchPoints),
|
||
|
||
isWebKit = /WebKit\//.test(ua),
|
||
|
||
ctrlKey = isMac ? 'meta-' : 'ctrl-',
|
||
osKey = isMac ? 'metaKey' : 'ctrlKey',
|
||
|
||
// Use [^ \t\r\n] instead of \S so that nbsp does not count as white-space
|
||
notWS = /[^ \t\r\n]/,
|
||
|
||
indexOf = (array, value) => Array.prototype.indexOf.call(array, value),
|
||
|
||
filterAccept = NodeFilter.FILTER_ACCEPT,
|
||
/*
|
||
typeToBitArray = {
|
||
// ELEMENT_NODE
|
||
1: 1,
|
||
// ATTRIBUTE_NODE
|
||
2: 2,
|
||
// TEXT_NODE
|
||
3: 4,
|
||
// COMMENT_NODE
|
||
8: 128,
|
||
// DOCUMENT_NODE
|
||
9: 256,
|
||
// DOCUMENT_FRAGMENT_NODE
|
||
11: 1024
|
||
},
|
||
*/
|
||
inlineNodeNames = /^(?:#text|A|ABBR|ACRONYM|B|BR|BD[IO]|CITE|CODE|DATA|DEL|DFN|EM|FONT|HR|I|IMG|INPUT|INS|KBD|Q|RP|RT|RUBY|S|SAMP|SMALL|SPAN|STR(IKE|ONG)|SU[BP]|TIME|U|VAR|WBR)$/,
|
||
// phrasingElements = 'ABBR,AUDIO,B,BDO,BR,BUTTON,CANVAS,CITE,CODE,COMMAND,DATA,DATALIST,DFN,EM,EMBED,I,IFRAME,IMG,INPUT,KBD,KEYGEN,LABEL,MARK,MATH,METER,NOSCRIPT,OBJECT,OUTPUT,PROGRESS,Q,RUBY,SAMP,SCRIPT,SELECT,SMALL,SPAN,STRONG,SUB,SUP,SVG,TEXTAREA,TIME,VAR,VIDEO,WBR',
|
||
|
||
leafNodeNames = {
|
||
BR: 1,
|
||
HR: 1,
|
||
IMG: 1
|
||
},
|
||
|
||
UNKNOWN = 0,
|
||
INLINE = 1,
|
||
BLOCK = 2,
|
||
CONTAINER = 3,
|
||
|
||
isLeaf = node => node.nodeType === ELEMENT_NODE && !!leafNodeNames[ node.nodeName ],
|
||
|
||
getNodeCategory = node => {
|
||
switch (node.nodeType) {
|
||
case TEXT_NODE:
|
||
return INLINE;
|
||
case ELEMENT_NODE:
|
||
case DOCUMENT_FRAGMENT_NODE:
|
||
if (nodeCategoryCache.has(node)) {
|
||
return nodeCategoryCache.get(node);
|
||
}
|
||
break;
|
||
default:
|
||
return UNKNOWN;
|
||
}
|
||
|
||
let nodeCategory =
|
||
Array.prototype.every.call(node.childNodes, isInline)
|
||
? (inlineNodeNames.test(node.nodeName) ? INLINE : BLOCK)
|
||
// Malformed HTML can have block tags inside inline tags. Need to treat
|
||
// these as containers rather than inline. See #239.
|
||
: CONTAINER;
|
||
nodeCategoryCache.set(node, nodeCategory);
|
||
return nodeCategory;
|
||
},
|
||
isInline = node => getNodeCategory(node) === INLINE,
|
||
isBlock = node => getNodeCategory(node) === BLOCK,
|
||
isContainer = node => getNodeCategory(node) === CONTAINER,
|
||
createTreeWalker = (root, whatToShow, filter) => doc.createTreeWalker(root, whatToShow, filter ? {
|
||
acceptNode: node => filter(node) ? filterAccept : NodeFilter.FILTER_SKIP
|
||
} : null
|
||
),
|
||
getBlockWalker = (node, root) => {
|
||
let walker = createTreeWalker(root, SHOW_ELEMENT, isBlock);
|
||
walker.currentNode = node;
|
||
return walker;
|
||
},
|
||
getPreviousBlock = (node, root) => {
|
||
// node = getClosest(node, root, blockElementNames);
|
||
node = getBlockWalker(node, root).previousNode();
|
||
return node !== root ? node : null;
|
||
},
|
||
getNextBlock = (node, root) => {
|
||
// node = getClosest(node, root, blockElementNames);
|
||
node = getBlockWalker(node, root).nextNode();
|
||
return node !== root ? node : null;
|
||
},
|
||
|
||
isEmptyBlock = block => !block.textContent && !block.querySelector('IMG'),
|
||
|
||
areAlike = (node, node2) => {
|
||
return !isLeaf(node) && (
|
||
node.nodeType === node2.nodeType &&
|
||
node.nodeName === node2.nodeName &&
|
||
node.nodeName !== 'A' &&
|
||
node.className === node2.className &&
|
||
node.style?.cssText === node2.style?.cssText
|
||
);
|
||
},
|
||
hasTagAttributes = (node, tag, attributes) => {
|
||
return node.nodeName === tag && Object.entries(attributes || {}).every(([k,v]) => node.getAttribute(k) === v);
|
||
},
|
||
getClosest = (node, root, selector) => {
|
||
node = (node && !node.closest) ? node.parentElement : node;
|
||
node = node?.closest(selector);
|
||
return (node && root.contains(node)) ? node : null;
|
||
},
|
||
getNearest = (node, root, tag, attributes) => {
|
||
while (node && node !== root) {
|
||
if (hasTagAttributes(node, tag, attributes)) {
|
||
return node;
|
||
}
|
||
node = node.parentNode;
|
||
}
|
||
return null;
|
||
},
|
||
|
||
getPath = (node, root) => {
|
||
let path = '', style;
|
||
if (node && node !== root) {
|
||
path = getPath(node.parentNode, root);
|
||
if (node.nodeType === ELEMENT_NODE) {
|
||
path += (path ? '>' : '') + node.nodeName;
|
||
if (node.id) {
|
||
path += '#' + node.id;
|
||
}
|
||
if (node.classList.length) {
|
||
path += '.' + [...node.classList].sort().join('.');
|
||
}
|
||
if (node.dir) {
|
||
path += '[dir=' + node.dir + ']';
|
||
}
|
||
if (style = node.style.cssText) {
|
||
path += '[style=' + style + ']';
|
||
}
|
||
}
|
||
}
|
||
return path;
|
||
},
|
||
|
||
getLength = node => {
|
||
let nodeType = node.nodeType;
|
||
return nodeType === ELEMENT_NODE || nodeType === DOCUMENT_FRAGMENT_NODE ?
|
||
node.childNodes.length : node.length || 0;
|
||
},
|
||
|
||
detach = node => {
|
||
// node.remove();
|
||
node.parentNode?.removeChild(node);
|
||
return node;
|
||
},
|
||
|
||
empty = node => {
|
||
let frag = doc.createDocumentFragment(),
|
||
childNodes = node.childNodes;
|
||
childNodes && frag.append(...childNodes);
|
||
return frag;
|
||
},
|
||
|
||
setStyle = (node, style) => {
|
||
if (typeof style === 'object') {
|
||
Object.entries(style).forEach(([k,v]) => node.style[k] = v);
|
||
} else if (style !== undefined) {
|
||
node.setAttribute('style', style);
|
||
}
|
||
},
|
||
|
||
createElement = (tag, props, children) => {
|
||
let el = doc.createElement(tag);
|
||
if (props instanceof Array) {
|
||
children = props;
|
||
props = null;
|
||
}
|
||
props && Object.entries(props).forEach(([k,v]) => {
|
||
if ('style' === k) {
|
||
setStyle(el, v);
|
||
} else if (v !== undefined) {
|
||
el.setAttribute(k, v);
|
||
}
|
||
});
|
||
children && el.append(...children);
|
||
return el;
|
||
},
|
||
|
||
fixCursor = (node, root) => {
|
||
// In Webkit and Gecko, block level elements are collapsed and
|
||
// unfocusable if they have no content (:empty). To remedy this, a <BR> must be
|
||
// inserted. In Opera and IE, we just need a textnode in order for the
|
||
// cursor to appear.
|
||
let self = root.__squire__;
|
||
let originalNode = node;
|
||
let fixer, child;
|
||
|
||
if (node === root) {
|
||
if (!(child = node.firstChild) || child.nodeName === 'BR') {
|
||
fixer = self.createDefaultBlock();
|
||
if (child) {
|
||
child.replaceWith(fixer);
|
||
}
|
||
else {
|
||
node.append(fixer);
|
||
}
|
||
node = fixer;
|
||
fixer = null;
|
||
}
|
||
}
|
||
|
||
if (node.nodeType === TEXT_NODE) {
|
||
return originalNode;
|
||
}
|
||
|
||
if (isInline(node)) {
|
||
child = node.firstChild;
|
||
while (isWebKit && child?.nodeType === TEXT_NODE && !child.data) {
|
||
child.remove();
|
||
child = node.firstChild;
|
||
}
|
||
if (!child) {
|
||
fixer = self._addZWS();
|
||
}
|
||
// } else if (!node.querySelector('BR')) {
|
||
// } else if (!node.innerText.trim().length) {
|
||
} else if (node.matches(':empty')) {
|
||
fixer = createElement('BR');
|
||
while ((child = node.lastElementChild) && !isInline(child)) {
|
||
node = child;
|
||
}
|
||
}
|
||
if (fixer) {
|
||
try {
|
||
node.append(fixer);
|
||
} catch (error) {
|
||
didError({
|
||
name: 'Squire: fixCursor – ' + error,
|
||
message: 'Parent: ' + node.nodeName + '/' + node.innerHTML +
|
||
' appendChild: ' + fixer.nodeName
|
||
});
|
||
}
|
||
}
|
||
|
||
return originalNode;
|
||
},
|
||
|
||
// Recursively examine container nodes and wrap any inline children.
|
||
fixContainer = (container, root) => {
|
||
let wrapper, isBR;
|
||
[...container.children].forEach(child => {
|
||
isBR = child.nodeName === 'BR';
|
||
if (!isBR && isInline(child)
|
||
// && (blockTag !== 'DIV' || (child.matches && !child.matches(phrasingElements)))
|
||
) {
|
||
wrapper = wrapper || createElement('div');
|
||
wrapper.append(child);
|
||
} else if (isBR || wrapper) {
|
||
wrapper = wrapper || createElement('div');
|
||
fixCursor(wrapper, root);
|
||
child[isBR ? 'replaceWith' : 'before'](wrapper);
|
||
wrapper = null;
|
||
}
|
||
isContainer(child) && fixContainer(child, root);
|
||
});
|
||
wrapper && container.append(fixCursor(wrapper, root));
|
||
return container;
|
||
},
|
||
|
||
split = (node, offset, stopNode, root) => {
|
||
let nodeType = node.nodeType,
|
||
parent, clone, next;
|
||
if (nodeType === TEXT_NODE && node !== stopNode) {
|
||
return split(
|
||
node.parentNode, node.splitText(offset), stopNode, root);
|
||
}
|
||
if (nodeType === ELEMENT_NODE) {
|
||
if (typeof(offset) === 'number') {
|
||
offset = offset < node.childNodes.length ?
|
||
node.childNodes[ offset ] : null;
|
||
}
|
||
if (node === stopNode) {
|
||
return offset;
|
||
}
|
||
|
||
// Clone node without children
|
||
parent = node.parentNode;
|
||
clone = node.cloneNode(false);
|
||
|
||
// Add right-hand siblings to the clone
|
||
while (offset) {
|
||
next = offset.nextSibling;
|
||
clone.append(offset);
|
||
offset = next;
|
||
}
|
||
|
||
// Maintain li numbering if inside a quote.
|
||
if (node.nodeName === 'OL' &&
|
||
getClosest(node, root, 'BLOCKQUOTE')) {
|
||
clone.start = (+node.start || 1) + node.childNodes.length - 1;
|
||
}
|
||
|
||
// DO NOT NORMALISE. This may undo the fixCursor() call
|
||
// of a node lower down the tree!
|
||
|
||
// We need something in the element in order for the cursor to appear.
|
||
fixCursor(node, root);
|
||
fixCursor(clone, root);
|
||
|
||
// Inject clone after original node
|
||
node.after(clone);
|
||
|
||
// Keep on splitting up the tree
|
||
return split(parent, clone, stopNode, root);
|
||
}
|
||
return offset;
|
||
},
|
||
|
||
_mergeInlines = (node, fakeRange) => {
|
||
let children = node.childNodes,
|
||
l = children.length,
|
||
frags = [],
|
||
child, prev;
|
||
while (l--) {
|
||
child = children[l];
|
||
prev = l && children[ l - 1 ];
|
||
if (l && isInline(child) && areAlike(child, prev) &&
|
||
!leafNodeNames[ child.nodeName ]) {
|
||
if (fakeRange.startContainer === child) {
|
||
fakeRange.startContainer = prev;
|
||
fakeRange.startOffset += getLength(prev);
|
||
}
|
||
if (fakeRange.endContainer === child) {
|
||
fakeRange.endContainer = prev;
|
||
fakeRange.endOffset += getLength(prev);
|
||
}
|
||
if (fakeRange.startContainer === node) {
|
||
if (fakeRange.startOffset > l) {
|
||
--fakeRange.startOffset;
|
||
}
|
||
else if (fakeRange.startOffset === l) {
|
||
fakeRange.startContainer = prev;
|
||
fakeRange.startOffset = getLength(prev);
|
||
}
|
||
}
|
||
if (fakeRange.endContainer === node) {
|
||
if (fakeRange.endOffset > l) {
|
||
--fakeRange.endOffset;
|
||
}
|
||
else if (fakeRange.endOffset === l) {
|
||
fakeRange.endContainer = prev;
|
||
fakeRange.endOffset = getLength(prev);
|
||
}
|
||
}
|
||
detach(child);
|
||
if (child.nodeType === TEXT_NODE) {
|
||
prev.appendData(child.data);
|
||
}
|
||
else {
|
||
frags.push(empty(child));
|
||
}
|
||
}
|
||
else if (child.nodeType === ELEMENT_NODE) {
|
||
child.append(...frags.reverse());
|
||
frags = [];
|
||
_mergeInlines(child, fakeRange);
|
||
}
|
||
}
|
||
},
|
||
|
||
mergeInlines = (node, range) => {
|
||
if (node.nodeType === TEXT_NODE) {
|
||
node = node.parentNode;
|
||
}
|
||
if (node.nodeType === ELEMENT_NODE) {
|
||
let fakeRange = {
|
||
startContainer: range.startContainer,
|
||
startOffset: range.startOffset,
|
||
endContainer: range.endContainer,
|
||
endOffset: range.endOffset
|
||
};
|
||
_mergeInlines(node, fakeRange);
|
||
range.setStart(fakeRange.startContainer, fakeRange.startOffset);
|
||
range.setEnd(fakeRange.endContainer, fakeRange.endOffset);
|
||
}
|
||
},
|
||
|
||
mergeWithBlock = (block, next, range, root) => {
|
||
let container = next;
|
||
let parent, last, offset;
|
||
while ((parent = container.parentNode) &&
|
||
parent !== root &&
|
||
parent.nodeType === ELEMENT_NODE &&
|
||
parent.childNodes.length === 1) {
|
||
container = parent;
|
||
}
|
||
detach(container);
|
||
|
||
offset = block.childNodes.length;
|
||
|
||
// Remove extra <BR> fixer if present.
|
||
last = block.lastChild;
|
||
if (last?.nodeName === 'BR') {
|
||
last.remove();
|
||
--offset;
|
||
}
|
||
|
||
block.append(empty(next));
|
||
|
||
range.setStart(block, offset);
|
||
range.collapse(true);
|
||
mergeInlines(block, range);
|
||
},
|
||
|
||
mergeContainers = (node, root) => {
|
||
let prev = node.previousSibling,
|
||
first = node.firstChild,
|
||
isListItem = (node.nodeName === 'LI'),
|
||
needsFix, block;
|
||
|
||
// Do not merge LIs, unless it only contains a UL
|
||
if (isListItem && (!first || !/^[OU]L$/.test(first.nodeName))) {
|
||
return;
|
||
}
|
||
|
||
if (prev && areAlike(prev, node)) {
|
||
if (!isContainer(prev)) {
|
||
if (!isListItem) {
|
||
return;
|
||
}
|
||
block = createElement('DIV');
|
||
block.append(empty(prev));
|
||
prev.append(block);
|
||
}
|
||
detach(node);
|
||
needsFix = !isContainer(node);
|
||
prev.append(empty(node));
|
||
if (needsFix) {
|
||
fixContainer(prev, root);
|
||
}
|
||
if (first) {
|
||
mergeContainers(first, root);
|
||
}
|
||
} else if (isListItem) {
|
||
prev = createElement('DIV');
|
||
node.insertBefore(prev, first);
|
||
fixCursor(prev, root);
|
||
}
|
||
},
|
||
|
||
getNodeBefore = (node, offset) => {
|
||
let children = node.childNodes;
|
||
while (offset && node.nodeType === ELEMENT_NODE) {
|
||
node = children[ offset - 1 ];
|
||
children = node.childNodes;
|
||
offset = children.length;
|
||
}
|
||
return node;
|
||
},
|
||
|
||
getNodeAfter = (node, offset) => {
|
||
if (node.nodeType === ELEMENT_NODE) {
|
||
let children = node.childNodes;
|
||
if (offset < children.length) {
|
||
node = children[ offset ];
|
||
} else {
|
||
while (node && !node.nextSibling) {
|
||
node = node.parentNode;
|
||
}
|
||
if (node) { node = node.nextSibling; }
|
||
}
|
||
}
|
||
return node;
|
||
},
|
||
|
||
insertNodeInRange = (range, node) => {
|
||
// Insert at start.
|
||
let startContainer = range.startContainer,
|
||
startOffset = range.startOffset,
|
||
endContainer = range.endContainer,
|
||
endOffset = range.endOffset,
|
||
parent, children, childCount, afterSplit;
|
||
|
||
// If part way through a text node, split it.
|
||
if (startContainer.nodeType === TEXT_NODE) {
|
||
parent = startContainer.parentNode;
|
||
children = parent.childNodes;
|
||
if (startOffset === startContainer.length) {
|
||
startOffset = indexOf(children, startContainer) + 1;
|
||
if (range.collapsed) {
|
||
endContainer = parent;
|
||
endOffset = startOffset;
|
||
}
|
||
} else {
|
||
if (startOffset) {
|
||
afterSplit = startContainer.splitText(startOffset);
|
||
if (endContainer === startContainer) {
|
||
endOffset -= startOffset;
|
||
endContainer = afterSplit;
|
||
}
|
||
else if (endContainer === parent) {
|
||
++endOffset;
|
||
}
|
||
startContainer = afterSplit;
|
||
}
|
||
startOffset = indexOf(children, startContainer);
|
||
}
|
||
startContainer = parent;
|
||
} else {
|
||
children = startContainer.childNodes;
|
||
}
|
||
|
||
childCount = children.length;
|
||
|
||
if (startOffset === childCount) {
|
||
startContainer.append(node);
|
||
} else {
|
||
startContainer.insertBefore(node, children[ startOffset ]);
|
||
}
|
||
|
||
if (startContainer === endContainer) {
|
||
endOffset += children.length - childCount;
|
||
}
|
||
|
||
range.setStart(startContainer, startOffset);
|
||
range.setEnd(endContainer, endOffset);
|
||
},
|
||
|
||
extractContentsOfRange = (range, common, root) => {
|
||
let startContainer = range.startContainer,
|
||
startOffset = range.startOffset,
|
||
endContainer = range.endContainer,
|
||
endOffset = range.endOffset;
|
||
|
||
if (!common) {
|
||
common = range.commonAncestorContainer;
|
||
}
|
||
|
||
if (common.nodeType === TEXT_NODE) {
|
||
common = common.parentNode;
|
||
}
|
||
|
||
let endNode = split(endContainer, endOffset, common, root),
|
||
startNode = split(startContainer, startOffset, common, root),
|
||
frag = doc.createDocumentFragment(),
|
||
next, before, after, beforeText, afterText;
|
||
|
||
// End node will be null if at end of child nodes list.
|
||
while (startNode !== endNode) {
|
||
next = startNode.nextSibling;
|
||
frag.append(startNode);
|
||
startNode = next;
|
||
}
|
||
|
||
startContainer = common;
|
||
startOffset = endNode ?
|
||
indexOf(common.childNodes, endNode) :
|
||
common.childNodes.length;
|
||
|
||
// Merge text nodes if adjacent. IE10 in particular will not focus
|
||
// between two text nodes
|
||
after = common.childNodes[ startOffset ];
|
||
before = after?.previousSibling;
|
||
if (before?.nodeType === TEXT_NODE && after.nodeType === TEXT_NODE) {
|
||
startContainer = before;
|
||
startOffset = before.length;
|
||
beforeText = before.data;
|
||
afterText = after.data;
|
||
|
||
// If we now have two adjacent spaces, the second one needs to become
|
||
// a nbsp, otherwise the browser will swallow it due to HTML whitespace
|
||
// collapsing.
|
||
if (beforeText.charAt(beforeText.length - 1) === ' ' &&
|
||
afterText.charAt(0) === ' ') {
|
||
afterText = NBSP + afterText.slice(1); // nbsp
|
||
}
|
||
before.appendData(afterText);
|
||
detach(after);
|
||
}
|
||
|
||
range.setStart(startContainer, startOffset);
|
||
range.collapse(true);
|
||
|
||
fixCursor(common, root);
|
||
|
||
return frag;
|
||
},
|
||
|
||
deleteContentsOfRange = (range, root) => {
|
||
let startBlock = getStartBlockOfRange(range, root);
|
||
let endBlock = getEndBlockOfRange(range, root);
|
||
let needsMerge = (startBlock !== endBlock);
|
||
let frag, child;
|
||
|
||
// Move boundaries up as much as possible without exiting block,
|
||
// to reduce need to split.
|
||
moveRangeBoundariesDownTree(range);
|
||
moveRangeBoundariesUpTree(range, startBlock, endBlock, root);
|
||
|
||
// Remove selected range
|
||
frag = extractContentsOfRange(range, null, root);
|
||
|
||
// Move boundaries back down tree as far as possible.
|
||
moveRangeBoundariesDownTree(range);
|
||
|
||
// If we split into two different blocks, merge the blocks.
|
||
if (needsMerge) {
|
||
// endBlock will have been split, so need to refetch
|
||
endBlock = getEndBlockOfRange(range, root);
|
||
if (startBlock && endBlock && startBlock !== endBlock) {
|
||
mergeWithBlock(startBlock, endBlock, range, root);
|
||
}
|
||
}
|
||
|
||
// Ensure block has necessary children
|
||
if (startBlock) {
|
||
fixCursor(startBlock, root);
|
||
}
|
||
|
||
// Ensure root has a block-level element in it.
|
||
child = root.firstChild;
|
||
if (child && child.nodeName !== 'BR') {
|
||
range.collapse(true);
|
||
} else {
|
||
fixCursor(root, root);
|
||
range.selectNodeContents(root.firstChild);
|
||
}
|
||
return frag;
|
||
},
|
||
|
||
// Contents of range will be deleted.
|
||
// After method, range will be around inserted content
|
||
insertTreeFragmentIntoRange = (range, frag, root) => {
|
||
let firstInFragIsInline = frag.firstChild && isInline(frag.firstChild);
|
||
let node, block, blockContentsAfterSplit, stopPoint, container, offset;
|
||
let replaceBlock, firstBlockInFrag, nodeAfterSplit, nodeBeforeSplit;
|
||
let tempRange;
|
||
|
||
// Fixup content: ensure no top-level inline, and add cursor fix elements.
|
||
fixContainer(frag, root);
|
||
node = frag;
|
||
while ((node = getNextBlock(node, root))) {
|
||
fixCursor(node, root);
|
||
}
|
||
|
||
// Delete any selected content.
|
||
if (!range.collapsed) {
|
||
deleteContentsOfRange(range, root);
|
||
}
|
||
|
||
// Move range down into text nodes.
|
||
moveRangeBoundariesDownTree(range);
|
||
range.collapse(false); // collapse to end
|
||
|
||
// Where will we split up to? First blockquote parent, otherwise root.
|
||
stopPoint = getClosest(range.endContainer, root, 'BLOCKQUOTE') || root;
|
||
|
||
// Merge the contents of the first block in the frag with the focused block.
|
||
// If there are contents in the block after the focus point, collect this
|
||
// up to insert in the last block later. This preserves the style that was
|
||
// present in this bit of the page.
|
||
//
|
||
// If the block being inserted into is empty though, replace it instead of
|
||
// merging if the fragment had block contents.
|
||
// e.g. <blockquote><p>Foo</p></blockquote>
|
||
// This seems a reasonable approximation of user intent.
|
||
|
||
block = getStartBlockOfRange(range, root);
|
||
firstBlockInFrag = getNextBlock(frag, frag);
|
||
replaceBlock = !firstInFragIsInline && !!block && isEmptyBlock(block);
|
||
if (block && firstBlockInFrag && !replaceBlock &&
|
||
// Don't merge table cells or PRE elements into block
|
||
!getClosest(firstBlockInFrag, frag, 'PRE,TABLE')) {
|
||
moveRangeBoundariesUpTree(range, block, block, root);
|
||
range.collapse(true); // collapse to start
|
||
container = range.endContainer;
|
||
offset = range.endOffset;
|
||
// Remove trailing <br> – we don't want this considered content to be
|
||
// inserted again later
|
||
cleanupBRs(block, root, false);
|
||
if (isInline(container)) {
|
||
// Split up to block parent.
|
||
nodeAfterSplit = split(
|
||
container, offset, getPreviousBlock(container, root), root);
|
||
container = nodeAfterSplit.parentNode;
|
||
offset = indexOf(container.childNodes, nodeAfterSplit);
|
||
}
|
||
if (/*isBlock(container) && */offset !== getLength(container)) {
|
||
// Collect any inline contents of the block after the range point
|
||
blockContentsAfterSplit = doc.createDocumentFragment();
|
||
while ((node = container.childNodes[ offset ])) {
|
||
blockContentsAfterSplit.append(node);
|
||
}
|
||
}
|
||
// And merge the first block in.
|
||
mergeWithBlock(container, firstBlockInFrag, range, root);
|
||
|
||
// And where we will insert
|
||
offset = indexOf(container.parentNode.childNodes, container) + 1;
|
||
container = container.parentNode;
|
||
range.setEnd(container, offset);
|
||
}
|
||
|
||
// Is there still any content in the fragment?
|
||
if (getLength(frag)) {
|
||
if (replaceBlock) {
|
||
range.setEndBefore(block);
|
||
range.collapse(false);
|
||
detach(block);
|
||
}
|
||
moveRangeBoundariesUpTree(range, stopPoint, stopPoint, root);
|
||
// Now split after block up to blockquote (if a parent) or root
|
||
nodeAfterSplit = split(
|
||
range.endContainer, range.endOffset, stopPoint, root);
|
||
nodeBeforeSplit = nodeAfterSplit ?
|
||
nodeAfterSplit.previousSibling :
|
||
stopPoint.lastChild;
|
||
stopPoint.insertBefore(frag, nodeAfterSplit);
|
||
if (nodeAfterSplit) {
|
||
range.setEndBefore(nodeAfterSplit);
|
||
} else {
|
||
range.setEnd(stopPoint, getLength(stopPoint));
|
||
}
|
||
block = getEndBlockOfRange(range, root);
|
||
|
||
// Get a reference that won't be invalidated if we merge containers.
|
||
moveRangeBoundariesDownTree(range);
|
||
container = range.endContainer;
|
||
offset = range.endOffset;
|
||
|
||
// Merge inserted containers with edges of split
|
||
if (nodeAfterSplit && isContainer(nodeAfterSplit)) {
|
||
mergeContainers(nodeAfterSplit, root);
|
||
}
|
||
nodeAfterSplit = nodeBeforeSplit?.nextSibling;
|
||
if (nodeAfterSplit && isContainer(nodeAfterSplit)) {
|
||
mergeContainers(nodeAfterSplit, root);
|
||
}
|
||
range.setEnd(container, offset);
|
||
}
|
||
|
||
// Insert inline content saved from before.
|
||
if (blockContentsAfterSplit) {
|
||
tempRange = range.cloneRange();
|
||
mergeWithBlock(block, blockContentsAfterSplit, tempRange, root);
|
||
range.setEnd(tempRange.endContainer, tempRange.endOffset);
|
||
}
|
||
moveRangeBoundariesDownTree(range);
|
||
},
|
||
|
||
isNodeContainedInRange = (range, node, partial = true) => {
|
||
let nodeRange = doc.createRange();
|
||
|
||
nodeRange.selectNode(node);
|
||
|
||
return partial
|
||
// Node must not finish before range starts or start after range finishes.
|
||
? range.compareBoundaryPoints(END_TO_START, nodeRange) < 0
|
||
&& range.compareBoundaryPoints(START_TO_END, nodeRange) > 0
|
||
// Node must start after range starts and finish before range finishes
|
||
: range.compareBoundaryPoints(START_TO_START, nodeRange) < 1
|
||
&& range.compareBoundaryPoints(END_TO_END, nodeRange) > -1;
|
||
},
|
||
|
||
moveRangeBoundariesDownTree = range => {
|
||
let startContainer = range.startContainer,
|
||
startOffset = range.startOffset,
|
||
endContainer = range.endContainer,
|
||
endOffset = range.endOffset,
|
||
maySkipBR = true,
|
||
child;
|
||
|
||
while (startContainer.nodeType !== TEXT_NODE) {
|
||
child = startContainer.childNodes[ startOffset ];
|
||
if (!child || isLeaf(child)) {
|
||
break;
|
||
}
|
||
startContainer = child;
|
||
startOffset = 0;
|
||
}
|
||
if (endOffset) {
|
||
while (endContainer.nodeType !== TEXT_NODE) {
|
||
child = endContainer.childNodes[ endOffset - 1 ];
|
||
if (!child || isLeaf(child)) {
|
||
if (maySkipBR && child?.nodeName === 'BR') {
|
||
--endOffset;
|
||
maySkipBR = false;
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
endContainer = child;
|
||
endOffset = getLength(endContainer);
|
||
}
|
||
} else {
|
||
while (endContainer.nodeType !== TEXT_NODE) {
|
||
child = endContainer.firstChild;
|
||
if (!child || isLeaf(child)) {
|
||
break;
|
||
}
|
||
endContainer = child;
|
||
}
|
||
}
|
||
|
||
// If collapsed, this algorithm finds the nearest text node positions
|
||
// *outside* the range rather than inside, but also it flips which is
|
||
// assigned to which.
|
||
if (range.collapsed) {
|
||
range.setStart(endContainer, endOffset);
|
||
range.setEnd(startContainer, startOffset);
|
||
} else {
|
||
range.setStart(startContainer, startOffset);
|
||
range.setEnd(endContainer, endOffset);
|
||
}
|
||
},
|
||
|
||
moveRangeBoundariesUpTree = (range, startMax, endMax, root) => {
|
||
let startContainer = range.startContainer;
|
||
let startOffset = range.startOffset;
|
||
let endContainer = range.endContainer;
|
||
let endOffset = range.endOffset;
|
||
let maySkipBR = true;
|
||
let parent;
|
||
|
||
if (!startMax) {
|
||
startMax = range.commonAncestorContainer;
|
||
}
|
||
if (!endMax) {
|
||
endMax = startMax;
|
||
}
|
||
|
||
while (!startOffset &&
|
||
startContainer !== startMax &&
|
||
startContainer !== root) {
|
||
parent = startContainer.parentNode;
|
||
startOffset = indexOf(parent.childNodes, startContainer);
|
||
startContainer = parent;
|
||
}
|
||
|
||
while (endContainer !== endMax && endContainer !== root) {
|
||
if (maySkipBR &&
|
||
endContainer.nodeType !== TEXT_NODE &&
|
||
endContainer.childNodes[ endOffset ] &&
|
||
endContainer.childNodes[ endOffset ].nodeName === 'BR') {
|
||
++endOffset;
|
||
maySkipBR = false;
|
||
}
|
||
if (endOffset !== getLength(endContainer)) {
|
||
break;
|
||
}
|
||
parent = endContainer.parentNode;
|
||
endOffset = indexOf(parent.childNodes, endContainer) + 1;
|
||
endContainer = parent;
|
||
}
|
||
|
||
range.setStart(startContainer, startOffset);
|
||
range.setEnd(endContainer, endOffset);
|
||
},
|
||
|
||
moveRangeBoundaryOutOf = (range, nodeName, root) => {
|
||
let parent = getClosest(range.endContainer, root, 'A');
|
||
if (parent) {
|
||
let clone = range.cloneRange();
|
||
parent = parent.parentNode;
|
||
moveRangeBoundariesUpTree(clone, parent, parent, root);
|
||
if (clone.endContainer === parent) {
|
||
range.setStart(clone.endContainer, clone.endOffset);
|
||
range.setEnd(clone.endContainer, clone.endOffset);
|
||
}
|
||
}
|
||
return range;
|
||
},
|
||
|
||
// Returns the first block at least partially contained by the range,
|
||
// or null if no block is contained by the range.
|
||
getStartBlockOfRange = (range, root) => {
|
||
let container = range.startContainer,
|
||
block;
|
||
|
||
// If inline, get the containing block.
|
||
if (isInline(container)) {
|
||
block = getPreviousBlock(container, root);
|
||
} else if (container !== root && isBlock(container)) {
|
||
block = container;
|
||
} else {
|
||
block = getNextBlock(getNodeBefore(container, range.startOffset), root);
|
||
}
|
||
// Check the block actually intersects the range
|
||
return block && isNodeContainedInRange(range, block) ? block : null;
|
||
},
|
||
|
||
// Returns the last block at least partially contained by the range,
|
||
// or null if no block is contained by the range.
|
||
getEndBlockOfRange = (range, root) => {
|
||
let container = range.endContainer,
|
||
block, child;
|
||
|
||
// If inline, get the containing block.
|
||
if (isInline(container)) {
|
||
block = getPreviousBlock(container, root);
|
||
} else if (container !== root && isBlock(container)) {
|
||
block = container;
|
||
} else {
|
||
block = getNodeAfter(container, range.endOffset);
|
||
if (!block || !root.contains(block)) {
|
||
block = root;
|
||
while (child = block.lastChild) {
|
||
block = child;
|
||
}
|
||
}
|
||
block = getPreviousBlock(block, root);
|
||
}
|
||
// Check the block actually intersects the range
|
||
return block && isNodeContainedInRange(range, block) ? block : null;
|
||
},
|
||
|
||
newContentWalker = root => createTreeWalker(root,
|
||
SHOW_ELEMENT_OR_TEXT,
|
||
node => node.nodeType === TEXT_NODE ? notWS.test(node.data) : node.nodeName === 'IMG'
|
||
),
|
||
|
||
rangeDoesStartAtBlockBoundary = (range, root) => {
|
||
let startContainer = range.startContainer;
|
||
let startOffset = range.startOffset;
|
||
let startBlock = getStartBlockOfRange(range, root);
|
||
let nodeAfterCursor;
|
||
|
||
if (!startBlock) {
|
||
return false;
|
||
}
|
||
|
||
// If in the middle or end of a text node, we're not at the boundary.
|
||
if (startContainer.nodeType === TEXT_NODE) {
|
||
if (startOffset) {
|
||
return false;
|
||
}
|
||
nodeAfterCursor = startContainer;
|
||
} else {
|
||
nodeAfterCursor = getNodeAfter(startContainer, startOffset);
|
||
if (nodeAfterCursor && !root.contains(nodeAfterCursor)) {
|
||
nodeAfterCursor = null;
|
||
}
|
||
// The cursor was right at the end of the document
|
||
if (!nodeAfterCursor) {
|
||
nodeAfterCursor = getNodeBefore(startContainer, startOffset);
|
||
if (nodeAfterCursor.nodeType === TEXT_NODE &&
|
||
nodeAfterCursor.length) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Otherwise, look for any previous content in the same block.
|
||
contentWalker = newContentWalker(getStartBlockOfRange(range, root));
|
||
contentWalker.currentNode = nodeAfterCursor;
|
||
|
||
return !contentWalker.previousNode();
|
||
},
|
||
|
||
rangeDoesEndAtBlockBoundary = (range, root) => {
|
||
let endContainer = range.endContainer,
|
||
endOffset = range.endOffset,
|
||
length;
|
||
|
||
// Otherwise, look for any further content in the same block.
|
||
contentWalker = newContentWalker(getStartBlockOfRange(range, root));
|
||
|
||
// If in a text node with content, and not at the end, we're not
|
||
// at the boundary
|
||
if (endContainer.nodeType === TEXT_NODE) {
|
||
length = endContainer.data.length;
|
||
if (length && endOffset < length) {
|
||
return false;
|
||
}
|
||
contentWalker.currentNode = endContainer;
|
||
} else {
|
||
contentWalker.currentNode = getNodeBefore(endContainer, endOffset);
|
||
}
|
||
|
||
return !contentWalker.nextNode();
|
||
},
|
||
|
||
expandRangeToBlockBoundaries = (range, root) => {
|
||
let start = getStartBlockOfRange(range, root),
|
||
end = getEndBlockOfRange(range, root);
|
||
|
||
if (start && end) {
|
||
range.setStart(start, 0);
|
||
range.setEnd(end, end.childNodes.length);
|
||
// parent = start.parentNode;
|
||
// range.setStart(parent, indexOf(parent.childNodes, start));
|
||
// parent = end.parentNode;
|
||
// range.setEnd(parent, indexOf(parent.childNodes, end) + 1);
|
||
}
|
||
},
|
||
|
||
didError = error => console.error(error),
|
||
|
||
createRange = (range, startOffset, endContainer, endOffset) => {
|
||
if (range instanceof Range) {
|
||
return range.cloneRange();
|
||
}
|
||
let domRange = doc.createRange();
|
||
domRange.setStart(range, startOffset);
|
||
if (endContainer) {
|
||
domRange.setEnd(endContainer, endOffset);
|
||
} else {
|
||
domRange.setEnd(range, startOffset);
|
||
}
|
||
return domRange;
|
||
},
|
||
|
||
mapKeyTo = method => (self, event) => {
|
||
event.preventDefault();
|
||
self[ method ]();
|
||
},
|
||
|
||
mapKeyToFormat = (tag, remove) => {
|
||
return (self, event) => {
|
||
event.preventDefault();
|
||
self.toggleTag(tag, remove);
|
||
};
|
||
},
|
||
|
||
// If you delete the content inside a span with a font styling, Webkit will
|
||
// replace it with a <font> tag (!). If you delete all the text inside a
|
||
// link in Opera, it won't delete the link. Let's make things consistent. If
|
||
// you delete all text inside an inline tag, remove the inline tag.
|
||
afterDelete = (self, range) => {
|
||
try {
|
||
if (!range) { range = self.getSelection(); }
|
||
let node = range.startContainer,
|
||
parent;
|
||
// Climb the tree from the focus point while we are inside an empty
|
||
// inline element
|
||
if (node.nodeType === TEXT_NODE) {
|
||
node = node.parentNode;
|
||
}
|
||
parent = node;
|
||
while (isInline(parent) && (!parent.textContent || parent.textContent === ZWS)) {
|
||
node = parent;
|
||
parent = node.parentNode;
|
||
}
|
||
// If focused in empty inline element
|
||
if (node !== parent) {
|
||
// Move focus to just before empty inline(s)
|
||
range.setStart(parent,
|
||
indexOf(parent.childNodes, node));
|
||
range.collapse(true);
|
||
// Remove empty inline(s)
|
||
node.remove();
|
||
// Fix cursor in block
|
||
if (!isBlock(parent)) {
|
||
parent = getPreviousBlock(parent, self._root);
|
||
}
|
||
fixCursor(parent, self._root);
|
||
// Move cursor into text node
|
||
moveRangeBoundariesDownTree(range);
|
||
}
|
||
// If you delete the last character in the sole <div> in Chrome,
|
||
// it removes the div and replaces it with just a <br> inside the
|
||
// root. Detach the <br>; the _ensureBottomLine call will insert a new
|
||
// block.
|
||
if (node === self._root && (node = node.firstChild) && node.nodeName === 'BR') {
|
||
detach(node);
|
||
}
|
||
self._ensureBottomLine();
|
||
self.setSelection(range);
|
||
self._updatePath(range, true);
|
||
} catch (error) {
|
||
didError(error);
|
||
}
|
||
},
|
||
|
||
detachUneditableNode = (node, root) => {
|
||
let parent;
|
||
while ((parent = node.parentNode)) {
|
||
if (parent === root || parent.isContentEditable) {
|
||
break;
|
||
}
|
||
node = parent;
|
||
}
|
||
detach(node);
|
||
},
|
||
|
||
handleEnter = (self, shiftKey, range) => {
|
||
let root = self._root;
|
||
let block, parent, node, offset, nodeAfterSplit;
|
||
|
||
// Save undo checkpoint and add any links in the preceding section.
|
||
// Remove any zws so we don't think there's content in an empty
|
||
// block.
|
||
self._recordUndoState(range, false);
|
||
self._config.addLinks && addLinks(range.startContainer, root, self);
|
||
self._removeZWS();
|
||
self._getRangeAndRemoveBookmark(range);
|
||
|
||
// Selected text is overwritten, therefore delete the contents
|
||
// to collapse selection.
|
||
if (!range.collapsed) {
|
||
deleteContentsOfRange(range, root);
|
||
}
|
||
|
||
block = getStartBlockOfRange(range, root);
|
||
|
||
// Inside a PRE, insert literal newline, unless on blank line.
|
||
if (block && (parent = getClosest(block, root, 'PRE'))) {
|
||
moveRangeBoundariesDownTree(range);
|
||
node = range.startContainer;
|
||
offset = range.startOffset;
|
||
if (node.nodeType !== TEXT_NODE) {
|
||
node = doc.createTextNode('');
|
||
parent.insertBefore(node, parent.firstChild);
|
||
}
|
||
// If blank line: split and insert default block
|
||
if (!shiftKey &&
|
||
(node.data.charAt(offset - 1) === '\n' ||
|
||
rangeDoesStartAtBlockBoundary(range, root)) &&
|
||
(node.data.charAt(offset) === '\n' ||
|
||
rangeDoesEndAtBlockBoundary(range, root))) {
|
||
node.deleteData(offset && offset - 1, offset ? 2 : 1);
|
||
nodeAfterSplit =
|
||
split(node, offset && offset - 1, root, root);
|
||
node = nodeAfterSplit.previousSibling;
|
||
if (!node.textContent) {
|
||
detach(node);
|
||
}
|
||
node = self.createDefaultBlock();
|
||
nodeAfterSplit.before(node);
|
||
if (!nodeAfterSplit.textContent) {
|
||
detach(nodeAfterSplit);
|
||
}
|
||
range.setStart(node, 0);
|
||
} else {
|
||
node.insertData(offset, '\n');
|
||
fixCursor(parent, root);
|
||
// Firefox bug: if you set the selection in the text node after
|
||
// the new line, it draws the cursor before the line break still
|
||
// but if you set the selection to the equivalent position
|
||
// in the parent, it works.
|
||
if (node.length === offset + 1) {
|
||
range.setStartAfter(node);
|
||
} else {
|
||
range.setStart(node, offset + 1);
|
||
}
|
||
}
|
||
range.collapse(true);
|
||
self.setSelection(range);
|
||
self._updatePath(range, true);
|
||
self._docWasChanged();
|
||
return;
|
||
}
|
||
|
||
// If this is a malformed bit of document or in a table;
|
||
// just play it safe and insert a <br>.
|
||
if (!block || shiftKey || /^T[HD]$/.test(block.nodeName)) {
|
||
// If inside an <a>, move focus out
|
||
moveRangeBoundaryOutOf(range, 'A', root);
|
||
insertNodeInRange(range, createElement('BR'));
|
||
range.collapse(false);
|
||
self.setSelection(range);
|
||
self._updatePath(range, true);
|
||
return;
|
||
}
|
||
|
||
// If in a list, we'll split the LI instead.
|
||
block = getClosest(block, root, 'LI') || block;
|
||
|
||
if (isEmptyBlock(block) && (parent = getClosest(block, root, 'UL,OL,BLOCKQUOTE'))) {
|
||
return 'BLOCKQUOTE' === parent.nodeName
|
||
// Break blockquote
|
||
? self.modifyBlocks((/* frag */) => self.createDefaultBlock(createBookmarkNodes(self)), range)
|
||
// Break list
|
||
: self.decreaseListLevel(range);
|
||
}
|
||
|
||
// Otherwise, split at cursor point.
|
||
nodeAfterSplit = splitBlock(self, block,
|
||
range.startContainer, range.startOffset);
|
||
|
||
// Clean up any empty inlines if we hit enter at the beginning of the block
|
||
removeZWS(block);
|
||
removeEmptyInlines(block);
|
||
fixCursor(block, root);
|
||
|
||
// Focus cursor
|
||
// If there's a <b>/<i> etc. at the beginning of the split
|
||
// make sure we focus inside it.
|
||
while (nodeAfterSplit.nodeType === ELEMENT_NODE) {
|
||
let child = nodeAfterSplit.firstChild,
|
||
next;
|
||
|
||
// Don't continue links over a block break; unlikely to be the
|
||
// desired outcome.
|
||
if (nodeAfterSplit.nodeName === 'A' && (!nodeAfterSplit.textContent || nodeAfterSplit.textContent === ZWS)) {
|
||
child = doc.createTextNode('');
|
||
nodeAfterSplit.replaceWith(child);
|
||
nodeAfterSplit = child;
|
||
break;
|
||
}
|
||
|
||
while (child?.nodeType === TEXT_NODE && !child.data) {
|
||
next = child.nextSibling;
|
||
if (!next || next.nodeName === 'BR') {
|
||
break;
|
||
}
|
||
detach(child);
|
||
child = next;
|
||
}
|
||
|
||
// 'BR's essentially don't count; they're a browser hack.
|
||
// If you try to select the contents of a 'BR', FF will not let
|
||
// you type anything!
|
||
if (!child || child.nodeName === 'BR' ||
|
||
child.nodeType === TEXT_NODE) {
|
||
break;
|
||
}
|
||
nodeAfterSplit = child;
|
||
}
|
||
range = createRange(nodeAfterSplit, 0);
|
||
self.setSelection(range);
|
||
self._updatePath(range, true);
|
||
},
|
||
|
||
|
||
changeIndentationLevel = direction => (self, event) => {
|
||
event.preventDefault();
|
||
self.changeIndentationLevel(direction);
|
||
},
|
||
|
||
toggleList = (type, methodIfNotInList) => (self, event) => {
|
||
event.preventDefault();
|
||
let parent = self.getSelectionClosest('UL,OL');
|
||
if (type == parent?.nodeName) {
|
||
self.removeList();
|
||
} else {
|
||
self[ methodIfNotInList ]();
|
||
}
|
||
},
|
||
|
||
fontSizes = {
|
||
1: 'x-small',
|
||
2: 'small',
|
||
3: 'medium',
|
||
4: 'large',
|
||
5: 'x-large',
|
||
6: 'xx-large',
|
||
7: 'xxx-large',
|
||
'-1': 'smaller',
|
||
'+1': 'larger'
|
||
},
|
||
|
||
styleToSemantic = {
|
||
fontWeight: {
|
||
regexp: /^bold|^700/i,
|
||
replace: () => createElement('B')
|
||
},
|
||
fontStyle: {
|
||
regexp: /^italic/i,
|
||
replace: () => createElement('I')
|
||
},
|
||
fontFamily: {
|
||
regexp: notWS,
|
||
replace: (doc, family) => createElement('SPAN', {
|
||
style: 'font-family:' + family
|
||
})
|
||
},
|
||
fontSize: {
|
||
regexp: notWS,
|
||
replace: (doc, size) => createElement('SPAN', {
|
||
style: 'font-size:' + size
|
||
})
|
||
},
|
||
textDecoration: {
|
||
regexp: /^underline/i,
|
||
replace: () => createElement('U')
|
||
}
|
||
/*
|
||
textDecoration: {
|
||
regexp: /^line-through/i,
|
||
replace: doc => createElement('S')
|
||
}
|
||
*/
|
||
},
|
||
|
||
replaceWithTag = tag => node => {
|
||
let el = createElement(tag);
|
||
Array.prototype.forEach.call(node.attributes, attr => el.setAttribute(attr.name, attr.value));
|
||
node.replaceWith(el);
|
||
el.append(empty(node));
|
||
return el;
|
||
},
|
||
|
||
replaceStyles = node => {
|
||
let style = node.style;
|
||
let css, newTreeBottom, newTreeTop, el;
|
||
|
||
Object.entries(styleToSemantic).forEach(([attr,converter])=>{
|
||
css = style[ attr ];
|
||
if (css && converter.regexp.test(css)) {
|
||
el = converter.replace(doc, css);
|
||
if (el.nodeName === node.nodeName &&
|
||
el.className === node.className) {
|
||
return;
|
||
}
|
||
if (!newTreeTop) {
|
||
newTreeTop = el;
|
||
}
|
||
if (newTreeBottom) {
|
||
newTreeBottom.append(el);
|
||
}
|
||
newTreeBottom = el;
|
||
node.style[ attr ] = '';
|
||
}
|
||
});
|
||
|
||
if (newTreeTop) {
|
||
newTreeBottom.append(empty(node));
|
||
node.append(newTreeTop);
|
||
}
|
||
|
||
return newTreeBottom || node;
|
||
},
|
||
|
||
stylesRewriters = {
|
||
SPAN: replaceStyles,
|
||
STRONG: replaceWithTag('B'),
|
||
EM: replaceWithTag('I'),
|
||
INS: replaceWithTag('U'),
|
||
STRIKE: replaceWithTag('S'),
|
||
FONT: node => {
|
||
let face = node.face,
|
||
size = node.size,
|
||
color = node.color,
|
||
newTag = createElement('SPAN'),
|
||
css = newTag.style;
|
||
if (face) {
|
||
css.fontFamily = face;
|
||
}
|
||
if (size) {
|
||
css.fontSize = fontSizes[ size ];
|
||
}
|
||
if (color && /^#?([\dA-F]{3}){1,2}$/i.test(color)) {
|
||
if (color.charAt(0) !== '#') {
|
||
color = '#' + color;
|
||
}
|
||
css.color = color;
|
||
}
|
||
node.replaceWith(newTag);
|
||
newTag.append(empty(node));
|
||
return newTag;
|
||
},
|
||
// KBD:
|
||
// VAR:
|
||
// CODE:
|
||
// SAMP:
|
||
TT: node => {
|
||
let el = createElement('SPAN', {
|
||
style: 'font-family:menlo,consolas,"courier new",monospace'
|
||
});
|
||
node.replaceWith(el);
|
||
el.append(empty(node));
|
||
return el;
|
||
}
|
||
},
|
||
|
||
allowedBlock = /^(?:A(?:DDRESS|RTICLE|SIDE|UDIO)|BLOCKQUOTE|CAPTION|D(?:[DLT]|IV)|F(?:IGURE|IGCAPTION|OOTER)|H[1-6]|HEADER|L(?:ABEL|EGEND|I)|O(?:L|UTPUT)|P(?:RE)?|SECTION|T(?:ABLE|BODY|D|FOOT|H|HEAD|R)|COL(?:GROUP)?|UL)$/,
|
||
|
||
blacklist = /^(?:HEAD|META|STYLE)/,
|
||
/*
|
||
// Previous node in post-order.
|
||
previousPONode = walker => {
|
||
let current = walker.currentNode,
|
||
root = walker.root,
|
||
nodeType = walker.nodeType, // whatToShow?
|
||
filter = walker.filter,
|
||
node;
|
||
while (current) {
|
||
node = current.lastChild;
|
||
while (!node && current && current !== root) {
|
||
node = current.previousSibling;
|
||
if (!node) { current = current.parentNode; }
|
||
}
|
||
if (node && (typeToBitArray[ node.nodeType ] & nodeType) && filter(node)) {
|
||
walker.currentNode = node;
|
||
return node;
|
||
}
|
||
current = node;
|
||
}
|
||
return null;
|
||
},
|
||
*/
|
||
/*
|
||
Two purposes:
|
||
|
||
1. Remove nodes we don't want, such as weird <o:p> tags, comment nodes
|
||
and whitespace nodes.
|
||
2. Convert inline tags into our preferred format.
|
||
*/
|
||
cleanTree = (node, preserveWS) => {
|
||
let children = node.childNodes,
|
||
nonInlineParent, i, l, child, nodeName, nodeType, childLength;
|
||
// startsWithWS, endsWithWS, data, sibling;
|
||
|
||
nonInlineParent = node;
|
||
while (isInline(nonInlineParent)) {
|
||
nonInlineParent = nonInlineParent.parentNode;
|
||
}
|
||
// let walker = createTreeWalker(nonInlineParent, SHOW_ELEMENT_OR_TEXT);
|
||
|
||
for (i = 0, l = children.length; i < l; ++i) {
|
||
child = children[i];
|
||
nodeName = child.nodeName;
|
||
nodeType = child.nodeType;
|
||
if (nodeType === ELEMENT_NODE) {
|
||
childLength = child.childNodes.length;
|
||
if (stylesRewriters[ nodeName ]) {
|
||
child = stylesRewriters[ nodeName ](child);
|
||
} else if (blacklist.test(nodeName)) {
|
||
child.remove();
|
||
--i;
|
||
--l;
|
||
continue;
|
||
} else if (!allowedBlock.test(nodeName) && !isInline(child)) {
|
||
--i;
|
||
l += childLength - 1;
|
||
child.replaceWith(empty(child));
|
||
continue;
|
||
}
|
||
if (childLength) {
|
||
cleanTree(child, preserveWS || (nodeName === 'PRE'));
|
||
}
|
||
/*
|
||
} else {
|
||
if (nodeType === TEXT_NODE) {
|
||
data = child.data;
|
||
startsWithWS = !notWS.test(data.charAt(0));
|
||
endsWithWS = !notWS.test(data.charAt(data.length - 1));
|
||
if (preserveWS || (!startsWithWS && !endsWithWS)) {
|
||
continue;
|
||
}
|
||
// Iterate through the nodes; if we hit some other content
|
||
// before the start of a new block we don't trim
|
||
if (startsWithWS) {
|
||
walker.currentNode = child;
|
||
while (sibling = previousPONode(walker)) {
|
||
nodeName = sibling.nodeName;
|
||
if (nodeName === 'IMG' || (nodeName === '#text' && notWS.test(sibling.data))) {
|
||
break;
|
||
}
|
||
if (!isInline(sibling)) {
|
||
sibling = null;
|
||
break;
|
||
}
|
||
}
|
||
data = data.replace(/^[ \r\n]+/g, sibling ? ' ' : '');
|
||
}
|
||
if (endsWithWS) {
|
||
walker.currentNode = child;
|
||
while (sibling = walker.nextNode()) {
|
||
if (nodeName === 'IMG' || (nodeName === '#text' && notWS.test(sibling.data))) {
|
||
break;
|
||
}
|
||
if (!isInline(sibling)) {
|
||
sibling = null;
|
||
break;
|
||
}
|
||
}
|
||
data = data.replace(/[ \r\n]+$/g, sibling ? ' ' : '');
|
||
}
|
||
if (data) {
|
||
child.data = data;
|
||
continue;
|
||
}
|
||
}
|
||
child.remove();
|
||
--i;
|
||
--l;
|
||
*/
|
||
}
|
||
}
|
||
return node;
|
||
},
|
||
|
||
// ---
|
||
|
||
removeEmptyInlines = node => {
|
||
let children = node.childNodes,
|
||
l = children.length,
|
||
child;
|
||
while (l--) {
|
||
child = children[l];
|
||
if (child.nodeType === ELEMENT_NODE && !isLeaf(child)) {
|
||
removeEmptyInlines(child);
|
||
if (!child.firstChild && isInline(child)) {
|
||
child.remove();
|
||
}
|
||
} else if (child.nodeType === TEXT_NODE && !child.data) {
|
||
child.remove();
|
||
}
|
||
}
|
||
},
|
||
|
||
// ---
|
||
|
||
notWSTextNode = node => node.nodeType === ELEMENT_NODE ? node.nodeName === 'BR' : notWS.test(node.data),
|
||
isLineBreak = (br, isLBIfEmptyBlock) => {
|
||
let walker, block = br.parentNode;
|
||
while (isInline(block)) {
|
||
block = block.parentNode;
|
||
}
|
||
walker = createTreeWalker(block, SHOW_ELEMENT_OR_TEXT, notWSTextNode);
|
||
walker.currentNode = br;
|
||
return !!walker.nextNode() || (isLBIfEmptyBlock && !walker.previousNode());
|
||
},
|
||
|
||
// <br> elements are treated specially, and differently depending on the
|
||
// browser, when in rich text editor mode. When adding HTML from external
|
||
// sources, we must remove them, replacing the ones that actually affect
|
||
// line breaks by wrapping the inline text in a <div>. Browsers that want <br>
|
||
// elements at the end of each block will then have them added back in a later
|
||
// fixCursor method call.
|
||
cleanupBRs = (node, root, keepForBlankLine) => {
|
||
let brs = node.querySelectorAll('BR');
|
||
let l = brs.length;
|
||
let br, parent;
|
||
while (l--) {
|
||
br = brs[l];
|
||
// Cleanup may have removed it
|
||
parent = br.parentNode;
|
||
if (!parent) { continue; }
|
||
// If it doesn't break a line, just remove it; it's not doing
|
||
// anything useful. We'll add it back later if required by the
|
||
// browser. If it breaks a line, wrap the content in div tags
|
||
// and replace the brs.
|
||
if (!isLineBreak(br, keepForBlankLine)) {
|
||
detach(br);
|
||
} else if (!isInline(parent)) {
|
||
fixContainer(parent, root);
|
||
}
|
||
}
|
||
},
|
||
|
||
// The (non-standard but supported enough) innerText property is based on the
|
||
// render tree in Firefox and possibly other browsers, so we must insert the
|
||
// DOM node into the document to ensure the text part is correct.
|
||
setClipboardData = (event, contents, root) => {
|
||
let clipboardData = event.clipboardData;
|
||
let body = doc.body;
|
||
let node = createElement('div');
|
||
let html, text;
|
||
|
||
node.append(contents);
|
||
|
||
html = node.innerHTML;
|
||
|
||
// Firefox will add an extra new line for BRs at the end of block when
|
||
// calculating innerText, even though they don't actually affect
|
||
// display, so we need to remove them first.
|
||
cleanupBRs(node, root, true);
|
||
node.setAttribute('style',
|
||
'position:fixed;overflow:hidden;bottom:100%;right:100%;');
|
||
body.append(node);
|
||
text = (node.innerText || node.textContent).replace(NBSP, ' '); // Replace nbsp with regular space
|
||
node.remove();
|
||
|
||
if (text !== html) {
|
||
clipboardData.setData('text/html', html);
|
||
}
|
||
clipboardData.setData('text/plain', text);
|
||
event.preventDefault();
|
||
},
|
||
|
||
mergeObjects = (base, extras, mayOverride) => {
|
||
base = base || {};
|
||
extras && Object.entries(extras).forEach(([prop,value])=>{
|
||
if (mayOverride || !(prop in base)) {
|
||
base[ prop ] = (value?.constructor === Object) ?
|
||
mergeObjects(base[ prop ], value, mayOverride) :
|
||
value;
|
||
}
|
||
});
|
||
return base;
|
||
},
|
||
|
||
// --- Events ---
|
||
|
||
// Subscribing to these events won't automatically add a listener to the
|
||
// document node, since these events are fired in a custom manner by the
|
||
// editor code.
|
||
customEvents = {
|
||
pathChange: 1, select: 1, input: 1, undoStateChange: 1
|
||
},
|
||
|
||
// --- Workaround for browsers that can't focus empty text nodes ---
|
||
|
||
// WebKit bug: https://bugs.webkit.org/show_bug.cgi?id=15256
|
||
|
||
// Walk down the tree starting at the root and remove any ZWS. If the node only
|
||
// contained ZWS space then remove it too. We may want to keep one ZWS node at
|
||
// the bottom of the tree so the block can be selected. Define that node as the
|
||
// keepNode.
|
||
removeZWS = (root, keepNode) => {
|
||
let walker = createTreeWalker(root, SHOW_TEXT);
|
||
let parent, node, index;
|
||
while (node = walker.nextNode()) {
|
||
while ((index = node.data.indexOf(ZWS)) > -1 && (!keepNode || node.parentNode !== keepNode)) {
|
||
if (node.length === 1) {
|
||
do {
|
||
parent = node.parentNode;
|
||
node.remove();
|
||
node = parent;
|
||
walker.currentNode = parent;
|
||
} while (isInline(node) && !getLength(node));
|
||
break;
|
||
} else {
|
||
node.deleteData(index, 1);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
// --- Bookmarking ---
|
||
|
||
startSelectionId = 'squire-selection-start',
|
||
endSelectionId = 'squire-selection-end',
|
||
|
||
createBookmarkNodes = () => [
|
||
createElement('INPUT', {
|
||
id: startSelectionId,
|
||
type: 'hidden'
|
||
}),
|
||
createElement('INPUT', {
|
||
id: endSelectionId,
|
||
type: 'hidden'
|
||
})
|
||
],
|
||
|
||
// --- Block formatting ---
|
||
|
||
tagAfterSplit = {
|
||
DT: 'DD',
|
||
DD: 'DT',
|
||
LI: 'LI',
|
||
PRE: 'PRE'
|
||
},
|
||
|
||
splitBlock = (self, block, node, offset) => {
|
||
let splitTag = tagAfterSplit[ block.nodeName ] || blockTag,
|
||
nodeAfterSplit = split(node, offset, block.parentNode, self._root);
|
||
|
||
// Make sure the new node is the correct type.
|
||
if (!hasTagAttributes(nodeAfterSplit, splitTag)) {
|
||
block = createElement(splitTag);
|
||
if (nodeAfterSplit.dir) {
|
||
block.dir = nodeAfterSplit.dir;
|
||
}
|
||
nodeAfterSplit.replaceWith(block);
|
||
block.append(empty(nodeAfterSplit));
|
||
nodeAfterSplit = block;
|
||
}
|
||
return nodeAfterSplit;
|
||
},
|
||
|
||
getListSelection = (range, root) => {
|
||
// Get start+end li in single common ancestor
|
||
let list = range.commonAncestorContainer;
|
||
let startLi = range.startContainer;
|
||
let endLi = range.endContainer;
|
||
while (list && list !== root && !/^[OU]L$/.test(list.nodeName)) {
|
||
list = list.parentNode;
|
||
}
|
||
if (!list || list === root) {
|
||
return null;
|
||
}
|
||
if (startLi === list) {
|
||
startLi = startLi.childNodes[ range.startOffset ];
|
||
}
|
||
if (endLi === list) {
|
||
endLi = endLi.childNodes[ range.endOffset ];
|
||
}
|
||
while (startLi && startLi.parentNode !== list) {
|
||
startLi = startLi.parentNode;
|
||
}
|
||
while (endLi && endLi.parentNode !== list) {
|
||
endLi = endLi.parentNode;
|
||
}
|
||
return [ list, startLi, endLi ];
|
||
},
|
||
|
||
makeList = (self, frag, type) => {
|
||
let walker = getBlockWalker(frag, self._root),
|
||
node, tag, prev, newLi;
|
||
|
||
while (node = walker.nextNode()) {
|
||
if (node.parentNode.nodeName === 'LI') {
|
||
node = node.parentNode;
|
||
walker.currentNode = node.lastChild;
|
||
}
|
||
if (node.nodeName !== 'LI') {
|
||
newLi = createElement('LI');
|
||
if (node.dir) {
|
||
newLi.dir = node.dir;
|
||
}
|
||
|
||
// Have we replaced the previous block with a new <ul>/<ol>?
|
||
if ((prev = node.previousSibling) && prev.nodeName === type) {
|
||
prev.append(newLi);
|
||
detach(node);
|
||
}
|
||
// Otherwise, replace this block with the <ul>/<ol>
|
||
else {
|
||
node.replaceWith(
|
||
createElement(type, null, [
|
||
newLi
|
||
])
|
||
);
|
||
}
|
||
newLi.append(empty(node));
|
||
walker.currentNode = newLi;
|
||
} else {
|
||
node = node.parentNode;
|
||
tag = node.nodeName;
|
||
if (tag !== type && (/^[OU]L$/.test(tag))) {
|
||
node.replaceWith(
|
||
createElement(type, null, [ empty(node) ])
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return frag;
|
||
},
|
||
|
||
linkRegExp = /\b(?:((?:(?:ht|f)tps?:\/\/|www\d{0,3}[.]|[a-z0-9][a-z0-9.-]*[.][a-z]{2,}\/)(?:[^\s()<>]+|\([^\s()<>]+\))+(?:[^\s?&`!()[\]{};:'".,<>«»“”‘’]|\([^\s()<>]+\)))|([\w\-.%+]+@(?:[\w-]+\.)+[a-z]{2,}\b(?:[?][^&?\s]+=[^\s?&`!()[\]{};:'".,<>«»“”‘’]+(?:&[^&?\s]+=[^\s?&`!()[\]{};:'".,<>«»“”‘’]+)*)?))/i,
|
||
|
||
addLinks = (frag, root) => {
|
||
let walker = createTreeWalker(frag, SHOW_TEXT, node => !getClosest(node, root, 'A'));
|
||
let node, data, parent, match, index, endIndex, child;
|
||
while ((node = walker.nextNode())) {
|
||
data = node.data;
|
||
parent = node.parentNode;
|
||
while ((match = linkRegExp.exec(data))) {
|
||
index = match.index;
|
||
endIndex = index + match[0].length;
|
||
if (index) {
|
||
child = doc.createTextNode(data.slice(0, index));
|
||
parent.insertBefore(child, node);
|
||
}
|
||
child = createElement('A', mergeObjects({
|
||
href: match[1] ?
|
||
/^(?:ht|f)tps?:/i.test(match[1]) ?
|
||
match[1] :
|
||
'http://' + match[1] :
|
||
'mailto:' + match[0]
|
||
}, null, false));
|
||
child.textContent = data.slice(index, endIndex);
|
||
parent.insertBefore(child, node);
|
||
node.data = data = data.slice(endIndex);
|
||
}
|
||
}
|
||
},
|
||
|
||
escapeHTML = text => text.replace('&', '&')
|
||
.replace('<', '<')
|
||
.replace('>', '>')
|
||
.replace('"', '"');
|
||
|
||
let contentWalker,
|
||
nodeCategoryCache = new WeakMap();
|
||
|
||
function onKey(event) {
|
||
if (event.defaultPrevented) {
|
||
return;
|
||
}
|
||
|
||
let key = event.key.toLowerCase(),
|
||
modifiers = '',
|
||
range = this.getSelection();
|
||
|
||
// We need to apply the backspace/delete handlers regardless of
|
||
// control key modifiers.
|
||
if (key !== 'backspace' && key !== 'delete') {
|
||
if (event.altKey) { modifiers += 'alt-'; }
|
||
if (event[osKey]) { modifiers += ctrlKey; }
|
||
if (event.shiftKey) { modifiers += 'shift-'; }
|
||
}
|
||
|
||
key = modifiers + key;
|
||
|
||
if (this._keyHandlers[ key ]) {
|
||
this._keyHandlers[ key ](this, event, range);
|
||
// !event.isComposing stops us from blatting Kana-Kanji conversion in Safari
|
||
} else if (!range.collapsed && !event.isComposing && !event[osKey] && key.length === 1) {
|
||
// Record undo checkpoint.
|
||
this.saveUndoState(range);
|
||
// Delete the selection
|
||
deleteContentsOfRange(range, this._root);
|
||
this._ensureBottomLine();
|
||
this.setSelection(range);
|
||
this._updatePath(range, true);
|
||
}
|
||
}
|
||
|
||
function onCut(event) {
|
||
let range = this.getSelection();
|
||
let root = this._root;
|
||
let self = this;
|
||
let startBlock, endBlock, copyRoot, contents, parent, newContents;
|
||
|
||
// Nothing to do
|
||
if (range.collapsed) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
|
||
// Save undo checkpoint
|
||
this.saveUndoState(range);
|
||
|
||
// Edge only seems to support setting plain text as of 2016-03-11.
|
||
if (event.clipboardData) {
|
||
// Clipboard content should include all parents within block, or all
|
||
// parents up to root if selection across blocks
|
||
startBlock = getStartBlockOfRange(range, root);
|
||
endBlock = getEndBlockOfRange(range, root);
|
||
copyRoot = ((startBlock === endBlock) && startBlock) || root;
|
||
// Extract the contents
|
||
contents = deleteContentsOfRange(range, root);
|
||
// Add any other parents not in extracted content, up to copy root
|
||
parent = range.commonAncestorContainer;
|
||
if (parent.nodeType === TEXT_NODE) {
|
||
parent = parent.parentNode;
|
||
}
|
||
while (parent && parent !== copyRoot) {
|
||
newContents = parent.cloneNode(false);
|
||
newContents.append(contents);
|
||
contents = newContents;
|
||
parent = parent.parentNode;
|
||
}
|
||
// Set clipboard data
|
||
setClipboardData(event, contents, root);
|
||
} else {
|
||
setTimeout(() => {
|
||
try {
|
||
// If all content removed, ensure div at start of root.
|
||
self._ensureBottomLine();
|
||
} catch (error) {
|
||
didError(error);
|
||
}
|
||
}, 0);
|
||
}
|
||
|
||
this.setSelection(range);
|
||
}
|
||
|
||
function onCopy(event) {
|
||
// Edge only seems to support setting plain text as of 2016-03-11.
|
||
if (event.clipboardData) {
|
||
let range = this.getSelection(), root = this._root,
|
||
// Clipboard content should include all parents within block, or all
|
||
// parents up to root if selection across blocks
|
||
startBlock = getStartBlockOfRange(range, root),
|
||
endBlock = getEndBlockOfRange(range, root),
|
||
copyRoot = ((startBlock === endBlock) && startBlock) || root,
|
||
contents, parent, newContents;
|
||
// Clone range to mutate, then move up as high as possible without
|
||
// passing the copy root node.
|
||
range = range.cloneRange();
|
||
moveRangeBoundariesDownTree(range);
|
||
moveRangeBoundariesUpTree(range, copyRoot, copyRoot, root);
|
||
// Extract the contents
|
||
contents = range.cloneContents();
|
||
// Add any other parents not in extracted content, up to copy root
|
||
parent = range.commonAncestorContainer;
|
||
if (parent.nodeType === TEXT_NODE) {
|
||
parent = parent.parentNode;
|
||
}
|
||
while (parent && parent !== copyRoot) {
|
||
newContents = parent.cloneNode(false);
|
||
newContents.append(contents);
|
||
contents = newContents;
|
||
parent = parent.parentNode;
|
||
}
|
||
// Set clipboard data
|
||
setClipboardData(event, contents, root);
|
||
}
|
||
}
|
||
|
||
function onPaste(event) {
|
||
let clipboardData = event.clipboardData;
|
||
let items = clipboardData?.items;
|
||
let imageItem = null;
|
||
let plainItem = null;
|
||
let htmlItem = null;
|
||
let self = this;
|
||
let type;
|
||
|
||
// Current HTML5 Clipboard interface
|
||
// ---------------------------------
|
||
// https://html.spec.whatwg.org/multipage/interaction.html
|
||
if (items) {
|
||
[...items].forEach(item => {
|
||
type = item.type;
|
||
if (type === 'text/html') {
|
||
htmlItem = item;
|
||
// iOS copy URL gives you type text/uri-list which is just a list
|
||
// of 1 or more URLs separated by new lines. Can just treat as
|
||
// plain text.
|
||
} else if (type === 'text/plain' || type === 'text/uri-list') {
|
||
plainItem = item;
|
||
} else if (item.kind === 'file' && /^image\/(png|jpeg|webp)/.test(type)) {
|
||
imageItem = item;
|
||
}
|
||
});
|
||
if (htmlItem || plainItem || imageItem) {
|
||
event.preventDefault();
|
||
if (imageItem) {
|
||
let reader = new FileReader();
|
||
reader.onload = event => {
|
||
let img = createElement('img', {src: event.target.result}),
|
||
canvas = createElement('canvas'),
|
||
ctx = canvas.getContext('2d');
|
||
img.onload = ()=>{
|
||
ctx.drawImage(img, 0, 0);
|
||
let width = img.width, height = img.height;
|
||
if (width > height) {
|
||
// Landscape
|
||
if (width > 1024) {
|
||
height = height * 1024 / width;
|
||
width = 1024;
|
||
}
|
||
} else if (height > 1024) {
|
||
// Portrait
|
||
width = width * 1024 / height;
|
||
height = 1024;
|
||
}
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
self.insertHTML('<img alt="" style="width:100%;max-width:'+width+'px" src="'+canvas.toDataURL()+'">', true);
|
||
};
|
||
}
|
||
reader.readAsDataURL(imageItem.getAsFile());
|
||
} else if (htmlItem && (!self.isShiftDown || !plainItem)) {
|
||
htmlItem.getAsString(html => self.insertHTML(html, true));
|
||
} else if (plainItem) {
|
||
plainItem.getAsString(text => self.insertPlainText(text, true));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// On Windows you can drag an drop text. We can't handle this ourselves, because
|
||
// as far as I can see, there's no way to get the drop insertion point. So just
|
||
// save an undo state and hope for the best.
|
||
function onDrop(event) {
|
||
let types = event.dataTransfer.types;
|
||
if (types.includes('text/plain') || types.includes('text/html')) {
|
||
this.saveUndoState();
|
||
}
|
||
}
|
||
|
||
let keyHandlers = {
|
||
// This song and dance is to force iOS to do enable the shift key
|
||
// automatically on enter. When you do the DOM split manipulation yourself,
|
||
// WebKit doesn't reset the IME state and so presents auto-complete options
|
||
// as though you were continuing to type on the previous line, and doesn't
|
||
// auto-enable the shift key. The old trick of blurring and focussing
|
||
// again no longer works in iOS 13, and I tried various execCommand options
|
||
// but they didn't seem to do anything. The only solution I've found is to
|
||
// let iOS handle the enter key, then after it's done that reset the HTML
|
||
// to what it was before and handle it properly in Squire; the IME state of
|
||
// course doesn't reset so you end up in the correct state!
|
||
enter: isIOS ? (self, event, range) => {
|
||
self._saveRangeToBookmark(range);
|
||
let html = self._getHTML();
|
||
let restoreAndDoEnter = () => {
|
||
self.removeEventListener('keyup', restoreAndDoEnter);
|
||
self._setHTML(html);
|
||
range = self._getRangeAndRemoveBookmark();
|
||
// Ignore the shift key on iOS, as this is for auto-capitalisation.
|
||
handleEnter(self, false, range);
|
||
};
|
||
self.addEventListener('keyup', restoreAndDoEnter);
|
||
} : (self, event, range) => {
|
||
event.preventDefault();
|
||
handleEnter(self, event.shiftKey, range);
|
||
},
|
||
|
||
'shift-enter': (self, event, range) => self._keyHandlers.enter(self, event, range),
|
||
|
||
backspace: (self, event, range) => {
|
||
let root = self._root;
|
||
self._removeZWS();
|
||
// Record undo checkpoint.
|
||
self.saveUndoState(range);
|
||
// If not collapsed, delete contents
|
||
if (!range.collapsed) {
|
||
event.preventDefault();
|
||
deleteContentsOfRange(range, root);
|
||
afterDelete(self, range);
|
||
}
|
||
// If at beginning of block, merge with previous
|
||
else if (rangeDoesStartAtBlockBoundary(range, root)) {
|
||
event.preventDefault();
|
||
let current = getStartBlockOfRange(range, root);
|
||
let previous;
|
||
if (!current) {
|
||
return;
|
||
}
|
||
// In case inline data has somehow got between blocks.
|
||
fixContainer(current.parentNode, root);
|
||
// Now get previous block
|
||
previous = getPreviousBlock(current, root);
|
||
// Must not be at the very beginning of the text area.
|
||
if (previous) {
|
||
// If not editable, just delete whole block.
|
||
if (!previous.isContentEditable) {
|
||
detachUneditableNode(previous, root);
|
||
return;
|
||
}
|
||
// Otherwise merge.
|
||
mergeWithBlock(previous, current, range, root);
|
||
// If deleted line between containers, merge newly adjacent
|
||
// containers.
|
||
current = previous.parentNode;
|
||
while (current !== root && !current.nextSibling) {
|
||
current = current.parentNode;
|
||
}
|
||
if (current !== root && (current = current.nextSibling)) {
|
||
mergeContainers(current, root);
|
||
}
|
||
self.setSelection(range);
|
||
}
|
||
// If at very beginning of text area, allow backspace
|
||
// to break lists/blockquote.
|
||
else if (current) {
|
||
let parent = getClosest(current, root, 'UL,OL,BLOCKQUOTE');
|
||
if (parent) {
|
||
return ('BLOCKQUOTE' === parent.nodeName)
|
||
// Break blockquote
|
||
? self.decreaseQuoteLevel(range)
|
||
// Break list
|
||
: self.decreaseListLevel(range);
|
||
}
|
||
self.setSelection(range);
|
||
self._updatePath(range, true);
|
||
}
|
||
}
|
||
// Otherwise, leave to browser but check afterwards whether it has
|
||
// left behind an empty inline tag.
|
||
else {
|
||
self.setSelection(range);
|
||
setTimeout(() => afterDelete(self), 0);
|
||
}
|
||
},
|
||
'delete': (self, event, range) => {
|
||
let root = self._root;
|
||
let current, next, originalRange,
|
||
cursorContainer, cursorOffset, nodeAfterCursor;
|
||
self._removeZWS();
|
||
// Record undo checkpoint.
|
||
self.saveUndoState(range);
|
||
// If not collapsed, delete contents
|
||
if (!range.collapsed) {
|
||
event.preventDefault();
|
||
deleteContentsOfRange(range, root);
|
||
afterDelete(self, range);
|
||
}
|
||
// If at end of block, merge next into this block
|
||
else if (rangeDoesEndAtBlockBoundary(range, root)) {
|
||
event.preventDefault();
|
||
if (current = getStartBlockOfRange(range, root)) {
|
||
// In case inline data has somehow got between blocks.
|
||
fixContainer(current.parentNode, root);
|
||
// Now get next block
|
||
// Must not be at the very end of the text area.
|
||
if (next = getNextBlock(current, root)) {
|
||
// If not editable, just delete whole block.
|
||
if (!next.isContentEditable) {
|
||
detachUneditableNode(next, root);
|
||
return;
|
||
}
|
||
// Otherwise merge.
|
||
mergeWithBlock(current, next, range, root);
|
||
// If deleted line between containers, merge newly adjacent
|
||
// containers.
|
||
next = current.parentNode;
|
||
while (next !== root && !next.nextSibling) {
|
||
next = next.parentNode;
|
||
}
|
||
if (next !== root && (next = next.nextSibling)) {
|
||
mergeContainers(next, root);
|
||
}
|
||
self.setSelection(range);
|
||
self._updatePath(range, true);
|
||
}
|
||
}
|
||
}
|
||
// Otherwise, leave to browser but check afterwards whether it has
|
||
// left behind an empty inline tag.
|
||
else {
|
||
// But first check if the cursor is just before an IMG tag. If so,
|
||
// delete it ourselves, because the browser won't if it is not
|
||
// inline.
|
||
originalRange = range.cloneRange();
|
||
moveRangeBoundariesUpTree(range, root, root, root);
|
||
cursorContainer = range.endContainer;
|
||
cursorOffset = range.endOffset;
|
||
if (cursorContainer.nodeType === ELEMENT_NODE) {
|
||
nodeAfterCursor = cursorContainer.childNodes[ cursorOffset ];
|
||
if (nodeAfterCursor?.nodeName === 'IMG') {
|
||
event.preventDefault();
|
||
detach(nodeAfterCursor);
|
||
moveRangeBoundariesDownTree(range);
|
||
afterDelete(self, range);
|
||
return;
|
||
}
|
||
}
|
||
self.setSelection(originalRange);
|
||
setTimeout(() => afterDelete(self), 0);
|
||
}
|
||
},
|
||
tab: (self, event, range) => {
|
||
let root = self._root;
|
||
let node, parent;
|
||
self._removeZWS();
|
||
// If no selection and at start of block
|
||
if (range.collapsed && rangeDoesStartAtBlockBoundary(range, root)) {
|
||
node = getStartBlockOfRange(range, root);
|
||
// Iterate through the block's parents
|
||
while ((parent = node.parentNode)) {
|
||
// If we find a UL or OL (so are in a list, node must be an LI)
|
||
if (parent.nodeName === 'UL' || parent.nodeName === 'OL') {
|
||
// Then increase the list level
|
||
event.preventDefault();
|
||
self.increaseListLevel(range);
|
||
break;
|
||
}
|
||
node = parent;
|
||
}
|
||
}
|
||
},
|
||
'shift-tab': (self, event, range) => {
|
||
let root = self._root;
|
||
let node;
|
||
self._removeZWS();
|
||
// If no selection and at start of block
|
||
if (range.collapsed && rangeDoesStartAtBlockBoundary(range, root)) {
|
||
// Break list
|
||
node = range.startContainer;
|
||
if (getClosest(node, root, 'UL,OL')) {
|
||
event.preventDefault();
|
||
self.decreaseListLevel(range);
|
||
}
|
||
}
|
||
},
|
||
space: (self, _, range) => {
|
||
let root = self._root;
|
||
self._recordUndoState(range, false);
|
||
self._config.addLinks && addLinks(range.startContainer, root, self);
|
||
self._getRangeAndRemoveBookmark(range);
|
||
/*
|
||
// If the cursor is at the end of a link (<a>foo|</a>) then move it
|
||
// outside of the link (<a>foo</a>|) so that the space is not part of
|
||
// the link text.
|
||
// SnappyMail: disabled as it fails in Firefox
|
||
let node = range.endContainer;
|
||
if (range.collapsed && range.endOffset === getLength(node)) {
|
||
do {
|
||
if (node.nodeName === 'A') {
|
||
range.setStartAfter(node);
|
||
break;
|
||
}
|
||
} while (!node.nextSibling && (node = node.parentNode) && node !== root);
|
||
}
|
||
*/
|
||
// Delete the selection if not collapsed
|
||
if (!range.collapsed) {
|
||
deleteContentsOfRange(range, root);
|
||
self._ensureBottomLine();
|
||
self.setSelection(range);
|
||
self._updatePath(range, true);
|
||
}
|
||
|
||
self.setSelection(range);
|
||
},
|
||
arrowleft: self => self._removeZWS(),
|
||
arrowright: self => self._removeZWS()
|
||
};
|
||
|
||
// System standard for page up/down on Mac is to just scroll, not move the
|
||
// cursor. On Linux/Windows, it should move the cursor, but some browsers don't
|
||
// implement this natively. Override to support it.
|
||
function _moveCursorTo(self, toStart) {
|
||
let root = self._root,
|
||
range = createRange(root, toStart ? 0 : root.childNodes.length);
|
||
moveRangeBoundariesDownTree(range);
|
||
self.setSelection(range);
|
||
return self;
|
||
}
|
||
if (!isMac) {
|
||
keyHandlers.pageup = self => _moveCursorTo(self, true);
|
||
keyHandlers.pagedown = self => _moveCursorTo(self, false);
|
||
}
|
||
|
||
keyHandlers[ ctrlKey + 'b' ] = mapKeyToFormat('B');
|
||
keyHandlers[ ctrlKey + 'i' ] = mapKeyToFormat('I');
|
||
keyHandlers[ ctrlKey + 'u' ] = mapKeyToFormat('U');
|
||
keyHandlers[ ctrlKey + 'shift-7' ] = mapKeyToFormat('S');
|
||
keyHandlers[ ctrlKey + 'shift-5' ] = mapKeyToFormat('SUB', 'SUP');
|
||
keyHandlers[ ctrlKey + 'shift-6' ] = mapKeyToFormat('SUP', 'SUB');
|
||
keyHandlers[ ctrlKey + 'shift-8' ] = toggleList('UL', 'makeUnorderedList');
|
||
keyHandlers[ ctrlKey + 'shift-9' ] = toggleList('OL', 'makeOrderedList');
|
||
keyHandlers[ ctrlKey + '[' ] = changeIndentationLevel('decrease');
|
||
keyHandlers[ ctrlKey + ']' ] = changeIndentationLevel('increase');
|
||
keyHandlers[ ctrlKey + 'd' ] = mapKeyTo('toggleCode');
|
||
keyHandlers[ ctrlKey + 'y' ] = mapKeyTo('redo');
|
||
keyHandlers[ 'redo' ] = mapKeyTo('redo');
|
||
keyHandlers[ ctrlKey + 'z' ] = mapKeyTo('undo');
|
||
keyHandlers[ 'undo' ] = mapKeyTo('undo');
|
||
keyHandlers[ ctrlKey + 'shift-z' ] = mapKeyTo('redo');
|
||
|
||
class EditStack extends Array
|
||
{
|
||
constructor(squire) {
|
||
super();
|
||
this.squire = squire;
|
||
this.index = -1;
|
||
this.inUndoState = false;
|
||
|
||
this.threshold = -1; // -1 means no threshold
|
||
this.limit = -1; // -1 means no limit
|
||
}
|
||
|
||
clear() {
|
||
this.index = -1;
|
||
this.length = 0;
|
||
}
|
||
|
||
stateChanged(/*canUndo, canRedo*/) {
|
||
this.squire.fireEvent('undoStateChange', {
|
||
canUndo: this.index > 0,
|
||
canRedo: this.index + 1 < this.length
|
||
});
|
||
this.squire.fireEvent('input');
|
||
}
|
||
|
||
docWasChanged() {
|
||
if (this.inUndoState) {
|
||
this.inUndoState = false;
|
||
this.stateChanged (/*true, false*/);
|
||
} else
|
||
this.squire.fireEvent('input');
|
||
}
|
||
|
||
// Leaves bookmark
|
||
recordUndoState(range, replace) {
|
||
replace = replace !== false && this.inUndoState;
|
||
// Don't record if we're already in an undo state
|
||
if (!this.inUndoState || replace) {
|
||
// Advance pointer to new position
|
||
let undoIndex = this.index;
|
||
let undoThreshold = this.threshold;
|
||
let undoLimit = this.limit;
|
||
let squire = this.squire;
|
||
let html;
|
||
|
||
if (!replace) {
|
||
++undoIndex;
|
||
}
|
||
undoIndex = Math.max(0, undoIndex);
|
||
|
||
// Truncate stack if longer (i.e. if has been previously undone)
|
||
this.length = Math.min(undoIndex + 1, this.length);
|
||
|
||
// Get data
|
||
if (range) {
|
||
squire._saveRangeToBookmark(range);
|
||
}
|
||
html = squire._getHTML();
|
||
|
||
// If this document is above the configured size threshold,
|
||
// limit the number of saved undo states.
|
||
// Threshold is in bytes, JS uses 2 bytes per character
|
||
if (undoThreshold > -1 && html.length * 2 > undoThreshold
|
||
&& undoLimit > -1 && undoIndex > undoLimit) {
|
||
this.splice(0, undoIndex - undoLimit);
|
||
}
|
||
|
||
// Save data
|
||
this[ undoIndex ] = html;
|
||
this.index = undoIndex;
|
||
this.inUndoState = true;
|
||
}
|
||
}
|
||
|
||
saveUndoState(range) {
|
||
let squire = this.squire;
|
||
if (range === undefined) {
|
||
range = squire.getSelection();
|
||
}
|
||
this.recordUndoState(range);
|
||
squire._getRangeAndRemoveBookmark(range);
|
||
}
|
||
|
||
undo() {
|
||
let squire = this.squire,
|
||
undoIndex = this.index - 1;
|
||
// Sanity check: must not be at beginning of the history stack
|
||
if (undoIndex >= 0 || !this.inUndoState) {
|
||
// Make sure any changes since last checkpoint are saved.
|
||
this.recordUndoState(squire.getSelection(), false);
|
||
this.index = undoIndex;
|
||
squire._setHTML(this[ undoIndex ]);
|
||
let range = squire._getRangeAndRemoveBookmark();
|
||
if (range) {
|
||
squire.setSelection(range);
|
||
}
|
||
this.inUndoState = true;
|
||
this.stateChanged (/*undoIndex > 0, true*/);
|
||
}
|
||
}
|
||
|
||
redo() {
|
||
// Sanity check: must not be at end of stack and must be in an undo state.
|
||
let squire = this.squire,
|
||
undoIndex = this.index + 1;
|
||
if (undoIndex < this.length && this.inUndoState) {
|
||
this.index = undoIndex;
|
||
squire._setHTML(this[ undoIndex ]);
|
||
let range = squire._getRangeAndRemoveBookmark();
|
||
if (range) {
|
||
squire.setSelection(range);
|
||
}
|
||
this.stateChanged (/*true, undoIndex + 1 < this.length*/);
|
||
}
|
||
}
|
||
}
|
||
|
||
class Squire
|
||
{
|
||
constructor(root, config) {
|
||
this._root = root;
|
||
|
||
this._events = {};
|
||
|
||
this._isFocused = false;
|
||
this._lastRange = null;
|
||
|
||
this._hasZWS = false;
|
||
|
||
this._lastAnchorNode = null;
|
||
this._lastFocusNode = null;
|
||
this._path = '';
|
||
|
||
let _willUpdatePath;
|
||
const selectionchange = () => {
|
||
if (root.contains(doc.activeElement)) {
|
||
if (this._isFocused && !_willUpdatePath) {
|
||
_willUpdatePath = setTimeout(() => {
|
||
_willUpdatePath = 0;
|
||
this._updatePath(this.getSelection());
|
||
}, 0);
|
||
}
|
||
} else {
|
||
this.removeEventListener('selectionchange', selectionchange);
|
||
}
|
||
};
|
||
this.addEventListener('selectstart', () => this.addEventListener('selectionchange', selectionchange));
|
||
|
||
this.editStack = new EditStack(this);
|
||
this._ignoreChange = false;
|
||
this._ignoreAllChanges = false;
|
||
|
||
this._mutation = new MutationObserver(()=>this._docWasChanged());
|
||
this._mutation.observe(root, {
|
||
childList: true,
|
||
attributes: true,
|
||
characterData: true,
|
||
subtree: true
|
||
});
|
||
|
||
// On blur, restore focus except if the user taps or clicks to focus a
|
||
// specific point. Can't actually use click event because focus happens
|
||
// before click, so use mousedown/touchstart
|
||
this._restoreSelection = false;
|
||
// https://caniuse.com/mdn-api_document_pointerup_event
|
||
this.addEventListener('blur', () => this._restoreSelection = true)
|
||
.addEventListener('pointerdown mousedown touchstart', () => this._restoreSelection = false)
|
||
.addEventListener('focus', () => this._restoreSelection && this.setSelection(this._lastRange))
|
||
.addEventListener('cut', onCut)
|
||
.addEventListener('copy', onCopy)
|
||
// Need to monitor for shift key like this, as event.shiftKey is not available in paste event.
|
||
.addEventListener('keydown keyup', event => this.isShiftDown = event.shiftKey)
|
||
.addEventListener('paste', onPaste)
|
||
.addEventListener('drop', onDrop)
|
||
.addEventListener('keydown', onKey)
|
||
.addEventListener('pointerup keyup mouseup touchend', ()=>this.getSelection());
|
||
|
||
// Add key handlers
|
||
this._keyHandlers = Object.create(keyHandlers);
|
||
|
||
// Override default properties
|
||
this.setConfig(config);
|
||
|
||
root.setAttribute('contenteditable', 'true');
|
||
// Grammarly breaks the editor, *sigh*
|
||
root.setAttribute('data-gramm', 'false');
|
||
|
||
// Remove Firefox's built-in controls
|
||
try {
|
||
doc.execCommand('enableObjectResizing', false, 'false');
|
||
doc.execCommand('enableInlineTableEditing', false, 'false');
|
||
} catch (error) {}
|
||
|
||
root.__squire__ = this;
|
||
|
||
// Need to register instance before calling setHTML, so that the fixCursor
|
||
// function can lookup any default block tag options set.
|
||
this.setHTML('');
|
||
}
|
||
|
||
setConfig(config) {
|
||
this._config = mergeObjects({
|
||
addLinks: true
|
||
}, config, true);
|
||
return this;
|
||
}
|
||
|
||
createDefaultBlock(children) {
|
||
return fixCursor(
|
||
createElement(blockTag, null, children),
|
||
this._root
|
||
);
|
||
}
|
||
|
||
getRoot() {
|
||
return this._root;
|
||
}
|
||
|
||
// --- Events ---
|
||
|
||
fireEvent(type, event) {
|
||
let handlers = this._events[ type ];
|
||
let isFocused, l, obj;
|
||
// UI code, especially modal views, may be monitoring for focus events and
|
||
// immediately removing focus. In certain conditions, this can cause the
|
||
// focus event to fire after the blur event, which can cause an infinite
|
||
// loop. So we detect whether we're actually focused/blurred before firing.
|
||
if (/^(?:focus|blur)/.test(type)) {
|
||
isFocused = this._root === doc.activeElement;
|
||
if (type === 'focus') {
|
||
if (!isFocused || this._isFocused) {
|
||
return this;
|
||
}
|
||
this._isFocused = true;
|
||
} else {
|
||
if (isFocused || !this._isFocused) {
|
||
return this;
|
||
}
|
||
this._isFocused = false;
|
||
}
|
||
}
|
||
if (handlers) {
|
||
event = event || {};
|
||
if (event.type !== type) {
|
||
event.type = type;
|
||
}
|
||
// Clone handlers array, so any handlers added/removed do not affect it.
|
||
handlers = handlers.slice();
|
||
l = handlers.length;
|
||
while (l--) {
|
||
obj = handlers[l];
|
||
try {
|
||
obj.handleEvent ? obj.handleEvent(event) : obj.call(this, event);
|
||
} catch (error) {
|
||
error.details = 'Squire: fireEvent error. Event type: ' + type;
|
||
didError(error);
|
||
}
|
||
}
|
||
}
|
||
return this;
|
||
}
|
||
|
||
handleEvent(event) {
|
||
this.fireEvent(event.type, event);
|
||
}
|
||
|
||
addEventListener(type, fn) {
|
||
type.split(/\s+/).forEach(type=>{
|
||
if (!fn) {
|
||
didError({
|
||
name: 'Squire: addEventListener with null or undefined fn',
|
||
message: 'Event type: ' + type
|
||
});
|
||
return this;
|
||
}
|
||
let handlers = this._events[ type ];
|
||
if (!handlers) {
|
||
handlers = this._events[ type ] = [];
|
||
customEvents[ type ]
|
||
|| (type === 'selectionchange' ? doc : this._root)
|
||
.addEventListener(type, this, {capture:true,passive:'touchstart'===type});
|
||
}
|
||
handlers.push(fn);
|
||
});
|
||
return this;
|
||
}
|
||
|
||
removeEventListener(type, fn) {
|
||
let handlers = this._events[ type ];
|
||
let l;
|
||
if (handlers) {
|
||
if (fn) {
|
||
l = handlers.length;
|
||
while (l--) {
|
||
if (handlers[l] === fn) {
|
||
handlers.splice(l, 1);
|
||
}
|
||
}
|
||
} else {
|
||
handlers.length = 0;
|
||
}
|
||
if (!handlers.length) {
|
||
delete this._events[ type ];
|
||
customEvents[ type ]
|
||
|| (type === 'selectionchange' ? doc : this._root)
|
||
.removeEventListener(type, this, true);
|
||
}
|
||
}
|
||
return this;
|
||
}
|
||
|
||
// --- Selection and Path ---
|
||
|
||
setSelection(range) {
|
||
if (range) {
|
||
this._lastRange = range;
|
||
// If we're setting selection, that automatically, and synchronously, // triggers a focus event. So just store the selection and mark it as
|
||
// needing restore on focus.
|
||
if (this._isFocused) {
|
||
// iOS bug: if you don't focus the iframe before setting the
|
||
// selection, you can end up in a state where you type but the input
|
||
// doesn't get directed into the contenteditable area but is instead
|
||
// lost in a black hole. Very strange.
|
||
if (isIOS) {
|
||
win.focus();
|
||
}
|
||
let sel = win.getSelection();
|
||
if (sel?.setBaseAndExtent) {
|
||
sel.setBaseAndExtent(
|
||
range.startContainer,
|
||
range.startOffset,
|
||
range.endContainer,
|
||
range.endOffset,
|
||
);
|
||
} else if (sel) {
|
||
// This is just for IE11
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
}
|
||
} else {
|
||
this._restoreSelection = true;
|
||
}
|
||
}
|
||
return this;
|
||
}
|
||
|
||
getSelection() {
|
||
let sel = win.getSelection();
|
||
let root = this._root;
|
||
let range, startContainer, endContainer;
|
||
// If not focused, always rely on cached range; another function may
|
||
// have set it but the DOM is not modified until focus again
|
||
if (this._isFocused && sel?.rangeCount) {
|
||
range = sel.getRangeAt(0).cloneRange();
|
||
startContainer = range.startContainer;
|
||
endContainer = range.endContainer;
|
||
// FF can return the range as being inside an <img>. WTF?
|
||
if (startContainer && isLeaf(startContainer)) {
|
||
range.setStartBefore(startContainer);
|
||
}
|
||
if (endContainer && isLeaf(endContainer)) {
|
||
range.setEndBefore(endContainer);
|
||
}
|
||
}
|
||
if (range && root.contains(range.commonAncestorContainer)) {
|
||
this._lastRange = range;
|
||
} else {
|
||
range = this._lastRange;
|
||
// Check the editor is in the live document; if not, the range has
|
||
// probably been rewritten by the browser and is bogus
|
||
if (!doc.contains(range.commonAncestorContainer)) {
|
||
range = null;
|
||
}
|
||
}
|
||
return range || createRange(root.firstChild, 0);
|
||
}
|
||
|
||
getSelectionClosest(selector) {
|
||
let range = this.getSelection();
|
||
return range && getClosest(range.commonAncestorContainer, this._root, selector);
|
||
}
|
||
|
||
getPath() {
|
||
return this._path;
|
||
}
|
||
|
||
// --- Workaround for browsers that can't focus empty text nodes ---
|
||
|
||
// WebKit bug: https://bugs.webkit.org/show_bug.cgi?id=15256
|
||
|
||
_addZWS () {
|
||
this._hasZWS = isWebKit;
|
||
return doc.createTextNode(isWebKit ? ZWS : '');
|
||
}
|
||
_removeZWS () {
|
||
if (this._hasZWS) {
|
||
removeZWS(this._root);
|
||
this._hasZWS = false;
|
||
}
|
||
}
|
||
|
||
// --- Path change events ---
|
||
|
||
_updatePath (range, force) {
|
||
if (range) {
|
||
let anchor = range.startContainer,
|
||
focus = range.endContainer,
|
||
newPath;
|
||
if (force || anchor !== this._lastAnchorNode || focus !== this._lastFocusNode) {
|
||
this._lastAnchorNode = anchor;
|
||
this._lastFocusNode = focus;
|
||
newPath = (anchor && focus) ? (anchor === focus ?
|
||
getPath(focus, this._root) : '(selection)') : '';
|
||
if (this._path !== newPath) {
|
||
this._path = newPath;
|
||
this.fireEvent('pathChange', { path: newPath });
|
||
}
|
||
}
|
||
this.fireEvent(range.collapsed ? 'cursor' : 'select', {
|
||
range: range
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- Focus ---
|
||
|
||
focus() {
|
||
this._root.focus({ preventScroll: true });
|
||
return this;
|
||
}
|
||
|
||
blur() {
|
||
this._root.blur();
|
||
return this;
|
||
}
|
||
|
||
// --- Bookmarking ---
|
||
|
||
_saveRangeToBookmark (range) {
|
||
let [startNode, endNode] = createBookmarkNodes(this),
|
||
temp;
|
||
|
||
insertNodeInRange(range, startNode);
|
||
range.collapse(false);
|
||
insertNodeInRange(range, endNode);
|
||
|
||
// In a collapsed range, the start is sometimes inserted after the end!
|
||
if (startNode.compareDocumentPosition(endNode) & DOCUMENT_POSITION_PRECEDING) {
|
||
startNode.id = endSelectionId;
|
||
endNode.id = startSelectionId;
|
||
temp = startNode;
|
||
startNode = endNode;
|
||
endNode = temp;
|
||
}
|
||
|
||
range.setStartAfter(startNode);
|
||
range.setEndBefore(endNode);
|
||
}
|
||
|
||
_getRangeAndRemoveBookmark (range) {
|
||
let root = this._root,
|
||
start = root.querySelector('#' + startSelectionId),
|
||
end = root.querySelector('#' + endSelectionId);
|
||
|
||
if (start && end) {
|
||
let startContainer = start.parentNode,
|
||
endContainer = end.parentNode,
|
||
startOffset = indexOf(startContainer.childNodes, start),
|
||
endOffset = indexOf(endContainer.childNodes, end);
|
||
|
||
if (startContainer === endContainer) {
|
||
--endOffset;
|
||
}
|
||
|
||
detach(start);
|
||
detach(end);
|
||
|
||
if (!range) {
|
||
range = doc.createRange();
|
||
}
|
||
range.setStart(startContainer, startOffset);
|
||
range.setEnd(endContainer, endOffset);
|
||
|
||
// Merge any text nodes we split
|
||
mergeInlines(startContainer, range);
|
||
if (startContainer !== endContainer) {
|
||
mergeInlines(endContainer, range);
|
||
}
|
||
|
||
// If we didn't split a text node, we should move into any adjacent
|
||
// text node to current selection point
|
||
if (range.collapsed) {
|
||
startContainer = range.startContainer;
|
||
if (startContainer.nodeType === TEXT_NODE) {
|
||
endContainer = startContainer.childNodes[ range.startOffset ];
|
||
if (!endContainer || endContainer.nodeType !== TEXT_NODE) {
|
||
endContainer =
|
||
startContainer.childNodes[ range.startOffset - 1 ];
|
||
}
|
||
if (endContainer?.nodeType === TEXT_NODE) {
|
||
range.setStart(endContainer, 0);
|
||
range.collapse(true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return range || null;
|
||
}
|
||
|
||
// --- Undo ---
|
||
|
||
_docWasChanged () {
|
||
nodeCategoryCache = new WeakMap();
|
||
if (!this._ignoreAllChanges) {
|
||
if (this._ignoreChange) {
|
||
this._ignoreChange = false;
|
||
} else {
|
||
this.editStack.docWasChanged();
|
||
}
|
||
}
|
||
}
|
||
|
||
_recordUndoState (range, replace) {
|
||
this.editStack.recordUndoState(range, replace);
|
||
}
|
||
|
||
saveUndoState(range) {
|
||
this.editStack.saveUndoState(range);
|
||
}
|
||
|
||
undo() {
|
||
this.editStack.undo();
|
||
}
|
||
|
||
redo() {
|
||
this.editStack.redo();
|
||
}
|
||
|
||
// --- Inline formatting ---
|
||
|
||
// Looks for matching tag and attributes, so won't work
|
||
// if <strong> instead of <b> etc.
|
||
hasFormat(tag, attributes, range) {
|
||
// 1. Normalise the arguments and get selection
|
||
tag = tag.toUpperCase();
|
||
if (!range && !(range = this.getSelection())) {
|
||
return false;
|
||
}
|
||
|
||
// Sanitize range to prevent weird IE artifacts
|
||
if (!range.collapsed &&
|
||
range.startContainer.nodeType === TEXT_NODE &&
|
||
range.startOffset === range.startContainer.length &&
|
||
range.startContainer.nextSibling) {
|
||
range.setStartBefore(range.startContainer.nextSibling);
|
||
}
|
||
if (!range.collapsed &&
|
||
range.endContainer.nodeType === TEXT_NODE &&
|
||
range.endOffset === 0 &&
|
||
range.endContainer.previousSibling) {
|
||
range.setEndAfter(range.endContainer.previousSibling);
|
||
}
|
||
|
||
// If the common ancestor is inside the tag we require, we definitely
|
||
// have the format.
|
||
let root = this._root;
|
||
let common = range.commonAncestorContainer;
|
||
let walker, node;
|
||
if (getNearest(common, root, tag, attributes)) {
|
||
return true;
|
||
}
|
||
|
||
// If common ancestor is a text node and doesn't have the format, we
|
||
// definitely don't have it.
|
||
if (common.nodeType === TEXT_NODE) {
|
||
return false;
|
||
}
|
||
|
||
// Otherwise, check each text node at least partially contained within
|
||
// the selection and make sure all of them have the format we want.
|
||
walker = createTreeWalker(common, SHOW_TEXT, node => isNodeContainedInRange(range, node));
|
||
|
||
let seenNode = false;
|
||
while (node = walker.nextNode()) {
|
||
if (!getNearest(node, root, tag, attributes)) {
|
||
return false;
|
||
}
|
||
seenNode = true;
|
||
}
|
||
|
||
return seenNode;
|
||
}
|
||
|
||
// Extracts the font-family and font-size (if any) of the element
|
||
// holding the cursor. If there's a selection, returns an empty object.
|
||
getFontInfo(range) {
|
||
let fontInfo = {
|
||
color: undefined,
|
||
backgroundColor: undefined,
|
||
family: undefined,
|
||
size: undefined
|
||
};
|
||
let seenAttributes = 0;
|
||
let element, style, attr;
|
||
|
||
if (!range && !(range = this.getSelection())) {
|
||
return fontInfo;
|
||
}
|
||
|
||
element = range.commonAncestorContainer;
|
||
if (range.collapsed || element.nodeType === TEXT_NODE) {
|
||
if (element.nodeType === TEXT_NODE) {
|
||
element = element.parentNode;
|
||
}
|
||
while (seenAttributes < 4 && element) {
|
||
if (style = element.style) {
|
||
if (!fontInfo.color && (attr = style.color)) {
|
||
fontInfo.color = attr;
|
||
++seenAttributes;
|
||
}
|
||
if (!fontInfo.backgroundColor && (attr = style.backgroundColor)) {
|
||
fontInfo.backgroundColor = attr;
|
||
++seenAttributes;
|
||
}
|
||
if (!fontInfo.family && (attr = style.fontFamily)) {
|
||
fontInfo.family = attr;
|
||
++seenAttributes;
|
||
}
|
||
if (!fontInfo.size && (attr = style.fontSize)) {
|
||
fontInfo.size = attr;
|
||
++seenAttributes;
|
||
}
|
||
}
|
||
element = element.parentNode;
|
||
}
|
||
}
|
||
return fontInfo;
|
||
}
|
||
|
||
_addFormat (tag, attributes, range) {
|
||
// If the range is collapsed we simply insert the node by wrapping
|
||
// it round the range and focus it.
|
||
let root = this._root;
|
||
let el, walker, startContainer, endContainer, startOffset, endOffset,
|
||
node, block;
|
||
|
||
if (range.collapsed) {
|
||
el = fixCursor(createElement(tag, attributes), root);
|
||
insertNodeInRange(range, el);
|
||
range.setStart(el.firstChild, el.firstChild.length);
|
||
range.collapse(true);
|
||
|
||
// Clean up any previous formats that may have been set on this block
|
||
// that are unused.
|
||
block = el;
|
||
while (isInline(block)) {
|
||
block = block.parentNode;
|
||
}
|
||
removeZWS(block, el);
|
||
}
|
||
// Otherwise we find all the textnodes in the range (splitting
|
||
// partially selected nodes) and if they're not already formatted
|
||
// correctly we wrap them in the appropriate tag.
|
||
else {
|
||
// Create an iterator to walk over all the text nodes under this
|
||
// ancestor which are in the range and not already formatted
|
||
// correctly.
|
||
//
|
||
// In Blink/WebKit, empty blocks may have no text nodes, just a <br>.
|
||
// Therefore we wrap this in the tag as well, as this will then cause it
|
||
// to apply when the user types something in the block, which is
|
||
// presumably what was intended.
|
||
//
|
||
// IMG tags are included because we may want to create a link around
|
||
// them, and adding other styles is harmless.
|
||
walker = createTreeWalker(
|
||
range.commonAncestorContainer,
|
||
SHOW_ELEMENT_OR_TEXT,
|
||
node => (node.nodeType === TEXT_NODE ||
|
||
node.nodeName === 'BR' ||
|
||
node.nodeName === 'IMG'
|
||
) && isNodeContainedInRange(range, node)
|
||
);
|
||
|
||
// Start at the beginning node of the range and iterate through
|
||
// all the nodes in the range that need formatting.
|
||
startContainer = range.startContainer;
|
||
startOffset = range.startOffset;
|
||
endContainer = range.endContainer;
|
||
endOffset = range.endOffset;
|
||
|
||
// Make sure we start with a valid node.
|
||
walker.currentNode = startContainer;
|
||
if (filterAccept != walker.filter.acceptNode(startContainer)) {
|
||
startContainer = walker.nextNode();
|
||
startOffset = 0;
|
||
}
|
||
|
||
// If there are interesting nodes in the selection
|
||
if (startContainer) {
|
||
do {
|
||
node = walker.currentNode;
|
||
if (!getNearest(node, root, tag, attributes)) {
|
||
// <br> can never be a container node, so must have a text node
|
||
// if node == (end|start)Container
|
||
if (node === endContainer && node.length > endOffset) {
|
||
node.splitText(endOffset);
|
||
}
|
||
if (node === startContainer && startOffset) {
|
||
node = node.splitText(startOffset);
|
||
if (endContainer === startContainer) {
|
||
endContainer = node;
|
||
endOffset -= startOffset;
|
||
}
|
||
startContainer = node;
|
||
startOffset = 0;
|
||
}
|
||
el = createElement(tag, attributes);
|
||
node.replaceWith(el);
|
||
el.append(node);
|
||
}
|
||
} while (walker.nextNode());
|
||
|
||
// If we don't finish inside a text node, offset may have changed.
|
||
if (endContainer.nodeType !== TEXT_NODE) {
|
||
if (node.nodeType === TEXT_NODE) {
|
||
endContainer = node;
|
||
endOffset = node.length;
|
||
} else {
|
||
// If <br>, we must have just wrapped it, so it must have only
|
||
// one child
|
||
endContainer = node.parentNode;
|
||
endOffset = 1;
|
||
}
|
||
}
|
||
|
||
// Now set the selection to as it was before
|
||
range = createRange(
|
||
startContainer, startOffset, endContainer, endOffset);
|
||
}
|
||
}
|
||
return range;
|
||
}
|
||
|
||
_removeFormat (tag, attributes, range, partial) {
|
||
// Add bookmark
|
||
this._saveRangeToBookmark(range);
|
||
|
||
// We need a node in the selection to break the surrounding
|
||
// formatted text.
|
||
let fixer;
|
||
if (range.collapsed) {
|
||
fixer = this._addZWS();
|
||
insertNodeInRange(range, fixer);
|
||
}
|
||
|
||
// Find block-level ancestor of selection
|
||
let root = range.commonAncestorContainer;
|
||
while (isInline(root)) {
|
||
root = root.parentNode;
|
||
}
|
||
|
||
// Find text nodes inside formatTags that are not in selection and
|
||
// add an extra tag with the same formatting.
|
||
let startContainer = range.startContainer,
|
||
startOffset = range.startOffset,
|
||
endContainer = range.endContainer,
|
||
endOffset = range.endOffset,
|
||
toWrap = [],
|
||
examineNode = (node, exemplar) => {
|
||
// If the node is completely contained by the range then
|
||
// we're going to remove all formatting so ignore it.
|
||
if (isNodeContainedInRange(range, node, false)) {
|
||
return;
|
||
}
|
||
|
||
let isText = (node.nodeType === TEXT_NODE),
|
||
child, next;
|
||
|
||
// If not at least partially contained, wrap entire contents
|
||
// in a clone of the tag we're removing and we're done.
|
||
if (!isNodeContainedInRange(range, node)) {
|
||
// Ignore bookmarks and empty text nodes
|
||
if (node.nodeName !== 'INPUT' && (!isText || node.data)) {
|
||
toWrap.push([ exemplar, node ]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Split any partially selected text nodes.
|
||
if (isText) {
|
||
if (node === endContainer && endOffset !== node.length) {
|
||
toWrap.push([ exemplar, node.splitText(endOffset) ]);
|
||
}
|
||
if (node === startContainer && startOffset) {
|
||
node.splitText(startOffset);
|
||
toWrap.push([ exemplar, node ]);
|
||
}
|
||
}
|
||
// If not a text node, recurse onto all children.
|
||
// Beware, the tree may be rewritten with each call
|
||
// to examineNode, hence find the next sibling first.
|
||
else {
|
||
for (child = node.firstChild; child; child = next) {
|
||
next = child.nextSibling;
|
||
examineNode(child, exemplar);
|
||
}
|
||
}
|
||
},
|
||
formatTags = Array.prototype.filter.call(
|
||
root.getElementsByTagName(tag),
|
||
el => isNodeContainedInRange(range, el) && hasTagAttributes(el, tag, attributes)
|
||
);
|
||
|
||
partial || formatTags.forEach(node => examineNode(node, node));
|
||
|
||
// Now wrap unselected nodes in the tag
|
||
toWrap.forEach(([exemplar, node]) => {
|
||
let el = exemplar.cloneNode(false);
|
||
node.replaceWith(el);
|
||
el.append(node);
|
||
});
|
||
// and remove old formatting tags.
|
||
formatTags.forEach(el => el.replaceWith(empty(el)));
|
||
|
||
// Merge adjacent inlines:
|
||
this._getRangeAndRemoveBookmark(range);
|
||
fixer && range.collapse(false);
|
||
mergeInlines(root, range);
|
||
|
||
return range;
|
||
}
|
||
|
||
toggleTag(name, remove) {
|
||
let range = this.getSelection();
|
||
if (this.hasFormat(name, null, range)) {
|
||
this.changeFormat (null, { tag: name }, range);
|
||
} else {
|
||
this.changeFormat ({ tag: name }, remove ? { tag: remove } : null, range);
|
||
}
|
||
}
|
||
|
||
changeFormat(add, remove, range, partial) {
|
||
// Normalise the arguments and get selection
|
||
if (range || (range = this.getSelection())) {
|
||
// Save undo checkpoint
|
||
this.saveUndoState(range);
|
||
|
||
if (remove) {
|
||
range = this._removeFormat(remove.tag.toUpperCase(),
|
||
remove.attributes || {}, range, partial);
|
||
}
|
||
|
||
if (add) {
|
||
range = this._addFormat(add.tag.toUpperCase(),
|
||
add.attributes || {}, range);
|
||
}
|
||
|
||
this.setSelection(range);
|
||
this._updatePath(range, true);
|
||
}
|
||
return this;
|
||
}
|
||
|
||
// --- Block formatting ---
|
||
|
||
forEachBlock(fn, range) {
|
||
if (range || (range = this.getSelection())) {
|
||
// Save undo checkpoint
|
||
this.saveUndoState(range);
|
||
|
||
let root = this._root;
|
||
let start = getStartBlockOfRange(range, root);
|
||
let end = getEndBlockOfRange(range, root);
|
||
if (start && end) {
|
||
do {
|
||
if (fn(start) || start === end) { break; }
|
||
} while (start = getNextBlock(start, root));
|
||
}
|
||
|
||
this.setSelection(range);
|
||
|
||
// Path may have changed
|
||
this._updatePath(range, true);
|
||
}
|
||
return this;
|
||
}
|
||
|
||
modifyBlocks(modify, range) {
|
||
if (range || (range = this.getSelection())) {
|
||
// 1. Save undo checkpoint and bookmark selection
|
||
this._recordUndoState(range);
|
||
|
||
let root = this._root;
|
||
let frag;
|
||
|
||
// 2. Expand range to block boundaries
|
||
expandRangeToBlockBoundaries(range, root);
|
||
|
||
// 3. Remove range.
|
||
moveRangeBoundariesUpTree(range, root, root, root);
|
||
frag = extractContentsOfRange(range, root, root);
|
||
|
||
// 4. Modify tree of fragment and reinsert.
|
||
insertNodeInRange(range, modify.call(this, frag));
|
||
|
||
// 5. Merge containers at edges
|
||
if (range.endOffset < range.endContainer.childNodes.length) {
|
||
mergeContainers(range.endContainer.childNodes[ range.endOffset ], root);
|
||
}
|
||
mergeContainers(range.startContainer.childNodes[ range.startOffset ], root);
|
||
|
||
// 6. Restore selection
|
||
this._getRangeAndRemoveBookmark(range);
|
||
this.setSelection(range);
|
||
this._updatePath(range, true);
|
||
}
|
||
return this;
|
||
}
|
||
|
||
increaseListLevel(range) {
|
||
if (range || (range = this.getSelection())) {
|
||
let root = this._root;
|
||
let listSelection = getListSelection(range, root);
|
||
if (listSelection) {
|
||
let list = listSelection[0];
|
||
let startLi = listSelection[1];
|
||
let endLi = listSelection[2];
|
||
if (startLi && startLi !== list.firstChild) {
|
||
// Save undo checkpoint and bookmark selection
|
||
this._recordUndoState(range);
|
||
|
||
// Increase list depth
|
||
let type = list.nodeName;
|
||
let newParent = startLi.previousSibling;
|
||
let next;
|
||
if (newParent.nodeName !== type) {
|
||
newParent = createElement(type);
|
||
startLi.before(newParent);
|
||
}
|
||
do {
|
||
next = startLi === endLi ? null : startLi.nextSibling;
|
||
newParent.append(startLi);
|
||
} while ((startLi = next));
|
||
next = newParent.nextSibling;
|
||
next && mergeContainers(next, root);
|
||
|
||
// Restore selection
|
||
this._getRangeAndRemoveBookmark(range);
|
||
this.setSelection(range);
|
||
this._updatePath(range, true);
|
||
}
|
||
}
|
||
}
|
||
return this.focus();
|
||
}
|
||
|
||
decreaseListLevel(range) {
|
||
if (range || (range = this.getSelection())) {
|
||
let root = this._root;
|
||
let listSelection = getListSelection(range, root);
|
||
if (listSelection) {
|
||
let list = listSelection[0];
|
||
let startLi = listSelection[1] || list.firstChild;
|
||
let endLi = listSelection[2] || list.lastChild;
|
||
let newParent, next, insertBefore, makeNotList;
|
||
|
||
// Save undo checkpoint and bookmark selection
|
||
this._recordUndoState(range);
|
||
|
||
if (startLi) {
|
||
// Find the new parent list node
|
||
newParent = list.parentNode;
|
||
|
||
// Split list if necesary
|
||
insertBefore = !endLi.nextSibling ?
|
||
list.nextSibling :
|
||
split(list, endLi.nextSibling, newParent, root);
|
||
|
||
if (newParent !== root && newParent.nodeName === 'LI') {
|
||
newParent = newParent.parentNode;
|
||
while (insertBefore) {
|
||
next = insertBefore.nextSibling;
|
||
endLi.append(insertBefore);
|
||
insertBefore = next;
|
||
}
|
||
insertBefore = list.parentNode.nextSibling;
|
||
}
|
||
|
||
makeNotList = !/^[OU]L$/.test(newParent.nodeName);
|
||
do {
|
||
next = startLi === endLi ? null : startLi.nextSibling;
|
||
startLi.remove();
|
||
if (makeNotList && startLi.nodeName === 'LI') {
|
||
startLi = this.createDefaultBlock([ empty(startLi) ]);
|
||
}
|
||
newParent.insertBefore(startLi, insertBefore);
|
||
} while ((startLi = next));
|
||
}
|
||
|
||
list.firstChild || detach(list);
|
||
|
||
insertBefore && mergeContainers(insertBefore, root);
|
||
|
||
// Restore selection
|
||
this._getRangeAndRemoveBookmark(range);
|
||
this.setSelection(range);
|
||
this._updatePath(range, true);
|
||
}
|
||
}
|
||
return this.focus();
|
||
}
|
||
|
||
_ensureBottomLine () {
|
||
let root = this._root;
|
||
let last = root.lastElementChild;
|
||
if (!last || last.nodeName !== blockTag || !isBlock(last)) {
|
||
root.append(this.createDefaultBlock());
|
||
}
|
||
}
|
||
|
||
// --- Get/Set data ---
|
||
|
||
_getHTML () {
|
||
return this._root.innerHTML;
|
||
}
|
||
|
||
_setHTML (html) {
|
||
let root = this._root;
|
||
let node = root;
|
||
empty(root);
|
||
root.appendChild(this._config.sanitizeToDOMFragment(html, false));
|
||
do {
|
||
fixCursor(node, root);
|
||
} while (node = getNextBlock(node, root));
|
||
this._ignoreChange = true;
|
||
}
|
||
|
||
getHTML(withBookMark) {
|
||
let html, range;
|
||
if (withBookMark && (range = this.getSelection())) {
|
||
this._saveRangeToBookmark(range);
|
||
}
|
||
html = this._getHTML().replace(/\u200B/g, '');
|
||
range && this._getRangeAndRemoveBookmark(range);
|
||
return html;
|
||
}
|
||
|
||
setHTML(html) {
|
||
let root = this._root,
|
||
// Parse HTML into DOM tree
|
||
frag = this._config.sanitizeToDOMFragment(html, false),
|
||
child;
|
||
|
||
cleanTree(frag);
|
||
cleanupBRs(frag, root, false);
|
||
|
||
fixContainer(frag, root);
|
||
|
||
// Fix cursor
|
||
let node, walker = getBlockWalker(frag, root);
|
||
while ((node = walker.nextNode()) && node !== root) {
|
||
fixCursor(node, root);
|
||
}
|
||
|
||
// Don't fire an input event
|
||
this._ignoreChange = true;
|
||
|
||
// Remove existing root children
|
||
while (child = root.lastChild) {
|
||
child.remove();
|
||
}
|
||
|
||
// And insert new content
|
||
root.append(frag);
|
||
fixCursor(root, root);
|
||
|
||
// Reset the undo stack
|
||
this.editStack.clear();
|
||
|
||
// Record undo state
|
||
let range = this._getRangeAndRemoveBookmark() ||
|
||
createRange(root.firstChild, 0);
|
||
this.saveUndoState(range);
|
||
// IE will also set focus when selecting text so don't use
|
||
// setSelection. Instead, just store it in lastSelection, so if
|
||
// anything calls getSelection before first focus, we have a range
|
||
// to return.
|
||
this._lastRange = range;
|
||
this._restoreSelection = true;
|
||
this._updatePath(range, true);
|
||
|
||
return this;
|
||
}
|
||
|
||
insertElement(el, range) {
|
||
if (!range) {
|
||
range = this.getSelection();
|
||
}
|
||
range.collapse(true);
|
||
if (isInline(el)) {
|
||
insertNodeInRange(range, el);
|
||
range.setStartAfter(el);
|
||
} else {
|
||
// Get containing block node.
|
||
let root = this._root;
|
||
let splitNode = getStartBlockOfRange(range, root) || root;
|
||
let parent, nodeAfterSplit;
|
||
// While at end of container node, move up DOM tree.
|
||
while (splitNode !== root && !splitNode.nextSibling) {
|
||
splitNode = splitNode.parentNode;
|
||
}
|
||
// If in the middle of a container node, split up to root.
|
||
if (splitNode !== root) {
|
||
parent = splitNode.parentNode;
|
||
nodeAfterSplit = split(parent, splitNode.nextSibling, root, root);
|
||
}
|
||
if (nodeAfterSplit) {
|
||
nodeAfterSplit.before(el);
|
||
} else {
|
||
root.append(el);
|
||
// Insert blank line below block.
|
||
nodeAfterSplit = this.createDefaultBlock();
|
||
root.append(nodeAfterSplit);
|
||
}
|
||
range.setStart(nodeAfterSplit, 0);
|
||
range.setEnd(nodeAfterSplit, 0);
|
||
moveRangeBoundariesDownTree(range);
|
||
}
|
||
this.focus();
|
||
this.setSelection(range);
|
||
this._updatePath(range);
|
||
|
||
return this;
|
||
}
|
||
|
||
insertImage(src, attributes) {
|
||
let img = createElement('IMG', mergeObjects({
|
||
src: src
|
||
}, attributes, true));
|
||
this.insertElement(img);
|
||
return img;
|
||
}
|
||
|
||
// Insert HTML at the cursor location. If the selection is not collapsed
|
||
// insertTreeFragmentIntoRange will delete the selection so that it is replaced
|
||
// by the html being inserted.
|
||
insertHTML(html, isPaste) {
|
||
let range = this.getSelection();
|
||
|
||
// Edge doesn't just copy the fragment, but includes the surrounding guff
|
||
// including the full <head> of the page. Need to strip this out.
|
||
if (isPaste) {
|
||
let startFragmentIndex = html.indexOf('<!--StartFragment-->'),
|
||
endFragmentIndex = html.lastIndexOf('<!--EndFragment-->');
|
||
if (startFragmentIndex > -1 && endFragmentIndex > -1) {
|
||
html = html.slice(startFragmentIndex + 20, endFragmentIndex);
|
||
}
|
||
}
|
||
|
||
let frag = this._config.sanitizeToDOMFragment(html, isPaste);
|
||
|
||
// Record undo checkpoint
|
||
this.saveUndoState(range);
|
||
|
||
try {
|
||
let root = this._root, node = frag;
|
||
|
||
addLinks(frag, frag, this);
|
||
cleanTree(frag);
|
||
cleanupBRs(frag, root, false);
|
||
removeEmptyInlines(frag);
|
||
frag.normalize();
|
||
|
||
while (node = getNextBlock(node, frag)) {
|
||
fixCursor(node, root);
|
||
}
|
||
|
||
insertTreeFragmentIntoRange(range, frag, root);
|
||
range.collapse(false);
|
||
|
||
// After inserting the fragment, check whether the cursor is inside
|
||
// an <a> element and if so if there is an equivalent cursor
|
||
// position after the <a> element. If there is, move it there.
|
||
moveRangeBoundaryOutOf(range, 'A', root);
|
||
|
||
this._ensureBottomLine();
|
||
|
||
this.setSelection(range);
|
||
this._updatePath(range, true);
|
||
// Safari sometimes loses focus after paste. Weird.
|
||
isPaste && this.focus();
|
||
} catch (error) {
|
||
didError(error);
|
||
}
|
||
return this;
|
||
}
|
||
|
||
insertPlainText(plainText, isPaste) {
|
||
let range = this.getSelection();
|
||
if (range.collapsed && getClosest(range.startContainer, this._root, 'PRE')) {
|
||
let node = range.startContainer;
|
||
let offset = range.startOffset;
|
||
let text;
|
||
if (node?.nodeType !== TEXT_NODE) {
|
||
text = doc.createTextNode('');
|
||
node?.childNodes[ offset ].before(text);
|
||
node = text;
|
||
offset = 0;
|
||
}
|
||
|
||
node.insertData(offset, plainText);
|
||
range.setStart(node, offset + plainText.length);
|
||
range.collapse(true);
|
||
this.setSelection(range);
|
||
return this;
|
||
}
|
||
let lines = plainText.split(/\r?\n/),
|
||
closeBlock = '</' + blockTag + '>',
|
||
openBlock = '<' + blockTag + '>',
|
||
i = lines.length,
|
||
line;
|
||
|
||
while (i--) {
|
||
line = escapeHTML(lines[i]).replace(/ (?=)/g, ' ');
|
||
// We don't wrap the first line in the block, so if it gets inserted
|
||
// into a blank line it keeps that line's formatting.
|
||
// Wrap each line in <div></div>
|
||
if (i) {
|
||
line = openBlock + (line || '<BR>') + closeBlock;
|
||
}
|
||
lines[i] = line;
|
||
}
|
||
return this.insertHTML(lines.join(''), isPaste);
|
||
}
|
||
|
||
// --- Formatting ---
|
||
|
||
makeLink(url, attributes) {
|
||
let range = this.getSelection();
|
||
if (range.collapsed) {
|
||
insertNodeInRange(
|
||
range,
|
||
doc.createTextNode(url.replace(/^[^:]*:\/*/, ''))
|
||
);
|
||
}
|
||
attributes = mergeObjects(
|
||
mergeObjects({
|
||
href: url
|
||
}, attributes, true),
|
||
null,
|
||
false
|
||
);
|
||
|
||
this.changeFormat({
|
||
tag: 'A',
|
||
attributes: attributes
|
||
}, {
|
||
tag: 'A'
|
||
}, range);
|
||
return this.focus();
|
||
}
|
||
|
||
removeLink() {
|
||
this.changeFormat(null, {
|
||
tag: 'A'
|
||
}, this.getSelection(), true);
|
||
return this.focus();
|
||
}
|
||
|
||
setStyle(style) {
|
||
let range = this.getSelection();
|
||
let start = range?.startContainer || {};
|
||
let end = range ? range.endContainer : 0;
|
||
// When the selection is all the text inside an element, set style on the element itself
|
||
if (TEXT_NODE === start?.nodeType && 0 === range.startOffset && start === end && end.length === range.endOffset) {
|
||
this.saveUndoState(range);
|
||
setStyle(start.parentNode, style);
|
||
this.setSelection(range);
|
||
this._updatePath(range, true);
|
||
}
|
||
// Else create a span element
|
||
else {
|
||
this.changeFormat({
|
||
tag: 'SPAN',
|
||
attributes: {
|
||
style: style
|
||
}
|
||
}, null, range);
|
||
}
|
||
return this.focus();
|
||
}
|
||
|
||
// ---
|
||
|
||
code() {
|
||
let range = this.getSelection();
|
||
if (range.collapsed || isContainer(range.commonAncestorContainer)) {
|
||
this.modifyBlocks(frag => {
|
||
let root = this._root;
|
||
let output = doc.createDocumentFragment();
|
||
let walker = getBlockWalker(frag, root);
|
||
let node;
|
||
// 1. Extract inline content; drop all blocks and contains.
|
||
while ((node = walker.nextNode())) {
|
||
// 2. Replace <br> with \n in content
|
||
node.querySelectorAll('BR').forEach(br => {
|
||
if (!isLineBreak(br, false)) {
|
||
detach(br);
|
||
} else {
|
||
br.replaceWith(doc.createTextNode('\n'));
|
||
}
|
||
});
|
||
// 3. Remove <code>; its format clashes with <pre>
|
||
node.querySelectorAll('CODE').forEach(el => detach(el));
|
||
if (output.childNodes.length) {
|
||
output.append(doc.createTextNode('\n'));
|
||
}
|
||
output.append(empty(node));
|
||
}
|
||
// 4. Replace nbsp with regular sp
|
||
walker = createTreeWalker(output, SHOW_TEXT);
|
||
while ((node = walker.nextNode())) {
|
||
node.data = node.data.replace(NBSP, ' '); // nbsp -> sp
|
||
}
|
||
output.normalize();
|
||
return fixCursor(createElement('PRE',
|
||
null, [
|
||
output
|
||
]), root);
|
||
}, range);
|
||
} else {
|
||
this.changeFormat({
|
||
tag: 'CODE'
|
||
}, null, range);
|
||
}
|
||
return this.focus();
|
||
}
|
||
|
||
removeCode() {
|
||
let range = this.getSelection();
|
||
let ancestor = range.commonAncestorContainer;
|
||
let inPre = getClosest(ancestor, this._root, 'PRE');
|
||
if (inPre) {
|
||
this.modifyBlocks(frag => {
|
||
let root = this._root;
|
||
let pres = frag.querySelectorAll('PRE');
|
||
let l = pres.length;
|
||
let pre, walker, node, value, contents, index;
|
||
while (l--) {
|
||
pre = pres[l];
|
||
walker = createTreeWalker(pre, SHOW_TEXT);
|
||
while ((node = walker.nextNode())) {
|
||
value = node.data;
|
||
value = value.replace(/ (?=)/g, NBSP); // sp -> nbsp
|
||
contents = doc.createDocumentFragment();
|
||
while ((index = value.indexOf('\n')) > -1) {
|
||
contents.append(
|
||
doc.createTextNode(value.slice(0, index))
|
||
);
|
||
contents.append(createElement('BR'));
|
||
value = value.slice(index + 1);
|
||
}
|
||
node.before(contents);
|
||
node.data = value;
|
||
}
|
||
fixContainer(pre, root);
|
||
pre.replaceWith(empty(pre));
|
||
}
|
||
return frag;
|
||
}, range);
|
||
} else {
|
||
this.changeFormat(null, { tag: 'CODE' }, range);
|
||
}
|
||
return this.focus();
|
||
}
|
||
|
||
toggleCode() {
|
||
return (this.hasFormat('PRE') || this.hasFormat('CODE'))
|
||
? this.removeCode()
|
||
: this.code();
|
||
}
|
||
|
||
// ---
|
||
|
||
changeIndentationLevel(direction) {
|
||
let parent = this.getSelectionClosest('UL,OL,BLOCKQUOTE');
|
||
if (parent || 'increase' === direction) {
|
||
let method = (!parent || 'BLOCKQUOTE' === parent.nodeName) ? 'Quote' : 'List';
|
||
this[ direction + method + 'Level' ]();
|
||
}
|
||
}
|
||
|
||
increaseQuoteLevel(range) {
|
||
this.modifyBlocks(
|
||
frag => createElement('BLOCKQUOTE', null, [ frag ]),
|
||
range
|
||
);
|
||
return this.focus();
|
||
}
|
||
|
||
decreaseQuoteLevel(range) {
|
||
this.modifyBlocks(
|
||
frag => {
|
||
Array.prototype.filter.call(frag.querySelectorAll('blockquote'), el =>
|
||
!getClosest(el.parentNode, frag, 'BLOCKQUOTE')
|
||
).forEach(el => el.replaceWith(empty(el)));
|
||
return frag;
|
||
},
|
||
range
|
||
);
|
||
return this.focus();
|
||
}
|
||
|
||
makeUnorderedList() {
|
||
this.modifyBlocks(frag => makeList(this, frag, 'UL'));
|
||
return this.focus();
|
||
}
|
||
|
||
makeOrderedList() {
|
||
this.modifyBlocks(frag => makeList(this, frag, 'OL'));
|
||
return this.focus();
|
||
}
|
||
|
||
removeList() {
|
||
this.modifyBlocks(frag => {
|
||
let root = this._root,
|
||
listFrag;
|
||
frag.querySelectorAll('UL, OL').forEach(list => {
|
||
listFrag = empty(list);
|
||
fixContainer(listFrag, root);
|
||
list.replaceWith(listFrag);
|
||
});
|
||
|
||
frag.querySelectorAll('LI').forEach(item => {
|
||
if (isBlock(item)) {
|
||
item.replaceWith(
|
||
this.createDefaultBlock([ empty(item) ])
|
||
);
|
||
} else {
|
||
fixContainer(item, root);
|
||
item.replaceWith(empty(item));
|
||
}
|
||
});
|
||
|
||
return frag;
|
||
});
|
||
return this.focus();
|
||
}
|
||
|
||
bold() { this.toggleTag('B'); }
|
||
italic() { this.toggleTag('I'); }
|
||
underline() { this.toggleTag('U'); }
|
||
strikethrough() { this.toggleTag('S'); }
|
||
subscript() { this.toggleTag('SUB', 'SUP'); }
|
||
superscript() { this.toggleTag('SUP', 'SUB'); }
|
||
}
|
||
|
||
win.Squire = Squire;
|
||
|
||
})(document);
|