mirror of
https://gitlab.winehq.org/wine/wine-gecko.git
synced 2024-09-13 09:24:08 -07:00
407 lines
12 KiB
JavaScript
407 lines
12 KiB
JavaScript
/* 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)
|
|
*
|
|
* <div template="{JSON Object}">
|
|
*
|
|
* {
|
|
* 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
|
|
* }
|
|
*
|
|
* <div template-loop="{JSON Object}">
|
|
*
|
|
* {
|
|
* 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));
|
|
}
|
|
|
|
};
|