browser/devtools/app-manager/content/template.js

changeset 0
6474c204b198
equal deleted inserted replaced
-1:000000000000 0:13565b2e1757
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5 /**
6 * Template mechanism based on Object Emitters.
7 *
8 * The data used to expand the templates comes from
9 * a ObjectEmitter object. The templates are automatically
10 * updated as the ObjectEmitter is updated (via the "set"
11 * event). See documentation in observable-object.js.
12 *
13 * Templates are used this way:
14 *
15 * (See examples in browser/devtools/app-manager/content/*.xhtml)
16 *
17 * <div template="{JSON Object}">
18 *
19 * {
20 * type: "attribute"
21 * name: name of the attribute
22 * path: location of the attribute value in the ObjectEmitter
23 * }
24 *
25 * {
26 * type: "textContent"
27 * path: location of the textContent value in the ObjectEmitter
28 * }
29 *
30 * {
31 * type: "localizedContent"
32 * paths: array of locations of the value of the arguments of the property
33 * property: l10n property
34 * }
35 *
36 * <div template-loop="{JSON Object}">
37 *
38 * {
39 * arrayPath: path of the array in the ObjectEmitter to loop from
40 * childSelector: selector of the element to duplicate in the loop
41 * }
42 *
43 */
44
45 const NOT_FOUND_STRING = "n/a";
46
47 /**
48 * let t = new Template(root, store, l10nResolver);
49 * t.start();
50 *
51 * @param DOMNode root.
52 * Node from where templates are expanded.
53 * @param ObjectEmitter store.
54 * ObjectEmitter object.
55 * @param function (property, args). l10nResolver
56 * A function that returns localized content.
57 */
58 function Template(root, store, l10nResolver) {
59 this._store = store;
60 this._rootResolver = new Resolver(this._store.object);
61 this._l10n = l10nResolver;
62
63 // Listeners are stored in Maps.
64 // path => Set(node1, node2, ..., nodeN)
65 // For example: "foo.bar.4.name" => Set(div1,div2)
66
67 this._nodeListeners = new Map();
68 this._loopListeners = new Map();
69 this._forListeners = new Map();
70 this._root = root;
71 this._doc = this._root.ownerDocument;
72
73 this._queuedNodeRegistrations = [];
74
75 this._storeChanged = this._storeChanged.bind(this);
76 this._store.on("set", this._storeChanged);
77 }
78
79 Template.prototype = {
80 start: function() {
81 this._processTree(this._root);
82 this._registerQueuedNodes();
83 },
84
85 destroy: function() {
86 this._store.off("set", this._storeChanged);
87 this._root = null;
88 this._doc = null;
89 },
90
91 _storeChanged: function(event, path, value) {
92
93 // The store has changed (a "set" event has been emitted).
94 // We need to invalidate and rebuild the affected elements.
95
96 let strpath = path.join(".");
97 this._invalidate(strpath);
98
99 for (let [registeredPath, set] of this._nodeListeners) {
100 if (strpath != registeredPath &&
101 registeredPath.indexOf(strpath) > -1) {
102 this._invalidate(registeredPath);
103 }
104 }
105
106 this._registerQueuedNodes();
107 },
108
109 _invalidate: function(path) {
110 // Loops:
111 let set = this._loopListeners.get(path);
112 if (set) {
113 for (let elt of set) {
114 this._processLoop(elt);
115 }
116 }
117
118 // For:
119 set = this._forListeners.get(path);
120 if (set) {
121 for (let elt of set) {
122 this._processFor(elt);
123 }
124 }
125
126 // Nodes:
127 set = this._nodeListeners.get(path);
128 if (set) {
129 for (let elt of set) {
130 this._processNode(elt);
131 }
132 }
133 },
134
135 // Delay node registration until the last step of starting / updating the UI.
136 // This allows us to avoid doing double work in _storeChanged where the first
137 // call to |_invalidate| registers new nodes, which would then be visited a
138 // second time when it iterates over node listeners.
139 _queueNodeRegistration: function(path, element) {
140 this._queuedNodeRegistrations.push([path, element]);
141 },
142
143 _registerQueuedNodes: function() {
144 for (let [path, element] of this._queuedNodeRegistrations) {
145 // We map a node to a path.
146 // If the value behind this path is updated,
147 // we get notified from the ObjectEmitter,
148 // and then we know which objects to update.
149 if (!this._nodeListeners.has(path)) {
150 this._nodeListeners.set(path, new Set());
151 }
152 let set = this._nodeListeners.get(path);
153 set.add(element);
154 }
155 this._queuedNodeRegistrations.length = 0;
156 },
157
158 _unregisterNodes: function(nodes) {
159 for (let e of nodes) {
160 for (let registeredPath of e.registeredPaths) {
161 let set = this._nodeListeners.get(registeredPath);
162 if (!set) {
163 continue;
164 }
165 set.delete(e);
166 if (set.size === 0) {
167 this._nodeListeners.delete(registeredPath);
168 }
169 }
170 e.registeredPaths = null;
171 }
172 },
173
174 _registerLoop: function(path, element) {
175 if (!this._loopListeners.has(path)) {
176 this._loopListeners.set(path, new Set());
177 }
178 let set = this._loopListeners.get(path);
179 set.add(element);
180 },
181
182 _registerFor: function(path, element) {
183 if (!this._forListeners.has(path)) {
184 this._forListeners.set(path, new Set());
185 }
186 let set = this._forListeners.get(path);
187 set.add(element);
188 },
189
190 _processNode: function(element, resolver=this._rootResolver) {
191 // The actual magic.
192 // The element has a template attribute.
193 // The value is supposed to be a JSON string.
194 // resolver is a helper object that is used to retrieve data
195 // from the template's data store, give the current path into
196 // the data store, or descend down another level of the store.
197 // See the Resolver object below.
198
199 let e = element;
200 let str = e.getAttribute("template");
201
202 try {
203 let json = JSON.parse(str);
204 // Sanity check
205 if (!("type" in json)) {
206 throw new Error("missing property");
207 }
208 if (json.rootPath) {
209 // If node has been generated through a loop, we stored
210 // previously its rootPath.
211 resolver = this._rootResolver.descend(json.rootPath);
212 }
213
214 // paths is an array that will store all the paths we needed
215 // to expand the node. We will then, via
216 // _registerQueuedNodes, link this element to these paths.
217
218 let paths = [];
219
220 switch (json.type) {
221 case "attribute": {
222 if (!("name" in json) ||
223 !("path" in json)) {
224 throw new Error("missing property");
225 }
226 e.setAttribute(json.name, resolver.get(json.path, NOT_FOUND_STRING));
227 paths.push(resolver.rootPathTo(json.path));
228 break;
229 }
230 case "textContent": {
231 if (!("path" in json)) {
232 throw new Error("missing property");
233 }
234 e.textContent = resolver.get(json.path, NOT_FOUND_STRING);
235 paths.push(resolver.rootPathTo(json.path));
236 break;
237 }
238 case "localizedContent": {
239 if (!("property" in json) ||
240 !("paths" in json)) {
241 throw new Error("missing property");
242 }
243 let params = json.paths.map((p) => {
244 paths.push(resolver.rootPathTo(p));
245 let str = resolver.get(p, NOT_FOUND_STRING);
246 return str;
247 });
248 e.textContent = this._l10n(json.property, params);
249 break;
250 }
251 }
252 if (resolver !== this._rootResolver) {
253 // We save the rootPath if any.
254 json.rootPath = resolver.path;
255 e.setAttribute("template", JSON.stringify(json));
256 }
257 if (paths.length > 0) {
258 for (let path of paths) {
259 this._queueNodeRegistration(path, e);
260 }
261 }
262 // Store all the paths on the node, to speed up unregistering later
263 e.registeredPaths = paths;
264 } catch(exception) {
265 console.error("Invalid template: " + e.outerHTML + " (" + exception + ")");
266 }
267 },
268
269 _processLoop: function(element, resolver=this._rootResolver) {
270 // The element has a template-loop attribute.
271 // The related path must be an array. We go
272 // through the array, and build one child per
273 // item. The template for this child is pointed
274 // by the childSelector property.
275 let e = element;
276 try {
277 let template, count;
278 let str = e.getAttribute("template-loop");
279 let json = JSON.parse(str);
280 if (!("arrayPath" in json) ||
281 !("childSelector" in json)) {
282 throw new Error("missing property");
283 }
284 let descendedResolver = resolver.descend(json.arrayPath);
285 let templateParent = this._doc.querySelector(json.childSelector);
286 if (!templateParent) {
287 throw new Error("can't find child");
288 }
289 template = this._doc.createElement("div");
290 template.innerHTML = templateParent.innerHTML;
291 template = template.firstElementChild;
292 let array = descendedResolver.get("", []);
293 if (!Array.isArray(array)) {
294 console.error("referenced array is not an array");
295 }
296 count = array.length;
297
298 let fragment = this._doc.createDocumentFragment();
299 for (let i = 0; i < count; i++) {
300 let node = template.cloneNode(true);
301 this._processTree(node, descendedResolver.descend(i));
302 fragment.appendChild(node);
303 }
304 this._registerLoop(descendedResolver.path, e);
305 this._registerLoop(descendedResolver.rootPathTo("length"), e);
306 this._unregisterNodes(e.querySelectorAll("[template]"));
307 e.innerHTML = "";
308 e.appendChild(fragment);
309 } catch(exception) {
310 console.error("Invalid template: " + e.outerHTML + " (" + exception + ")");
311 }
312 },
313
314 _processFor: function(element, resolver=this._rootResolver) {
315 let e = element;
316 try {
317 let template;
318 let str = e.getAttribute("template-for");
319 let json = JSON.parse(str);
320 if (!("path" in json) ||
321 !("childSelector" in json)) {
322 throw new Error("missing property");
323 }
324
325 if (!json.path) {
326 // Nothing to show.
327 this._unregisterNodes(e.querySelectorAll("[template]"));
328 e.innerHTML = "";
329 return;
330 }
331
332 let descendedResolver = resolver.descend(json.path);
333 let templateParent = this._doc.querySelector(json.childSelector);
334 if (!templateParent) {
335 throw new Error("can't find child");
336 }
337 let content = this._doc.createElement("div");
338 content.innerHTML = templateParent.innerHTML;
339 content = content.firstElementChild;
340
341 this._processTree(content, descendedResolver);
342
343 this._unregisterNodes(e.querySelectorAll("[template]"));
344 this._registerFor(descendedResolver.path, e);
345
346 e.innerHTML = "";
347 e.appendChild(content);
348
349 } catch(exception) {
350 console.error("Invalid template: " + e.outerHTML + " (" + exception + ")");
351 }
352 },
353
354 _processTree: function(parent, resolver=this._rootResolver) {
355 let loops = parent.querySelectorAll(":not(template) [template-loop]");
356 let fors = parent.querySelectorAll(":not(template) [template-for]");
357 let nodes = parent.querySelectorAll(":not(template) [template]");
358 for (let i = 0; i < loops.length; i++) {
359 this._processLoop(loops[i], resolver);
360 }
361 for (let i = 0; i < fors.length; i++) {
362 this._processFor(fors[i], resolver);
363 }
364 for (let i = 0; i < nodes.length; i++) {
365 this._processNode(nodes[i], resolver);
366 }
367 if (parent.hasAttribute("template")) {
368 this._processNode(parent, resolver);
369 }
370 },
371 };
372
373 function Resolver(object, path = "") {
374 this._object = object;
375 this.path = path;
376 }
377
378 Resolver.prototype = {
379
380 get: function(path, defaultValue = null) {
381 let obj = this._object;
382 if (path === "") {
383 return obj;
384 }
385 let chunks = path.toString().split(".");
386 for (let i = 0; i < chunks.length; i++) {
387 let word = chunks[i];
388 if ((typeof obj) == "object" &&
389 (word in obj)) {
390 obj = obj[word];
391 } else {
392 return defaultValue;
393 }
394 }
395 return obj;
396 },
397
398 rootPathTo: function(path) {
399 return this.path ? this.path + "." + path : path;
400 },
401
402 descend: function(path) {
403 return new Resolver(this.get(path), this.rootPathTo(path));
404 }
405
406 };

mercurial