gecko-dev/browser/devtools/eyedropper/eyedropper.js

819 lines
23 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/. */
const {Cc, Ci, Cu} = require("chrome");
const {rgbToHsl} = require("devtools/css-color").colorUtils;
const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js");
const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
Cu.import("resource://gre/modules/Services.jsm");
loader.lazyGetter(this, "clipboardHelper", function() {
return Cc["@mozilla.org/widget/clipboardhelper;1"]
.getService(Ci.nsIClipboardHelper);
});
loader.lazyGetter(this, "ssService", function() {
return Cc["@mozilla.org/content/style-sheet-service;1"]
.getService(Ci.nsIStyleSheetService);
});
loader.lazyGetter(this, "ioService", function() {
return Cc["@mozilla.org/network/io-service;1"]
.getService(Ci.nsIIOService);
});
loader.lazyGetter(this, "DOMUtils", function () {
return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
});
loader.lazyGetter(this, "XULRuntime", function() {
return Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime);
});
loader.lazyGetter(this, "l10n", () => Services.strings
.createBundle("chrome://browser/locale/devtools/eyedropper.properties"));
const EYEDROPPER_URL = "chrome://browser/content/devtools/eyedropper.xul";
const CROSSHAIRS_URL = "chrome://browser/content/devtools/eyedropper/crosshairs.css";
const NOCURSOR_URL = "chrome://browser/content/devtools/eyedropper/nocursor.css";
const ZOOM_PREF = "devtools.eyedropper.zoom";
const FORMAT_PREF = "devtools.defaultColorUnit";
const CANVAS_WIDTH = 96;
const CANVAS_OFFSET = 3; // equals the border width of the canvas.
const CLOSE_DELAY = 750;
const HEX_BOX_WIDTH = CANVAS_WIDTH + CANVAS_OFFSET * 2;
const HSL_BOX_WIDTH = 158;
/**
* Manage instances of eyedroppers for windows. Registering here isn't
* necessary for creating an eyedropper, but can be used for testing.
*/
let EyedropperManager = {
_instances: new WeakMap(),
getInstance: function(chromeWindow) {
return this._instances.get(chromeWindow);
},
createInstance: function(chromeWindow) {
let dropper = this.getInstance(chromeWindow);
if (dropper) {
return dropper;
}
dropper = new Eyedropper(chromeWindow);
this._instances.set(chromeWindow, dropper);
dropper.on("destroy", () => {
this.deleteInstance(chromeWindow);
});
return dropper;
},
deleteInstance: function(chromeWindow) {
this._instances.delete(chromeWindow);
}
}
exports.EyedropperManager = EyedropperManager;
/**
* Eyedropper widget. Once opened, shows zoomed area above current pixel and
* displays the color value of the center pixel. Clicking on the window will
* close the widget and fire a 'select' event. If 'copyOnSelect' is true, the color
* will also be copied to the clipboard.
*
* let eyedropper = new Eyedropper(window);
* eyedropper.open();
*
* eyedropper.once("select", (ev, color) => {
* console.log(color); // "rgb(20, 50, 230)"
* })
*
* @param {DOMWindow} chromeWindow
* window to inspect
* @param {object} opts
* optional options object, with 'copyOnSelect'
*/
function Eyedropper(chromeWindow, opts = { copyOnSelect: true }) {
this.copyOnSelect = opts.copyOnSelect;
this._onFirstMouseMove = this._onFirstMouseMove.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onMouseDown = this._onMouseDown.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
this._onFrameLoaded = this._onFrameLoaded.bind(this);
this._chromeWindow = chromeWindow;
this._chromeDocument = chromeWindow.document;
this._OS = XULRuntime.OS;
this._dragging = true;
this.loaded = false;
this._mouseMoveCounter = 0;
this.format = Services.prefs.getCharPref(FORMAT_PREF); // color value format
this.zoom = Services.prefs.getIntPref(ZOOM_PREF); // zoom level - integer
this._zoomArea = {
x: 0, // the left coordinate of the center of the inspected region
y: 0, // the top coordinate of the center of the inspected region
width: CANVAS_WIDTH, // width of canvas to draw zoomed area onto
height: CANVAS_WIDTH // height of canvas
};
let mm = this._contentTab.linkedBrowser.messageManager;
mm.loadFrameScript("resource:///modules/devtools/eyedropper/eyedropper-child.js", true);
EventEmitter.decorate(this);
}
exports.Eyedropper = Eyedropper;
Eyedropper.prototype = {
/**
* 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._zoomArea.width / this.zoom);
cellsWide += cellsWide % 2;
return cellsWide;
},
/**
* Get the size of each cell (blown-up pixel) in the grid.
*/
get cellSize() {
return this._zoomArea.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() {
let x = y = (this.centerCell * this.cellSize) + (this.cellSize / 2);
let rgb = this._ctx.getImageData(x, y, 1, 1).data;
return rgb;
},
get _contentTab() {
return this._chromeWindow.gBrowser.selectedTab;
},
/**
* Fetch a screenshot of the content.
*
* @return {promise}
* Promise that resolves with the screenshot as a dataURL
*/
getContentScreenshot: function() {
let deferred = promise.defer();
let mm = this._contentTab.linkedBrowser.messageManager;
function onScreenshot(message) {
mm.removeMessageListener("Eyedropper:Screenshot", onScreenshot);
deferred.resolve(message.data);
}
mm.addMessageListener("Eyedropper:Screenshot", onScreenshot);
mm.sendAsyncMessage("Eyedropper:RequestContentScreenshot");
return deferred.promise;
},
/**
* Start the eyedropper. Add listeners for a mouse move in the window to
* show the eyedropper.
*/
open: function() {
if (this.isOpen) {
// the eyedropper is aready open, don't create another panel.
return promise.resolve();
}
let deferred = promise.defer();
this.isOpen = true;
this._showCrosshairs();
// Get screenshot of content so we can inspect colors
this.getContentScreenshot().then((dataURL) => {
this._contentImage = new this._chromeWindow.Image();
this._contentImage.src = dataURL;
// Wait for screenshot to load
this._contentImage.onload = () => {
// Then start showing the eyedropper UI
this._chromeDocument.addEventListener("mousemove", this._onFirstMouseMove);
deferred.resolve();
this.isStarted = true;
this.emit("started");
}
});
return deferred.promise;
},
/**
* Called on the first mouse move over the window. Opens the eyedropper
* panel where the mouse is.
*/
_onFirstMouseMove: function(event) {
this._chromeDocument.removeEventListener("mousemove", this._onFirstMouseMove);
this._panel = this._buildPanel();
let popupSet = this._chromeDocument.querySelector("#mainPopupSet");
popupSet.appendChild(this._panel);
let { panelX, panelY } = this._getPanelCoordinates(event);
this._panel.openPopupAtScreen(panelX, panelY);
this._setCoordinates(event);
this._addListeners();
// hide cursor as we'll be showing the panel over the mouse instead.
this._hideCrosshairs();
this._hideCursor();
},
/**
* Whether the coordinates are over the content or chrome.
*
* @param {number} clientX
* x-coordinate of mouse relative to browser window.
* @param {number} clientY
* y-coordinate of mouse relative to browser window.
*/
_isInContent: function(clientX, clientY) {
let box = this._contentTab.linkedBrowser.getBoundingClientRect();
if (clientX > box.left &&
clientX < box.right &&
clientY > box.top &&
clientY < box.bottom) {
return true;
}
return false;
},
/**
* Set the current coordinates to inspect from where a mousemove originated.
*
* @param {MouseEvent} event
* Event for the mouse move.
*/
_setCoordinates: function(event) {
let inContent = this._isInContent(event.clientX, event.clientY);
let win = this._chromeWindow;
// offset of mouse from browser window
let x = event.clientX;
let y = event.clientY;
if (inContent) {
// calculate the offset of the mouse from the content window
let box = this._contentTab.linkedBrowser.getBoundingClientRect();
x = x - box.left;
y = y - box.top;
this._zoomArea.contentWidth = box.width;
this._zoomArea.contentHeight = box.height;
}
this._zoomArea.inContent = inContent;
// don't let it inspect outside the browser window
x = Math.max(0, Math.min(x, win.outerWidth - 1));
y = Math.max(0, Math.min(y, win.outerHeight - 1));
this._zoomArea.x = x;
this._zoomArea.y = y;
},
/**
* Build and add a new eyedropper panel to the window.
*
* @return {Panel}
* The XUL panel holding the eyedropper UI.
*/
_buildPanel: function() {
let panel = this._chromeDocument.createElement("panel");
panel.setAttribute("noautofocus", true);
panel.setAttribute("noautohide", true);
panel.setAttribute("level", "floating");
panel.setAttribute("class", "devtools-eyedropper-panel");
let iframe = this._iframe = this._chromeDocument.createElement("iframe");
iframe.addEventListener("load", this._onFrameLoaded, true);
iframe.setAttribute("flex", "1");
iframe.setAttribute("transparent", "transparent");
iframe.setAttribute("allowTransparency", true);
iframe.setAttribute("class", "devtools-eyedropper-iframe");
iframe.setAttribute("src", EYEDROPPER_URL);
iframe.setAttribute("width", CANVAS_WIDTH);
iframe.setAttribute("height", CANVAS_WIDTH);
panel.appendChild(iframe);
return panel;
},
/**
* Event handler for the panel's iframe's load event. Emits
* a "load" event from this eyedropper object.
*/
_onFrameLoaded: function() {
this._iframe.removeEventListener("load", this._onFrameLoaded, true);
this._iframeDocument = this._iframe.contentDocument;
this._colorPreview = this._iframeDocument.querySelector("#color-preview");
this._colorValue = this._iframeDocument.querySelector("#color-value");
// value box will be too long for hex values and too short for hsl
let valueBox = this._iframeDocument.querySelector("#color-value-box");
if (this.format == "hex") {
valueBox.style.width = HEX_BOX_WIDTH + "px";
}
else if (this.format == "hsl") {
valueBox.style.width = HSL_BOX_WIDTH + "px";
}
this._canvas = this._iframeDocument.querySelector("#canvas");
this._ctx = this._canvas.getContext("2d");
// so we preserve the clear pixel boundaries
this._ctx.mozImageSmoothingEnabled = false;
this._drawWindow();
this._addPanelListeners();
this._iframe.focus();
this.loaded = true;
this.emit("load");
},
/**
* Add key listeners to the panel.
*/
_addPanelListeners: function() {
this._iframeDocument.addEventListener("keydown", this._onKeyDown);
let closeCmd = this._iframeDocument.getElementById("eyedropper-cmd-close");
closeCmd.addEventListener("command", this.destroy.bind(this), true);
let copyCmd = this._iframeDocument.getElementById("eyedropper-cmd-copy");
copyCmd.addEventListener("command", this.selectColor.bind(this), true);
},
/**
* Remove listeners from the panel.
*/
_removePanelListeners: function() {
this._iframeDocument.removeEventListener("keydown", this._onKeyDown);
},
/**
* Add mouse event listeners to the document we're inspecting.
*/
_addListeners: function() {
this._chromeDocument.addEventListener("mousemove", this._onMouseMove);
this._chromeDocument.addEventListener("mousedown", this._onMouseDown);
},
/**
* Remove mouse event listeners from the document we're inspecting.
*/
_removeListeners: function() {
this._chromeDocument.removeEventListener("mousemove", this._onFirstMouseMove);
this._chromeDocument.removeEventListener("mousemove", this._onMouseMove);
this._chromeDocument.removeEventListener("mousedown", this._onMouseDown);
},
/**
* Hide the cursor.
*/
_hideCursor: function() {
registerStyleSheet(NOCURSOR_URL);
},
/**
* Reset the cursor back to default.
*/
_resetCursor: function() {
unregisterStyleSheet(NOCURSOR_URL);
},
/**
* Show a crosshairs as the mouse cursor
*/
_showCrosshairs: function() {
registerStyleSheet(CROSSHAIRS_URL);
},
/**
* Reset cursor.
*/
_hideCrosshairs: function() {
unregisterStyleSheet(CROSSHAIRS_URL);
},
/**
* Event handler for a mouse move over the page we're inspecting.
* Preview the area under the cursor, and move panel to be under the cursor.
*
* @param {DOMEvent} event
* MouseEvent for the mouse moving
*/
_onMouseMove: function(event) {
if (!this._dragging || !this._panel || !this._canvas) {
return;
}
if (this._OS == "Linux" && ++this._mouseMoveCounter % 2 == 0) {
// skip every other mousemove to preserve performance.
return;
}
this._setCoordinates(event);
this._drawWindow();
let { panelX, panelY } = this._getPanelCoordinates(event);
this._movePanel(panelX, panelY);
},
/**
* Get coordinates of where the eyedropper panel should go based on
* the current coordinates of the mouse cursor.
*
* @param {MouseEvent} event
* object with properties 'screenX' and 'screenY'
*
* @return {object}
* object with properties 'panelX', 'panelY'
*/
_getPanelCoordinates: function({screenX, screenY}) {
let win = this._chromeWindow;
let offset = CANVAS_WIDTH / 2 + CANVAS_OFFSET;
let panelX = screenX - offset;
let windowX = win.screenX + (win.outerWidth - win.innerWidth);
let maxX = win.screenX + win.outerWidth - offset - 1;
let panelY = screenY - offset;
let windowY = win.screenY + (win.outerHeight - win.innerHeight);
let maxY = win.screenY + win.outerHeight - offset - 1;
// don't let the panel move outside the browser window
panelX = Math.max(windowX - offset, Math.min(panelX, maxX));
panelY = Math.max(windowY - offset, Math.min(panelY, maxY));
return { panelX: panelX, panelY: panelY };
},
/**
* Move the eyedropper panel to the given coordinates.
*
* @param {number} screenX
* left coordinate on the screen
* @param {number} screenY
* top coordinate
*/
_movePanel: function(screenX, screenY) {
this._panelX = screenX;
this._panelY = screenY;
this._panel.moveTo(screenX, screenY);
},
/**
* Handler for the mouse down event on the inspected page. This means a
* click, so we'll select the color that's currently hovered.
*
* @param {Event} event
* DOM MouseEvent object
*/
_onMouseDown: function(event) {
event.preventDefault();
event.stopPropagation();
this.selectColor();
},
/**
* Select the current color that's being previewed. Fire a
* "select" event with the color as an rgb string.
*/
selectColor: function() {
if (this._isSelecting) {
return;
}
this._isSelecting = true;
this._dragging = false;
this.emit("select", this._colorValue.value);
if (this.copyOnSelect) {
this.copyColor(this.destroy.bind(this));
}
else {
this.destroy();
}
},
/**
* Copy the currently inspected color to the clipboard.
*
* @param {Function} callback
* Callback to be called when the color is in the clipboard.
*/
copyColor: function(callback) {
Services.appShell.hiddenDOMWindow.clearTimeout(this._copyTimeout);
let color = this._colorValue.value;
clipboardHelper.copyString(color);
this._colorValue.classList.add("highlight");
this._colorValue.value = "✓ " + l10n.GetStringFromName("colorValue.copied");
this._copyTimeout = Services.appShell.hiddenDOMWindow.setTimeout(() => {
this._colorValue.classList.remove("highlight");
this._colorValue.value = color;
if (callback) {
callback();
}
}, CLOSE_DELAY);
},
/**
* Handler for the keydown event on the panel. Either copy the color
* or move the panel in a direction depending on the key pressed.
*
* @param {Event} event
* DOM KeyboardEvent object
*/
_onKeyDown: function(event) {
if (event.metaKey && event.keyCode === event.DOM_VK_C) {
this.copyColor();
return;
}
let offsetX = 0;
let offsetY = 0;
let modifier = 1;
if (event.keyCode === event.DOM_VK_LEFT) {
offsetX = -1;
}
if (event.keyCode === event.DOM_VK_RIGHT) {
offsetX = 1;
}
if (event.keyCode === event.DOM_VK_UP) {
offsetY = -1;
}
if (event.keyCode === event.DOM_VK_DOWN) {
offsetY = 1;
}
if (event.shiftKey) {
modifier = 10;
}
offsetY *= modifier;
offsetX *= modifier;
if (offsetX !== 0 || offsetY !== 0) {
this._zoomArea.x += offsetX;
this._zoomArea.y += offsetY;
this._drawWindow();
this._movePanel(this._panelX + offsetX, this._panelY + offsetY);
event.preventDefault();
}
},
/**
* Draw the inspected area onto the canvas using the zoom level.
*/
_drawWindow: function() {
let { width, height, x, y, inContent,
contentWidth, contentHeight } = this._zoomArea;
let zoomedWidth = width / this.zoom;
let zoomedHeight = height / this.zoom;
let leftX = x - (zoomedWidth / 2);
let topY = y - (zoomedHeight / 2);
// draw the portion of the window we're inspecting
if (inContent) {
// draw from content source image "s" to destination rect "d"
let sx = leftX;
let sy = topY;
let sw = zoomedWidth;
let sh = zoomedHeight;
let dx = 0;
let dy = 0;
// we're at the content edge, so we have to crop the drawing
if (leftX < 0) {
sx = 0;
sw = zoomedWidth + leftX;
dx = -leftX;
}
else if (leftX + zoomedWidth > contentWidth) {
sw = contentWidth - leftX;
}
if (topY < 0) {
sy = 0;
sh = zoomedHeight + topY;
dy = -topY;
}
else if (topY + zoomedHeight > contentHeight) {
sh = contentHeight - topY;
}
let dw = sw;
let dh = sh;
// we don't want artifacts when we're inspecting the edges of content
if (leftX < 0 || topY < 0 ||
leftX + zoomedWidth > contentWidth ||
topY + zoomedHeight > contentHeight) {
this._ctx.fillStyle = "white";
this._ctx.fillRect(0, 0, width, height);
}
// draw from the screenshot to the eyedropper canvas
this._ctx.drawImage(this._contentImage, sx, sy, sw,
sh, dx, dy, dw, dh);
}
else {
// the mouse is over the chrome, so draw that instead of the content
this._ctx.drawWindow(this._chromeWindow, leftX, topY, zoomedWidth,
zoomedHeight, "white");
}
// now scale it
this._ctx.drawImage(this._canvas, 0, 0, zoomedWidth, zoomedHeight,
0, 0, width, height);
let rgb = this.centerColor;
this._colorPreview.style.backgroundColor = toColorString(rgb, "rgb");
this._colorValue.value = toColorString(rgb, this.format);
if (this.zoom > 2) {
// grid at 2x is too busy
this._drawGrid();
}
this._drawCrosshair();
},
/**
* Draw a grid on the canvas representing pixel boundaries.
*/
_drawGrid: function() {
let { width, height } = this._zoomArea;
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 - .5, 0);
this._ctx.lineTo(i - .5, height);
this._ctx.stroke();
this._ctx.beginPath();
this._ctx.moveTo(0, i - .5);
this._ctx.lineTo(width, i - .5);
this._ctx.stroke();
}
},
/**
* Draw a box on the canvas to highlight the center cell.
*/
_drawCrosshair: function() {
let x = y = this.centerCell * this.cellSize;
this._ctx.lineWidth = 1;
this._ctx.lineJoin = 'miter';
this._ctx.strokeStyle = "rgba(0, 0, 0, 1)";
this._ctx.strokeRect(x - 1.5, y - 1.5, this.cellSize + 2, this.cellSize + 2);
this._ctx.strokeStyle = "rgba(255, 255, 255, 1)";
this._ctx.strokeRect(x - 0.5, y - 0.5, this.cellSize, this.cellSize);
},
/**
* Destroy the eyedropper and clean up. Emits a "destroy" event.
*/
destroy: function() {
this._resetCursor();
this._hideCrosshairs();
if (this._panel) {
this._panel.hidePopup();
this._panel.remove();
this._panel = null;
}
this._removePanelListeners();
this._removeListeners();
this.isStarted = false;
this.isOpen = false;
this._isSelecting = false;
this.emit("destroy");
}
}
/**
* Add a user style sheet that applies to all documents.
*/
function registerStyleSheet(url) {
var uri = ioService.newURI(url, null, null);
if (!ssService.sheetRegistered(uri, ssService.AGENT_SHEET)) {
ssService.loadAndRegisterSheet(uri, ssService.AGENT_SHEET);
}
}
/**
* Remove a user style sheet.
*/
function unregisterStyleSheet(url) {
var uri = ioService.newURI(url, null, null);
if (ssService.sheetRegistered(uri, ssService.AGENT_SHEET)) {
ssService.unregisterSheet(uri, ssService.AGENT_SHEET);
}
}
/**
* 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) {
let [r,g,b] = rgb;
switch(format) {
case "hex":
return hexString(rgb);
case "rgb":
return "rgb(" + r + ", " + g + ", " + b + ")";
case "hsl":
let [h,s,l] = rgbToHsl(rgb);
return "hsl(" + h + ", " + s + "%, " + l + "%)";
case "name":
let str;
try {
str = DOMUtils.rgbToColorName(r, g, b);
} catch(e) {
str = 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]) {
let val = (1 << 24) + (r << 16) + (g << 8) + (b << 0);
return "#" + val.toString(16).substr(-6).toUpperCase();
}