diff options
Diffstat (limited to 'devtools/client/inspector/components/box-model.js')
-rw-r--r-- | devtools/client/inspector/components/box-model.js | 841 |
1 files changed, 841 insertions, 0 deletions
diff --git a/devtools/client/inspector/components/box-model.js b/devtools/client/inspector/components/box-model.js new file mode 100644 index 000000000..fc36fac71 --- /dev/null +++ b/devtools/client/inspector/components/box-model.js @@ -0,0 +1,841 @@ +/* -*- 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; |