UNPKG

wed

Version:

Wed is a schema-aware editor for XML documents.

1,256 lines 63.7 kB
/** * Utilities that manipulate or query the DOM tree. * @author Louis-Dominique Dubeau * @license MPL 2.0 * @copyright Mangalam Research Center for Buddhist Languages */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; result["default"] = mod; return result; }; define(["require", "exports", "jquery", "./domtypeguards", "./util"], function (require, exports, jquery_1, domtypeguards_1, util) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); jquery_1 = __importDefault(jquery_1); util = __importStar(util); exports.isAttr = domtypeguards_1.isAttr; function indexOf(a, target) { const length = a.length; for (let i = 0; i < length; ++i) { if (a[i] === target) { return i; } } return -1; } exports.indexOf = indexOf; /** * Compare two locations that have already been determined to be in a * parent-child relation. **Important: the relationship must have been formally * tested *before* calling this function.** * * @returns -1 if ``parent`` is before ``child``, 1 otherwise. */ function parentChildCompare(parentNode, parentOffset, childNode) { // Find which child of parent is or contains the other node. let curChild = parentNode.firstChild; let ix = 0; while (curChild !== null) { if (curChild.contains(childNode)) { break; } ix++; curChild = curChild.nextSibling; } // This is ``<= 0`` and not just ``< 0`` because if our offset points exactly // to the child we found, then parent location is necessarily before the child // location. return (parentOffset - ix) <= 0 ? -1 : 1; } /** * Compare two positions in document order. * * This function relies on DOM's ``compareDocumentPosition`` function. Remember * that calling that function with attributes can be problematic. (For instance, * two attributes on the same element are not ordered.) * * @param firstNode Node of the first position. * * @param firstOffset Offset of the first position. * * @param secondNode Node of the second position. * * @param secondOffset Offset of the second position. * * @returns -1 if the first position comes before the second. 1 if the first * position comes after the other. 0 if the two positions are equal. */ function comparePositions(firstNode, firstOffset, secondNode, secondOffset) { if (firstNode === secondNode) { const d = firstOffset - secondOffset; if (d === 0) { return 0; } return d < 0 ? -1 : 1; } const comparison = firstNode.compareDocumentPosition(secondNode); // tslint:disable:no-bitwise if ((comparison & Node.DOCUMENT_POSITION_DISCONNECTED) !== 0) { throw new Error("cannot compare disconnected nodes"); } if ((comparison & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0) { return parentChildCompare(firstNode, firstOffset, secondNode); } if ((comparison & Node.DOCUMENT_POSITION_CONTAINS) !== 0) { // This raises a type error: // // return -parentChildCompare(secondNode, secondOffset, firstNode); return parentChildCompare(secondNode, secondOffset, firstNode) < 0 ? 1 : -1; } if ((comparison & Node.DOCUMENT_POSITION_PRECEDING) !== 0) { return 1; } if ((comparison & Node.DOCUMENT_POSITION_FOLLOWING) !== 0) { return -1; } // tslint:enable:no-bitwise throw new Error("neither preceding nor following: this should not happen"); } exports.comparePositions = comparePositions; /** * Gets the first range in the selection. * * @param win The window for which we want the selection. * * @returns The first range in the selection. Undefined if there is no selection * or no range. */ function getSelectionRange(win) { const sel = win.getSelection(); if (sel === undefined || sel.rangeCount < 1) { return undefined; } return sel.getRangeAt(0); } exports.getSelectionRange = getSelectionRange; /** * Creates a range from two points in a document. * * @returns The range information. */ function rangeFromPoints(startContainer, startOffset, endContainer, endOffset) { const range = startContainer.ownerDocument.createRange(); let reversed = false; if (comparePositions(startContainer, startOffset, endContainer, endOffset) <= 0) { range.setStart(startContainer, startOffset); range.setEnd(endContainer, endOffset); } else { range.setStart(endContainer, endOffset); range.setEnd(startContainer, startOffset); reversed = true; } return { range: range, reversed: reversed }; } exports.rangeFromPoints = rangeFromPoints; /** * Focuses the node itself or if the node is a text node, focuses the parent. * * @param node The node to focus. * * @throws {Error} If the node is neither a text node nor an element. Trying to * focus something other than these is almost certainly an algorithmic bug. */ function focusNode(node) { const nodeType = node != null ? node.nodeType : undefined; switch (nodeType) { case Node.TEXT_NODE: if (node.parentNode == null) { throw new Error("detached node"); } node.parentNode.focus(); break; case Node.ELEMENT_NODE: node.focus(); break; default: throw new Error("tried to focus something other than a text node or " + "an element."); } } exports.focusNode = focusNode; /** * This function determines the caret position if the caret was moved forward. * * This function does not fully emulate how a browser moves the caret. The sole * emulation it performs is to check whether whitespace matters or not. It skips * whitespace that does not matter. * * @param caret A caret position where the search starts. This should be an * array of length two that has in first position the node where the caret is * and in second position the offset in that node. This pair is to be * interpreted in the same way node, offset pairs are interpreted in selection * or range objects. * * @param container A DOM node which indicates the container within which caret * movements must be contained. * * @param noText If true, and a text node would be returned, the function will * instead return the parent of the text node. * * @returns The next caret position, or ``null`` if such position does not * exist. The ``container`` parameter constrains movements to positions inside * it. */ // tslint:disable-next-line:cyclomatic-complexity function nextCaretPosition(caret, container, noText) { let [node, offset] = caret; let found = false; if (!container.contains(node)) { return null; } const doc = domtypeguards_1.isDocument(node) ? node : node.ownerDocument; const window = doc.defaultView; let parent; search_loop: while (!found) { parent = node.parentNode; switch (node.nodeType) { case Node.TEXT_NODE: if (offset >= node.length || // If the parent node is set to normal whitespace handling, then // moving the caret forward by one position will skip this whitespace. (parent != null && parent.lastChild === node && window.getComputedStyle(parent, undefined).whiteSpace === "normal" && /^\s+$/.test(node.data.slice(offset)))) { // We would move outside the container if (parent == null || node === container) { break search_loop; } offset = indexOf(parent.childNodes, node) + 1; node = parent; } else { offset++; found = true; } break; case Node.ELEMENT_NODE: if (offset >= node.childNodes.length) { // If we've hit the end of what we can search, stop. if (parent == null || node === container) { break search_loop; } offset = indexOf(parent.childNodes, node) + 1; node = parent; found = true; } else { node = node.childNodes[offset]; offset = 0; found = !(node.childNodes.length > 0 && domtypeguards_1.isText(node.childNodes[offset])); } break; default: } } if (!found) { return null; } if (noText && domtypeguards_1.isText(node)) { parent = node.parentNode; if (parent == null) { throw new Error("detached node"); } offset = indexOf(parent.childNodes, node); node = parent; } // We've moved to a position outside the container. if (!container.contains(node) || (node === container && offset >= node.childNodes.length)) { return null; } return [node, offset]; } exports.nextCaretPosition = nextCaretPosition; /** * This function determines the caret position if the caret was moved backwards. * * This function does not fully emulate how a browser moves the caret. The sole * emulation it performs is to check whether whitespace matters or not. It skips * whitespace that does not matter. * * @param caret A caret position where the search starts. This should be an * array of length two that has in first position the node where the caret is * and in second position the offset in that node. This pair is to be * interpreted in the same way node, offset pairs are interpreted in selection * or range objects. * * @param container A DOM node which indicates the container within which caret * movements must be contained. * * @param noText If true, and a text node would be returned, the function will * instead return the parent of the text node. * * @returns The previous caret position, or ``null`` if such position does not * exist. The ``container`` parameter constrains movements to positions inside * it. */ // tslint:disable-next-line:cyclomatic-complexity function prevCaretPosition(caret, container, noText) { let [node, offset] = caret; let found = false; if (!container.contains(node)) { return null; } const doc = domtypeguards_1.isDocument(node) ? node : node.ownerDocument; const window = doc.defaultView; let parent; search_loop: while (!found) { offset--; // We've moved to a position outside the container. if (node === container && offset < 0) { return null; } parent = node.parentNode; switch (node.nodeType) { case Node.TEXT_NODE: if (offset < 0 || // If the parent node is set to normal whitespace handling, then // moving the caret back by one position will skip this whitespace. (parent != null && parent.firstChild === node && window.getComputedStyle(parent, undefined).whiteSpace === "normal" && /^\s+$/.test(node.data.slice(0, offset)))) { // We would move outside the container if (parent === null || node === container) { break search_loop; } offset = indexOf(parent.childNodes, node); node = parent; } else { found = true; } break; case Node.ELEMENT_NODE: if (offset < 0 || node.childNodes.length === 0) { // If we've hit the end of what we can search, stop. if (parent == null || node === container) { break search_loop; } offset = indexOf(parent.childNodes, node); node = parent; found = true; } // If node.childNodes.length === 0, the first branch would have been // taken. No need to test that offset indexes to something that exists. else { node = node.childNodes[offset]; if (domtypeguards_1.isElement(node)) { offset = node.childNodes.length; found = !(node.childNodes.length > 0 && domtypeguards_1.isText(node.childNodes[offset - 1])); } else { offset = node.length + 1; } } break; default: } } if (!found) { return null; } if (noText && domtypeguards_1.isText(node)) { parent = node.parentNode; if (parent == null) { throw new Error("detached node"); } offset = indexOf(parent.childNodes, node); node = parent; } // We've moved to a position outside the container. if (!container.contains(node) || (node === container && offset < 0)) { return null; } return [node, offset]; } exports.prevCaretPosition = prevCaretPosition; /** * Given two trees A and B of DOM nodes, this function finds the node in tree B * which corresponds to a node in tree A. The two trees must be structurally * identical. If tree B is cloned from tree A, it will satisfy this * requirement. This function does not work with attribute nodes. * * @param treeA The root of the first tree. * * @param treeB The root of the second tree. * * @param nodeInA A node in the first tree. * * @returns The node which corresponds to ``nodeInA`` in ``treeB``. * * @throws {Error} If ``nodeInA`` is not ``treeA`` or a child of ``treeA``. */ function correspondingNode(treeA, treeB, nodeInA) { const path = []; let current = nodeInA; while (current !== treeA) { const parent = current.parentNode; if (parent == null) { throw new Error("nodeInA is not treeA or a child of treeA"); } path.unshift(indexOf(parent.childNodes, current)); current = parent; } let ret = treeB; while (path.length !== 0) { ret = ret.childNodes[path.shift()]; } return ret; } exports.correspondingNode = correspondingNode; /** * Makes a placeholder element * * @param text The text to put in the placeholder. * * @returns A node. */ function makePlaceholder(text) { const span = document.createElement("span"); span.className = "_placeholder"; span.textContent = text !== undefined ? text : " "; return span; } exports.makePlaceholder = makePlaceholder; /** * Inserts an element into text, effectively splitting the text node in * two. This function takes care to modify the DOM tree only once. * * @private * * @param textNode The text node that will be cut in two by the new element. * * @param index The offset into the text node where the new element is to be * inserted. * * @param node The node to insert. If undefined, then this function effectively * splits the text node into two parts. * * @param The operation must clean contiguous text nodes so as to merge them and * must not create empty nodes. **This code assumes that the text node into * which data is added is not preceded or followed by another text node and that * it is not empty.** In other words, if the DOM tree on which this code is used * does not have consecutive text nodes and no empty nodes, then after the call, * it still won't. * * @returns A pair containing a caret position marking the boundary between what * comes before the material inserted and the material inserted, and a caret * position marking the boundary between the material inserted and what comes * after. If I insert "foo" at position 2 in "abcd", then the final result would * be "abfoocd" and the first caret would mark the boundary between "ab" and * "foo" and the second caret the boundary between "foo" and "cd". * * @throws {Error} If ``textNode`` is not a text node. */ function _genericInsertIntoText(textNode, index, node, clean = true) { // This function is meant to be called with this set to a proper // value. /* jshint validthis:true */ if (!domtypeguards_1.isText(textNode)) { throw new Error("insertIntoText called on non-text"); } let startCaret; let endCaret; if (clean === undefined) { clean = true; } // Normalize if (index < 0) { index = 0; } else if (index > textNode.length) { index = textNode.length; } let prev; let next; const isFragment = domtypeguards_1.isDocumentFragment(node); // A parent is necessarily an element. const parent = textNode.parentNode; if (parent == null) { throw new Error("detached node"); } let textNodeAt = indexOf(parent.childNodes, textNode); if (clean && (node == null || (isFragment && node.childNodes.length === 0))) { startCaret = endCaret = [textNode, index]; } else { const frag = document.createDocumentFragment(); prev = document.createTextNode(textNode.data.slice(0, index)); frag.appendChild(prev); if (node != null) { frag.appendChild(node); } next = document.createTextNode(textNode.data.slice(index)); const nextLen = next.length; frag.appendChild(next); if (clean) { frag.normalize(); } if (clean && index === 0) { startCaret = [parent, textNodeAt]; } else { startCaret = [frag.firstChild, index]; } if (clean && index === textNode.length) { endCaret = [parent, textNodeAt + frag.childNodes.length]; } else { endCaret = [frag.lastChild, frag.lastChild.length - nextLen]; } // tslint:disable:no-invalid-this this.deleteNode(textNode); if (this.insertFragAt !== undefined) { this.insertFragAt(parent, textNodeAt, frag); } else { while (frag.firstChild != null) { this.insertNodeAt(parent, textNodeAt++, frag.firstChild); } } // tslint:enable:no-invalid-this } return [startCaret, endCaret]; } /** * Inserts an element into text, effectively splitting the text node in * two. This function takes care to modify the DOM tree only once. * * @param textNode The text node that will be cut in two by the new element. * * @param index The offset into the text node where the new element is to be * inserted. * * @param node The node to insert. * * @returns A pair containing a caret position marking the boundary between what * comes before the material inserted and the material inserted, and a caret * position marking the boundary between the material inserted and what comes * after. If I insert "foo" at position 2 in "abcd", then the final result would * be "abfoocd" and the first caret would mark the boundary between "ab" and * "foo" and the second caret the boundary between "foo" and "cd". * * @throws {Error} If the node to insert is undefined or null. */ function genericInsertIntoText(textNode, index, node) { // This function is meant to be called with this set to a proper // value. if (node == null) { throw new Error("must pass an actual node to insert"); } // tslint:disable-next-line:no-invalid-this return _genericInsertIntoText.call(this, textNode, index, node); } exports.genericInsertIntoText = genericInsertIntoText; /** * Inserts text into a node. This function will use already existing * text nodes whenever possible rather than create a new text node. * * @param node The node where the text is to be inserted. * * @param index The location in the node where the text is * to be inserted. * * @param text The text to insert. * * @param caretAtEnd Whether the caret position returned should be placed at the * end of the inserted text. * * @returns The result of inserting the text. * * @throws {Error} If ``node`` is not an element or text Node type. */ function genericInsertText(node, index, text, caretAtEnd = true) { // This function is meant to be called with this set to a proper // value. if (text === "") { return { node: undefined, isNew: false, caret: [node, index], }; } let isNew = false; let textNode; let caret; work: // tslint:disable-next-line:no-constant-condition strict-boolean-expressions while (true) { switch (node.nodeType) { case Node.ELEMENT_NODE: const child = node.childNodes[index]; if (domtypeguards_1.isText(child)) { // Prepend to already existing text node. node = child; index = 0; continue work; } const prev = node.childNodes[index - 1]; if (domtypeguards_1.isText(prev)) { // Append to already existing text node. node = prev; index = prev.length; continue work; } // We have to create a text node textNode = document.createTextNode(text); isNew = true; // Node is necessarily an element when we get here. // tslint:disable-next-line:no-invalid-this this.insertNodeAt(node, index, textNode); caret = [textNode, caretAtEnd ? text.length : 0]; break work; case Node.TEXT_NODE: textNode = node; const pre = textNode.data.slice(0, index); const post = textNode.data.slice(index); // tslint:disable-next-line:no-invalid-this this.setTextNodeValue(textNode, pre + text + post); caret = [textNode, caretAtEnd ? index + text.length : index]; break work; default: throw new Error(`unexpected node type: ${node.nodeType}`); } } return { node: textNode, isNew, caret: caret, }; } exports.genericInsertText = genericInsertText; /** * Deletes text from a text node. If the text node becomes empty, it is deleted. * * @param node The text node from which to delete text. * * @param index The index at which to delete text. * * @param length The length of text to delete. * * @throws {Error} If ``node`` is not a text Node type. */ function deleteText(node, index, length) { if (!domtypeguards_1.isText(node)) { throw new Error("deleteText called on non-text"); } node.deleteData(index, length); if (node.length === 0) { if (node.parentNode == null) { throw new Error("detached node"); } node.parentNode.removeChild(node); } } exports.deleteText = deleteText; /** * This function recursively links two DOM trees through the jQuery ``.data()`` * method. For an element in the first tree the data item named * "wed_mirror_node" points to the corresponding element in the second tree, and * vice-versa. It is presumed that the two DOM trees are perfect mirrors of each * other, although no test is performed to confirm this. */ function linkTrees(rootA, rootB) { jquery_1.default.data(rootA, "wed_mirror_node", rootB); jquery_1.default.data(rootB, "wed_mirror_node", rootA); for (let i = 0; i < rootA.children.length; ++i) { const childA = rootA.children[i]; const childB = rootB.children[i]; linkTrees(childA, childB); } } exports.linkTrees = linkTrees; /** * This function recursively unlinks a DOM tree though the jQuery ``.data()`` * method. * * @param root A DOM node. * */ function unlinkTree(root) { jquery_1.default.removeData(root, "wed_mirror_node"); for (let i = 0; i < root.children.length; ++i) { unlinkTree(root.children[i]); } } exports.unlinkTree = unlinkTree; /** * Returns the first descendant or the node passed to the function if the node * happens to not have a descendant. The function searches in document order. * * When passed ``<p><b>A</b><b><q>B</q></b></p>`` this code would return the * text node "A" because it has no children and is first. * * @param node The node to search. * * @returns The first node which is both first in its parent and has no * children. */ function firstDescendantOrSelf(node) { if (node === undefined) { node = null; } while (node !== null && node.firstChild !== null) { node = node.firstChild; } return node; } exports.firstDescendantOrSelf = firstDescendantOrSelf; /** * Returns the last descendant or the node passed to the function if the node * happens to not have a descendant. The function searches in reverse document * order. * * When passed ``<p><b>A</b><b><q>B</q></b></p>`` this code would return the * text node "B" because it has no children and is last. * * @param node The node to search. * * @returns The last node which is both last in its parent and has no * children. */ function lastDescendantOrSelf(node) { if (node === undefined) { node = null; } while (node !== null && node.lastChild !== null) { node = node.lastChild; } return node; } exports.lastDescendantOrSelf = lastDescendantOrSelf; /** * Removes the node. Mainly for use with the generic functions defined here. * * @param node The node to remove. */ function deleteNode(node) { if (node.parentNode == null) { // For historical reasons we raise an error rather than make it a noop. throw new Error("detached node"); } node.parentNode.removeChild(node); } exports.deleteNode = deleteNode; /** * Inserts a node at the position specified. Mainly for use with the generic * functions defined here. * * @param parent The node which will become the parent of the inserted node. * * @param index The position at which to insert the node into the parent. * * @param node The node to insert. */ function insertNodeAt(parent, index, node) { const child = parent.childNodes[index]; parent.insertBefore(node, child != null ? child : null); } /** * Inserts text into a node. This function will use already existing text nodes * whenever possible rather than create a new text node. * * @function * * @param node The node where the text is to be inserted. * * @param index The location in the node where the text is to be inserted. * * @param text The text to insert. * * @param caretAtEnd Whether to return the caret position at the end of the * inserted text or at the beginning. Default to ``true``. * * @returns The result of inserting the text. * * @throws {Error} If ``node`` is not an element or text Node type. */ function insertText(node, index, text, caretAtEnd) { return genericInsertText.call({ insertNodeAt: insertNodeAt, setTextNodeValue: (textNode, value) => { textNode.data = value; }, }, node, index, text, caretAtEnd); } exports.insertText = insertText; const plainDOMMockup = { insertNodeAt: insertNodeAt, insertFragAt: insertNodeAt, deleteNode: deleteNode, }; /** * See [[_genericInsertIntoText]]. * * @private */ function _insertIntoText(textNode, index, node, clean = true) { return _genericInsertIntoText.call(plainDOMMockup, textNode, index, node, clean); } /** * Inserts an element into text, effectively splitting the text node in * two. This function takes care to modify the DOM tree only once. * * @param textNode The text node that will be cut in two by the new element. * * @param index The offset into the text node where the new element is to be * inserted. * * @param node The node to insert. * * @returns A pair containing a caret position marking the boundary between what * comes before the material inserted and the material inserted, and a caret * position marking the boundary between the material inserted and what comes * after. If I insert "foo" at position 2 in "abcd", then the final result would * be "abfoocd" and the first caret would mark the boundary between "ab" and * "foo" and the second caret the boundary between "foo" and "cd". */ function insertIntoText(textNode, index, node) { return genericInsertIntoText.call(plainDOMMockup, textNode, index, node); } exports.insertIntoText = insertIntoText; /** * Splits a text node into two nodes. This function takes care to modify the DOM * tree only once. * * @param textNode The text node to split into two text nodes. * * @param index The offset into the text node where to split. * * @returns The first element is the node before index after split and the * second element is the node after the index after split. */ function splitTextNode(textNode, index) { const carets = _insertIntoText(textNode, index, undefined, false); return [carets[0][0], carets[1][0]]; } exports.splitTextNode = splitTextNode; /** * Merges a text node with the next text node, if present. When called on * something which is not a text node or if the next node is not text, does * nothing. Mainly for use with the generic functions defined here. * * @param node The node to merge with the next node. * * @returns A caret position between the two parts that were merged, or between * the two nodes that were not merged (because they were not both text). */ function mergeTextNodes(node) { const next = node.nextSibling; if (domtypeguards_1.isText(node) && domtypeguards_1.isText(next)) { const offset = node.length; node.appendData(next.data); next.parentNode.removeChild(next); return [node, offset]; } const parent = node.parentNode; if (parent == null) { throw new Error("detached node"); } return [parent, indexOf(parent.childNodes, node) + 1]; } exports.mergeTextNodes = mergeTextNodes; /** * Returns the **element** nodes that contain the start and the end of the * range. If an end of the range happens to be in a text node, the element node * will be that node's parent. * * @private * * @param range An object which has the ``startContainer``, ``startOffset``, * ``endContainer``, ``endOffset`` attributes set. The interpretation of these * values is the same as for DOM ``Range`` objects. Therefore, the object passed * can be a DOM range. * * @returns A pair of nodes. * * @throws {Error} If a node in ``range`` is not of element or text Node types. */ function nodePairFromRange(range) { let startNode; switch (range.startContainer.nodeType) { case Node.TEXT_NODE: startNode = range.startContainer.parentNode; if (startNode == null) { throw new Error("detached node"); } break; case Node.ELEMENT_NODE: startNode = range.startContainer; break; default: throw new Error(`unexpected node type: ${range.startContainer.nodeType}`); } let endNode; switch (range.endContainer.nodeType) { case Node.TEXT_NODE: endNode = range.endContainer.parentNode; if (endNode == null) { throw new Error("detached node"); } break; case Node.ELEMENT_NODE: endNode = range.endContainer; break; default: throw new Error(`unexpected node type: ${range.endContainer.nodeType}`); } return [startNode, endNode]; } /** * Determines whether a range is well-formed. A well-formed range is one which * starts and ends in the same element. * * @param range An object which has the ``startContainer``, * ``startOffset``, ``endContainer``, ``endOffset`` attributes set. The * interpretation of these values is the same as for DOM ``Range`` * objects. Therefore, the object passed can be a DOM range. * * @returns ``true`` if the range is well-formed. ``false`` if not. */ function isWellFormedRange(range) { const pair = nodePairFromRange(range); return pair[0] === pair[1]; } exports.isWellFormedRange = isWellFormedRange; /** * Removes the contents between the start and end carets from the DOM tree. If * two text nodes become adjacent, they are merged. * * @param startCaret Start caret position. * * @param endCaret Ending caret position. * * @returns The first item is the caret position indicating where the cut * happened. The second item is a list of nodes, the cut contents. * * @throws {Error} If Nodes in the range are not in the same element. */ // tslint:disable-next-line:max-func-body-length function genericCutFunction(startCaret, endCaret) { // copy uses an algorithm similar to the one here and probably should also be // modified if this function is modified. let [startContainer, startOffset] = startCaret; let [endContainer, endOffset] = endCaret; if (!isWellFormedRange({ startContainer, startOffset, endContainer, endOffset })) { throw new Error("range is not well-formed"); } let parent = startContainer.parentNode; if (parent == null) { throw new Error("detached node"); } if (domtypeguards_1.isText(startContainer) && startOffset === 0) { // We are at the start of a text node, move up to the parent. startOffset = indexOf(parent.childNodes, startContainer); startContainer = parent; parent = startContainer.parentNode; if (parent == null) { throw new Error("detached node"); } } let finalCaret; let startText; if (domtypeguards_1.isText(startContainer)) { const sameContainer = startContainer === endContainer; const startContainerOffset = indexOf(parent.childNodes, startContainer); const endTextOffset = sameContainer ? endOffset : startContainer.length; startText = parent.ownerDocument.createTextNode(startContainer.data.slice(startOffset, endTextOffset)); // tslint:disable-next-line:no-invalid-this this.deleteText(startContainer, startOffset, startText.length); // deleteText will delete startContainer from the tree if it happens that // we've emptied it. const notEmptied = startContainer.parentNode !== null; finalCaret = notEmptied ? [startContainer, startOffset] : // Selection was such that the text node was emptied. [parent, startContainerOffset]; if (sameContainer) { // Both the start and end were in the same node, so the deleteText // operation above did everything needed. return [finalCaret, [startText]]; } // Alter our start to take care of the rest startOffset = notEmptied ? // Look after the text node we just modified. startContainerOffset + 1 : // Selection was such that the text node was emptied, and thus removed. So // stay at the same place. startContainerOffset; startContainer = parent; } else { finalCaret = [startContainer, startOffset]; } let endText; if (domtypeguards_1.isText(endContainer)) { parent = endContainer.parentNode; if (parent == null) { throw new Error("detached node"); } const endContainerOffset = indexOf(parent.childNodes, endContainer); endText = parent.ownerDocument.createTextNode(endContainer.data.slice(0, endOffset)); // tslint:disable-next-line:no-invalid-this this.deleteText(endContainer, 0, endOffset); // Alter our end to take care of the rest endOffset = endContainerOffset; endContainer = parent; } // At this point, the following checks must hold if (startContainer !== endContainer) { throw new Error("internal error in cut: containers unequal"); } if (!domtypeguards_1.isElement(startContainer)) { throw new Error("internal error in cut: not an element"); } const returnNodes = startText === undefined ? [] : [startText]; endOffset--; for (let count = endOffset - startOffset; count >= 0; count--) { returnNodes.push(endContainer.childNodes[startOffset]); // tslint:disable-next-line:no-invalid-this this.deleteNode(endContainer.childNodes[startOffset]); } if (endText != null) { returnNodes.push(endText); } if (endContainer.childNodes[startOffset - 1] != null) { // tslint:disable-next-line:no-invalid-this this.mergeTextNodes(endContainer.childNodes[startOffset - 1]); } return [finalCaret, returnNodes]; } exports.genericCutFunction = genericCutFunction; /** * Copies a well formed region of the DOM tree. * * @param startCaret Start caret position. * * @param endCaret Ending caret position. * * @returns A copy of the contents. * * @throws {Error} If Nodes in the range are not in the same element. */ // tslint:disable-next-line:max-func-body-length function copy(startCaret, endCaret) { // genericCutFunction uses an algorithm similar to the one here and probably // should also be modified if this function is modified. let [startContainer, startOffset] = startCaret; let [endContainer, endOffset] = endCaret; if (!isWellFormedRange({ startContainer, startOffset, endContainer, endOffset })) { throw new Error("range is not well-formed"); } let parent = startContainer.parentNode; if (parent == null) { throw new Error("detached node"); } if (domtypeguards_1.isText(startContainer) && startOffset === 0) { // We are at the start of a text node, move up to the parent. startOffset = indexOf(parent.childNodes, startContainer); startContainer = parent; parent = startContainer.parentNode; if (parent == null) { throw new Error("detached node"); } } let startText; if (domtypeguards_1.isText(startContainer)) { const sameContainer = startContainer === endContainer; const startContainerOffset = indexOf(parent.childNodes, startContainer); const endTextOffset = sameContainer ? endOffset : startContainer.length; startText = parent.ownerDocument.createTextNode(startContainer.data.slice(startOffset, endTextOffset)); if (sameContainer) { // Both the start and end were in the same node, so the deleteText // operation above did everything needed. return [startText]; } startOffset = startContainerOffset + 1; startContainer = parent; } let endText; if (domtypeguards_1.isText(endContainer)) { parent = endContainer.parentNode; if (parent == null) { throw new Error("detached node"); } const endContainerOffset = indexOf(parent.childNodes, endContainer); endText = parent.ownerDocument.createTextNode(endContainer.data.slice(0, endOffset)); // Alter our end to take care of the rest endOffset = endContainerOffset; endContainer = parent; } // At this point, the following checks must hold if (startContainer !== endContainer) { throw new Error("internal error in cut: containers unequal"); } if (!domtypeguards_1.isElement(startContainer)) { throw new Error("internal error in cut: not an element"); } const returnNodes = startText === undefined ? [] : [startText]; endOffset--; while (startOffset <= endOffset) { returnNodes.push(endContainer.childNodes[startOffset++].cloneNode(true)); } if (endText != null) { returnNodes.push(endText); } return returnNodes; } exports.copy = copy; /** * Dumps a range to the console. * * @param msg A message to output in front of the range information. * * @param range The range. */ function dumpRange(msg, range) { if (range == null) { // tslint:disable-next-line:no-console console.log(msg, "no range"); } else { // tslint:disable-next-line:no-console console.log(msg, range.startContainer, range.startOffset, range.endContainer, range.endOffset); } } exports.dumpRange = dumpRange; /** * Dumps the current selection to the console. * * @param msg A message to output in front of the range information. * * @param win The window for which to dump selection information. */ function dumpCurrentSelection(msg, win) { dumpRange(msg, getSelectionRange(win)); } exports.dumpCurrentSelection = dumpCurrentSelection; /** * Dumps a range to a string. * * @param msg A message to output in front of the range information. * * @param range The range. */ function dumpRangeToString(msg, range) { let ret; if (range == null) { ret = [msg, "no range"]; } else { ret = [msg, range.startContainer.outerHTML, range.startOffset, range.endContainer.outerHTML, range.endOffset]; } return ret.join(", "); } exports.dumpRangeToString = dumpRangeToString; /** * Checks whether a point is in the element's contents. This means inside the * element and **not** inside one of the scrollbars that the element may * have. The coordinates passed must be **relative to the document.** If the * coordinates are taken from an event, this means passing ``pageX`` and * ``pageY``. * * @param element The element to check. * * @param x The x coordinate **relative to the document.** * * @param y The y coordinate **relative to the document.** * * @returns ``true`` if inside, ``false`` if not. */ function pointInContents(element, x, y) { // Convert the coordinates relative to the document to coordinates relative to // the element. const body = element.ownerDocument.body; // Using clientLeft and clientTop is not equivalent to using the rect. const rect = element.getBoundingClientRect(); x -= rect.left + body.scrollLeft; y -= rect.top + body.scrollTop; return ((x >= 0) && (y >= 0) && (x < element.clientWidth) && (y < element.clientHeight)); } exports.pointInContents = pointInContents; /** * Starting with the node passed, and walking up the node's * parents, returns the first node that matches the selector. * * @param node The node to start with. * * @param selector The selector to use for matches. * * @param limit The algorithm will search up to this limit, inclusively. * * @returns The first element that matches the selector, or ``null`` if nothing * matches. */ function closest(node, selector, limit) { if (node == null) { return null; } // Immediately move out of text nodes. if (domtypeguards_1.isText(node)) { node = node.parentNode; } while (node != null) { if (!domtypeguards_1.isElement(node)) { return null; } if (node.matches(selector)) { break; } if (node === limit) { node = null; break; } node = node.parentNode; } return node; } exports.closest = closest; /** * Starting with the node passed, and walking up the node's parents, returns the * first element that matches the class. * * @param node The node to start with. * * @param cl The class to use for matches. * * @param limit The algorithm will search up to this limit, inclusively. * * @returns The first element that matches the class, or ``null`` if nothing * matches. */ function closestByClass(node, cl, limit) { if (node == null) { return null; } // Immediately move out of text nodes. if (domtypeguards_1.isText(node)) { node = node.parentNode; } while (node != null) { if (!domtypeguards_1.isElement(node)) { return null; } if (node.classList.contains(cl)) { break; } if (node === limit) { node = null; break; } node = node.parentNode; } return node; } exports.closestByClass = closestByClass; /** * Find a sibling matching the class. * * @param node The element whose sibling we are looking for. * * @param cl The clas