summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/boxmodel/box-model.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/inspector/boxmodel/box-model.js')
-rw-r--r--devtools/client/inspector/boxmodel/box-model.js446
1 files changed, 446 insertions, 0 deletions
diff --git a/devtools/client/inspector/boxmodel/box-model.js b/devtools/client/inspector/boxmodel/box-model.js
new file mode 100644
index 0000000000..c6870ef016
--- /dev/null
+++ b/devtools/client/inspector/boxmodel/box-model.js
@@ -0,0 +1,446 @@
+/* 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 boxModelReducer = require("resource://devtools/client/inspector/boxmodel/reducers/box-model.js");
+const {
+ updateGeometryEditorEnabled,
+ updateLayout,
+ updateOffsetParent,
+} = require("resource://devtools/client/inspector/boxmodel/actions/box-model.js");
+
+loader.lazyRequireGetter(
+ this,
+ "EditingSession",
+ "resource://devtools/client/inspector/boxmodel/utils/editing-session.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "InplaceEditor",
+ "resource://devtools/client/shared/inplace-editor.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "RulePreviewTooltip",
+ "resource://devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js"
+);
+
+const NUMERIC = /^-?[\d\.]+$/;
+
+/**
+ * A singleton instance of the box model controllers.
+ *
+ * @param {Inspector} inspector
+ * An instance of the Inspector currently loaded in the toolbox.
+ * @param {Window} window
+ * The document window of the toolbox.
+ */
+function BoxModel(inspector, window) {
+ this.document = window.document;
+ this.inspector = inspector;
+ this.store = inspector.store;
+
+ this.store.injectReducer("boxModel", boxModelReducer);
+
+ this.updateBoxModel = this.updateBoxModel.bind(this);
+
+ this.onHideGeometryEditor = this.onHideGeometryEditor.bind(this);
+ this.onMarkupViewLeave = this.onMarkupViewLeave.bind(this);
+ this.onMarkupViewNodeHover = this.onMarkupViewNodeHover.bind(this);
+ this.onNewSelection = this.onNewSelection.bind(this);
+ this.onShowBoxModelEditor = this.onShowBoxModelEditor.bind(this);
+ this.onShowRulePreviewTooltip = this.onShowRulePreviewTooltip.bind(this);
+ this.onSidebarSelect = this.onSidebarSelect.bind(this);
+ this.onToggleGeometryEditor = this.onToggleGeometryEditor.bind(this);
+
+ this.inspector.selection.on("new-node-front", this.onNewSelection);
+ this.inspector.sidebar.on("select", this.onSidebarSelect);
+}
+
+BoxModel.prototype = {
+ /**
+ * Destruction function called when the inspector is destroyed. Removes event listeners
+ * and cleans up references.
+ */
+ destroy() {
+ this.inspector.selection.off("new-node-front", this.onNewSelection);
+ this.inspector.sidebar.off("select", this.onSidebarSelect);
+
+ if (this._geometryEditorEventsAbortController) {
+ this._geometryEditorEventsAbortController.abort();
+ this._geometryEditorEventsAbortController = null;
+ }
+
+ if (this._tooltip) {
+ this._tooltip.destroy();
+ }
+
+ this.untrackReflows();
+
+ this.elementRules = null;
+ this._highlighters = null;
+ this._tooltip = null;
+ this.document = null;
+ this.inspector = null;
+ },
+
+ get highlighters() {
+ if (!this._highlighters) {
+ // highlighters is a lazy getter in the inspector.
+ this._highlighters = this.inspector.highlighters;
+ }
+
+ return this._highlighters;
+ },
+
+ get rulePreviewTooltip() {
+ if (!this._tooltip) {
+ this._tooltip = new RulePreviewTooltip(this.inspector.toolbox.doc);
+ }
+
+ return this._tooltip;
+ },
+
+ /**
+ * Returns an object containing the box model's handler functions used in the box
+ * model's React component props.
+ */
+ getComponentProps() {
+ return {
+ onShowBoxModelEditor: this.onShowBoxModelEditor,
+ onShowRulePreviewTooltip: this.onShowRulePreviewTooltip,
+ onToggleGeometryEditor: this.onToggleGeometryEditor,
+ };
+ },
+
+ /**
+ * Returns true if the layout panel is visible, and false otherwise.
+ */
+ isPanelVisible() {
+ return (
+ this.inspector.toolbox &&
+ this.inspector.sidebar &&
+ this.inspector.toolbox.currentToolId === "inspector" &&
+ this.inspector.sidebar.getCurrentTabID() === "layoutview"
+ );
+ },
+
+ /**
+ * Returns true if the layout panel is visible and the current element is valid to
+ * be displayed in the view.
+ */
+ isPanelVisibleAndNodeValid() {
+ return (
+ this.isPanelVisible() &&
+ this.inspector.selection.isConnected() &&
+ this.inspector.selection.isElementNode()
+ );
+ },
+
+ /**
+ * Starts listening to reflows in the current tab.
+ */
+ trackReflows() {
+ this.inspector.on("reflow-in-selected-target", this.updateBoxModel);
+ },
+
+ /**
+ * Stops listening to reflows in the current tab.
+ */
+ untrackReflows() {
+ this.inspector.off("reflow-in-selected-target", this.updateBoxModel);
+ },
+
+ /**
+ * Updates the box model panel by dispatching the new layout data.
+ *
+ * @param {String} reason
+ * Optional string describing the reason why the boxmodel is updated.
+ */
+ updateBoxModel(reason) {
+ this._updateReasons = this._updateReasons || [];
+ if (reason) {
+ this._updateReasons.push(reason);
+ }
+
+ const lastRequest = async function () {
+ if (
+ !this.inspector ||
+ !this.isPanelVisible() ||
+ !this.inspector.selection.isConnected() ||
+ !this.inspector.selection.isElementNode()
+ ) {
+ return null;
+ }
+
+ const { nodeFront } = this.inspector.selection;
+ const inspectorFront = this.getCurrentInspectorFront();
+ const { pageStyle } = inspectorFront;
+
+ let layout = await pageStyle.getLayout(nodeFront, {
+ autoMargins: true,
+ });
+
+ const styleEntries = await pageStyle.getApplied(nodeFront, {
+ // We don't need styles applied to pseudo elements of the current node.
+ skipPseudo: true,
+ });
+ this.elementRules = styleEntries.map(e => e.rule);
+
+ // Update the layout properties with whether or not the element's position is
+ // editable with the geometry editor.
+ const isPositionEditable = await pageStyle.isPositionEditable(nodeFront);
+
+ layout = Object.assign({}, layout, {
+ isPositionEditable,
+ });
+
+ // Update the redux store with the latest offset parent DOM node
+ const offsetParent = await inspectorFront.walker.getOffsetParent(
+ nodeFront
+ );
+ this.store.dispatch(updateOffsetParent(offsetParent));
+
+ // Update the redux store with the latest layout properties and update the box
+ // model view.
+ this.store.dispatch(updateLayout(layout));
+
+ // If a subsequent request has been made, wait for that one instead.
+ if (this._lastRequest != lastRequest) {
+ return this._lastRequest;
+ }
+
+ this.inspector.emit("boxmodel-view-updated", this._updateReasons);
+
+ this._lastRequest = null;
+ this._updateReasons = [];
+
+ return null;
+ }
+ .bind(this)()
+ .catch(error => {
+ // If we failed because we were being destroyed while waiting for a request, ignore.
+ if (this.document) {
+ console.error(error);
+ }
+ });
+
+ this._lastRequest = lastRequest;
+ },
+
+ /**
+ * Hides the geometry editor and updates the box moodel store with the new
+ * geometry editor enabled state.
+ */
+ onHideGeometryEditor() {
+ this.highlighters.hideGeometryEditor();
+ this.store.dispatch(updateGeometryEditorEnabled(false));
+
+ if (this._geometryEditorEventsAbortController) {
+ this._geometryEditorEventsAbortController.abort();
+ this._geometryEditorEventsAbortController = null;
+ }
+ },
+
+ /**
+ * Handler function that re-shows the geometry editor for an element that already
+ * had the geometry editor enabled. This handler function is called on a "leave" event
+ * on the markup view.
+ */
+ onMarkupViewLeave() {
+ const state = this.store.getState();
+ const enabled = state.boxModel.geometryEditorEnabled;
+
+ if (!enabled) {
+ return;
+ }
+
+ const nodeFront = this.inspector.selection.nodeFront;
+ this.highlighters.showGeometryEditor(nodeFront);
+ },
+
+ /**
+ * Handler function that temporarily hides the geomery editor when the
+ * markup view has a "node-hover" event.
+ */
+ onMarkupViewNodeHover() {
+ this.highlighters.hideGeometryEditor();
+ },
+
+ /**
+ * Selection 'new-node-front' event handler.
+ */
+ onNewSelection() {
+ if (!this.isPanelVisibleAndNodeValid()) {
+ return;
+ }
+
+ if (
+ this.inspector.selection.isConnected() &&
+ this.inspector.selection.isElementNode()
+ ) {
+ this.trackReflows();
+ }
+
+ this.updateBoxModel("new-selection");
+ },
+
+ /**
+ * Shows the RulePreviewTooltip when a box model editable value is hovered on the
+ * box model panel.
+ *
+ * @param {Element} target
+ * The target element.
+ * @param {String} property
+ * The name of the property.
+ */
+ onShowRulePreviewTooltip(target, property) {
+ const { highlightProperty } = this.inspector.getPanel("ruleview").view;
+ const isHighlighted = highlightProperty(property);
+
+ // Only show the tooltip if the property is not highlighted.
+ // TODO: In the future, use an associated ruleId for toggling the tooltip instead of
+ // the Boolean returned from highlightProperty.
+ if (!isHighlighted) {
+ this.rulePreviewTooltip.show(target);
+ }
+ },
+
+ /**
+ * Shows the inplace editor when a box model editable value is clicked on the
+ * box model panel.
+ *
+ * @param {DOMNode} element
+ * The element that was clicked.
+ * @param {Event} event
+ * The event object.
+ * @param {String} property
+ * The name of the property.
+ */
+ onShowBoxModelEditor(element, event, property) {
+ const session = new EditingSession({
+ inspector: this.inspector,
+ doc: this.document,
+ elementRules: this.elementRules,
+ });
+ const initialValue = session.getProperty(property);
+
+ const editor = new InplaceEditor(
+ {
+ element,
+ initial: initialValue,
+ contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE,
+ property: {
+ name: property,
+ },
+ start: self => {
+ self.elt.parentNode.classList.add("boxmodel-editing");
+ },
+ change: value => {
+ if (NUMERIC.test(value)) {
+ value += "px";
+ }
+
+ const properties = [{ name: property, value }];
+
+ if (property.substring(0, 7) == "border-") {
+ const bprop = property.substring(0, property.length - 5) + "style";
+ const style = session.getProperty(bprop);
+ if (!style || style == "none" || style == "hidden") {
+ properties.push({ name: bprop, value: "solid" });
+ }
+ }
+
+ if (property.substring(0, 9) == "position-") {
+ properties[0].name = property.substring(9);
+ }
+
+ session.setProperties(properties).catch(console.error);
+ },
+ done: (value, commit) => {
+ editor.elt.parentNode.classList.remove("boxmodel-editing");
+ if (!commit) {
+ session.revert().then(() => {
+ session.destroy();
+ }, console.error);
+ return;
+ }
+
+ this.updateBoxModel("editable-value-change");
+ },
+ cssProperties: this.inspector.cssProperties,
+ },
+ event
+ );
+ },
+
+ /**
+ * Handler for the inspector sidebar select event. Starts tracking reflows if the
+ * layout panel is visible. Otherwise, stop tracking reflows. Finally, refresh the box
+ * model view if it is visible.
+ */
+ onSidebarSelect() {
+ if (!this.isPanelVisible()) {
+ this.untrackReflows();
+ return;
+ }
+
+ if (
+ this.inspector.selection.isConnected() &&
+ this.inspector.selection.isElementNode()
+ ) {
+ this.trackReflows();
+ }
+
+ this.updateBoxModel();
+ },
+
+ /**
+ * Toggles on/off the geometry editor for the current element when the geometry editor
+ * toggle button is clicked.
+ */
+ onToggleGeometryEditor() {
+ const { markup, selection, toolbox } = this.inspector;
+ const nodeFront = this.inspector.selection.nodeFront;
+ const state = this.store.getState();
+ const enabled = !state.boxModel.geometryEditorEnabled;
+
+ this.highlighters.toggleGeometryHighlighter(nodeFront);
+ this.store.dispatch(updateGeometryEditorEnabled(enabled));
+
+ if (enabled) {
+ this._geometryEditorEventsAbortController = new AbortController();
+ const eventListenersConfig = {
+ signal: this._geometryEditorEventsAbortController.signal,
+ };
+ // Hide completely the geometry editor if:
+ // - the picker is clicked
+ // - or if a new node is selected
+ toolbox.nodePicker.on(
+ "picker-started",
+ this.onHideGeometryEditor,
+ eventListenersConfig
+ );
+ selection.on(
+ "new-node-front",
+ this.onHideGeometryEditor,
+ eventListenersConfig
+ );
+ // Temporarily hide the geometry editor
+ markup.on("leave", this.onMarkupViewLeave, eventListenersConfig);
+ markup.on("node-hover", this.onMarkupViewNodeHover, eventListenersConfig);
+ } else if (this._geometryEditorEventsAbortController) {
+ this._geometryEditorEventsAbortController.abort();
+ this._geometryEditorEventsAbortController = null;
+ }
+ },
+
+ getCurrentInspectorFront() {
+ return this.inspector.selection.nodeFront.inspectorFront;
+ },
+};
+
+module.exports = BoxModel;