diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/server/actors/highlighters/eye-dropper.js | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | devtools/server/actors/highlighters/eye-dropper.js | 608 |
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)); +} |