/**
 * 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;
            }
            // index and text are guaranteed to be the right types at this point.
            const result = domutil.genericInsertText.call(this, node, index, text, caretAtEnd);
            return Object.assign({}, result, { caret: dloc_1.DLoc.mustMakeDLoc(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());
            return [start.make(ret[0]), ret[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 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