snappymail/vendors/squire/build/squire-raw.js
2023-02-20 13:12:09 +01:00

3718 lines
104 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;');
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, '&nbsp;');
// 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);