445 lines
13 KiB
JavaScript
445 lines
13 KiB
JavaScript
/* 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;
|