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)); + } + +};