/* 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;