/* 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: promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); const IOService = Cc["@mozilla.org/network/io-service;1"] .getService(Ci.nsIIOService); const {Spectrum} = require("devtools/shared/widgets/Spectrum"); const {CubicBezierWidget} = require("devtools/shared/widgets/CubicBezierWidget"); const EventEmitter = require("devtools/toolkit/event-emitter"); const {colorUtils} = require("devtools/css-color"); const Heritage = require("sdk/core/heritage"); const {Eyedropper} = require("devtools/eyedropper/eyedropper"); const Editor = require("devtools/sourceeditor/editor"); const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}); devtools.lazyRequireGetter(this, "beautify", "devtools/jsbeautify"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "setNamedTimeout", "resource:///modules/devtools/ViewHelpers.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "clearNamedTimeout", "resource:///modules/devtools/ViewHelpers.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "VariablesView", "resource:///modules/devtools/VariablesView.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "VariablesViewController", "resource:///modules/devtools/VariablesViewController.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.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 XHTML_NS = "http://www.w3.org/1999/xhtml"; const SPECTRUM_FRAME = "chrome://browser/content/devtools/spectrum-frame.xhtml"; const CUBIC_BEZIER_FRAME = "chrome://browser/content/devtools/cubic-bezier-frame.xhtml"; const ESCAPE_KEYCODE = Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE; const RETURN_KEYCODE = Ci.nsIDOMKeyEvent.DOM_VK_RETURN; const POPUP_EVENTS = ["shown", "hidden", "showing", "hiding"]; /** * 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) */ /** * Container used for dealing with optional parameters. * * @param {Object} defaults * An object with all default options {p1: v1, p2: v2, ...} * @param {Object} options * The actual values. */ function OptionsStore(defaults, options) { this.defaults = defaults || {}; this.options = options || {}; } OptionsStore.prototype = { /** * Get the value for a given option name. * @return {Object} Returns the value for that option, coming either for the * actual values that have been set in the constructor, or from the * defaults if that options was not specified. */ get: function(name) { if (typeof this.options[name] !== "undefined") { return this.options[name]; } else { return this.defaults[name]; } } }; /** * The low level structure of a tooltip is a XUL element (a ). */ let PanelFactory = { /** * Get a new XUL panel instance. * @param {XULDocument} doc * The XUL document to put that panel into * @param {OptionsStore} options * An options store to get some configuration from */ get: function(doc, options) { // Create the tooltip let panel = doc.createElement("panel"); panel.setAttribute("hidden", true); panel.setAttribute("ignorekeys", true); panel.setAttribute("animate", false); panel.setAttribute("consumeoutsideclicks", options.get("consumeOutsideClick")); panel.setAttribute("noautofocus", options.get("noAutoFocus")); panel.setAttribute("type", "arrow"); panel.setAttribute("level", "top"); panel.setAttribute("class", "devtools-tooltip theme-tooltip-panel"); 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 * @param {Object} options * Optional options that give options to consumers: * - consumeOutsideClick {Boolean} Wether the first click outside of the * tooltip should close the tooltip and be consumed or not. * Defaults to false. * - closeOnKeys {Array} An array of key codes that should close the * tooltip. Defaults to [27] (escape key). * - closeOnEvents [{emitter: {Object}, event: {String}, useCapture: {Boolean}}] * Provide an optional list of emitter objects and event names here to * trigger the closing of the tooltip when these events are fired by the * emitters. The emitter objects should either implement on/off(event, cb) * or addEventListener/removeEventListener(event, cb). Defaults to []. * For instance, the following would close the tooltip whenever the * toolbox selects a new tool and when a DOM node gets scrolled: * new Tooltip(doc, { * closeOnEvents: [ * {emitter: toolbox, event: "select"}, * {emitter: myContainer, event: "scroll", useCapture: true} * ] * }); * - noAutoFocus {Boolean} Should the focus automatically go to the panel * when it opens. Defaults to true. * * Fires these events: * - showing : just before the tooltip shows * - shown : when the tooltip is shown * - hiding : just before the tooltip closes * - hidden : when the tooltip gets hidden * - keypress : when any key gets pressed, with keyCode */ function Tooltip(doc, options) { EventEmitter.decorate(this); this.doc = doc; this.options = new OptionsStore({ consumeOutsideClick: false, closeOnKeys: [ESCAPE_KEYCODE], noAutoFocus: true, closeOnEvents: [] }, options); this.panel = PanelFactory.get(doc, this.options); // Used for namedTimeouts in the mouseover handling this.uid = "tooltip-" + Date.now(); // Emit show/hide events for (let event of POPUP_EVENTS) { this["_onPopup" + event] = ((e) => { return () => this.emit(e); })(event); this.panel.addEventListener("popup" + event, this["_onPopup" + event], false); } // Listen to keypress events to close the tooltip if configured to do so let win = this.doc.querySelector("window"); this._onKeyPress = event => { if (this.panel.hidden) { return; } this.emit("keypress", event.keyCode); if (this.options.get("closeOnKeys").indexOf(event.keyCode) !== -1) { event.stopPropagation(); this.hide(); } }; win.addEventListener("keypress", this._onKeyPress, false); // Listen to custom emitters' events to close the tooltip this.hide = this.hide.bind(this); let closeOnEvents = this.options.get("closeOnEvents"); for (let {emitter, event, useCapture} of closeOnEvents) { for (let add of ["addEventListener", "on"]) { if (add in emitter) { emitter[add](event, this.hide, useCapture); break; } } } } module.exports.Tooltip = Tooltip; Tooltip.prototype = { defaultPosition: "before_start", defaultOffsetX: 0, // px defaultOffsetY: 0, // px defaultShowDelay: 50, // ms /** * 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.content. * @param {node} anchor * Which node should the tooltip be shown on * @param {string} position [optional] * Optional tooltip position. Defaults to before_start * https://developer.mozilla.org/en-US/docs/XUL/PopupGuide/Positioning * @param {number} x, y [optional] * The left and top offset coordinates, in pixels. */ show: function(anchor, position = this.defaultPosition, x = this.defaultOffsetX, y = this.defaultOffsetY) { this.panel.hidden = false; this.panel.openPopup(anchor, position, x, y); }, /** * Hide the tooltip */ hide: function() { this.panel.hidden = true; this.panel.hidePopup(); }, isShown: function() { return this.panel && this.panel.state !== "closed" && this.panel.state !== "hiding"; }, setSize: function(width, height) { this.panel.sizeTo(width, height); }, /** * Empty the tooltip's content */ empty: function() { while (this.panel.hasChildNodes()) { this.panel.removeChild(this.panel.firstChild); } }, /** * Gets this panel's visibility state. * @return boolean */ isHidden: function() { return this.panel.state == "closed" || this.panel.state == "hiding"; }, /** * Gets if this panel has any child nodes. * @return boolean */ isEmpty: function() { return !this.panel.hasChildNodes(); }, /** * Get rid of references and event listeners */ destroy: function () { this.hide(); for (let event of POPUP_EVENTS) { this.panel.removeEventListener("popup" + event, this["_onPopup" + event], false); } let win = this.doc.querySelector("window"); win.removeEventListener("keypress", this._onKeyPress, false); let closeOnEvents = this.options.get("closeOnEvents"); for (let {emitter, event, useCapture} of closeOnEvents) { for (let remove of ["removeEventListener", "off"]) { if (remove in emitter) { emitter[remove](event, this.hide, useCapture); break; } } } this.content = null; if (this._basedNode) { this.stopTogglingOnHover(); } this.doc = null; this.panel.remove(); this.panel = null; }, /** * 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 * (or a promise that resolves or rejects) 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 this.defaultShowDelay. */ startTogglingOnHover: function(baseNode, targetNodeCb, showDelay=this.defaultShowDelay) { if (this._basedNode) { this.stopTogglingOnHover(); } if (!baseNode) { // Calling tool is in the process of being destroyed. return; } 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); if (!this._basedNode) { return; } 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 = event.target; setNamedTimeout(this.uid, this._showDelay, () => { this.isValidHoverTarget(event.target).then(target => { this.show(target); }); }); } }, /** * Is the given target DOMNode a valid node for toggling the tooltip on hover. * This delegates to the user-defined _targetNodeCb callback. * @return a promise that resolves or rejects depending if the tooltip should * be shown or not. If it resolves, it does to the actual anchor to be used */ isValidHoverTarget: function(target) { // Execute the user-defined callback which should return either true/false // or a promise that resolves or rejects let res = this._targetNodeCb(target, this); // The callback can additionally return a DOMNode to replace the anchor of // the tooltip when shown if (res && res.then) { return res.then(arg => { return arg instanceof Ci.nsIDOMNode ? arg : target; }, () => { return false; }); } else { let newTarget = res instanceof Ci.nsIDOMNode ? res : target; return res ? promise.resolve(newTarget) : promise.reject(false); } }, _onBaseNodeMouseLeave: function() { clearNamedTimeout(this.uid); this._lastHovered = null; this.hide(); }, /** * 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) { if (this.content == content) { return; } this.empty(); this.panel.removeAttribute("clamped-dimensions"); this.panel.removeAttribute("clamped-dimensions-no-min-height"); this.panel.removeAttribute("clamped-dimensions-no-max-or-min-height"); this.panel.removeAttribute("wide"); if (content) { this.panel.appendChild(content); } }, get content() { return this.panel.firstChild; }, /** * Sets some text as the content of this tooltip. * * @param {array} messages * A list of text messages. * @param {string} messagesClass [optional] * A style class for the text messages. * @param {string} containerClass [optional] * A style class for the text messages container. * @param {boolean} isAlertTooltip [optional] * Pass true to add an alert image for your tooltip. */ setTextContent: function( { messages, messagesClass, containerClass, isAlertTooltip }, extraButtons = []) { messagesClass = messagesClass || "default-tooltip-simple-text-colors"; containerClass = containerClass || "default-tooltip-simple-text-colors"; let vbox = this.doc.createElement("vbox"); vbox.className = "devtools-tooltip-simple-text-container " + containerClass; vbox.setAttribute("flex", "1"); for (let text of messages) { let description = this.doc.createElement("description"); description.setAttribute("flex", "1"); description.className = "devtools-tooltip-simple-text " + messagesClass; description.textContent = text; vbox.appendChild(description); } for (let { label, className, command } of extraButtons) { let button = this.doc.createElement("button"); button.className = className; button.setAttribute("label", label); button.addEventListener("command", command); vbox.appendChild(button); } if (isAlertTooltip) { let hbox = this.doc.createElement("hbox"); hbox.setAttribute("align", "start"); let alertImg = this.doc.createElement("image"); alertImg.className = "devtools-tooltip-alert-icon"; hbox.appendChild(alertImg); hbox.appendChild(vbox); this.content = hbox; } else { this.content = vbox; } }, /** * Sets some event listener info as the content of this tooltip. * * @param {Object} (destructuring assignment) * @0 {array} eventListenerInfos * A list of event listeners. * @1 {toolbox} toolbox * Toolbox used to select debugger panel. */ setEventContent: function({ eventListenerInfos, toolbox }) { new EventTooltip(this, eventListenerInfos, toolbox); }, /** * Fill the tooltip with a variables view, inspecting an object via its * corresponding object actor, as specified in the remote debugging protocol. * * @param {object} objectActor * The value grip for the object actor. * @param {object} viewOptions [optional] * Options for the variables view visualization. * @param {object} controllerOptions [optional] * Options for the variables view controller. * @param {object} relayEvents [optional] * A collection of events to listen on the variables view widget. * For example, { fetched: () => ... } * @param {boolean} reuseCachedWidget [optional] * Pass false to instantiate a brand new widget for this variable. * Otherwise, if a variable was previously inspected, its widget * will be reused. * @param {Toolbox} toolbox [optional] * Pass the instance of the current toolbox if you want the variables * view widget to allow highlighting and selection of DOM nodes */ setVariableContent: function( objectActor, viewOptions = {}, controllerOptions = {}, relayEvents = {}, extraButtons = [], toolbox = null) { let vbox = this.doc.createElement("vbox"); vbox.className = "devtools-tooltip-variables-view-box"; vbox.setAttribute("flex", "1"); let innerbox = this.doc.createElement("vbox"); innerbox.className = "devtools-tooltip-variables-view-innerbox"; innerbox.setAttribute("flex", "1"); vbox.appendChild(innerbox); for (let { label, className, command } of extraButtons) { let button = this.doc.createElement("button"); button.className = className; button.setAttribute("label", label); button.addEventListener("command", command); vbox.appendChild(button); } let widget = new VariablesView(innerbox, viewOptions); // If a toolbox was provided, link it to the vview if (toolbox) { widget.toolbox = toolbox; } // Analyzing state history isn't useful with transient object inspectors. widget.commitHierarchy = () => {}; for (let e in relayEvents) widget.on(e, relayEvents[e]); VariablesViewController.attach(widget, controllerOptions); // Some of the view options are allowed to change between uses. widget.searchPlaceholder = viewOptions.searchPlaceholder; widget.searchEnabled = viewOptions.searchEnabled; // Use the object actor's grip to display it as a variable in the widget. // The controller options are allowed to change between uses. widget.controller.setSingleVariable( { objectActor: objectActor }, controllerOptions); this.content = vbox; this.panel.setAttribute("clamped-dimensions", ""); }, /** * Uses the provided inspectorFront's getImageDataFromURL method to resolve * the relative URL on the server-side, in the page context, and then sets the * tooltip content with the resulting image just like |setImageContent| does. * @return a promise that resolves when the image is shown in the tooltip or * resolves when the broken image tooltip content is ready, but never rejects. */ setRelativeImageContent: Task.async(function*(imageUrl, inspectorFront, maxDim) { if (imageUrl.startsWith("data:")) { // If the imageUrl already is a data-url, save ourselves a round-trip this.setImageContent(imageUrl, {maxDim: maxDim}); } else if (inspectorFront) { try { let {data, size} = yield inspectorFront.getImageDataFromURL(imageUrl, maxDim); size.maxDim = maxDim; let str = yield data.string(); this.setImageContent(str, size); } catch (e) { this.setBrokenImageContent(); } } }), /** * Fill the tooltip with a message explaining the the image is missing */ setBrokenImageContent: function() { this.setTextContent({ messages: [l10n.strings.GetStringFromName("previewTooltip.image.brokenImage")] }); }, /** * Fill the tooltip with an image and add the image dimension at the bottom. * * Only use this for absolute URLs that can be queried from the devtools * client-side. For relative URLs, use |setRelativeImageContent|. * * @param {string} imageUrl * The url to load the image from * @param {Object} options * The following options are supported: * - resized : whether or not the image identified by imageUrl has been * resized before this function was called. * - naturalWidth/naturalHeight : the original size of the image before * it was resized, if if was resized before this function was called. * If not provided, will be measured on the loaded image. * - maxDim : if the image should be resized before being shown, pass * a number here. * - hideDimensionLabel : if the dimension label should be appended * after the image. */ setImageContent: function(imageUrl, options={}) { if (!imageUrl) { return; } // Main container let vbox = this.doc.createElement("vbox"); vbox.setAttribute("align", "center"); // Display the image let image = this.doc.createElement("image"); image.setAttribute("src", imageUrl); if (options.maxDim) { image.style.maxWidth = options.maxDim + "px"; image.style.maxHeight = options.maxDim + "px"; } vbox.appendChild(image); if (!options.hideDimensionLabel) { let label = this.doc.createElement("label"); label.classList.add("devtools-tooltip-caption"); label.classList.add("theme-comment"); if (options.naturalWidth && options.naturalHeight) { label.textContent = this._getImageDimensionLabel(options.naturalWidth, options.naturalHeight); } else { // If no dimensions were provided, load the image to get them label.textContent = l10n.strings.GetStringFromName("previewTooltip.image.brokenImage"); let imgObj = new this.doc.defaultView.Image(); imgObj.src = imageUrl; imgObj.onload = () => { imgObj.onload = null; label.textContent = this._getImageDimensionLabel(imgObj.naturalWidth, imgObj.naturalHeight); }; } vbox.appendChild(label); } this.content = vbox; }, _getImageDimensionLabel: (w, h) => w + " x " + h, /** * Fill the tooltip with a new instance of the spectrum color picker widget * initialized with the given color, and return a promise that resolves to * the instance of spectrum */ setColorPickerContent: function(color) { let def = promise.defer(); // Create an iframe to contain spectrum let iframe = this.doc.createElementNS(XHTML_NS, "iframe"); iframe.setAttribute("transparent", true); iframe.setAttribute("width", "210"); iframe.setAttribute("height", "216"); iframe.setAttribute("flex", "1"); iframe.setAttribute("class", "devtools-tooltip-iframe"); let panel = this.panel; let xulWin = this.doc.ownerGlobal; // Wait for the load to initialize spectrum function onLoad() { iframe.removeEventListener("load", onLoad, true); let win = iframe.contentWindow.wrappedJSObject; let container = win.document.getElementById("spectrum"); let spectrum = new Spectrum(container, color); function finalizeSpectrum() { spectrum.show(); def.resolve(spectrum); } // Finalize spectrum's init when the tooltip becomes visible if (panel.state == "open") { finalizeSpectrum(); } else { panel.addEventListener("popupshown", function shown() { panel.removeEventListener("popupshown", shown, true); finalizeSpectrum(); }, true); } } iframe.addEventListener("load", onLoad, true); iframe.setAttribute("src", SPECTRUM_FRAME); // Put the iframe in the tooltip this.content = iframe; return def.promise; }, /** * Fill the tooltip with a new instance of the cubic-bezier widget * initialized with the given value, and return a promise that resolves to * the instance of the widget */ setCubicBezierContent: function(bezier) { let def = promise.defer(); // Create an iframe to host the cubic-bezier widget let iframe = this.doc.createElementNS(XHTML_NS, "iframe"); iframe.setAttribute("transparent", true); iframe.setAttribute("width", "200"); iframe.setAttribute("height", "415"); iframe.setAttribute("flex", "1"); iframe.setAttribute("class", "devtools-tooltip-iframe"); let panel = this.panel; let xulWin = this.doc.ownerGlobal; // Wait for the load to initialize the widget function onLoad() { iframe.removeEventListener("load", onLoad, true); let win = iframe.contentWindow.wrappedJSObject; let container = win.document.getElementById("container"); let widget = new CubicBezierWidget(container, bezier); // Resolve to the widget instance whenever the popup becomes visible if (panel.state == "open") { def.resolve(widget); } else { panel.addEventListener("popupshown", function shown() { panel.removeEventListener("popupshown", shown, true); def.resolve(widget); }, true); } } iframe.addEventListener("load", onLoad, true); iframe.setAttribute("src", CUBIC_BEZIER_FRAME); // Put the iframe in the tooltip this.content = iframe; return def.promise; }, /** * Set the content of the tooltip to display a font family preview. * This is based on Lea Verou's Dablet. See https://github.com/LeaVerou/dabblet * for more info. * @param {String} font The font family value. * @param {object} nodeFront * The NodeActor that will used to retrieve the dataURL for the font * family tooltip contents. * @return A promise that resolves when the font tooltip content is ready, or * rejects if no font is provided */ setFontFamilyContent: Task.async(function*(font, nodeFront) { if (!font || !nodeFront) { throw "Missing font"; } if (typeof nodeFront.getFontFamilyDataURL === "function") { font = font.replace(/"/g, "'"); font = font.replace("!important", ""); font = font.trim(); let fillStyle = (Services.prefs.getCharPref("devtools.theme") === "light") ? "black" : "white"; let {data, size} = yield nodeFront.getFontFamilyDataURL(font, fillStyle); let str = yield data.string(); this.setImageContent(str, { hideDimensionLabel: true, maxDim: size }); } }) }; /** * Base class for all (color, gradient, ...)-swatch based value editors inside * tooltips * * @param {XULDocument} doc */ function SwatchBasedEditorTooltip(doc) { // Creating a tooltip instance // This one will consume outside clicks as it makes more sense to let the user // close the tooltip by clicking out // It will also close on and this.tooltip = new Tooltip(doc, { consumeOutsideClick: true, closeOnKeys: [ESCAPE_KEYCODE, RETURN_KEYCODE], noAutoFocus: false }); // By default, swatch-based editor tooltips revert value change on and // commit value change on this._onTooltipKeypress = (event, code) => { if (code === ESCAPE_KEYCODE) { this.revert(); } else if (code === RETURN_KEYCODE) { this.commit(); } }; this.tooltip.on("keypress", this._onTooltipKeypress); // All target swatches are kept in a map, indexed by swatch DOM elements this.swatches = new Map(); // When a swatch is clicked, and for as long as the tooltip is shown, the // activeSwatch property will hold the reference to the swatch DOM element // that was clicked this.activeSwatch = null; this._onSwatchClick = this._onSwatchClick.bind(this); } SwatchBasedEditorTooltip.prototype = { show: function() { if (this.activeSwatch) { this.tooltip.show(this.activeSwatch, "topcenter bottomleft"); // When the tooltip is closed by clicking outside the panel we want to // commit any changes. Because the "hidden" event destroys the tooltip we // need to do this before the tooltip is destroyed (in the "hiding" event). this.tooltip.once("hiding", () => { if (!this._reverted && !this.eyedropperOpen) { this.commit(); } this._reverted = false; }); // Once the tooltip is hidden we need to clean up any remaining objects. this.tooltip.once("hidden", () => { if (!this.eyedropperOpen) { this.activeSwatch = null; } }); } }, hide: function() { this.tooltip.hide(); }, /** * Add a new swatch DOM element to the list of swatch elements this editor * tooltip knows about. That means from now on, clicking on that swatch will * toggle the editor. * * @param {node} swatchEl * The element to add * @param {object} callbacks * Callbacks that will be executed when the editor wants to preview a * value change, or revert a change, or commit a change. * - onPreview: will be called when one of the sub-classes calls preview * - onRevert: will be called when the user ESCapes out of the tooltip * - onCommit: will be called when the user presses ENTER or clicks * outside the tooltip. */ addSwatch: function(swatchEl, callbacks={}) { if (!callbacks.onPreview) callbacks.onPreview = function() {}; if (!callbacks.onRevert) callbacks.onRevert = function() {}; if (!callbacks.onCommit) callbacks.onCommit = function() {}; this.swatches.set(swatchEl, { callbacks: callbacks }); swatchEl.addEventListener("click", this._onSwatchClick, false); }, removeSwatch: function(swatchEl) { if (this.swatches.has(swatchEl)) { if (this.activeSwatch === swatchEl) { this.hide(); this.activeSwatch = null; } swatchEl.removeEventListener("click", this._onSwatchClick, false); this.swatches.delete(swatchEl); } }, _onSwatchClick: function(event) { let swatch = this.swatches.get(event.target); if (swatch) { this.activeSwatch = event.target; this.show(); event.stopPropagation(); } }, /** * Not called by this parent class, needs to be taken care of by sub-classes */ preview: function(value) { if (this.activeSwatch) { let swatch = this.swatches.get(this.activeSwatch); swatch.callbacks.onPreview(value); } }, /** * This parent class only calls this on keypress */ revert: function() { if (this.activeSwatch) { let swatch = this.swatches.get(this.activeSwatch); swatch.callbacks.onRevert(); this._reverted = true; } }, /** * This parent class only calls this on keypress */ commit: function() { if (this.activeSwatch) { let swatch = this.swatches.get(this.activeSwatch); swatch.callbacks.onCommit(); } }, destroy: function() { this.swatches.clear(); this.activeSwatch = null; this.tooltip.off("keypress", this._onTooltipKeypress); this.tooltip.destroy(); } }; /** * The swatch color picker tooltip class is a specific class meant to be used * along with output-parser's generated color swatches. * It extends the parent SwatchBasedEditorTooltip class. * It just wraps a standard Tooltip and sets its content with an instance of a * color picker. * * @param {XULDocument} doc */ function SwatchColorPickerTooltip(doc) { SwatchBasedEditorTooltip.call(this, doc); // Creating a spectrum instance. this.spectrum will always be a promise that // resolves to the spectrum instance this.spectrum = this.tooltip.setColorPickerContent([0, 0, 0, 1]); this._onSpectrumColorChange = this._onSpectrumColorChange.bind(this); this._openEyeDropper = this._openEyeDropper.bind(this); } module.exports.SwatchColorPickerTooltip = SwatchColorPickerTooltip; SwatchColorPickerTooltip.prototype = Heritage.extend(SwatchBasedEditorTooltip.prototype, { /** * Overriding the SwatchBasedEditorTooltip.show function to set spectrum's * color. */ show: function() { // Call then parent class' show function SwatchBasedEditorTooltip.prototype.show.call(this); // Then set spectrum's color and listen to color changes to preview them if (this.activeSwatch) { this.currentSwatchColor = this.activeSwatch.nextSibling; let color = this.activeSwatch.style.backgroundColor; this.spectrum.then(spectrum => { spectrum.off("changed", this._onSpectrumColorChange); spectrum.rgb = this._colorToRgba(color); spectrum.on("changed", this._onSpectrumColorChange); spectrum.updateUI(); }); } let tooltipDoc = this.tooltip.content.contentDocument; let eyeButton = tooltipDoc.querySelector("#eyedropper-button"); eyeButton.addEventListener("click", this._openEyeDropper); }, _onSpectrumColorChange: function(event, rgba, cssColor) { this._selectColor(cssColor); }, _selectColor: function(color) { if (this.activeSwatch) { this.activeSwatch.style.backgroundColor = color; this.activeSwatch.parentNode.dataset.color = color; color = this._toDefaultType(color); this.currentSwatchColor.textContent = color; this.preview(color); if (this.eyedropperOpen) { this.commit(); } } }, _openEyeDropper: function() { let chromeWindow = this.tooltip.doc.defaultView.top; let windowType = chromeWindow.document.documentElement .getAttribute("windowtype"); let toolboxWindow; if (windowType != "navigator:browser") { // this means the toolbox is in a seperate window. We need to make // sure we'll be inspecting the browser window instead toolboxWindow = chromeWindow; chromeWindow = Services.wm.getMostRecentWindow("navigator:browser"); chromeWindow.focus(); } let dropper = new Eyedropper(chromeWindow, { copyOnSelect: false }); dropper.once("select", (event, color) => { if (toolboxWindow) { toolboxWindow.focus(); } this._selectColor(color); }); dropper.once("destroy", () => { this.eyedropperOpen = false; this.activeSwatch = null; }); dropper.open(); this.eyedropperOpen = true; // close the colorpicker tooltip so that only the eyedropper is open. this.hide(); this.tooltip.emit("eyedropper-opened", dropper); }, _colorToRgba: function(color) { color = new colorUtils.CssColor(color); let rgba = color._getRGBATuple(); return [rgba.r, rgba.g, rgba.b, rgba.a]; }, _toDefaultType: function(color) { let colorObj = new colorUtils.CssColor(color); return colorObj.toString(); }, destroy: function() { SwatchBasedEditorTooltip.prototype.destroy.call(this); this.currentSwatchColor = null; this.spectrum.then(spectrum => { spectrum.off("changed", this._onSpectrumColorChange); spectrum.destroy(); }); } }); function EventTooltip(tooltip, eventListenerInfos, toolbox) { this._tooltip = tooltip; this._eventListenerInfos = eventListenerInfos; this._toolbox = toolbox; this._tooltip.eventEditors = new WeakMap(); this._headerClicked = this._headerClicked.bind(this); this._debugClicked = this._debugClicked.bind(this); this.destroy = this.destroy.bind(this); this._init(); } EventTooltip.prototype = { _init: function() { let config = { mode: Editor.modes.js, lineNumbers: false, lineWrapping: false, readOnly: true, styleActiveLine: true, extraKeys: {}, theme: "mozilla markup-view" }; let doc = this._tooltip.doc; let container = doc.createElement("vbox"); container.setAttribute("id", "devtools-tooltip-events-container"); for (let listener of this._eventListenerInfos) { let phase = listener.capturing ? "Capturing" : "Bubbling"; let level = listener.DOM0 ? "DOM0" : "DOM2"; // Header let header = doc.createElement("hbox"); header.className = "event-header devtools-toolbar"; container.appendChild(header); if (!listener.hide.debugger) { let debuggerIcon = doc.createElement("image"); debuggerIcon.className = "event-tooltip-debugger-icon"; debuggerIcon.setAttribute("src", "chrome://browser/skin/devtools/tool-debugger.svg"); let openInDebugger = l10n.strings.GetStringFromName("eventsTooltip.openInDebugger"); debuggerIcon.setAttribute("tooltiptext", openInDebugger); header.appendChild(debuggerIcon); } if (!listener.hide.type) { let eventTypeLabel = doc.createElement("label"); eventTypeLabel.className = "event-tooltip-event-type"; eventTypeLabel.setAttribute("value", listener.type); eventTypeLabel.setAttribute("tooltiptext", listener.type); header.appendChild(eventTypeLabel); } if (!listener.hide.filename) { let filename = doc.createElement("label"); filename.className = "event-tooltip-filename devtools-monospace"; filename.setAttribute("value", listener.origin); filename.setAttribute("tooltiptext", listener.origin); filename.setAttribute("crop", "left"); header.appendChild(filename); } let attributesContainer = doc.createElement("hbox"); attributesContainer.setAttribute("class", "event-tooltip-attributes-container"); header.appendChild(attributesContainer); if (!listener.hide.capturing) { let attributesBox = doc.createElement("box"); attributesBox.setAttribute("class", "event-tooltip-attributes-box"); attributesContainer.appendChild(attributesBox); let capturing = doc.createElement("label"); capturing.className = "event-tooltip-attributes"; capturing.setAttribute("value", phase); capturing.setAttribute("tooltiptext", phase); attributesBox.appendChild(capturing); } if (listener.tags) { for (let tag of listener.tags.split(",")) { let attributesBox = doc.createElement("box"); attributesBox.setAttribute("class", "event-tooltip-attributes-box"); attributesContainer.appendChild(attributesBox); let tagBox = doc.createElement("label"); tagBox.className = "event-tooltip-attributes"; tagBox.setAttribute("value", tag); tagBox.setAttribute("tooltiptext", tag); attributesBox.appendChild(tagBox); } } if (!listener.hide.dom0) { let attributesBox = doc.createElement("box"); attributesBox.setAttribute("class", "event-tooltip-attributes-box"); attributesContainer.appendChild(attributesBox); let dom0 = doc.createElement("label"); dom0.className = "event-tooltip-attributes"; dom0.setAttribute("value", level); dom0.setAttribute("tooltiptext", level); attributesBox.appendChild(dom0); } // Content let content = doc.createElement("box"); let editor = new Editor(config); this._tooltip.eventEditors.set(content, { editor: editor, handler: listener.handler, searchString: listener.searchString, uri: listener.origin, dom0: listener.DOM0, appended: false }); content.className = "event-tooltip-content-box"; container.appendChild(content); this._addContentListeners(header); } this._tooltip.content = container; this._tooltip.panel.setAttribute("clamped-dimensions-no-max-or-min-height", ""); this._tooltip.panel.setAttribute("wide", ""); this._tooltip.panel.addEventListener("popuphiding", () => { this.destroy(container); }, false); }, _addContentListeners: function(header) { header.addEventListener("click", this._headerClicked); }, _headerClicked: function(event) { if (event.target.classList.contains("event-tooltip-debugger-icon")) { this._debugClicked(event); event.stopPropagation(); return; } let doc = this._tooltip.doc; let header = event.currentTarget; let content = header.nextElementSibling; if (content.hasAttribute("open")) { content.removeAttribute("open"); } else { let contentNodes = doc.querySelectorAll(".event-tooltip-content-box"); for (let node of contentNodes) { if (node !== content) { node.removeAttribute("open"); } } content.setAttribute("open", ""); let eventEditors = this._tooltip.eventEditors.get(content); if (eventEditors.appended) { return; } let {editor, handler} = eventEditors; let iframe = doc.createElement("iframe"); iframe.setAttribute("style", "width:100%;"); editor.appendTo(content, iframe).then(() => { let tidied = beautify.js(handler, { indent_size: 2 }); editor.setText(tidied); eventEditors.appended = true; let container = header.parentElement.getBoundingClientRect(); if (header.getBoundingClientRect().top < container.top) { header.scrollIntoView(true); } else if (content.getBoundingClientRect().bottom > container.bottom) { content.scrollIntoView(false); } this._tooltip.emit("event-tooltip-ready"); }); } }, _debugClicked: function(event) { let header = event.currentTarget; let content = header.nextElementSibling; let {uri, searchString, dom0} = this._tooltip.eventEditors.get(content); if (uri && uri !== "?") { // Save a copy of toolbox as it will be set to null when we hide the // tooltip. let toolbox = this._toolbox; this._tooltip.hide(); uri = uri.replace(/"/g, ""); let showSource = ({ DebuggerView }) => { let matches = uri.match(/(.*):(\d+$)/); let line = 1; if (matches) { uri = matches[1]; line = matches[2]; } if (DebuggerView.Sources.containsValue(uri)) { DebuggerView.setEditorLocation(uri, line, {noDebug: true}).then(() => { if (dom0) { let text = DebuggerView.editor.getText(); let index = text.indexOf(searchString); let lastIndex = text.lastIndexOf(searchString); // To avoid confusion we only search for DOM0 event handlers when // there is only one possible match in the file. if (index !== -1 && index === lastIndex) { text = text.substr(0, index); let matches = text.match(/\n/g); if (matches) { DebuggerView.editor.setCursor({ line: matches.length }); } } } }); } }; let debuggerAlreadyOpen = toolbox.getPanel("jsdebugger"); toolbox.selectTool("jsdebugger").then(({ panelWin: dbg }) => { if (debuggerAlreadyOpen) { showSource(dbg); } else { dbg.once(dbg.EVENTS.SOURCES_ADDED, () => showSource(dbg)); } }); } }, destroy: function(container) { if (this._tooltip) { this._tooltip.panel.removeEventListener("popuphiding", this.destroy, false); let boxes = container.querySelectorAll(".event-tooltip-content-box"); for (let box of boxes) { let {editor} = this._tooltip.eventEditors.get(box); editor.destroy(); } this._tooltip.eventEditors.clear(); this._tooltip.eventEditors = null; } let headerNodes = container.querySelectorAll(".event-header"); for (let node of headerNodes) { node.removeEventListener("click", this._headerClicked); } let sourceNodes = container.querySelectorAll(".event-tooltip-debugger-icon"); for (let node of sourceNodes) { node.removeEventListener("click", this._debugClicked); } this._eventListenerInfos = this._toolbox = this._tooltip = null; } }; /** * The swatch cubic-bezier tooltip class is a specific class meant to be used * along with rule-view's generated cubic-bezier swatches. * It extends the parent SwatchBasedEditorTooltip class. * It just wraps a standard Tooltip and sets its content with an instance of a * CubicBezierWidget. * * @param {XULDocument} doc */ function SwatchCubicBezierTooltip(doc) { SwatchBasedEditorTooltip.call(this, doc); // Creating a cubic-bezier instance. // this.widget will always be a promise that resolves to the widget instance this.widget = this.tooltip.setCubicBezierContent([0, 0, 1, 1]); this._onUpdate = this._onUpdate.bind(this); } module.exports.SwatchCubicBezierTooltip = SwatchCubicBezierTooltip; SwatchCubicBezierTooltip.prototype = Heritage.extend(SwatchBasedEditorTooltip.prototype, { /** * Overriding the SwatchBasedEditorTooltip.show function to set the cubic * bezier curve in the widget */ show: function() { // Call then parent class' show function SwatchBasedEditorTooltip.prototype.show.call(this); // Then set the curve and listen to changes to preview them if (this.activeSwatch) { this.currentBezierValue = this.activeSwatch.nextSibling; let swatch = this.swatches.get(this.activeSwatch); this.widget.then(widget => { widget.off("updated", this._onUpdate); widget.cssCubicBezierValue = this.currentBezierValue.textContent; widget.on("updated", this._onUpdate); }); } }, _onUpdate: function(event, bezier) { if (!this.activeSwatch) { return; } this.currentBezierValue.textContent = bezier + ""; this.preview(bezier + ""); }, destroy: function() { SwatchBasedEditorTooltip.prototype.destroy.call(this); this.currentBezierValue = null; this.widget.then(widget => { widget.off("updated", this._onUpdate); widget.destroy(); }); } }); /** * 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"); });