flexbox.js (18068B)
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 { throttle } = require("resource://devtools/shared/throttle.js"); 8 9 const { 10 clearFlexbox, 11 updateFlexbox, 12 updateFlexboxColor, 13 updateFlexboxHighlighted, 14 } = require("resource://devtools/client/inspector/flexbox/actions/flexbox.js"); 15 const flexboxReducer = require("resource://devtools/client/inspector/flexbox/reducers/flexbox.js"); 16 17 loader.lazyRequireGetter( 18 this, 19 "parseURL", 20 "resource://devtools/client/shared/source-utils.js", 21 true 22 ); 23 loader.lazyRequireGetter( 24 this, 25 "asyncStorage", 26 "resource://devtools/shared/async-storage.js" 27 ); 28 29 const FLEXBOX_COLOR = "#9400FF"; 30 31 class FlexboxInspector { 32 constructor(inspector, window) { 33 this.document = window.document; 34 this.inspector = inspector; 35 this.selection = inspector.selection; 36 this.store = inspector.store; 37 38 this.store.injectReducer("flexbox", flexboxReducer); 39 40 this.onHighlighterShown = this.onHighlighterShown.bind(this); 41 this.onHighlighterHidden = this.onHighlighterHidden.bind(this); 42 this.onNavigate = this.onNavigate.bind(this); 43 this.onReflow = throttle(this.onReflow, 500, this); 44 this.onSetFlexboxOverlayColor = this.onSetFlexboxOverlayColor.bind(this); 45 this.onSidebarSelect = this.onSidebarSelect.bind(this); 46 this.onUpdatePanel = this.onUpdatePanel.bind(this); 47 48 this.init(); 49 } 50 51 init() { 52 if (!this.inspector) { 53 return; 54 } 55 56 this.inspector.highlighters.on( 57 "highlighter-shown", 58 this.onHighlighterShown 59 ); 60 this.inspector.highlighters.on( 61 "highlighter-hidden", 62 this.onHighlighterHidden 63 ); 64 this.inspector.sidebar.on("select", this.onSidebarSelect); 65 66 this.onSidebarSelect(); 67 } 68 69 destroy() { 70 this.selection.off("new-node-front", this.onUpdatePanel); 71 this.inspector.off("new-root", this.onNavigate); 72 this.inspector.off("reflow-in-selected-target", this.onReflow); 73 this.inspector.highlighters.off( 74 "highlighter-shown", 75 this.onHighlighterShown 76 ); 77 this.inspector.highlighters.off( 78 "highlighter-hidden", 79 this.onHighlighterHidden 80 ); 81 this.inspector.sidebar.off("select", this.onSidebarSelect); 82 83 this._customHostColors = null; 84 this._overlayColor = null; 85 this.document = null; 86 this.inspector = null; 87 this.selection = null; 88 this.store = null; 89 } 90 91 getComponentProps() { 92 return { 93 onSetFlexboxOverlayColor: this.onSetFlexboxOverlayColor, 94 }; 95 } 96 97 /** 98 * Returns an object containing the custom flexbox colors for different hosts. 99 * 100 * @return {object} that maps a host name to a custom flexbox color for a given host. 101 */ 102 async getCustomHostColors() { 103 if (this._customHostColors) { 104 return this._customHostColors; 105 } 106 107 // Cache the custom host colors to avoid refetching from async storage. 108 this._customHostColors = 109 (await asyncStorage.getItem("flexboxInspectorHostColors")) || {}; 110 return this._customHostColors; 111 } 112 113 /** 114 * Returns the flex container properties for a given node. If the given node is a flex 115 * item, it attempts to fetch the flex container of the parent node of the given node. 116 * 117 * @param {NodeFront} nodeFront 118 * The NodeFront to fetch the flex container properties. 119 * @param {boolean} onlyLookAtParents 120 * Whether or not to only consider the parent node of the given node. 121 * @return {object} consisting of the given node's flex container's properties. 122 */ 123 async getFlexContainerProps(nodeFront, onlyLookAtParents = false) { 124 const layoutFront = await nodeFront.walkerFront.getLayoutInspector(); 125 const flexboxFront = await layoutFront.getCurrentFlexbox( 126 nodeFront, 127 onlyLookAtParents 128 ); 129 130 if (!flexboxFront) { 131 return null; 132 } 133 134 // If the FlexboxFront doesn't yet have access to the NodeFront for its container, 135 // then get it from the walker. This happens when the walker hasn't seen this 136 // particular DOM Node in the tree yet or when we are connected to an older server. 137 let containerNodeFront = flexboxFront.containerNodeFront; 138 if (!containerNodeFront) { 139 containerNodeFront = await flexboxFront.walkerFront.getNodeFromActor( 140 flexboxFront.actorID, 141 ["containerEl"] 142 ); 143 } 144 145 const flexItems = await this.getFlexItems(flexboxFront); 146 147 // If the current selected node is a flex item, display its flex item sizing 148 // properties. 149 let flexItemShown = null; 150 if (onlyLookAtParents) { 151 flexItemShown = this.selection.nodeFront.actorID; 152 } else { 153 const selectedFlexItem = flexItems.find( 154 item => item.nodeFront === this.selection.nodeFront 155 ); 156 if (selectedFlexItem) { 157 flexItemShown = selectedFlexItem.nodeFront.actorID; 158 } 159 } 160 161 return { 162 actorID: flexboxFront.actorID, 163 flexItems, 164 flexItemShown, 165 isFlexItemContainer: onlyLookAtParents, 166 nodeFront: containerNodeFront, 167 properties: flexboxFront.properties, 168 }; 169 } 170 171 /** 172 * Returns an array of flex items object for the given flex container front. 173 * 174 * @param {FlexboxFront} flexboxFront 175 * A flex container FlexboxFront. 176 * @return {Array} of objects containing the flex item front properties. 177 */ 178 async getFlexItems(flexboxFront) { 179 const flexItemFronts = await flexboxFront.getFlexItems(); 180 const flexItems = []; 181 182 for (const flexItemFront of flexItemFronts) { 183 // Fetch the NodeFront of the flex items. 184 let itemNodeFront = flexItemFront.nodeFront; 185 if (!itemNodeFront) { 186 itemNodeFront = await flexItemFront.walkerFront.getNodeFromActor( 187 flexItemFront.actorID, 188 ["element"] 189 ); 190 } 191 192 flexItems.push({ 193 actorID: flexItemFront.actorID, 194 computedStyle: flexItemFront.computedStyle, 195 flexItemSizing: flexItemFront.flexItemSizing, 196 nodeFront: itemNodeFront, 197 properties: flexItemFront.properties, 198 }); 199 } 200 201 return flexItems; 202 } 203 204 /** 205 * Returns the custom overlay color for the current host or the default flexbox color. 206 * 207 * @return {string} overlay color. 208 */ 209 async getOverlayColor() { 210 if (this._overlayColor) { 211 return this._overlayColor; 212 } 213 214 // Cache the overlay color for the current host to avoid repeatably parsing the host 215 // and fetching the custom color from async storage. 216 const customColors = await this.getCustomHostColors(); 217 const currentUrl = this.inspector.currentTarget.url; 218 // Get the hostname, if there is no hostname, fall back on protocol 219 // ex: `data:` uri, and `about:` pages 220 const hostname = 221 parseURL(currentUrl).hostname || parseURL(currentUrl).protocol; 222 this._overlayColor = customColors[hostname] 223 ? customColors[hostname] 224 : FLEXBOX_COLOR; 225 return this._overlayColor; 226 } 227 228 /** 229 * Returns true if the layout panel is visible, and false otherwise. 230 */ 231 isPanelVisible() { 232 return ( 233 this.inspector && 234 this.inspector.toolbox && 235 this.inspector.sidebar && 236 this.inspector.toolbox.currentToolId === "inspector" && 237 this.inspector.sidebar.getCurrentTabID() === "layoutview" 238 ); 239 } 240 241 /** 242 * Handler for "highlighter-shown" events emitted by HighlightersOverlay. 243 * If the event is dispatched on behalf of a flex highlighter, toggle the 244 * corresponding flex container's highlighted state in the Redux store. 245 * 246 * @param {object} data 247 * Object with data associated with the highlighter event. 248 * {NodeFront} data.nodeFront 249 * The NodeFront of the flex container element for which the flexbox 250 * highlighter is shown for. 251 * {String} data.type 252 * Highlighter type 253 */ 254 onHighlighterShown(data) { 255 if (data.type === this.inspector.highlighters.TYPES.FLEXBOX) { 256 this.onHighlighterChange(true, data.nodeFront); 257 } 258 } 259 260 /** 261 * Handler for "highlighter-shown" events emitted by HighlightersOverlay. 262 * If the event is dispatched on behalf of a flex highlighter, toggle the 263 * corresponding flex container's highlighted state in the Redux store. 264 * 265 * @param {object} data 266 * Object with data associated with the highlighter event. 267 * {NodeFront} data.nodeFront 268 * The NodeFront of the flex container element for which the flexbox 269 * highlighter was previously shown for. 270 * {String} data.type 271 * Highlighter type 272 */ 273 onHighlighterHidden(data) { 274 if (data.type === this.inspector.highlighters.TYPES.FLEXBOX) { 275 this.onHighlighterChange(false, data.nodeFront); 276 } 277 } 278 279 /** 280 * Updates the flex container highlighted state in the Redux store if the provided 281 * NodeFront is the current selected flex container. 282 * 283 * @param {boolean} highlighted 284 * Whether the change is to highlight or hide the overlay. 285 * @param {NodeFront} nodeFront 286 * The NodeFront of the flex container element for which the flexbox 287 * highlighter is shown for. 288 */ 289 onHighlighterChange(highlighted, nodeFront) { 290 const { flexbox } = this.store.getState(); 291 292 if ( 293 flexbox.flexContainer.nodeFront === nodeFront && 294 flexbox.highlighted !== highlighted 295 ) { 296 this.store.dispatch(updateFlexboxHighlighted(highlighted)); 297 } 298 } 299 300 /** 301 * Handler for the "new-root" event fired by the inspector. Clears the cached overlay 302 * color for the flexbox highlighter and updates the panel. 303 */ 304 onNavigate() { 305 this._overlayColor = null; 306 this.onUpdatePanel(); 307 } 308 309 /** 310 * Handler for reflow events fired by the inspector when a node is selected. On reflows, 311 * update the flexbox panel because the shape of the flexbox on the page may have 312 * changed. 313 */ 314 async onReflow() { 315 if ( 316 !this.isPanelVisible() || 317 !this.store || 318 !this.selection.nodeFront || 319 this._isUpdating 320 ) { 321 return; 322 } 323 324 try { 325 const flexContainer = await this.getFlexContainerProps( 326 this.selection.nodeFront 327 ); 328 329 // Clear the flexbox panel if there is no flex container for the current node 330 // selection. 331 if (!flexContainer) { 332 this.store.dispatch(clearFlexbox()); 333 return; 334 } 335 336 const { flexbox } = this.store.getState(); 337 338 // Compare the new flexbox state of the current selected nodeFront with the old 339 // flexbox state to determine if we need to update. 340 if (hasFlexContainerChanged(flexbox.flexContainer, flexContainer)) { 341 this.update(flexContainer); 342 return; 343 } 344 345 let flexItemContainer = null; 346 // If the current selected node is also the flex container node, check if it is 347 // a flex item of a parent flex container. 348 if (flexContainer.nodeFront === this.selection.nodeFront) { 349 flexItemContainer = await this.getFlexContainerProps( 350 this.selection.nodeFront, 351 true 352 ); 353 } 354 355 // Compare the new and old state of the parent flex container properties. 356 if ( 357 hasFlexContainerChanged(flexbox.flexItemContainer, flexItemContainer) 358 ) { 359 this.update(flexContainer, flexItemContainer); 360 } 361 } catch (e) { 362 // This call might fail if called asynchrously after the toolbox is finished 363 // closing. 364 } 365 } 366 367 /** 368 * Handler for a change in the flexbox overlay color picker for a flex container. 369 * 370 * @param {string} color 371 * A hex string representing the color to use. 372 */ 373 async onSetFlexboxOverlayColor(color) { 374 this.store.dispatch(updateFlexboxColor(color)); 375 376 const { flexbox } = this.store.getState(); 377 378 if (flexbox.highlighted) { 379 this.inspector.highlighters.showFlexboxHighlighter( 380 flexbox.flexContainer.nodeFront 381 ); 382 } 383 384 this._overlayColor = color; 385 386 const currentUrl = this.inspector.currentTarget.url; 387 // Get the hostname, if there is no hostname, fall back on protocol 388 // ex: `data:` uri, and `about:` pages 389 const hostname = 390 parseURL(currentUrl).hostname || parseURL(currentUrl).protocol; 391 const customColors = await this.getCustomHostColors(); 392 customColors[hostname] = color; 393 this._customHostColors = customColors; 394 await asyncStorage.setItem("flexboxInspectorHostColors", customColors); 395 } 396 397 /** 398 * Handler for the inspector sidebar "select" event. Updates the flexbox panel if it 399 * is visible. 400 */ 401 onSidebarSelect() { 402 if (!this.isPanelVisible()) { 403 this.inspector.off("reflow-in-selected-target", this.onReflow); 404 this.inspector.off("new-root", this.onNavigate); 405 this.selection.off("new-node-front", this.onUpdatePanel); 406 return; 407 } 408 409 this.inspector.on("reflow-in-selected-target", this.onReflow); 410 this.inspector.on("new-root", this.onNavigate); 411 this.selection.on("new-node-front", this.onUpdatePanel); 412 413 this.update(); 414 } 415 416 /** 417 * Handler for "new-root" event fired by the inspector and "new-node-front" event fired 418 * by the inspector selection. Updates the flexbox panel if it is visible. 419 * 420 * @param {object} 421 * This callback is sometimes executed on "new-node-front" events which means 422 * that a first param is passed here (the nodeFront), which we don't care about. 423 * @param {string} reason 424 * On "new-node-front" events, a reason is passed here, and we need it to detect 425 * if this update was caused by a node selection from the markup-view. 426 */ 427 onUpdatePanel(_, reason) { 428 if (!this.isPanelVisible()) { 429 return; 430 } 431 432 this.update(null, null, reason === "treepanel"); 433 } 434 435 /** 436 * Updates the flexbox panel by dispatching the new flexbox data. This is called when 437 * the layout view becomes visible or a new node is selected and needs to be update 438 * with new flexbox data. 439 * 440 * @param {object | null} flexContainer 441 * An object consisting of the current flex container's flex items and 442 * properties. 443 * @param {object | null} flexItemContainer 444 * An object consisting of the parent flex container's flex items and 445 * properties. 446 * @param {boolean} initiatedByMarkupViewSelection 447 * True if the update was due to a node selection in the markup-view. 448 */ 449 async update( 450 flexContainer, 451 flexItemContainer, 452 initiatedByMarkupViewSelection 453 ) { 454 this._isUpdating = true; 455 456 // Stop refreshing if the inspector or store is already destroyed or no node is 457 // selected. 458 if (!this.inspector || !this.store || !this.selection.nodeFront) { 459 this._isUpdating = false; 460 return; 461 } 462 463 try { 464 // Fetch the current flexbox if no flexbox front was passed into this update. 465 if (!flexContainer) { 466 flexContainer = await this.getFlexContainerProps( 467 this.selection.nodeFront 468 ); 469 } 470 471 // Clear the flexbox panel if there is no flex container for the current node 472 // selection. 473 if (!flexContainer) { 474 this.store.dispatch(clearFlexbox()); 475 this._isUpdating = false; 476 return; 477 } 478 479 if ( 480 !flexItemContainer && 481 flexContainer.nodeFront === this.selection.nodeFront 482 ) { 483 flexItemContainer = await this.getFlexContainerProps( 484 this.selection.nodeFront, 485 true 486 ); 487 } 488 489 const highlighted = 490 flexContainer.nodeFront === 491 this.inspector.highlighters.getNodeForActiveHighlighter( 492 this.inspector.highlighters.TYPES.FLEXBOX 493 ); 494 const color = await this.getOverlayColor(); 495 496 this.store.dispatch( 497 updateFlexbox({ 498 color, 499 flexContainer, 500 flexItemContainer, 501 highlighted, 502 initiatedByMarkupViewSelection, 503 }) 504 ); 505 } catch (e) { 506 // This call might fail if called asynchrously after the toolbox is finished 507 // closing. 508 } 509 510 this._isUpdating = false; 511 } 512 } 513 514 /** 515 * For a given flex container object, returns the flex container properties that can be 516 * used to check if 2 flex container objects are the same. 517 * 518 * @param {object | null} flexContainer 519 * Object consisting of the flex container's properties. 520 * @return {object | null} consisting of the comparable flex container's properties. 521 */ 522 function getComparableFlexContainerProperties(flexContainer) { 523 if (!flexContainer) { 524 return null; 525 } 526 527 return { 528 flexItems: getComparableFlexItemsProperties(flexContainer.flexItems), 529 nodeFront: flexContainer.nodeFront.actorID, 530 properties: flexContainer.properties, 531 }; 532 } 533 534 /** 535 * Given an array of flex item objects, returns the relevant flex item properties that can 536 * be compared to check if any changes has occurred. 537 * 538 * @param {Array} flexItems 539 * Array of objects containing the flex item properties. 540 * @return {Array} of objects consisting of the comparable flex item's properties. 541 */ 542 function getComparableFlexItemsProperties(flexItems) { 543 return flexItems.map(item => { 544 return { 545 computedStyle: item.computedStyle, 546 flexItemSizing: item.flexItemSizing, 547 nodeFront: item.nodeFront.actorID, 548 properties: item.properties, 549 }; 550 }); 551 } 552 553 /** 554 * Compares the old and new flex container properties 555 * 556 * @param {object} oldFlexContainer 557 * Object consisting of the old flex container's properties. 558 * @param {object} newFlexContainer 559 * Object consisting of the new flex container's properties. 560 * @return {boolean} true if the flex container properties are the same, false otherwise. 561 */ 562 function hasFlexContainerChanged(oldFlexContainer, newFlexContainer) { 563 return ( 564 JSON.stringify(getComparableFlexContainerProperties(oldFlexContainer)) !== 565 JSON.stringify(getComparableFlexContainerProperties(newFlexContainer)) 566 ); 567 } 568 569 module.exports = FlexboxInspector;