/* 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.

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); 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 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
in Chrome, // it removes the div and replaces it with just a
inside the // root. Detach the
; 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
. if (!block || shiftKey || /^T[HD]$/.test(block.nodeName)) { // If inside an , 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 / 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 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()); }, //
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
. 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); } } }, // 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