UNPKG

wed

Version:

Wed is a schema-aware editor for XML documents.

455 lines 18.4 kB
/** * Listener for DOM tree changes. * * @author Louis-Dominique Dubeau * @license MPL 2.0 * @copyright Mangalam Research Center for Buddhist Languages */ define(["require", "exports", "./domtypeguards"], function (require, exports, domtypeguards_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); /** * This class models a listener designed to listen to changes to a DOM tree and * fire events on the basis of the changes that it detects. * * An ``included-element`` event is fired when an element appears in the * observed tree whether it is directly added or added because its parent was * added. The opposite events are ``excluding-element`` and * ``excluded-element``. The event ``excluding-element`` is generated *before * the tree fragment is removed, and ``excluded-element`` *after*. * * An ``added-element`` event is fired when an element is directly added to the * observed tree. The opposite events are ``excluding-element`` and * ``removed-element``. * * A ``children-changing`` and ``children-changed`` event are fired when an * element's children are being changed. * * A ``text-changed`` event is fired when a text node has changed. * * An ``attribute-changed`` is fired when an attribute has changed. * * A ``trigger`` event with name ``[name]`` is fired when ``trigger([name])`` is * called. Trigger events are meant to be triggered by event handlers called by * the listener, not by other code. * * <h2>Example</h2> * * Consider the following HTML fragment: * * <ul> * <li>foo</li> * </ul> * * If the fragment is added to a ``<div>`` element, an ``included-element`` * event will be generated for ``<ul>`` and ``<li>`` but an ``added-element`` * event will be generated only for ``<ul>``. A ``changed-children`` event will * be generated for the parent of ``<ul>``. * * If the fragment is removed, an ``excluding-element`` and ``excluded-element`` * event will be generated for ``<ul>`` and ``<li>`` but a ``removing-element`` * and ``remove-element`` event will be generated only for ``<ul>``. A * ``children-changing`` and ``children-changed`` event will be generated for * the parent of ``<ul>``. * * The order in which handlers are added matters. The listener provides the * following guarantee: for any given type of event, the handlers will be called * in the order that they were added to the listener. * * <h2>Warnings:</h2> * * - Keep in mind that the ``children-changed``, ``excluded-element`` and * ``removed-element`` events are generated **after** the DOM operation that * triggers them. This has some consequences. In particular, a selector that * will work perfectly with ``removing-element`` or ``excluding-element`` may * not work with ``removed-element`` and ``excluded-element``. This would * happen if the selector tests for ancestors of the element removed or * excluded. By the time the ``-ed`` events are generated, the element is gone * from the DOM tree and such selectors will fail. * * The ``-ed`` version of these events are still useful. For instance, a wed * mode in use for editing scholarly articles listens for ``excluded-element`` * with a selector that is a tag name so that it can remove references to * these elements when they are removed. Since it does not need anything more * complex then ``excluded-element`` works perfectly. * * - A listener does not verify whether the parameters passed to handlers are * part of the DOM tree. For instance, handler A could operate on element X so * that it is removed from the DOM tree. If there is already another mutation * on X in the pipeline by the time A is called and handler B is called to * deal with it, then by the time B is run X will no longer be part of the * tree. * * To put it differently, even if when an event is generated element X was * part of the DOM tree, it is possible that by the time the handlers that * must be run for that mutation are run, X is no longer part of the DOM tree. * * Handlers that care about whether they are operating on elements that are in * the DOM tree should perform a test themselves to check whether what is * passed to them is still in the tree. * * The handlers fired on removed-elements events work on nodes that have been * removed from the DOM tree. To know what was before and after these nodes * **before** they were removed use events that have ``previous_sibling`` and * ``next_sibling`` parameters, because it is likely that the nodes themselves * will have both their ``previousSibling`` and ``nextSibling`` set to * ``null``. * * - Handlers that are fired on children-changed events, **and** which modify * the DOM tree can easily result in infinite loops. Care should be taken * early in any such handler to verify that the kind of elements added or * removed **should** result in a change to the DOM tree, and ignore those * changes that are not relevant. */ class DOMListener { /** * @param root The root of the DOM tree about which the listener should listen * to changes. */ constructor(root, updater) { this.root = root; this.updater = updater; this.eventHandlers = { "included-element": [], "added-element": [], "excluded-element": [], "excluding-element": [], "removed-element": [], "removing-element": [], "children-changed": [], "children-changing": [], "text-changed": [], "attribute-changed": [], }; this.triggerHandlers = Object.create(null); this.triggersToFire = Object.create(null); this.stopped = true; this.updater.events.subscribe((ev) => { switch (ev.name) { case "InsertNodeAt": this._insertNodeAtHandler(ev); break; case "SetTextNodeValue": this._setTextNodeValueHandler(ev); break; case "BeforeDeleteNode": this._beforeDeleteNodeHandler(ev); break; case "DeleteNode": this._deleteNodeHandler(ev); break; case "SetAttributeNS": this._setAttributeNSHandler(ev); break; default: // Do nothing... } }); } /** * Start listening to changes on the root passed when the object was * constructed. */ startListening() { this.stopped = false; } /** * Stops listening to DOM changes. */ stopListening() { this.stopped = true; } /** * Process all changes immediately. */ processImmediately() { if (this.scheduledProcessTriggers !== undefined) { this.clearPending(); this._processTriggers(); } } /** * Clear anything that is pending. Some implementations may have triggers * delivered asynchronously. */ clearPending() { if (this.scheduledProcessTriggers !== undefined) { window.clearTimeout(this.scheduledProcessTriggers); this.scheduledProcessTriggers = undefined; } } addHandler(eventType, selector, handler) { if (eventType === "trigger") { let handlers = this.triggerHandlers[selector]; if (handlers === undefined) { handlers = this.triggerHandlers[selector] = []; } handlers.push(handler); } else { // As of TS 2.2.2, we need to the type annotation in the next line. const pairs = this.eventHandlers[eventType]; if (pairs === undefined) { throw new Error(`invalid eventType: ${eventType}`); } pairs.push([selector, handler]); } } /** * Tells the listener to fire the named trigger as soon as possible. * * @param {string} name The name of the trigger to fire. */ trigger(name) { this.triggersToFire[name] = 1; } /** * Processes pending triggers. */ _processTriggers() { let keys = Object.keys(this.triggersToFire); while (keys.length > 0) { // We flush the map because the triggers could trigger // more triggers. This also explains why we are in a loop. this.triggersToFire = Object.create(null); const triggerMap = this.triggerHandlers; for (const key of keys) { const handlers = triggerMap[key]; if (handlers !== undefined) { for (const handler of handlers) { this._callHandler(handler); } } } // See whether there is more to trigger. keys = Object.keys(this.triggersToFire); } } /** * Utility function for calling event handlers. * * @param handler The handler. * * @param rest The arguments to pass to the handler. */ // tslint:disable-next-line:no-any _callHandler(handler, ...rest) { rest.unshift(this.root); handler.apply(undefined, rest); } /** * Handles node additions. * * @param ev The event. */ _insertNodeAtHandler(ev) { if (this.stopped) { return; } const parent = ev.parent; const node = ev.node; const ccCalls = this._childrenCalls("children-changed", parent, [node], [], node.previousSibling, node.nextSibling); let arCalls = []; let ieCalls = []; if (domtypeguards_1.isElement(node)) { arCalls = this._addRemCalls("added-element", node, parent); ieCalls = this._incExcCalls("included-element", node, parent); } const toCall = ccCalls.concat(arCalls, ieCalls); for (const call of toCall) { this._callHandler.call(this, call.fn, ...call.params); } this._scheduleProcessTriggers(); } /** * Handles node deletions. * * @param ev The event. */ _beforeDeleteNodeHandler(ev) { if (this.stopped) { return; } const node = ev.node; const parent = node.parentNode; const ccCalls = this._childrenCalls("children-changing", parent, [], [node], node.previousSibling, node.nextSibling); let arCalls = []; let ieCalls = []; if (domtypeguards_1.isElement(node)) { arCalls = this._addRemCalls("removing-element", node, parent); ieCalls = this._incExcCalls("excluding-element", node, parent); } const toCall = ccCalls.concat(arCalls, ieCalls); for (const call of toCall) { this._callHandler.call(this, call.fn, ...call.params); } this._scheduleProcessTriggers(); } /** * Handles node deletion events. * * @param ev The event. */ _deleteNodeHandler(ev) { if (this.stopped) { return; } const node = ev.node; const parent = ev.formerParent; const ccCalls = this._childrenCalls("children-changed", parent, [], [node], null, null); let arCalls = []; let ieCalls = []; if (domtypeguards_1.isElement(node)) { arCalls = this._addRemCalls("removed-element", node, parent); ieCalls = this._incExcCalls("excluded-element", node, parent); } const toCall = ccCalls.concat(arCalls, ieCalls); for (const call of toCall) { this._callHandler.call(this, call.fn, ...call.params); } this._scheduleProcessTriggers(); } /** * Produces the calls for ``children-...`` events. * * @param call The type of call to produce. * * @param parent The parent of the children that have changed. * * @param added The children that were added. * * @param removed The children that were removed. * * @param prev Node preceding the children. * * @param next Node following the children. * * @returns A list of call specs. */ _childrenCalls(call, parent, added, removed, prev, next) { if (added.length !== 0 && removed.length !== 0) { throw new Error("we do not support having nodes added " + "and removed in the same event"); } const pairs = this.eventHandlers[call]; const ret = []; // Go over all the elements for which we have handlers for (const [sel, fn] of pairs) { if (parent.matches(sel)) { ret.push({ fn, params: [added, removed, prev, next, parent] }); } } return ret; } /** * Handles text node changes events. * * @param ev The event. */ _setTextNodeValueHandler(ev) { if (this.stopped) { return; } const pairs = this.eventHandlers["text-changed"]; const node = ev.node; // Go over all the elements for which we have // handlers const parent = node.parentNode; for (const [sel, fn] of pairs) { if (parent.matches(sel)) { this._callHandler(fn, node, ev.oldValue); } } this._scheduleProcessTriggers(); } /** * Handles attribute change events. * * @param ev The event. */ _setAttributeNSHandler(ev) { if (this.stopped) { return; } const target = ev.node; // Go over all the elements for which we have handlers const pairs = this.eventHandlers["attribute-changed"]; for (const [sel, fn] of pairs) { if (target.matches(sel)) { this._callHandler(fn, target, ev.ns, ev.attribute, ev.oldValue); } } this._scheduleProcessTriggers(); } /** * Sets a timeout to run the triggers that must be run. */ _scheduleProcessTriggers() { if (this.scheduledProcessTriggers !== undefined) { return; } this.scheduledProcessTriggers = window.setTimeout(() => { this.scheduledProcessTriggers = undefined; this._processTriggers(); }, 0); } /** * Produces the calls for the added/removed family of events. * * @param name The event name. * * @param node The node added or removed. * * @param target The parent of this node. * * @returns A list of call specs. */ _addRemCalls(name, node, target) { const pairs = this.eventHandlers[name]; const ret = []; const prev = node.previousSibling; const next = node.nextSibling; // Go over all the elements for which we have handlers for (const [sel, fn] of pairs) { if (node.matches(sel)) { ret.push({ fn, params: [target, prev, next, node] }); } } return ret; } /** * Produces the calls for included/excluded family of events. * * @param name The event name. * * @param node The node which was included or excluded and for which we must * issue the events. * * @param target The parent of this node. * * @returns A list of call specs. */ _incExcCalls(name, node, target) { const pairs = this.eventHandlers[name]; const prev = node.previousSibling; const next = node.nextSibling; const ret = []; // Go over all the elements for which we have handlers for (const [sel, fn] of pairs) { if (node.matches(sel)) { ret.push({ fn, params: [node, target, prev, next, node] }); } const targets = node.querySelectorAll(sel); for (const subtarget of Array.prototype.slice.call(targets)) { ret.push({ fn, params: [node, target, prev, next, subtarget] }); } } return ret; } } exports.DOMListener = DOMListener; }); // LocalWords: eventType SetAttributeNS DeleteNode BeforeDeleteNode ul li MPL // LocalWords: SetTextNodeValue nextSibling InsertNodeAt previousSibling DOM // LocalWords: Dubeau Mangalam //# sourceMappingURL=domlistener.js.map