diff options
Diffstat (limited to 'devtools/client/inspector/boxmodel/box-model.js')
-rw-r--r-- | devtools/client/inspector/boxmodel/box-model.js | 446 |
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; |