1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/browser/devtools/app-manager/content/template.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,406 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +/** 1.9 + * Template mechanism based on Object Emitters. 1.10 + * 1.11 + * The data used to expand the templates comes from 1.12 + * a ObjectEmitter object. The templates are automatically 1.13 + * updated as the ObjectEmitter is updated (via the "set" 1.14 + * event). See documentation in observable-object.js. 1.15 + * 1.16 + * Templates are used this way: 1.17 + * 1.18 + * (See examples in browser/devtools/app-manager/content/*.xhtml) 1.19 + * 1.20 + * <div template="{JSON Object}"> 1.21 + * 1.22 + * { 1.23 + * type: "attribute" 1.24 + * name: name of the attribute 1.25 + * path: location of the attribute value in the ObjectEmitter 1.26 + * } 1.27 + * 1.28 + * { 1.29 + * type: "textContent" 1.30 + * path: location of the textContent value in the ObjectEmitter 1.31 + * } 1.32 + * 1.33 + * { 1.34 + * type: "localizedContent" 1.35 + * paths: array of locations of the value of the arguments of the property 1.36 + * property: l10n property 1.37 + * } 1.38 + * 1.39 + * <div template-loop="{JSON Object}"> 1.40 + * 1.41 + * { 1.42 + * arrayPath: path of the array in the ObjectEmitter to loop from 1.43 + * childSelector: selector of the element to duplicate in the loop 1.44 + * } 1.45 + * 1.46 + */ 1.47 + 1.48 +const NOT_FOUND_STRING = "n/a"; 1.49 + 1.50 +/** 1.51 + * let t = new Template(root, store, l10nResolver); 1.52 + * t.start(); 1.53 + * 1.54 + * @param DOMNode root. 1.55 + * Node from where templates are expanded. 1.56 + * @param ObjectEmitter store. 1.57 + * ObjectEmitter object. 1.58 + * @param function (property, args). l10nResolver 1.59 + * A function that returns localized content. 1.60 + */ 1.61 +function Template(root, store, l10nResolver) { 1.62 + this._store = store; 1.63 + this._rootResolver = new Resolver(this._store.object); 1.64 + this._l10n = l10nResolver; 1.65 + 1.66 + // Listeners are stored in Maps. 1.67 + // path => Set(node1, node2, ..., nodeN) 1.68 + // For example: "foo.bar.4.name" => Set(div1,div2) 1.69 + 1.70 + this._nodeListeners = new Map(); 1.71 + this._loopListeners = new Map(); 1.72 + this._forListeners = new Map(); 1.73 + this._root = root; 1.74 + this._doc = this._root.ownerDocument; 1.75 + 1.76 + this._queuedNodeRegistrations = []; 1.77 + 1.78 + this._storeChanged = this._storeChanged.bind(this); 1.79 + this._store.on("set", this._storeChanged); 1.80 +} 1.81 + 1.82 +Template.prototype = { 1.83 + start: function() { 1.84 + this._processTree(this._root); 1.85 + this._registerQueuedNodes(); 1.86 + }, 1.87 + 1.88 + destroy: function() { 1.89 + this._store.off("set", this._storeChanged); 1.90 + this._root = null; 1.91 + this._doc = null; 1.92 + }, 1.93 + 1.94 + _storeChanged: function(event, path, value) { 1.95 + 1.96 + // The store has changed (a "set" event has been emitted). 1.97 + // We need to invalidate and rebuild the affected elements. 1.98 + 1.99 + let strpath = path.join("."); 1.100 + this._invalidate(strpath); 1.101 + 1.102 + for (let [registeredPath, set] of this._nodeListeners) { 1.103 + if (strpath != registeredPath && 1.104 + registeredPath.indexOf(strpath) > -1) { 1.105 + this._invalidate(registeredPath); 1.106 + } 1.107 + } 1.108 + 1.109 + this._registerQueuedNodes(); 1.110 + }, 1.111 + 1.112 + _invalidate: function(path) { 1.113 + // Loops: 1.114 + let set = this._loopListeners.get(path); 1.115 + if (set) { 1.116 + for (let elt of set) { 1.117 + this._processLoop(elt); 1.118 + } 1.119 + } 1.120 + 1.121 + // For: 1.122 + set = this._forListeners.get(path); 1.123 + if (set) { 1.124 + for (let elt of set) { 1.125 + this._processFor(elt); 1.126 + } 1.127 + } 1.128 + 1.129 + // Nodes: 1.130 + set = this._nodeListeners.get(path); 1.131 + if (set) { 1.132 + for (let elt of set) { 1.133 + this._processNode(elt); 1.134 + } 1.135 + } 1.136 + }, 1.137 + 1.138 + // Delay node registration until the last step of starting / updating the UI. 1.139 + // This allows us to avoid doing double work in _storeChanged where the first 1.140 + // call to |_invalidate| registers new nodes, which would then be visited a 1.141 + // second time when it iterates over node listeners. 1.142 + _queueNodeRegistration: function(path, element) { 1.143 + this._queuedNodeRegistrations.push([path, element]); 1.144 + }, 1.145 + 1.146 + _registerQueuedNodes: function() { 1.147 + for (let [path, element] of this._queuedNodeRegistrations) { 1.148 + // We map a node to a path. 1.149 + // If the value behind this path is updated, 1.150 + // we get notified from the ObjectEmitter, 1.151 + // and then we know which objects to update. 1.152 + if (!this._nodeListeners.has(path)) { 1.153 + this._nodeListeners.set(path, new Set()); 1.154 + } 1.155 + let set = this._nodeListeners.get(path); 1.156 + set.add(element); 1.157 + } 1.158 + this._queuedNodeRegistrations.length = 0; 1.159 + }, 1.160 + 1.161 + _unregisterNodes: function(nodes) { 1.162 + for (let e of nodes) { 1.163 + for (let registeredPath of e.registeredPaths) { 1.164 + let set = this._nodeListeners.get(registeredPath); 1.165 + if (!set) { 1.166 + continue; 1.167 + } 1.168 + set.delete(e); 1.169 + if (set.size === 0) { 1.170 + this._nodeListeners.delete(registeredPath); 1.171 + } 1.172 + } 1.173 + e.registeredPaths = null; 1.174 + } 1.175 + }, 1.176 + 1.177 + _registerLoop: function(path, element) { 1.178 + if (!this._loopListeners.has(path)) { 1.179 + this._loopListeners.set(path, new Set()); 1.180 + } 1.181 + let set = this._loopListeners.get(path); 1.182 + set.add(element); 1.183 + }, 1.184 + 1.185 + _registerFor: function(path, element) { 1.186 + if (!this._forListeners.has(path)) { 1.187 + this._forListeners.set(path, new Set()); 1.188 + } 1.189 + let set = this._forListeners.get(path); 1.190 + set.add(element); 1.191 + }, 1.192 + 1.193 + _processNode: function(element, resolver=this._rootResolver) { 1.194 + // The actual magic. 1.195 + // The element has a template attribute. 1.196 + // The value is supposed to be a JSON string. 1.197 + // resolver is a helper object that is used to retrieve data 1.198 + // from the template's data store, give the current path into 1.199 + // the data store, or descend down another level of the store. 1.200 + // See the Resolver object below. 1.201 + 1.202 + let e = element; 1.203 + let str = e.getAttribute("template"); 1.204 + 1.205 + try { 1.206 + let json = JSON.parse(str); 1.207 + // Sanity check 1.208 + if (!("type" in json)) { 1.209 + throw new Error("missing property"); 1.210 + } 1.211 + if (json.rootPath) { 1.212 + // If node has been generated through a loop, we stored 1.213 + // previously its rootPath. 1.214 + resolver = this._rootResolver.descend(json.rootPath); 1.215 + } 1.216 + 1.217 + // paths is an array that will store all the paths we needed 1.218 + // to expand the node. We will then, via 1.219 + // _registerQueuedNodes, link this element to these paths. 1.220 + 1.221 + let paths = []; 1.222 + 1.223 + switch (json.type) { 1.224 + case "attribute": { 1.225 + if (!("name" in json) || 1.226 + !("path" in json)) { 1.227 + throw new Error("missing property"); 1.228 + } 1.229 + e.setAttribute(json.name, resolver.get(json.path, NOT_FOUND_STRING)); 1.230 + paths.push(resolver.rootPathTo(json.path)); 1.231 + break; 1.232 + } 1.233 + case "textContent": { 1.234 + if (!("path" in json)) { 1.235 + throw new Error("missing property"); 1.236 + } 1.237 + e.textContent = resolver.get(json.path, NOT_FOUND_STRING); 1.238 + paths.push(resolver.rootPathTo(json.path)); 1.239 + break; 1.240 + } 1.241 + case "localizedContent": { 1.242 + if (!("property" in json) || 1.243 + !("paths" in json)) { 1.244 + throw new Error("missing property"); 1.245 + } 1.246 + let params = json.paths.map((p) => { 1.247 + paths.push(resolver.rootPathTo(p)); 1.248 + let str = resolver.get(p, NOT_FOUND_STRING); 1.249 + return str; 1.250 + }); 1.251 + e.textContent = this._l10n(json.property, params); 1.252 + break; 1.253 + } 1.254 + } 1.255 + if (resolver !== this._rootResolver) { 1.256 + // We save the rootPath if any. 1.257 + json.rootPath = resolver.path; 1.258 + e.setAttribute("template", JSON.stringify(json)); 1.259 + } 1.260 + if (paths.length > 0) { 1.261 + for (let path of paths) { 1.262 + this._queueNodeRegistration(path, e); 1.263 + } 1.264 + } 1.265 + // Store all the paths on the node, to speed up unregistering later 1.266 + e.registeredPaths = paths; 1.267 + } catch(exception) { 1.268 + console.error("Invalid template: " + e.outerHTML + " (" + exception + ")"); 1.269 + } 1.270 + }, 1.271 + 1.272 + _processLoop: function(element, resolver=this._rootResolver) { 1.273 + // The element has a template-loop attribute. 1.274 + // The related path must be an array. We go 1.275 + // through the array, and build one child per 1.276 + // item. The template for this child is pointed 1.277 + // by the childSelector property. 1.278 + let e = element; 1.279 + try { 1.280 + let template, count; 1.281 + let str = e.getAttribute("template-loop"); 1.282 + let json = JSON.parse(str); 1.283 + if (!("arrayPath" in json) || 1.284 + !("childSelector" in json)) { 1.285 + throw new Error("missing property"); 1.286 + } 1.287 + let descendedResolver = resolver.descend(json.arrayPath); 1.288 + let templateParent = this._doc.querySelector(json.childSelector); 1.289 + if (!templateParent) { 1.290 + throw new Error("can't find child"); 1.291 + } 1.292 + template = this._doc.createElement("div"); 1.293 + template.innerHTML = templateParent.innerHTML; 1.294 + template = template.firstElementChild; 1.295 + let array = descendedResolver.get("", []); 1.296 + if (!Array.isArray(array)) { 1.297 + console.error("referenced array is not an array"); 1.298 + } 1.299 + count = array.length; 1.300 + 1.301 + let fragment = this._doc.createDocumentFragment(); 1.302 + for (let i = 0; i < count; i++) { 1.303 + let node = template.cloneNode(true); 1.304 + this._processTree(node, descendedResolver.descend(i)); 1.305 + fragment.appendChild(node); 1.306 + } 1.307 + this._registerLoop(descendedResolver.path, e); 1.308 + this._registerLoop(descendedResolver.rootPathTo("length"), e); 1.309 + this._unregisterNodes(e.querySelectorAll("[template]")); 1.310 + e.innerHTML = ""; 1.311 + e.appendChild(fragment); 1.312 + } catch(exception) { 1.313 + console.error("Invalid template: " + e.outerHTML + " (" + exception + ")"); 1.314 + } 1.315 + }, 1.316 + 1.317 + _processFor: function(element, resolver=this._rootResolver) { 1.318 + let e = element; 1.319 + try { 1.320 + let template; 1.321 + let str = e.getAttribute("template-for"); 1.322 + let json = JSON.parse(str); 1.323 + if (!("path" in json) || 1.324 + !("childSelector" in json)) { 1.325 + throw new Error("missing property"); 1.326 + } 1.327 + 1.328 + if (!json.path) { 1.329 + // Nothing to show. 1.330 + this._unregisterNodes(e.querySelectorAll("[template]")); 1.331 + e.innerHTML = ""; 1.332 + return; 1.333 + } 1.334 + 1.335 + let descendedResolver = resolver.descend(json.path); 1.336 + let templateParent = this._doc.querySelector(json.childSelector); 1.337 + if (!templateParent) { 1.338 + throw new Error("can't find child"); 1.339 + } 1.340 + let content = this._doc.createElement("div"); 1.341 + content.innerHTML = templateParent.innerHTML; 1.342 + content = content.firstElementChild; 1.343 + 1.344 + this._processTree(content, descendedResolver); 1.345 + 1.346 + this._unregisterNodes(e.querySelectorAll("[template]")); 1.347 + this._registerFor(descendedResolver.path, e); 1.348 + 1.349 + e.innerHTML = ""; 1.350 + e.appendChild(content); 1.351 + 1.352 + } catch(exception) { 1.353 + console.error("Invalid template: " + e.outerHTML + " (" + exception + ")"); 1.354 + } 1.355 + }, 1.356 + 1.357 + _processTree: function(parent, resolver=this._rootResolver) { 1.358 + let loops = parent.querySelectorAll(":not(template) [template-loop]"); 1.359 + let fors = parent.querySelectorAll(":not(template) [template-for]"); 1.360 + let nodes = parent.querySelectorAll(":not(template) [template]"); 1.361 + for (let i = 0; i < loops.length; i++) { 1.362 + this._processLoop(loops[i], resolver); 1.363 + } 1.364 + for (let i = 0; i < fors.length; i++) { 1.365 + this._processFor(fors[i], resolver); 1.366 + } 1.367 + for (let i = 0; i < nodes.length; i++) { 1.368 + this._processNode(nodes[i], resolver); 1.369 + } 1.370 + if (parent.hasAttribute("template")) { 1.371 + this._processNode(parent, resolver); 1.372 + } 1.373 + }, 1.374 +}; 1.375 + 1.376 +function Resolver(object, path = "") { 1.377 + this._object = object; 1.378 + this.path = path; 1.379 +} 1.380 + 1.381 +Resolver.prototype = { 1.382 + 1.383 + get: function(path, defaultValue = null) { 1.384 + let obj = this._object; 1.385 + if (path === "") { 1.386 + return obj; 1.387 + } 1.388 + let chunks = path.toString().split("."); 1.389 + for (let i = 0; i < chunks.length; i++) { 1.390 + let word = chunks[i]; 1.391 + if ((typeof obj) == "object" && 1.392 + (word in obj)) { 1.393 + obj = obj[word]; 1.394 + } else { 1.395 + return defaultValue; 1.396 + } 1.397 + } 1.398 + return obj; 1.399 + }, 1.400 + 1.401 + rootPathTo: function(path) { 1.402 + return this.path ? this.path + "." + path : path; 1.403 + }, 1.404 + 1.405 + descend: function(path) { 1.406 + return new Resolver(this.get(path), this.rootPathTo(path)); 1.407 + } 1.408 + 1.409 +};