/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ft=javascript ts=2 et sw=2 tw=80: */ /* 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/. */ "use strict"; const {Task} = require("devtools/shared/task"); const {InplaceEditor, editableItem} = require("devtools/client/shared/inplace-editor"); const {ReflowFront} = require("devtools/shared/fronts/reflow"); const {LocalizationHelper} = require("devtools/shared/l10n"); const {getCssProperties} = require("devtools/shared/fronts/css-properties"); const STRINGS_URI = "devtools/client/locales/shared.properties"; const STRINGS_INSPECTOR = "devtools/shared/locales/styleinspector.properties"; const SHARED_L10N = new LocalizationHelper(STRINGS_URI); const INSPECTOR_L10N = new LocalizationHelper(STRINGS_INSPECTOR); const NUMERIC = /^-?[\d\.]+$/; const LONG_TEXT_ROTATE_LIMIT = 3; /** * An instance of EditingSession tracks changes that have been made during the * modification of box model values. All of these changes can be reverted by * calling revert. The main parameter is the BoxModelView that created it. * * @param inspector The inspector panel. * @param doc A DOM document that can be used to test style rules. * @param rules An array of the style rules defined for the node being * edited. These should be in order of priority, least * important first. */ function EditingSession({inspector, doc, elementRules}) { this._doc = doc; this._rules = elementRules; this._modifications = new Map(); this._cssProperties = getCssProperties(inspector.toolbox); } EditingSession.prototype = { /** * Gets the value of a single property from the CSS rule. * * @param {StyleRuleFront} rule The CSS rule. * @param {String} property The name of the property. * @return {String} The value. */ getPropertyFromRule: function (rule, property) { // Use the parsed declarations in the StyleRuleFront object if available. let index = this.getPropertyIndex(property, rule); if (index !== -1) { return rule.declarations[index].value; } // Fallback to parsing the cssText locally otherwise. let dummyStyle = this._element.style; dummyStyle.cssText = rule.cssText; return dummyStyle.getPropertyValue(property); }, /** * Returns the current value for a property as a string or the empty string if * no style rules affect the property. * * @param property The name of the property as a string */ getProperty: function (property) { // Create a hidden element for getPropertyFromRule to use let div = this._doc.createElement("div"); div.setAttribute("style", "display: none"); this._doc.getElementById("sidebar-panel-computedview").appendChild(div); this._element = this._doc.createElement("p"); div.appendChild(this._element); // As the rules are in order of priority we can just iterate until we find // the first that defines a value for the property and return that. for (let rule of this._rules) { let value = this.getPropertyFromRule(rule, property); if (value !== "") { div.remove(); return value; } } div.remove(); return ""; }, /** * Get the index of a given css property name in a CSS rule. * Or -1, if there are no properties in the rule yet. * @param {String} name The property name. * @param {StyleRuleFront} rule Optional, defaults to the element style rule. * @return {Number} The property index in the rule. */ getPropertyIndex: function (name, rule = this._rules[0]) { let elementStyleRule = this._rules[0]; if (!elementStyleRule.declarations.length) { return -1; } return elementStyleRule.declarations.findIndex(p => p.name === name); }, /** * Sets a number of properties on the node. * @param properties An array of properties, each is an object with name and * value properties. If the value is "" then the property * is removed. * @return {Promise} Resolves when the modifications are complete. */ setProperties: Task.async(function* (properties) { for (let property of properties) { // Get a RuleModificationList or RuleRewriter helper object from the // StyleRuleActor to make changes to CSS properties. // Note that RuleRewriter doesn't support modifying several properties at // once, so we do this in a sequence here. let modifications = this._rules[0].startModifyingProperties( this._cssProperties); // Remember the property so it can be reverted. if (!this._modifications.has(property.name)) { this._modifications.set(property.name, this.getPropertyFromRule(this._rules[0], property.name)); } // Find the index of the property to be changed, or get the next index to // insert the new property at. let index = this.getPropertyIndex(property.name); if (index === -1) { index = this._rules[0].declarations.length; } if (property.value == "") { modifications.removeProperty(index, property.name); } else { modifications.setProperty(index, property.name, property.value, ""); } yield modifications.apply(); } }), /** * Reverts all of the property changes made by this instance. * @return {Promise} Resolves when all properties have been reverted. */ revert: Task.async(function* () { // Revert each property that we modified previously, one by one. See // setProperties for information about why. for (let [property, value] of this._modifications) { let modifications = this._rules[0].startModifyingProperties( this._cssProperties); // Find the index of the property to be reverted. let index = this.getPropertyIndex(property); if (value != "") { // If the property doesn't exist anymore, insert at the beginning of the // rule. if (index === -1) { index = 0; } modifications.setProperty(index, property, value, ""); } else { // If the property doesn't exist anymore, no need to remove it. It had // not been added after all. if (index === -1) { continue; } modifications.removeProperty(index, property); } yield modifications.apply(); } }), destroy: function () { this._doc = null; this._rules = null; this._modifications.clear(); } }; /** * The box model view * @param {InspectorPanel} inspector * An instance of the inspector-panel currently loaded in the toolbox * @param {Document} document * The document that will contain the box model view. */ function BoxModelView(inspector, document) { this.inspector = inspector; this.doc = document; this.wrapper = this.doc.getElementById("boxmodel-wrapper"); this.container = this.doc.getElementById("boxmodel-container"); this.expander = this.doc.getElementById("boxmodel-expander"); this.sizeLabel = this.doc.querySelector(".boxmodel-size > span"); this.sizeHeadingLabel = this.doc.getElementById("boxmodel-element-size"); this._geometryEditorHighlighter = null; this._cssProperties = getCssProperties(inspector.toolbox); this.init(); } BoxModelView.prototype = { init: function () { this.update = this.update.bind(this); this.onNewSelection = this.onNewSelection.bind(this); this.inspector.selection.on("new-node-front", this.onNewSelection); this.onNewNode = this.onNewNode.bind(this); this.inspector.sidebar.on("computedview-selected", this.onNewNode); this.onSidebarSelect = this.onSidebarSelect.bind(this); this.inspector.sidebar.on("select", this.onSidebarSelect); this.onToggleExpander = this.onToggleExpander.bind(this); this.expander.addEventListener("click", this.onToggleExpander); let header = this.doc.getElementById("boxmodel-header"); header.addEventListener("dblclick", this.onToggleExpander); this.onFilterComputedView = this.onFilterComputedView.bind(this); this.inspector.on("computed-view-filtered", this.onFilterComputedView); this.onPickerStarted = this.onPickerStarted.bind(this); this.onMarkupViewLeave = this.onMarkupViewLeave.bind(this); this.onMarkupViewNodeHover = this.onMarkupViewNodeHover.bind(this); this.onWillNavigate = this.onWillNavigate.bind(this); this.initBoxModelHighlighter(); // Store for the different dimensions of the node. // 'selector' refers to the element that holds the value; // 'property' is what we are measuring; // 'value' is the computed dimension, computed in update(). this.map = { position: { selector: "#boxmodel-element-position", property: "position", value: undefined }, marginTop: { selector: ".boxmodel-margin.boxmodel-top > span", property: "margin-top", value: undefined }, marginBottom: { selector: ".boxmodel-margin.boxmodel-bottom > span", property: "margin-bottom", value: undefined }, marginLeft: { selector: ".boxmodel-margin.boxmodel-left > span", property: "margin-left", value: undefined }, marginRight: { selector: ".boxmodel-margin.boxmodel-right > span", property: "margin-right", value: undefined }, paddingTop: { selector: ".boxmodel-padding.boxmodel-top > span", property: "padding-top", value: undefined }, paddingBottom: { selector: ".boxmodel-padding.boxmodel-bottom > span", property: "padding-bottom", value: undefined }, paddingLeft: { selector: ".boxmodel-padding.boxmodel-left > span", property: "padding-left", value: undefined }, paddingRight: { selector: ".boxmodel-padding.boxmodel-right > span", property: "padding-right", value: undefined }, borderTop: { selector: ".boxmodel-border.boxmodel-top > span", property: "border-top-width", value: undefined }, borderBottom: { selector: ".boxmodel-border.boxmodel-bottom > span", property: "border-bottom-width", value: undefined }, borderLeft: { selector: ".boxmodel-border.boxmodel-left > span", property: "border-left-width", value: undefined }, borderRight: { selector: ".boxmodel-border.boxmodel-right > span", property: "border-right-width", value: undefined } }; // Make each element the dimensions editable for (let i in this.map) { if (i == "position") { continue; } let dimension = this.map[i]; editableItem({ element: this.doc.querySelector(dimension.selector) }, (element, event) => { this.initEditor(element, event, dimension); }); } this.onNewNode(); let nodeGeometry = this.doc.getElementById("layout-geometry-editor"); this.onGeometryButtonClick = this.onGeometryButtonClick.bind(this); nodeGeometry.addEventListener("click", this.onGeometryButtonClick); }, initBoxModelHighlighter: function () { let highlightElts = this.doc.querySelectorAll("#boxmodel-container *[title]"); this.onHighlightMouseOver = this.onHighlightMouseOver.bind(this); this.onHighlightMouseOut = this.onHighlightMouseOut.bind(this); for (let element of highlightElts) { element.addEventListener("mouseover", this.onHighlightMouseOver, true); element.addEventListener("mouseout", this.onHighlightMouseOut, true); } }, /** * Start listening to reflows in the current tab. */ trackReflows: function () { if (!this.reflowFront) { let { target } = this.inspector; if (target.form.reflowActor) { this.reflowFront = ReflowFront(target.client, target.form); } else { return; } } this.reflowFront.on("reflows", this.update); this.reflowFront.start(); }, /** * Stop listening to reflows in the current tab. */ untrackReflows: function () { if (!this.reflowFront) { return; } this.reflowFront.off("reflows", this.update); this.reflowFront.stop(); }, /** * Called when the user clicks on one of the editable values in the box model view */ initEditor: function (element, event, dimension) { let { property } = dimension; let session = new EditingSession(this); let initialValue = session.getProperty(property); let editor = new InplaceEditor({ element: element, initial: initialValue, contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE, property: { name: dimension.property }, start: self => { self.elt.parentNode.classList.add("boxmodel-editing"); }, change: value => { if (NUMERIC.test(value)) { value += "px"; } let properties = [ { name: property, value: value } ]; if (property.substring(0, 7) == "border-") { let bprop = property.substring(0, property.length - 5) + "style"; let style = session.getProperty(bprop); if (!style || style == "none" || style == "hidden") { properties.push({ name: bprop, value: "solid" }); } } session.setProperties(properties).catch(e => console.error(e)); }, done: (value, commit) => { editor.elt.parentNode.classList.remove("boxmodel-editing"); if (!commit) { session.revert().then(() => { session.destroy(); }, e => console.error(e)); } }, contextMenu: this.inspector.onTextBoxContextMenu, cssProperties: this._cssProperties }, event); }, /** * Is the BoxModelView visible in the sidebar. * @return {Boolean} */ isViewVisible: function () { return this.inspector && this.inspector.sidebar.getCurrentTabID() == "computedview"; }, /** * Is the BoxModelView visible in the sidebar and is the current node valid to * be displayed in the view. * @return {Boolean} */ isViewVisibleAndNodeValid: function () { return this.isViewVisible() && this.inspector.selection.isConnected() && this.inspector.selection.isElementNode(); }, /** * Destroy the nodes. Remove listeners. */ destroy: function () { let highlightElts = this.doc.querySelectorAll("#boxmodel-container *[title]"); for (let element of highlightElts) { element.removeEventListener("mouseover", this.onHighlightMouseOver, true); element.removeEventListener("mouseout", this.onHighlightMouseOut, true); } this.expander.removeEventListener("click", this.onToggleExpander); let header = this.doc.getElementById("boxmodel-header"); header.removeEventListener("dblclick", this.onToggleExpander); let nodeGeometry = this.doc.getElementById("layout-geometry-editor"); nodeGeometry.removeEventListener("click", this.onGeometryButtonClick); this.inspector.off("picker-started", this.onPickerStarted); // Inspector Panel will destroy `markup` object on "will-navigate" event, // therefore we have to check if it's still available in case BoxModelView // is destroyed immediately after. if (this.inspector.markup) { this.inspector.markup.off("leave", this.onMarkupViewLeave); this.inspector.markup.off("node-hover", this.onMarkupViewNodeHover); } this.inspector.sidebar.off("computedview-selected", this.onNewNode); this.inspector.selection.off("new-node-front", this.onNewSelection); this.inspector.sidebar.off("select", this.onSidebarSelect); this.inspector.target.off("will-navigate", this.onWillNavigate); this.inspector.off("computed-view-filtered", this.onFilterComputedView); this.inspector = null; this.doc = null; this.wrapper = null; this.container = null; this.expander = null; this.sizeLabel = null; this.sizeHeadingLabel = null; if (this.reflowFront) { this.untrackReflows(); this.reflowFront.destroy(); this.reflowFront = null; } }, onSidebarSelect: function (e, sidebar) { this.setActive(sidebar === "computedview"); }, /** * Selection 'new-node-front' event handler. */ onNewSelection: function () { let done = this.inspector.updating("computed-view"); this.onNewNode() .then(() => this.hideGeometryEditor()) .then(done, (err) => { console.error(err); done(); }).catch(console.error); }, /** * @return a promise that resolves when the view has been updated */ onNewNode: function () { this.setActive(this.isViewVisibleAndNodeValid()); return this.update(); }, onHighlightMouseOver: function (e) { let region = e.target.getAttribute("data-box"); if (!region) { return; } this.showBoxModel({ region, showOnly: region, onlyRegionArea: true }); }, onHighlightMouseOut: function () { this.hideBoxModel(); }, onGeometryButtonClick: function ({target}) { if (target.hasAttribute("checked")) { target.removeAttribute("checked"); this.hideGeometryEditor(); } else { target.setAttribute("checked", "true"); this.showGeometryEditor(); } }, onPickerStarted: function () { this.hideGeometryEditor(); }, onToggleExpander: function () { let isOpen = this.expander.hasAttribute("open"); if (isOpen) { this.container.hidden = true; this.expander.removeAttribute("open"); } else { this.container.hidden = false; this.expander.setAttribute("open", ""); } }, onMarkupViewLeave: function () { this.showGeometryEditor(true); }, onMarkupViewNodeHover: function () { this.hideGeometryEditor(false); }, onWillNavigate: function () { this._geometryEditorHighlighter.release().catch(console.error); this._geometryEditorHighlighter = null; }, /** * Event handler that responds to the computed view being filtered * @param {String} reason * @param {Boolean} hidden * Whether or not to hide the box model wrapper */ onFilterComputedView: function (reason, hidden) { this.wrapper.hidden = hidden; }, /** * Stop tracking reflows and hide all values when no node is selected or the * box model view is hidden, otherwise track reflows and show values. * @param {Boolean} isActive */ setActive: function (isActive) { if (isActive === this.isActive) { return; } this.isActive = isActive; if (isActive) { this.trackReflows(); } else { this.untrackReflows(); } }, /** * Compute the dimensions of the node and update the values in * the inspector.xul document. * @return a promise that will be resolved when complete. */ update: function () { let lastRequest = Task.spawn((function* () { if (!this.isViewVisibleAndNodeValid()) { this.wrapper.hidden = true; this.inspector.emit("boxmodel-view-updated"); return null; } let node = this.inspector.selection.nodeFront; let layout = yield this.inspector.pageStyle.getLayout(node, { autoMargins: this.isActive }); let styleEntries = yield this.inspector.pageStyle.getApplied(node, {}); yield this.updateGeometryButton(); // If a subsequent request has been made, wait for that one instead. if (this._lastRequest != lastRequest) { return this._lastRequest; } this._lastRequest = null; let width = layout.width; let height = layout.height; let newLabel = SHARED_L10N.getFormatStr("dimensions", width, height); if (this.sizeHeadingLabel.textContent != newLabel) { this.sizeHeadingLabel.textContent = newLabel; } for (let i in this.map) { let property = this.map[i].property; if (!(property in layout)) { // Depending on the actor version, some properties // might be missing. continue; } let parsedValue = parseFloat(layout[property]); if (Number.isNaN(parsedValue)) { // Not a number. We use the raw string. // Useful for "position" for example. this.map[i].value = layout[property]; } else { this.map[i].value = parsedValue; } } let margins = layout.autoMargins; if ("top" in margins) { this.map.marginTop.value = "auto"; } if ("right" in margins) { this.map.marginRight.value = "auto"; } if ("bottom" in margins) { this.map.marginBottom.value = "auto"; } if ("left" in margins) { this.map.marginLeft.value = "auto"; } for (let i in this.map) { let selector = this.map[i].selector; let span = this.doc.querySelector(selector); this.updateSourceRuleTooltip(span, this.map[i].property, styleEntries); if (span.textContent.length > 0 && span.textContent == this.map[i].value) { continue; } span.textContent = this.map[i].value; this.manageOverflowingText(span); } width -= this.map.borderLeft.value + this.map.borderRight.value + this.map.paddingLeft.value + this.map.paddingRight.value; width = parseFloat(width.toPrecision(6)); height -= this.map.borderTop.value + this.map.borderBottom.value + this.map.paddingTop.value + this.map.paddingBottom.value; height = parseFloat(height.toPrecision(6)); let newValue = width + "\u00D7" + height; if (this.sizeLabel.textContent != newValue) { this.sizeLabel.textContent = newValue; } this.elementRules = styleEntries.map(e => e.rule); this.wrapper.hidden = false; this.inspector.emit("boxmodel-view-updated"); return null; }).bind(this)).catch(console.error); this._lastRequest = lastRequest; return this._lastRequest; }, /** * Update the text in the tooltip shown when hovering over a value to provide * information about the source CSS rule that sets this value. * @param {DOMNode} el The element that will receive the tooltip. * @param {String} property The name of the CSS property for the tooltip. * @param {Array} rules An array of applied rules retrieved by * styleActor.getApplied. */ updateSourceRuleTooltip: function (el, property, rules) { // Dummy element used to parse the cssText of applied rules. let dummyEl = this.doc.createElement("div"); // Rules are in order of priority so iterate until we find the first that // defines a value for the property. let sourceRule, value; for (let {rule} of rules) { dummyEl.style.cssText = rule.cssText; value = dummyEl.style.getPropertyValue(property); if (value !== "") { sourceRule = rule; break; } } let title = property; if (sourceRule && sourceRule.selectors) { title += "\n" + sourceRule.selectors.join(", "); } if (sourceRule && sourceRule.parentStyleSheet) { if (sourceRule.parentStyleSheet.href) { title += "\n" + sourceRule.parentStyleSheet.href + ":" + sourceRule.line; } else { title += "\n" + INSPECTOR_L10N.getStr("rule.sourceInline") + ":" + sourceRule.line; } } el.setAttribute("title", title); }, /** * Show the box-model highlighter on the currently selected element * @param {Object} options Options passed to the highlighter actor */ showBoxModel: function (options = {}) { let toolbox = this.inspector.toolbox; let nodeFront = this.inspector.selection.nodeFront; toolbox.highlighterUtils.highlightNodeFront(nodeFront, options); }, /** * Hide the box-model highlighter on the currently selected element */ hideBoxModel: function () { let toolbox = this.inspector.toolbox; toolbox.highlighterUtils.unhighlight(); }, /** * Show the geometry editor highlighter on the currently selected element * @param {Boolean} [showOnlyIfActive=false] * Indicates if the Geometry Editor should be shown only if it's active but * hidden. */ showGeometryEditor: function (showOnlyIfActive = false) { let toolbox = this.inspector.toolbox; let nodeFront = this.inspector.selection.nodeFront; let nodeGeometry = this.doc.getElementById("layout-geometry-editor"); let isActive = nodeGeometry.hasAttribute("checked"); if (showOnlyIfActive && !isActive) { return; } if (this._geometryEditorHighlighter) { this._geometryEditorHighlighter.show(nodeFront).catch(console.error); return; } // instantiate Geometry Editor highlighter toolbox.highlighterUtils .getHighlighterByType("GeometryEditorHighlighter").then(highlighter => { highlighter.show(nodeFront).catch(console.error); this._geometryEditorHighlighter = highlighter; // Hide completely the geometry editor if the picker is clicked toolbox.on("picker-started", this.onPickerStarted); // Temporary hide the geometry editor this.inspector.markup.on("leave", this.onMarkupViewLeave); this.inspector.markup.on("node-hover", this.onMarkupViewNodeHover); // Release the actor on will-navigate event this.inspector.target.once("will-navigate", this.onWillNavigate); }); }, /** * Hide the geometry editor highlighter on the currently selected element * @param {Boolean} [updateButton=true] * Indicates if the Geometry Editor's button needs to be unchecked too */ hideGeometryEditor: function (updateButton = true) { if (this._geometryEditorHighlighter) { this._geometryEditorHighlighter.hide().catch(console.error); } if (updateButton) { let nodeGeometry = this.doc.getElementById("layout-geometry-editor"); nodeGeometry.removeAttribute("checked"); } }, /** * Update the visibility and the state of the geometry editor button, * based on the selected node. */ updateGeometryButton: Task.async(function* () { let node = this.inspector.selection.nodeFront; let isEditable = false; if (node) { isEditable = yield this.inspector.pageStyle.isPositionEditable(node); } let nodeGeometry = this.doc.getElementById("layout-geometry-editor"); nodeGeometry.style.visibility = isEditable ? "visible" : "hidden"; }), manageOverflowingText: function (span) { let classList = span.parentNode.classList; if (classList.contains("boxmodel-left") || classList.contains("boxmodel-right")) { let force = span.textContent.length > LONG_TEXT_ROTATE_LIMIT; classList.toggle("boxmodel-rotate", force); } } }; exports.BoxModelView = BoxModelView;