608 lines
18 KiB
JavaScript
608 lines
18 KiB
JavaScript
/* 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));
|
|
}
|