auto-refresh.js (9929B)
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 EventEmitter = require("resource://devtools/shared/event-emitter.js"); 8 const { 9 isNodeValid, 10 } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); 11 const { 12 getAdjustedQuads, 13 getWindowDimensions, 14 } = require("resource://devtools/shared/layout/utils.js"); 15 16 // Note that the order of items in this array is important because it is used 17 // for drawing the BoxModelHighlighter's path elements correctly. 18 const BOX_MODEL_REGIONS = ["margin", "border", "padding", "content"]; 19 const QUADS_PROPS = ["p1", "p2", "p3", "p4"]; 20 21 function arePointsDifferent(pointA, pointB) { 22 return ( 23 Math.abs(pointA.x - pointB.x) >= 0.5 || 24 Math.abs(pointA.y - pointB.y) >= 0.5 || 25 Math.abs(pointA.w - pointB.w) >= 0.5 26 ); 27 } 28 29 function areQuadsDifferent(oldQuads, newQuads) { 30 for (const region of BOX_MODEL_REGIONS) { 31 const { length } = oldQuads[region]; 32 33 if (length !== newQuads[region].length) { 34 return true; 35 } 36 37 for (let i = 0; i < length; i++) { 38 for (const prop of QUADS_PROPS) { 39 const oldPoint = oldQuads[region][i][prop]; 40 const newPoint = newQuads[region][i][prop]; 41 42 if (arePointsDifferent(oldPoint, newPoint)) { 43 return true; 44 } 45 } 46 } 47 } 48 49 return false; 50 } 51 52 /** 53 * Base class for auto-refresh-on-change highlighters. Sub classes will have a 54 * chance to update whenever the current node's geometry changes. 55 * 56 * Sub classes must implement the following methods: 57 * _show: called when the highlighter should be shown, 58 * _hide: called when the highlighter should be hidden, 59 * _update: called while the highlighter is shown and the geometry of the 60 * current node changes. 61 * 62 * Sub classes will have access to the following properties: 63 * - this.currentNode: the node to be shown 64 * - this.currentQuads: all of the node's box model region quads 65 * - this.win: the current window 66 * 67 * Emits the following events: 68 * - shown 69 * - hidden 70 * - updated 71 */ 72 class AutoRefreshHighlighter extends EventEmitter { 73 constructor(highlighterEnv) { 74 super(); 75 76 this.highlighterEnv = highlighterEnv; 77 78 this._updateSimpleHighlighters = this._updateSimpleHighlighters.bind(this); 79 this.highlighterEnv.on( 80 "use-simple-highlighters-updated", 81 this._updateSimpleHighlighters 82 ); 83 84 this.currentNode = null; 85 this.currentQuads = {}; 86 87 this._winDimensions = getWindowDimensions(this.win); 88 this._scroll = { x: this.win.pageXOffset, y: this.win.pageYOffset }; 89 90 this.update = this.update.bind(this); 91 } 92 93 _ignoreZoom = false; 94 _ignoreScroll = false; 95 96 /** 97 * Window corresponding to the current highlighterEnv. 98 */ 99 get win() { 100 if (!this.highlighterEnv) { 101 return null; 102 } 103 return this.highlighterEnv.window; 104 } 105 106 /* Window containing the target content. */ 107 get contentWindow() { 108 return this.win; 109 } 110 111 get supportsSimpleHighlighters() { 112 return false; 113 } 114 115 /** 116 * Show the highlighter on a given node 117 * 118 * @param {DOMNode} node 119 * @param {object} options 120 * Object used for passing options 121 */ 122 show(node, options = {}) { 123 const isSameNode = node === this.currentNode; 124 const isSameOptions = this._isSameOptions(options); 125 126 if (!this._isNodeValid(node) || (isSameNode && isSameOptions)) { 127 return false; 128 } 129 130 this.options = options; 131 132 this._stopRefreshLoop(); 133 this.currentNode = node; 134 135 // For offset-path, the highlighter needs to be computed from the containing block 136 // of the node, not the node itself. 137 this.useContainingBlock = this.options.mode === "cssOffsetPath"; 138 this.drawingNode = this.useContainingBlock 139 ? InspectorUtils.containingBlockOf(this.currentNode) 140 : this.currentNode; 141 142 this._updateAdjustedQuads(); 143 this._startRefreshLoop(); 144 145 const shown = this._show(); 146 if (shown) { 147 this.emit("shown"); 148 } 149 return shown; 150 } 151 152 /** 153 * Hide the highlighter 154 */ 155 hide() { 156 if (!this.currentNode || !this.highlighterEnv.window) { 157 return; 158 } 159 160 this._hide(); 161 this._stopRefreshLoop(); 162 this.currentNode = null; 163 this.currentQuads = {}; 164 this.options = null; 165 166 this.emit("hidden"); 167 } 168 169 /** 170 * Whether the current node is valid for this highlighter type. 171 * This is implemented by default to check if the node is an element node. Highlighter 172 * sub-classes should override this method if they want to highlight other node types. 173 * 174 * @param {DOMNode} node 175 * @return {boolean} 176 */ 177 _isNodeValid(node) { 178 return isNodeValid(node); 179 } 180 181 /** 182 * Are the provided options the same as the currently stored options? 183 * Returns false if there are no options stored currently. 184 */ 185 _isSameOptions(options) { 186 if (!this.options) { 187 return false; 188 } 189 190 const keys = Object.keys(options); 191 192 if (keys.length !== Object.keys(this.options).length) { 193 return false; 194 } 195 196 for (const key of keys) { 197 if (this.options[key] !== options[key]) { 198 return false; 199 } 200 } 201 202 return true; 203 } 204 205 /** 206 * Update the stored box quads by reading the current node's box quads. 207 */ 208 _updateAdjustedQuads() { 209 this.currentQuads = {}; 210 211 // If we need to use the containing block, and if it is the <html> element, 212 // we need to use the viewport quads. 213 const useViewport = 214 this.useContainingBlock && 215 this.drawingNode === this.currentNode.ownerDocument.documentElement; 216 const node = useViewport 217 ? this.drawingNode.ownerDocument 218 : this.drawingNode; 219 220 for (const region of BOX_MODEL_REGIONS) { 221 this.currentQuads[region] = getAdjustedQuads( 222 this.contentWindow, 223 node, 224 region, 225 { ignoreScroll: this._ignoreScroll, ignoreZoom: this._ignoreZoom } 226 ); 227 } 228 } 229 230 /** 231 * Update the knowledge we have of the current node's boxquads and return true 232 * if any of the points x/y or bounds have change since. 233 * 234 * @return {boolean} 235 */ 236 _hasMoved() { 237 const oldQuads = this.currentQuads; 238 this._updateAdjustedQuads(); 239 240 return areQuadsDifferent(oldQuads, this.currentQuads); 241 } 242 243 /** 244 * Update the knowledge we have of the current window's scrolling offset, both 245 * horizontal and vertical, and return `true` if they have changed since. 246 * 247 * @return {boolean} 248 */ 249 _hasWindowScrolled() { 250 if (!this.win) { 251 return false; 252 } 253 254 const { pageXOffset, pageYOffset } = this.win; 255 const hasChanged = 256 this._scroll.x !== pageXOffset || this._scroll.y !== pageYOffset; 257 258 this._scroll = { x: pageXOffset, y: pageYOffset }; 259 260 return hasChanged; 261 } 262 263 /** 264 * Update the knowledge we have of the current window's dimensions and return `true` 265 * if they have changed since. 266 * 267 * @return {boolean} 268 */ 269 _haveWindowDimensionsChanged() { 270 const { width, height } = getWindowDimensions(this.win); 271 const haveChanged = 272 this._winDimensions.width !== width || 273 this._winDimensions.height !== height; 274 275 this._winDimensions = { width, height }; 276 return haveChanged; 277 } 278 279 /** 280 * Update the highlighter if the node has moved since the last update. 281 */ 282 update() { 283 if ( 284 !this._isNodeValid(this.currentNode) || 285 (!this._hasMoved() && !this._haveWindowDimensionsChanged()) 286 ) { 287 // At this point we're not calling the `_update` method. However, if the window has 288 // scrolled, we want to invoke `_scrollUpdate`. 289 if (this._hasWindowScrolled()) { 290 this._scrollUpdate(); 291 } 292 293 return; 294 } 295 296 this._update(); 297 this.emit("updated"); 298 } 299 300 _show() { 301 // To be implemented by sub classes 302 // When called, sub classes should actually show the highlighter for 303 // this.currentNode, potentially using options in this.options 304 throw new Error("Custom highlighter class had to implement _show method"); 305 } 306 307 _update() { 308 // To be implemented by sub classes 309 // When called, sub classes should update the highlighter shown for 310 // this.currentNode 311 // This is called as a result of a page zoom or repaint 312 throw new Error("Custom highlighter class had to implement _update method"); 313 } 314 315 _scrollUpdate() { 316 // Can be implemented by sub classes 317 // When called, sub classes can upate the highlighter shown for 318 // this.currentNode 319 // This is called as a result of a page scroll 320 } 321 322 _hide() { 323 // To be implemented by sub classes 324 // When called, sub classes should actually hide the highlighter 325 throw new Error("Custom highlighter class had to implement _hide method"); 326 } 327 328 _startRefreshLoop() { 329 const win = this.currentNode.ownerGlobal; 330 this.rafID = win.requestAnimationFrame(this._startRefreshLoop.bind(this)); 331 this.rafWin = win; 332 this.update(); 333 } 334 335 _stopRefreshLoop() { 336 if (this.rafID && !Cu.isDeadWrapper(this.rafWin)) { 337 this.rafWin.cancelAnimationFrame(this.rafID); 338 } 339 this.rafID = this.rafWin = null; 340 } 341 342 _updateSimpleHighlighters() { 343 if (!this.supportsSimpleHighlighters) { 344 return; 345 } 346 347 if (!this.rootEl) { 348 // Highlighters which support simple highlighters are expected to use a root element. 349 return; 350 } 351 352 // Add/remove the `user-simple-highlighters` class based on the current 353 // toolbox configuration. 354 this.rootEl.classList.toggle( 355 "use-simple-highlighters", 356 this.highlighterEnv.useSimpleHighlightersForReducedMotion 357 ); 358 } 359 360 destroy() { 361 this.hide(); 362 363 this.highlighterEnv.off( 364 "use-simple-highlighters-updated", 365 this._updateSimpleHighlighters 366 ); 367 this.highlighterEnv = null; 368 this.currentNode = null; 369 } 370 } 371 exports.AutoRefreshHighlighter = AutoRefreshHighlighter;