browser/devtools/eyedropper/eyedropper.js

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

     1 /* This Source Code Form is subject to the terms of the Mozilla Public
     2  * License, v. 2.0. If a copy of the MPL was not distributed with this
     3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     5 const {Cc, Ci, Cu} = require("chrome");
     6 const {rgbToHsl} = require("devtools/css-color").colorUtils;
     7 const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js");
     9 Cu.import("resource://gre/modules/Services.jsm");
    11 loader.lazyGetter(this, "clipboardHelper", function() {
    12   return Cc["@mozilla.org/widget/clipboardhelper;1"]
    13     .getService(Ci.nsIClipboardHelper);
    14 });
    16 loader.lazyGetter(this, "ssService", function() {
    17   return Cc["@mozilla.org/content/style-sheet-service;1"]
    18     .getService(Ci.nsIStyleSheetService);
    19 });
    21 loader.lazyGetter(this, "ioService", function() {
    22   return Cc["@mozilla.org/network/io-service;1"]
    23     .getService(Ci.nsIIOService);
    24 });
    26 loader.lazyGetter(this, "DOMUtils", function () {
    27   return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
    28 });
    30 loader.lazyGetter(this, "XULRuntime", function() {
    31   return Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime);
    32 });
    34 loader.lazyGetter(this, "l10n", () => Services.strings
    35   .createBundle("chrome://browser/locale/devtools/eyedropper.properties"));
    37 const EYEDROPPER_URL = "chrome://browser/content/devtools/eyedropper.xul";
    38 const CROSSHAIRS_URL = "chrome://browser/content/devtools/eyedropper/crosshairs.css";
    39 const NOCURSOR_URL = "chrome://browser/content/devtools/eyedropper/nocursor.css";
    41 const ZOOM_PREF = "devtools.eyedropper.zoom";
    42 const FORMAT_PREF = "devtools.defaultColorUnit";
    44 const CANVAS_WIDTH = 96;
    45 const CANVAS_OFFSET = 3; // equals the border width of the canvas.
    46 const CLOSE_DELAY = 750;
    48 const HEX_BOX_WIDTH = CANVAS_WIDTH + CANVAS_OFFSET * 2;
    49 const HSL_BOX_WIDTH = 158;
    51 /**
    52  * Manage instances of eyedroppers for windows. Registering here isn't
    53  * necessary for creating an eyedropper, but can be used for testing.
    54  */
    55 let EyedropperManager = {
    56   _instances: new WeakMap(),
    58   getInstance: function(chromeWindow) {
    59     return this._instances.get(chromeWindow);
    60   },
    62   createInstance: function(chromeWindow) {
    63     let dropper = this.getInstance(chromeWindow);
    64     if (dropper) {
    65       return dropper;
    66     }
    68     dropper = new Eyedropper(chromeWindow);
    69     this._instances.set(chromeWindow, dropper);
    71     dropper.on("destroy", () => {
    72       this.deleteInstance(chromeWindow);
    73     });
    75     return dropper;
    76   },
    78   deleteInstance: function(chromeWindow) {
    79     this._instances.delete(chromeWindow);
    80   }
    81 }
    83 exports.EyedropperManager = EyedropperManager;
    85 /**
    86  * Eyedropper widget. Once opened, shows zoomed area above current pixel and
    87  * displays the color value of the center pixel. Clicking on the window will
    88  * close the widget and fire a 'select' event. If 'copyOnSelect' is true, the color
    89  * will also be copied to the clipboard.
    90  *
    91  * let eyedropper = new Eyedropper(window);
    92  * eyedropper.open();
    93  *
    94  * eyedropper.once("select", (ev, color) => {
    95  *   console.log(color);  // "rgb(20, 50, 230)"
    96  * })
    97  *
    98  * @param {DOMWindow} chromeWindow
    99  *        window to inspect
   100  * @param {object} opts
   101  *        optional options object, with 'copyOnSelect'
   102  */
   103 function Eyedropper(chromeWindow, opts = { copyOnSelect: true }) {
   104   this.copyOnSelect = opts.copyOnSelect;
   106   this._onFirstMouseMove = this._onFirstMouseMove.bind(this);
   107   this._onMouseMove = this._onMouseMove.bind(this);
   108   this._onMouseDown = this._onMouseDown.bind(this);
   109   this._onKeyDown = this._onKeyDown.bind(this);
   110   this._onFrameLoaded = this._onFrameLoaded.bind(this);
   112   this._chromeWindow = chromeWindow;
   113   this._chromeDocument = chromeWindow.document;
   115   this._dragging = true;
   116   this.loaded = false;
   118   this._mouseMoveCounter = 0;
   120   this.format = Services.prefs.getCharPref(FORMAT_PREF); // color value format
   121   this.zoom = Services.prefs.getIntPref(ZOOM_PREF);      // zoom level - integer
   123   this._zoomArea = {
   124     x: 0,          // the left coordinate of the center of the inspected region
   125     y: 0,          // the top coordinate of the center of the inspected region
   126     width: CANVAS_WIDTH,      // width of canvas to draw zoomed area onto
   127     height: CANVAS_WIDTH      // height of canvas
   128   };
   129   EventEmitter.decorate(this);
   130 }
   132 exports.Eyedropper = Eyedropper;
   134 Eyedropper.prototype = {
   135   /**
   136    * Get the number of cells (blown-up pixels) per direction in the grid.
   137    */
   138   get cellsWide() {
   139     // Canvas will render whole "pixels" (cells) only, and an even
   140     // number at that. Round up to the nearest even number of pixels.
   141     let cellsWide = Math.ceil(this._zoomArea.width / this.zoom);
   142     cellsWide += cellsWide % 2;
   144     return cellsWide;
   145   },
   147   /**
   148    * Get the size of each cell (blown-up pixel) in the grid.
   149    */
   150   get cellSize() {
   151     return this._zoomArea.width / this.cellsWide;
   152   },
   154   /**
   155    * Get index of cell in the center of the grid.
   156    */
   157   get centerCell() {
   158     return Math.floor(this.cellsWide / 2);
   159   },
   161   /**
   162    * Get color of center cell in the grid.
   163    */
   164   get centerColor() {
   165     let x = y = (this.centerCell * this.cellSize) + (this.cellSize / 2);
   166     let rgb = this._ctx.getImageData(x, y, 1, 1).data;
   167     return rgb;
   168   },
   170   /**
   171    * Start the eyedropper. Add listeners for a mouse move in the window to
   172    * show the eyedropper.
   173    */
   174   open: function() {
   175     if (this.isOpen) {
   176       // the eyedropper is aready open, don't create another panel.
   177       return;
   178     }
   179     this.isOpen = true;
   181     this._OS = XULRuntime.OS;
   183     this._chromeDocument.addEventListener("mousemove", this._onFirstMouseMove);
   185     this._showCrosshairs();
   186   },
   188   /**
   189    * Called on the first mouse move over the window. Opens the eyedropper
   190    * panel where the mouse is.
   191    */
   192   _onFirstMouseMove: function(event) {
   193     this._chromeDocument.removeEventListener("mousemove", this._onFirstMouseMove);
   195     this._panel = this._buildPanel();
   197     let popupSet = this._chromeDocument.querySelector("#mainPopupSet");
   198     popupSet.appendChild(this._panel);
   200     let { panelX, panelY } = this._getPanelCoordinates(event);
   201     this._panel.openPopupAtScreen(panelX, panelY);
   203     this._setCoordinates(event);
   205     this._addListeners();
   207     // hide cursor as we'll be showing the panel over the mouse instead.
   208     this._hideCrosshairs();
   209     this._hideCursor();
   210   },
   212   /**
   213    * Set the current coordinates to inspect from where a mousemove originated.
   214    *
   215    * @param {MouseEvent} event
   216    *        Event for the mouse move.
   217    */
   218   _setCoordinates: function(event) {
   219     let win = this._chromeWindow;
   221     let x, y;
   222     if (this._OS == "Linux") {
   223       // event.clientX is off on Linux, so calculate it by hand
   224       let windowX = win.screenX + (win.outerWidth - win.innerWidth);
   225       x = event.screenX - windowX;
   227       let windowY = win.screenY + (win.outerHeight - win.innerHeight);
   228       y = event.screenY - windowY;
   229     }
   230     else {
   231       x = event.clientX;
   232       y = event.clientY;
   233     }
   235     // don't let it inspect outside the browser window
   236     x = Math.max(0, Math.min(x, win.outerWidth - 1));
   237     y = Math.max(0, Math.min(y, win.outerHeight - 1));
   239     this._zoomArea.x = x;
   240     this._zoomArea.y = y;
   241   },
   243   /**
   244    * Build and add a new eyedropper panel to the window.
   245    *
   246    * @return {Panel}
   247    *         The XUL panel holding the eyedropper UI.
   248    */
   249   _buildPanel: function() {
   250     let panel = this._chromeDocument.createElement("panel");
   251     panel.setAttribute("noautofocus", true);
   252     panel.setAttribute("noautohide", true);
   253     panel.setAttribute("level", "floating");
   254     panel.setAttribute("class", "devtools-eyedropper-panel");
   256     let iframe = this._iframe = this._chromeDocument.createElement("iframe");
   257     iframe.addEventListener("load", this._onFrameLoaded, true);
   258     iframe.setAttribute("flex", "1");
   259     iframe.setAttribute("transparent", "transparent");
   260     iframe.setAttribute("allowTransparency", true);
   261     iframe.setAttribute("class", "devtools-eyedropper-iframe");
   262     iframe.setAttribute("src", EYEDROPPER_URL);
   263     iframe.setAttribute("width", CANVAS_WIDTH);
   264     iframe.setAttribute("height", CANVAS_WIDTH);
   266     panel.appendChild(iframe);
   268     return panel;
   269   },
   271   /**
   272    * Event handler for the panel's iframe's load event. Emits
   273    * a "load" event from this eyedropper object.
   274    */
   275   _onFrameLoaded: function() {
   276     this._iframe.removeEventListener("load", this._onFrameLoaded, true);
   278     this._iframeDocument = this._iframe.contentDocument;
   279     this._colorPreview = this._iframeDocument.querySelector("#color-preview");
   280     this._colorValue = this._iframeDocument.querySelector("#color-value");
   282     // value box will be too long for hex values and too short for hsl
   283     let valueBox = this._iframeDocument.querySelector("#color-value-box");
   284     if (this.format == "hex") {
   285       valueBox.style.width = HEX_BOX_WIDTH + "px";
   286     }
   287     else if (this.format == "hsl") {
   288       valueBox.style.width = HSL_BOX_WIDTH + "px";
   289     }
   291     this._canvas = this._iframeDocument.querySelector("#canvas");
   292     this._ctx = this._canvas.getContext("2d");
   294     // so we preserve the clear pixel boundaries
   295     this._ctx.mozImageSmoothingEnabled = false;
   297     this._drawWindow();
   299     this._addPanelListeners();
   300     this._iframe.focus();
   302     this.loaded = true;
   303     this.emit("load");
   304   },
   306   /**
   307    * Add key listeners to the panel.
   308    */
   309   _addPanelListeners: function() {
   310     this._iframeDocument.addEventListener("keydown", this._onKeyDown);
   312     let closeCmd = this._iframeDocument.getElementById("eyedropper-cmd-close");
   313     closeCmd.addEventListener("command", this.destroy.bind(this), true);
   315     let copyCmd = this._iframeDocument.getElementById("eyedropper-cmd-copy");
   316     copyCmd.addEventListener("command", this.selectColor.bind(this), true);
   317   },
   319   /**
   320    * Remove listeners from the panel.
   321    */
   322   _removePanelListeners: function() {
   323     this._iframeDocument.removeEventListener("keydown", this._onKeyDown);
   324   },
   326   /**
   327    * Add mouse event listeners to the document we're inspecting.
   328    */
   329   _addListeners: function() {
   330     this._chromeDocument.addEventListener("mousemove", this._onMouseMove);
   331     this._chromeDocument.addEventListener("mousedown", this._onMouseDown);
   332   },
   334   /**
   335    * Remove mouse event listeners from the document we're inspecting.
   336    */
   337   _removeListeners: function() {
   338     this._chromeDocument.removeEventListener("mousemove", this._onFirstMouseMove);
   339     this._chromeDocument.removeEventListener("mousemove", this._onMouseMove);
   340     this._chromeDocument.removeEventListener("mousedown", this._onMouseDown);
   341   },
   343   /**
   344    * Hide the cursor.
   345    */
   346   _hideCursor: function() {
   347     registerStyleSheet(NOCURSOR_URL);
   348   },
   350   /**
   351    * Reset the cursor back to default.
   352    */
   353   _resetCursor: function() {
   354     unregisterStyleSheet(NOCURSOR_URL);
   355   },
   357   /**
   358    * Show a crosshairs as the mouse cursor
   359    */
   360   _showCrosshairs: function() {
   361     registerStyleSheet(CROSSHAIRS_URL);
   362   },
   364   /**
   365    * Reset cursor.
   366    */
   367   _hideCrosshairs: function() {
   368     unregisterStyleSheet(CROSSHAIRS_URL);
   369   },
   371   /**
   372    * Event handler for a mouse move over the page we're inspecting.
   373    * Preview the area under the cursor, and move panel to be under the cursor.
   374    *
   375    * @param  {DOMEvent} event
   376    *         MouseEvent for the mouse moving
   377    */
   378   _onMouseMove: function(event) {
   379     if (!this._dragging || !this._panel || !this._canvas) {
   380       return;
   381     }
   383     if (this._OS == "Linux" && ++this._mouseMoveCounter % 2 == 0) {
   384       // skip every other mousemove to preserve performance.
   385       return;
   386     }
   388     this._setCoordinates(event);
   389     this._drawWindow();
   391     let { panelX, panelY } = this._getPanelCoordinates(event);
   392     this._movePanel(panelX, panelY);
   393   },
   395   /**
   396    * Get coordinates of where the eyedropper panel should go based on
   397    * the current coordinates of the mouse cursor.
   398    *
   399    * @param {MouseEvent} event
   400    *        object with properties 'screenX' and 'screenY'
   401    *
   402    * @return {object}
   403   *          object with properties 'panelX', 'panelY'
   404    */
   405   _getPanelCoordinates: function({screenX, screenY}) {
   406     let win = this._chromeWindow;
   407     let offset = CANVAS_WIDTH / 2 + CANVAS_OFFSET;
   409     let panelX = screenX - offset;
   410     let windowX = win.screenX + (win.outerWidth - win.innerWidth);
   411     let maxX = win.screenX + win.outerWidth - offset - 1;
   413     let panelY = screenY - offset;
   414     let windowY = win.screenY + (win.outerHeight - win.innerHeight);
   415     let maxY = win.screenY + win.outerHeight - offset - 1;
   417     // don't let the panel move outside the browser window
   418     panelX = Math.max(windowX - offset, Math.min(panelX, maxX));
   419     panelY = Math.max(windowY - offset, Math.min(panelY, maxY));
   421     return { panelX: panelX, panelY: panelY };
   422   },
   424   /**
   425    * Move the eyedropper panel to the given coordinates.
   426    *
   427    * @param  {number} screenX
   428    *         left coordinate on the screen
   429    * @param  {number} screenY
   430    *         top coordinate
   431    */
   432   _movePanel: function(screenX, screenY) {
   433     this._panelX = screenX;
   434     this._panelY = screenY;
   436     this._panel.moveTo(screenX, screenY);
   437   },
   439   /**
   440    * Handler for the mouse down event on the inspected page. This means a
   441    * click, so we'll select the color that's currently hovered.
   442    *
   443    * @param  {Event} event
   444    *         DOM MouseEvent object
   445    */
   446   _onMouseDown: function(event) {
   447     event.preventDefault();
   448     event.stopPropagation();
   450     this.selectColor();
   451   },
   453   /**
   454    * Select the current color that's being previewed. Fire a
   455    * "select" event with the color as an rgb string.
   456    */
   457   selectColor: function() {
   458     if (this._isSelecting) {
   459       return;
   460     }
   461     this._isSelecting = true;
   462     this._dragging = false;
   464     this.emit("select", this._colorValue.value);
   466     if (this.copyOnSelect) {
   467       this.copyColor(this.destroy.bind(this));
   468     }
   469     else {
   470       this.destroy();
   471     }
   472   },
   474   /**
   475    * Copy the currently inspected color to the clipboard.
   476    *
   477    * @param  {Function} callback
   478    *         Callback to be called when the color is in the clipboard.
   479    */
   480   copyColor: function(callback) {
   481     Services.appShell.hiddenDOMWindow.clearTimeout(this._copyTimeout);
   483     let color = this._colorValue.value;
   484     clipboardHelper.copyString(color);
   486     this._colorValue.classList.add("highlight");
   487     this._colorValue.value = "✓ " + l10n.GetStringFromName("colorValue.copied");
   489     this._copyTimeout = Services.appShell.hiddenDOMWindow.setTimeout(() => {
   490       this._colorValue.classList.remove("highlight");
   491       this._colorValue.value = color;
   493       if (callback) {
   494         callback();
   495       }
   496     }, CLOSE_DELAY);
   497   },
   499   /**
   500    * Handler for the keydown event on the panel. Either copy the color
   501    * or move the panel in a direction depending on the key pressed.
   502    *
   503    * @param  {Event} event
   504    *         DOM KeyboardEvent object
   505    */
   506   _onKeyDown: function(event) {
   507     if (event.metaKey && event.keyCode === event.DOM_VK_C) {
   508       this.copyColor();
   509       return;
   510     }
   512     let offsetX = 0;
   513     let offsetY = 0;
   514     let modifier = 1;
   516     if (event.keyCode === event.DOM_VK_LEFT) {
   517       offsetX = -1;
   518     }
   519     if (event.keyCode === event.DOM_VK_RIGHT) {
   520       offsetX = 1;
   521     }
   522     if (event.keyCode === event.DOM_VK_UP) {
   523       offsetY = -1;
   524     }
   525     if (event.keyCode === event.DOM_VK_DOWN) {
   526       offsetY = 1;
   527     }
   528     if (event.shiftKey) {
   529       modifier = 10;
   530     }
   532     offsetY *= modifier;
   533     offsetX *= modifier;
   535     if (offsetX !== 0 || offsetY !== 0) {
   536       this._zoomArea.x += offsetX;
   537       this._zoomArea.y += offsetY;
   539       this._drawWindow();
   541       this._movePanel(this._panelX + offsetX, this._panelY + offsetY);
   543       event.preventDefault();
   544     }
   545   },
   547   /**
   548    * Draw the inspected area onto the canvas using the zoom level.
   549    */
   550   _drawWindow: function() {
   551     let { width, height, x, y } = this._zoomArea;
   553     let zoomedWidth = width / this.zoom;
   554     let zoomedHeight = height / this.zoom;
   556     let drawX = x - (zoomedWidth / 2);
   557     let drawY = y - (zoomedHeight / 2);
   559     // draw the portion of the window we're inspecting
   560     this._ctx.drawWindow(this._chromeWindow, drawX, drawY, zoomedWidth,
   561                         zoomedHeight, "white");
   563     // now scale it
   564     let sx = 0;
   565     let sy = 0;
   566     let sw = zoomedWidth;
   567     let sh = zoomedHeight;
   568     let dx = 0;
   569     let dy = 0;
   570     let dw = width;
   571     let dh = height;
   573     this._ctx.drawImage(this._canvas, sx, sy, sw, sh, dx, dy, dw, dh);
   575     let rgb = this.centerColor;
   576     this._colorPreview.style.backgroundColor = toColorString(rgb, "rgb");
   577     this._colorValue.value = toColorString(rgb, this.format);
   579     if (this.zoom > 2) {
   580       // grid at 2x is too busy
   581       this._drawGrid();
   582     }
   583     this._drawCrosshair();
   584   },
   586   /**
   587    * Draw a grid on the canvas representing pixel boundaries.
   588    */
   589   _drawGrid: function() {
   590     let { width, height } = this._zoomArea;
   592     this._ctx.lineWidth = 1;
   593     this._ctx.strokeStyle = "rgba(143, 143, 143, 0.2)";
   595     for (let i = 0; i < width; i += this.cellSize) {
   596       this._ctx.beginPath();
   597       this._ctx.moveTo(i - .5, 0);
   598       this._ctx.lineTo(i - .5, height);
   599       this._ctx.stroke();
   601       this._ctx.beginPath();
   602       this._ctx.moveTo(0, i - .5);
   603       this._ctx.lineTo(width, i - .5);
   604       this._ctx.stroke();
   605     }
   606   },
   608   /**
   609    * Draw a box on the canvas to highlight the center cell.
   610    */
   611   _drawCrosshair: function() {
   612     let x = y = this.centerCell * this.cellSize;
   614     this._ctx.lineWidth = 1;
   615     this._ctx.lineJoin = 'miter';
   616     this._ctx.strokeStyle = "rgba(0, 0, 0, 1)";
   617     this._ctx.strokeRect(x - 1.5, y - 1.5, this.cellSize + 2, this.cellSize + 2);
   619     this._ctx.strokeStyle = "rgba(255, 255, 255, 1)";
   620     this._ctx.strokeRect(x - 0.5, y - 0.5, this.cellSize, this.cellSize);
   621   },
   623   /**
   624    * Destroy the eyedropper and clean up. Emits a "destroy" event.
   625    */
   626   destroy: function() {
   627     this._resetCursor();
   628     this._hideCrosshairs();
   630     if (this._panel) {
   631       this._panel.hidePopup();
   632       this._panel.remove();
   633       this._panel = null;
   634     }
   635     this._removePanelListeners();
   636     this._removeListeners();
   638     this.isOpen = false;
   639     this._isSelecting = false;
   641     this.emit("destroy");
   642   }
   643 }
   645 /**
   646  * Add a user style sheet that applies to all documents.
   647  */
   648 function registerStyleSheet(url) {
   649   var uri = ioService.newURI(url, null, null);
   650   if (!ssService.sheetRegistered(uri, ssService.AGENT_SHEET)) {
   651     ssService.loadAndRegisterSheet(uri, ssService.AGENT_SHEET);
   652   }
   653 }
   655 /**
   656  * Remove a user style sheet.
   657  */
   658 function unregisterStyleSheet(url) {
   659   var uri = ioService.newURI(url, null, null);
   660   if (ssService.sheetRegistered(uri, ssService.AGENT_SHEET)) {
   661     ssService.unregisterSheet(uri, ssService.AGENT_SHEET);
   662   }
   663 }
   665 /**
   666  * Get a formatted CSS color string from a color value.
   667  *
   668  * @param {array} rgb
   669  *        Rgb values of a color to format
   670  * @param {string} format
   671  *        Format of string. One of "hex", "rgb", "hsl", "name"
   672  *
   673  * @return {string}
   674  *        Formatted color value, e.g. "#FFF" or "hsl(20, 10%, 10%)"
   675  */
   676 function toColorString(rgb, format) {
   677   let [r,g,b] = rgb;
   679   switch(format) {
   680     case "hex":
   681       return hexString(rgb);
   682     case "rgb":
   683       return "rgb(" + r + ", " + g + ", " + b + ")";
   684     case "hsl":
   685       let [h,s,l] = rgbToHsl(rgb);
   686       return "hsl(" + h + ", " + s + "%, " + l + "%)";
   687     case "name":
   688       let str;
   689       try {
   690         str = DOMUtils.rgbToColorName(r, g, b);
   691       } catch(e) {
   692         str = hexString(rgb);
   693       }
   694       return str;
   695     default:
   696       return hexString(rgb);
   697   }
   698 }
   700 /**
   701  * Produce a hex-formatted color string from rgb values.
   702  *
   703  * @param {array} rgb
   704  *        Rgb values of color to stringify
   705  *
   706  * @return {string}
   707  *        Hex formatted string for color, e.g. "#FFEE00"
   708  */
   709 function hexString([r,g,b]) {
   710   let val = (1 << 24) + (r << 16) + (g << 8) + (b << 0);
   711   return "#" + val.toString(16).substr(-6).toUpperCase();
   712 }

mercurial