diff -r 000000000000 -r 6474c204b198 browser/devtools/eyedropper/eyedropper.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser/devtools/eyedropper/eyedropper.js Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,712 @@ +/* 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"); + +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._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 + }; + 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; + }, + + /** + * 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; + } + this.isOpen = true; + + this._OS = XULRuntime.OS; + + this._chromeDocument.addEventListener("mousemove", this._onFirstMouseMove); + + this._showCrosshairs(); + }, + + /** + * 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(); + }, + + /** + * Set the current coordinates to inspect from where a mousemove originated. + * + * @param {MouseEvent} event + * Event for the mouse move. + */ + _setCoordinates: function(event) { + let win = this._chromeWindow; + + let x, y; + if (this._OS == "Linux") { + // event.clientX is off on Linux, so calculate it by hand + let windowX = win.screenX + (win.outerWidth - win.innerWidth); + x = event.screenX - windowX; + + let windowY = win.screenY + (win.outerHeight - win.innerHeight); + y = event.screenY - windowY; + } + else { + x = event.clientX; + y = event.clientY; + } + + // 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 } = this._zoomArea; + + let zoomedWidth = width / this.zoom; + let zoomedHeight = height / this.zoom; + + let drawX = x - (zoomedWidth / 2); + let drawY = y - (zoomedHeight / 2); + + // draw the portion of the window we're inspecting + this._ctx.drawWindow(this._chromeWindow, drawX, drawY, zoomedWidth, + zoomedHeight, "white"); + + // now scale it + let sx = 0; + let sy = 0; + let sw = zoomedWidth; + let sh = zoomedHeight; + let dx = 0; + let dy = 0; + let dw = width; + let dh = height; + + this._ctx.drawImage(this._canvas, sx, sy, sw, sh, dx, dy, dw, dh); + + 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.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(); +}