/* 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
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
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.
// 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 trailingFoo
; its format clashes with
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);