wed
Version:
Wed is a schema-aware editor for XML documents.
650 lines • 27.4 kB
JavaScript
/**
* Facility for updating a DOM tree and issue synchronous events on changes.
* @author Louis-Dominique Dubeau
* @license MPL 2.0
* @copyright Mangalam Research Center for Buddhist Languages
*/
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", "rxjs", "./dloc", "./domtypeguards", "./domutil"], function (require, exports, rxjs_1, dloc_1, domtypeguards_1, domutil) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
domutil = __importStar(domutil);
const indexOf = domutil.indexOf;
/**
* A TreeUpdater is meant to serve as the sole point of modification for a DOM
* tree. As methods are invoked on the TreeUpdater to modify the tree, events
* are issued synchronously, which allows a listener to know what is happening
* on the tree.
*
* Methods are divided into primitive and complex methods. Primitive methods
* perform one and only one modification and issue an event of the same name as
* their own name. Complex methods use primitive methods to perform a series of
* modifications on the tree. Or they delegate the actual modification work to
* the primitive methods. They may emit one or more events of a name different
* from their own name. Events are emitted **after** their corresponding
* operation is performed on the tree.
*
* For primitive methods, the list of events which they are documented to be
* firing is exhaustive. For complex methods, the list is not exhaustive.
*
* Many events have a name identical to a corresponding method. Such events are
* accompanied by event objects which have the same properties as the parameters
* of the corresponding method, with the same meaning. Therefore, their
* properties are not further documented.
*
* There is a generic [[ChangedEvent]] that is emitted with every other
* event. This event does not carry information about what changed exactly.
*
* The [[TreeUpdater.deleteNode]] operation is the one major exception to the
* basic rules given above:
*
* - [[BeforeDeleteNodeEvent]] is emitted **before** the deletion is
* performed. This allows performing operations based on the node's location
* before it is removed. For instance, calling the DOM method ``matches`` on a
* node that has been removed from its DOM tree is generally going to fail to
* perform the intended check.
*
* - [[DeleteNodeEvent]] has the additional ``formerParent`` property.
*
* ``DocumentFragment`` have special handling. Although the methods below that
* insert new content into the tree accept ``DocumentFragment`` nodes, the tree
* updater inserts the *contents* of the fragment rather than the fragment
* itself. Or to put it differently, inserting a fragment is equivalent to
* iterating through the fragment's children and inserting each one in order. A
* fragment is essentially equivalent to an array.
*/
class TreeUpdater {
/**
* @param tree The node which contains the tree to update.
*/
constructor(tree) {
this.tree = tree;
const root = dloc_1.findRoot(tree);
if (root === undefined) {
throw new Error("the tree must have a DLocRoot");
}
this.dlocRoot = root;
this._events = new rxjs_1.Subject();
this.events = this._events.asObservable();
}
_emit(event) {
this._events.next(event);
this._events.next({ name: "Changed" });
}
insertAt(loc, offset, what) {
let parent;
let index;
if (loc instanceof dloc_1.DLoc) {
parent = loc.node;
index = loc.offset;
if (typeof offset === "number") {
throw new Error("incorrect call on insertAt: offset cannot be a number");
}
what = offset;
}
else {
parent = loc;
if (typeof offset !== "number") {
throw new Error("incorrect call on insertAt: offset must be a number");
}
index = offset;
}
if (what instanceof Array || what instanceof NodeList) {
for (let i = 0; i < what.length; ++i, ++index) {
const item = what[i];
if (!(typeof item === "string" || domtypeguards_1.isElement(item) || domtypeguards_1.isText(item))) {
throw new Error("Array or NodeList element of the wrong type");
}
this.insertAt(parent, index, item);
}
}
else if (typeof what === "string") {
this.insertText(parent, index, what);
}
else if (domtypeguards_1.isText(what)) {
switch (parent.nodeType) {
case Node.TEXT_NODE:
this.insertText(parent, index, what.data);
break;
case Node.ELEMENT_NODE:
this.insertNodeAt(parent, index, what);
break;
default:
throw new Error(`unexpected node type: ${parent.nodeType}`);
}
}
else if (domtypeguards_1.isElement(what) || domtypeguards_1.isDocumentFragment(what)) {
switch (parent.nodeType) {
case Node.TEXT_NODE:
this.insertIntoText(parent, index, what);
break;
case Node.DOCUMENT_NODE:
case Node.ELEMENT_NODE:
this.insertNodeAt(parent, index, what);
break;
default:
throw new Error(`unexpected node type: ${parent.nodeType}`);
}
}
else {
throw new Error(`unexpected value for what: ${what}`);
}
}
splitAt(top, loc, index) {
let node;
if (loc instanceof dloc_1.DLoc) {
node = loc.node;
index = loc.offset;
}
else {
node = loc;
}
if (index === undefined) {
throw new Error("splitAt was called with undefined index");
}
if (node === top && node.nodeType === Node.TEXT_NODE) {
throw new Error("splitAt called in a way that would result in " +
"two adjacent text nodes");
}
if (!top.contains(node)) {
throw new Error("split location is not inside top");
}
const clonedTop = top.cloneNode(true);
const clonedNode = domutil.correspondingNode(top, clonedTop, node);
const pair = this._splitAt(clonedTop, clonedNode, index);
const [first, second] = pair;
const parent = top.parentNode;
if (parent === null) {
throw new Error("called with detached top");
}
const at = indexOf(parent.childNodes, top);
this.deleteNode(top);
if (first !== null) {
this.insertNodeAt(parent, at, first);
}
if (second !== null) {
this.insertNodeAt(parent, at + 1, second);
}
return pair;
}
/**
* Splits a DOM tree into two halves.
*
* @param top The node at which the splitting operation should end. This node
* will be split but the function won't split anything above this node.
*
* @param node The node at which to start.
*
* @param index The index at which to start in the node.
*
* @returns An array containing in order the first and second half of the
* split.
*/
_splitAt(top, node, index) {
// We need to check this now because some operations below may remove node
// from the DOM tree.
const stop = (node === top);
const parent = node.parentNode;
let ret;
if (domtypeguards_1.isText(node)) {
if (index === 0) {
ret = [null, node];
}
else if (index === node.length) {
ret = [node, null];
}
else {
const textAfter = node.data.slice(index);
node.deleteData(index, node.length - index);
if (parent !== null) {
parent.insertBefore(parent.ownerDocument.createTextNode(textAfter), node.nextSibling);
}
ret = [node, node.nextSibling];
}
}
else if (domtypeguards_1.isElement(node)) {
if (index < 0) {
index = 0;
}
else if (index > node.childNodes.length) {
index = node.childNodes.length;
}
const clone = node.cloneNode(true);
// Remove all nodes at index and after.
while (node.childNodes[index] != null) {
node.removeChild(node.childNodes[index]);
}
// Remove all nodes before index
while (index-- !== 0) {
clone.removeChild(clone.firstChild);
}
if (parent !== null) {
parent.insertBefore(clone, node.nextSibling);
}
ret = [node, clone];
}
else {
throw new Error(`unexpected node type: ${node.nodeType}`);
}
if (stop) { // We've just split the top, so end here...
return ret;
}
if (parent === null) {
throw new Error("unable to reach the top");
}
return this._splitAt(top, parent, indexOf(parent.childNodes, node) + 1);
}
/**
* A complex method. Inserts the specified item before another one. Note that
* the order of operands is the same as for the ``insertBefore`` DOM method.
*
* @param parent The node that contains the two other parameters.
*
* @param toInsert The node to insert.
*
* @param beforeThis The node in front of which to insert. A value of
* ``null`` results in appending to the parent node.
*
* @throws {Error} If ``beforeThis`` is not a child of ``parent``.
*/
insertBefore(parent, toInsert, beforeThis) {
// Convert it to an insertAt operation.
const index = beforeThis == null ? parent.childNodes.length :
indexOf(parent.childNodes, beforeThis);
if (index === -1) {
throw new Error("insertBefore called with a beforeThis value " +
"which is not a child of parent");
}
this.insertAt(parent, index, toInsert);
}
insertText(loc, index, text = true, caretAtEnd = true) {
let node;
if (loc instanceof dloc_1.DLoc) {
if (typeof index !== "string") {
throw new Error("text must be a string");
}
if (typeof text !== "boolean") {
throw new Error("caretAtEnd must be a boolean");
}
caretAtEnd = text;
text = index;
node = loc.node;
index = loc.offset;
}
else {
node = loc;
}
const result = domutil.genericInsertText.call(this, node, index, text, caretAtEnd);
return Object.assign({}, result, { caret: dloc_1.DLoc.makeDLoc(this.dlocRoot, result.caret[0], result.caret[1]) });
}
deleteText(loc, index, length) {
let node;
if (loc instanceof dloc_1.DLoc) {
length = index;
node = loc.node;
index = loc.offset;
}
else {
node = loc;
if (length === undefined) {
throw new Error("length cannot be undefined");
}
}
if (!domtypeguards_1.isText(node)) {
throw new Error("deleteText called on non-text");
}
this.setTextNode(node, node.data.slice(0, index) +
node.data.slice(index + length));
}
insertIntoText(loc, index, node) {
let parent;
if (loc instanceof dloc_1.DLoc) {
if (!domtypeguards_1.isNode(index)) {
throw new Error("must pass a node as the 2nd argument");
}
node = index;
index = loc.offset;
parent = loc.node;
}
else {
parent = loc;
}
// genericInsertIntoTextContext handles inserting fragments.
const ret = domutil.genericInsertIntoText.call(this, parent, index, node);
return [dloc_1.DLoc.mustMakeDLoc(this.tree, ret[0]),
dloc_1.DLoc.mustMakeDLoc(this.tree, ret[1])];
}
insertNodeAt(loc, index, node) {
let parent;
if (loc instanceof dloc_1.DLoc) {
if (!domtypeguards_1.isNode(index)) {
throw new Error("the 2nd argument must be a Node");
}
node = index;
index = loc.offset;
parent = loc.node;
}
else {
parent = loc;
if (typeof index !== "number") {
throw new Error("index must be a number");
}
}
if (node == null) {
throw new Error("called insertNodeAt with absent node");
}
if (domtypeguards_1.isDocumentFragment(node)) {
while (node.firstChild != null) {
this.insertNodeAt(parent, index++, node.firstChild);
}
return;
}
this._emit({ name: "BeforeInsertNodeAt", parent, index, node });
const child = parent.childNodes[index];
parent.insertBefore(node, child != null ? child : null);
this._emit({ name: "InsertNodeAt", parent, index, node });
}
/**
* A complex method. Sets a text node to a specified value.
*
* @param node The node to modify.
*
* @param value The new value of the node.
*
* @throws {Error} If called on a non-text Node type.
*/
setTextNode(node, value) {
if (!domtypeguards_1.isText(node)) {
throw new Error("setTextNode called on non-text");
}
if (value !== "") {
this.setTextNodeValue(node, value);
}
else {
this.deleteNode(node);
}
}
/**
* A primitive method. Sets a text node to a specified value. This method must
* not be called directly by code that performs changes of the DOM tree at a
* high level, because it does not prevent a text node from becoming
* empty. Call [[TreeUpdater.setTextNode]] instead. This method is meant to be
* used by other complex methods of TreeUpdater and by some low-level
* facilities of wed.
*
* @param node The node to modify. Must be a text node.
*
* @param value The new value of the node.
*
* @emits SetTextNodeValueEvent
* @emits ChangedEvent
* @throws {Error} If called on a non-text Node type.
*/
setTextNodeValue(node, value) {
if (!domtypeguards_1.isText(node)) {
throw new Error("setTextNodeValue called on non-text");
}
const oldValue = node.data;
node.data = value;
this._emit({ name: "SetTextNodeValue", node, value, oldValue });
}
/**
* A complex method. Removes a node from the DOM tree. If two text nodes
* become adjacent, they are merged.
*
* @param node The node to remove. This method will fail with an exception if
* this parameter is ``undefined`` or ``null``. Use [[removeNodeNF]] if you
* want a method that will silently do nothing if ``undefined`` or ``null``
* are expected values.
*
* @returns A location between the two parts that were merged, or between the
* two nodes that were not merged (because they were not both text).
*/
removeNode(node) {
if (node == null) {
throw new Error("called without a node value");
}
const prev = node.previousSibling;
const parent = node.parentNode;
if (parent === null) {
throw new Error("called with detached node");
}
const ix = indexOf(parent.childNodes, node);
this.deleteNode(node);
if (prev === null) {
return dloc_1.DLoc.mustMakeDLoc(this.tree, parent, ix);
}
return this.mergeTextNodes(prev);
}
/**
* A complex method. Removes a node from the DOM tree. If two text nodes
* become adjacent, they are merged.
*
* @param node The node to remove. This method will do nothing if the node to
* remove is ``undefined`` or ``null``.
*
* @returns A location between the two parts that were merged, or between the
* two nodes that were not merged (because they were not both text). This will
* be ``undefined`` if there was no node to remove.
*/
removeNodeNF(node) {
if (node == null) {
return undefined;
}
return this.removeNode(node);
}
/**
* A complex method. Removes a list of nodes from the DOM tree. If two text
* nodes become adjacent, they are merged.
*
* @param nodes These nodes must be immediately contiguous siblings in
* document order.
*
* @returns The location between the two parts that were merged, or between
* the two nodes that were not merged (because they were not both
* text). Undefined if the list of nodes is empty.
*
* @throws {Error} If nodes are not contiguous siblings.
*/
removeNodes(nodes) {
if (nodes.length === 0) {
return undefined;
}
const prev = nodes[0].previousSibling;
const parent = nodes[0].parentNode;
if (parent === null) {
throw new Error("called with detached node");
}
const ix = indexOf(parent.childNodes, nodes[0]);
for (let i = 0; i < nodes.length; ++i) {
if (i < nodes.length - 1 && nodes[i].nextSibling !== nodes[i + 1]) {
throw new Error("nodes are not immediately contiguous in " +
"document order");
}
this.deleteNode(nodes[i]);
}
if (prev === null) {
return dloc_1.DLoc.makeDLoc(this.tree, parent, ix);
}
return this.mergeTextNodes(prev);
}
/**
* A complex method. Removes the contents between the start and end carets
* from the DOM tree. If two text nodes become adjacent, they are merged.
*
* @param start The start position.
*
* @param end The end position.
*
* @returns A pair of items. The first item is a ``DLoc`` object indicating
* the position 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.
*/
cut(start, end) {
const ret = domutil.genericCutFunction.call(this, start.toArray(), end.toArray());
ret[0] = start.make(ret[0]);
return ret;
}
/**
* A complex method. If the node is a text node and followed by a text node,
* this method will combine them.
*
* @param node The node to check. This method will fail with an exception if
* this parameter is ``undefined`` or ``null``. Use [[mergeTextNodesNF]] if
* you want a method that will silently do nothing if ``undefined`` or
* ``null`` are expected values.
*
* @returns A position between the two parts that were merged, or between the
* two nodes that were not merged (because they were not both text).
*/
mergeTextNodes(node) {
const next = node.nextSibling;
if (domtypeguards_1.isText(node) && next !== null && domtypeguards_1.isText(next)) {
const offset = node.length;
this.setTextNodeValue(node, node.data + next.data);
this.deleteNode(next);
return dloc_1.DLoc.mustMakeDLoc(this.tree, node, offset);
}
const parent = node.parentNode;
if (parent === null) {
throw new Error("called with detached node");
}
return dloc_1.DLoc.mustMakeDLoc(this.tree, parent, indexOf(parent.childNodes, node) + 1);
}
/**
* A complex method. If the node is a text node and followed by a text node,
* this method will combine them.
*
* @param node The node to check. This method will do nothing if the node to
* remove is ``undefined`` or ``null``.
*
* @returns A position between the two parts that were merged, or between the
* two nodes that were not merged (because they were not both text). This will
* be ``undefined`` if there was no node to remove.
*/
mergeTextNodesNF(node) {
if (node == null) {
return undefined;
}
return this.mergeTextNodes(node);
}
/**
* A primitive method. Removes a node from the DOM tree. This method must not
* be called directly by code that performs changes of the DOM tree at a high
* level, because it does not prevent two text nodes from being contiguous
* after deletion of the node. Call [[removeNode]] instead. This method is
* meant to be used by other complex methods of TreeUpdater and by some
* low-level facilities of wed.
*
* @param node The node to remove
*
* @emits DeleteNodeEvent
* @emits BeforeDeleteNodeEvent
* @emits ChangedEvent
*/
deleteNode(node) {
this._emit({ name: "BeforeDeleteNode", node: node });
// The following is functionally equivalent to $(node).detach(), which is
// what we want.
const parent = node.parentNode;
if (parent === null) {
throw new Error("called with detached node");
}
parent.removeChild(node);
this._emit({ name: "DeleteNode", node, formerParent: parent });
}
/**
* A complex method. Sets an attribute to a value. Setting to the value
* ``null`` or ``undefined`` deletes the attribute. This method sets
* attributes outside of any namespace.
*
* @param node The node to modify.
*
* @param attribute The name of the attribute to modify.
*
* @param value The value to give to the attribute.
*
* @emits SetAttributeNSEvent
* @emits ChangedEvent
*/
setAttribute(node, attribute, value) {
this.setAttributeNS(node, "", attribute, value);
}
/**
* A primitive method. Sets an attribute to a value. Setting to the value
* ``null`` or ``undefined`` deletes the attribute.
*
* @param node The node to modify.
*
* @param ns The URI of the namespace of the attribute.
*
* @param attribute The name of the attribute to modify.
*
* @param value The value to give to the attribute.
*
* @emits SetAttributeNSEvent
* @emits ChangedEvent
*/
setAttributeNS(node, ns, attribute, value) {
// Normalize to null.
if (value === undefined) {
value = null;
}
if (!domtypeguards_1.isElement(node)) {
throw new Error("setAttribute called on non-element");
}
let oldValue = node.getAttributeNS(ns, attribute);
// Chrome 32 returns an empty string if the attribute is not present, so
// normalize.
if (oldValue === "" && !node.hasAttributeNS(ns, attribute)) {
oldValue = null;
}
if (value != null) {
node.setAttributeNS(ns, attribute, value);
}
else {
node.removeAttributeNS(ns, attribute);
}
this._emit({ name: "SetAttributeNS", node, ns, attribute, oldValue,
newValue: value });
}
/**
* Converts a node to a path.
*
* @param node The node for which to return a path.
*
* @returns The path of the node relative to the root of the tree we are
* updating.
*/
nodeToPath(node) {
return this.dlocRoot.nodeToPath(node);
}
/**
* Converts a path to a node.
*
* @param path The path to convert.
*
* @returns The node corresponding to the path passed.
*/
pathToNode(path) {
return this.dlocRoot.pathToNode(path);
}
}
exports.TreeUpdater = TreeUpdater;
});
// LocalWords: domutil splitAt insertAt insertText insertBefore deleteText cd
// LocalWords: removeNode setTextNodeValue param TreeUpdater insertNodeAt MPL
// LocalWords: abcd abfoocd setTextNode deleteNode pathToNode nodeToPath prev
// LocalWords: insertIntoText mergeTextNodes nextSibling previousSibling DOM
// LocalWords: Dubeau Mangalam BeforeInsertNodeAt BeforeDeleteNode DLocRoot
// LocalWords: SetAttributeNS NodeList nodeType beforeThis nd setAttribute
// LocalWords: caretAtEnd
//# sourceMappingURL=tree-updater.js.map