Wed, 31 Dec 2014 06:09:35 +0100
Cloned upstream origin tor-browser at tor-browser-31.3.0esr-4.5-1-build1
revision ID fc1c9ff7c1b2defdbc039f12214767608f46423f for hacking purpose.
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 }