/* 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));
}