summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets/tooltip
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /devtools/client/shared/widgets/tooltip
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/client/shared/widgets/tooltip')
-rw-r--r--devtools/client/shared/widgets/tooltip/CssDocsTooltip.js93
-rw-r--r--devtools/client/shared/widgets/tooltip/EventTooltipHelper.js313
-rw-r--r--devtools/client/shared/widgets/tooltip/HTMLTooltip.js638
-rw-r--r--devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js131
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js209
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js182
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js102
-rw-r--r--devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js116
-rw-r--r--devtools/client/shared/widgets/tooltip/Tooltip.js410
-rw-r--r--devtools/client/shared/widgets/tooltip/TooltipToggle.js182
-rw-r--r--devtools/client/shared/widgets/tooltip/VariableContentHelper.js89
-rw-r--r--devtools/client/shared/widgets/tooltip/moz.build19
12 files changed, 2484 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/tooltip/CssDocsTooltip.js b/devtools/client/shared/widgets/tooltip/CssDocsTooltip.js
new file mode 100644
index 000000000..880c34de3
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/CssDocsTooltip.js
@@ -0,0 +1,93 @@
+/* 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 {HTMLTooltip} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip");
+const {MdnDocsWidget} = require("devtools/client/shared/widgets/MdnDocsWidget");
+const {KeyShortcuts} = require("devtools/client/shared/key-shortcuts");
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+const TOOLTIP_WIDTH = 418;
+const TOOLTIP_HEIGHT = 308;
+
+/**
+ * Tooltip for displaying docs for CSS properties from MDN.
+ *
+ * @param {Document} toolboxDoc
+ * The toolbox document to attach the CSS docs tooltip.
+ */
+function CssDocsTooltip(toolboxDoc) {
+ this.tooltip = new HTMLTooltip(toolboxDoc, {
+ type: "arrow",
+ consumeOutsideClicks: true,
+ autofocus: true,
+ useXulWrapper: true,
+ stylesheet: "chrome://devtools/content/shared/widgets/mdn-docs.css",
+ });
+ this.widget = this.setMdnDocsContent();
+ this._onVisitLink = this._onVisitLink.bind(this);
+ this.widget.on("visitlink", this._onVisitLink);
+
+ // Initialize keyboard shortcuts
+ this.shortcuts = new KeyShortcuts({ window: this.tooltip.topWindow });
+ this._onShortcut = this._onShortcut.bind(this);
+
+ this.shortcuts.on("Escape", this._onShortcut);
+}
+
+CssDocsTooltip.prototype = {
+ /**
+ * Load CSS docs for the given property,
+ * then display the tooltip.
+ */
+ show: function (anchor, propertyName) {
+ this.tooltip.once("shown", () => {
+ this.widget.loadCssDocs(propertyName);
+ });
+ this.tooltip.show(anchor);
+ },
+
+ hide: function () {
+ this.tooltip.hide();
+ },
+
+ _onShortcut: function (shortcut, event) {
+ if (!this.tooltip.isVisible()) {
+ return;
+ }
+ event.stopPropagation();
+ event.preventDefault();
+ this.hide();
+ },
+
+ _onVisitLink: function () {
+ this.hide();
+ },
+
+ /**
+ * Set the content of this tooltip to the MDN docs widget. This is called when the
+ * tooltip is first constructed.
+ * The caller can use the MdnDocsWidget to update the tooltip's UI with new content
+ * each time the tooltip is shown.
+ *
+ * @return {MdnDocsWidget} the created MdnDocsWidget instance.
+ */
+ setMdnDocsContent: function () {
+ let container = this.tooltip.doc.createElementNS(XHTML_NS, "div");
+ container.setAttribute("class", "mdn-container theme-body");
+ this.tooltip.setContent(container, {width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT});
+ return new MdnDocsWidget(container);
+ },
+
+ destroy: function () {
+ this.widget.off("visitlink", this._onVisitLink);
+ this.widget.destroy();
+
+ this.shortcuts.destroy();
+ this.tooltip.destroy();
+ }
+};
+
+module.exports = CssDocsTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
new file mode 100644
index 000000000..63507bc5e
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
@@ -0,0 +1,313 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {LocalizationHelper} = require("devtools/shared/l10n");
+const L10N = new LocalizationHelper("devtools/client/locales/inspector.properties");
+
+const Editor = require("devtools/client/sourceeditor/editor");
+const beautify = require("devtools/shared/jsbeautify/beautify");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const CONTAINER_WIDTH = 500;
+
+/**
+ * Set the content of a provided HTMLTooltip instance to display a list of event
+ * listeners, with their event type, capturing argument and a link to the code
+ * of the event handler.
+ *
+ * @param {HTMLTooltip} tooltip
+ * The tooltip instance on which the event details content should be set
+ * @param {Array} eventListenerInfos
+ * A list of event listeners
+ * @param {Toolbox} toolbox
+ * Toolbox used to select debugger panel
+ */
+function setEventTooltip(tooltip, eventListenerInfos, toolbox) {
+ let eventTooltip = new EventTooltip(tooltip, eventListenerInfos, toolbox);
+ eventTooltip.init();
+}
+
+function EventTooltip(tooltip, eventListenerInfos, toolbox) {
+ this._tooltip = tooltip;
+ this._eventListenerInfos = eventListenerInfos;
+ this._toolbox = toolbox;
+ this._eventEditors = new WeakMap();
+
+ // Used in tests: add a reference to the EventTooltip instance on the HTMLTooltip.
+ this._tooltip.eventTooltip = this;
+
+ this._headerClicked = this._headerClicked.bind(this);
+ this._debugClicked = this._debugClicked.bind(this);
+ this.destroy = this.destroy.bind(this);
+}
+
+EventTooltip.prototype = {
+ init: function () {
+ let config = {
+ mode: Editor.modes.js,
+ lineNumbers: false,
+ lineWrapping: true,
+ readOnly: true,
+ styleActiveLine: true,
+ extraKeys: {},
+ theme: "mozilla markup-view"
+ };
+
+ let doc = this._tooltip.doc;
+ this.container = doc.createElementNS(XHTML_NS, "div");
+ this.container.className = "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.createElementNS(XHTML_NS, "div");
+ header.className = "event-header devtools-toolbar";
+ this.container.appendChild(header);
+
+ if (!listener.hide.debugger) {
+ let debuggerIcon = doc.createElementNS(XHTML_NS, "img");
+ debuggerIcon.className = "event-tooltip-debugger-icon";
+ debuggerIcon.setAttribute("src",
+ "chrome://devtools/skin/images/tool-debugger.svg");
+ let openInDebugger = L10N.getStr("eventsTooltip.openInDebugger");
+ debuggerIcon.setAttribute("title", openInDebugger);
+ header.appendChild(debuggerIcon);
+ }
+
+ if (!listener.hide.type) {
+ let eventTypeLabel = doc.createElementNS(XHTML_NS, "span");
+ eventTypeLabel.className = "event-tooltip-event-type";
+ eventTypeLabel.textContent = listener.type;
+ eventTypeLabel.setAttribute("title", listener.type);
+ header.appendChild(eventTypeLabel);
+ }
+
+ if (!listener.hide.filename) {
+ let filename = doc.createElementNS(XHTML_NS, "span");
+ filename.className = "event-tooltip-filename devtools-monospace";
+ filename.textContent = listener.origin;
+ filename.setAttribute("title", listener.origin);
+ header.appendChild(filename);
+ }
+
+ let attributesContainer = doc.createElementNS(XHTML_NS, "div");
+ attributesContainer.className = "event-tooltip-attributes-container";
+ header.appendChild(attributesContainer);
+
+ if (!listener.hide.capturing) {
+ let attributesBox = doc.createElementNS(XHTML_NS, "div");
+ attributesBox.className = "event-tooltip-attributes-box";
+ attributesContainer.appendChild(attributesBox);
+
+ let capturing = doc.createElementNS(XHTML_NS, "span");
+ capturing.className = "event-tooltip-attributes";
+ capturing.textContent = phase;
+ capturing.setAttribute("title", phase);
+ attributesBox.appendChild(capturing);
+ }
+
+ if (listener.tags) {
+ for (let tag of listener.tags.split(",")) {
+ let attributesBox = doc.createElementNS(XHTML_NS, "div");
+ attributesBox.className = "event-tooltip-attributes-box";
+ attributesContainer.appendChild(attributesBox);
+
+ let tagBox = doc.createElementNS(XHTML_NS, "span");
+ tagBox.className = "event-tooltip-attributes";
+ tagBox.textContent = tag;
+ tagBox.setAttribute("title", tag);
+ attributesBox.appendChild(tagBox);
+ }
+ }
+
+ if (!listener.hide.dom0) {
+ let attributesBox = doc.createElementNS(XHTML_NS, "div");
+ attributesBox.className = "event-tooltip-attributes-box";
+ attributesContainer.appendChild(attributesBox);
+
+ let dom0 = doc.createElementNS(XHTML_NS, "span");
+ dom0.className = "event-tooltip-attributes";
+ dom0.textContent = level;
+ dom0.setAttribute("title", level);
+ attributesBox.appendChild(dom0);
+ }
+
+ // Content
+ let content = doc.createElementNS(XHTML_NS, "div");
+ let editor = new Editor(config);
+ this._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";
+ this.container.appendChild(content);
+
+ this._addContentListeners(header);
+ }
+
+ this._tooltip.setContent(this.container, {width: CONTAINER_WIDTH});
+ this._tooltip.on("hidden", this.destroy);
+ },
+
+ _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 eventEditor = this._eventEditors.get(content);
+
+ if (eventEditor.appended) {
+ return;
+ }
+
+ let {editor, handler} = eventEditor;
+
+ let iframe = doc.createElementNS(XHTML_NS, "iframe");
+ iframe.setAttribute("style", "width: 100%; height: 100%; border-style: none;");
+
+ editor.appendTo(content, iframe).then(() => {
+ let tidied = beautify.js(handler, { "indent_size": 2 });
+ editor.setText(tidied);
+
+ eventEditor.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._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];
+ }
+
+ let item = DebuggerView.Sources.getItemForAttachment(a => a.source.url === uri);
+ if (item) {
+ let actor = item.attachment.source.actor;
+ DebuggerView.setEditorLocation(
+ actor, 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 newlineMatches = text.match(/\n/g);
+
+ if (newlineMatches) {
+ DebuggerView.editor.setCursor({
+ line: newlineMatches.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 () {
+ if (this._tooltip) {
+ this._tooltip.off("hidden", this.destroy);
+
+ let boxes = this.container.querySelectorAll(".event-tooltip-content-box");
+
+ for (let box of boxes) {
+ let {editor} = this._eventEditors.get(box);
+ editor.destroy();
+ }
+
+ this._eventEditors = null;
+ this._tooltip.eventTooltip = null;
+ }
+
+ let headerNodes = this.container.querySelectorAll(".event-header");
+
+ for (let node of headerNodes) {
+ node.removeEventListener("click", this._headerClicked);
+ }
+
+ let sourceNodes = this.container.querySelectorAll(".event-tooltip-debugger-icon");
+ for (let node of sourceNodes) {
+ node.removeEventListener("click", this._debugClicked);
+ }
+
+ this._eventListenerInfos = this._toolbox = this._tooltip = null;
+ }
+};
+
+module.exports.setEventTooltip = setEventTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/HTMLTooltip.js b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
new file mode 100644
index 000000000..749878220
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
@@ -0,0 +1,638 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 EventEmitter = require("devtools/shared/event-emitter");
+const {TooltipToggle} = require("devtools/client/shared/widgets/tooltip/TooltipToggle");
+const {listenOnce} = require("devtools/shared/async-utils");
+const {Task} = require("devtools/shared/task");
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+const POSITION = {
+ TOP: "top",
+ BOTTOM: "bottom",
+};
+
+module.exports.POSITION = POSITION;
+
+const TYPE = {
+ NORMAL: "normal",
+ ARROW: "arrow",
+};
+
+module.exports.TYPE = TYPE;
+
+const ARROW_WIDTH = 32;
+
+// Default offset between the tooltip's left edge and the tooltip arrow.
+const ARROW_OFFSET = 20;
+
+const EXTRA_HEIGHT = {
+ "normal": 0,
+ // The arrow is 16px tall, but merges on 3px with the panel border
+ "arrow": 13,
+};
+
+const EXTRA_BORDER = {
+ "normal": 0,
+ "arrow": 3,
+};
+
+/**
+ * Calculate the vertical position & offsets to use for the tooltip. Will attempt to
+ * respect the provided height and position preferences, unless the available height
+ * prevents this.
+ *
+ * @param {DOMRect} anchorRect
+ * Bounding rectangle for the anchor, relative to the tooltip document.
+ * @param {DOMRect} viewportRect
+ * Bounding rectangle for the viewport. top/left can be different from 0 if some
+ * space should not be used by tooltips (for instance OS toolbars, taskbars etc.).
+ * @param {Number} height
+ * Preferred height for the tooltip.
+ * @param {String} pos
+ * Preferred position for the tooltip. Possible values: "top" or "bottom".
+ * @return {Object}
+ * - {Number} top: the top offset for the tooltip.
+ * - {Number} height: the height to use for the tooltip container.
+ * - {String} computedPosition: Can differ from the preferred position depending
+ * on the available height). "top" or "bottom"
+ */
+const calculateVerticalPosition =
+function (anchorRect, viewportRect, height, pos, offset) {
+ let {TOP, BOTTOM} = POSITION;
+
+ let {top: anchorTop, height: anchorHeight} = anchorRect;
+
+ // Translate to the available viewport space before calculating dimensions and position.
+ anchorTop -= viewportRect.top;
+
+ // Calculate available space for the tooltip.
+ let availableTop = anchorTop;
+ let availableBottom = viewportRect.height - (anchorTop + anchorHeight);
+
+ // Find POSITION
+ let keepPosition = false;
+ if (pos === TOP) {
+ keepPosition = availableTop >= height + offset;
+ } else if (pos === BOTTOM) {
+ keepPosition = availableBottom >= height + offset;
+ }
+ if (!keepPosition) {
+ pos = availableTop > availableBottom ? TOP : BOTTOM;
+ }
+
+ // Calculate HEIGHT.
+ let availableHeight = pos === TOP ? availableTop : availableBottom;
+ height = Math.min(height, availableHeight - offset);
+ height = Math.floor(height);
+
+ // Calculate TOP.
+ let top = pos === TOP ? anchorTop - height - offset : anchorTop + anchorHeight + offset;
+
+ // Translate back to absolute coordinates by re-including viewport top margin.
+ top += viewportRect.top;
+
+ return {top, height, computedPosition: pos};
+};
+
+/**
+ * Calculate the vertical position & offsets to use for the tooltip. Will attempt to
+ * respect the provided height and position preferences, unless the available height
+ * prevents this.
+ *
+ * @param {DOMRect} anchorRect
+ * Bounding rectangle for the anchor, relative to the tooltip document.
+ * @param {DOMRect} viewportRect
+ * Bounding rectangle for the viewport. top/left can be different from 0 if some
+ * space should not be used by tooltips (for instance OS toolbars, taskbars etc.).
+ * @param {Number} width
+ * Preferred width for the tooltip.
+ * @param {String} type
+ * The tooltip type (e.g. "arrow").
+ * @param {Number} offset
+ * Horizontal offset in pixels.
+ * @param {Boolean} isRtl
+ * If the anchor is in RTL, the tooltip should be aligned to the right.
+ * @return {Object}
+ * - {Number} left: the left offset for the tooltip.
+ * - {Number} width: the width to use for the tooltip container.
+ * - {Number} arrowLeft: the left offset to use for the arrow element.
+ */
+const calculateHorizontalPosition =
+function (anchorRect, viewportRect, width, type, offset, isRtl) {
+ let anchorWidth = anchorRect.width;
+ let anchorStart = isRtl ? anchorRect.right : anchorRect.left;
+
+ // Translate to the available viewport space before calculating dimensions and position.
+ anchorStart -= viewportRect.left;
+
+ // Calculate WIDTH.
+ width = Math.min(width, viewportRect.width);
+
+ // Calculate LEFT.
+ // By default the tooltip is aligned with the anchor left edge. Unless this
+ // makes it overflow the viewport, in which case is shifts to the left.
+ let left = anchorStart + offset - (isRtl ? width : 0);
+ left = Math.min(left, viewportRect.width - width);
+ left = Math.max(0, left);
+
+ // Calculate ARROW LEFT (tooltip's LEFT might be updated)
+ let arrowLeft;
+ // Arrow style tooltips may need to be shifted to the left
+ if (type === TYPE.ARROW) {
+ let arrowCenter = left + ARROW_OFFSET + ARROW_WIDTH / 2;
+ let anchorCenter = anchorStart + anchorWidth / 2;
+ // If the anchor is too narrow, align the arrow and the anchor center.
+ if (arrowCenter > anchorCenter) {
+ left = Math.max(0, left - (arrowCenter - anchorCenter));
+ }
+ // Arrow's left offset relative to the anchor.
+ arrowLeft = Math.min(ARROW_OFFSET, (anchorWidth - ARROW_WIDTH) / 2) | 0;
+ // Translate the coordinate to tooltip container
+ arrowLeft += anchorStart - left;
+ // Make sure the arrow remains in the tooltip container.
+ arrowLeft = Math.min(arrowLeft, width - ARROW_WIDTH);
+ arrowLeft = Math.max(arrowLeft, 0);
+ }
+
+ // Translate back to absolute coordinates by re-including viewport left margin.
+ left += viewportRect.left;
+
+ return {left, width, arrowLeft};
+};
+
+/**
+ * Get the bounding client rectangle for a given node, relative to a custom
+ * reference element (instead of the default for getBoundingClientRect which
+ * is always the element's ownerDocument).
+ */
+const getRelativeRect = function (node, relativeTo) {
+ // Width and Height can be taken from the rect.
+ let {width, height} = node.getBoundingClientRect();
+
+ let quads = node.getBoxQuads({relativeTo});
+ let top = quads[0].bounds.top;
+ let left = quads[0].bounds.left;
+
+ // Compute right and bottom coordinates using the rest of the data.
+ let right = left + width;
+ let bottom = top + height;
+
+ return {top, right, bottom, left, width, height};
+};
+
+/**
+ * The HTMLTooltip can display HTML content in a tooltip popup.
+ *
+ * @param {Document} toolboxDoc
+ * The toolbox document to attach the HTMLTooltip popup.
+ * @param {Object}
+ * - {String} type
+ * Display type of the tooltip. Possible values: "normal", "arrow"
+ * - {Boolean} autofocus
+ * Defaults to false. Should the tooltip be focused when opening it.
+ * - {Boolean} consumeOutsideClicks
+ * Defaults to true. The tooltip is closed when clicking outside.
+ * Should this event be stopped and consumed or not.
+ * - {Boolean} useXulWrapper
+ * Defaults to false. If the tooltip is hosted in a XUL document, use a XUL panel
+ * in order to use all the screen viewport available.
+ * - {String} stylesheet
+ * Style sheet URL to apply to the tooltip content.
+ */
+function HTMLTooltip(toolboxDoc, {
+ type = "normal",
+ autofocus = false,
+ consumeOutsideClicks = true,
+ useXulWrapper = false,
+ stylesheet = "",
+ } = {}) {
+ EventEmitter.decorate(this);
+
+ this.doc = toolboxDoc;
+ this.type = type;
+ this.autofocus = autofocus;
+ this.consumeOutsideClicks = consumeOutsideClicks;
+ this.useXulWrapper = this._isXUL() && useXulWrapper;
+
+ // The top window is used to attach click event listeners to close the tooltip if the
+ // user clicks on the content page.
+ this.topWindow = this._getTopWindow();
+
+ this._position = null;
+
+ this._onClick = this._onClick.bind(this);
+ this._onXulPanelHidden = this._onXulPanelHidden.bind(this);
+
+ this._toggle = new TooltipToggle(this);
+ this.startTogglingOnHover = this._toggle.start.bind(this._toggle);
+ this.stopTogglingOnHover = this._toggle.stop.bind(this._toggle);
+
+ this.container = this._createContainer();
+
+ if (stylesheet) {
+ this._applyStylesheet(stylesheet);
+ }
+ if (this.useXulWrapper) {
+ // When using a XUL panel as the wrapper, the actual markup for the tooltip is as
+ // follows :
+ // <panel> <!-- XUL panel used to position the tooltip anywhere on screen -->
+ // <div> <!-- div wrapper used to isolate the tooltip container -->
+ // <div> <! the actual tooltip.container element -->
+ this.xulPanelWrapper = this._createXulPanelWrapper();
+ let inner = this.doc.createElementNS(XHTML_NS, "div");
+ inner.classList.add("tooltip-xul-wrapper-inner");
+
+ this.doc.documentElement.appendChild(this.xulPanelWrapper);
+ this.xulPanelWrapper.appendChild(inner);
+ inner.appendChild(this.container);
+ } else if (this._isXUL()) {
+ this.doc.documentElement.appendChild(this.container);
+ } else {
+ // In non-XUL context the container is ready to use as is.
+ this.doc.body.appendChild(this.container);
+ }
+}
+
+module.exports.HTMLTooltip = HTMLTooltip;
+
+HTMLTooltip.prototype = {
+ /**
+ * The tooltip panel is the parentNode of the tooltip content provided in
+ * setContent().
+ */
+ get panel() {
+ return this.container.querySelector(".tooltip-panel");
+ },
+
+ /**
+ * The arrow element. Might be null depending on the tooltip type.
+ */
+ get arrow() {
+ return this.container.querySelector(".tooltip-arrow");
+ },
+
+ /**
+ * Retrieve the displayed position used for the tooltip. Null if the tooltip is hidden.
+ */
+ get position() {
+ return this.isVisible() ? this._position : null;
+ },
+
+ /**
+ * Set the tooltip content element. The preferred width/height should also be
+ * specified here.
+ *
+ * @param {Element} content
+ * The tooltip content, should be a HTML element.
+ * @param {Object}
+ * - {Number} width: preferred width for the tooltip container. If not specified
+ * the tooltip container will be measured before being displayed, and the
+ * measured width will be used as preferred width.
+ * - {Number} height: optional, preferred height for the tooltip container. If
+ * not specified, the tooltip will be able to use all the height available.
+ */
+ setContent: function (content, {width = "auto", height = Infinity} = {}) {
+ this.preferredWidth = width;
+ this.preferredHeight = height;
+
+ this.panel.innerHTML = "";
+ this.panel.appendChild(content);
+ },
+
+ /**
+ * Show the tooltip next to the provided anchor element. A preferred position
+ * can be set. The event "shown" will be fired after the tooltip is displayed.
+ *
+ * @param {Element} anchor
+ * The reference element with which the tooltip should be aligned
+ * @param {Object}
+ * - {String} position: optional, possible values: top|bottom
+ * If layout permits, the tooltip will be displayed on top/bottom
+ * of the anchor. If ommitted, the tooltip will be displayed where
+ * more space is available.
+ * - {Number} x: optional, horizontal offset between the anchor and the tooltip
+ * - {Number} y: optional, vertical offset between the anchor and the tooltip
+ */
+ show: Task.async(function* (anchor, {position, x = 0, y = 0} = {}) {
+ // Get anchor geometry
+ let anchorRect = getRelativeRect(anchor, this.doc);
+ if (this.useXulWrapper) {
+ anchorRect = this._convertToScreenRect(anchorRect);
+ }
+
+ // Get viewport size
+ let viewportRect = this._getViewportRect();
+
+ let themeHeight = EXTRA_HEIGHT[this.type] + 2 * EXTRA_BORDER[this.type];
+ let preferredHeight = this.preferredHeight + themeHeight;
+
+ let {top, height, computedPosition} =
+ calculateVerticalPosition(anchorRect, viewportRect, preferredHeight, position, y);
+
+ this._position = computedPosition;
+ // Apply height before measuring the content width (if width="auto").
+ let isTop = computedPosition === POSITION.TOP;
+ this.container.classList.toggle("tooltip-top", isTop);
+ this.container.classList.toggle("tooltip-bottom", !isTop);
+
+ // If the preferred height is set to Infinity, the tooltip container should grow based
+ // on its content's height and use as much height as possible.
+ this.container.classList.toggle("tooltip-flexible-height",
+ this.preferredHeight === Infinity);
+
+ this.container.style.height = height + "px";
+
+ let preferredWidth;
+ if (this.preferredWidth === "auto") {
+ preferredWidth = this._measureContainerWidth();
+ } else {
+ let themeWidth = 2 * EXTRA_BORDER[this.type];
+ preferredWidth = this.preferredWidth + themeWidth;
+ }
+
+ let anchorWin = anchor.ownerDocument.defaultView;
+ let isRtl = anchorWin.getComputedStyle(anchor).direction === "rtl";
+ let {left, width, arrowLeft} = calculateHorizontalPosition(
+ anchorRect, viewportRect, preferredWidth, this.type, x, isRtl);
+
+ this.container.style.width = width + "px";
+
+ if (this.type === TYPE.ARROW) {
+ this.arrow.style.left = arrowLeft + "px";
+ }
+
+ if (this.useXulWrapper) {
+ yield this._showXulWrapperAt(left, top);
+ } else {
+ this.container.style.left = left + "px";
+ this.container.style.top = top + "px";
+ }
+
+ this.container.classList.add("tooltip-visible");
+
+ // Keep a pointer on the focused element to refocus it when hiding the tooltip.
+ this._focusedElement = this.doc.activeElement;
+
+ this.doc.defaultView.clearTimeout(this.attachEventsTimer);
+ this.attachEventsTimer = this.doc.defaultView.setTimeout(() => {
+ this._maybeFocusTooltip();
+ // Updated the top window reference each time in case the host changes.
+ this.topWindow = this._getTopWindow();
+ this.topWindow.addEventListener("click", this._onClick, true);
+ this.emit("shown");
+ }, 0);
+ }),
+
+ /**
+ * Calculate the rect of the viewport that limits the tooltip dimensions. When using a
+ * XUL panel wrapper, the viewport will be able to use the whole screen (excluding space
+ * reserved by the OS for toolbars etc.). Otherwise, the viewport is limited to the
+ * tooltip's document.
+ *
+ * @return {Object} DOMRect-like object with the Number properties: top, right, bottom,
+ * left, width, height
+ */
+ _getViewportRect: function () {
+ if (this.useXulWrapper) {
+ // availLeft/Top are the coordinates first pixel available on the screen for
+ // applications (excluding space dedicated for OS toolbars, menus etc...)
+ // availWidth/Height are the dimensions available to applications excluding all
+ // the OS reserved space
+ let {availLeft, availTop, availHeight, availWidth} = this.doc.defaultView.screen;
+ return {
+ top: availTop,
+ right: availLeft + availWidth,
+ bottom: availTop + availHeight,
+ left: availLeft,
+ width: availWidth,
+ height: availHeight,
+ };
+ }
+
+ return this.doc.documentElement.getBoundingClientRect();
+ },
+
+ _measureContainerWidth: function () {
+ let xulParent = this.container.parentNode;
+ if (this.useXulWrapper && !this.isVisible()) {
+ // Move the container out of the XUL Panel to measure it.
+ this.doc.documentElement.appendChild(this.container);
+ }
+
+ this.container.classList.add("tooltip-hidden");
+ this.container.style.width = "auto";
+ let width = this.container.getBoundingClientRect().width;
+ this.container.classList.remove("tooltip-hidden");
+
+ if (this.useXulWrapper && !this.isVisible()) {
+ xulParent.appendChild(this.container);
+ }
+
+ return width;
+ },
+
+ /**
+ * Hide the current tooltip. The event "hidden" will be fired when the tooltip
+ * is hidden.
+ */
+ hide: Task.async(function* () {
+ this.doc.defaultView.clearTimeout(this.attachEventsTimer);
+ if (!this.isVisible()) {
+ this.emit("hidden");
+ return;
+ }
+
+ this.topWindow.removeEventListener("click", this._onClick, true);
+ this.container.classList.remove("tooltip-visible");
+ if (this.useXulWrapper) {
+ yield this._hideXulWrapper();
+ }
+
+ this.emit("hidden");
+
+ let tooltipHasFocus = this.container.contains(this.doc.activeElement);
+ if (tooltipHasFocus && this._focusedElement) {
+ this._focusedElement.focus();
+ this._focusedElement = null;
+ }
+ }),
+
+ /**
+ * Check if the tooltip is currently displayed.
+ * @return {Boolean} true if the tooltip is visible
+ */
+ isVisible: function () {
+ return this.container.classList.contains("tooltip-visible");
+ },
+
+ /**
+ * Destroy the tooltip instance. Hide the tooltip if displayed, remove the
+ * tooltip container from the document.
+ */
+ destroy: function () {
+ this.hide();
+ this.container.remove();
+ if (this.xulPanelWrapper) {
+ this.xulPanelWrapper.remove();
+ }
+ },
+
+ _createContainer: function () {
+ let container = this.doc.createElementNS(XHTML_NS, "div");
+ container.setAttribute("type", this.type);
+ container.classList.add("tooltip-container");
+
+ let html = '<div class="tooltip-filler"></div>';
+ html += '<div class="tooltip-panel"></div>';
+
+ if (this.type === TYPE.ARROW) {
+ html += '<div class="tooltip-arrow"></div>';
+ }
+ container.innerHTML = html;
+ return container;
+ },
+
+ _onClick: function (e) {
+ if (this._isInTooltipContainer(e.target)) {
+ return;
+ }
+
+ this.hide();
+ if (this.consumeOutsideClicks && e.button === 0) {
+ // Consume only left click events (button === 0).
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ },
+
+ _isInTooltipContainer: function (node) {
+ // Check if the target is the tooltip arrow.
+ if (this.arrow && this.arrow === node) {
+ return true;
+ }
+
+ let tooltipWindow = this.panel.ownerDocument.defaultView;
+ let win = node.ownerDocument.defaultView;
+
+ // Check if the tooltip panel contains the node if they live in the same document.
+ if (win === tooltipWindow) {
+ return this.panel.contains(node);
+ }
+
+ // Check if the node window is in the tooltip container.
+ while (win.parent && win.parent !== win) {
+ if (win.parent === tooltipWindow) {
+ // If the parent window is the tooltip window, check if the tooltip contains
+ // the current frame element.
+ return this.panel.contains(win.frameElement);
+ }
+ win = win.parent;
+ }
+
+ return false;
+ },
+
+ _onXulPanelHidden: function () {
+ if (this.isVisible()) {
+ this.hide();
+ }
+ },
+
+ /**
+ * If the tootlip is configured to autofocus and a focusable element can be found,
+ * focus it.
+ */
+ _maybeFocusTooltip: function () {
+ // Simplied selector targetting elements that can receive the focus, full version at
+ // http://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus .
+ let focusableSelector = "a, button, iframe, input, select, textarea";
+ let focusableElement = this.panel.querySelector(focusableSelector);
+ if (this.autofocus && focusableElement) {
+ focusableElement.focus();
+ }
+ },
+
+ _getTopWindow: function () {
+ return this.doc.defaultView.top;
+ },
+
+ /**
+ * Check if the tooltip's owner document is a XUL document.
+ */
+ _isXUL: function () {
+ return this.doc.documentElement.namespaceURI === XUL_NS;
+ },
+
+ _createXulPanelWrapper: function () {
+ let panel = this.doc.createElementNS(XUL_NS, "panel");
+
+ // XUL panel is only a way to display DOM elements outside of the document viewport,
+ // so disable all features that impact the behavior.
+ panel.setAttribute("animate", false);
+ panel.setAttribute("consumeoutsideclicks", false);
+ panel.setAttribute("noautofocus", true);
+ panel.setAttribute("ignorekeys", true);
+ panel.setAttribute("tooltip", "aHTMLTooltip");
+
+ // Use type="arrow" to prevent side effects (see Bug 1285206)
+ panel.setAttribute("type", "arrow");
+
+ panel.setAttribute("level", "top");
+ panel.setAttribute("class", "tooltip-xul-wrapper");
+
+ return panel;
+ },
+
+ _showXulWrapperAt: function (left, top) {
+ this.xulPanelWrapper.addEventListener("popuphidden", this._onXulPanelHidden);
+ let onPanelShown = listenOnce(this.xulPanelWrapper, "popupshown");
+ this.xulPanelWrapper.openPopupAtScreen(left, top, false);
+ return onPanelShown;
+ },
+
+ _hideXulWrapper: function () {
+ this.xulPanelWrapper.removeEventListener("popuphidden", this._onXulPanelHidden);
+
+ if (this.xulPanelWrapper.state === "closed") {
+ // XUL panel is already closed, resolve immediately.
+ return Promise.resolve();
+ }
+
+ let onPanelHidden = listenOnce(this.xulPanelWrapper, "popuphidden");
+ this.xulPanelWrapper.hidePopup();
+ return onPanelHidden;
+ },
+
+ /**
+ * Convert from coordinates relative to the tooltip's document, to coordinates relative
+ * to the "available" screen. By "available" we mean the screen, excluding the OS bars
+ * display on screen edges.
+ */
+ _convertToScreenRect: function ({left, top, width, height}) {
+ // mozInnerScreenX/Y are the coordinates of the top left corner of the window's
+ // viewport, excluding chrome UI.
+ left += this.doc.defaultView.mozInnerScreenX;
+ top += this.doc.defaultView.mozInnerScreenY;
+ return {top, right: left + width, bottom: top + height, left, width, height};
+ },
+
+ /**
+ * Apply a scoped stylesheet to the container so that this css file only
+ * applies to it.
+ */
+ _applyStylesheet: function (url) {
+ let style = this.doc.createElementNS(XHTML_NS, "style");
+ style.setAttribute("scoped", "true");
+ url = url.replace(/"/g, "\\\"");
+ style.textContent = `@import url("${url}");`;
+ this.container.appendChild(style);
+ }
+};
diff --git a/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js b/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js
new file mode 100644
index 000000000..04c932005
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js
@@ -0,0 +1,131 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {LocalizationHelper} = require("devtools/shared/l10n");
+const L10N = new LocalizationHelper("devtools/client/locales/inspector.properties");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+// Default image tooltip max dimension
+const MAX_DIMENSION = 200;
+const CONTAINER_MIN_WIDTH = 100;
+const LABEL_HEIGHT = 20;
+const IMAGE_PADDING = 4;
+
+/**
+ * Image preview tooltips should be provided with the naturalHeight and
+ * naturalWidth value for the image to display. This helper loads the provided
+ * image URL in an image object in order to retrieve the image dimensions after
+ * the load.
+ *
+ * @param {Document} doc the document element to use to create the image object
+ * @param {String} imageUrl the url of the image to measure
+ * @return {Promise} returns a promise that will resolve after the iamge load:
+ * - {Number} naturalWidth natural width of the loaded image
+ * - {Number} naturalHeight natural height of the loaded image
+ */
+function getImageDimensions(doc, imageUrl) {
+ return new Promise(resolve => {
+ let imgObj = new doc.defaultView.Image();
+ imgObj.onload = () => {
+ imgObj.onload = null;
+ let { naturalWidth, naturalHeight } = imgObj;
+ resolve({ naturalWidth, naturalHeight });
+ };
+ imgObj.src = imageUrl;
+ });
+}
+
+/**
+ * Set the tooltip content of a provided HTMLTooltip instance to display an
+ * image preview matching the provided imageUrl.
+ *
+ * @param {HTMLTooltip} tooltip
+ * The tooltip instance on which the image preview content should be set
+ * @param {Document} doc
+ * A document element to create the HTML elements needed for the tooltip
+ * @param {String} imageUrl
+ * Absolute URL of the image to display in the tooltip
+ * @param {Object} options
+ * - {Number} naturalWidth mandatory, width of the image to display
+ * - {Number} naturalHeight mandatory, height of the image to display
+ * - {Number} maxDim optional, max width/height of the preview
+ * - {Boolean} hideDimensionLabel optional, pass true to hide the label
+ */
+function setImageTooltip(tooltip, doc, imageUrl, options) {
+ let {naturalWidth, naturalHeight, hideDimensionLabel, maxDim} = options;
+ maxDim = maxDim || MAX_DIMENSION;
+
+ let imgHeight = naturalHeight;
+ let imgWidth = naturalWidth;
+ if (imgHeight > maxDim || imgWidth > maxDim) {
+ let scale = maxDim / Math.max(imgHeight, imgWidth);
+ // Only allow integer values to avoid rounding errors.
+ imgHeight = Math.floor(scale * naturalHeight);
+ imgWidth = Math.ceil(scale * naturalWidth);
+ }
+
+ // Create tooltip content
+ let div = doc.createElementNS(XHTML_NS, "div");
+ div.style.cssText = `
+ height: 100%;
+ min-width: 100px;
+ display: flex;
+ flex-direction: column;
+ text-align: center;`;
+ let html = `
+ <div style="flex: 1;
+ display: flex;
+ padding: ${IMAGE_PADDING}px;
+ align-items: center;
+ justify-content: center;
+ min-height: 1px;">
+ <img style="height: ${imgHeight}px; max-height: 100%;"
+ src="${encodeURI(imageUrl)}"/>
+ </div>`;
+
+ if (!hideDimensionLabel) {
+ let label = naturalWidth + " \u00D7 " + naturalHeight;
+ html += `
+ <div style="height: ${LABEL_HEIGHT}px;
+ text-align: center;">
+ <span class="theme-comment devtools-tooltip-caption">${label}</span>
+ </div>`;
+ }
+ div.innerHTML = html;
+
+ // Calculate tooltip dimensions
+ let height = imgHeight + 2 * IMAGE_PADDING;
+ if (!hideDimensionLabel) {
+ height += LABEL_HEIGHT;
+ }
+ let width = Math.max(CONTAINER_MIN_WIDTH, imgWidth + 2 * IMAGE_PADDING);
+
+ tooltip.setContent(div, {width, height});
+}
+
+/*
+ * Set the tooltip content of a provided HTMLTooltip instance to display a
+ * fallback error message when an image preview tooltip can not be displayed.
+ *
+ * @param {HTMLTooltip} tooltip
+ * The tooltip instance on which the image preview content should be set
+ * @param {Document} doc
+ * A document element to create the HTML elements needed for the tooltip
+ */
+function setBrokenImageTooltip(tooltip, doc) {
+ let div = doc.createElementNS(XHTML_NS, "div");
+ div.className = "theme-comment devtools-tooltip-image-broken";
+ let message = L10N.getStr("previewTooltip.image.brokenImage");
+ div.textContent = message;
+ tooltip.setContent(div, {width: 150, height: 30});
+}
+
+module.exports.getImageDimensions = getImageDimensions;
+module.exports.setImageTooltip = setImageTooltip;
+module.exports.setBrokenImageTooltip = setBrokenImageTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js
new file mode 100644
index 000000000..52bf565e2
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js
@@ -0,0 +1,209 @@
+/* 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 EventEmitter = require("devtools/shared/event-emitter");
+const {KeyShortcuts} = require("devtools/client/shared/key-shortcuts");
+const {HTMLTooltip} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip");
+
+/**
+ * Base class for all (color, gradient, ...)-swatch based value editors inside
+ * tooltips
+ *
+ * @param {Document} document
+ * The document to attach the SwatchBasedEditorTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ */
+function SwatchBasedEditorTooltip(document, stylesheet) {
+ EventEmitter.decorate(this);
+ // 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 <escape> and <enter>
+ this.tooltip = new HTMLTooltip(document, {
+ type: "arrow",
+ consumeOutsideClicks: true,
+ useXulWrapper: true,
+ stylesheet
+ });
+
+ // By default, swatch-based editor tooltips revert value change on <esc> and
+ // commit value change on <enter>
+ this.shortcuts = new KeyShortcuts({
+ window: this.tooltip.topWindow
+ });
+ this.shortcuts.on("Escape", (name, event) => {
+ if (!this.tooltip.isVisible()) {
+ return;
+ }
+ this.revert();
+ this.hide();
+ event.stopPropagation();
+ event.preventDefault();
+ });
+ this.shortcuts.on("Return", (name, event) => {
+ if (!this.tooltip.isVisible()) {
+ return;
+ }
+ this.commit();
+ this.hide();
+ event.stopPropagation();
+ event.preventDefault();
+ });
+
+ // 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 the editor tooltip for the currently active swatch.
+ *
+ * @return {Promise} a promise that resolves once the editor tooltip is displayed, or
+ * immediately if there is no currently active swatch.
+ */
+ show: function () {
+ if (this.activeSwatch) {
+ let onShown = this.tooltip.once("shown");
+ this.tooltip.show(this.activeSwatch, "topcenter bottomleft");
+
+ // When the tooltip is closed by clicking outside the panel we want to
+ // commit any changes.
+ this.tooltip.once("hidden", () => {
+ if (!this._reverted && !this.eyedropperOpen) {
+ this.commit();
+ }
+ this._reverted = false;
+
+ // Once the tooltip is hidden we need to clean up any remaining objects.
+ if (!this.eyedropperOpen) {
+ this.activeSwatch = null;
+ }
+ });
+
+ return onShown;
+ }
+
+ return Promise.resolve();
+ },
+
+ 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.
+ * - onShow: will be called when one of the swatch tooltip is shown
+ * - 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.onShow) {
+ callbacks.onShow = function () {};
+ }
+ 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 (event.shiftKey) {
+ event.stopPropagation();
+ return;
+ }
+ if (swatch) {
+ this.activeSwatch = event.target;
+ this.show();
+ swatch.callbacks.onShow();
+ 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 <esc> keypress
+ */
+ revert: function () {
+ if (this.activeSwatch) {
+ this._reverted = true;
+ let swatch = this.swatches.get(this.activeSwatch);
+ this.tooltip.once("hidden", () => {
+ swatch.callbacks.onRevert();
+ });
+ }
+ },
+
+ /**
+ * This parent class only calls this on <enter> 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();
+ this.shortcuts.destroy();
+ }
+};
+
+module.exports = SwatchBasedEditorTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js
new file mode 100644
index 000000000..bf211b8b9
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js
@@ -0,0 +1,182 @@
+/* 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 {Task} = require("devtools/shared/task");
+const {colorUtils} = require("devtools/shared/css/color");
+const {Spectrum} = require("devtools/client/shared/widgets/Spectrum");
+const SwatchBasedEditorTooltip = require("devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip");
+const {LocalizationHelper} = require("devtools/shared/l10n");
+const L10N = new LocalizationHelper("devtools/client/locales/inspector.properties");
+
+const Heritage = require("sdk/core/heritage");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * 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 {Document} document
+ * The document to attach the SwatchColorPickerTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ * @param {InspectorPanel} inspector
+ * The inspector panel, needed for the eyedropper.
+ */
+function SwatchColorPickerTooltip(document, inspector) {
+ let stylesheet = "chrome://devtools/content/shared/widgets/spectrum.css";
+ SwatchBasedEditorTooltip.call(this, document, stylesheet);
+
+ this.inspector = inspector;
+
+ // Creating a spectrum instance. this.spectrum will always be a promise that
+ // resolves to the spectrum instance
+ this.spectrum = this.setColorPickerContent([0, 0, 0, 1]);
+ this._onSpectrumColorChange = this._onSpectrumColorChange.bind(this);
+ this._openEyeDropper = this._openEyeDropper.bind(this);
+}
+
+SwatchColorPickerTooltip.prototype = Heritage.extend(SwatchBasedEditorTooltip.prototype, {
+ /**
+ * Fill the tooltip with a new instance of the spectrum color picker widget
+ * initialized with the given color, and return the instance of spectrum
+ */
+ setColorPickerContent: function (color) {
+ let { doc } = this.tooltip;
+
+ let container = doc.createElementNS(XHTML_NS, "div");
+ container.id = "spectrum-tooltip";
+ let spectrumNode = doc.createElementNS(XHTML_NS, "div");
+ spectrumNode.id = "spectrum";
+ container.appendChild(spectrumNode);
+ let eyedropper = doc.createElementNS(XHTML_NS, "button");
+ eyedropper.id = "eyedropper-button";
+ eyedropper.className = "devtools-button";
+ /* pointerEvents for eyedropper has to be set auto to display tooltip when
+ * eyedropper is disabled in non-HTML documents.
+ */
+ eyedropper.style.pointerEvents = "auto";
+ container.appendChild(eyedropper);
+
+ this.tooltip.setContent(container, { width: 218, height: 224 });
+
+ let spectrum = new Spectrum(spectrumNode, color);
+
+ // Wait for the tooltip to be shown before calling spectrum.show
+ // as it expect to be visible in order to compute DOM element sizes.
+ this.tooltip.once("shown", () => {
+ spectrum.show();
+ });
+
+ return spectrum;
+ },
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set spectrum's
+ * color.
+ */
+ show: Task.async(function* () {
+ // Call then parent class' show function
+ yield 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;
+ this._originalColor = this.currentSwatchColor.textContent;
+ let color = this.activeSwatch.style.backgroundColor;
+ this.spectrum.off("changed", this._onSpectrumColorChange);
+ this.spectrum.rgb = this._colorToRgba(color);
+ this.spectrum.on("changed", this._onSpectrumColorChange);
+ this.spectrum.updateUI();
+ }
+
+ let {target} = this.inspector;
+ target.actorHasMethod("inspector", "pickColorFromPage").then(value => {
+ let tooltipDoc = this.tooltip.doc;
+ let eyeButton = tooltipDoc.querySelector("#eyedropper-button");
+ if (value && this.inspector.selection.nodeFront.isInHTMLDocument) {
+ eyeButton.disabled = false;
+ eyeButton.removeAttribute("title");
+ eyeButton.addEventListener("click", this._openEyeDropper);
+ } else {
+ eyeButton.disabled = true;
+ eyeButton.title = L10N.getStr("eyedropper.disabled.title");
+ }
+ this.emit("ready");
+ }, e => console.error(e));
+ }),
+
+ _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 {inspector, toolbox, telemetry} = this.inspector;
+ telemetry.toolOpened("pickereyedropper");
+ inspector.pickColorFromPage(toolbox, {copyOnSelect: false}).then(() => {
+ this.eyedropperOpen = true;
+
+ // close the colorpicker tooltip so that only the eyedropper is open.
+ this.hide();
+
+ this.tooltip.emit("eyedropper-opened");
+ }, e => console.error(e));
+
+ inspector.once("color-picked", color => {
+ toolbox.win.focus();
+ this._selectColor(color);
+ this._onEyeDropperDone();
+ });
+
+ inspector.once("color-pick-canceled", () => {
+ this._onEyeDropperDone();
+ });
+ },
+
+ _onEyeDropperDone: function () {
+ this.eyedropperOpen = false;
+ this.activeSwatch = null;
+ },
+
+ _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);
+ colorObj.setAuthoredUnitFromColor(this._originalColor);
+ return colorObj.toString();
+ },
+
+ destroy: function () {
+ SwatchBasedEditorTooltip.prototype.destroy.call(this);
+ this.inspector = null;
+ this.currentSwatchColor = null;
+ this.spectrum.off("changed", this._onSpectrumColorChange);
+ this.spectrum.destroy();
+ }
+});
+
+module.exports = SwatchColorPickerTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js
new file mode 100644
index 000000000..02f6fbea4
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js
@@ -0,0 +1,102 @@
+/* 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 defer = require("devtools/shared/defer");
+const {Task} = require("devtools/shared/task");
+const {CubicBezierWidget} = require("devtools/client/shared/widgets/CubicBezierWidget");
+const SwatchBasedEditorTooltip = require("devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip");
+
+const Heritage = require("sdk/core/heritage");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * 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 {Document} document
+ * The document to attach the SwatchCubicBezierTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ */
+function SwatchCubicBezierTooltip(document) {
+ let stylesheet = "chrome://devtools/content/shared/widgets/cubic-bezier.css";
+ SwatchBasedEditorTooltip.call(this, document, stylesheet);
+
+ // Creating a cubic-bezier instance.
+ // this.widget will always be a promise that resolves to the widget instance
+ this.widget = this.setCubicBezierContent([0, 0, 1, 1]);
+ this._onUpdate = this._onUpdate.bind(this);
+}
+
+SwatchCubicBezierTooltip.prototype = Heritage.extend(SwatchBasedEditorTooltip.prototype, {
+ /**
+ * 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 { doc } = this.tooltip;
+
+ let container = doc.createElementNS(XHTML_NS, "div");
+ container.className = "cubic-bezier-container";
+
+ this.tooltip.setContent(container, { width: 510, height: 370 });
+
+ let def = defer();
+
+ // Wait for the tooltip to be shown before calling instanciating the widget
+ // as it expect its DOM elements to be visible.
+ this.tooltip.once("shown", () => {
+ let widget = new CubicBezierWidget(container, bezier);
+ def.resolve(widget);
+ });
+
+ return def.promise;
+ },
+
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set the cubic
+ * bezier curve in the widget
+ */
+ show: Task.async(function* () {
+ // Call the parent class' show function
+ yield SwatchBasedEditorTooltip.prototype.show.call(this);
+ // Then set the curve and listen to changes to preview them
+ if (this.activeSwatch) {
+ this.currentBezierValue = this.activeSwatch.nextSibling;
+ this.widget.then(widget => {
+ widget.off("updated", this._onUpdate);
+ widget.cssCubicBezierValue = this.currentBezierValue.textContent;
+ widget.on("updated", this._onUpdate);
+ this.emit("ready");
+ });
+ }
+ }),
+
+ _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();
+ });
+ }
+});
+
+module.exports = SwatchCubicBezierTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js
new file mode 100644
index 000000000..bc69c3b70
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js
@@ -0,0 +1,116 @@
+/* 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 {Task} = require("devtools/shared/task");
+const {CSSFilterEditorWidget} = require("devtools/client/shared/widgets/FilterWidget");
+const SwatchBasedEditorTooltip = require("devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip");
+
+const Heritage = require("sdk/core/heritage");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * The swatch-based css filter tooltip class is a specific class meant to be
+ * used along with rule-view's generated css filter swatches.
+ * It extends the parent SwatchBasedEditorTooltip class.
+ * It just wraps a standard Tooltip and sets its content with an instance of a
+ * CSSFilterEditorWidget.
+ *
+ * @param {Document} document
+ * The document to attach the SwatchFilterTooltip. This is either the toolbox
+ * document if the tooltip is a popup tooltip or the panel's document if it is an
+ * inline editor.
+ * @param {function} cssIsValid
+ * A function to check that css declaration's name and values are valid together.
+ * This can be obtained from "shared/fronts/css-properties.js".
+ */
+function SwatchFilterTooltip(document, cssIsValid) {
+ let stylesheet = "chrome://devtools/content/shared/widgets/filter-widget.css";
+ SwatchBasedEditorTooltip.call(this, document, stylesheet);
+ this._cssIsValid = cssIsValid;
+
+ // Creating a filter editor instance.
+ this.widget = this.setFilterContent("none");
+ this._onUpdate = this._onUpdate.bind(this);
+}
+
+SwatchFilterTooltip.prototype = Heritage.extend(SwatchBasedEditorTooltip.prototype, {
+ /**
+ * Fill the tooltip with a new instance of the CSSFilterEditorWidget
+ * widget initialized with the given filter value, and return a promise
+ * that resolves to the instance of the widget when ready.
+ */
+ setFilterContent: function (filter) {
+ let { doc } = this.tooltip;
+
+ let container = doc.createElementNS(XHTML_NS, "div");
+ container.id = "filter-container";
+
+ this.tooltip.setContent(container, { width: 510, height: 200 });
+
+ return new CSSFilterEditorWidget(container, filter, this._cssIsValid);
+ },
+
+ show: Task.async(function* () {
+ // Call the parent class' show function
+ yield SwatchBasedEditorTooltip.prototype.show.call(this);
+ // Then set the filter value and listen to changes to preview them
+ if (this.activeSwatch) {
+ this.currentFilterValue = this.activeSwatch.nextSibling;
+ this.widget.off("updated", this._onUpdate);
+ this.widget.on("updated", this._onUpdate);
+ this.widget.setCssValue(this.currentFilterValue.textContent);
+ this.widget.render();
+ this.emit("ready");
+ }
+ }),
+
+ _onUpdate: function (event, filters) {
+ if (!this.activeSwatch) {
+ return;
+ }
+
+ // Remove the old children and reparse the property value to
+ // recompute them.
+ while (this.currentFilterValue.firstChild) {
+ this.currentFilterValue.firstChild.remove();
+ }
+ let node = this._parser.parseCssProperty("filter", filters, this._options);
+ this.currentFilterValue.appendChild(node);
+
+ this.preview();
+ },
+
+ destroy: function () {
+ SwatchBasedEditorTooltip.prototype.destroy.call(this);
+ this.currentFilterValue = null;
+ this.widget.off("updated", this._onUpdate);
+ this.widget.destroy();
+ },
+
+ /**
+ * Like SwatchBasedEditorTooltip.addSwatch, but accepts a parser object
+ * to use when previewing the updated property value.
+ *
+ * @param {node} swatchEl
+ * @see SwatchBasedEditorTooltip.addSwatch
+ * @param {object} callbacks
+ * @see SwatchBasedEditorTooltip.addSwatch
+ * @param {object} parser
+ * A parser object; @see OutputParser object
+ * @param {object} options
+ * options to pass to the output parser, with
+ * the option |filterSwatch| set.
+ */
+ addSwatch: function (swatchEl, callbacks, parser, options) {
+ SwatchBasedEditorTooltip.prototype.addSwatch.call(this, swatchEl,
+ callbacks);
+ this._parser = parser;
+ this._options = options;
+ }
+});
+
+module.exports = SwatchFilterTooltip;
diff --git a/devtools/client/shared/widgets/tooltip/Tooltip.js b/devtools/client/shared/widgets/tooltip/Tooltip.js
new file mode 100644
index 000000000..c3c365152
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/Tooltip.js
@@ -0,0 +1,410 @@
+/* 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 defer = require("devtools/shared/defer");
+const EventEmitter = require("devtools/shared/event-emitter");
+const {KeyCodes} = require("devtools/client/shared/keycodes");
+const {TooltipToggle} = require("devtools/client/shared/widgets/tooltip/TooltipToggle");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const ESCAPE_KEYCODE = KeyCodes.DOM_VK_ESCAPE;
+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 <panel>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)
+ */
+
+/**
+ * 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 (<condition based on target>) {
+ * t.content = el;
+ * 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, {
+ consumeOutsideClick = false,
+ closeOnKeys = [ESCAPE_KEYCODE],
+ noAutoFocus = true,
+ closeOnEvents = [],
+ } = {}) {
+ EventEmitter.decorate(this);
+
+ this.doc = doc;
+ this.consumeOutsideClick = consumeOutsideClick;
+ this.closeOnKeys = closeOnKeys;
+ this.noAutoFocus = noAutoFocus;
+ this.closeOnEvents = closeOnEvents;
+
+ this.panel = this._createPanel();
+
+ // Create tooltip toggle helper and decorate the Tooltip instance with
+ // shortcut methods.
+ this._toggle = new TooltipToggle(this);
+ this.startTogglingOnHover = this._toggle.start.bind(this._toggle);
+ this.stopTogglingOnHover = this._toggle.stop.bind(this._toggle);
+
+ // Emit show/hide events when the panel does.
+ for (let eventName of POPUP_EVENTS) {
+ this["_onPopup" + eventName] = (name => {
+ return e => {
+ if (e.target === this.panel) {
+ this.emit(name);
+ }
+ };
+ })(eventName);
+ this.panel.addEventListener("popup" + eventName,
+ this["_onPopup" + eventName], 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.closeOnKeys.indexOf(event.keyCode) !== -1 &&
+ this.isShown()) {
+ 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);
+ for (let {emitter, event, useCapture} of this.closeOnEvents) {
+ for (let add of ["addEventListener", "on"]) {
+ if (add in emitter) {
+ emitter[add](event, this.hide, useCapture);
+ break;
+ }
+ }
+ }
+}
+
+Tooltip.prototype = {
+ defaultPosition: "before_start",
+ // px
+ defaultOffsetX: 0,
+ // px
+ defaultOffsetY: 0,
+ // px
+
+ /**
+ * 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 eventName of POPUP_EVENTS) {
+ this.panel.removeEventListener("popup" + eventName,
+ this["_onPopup" + eventName], false);
+ }
+
+ let win = this.doc.querySelector("window");
+ win.removeEventListener("keypress", this._onKeyPress, false);
+
+ for (let {emitter, event, useCapture} of this.closeOnEvents) {
+ for (let remove of ["removeEventListener", "off"]) {
+ if (remove in emitter) {
+ emitter[remove](event, this.hide, useCapture);
+ break;
+ }
+ }
+ }
+
+ this.content = null;
+
+ this._toggle.destroy();
+
+ this.doc = null;
+
+ this.panel.remove();
+ this.panel = null;
+ },
+
+ /**
+ * Returns the outer container node (that includes the arrow etc.). Happens
+ * to be identical to this.panel here, can be different element in other
+ * Tooltip implementations.
+ */
+ get container() {
+ return this.panel;
+ },
+
+ /**
+ * Set the content of this tooltip. Will first empty the tooltip and then
+ * append the new content element.
+ * Consider using one of the set<type>Content() 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.
+ */
+ setTextContent: function (
+ {
+ messages,
+ messagesClass,
+ containerClass
+ },
+ 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);
+ }
+
+ this.content = vbox;
+ },
+
+ /**
+ * Load a document into an iframe, and set the iframe
+ * to be the tooltip's content.
+ *
+ * Used by tooltips that want to load their interface
+ * into an iframe from a URL.
+ *
+ * @param {string} width
+ * Width of the iframe.
+ * @param {string} height
+ * Height of the iframe.
+ * @param {string} url
+ * URL of the document to load into the iframe.
+ *
+ * @return {promise} A promise which is resolved with
+ * the iframe.
+ *
+ * This function creates an iframe, loads the specified document
+ * into it, sets the tooltip's content to the iframe, and returns
+ * a promise.
+ *
+ * When the document is loaded, the function gets the content window
+ * and resolves the promise with the content window.
+ */
+ setIFrameContent: function ({width, height}, url) {
+ let def = defer();
+
+ // Create an iframe
+ let iframe = this.doc.createElementNS(XHTML_NS, "iframe");
+ iframe.setAttribute("transparent", true);
+ iframe.setAttribute("width", width);
+ iframe.setAttribute("height", height);
+ iframe.setAttribute("flex", "1");
+ iframe.setAttribute("tooltip", "aHTMLTooltip");
+ iframe.setAttribute("class", "devtools-tooltip-iframe");
+
+ // Wait for the load to initialize the widget
+ function onLoad() {
+ iframe.removeEventListener("load", onLoad, true);
+ def.resolve(iframe);
+ }
+ iframe.addEventListener("load", onLoad, true);
+
+ // load the document from url into the iframe
+ iframe.setAttribute("src", url);
+
+ // Put the iframe in the tooltip
+ this.content = iframe;
+
+ return def.promise;
+ },
+
+ /**
+ * Create the tooltip panel
+ */
+ _createPanel() {
+ let panel = this.doc.createElement("panel");
+ panel.setAttribute("hidden", true);
+ panel.setAttribute("ignorekeys", true);
+ panel.setAttribute("animate", false);
+
+ panel.setAttribute("consumeoutsideclicks",
+ this.consumeOutsideClick);
+ panel.setAttribute("noautofocus", this.noAutoFocus);
+ panel.setAttribute("type", "arrow");
+ panel.setAttribute("level", "top");
+
+ panel.setAttribute("class", "devtools-tooltip theme-tooltip-panel");
+ this.doc.querySelector("window").appendChild(panel);
+
+ return panel;
+ }
+};
+
+module.exports = Tooltip;
diff --git a/devtools/client/shared/widgets/tooltip/TooltipToggle.js b/devtools/client/shared/widgets/tooltip/TooltipToggle.js
new file mode 100644
index 000000000..b53664f34
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/TooltipToggle.js
@@ -0,0 +1,182 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {Task} = require("devtools/shared/task");
+
+const DEFAULT_TOGGLE_DELAY = 50;
+
+/**
+ * Tooltip helper designed to show/hide the tooltip when the mouse hovers over
+ * particular nodes.
+ *
+ * This works by tracking mouse movements on a base container node (baseNode)
+ * and showing the tooltip when the mouse stops moving. A callback can be
+ * provided to the start() method to know whether or not the node being
+ * hovered over should indeed receive the tooltip.
+ */
+function TooltipToggle(tooltip) {
+ this.tooltip = tooltip;
+ this.win = tooltip.doc.defaultView;
+
+ this._onMouseMove = this._onMouseMove.bind(this);
+ this._onMouseOut = this._onMouseOut.bind(this);
+
+ this._onTooltipMouseOver = this._onTooltipMouseOver.bind(this);
+ this._onTooltipMouseOut = this._onTooltipMouseOut.bind(this);
+}
+
+module.exports.TooltipToggle = TooltipToggle;
+
+TooltipToggle.prototype = {
+ /**
+ * Start tracking mouse movements on the provided baseNode to show the
+ * tooltip.
+ *
+ * 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.
+ *
+ * Note that if you call this function a second time, it will itself call
+ * stop() 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 that checks if a tooltip
+ * should be displayed. Possible return values are:
+ * - false (or a falsy value) if the tooltip should not be displayed
+ * - true if the tooltip should be displayed
+ * - a DOM node to display the tooltip on the returned anchor
+ * The function can also return a promise that will resolve to one of
+ * the values listed above.
+ * If omitted, the tooltip will be shown everytime.
+ * @param {Object} options
+ Set of optional arguments:
+ * - {Number} toggleDelay
+ * An optional delay (in ms) that will be observed before showing
+ * and before hiding the tooltip. Defaults to DEFAULT_TOGGLE_DELAY.
+ * - {Boolean} interactive
+ * If enabled, the tooltip is not hidden when mouse leaves the
+ * target element and enters the tooltip. Allows the tooltip
+ * content to be interactive.
+ */
+ start: function (baseNode, targetNodeCb,
+ {toggleDelay = DEFAULT_TOGGLE_DELAY, interactive = false} = {}) {
+ this.stop();
+
+ if (!baseNode) {
+ // Calling tool is in the process of being destroyed.
+ return;
+ }
+
+ this._baseNode = baseNode;
+ this._targetNodeCb = targetNodeCb || (() => true);
+ this._toggleDelay = toggleDelay;
+ this._interactive = interactive;
+
+ baseNode.addEventListener("mousemove", this._onMouseMove);
+ baseNode.addEventListener("mouseout", this._onMouseOut);
+
+ if (this._interactive) {
+ this.tooltip.container.addEventListener("mouseover", this._onTooltipMouseOver);
+ this.tooltip.container.addEventListener("mouseout", this._onTooltipMouseOut);
+ }
+ },
+
+ /**
+ * If the start() function has been used previously, and you want to get rid
+ * of this behavior, then call this function to remove the mouse movement
+ * tracking
+ */
+ stop: function () {
+ this.win.clearTimeout(this.toggleTimer);
+
+ if (!this._baseNode) {
+ return;
+ }
+
+ this._baseNode.removeEventListener("mousemove", this._onMouseMove);
+ this._baseNode.removeEventListener("mouseout", this._onMouseOut);
+
+ if (this._interactive) {
+ this.tooltip.container.removeEventListener("mouseover", this._onTooltipMouseOver);
+ this.tooltip.container.removeEventListener("mouseout", this._onTooltipMouseOut);
+ }
+
+ this._baseNode = null;
+ this._targetNodeCb = null;
+ this._lastHovered = null;
+ },
+
+ _onMouseMove: function (event) {
+ if (event.target !== this._lastHovered) {
+ this._lastHovered = event.target;
+
+ this.win.clearTimeout(this.toggleTimer);
+ this.toggleTimer = this.win.setTimeout(() => {
+ this.tooltip.hide();
+ this.isValidHoverTarget(event.target).then(target => {
+ if (target === null) {
+ return;
+ }
+ this.tooltip.show(target);
+ }, reason => {
+ console.error("isValidHoverTarget rejected with unexpected reason:");
+ console.error(reason);
+ });
+ }, this._toggleDelay);
+ }
+ },
+
+ /**
+ * Is the given target DOMNode a valid node for toggling the tooltip on hover.
+ * This delegates to the user-defined _targetNodeCb callback.
+ * @return {Promise} a promise that will resolve the anchor to use for the
+ * tooltip or null if no valid target was found.
+ */
+ isValidHoverTarget: Task.async(function* (target) {
+ let res = yield this._targetNodeCb(target, this.tooltip);
+ if (res) {
+ return res.nodeName ? res : target;
+ }
+
+ return null;
+ }),
+
+ _onMouseOut: function (event) {
+ // Only hide the tooltip if the mouse leaves baseNode.
+ if (event && this._baseNode && !this._baseNode.contains(event.relatedTarget)) {
+ return;
+ }
+
+ this._lastHovered = null;
+ this.win.clearTimeout(this.toggleTimer);
+ this.toggleTimer = this.win.setTimeout(() => {
+ this.tooltip.hide();
+ }, this._toggleDelay);
+ },
+
+ _onTooltipMouseOver() {
+ this.win.clearTimeout(this.toggleTimer);
+ },
+
+ _onTooltipMouseOut() {
+ this.win.clearTimeout(this.toggleTimer);
+ this.toggleTimer = this.win.setTimeout(() => {
+ this.tooltip.hide();
+ }, this._toggleDelay);
+ },
+
+ destroy: function () {
+ this.stop();
+ }
+};
diff --git a/devtools/client/shared/widgets/tooltip/VariableContentHelper.js b/devtools/client/shared/widgets/tooltip/VariableContentHelper.js
new file mode 100644
index 000000000..4dc02da9b
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/VariableContentHelper.js
@@ -0,0 +1,89 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "VariablesView",
+ "resource://devtools/client/shared/widgets/VariablesView.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "VariablesViewController",
+ "resource://devtools/client/shared/widgets/VariablesViewController.jsm");
+
+/**
+ * Fill the tooltip with a variables view, inspecting an object via its
+ * corresponding object actor, as specified in the remote debugging protocol.
+ *
+ * @param {Tooltip} tooltip
+ * The tooltip to use
+ * @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 {array} extraButtons [optional]
+ * An array of extra buttons to add. Each element of the array
+ * should be of the form {label, className, command}.
+ * @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
+ */
+
+function setTooltipVariableContent(tooltip, objectActor,
+ viewOptions = {}, controllerOptions = {},
+ relayEvents = {}, extraButtons = [],
+ toolbox = null) {
+ let doc = tooltip.doc;
+ let vbox = doc.createElement("vbox");
+ vbox.className = "devtools-tooltip-variables-view-box";
+ vbox.setAttribute("flex", "1");
+
+ let innerbox = 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 = 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);
+
+ tooltip.content = vbox;
+ tooltip.panel.setAttribute("clamped-dimensions", "");
+}
+
+exports.setTooltipVariableContent = setTooltipVariableContent;
diff --git a/devtools/client/shared/widgets/tooltip/moz.build b/devtools/client/shared/widgets/tooltip/moz.build
new file mode 100644
index 000000000..93172227a
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/moz.build
@@ -0,0 +1,19 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'CssDocsTooltip.js',
+ 'EventTooltipHelper.js',
+ 'HTMLTooltip.js',
+ 'ImageTooltipHelper.js',
+ 'SwatchBasedEditorTooltip.js',
+ 'SwatchColorPickerTooltip.js',
+ 'SwatchCubicBezierTooltip.js',
+ 'SwatchFilterTooltip.js',
+ 'Tooltip.js',
+ 'TooltipToggle.js',
+ 'VariableContentHelper.js',
+)