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