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

changeset 0
6474c204b198
     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 +};

mercurial