summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/highlighters/eye-dropper.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/highlighters/eye-dropper.js')
-rw-r--r--devtools/server/actors/highlighters/eye-dropper.js608
1 files changed, 608 insertions, 0 deletions
diff --git a/devtools/server/actors/highlighters/eye-dropper.js b/devtools/server/actors/highlighters/eye-dropper.js
new file mode 100644
index 0000000000..8a206bc84f
--- /dev/null
+++ b/devtools/server/actors/highlighters/eye-dropper.js
@@ -0,0 +1,608 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+// Eye-dropper tool. This is implemented as a highlighter so it can be displayed in the
+// content page.
+// It basically displays a magnifier that tracks mouse moves and shows a magnified version
+// of the page. On click, it samples the color at the pixel being hovered.
+
+const {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const { rgbToHsl } =
+ require("resource://devtools/shared/css/color.js").colorUtils;
+const {
+ getCurrentZoom,
+ getFrameOffsets,
+} = require("resource://devtools/shared/layout/utils.js");
+
+loader.lazyGetter(this, "clipboardHelper", () =>
+ Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper)
+);
+loader.lazyGetter(this, "l10n", () =>
+ Services.strings.createBundle(
+ "chrome://devtools-shared/locale/eyedropper.properties"
+ )
+);
+
+const ZOOM_LEVEL_PREF = "devtools.eyedropper.zoom";
+const FORMAT_PREF = "devtools.defaultColorUnit";
+// Width of the canvas.
+const MAGNIFIER_WIDTH = 96;
+// Height of the canvas.
+const MAGNIFIER_HEIGHT = 96;
+// Start position, when the tool is first shown. This should match the top/left position
+// defined in CSS.
+const DEFAULT_START_POS_X = 100;
+const DEFAULT_START_POS_Y = 100;
+// How long to wait before closing after copy.
+const CLOSE_DELAY = 750;
+
+/**
+ * The EyeDropper allows the user to select a color of a pixel within the content page,
+ * showing a magnified circle and color preview while the user hover the page.
+ */
+class EyeDropper {
+ #pageEventListenersAbortController;
+ constructor(highlighterEnv) {
+ EventEmitter.decorate(this);
+
+ this.highlighterEnv = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ // Get a couple of settings from prefs.
+ this.format = Services.prefs.getCharPref(FORMAT_PREF);
+ this.eyeDropperZoomLevel = Services.prefs.getIntPref(ZOOM_LEVEL_PREF);
+ }
+
+ ID_CLASS_PREFIX = "eye-dropper-";
+
+ get win() {
+ return this.highlighterEnv.window;
+ }
+
+ _buildMarkup() {
+ // Highlighter main container.
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ // Wrapper element.
+ const wrapper = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // The magnifier canvas element.
+ this.markup.createNode({
+ parent: wrapper,
+ nodeType: "canvas",
+ attributes: {
+ id: "canvas",
+ class: "canvas",
+ width: MAGNIFIER_WIDTH,
+ height: MAGNIFIER_HEIGHT,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // The color label element.
+ const colorLabelContainer = this.markup.createNode({
+ parent: wrapper,
+ attributes: { class: "color-container" },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "div",
+ parent: colorLabelContainer,
+ attributes: { id: "color-preview", class: "color-preview" },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "div",
+ parent: colorLabelContainer,
+ attributes: { id: "color-value", class: "color-value" },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return container;
+ }
+
+ destroy() {
+ this.hide();
+ this.markup.destroy();
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Show the eye-dropper highlighter.
+ *
+ * @param {DOMNode} node The node which document the highlighter should be inserted in.
+ * @param {Object} options The options object may contain the following properties:
+ * - {Boolean} copyOnSelect: Whether selecting a color should copy it to the clipboard.
+ * - {String|null} screenshot: a dataURL representation of the page screenshot. If null,
+ * the eyedropper will use `drawWindow` to get the the screenshot
+ * (⚠️ but it won't handle remote frames).
+ */
+ show(node, options = {}) {
+ if (this.highlighterEnv.isXUL) {
+ return false;
+ }
+
+ this.options = options;
+
+ // Get the page's current zoom level.
+ this.pageZoom = getCurrentZoom(this.win);
+
+ // Take a screenshot of the viewport. This needs to be done first otherwise the
+ // eyedropper UI will appear in the screenshot itself (since the UI is injected as
+ // native anonymous content in the page).
+ // Once the screenshot is ready, the magnified area will be drawn.
+ this.prepareImageCapture(options.screenshot);
+
+ // Start listening for user events.
+ const { pageListenerTarget } = this.highlighterEnv;
+ this.#pageEventListenersAbortController = new AbortController();
+ const signal = this.#pageEventListenersAbortController.signal;
+ pageListenerTarget.addEventListener("mousemove", this, { signal });
+ pageListenerTarget.addEventListener("click", this, {
+ signal,
+ useCapture: true,
+ });
+ pageListenerTarget.addEventListener("keydown", this, { signal });
+ pageListenerTarget.addEventListener("DOMMouseScroll", this, { signal });
+ pageListenerTarget.addEventListener("FullZoomChange", this, { signal });
+
+ // Show the eye-dropper.
+ this.getElement("root").removeAttribute("hidden");
+
+ // Prepare the canvas context on which we're drawing the magnified page portion.
+ this.ctx = this.getElement("canvas").getCanvasContext();
+ this.ctx.imageSmoothingEnabled = false;
+
+ this.magnifiedArea = {
+ width: MAGNIFIER_WIDTH,
+ height: MAGNIFIER_HEIGHT,
+ x: DEFAULT_START_POS_X,
+ y: DEFAULT_START_POS_Y,
+ };
+
+ this.moveTo(DEFAULT_START_POS_X, DEFAULT_START_POS_Y);
+
+ // Focus the content so the keyboard can be used.
+ this.win.focus();
+
+ // Make sure we receive mouse events when the debugger has paused execution
+ // in the page.
+ this.win.document.setSuppressedEventListener(this);
+
+ return true;
+ }
+
+ /**
+ * Hide the eye-dropper highlighter.
+ */
+ hide() {
+ this.pageImage = null;
+
+ if (this.#pageEventListenersAbortController) {
+ this.#pageEventListenersAbortController.abort();
+ this.#pageEventListenersAbortController = null;
+
+ const rootElement = this.getElement("root");
+ rootElement.setAttribute("hidden", "true");
+ rootElement.removeAttribute("drawn");
+
+ this.emit("hidden");
+
+ this.win.document.setSuppressedEventListener(null);
+ }
+ }
+
+ /**
+ * Convert a base64 png data-uri to raw binary data.
+ */
+ #dataURItoBlob(dataURI) {
+ const byteString = atob(dataURI.split(",")[1]);
+
+ // write the bytes of the string to an ArrayBuffer
+ const buffer = new ArrayBuffer(byteString.length);
+ // Update the buffer through a typed array.
+ const typedArray = new Uint8Array(buffer);
+ for (let i = 0; i < byteString.length; i++) {
+ typedArray[i] = byteString.charCodeAt(i);
+ }
+
+ return new Blob([buffer], { type: "image/png" });
+ }
+
+ /**
+ * Create an image bitmap from the page screenshot, draw the eyedropper and set the
+ * "drawn" attribute on the "root" element once it's done.
+ *
+ * @params {String|null} screenshot: a dataURL representation of the page screenshot.
+ * If null, we'll use `drawWindow` to get the the page screenshot
+ * (⚠️ but it won't handle remote frames).
+ */
+ async prepareImageCapture(screenshot) {
+ let imageSource;
+ if (screenshot) {
+ imageSource = this.#dataURItoBlob(screenshot);
+ } else {
+ imageSource = getWindowAsImageData(this.win);
+ }
+
+ // We need to transform the blob/imageData to something drawWindow will consume.
+ // An ImageBitmap works well. We could have used an Image, but doing so results
+ // in errors if the page defines CSP headers.
+ const image = await this.win.createImageBitmap(imageSource);
+
+ this.pageImage = image;
+ // We likely haven't drawn anything yet (no mousemove events yet), so start now.
+ this.draw();
+
+ // Set an attribute on the root element to be able to run tests after the first draw
+ // was done.
+ this.getElement("root").setAttribute("drawn", "true");
+ }
+
+ /**
+ * Get the number of cells (blown-up pixels) per direction in the grid.
+ */
+ get cellsWide() {
+ // Canvas will render whole "pixels" (cells) only, and an even number at that. Round
+ // up to the nearest even number of pixels.
+ let cellsWide = Math.ceil(
+ this.magnifiedArea.width / this.eyeDropperZoomLevel
+ );
+ cellsWide += cellsWide % 2;
+
+ return cellsWide;
+ }
+
+ /**
+ * Get the size of each cell (blown-up pixel) in the grid.
+ */
+ get cellSize() {
+ return this.magnifiedArea.width / this.cellsWide;
+ }
+
+ /**
+ * Get index of cell in the center of the grid.
+ */
+ get centerCell() {
+ return Math.floor(this.cellsWide / 2);
+ }
+
+ /**
+ * Get color of center cell in the grid.
+ */
+ get centerColor() {
+ const pos = this.centerCell * this.cellSize + this.cellSize / 2;
+ const rgb = this.ctx.getImageData(pos, pos, 1, 1).data;
+ return rgb;
+ }
+
+ draw() {
+ // If the image of the page isn't ready yet, bail out, we'll draw later on mousemove.
+ if (!this.pageImage) {
+ return;
+ }
+
+ const { width, height, x, y } = this.magnifiedArea;
+
+ const zoomedWidth = width / this.eyeDropperZoomLevel;
+ const zoomedHeight = height / this.eyeDropperZoomLevel;
+
+ const sx = x - zoomedWidth / 2;
+ const sy = y - zoomedHeight / 2;
+ const sw = zoomedWidth;
+ const sh = zoomedHeight;
+
+ this.ctx.drawImage(this.pageImage, sx, sy, sw, sh, 0, 0, width, height);
+
+ // Draw the grid on top, but only at 3x or more, otherwise it's too busy.
+ if (this.eyeDropperZoomLevel > 2) {
+ this.drawGrid();
+ }
+
+ this.drawCrosshair();
+
+ // Update the color preview and value.
+ const rgb = this.centerColor;
+ this.getElement("color-preview").setAttribute(
+ "style",
+ `background-color:${toColorString(rgb, "rgb")};`
+ );
+ this.getElement("color-value").setTextContent(
+ toColorString(rgb, this.format)
+ );
+ }
+
+ /**
+ * Draw a grid on the canvas representing pixel boundaries.
+ */
+ drawGrid() {
+ const { width, height } = this.magnifiedArea;
+
+ this.ctx.lineWidth = 1;
+ this.ctx.strokeStyle = "rgba(143, 143, 143, 0.2)";
+
+ for (let i = 0; i < width; i += this.cellSize) {
+ this.ctx.beginPath();
+ this.ctx.moveTo(i - 0.5, 0);
+ this.ctx.lineTo(i - 0.5, height);
+ this.ctx.stroke();
+
+ this.ctx.beginPath();
+ this.ctx.moveTo(0, i - 0.5);
+ this.ctx.lineTo(width, i - 0.5);
+ this.ctx.stroke();
+ }
+ }
+
+ /**
+ * Draw a box on the canvas to highlight the center cell.
+ */
+ drawCrosshair() {
+ const pos = this.centerCell * this.cellSize;
+
+ this.ctx.lineWidth = 1;
+ this.ctx.lineJoin = "miter";
+ this.ctx.strokeStyle = "rgba(0, 0, 0, 1)";
+ this.ctx.strokeRect(
+ pos - 1.5,
+ pos - 1.5,
+ this.cellSize + 2,
+ this.cellSize + 2
+ );
+
+ this.ctx.strokeStyle = "rgba(255, 255, 255, 1)";
+ this.ctx.strokeRect(pos - 0.5, pos - 0.5, this.cellSize, this.cellSize);
+ }
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "mousemove":
+ // We might be getting an event from a child frame, so account for the offset.
+ const [xOffset, yOffset] = getFrameOffsets(this.win, e.target);
+ const x = xOffset + e.pageX - this.win.scrollX;
+ const y = yOffset + e.pageY - this.win.scrollY;
+ // Update the zoom area.
+ this.magnifiedArea.x = x * this.pageZoom;
+ this.magnifiedArea.y = y * this.pageZoom;
+ // Redraw the portion of the screenshot that is now under the mouse.
+ this.draw();
+ // And move the eye-dropper's UI so it follows the mouse.
+ this.moveTo(x, y);
+ break;
+ // Note: when events are suppressed we will only get mousedown/mouseup and
+ // not any click events.
+ case "click":
+ case "mouseup":
+ this.selectColor();
+ break;
+ case "keydown":
+ this.handleKeyDown(e);
+ break;
+ case "DOMMouseScroll":
+ // Prevent scrolling. That's because we only took a screenshot of the viewport, so
+ // scrolling out of the viewport wouldn't draw the expected things. In the future
+ // we can take the screenshot again on scroll, but for now it doesn't seem
+ // important.
+ e.preventDefault();
+ break;
+ case "FullZoomChange":
+ this.hide();
+ this.show();
+ break;
+ }
+ }
+
+ moveTo(x, y) {
+ const root = this.getElement("root");
+ root.setAttribute("style", `top:${y}px;left:${x}px;`);
+
+ // Move the label container to the top if the magnifier is close to the bottom edge.
+ if (y >= this.win.innerHeight - MAGNIFIER_HEIGHT) {
+ root.setAttribute("top", "");
+ } else {
+ root.removeAttribute("top");
+ }
+
+ // Also offset the label container to the right or left if the magnifier is close to
+ // the edge.
+ root.removeAttribute("left");
+ root.removeAttribute("right");
+ if (x <= MAGNIFIER_WIDTH) {
+ root.setAttribute("right", "");
+ } else if (x >= this.win.innerWidth - MAGNIFIER_WIDTH) {
+ root.setAttribute("left", "");
+ }
+ }
+
+ /**
+ * Select the current color that's being previewed. Depending on the current options,
+ * selecting might mean copying to the clipboard and closing the
+ */
+ selectColor() {
+ let onColorSelected = Promise.resolve();
+ if (this.options.copyOnSelect) {
+ onColorSelected = this.copyColor();
+ }
+
+ this.emit("selected", toColorString(this.centerColor, this.format));
+ onColorSelected.then(() => this.hide(), console.error);
+ }
+
+ /**
+ * Handler for the keydown event. Either select the color or move the panel in a
+ * direction depending on the key pressed.
+ */
+ handleKeyDown(e) {
+ // Bail out early if any unsupported modifier is used, so that we let
+ // keyboard shortcuts through.
+ if (e.metaKey || e.ctrlKey || e.altKey) {
+ return;
+ }
+
+ if (e.keyCode === e.DOM_VK_RETURN) {
+ this.selectColor();
+ e.preventDefault();
+ return;
+ }
+
+ if (e.keyCode === e.DOM_VK_ESCAPE) {
+ this.emit("canceled");
+ this.hide();
+ e.preventDefault();
+ return;
+ }
+
+ let offsetX = 0;
+ let offsetY = 0;
+ let modifier = 1;
+
+ if (e.keyCode === e.DOM_VK_LEFT) {
+ offsetX = -1;
+ } else if (e.keyCode === e.DOM_VK_RIGHT) {
+ offsetX = 1;
+ } else if (e.keyCode === e.DOM_VK_UP) {
+ offsetY = -1;
+ } else if (e.keyCode === e.DOM_VK_DOWN) {
+ offsetY = 1;
+ }
+
+ if (e.shiftKey) {
+ modifier = 10;
+ }
+
+ offsetY *= modifier;
+ offsetX *= modifier;
+
+ if (offsetX !== 0 || offsetY !== 0) {
+ this.magnifiedArea.x = cap(
+ this.magnifiedArea.x + offsetX,
+ 0,
+ this.win.innerWidth * this.pageZoom
+ );
+ this.magnifiedArea.y = cap(
+ this.magnifiedArea.y + offsetY,
+ 0,
+ this.win.innerHeight * this.pageZoom
+ );
+
+ this.draw();
+
+ this.moveTo(
+ this.magnifiedArea.x / this.pageZoom,
+ this.magnifiedArea.y / this.pageZoom
+ );
+
+ e.preventDefault();
+ }
+ }
+
+ /**
+ * Copy the currently inspected color to the clipboard.
+ * @return {Promise} Resolves when the copy has been done (after a delay that is used to
+ * let users know that something was copied).
+ */
+ copyColor() {
+ // Copy to the clipboard.
+ const color = toColorString(this.centerColor, this.format);
+ clipboardHelper.copyString(color);
+
+ // Provide some feedback.
+ this.getElement("color-value").setTextContent(
+ "✓ " + l10n.GetStringFromName("colorValue.copied")
+ );
+
+ // Hide the tool after a delay.
+ clearTimeout(this._copyTimeout);
+ return new Promise(resolve => {
+ this._copyTimeout = setTimeout(resolve, CLOSE_DELAY);
+ });
+ }
+}
+
+exports.EyeDropper = EyeDropper;
+
+/**
+ * Draw the visible portion of the window on a canvas and get the resulting ImageData.
+ * @param {Window} win
+ * @return {ImageData} The image data for the window.
+ */
+function getWindowAsImageData(win) {
+ const canvas = win.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ const scale = getCurrentZoom(win);
+ const width = win.innerWidth;
+ const height = win.innerHeight;
+ canvas.width = width * scale;
+ canvas.height = height * scale;
+ canvas.mozOpaque = true;
+
+ const ctx = canvas.getContext("2d");
+
+ ctx.scale(scale, scale);
+ ctx.drawWindow(win, win.scrollX, win.scrollY, width, height, "#fff");
+
+ return ctx.getImageData(0, 0, canvas.width, canvas.height);
+}
+
+/**
+ * Get a formatted CSS color string from a color value.
+ * @param {array} rgb Rgb values of a color to format.
+ * @param {string} format Format of string. One of "hex", "rgb", "hsl", "name".
+ * @return {string} Formatted color value, e.g. "#FFF" or "hsl(20, 10%, 10%)".
+ */
+function toColorString(rgb, format) {
+ const [r, g, b] = rgb;
+
+ switch (format) {
+ case "hex":
+ return hexString(rgb);
+ case "rgb":
+ return "rgb(" + r + ", " + g + ", " + b + ")";
+ case "hsl":
+ const [h, s, l] = rgbToHsl(rgb);
+ return "hsl(" + h + ", " + s + "%, " + l + "%)";
+ case "name":
+ const str = InspectorUtils.rgbToColorName(r, g, b) || hexString(rgb);
+ return str;
+ default:
+ return hexString(rgb);
+ }
+}
+
+/**
+ * Produce a hex-formatted color string from rgb values.
+ * @param {array} rgb Rgb values of color to stringify.
+ * @return {string} Hex formatted string for color, e.g. "#FFEE00".
+ */
+function hexString([r, g, b]) {
+ const val = (1 << 24) + (r << 16) + (g << 8) + (b << 0);
+ return "#" + val.toString(16).substr(-6);
+}
+
+function cap(value, min, max) {
+ return Math.max(min, Math.min(value, max));
+}