. Browsers that want
// 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);
}
}
},
escapeHTML = text => text.replace('&', '&')
.replace('<', '<')
.replace('>', '>')
.replace('"', '"'),
// source/node/Block.ts
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'),
// source/range/Block.ts
// 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;
},
rangeDoesStartAtBlockBoundary = (range, root) => {
let startContainer = range.startContainer;
let startOffset = range.startOffset;
let startBlock = getStartBlockOfRange(range, root);
let nodeAfterCursor;
if (startBlock) {
// If in the middle or end of a text node, we're not at the boundary.
if (isTextNode(startContainer)) {
if (startOffset) {
return false;
}
nodeAfterCursor = startContainer;
} else {
nodeAfterCursor = getNodeAfter(startContainer, startOffset);
// The cursor was right at the end of the document
if (!nodeAfterCursor || !root.contains(nodeAfterCursor)) {
nodeAfterCursor = getNodeBefore(startContainer, startOffset);
if (isTextNode(nodeAfterCursor) && 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 (isTextNode(endContainer)) {
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);
}
},
// source/range/InsertDelete.ts
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 (isTextNode(startContainer)) {
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 (isTextNode(common)) {
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 (isTextNode(before) && isTextNode(after)) {
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(); // 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.
Foo
// 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
– 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,
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 && block) {
range.setEndBefore(block);
range.collapse();
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 && block) {
tempRange = range.cloneRange();
mergeWithBlock(block, blockContentsAfterSplit, tempRange, root);
range.setEnd(tempRange.endContainer, tempRange.endOffset);
}
moveRangeBoundariesDownTree(range);
},
// source/Clipboard.ts
// 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();
},
onCut = function(event) {
let self = this;
let range = self.getSelection();
let root = self._root;
let startBlock, endBlock, copyRoot, contents, parent, newContents;
// Nothing to do
if (range.collapsed) {
event.preventDefault();
return;
}
// Save undo checkpoint
self.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 (isTextNode(parent)) {
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);
}
self.setSelection(range);
},
onCopy = function(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 (isTextNode(parent)) {
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);
}
},
onPaste = function(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('
+')
', 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.
onDrop = function(event) {
let types = event.dataTransfer.types;
if (types.includes('text/plain') || types.includes('text/html')) {
this.saveUndoState();
}
},
// source/keyboard/KeyHandlers.ts
onKey = function(event) {
if (event.defaultPrevented) {
return;
}
let key = event.key.toLowerCase(),
modifiers = '',
range = this.getSelection(),
root = this._root;
// 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, root);
this._ensureBottomLine();
this.setSelection(range);
this._updatePath(range, true);
} else if (range.collapsed
&& range.startContainer === root
&& root.children.length > 0) {
// Under certain conditions, cursor/range can be positioned directly
// under this._root (not wrapped) and when this happens, an inline(TEXT)
// element is attached directly to this._root. There might be other
// issues, but squire.makeUnorderedList(), squire.makeOrderedList() and
// maybe other functions that call squire.modifyBlocks() do NOT work
// on inline(TEXT) nodes that are direct children of this._root.
// Therefor, we try to detect this case here and wrap the cursor/range
// before the text is inserted.
const nextElement = root.children[range.startOffset];
if (nextElement && !isBlock(nextElement)) {
// create a new wrapper
range = createRange(root.insertBefore(
this.createDefaultBlock(), nextElement
), 0);
if (nextElement.tagName === 'BR') {
// delete it because a new
is created by createDefaultBlock()
root.removeChild(nextElement);
}
const restore = this._restoreSelection;
this.setSelection(range);
this._restoreSelection = restore;
}
}
},
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 (isElement(cursorContainer)) {
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 (
foo|) then move it
// outside of the link (
foo|) 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()
},
mapKeyToFormat = (tag, remove) => {
return (self, event) => {
event.preventDefault();
self.toggleTag(tag, remove);
};
},
mapKeyTo = method => (self, event) => {
event.preventDefault();
self[method]();
},
blockTag = 'DIV',
DOCUMENT_POSITION_PRECEDING = 2, // Node.DOCUMENT_POSITION_PRECEDING
NBSP = '\u00A0',
win = doc.defaultView,
osKey = isMac ? 'metaKey' : 'ctrlKey',
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
},
*/
isElement = node => node instanceof Element,
isTextNode = node => node instanceof Text,
createTreeWalker = (root, whatToShow, filter) => doc.createTreeWalker(root, whatToShow, filter ? {
acceptNode: node => filter(node) ? filterAccept : NodeFilter.FILTER_SKIP
} : null
),
getPath = (node, root) => {
let path = '', style;
if (node && node !== root) {
path = getPath(node.parentNode, root);
if (isElement(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;
},
setAttributes = (node, props) => {
props && Object.entries(props).forEach(([k,v]) => {
if ('style' === k && typeof v === 'object') {
Object.entries(v).forEach(([k,v]) => node.style[k] = v);
} else if (v != null) {
node.setAttribute(k, v);
} else {
node.removeAttribute(k);
}
});
},
newContentWalker = root => createTreeWalker(root,
SHOW_ELEMENT_OR_TEXT,
node => isTextNode(node) ? notWS.test(node.data) : node.nodeName === 'IMG'
),
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;
},
// If you delete the content inside a span with a font styling, Webkit will
// replace it with a
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 (isTextNode(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