Bug 1262439 - 1 - Introduce a new eye-dropper highlighter; r=zer0 draft
authorPatrick Brosset <pbrosset@mozilla.com>
Thu, 07 Jul 2016 12:43:14 +0200
changeset 385007 db2cfd6ef42a3d9c961dee4c994d7b4a6d93b3cb
parent 385006 aa73438f7e63760e84b7932f28ab4d40810ab735
child 385008 b44f042366f442406da6230f1b936a4aabcc1510
push id22388
push userpbrosset@mozilla.com
push dateThu, 07 Jul 2016 13:03:20 +0000
reviewerszer0
bugs1262439
milestone50.0a1
Bug 1262439 - 1 - Introduce a new eye-dropper highlighter; r=zer0 This is mostly porting code from the XUL eye-dropper over to a new highlighter file. While I have tested this locally, this highlighter isn't yet used in devtools. This will come in later patches. MozReview-Commit-ID: IF0puLu5Yc7
devtools/client/eyedropper/eyedropper.js
devtools/client/inspector/test/browser.ini
devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-clipboard.js
devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-events.js
devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-show-hide.js
devtools/client/inspector/test/head.js
devtools/client/performance/modules/widgets/graphs.js
devtools/client/performance/modules/widgets/markers-overview.js
devtools/client/shared/css-color-db.js
devtools/client/shared/css-color.js
devtools/client/shared/moz.build
devtools/client/shared/output-parser.js
devtools/client/shared/test/browser_css_color.js
devtools/client/shared/test/unit/test_cssColor.js
devtools/client/shared/test/unit/test_cssColorDatabase.js
devtools/client/shared/widgets/Tooltip.js
devtools/server/actors/highlighters.css
devtools/server/actors/highlighters.js
devtools/server/actors/highlighters/eye-dropper.js
devtools/server/actors/highlighters/moz.build
devtools/server/actors/highlighters/utils/markup.js
devtools/shared/css-color-db.js
devtools/shared/css-color.js
devtools/shared/moz.build
--- a/devtools/client/eyedropper/eyedropper.js
+++ b/devtools/client/eyedropper/eyedropper.js
@@ -1,17 +1,15 @@
 /* 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/. */
 
 "use strict";
-
+const {rgbToHsl, rgbToColorName} = require("devtools/shared/css-color").colorUtils;
 const {Cc, Ci} = require("chrome");
