tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }