michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public
michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this
michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
michael@0:
michael@0: /**
michael@0: * Template mechanism based on Object Emitters.
michael@0: *
michael@0: * The data used to expand the templates comes from
michael@0: * a ObjectEmitter object. The templates are automatically
michael@0: * updated as the ObjectEmitter is updated (via the "set"
michael@0: * event). See documentation in observable-object.js.
michael@0: *
michael@0: * Templates are used this way:
michael@0: *
michael@0: * (See examples in browser/devtools/app-manager/content/*.xhtml)
michael@0: *
michael@0: *
michael@0: *
michael@0: * {
michael@0: * type: "attribute"
michael@0: * name: name of the attribute
michael@0: * path: location of the attribute value in the ObjectEmitter
michael@0: * }
michael@0: *
michael@0: * {
michael@0: * type: "textContent"
michael@0: * path: location of the textContent value in the ObjectEmitter
michael@0: * }
michael@0: *
michael@0: * {
michael@0: * type: "localizedContent"
michael@0: * paths: array of locations of the value of the arguments of the property
michael@0: * property: l10n property
michael@0: * }
michael@0: *
michael@0: *
michael@0: *
michael@0: * {
michael@0: * arrayPath: path of the array in the ObjectEmitter to loop from
michael@0: * childSelector: selector of the element to duplicate in the loop
michael@0: * }
michael@0: *
michael@0: */
michael@0:
michael@0: const NOT_FOUND_STRING = "n/a";
michael@0:
michael@0: /**
michael@0: * let t = new Template(root, store, l10nResolver);
michael@0: * t.start();
michael@0: *
michael@0: * @param DOMNode root.
michael@0: * Node from where templates are expanded.
michael@0: * @param ObjectEmitter store.
michael@0: * ObjectEmitter object.
michael@0: * @param function (property, args). l10nResolver
michael@0: * A function that returns localized content.
michael@0: */
michael@0: function Template(root, store, l10nResolver) {
michael@0: this._store = store;
michael@0: this._rootResolver = new Resolver(this._store.object);
michael@0: this._l10n = l10nResolver;
michael@0:
michael@0: // Listeners are stored in Maps.
michael@0: // path => Set(node1, node2, ..., nodeN)
michael@0: // For example: "foo.bar.4.name" => Set(div1,div2)
michael@0:
michael@0: this._nodeListeners = new Map();
michael@0: this._loopListeners = new Map();
michael@0: this._forListeners = new Map();
michael@0: this._root = root;
michael@0: this._doc = this._root.ownerDocument;
michael@0:
michael@0: this._queuedNodeRegistrations = [];
michael@0:
michael@0: this._storeChanged = this._storeChanged.bind(this);
michael@0: this._store.on("set", this._storeChanged);
michael@0: }
michael@0:
michael@0: Template.prototype = {
michael@0: start: function() {
michael@0: this._processTree(this._root);
michael@0: this._registerQueuedNodes();
michael@0: },
michael@0:
michael@0: destroy: function() {
michael@0: this._store.off("set", this._storeChanged);
michael@0: this._root = null;
michael@0: this._doc = null;
michael@0: },
michael@0:
michael@0: _storeChanged: function(event, path, value) {
michael@0:
michael@0: // The store has changed (a "set" event has been emitted).
michael@0: // We need to invalidate and rebuild the affected elements.
michael@0:
michael@0: let strpath = path.join(".");
michael@0: this._invalidate(strpath);
michael@0:
michael@0: for (let [registeredPath, set] of this._nodeListeners) {
michael@0: if (strpath != registeredPath &&
michael@0: registeredPath.indexOf(strpath) > -1) {
michael@0: this._invalidate(registeredPath);
michael@0: }
michael@0: }
michael@0:
michael@0: this._registerQueuedNodes();
michael@0: },
michael@0:
michael@0: _invalidate: function(path) {
michael@0: // Loops:
michael@0: let set = this._loopListeners.get(path);
michael@0: if (set) {
michael@0: for (let elt of set) {
michael@0: this._processLoop(elt);
michael@0: }
michael@0: }
michael@0:
michael@0: // For:
michael@0: set = this._forListeners.get(path);
michael@0: if (set) {
michael@0: for (let elt of set) {
michael@0: this._processFor(elt);
michael@0: }
michael@0: }
michael@0:
michael@0: // Nodes:
michael@0: set = this._nodeListeners.get(path);
michael@0: if (set) {
michael@0: for (let elt of set) {
michael@0: this._processNode(elt);
michael@0: }
michael@0: }
michael@0: },
michael@0:
michael@0: // Delay node registration until the last step of starting / updating the UI.
michael@0: // This allows us to avoid doing double work in _storeChanged where the first
michael@0: // call to |_invalidate| registers new nodes, which would then be visited a
michael@0: // second time when it iterates over node listeners.
michael@0: _queueNodeRegistration: function(path, element) {
michael@0: this._queuedNodeRegistrations.push([path, element]);
michael@0: },
michael@0:
michael@0: _registerQueuedNodes: function() {
michael@0: for (let [path, element] of this._queuedNodeRegistrations) {
michael@0: // We map a node to a path.
michael@0: // If the value behind this path is updated,
michael@0: // we get notified from the ObjectEmitter,
michael@0: // and then we know which objects to update.
michael@0: if (!this._nodeListeners.has(path)) {
michael@0: this._nodeListeners.set(path, new Set());
michael@0: }
michael@0: let set = this._nodeListeners.get(path);
michael@0: set.add(element);
michael@0: }
michael@0: this._queuedNodeRegistrations.length = 0;
michael@0: },
michael@0:
michael@0: _unregisterNodes: function(nodes) {
michael@0: for (let e of nodes) {
michael@0: for (let registeredPath of e.registeredPaths) {
michael@0: let set = this._nodeListeners.get(registeredPath);
michael@0: if (!set) {
michael@0: continue;
michael@0: }
michael@0: set.delete(e);
michael@0: if (set.size === 0) {
michael@0: this._nodeListeners.delete(registeredPath);
michael@0: }
michael@0: }
michael@0: e.registeredPaths = null;
michael@0: }
michael@0: },
michael@0:
michael@0: _registerLoop: function(path, element) {
michael@0: if (!this._loopListeners.has(path)) {
michael@0: this._loopListeners.set(path, new Set());
michael@0: }
michael@0: let set = this._loopListeners.get(path);
michael@0: set.add(element);
michael@0: },
michael@0:
michael@0: _registerFor: function(path, element) {
michael@0: if (!this._forListeners.has(path)) {
michael@0: this._forListeners.set(path, new Set());
michael@0: }
michael@0: let set = this._forListeners.get(path);
michael@0: set.add(element);
michael@0: },
michael@0:
michael@0: _processNode: function(element, resolver=this._rootResolver) {
michael@0: // The actual magic.
michael@0: // The element has a template attribute.
michael@0: // The value is supposed to be a JSON string.
michael@0: // resolver is a helper object that is used to retrieve data
michael@0: // from the template's data store, give the current path into
michael@0: // the data store, or descend down another level of the store.
michael@0: // See the Resolver object below.
michael@0:
michael@0: let e = element;
michael@0: let str = e.getAttribute("template");
michael@0:
michael@0: try {
michael@0: let json = JSON.parse(str);
michael@0: // Sanity check
michael@0: if (!("type" in json)) {
michael@0: throw new Error("missing property");
michael@0: }
michael@0: if (json.rootPath) {
michael@0: // If node has been generated through a loop, we stored
michael@0: // previously its rootPath.
michael@0: resolver = this._rootResolver.descend(json.rootPath);
michael@0: }
michael@0:
michael@0: // paths is an array that will store all the paths we needed
michael@0: // to expand the node. We will then, via
michael@0: // _registerQueuedNodes, link this element to these paths.
michael@0:
michael@0: let paths = [];
michael@0:
michael@0: switch (json.type) {
michael@0: case "attribute": {
michael@0: if (!("name" in json) ||
michael@0: !("path" in json)) {
michael@0: throw new Error("missing property");
michael@0: }
michael@0: e.setAttribute(json.name, resolver.get(json.path, NOT_FOUND_STRING));
michael@0: paths.push(resolver.rootPathTo(json.path));
michael@0: break;
michael@0: }
michael@0: case "textContent": {
michael@0: if (!("path" in json)) {
michael@0: throw new Error("missing property");
michael@0: }
michael@0: e.textContent = resolver.get(json.path, NOT_FOUND_STRING);
michael@0: paths.push(resolver.rootPathTo(json.path));
michael@0: break;
michael@0: }
michael@0: case "localizedContent": {
michael@0: if (!("property" in json) ||
michael@0: !("paths" in json)) {
michael@0: throw new Error("missing property");
michael@0: }
michael@0: let params = json.paths.map((p) => {
michael@0: paths.push(resolver.rootPathTo(p));
michael@0: let str = resolver.get(p, NOT_FOUND_STRING);
michael@0: return str;
michael@0: });
michael@0: e.textContent = this._l10n(json.property, params);
michael@0: break;
michael@0: }
michael@0: }
michael@0: if (resolver !== this._rootResolver) {
michael@0: // We save the rootPath if any.
michael@0: json.rootPath = resolver.path;
michael@0: e.setAttribute("template", JSON.stringify(json));
michael@0: }
michael@0: if (paths.length > 0) {
michael@0: for (let path of paths) {
michael@0: this._queueNodeRegistration(path, e);
michael@0: }
michael@0: }
michael@0: // Store all the paths on the node, to speed up unregistering later
michael@0: e.registeredPaths = paths;
michael@0: } catch(exception) {
michael@0: console.error("Invalid template: " + e.outerHTML + " (" + exception + ")");
michael@0: }
michael@0: },
michael@0:
michael@0: _processLoop: function(element, resolver=this._rootResolver) {
michael@0: // The element has a template-loop attribute.
michael@0: // The related path must be an array. We go
michael@0: // through the array, and build one child per
michael@0: // item. The template for this child is pointed
michael@0: // by the childSelector property.
michael@0: let e = element;
michael@0: try {
michael@0: let template, count;
michael@0: let str = e.getAttribute("template-loop");
michael@0: let json = JSON.parse(str);
michael@0: if (!("arrayPath" in json) ||
michael@0: !("childSelector" in json)) {
michael@0: throw new Error("missing property");
michael@0: }
michael@0: let descendedResolver = resolver.descend(json.arrayPath);
michael@0: let templateParent = this._doc.querySelector(json.childSelector);
michael@0: if (!templateParent) {
michael@0: throw new Error("can't find child");
michael@0: }
michael@0: template = this._doc.createElement("div");
michael@0: template.innerHTML = templateParent.innerHTML;
michael@0: template = template.firstElementChild;
michael@0: let array = descendedResolver.get("", []);
michael@0: if (!Array.isArray(array)) {
michael@0: console.error("referenced array is not an array");
michael@0: }
michael@0: count = array.length;
michael@0:
michael@0: let fragment = this._doc.createDocumentFragment();
michael@0: for (let i = 0; i < count; i++) {
michael@0: let node = template.cloneNode(true);
michael@0: this._processTree(node, descendedResolver.descend(i));
michael@0: fragment.appendChild(node);
michael@0: }
michael@0: this._registerLoop(descendedResolver.path, e);
michael@0: this._registerLoop(descendedResolver.rootPathTo("length"), e);
michael@0: this._unregisterNodes(e.querySelectorAll("[template]"));
michael@0: e.innerHTML = "";
michael@0: e.appendChild(fragment);
michael@0: } catch(exception) {
michael@0: console.error("Invalid template: " + e.outerHTML + " (" + exception + ")");
michael@0: }
michael@0: },
michael@0:
michael@0: _processFor: function(element, resolver=this._rootResolver) {
michael@0: let e = element;
michael@0: try {
michael@0: let template;
michael@0: let str = e.getAttribute("template-for");
michael@0: let json = JSON.parse(str);
michael@0: if (!("path" in json) ||
michael@0: !("childSelector" in json)) {
michael@0: throw new Error("missing property");
michael@0: }
michael@0:
michael@0: if (!json.path) {
michael@0: // Nothing to show.
michael@0: this._unregisterNodes(e.querySelectorAll("[template]"));
michael@0: e.innerHTML = "";
michael@0: return;
michael@0: }
michael@0:
michael@0: let descendedResolver = resolver.descend(json.path);
michael@0: let templateParent = this._doc.querySelector(json.childSelector);
michael@0: if (!templateParent) {
michael@0: throw new Error("can't find child");
michael@0: }
michael@0: let content = this._doc.createElement("div");
michael@0: content.innerHTML = templateParent.innerHTML;
michael@0: content = content.firstElementChild;
michael@0:
michael@0: this._processTree(content, descendedResolver);
michael@0:
michael@0: this._unregisterNodes(e.querySelectorAll("[template]"));
michael@0: this._registerFor(descendedResolver.path, e);
michael@0:
michael@0: e.innerHTML = "";
michael@0: e.appendChild(content);
michael@0:
michael@0: } catch(exception) {
michael@0: console.error("Invalid template: " + e.outerHTML + " (" + exception + ")");
michael@0: }
michael@0: },
michael@0:
michael@0: _processTree: function(parent, resolver=this._rootResolver) {
michael@0: let loops = parent.querySelectorAll(":not(template) [template-loop]");
michael@0: let fors = parent.querySelectorAll(":not(template) [template-for]");
michael@0: let nodes = parent.querySelectorAll(":not(template) [template]");
michael@0: for (let i = 0; i < loops.length; i++) {
michael@0: this._processLoop(loops[i], resolver);
michael@0: }
michael@0: for (let i = 0; i < fors.length; i++) {
michael@0: this._processFor(fors[i], resolver);
michael@0: }
michael@0: for (let i = 0; i < nodes.length; i++) {
michael@0: this._processNode(nodes[i], resolver);
michael@0: }
michael@0: if (parent.hasAttribute("template")) {
michael@0: this._processNode(parent, resolver);
michael@0: }
michael@0: },
michael@0: };
michael@0:
michael@0: function Resolver(object, path = "") {
michael@0: this._object = object;
michael@0: this.path = path;
michael@0: }
michael@0:
michael@0: Resolver.prototype = {
michael@0:
michael@0: get: function(path, defaultValue = null) {
michael@0: let obj = this._object;
michael@0: if (path === "") {
michael@0: return obj;
michael@0: }
michael@0: let chunks = path.toString().split(".");
michael@0: for (let i = 0; i < chunks.length; i++) {
michael@0: let word = chunks[i];
michael@0: if ((typeof obj) == "object" &&
michael@0: (word in obj)) {
michael@0: obj = obj[word];
michael@0: } else {
michael@0: return defaultValue;
michael@0: }
michael@0: }
michael@0: return obj;
michael@0: },
michael@0:
michael@0: rootPathTo: function(path) {
michael@0: return this.path ? this.path + "." + path : path;
michael@0: },
michael@0:
michael@0: descend: function(path) {
michael@0: return new Resolver(this.get(path), this.rootPathTo(path));
michael@0: }
michael@0:
michael@0: };