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: };