From 1b7307bf0b7732e0b12a5f1e0618a9bd37943572 Mon Sep 17 00:00:00 2001 From: Patrick Brosset Date: Sat, 26 Oct 2013 00:51:01 +0530 Subject: [PATCH] Bug 765105 - Tooltip shared component, showing image previews in markup, css rules and computed views, r=miker,harth --- browser/devtools/markupview/markup-view.js | 62 ++- browser/devtools/markupview/test/browser.ini | 2 + ...browser_inspector_markup_765105_tooltip.js | 137 ++++++ ...rowser_inspector_markup_765105_tooltip.png | Bin 0 -> 1095 bytes browser/devtools/shared/Makefile.in | 1 + browser/devtools/shared/widgets/Tooltip.js | 420 ++++++++++++++++++ .../devtools/styleinspector/computed-view.js | 40 +- browser/devtools/styleinspector/rule-view.js | 49 +- .../styleinspector/style-inspector.js | 2 +- .../devtools/styleinspector/test/Makefile.in | 1 + ...wser_bug765105_background_image_tooltip.js | 162 +++++++ .../browser/devtools/inspector.properties | 4 +- browser/themes/shared/devtools/common.inc.css | 22 + toolkit/devtools/server/actors/inspector.js | 44 +- 14 files changed, 932 insertions(+), 14 deletions(-) create mode 100644 browser/devtools/markupview/test/browser_inspector_markup_765105_tooltip.js create mode 100644 browser/devtools/markupview/test/browser_inspector_markup_765105_tooltip.png create mode 100644 browser/devtools/shared/widgets/Tooltip.js create mode 100644 browser/devtools/styleinspector/test/browser_bug765105_background_image_tooltip.js diff --git a/browser/devtools/markupview/markup-view.js b/browser/devtools/markupview/markup-view.js index aa1a88301e6..02f0aa4c232 100644 --- a/browser/devtools/markupview/markup-view.js +++ b/browser/devtools/markupview/markup-view.js @@ -21,6 +21,7 @@ const {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {}); const {HTMLEditor} = require("devtools/markupview/html-editor"); const {OutputParser} = require("devtools/output-parser"); const promise = require("sdk/core/promise"); +const {Tooltip} = require("devtools/shared/widgets/Tooltip"); Cu.import("resource://gre/modules/devtools/LayoutHelpers.jsm"); Cu.import("resource://gre/modules/devtools/Templater.jsm"); @@ -374,7 +375,7 @@ MarkupView.prototype = { this._elt.appendChild(container.elt); this._rootNode = aNode; } else { - var container = new MarkupContainer(this, aNode); + var container = new MarkupContainer(this, aNode, this._inspector); if (aFlashNode) { container.flashMutation(); } @@ -1046,12 +1047,15 @@ MarkupView.prototype = { * The markup view that owns this container. * @param DOMNode aNode * The node to display. + * @param Inspector aInspector + * The inspector tool container the markup-view */ -function MarkupContainer(aMarkupView, aNode) { +function MarkupContainer(aMarkupView, aNode, aInspector) { this.markup = aMarkupView; this.doc = this.markup.doc; this.undo = this.markup.undo; this.node = aNode; + this._inspector = aInspector; if (aNode.nodeType == Ci.nsIDOMNode.TEXT_NODE) { this.editor = new TextEditor(this, aNode, "text"); @@ -1094,6 +1098,9 @@ function MarkupContainer(aMarkupView, aNode) { this._onMouseDown = this._onMouseDown.bind(this); this.elt.addEventListener("mousedown", this._onMouseDown, false); + + this.tooltip = null; + this._attachTooltipIfNeeded(); } MarkupContainer.prototype = { @@ -1101,6 +1108,39 @@ MarkupContainer.prototype = { return "[MarkupContainer for " + this.node + "]"; }, + _attachTooltipIfNeeded: function() { + if (this.node.tagName) { + let tagName = this.node.tagName.toLowerCase(); + let isImage = tagName === "img" && + this.editor.getAttributeElement("src"); + let isCanvas = tagName && tagName === "canvas"; + + // Get the image data for later so that when the user actually hovers over + // the element, the tooltip does contain the image + if (isImage || isCanvas) { + this.tooltip = new Tooltip(this._inspector.panelDoc); + + this.node.getImageData().then(data => { + if (data) { + data.string().then(str => { + this.tooltip.setImageContent(str); + }); + } + }); + } + + // If it's an image, show the tooltip on the src attribute + if (isImage) { + this.tooltip.startTogglingOnHover(this.editor.getAttributeElement("src")); + } + + // If it's a canvas, show it on the tag + if (isCanvas) { + this.tooltip.startTogglingOnHover(this.editor.tag); + } + } + }, + /** * True if the current node has children. The MarkupView * will set this attribute for the MarkupContainer. @@ -1335,6 +1375,12 @@ MarkupContainer.prototype = { // Destroy my editor this.editor.destroy(); + + // Destroy the tooltip if any + if (this.tooltip) { + this.tooltip.destroy(); + this.tooltip = null; + } } }; @@ -1348,7 +1394,7 @@ function RootContainer(aMarkupView, aNode) { this.elt.container = this; this.children = this.elt; this.node = aNode; - this.toString = function() { return "[root container]"} + this.toString = () => "[root container]"; } RootContainer.prototype = { @@ -1578,6 +1624,16 @@ ElementEditor.prototype = { return this.node.startModifyingAttributes(); }, + /** + * Get the element used for one of the attributes of this element + * @param string attrName The name of the attribute to get the element for + * @return DOMElement + */ + getAttributeElement: function(attrName) { + return this.attrList.querySelector( + ".attreditor[data-attr=" + attrName + "] .attr-value"); + }, + _createAttribute: function(aAttr, aBefore = null) { // Create the template editor, which will save some variables here. let data = { diff --git a/browser/devtools/markupview/test/browser.ini b/browser/devtools/markupview/test/browser.ini index b278097ea24..b8d715c8c2b 100644 --- a/browser/devtools/markupview/test/browser.ini +++ b/browser/devtools/markupview/test/browser.ini @@ -15,3 +15,5 @@ skip-if = true [browser_inspector_markup_navigation.js] [browser_inspector_markup_subset.html] [browser_inspector_markup_subset.js] +[browser_inspector_markup_765105_tooltip.js] +[browser_inspector_markup_765105_tooltip.png] diff --git a/browser/devtools/markupview/test/browser_inspector_markup_765105_tooltip.js b/browser/devtools/markupview/test/browser_inspector_markup_765105_tooltip.js new file mode 100644 index 00000000000..62ec2e434c0 --- /dev/null +++ b/browser/devtools/markupview/test/browser_inspector_markup_765105_tooltip.js @@ -0,0 +1,137 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +let {PanelFactory} = devtools.require("devtools/shared/widgets/Tooltip"); + +let contentDoc; +let inspector; +let markup; + +const PAGE_CONTENT = [ + '', + '', + '', + '' +].join("\n"); + +const TEST_NODES = [ + "img.local", + "img.data", + "img.remote", + ".canvas" +]; + +function test() { + waitForExplicitFinish(); + + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function(evt) { + gBrowser.selectedBrowser.removeEventListener(evt.type, arguments.callee, true); + contentDoc = content.document; + waitForFocus(createDocument, content); + }, true); + + content.location = "data:text/html,markup view tooltip test"; +} + +function createDocument() { + contentDoc.body.innerHTML = PAGE_CONTENT; + + var target = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.showToolbox(target, "inspector").then(function(toolbox) { + inspector = toolbox.getCurrentPanel(); + markup = inspector.markup; + startTests(); + }); +} + +function startTests() { + // Draw something in the canvas :) + let doc = content.document; + let context = doc.querySelector(".canvas").getContext("2d"); + + context.beginPath(); + context.moveTo(300, 0); + context.lineTo(600, 600); + context.lineTo(0, 600); + context.closePath(); + context.fillStyle = "#ffc821"; + context.fill(); + + // Actually start testing + inspector.selection.setNode(contentDoc.querySelector("img")); + inspector.once("inspector-updated", () => { + testImageTooltip(0); + }); +} + +function endTests() { + contentDoc = inspector = markup = null; + gBrowser.removeCurrentTab(); + finish(); +} + +function testImageTooltip(index) { + if (index === TEST_NODES.length) { + return endTests(); + } + + let node = contentDoc.querySelector(TEST_NODES[index]); + ok(node, "We have the [" + TEST_NODES[index] + "] image node to test for tooltip"); + let isImg = node.tagName.toLowerCase() === "img"; + + let container = getContainerForRawNode(markup, node); + + let target = container.editor.tag; + if (isImg) { + target = container.editor.getAttributeElement("src"); + } + + assertTooltipShownOn(container.tooltip, target, () => { + let images = container.tooltip.panel.getElementsByTagName("image"); + is(images.length, 1, "Tooltip for [" + TEST_NODES[index] + "] contains an image"); + if (isImg) { + compareImageData(node, images[0].src); + } + + container.tooltip.hide(); + + testImageTooltip(index + 1); + }); +} + +function compareImageData(img, imgData) { + let canvas = content.document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + let ctx = canvas.getContext("2d"); + let data = ""; + try { + ctx.drawImage(img, 0, 0); + data = canvas.toDataURL("image/png"); + } catch (e) {} + + is(data, imgData, "Tooltip image has the right content"); +} + +function assertTooltipShownOn(tooltip, element, cb) { + // If there is indeed a show-on-hover on element, the xul panel will be shown + tooltip.panel.addEventListener("popupshown", function shown() { + tooltip.panel.removeEventListener("popupshown", shown, true); + + // Poll until the image gets loaded in the tooltip. This is required because + // markup containers only load images in their associated tooltips when + // the image data comes back from the server. However, this test is executed + // synchronously as soon as "inspector-updated" is fired, which is before + // the data for images is known. + let hasImage = () => tooltip.panel.getElementsByTagName("image").length; + let poll = setInterval(() => { + if (hasImage()) { + clearInterval(poll); + cb(); + } + }, 200); + }, true); + tooltip._showOnHover(element); +} diff --git a/browser/devtools/markupview/test/browser_inspector_markup_765105_tooltip.png b/browser/devtools/markupview/test/browser_inspector_markup_765105_tooltip.png new file mode 100644 index 0000000000000000000000000000000000000000..699ef7940b03179b35c17be4782447f0f2cda58b GIT binary patch literal 1095 zcmeAS@N?(olHy`uVBq!ia0vp^Vn8g;!3-oV%>36dFfd*T@Ck8cU}pFa2FMIJ8z=)| zBa2+Vav382>eVYCLr6@Bm6H{yK+TIC$W`%RRrO#~^<-D`WLI`)RdHuib!S)gU{`cu zQgmfjb_I%nl*v0WC^$1IIx{J`vMRf>DLJ!B+cPLRvq{=ANZBzcIIu|BFv!`n$lEh3 zIwb_o-PPoF-C z7&3?(F>q+IUATAwh{W|7ftK=UFbEk6l~$CB>M|%=E6SS5UcYhu=B=9o8VtOu4E*X0 zN;*n>Y7Bxpf_4QGZK?Blh`C#nFX1zUcIVgspB8%r)RAA<>kwlK*q;UAA#h*fB%3aoRKm~8OXkNL{$(N&9h2^{DK)6SlPI^dH95cMZ_hg zv~2BMT|GU6LlTqH($aw-0|+ufAPWStb8_4y`!U}v$M0StGm0Wr>74B`uitLoCF4wfnds%DO0CSpD|;`tXZ?? zEnK{K$&w|@SFBlk;NYPXXRbbb_eIrRXeKZfc6hothE&|Ly%-wqlqk^pQ1MOPb~!sy zVQ%Y?zPq3kA(E zgV*fVhHd|T{cYYGe?7bLj_#{9lNkPfxU0Tf{u*;os5)!!5~f8@emuSX^63s2hLy^W zOYZ6Qvpt%Xw_m1j{q1EI4qEraCM%UFddC_6cu-)&b4q*DGv`i`Q`6?}A>8BDd z-wP=(F8+RHo@RvSoS9Y%r*`bf=-~`YcvoPlTroIWll*8cZ>gTe~DWM4f5P!u- literal 0 HcmV?d00001 diff --git a/browser/devtools/shared/Makefile.in b/browser/devtools/shared/Makefile.in index af786db2876..d0e172b9519 100644 --- a/browser/devtools/shared/Makefile.in +++ b/browser/devtools/shared/Makefile.in @@ -9,3 +9,4 @@ libs:: $(NSINSTALL) $(srcdir)/*.jsm $(FINAL_TARGET)/modules/devtools $(NSINSTALL) $(srcdir)/widgets/*.jsm $(FINAL_TARGET)/modules/devtools $(NSINSTALL) $(srcdir)/*.js $(FINAL_TARGET)/modules/devtools/shared + $(NSINSTALL) $(srcdir)/widgets/*.js $(FINAL_TARGET)/modules/devtools/shared/widgets diff --git a/browser/devtools/shared/widgets/Tooltip.js b/browser/devtools/shared/widgets/Tooltip.js new file mode 100644 index 00000000000..965ecd21453 --- /dev/null +++ b/browser/devtools/shared/widgets/Tooltip.js @@ -0,0 +1,420 @@ +/* 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, Cu, Ci} = require("chrome"); +const promise = require("sdk/core/promise"); +const IOService = Cc["@mozilla.org/network/io-service;1"] + .getService(Ci.nsIIOService); + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); + +const GRADIENT_RE = /\b(repeating-)?(linear|radial)-gradient\(((rgb|hsl)a?\(.+?\)|[^\)])+\)/gi; +const BORDERCOLOR_RE = /^border-[-a-z]*color$/ig; +const BORDER_RE = /^border(-(top|bottom|left|right))?$/ig; +const BACKGROUND_IMAGE_RE = /url\([\'\"]?(.*?)[\'\"]?\)/; + +/** + * Tooltip widget. + * + * This widget is intended at any tool that may need to show rich content in the + * form of floating panels. + * A common use case is image previewing in the CSS rule view, but more complex + * use cases may include color pickers, object inspection, etc... + * + * Tooltips are based on XUL (namely XUL arrow-type s), and therefore + * need a XUL Document to live in. + * This is pretty much the only requirement they have on their environment. + * + * The way to use a tooltip is simply by instantiating a tooltip yourself and + * attaching some content in it, or using one of the ready-made content types. + * + * A convenient `startTogglingOnHover` method may avoid having to register event + * handlers yourself if the tooltip has to be shown when hovering over a + * specific element or group of elements (which is usually the most common case) + */ + +/** + * The low level structure of a tooltip is a XUL element (a , although + * is supported too, it won't have the nice arrow shape). + */ +let PanelFactory = { + get: function(doc, xulTag="panel") { + // Create the tooltip + let panel = doc.createElement(xulTag); + panel.setAttribute("hidden", true); + + if (xulTag === "panel") { + // Prevent the click used to close the panel from being consumed + panel.setAttribute("consumeoutsideclicks", false); + panel.setAttribute("type", "arrow"); + panel.setAttribute("level", "top"); + } + + panel.setAttribute("class", "devtools-tooltip devtools-tooltip-" + xulTag); + doc.querySelector("window").appendChild(panel); + + return panel; + } +}; + +/** + * Tooltip class. + * + * Basic usage: + * let t = new Tooltip(xulDoc); + * t.content = someXulContent; + * t.show(); + * t.hide(); + * t.destroy(); + * + * Better usage: + * let t = new Tooltip(xulDoc); + * t.startTogglingOnHover(container, target => { + * if () { + * t.setImageContent("http://image.png"); + * return true; + * } + * }); + * t.destroy(); + * + * @param XULDocument doc + * The XUL document hosting this tooltip + */ +function Tooltip(doc) { + this.doc = doc; + this.panel = PanelFactory.get(doc); + + // Used for namedTimeouts in the mouseover handling + this.uid = "tooltip-" + Date.now(); +} + +module.exports.Tooltip = Tooltip; + +Tooltip.prototype = { + /** + * Show the tooltip. It might be wise to append some content first if you + * don't want the tooltip to be empty. You may access the content of the + * tooltip by setting a XUL node to t.tooltip.content. + * @param {node} anchor + * Which node should the tooltip be shown on + * @param {string} position + * https://developer.mozilla.org/en-US/docs/XUL/PopupGuide/Positioning + * Defaults to before_start + */ + show: function(anchor, position="before_start") { + this.panel.hidden = false; + this.panel.openPopup(anchor, position); + }, + + /** + * Hide the tooltip + */ + hide: function() { + this.panel.hidden = true; + this.panel.hidePopup(); + }, + + /** + * Empty the tooltip's content + */ + empty: function() { + while (this.panel.hasChildNodes()) { + this.panel.removeChild(this.panel.firstChild); + } + }, + + /** + * Get rid of references and event listeners + */ + destroy: function () { + this.hide(); + this.content = null; + + this.doc = null; + + this.panel.parentNode.removeChild(this.panel); + this.panel = null; + + if (this._basedNode) { + this.stopTogglingOnHover(); + } + }, + + /** + * Show/hide the tooltip when the mouse hovers over particular nodes. + * + * 2 Ways to make this work: + * - Provide a single node to attach the tooltip to, as the baseNode, and + * omit the second targetNodeCb argument + * - Provide a baseNode that is the container of possibly numerous children + * elements that may receive a tooltip. In this case, provide the second + * targetNodeCb argument to decide wether or not a child should receive + * a tooltip. + * + * This works by tracking mouse movements on a base container node (baseNode) + * and showing the tooltip when the mouse stops moving. The targetNodeCb + * callback is used to know whether or not the particular element being + * hovered over should indeed receive the tooltip. If you don't provide it + * it's equivalent to a function that always returns true. + * + * Note that if you call this function a second time, it will itself call + * stopTogglingOnHover before adding mouse tracking listeners again. + * + * @param {node} baseNode + * The container for all target nodes + * @param {Function} targetNodeCb + * A function that accepts a node argument and returns true or false + * to signify if the tooltip should be shown on that node or not. + * Additionally, the function receives a second argument which is the + * tooltip instance itself, to be used to add/modify the content of the + * tooltip if needed. If omitted, the tooltip will be shown everytime. + * @param {Number} showDelay + * An optional delay that will be observed before showing the tooltip. + * Defaults to 750ms + */ + startTogglingOnHover: function(baseNode, targetNodeCb, showDelay = 750) { + if (this._basedNode) { + this.stopTogglingOnHover(); + } + + this._basedNode = baseNode; + this._showDelay = showDelay; + this._targetNodeCb = targetNodeCb || (() => true); + + this._onBaseNodeMouseMove = this._onBaseNodeMouseMove.bind(this); + this._onBaseNodeMouseLeave = this._onBaseNodeMouseLeave.bind(this); + + baseNode.addEventListener("mousemove", this._onBaseNodeMouseMove, false); + baseNode.addEventListener("mouseleave", this._onBaseNodeMouseLeave, false); + }, + + /** + * If the startTogglingOnHover function has been used previously, and you want + * to get rid of this behavior, then call this function to remove the mouse + * movement tracking + */ + stopTogglingOnHover: function() { + clearNamedTimeout(this.uid); + + this._basedNode.removeEventListener("mousemove", + this._onBaseNodeMouseMove, false); + this._basedNode.removeEventListener("mouseleave", + this._onBaseNodeMouseLeave, false); + + this._basedNode = null; + this._targetNodeCb = null; + this._lastHovered = null; + }, + + _onBaseNodeMouseMove: function(event) { + if (event.target !== this._lastHovered) { + this.hide(); + this._lastHovered = null; + setNamedTimeout(this.uid, this._showDelay, () => { + this._showOnHover(event.target); + }); + } + }, + + _showOnHover: function(target) { + if (this._targetNodeCb && this._targetNodeCb(target, this)) { + this.show(target); + this._lastHovered = target; + } + }, + + _onBaseNodeMouseLeave: function() { + clearNamedTimeout(this.uid); + this._lastHovered = null; + }, + + /** + * Set the content of this tooltip. Will first empty the tooltip and then + * append the new content element. + * Consider using one of the setContent() functions instead. + * @param {node} content + * A node that can be appended in the tooltip XUL element + */ + set content(content) { + this.empty(); + if (content) { + this.panel.appendChild(content); + } + }, + + get content() { + return this.panel.firstChild; + }, + + /** + * Fill the tooltip with an image, displayed over a tiled background useful + * for transparent images. + * Also adds the image dimension as a label at the bottom. + */ + setImageContent: function(imageUrl, maxDim=400) { + // Main container + let vbox = this.doc.createElement("vbox"); + vbox.setAttribute("align", "center") + + // Transparency tiles (image will go in there) + let tiles = createTransparencyTiles(this.doc, vbox); + + // Temporary label during image load + let label = this.doc.createElement("label"); + label.classList.add("devtools-tooltip-caption"); + label.textContent = l10n.strings.GetStringFromName("previewTooltip.image.brokenImage"); + vbox.appendChild(label); + + // Display the image + let image = this.doc.createElement("image"); + image.setAttribute("src", imageUrl); + if (maxDim) { + image.style.maxWidth = maxDim + "px"; + image.style.maxHeight = maxDim + "px"; + } + tiles.appendChild(image); + + this.content = vbox; + + // Load the image to get dimensions and display it when done + let imgObj = new this.doc.defaultView.Image(); + imgObj.src = imageUrl; + imgObj.onload = () => { + imgObj.onload = null; + + // Display dimensions + label.textContent = imgObj.naturalWidth + " x " + imgObj.naturalHeight; + if (imgObj.naturalWidth > maxDim || + imgObj.naturalHeight > maxDim) { + label.textContent += " *"; + } + } + }, + + /** + * Exactly the same as the `image` function but takes a css background image + * value instead : url(....) + */ + setCssBackgroundImageContent: function(cssBackground, sheetHref, maxDim=400) { + let uri = getBackgroundImageUri(cssBackground, sheetHref); + if (uri) { + this.setImageContent(uri, maxDim); + } + }, + + setCssGradientContent: function(cssGradient) { + let tiles = createTransparencyTiles(this.doc); + + let gradientBox = this.doc.createElement("box"); + gradientBox.width = "100"; + gradientBox.height = "100"; + gradientBox.style.background = this.cssGradient; + gradientBox.style.borderRadius = "2px"; + gradientBox.style.boxShadow = "inset 0 0 4px #333"; + + tiles.appendChild(gradientBox) + + this.content = tiles; + }, + + _setSimpleCssPropertiesContent: function(properties, width, height) { + let tiles = createTransparencyTiles(this.doc); + + let box = this.doc.createElement("box"); + box.width = width + ""; + box.height = height + ""; + properties.forEach(({name, value}) => { + box.style[name] = value; + }); + tiles.appendChild(box); + + this.content = tiles; + }, + + setCssColorContent: function(cssColor) { + this._setSimpleCssPropertiesContent([ + {name: "background", value: cssColor}, + {name: "borderRadius", value: "2px"}, + {name: "boxShadow", value: "inset 0 0 4px #333"}, + ], 50, 50); + }, + + setCssBoxShadowContent: function(cssBoxShadow) { + this._setSimpleCssPropertiesContent([ + {name: "background", value: "white"}, + {name: "boxShadow", value: cssBoxShadow} + ], 80, 80); + }, + + setCssBorderContent: function(cssBorder) { + this._setSimpleCssPropertiesContent([ + {name: "background", value: "white"}, + {name: "border", value: cssBorder} + ], 80, 80); + } +}; + +/** + * Internal utility function that creates a tiled background useful for + * displaying semi-transparent images + */ +function createTransparencyTiles(doc, parentEl) { + let tiles = doc.createElement("box"); + tiles.classList.add("devtools-tooltip-tiles"); + if (parentEl) { + parentEl.appendChild(tiles); + } + return tiles; +} + +/** + * Internal util, checks whether a css declaration is a gradient + */ +function isGradientRule(property, value) { + return (property === "background" || property === "background-image") && + value.match(GRADIENT_RE); +} + +/** + * Internal util, checks whether a css declaration is a color + */ +function isColorOnly(property, value) { + return property === "background-color" || + property === "color" || + property.match(BORDERCOLOR_RE); +} + +/** + * Internal util, returns the background image uri if any + */ +function getBackgroundImageUri(value, sheetHref) { + let uriMatch = BACKGROUND_IMAGE_RE.exec(value); + let uri = null; + + if (uriMatch && uriMatch[1]) { + uri = uriMatch[1]; + if (sheetHref) { + let sheetUri = IOService.newURI(sheetHref, null, null); + uri = sheetUri.resolve(uri); + } + } + + return uri; +} + +/** + * L10N utility class + */ +function L10N() {} +L10N.prototype = {}; + +let l10n = new L10N(); + +loader.lazyGetter(L10N.prototype, "strings", () => { + return Services.strings.createBundle( + "chrome://browser/locale/devtools/inspector.properties"); +}); diff --git a/browser/devtools/styleinspector/computed-view.js b/browser/devtools/styleinspector/computed-view.js index ecb44ac3f53..83cec795352 100644 --- a/browser/devtools/styleinspector/computed-view.js +++ b/browser/devtools/styleinspector/computed-view.js @@ -11,8 +11,8 @@ let {CssLogic} = require("devtools/styleinspector/css-logic"); let {ELEMENT_STYLE} = require("devtools/server/actors/styles"); let promise = require("sdk/core/promise"); let {EventEmitter} = require("devtools/shared/event-emitter"); - const {OutputParser} = require("devtools/output-parser"); +const {Tooltip} = require("devtools/shared/widgets/Tooltip"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/PluralForm.jsm"); @@ -169,6 +169,11 @@ function CssHtmlTree(aStyleInspector, aPageStyle) // The element that we're inspecting, and the document that it comes from. this.viewedElement = null; + // Properties preview tooltip + this.tooltip = new Tooltip(this.styleInspector.inspector.panelDoc); + this.tooltip.startTogglingOnHover(this.propertyContainer, + this._buildTooltipContent.bind(this)); + this._buildContextMenu(); this.createStyleViews(); } @@ -490,6 +495,29 @@ CssHtmlTree.prototype = { win.focus(); }, + /** + * Verify that target is indeed a css value we want a tooltip on, and if yes + * prepare some content for the tooltip + */ + _buildTooltipContent: function(target) + { + // If the hovered element is not a property view and is not a background + // image, then don't show a tooltip + let isPropertyValue = target.classList.contains("property-value"); + if (!isPropertyValue) { + return false; + } + let propName = target.parentNode.querySelector(".property-name"); + let isBackgroundImage = propName.textContent === "background-image"; + if (!isBackgroundImage) { + return false; + } + + // Fill some content + this.tooltip.setCssBackgroundImageContent(target.textContent); + return true; + }, + /** * Create a context menu. */ @@ -648,6 +676,9 @@ CssHtmlTree.prototype = { this._contextmenu = null; } + this.tooltip.stopTogglingOnHover(this.propertyContainer); + this.tooltip.destroy(); + // Remove bound listeners this.styleDocument.removeEventListener("contextmenu", this._onContextMenu); this.styleDocument.removeEventListener("copy", this._onCopy); @@ -831,9 +862,8 @@ PropertyView.prototype = { { let doc = this.tree.styleDocument; - this.onMatchedToggle = this.onMatchedToggle.bind(this); - // Build the container element + this.onMatchedToggle = this.onMatchedToggle.bind(this); this.element = doc.createElementNS(HTML_NS, "div"); this.element.setAttribute("class", this.propertyHeaderClassName); this.element.addEventListener("dblclick", this.onMatchedToggle, false); @@ -858,6 +888,8 @@ PropertyView.prototype = { this.matchedExpander.addEventListener("click", this.onMatchedToggle, false); this.element.appendChild(this.matchedExpander); + this.focusElement = () => this.element.focus(); + // Build the style name element this.nameNode = doc.createElementNS(HTML_NS, "div"); this.nameNode.setAttribute("class", "property-name theme-fg-color5"); @@ -1034,7 +1066,7 @@ PropertyView.prototype = { }; /** - * A container to view us easy access to display data from a CssRule + * A container to give us easy access to display data from a CssRule * @param CssHtmlTree aTree, the owning CssHtmlTree * @param aSelectorInfo */ diff --git a/browser/devtools/styleinspector/rule-view.js b/browser/devtools/styleinspector/rule-view.js index a07b241b44d..d84862a96e1 100644 --- a/browser/devtools/styleinspector/rule-view.js +++ b/browser/devtools/styleinspector/rule-view.js @@ -13,6 +13,7 @@ let {CssLogic} = require("devtools/styleinspector/css-logic"); let {InplaceEditor, editableField, editableItem} = require("devtools/shared/inplace-editor"); let {ELEMENT_STYLE, PSEUDO_ELEMENTS} = require("devtools/server/actors/styles"); let {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {}); +let {Tooltip} = require("devtools/shared/widgets/Tooltip"); const {OutputParser} = require("devtools/output-parser"); @@ -1029,6 +1030,7 @@ TextProperty.prototype = { * apply to a given element. After construction, the 'element' * property will be available with the user interface. * + * @param {Inspector} aInspector * @param {Document} aDoc * The document that will contain the rule view. * @param {object} aStore @@ -1039,8 +1041,9 @@ TextProperty.prototype = { * The PageStyleFront for communicating with the remote server. * @constructor */ -function CssRuleView(aDoc, aStore, aPageStyle) +function CssRuleView(aInspector, aDoc, aStore, aPageStyle) { + this.inspector = aInspector; this.doc = aDoc; this.store = aStore || {}; this.pageStyle = aPageStyle; @@ -1067,6 +1070,9 @@ function CssRuleView(aDoc, aStore, aPageStyle) }; this.popup = new AutocompletePopup(aDoc.defaultView.parent.document, options); + this.tooltip = new Tooltip(this.inspector.panelDoc); + this.tooltip.startTogglingOnHover(this.element, this._buildTooltipContent.bind(this)); + this._buildContextMenu(); this._showEmpty(); } @@ -1107,6 +1113,37 @@ CssRuleView.prototype = { popupset.appendChild(this._contextmenu); }, + /** + * Verify that target is indeed a css value we want a tooltip on, and if yes + * prepare some content for the tooltip + */ + _buildTooltipContent: function(target) { + let isValueWithImage = target.classList.contains("ruleview-propertyvalue") && + target.querySelector(".theme-link"); + + let isImageHref = target.classList.contains("theme-link") && + target.parentNode.classList.contains("ruleview-propertyvalue"); + if (isImageHref) { + target = target.parentNode; + } + + let isEditing = this.isEditing; + + // If the inplace-editor is visible or if this is not a background image + // don't show the tooltip + if (this.isEditing || (!isImageHref && !isValueWithImage)) { + return false; + } + + // Retrieve the TextProperty for the hovered element + let property = target.textProperty; + let href = property.rule.domRule.href; + + // Fill some content + this.tooltip.setCssBackgroundImageContent(property.value, href); + return true; + }, + /** * Update the context menu. This means enabling or disabling menuitems as * appropriate. @@ -1240,6 +1277,9 @@ CssRuleView.prototype = { // We manage the popupNode ourselves so we also need to destroy it. this.doc.popupNode = null; + this.tooltip.stopTogglingOnHover(this.element); + this.tooltip.destroy(); + if (this.element.parentNode) { this.element.parentNode.removeChild(this.element); } @@ -1307,7 +1347,6 @@ CssRuleView.prototype = { } this._createEditors(); - // Notify anyone that cares that we refreshed. var evt = this.doc.createEvent("Events"); evt.initEvent("CssRuleViewRefreshed", true, false); @@ -1853,6 +1892,10 @@ TextPropertyEditor.prototype = { tabindex: "0", }); + // Storing the TextProperty on the valuespan for easy access + // (for instance by the tooltip) + this.valueSpan.textProperty = this.prop; + // Save the initial value as the last committed value, // for restoring after pressing escape. this.committed = { name: this.prop.name, @@ -1971,7 +2014,6 @@ TextPropertyEditor.prototype = { }); a.addEventListener("click", (aEvent) => { - // Clicks within the link shouldn't trigger editing. aEvent.stopPropagation(); aEvent.preventDefault(); @@ -2133,6 +2175,7 @@ TextPropertyEditor.prototype = { { this.element.parentNode.removeChild(this.element); this.ruleEditor.rule.editClosestTextProperty(this.prop); + this.valueSpan.textProperty = null; this.prop.remove(); }, diff --git a/browser/devtools/styleinspector/style-inspector.js b/browser/devtools/styleinspector/style-inspector.js index 17a5c39c889..99bb7fd8330 100644 --- a/browser/devtools/styleinspector/style-inspector.js +++ b/browser/devtools/styleinspector/style-inspector.js @@ -25,7 +25,7 @@ function RuleViewTool(aInspector, aWindow, aIFrame) this.doc = aWindow.document; this.outerIFrame = aIFrame; - this.view = new RuleView.CssRuleView(this.doc); + this.view = new RuleView.CssRuleView(aInspector, this.doc); this.doc.documentElement.appendChild(this.view.element); this._changeHandler = () => { diff --git a/browser/devtools/styleinspector/test/Makefile.in b/browser/devtools/styleinspector/test/Makefile.in index 444bef7d1d9..c1d37b9c719 100644 --- a/browser/devtools/styleinspector/test/Makefile.in +++ b/browser/devtools/styleinspector/test/Makefile.in @@ -38,6 +38,7 @@ MOCHITEST_BROWSER_FILES = \ browser_ruleview_pseudoelement.js \ browser_computedview_bug835808_keyboard_nav.js \ browser_bug913014_matched_expand.js \ + browser_bug765105_background_image_tooltip.js \ head.js \ $(NULL) diff --git a/browser/devtools/styleinspector/test/browser_bug765105_background_image_tooltip.js b/browser/devtools/styleinspector/test/browser_bug765105_background_image_tooltip.js new file mode 100644 index 00000000000..6eb555ec7cf --- /dev/null +++ b/browser/devtools/styleinspector/test/browser_bug765105_background_image_tooltip.js @@ -0,0 +1,162 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +let contentDoc; +let inspector; +let ruleView; +let computedView; + +const PAGE_CONTENT = [ + '', + '
test element
', + '
test element 2
' +].join("\n"); + +function test() { + waitForExplicitFinish(); + + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function(evt) { + gBrowser.selectedBrowser.removeEventListener(evt.type, arguments.callee, true); + contentDoc = content.document; + waitForFocus(createDocument, content); + }, true); + + content.location = "data:text/html,rule view tooltip test"; +} + +function createDocument() { + contentDoc.body.innerHTML = PAGE_CONTENT; + + openRuleView((aInspector, aRuleView) => { + inspector = aInspector; + ruleView = aRuleView; + startTests(); + }); +} + +function startTests() { + // let testElement = contentDoc.querySelector(".test-element"); + + inspector.selection.setNode(contentDoc.body); + inspector.once("inspector-updated", testBodyRuleView); +} + +function endTests() { + contentDoc = inspector = ruleView = computedView = null; + gBrowser.removeCurrentTab(); + finish(); +} + +function assertTooltipShownOn(tooltip, element, cb) { + // If there is indeed a show-on-hover on element, the xul panel will be shown + tooltip.panel.addEventListener("popupshown", function shown() { + tooltip.panel.removeEventListener("popupshown", shown, true); + cb(); + }, true); + tooltip._showOnHover(element); +} + +function testBodyRuleView() { + info("Testing tooltips in the rule view"); + + let panel = ruleView.tooltip.panel; + + // Check that the rule view has a tooltip and that a XUL panel has been created + ok(ruleView.tooltip, "Tooltip instance exists"); + ok(panel, "XUL panel exists"); + + // Get the background-image property inside the rule view + let {nameSpan, valueSpan} = getRuleViewProperty("background-image"); + // And verify that the tooltip gets shown on this property + assertTooltipShownOn(ruleView.tooltip, valueSpan, () => { + let images = panel.getElementsByTagName("image"); + is(images.length, 1, "Tooltip contains an image"); + ok(images[0].src.indexOf("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHe") !== -1, "The image URL seems fine"); + + ruleView.tooltip.hide(); + + inspector.selection.setNode(contentDoc.querySelector(".test-element")); + inspector.once("inspector-updated", testDivRuleView); + }); +} + +function testDivRuleView() { + let panel = ruleView.tooltip.panel; + + // Get the background property inside the rule view + let {nameSpan, valueSpan} = getRuleViewProperty("background"); + let uriSpan = valueSpan.querySelector(".theme-link"); + + // And verify that the tooltip gets shown on this property + assertTooltipShownOn(ruleView.tooltip, uriSpan, () => { + let images = panel.getElementsByTagName("image"); + is(images.length, 1, "Tooltip contains an image"); + ok(images[0].src === "chrome://global/skin/icons/warning-64.png"); + + ruleView.tooltip.hide(); + + testComputedView(); + }); +} + +function testComputedView() { + info("Testing tooltips in the computed view"); + + inspector.sidebar.select("computedview"); + computedView = inspector.sidebar.getWindowForTab("computedview").computedview.view; + let doc = computedView.styleDocument; + + let panel = computedView.tooltip.panel; + let {nameSpan, valueSpan} = getComputedViewProperty("background-image"); + + assertTooltipShownOn(computedView.tooltip, valueSpan, () => { + let images = panel.getElementsByTagName("image"); + is(images.length, 1, "Tooltip contains an image"); + ok(images[0].src === "chrome://global/skin/icons/warning-64.png"); + + computedView.tooltip.hide(); + + endTests(); + }); +} + +function getRuleViewProperty(name) { + let prop = null; + [].forEach.call(ruleView.doc.querySelectorAll(".ruleview-property"), property => { + let nameSpan = property.querySelector(".ruleview-propertyname"); + let valueSpan = property.querySelector(".ruleview-propertyvalue"); + + if (nameSpan.textContent === name) { + prop = {nameSpan: nameSpan, valueSpan: valueSpan}; + } + }); + return prop; +} + +function getComputedViewProperty(name) { + let prop = null; + [].forEach.call(computedView.styleDocument.querySelectorAll(".property-view"), property => { + let nameSpan = property.querySelector(".property-name"); + let valueSpan = property.querySelector(".property-value"); + + if (nameSpan.textContent === name) { + prop = {nameSpan: nameSpan, valueSpan: valueSpan}; + } + }); + return prop; +} diff --git a/browser/locales/en-US/chrome/browser/devtools/inspector.properties b/browser/locales/en-US/chrome/browser/devtools/inspector.properties index cb46aafced0..73438a8532a 100644 --- a/browser/locales/en-US/chrome/browser/devtools/inspector.properties +++ b/browser/locales/en-US/chrome/browser/devtools/inspector.properties @@ -31,7 +31,6 @@ debuggerPausedWarning.message=Debugger is paused. Some features like mouse selec # the node is selected. nodeMenu.tooltiptext=Node operations - # LOCALIZATION NOTE (inspector.*) # Used for the menuitem in the tool menu inspector.label=Inspector @@ -44,3 +43,6 @@ inspector.accesskey=I markupView.more.showing=Some nodes were hidden. markupView.more.showAll=Show All %S Nodes inspector.tooltip=DOM and Style Inspector + +#LOCALIZATION NOTE: Used in the image preview tooltip when the image could not be loaded +previewTooltip.image.brokenImage=Could not load the image diff --git a/browser/themes/shared/devtools/common.inc.css b/browser/themes/shared/devtools/common.inc.css index 268164e01c0..4208e9d4658 100644 --- a/browser/themes/shared/devtools/common.inc.css +++ b/browser/themes/shared/devtools/common.inc.css @@ -111,3 +111,25 @@ max-height: 75vh; } } + +/* Tooltip widget (see browser/devtools/shared/widgets/Tooltip.js) */ + +.devtools-tooltip.devtools-tooltip-tooltip { + /* If the tooltip uses a XUL element */ + -moz-appearance: none; + padding: 4px; + background: #eee; + border-radius: 3px; +} +.devtools-tooltip.devtools-tooltip-panel .panel-arrowcontent { + /* If the tooltip uses a XUL element instead */ + padding: 4px; +} + +.devtools-tooltip-tiles { + background-color: #eee; + background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc), + linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc); + background-size: 20px 20px; + background-position: 0 0, 10px 10px; +} diff --git a/toolkit/devtools/server/actors/inspector.js b/toolkit/devtools/server/actors/inspector.js index 13073569979..b4202cf24dd 100644 --- a/toolkit/devtools/server/actors/inspector.js +++ b/toolkit/devtools/server/actors/inspector.js @@ -248,6 +248,47 @@ var NodeActor = protocol.ActorClass({ response: {} }), + /** + * Get the node's image data if any (for canvas and img nodes). + * Returns a LongStringActor with the image or canvas' image data as png + * a data:image/png;base64,.... string + * A null return value means the node isn't an image + * An empty string return value means the node is an image but image data + * could not be retrieved (missing/broken image). + */ + getImageData: method(function() { + let isImg = this.rawNode.tagName.toLowerCase() === "img"; + let isCanvas = this.rawNode.tagName.toLowerCase() === "canvas"; + + if (!isImg && !isCanvas) { + return null; + } + + let imageData; + if (isImg) { + let canvas = this.rawNode.ownerDocument.createElement("canvas"); + canvas.width = this.rawNode.naturalWidth; + canvas.height = this.rawNode.naturalHeight; + let ctx = canvas.getContext("2d"); + try { + // This will fail if the image is missing + ctx.drawImage(this.rawNode, 0, 0); + imageData = canvas.toDataURL("image/png"); + } catch (e) { + imageData = ""; + } + } else if (isCanvas) { + imageData = this.rawNode.toDataURL("image/png"); + } + + return LongStringActor(this.conn, imageData); + }, { + request: {}, + response: { + data: RetVal("nullable:longstring") + } + }), + /** * Modify a node's attributes. Passed an array of modifications * similar in format to "attributes" mutations. @@ -283,8 +324,7 @@ var NodeActor = protocol.ActorClass({ modifications: Arg(0, "array:json") }, response: {} - }), - + }) }); /**