diff options
Diffstat (limited to 'devtools/client/shared/widgets/tooltip')
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', +) |