-const {rgbToHsl, rgbToColorName} =
-      require("devtools/client/shared/css-color").colorUtils;
 const Telemetry = require("devtools/client/shared/telemetry");
 const EventEmitter = require("devtools/shared/event-emitter");
 const promise = require("promise");
 const defer = require("devtools/shared/defer");
 const Services = require("Services");
 
 loader.lazyGetter(this, "clipboardHelper", function () {
   return Cc["@mozilla.org/widget/clipboardhelper;1"]
--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -58,16 +58,20 @@ skip-if = os == "mac" # Full keyboard na
 [browser_inspector_highlighter-02.js]
 [browser_inspector_highlighter-03.js]
 [browser_inspector_highlighter-04.js]
 [browser_inspector_highlighter-by-type.js]
 [browser_inspector_highlighter-comments.js]
 [browser_inspector_highlighter-csstransform_01.js]
 [browser_inspector_highlighter-csstransform_02.js]
 [browser_inspector_highlighter-embed.js]
+[browser_inspector_highlighter-eyedropper-clipboard.js]
+subsuite = clipboard
+[browser_inspector_highlighter-eyedropper-events.js]
+[browser_inspector_highlighter-eyedropper-show-hide.js]
 [browser_inspector_highlighter-geometry_01.js]
 [browser_inspector_highlighter-geometry_02.js]
 [browser_inspector_highlighter-geometry_03.js]
 [browser_inspector_highlighter-geometry_04.js]
 [browser_inspector_highlighter-geometry_05.js]
 [browser_inspector_highlighter-geometry_06.js]
 [browser_inspector_highlighter-hover_01.js]
 [browser_inspector_highlighter-hover_02.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-clipboard.js
@@ -0,0 +1,65 @@
+/* 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/. */
+"use strict";
+
+// Test that the eyedropper can copy colors to the clipboard
+
+const HIGHLIGHTER_TYPE = "EyeDropper";
+const ID = "eye-dropper-";
+const TEST_URI = "data:text/html;charset=utf-8,<style>html{background:red}</style>";
+
+add_task(function* () {
+  let helper = yield openInspectorForURL(TEST_URI)
+               .then(getHighlighterHelperFor(HIGHLIGHTER_TYPE));
+  helper.prefix = ID;
+
+  let {show, synthesizeKey, finalize} = helper;
+
+  info("Show the eyedropper with the copyOnSelect option");
+  yield show("html", {copyOnSelect: true});
+
+  info("Make sure to wait until the eyedropper is done taking a screenshot of the page");
+  yield waitForElementAttributeSet("root", "drawn", helper);
+
+  yield waitForClipboard(() => {
+    info("Activate the eyedropper so the background color is copied");
+    let generateKey = synthesizeKey({key: "VK_RETURN", options: {}});
+    generateKey.next();
+  }, "#FF0000");
+
+  ok(true, "The clipboard contains the right value");
+
+  yield waitForElementAttributeRemoved("root", "drawn", helper);
+  yield waitForElementAttributeSet("root", "hidden", helper);
+  ok(true, "The eyedropper is now hidden");
+
+  finalize();
+});
+
+function* waitForElementAttributeSet(id, name, {getElementAttribute}) {
+  yield poll(function* () {
+    let value = yield getElementAttribute(id, name);
+    return !!value;
+  }, `Waiting for element ${id} to have attribute ${name} set`);
+}
+
+function* waitForElementAttributeRemoved(id, name, {getElementAttribute}) {
+  yield poll(function* () {
+    let value = yield getElementAttribute(id, name);
+    return !value;
+  }, `Waiting for element ${id} to have attribute ${name} removed`);
+}
+
+function* poll(check, desc) {
+  info(desc);
+
+  for (let i = 0; i < 10; i++) {
+    if (yield check()) {
+      return;
+    }
+    yield new Promise(resolve => setTimeout(resolve, 200));
+  }
+
+  throw new Error(`Timeout while: ${desc}`);
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-events.js
@@ -0,0 +1,71 @@
+/* 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/. */
+"use strict";
+
+// Test the eyedropper mouse and keyboard handling.
+
+const HIGHLIGHTER_TYPE = "EyeDropper";
+const ID = "eye-dropper-";
+
+const MOVE_EVENTS_DATA = [
+  {type: "mouse", x: 200, y: 100, expected: {x: 200, y: 100}},
+  {type: "mouse", x: 100, y: 200, expected: {x: 100, y: 200}},
+  {type: "keyboard", key: "VK_LEFT", expected: {x: 99, y: 200}},
+  {type: "keyboard", key: "VK_LEFT", shift: true, expected: {x: 89, y: 200}},
+  {type: "keyboard", key: "VK_RIGHT", expected: {x: 90, y: 200}},
+  {type: "keyboard", key: "VK_RIGHT", shift: true, expected: {x: 100, y: 200}},
+  {type: "keyboard", key: "VK_DOWN", expected: {x: 100, y: 201}},
+  {type: "keyboard", key: "VK_DOWN", shift: true, expected: {x: 100, y: 211}},
+  {type: "keyboard", key: "VK_UP", expected: {x: 100, y: 210}},
+  {type: "keyboard", key: "VK_UP", shift: true, expected: {x: 100, y: 200}},
+];
+
+add_task(function* () {
+  let helper = yield openInspectorForURL("data:text/html;charset=utf-8,eye-dropper test")
+               .then(getHighlighterHelperFor(HIGHLIGHTER_TYPE));
+  helper.prefix = ID;
+
+  yield helper.show("html");
+  yield respondsToMoveEvents(helper);
+  yield respondsToReturnAndEscape(helper);
+
+  helper.finalize();
+});
+
+function* respondsToMoveEvents(helper) {
+  info("Checking that the eyedropper responds to events from the mouse and keyboard");
+  let {mouse, synthesizeKey} = helper;
+
+  for (let {type, x, y, key, shift, expected} of MOVE_EVENTS_DATA) {
+    info(`Simulating a ${type} event to move to ${expected.x} ${expected.y}`);
+    if (type === "mouse") {
+      yield mouse.move(x, y);
+    } else if (type === "keyboard") {
+      let options = shift ? {shiftKey: true} : {};
+      yield synthesizeKey({key, options});
+    }
+    yield checkPosition(expected, helper);
+  }
+}
+
+function* checkPosition({x, y}, {getElementAttribute}) {
+  let style = yield getElementAttribute("root", "style");
+  is(style, `top:${y}px;left:${x}px;`,
+     `The eyedropper is at the expected ${x} ${y} position`);
+}
+
+function* respondsToReturnAndEscape({synthesizeKey, isElementHidden, show}) {
+  info("Simulating return to select the color and hide the eyedropper");
+
+  yield synthesizeKey({key: "VK_RETURN", options: {}});
+  let hidden = yield isElementHidden("root");
+  ok(hidden, "The eyedropper has been hidden");
+
+  info("Showing the eyedropper again and simulating escape to hide it");
+
+  yield show("html");
+  yield synthesizeKey({key: "VK_ESCAPE", options: {}});
+  hidden = yield isElementHidden("root");
+  ok(hidden, "The eyedropper has been hidden again");
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-show-hide.js
@@ -0,0 +1,42 @@
+/* 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/. */
+"use strict";
+
+// Test the basic structure of the eye-dropper highlighter.
+
+const HIGHLIGHTER_TYPE = "EyeDropper";
+const ID = "eye-dropper-";
+
+add_task(function* () {
+  let helper = yield openInspectorForURL("data:text/html;charset=utf-8,eye-dropper test")
+               .then(getHighlighterHelperFor(HIGHLIGHTER_TYPE));
+  helper.prefix = ID;
+
+  yield isInitiallyHidden(helper);
+  yield canBeShownAndHidden(helper);
+
+  helper.finalize();
+});
+
+function* isInitiallyHidden({isElementHidden}) {
+  info("Checking that the eyedropper is hidden by default");
+
+  let hidden = yield isElementHidden("root");
+  ok(hidden, "The eyedropper is hidden by default");
+}
+
+function* canBeShownAndHidden({show, hide, isElementHidden, getElementAttribute}) {
+  info("Asking to show and hide the highlighter actually works");
+
+  yield show("html");
+  let hidden = yield isElementHidden("root");
+  ok(!hidden, "The eyedropper is now shown");
+
+  let style = yield getElementAttribute("root", "style");
+  is(style, "top:100px;left:100px;", "The eyedropper is correctly positioned");
+
+  yield hide();
+  hidden = yield isElementHidden("root");
+  ok(hidden, "The eyedropper is now hidden again");
+}
--- a/devtools/client/inspector/test/head.js
+++ b/devtools/client/inspector/test/head.js
@@ -553,32 +553,33 @@ const getHighlighterHelperFor = (type) =
 
     // Highlighted node
     let highlightedNode = null;
 
     return {
       set prefix(value) {
         prefix = value;
       },
+
       get highlightedNode() {
         if (!highlightedNode) {
           return null;
         }
 
         return {
           getComputedStyle: function* (options = {}) {
             return yield inspector.pageStyle.getComputed(
               highlightedNode, options);
           }
         };
       },
 
-      show: function* (selector = ":root") {
+      show: function* (selector = ":root", options) {
         highlightedNode = yield getNodeFront(selector, inspector);
-        return yield highlighter.show(highlightedNode);
+        return yield highlighter.show(highlightedNode, options);
       },
 
       hide: function* () {
         yield highlighter.hide();
       },
 
       isElementHidden: function* (id) {
         return (yield testActor.getHighlighterNodeAttribute(
@@ -595,16 +596,20 @@ const getHighlighterHelperFor = (type) =
           prefix + id, name, highlighter);
       },
 
       synthesizeMouse: function* (options) {
         options = Object.assign({selector: ":root"}, options);
         yield testActor.synthesizeMouse(options);
       },
 
+      synthesizeKey: function* (options) {
+        yield testActor.synthesizeKey(options);
+      },
+
       // This object will synthesize any "mouse" prefixed event to the
       // `testActor`, using the name of method called as suffix for the
       // event's name.
       // If no x, y coords are given, the previous ones are used.
       //
       // For example:
       //   mouse.down(10, 20); // synthesize "mousedown" at 10,20
       //   mouse.move(20, 30); // synthesize "mousemove" at 20,30
--- a/devtools/client/performance/modules/widgets/graphs.js
+++ b/devtools/client/performance/modules/widgets/graphs.js
@@ -13,17 +13,17 @@ const { Heritage } = require("devtools/c
 const LineGraphWidget = require("devtools/client/shared/widgets/LineGraphWidget");
 const BarGraphWidget = require("devtools/client/shared/widgets/BarGraphWidget");
 const MountainGraphWidget = require("devtools/client/shared/widgets/MountainGraphWidget");
 const { CanvasGraphUtils } = require("devtools/client/shared/widgets/Graphs");
 
 const promise = require("promise");
 const EventEmitter = require("devtools/shared/event-emitter");
 
-const { colorUtils } = require("devtools/client/shared/css-color");
+const { colorUtils } = require("devtools/shared/css-color");
 const { getColor } = require("devtools/client/shared/theme");
 const ProfilerGlobal = require("devtools/client/performance/modules/global");
 const { MarkersOverview } = require("devtools/client/performance/modules/widgets/markers-overview");
 const { createTierGraphDataFromFrameNode } = require("devtools/client/performance/modules/logic/jit");
 
 /**
  * For line graphs
  */
--- a/devtools/client/performance/modules/widgets/markers-overview.js
+++ b/devtools/client/performance/modules/widgets/markers-overview.js
@@ -8,17 +8,17 @@
  * the timeline data. Regions inside it may be selected, determining which
  * markers are visible in the "waterfall".
  */
 
 const { Cc, Ci, Cu, Cr } = require("chrome");
 const { Heritage } = require("devtools/client/shared/widgets/view-helpers");
 const { AbstractCanvasGraph } = require("devtools/client/shared/widgets/Graphs");
 
-const { colorUtils } = require("devtools/client/shared/css-color");
+const { colorUtils } = require("devtools/shared/css-color");
 const { getColor } = require("devtools/client/shared/theme");
 const ProfilerGlobal = require("devtools/client/performance/modules/global");
 const { MarkerBlueprintUtils } = require("devtools/client/performance/modules/marker-blueprint-utils");
 const { TickUtils } = require("devtools/client/performance/modules/widgets/waterfall-ticks");
 const { TIMELINE_BLUEPRINT } = require("devtools/client/performance/modules/markers");
 
 const OVERVIEW_HEADER_HEIGHT = 14; // px
 const OVERVIEW_ROW_HEIGHT = 11; // px
--- a/devtools/client/shared/moz.build
+++ b/devtools/client/shared/moz.build
@@ -15,18 +15,16 @@ DIRS += [
     'widgets',
 ]
 
 DevToolsModules(
     'AppCacheUtils.jsm',
     'autocomplete-popup.js',
     'browser-loader.js',
     'css-angle.js',
-    'css-color-db.js',
-    'css-color.js',
     'css-reload.js',
     'Curl.jsm',
     'demangle.js',
     'developer-toolbar.js',
     'devices.js',
     'devtools-file-watcher.js',
     'DOMHelpers.jsm',
     'doorhanger.js',
--- a/devtools/client/shared/output-parser.js
+++ b/devtools/client/shared/output-parser.js
@@ -1,17 +1,17 @@
 /* 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/. */
 
 "use strict";
 
 const {Cc, Ci} = require("chrome");
 const {angleUtils} = require("devtools/client/shared/css-angle");
-const {colorUtils} = require("devtools/client/shared/css-color");
+const {colorUtils} = require("devtools/shared/css-color");
 const {getCSSLexer} = require("devtools/shared/css-lexer");
 const EventEmitter = require("devtools/shared/event-emitter");
 const {
   ANGLE_TAKING_FUNCTIONS,
   BEZIER_KEYWORDS,
   COLOR_TAKING_FUNCTIONS,
   CSS_TYPES
 } = require("devtools/shared/css-properties-db");
--- a/devtools/client/shared/test/browser_css_color.js
+++ b/devtools/client/shared/test/browser_css_color.js
@@ -1,13 +1,13 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const TEST_URI = "data:text/html;charset=utf-8,browser_css_color.js";
-var {colorUtils} = require("devtools/client/shared/css-color");
+var {colorUtils} = require("devtools/shared/css-color");
 var origColorUnit;
 
 add_task(function* () {
   yield addTab("about:blank");
   let [host, win, doc] = yield createHost("bottom", TEST_URI);
 
   info("Creating a test canvas element to test colors");
   let canvas = createTestCanvas(doc);
--- a/devtools/client/shared/test/unit/test_cssColor.js
+++ b/devtools/client/shared/test/unit/test_cssColor.js
@@ -5,17 +5,17 @@
 
 "use strict";
 
 var Cu = Components.utils;
 var Ci = Components.interfaces;
 var Cc = Components.classes;
 
 var {require, loader} = Cu.import("resource://devtools/shared/Loader.jsm", {});
-const {colorUtils} = require("devtools/client/shared/css-color");
+const {colorUtils} = require("devtools/shared/css-color");
 
 loader.lazyGetter(this, "DOMUtils", function () {
   return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
 });
 
 const CLASSIFY_TESTS = [
   { input: "rgb(255,0,192)", output: "rgb" },
   { input: "RGB(255,0,192)", output: "rgb" },
--- a/devtools/client/shared/test/unit/test_cssColorDatabase.js
+++ b/devtools/client/shared/test/unit/test_cssColorDatabase.js
@@ -8,18 +8,18 @@
 var Cu = Components.utils;
 var Ci = Components.interfaces;
 var Cc = Components.classes;
 
 var {require} = Cu.import("resource://devtools/shared/Loader.jsm", {});
 
 const DOMUtils = Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
 
-const {colorUtils} = require("devtools/client/shared/css-color");
-const {cssColors} = require("devtools/client/shared/css-color-db");
+const {colorUtils} = require("devtools/shared/css-color");
+const {cssColors} = require("devtools/shared/css-color-db");
 
 function isValid(colorName) {
   ok(colorUtils.isValidCSSColor(colorName),
      colorName + " is valid in database");
   ok(DOMUtils.isValidCSSColor(colorName),
      colorName + " is valid in DOMUtils");
 }
 
--- a/devtools/client/shared/widgets/Tooltip.js
+++ b/devtools/client/shared/widgets/Tooltip.js
@@ -8,17 +8,17 @@ const {Ci} = require("chrome");
 const defer = require("devtools/shared/defer");
 const {Spectrum} = require("devtools/client/shared/widgets/Spectrum");
 const {CubicBezierWidget} =
       require("devtools/client/shared/widgets/CubicBezierWidget");
 const {MdnDocsWidget} = require("devtools/client/shared/widgets/MdnDocsWidget");
 const {CSSFilterEditorWidget} = require("devtools/client/shared/widgets/FilterWidget");
 const {TooltipToggle} = require("devtools/client/shared/widgets/tooltip/TooltipToggle");
 const EventEmitter = require("devtools/shared/event-emitter");
-const {colorUtils} = require("devtools/client/shared/css-color");
+const {colorUtils} = require("devtools/shared/css-color");
 const Heritage = require("sdk/core/heritage");
 const {Eyedropper} = require("devtools/client/eyedropper/eyedropper");
 const {gDevTools} = require("devtools/client/framework/devtools");
 const Services = require("Services");
 const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm");
 
 loader.lazyRequireGetter(this, "beautify", "devtools/shared/jsbeautify/beautify");
 loader.lazyRequireGetter(this, "setNamedTimeout", "devtools/client/shared/widgets/view-helpers", true);
--- a/devtools/server/actors/highlighters.css
+++ b/devtools/server/actors/highlighters.css
@@ -391,8 +391,67 @@
 :-moz-native-anonymous .measuring-tool-highlighter-guide-top,
 :-moz-native-anonymous .measuring-tool-highlighter-guide-right,
 :-moz-native-anonymous .measuring-tool-highlighter-guide-bottom,
 :-moz-native-anonymous .measuring-tool-highlighter-guide-left {
   stroke: var(--highlighter-guide-color);
   stroke-dasharray: 5 3;
   shape-rendering: crispEdges;
 }
+
+/* Eye dropper */
+
+:-moz-native-anonymous .eye-dropper-root {
+  --magnifier-width: 96px;
+  --magnifier-height: 96px;
+  /* Width accounts for all color formats (hsl being the longest) */
+  --label-width: 160px;
+  --color: #e0e0e0;
+
+  position: absolute;
+  /* Tool start position. This should match the X/Y defines in JS */
+  top: 100px;
+  left: 100px;
+
+  /* Prevent interacting with the page when hovering and clicking */
+  pointer-events: auto;
+
+  /* Offset the UI so it is centered around the pointer */
+  transform: translate(
+    calc(var(--magnifier-width) / -2), calc(var(--magnifier-height) / -2));
+
+  filter: drop-shadow(0 0 1px rgba(0,0,0,.4));
+}
+
+:-moz-native-anonymous .eye-dropper-canvas {
+  image-rendering: -moz-crisp-edges;
+  cursor: none;
+  width: var(--magnifier-width);
+  height: var(--magnifier-height);
+  border-radius: 50%;
+  box-shadow: 0 0 0 3px var(--color);
+  display: block;
+}
+
+:-moz-native-anonymous .eye-dropper-color-container {
+  background-color: var(--color);
+  border-radius: 2px;
+  width: var(--label-width);
+  transform: translateX(calc((var(--magnifier-width) - var(--label-width)) / 2));
+  display: flex;
+  align-items: center;
+}
+
+:-moz-native-anonymous .eye-dropper-color-preview {
+  width: 16px;
+  height: 16px;
+  box-shadow: 0px 0px 0px black;
+  border: solid 1px #fff;
+  margin: 3px;
+}
+
+:-moz-native-anonymous .eye-dropper-color-value {
+  text-shadow: 1px 1px 1px #fff;
+  flex-grow: 1;
+  text-align: center;
+  font: message-box;
+  font-size: 11px;
+}
--- a/devtools/server/actors/highlighters.js
+++ b/devtools/server/actors/highlighters.js
@@ -674,8 +674,12 @@ exports.GeometryEditorHighlighter = Geom
 
 const { RulersHighlighter } = require("./highlighters/rulers");
 register(RulersHighlighter);
 exports.RulersHighlighter = RulersHighlighter;
 
 const { MeasuringToolHighlighter } = require("./highlighters/measuring-tool");
 register(MeasuringToolHighlighter);
 exports.MeasuringToolHighlighter = MeasuringToolHighlighter;
+
+const { EyeDropper } = require("./highlighters/eye-dropper");
+register(EyeDropper);
+exports.EyeDropper = EyeDropper;
new file mode 100644
--- /dev/null
+++ b/devtools/server/actors/highlighters/eye-dropper.js
@@ -0,0 +1,499 @@
+/* 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/. */
+"use strict";
+
+// Eye-dropper tool. This is implemented as a highlighter so it can be displayed in the
+// content page.
+// It basically displays a magnifier that tracks mouse moves and shows a magnified version
+// of the page. On click, it samples the color at the pixel being hovered.
+
+const {Ci, Cc} = require("chrome");
+const {CanvasFrameAnonymousContentHelper, createNode} = require("./utils/markup");
+const Services = require("Services");
+const EventEmitter = require("devtools/shared/event-emitter");
+const {rgbToHsl, rgbToColorName} = require("devtools/shared/css-color").colorUtils;
+const {getCurrentZoom} = require("devtools/shared/layout/utils");
+
+loader.lazyGetter(this, "clipboardHelper",
+  () => Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper));
+loader.lazyGetter(this, "l10n",
+  () => Services.strings.createBundle("chrome://devtools/locale/eyedropper.properties"));
+
+const ZOOM_LEVEL_PREF = "devtools.eyedropper.zoom";
+const FORMAT_PREF = "devtools.defaultColorUnit";
+// Width of the canvas.
+const MAGNIFIER_WIDTH = 96;
+// Height of the canvas.
+const MAGNIFIER_HEIGHT = 96;
+// Start position, when the tool is first shown. This should match the top/left position
+// defined in CSS.
+const DEFAULT_START_POS_X = 100;
+const DEFAULT_START_POS_Y = 100;
+// How long to wait before closing after copy.
+const CLOSE_DELAY = 750;
+
+/**
+ * The EyeDropper is the class that draws the gradient line and
+ * color stops as an overlay on top of a linear-gradient background-image.
+ */
+function EyeDropper(highlighterEnv) {
+  EventEmitter.decorate(this);
+
+  this.highlighterEnv = highlighterEnv;
+  this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv,
+                                                      this._buildMarkup.bind(this));
+
+  // Get a couple of settings from prefs.
+  this.format = Services.prefs.getCharPref(FORMAT_PREF);
+  this.eyeDropperZoomLevel = Services.prefs.getIntPref(ZOOM_LEVEL_PREF);
+}
+
+EyeDropper.prototype = {
+  typeName: "EyeDropper",
+
+  ID_CLASS_PREFIX: "eye-dropper-",
+
+  get win() {
+    return this.highlighterEnv.window;
+  },
+
+  _buildMarkup() {
+    // Highlighter main container.
+    let container = createNode(this.win, {
+      attributes: {"class": "highlighter-container"}
+    });
+
+    // Wrapper element.
+    let wrapper = createNode(this.win, {
+      parent: container,
+      attributes: {
+        "id": "root",
+        "class": "root",
+        "hidden": "true"
+      },
+      prefix: this.ID_CLASS_PREFIX
+    });
+
+    // The magnifier canvas element.
+    createNode(this.win, {
+      parent: wrapper,
+      nodeType: "canvas",
+      attributes: {
+        "id": "canvas",
+        "class": "canvas",
+        "width": MAGNIFIER_WIDTH,
+        "height": MAGNIFIER_HEIGHT
+      },
+      prefix: this.ID_CLASS_PREFIX
+    });
+
+    // The color label element.
+    let colorLabelContainer = createNode(this.win, {
+      parent: wrapper,
+      attributes: {"class": "color-container"},
+      prefix: this.ID_CLASS_PREFIX
+    });
+    createNode(this.win, {
+      nodeType: "span",
+      parent: colorLabelContainer,
+      attributes: {"id": "color-preview", "class": "color-preview"},
+      prefix: this.ID_CLASS_PREFIX
+    });
+    createNode(this.win, {
+      nodeType: "span",
+      parent: colorLabelContainer,
+      attributes: {"id": "color-value", "class": "color-value"},
+      prefix: this.ID_CLASS_PREFIX
+    });
+
+    return container;
+  },
+
+  destroy() {
+    this.hide();
+    this.markup.destroy();
+  },
+
+  getElement(id) {
+    return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+  },
+
+  /**
+   * Show the eye-dropper highlighter.
+   * @param {DOMNode} node The node which document the highlighter should be inserted in.
+   * @param {Object} options The options object may contain the following properties:
+   * - {Boolean} copyOnSelect Whether selecting a color should copy it to the clipboard.
+   */
+  show(node, options = {}) {
+    this.options = options;
+
+    // Get the page's current zoom level.
+    this.pageZoom = getCurrentZoom(this.win);
+
+    // Take a screenshot of the viewport. This needs to be done first otherwise the
+    // eyedropper UI will appear in the screenshot itself (since the UI is injected as
+    // native anonymous content in the page).
+    // Once the screenshot is ready, the magnified area will be drawn.
+    this.prepareImageCapture();
+
+    // Start listening for user events.
+    let {pageListenerTarget} = this.highlighterEnv;
+    pageListenerTarget.addEventListener("mousemove", this);
+    pageListenerTarget.addEventListener("click", this);
+    pageListenerTarget.addEventListener("keydown", this);
+    pageListenerTarget.addEventListener("DOMMouseScroll", this);
+    pageListenerTarget.addEventListener("FullZoomChange", this);
+
+    // Show the eye-dropper.
+    this.getElement("root").removeAttribute("hidden");
+
+    // Prepare the canvas context on which we're drawing the magnified page portion.
+    this.ctx = this.getElement("canvas").getCanvasContext();
+    this.ctx.mozImageSmoothingEnabled = false;
+
+    this.magnifiedArea = {width: MAGNIFIER_WIDTH, height: MAGNIFIER_HEIGHT,
+                          x: DEFAULT_START_POS_X, y: DEFAULT_START_POS_Y};
+
+    this.moveTo(DEFAULT_START_POS_X, DEFAULT_START_POS_Y);
+
+    // Focus the content so the keyboard can be used.
+    this.win.document.documentElement.focus();
+
+    return true;
+  },
+
+  /**
+   * Hide the eye-dropper highlighter.
+   */
+  hide() {
+    this.pageImage = null;
+
+    let {pageListenerTarget} = this.highlighterEnv;
+    pageListenerTarget.removeEventListener("mousemove", this);
+    pageListenerTarget.removeEventListener("click", this);
+    pageListenerTarget.removeEventListener("keydown", this);
+    pageListenerTarget.removeEventListener("DOMMouseScroll", this);
+    pageListenerTarget.removeEventListener("FullZoomChange", this);
+
+    this.getElement("root").setAttribute("hidden", "true");
+    this.getElement("root").removeAttribute("drawn");
+
+    this.emit("hidden");
+  },
+
+  prepareImageCapture() {
+    // Get the page as an image.
+    let imageData = getWindowAsImageData(this.win);
+    let image = new this.win.Image();
+    image.src = imageData;
+
+    // Wait for screenshot to load
+    image.onload = () => {
+      this.pageImage = image;
+      // We likely haven't drawn anything yet (no mousemove events yet), so start now.
+      this.draw();
+
+      // Set an attribute on the root element to be able to run tests after the first draw
+      // was done.
+      this.getElement("root").setAttribute("drawn", "true");
+    };
+  },
+
+  /**
+   * 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.magnifiedArea.width / this.eyeDropperZoomLevel);
+    cellsWide += cellsWide % 2;
+
+    return cellsWide;
+  },
+
+  /**
+   * Get the size of each cell (blown-up pixel) in the grid.
+   */
+  get cellSize() {
+    return this.magnifiedArea.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 pos = (this.centerCell * this.cellSize) + (this.cellSize / 2);
+    let rgb = this.ctx.getImageData(pos, pos, 1, 1).data;
+    return rgb;
+  },
+
+  draw() {
+    // If the image of the page isn't ready yet, bail out, we'll draw later on mousemove.
+    if (!this.pageImage) {
+      return;
+    }
+
+    let {width, height, x, y} = this.magnifiedArea;
+
+    let zoomedWidth = width / this.eyeDropperZoomLevel;
+    let zoomedHeight = height / this.eyeDropperZoomLevel;
+
+    let sx = x - (zoomedWidth / 2);
+    let sy = y - (zoomedHeight / 2);
+    let sw = zoomedWidth;
+    let sh = zoomedHeight;
+
+    this.ctx.drawImage(this.pageImage, sx, sy, sw, sh, 0, 0, width, height);
+
+    // Draw the grid on top, but only at 3x or more, otherwise it's too busy.
+    if (this.eyeDropperZoomLevel > 2) {
+      this.drawGrid();
+    }
+
+    this.drawCrosshair();
+
+    // Update the color preview and value.
+    let rgb = this.centerColor;
+    this.getElement("color-preview").setAttribute("style",
+      `background-color:${toColorString(rgb, "rgb")};`);
+    this.getElement("color-value").setTextContent(toColorString(rgb, this.format));
+  },
+
+  /**
+   * Draw a grid on the canvas representing pixel boundaries.
+   */
+  drawGrid() {
+    let {width, height} = this.magnifiedArea;
+
+    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() {
+    let pos = this.centerCell * this.cellSize;
+
+    this.ctx.lineWidth = 1;
+    this.ctx.lineJoin = "miter";
+    this.ctx.strokeStyle = "rgba(0, 0, 0, 1)";
+    this.ctx.strokeRect(pos - 1.5, pos - 1.5, this.cellSize + 2, this.cellSize + 2);
+
+    this.ctx.strokeStyle = "rgba(255, 255, 255, 1)";
+    this.ctx.strokeRect(pos - 0.5, pos - 0.5, this.cellSize, this.cellSize);
+  },
+
+  handleEvent(e) {
+    switch (e.type) {
+      case "mousemove":
+        let x = e.pageX - this.win.scrollX;
+        let y = e.pageY - this.win.scrollY;
+        // Update the zoom area.
+        this.magnifiedArea.x = x * this.pageZoom;
+        this.magnifiedArea.y = y * this.pageZoom;
+        // Redraw the portion of the screenshot that is now under the mouse.
+        this.draw();
+        // And move the eye-dropper's UI so it follows the mouse.
+        this.moveTo(x, y);
+        break;
+      case "click":
+        this.selectColor();
+        break;
+      case "keydown":
+        this.handleKeyDown(e);
+        break;
+      case "DOMMouseScroll":
+        // Prevent scrolling. That's because we only took a screenshot of the viewport, so
+        // scrolling out of the viewport wouldn't draw the expected things. In the future
+        // we can take the screenshot again on scroll, but for now it doesn't seem
+        // important.
+        e.preventDefault();
+        break;
+      case "FullZoomChange":
+        this.hide();
+        this.show();
+        break;
+    }
+  },
+
+  moveTo(x, y) {
+    this.getElement("root").setAttribute("style", `top:${y}px;left:${x}px;`);
+  },
+
+  /**
+   * Select the current color that's being previewed. Depending on the current options,
+   * selecting might mean copying to the clipboard and closing the
+   */
+  selectColor() {
+    let onColorSelected = Promise.resolve();
+    if (this.options.copyOnSelect) {
+      onColorSelected = this.copyColor();
+    }
+
+    this.emit("selected", toColorString(this.centerColor, this.format));
+    onColorSelected.then(() => this.hide(), e => console.error(e));
+  },
+
+  /**
+   * Handler for the keydown event. Either select the color or move the panel in a
+   * direction depending on the key pressed.
+   */
+  handleKeyDown(e) {
+    if (e.keyCode === e.DOM_VK_RETURN) {
+      this.selectColor();
+      return;
+    }
+
+    if (e.keyCode === e.DOM_VK_ESCAPE) {
+      this.emit("canceled");
+      this.hide();
+      return;
+    }
+
+    let offsetX = 0;
+    let offsetY = 0;
+    let modifier = 1;
+
+    if (e.keyCode === e.DOM_VK_LEFT) {
+      offsetX = -1;
+    }
+    if (e.keyCode === e.DOM_VK_RIGHT) {
+      offsetX = 1;
+    }
+    if (e.keyCode === e.DOM_VK_UP) {
+      offsetY = -1;
+    }
+    if (e.keyCode === e.DOM_VK_DOWN) {
+      offsetY = 1;
+    }
+    if (e.shiftKey) {
+      modifier = 10;
+    }
+
+    offsetY *= modifier;
+    offsetX *= modifier;
+
+    if (offsetX !== 0 || offsetY !== 0) {
+      this.magnifiedArea.x += offsetX;
+      this.magnifiedArea.y += offsetY;
+
+      this.draw();
+
+      this.moveTo(this.magnifiedArea.x / this.pageZoom,
+                  this.magnifiedArea.y / this.pageZoom);
+    }
+
+    // Prevent all keyboard interaction with the page, except if a modifier is used to let
+    // keyboard shortcuts through.
+    let hasModifier = e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
+    if (!hasModifier) {
+      e.preventDefault();
+    }
+  },
+
+  /**
+   * Copy the currently inspected color to the clipboard.
+   * @return {Promise} Resolves when the copy has been done (after a delay that is used to
+   * let users know that something was copied).
+   */
+  copyColor() {
+    // Copy to the clipboard.
+    let color = toColorString(this.centerColor, this.format);
+    clipboardHelper.copyString(color);
+
+    // Provide some feedback.
+    this.getElement("color-value").setTextContent(
+      "✓ " + l10n.GetStringFromName("colorValue.copied"));
+
+    // Hide the tool after a delay.
+    clearTimeout(this._copyTimeout);
+    return new Promise(resolve => {
+      this._copyTimeout = setTimeout(resolve, CLOSE_DELAY);
+    });
+  }
+};
+
+exports.EyeDropper = EyeDropper;
+
+/**
+ * Get a content window as image data-url.
+ * @param {Window} win
+ * @return {String} The data-url
+ */
+function getWindowAsImageData(win) {
+  let canvas = win.document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
+  let scale = getCurrentZoom(win);
+  let width = win.innerWidth;
+  let height = win.innerHeight;
+  canvas.width = width * scale;
+  canvas.height = height * scale;
+  canvas.mozOpaque = true;
+
+  let ctx = canvas.getContext("2d");
+
+  ctx.scale(scale, scale);
+  ctx.drawWindow(win, win.scrollX, win.scrollY, width, height, "#fff");
+
+  return canvas.toDataURL();
+}
+
+/**
+ * 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 = 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();
+}
--- a/devtools/server/actors/highlighters/moz.build
+++ b/devtools/server/actors/highlighters/moz.build
@@ -7,15 +7,16 @@
 DIRS += [
     'utils',
 ]
 
 DevToolsModules(
     'auto-refresh.js',
     'box-model.js',
     'css-transform.js',
+    'eye-dropper.js',
     'geometry-editor.js',
     'measuring-tool.js',
     'rect.js',
     'rulers.js',
     'selector.js',
     'simple-outline.js'
 )
--- a/devtools/server/actors/highlighters/utils/markup.js
+++ b/devtools/server/actors/highlighters/utils/markup.js
@@ -320,16 +320,20 @@ CanvasFrameAnonymousContentHelper.protot
       this.content.removeAttributeForElement(id, name);
     }
   },
 
   hasAttributeForElement: function (id, name) {
     return typeof this.getAttributeForElement(id, name) === "string";
   },
 
+  getCanvasContext: function (id, type = "2d") {
+    return this.content ? this.content.getCanvasContext(id, type) : null;
+  },
+
   /**
    * Add an event listener to one of the elements inserted in the canvasFrame
    * native anonymous container.
    * Like other methods in this helper, this requires the ID of the element to
    * be passed in.
    *
    * Note that if the content page navigates, the event listeners won't be
    * added again.
@@ -455,16 +459,17 @@ CanvasFrameAnonymousContentHelper.protot
 
     return {
       getTextContent: () => this.getTextContentForElement(id),
       setTextContent: text => this.setTextContentForElement(id, text),
       setAttribute: (name, val) => this.setAttributeForElement(id, name, val),
       getAttribute: name => this.getAttributeForElement(id, name),
       removeAttribute: name => this.removeAttributeForElement(id, name),
       hasAttribute: name => this.hasAttributeForElement(id, name),
+      getCanvasContext: type => this.getCanvasContext(id, type),
       addEventListener: (type, handler) => {
         return this.addEventListenerForElement(id, type, handler);
       },
       removeEventListener: (type, handler) => {
         return this.removeEventListenerForElement(id, type, handler);
       },
       classList
     };
rename from devtools/client/shared/css-color-db.js
rename to devtools/shared/css-color-db.js
rename from devtools/client/shared/css-color.js
rename to devtools/shared/css-color.js
--- a/devtools/client/shared/css-color.js
+++ b/devtools/shared/css-color.js
@@ -2,17 +2,17 @@
  * 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/. */
 
 "use strict";
 
 const Services = require("Services");
 
 const {getCSSLexer} = require("devtools/shared/css-lexer");
-const {cssColors} = require("devtools/client/shared/css-color-db");
+const {cssColors} = require("devtools/shared/css-color-db");
 
 const COLOR_UNIT_PREF = "devtools.defaultColorUnit";
 
 const SPECIALVALUES = new Set([
   "currentcolor",
   "initial",
   "inherit",
   "transparent",
--- a/devtools/shared/moz.build
+++ b/devtools/shared/moz.build
@@ -37,16 +37,18 @@ XPCSHELL_TESTS_MANIFESTS += ['tests/unit
 
 JAR_MANIFESTS += ['jar.mn']
 
 DevToolsModules(
     'async-storage.js',
     'async-utils.js',
     'builtin-modules.js',
     'content-observer.js',
+    'css-color-db.js',
+    'css-color.js',
     'css-lexer.js',
     'css-parsing-utils.js',
     'css-properties-db.js',
     'defer.js',
     'deprecated-sync-thenables.js',
     'DevToolsUtils.js',
     'dom-node-constants.js',
     'event-emitter.js',