michael@0: /* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ michael@0: /* vim: set ft=javascript ts=2 et sw=2 tw=80: */ michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: const Cu = Components.utils; michael@0: const Ci = Components.interfaces; michael@0: const Cc = Components.classes; michael@0: michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/Task.jsm"); michael@0: Cu.import("resource://gre/modules/devtools/Loader.jsm"); michael@0: Cu.import("resource://gre/modules/devtools/Console.jsm"); michael@0: michael@0: const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); michael@0: const {InplaceEditor, editableItem} = devtools.require("devtools/shared/inplace-editor"); michael@0: const {parseDeclarations} = devtools.require("devtools/styleinspector/css-parsing-utils"); michael@0: michael@0: const NUMERIC = /^-?[\d\.]+$/; michael@0: const LONG_TEXT_ROTATE_LIMIT = 3; michael@0: michael@0: /** michael@0: * An instance of EditingSession tracks changes that have been made during the michael@0: * modification of box model values. All of these changes can be reverted by michael@0: * calling revert. michael@0: * michael@0: * @param doc A DOM document that can be used to test style rules. michael@0: * @param rules An array of the style rules defined for the node being edited. michael@0: * These should be in order of priority, least important first. michael@0: */ michael@0: function EditingSession(doc, rules) { michael@0: this._doc = doc; michael@0: this._rules = rules; michael@0: this._modifications = new Map(); michael@0: } michael@0: michael@0: EditingSession.prototype = { michael@0: /** michael@0: * Gets the value of a single property from the CSS rule. michael@0: * michael@0: * @param rule The CSS rule michael@0: * @param property The name of the property michael@0: */ michael@0: getPropertyFromRule: function(rule, property) { michael@0: let dummyStyle = this._element.style; michael@0: michael@0: dummyStyle.cssText = rule.cssText; michael@0: return dummyStyle.getPropertyValue(property); michael@0: }, michael@0: michael@0: /** michael@0: * Returns the current value for a property as a string or the empty string if michael@0: * no style rules affect the property. michael@0: * michael@0: * @param property The name of the property as a string michael@0: */ michael@0: getProperty: function(property) { michael@0: // Create a hidden element for getPropertyFromRule to use michael@0: let div = this._doc.createElement("div"); michael@0: div.setAttribute("style", "display: none"); michael@0: this._doc.body.appendChild(div); michael@0: this._element = this._doc.createElement("p"); michael@0: div.appendChild(this._element); michael@0: michael@0: // As the rules are in order of priority we can just iterate until we find michael@0: // the first that defines a value for the property and return that. michael@0: for (let rule of this._rules) { michael@0: let value = this.getPropertyFromRule(rule, property); michael@0: if (value !== "") { michael@0: div.remove(); michael@0: return value; michael@0: } michael@0: } michael@0: div.remove(); michael@0: return ""; michael@0: }, michael@0: michael@0: /** michael@0: * Sets a number of properties on the node. Returns a promise that will be michael@0: * resolved when the modifications are complete. michael@0: * michael@0: * @param properties An array of properties, each is an object with name and michael@0: * value properties. If the value is "" then the property michael@0: * is removed. michael@0: */ michael@0: setProperties: function(properties) { michael@0: let modifications = this._rules[0].startModifyingProperties(); michael@0: michael@0: for (let property of properties) { michael@0: if (!this._modifications.has(property.name)) michael@0: this._modifications.set(property.name, this.getPropertyFromRule(this._rules[0], property.name)); michael@0: michael@0: if (property.value == "") michael@0: modifications.removeProperty(property.name); michael@0: else michael@0: modifications.setProperty(property.name, property.value, ""); michael@0: } michael@0: michael@0: return modifications.apply().then(null, console.error); michael@0: }, michael@0: michael@0: /** michael@0: * Reverts all of the property changes made by this instance. Returns a michael@0: * promise that will be resolved when complete. michael@0: */ michael@0: revert: function() { michael@0: let modifications = this._rules[0].startModifyingProperties(); michael@0: michael@0: for (let [property, value] of this._modifications) { michael@0: if (value != "") michael@0: modifications.setProperty(property, value, ""); michael@0: else michael@0: modifications.removeProperty(property); michael@0: } michael@0: michael@0: return modifications.apply().then(null, console.error); michael@0: } michael@0: }; michael@0: michael@0: function LayoutView(aInspector, aWindow) michael@0: { michael@0: this.inspector = aInspector; michael@0: michael@0: // is not always available (for Chrome targets for example) michael@0: if (this.inspector.target.tab) { michael@0: this.browser = aInspector.target.tab.linkedBrowser; michael@0: } michael@0: michael@0: this.doc = aWindow.document; michael@0: this.sizeLabel = this.doc.querySelector(".size > span"); michael@0: this.sizeHeadingLabel = this.doc.getElementById("element-size"); michael@0: michael@0: this.init(); michael@0: } michael@0: michael@0: LayoutView.prototype = { michael@0: init: function LV_init() { michael@0: this.update = this.update.bind(this); michael@0: this.onNewNode = this.onNewNode.bind(this); michael@0: this.onNewSelection = this.onNewSelection.bind(this); michael@0: this.inspector.selection.on("new-node-front", this.onNewSelection); michael@0: this.inspector.sidebar.on("layoutview-selected", this.onNewNode); michael@0: michael@0: // Store for the different dimensions of the node. michael@0: // 'selector' refers to the element that holds the value in view.xhtml; michael@0: // 'property' is what we are measuring; michael@0: // 'value' is the computed dimension, computed in update(). michael@0: this.map = { michael@0: position: {selector: "#element-position", michael@0: property: "position", michael@0: value: undefined}, michael@0: marginTop: {selector: ".margin.top > span", michael@0: property: "margin-top", michael@0: value: undefined}, michael@0: marginBottom: {selector: ".margin.bottom > span", michael@0: property: "margin-bottom", michael@0: value: undefined}, michael@0: // margin-left is a shorthand for some internal properties, michael@0: // margin-left-ltr-source and margin-left-rtl-source for example. The michael@0: // real margin value we want is in margin-left-value michael@0: marginLeft: {selector: ".margin.left > span", michael@0: property: "margin-left", michael@0: realProperty: "margin-left-value", michael@0: value: undefined}, michael@0: // margin-right behaves the same as margin-left michael@0: marginRight: {selector: ".margin.right > span", michael@0: property: "margin-right", michael@0: realProperty: "margin-right-value", michael@0: value: undefined}, michael@0: paddingTop: {selector: ".padding.top > span", michael@0: property: "padding-top", michael@0: value: undefined}, michael@0: paddingBottom: {selector: ".padding.bottom > span", michael@0: property: "padding-bottom", michael@0: value: undefined}, michael@0: // padding-left behaves the same as margin-left michael@0: paddingLeft: {selector: ".padding.left > span", michael@0: property: "padding-left", michael@0: realProperty: "padding-left-value", michael@0: value: undefined}, michael@0: // padding-right behaves the same as margin-left michael@0: paddingRight: {selector: ".padding.right > span", michael@0: property: "padding-right", michael@0: realProperty: "padding-right-value", michael@0: value: undefined}, michael@0: borderTop: {selector: ".border.top > span", michael@0: property: "border-top-width", michael@0: value: undefined}, michael@0: borderBottom: {selector: ".border.bottom > span", michael@0: property: "border-bottom-width", michael@0: value: undefined}, michael@0: borderLeft: {selector: ".border.left > span", michael@0: property: "border-left-width", michael@0: value: undefined}, michael@0: borderRight: {selector: ".border.right > span", michael@0: property: "border-right-width", michael@0: value: undefined}, michael@0: }; michael@0: michael@0: // Make each element the dimensions editable michael@0: for (let i in this.map) { michael@0: if (i == "position") michael@0: continue; michael@0: michael@0: let dimension = this.map[i]; michael@0: editableItem({ element: this.doc.querySelector(dimension.selector) }, (element, event) => { michael@0: this.initEditor(element, event, dimension); michael@0: }); michael@0: } michael@0: michael@0: this.onNewNode(); michael@0: }, michael@0: michael@0: /** michael@0: * Called when the user clicks on one of the editable values in the layoutview michael@0: */ michael@0: initEditor: function LV_initEditor(element, event, dimension) { michael@0: let { property, realProperty } = dimension; michael@0: if (!realProperty) michael@0: realProperty = property; michael@0: let session = new EditingSession(document, this.elementRules); michael@0: let initialValue = session.getProperty(realProperty); michael@0: michael@0: let editor = new InplaceEditor({ michael@0: element: element, michael@0: initial: initialValue, michael@0: michael@0: start: (editor) => { michael@0: editor.elt.parentNode.classList.add("editing"); michael@0: }, michael@0: michael@0: change: (value) => { michael@0: if (NUMERIC.test(value)) michael@0: value += "px"; michael@0: let properties = [ michael@0: { name: property, value: value } michael@0: ] michael@0: michael@0: if (property.substring(0, 7) == "border-") { michael@0: let bprop = property.substring(0, property.length - 5) + "style"; michael@0: let style = session.getProperty(bprop); michael@0: if (!style || style == "none" || style == "hidden") michael@0: properties.push({ name: bprop, value: "solid" }); michael@0: } michael@0: michael@0: session.setProperties(properties); michael@0: }, michael@0: michael@0: done: (value, commit) => { michael@0: editor.elt.parentNode.classList.remove("editing"); michael@0: if (!commit) michael@0: session.revert(); michael@0: } michael@0: }, event); michael@0: }, michael@0: michael@0: /** michael@0: * Is the layoutview visible in the sidebar? michael@0: */ michael@0: isActive: function LV_isActive() { michael@0: return this.inspector.sidebar.getCurrentTabID() == "layoutview"; michael@0: }, michael@0: michael@0: /** michael@0: * Destroy the nodes. Remove listeners. michael@0: */ michael@0: destroy: function LV_destroy() { michael@0: this.inspector.sidebar.off("layoutview-selected", this.onNewNode); michael@0: this.inspector.selection.off("new-node-front", this.onNewSelection); michael@0: if (this.browser) { michael@0: this.browser.removeEventListener("MozAfterPaint", this.update, true); michael@0: } michael@0: this.sizeHeadingLabel = null; michael@0: this.sizeLabel = null; michael@0: this.inspector = null; michael@0: this.doc = null; michael@0: }, michael@0: michael@0: /** michael@0: * Selection 'new-node-front' event handler. michael@0: */ michael@0: onNewSelection: function() { michael@0: let done = this.inspector.updating("layoutview"); michael@0: this.onNewNode().then(done, (err) => { console.error(err); done() }); michael@0: }, michael@0: michael@0: onNewNode: function LV_onNewNode() { michael@0: if (this.isActive() && michael@0: this.inspector.selection.isConnected() && michael@0: this.inspector.selection.isElementNode()) { michael@0: this.undim(); michael@0: } else { michael@0: this.dim(); michael@0: } michael@0: return this.update(); michael@0: }, michael@0: michael@0: /** michael@0: * Hide the layout boxes. No node are selected. michael@0: */ michael@0: dim: function LV_dim() { michael@0: if (this.browser) { michael@0: this.browser.removeEventListener("MozAfterPaint", this.update, true); michael@0: } michael@0: this.trackingPaint = false; michael@0: this.doc.body.classList.add("dim"); michael@0: this.dimmed = true; michael@0: }, michael@0: michael@0: /** michael@0: * Show the layout boxes. A node is selected. michael@0: */ michael@0: undim: function LV_undim() { michael@0: if (!this.trackingPaint) { michael@0: if (this.browser) { michael@0: this.browser.addEventListener("MozAfterPaint", this.update, true); michael@0: } michael@0: this.trackingPaint = true; michael@0: } michael@0: this.doc.body.classList.remove("dim"); michael@0: this.dimmed = false; michael@0: }, michael@0: michael@0: /** michael@0: * Compute the dimensions of the node and update the values in michael@0: * the layoutview/view.xhtml document. Returns a promise that will be resolved michael@0: * when complete. michael@0: */ michael@0: update: function LV_update() { michael@0: let lastRequest = Task.spawn((function*() { michael@0: if (!this.isActive() || michael@0: !this.inspector.selection.isConnected() || michael@0: !this.inspector.selection.isElementNode()) { michael@0: return; michael@0: } michael@0: michael@0: let node = this.inspector.selection.nodeFront; michael@0: let layout = yield this.inspector.pageStyle.getLayout(node, { michael@0: autoMargins: !this.dimmed michael@0: }); michael@0: let styleEntries = yield this.inspector.pageStyle.getApplied(node, {}); michael@0: michael@0: // If a subsequent request has been made, wait for that one instead. michael@0: if (this._lastRequest != lastRequest) { michael@0: return this._lastRequest; michael@0: } michael@0: michael@0: this._lastRequest = null; michael@0: let width = layout.width; michael@0: let height = layout.height; michael@0: let newLabel = width + "x" + height; michael@0: if (this.sizeHeadingLabel.textContent != newLabel) { michael@0: this.sizeHeadingLabel.textContent = newLabel; michael@0: } michael@0: michael@0: // If the view is dimmed, no need to do anything more. michael@0: if (this.dimmed) { michael@0: this.inspector.emit("layoutview-updated"); michael@0: return null; michael@0: } michael@0: michael@0: for (let i in this.map) { michael@0: let property = this.map[i].property; michael@0: if (!(property in layout)) { michael@0: // Depending on the actor version, some properties michael@0: // might be missing. michael@0: continue; michael@0: } michael@0: let parsedValue = parseInt(layout[property]); michael@0: if (Number.isNaN(parsedValue)) { michael@0: // Not a number. We use the raw string. michael@0: // Useful for "position" for example. michael@0: this.map[i].value = layout[property]; michael@0: } else { michael@0: this.map[i].value = parsedValue; michael@0: } michael@0: } michael@0: michael@0: let margins = layout.autoMargins; michael@0: if ("top" in margins) this.map.marginTop.value = "auto"; michael@0: if ("right" in margins) this.map.marginRight.value = "auto"; michael@0: if ("bottom" in margins) this.map.marginBottom.value = "auto"; michael@0: if ("left" in margins) this.map.marginLeft.value = "auto"; michael@0: michael@0: for (let i in this.map) { michael@0: let selector = this.map[i].selector; michael@0: let span = this.doc.querySelector(selector); michael@0: if (span.textContent.length > 0 && michael@0: span.textContent == this.map[i].value) { michael@0: continue; michael@0: } michael@0: span.textContent = this.map[i].value; michael@0: this.manageOverflowingText(span); michael@0: } michael@0: michael@0: width -= this.map.borderLeft.value + this.map.borderRight.value + michael@0: this.map.paddingLeft.value + this.map.paddingRight.value; michael@0: michael@0: height -= this.map.borderTop.value + this.map.borderBottom.value + michael@0: this.map.paddingTop.value + this.map.paddingBottom.value; michael@0: michael@0: let newValue = width + "x" + height; michael@0: if (this.sizeLabel.textContent != newValue) { michael@0: this.sizeLabel.textContent = newValue; michael@0: } michael@0: michael@0: this.elementRules = [e.rule for (e of styleEntries)]; michael@0: michael@0: this.inspector.emit("layoutview-updated"); michael@0: }).bind(this)).then(null, console.error); michael@0: michael@0: return this._lastRequest = lastRequest; michael@0: }, michael@0: michael@0: showBoxModel: function(options={}) { michael@0: let toolbox = this.inspector.toolbox; michael@0: let nodeFront = this.inspector.selection.nodeFront; michael@0: michael@0: toolbox.highlighterUtils.highlightNodeFront(nodeFront, options); michael@0: }, michael@0: michael@0: hideBoxModel: function() { michael@0: let toolbox = this.inspector.toolbox; michael@0: michael@0: toolbox.highlighterUtils.unhighlight(); michael@0: }, michael@0: michael@0: manageOverflowingText: function(span) { michael@0: let classList = span.parentNode.classList; michael@0: michael@0: if (classList.contains("left") || classList.contains("right")) { michael@0: let force = span.textContent.length > LONG_TEXT_ROTATE_LIMIT; michael@0: classList.toggle("rotate", force); michael@0: } michael@0: } michael@0: }; michael@0: michael@0: let elts; michael@0: let tooltip; michael@0: michael@0: let onmouseover = function(e) { michael@0: let region = e.target.getAttribute("data-box"); michael@0: michael@0: tooltip.textContent = e.target.getAttribute("tooltip"); michael@0: this.layoutview.showBoxModel({region: region}); michael@0: michael@0: return false; michael@0: }.bind(window); michael@0: michael@0: let onmouseout = function(e) { michael@0: tooltip.textContent = ""; michael@0: this.layoutview.hideBoxModel(); michael@0: michael@0: return false; michael@0: }.bind(window); michael@0: michael@0: window.setPanel = function(panel) { michael@0: this.layoutview = new LayoutView(panel, window); michael@0: michael@0: // Tooltip mechanism michael@0: elts = document.querySelectorAll("*[tooltip]"); michael@0: tooltip = document.querySelector(".tooltip"); michael@0: for (let i = 0; i < elts.length; i++) { michael@0: let elt = elts[i]; michael@0: elt.addEventListener("mouseover", onmouseover, true); michael@0: elt.addEventListener("mouseout", onmouseout, true); michael@0: } michael@0: michael@0: // Mark document as RTL or LTR: michael@0: let chromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"]. michael@0: getService(Ci.nsIXULChromeRegistry); michael@0: let dir = chromeReg.isLocaleRTL("global"); michael@0: document.body.setAttribute("dir", dir ? "rtl" : "ltr"); michael@0: michael@0: window.parent.postMessage("layoutview-ready", "*"); michael@0: }; michael@0: michael@0: window.onunload = function() { michael@0: this.layoutview.destroy(); michael@0: if (elts) { michael@0: for (let i = 0; i < elts.length; i++) { michael@0: let elt = elts[i]; michael@0: elt.removeEventListener("mouseover", onmouseover, true); michael@0: elt.removeEventListener("mouseout", onmouseout, true); michael@0: } michael@0: } michael@0: };