browser/devtools/layoutview/view.js

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/browser/devtools/layoutview/view.js	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,490 @@
     1.4 +/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
     1.5 +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
     1.6 +/* This Source Code Form is subject to the terms of the Mozilla Public
     1.7 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     1.8 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.9 +
    1.10 +"use strict";
    1.11 +
    1.12 +const Cu = Components.utils;
    1.13 +const Ci = Components.interfaces;
    1.14 +const Cc = Components.classes;
    1.15 +
    1.16 +Cu.import("resource://gre/modules/Services.jsm");
    1.17 +Cu.import("resource://gre/modules/Task.jsm");
    1.18 +Cu.import("resource://gre/modules/devtools/Loader.jsm");
    1.19 +Cu.import("resource://gre/modules/devtools/Console.jsm");
    1.20 +
    1.21 +const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
    1.22 +const {InplaceEditor, editableItem} = devtools.require("devtools/shared/inplace-editor");
    1.23 +const {parseDeclarations} = devtools.require("devtools/styleinspector/css-parsing-utils");
    1.24 +
    1.25 +const NUMERIC = /^-?[\d\.]+$/;
    1.26 +const LONG_TEXT_ROTATE_LIMIT = 3;
    1.27 +
    1.28 +/**
    1.29 + * An instance of EditingSession tracks changes that have been made during the
    1.30 + * modification of box model values. All of these changes can be reverted by
    1.31 + * calling revert.
    1.32 + *
    1.33 + * @param doc    A DOM document that can be used to test style rules.
    1.34 + * @param rules  An array of the style rules defined for the node being edited.
    1.35 + *               These should be in order of priority, least important first.
    1.36 + */
    1.37 +function EditingSession(doc, rules) {
    1.38 +  this._doc = doc;
    1.39 +  this._rules = rules;
    1.40 +  this._modifications = new Map();
    1.41 +}
    1.42 +
    1.43 +EditingSession.prototype = {
    1.44 +  /**
    1.45 +   * Gets the value of a single property from the CSS rule.
    1.46 +   *
    1.47 +   * @param rule      The CSS rule
    1.48 +   * @param property  The name of the property
    1.49 +   */
    1.50 +  getPropertyFromRule: function(rule, property) {
    1.51 +    let dummyStyle = this._element.style;
    1.52 +
    1.53 +    dummyStyle.cssText = rule.cssText;
    1.54 +    return dummyStyle.getPropertyValue(property);
    1.55 +  },
    1.56 +
    1.57 +  /**
    1.58 +   * Returns the current value for a property as a string or the empty string if
    1.59 +   * no style rules affect the property.
    1.60 +   *
    1.61 +   * @param property  The name of the property as a string
    1.62 +   */
    1.63 +  getProperty: function(property) {
    1.64 +    // Create a hidden element for getPropertyFromRule to use
    1.65 +    let div = this._doc.createElement("div");
    1.66 +    div.setAttribute("style", "display: none");
    1.67 +    this._doc.body.appendChild(div);
    1.68 +    this._element = this._doc.createElement("p");
    1.69 +    div.appendChild(this._element);
    1.70 +
    1.71 +    // As the rules are in order of priority we can just iterate until we find
    1.72 +    // the first that defines a value for the property and return that.
    1.73 +    for (let rule of this._rules) {
    1.74 +      let value = this.getPropertyFromRule(rule, property);
    1.75 +      if (value !== "") {
    1.76 +        div.remove();
    1.77 +        return value;
    1.78 +      }
    1.79 +    }
    1.80 +    div.remove();
    1.81 +    return "";
    1.82 +  },
    1.83 +
    1.84 +  /**
    1.85 +   * Sets a number of properties on the node. Returns a promise that will be
    1.86 +   * resolved when the modifications are complete.
    1.87 +   *
    1.88 +   * @param properties  An array of properties, each is an object with name and
    1.89 +   *                    value properties. If the value is "" then the property
    1.90 +   *                    is removed.
    1.91 +   */
    1.92 +  setProperties: function(properties) {
    1.93 +    let modifications = this._rules[0].startModifyingProperties();
    1.94 +
    1.95 +    for (let property of properties) {
    1.96 +      if (!this._modifications.has(property.name))
    1.97 +        this._modifications.set(property.name, this.getPropertyFromRule(this._rules[0], property.name));
    1.98 +
    1.99 +      if (property.value == "")
   1.100 +        modifications.removeProperty(property.name);
   1.101 +      else
   1.102 +        modifications.setProperty(property.name, property.value, "");
   1.103 +    }
   1.104 +
   1.105 +    return modifications.apply().then(null, console.error);
   1.106 +  },
   1.107 +
   1.108 +  /**
   1.109 +   * Reverts all of the property changes made by this instance. Returns a
   1.110 +   * promise that will be resolved when complete.
   1.111 +   */
   1.112 +  revert: function() {
   1.113 +    let modifications = this._rules[0].startModifyingProperties();
   1.114 +
   1.115 +    for (let [property, value] of this._modifications) {
   1.116 +      if (value != "")
   1.117 +        modifications.setProperty(property, value, "");
   1.118 +      else
   1.119 +        modifications.removeProperty(property);
   1.120 +    }
   1.121 +
   1.122 +    return modifications.apply().then(null, console.error);
   1.123 +  }
   1.124 +};
   1.125 +
   1.126 +function LayoutView(aInspector, aWindow)
   1.127 +{
   1.128 +  this.inspector = aInspector;
   1.129 +
   1.130 +  // <browser> is not always available (for Chrome targets for example)
   1.131 +  if (this.inspector.target.tab) {
   1.132 +    this.browser = aInspector.target.tab.linkedBrowser;
   1.133 +  }
   1.134 +
   1.135 +  this.doc = aWindow.document;
   1.136 +  this.sizeLabel = this.doc.querySelector(".size > span");
   1.137 +  this.sizeHeadingLabel = this.doc.getElementById("element-size");
   1.138 +
   1.139 +  this.init();
   1.140 +}
   1.141 +
   1.142 +LayoutView.prototype = {
   1.143 +  init: function LV_init() {
   1.144 +    this.update = this.update.bind(this);
   1.145 +    this.onNewNode = this.onNewNode.bind(this);
   1.146 +    this.onNewSelection = this.onNewSelection.bind(this);
   1.147 +    this.inspector.selection.on("new-node-front", this.onNewSelection);
   1.148 +    this.inspector.sidebar.on("layoutview-selected", this.onNewNode);
   1.149 +
   1.150 +    // Store for the different dimensions of the node.
   1.151 +    // 'selector' refers to the element that holds the value in view.xhtml;
   1.152 +    // 'property' is what we are measuring;
   1.153 +    // 'value' is the computed dimension, computed in update().
   1.154 +    this.map = {
   1.155 +      position: {selector: "#element-position",
   1.156 +                 property: "position",
   1.157 +                 value: undefined},
   1.158 +      marginTop: {selector: ".margin.top > span",
   1.159 +                  property: "margin-top",
   1.160 +                  value: undefined},
   1.161 +      marginBottom: {selector: ".margin.bottom > span",
   1.162 +                  property: "margin-bottom",
   1.163 +                  value: undefined},
   1.164 +      // margin-left is a shorthand for some internal properties,
   1.165 +      // margin-left-ltr-source and margin-left-rtl-source for example. The
   1.166 +      // real margin value we want is in margin-left-value
   1.167 +      marginLeft: {selector: ".margin.left > span",
   1.168 +                  property: "margin-left",
   1.169 +                  realProperty: "margin-left-value",
   1.170 +                  value: undefined},
   1.171 +      // margin-right behaves the same as margin-left
   1.172 +      marginRight: {selector: ".margin.right > span",
   1.173 +                  property: "margin-right",
   1.174 +                  realProperty: "margin-right-value",
   1.175 +                  value: undefined},
   1.176 +      paddingTop: {selector: ".padding.top > span",
   1.177 +                  property: "padding-top",
   1.178 +                  value: undefined},
   1.179 +      paddingBottom: {selector: ".padding.bottom > span",
   1.180 +                  property: "padding-bottom",
   1.181 +                  value: undefined},
   1.182 +      // padding-left behaves the same as margin-left
   1.183 +      paddingLeft: {selector: ".padding.left > span",
   1.184 +                  property: "padding-left",
   1.185 +                  realProperty: "padding-left-value",
   1.186 +                  value: undefined},
   1.187 +      // padding-right behaves the same as margin-left
   1.188 +      paddingRight: {selector: ".padding.right > span",
   1.189 +                  property: "padding-right",
   1.190 +                  realProperty: "padding-right-value",
   1.191 +                  value: undefined},
   1.192 +      borderTop: {selector: ".border.top > span",
   1.193 +                  property: "border-top-width",
   1.194 +                  value: undefined},
   1.195 +      borderBottom: {selector: ".border.bottom > span",
   1.196 +                  property: "border-bottom-width",
   1.197 +                  value: undefined},
   1.198 +      borderLeft: {selector: ".border.left > span",
   1.199 +                  property: "border-left-width",
   1.200 +                  value: undefined},
   1.201 +      borderRight: {selector: ".border.right > span",
   1.202 +                  property: "border-right-width",
   1.203 +                  value: undefined},
   1.204 +    };
   1.205 +
   1.206 +    // Make each element the dimensions editable
   1.207 +    for (let i in this.map) {
   1.208 +      if (i == "position")
   1.209 +        continue;
   1.210 +
   1.211 +      let dimension = this.map[i];
   1.212 +      editableItem({ element: this.doc.querySelector(dimension.selector) }, (element, event) => {
   1.213 +        this.initEditor(element, event, dimension);
   1.214 +      });
   1.215 +    }
   1.216 +
   1.217 +    this.onNewNode();
   1.218 +  },
   1.219 +
   1.220 +  /**
   1.221 +   * Called when the user clicks on one of the editable values in the layoutview
   1.222 +   */
   1.223 +  initEditor: function LV_initEditor(element, event, dimension) {
   1.224 +    let { property, realProperty } = dimension;
   1.225 +    if (!realProperty)
   1.226 +      realProperty = property;
   1.227 +    let session = new EditingSession(document, this.elementRules);
   1.228 +    let initialValue = session.getProperty(realProperty);
   1.229 +
   1.230 +    let editor = new InplaceEditor({
   1.231 +      element: element,
   1.232 +      initial: initialValue,
   1.233 +
   1.234 +      start: (editor) => {
   1.235 +        editor.elt.parentNode.classList.add("editing");
   1.236 +      },
   1.237 +
   1.238 +      change: (value) => {
   1.239 +        if (NUMERIC.test(value))
   1.240 +          value += "px";
   1.241 +        let properties = [
   1.242 +          { name: property, value: value }
   1.243 +        ]
   1.244 +
   1.245 +        if (property.substring(0, 7) == "border-") {
   1.246 +          let bprop = property.substring(0, property.length - 5) + "style";
   1.247 +          let style = session.getProperty(bprop);
   1.248 +          if (!style || style == "none" || style == "hidden")
   1.249 +            properties.push({ name: bprop, value: "solid" });
   1.250 +        }
   1.251 +
   1.252 +        session.setProperties(properties);
   1.253 +      },
   1.254 +
   1.255 +      done: (value, commit) => {
   1.256 +        editor.elt.parentNode.classList.remove("editing");
   1.257 +        if (!commit)
   1.258 +          session.revert();
   1.259 +      }
   1.260 +    }, event);
   1.261 +  },
   1.262 +
   1.263 +  /**
   1.264 +   * Is the layoutview visible in the sidebar?
   1.265 +   */
   1.266 +  isActive: function LV_isActive() {
   1.267 +    return this.inspector.sidebar.getCurrentTabID() == "layoutview";
   1.268 +  },
   1.269 +
   1.270 +  /**
   1.271 +   * Destroy the nodes. Remove listeners.
   1.272 +   */
   1.273 +  destroy: function LV_destroy() {
   1.274 +    this.inspector.sidebar.off("layoutview-selected", this.onNewNode);
   1.275 +    this.inspector.selection.off("new-node-front", this.onNewSelection);
   1.276 +    if (this.browser) {
   1.277 +      this.browser.removeEventListener("MozAfterPaint", this.update, true);
   1.278 +    }
   1.279 +    this.sizeHeadingLabel = null;
   1.280 +    this.sizeLabel = null;
   1.281 +    this.inspector = null;
   1.282 +    this.doc = null;
   1.283 +  },
   1.284 +
   1.285 +  /**
   1.286 +   * Selection 'new-node-front' event handler.
   1.287 +   */
   1.288 +  onNewSelection: function() {
   1.289 +    let done = this.inspector.updating("layoutview");
   1.290 +    this.onNewNode().then(done, (err) => { console.error(err); done() });
   1.291 +  },
   1.292 +
   1.293 +  onNewNode: function LV_onNewNode() {
   1.294 +    if (this.isActive() &&
   1.295 +        this.inspector.selection.isConnected() &&
   1.296 +        this.inspector.selection.isElementNode()) {
   1.297 +      this.undim();
   1.298 +    } else {
   1.299 +      this.dim();
   1.300 +    }
   1.301 +    return this.update();
   1.302 +  },
   1.303 +
   1.304 +  /**
   1.305 +   * Hide the layout boxes. No node are selected.
   1.306 +   */
   1.307 +  dim: function LV_dim() {
   1.308 +    if (this.browser) {
   1.309 +      this.browser.removeEventListener("MozAfterPaint", this.update, true);
   1.310 +    }
   1.311 +    this.trackingPaint = false;
   1.312 +    this.doc.body.classList.add("dim");
   1.313 +    this.dimmed = true;
   1.314 +  },
   1.315 +
   1.316 +  /**
   1.317 +   * Show the layout boxes. A node is selected.
   1.318 +   */
   1.319 +  undim: function LV_undim() {
   1.320 +    if (!this.trackingPaint) {
   1.321 +      if (this.browser) {
   1.322 +        this.browser.addEventListener("MozAfterPaint", this.update, true);
   1.323 +      }
   1.324 +      this.trackingPaint = true;
   1.325 +    }
   1.326 +    this.doc.body.classList.remove("dim");
   1.327 +    this.dimmed = false;
   1.328 +  },
   1.329 +
   1.330 +  /**
   1.331 +   * Compute the dimensions of the node and update the values in
   1.332 +   * the layoutview/view.xhtml document. Returns a promise that will be resolved
   1.333 +   * when complete.
   1.334 +   */
   1.335 +  update: function LV_update() {
   1.336 +    let lastRequest = Task.spawn((function*() {
   1.337 +      if (!this.isActive() ||
   1.338 +          !this.inspector.selection.isConnected() ||
   1.339 +          !this.inspector.selection.isElementNode()) {
   1.340 +        return;
   1.341 +      }
   1.342 +
   1.343 +      let node = this.inspector.selection.nodeFront;
   1.344 +      let layout = yield this.inspector.pageStyle.getLayout(node, {
   1.345 +        autoMargins: !this.dimmed
   1.346 +      });
   1.347 +      let styleEntries = yield this.inspector.pageStyle.getApplied(node, {});
   1.348 +
   1.349 +      // If a subsequent request has been made, wait for that one instead.
   1.350 +      if (this._lastRequest != lastRequest) {
   1.351 +        return this._lastRequest;
   1.352 +      }
   1.353 +
   1.354 +      this._lastRequest = null;
   1.355 +      let width = layout.width;
   1.356 +      let height = layout.height;
   1.357 +      let newLabel = width + "x" + height;
   1.358 +      if (this.sizeHeadingLabel.textContent != newLabel) {
   1.359 +        this.sizeHeadingLabel.textContent = newLabel;
   1.360 +      }
   1.361 +
   1.362 +      // If the view is dimmed, no need to do anything more.
   1.363 +      if (this.dimmed) {
   1.364 +        this.inspector.emit("layoutview-updated");
   1.365 +        return null;
   1.366 +      }
   1.367 +
   1.368 +      for (let i in this.map) {
   1.369 +        let property = this.map[i].property;
   1.370 +        if (!(property in layout)) {
   1.371 +          // Depending on the actor version, some properties
   1.372 +          // might be missing.
   1.373 +          continue;
   1.374 +        }
   1.375 +        let parsedValue = parseInt(layout[property]);
   1.376 +        if (Number.isNaN(parsedValue)) {
   1.377 +          // Not a number. We use the raw string.
   1.378 +          // Useful for "position" for example.
   1.379 +          this.map[i].value = layout[property];
   1.380 +        } else {
   1.381 +          this.map[i].value = parsedValue;
   1.382 +        }
   1.383 +      }
   1.384 +
   1.385 +      let margins = layout.autoMargins;
   1.386 +      if ("top" in margins) this.map.marginTop.value = "auto";
   1.387 +      if ("right" in margins) this.map.marginRight.value = "auto";
   1.388 +      if ("bottom" in margins) this.map.marginBottom.value = "auto";
   1.389 +      if ("left" in margins) this.map.marginLeft.value = "auto";
   1.390 +
   1.391 +      for (let i in this.map) {
   1.392 +        let selector = this.map[i].selector;
   1.393 +        let span = this.doc.querySelector(selector);
   1.394 +        if (span.textContent.length > 0 &&
   1.395 +            span.textContent == this.map[i].value) {
   1.396 +          continue;
   1.397 +        }
   1.398 +        span.textContent = this.map[i].value;
   1.399 +        this.manageOverflowingText(span);
   1.400 +      }
   1.401 +
   1.402 +      width -= this.map.borderLeft.value + this.map.borderRight.value +
   1.403 +               this.map.paddingLeft.value + this.map.paddingRight.value;
   1.404 +
   1.405 +      height -= this.map.borderTop.value + this.map.borderBottom.value +
   1.406 +                this.map.paddingTop.value + this.map.paddingBottom.value;
   1.407 +
   1.408 +      let newValue = width + "x" + height;
   1.409 +      if (this.sizeLabel.textContent != newValue) {
   1.410 +        this.sizeLabel.textContent = newValue;
   1.411 +      }
   1.412 +
   1.413 +      this.elementRules = [e.rule for (e of styleEntries)];
   1.414 +
   1.415 +      this.inspector.emit("layoutview-updated");
   1.416 +    }).bind(this)).then(null, console.error);
   1.417 +
   1.418 +    return this._lastRequest = lastRequest;
   1.419 +  },
   1.420 +
   1.421 +  showBoxModel: function(options={}) {
   1.422 +    let toolbox = this.inspector.toolbox;
   1.423 +    let nodeFront = this.inspector.selection.nodeFront;
   1.424 +
   1.425 +    toolbox.highlighterUtils.highlightNodeFront(nodeFront, options);
   1.426 +  },
   1.427 +
   1.428 +  hideBoxModel: function() {
   1.429 +    let toolbox = this.inspector.toolbox;
   1.430 +
   1.431 +    toolbox.highlighterUtils.unhighlight();
   1.432 +  },
   1.433 +
   1.434 +  manageOverflowingText: function(span) {
   1.435 +    let classList = span.parentNode.classList;
   1.436 +
   1.437 +    if (classList.contains("left") || classList.contains("right")) {
   1.438 +      let force = span.textContent.length > LONG_TEXT_ROTATE_LIMIT;
   1.439 +      classList.toggle("rotate", force);
   1.440 +    }
   1.441 +  }
   1.442 +};
   1.443 +
   1.444 +let elts;
   1.445 +let tooltip;
   1.446 +
   1.447 +let onmouseover = function(e) {
   1.448 +  let region = e.target.getAttribute("data-box");
   1.449 +
   1.450 +  tooltip.textContent = e.target.getAttribute("tooltip");
   1.451 +  this.layoutview.showBoxModel({region: region});
   1.452 +
   1.453 +  return false;
   1.454 +}.bind(window);
   1.455 +
   1.456 +let onmouseout = function(e) {
   1.457 +  tooltip.textContent = "";
   1.458 +  this.layoutview.hideBoxModel();
   1.459 +
   1.460 +  return false;
   1.461 +}.bind(window);
   1.462 +
   1.463 +window.setPanel = function(panel) {
   1.464 +  this.layoutview = new LayoutView(panel, window);
   1.465 +
   1.466 +  // Tooltip mechanism
   1.467 +  elts = document.querySelectorAll("*[tooltip]");
   1.468 +  tooltip = document.querySelector(".tooltip");
   1.469 +  for (let i = 0; i < elts.length; i++) {
   1.470 +    let elt = elts[i];
   1.471 +    elt.addEventListener("mouseover", onmouseover, true);
   1.472 +    elt.addEventListener("mouseout", onmouseout, true);
   1.473 +  }
   1.474 +
   1.475 +  // Mark document as RTL or LTR:
   1.476 +  let chromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].
   1.477 +    getService(Ci.nsIXULChromeRegistry);
   1.478 +  let dir = chromeReg.isLocaleRTL("global");
   1.479 +  document.body.setAttribute("dir", dir ? "rtl" : "ltr");
   1.480 +
   1.481 +  window.parent.postMessage("layoutview-ready", "*");
   1.482 +};
   1.483 +
   1.484 +window.onunload = function() {
   1.485 +  this.layoutview.destroy();
   1.486 +  if (elts) {
   1.487 +    for (let i = 0; i < elts.length; i++) {
   1.488 +      let elt = elts[i];
   1.489 +      elt.removeEventListener("mouseover", onmouseover, true);
   1.490 +      elt.removeEventListener("mouseout", onmouseout, true);
   1.491 +    }
   1.492 +  }
   1.493 +};

mercurial