diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/client/shared/widgets/tooltip | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/shared/widgets/tooltip')
16 files changed, 3520 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js new file mode 100644 index 0000000000..01f1b0ec91 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js @@ -0,0 +1,419 @@ +/* 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("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/inspector.properties" +); + +const Editor = require("resource://devtools/client/shared/sourceeditor/editor.js"); +const beautify = require("resource://devtools/shared/jsbeautify/beautify.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const CONTAINER_WIDTH = 500; + +const L10N_BUBBLING = L10N.getStr("eventsTooltip.Bubbling"); +const L10N_CAPTURING = L10N.getStr("eventsTooltip.Capturing"); + +class EventTooltip extends EventEmitter { + /** + * 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 + * @param {NodeFront} nodeFront + * The nodeFront we're displaying event listeners for. + */ + constructor(tooltip, eventListenerInfos, toolbox, nodeFront) { + super(); + + this._tooltip = tooltip; + this._toolbox = toolbox; + this._eventEditors = new WeakMap(); + this._nodeFront = nodeFront; + this._eventListenersAbortController = new AbortController(); + + // Used in tests: add a reference to the EventTooltip instance on the HTMLTooltip. + this._tooltip.eventTooltip = this; + + this._headerClicked = this._headerClicked.bind(this); + this._eventToggleCheckboxChanged = + this._eventToggleCheckboxChanged.bind(this); + + this._subscriptions = []; + + const config = { + mode: Editor.modes.js, + lineNumbers: false, + lineWrapping: true, + readOnly: true, + styleActiveLine: true, + extraKeys: {}, + theme: "mozilla markup-view", + cm6: true, + }; + + const doc = this._tooltip.doc; + this.container = doc.createElementNS(XHTML_NS, "ul"); + this.container.className = "devtools-tooltip-events-container"; + + const sourceMapURLService = this._toolbox.sourceMapURLService; + + for (let i = 0; i < eventListenerInfos.length; i++) { + const listener = eventListenerInfos[i]; + + // Create this early so we can refer to it from a closure, below. + const content = doc.createElementNS(XHTML_NS, "div"); + const codeMirrorContainerId = `cm-${i}`; + content.id = codeMirrorContainerId; + + // Header + const header = doc.createElementNS(XHTML_NS, "div"); + header.className = "event-header"; + header.setAttribute("data-event-type", listener.type); + + const arrow = doc.createElementNS(XHTML_NS, "button"); + arrow.className = "theme-twisty"; + arrow.setAttribute("aria-expanded", "false"); + arrow.setAttribute("aria-owns", codeMirrorContainerId); + arrow.setAttribute( + "title", + L10N.getFormatStr("eventsTooltip.toggleButton.label", listener.type) + ); + + header.appendChild(arrow); + + if (!listener.hide.type) { + const eventTypeLabel = doc.createElementNS(XHTML_NS, "span"); + eventTypeLabel.className = "event-tooltip-event-type"; + eventTypeLabel.textContent = listener.type; + eventTypeLabel.setAttribute("title", listener.type); + header.appendChild(eventTypeLabel); + } + + const filename = doc.createElementNS(XHTML_NS, "span"); + filename.className = "event-tooltip-filename devtools-monospace"; + + let location = null; + let text = listener.origin; + let title = text; + if (listener.hide.filename) { + text = L10N.getStr("eventsTooltip.unknownLocation"); + title = L10N.getStr("eventsTooltip.unknownLocationExplanation"); + } else { + location = this._parseLocation(listener.origin); + + // There will be no source actor if the listener is a native function + // or wasn't a debuggee, in which case there's also not going to be + // a sourcemap, so we don't need to worry about subscribing. + if (location && listener.sourceActor) { + location.id = listener.sourceActor; + + this._subscriptions.push( + sourceMapURLService.subscribeByID( + location.id, + location.line, + location.column, + originalLocation => { + const currentLoc = originalLocation || location; + + const newURI = currentLoc.url + ":" + currentLoc.line; + filename.textContent = newURI; + filename.setAttribute("title", newURI); + + // This is emitted for testing. + this._tooltip.emitForTests("event-tooltip-source-map-ready"); + } + ) + ); + } + } + + filename.textContent = text; + filename.setAttribute("title", title); + header.appendChild(filename); + + if (!listener.hide.debugger) { + const debuggerIcon = doc.createElementNS(XHTML_NS, "button"); + debuggerIcon.className = "event-tooltip-debugger-icon"; + const openInDebugger = L10N.getFormatStr( + "eventsTooltip.openInDebugger2", + listener.type + ); + debuggerIcon.setAttribute("title", openInDebugger); + header.appendChild(debuggerIcon); + } + + const attributesContainer = doc.createElementNS(XHTML_NS, "div"); + attributesContainer.className = "event-tooltip-attributes-container"; + header.appendChild(attributesContainer); + + if (listener.tags) { + for (const tag of listener.tags.split(",")) { + const attributesBox = doc.createElementNS(XHTML_NS, "div"); + attributesBox.className = "event-tooltip-attributes-box"; + attributesContainer.appendChild(attributesBox); + + const tagBox = doc.createElementNS(XHTML_NS, "span"); + tagBox.className = "event-tooltip-attributes"; + tagBox.textContent = tag; + tagBox.setAttribute("title", tag); + attributesBox.appendChild(tagBox); + } + } + + if (!listener.hide.capturing) { + const attributesBox = doc.createElementNS(XHTML_NS, "div"); + attributesBox.className = "event-tooltip-attributes-box"; + attributesContainer.appendChild(attributesBox); + + const capturing = doc.createElementNS(XHTML_NS, "span"); + capturing.className = "event-tooltip-attributes"; + + const phase = listener.capturing ? L10N_CAPTURING : L10N_BUBBLING; + capturing.textContent = phase; + capturing.setAttribute("title", phase); + attributesBox.appendChild(capturing); + } + + const toggleListenerCheckbox = doc.createElementNS(XHTML_NS, "input"); + toggleListenerCheckbox.type = "checkbox"; + toggleListenerCheckbox.className = + "event-tooltip-listener-toggle-checkbox"; + toggleListenerCheckbox.setAttribute( + "aria-label", + L10N.getFormatStr("eventsTooltip.toggleListenerLabel", listener.type) + ); + if (listener.eventListenerInfoId) { + toggleListenerCheckbox.checked = listener.enabled; + toggleListenerCheckbox.setAttribute( + "data-event-listener-info-id", + listener.eventListenerInfoId + ); + toggleListenerCheckbox.addEventListener( + "change", + this._eventToggleCheckboxChanged, + { signal: this._eventListenersAbortController.signal } + ); + } else { + toggleListenerCheckbox.checked = true; + toggleListenerCheckbox.setAttribute("disabled", true); + } + header.appendChild(toggleListenerCheckbox); + + // Content + const editor = new Editor(config); + this._eventEditors.set(content, { + editor, + handler: listener.handler, + native: listener.native, + appended: false, + location, + }); + + content.className = "event-tooltip-content-box"; + + const li = doc.createElementNS(XHTML_NS, "li"); + li.append(header, content); + this.container.appendChild(li); + this._addContentListeners(header); + } + + this._tooltip.panel.innerHTML = ""; + this._tooltip.panel.appendChild(this.container); + this._tooltip.setContentSize({ width: CONTAINER_WIDTH, height: Infinity }); + } + + _addContentListeners(header) { + header.addEventListener("click", this._headerClicked, { + signal: this._eventListenersAbortController.signal, + }); + } + + _headerClicked(event) { + // Clicking on the checkbox shouldn't impact the header (checkbox state change is + // handled in _eventToggleCheckboxChanged). + if ( + event.target.classList.contains("event-tooltip-listener-toggle-checkbox") + ) { + event.stopPropagation(); + return; + } + + if (event.target.classList.contains("event-tooltip-debugger-icon")) { + this._debugClicked(event); + event.stopPropagation(); + return; + } + + const doc = this._tooltip.doc; + const header = event.currentTarget; + const content = header.nextElementSibling; + const twisty = header.querySelector(".theme-twisty"); + + if (content.hasAttribute("open")) { + header.classList.remove("content-expanded"); + twisty.setAttribute("aria-expanded", false); + content.removeAttribute("open"); + } else { + // Close other open events first + const openHeaders = doc.querySelectorAll( + ".event-header.content-expanded" + ); + const openContent = doc.querySelectorAll( + ".event-tooltip-content-box[open]" + ); + for (const node of openHeaders) { + node.classList.remove("content-expanded"); + const nodeTwisty = node.querySelector(".theme-twisty"); + nodeTwisty.setAttribute("aria-expanded", false); + } + for (const node of openContent) { + node.removeAttribute("open"); + } + + header.classList.add("content-expanded"); + content.setAttribute("open", ""); + twisty.setAttribute("aria-expanded", true); + + const eventEditor = this._eventEditors.get(content); + + if (eventEditor.appended) { + return; + } + + const { editor, handler } = eventEditor; + + const iframe = doc.createElementNS(XHTML_NS, "iframe"); + iframe.classList.add("event-tooltip-editor-frame"); + iframe.setAttribute( + "title", + L10N.getFormatStr( + "eventsTooltip.codeIframeTitle", + header.getAttribute("data-event-type") + ) + ); + + editor.appendTo(content, iframe).then(() => { + const tidied = beautify.js(handler, { indent_size: 2 }); + editor.setText(tidied); + + eventEditor.appended = true; + + const 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.emitForTests("event-tooltip-ready"); + }); + } + } + + _debugClicked(event) { + const header = event.currentTarget; + const content = header.nextElementSibling; + + const { location } = this._eventEditors.get(content); + if (location) { + // Save a copy of toolbox as it will be set to null when we hide the tooltip. + const toolbox = this._toolbox; + + this._tooltip.hide(); + + toolbox.viewSourceInDebugger( + location.url, + location.line, + location.column, + location.id + ); + } + } + + async _eventToggleCheckboxChanged(event) { + const checkbox = event.currentTarget; + const id = checkbox.getAttribute("data-event-listener-info-id"); + if (checkbox.checked) { + await this._nodeFront.enableEventListener(id); + } else { + await this._nodeFront.disableEventListener(id); + } + this.emit("event-tooltip-listener-toggled", { + hasDisabledEventListeners: + // No need to query the other checkboxes if the handled checkbox is unchecked + !checkbox.checked || + this._tooltip.doc.querySelector( + `input.event-tooltip-listener-toggle-checkbox:not(:checked)` + ) !== null, + }); + } + + /** + * Parse URI and return {url, line, column}; or return null if it can't be parsed. + */ + _parseLocation(uri) { + if (uri && uri !== "?") { + uri = uri.replace(/"/g, ""); + + let matches = uri.match(/(.*):(\d+):(\d+$)/); + + if (matches) { + return { + url: matches[1], + line: parseInt(matches[2], 10), + column: parseInt(matches[3], 10), + }; + } else if ((matches = uri.match(/(.*):(\d+$)/))) { + return { + url: matches[1], + line: parseInt(matches[2], 10), + column: null, + }; + } + return { url: uri, line: 1, column: null }; + } + return null; + } + + destroy() { + if (this._tooltip) { + const boxes = this.container.querySelectorAll( + ".event-tooltip-content-box" + ); + + for (const box of boxes) { + const { editor } = this._eventEditors.get(box); + editor.destroy(); + } + + this._eventEditors = null; + this._tooltip.eventTooltip = null; + } + + this.clearEvents(); + if (this._eventListenersAbortController) { + this._eventListenersAbortController.abort(); + this._eventListenersAbortController = null; + } + + for (const unsubscribe of this._subscriptions) { + unsubscribe(); + } + + this._toolbox = this._tooltip = this._nodeFront = null; + } +} + +module.exports.EventTooltip = EventTooltip; diff --git a/devtools/client/shared/widgets/tooltip/HTMLTooltip.js b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js new file mode 100644 index 0000000000..d505e3b78d --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js @@ -0,0 +1,1062 @@ +/* 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("resource://devtools/shared/event-emitter.js"); + +loader.lazyRequireGetter( + this, + "focusableSelector", + "resource://devtools/client/shared/focus.js", + true +); +loader.lazyRequireGetter( + this, + "TooltipToggle", + "resource://devtools/client/shared/widgets/tooltip/TooltipToggle.js", + true +); +loader.lazyRequireGetter( + this, + "listenOnce", + "resource://devtools/shared/async-utils.js", + true +); +loader.lazyRequireGetter( + this, + "DevToolsUtils", + "resource://devtools/shared/DevToolsUtils.js" +); + +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", + DOORHANGER: "doorhanger", +}; + +module.exports.TYPE = TYPE; + +const ARROW_WIDTH = { + normal: 0, + arrow: 32, + // This is the value calculated for the .tooltip-arrow element in tooltip.css + // which includes the arrow width (20px) plus the extra margin added so that + // the drop shadow is not cropped (2px each side). + doorhanger: 24, +}; + +const ARROW_OFFSET = { + normal: 0, + // Default offset between the tooltip's edge and the tooltip arrow. + arrow: 20, + // Match other Firefox menus which use 10px from edge (but subtract the 2px + // margin included in the ARROW_WIDTH above). + doorhanger: 8, +}; + +const EXTRA_HEIGHT = { + normal: 0, + // The arrow is 16px tall, but merges on with the panel border + arrow: 14, + // The doorhanger arrow is 10px tall, but merges on 1px with the panel border + doorhanger: 9, +}; + +/** + * 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". + * @param {Number} offset + * Offset between the top of the anchor and the tooltip. + * @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 = ( + anchorRect, + viewportRect, + height, + pos, + offset +) => { + const { 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. + const availableTop = anchorTop; + const 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. + const availableHeight = pos === TOP ? availableTop : availableBottom; + height = Math.min(height, availableHeight - offset); + + // 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: Math.round(top), + height: Math.round(height), + computedPosition: pos, + }; +}; + +/** + * Calculate the horizontal position & offsets to use for the tooltip. Will + * attempt to respect the provided width and position preferences, unless the + * available width 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 {DOMRect} windowRect + * Bounding rectangle for the window. Used to determine which direction + * doorhangers should hang. + * @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 {Number} borderRadius + * The border radius of the panel. This is added to ARROW_OFFSET to + * calculate the distance from the edge of the tooltip to the start + * of arrow. It is separate from ARROW_OFFSET since it will vary by + * platform. + * @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 = ( + anchorRect, + viewportRect, + windowRect, + width, + type, + offset, + borderRadius, + isRtl, + isMenuTooltip +) => { + // All tooltips from content should follow the writing direction. + // + // For tooltips (including doorhanger tooltips) we follow the writing + // direction but for menus created using doorhangers the guidelines[1] say + // that: + // + // "Doorhangers opening on the right side of the view show the directional + // arrow on the right. + // + // Doorhangers opening on the left side of the view show the directional + // arrow on the left. + // + // Never place the directional arrow at the center of doorhangers." + // + // [1] https://design.firefox.com/photon/components/doorhangers.html#directional-arrow + // + // So for those we need to check if the anchor is more right or left. + let hangDirection; + if (type === TYPE.DOORHANGER && isMenuTooltip) { + const anchorCenter = anchorRect.left + anchorRect.width / 2; + const viewCenter = windowRect.left + windowRect.width / 2; + hangDirection = anchorCenter >= viewCenter ? "left" : "right"; + } else { + hangDirection = isRtl ? "left" : "right"; + } + + const anchorWidth = anchorRect.width; + + // Calculate logical start of anchor relative to the viewport. + const anchorStart = + hangDirection === "right" + ? anchorRect.left - viewportRect.left + : viewportRect.right - anchorRect.right; + + // Calculate tooltip width. + const tooltipWidth = Math.min(width, viewportRect.width); + + // Calculate tooltip start. + let tooltipStart = anchorStart + offset; + tooltipStart = Math.min(tooltipStart, viewportRect.width - tooltipWidth); + tooltipStart = Math.max(0, tooltipStart); + + // Calculate arrow start (tooltip's start might be updated) + const arrowWidth = ARROW_WIDTH[type]; + let arrowStart; + // Arrow and doorhanger style tooltips may need to be shifted + if (type === TYPE.ARROW || type === TYPE.DOORHANGER) { + const arrowOffset = ARROW_OFFSET[type] + borderRadius; + + // Where will the point of the arrow be if we apply the standard offset? + const arrowCenter = tooltipStart + arrowOffset + arrowWidth / 2; + + // How does that compare to the center of the anchor? + const anchorCenter = anchorStart + anchorWidth / 2; + + // If the anchor is too narrow, align the arrow and the anchor center. + if (arrowCenter > anchorCenter) { + tooltipStart = Math.max(0, tooltipStart - (arrowCenter - anchorCenter)); + } + // Arrow's start offset relative to the anchor. + arrowStart = Math.min(arrowOffset, (anchorWidth - arrowWidth) / 2) | 0; + // Translate the coordinate to tooltip container + arrowStart += anchorStart - tooltipStart; + // Make sure the arrow remains in the tooltip container. + arrowStart = Math.min(arrowStart, tooltipWidth - arrowWidth - borderRadius); + arrowStart = Math.max(arrowStart, borderRadius); + } + + // Convert from logical coordinates to physical + const left = + hangDirection === "right" + ? viewportRect.left + tooltipStart + : viewportRect.right - tooltipStart - tooltipWidth; + const arrowLeft = + hangDirection === "right" + ? arrowStart + : tooltipWidth - arrowWidth - arrowStart; + + return { + left: Math.round(left), + width: Math.round(tooltipWidth), + arrowLeft: Math.round(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) { + // getBoxQuads is a non-standard WebAPI which will not work on non-firefox + // browser when running launchpad on Chrome. + if ( + !node.getBoxQuads || + !node.getBoxQuads({ + relativeTo, + createFramesForSuppressedWhitespace: false, + })[0] + ) { + const { top, left, width, height } = node.getBoundingClientRect(); + const right = left + width; + const bottom = top + height; + return { top, right, bottom, left, width, height }; + } + + // Width and Height can be taken from the rect. + const { width, height } = node.getBoundingClientRect(); + + const quadBounds = node + .getBoxQuads({ relativeTo, createFramesForSuppressedWhitespace: false })[0] + .getBounds(); + const top = quadBounds.top; + const left = quadBounds.left; + + // Compute right and bottom coordinates using the rest of the data. + const right = left + width; + const 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} className + * A string separated list of classes to add to the tooltip container + * element. + * - {Boolean} consumeOutsideClicks + * Defaults to true. The tooltip is closed when clicking outside. + * Should this event be stopped and consumed or not. + * - {String} id + * The ID to assign to the tooltip container element. + * - {Boolean} isMenuTooltip + * Defaults to false. If the tooltip is a menu then this should be set + * to true. + * - {String} type + * Display type of the tooltip. Possible values: "normal", "arrow", and + * "doorhanger". + * - {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. + * - {Boolean} noAutoHide + * Defaults to false. If this property is set to false or omitted, the + * tooltip will automatically disappear after a few seconds. If this + * attribute is set to true, this will not happen and the tooltip will + * only hide when the user moves the mouse to another element. + */ +function HTMLTooltip( + toolboxDoc, + { + className = "", + consumeOutsideClicks = true, + id = "", + isMenuTooltip = false, + type = "normal", + useXulWrapper = false, + noAutoHide = false, + } = {} +) { + EventEmitter.decorate(this); + + this.doc = toolboxDoc; + this.id = id; + this.className = className; + this.type = type; + this.noAutoHide = noAutoHide; + // consumeOutsideClicks cannot be used if the tooltip is not closed on click + this.consumeOutsideClicks = this.noAutoHide ? false : consumeOutsideClicks; + this.isMenuTooltip = isMenuTooltip; + this.useXulWrapper = this._isXULPopupAvailable() && useXulWrapper; + this.preferredWidth = "auto"; + this.preferredHeight = "auto"; + + // 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._onMouseup = this._onMouseup.bind(this); + this._onXulPanelHidden = this._onXulPanelHidden.bind(this); + + this.container = this._createContainer(); + 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> <! the actual tooltip-container element --> + this.xulPanelWrapper = this._createXulPanelWrapper(); + this.doc.documentElement.appendChild(this.xulPanelWrapper); + this.xulPanelWrapper.appendChild(this.container); + } else if (this._hasXULRootElement()) { + 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. + */ + 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; + }, + + get toggle() { + if (!this._toggle) { + this._toggle = new TooltipToggle(this); + } + + return this._toggle; + }, + + /** + * Set the preferred width/height of the panel content. + * The panel content is set by appending content to `this.panel`. + * + * @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 the preferred width. + * - {Number} height: preferred height for the tooltip container. If + * not specified the tooltip container will be measured before being + * displayed, and the measured height will be used as the preferred + * height. + * + * For tooltips whose content height may change while being + * displayed, the special value Infinity may be used to produce + * a flexible container that accommodates resizing content. Note, + * however, that when used in combination with the XUL wrapper the + * unfilled part of this container will consume all mouse events + * making content behind this area inaccessible until the tooltip is + * dismissed. + */ + setContentSize({ width = "auto", height = "auto" } = {}) { + this.preferredWidth = width; + this.preferredHeight = height; + }, + + /** + * Show the tooltip next to the provided anchor element, or update the tooltip position + * if it was already visible. 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} options + * Optional settings for positioning the tooltip. + * @param {String} options.position + * Optional, possible values: top|bottom + * If layout permits, the tooltip will be displayed on top/bottom + * of the anchor. If omitted, the tooltip will be displayed where + * more space is available. + * @param {Number} options.x + * Optional, horizontal offset between the anchor and the tooltip. + * @param {Number} options.y + * Optional, vertical offset between the anchor and the tooltip. + */ + async show(anchor, options) { + const { left, top } = this._updateContainerBounds(anchor, options); + const isTooltipVisible = this.isVisible(); + + if (this.useXulWrapper) { + if (!isTooltipVisible) { + await this._showXulWrapperAt(left, top); + } else { + this._moveXulWrapperTo(left, top); + } + } else { + this.container.style.left = left + "px"; + this.container.style.top = top + "px"; + } + + if (isTooltipVisible) { + return; + } + + 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; + + if (this.doc.defaultView) { + if (!this._pendingEventListenerPromise) { + // On Windows and Linux, if the tooltip is shown on mousedown/click (which is the + // case for the MenuButton component for example), attaching the events listeners + // on the window right away would trigger the callbacks; which means the tooltip + // would be instantly hidden. To prevent such thing, the event listeners are set + // on the next tick. + this._pendingEventListenerPromise = new Promise(resolve => { + this.doc.defaultView.setTimeout(() => { + // Update the top window reference each time in case the host changes. + this.topWindow = this._getTopWindow(); + this.topWindow.addEventListener("click", this._onClick, true); + this.topWindow.addEventListener("mouseup", this._onMouseup, true); + resolve(); + }, 0); + }); + } + + await this._pendingEventListenerPromise; + this._pendingEventListenerPromise = null; + } + + // This is redundant with tooltip-visible, and tooltip-visible + // should only be added from here, after the click listener is set. + // Otherwise, code listening to tooltip-visible may be firing a click that would be lost. + // Unfortunately, doing this cause many non trivial test failures. + this.container.classList.add("tooltip-shown"); + + this.emit("shown"); + }, + + startTogglingOnHover(baseNode, targetNodeCb, options) { + this.toggle.start(baseNode, targetNodeCb, options); + }, + + stopTogglingOnHover() { + this.toggle.stop(); + }, + + _updateContainerBounds(anchor, { position, x = 0, y = 0 } = {}) { + // Get anchor geometry + let anchorRect = getRelativeRect(anchor, this.doc); + if (this.useXulWrapper) { + anchorRect = this._convertToScreenRect(anchorRect); + } + + const { viewportRect, windowRect } = this._getBoundingRects(anchorRect); + + // Calculate the horizontal position and width + let preferredWidth; + // Record the height too since it might save us from having to look it up + // later. + let measuredHeight; + const currentScrollTop = this.panel.scrollTop; + if (this.preferredWidth === "auto") { + // Reset any styles that constrain the dimensions we want to calculate. + this.container.style.width = "auto"; + if (this.preferredHeight === "auto") { + this.container.style.height = "auto"; + } + ({ width: preferredWidth, height: measuredHeight } = + this._measureContainerSize()); + } else { + preferredWidth = this.preferredWidth; + } + + const anchorWin = anchor.ownerDocument.defaultView; + const anchorCS = anchorWin.getComputedStyle(anchor); + const isRtl = anchorCS.direction === "rtl"; + + let borderRadius = 0; + if (this.type === TYPE.DOORHANGER) { + borderRadius = parseFloat( + anchorCS.getPropertyValue("--theme-arrowpanel-border-radius") + ); + if (Number.isNaN(borderRadius)) { + borderRadius = 0; + } + } + + const { left, width, arrowLeft } = calculateHorizontalPosition( + anchorRect, + viewportRect, + windowRect, + preferredWidth, + this.type, + x, + borderRadius, + isRtl, + this.isMenuTooltip + ); + + // If we constrained the width, then any measured height we have is no + // longer valid. + if (measuredHeight && width !== preferredWidth) { + measuredHeight = undefined; + } + + // Apply width and arrow positioning + this.container.style.width = width + "px"; + if (this.type === TYPE.ARROW || this.type === TYPE.DOORHANGER) { + this.arrow.style.left = arrowLeft + "px"; + } + + // Work out how much vertical margin we have. + // + // This relies on us having set either .tooltip-top or .tooltip-bottom + // and on the margins for both being symmetrical. Fortunately the call to + // _measureContainerSize above will set .tooltip-top for us and it also + // assumes these styles are symmetrical so this should be ok. + const panelWindow = this.panel.ownerDocument.defaultView; + const panelComputedStyle = panelWindow.getComputedStyle(this.panel); + const verticalMargin = + parseFloat(panelComputedStyle.marginTop) + + parseFloat(panelComputedStyle.marginBottom); + + // Calculate the vertical position and height + let preferredHeight; + if (this.preferredHeight === "auto") { + if (measuredHeight) { + // We already have a valid height measured in a previous step. + preferredHeight = measuredHeight; + } else { + this.container.style.height = "auto"; + ({ height: preferredHeight } = this._measureContainerSize()); + } + preferredHeight += verticalMargin; + } else { + const themeHeight = EXTRA_HEIGHT[this.type] + verticalMargin; + preferredHeight = this.preferredHeight + themeHeight; + } + + const { top, height, computedPosition } = calculateVerticalPosition( + anchorRect, + viewportRect, + preferredHeight, + position, + y + ); + + this._position = computedPosition; + const 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"; + this.panel.scrollTop = currentScrollTop; + + return { left, top }; + }, + + /** + * Calculate the following boundary rectangles: + * + * - Viewport rect: This is the region that limits the tooltip dimensions. + * When using a XUL panel wrapper, the tooltip will be able to use the whole + * screen (excluding space reserved by the OS for toolbars etc.) and hence + * the result will be in screen coordinates. + * Otherwise, the tooltip is limited to the tooltip's document. + * + * - Window rect: This is the bounds of the view in which the tooltip is + * presented. It is reported in the same coordinates as the viewport + * rect and is used for determining in which direction a doorhanger-type + * tooltip should "hang". + * When using the XUL panel wrapper this will be the dimensions of the + * window in screen coordinates. Otherwise it will be the same as the + * viewport rect. + * + * @param {Object} anchorRect + * DOMRect-like object of the target anchor element. + * We need to pass this to detect the case when the anchor is not in + * the current window (because, the center of the window is in + * a different window to the anchor). + * + * @return {Object} An object with the following properties + * viewportRect {Object} DOMRect-like object with the Number + * properties: top, right, bottom, left, width, height + * representing the viewport rect. + * windowRect {Object} DOMRect-like object with the Number + * properties: top, right, bottom, left, width, height + * representing the window rect. + */ + _getBoundingRects(anchorRect) { + let viewportRect; + let windowRect; + + 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 + const { availLeft, availTop, availHeight, availWidth } = + this.doc.defaultView.screen; + viewportRect = { + top: availTop, + right: availLeft + availWidth, + bottom: availTop + availHeight, + left: availLeft, + width: availWidth, + height: availHeight, + }; + + const { screenX, screenY, outerWidth, outerHeight } = + this.doc.defaultView; + windowRect = { + top: screenY, + right: screenX + outerWidth, + bottom: screenY + outerHeight, + left: screenX, + width: outerWidth, + height: outerHeight, + }; + + // If the anchor is outside the viewport, it possibly means we have a + // multi-monitor environment where the anchor is displayed on a different + // monitor to the "current" screen (as determined by the center of the + // window). This can happen when, for example, the screen is spread across + // two monitors. + // + // In this case we simply expand viewport in the direction of the anchor + // so that we can still calculate the popup position correctly. + if (anchorRect.left > viewportRect.right) { + const diffWidth = windowRect.right - viewportRect.right; + viewportRect.right += diffWidth; + viewportRect.width += diffWidth; + } + if (anchorRect.right < viewportRect.left) { + const diffWidth = viewportRect.left - windowRect.left; + viewportRect.left -= diffWidth; + viewportRect.width += diffWidth; + } + } else { + viewportRect = windowRect = + this.doc.documentElement.getBoundingClientRect(); + } + + return { viewportRect, windowRect }; + }, + + _measureContainerSize() { + const 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"); + // Set either of the tooltip-top or tooltip-bottom styles so that we get an + // accurate height. We're assuming that the two styles will be symmetrical + // and that we will clear this as necessary later. + this.container.classList.add("tooltip-top"); + this.container.classList.remove("tooltip-bottom"); + const { width, height } = this.container.getBoundingClientRect(); + this.container.classList.remove("tooltip-hidden"); + + if (this.useXulWrapper && !this.isVisible()) { + xulParent.appendChild(this.container); + } + + return { width, height }; + }, + + /** + * Hide the current tooltip. The event "hidden" will be fired when the tooltip + * is hidden. + */ + async hide({ fromMouseup = false } = {}) { + // Exit if the disable autohide setting is in effect or if hide() is called + // from a mouseup event and the tooltip has noAutoHide set to true. + if ( + Services.prefs.getBoolPref("devtools.popup.disable_autohide", false) || + (this.noAutoHide && this.isVisible() && fromMouseup) + ) { + return; + } + + if (!this.isVisible()) { + this.emit("hidden"); + return; + } + + // If the tooltip is hidden from a mouseup event, wait for a potential click event + // to be consumed before removing event listeners. + if (fromMouseup) { + await new Promise(resolve => this.topWindow.setTimeout(resolve, 0)); + } + + if (this._pendingEventListenerPromise) { + this._pendingEventListenerPromise.then(() => this.removeEventListeners()); + } else { + this.removeEventListeners(); + } + + this.container.classList.remove("tooltip-visible", "tooltip-shown"); + if (this.useXulWrapper) { + await this._hideXulWrapper(); + } + + this.emit("hidden"); + + const tooltipHasFocus = this.container.contains(this.doc.activeElement); + if (tooltipHasFocus && this._focusedElement) { + this._focusedElement.focus(); + this._focusedElement = null; + } + }, + + removeEventListeners() { + this.topWindow.removeEventListener("click", this._onClick, true); + this.topWindow.removeEventListener("mouseup", this._onMouseup, true); + }, + + /** + * Check if the tooltip is currently displayed. + * @return {Boolean} true if the tooltip is visible + */ + isVisible() { + return this.container.classList.contains("tooltip-visible"); + }, + + /** + * Destroy the tooltip instance. Hide the tooltip if displayed, remove the + * tooltip container from the document. + */ + destroy() { + this.hide(); + this.removeEventListeners(); + this.container.remove(); + if (this.xulPanelWrapper) { + this.xulPanelWrapper.remove(); + } + if (this._toggle) { + this._toggle.destroy(); + this._toggle = null; + } + }, + + _createContainer() { + const container = this.doc.createElementNS(XHTML_NS, "div"); + container.setAttribute("type", this.type); + + if (this.id) { + container.setAttribute("id", this.id); + } + + container.classList.add("tooltip-container"); + if (this.className) { + container.classList.add(...this.className.split(" ")); + } + + const filler = this.doc.createElementNS(XHTML_NS, "div"); + filler.classList.add("tooltip-filler"); + container.appendChild(filler); + + const panel = this.doc.createElementNS(XHTML_NS, "div"); + panel.classList.add("tooltip-panel"); + container.appendChild(panel); + + if (this.type === TYPE.ARROW || this.type === TYPE.DOORHANGER) { + const arrow = this.doc.createElementNS(XHTML_NS, "div"); + arrow.classList.add("tooltip-arrow"); + container.appendChild(arrow); + } + return container; + }, + + _onClick(e) { + if (this._isInTooltipContainer(e.target)) { + return; + } + + if (this.consumeOutsideClicks && e.button === 0) { + // Consume only left click events (button === 0). + e.preventDefault(); + e.stopPropagation(); + } + }, + + /** + * Hide the tooltip on mouseup rather than on click because the surrounding markup + * may change on mousedown in a way that prevents a "click" event from being fired. + * If the element that received the mousedown and the mouseup are different, click + * will not be fired. + */ + _onMouseup(e) { + if (this._isInTooltipContainer(e.target)) { + return; + } + + this.hide({ fromMouseup: true }); + }, + + _isInTooltipContainer(node) { + // Check if the target is the tooltip arrow. + if (this.arrow && this.arrow === node) { + return true; + } + + if (typeof node.closest == "function" && node.closest("menupopup")) { + // Ignore events from menupopup elements which will not be children of the + // tooltip container even if their owner element is in the tooltip. + // See Bug 1811002. + return true; + } + + const 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() { + if (this.isVisible()) { + this.hide(); + } + }, + + /** + * Focus on the first focusable item in the tooltip. + * + * Returns true if we found something to focus on, false otherwise. + */ + focus() { + const focusableElement = this.panel.querySelector(focusableSelector); + if (focusableElement) { + focusableElement.focus(); + } + return !!focusableElement; + }, + + /** + * Focus on the last focusable item in the tooltip. + * + * Returns true if we found something to focus on, false otherwise. + */ + focusEnd() { + const focusableElements = this.panel.querySelectorAll(focusableSelector); + if (focusableElements.length) { + focusableElements[focusableElements.length - 1].focus(); + } + return focusableElements.length !== 0; + }, + + _getTopWindow() { + return DevToolsUtils.getTopWindow(this.doc.defaultView); + }, + + /** + * Check if the tooltip's owner document has XUL root element. + */ + _hasXULRootElement() { + return this.doc.documentElement.namespaceURI === XUL_NS; + }, + + _isXULPopupAvailable() { + return this.doc.nodePrincipal.isSystemPrincipal; + }, + + _createXulPanelWrapper() { + const panel = this.doc.createXULElement("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("incontentshell", false); + panel.setAttribute("noautofocus", true); + panel.setAttribute("noautohide", this.noAutoHide); + + panel.setAttribute("ignorekeys", true); + panel.setAttribute("tooltip", "aHTMLTooltip"); + + // Use type="arrow" to prevent side effects (see Bug 1285206) + panel.setAttribute("type", "arrow"); + panel.setAttribute("tooltip-type", this.type); + + panel.setAttribute("flip", "none"); + + panel.setAttribute("level", "top"); + panel.setAttribute("class", "tooltip-xul-wrapper"); + + // Stop this appearing as an alert to accessibility. + panel.setAttribute("role", "presentation"); + + return panel; + }, + + _showXulWrapperAt(left, top) { + this.xulPanelWrapper.addEventListener( + "popuphidden", + this._onXulPanelHidden + ); + const onPanelShown = listenOnce(this.xulPanelWrapper, "popupshown"); + this.xulPanelWrapper.openPopupAtScreen(left, top, false); + return onPanelShown; + }, + + _moveXulWrapperTo(left, top) { + // FIXME: moveTo should probably account for margins when called from + // script. Our current shadow set-up only supports one margin, so it's fine + // to use the margin top in both directions. + const margin = parseFloat( + this.xulPanelWrapper.ownerGlobal.getComputedStyle(this.xulPanelWrapper) + .marginTop + ); + this.xulPanelWrapper.moveTo(left + margin, top + margin); + }, + + _hideXulWrapper() { + this.xulPanelWrapper.removeEventListener( + "popuphidden", + this._onXulPanelHidden + ); + + if (this.xulPanelWrapper.state === "closed") { + // XUL panel is already closed, resolve immediately. + return Promise.resolve(); + } + + const 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({ 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, + }; + }, +}; diff --git a/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js b/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js new file mode 100644 index 0000000000..c73bd4b6b6 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js @@ -0,0 +1,145 @@ +/* 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("resource://devtools/shared/l10n.js"); +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; +// Should remain synchronized with tooltips.css --image-tooltip-image-padding +const IMAGE_PADDING = 4; +// Should remain synchronized with tooltips.css --image-tooltip-label-height +const LABEL_HEIGHT = 20; + +/** + * 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 => { + const imgObj = new doc.defaultView.Image(); + imgObj.onload = () => { + imgObj.onload = null; + const { 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 + * - {Boolean} hideCheckeredBackground optional, pass true to hide + the checkered background + */ +function setImageTooltip(tooltip, doc, imageUrl, options) { + let { + naturalWidth, + naturalHeight, + hideDimensionLabel, + hideCheckeredBackground, + maxDim, + } = options; + maxDim = maxDim || MAX_DIMENSION; + + let imgHeight = naturalHeight; + let imgWidth = naturalWidth; + if (imgHeight > maxDim || imgWidth > maxDim) { + const 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 + const container = doc.createElementNS(XHTML_NS, "div"); + container.classList.add("devtools-tooltip-image-container"); + + const wrapper = doc.createElementNS(XHTML_NS, "div"); + wrapper.classList.add("devtools-tooltip-image-wrapper"); + container.appendChild(wrapper); + + const img = doc.createElementNS(XHTML_NS, "img"); + img.classList.add("devtools-tooltip-image"); + img.classList.toggle("devtools-tooltip-tiles", !hideCheckeredBackground); + img.style.height = imgHeight; + img.src = encodeURI(imageUrl); + wrapper.appendChild(img); + + if (!hideDimensionLabel) { + const dimensions = doc.createElementNS(XHTML_NS, "div"); + dimensions.classList.add("devtools-tooltip-image-dimensions"); + container.appendChild(dimensions); + + const label = naturalWidth + " \u00D7 " + naturalHeight; + const span = doc.createElementNS(XHTML_NS, "span"); + span.classList.add("devtools-tooltip-caption"); + span.textContent = label; + dimensions.appendChild(span); + } + + tooltip.panel.innerHTML = ""; + tooltip.panel.appendChild(container); + + // Calculate tooltip dimensions + const width = Math.max(CONTAINER_MIN_WIDTH, imgWidth + 2 * IMAGE_PADDING); + let height = imgHeight + 2 * IMAGE_PADDING; + if (!hideDimensionLabel) { + height += parseFloat(LABEL_HEIGHT); + } + + tooltip.setContentSize({ 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) { + const div = doc.createElementNS(XHTML_NS, "div"); + div.className = "devtools-tooltip-image-broken"; + const message = L10N.getStr("previewTooltip.image.brokenImage"); + div.textContent = message; + + tooltip.panel.innerHTML = ""; + tooltip.panel.appendChild(div); + tooltip.setContentSize({ width: "auto", height: "auto" }); +} + +module.exports.getImageDimensions = getImageDimensions; +module.exports.setImageTooltip = setImageTooltip; +module.exports.setBrokenImageTooltip = setBrokenImageTooltip; diff --git a/devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js b/devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js new file mode 100644 index 0000000000..87e089d604 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/RulePreviewTooltip.js @@ -0,0 +1,69 @@ +/* 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("resource://devtools/shared/l10n.js"); +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); + +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const L10N = new LocalizationHelper( + "devtools/client/locales/inspector.properties" +); + +/** + * Tooltip displayed for when a CSS property is selected/highlighted. + * TODO: For now, the tooltip content only shows "No Associated Rule". In Bug 1528288, + * we will be implementing content for showing the source CSS rule. + */ +class RulePreviewTooltip { + constructor(doc) { + this.show = this.show.bind(this); + this.destroy = this.destroy.bind(this); + + // Initialize tooltip structure. + this._tooltip = new HTMLTooltip(doc, { + type: "arrow", + consumeOutsideClicks: true, + useXulWrapper: true, + }); + + this.container = doc.createElementNS(XHTML_NS, "div"); + this.container.className = "rule-preview-tooltip-container"; + + this.message = doc.createElementNS(XHTML_NS, "span"); + this.message.className = "rule-preview-tooltip-message"; + this.message.textContent = L10N.getStr( + "rulePreviewTooltip.noAssociatedRule" + ); + this.container.appendChild(this.message); + + // TODO: Implement structure for showing the source CSS rule. + + this._tooltip.panel.innerHTML = ""; + this._tooltip.panel.appendChild(this.container); + this._tooltip.setContentSize({ width: "auto", height: "auto" }); + } + + /** + * Shows the tooltip on a given element. + * + * @param {Element} element + * The target element to show the tooltip with. + */ + show(element) { + element.addEventListener("mouseout", () => this._tooltip.hide()); + this._tooltip.show(element); + } + + destroy() { + this._tooltip.destroy(); + this.container = null; + this.message = null; + } +} + +module.exports = RulePreviewTooltip; diff --git a/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js new file mode 100644 index 0000000000..acc71125e8 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js @@ -0,0 +1,270 @@ +/* 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("resource://devtools/shared/event-emitter.js"); +const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js"); +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); + +loader.lazyRequireGetter( + this, + "KeyCodes", + "resource://devtools/client/shared/keycodes.js", + true +); + +/** + * Base class for all (color, gradient, ...)-swatch based value editors inside + * tooltips + * + * @param {Document} document + * The document to attach the SwatchBasedEditorTooltip. This should be the + * toolbox document + */ + +class SwatchBasedEditorTooltip { + constructor(document) { + EventEmitter.decorate(this); + + // 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, + }); + + // By default, swatch-based editor tooltips revert value change on <esc> and + // commit value change on <enter> + this.shortcuts = new KeyShortcuts({ + window: this.tooltip.doc.defaultView, + }); + this.shortcuts.on("Escape", event => { + if (!this.tooltip.isVisible()) { + return; + } + this.revert(); + this.hide(); + event.stopPropagation(); + event.preventDefault(); + }); + this.shortcuts.on("Return", 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); + this._onSwatchKeyDown = this._onSwatchKeyDown.bind(this); + } + + /** + * Reports if the tooltip is currently shown + * + * @return {Boolean} True if the tooltip is displayed. + */ + isVisible() { + return this.tooltip.isVisible(); + } + + /** + * Reports if the tooltip is currently editing the targeted value + * + * @return {Boolean} True if the tooltip is editing. + */ + isEditing() { + return this.isVisible(); + } + + /** + * 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() { + if (this.tooltipAnchor) { + const onShown = this.tooltip.once("shown"); + + this.tooltip.show(this.tooltipAnchor); + this.tooltip.once("hidden", () => this.onTooltipHidden()); + + return onShown; + } + + return Promise.resolve(); + } + + /** + * Can be overridden by subclasses if implementation specific behavior is needed on + * tooltip hidden. + */ + onTooltipHidden() { + // When the tooltip is closed by clicking outside the panel we want to commit any + // changes. + if (!this._reverted) { + this.commit(); + } + this._reverted = false; + + // Once the tooltip is hidden we need to clean up any remaining objects. + this.activeSwatch = null; + } + + hide() { + if (this.swatchActivatedWithKeyboard) { + this.activeSwatch.focus(); + this.swatchActivatedWithKeyboard = null; + } + + 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(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, + }); + swatchEl.addEventListener("click", this._onSwatchClick); + swatchEl.addEventListener("keydown", this._onSwatchKeyDown); + } + + removeSwatch(swatchEl) { + if (this.swatches.has(swatchEl)) { + if (this.activeSwatch === swatchEl) { + this.hide(); + this.activeSwatch = null; + } + swatchEl.removeEventListener("click", this._onSwatchClick); + swatchEl.removeEventListener("keydown", this._onSwatchKeyDown); + this.swatches.delete(swatchEl); + } + } + + _onSwatchKeyDown(event) { + if ( + event.keyCode === KeyCodes.DOM_VK_RETURN || + event.keyCode === KeyCodes.DOM_VK_SPACE + ) { + event.preventDefault(); + event.stopPropagation(); + this._onSwatchClick(event); + } + } + + _onSwatchClick(event) { + const { shiftKey, clientX, clientY, target } = event; + + // If mouse coordinates are 0, the event listener could have been triggered + // by a keybaord + this.swatchActivatedWithKeyboard = + event.key && clientX === 0 && clientY === 0; + + if (shiftKey) { + event.stopPropagation(); + return; + } + + const swatch = this.swatches.get(target); + + if (swatch) { + this.activeSwatch = target; + this.show(); + swatch.callbacks.onShow(); + event.stopPropagation(); + } + } + + /** + * Not called by this parent class, needs to be taken care of by sub-classes + */ + preview(value) { + if (this.activeSwatch) { + const swatch = this.swatches.get(this.activeSwatch); + swatch.callbacks.onPreview(value); + } + } + + /** + * This parent class only calls this on <esc> keydown + */ + revert() { + if (this.activeSwatch) { + this._reverted = true; + const swatch = this.swatches.get(this.activeSwatch); + this.tooltip.once("hidden", () => { + swatch.callbacks.onRevert(); + }); + } + } + + /** + * This parent class only calls this on <enter> keydown + */ + commit() { + if (this.activeSwatch) { + const swatch = this.swatches.get(this.activeSwatch); + swatch.callbacks.onCommit(); + } + } + + get tooltipAnchor() { + return this.activeSwatch; + } + + destroy() { + this.swatches.clear(); + this.activeSwatch = null; + this.tooltip.off("keydown", this._onTooltipKeydown); + 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 0000000000..6cfceccc0b --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js @@ -0,0 +1,363 @@ +/* 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 { colorUtils } = require("resource://devtools/shared/css/color.js"); +const Spectrum = require("resource://devtools/client/shared/widgets/Spectrum.js"); +const SwatchBasedEditorTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js"); +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/inspector.properties" +); +const { openDocLink } = require("resource://devtools/client/shared/link.js"); +const { + A11Y_CONTRAST_LEARN_MORE_LINK, +} = require("resource://devtools/client/accessibility/constants.js"); +loader.lazyRequireGetter( + this, + "throttle", + "resource://devtools/shared/throttle.js", + true +); + +loader.lazyRequireGetter( + this, + ["getFocusableElements", "wrapMoveFocus"], + "resource://devtools/client/shared/focus.js", + true +); +loader.lazyRequireGetter( + this, + "PICKER_TYPES", + "resource://devtools/shared/picker-constants.js" +); + +const TELEMETRY_PICKER_EYEDROPPER_OPEN_COUNT = + "DEVTOOLS_PICKER_EYEDROPPER_OPENED_COUNT"; +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. + * + * The activeSwatch element expected by the tooltip must follow some guidelines + * to be compatible with this feature: + * - the background-color of the activeSwatch should be set to the current + * color, it will be updated when the color is changed via the color-picker. + * - the `data-color` attribute should be set either on the activeSwatch or on + * a parent node, and should also contain the current color. + * - finally if the color value should be displayed next to the swatch as text, + * the activeSwatch should have a nextSibling. Note that this sibling may + * contain more than just text initially, but it will be updated after a color + * change and will only contain the text. + * + * An example of valid markup (with data-color on a parent and a nextSibling): + * + * <span data-color="#FFF"> <!-- activeSwatch.closest("[data-color]") --> + * <span + * style="background-color: rgb(255, 255, 255);" + * ></span> <!-- activeSwatch --> + * <span>#FFF</span> <!-- activeSwatch.nextSibling --> + * </span> + * + * Another example with everything on the activeSwatch itself: + * + * <span> <!-- container, to illustrate that the swatch has no sibling here. --> + * <span + * data-color="#FFF" + * style="background-color: rgb(255, 255, 255);" + * ></span> <!-- activeSwatch & activeSwatch.closest("[data-color]") --> + * </span> + * + * @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. + */ + +class SwatchColorPickerTooltip extends SwatchBasedEditorTooltip { + constructor(document, inspector) { + super(document); + 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); + this._openDocLink = this._openDocLink.bind(this); + this._onTooltipKeydown = this._onTooltipKeydown.bind(this); + + // Selecting color by hovering on the spectrum widget could create a lot + // of requests. Throttle by 50ms to avoid this. See Bug 1665547. + this._selectColor = throttle(this._selectColor.bind(this), 50); + + this.tooltip.container.addEventListener("keydown", this._onTooltipKeydown); + } + + /** + * 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(color) { + const { doc } = this.tooltip; + this.tooltip.panel.innerHTML = ""; + + const container = doc.createElementNS(XHTML_NS, "div"); + container.id = "spectrum-tooltip"; + + const node = doc.createElementNS(XHTML_NS, "div"); + node.id = "spectrum"; + container.appendChild(node); + + const widget = new Spectrum(node, color); + this.tooltip.panel.appendChild(container); + this.tooltip.setContentSize({ width: 215 }); + + widget.inspector = this.inspector; + + // Wait for the tooltip to be shown before calling widget.show + // as it expect to be visible in order to compute DOM element sizes. + this.tooltip.once("shown", () => { + widget.show(); + }); + + return widget; + } + + /** + * Overriding the SwatchBasedEditorTooltip.show function to set spectrum's + * color. + */ + async show() { + // set contrast enabled for the spectrum + const name = this.activeSwatch.dataset.propertyName; + const colorFunction = this.activeSwatch.dataset.colorFunction; + + // Only enable contrast if the type of property is color + // and its value isn't inside a color-modifying function (e.g. color-mix()). + this.spectrum.contrastEnabled = + name === "color" && colorFunction !== "color-mix"; + if (this.spectrum.contrastEnabled) { + const { nodeFront } = this.inspector.selection; + const { pageStyle } = nodeFront.inspectorFront; + this.spectrum.textProps = await pageStyle.getComputed(nodeFront, { + filterProperties: ["font-size", "font-weight", "opacity"], + }); + this.spectrum.backgroundColorData = await nodeFront.getBackgroundColor(); + } + + // Then set spectrum's color and listen to color changes to preview them + if (this.activeSwatch) { + this._originalColor = this._getSwatchColorContainer().dataset.color; + const 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(); + } + + // Call then parent class' show function + await super.show(); + + const eyeButton = + this.tooltip.container.querySelector("#eyedropper-button"); + const canShowEyeDropper = await this.inspector.supportsEyeDropper(); + if (canShowEyeDropper) { + eyeButton.disabled = false; + eyeButton.removeAttribute("title"); + eyeButton.addEventListener("click", this._openEyeDropper); + } else { + eyeButton.disabled = true; + eyeButton.title = L10N.getStr("eyedropper.disabled.title"); + } + + const learnMoreButton = + this.tooltip.container.querySelector("#learn-more-button"); + if (learnMoreButton) { + learnMoreButton.addEventListener("click", this._openDocLink); + learnMoreButton.addEventListener("keydown", e => e.stopPropagation()); + } + + // Add focus to the first focusable element in the tooltip and attach keydown + // event listener to tooltip + this.focusableElements[0].focus(); + this.tooltip.container.addEventListener( + "keydown", + this._onTooltipKeydown, + true + ); + + this.emit("ready"); + } + + _onTooltipKeydown(event) { + const { target, key, shiftKey } = event; + + if (key !== "Tab") { + return; + } + + const focusMoved = !!wrapMoveFocus( + this.focusableElements, + target, + shiftKey + ); + if (focusMoved) { + // Focus was moved to the begining/end of the tooltip, so we need to prevent the + // default focus change that would happen here. + event.preventDefault(); + } + + event.stopPropagation(); + } + + _getSwatchColorContainer() { + // Depending on the UI, the data-color attribute might be set on the + // swatch itself, or a parent node. + // This data attribute is also used for the "Copy color" feature, so it + // can be useful to set it on a container rather than on the swatch. + return this.activeSwatch.closest("[data-color]"); + } + + _onSpectrumColorChange(rgba, cssColor) { + this._selectColor(cssColor); + } + + _selectColor(color) { + if (this.activeSwatch) { + this.activeSwatch.style.backgroundColor = color; + + color = this._toDefaultType(color); + + this._getSwatchColorContainer().dataset.color = color; + if (this.activeSwatch.nextSibling) { + this.activeSwatch.nextSibling.textContent = color; + } + this.preview(color); + + if (this.eyedropperOpen) { + this.commit(); + } + } + } + + /** + * Override the implementation from SwatchBasedEditorTooltip. + */ + onTooltipHidden() { + // If the tooltip is hidden while the eyedropper is being used, we should not commit + // the changes. + if (this.eyedropperOpen) { + return; + } + + super.onTooltipHidden(); + this.tooltip.container.removeEventListener( + "keydown", + this._onTooltipKeydown + ); + } + + _openEyeDropper() { + const { inspectorFront, toolbox, telemetry } = this.inspector; + + telemetry + .getHistogramById(TELEMETRY_PICKER_EYEDROPPER_OPEN_COUNT) + .add(true); + + // cancelling picker(if it is already selected) on opening eye-dropper + toolbox.nodePicker.stop({ canceled: true }); + + // disable simulating touch events if RDM is active + toolbox.tellRDMAboutPickerState(true, PICKER_TYPES.EYEDROPPER); + + // pickColorFromPage will focus the content document. If the devtools are in a + // separate window, the colorpicker tooltip will be closed before pickColorFromPage + // resolves. Flip the flag early to avoid issues with onTooltipHidden(). + this.eyedropperOpen = true; + + inspectorFront.pickColorFromPage({ copyOnSelect: false }).then(() => { + // close the colorpicker tooltip so that only the eyedropper is open. + this.hide(); + + this.tooltip.emit("eyedropper-opened"); + }, console.error); + + inspectorFront.once("color-picked", color => { + toolbox.win.focus(); + this._selectColor(color); + this._onEyeDropperDone(); + }); + + inspectorFront.once("color-pick-canceled", () => { + this._onEyeDropperDone(); + }); + } + + _openDocLink() { + openDocLink(A11Y_CONTRAST_LEARN_MORE_LINK); + this.hide(); + } + + _onEyeDropperDone() { + // enable simulating touch events if RDM is active + this.inspector.toolbox.tellRDMAboutPickerState( + false, + PICKER_TYPES.EYEDROPPER + ); + + this.eyedropperOpen = false; + this.activeSwatch = null; + } + + _colorToRgba(color) { + color = new colorUtils.CssColor(color); + const rgba = color.getRGBATuple(); + return [rgba.r, rgba.g, rgba.b, rgba.a]; + } + + _toDefaultType(color) { + let unit = this.inspector.defaultColorUnit; + let forceUppercase = false; + if (unit === colorUtils.CssColor.COLORUNIT.authored) { + unit = colorUtils.classifyColor(this._originalColor); + forceUppercase = colorUtils.colorIsUppercase(this._originalColor); + } + + const colorObj = new colorUtils.CssColor(color); + return colorObj.toString(unit, forceUppercase); + } + + /** + * Overriding the SwatchBasedEditorTooltip.isEditing function to consider the + * eyedropper. + */ + isEditing() { + return this.tooltip.isVisible() || this.eyedropperOpen; + } + + get focusableElements() { + return getFocusableElements(this.tooltip.container).filter( + el => !!el.offsetParent + ); + } + + destroy() { + super.destroy(); + this.inspector = 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 0000000000..bfec4bccea --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js @@ -0,0 +1,95 @@ +/* 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 { + CubicBezierWidget, +} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js"); +const SwatchBasedEditorTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js"); + +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. + */ + +class SwatchCubicBezierTooltip extends SwatchBasedEditorTooltip { + constructor(document) { + super(document); + + // 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); + } + + /** + * 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 + */ + + async setCubicBezierContent(bezier) { + const { doc } = this.tooltip; + this.tooltip.panel.innerHTML = ""; + + const container = doc.createElementNS(XHTML_NS, "div"); + container.className = "cubic-bezier-container"; + + this.tooltip.panel.appendChild(container); + this.tooltip.setContentSize({ width: 510, height: 370 }); + + await this.tooltip.once("shown"); + return new CubicBezierWidget(container, bezier); + } + + /** + * Overriding the SwatchBasedEditorTooltip.show function to set the cubic + * bezier curve in the widget + */ + async show() { + // Call the parent class' show function + await super.show(); + // 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(bezier) { + if (!this.activeSwatch) { + return; + } + + this.currentBezierValue.textContent = bezier + ""; + this.preview(bezier + ""); + } + + destroy() { + super.destroy(); + 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 0000000000..cc28176a13 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js @@ -0,0 +1,117 @@ +/* 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 { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); +const SwatchBasedEditorTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip.js"); + +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. + */ + +class SwatchFilterTooltip extends SwatchBasedEditorTooltip { + constructor(document) { + super(document); + + // Creating a filter editor instance. + this.widget = this.setFilterContent("none"); + this._onUpdate = this._onUpdate.bind(this); + } + + /** + * 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(filter) { + const { doc } = this.tooltip; + this.tooltip.panel.innerHTML = ""; + + const container = doc.createElementNS(XHTML_NS, "div"); + container.id = "filter-container"; + + this.tooltip.panel.appendChild(container); + this.tooltip.setContentSize({ width: 510, height: 200 }); + + return new CSSFilterEditorWidget(container, filter); + } + + async show() { + // Call the parent class' show function + await super.show(); + // 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(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(); + } + const node = this._parser.parseCssProperty( + "filter", + filters, + this._options + ); + this.currentFilterValue.appendChild(node); + + this.preview(); + } + + destroy() { + super.destroy(); + 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(swatchEl, callbacks, parser, options) { + super.addSwatch(swatchEl, callbacks); + this._parser = parser; + this._options = options; + } +} + +module.exports = SwatchFilterTooltip; diff --git a/devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip.js b/devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip.js new file mode 100644 index 0000000000..371bbd79fc --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip.js @@ -0,0 +1,97 @@ +/* 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 { + LinearEasingFunctionWidget, +} = require("devtools/client/shared/widgets/LinearEasingFunctionWidget"); +const SwatchBasedEditorTooltip = require("devtools/client/shared/widgets/tooltip/SwatchBasedEditorTooltip"); + +const XHTML_NS = "http://www.w3.org/1999/xhtml"; + +/** + * The swatch linear-easing-function tooltip class is a specific class meant to be used + * along with rule-view's generated linear-easing-function swatches. + * It extends the parent SwatchBasedEditorTooltip class. + * It just wraps a standard Tooltip and sets its content with an instance of a + * LinearEasingFunctionWidget. + * + * @param {Document} document + * The document to attach the SwatchLinearEasingFunctionTooltip. This is either the toolbox + * document if the tooltip is a popup tooltip or the panel's document if it is an + * inline editor. + */ + +class SwatchLinearEasingFunctionTooltip extends SwatchBasedEditorTooltip { + constructor(document) { + super(document); + + this.onWidgetUpdated = this.onWidgetUpdated.bind(this); + + // Creating a linear-easing-function instance. + // this.widget will always be a promise that resolves to the widget instance + this.widget = this.createWidget(); + } + + /** + * Fill the tooltip with a new instance of the linear-easing-function widget + * initialized with the given value, and return a promise that resolves to + * the instance of the widget + */ + + async createWidget() { + const { doc } = this.tooltip; + this.tooltip.panel.innerHTML = ""; + + const container = doc.createElementNS(XHTML_NS, "div"); + container.className = "linear-easing-function-container"; + + this.tooltip.panel.appendChild(container); + this.tooltip.setContentSize({ width: 400, height: 400 }); + + await this.tooltip.once("shown"); + return new LinearEasingFunctionWidget(container); + } + + /** + * Overriding the SwatchBasedEditorTooltip.show function to set the linear function line + * in the widget + */ + async show() { + // Call the parent class' show function + await super.show(); + // Then set the line and listen to changes to preview them + if (this.activeSwatch) { + this.ruleViewCurrentLinearValueElement = this.activeSwatch.nextSibling; + this.widget.then(widget => { + widget.off("updated", this.onWidgetUpdated); + widget.setCssLinearValue(this.activeSwatch.getAttribute("data-linear")); + widget.on("updated", this.onWidgetUpdated); + this.emit("ready"); + }); + } + } + + onWidgetUpdated(newValue) { + if (!this.activeSwatch) { + return; + } + + this.ruleViewCurrentLinearValueElement.textContent = newValue; + this.activeSwatch.setAttribute("data-linear", newValue); + this.preview(newValue); + } + + destroy() { + super.destroy(); + this.currentFunctionText = null; + this.widget.then(widget => { + widget.off("updated", this.onWidgetUpdated); + widget.destroy(); + }); + } +} + +module.exports = SwatchLinearEasingFunctionTooltip; diff --git a/devtools/client/shared/widgets/tooltip/TooltipToggle.js b/devtools/client/shared/widgets/tooltip/TooltipToggle.js new file mode 100644 index 0000000000..9458c9382d --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/TooltipToggle.js @@ -0,0 +1,197 @@ +/* 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 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( + 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); + + const target = this.tooltip.xulPanelWrapper || this.tooltip.container; + if (this._interactive) { + target.addEventListener("mouseover", this._onTooltipMouseOver); + target.addEventListener("mouseout", this._onTooltipMouseOut); + } else { + target.classList.add("non-interactive-toggle"); + } + }, + + /** + * 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() { + this.win.clearTimeout(this.toggleTimer); + + if (!this._baseNode) { + return; + } + + this._baseNode.removeEventListener("mousemove", this._onMouseMove); + this._baseNode.removeEventListener("mouseout", this._onMouseOut); + + const target = this.tooltip.xulPanelWrapper || this.tooltip.container; + if (this._interactive) { + target.removeEventListener("mouseover", this._onTooltipMouseOver); + target.removeEventListener("mouseout", this._onTooltipMouseOut); + } else { + target.classList.remove("non-interactive-toggle"); + } + + this._baseNode = null; + this._targetNodeCb = null; + this._lastHovered = null; + }, + + _onMouseMove(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 || !this._baseNode) { + // bail out if no target or if the toggle has been destroyed. + 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. + */ + async isValidHoverTarget(target) { + const res = await this._targetNodeCb(target, this.tooltip); + if (res) { + return res.nodeName ? res : target; + } + + return null; + }, + + _onMouseOut(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() { + this.stop(); + }, +}; diff --git a/devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js b/devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js new file mode 100644 index 0000000000..bd458fbbf1 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js @@ -0,0 +1,31 @@ +/* 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 XHTML_NS = "http://www.w3.org/1999/xhtml"; + +/** + * Set the tooltip content of a provided HTMLTooltip instance to display a + * variable preview matching the provided text. + * + * @param {HTMLTooltip} tooltip + * The tooltip instance on which the text preview content should be set. + * @param {Document} doc + * A document element to create the HTML elements needed for the tooltip. + * @param {String} text + * Text to display in tooltip. + */ +function setVariableTooltip(tooltip, doc, text) { + // Create tooltip content + const div = doc.createElementNS(XHTML_NS, "div"); + div.classList.add("devtools-monospace", "devtools-tooltip-css-variable"); + div.textContent = text; + + tooltip.panel.innerHTML = ""; + tooltip.panel.appendChild(div); + tooltip.setContentSize({ width: "auto", height: "auto" }); +} + +module.exports.setVariableTooltip = setVariableTooltip; diff --git a/devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js b/devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js new file mode 100644 index 0000000000..40755a212b --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js @@ -0,0 +1,292 @@ +/* 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 { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" +); + +loader.lazyRequireGetter( + this, + "openDocLink", + "resource://devtools/client/shared/link.js", + true +); + +class CssCompatibilityTooltipHelper { + constructor() { + this.addTab = this.addTab.bind(this); + } + + #currentTooltip = null; + #currentUrl = null; + + #createElement(doc, tag, classList = [], attributeList = {}) { + const XHTML_NS = "http://www.w3.org/1999/xhtml"; + const newElement = doc.createElementNS(XHTML_NS, tag); + for (const elementClass of classList) { + newElement.classList.add(elementClass); + } + + for (const key in attributeList) { + newElement.setAttribute(key, attributeList[key]); + } + + return newElement; + } + + /* + * Attach the UnsupportedBrowserList component to the + * ".compatibility-browser-list-wrapper" div to render the + * unsupported browser list + */ + #renderUnsupportedBrowserList(container, unsupportedBrowsers) { + // Mount the ReactDOM only if the unsupported browser + // list is not empty. Else "compatibility-browser-list-wrapper" + // is not defined. For example, for property clip, + // unsupportedBrowsers is an empty array + if (!unsupportedBrowsers.length) { + return; + } + + const { require } = BrowserLoader({ + baseURI: "resource://devtools/client/shared/widgets/tooltip/", + window: this.#currentTooltip.doc.defaultView, + }); + const { + createFactory, + createElement, + } = require("resource://devtools/client/shared/vendor/react.js"); + const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js"); + const UnsupportedBrowserList = createFactory( + require("resource://devtools/client/inspector/compatibility/components/UnsupportedBrowserList.js") + ); + + const unsupportedBrowserList = createElement(UnsupportedBrowserList, { + browsers: unsupportedBrowsers, + }); + ReactDOM.render( + unsupportedBrowserList, + container.querySelector(".compatibility-browser-list-wrapper") + ); + } + + /* + * Get the first paragraph for the compatibility tooltip + * Return a subtree similar to: + * <p data-l10n-id="css-compatibility-default-message" + * data-l10n-args="{"property":"user-select"}"> + * </p> + */ + #getCompatibilityMessage(doc, data) { + const { msgId, property } = data; + return this.#createElement(doc, "p", [], { + "data-l10n-id": msgId, + "data-l10n-args": JSON.stringify({ property }), + }); + } + + /** + * Gets the paragraph elements related to the browserList. + * This returns an array with following subtree: + * [ + * <p data-l10n-id="css-compatibility-browser-list-message"></p>, + * <p> + * <ul class="compatibility-unsupported-browser-list"> + * <list-element /> + * </ul> + * </p> + * ] + * The first element is the message and the second element is the + * unsupported browserList itself. + * If the unsupportedBrowser is an empty array, we return an empty + * array back. + */ + #getBrowserListContainer(doc, unsupportedBrowsers) { + if (!unsupportedBrowsers.length) { + return null; + } + + const browserList = this.#createElement(doc, "p"); + const browserListWrapper = this.#createElement(doc, "div", [ + "compatibility-browser-list-wrapper", + ]); + browserList.appendChild(browserListWrapper); + + return browserList; + } + + /* + * This is the learn more message element linking to the MDN documentation + * for the particular incompatible CSS declaration. + * The element returned is: + * <p data-l10n-id="css-compatibility-learn-more-message" + * data-l10n-args="{"property":"user-select"}"> + * <span data-l10n-name="link" class="link"></span> + * </p> + */ + #getLearnMoreMessage(doc, { rootProperty }) { + const learnMoreMessage = this.#createElement(doc, "p", [], { + "data-l10n-id": "css-compatibility-learn-more-message", + "data-l10n-args": JSON.stringify({ rootProperty }), + }); + learnMoreMessage.appendChild( + this.#createElement(doc, "span", ["link"], { + "data-l10n-name": "link", + }) + ); + + return learnMoreMessage; + } + + /** + * Fill the tooltip with inactive CSS information. + * + * @param {Object} data + * An object in the following format: { + * // Type of compatibility issue + * type: <string>, + * // The CSS declaration that has compatibility issues + * // The raw CSS declaration name that has compatibility issues + * declaration: <string>, + * property: <string>, + * // Alias to the given CSS property + * alias: <Array>, + * // Link to MDN documentation for the particular CSS rule + * url: <string>, + * deprecated: <boolean>, + * experimental: <boolean>, + * // An array of all the browsers that don't support the given CSS rule + * unsupportedBrowsers: <Array>, + * } + * @param {HTMLTooltip} tooltip + * The tooltip we are targetting. + */ + async setContent(data, tooltip) { + const fragment = this.getTemplate(data, tooltip); + const { doc } = tooltip; + + tooltip.panel.innerHTML = ""; + + tooltip.panel.addEventListener("click", this.addTab); + tooltip.once("hidden", () => { + tooltip.panel.removeEventListener("click", this.addTab); + }); + + // Because Fluent is async we need to manually translate the fragment and + // then insert it into the tooltip. This is needed in order for the tooltip + // to size to the contents properly and for tests. + await doc.l10n.translateFragment(fragment); + doc.l10n.pauseObserving(); + tooltip.panel.appendChild(fragment); + doc.l10n.resumeObserving(); + + // Size the content. + tooltip.setContentSize({ width: 267, height: Infinity }); + } + + /** + * Get the template that the Fluent string will be merged with. This template + * looks like this: + * + * <div class="devtools-tooltip-css-compatibility"> + * <p data-l10n-id="css-compatibility-default-message" + * data-l10n-args="{"property":"user-select"}"> + * <strong></strong> + * </p> + * <browser-list /> + * <p data-l10n-id="css-compatibility-learn-more-message" + * data-l10n-args="{"property":"user-select"}"> + * <span data-l10n-name="link" class="link"></span> + * <strong></strong> + * </p> + * </div> + * + * @param {Object} data + * An object in the following format: { + * // Type of compatibility issue + * type: <string>, + * // The CSS declaration that has compatibility issues + * // The raw CSS declaration name that has compatibility issues + * declaration: <string>, + * property: <string>, + * // Alias to the given CSS property + * alias: <Array>, + * // Link to MDN documentation for the particular CSS rule + * url: <string>, + * // Link to the spec for the particular CSS rule + * specUrl: <string>, + * deprecated: <boolean>, + * experimental: <boolean>, + * // An array of all the browsers that don't support the given CSS rule + * unsupportedBrowsers: <Array>, + * } + * @param {HTMLTooltip} tooltip + * The tooltip we are targetting. + */ + getTemplate(data, tooltip) { + const { doc } = tooltip; + const { specUrl, url, unsupportedBrowsers } = data; + + this.#currentTooltip = tooltip; + this.#currentUrl = url + ? `${url}?utm_source=devtools&utm_medium=inspector-css-compatibility&utm_campaign=default` + : specUrl; + const templateNode = this.#createElement(doc, "template"); + + const tooltipContainer = this.#createElement(doc, "div", [ + "devtools-tooltip-css-compatibility", + ]); + + tooltipContainer.appendChild(this.#getCompatibilityMessage(doc, data)); + const browserListContainer = this.#getBrowserListContainer( + doc, + unsupportedBrowsers + ); + if (browserListContainer) { + tooltipContainer.appendChild(browserListContainer); + this.#renderUnsupportedBrowserList(tooltipContainer, unsupportedBrowsers); + } + + if (this.#currentUrl) { + tooltipContainer.appendChild(this.#getLearnMoreMessage(doc, data)); + } + + templateNode.content.appendChild(tooltipContainer); + return doc.importNode(templateNode.content, true); + } + + /** + * Hide the tooltip, open `this.#currentUrl` in a new tab and focus it. + * + * @param {DOMEvent} event + * The click event originating from the tooltip. + * + */ + addTab(event) { + // The XUL panel swallows click events so handlers can't be added directly + // to the link span. As a workaround we listen to all click events in the + // panel and if a link span is clicked we proceed. + if (event.target.className !== "link") { + return; + } + + const tooltip = this.#currentTooltip; + tooltip.hide(); + + const isMacOS = Services.appinfo.OS === "Darwin"; + openDocLink(this.#currentUrl, { + relatedToCurrent: true, + inBackground: isMacOS ? event.metaKey : event.ctrlKey, + }); + } + + destroy() { + this.#currentTooltip = null; + this.#currentUrl = null; + } +} + +module.exports = CssCompatibilityTooltipHelper; diff --git a/devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js b/devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js new file mode 100644 index 0000000000..633e57b9cf --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js @@ -0,0 +1,145 @@ +/* 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 XHTML_NS = "http://www.w3.org/1999/xhtml"; + +class CssQueryContainerTooltipHelper { + /** + * Fill the tooltip with container information. + */ + async setContent(data, tooltip) { + const res = await data.rule.domRule.getQueryContainerForNode( + data.ancestorIndex, + data.rule.inherited || + data.rule.elementStyle.ruleView.inspector.selection.nodeFront + ); + + const fragment = this.#getTemplate(res, tooltip); + tooltip.panel.innerHTML = ""; + tooltip.panel.appendChild(fragment); + + // Size the content. + tooltip.setContentSize({ width: 267, height: Infinity }); + } + + /** + * Get the template of the tooltip. + * + * @param {Object} data + * @param {NodeFront} data.node + * @param {string} data.containerType + * @param {string} data.inlineSize + * @param {string} data.blockSize + * @param {HTMLTooltip} tooltip + * The tooltip we are targetting. + */ + #getTemplate(data, tooltip) { + const { doc } = tooltip; + + const templateNode = doc.createElementNS(XHTML_NS, "template"); + + const tooltipContainer = doc.createElementNS(XHTML_NS, "div"); + tooltipContainer.classList.add("devtools-tooltip-query-container"); + templateNode.content.appendChild(tooltipContainer); + + const nodeContainer = doc.createElementNS(XHTML_NS, "header"); + tooltipContainer.append(nodeContainer); + + const containerQueryLabel = doc.createElementNS(XHTML_NS, "span"); + containerQueryLabel.classList.add("property-name"); + containerQueryLabel.appendChild(doc.createTextNode(`query container`)); + + const nodeEl = doc.createElementNS(XHTML_NS, "span"); + nodeEl.classList.add("objectBox-node"); + nodeContainer.append(doc.createTextNode("<"), nodeEl); + + const nodeNameEl = doc.createElementNS(XHTML_NS, "span"); + nodeNameEl.classList.add("tag-name"); + nodeNameEl.appendChild( + doc.createTextNode(data.node.nodeName.toLowerCase()) + ); + + nodeEl.appendChild(nodeNameEl); + + if (data.node.id) { + const idEl = doc.createElementNS(XHTML_NS, "span"); + idEl.classList.add("attribute-name"); + idEl.appendChild(doc.createTextNode(`#${data.node.id}`)); + nodeEl.appendChild(idEl); + } + + for (const attr of data.node.attributes) { + if (attr.name !== "class") { + continue; + } + for (const cls of attr.value.split(/\s/)) { + const el = doc.createElementNS(XHTML_NS, "span"); + el.classList.add("attribute-name"); + el.appendChild(doc.createTextNode(`.${cls}`)); + nodeEl.appendChild(el); + } + } + nodeContainer.append(doc.createTextNode(">")); + + const ul = doc.createElementNS(XHTML_NS, "ul"); + tooltipContainer.appendChild(ul); + + const containerTypeEl = doc.createElementNS(XHTML_NS, "li"); + const containerTypeLabel = doc.createElementNS(XHTML_NS, "span"); + containerTypeLabel.classList.add("property-name"); + containerTypeLabel.appendChild(doc.createTextNode(`container-type`)); + + const containerTypeValue = doc.createElementNS(XHTML_NS, "span"); + containerTypeValue.classList.add("property-value"); + containerTypeValue.appendChild(doc.createTextNode(data.containerType)); + + containerTypeEl.append( + containerTypeLabel, + doc.createTextNode(": "), + containerTypeValue + ); + ul.appendChild(containerTypeEl); + + const inlineSizeEl = doc.createElementNS(XHTML_NS, "li"); + + const inlineSizeLabel = doc.createElementNS(XHTML_NS, "span"); + inlineSizeLabel.classList.add("property-name"); + inlineSizeLabel.appendChild(doc.createTextNode(`inline-size`)); + + const inlineSizeValue = doc.createElementNS(XHTML_NS, "span"); + inlineSizeValue.classList.add("property-value"); + inlineSizeValue.appendChild(doc.createTextNode(data.inlineSize)); + + inlineSizeEl.append( + inlineSizeLabel, + doc.createTextNode(": "), + inlineSizeValue + ); + ul.appendChild(inlineSizeEl); + + if (data.containerType != "inline-size") { + const blockSizeEl = doc.createElementNS(XHTML_NS, "li"); + const blockSizeLabel = doc.createElementNS(XHTML_NS, "span"); + blockSizeLabel.classList.add("property-name"); + blockSizeLabel.appendChild(doc.createTextNode(`block-size`)); + + const blockSizeValue = doc.createElementNS(XHTML_NS, "span"); + blockSizeValue.classList.add("property-value"); + blockSizeValue.appendChild(doc.createTextNode(data.blockSize)); + + blockSizeEl.append( + blockSizeLabel, + doc.createTextNode(": "), + blockSizeValue + ); + ul.appendChild(blockSizeEl); + } + + return doc.importNode(templateNode.content, true); + } +} + +module.exports = CssQueryContainerTooltipHelper; diff --git a/devtools/client/shared/widgets/tooltip/css-selector-warnings-tooltip-helper.js b/devtools/client/shared/widgets/tooltip/css-selector-warnings-tooltip-helper.js new file mode 100644 index 0000000000..9150a2e6a9 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/css-selector-warnings-tooltip-helper.js @@ -0,0 +1,64 @@ +/* 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 XHTML_NS = "http://www.w3.org/1999/xhtml"; + +const SELECTOR_WARNINGS = { + UnconstrainedHas: { + l10nId: "css-selector-warning-unconstrained-has", + // There could be a specific section on the MDN :has page for this: https://github.com/mdn/mdn/issues/469 + learnMoreUrl: null, + }, +}; + +class CssSelectorWarningsTooltipHelper { + /** + * Fill the tooltip with selector warnings. + */ + async setContent(data, tooltip) { + const fragment = this.#getTemplate(data, tooltip); + tooltip.panel.innerHTML = ""; + tooltip.panel.append(fragment); + + // Size the content. + tooltip.setContentSize({ width: 267, height: Infinity }); + } + + /** + * Get the template of the tooltip. + * + * @param {Array<String>} data: Array of selector warning kind returned by + * CSSRule#getSelectorWarnings + * @param {HTMLTooltip} tooltip + * The tooltip we are targetting. + */ + #getTemplate(data, tooltip) { + const { doc } = tooltip; + + const templateNode = doc.createElementNS(XHTML_NS, "template"); + + const tooltipContainer = doc.createElementNS(XHTML_NS, "ul"); + tooltipContainer.classList.add("devtools-tooltip-selector-warnings"); + templateNode.content.appendChild(tooltipContainer); + + for (const selectorWarningKind of data) { + if (!SELECTOR_WARNINGS[selectorWarningKind]) { + console.error("Unknown selector warning kind", data); + continue; + } + + const { l10nId } = SELECTOR_WARNINGS[selectorWarningKind]; + + const li = doc.createElementNS(XHTML_NS, "li"); + li.setAttribute("data-l10n-id", l10nId); + tooltipContainer.append(li); + } + + return doc.importNode(templateNode.content, true); + } +} + +module.exports = CssSelectorWarningsTooltipHelper; diff --git a/devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js b/devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js new file mode 100644 index 0000000000..0c19df3e2e --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js @@ -0,0 +1,131 @@ +/* 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"; + +loader.lazyRequireGetter( + this, + "openDocLink", + "resource://devtools/client/shared/link.js", + true +); + +class InactiveCssTooltipHelper { + constructor() { + this.addTab = this.addTab.bind(this); + } + + /** + * Fill the tooltip with inactive CSS information. + * + * @param {String} propertyName + * The property name to be displayed in bold. + * @param {String} text + * The main text, which follows property name. + */ + async setContent(data, tooltip) { + const fragment = this.getTemplate(data, tooltip); + const { doc } = tooltip; + + tooltip.panel.innerHTML = ""; + + tooltip.panel.addEventListener("click", this.addTab); + tooltip.once("hidden", () => { + tooltip.panel.removeEventListener("click", this.addTab); + }); + + // Because Fluent is async we need to manually translate the fragment and + // then insert it into the tooltip. This is needed in order for the tooltip + // to size to the contents properly and for tests. + await doc.l10n.translateFragment(fragment); + doc.l10n.pauseObserving(); + tooltip.panel.appendChild(fragment); + doc.l10n.resumeObserving(); + + // Size the content. + tooltip.setContentSize({ width: 267, height: Infinity }); + } + + /** + * Get the template that the Fluent string will be merged with. This template + * looks something like this but there is a variable amount of properties in the + * fix section: + * + * <div class="devtools-tooltip-inactive-css"> + * <p data-l10n-id="inactive-css-not-grid-or-flex-container" + * data-l10n-args="{"property":"align-content"}"> + * </p> + * <p data-l10n-id="inactive-css-not-grid-or-flex-container-fix"> + * <span data-l10n-name="link" class="link"></span> + * </p> + * </div> + * + * @param {Object} data + * An object in the following format: { + * fixId: "inactive-css-not-grid-item-fix-2", // Fluent id containing the + * // Inactive CSS fix. + * msgId: "inactive-css-not-grid-item", // Fluent id containing the + * // Inactive CSS message. + * property: "color", // Property name + * } + * @param {HTMLTooltip} tooltip + * The tooltip we are targetting. + */ + getTemplate(data, tooltip) { + const XHTML_NS = "http://www.w3.org/1999/xhtml"; + const { fixId, msgId, property, display, lineCount, learnMoreURL } = data; + const { doc } = tooltip; + + const documentUrl = new URL( + learnMoreURL || `https://developer.mozilla.org/docs/Web/CSS/${property}` + ); + this._currentTooltip = tooltip; + const { searchParams } = documentUrl; + searchParams.append("utm_source", "devtools"); + searchParams.append("utm_medium", "inspector-inactive-css"); + this._currentUrl = documentUrl.toString(); + + const templateNode = doc.createElementNS(XHTML_NS, "template"); + + // eslint-disable-next-line + templateNode.innerHTML = ` + <div class="devtools-tooltip-inactive-css"> + <p data-l10n-id="${msgId}" + data-l10n-args='${JSON.stringify({ property, display, lineCount })}'> + </p> + <p data-l10n-id="${fixId}"> + <span data-l10n-name="link" class="link"></span> + </p> + </div>`; + + return doc.importNode(templateNode.content, true); + } + + /** + * Hide the tooltip, open `this._currentUrl` in a new tab and focus it. + * + * @param {DOMEvent} event + * The click event originating from the tooltip. + * + */ + addTab(event) { + // The XUL panel swallows click events so handlers can't be added directly + // to the link span. As a workaround we listen to all click events in the + // panel and if a link span is clicked we proceed. + if (event.target.className !== "link") { + return; + } + + const tooltip = this._currentTooltip; + tooltip.hide(); + openDocLink(this._currentUrl); + } + + destroy() { + this._currentTooltip = null; + this._currentUrl = null; + } +} + +module.exports = InactiveCssTooltipHelper; diff --git a/devtools/client/shared/widgets/tooltip/moz.build b/devtools/client/shared/widgets/tooltip/moz.build new file mode 100644 index 0000000000..57d873c8b5 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/moz.build @@ -0,0 +1,23 @@ +# -*- 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( + "css-compatibility-tooltip-helper.js", + "css-query-container-tooltip-helper.js", + "css-selector-warnings-tooltip-helper.js", + "EventTooltipHelper.js", + "HTMLTooltip.js", + "ImageTooltipHelper.js", + "inactive-css-tooltip-helper.js", + "RulePreviewTooltip.js", + "SwatchBasedEditorTooltip.js", + "SwatchColorPickerTooltip.js", + "SwatchCubicBezierTooltip.js", + "SwatchFilterTooltip.js", + "SwatchLinearEasingFunctionTooltip.js", + "TooltipToggle.js", + "VariableTooltipHelper.js", +) |