wed
Version:
Wed is a schema-aware editor for XML documents.
1,256 lines • 63.7 kB
JavaScript
/**
* 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