eye-dropper.js (18665B)
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 "use strict"; 5 6 // Eye-dropper tool. This is implemented as a highlighter so it can be displayed in the 7 // content page. 8 // It basically displays a magnifier that tracks mouse moves and shows a magnified version 9 // of the page. On click, it samples the color at the pixel being hovered. 10 11 const { 12 CanvasFrameAnonymousContentHelper, 13 } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); 14 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 15 const { rgbToHsl } = 16 require("resource://devtools/shared/css/color.js").colorUtils; 17 const { 18 getCurrentZoom, 19 getFrameOffsets, 20 } = require("resource://devtools/shared/layout/utils.js"); 21 const { debounce } = require("resource://devtools/shared/debounce.js"); 22 23 loader.lazyGetter(this, "clipboardHelper", () => 24 Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper) 25 ); 26 loader.lazyGetter(this, "l10n", () => 27 Services.strings.createBundle( 28 "chrome://devtools-shared/locale/eyedropper.properties" 29 ) 30 ); 31 32 const ZOOM_LEVEL_PREF = "devtools.eyedropper.zoom"; 33 const FORMAT_PREF = "devtools.defaultColorUnit"; 34 // Width of the canvas. 35 const MAGNIFIER_WIDTH = 96; 36 // Height of the canvas. 37 const MAGNIFIER_HEIGHT = 96; 38 // Start position, when the tool is first shown. This should match the top/left position 39 // defined in CSS. 40 const DEFAULT_START_POS_X = 100; 41 const DEFAULT_START_POS_Y = 100; 42 // How long to wait before closing after copy. 43 const CLOSE_DELAY = 750; 44 45 /** 46 * The EyeDropper allows the user to select a color of a pixel within the content page, 47 * showing a magnified circle and color preview while the user hover the page. 48 */ 49 class EyeDropper { 50 #pageEventListenersAbortController; 51 #debouncedUpdateScreenshot; 52 constructor(highlighterEnv) { 53 EventEmitter.decorate(this); 54 55 this.highlighterEnv = highlighterEnv; 56 this.markup = new CanvasFrameAnonymousContentHelper( 57 this.highlighterEnv, 58 this._buildMarkup.bind(this), 59 { 60 contentRootHostClassName: "devtools-highlighter-eye-dropper", 61 } 62 ); 63 this.isReady = this.markup.initialize(); 64 65 // Get a couple of settings from prefs. 66 this.format = Services.prefs.getCharPref(FORMAT_PREF); 67 this.eyeDropperZoomLevel = Services.prefs.getIntPref(ZOOM_LEVEL_PREF); 68 69 this.#debouncedUpdateScreenshot = debounce( 70 this.updateScreenshot.bind(this), 71 200, 72 this 73 ); 74 } 75 76 get win() { 77 return this.highlighterEnv.window; 78 } 79 80 _buildMarkup() { 81 // Highlighter main container. 82 const container = this.markup.createNode({ 83 attributes: { class: "highlighter-container" }, 84 }); 85 86 // Wrapper element. 87 const wrapper = this.markup.createNode({ 88 parent: container, 89 attributes: { 90 id: "eye-dropper-root", 91 class: "eye-dropper-root", 92 hidden: "true", 93 }, 94 }); 95 96 // The magnifier canvas element. 97 this.markup.createNode({ 98 parent: wrapper, 99 nodeType: "canvas", 100 attributes: { 101 id: "eye-dropper-canvas", 102 class: "eye-dropper-canvas", 103 width: MAGNIFIER_WIDTH, 104 height: MAGNIFIER_HEIGHT, 105 }, 106 }); 107 108 // The color label element. 109 const colorLabelContainer = this.markup.createNode({ 110 parent: wrapper, 111 attributes: { class: "eye-dropper-color-container" }, 112 }); 113 this.markup.createNode({ 114 nodeType: "div", 115 parent: colorLabelContainer, 116 attributes: { 117 id: "eye-dropper-color-preview", 118 class: "eye-dropper-color-preview", 119 }, 120 }); 121 this.markup.createNode({ 122 nodeType: "div", 123 parent: colorLabelContainer, 124 attributes: { 125 id: "eye-dropper-color-value", 126 class: "eye-dropper-color-value", 127 }, 128 }); 129 130 return container; 131 } 132 133 destroy() { 134 this.hide(); 135 this.markup.destroy(); 136 } 137 138 getElement(id) { 139 return this.markup.getElement(id); 140 } 141 142 /** 143 * Show the eye-dropper highlighter. 144 * 145 * @param {DOMNode} node The node which document the highlighter should be inserted in. 146 * @param {object} options The options object may contain the following properties: 147 * - {Boolean} copyOnSelect: Whether selecting a color should copy it to the clipboard. 148 * - {String|null} screenshot: a dataURL representation of the page screenshot. If null, 149 * the eyedropper will use `drawWindow` to get the the screenshot 150 * (⚠️ but it won't handle remote frames). 151 */ 152 show(node, options = {}) { 153 if (this.highlighterEnv.isXUL) { 154 return false; 155 } 156 157 this.options = options; 158 159 // Get the page's current zoom level. 160 this.pageZoom = getCurrentZoom(this.win); 161 162 // Take a screenshot of the viewport. 163 // Once the screenshot is ready, the magnified area will be drawn. 164 this.updateScreenshot(options.screenshot); 165 166 // Start listening for user events. 167 const { pageListenerTarget } = this.highlighterEnv; 168 this.#pageEventListenersAbortController = new AbortController(); 169 const signal = this.#pageEventListenersAbortController.signal; 170 pageListenerTarget.addEventListener("mousemove", this, { signal }); 171 pageListenerTarget.addEventListener("click", this, { 172 signal, 173 useCapture: true, 174 }); 175 pageListenerTarget.addEventListener("keydown", this, { signal }); 176 pageListenerTarget.addEventListener("DOMMouseScroll", this, { signal }); 177 pageListenerTarget.addEventListener("FullZoomChange", this, { signal }); 178 pageListenerTarget.addEventListener("resize", this, { signal }); 179 180 // Prepare the canvas context on which we're drawing the magnified page portion. 181 this.ctx = this.getElement("eye-dropper-canvas").getCanvasContext(); 182 this.ctx.imageSmoothingEnabled = false; 183 184 this.magnifiedArea = { 185 width: MAGNIFIER_WIDTH, 186 height: MAGNIFIER_HEIGHT, 187 x: DEFAULT_START_POS_X, 188 y: DEFAULT_START_POS_Y, 189 }; 190 191 this.moveTo(DEFAULT_START_POS_X, DEFAULT_START_POS_Y); 192 193 // Focus the content so the keyboard can be used. 194 this.win.focus(); 195 196 // Make sure we receive mouse events when the debugger has paused execution 197 // in the page. 198 this.win.document.setSuppressedEventListener(this); 199 200 return true; 201 } 202 203 /** 204 * Hide the eye-dropper highlighter. 205 */ 206 hide() { 207 this.pageImage = null; 208 209 if (this.#pageEventListenersAbortController) { 210 this.#pageEventListenersAbortController.abort(); 211 this.#pageEventListenersAbortController = null; 212 213 const rootElement = this.getElement("eye-dropper-root"); 214 rootElement.setAttribute("hidden", "true"); 215 rootElement.removeAttribute("drawn"); 216 217 this.emit("hidden"); 218 219 this.win.document.setSuppressedEventListener(null); 220 } 221 } 222 223 /** 224 * Convert a base64 png data-uri to raw binary data. 225 */ 226 #dataURItoBlob(dataURI) { 227 const byteString = atob(dataURI.split(",")[1]); 228 229 // write the bytes of the string to an ArrayBuffer 230 const buffer = new ArrayBuffer(byteString.length); 231 // Update the buffer through a typed array. 232 const typedArray = new Uint8Array(buffer); 233 for (let i = 0; i < byteString.length; i++) { 234 typedArray[i] = byteString.charCodeAt(i); 235 } 236 237 return new Blob([buffer], { type: "image/png" }); 238 } 239 240 /** 241 * Create an image bitmap from the page screenshot, draw the eyedropper and set the 242 * "drawn" attribute on the "root" element once it's done. 243 * 244 * @param {string | null} screenshot 245 * A dataURL representation of the page screenshot. 246 * If null, we'll use `drawWindow` to get the the page screenshot 247 * (⚠️ but it won't handle remote frames). 248 */ 249 async updateScreenshot(screenshot) { 250 const rootElement = this.getElement("eye-dropper-root"); 251 252 let imageSource; 253 if (screenshot) { 254 imageSource = this.#dataURItoBlob(screenshot); 255 } else { 256 // Hide the eyedropper while we take the screenshot. 257 rootElement.setAttribute("hidden", "true"); 258 imageSource = getWindowAsImageData(this.win); 259 } 260 261 // We need to transform the blob/imageData to something drawWindow will consume. 262 // An ImageBitmap works well. We could have used an Image, but doing so results 263 // in errors if the page defines CSP headers. 264 const image = await this.win.createImageBitmap(imageSource); 265 266 this.pageImage = image; 267 // We likely haven't drawn anything yet (no mousemove events yet), so start now. 268 this.draw(); 269 270 // Set an attribute on the root element to be able to run tests after the first draw 271 // was done. 272 rootElement.setAttribute("drawn", "true"); 273 274 // Show the eyedropper. 275 rootElement.removeAttribute("hidden"); 276 } 277 278 /** 279 * Get the number of cells (blown-up pixels) per direction in the grid. 280 */ 281 get cellsWide() { 282 // Canvas will render whole "pixels" (cells) only, and an even number at that. Round 283 // up to the nearest even number of pixels. 284 let cellsWide = Math.ceil( 285 this.magnifiedArea.width / this.eyeDropperZoomLevel 286 ); 287 cellsWide += cellsWide % 2; 288 289 return cellsWide; 290 } 291 292 /** 293 * Get the size of each cell (blown-up pixel) in the grid. 294 */ 295 get cellSize() { 296 return this.magnifiedArea.width / this.cellsWide; 297 } 298 299 /** 300 * Get index of cell in the center of the grid. 301 */ 302 get centerCell() { 303 return Math.floor(this.cellsWide / 2); 304 } 305 306 /** 307 * Get color of center cell in the grid. 308 */ 309 get centerColor() { 310 const pos = this.centerCell * this.cellSize + this.cellSize / 2; 311 const rgb = this.ctx.getImageData(pos, pos, 1, 1).data; 312 return rgb; 313 } 314 315 draw() { 316 // If the image of the page isn't ready yet, bail out, we'll draw later on mousemove. 317 if (!this.pageImage) { 318 return; 319 } 320 321 const { width, height, x, y } = this.magnifiedArea; 322 323 const zoomedWidth = width / this.eyeDropperZoomLevel; 324 const zoomedHeight = height / this.eyeDropperZoomLevel; 325 326 const sx = x - zoomedWidth / 2; 327 const sy = y - zoomedHeight / 2; 328 const sw = zoomedWidth; 329 const sh = zoomedHeight; 330 331 this.ctx.drawImage(this.pageImage, sx, sy, sw, sh, 0, 0, width, height); 332 333 // Draw the grid on top, but only at 3x or more, otherwise it's too busy. 334 if (this.eyeDropperZoomLevel > 2) { 335 this.drawGrid(); 336 } 337 338 this.drawCrosshair(); 339 340 // Update the color preview and value. 341 const rgb = this.centerColor; 342 this.getElement("eye-dropper-color-preview").setAttribute( 343 "style", 344 `background-color:${toColorString(rgb, "rgb")};` 345 ); 346 this.getElement("eye-dropper-color-value").setTextContent( 347 toColorString(rgb, this.format) 348 ); 349 } 350 351 /** 352 * Draw a grid on the canvas representing pixel boundaries. 353 */ 354 drawGrid() { 355 const { width, height } = this.magnifiedArea; 356 357 this.ctx.lineWidth = 1; 358 this.ctx.strokeStyle = "rgba(143, 143, 143, 0.2)"; 359 360 for (let i = 0; i < width; i += this.cellSize) { 361 this.ctx.beginPath(); 362 this.ctx.moveTo(i - 0.5, 0); 363 this.ctx.lineTo(i - 0.5, height); 364 this.ctx.stroke(); 365 366 this.ctx.beginPath(); 367 this.ctx.moveTo(0, i - 0.5); 368 this.ctx.lineTo(width, i - 0.5); 369 this.ctx.stroke(); 370 } 371 } 372 373 /** 374 * Draw a box on the canvas to highlight the center cell. 375 */ 376 drawCrosshair() { 377 const pos = this.centerCell * this.cellSize; 378 379 this.ctx.lineWidth = 1; 380 this.ctx.lineJoin = "miter"; 381 this.ctx.strokeStyle = "rgba(0, 0, 0, 1)"; 382 this.ctx.strokeRect( 383 pos - 1.5, 384 pos - 1.5, 385 this.cellSize + 2, 386 this.cellSize + 2 387 ); 388 389 this.ctx.strokeStyle = "rgba(255, 255, 255, 1)"; 390 this.ctx.strokeRect(pos - 0.5, pos - 0.5, this.cellSize, this.cellSize); 391 } 392 393 handleEvent(e) { 394 switch (e.type) { 395 case "mousemove": { 396 // We might be getting an event from a child frame, so account for the offset. 397 const [xOffset, yOffset] = getFrameOffsets(this.win, e.target); 398 const x = xOffset + e.pageX - this.win.scrollX; 399 const y = yOffset + e.pageY - this.win.scrollY; 400 // Update the zoom area. 401 this.magnifiedArea.x = x * this.pageZoom; 402 this.magnifiedArea.y = y * this.pageZoom; 403 // Redraw the portion of the screenshot that is now under the mouse. 404 this.draw(); 405 // And move the eye-dropper's UI so it follows the mouse. 406 this.moveTo(x, y); 407 break; 408 } 409 // Note: when events are suppressed we will only get mousedown/mouseup and 410 // not any click events. 411 case "click": 412 case "mouseup": 413 this.selectColor(); 414 break; 415 case "keydown": 416 this.handleKeyDown(e); 417 break; 418 case "DOMMouseScroll": 419 // Prevent scrolling. That's because we only took a screenshot of the viewport, so 420 // scrolling out of the viewport wouldn't draw the expected things. In the future 421 // we can take the screenshot again on scroll, but for now it doesn't seem 422 // important. 423 e.preventDefault(); 424 break; 425 case "FullZoomChange": 426 this.hide(); 427 this.show(); 428 break; 429 case "resize": 430 this.getElement("eye-dropper-root").removeAttribute("drawn"); 431 this.#debouncedUpdateScreenshot(); 432 break; 433 } 434 } 435 436 moveTo(x, y) { 437 const root = this.getElement("eye-dropper-root"); 438 root.setAttribute("style", `top:${y}px;left:${x}px;`); 439 440 // Move the label container to the top if the magnifier is close to the bottom edge. 441 if (y >= this.win.innerHeight - MAGNIFIER_HEIGHT) { 442 root.setAttribute("top", ""); 443 } else { 444 root.removeAttribute("top"); 445 } 446 447 // Also offset the label container to the right or left if the magnifier is close to 448 // the edge. 449 root.removeAttribute("left"); 450 root.removeAttribute("right"); 451 if (x <= MAGNIFIER_WIDTH) { 452 root.setAttribute("right", ""); 453 } else if (x >= this.win.innerWidth - MAGNIFIER_WIDTH) { 454 root.setAttribute("left", ""); 455 } 456 } 457 458 /** 459 * Select the current color that's being previewed. Depending on the current options, 460 * selecting might mean copying to the clipboard and closing the 461 */ 462 selectColor() { 463 let onColorSelected = Promise.resolve(); 464 if (this.options.copyOnSelect) { 465 onColorSelected = this.copyColor(); 466 } 467 468 this.emit("selected", toColorString(this.centerColor, this.format)); 469 onColorSelected.then(() => this.hide(), console.error); 470 } 471 472 /** 473 * Handler for the keydown event. Either select the color or move the panel in a 474 * direction depending on the key pressed. 475 */ 476 handleKeyDown(e) { 477 // Bail out early if any unsupported modifier is used, so that we let 478 // keyboard shortcuts through. 479 if (e.metaKey || e.ctrlKey || e.altKey) { 480 return; 481 } 482 483 if (e.keyCode === e.DOM_VK_RETURN) { 484 this.selectColor(); 485 e.preventDefault(); 486 return; 487 } 488 489 if (e.keyCode === e.DOM_VK_ESCAPE) { 490 this.emit("canceled"); 491 this.hide(); 492 e.preventDefault(); 493 return; 494 } 495 496 let offsetX = 0; 497 let offsetY = 0; 498 let modifier = 1; 499 500 if (e.keyCode === e.DOM_VK_LEFT) { 501 offsetX = -1; 502 } else if (e.keyCode === e.DOM_VK_RIGHT) { 503 offsetX = 1; 504 } else if (e.keyCode === e.DOM_VK_UP) { 505 offsetY = -1; 506 } else if (e.keyCode === e.DOM_VK_DOWN) { 507 offsetY = 1; 508 } 509 510 if (e.shiftKey) { 511 modifier = 10; 512 } 513 514 offsetY *= modifier; 515 offsetX *= modifier; 516 517 if (offsetX !== 0 || offsetY !== 0) { 518 this.magnifiedArea.x = cap( 519 this.magnifiedArea.x + offsetX, 520 0, 521 this.win.innerWidth * this.pageZoom 522 ); 523 this.magnifiedArea.y = cap( 524 this.magnifiedArea.y + offsetY, 525 0, 526 this.win.innerHeight * this.pageZoom 527 ); 528 529 this.draw(); 530 531 this.moveTo( 532 this.magnifiedArea.x / this.pageZoom, 533 this.magnifiedArea.y / this.pageZoom 534 ); 535 536 e.preventDefault(); 537 } 538 } 539 540 /** 541 * Copy the currently inspected color to the clipboard. 542 * 543 * @return {Promise} Resolves when the copy has been done (after a delay that is used to 544 * let users know that something was copied). 545 */ 546 copyColor() { 547 // Copy to the clipboard. 548 const color = toColorString(this.centerColor, this.format); 549 clipboardHelper.copyString(color); 550 551 // Provide some feedback. 552 this.getElement("eye-dropper-color-value").setTextContent( 553 "✓ " + l10n.GetStringFromName("colorValue.copied") 554 ); 555 556 // Hide the tool after a delay. 557 clearTimeout(this._copyTimeout); 558 return new Promise(resolve => { 559 this._copyTimeout = setTimeout(resolve, CLOSE_DELAY); 560 }); 561 } 562 } 563 564 exports.EyeDropper = EyeDropper; 565 566 /** 567 * Draw the visible portion of the window on a canvas and get the resulting ImageData. 568 * 569 * @param {Window} win 570 * @return {ImageData} The image data for the window. 571 */ 572 function getWindowAsImageData(win) { 573 const canvas = win.document.createElementNS( 574 "http://www.w3.org/1999/xhtml", 575 "canvas" 576 ); 577 const scale = getCurrentZoom(win); 578 const width = win.innerWidth; 579 const height = win.innerHeight; 580 canvas.width = width * scale; 581 canvas.height = height * scale; 582 canvas.mozOpaque = true; 583 584 const ctx = canvas.getContext("2d"); 585 586 ctx.scale(scale, scale); 587 ctx.drawWindow(win, win.scrollX, win.scrollY, width, height, "#fff"); 588 589 return ctx.getImageData(0, 0, canvas.width, canvas.height); 590 } 591 592 /** 593 * Get a formatted CSS color string from a color value. 594 * 595 * @param {Array} rgb Rgb values of a color to format. 596 * @param {string} format Format of string. One of "hex", "rgb", "hsl", "name". 597 * @return {string} Formatted color value, e.g. "#FFF" or "hsl(20, 10%, 10%)". 598 */ 599 function toColorString(rgb, format) { 600 const [r, g, b] = rgb; 601 602 switch (format) { 603 case "hex": 604 return hexString(rgb); 605 case "rgb": 606 return "rgb(" + r + ", " + g + ", " + b + ")"; 607 case "hsl": { 608 const [h, s, l] = rgbToHsl(rgb); 609 return "hsl(" + h + ", " + s + "%, " + l + "%)"; 610 } 611 case "name": 612 return InspectorUtils.rgbToColorName(r, g, b) || hexString(rgb); 613 default: 614 return hexString(rgb); 615 } 616 } 617 618 /** 619 * Produce a hex-formatted color string from rgb values. 620 * 621 * @param {Array} rgb Rgb values of color to stringify. 622 * @return {string} Hex formatted string for color, e.g. "#FFEE00". 623 */ 624 function hexString([r, g, b]) { 625 const val = (1 << 24) + (r << 16) + (g << 8) + (b << 0); 626 return "#" + val.toString(16).substr(-6); 627 } 628 629 function cap(value, min, max) { 630 return Math.max(min, Math.min(value, max)); 631 }