box-model.js (13176B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const boxModelReducer = require("resource://devtools/client/inspector/boxmodel/reducers/box-model.js"); 8 const { 9 updateGeometryEditorEnabled, 10 updateLayout, 11 updateOffsetParent, 12 } = require("resource://devtools/client/inspector/boxmodel/actions/box-model.js"); 13 14 loader.lazyRequireGetter( 15 this, 16 "EditingSession", 17 "resource://devtools/client/inspector/boxmodel/utils/editing-session.js" 18 ); 19 loader.lazyRequireGetter( 20 this, 21 "InplaceEditor", 22 "resource://devtools/client/shared/inplace-editor.js", 23 true 24 ); 25 loader.lazyRequireGetter( 26 this, 27 "RulePreviewTooltip", 28 "resource://devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js" 29 ); 30 31 const NUMERIC = /^-?[\d\.]+$/; 32 33 /** 34 * A singleton instance of the box model controllers. 35 */ 36 class BoxModel { 37 /** 38 * @param {Inspector} inspector 39 * An instance of the Inspector currently loaded in the toolbox. 40 * @param {Window} window 41 * The document window of the toolbox. 42 */ 43 constructor(inspector, window) { 44 this.document = window.document; 45 this.inspector = inspector; 46 this.store = inspector.store; 47 48 this.store.injectReducer("boxModel", boxModelReducer); 49 50 this.updateBoxModel = this.updateBoxModel.bind(this); 51 52 this.onHideGeometryEditor = this.onHideGeometryEditor.bind(this); 53 this.onMarkupViewLeave = this.onMarkupViewLeave.bind(this); 54 this.onMarkupViewNodeHover = this.onMarkupViewNodeHover.bind(this); 55 this.onNewSelection = this.onNewSelection.bind(this); 56 this.onShowBoxModelEditor = this.onShowBoxModelEditor.bind(this); 57 this.onShowRulePreviewTooltip = this.onShowRulePreviewTooltip.bind(this); 58 this.onSidebarSelect = this.onSidebarSelect.bind(this); 59 this.onToggleGeometryEditor = this.onToggleGeometryEditor.bind(this); 60 61 this.inspector.selection.on("new-node-front", this.onNewSelection); 62 this.inspector.sidebar.on("select", this.onSidebarSelect); 63 } 64 /** 65 * Destruction function called when the inspector is destroyed. Removes event listeners 66 * and cleans up references. 67 */ 68 destroy() { 69 this.inspector.selection.off("new-node-front", this.onNewSelection); 70 this.inspector.sidebar.off("select", this.onSidebarSelect); 71 72 if (this._geometryEditorEventsAbortController) { 73 this._geometryEditorEventsAbortController.abort(); 74 this._geometryEditorEventsAbortController = null; 75 } 76 77 if (this._tooltip) { 78 this._tooltip.destroy(); 79 } 80 81 this.untrackReflows(); 82 83 this.elementRules = null; 84 this._highlighters = null; 85 this._tooltip = null; 86 this.document = null; 87 this.inspector = null; 88 } 89 90 get highlighters() { 91 if (!this._highlighters) { 92 // highlighters is a lazy getter in the inspector. 93 this._highlighters = this.inspector.highlighters; 94 } 95 96 return this._highlighters; 97 } 98 99 get rulePreviewTooltip() { 100 if (!this._tooltip) { 101 this._tooltip = new RulePreviewTooltip(this.inspector.toolbox.doc); 102 } 103 104 return this._tooltip; 105 } 106 107 /** 108 * Returns an object containing the box model's handler functions used in the box 109 * model's React component props. 110 */ 111 getComponentProps() { 112 return { 113 onShowBoxModelEditor: this.onShowBoxModelEditor, 114 onShowRulePreviewTooltip: this.onShowRulePreviewTooltip, 115 onToggleGeometryEditor: this.onToggleGeometryEditor, 116 }; 117 } 118 119 /** 120 * Returns true if the layout panel is visible, and false otherwise. 121 */ 122 isPanelVisible() { 123 return ( 124 this.inspector.toolbox && 125 this.inspector.sidebar && 126 this.inspector.toolbox.currentToolId === "inspector" && 127 this.inspector.sidebar.getCurrentTabID() === "layoutview" 128 ); 129 } 130 131 /** 132 * Returns true if the layout panel is visible and the current element is valid to 133 * be displayed in the view. 134 */ 135 isPanelVisibleAndNodeValid() { 136 return ( 137 this.isPanelVisible() && 138 this.inspector.selection.isConnected() && 139 this.inspector.selection.isElementNode() 140 ); 141 } 142 143 /** 144 * Starts listening to reflows in the current tab. 145 */ 146 trackReflows() { 147 this.inspector.on("reflow-in-selected-target", this.updateBoxModel); 148 } 149 150 /** 151 * Stops listening to reflows in the current tab. 152 */ 153 untrackReflows() { 154 this.inspector.off("reflow-in-selected-target", this.updateBoxModel); 155 } 156 157 /** 158 * Updates the box model panel by dispatching the new layout data. 159 * 160 * @param {string} reason 161 * Optional string describing the reason why the boxmodel is updated. 162 */ 163 updateBoxModel(reason) { 164 this._updateReasons = this._updateReasons || []; 165 if (reason) { 166 this._updateReasons.push(reason); 167 } 168 169 const lastRequest = async function () { 170 if ( 171 !this.inspector || 172 !this.isPanelVisible() || 173 !this.inspector.selection.isConnected() || 174 !this.inspector.selection.isElementNode() 175 ) { 176 return null; 177 } 178 179 const { nodeFront } = this.inspector.selection; 180 const inspectorFront = this.getCurrentInspectorFront(); 181 const { pageStyle } = inspectorFront; 182 183 let layout = await pageStyle.getLayout(nodeFront, { 184 autoMargins: true, 185 }); 186 187 const styleEntries = await pageStyle.getApplied(nodeFront, { 188 // We don't need styles applied to pseudo elements of the current node. 189 skipPseudo: true, 190 }); 191 this.elementRules = styleEntries.map(e => e.rule); 192 193 // Update the layout properties with whether or not the element's position is 194 // editable with the geometry editor. 195 const isPositionEditable = await pageStyle.isPositionEditable(nodeFront); 196 197 layout = Object.assign({}, layout, { 198 isPositionEditable, 199 }); 200 201 // Update the redux store with the latest offset parent DOM node 202 const offsetParent = 203 await inspectorFront.walker.getOffsetParent(nodeFront); 204 this.store.dispatch(updateOffsetParent(offsetParent)); 205 206 // Update the redux store with the latest layout properties and update the box 207 // model view. 208 this.store.dispatch(updateLayout(layout)); 209 210 // If a subsequent request has been made, wait for that one instead. 211 if (this._lastRequest != lastRequest) { 212 return this._lastRequest; 213 } 214 215 this.inspector.emit("boxmodel-view-updated", this._updateReasons); 216 217 this._lastRequest = null; 218 this._updateReasons = []; 219 220 return null; 221 } 222 .bind(this)() 223 .catch(error => { 224 // If we failed because we were being destroyed while waiting for a request, ignore. 225 if (this.document) { 226 console.error(error); 227 } 228 }); 229 230 this._lastRequest = lastRequest; 231 } 232 233 /** 234 * Hides the geometry editor and updates the box moodel store with the new 235 * geometry editor enabled state. 236 */ 237 onHideGeometryEditor() { 238 this.highlighters.hideGeometryEditor(); 239 this.store.dispatch(updateGeometryEditorEnabled(false)); 240 241 if (this._geometryEditorEventsAbortController) { 242 this._geometryEditorEventsAbortController.abort(); 243 this._geometryEditorEventsAbortController = null; 244 } 245 } 246 247 /** 248 * Handler function that re-shows the geometry editor for an element that already 249 * had the geometry editor enabled. This handler function is called on a "leave" event 250 * on the markup view. 251 */ 252 onMarkupViewLeave() { 253 const state = this.store.getState(); 254 const enabled = state.boxModel.geometryEditorEnabled; 255 256 if (!enabled) { 257 return; 258 } 259 260 const nodeFront = this.inspector.selection.nodeFront; 261 this.highlighters.showGeometryEditor(nodeFront); 262 } 263 264 /** 265 * Handler function that temporarily hides the geomery editor when the 266 * markup view has a "node-hover" event. 267 */ 268 onMarkupViewNodeHover() { 269 this.highlighters.hideGeometryEditor(); 270 } 271 272 /** 273 * Selection 'new-node-front' event handler. 274 */ 275 onNewSelection() { 276 if (!this.isPanelVisibleAndNodeValid()) { 277 return; 278 } 279 280 if ( 281 this.inspector.selection.isConnected() && 282 this.inspector.selection.isElementNode() 283 ) { 284 this.trackReflows(); 285 } 286 287 this.updateBoxModel("new-selection"); 288 } 289 290 /** 291 * Shows the RulePreviewTooltip when a box model editable value is hovered on the 292 * box model panel. 293 * 294 * @param {Element} target 295 * The target element. 296 * @param {string} property 297 * The name of the property. 298 */ 299 onShowRulePreviewTooltip(target, property) { 300 const { highlightProperty } = this.inspector.getPanel("ruleview").view; 301 const isHighlighted = highlightProperty(property); 302 303 // Only show the tooltip if the property is not highlighted. 304 // TODO: In the future, use an associated ruleId for toggling the tooltip instead of 305 // the Boolean returned from highlightProperty. 306 if (!isHighlighted) { 307 this.rulePreviewTooltip.show(target); 308 } 309 } 310 311 /** 312 * Shows the inplace editor when a box model editable value is clicked on the 313 * box model panel. 314 * 315 * @param {DOMNode} element 316 * The element that was clicked. 317 * @param {Event} event 318 * The event object. 319 * @param {string} property 320 * The name of the property. 321 */ 322 onShowBoxModelEditor(element, event, property) { 323 const session = new EditingSession({ 324 inspector: this.inspector, 325 doc: this.document, 326 elementRules: this.elementRules, 327 }); 328 const initialValue = session.getProperty(property); 329 330 const editor = new InplaceEditor( 331 { 332 element, 333 initial: initialValue, 334 contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE, 335 property: { 336 name: property, 337 }, 338 start: self => { 339 self.elt.parentNode.classList.add("boxmodel-editing"); 340 }, 341 change: value => { 342 if (NUMERIC.test(value)) { 343 value += "px"; 344 } 345 346 const properties = [{ name: property, value }]; 347 348 if (property.substring(0, 7) == "border-") { 349 const bprop = property.substring(0, property.length - 5) + "style"; 350 const style = session.getProperty(bprop); 351 if (!style || style == "none" || style == "hidden") { 352 properties.push({ name: bprop, value: "solid" }); 353 } 354 } 355 356 if (property.substring(0, 9) == "position-") { 357 properties[0].name = property.substring(9); 358 } 359 360 session.setProperties(properties).catch(console.error); 361 }, 362 done: (value, commit) => { 363 editor.elt.parentNode.classList.remove("boxmodel-editing"); 364 if (!commit) { 365 session.revert().then(() => { 366 session.destroy(); 367 }, console.error); 368 return; 369 } 370 371 this.updateBoxModel("editable-value-change"); 372 }, 373 cssProperties: this.inspector.cssProperties, 374 }, 375 event 376 ); 377 } 378 379 /** 380 * Handler for the inspector sidebar select event. Starts tracking reflows if the 381 * layout panel is visible. Otherwise, stop tracking reflows. Finally, refresh the box 382 * model view if it is visible. 383 */ 384 onSidebarSelect() { 385 if (!this.isPanelVisible()) { 386 this.untrackReflows(); 387 return; 388 } 389 390 if ( 391 this.inspector.selection.isConnected() && 392 this.inspector.selection.isElementNode() 393 ) { 394 this.trackReflows(); 395 } 396 397 this.updateBoxModel(); 398 } 399 400 /** 401 * Toggles on/off the geometry editor for the current element when the geometry editor 402 * toggle button is clicked. 403 */ 404 onToggleGeometryEditor() { 405 const { markup, selection, toolbox } = this.inspector; 406 const nodeFront = this.inspector.selection.nodeFront; 407 const state = this.store.getState(); 408 const enabled = !state.boxModel.geometryEditorEnabled; 409 410 this.highlighters.toggleGeometryHighlighter(nodeFront); 411 this.store.dispatch(updateGeometryEditorEnabled(enabled)); 412 413 if (enabled) { 414 this._geometryEditorEventsAbortController = new AbortController(); 415 const eventListenersConfig = { 416 signal: this._geometryEditorEventsAbortController.signal, 417 }; 418 // Hide completely the geometry editor if: 419 // - the picker is clicked 420 // - or if a new node is selected 421 toolbox.nodePicker.on( 422 "picker-started", 423 this.onHideGeometryEditor, 424 eventListenersConfig 425 ); 426 selection.on( 427 "new-node-front", 428 this.onHideGeometryEditor, 429 eventListenersConfig 430 ); 431 // Temporarily hide the geometry editor 432 markup.on("leave", this.onMarkupViewLeave, eventListenersConfig); 433 markup.on("node-hover", this.onMarkupViewNodeHover, eventListenersConfig); 434 } else if (this._geometryEditorEventsAbortController) { 435 this._geometryEditorEventsAbortController.abort(); 436 this._geometryEditorEventsAbortController = null; 437 } 438 } 439 440 getCurrentInspectorFront() { 441 return this.inspector.selection.nodeFront.inspectorFront; 442 } 443 } 444 445 module.exports = BoxModel;