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