diff --git a/devtools/client/eyedropper/eyedropper.js b/devtools/client/eyedropper/eyedropper.js index 0bfe41cfd086..3784f2ae86a9 100644 --- a/devtools/client/eyedropper/eyedropper.js +++ b/devtools/client/eyedropper/eyedropper.js @@ -3,10 +3,8 @@ * 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"); diff --git a/devtools/client/inspector/test/browser.ini b/devtools/client/inspector/test/browser.ini index f6bfe969114e..b08219bd188f 100644 --- a/devtools/client/inspector/test/browser.ini +++ b/devtools/client/inspector/test/browser.ini @@ -63,6 +63,10 @@ skip-if = os == "mac" # Full keyboard navigation on OSX only works if Full Keybo [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] diff --git a/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-clipboard.js b/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-clipboard.js new file mode 100644 index 000000000000..30332200ea57 --- /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,"; + +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}`); +} diff --git a/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-events.js b/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-events.js new file mode 100644 index 000000000000..c958ae567261 --- /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"); +} diff --git a/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-show-hide.js b/devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-show-hide.js new file mode 100644 index 000000000000..89153ab68df8 --- /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"); +} diff --git a/devtools/client/inspector/test/head.js b/devtools/client/inspector/test/head.js index f039b4a5031b..8ca9aa30fdd8 100644 --- a/devtools/client/inspector/test/head.js +++ b/devtools/client/inspector/test/head.js @@ -422,6 +422,7 @@ const getHighlighterHelperFor = (type) => Task.async( set prefix(value) { prefix = value; }, + get highlightedNode() { if (!highlightedNode) { return null; @@ -435,9 +436,9 @@ const getHighlighterHelperFor = (type) => Task.async( }; }, - 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* () { @@ -464,6 +465,10 @@ const getHighlighterHelperFor = (type) => Task.async( 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. diff --git a/devtools/client/performance/modules/widgets/graphs.js b/devtools/client/performance/modules/widgets/graphs.js index ee6edfbe15ac..b821d0d90d7c 100644 --- a/devtools/client/performance/modules/widgets/graphs.js +++ b/devtools/client/performance/modules/widgets/graphs.js @@ -18,7 +18,7 @@ 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"); diff --git a/devtools/client/performance/modules/widgets/markers-overview.js b/devtools/client/performance/modules/widgets/markers-overview.js index bbb0d10b4b2b..16dd2e8ac9bf 100644 --- a/devtools/client/performance/modules/widgets/markers-overview.js +++ b/devtools/client/performance/modules/widgets/markers-overview.js @@ -13,7 +13,7 @@ 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"); diff --git a/devtools/client/shared/moz.build b/devtools/client/shared/moz.build index 9a14741184fd..25cfc36e1aea 100644 --- a/devtools/client/shared/moz.build +++ b/devtools/client/shared/moz.build @@ -20,8 +20,6 @@ DevToolsModules( 'autocomplete-popup.js', 'browser-loader.js', 'css-angle.js', - 'css-color-db.js', - 'css-color.js', 'css-reload.js', 'Curl.jsm', 'demangle.js', diff --git a/devtools/client/shared/output-parser.js b/devtools/client/shared/output-parser.js index 4927bc9e3afd..8e425b5167f8 100644 --- a/devtools/client/shared/output-parser.js +++ b/devtools/client/shared/output-parser.js @@ -6,7 +6,7 @@ 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 { diff --git a/devtools/client/shared/test/browser_css_color.js b/devtools/client/shared/test/browser_css_color.js index cbe14fdc9722..e4e8e43f24f9 100644 --- a/devtools/client/shared/test/browser_css_color.js +++ b/devtools/client/shared/test/browser_css_color.js @@ -2,7 +2,7 @@ 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* () { diff --git a/devtools/client/shared/test/unit/test_cssColor.js b/devtools/client/shared/test/unit/test_cssColor.js index b4f04e9f9a9c..6a00711c9e45 100644 --- a/devtools/client/shared/test/unit/test_cssColor.js +++ b/devtools/client/shared/test/unit/test_cssColor.js @@ -10,7 +10,7 @@ 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); diff --git a/devtools/client/shared/test/unit/test_cssColorDatabase.js b/devtools/client/shared/test/unit/test_cssColorDatabase.js index cb6ed31fb956..2566645616c4 100644 --- a/devtools/client/shared/test/unit/test_cssColorDatabase.js +++ b/devtools/client/shared/test/unit/test_cssColorDatabase.js @@ -13,8 +13,8 @@ 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), diff --git a/devtools/client/shared/widgets/Tooltip.js b/devtools/client/shared/widgets/Tooltip.js index 4a63b07c5780..a27fb3c50a70 100644 --- a/devtools/client/shared/widgets/Tooltip.js +++ b/devtools/client/shared/widgets/Tooltip.js @@ -12,7 +12,7 @@ const {CubicBezierWidget} = 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"); diff --git a/devtools/server/actors/highlighters.css b/devtools/server/actors/highlighters.css index 67cff5dac3f7..657ded068b47 100644 --- a/devtools/server/actors/highlighters.css +++ b/devtools/server/actors/highlighters.css @@ -400,3 +400,67 @@ 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)); + + /* We don't need the UI to be reversed in RTL locales, otherwise the # would appear + to the right of the hex code. Force LTR */ + direction: ltr; +} + +:-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)); + position: relative; +} + +:-moz-native-anonymous .eye-dropper-color-preview { + width: 16px; + height: 16px; + position: absolute; + offset-inline-start: 3px; + offset-block-start: 3px; + box-shadow: 0px 0px 0px black; + border: solid 1px #fff; +} + +:-moz-native-anonymous .eye-dropper-color-value { + text-shadow: 1px 1px 1px #fff; + font: message-box; + font-size: 11px; + text-align: center; + padding: 4px 0; +} diff --git a/devtools/server/actors/highlighters.js b/devtools/server/actors/highlighters.js index c500fb8f1c9b..fc357424af57 100644 --- a/devtools/server/actors/highlighters.js +++ b/devtools/server/actors/highlighters.js @@ -679,3 +679,7 @@ 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; diff --git a/devtools/server/actors/highlighters/eye-dropper.js b/devtools/server/actors/highlighters/eye-dropper.js new file mode 100644 index 000000000000..7e955aca1639 --- /dev/null +++ b/devtools/server/actors/highlighters/eye-dropper.js @@ -0,0 +1,501 @@ +/* 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, getFrameOffsets} = 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: "div", + parent: colorLabelContainer, + attributes: {"id": "color-preview", "class": "color-preview"}, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "div", + 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": + // We might be getting an event from a child frame, so account for the offset. + let [xOffset, yOffset] = getFrameOffsets(this.win, e.target); + let x = xOffset + e.pageX - this.win.scrollX; + let y = yOffset + 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(); +} diff --git a/devtools/server/actors/highlighters/moz.build b/devtools/server/actors/highlighters/moz.build index a3d643d74d61..b85d69f8ca53 100644 --- a/devtools/server/actors/highlighters/moz.build +++ b/devtools/server/actors/highlighters/moz.build @@ -12,6 +12,7 @@ DevToolsModules( 'auto-refresh.js', 'box-model.js', 'css-transform.js', + 'eye-dropper.js', 'geometry-editor.js', 'measuring-tool.js', 'rect.js', diff --git a/devtools/server/actors/highlighters/utils/markup.js b/devtools/server/actors/highlighters/utils/markup.js index 344a32945b3f..d496e0bc36d2 100644 --- a/devtools/server/actors/highlighters/utils/markup.js +++ b/devtools/server/actors/highlighters/utils/markup.js @@ -325,6 +325,10 @@ CanvasFrameAnonymousContentHelper.prototype = { 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. @@ -460,6 +464,7 @@ CanvasFrameAnonymousContentHelper.prototype = { 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); }, diff --git a/devtools/client/shared/css-color-db.js b/devtools/shared/css-color-db.js similarity index 100% rename from devtools/client/shared/css-color-db.js rename to devtools/shared/css-color-db.js diff --git a/devtools/client/shared/css-color.js b/devtools/shared/css-color.js similarity index 99% rename from devtools/client/shared/css-color.js rename to devtools/shared/css-color.js index 424e4397ca4d..b4cf2e78b2a4 100644 --- a/devtools/client/shared/css-color.js +++ b/devtools/shared/css-color.js @@ -7,7 +7,7 @@ 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"; diff --git a/devtools/shared/layout/utils.js b/devtools/shared/layout/utils.js index f9978c24b38c..9ee15afa085a 100644 --- a/devtools/shared/layout/utils.js +++ b/devtools/shared/layout/utils.js @@ -166,6 +166,7 @@ function getFrameOffsets(boundaryWindow, node) { return [xOffset * scale, yOffset * scale]; } +exports.getFrameOffsets = getFrameOffsets; /** * Get box quads adjusted for iframes and zoom level. diff --git a/devtools/shared/moz.build b/devtools/shared/moz.build index c57754c6952a..736d2a49457a 100644 --- a/devtools/shared/moz.build +++ b/devtools/shared/moz.build @@ -42,6 +42,8 @@ DevToolsModules( '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',