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