UNPKG

@theia/core

Version:

Theia is a cloud & desktop IDE framework implemented in TypeScript.

425 lines (398 loc) • 20.7 kB
// ***************************************************************************** // Copyright (C) 2022 Ericsson and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { inject, injectable } from 'inversify'; import { Disposable, DisposableCollection, disposableTimeout, isOSX, PreferenceService } from '../common'; import { MarkdownString } from '../common/markdown-rendering/markdown-string'; import { animationFrame } from './browser'; import { wireMarkdownLinkHandler } from './markdown-rendering/markdown-link-handler'; import { CoreMarkdownRenderer, MarkdownRenderer } from './markdown-rendering/markdown-renderer'; import { OpenerService } from './opener-service'; import '../../src/browser/style/hover-service.css'; export type HoverPosition = 'left' | 'right' | 'top' | 'bottom'; // Threshold, in milliseconds, over which a mouse movement is not considered // quick enough as to be ignored const quickMouseThresholdMillis = 200; export namespace HoverPosition { export function invertIfNecessary(position: HoverPosition, target: DOMRect, host: DOMRect, totalWidth: number, totalHeight: number): HoverPosition { if (position === 'left') { if (target.left - host.width - 5 < 0) { return 'right'; } } else if (position === 'right') { if (target.right + host.width + 5 > totalWidth) { return 'left'; } } else if (position === 'top') { if (target.top - host.height - 5 < 0) { return 'bottom'; } } else if (position === 'bottom') { if (target.bottom + host.height + 5 > totalHeight) { return 'top'; } } return position; } /** * Tests whether a hover of the given dimensions fits next to its target in the given * position without extending beyond the window bounds. */ export function fits(position: HoverPosition, target: DOMRect, host: DOMRect, totalWidth: number, totalHeight: number): boolean { if (position === 'left') { return target.left - host.width - 5 >= 0; } else if (position === 'right') { return target.right + host.width + 5 <= totalWidth; } else if (position === 'top') { return target.top - host.height - 5 >= 0; } else { return target.bottom + host.height + 5 <= totalHeight; } } } export interface HoverRequest { content: string | MarkdownString | HTMLElement target: HTMLElement /** * The position where the hover should appear. * Note that the hover service will try to invert the position (i.e. right -> left) * if the specified content does not fit in the window next to the target element */ position: HoverPosition /** * Additional css classes that should be added to the hover box. * Used to style certain boxes different e.g. for the extended tab preview. */ cssClasses?: string[] /** * A function to render a visual preview on the hover. * Function that takes the desired width and returns a HTMLElement to be rendered. */ visualPreview?: (width: number) => HTMLElement | undefined; /** * Indicates if the hover contains interactive/clickable items. * When true, the hover will register a click handler to allow interaction with elements in the hover area. */ interactive?: boolean; /** * If implemented, this method will be called when the hover is no longer shown or no longer scheduled to be shown. */ onHide?(): void; /** * When true, the hover will be shown immediately without any delay. * Useful for explicitly triggered hovers (e.g., on click) where the user expects instant feedback. * @default false */ skipHoverDelay?: boolean; } @injectable() export class HoverService { protected static hostClassName = 'theia-hover'; protected static styleSheetId = 'theia-hover-style'; @inject(PreferenceService) protected readonly preferences: PreferenceService; // Inject CoreMarkdownRenderer rather than the MarkdownRenderer symbol, // which is rebound by Monaco to a renderer that strips code block content. @inject(CoreMarkdownRenderer) protected readonly markdownRenderer: MarkdownRenderer; @inject(OpenerService) protected readonly openerService: OpenerService; protected _hoverHost: HTMLElement | undefined; /** * The host of the current hover, which may live in a secondary window's document. * Resolving against the main document here instead would silently replace the host * whenever the current hover lives in another document, detaching the dismissal * listeners and `unRenderHover` from the host that is actually rendered. */ protected get hoverHost(): HTMLElement { return this._hoverHost ?? this.getOrCreateHoverHost(document); } /** * Returns the host element to render hovers into for the given document, creating it if the * current one belongs to a different document. A host is always created in the document it is * shown in: adopting a host into another document would let it outlive its window, and touching * it after that window was closed breaks (and in Electron crashes) the application. */ protected getOrCreateHoverHost(targetDocument: Document): HTMLElement { if (!this._hoverHost || this._hoverHost.ownerDocument !== targetDocument) { this._hoverHost = targetDocument.createElement('div'); this._hoverHost.classList.add(HoverService.hostClassName); this._hoverHost.style.position = 'absolute'; this._hoverHost.setAttribute('popover', 'hint'); } return this._hoverHost; } protected pendingTimeout: Disposable | undefined; protected hoverTarget: HTMLElement | undefined; /** Identifies the latest render so that superseded renders can detect they are stale. */ protected renderSequence = 0; protected lastHidHover = Date.now(); protected readonly disposeOnHide = new DisposableCollection(); requestHover(request: HoverRequest): void { this.cancelHover(); const targetWindow = request.target.ownerDocument.defaultView; if (!targetWindow || targetWindow.closed) { // the window hosting the target is already gone, e.g. a closed secondary window: // its document must not be touched anymore return; } const delay = request.skipHoverDelay ? 0 : this.getHoverDelay(); this.pendingTimeout = disposableTimeout(() => this.renderHover(request), delay); this.hoverTarget = request.target; // resolve the host for the target's document up front so that the listeners below attach to the host that will be rendered this.getOrCreateHoverHost(request.target.ownerDocument); this.listenForWindowClose(request.target); this.listenForMouseOut(); this.listenForMouseClick(request); } /** * Cancels the hover when the window hosting the target element goes away, e.g. when the * secondary window containing the target is closed: neither the hover host nor any listeners * may outlive the document they belong to. */ protected listenForWindowClose(target: HTMLElement): void { const targetWindow = target.ownerDocument.defaultView; if (!targetWindow || targetWindow === window) { return; } const handlePageHide = () => this.cancelHover(); targetWindow.addEventListener('pagehide', handlePageHide); this.disposeOnHide.push({ dispose: () => targetWindow.removeEventListener('pagehide', handlePageHide) }); } protected getHoverDelay(): number { return Date.now() - this.lastHidHover < quickMouseThresholdMillis ? 0 : this.preferences.get('workbench.hover.delay', isOSX ? 1500 : 500); } protected async renderHover(request: HoverRequest): Promise<void> { const { target, content, position, cssClasses, interactive, onHide } = request; const targetWindow = target.ownerDocument.defaultView; if (!targetWindow || targetWindow.closed) { // the window hosting the target is already gone, e.g. a closed secondary window: // its document must not be touched anymore return; } const host = this.getOrCreateHoverHost(target.ownerDocument); const sequence = ++this.renderSequence; let firstChild: HTMLElement | undefined; if (onHide) { this.disposeOnHide.push({ dispose: onHide.bind(request) }); } if (cssClasses) { host.classList.add(...cssClasses); } if (content instanceof HTMLElement) { host.appendChild(content); firstChild = content; } else if (typeof content === 'string') { host.textContent = content; } else { const renderedContent = this.markdownRenderer.render(content); this.disposeOnHide.push(renderedContent); host.appendChild(renderedContent.element); firstChild = renderedContent.element; this.disposeOnHide.push(wireMarkdownLinkHandler(renderedContent.element, content, this.openerService)); } // browsers might insert linebreaks when the hover appears at the edge of the window // resetting the position prevents that host.style.left = '0px'; host.style.top = '0px'; // Keep the host hidden while it is parked at (0,0) for measurement. A visible popover there // would, for a target in the upper-left of the viewport (e.g. a view docked in the left panel), // flash over the target for one frame. That steals the pointer from the target, whose mouse-leave // handler then cancels the hover, and the cycle repeats: the tooltip flickers and never settles. // `visibility: hidden` still lays the host out (so it can be measured) but is not hit-tested. host.style.visibility = 'hidden'; // Render the hover in the document of the target: it may be hosted in a secondary window, // whose coordinate space is unrelated to the main window's. target.ownerDocument.body.append(host); if (!host.matches(':popover-open')) { host.showPopover(); } if (interactive) { // Add a click handler to the hover host to ensure clicks within the hover area work properly const clickHandler = (e: MouseEvent) => { // Let click events within the hover area be processed by their handlers // but prevent them from triggering document handlers that might dismiss the tooltip e.stopImmediatePropagation(); }; host.addEventListener('click', clickHandler); this.disposeOnHide.push({ dispose: () => host.removeEventListener('click', clickHandler) }); } if (request.visualPreview) { // If just a string is being rendered use the size of the outer box const width = firstChild ? firstChild.offsetWidth : this.hoverHost.offsetWidth; const visualPreview = request.visualPreview(width); if (visualPreview) { host.appendChild(visualPreview); } } await this.hostAnimationFrame(target); // Allow the browser to size the host if (sequence !== this.renderSequence || !host.isConnected) { // this hover was cancelled or superseded by a newer one while waiting for the animation // frame: it must neither reposition nor reveal the host return; } const updatedPosition = this.setHostPosition(target, host, position); // Reveal the host only once it sits at its final position, so it never overlaps the target while // parked at (0,0). Dropping the declaration rather than assigning a value hands `visibility` back // to the stylesheet, which is where it comes from for every hover but the one being measured. host.style.removeProperty('visibility'); this.disposeOnHide.push({ dispose: () => { this.lastHidHover = Date.now(); host.classList.remove(updatedPosition); if (cssClasses) { host.classList.remove(...cssClasses); } } }); } /** * Waits for an animation frame in the window hosting the given target element, * which may be a secondary window whose rendering is independent of the main window's. */ protected hostAnimationFrame(target: HTMLElement): Promise<void> { const targetWindow = target.ownerDocument.defaultView; if (!targetWindow || targetWindow === window) { return animationFrame(); } return new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); } protected setHostPosition(target: HTMLElement, host: HTMLElement, position: HoverPosition): HoverPosition { const hostDocument = target.ownerDocument; const targetDimensions = target.getBoundingClientRect(); const hostDimensions = host.getBoundingClientRect(); const documentWidth = hostDocument.body.getBoundingClientRect().width; // document.body.getBoundingClientRect().height doesn't work as expected // scrollHeight will always be accurate here: https://stackoverflow.com/a/44077777 const documentHeight = hostDocument.documentElement.scrollHeight; position = HoverPosition.invertIfNecessary(position, targetDimensions, hostDimensions, documentWidth, documentHeight); if (!HoverPosition.fits(position, targetDimensions, hostDimensions, documentWidth, documentHeight)) { // The hover fits on neither side of the target in the requested direction, e.g. when the // target spans the full width of a narrow secondary window. Try the perpendicular direction // so that the target and the hover both remain visible. If that does not fit either, // keep the requested direction; the clamping below keeps the hover inside the viewport. const fallback = HoverPosition.invertIfNecessary( position === 'left' || position === 'right' ? 'bottom' : 'right', targetDimensions, hostDimensions, documentWidth, documentHeight ); if (HoverPosition.fits(fallback, targetDimensions, hostDimensions, documentWidth, documentHeight)) { position = fallback; } } if (position === 'top' || position === 'bottom') { const targetMiddleWidth = targetDimensions.left + (targetDimensions.width / 2); const middleAlignment = targetMiddleWidth - (hostDimensions.width / 2); const furthestRight = Math.min(documentWidth - hostDimensions.width, middleAlignment); const left = Math.max(0, furthestRight); const top = position === 'top' ? targetDimensions.top - hostDimensions.height - 5 : targetDimensions.bottom + 5; host.style.setProperty('--theia-hover-before-position', `${targetMiddleWidth - left - 5}px`); host.style.top = `${top}px`; host.style.left = `${left}px`; } else { const targetMiddleHeight = targetDimensions.top + (targetDimensions.height / 2); const middleAlignment = targetMiddleHeight - (hostDimensions.height / 2); const furthestTop = Math.min(documentHeight - hostDimensions.height, middleAlignment); const top = Math.max(0, furthestTop); const desiredLeft = position === 'left' ? targetDimensions.left - hostDimensions.width - 5 : targetDimensions.right + 5; // the hover may not fit on either side of the target, e.g. when the target // spans the full width of a narrow (secondary) window: keep it in the viewport const furthestRight = Math.min(documentWidth - hostDimensions.width, desiredLeft); const left = Math.max(0, furthestRight); host.style.setProperty('--theia-hover-before-position', `${targetMiddleHeight - top - 5}px`); host.style.left = `${left}px`; host.style.top = `${top}px`; } host.classList.add(position); return position; } protected listenForMouseOut(): void { const handleMouseLeave = (e: MouseEvent) => { this.disposeOnHide.push(disposableTimeout(() => { if (!this.hoverHost.matches(':hover') && !this.hoverTarget?.matches(':hover')) { this.cancelHover(); } }, quickMouseThresholdMillis)); }; this.hoverTarget?.addEventListener('mouseout', handleMouseLeave); this.hoverHost.addEventListener('mouseout', handleMouseLeave); this.disposeOnHide.push({ dispose: () => { this.hoverTarget?.removeEventListener('mouseout', handleMouseLeave); this.hoverHost.removeEventListener('mouseout', handleMouseLeave); } }); } cancelHover(): void { this.pendingTimeout?.dispose(); this.unRenderHover(); this.disposeOnHide.dispose(); this.hoverTarget = undefined; } /** * Listen for mouse click (mousedown) events and handle them based on hover interactivity. * For non-interactive hovers, any mousedown cancels the hover immediately. * For interactive hovers, the hover remains visible to allow interaction with its elements. */ protected listenForMouseClick(request: HoverRequest): void { const handleMouseDown = (e: MouseEvent) => { if (this.hoverHost.contains(e.target as Node)) { return; } if (!request.interactive) { this.cancelHover(); } }; // Listen in the document of the target, which may be hosted in a secondary window const targetDocument = request.target.ownerDocument; targetDocument.addEventListener('mousedown', handleMouseDown, true); this.disposeOnHide.push({ dispose: () => targetDocument.removeEventListener('mousedown', handleMouseDown, true) }); } protected unRenderHover(): void { const host = this._hoverHost; if (!host) { return; } const hostWindow = host.ownerDocument.defaultView; if (!hostWindow || hostWindow.closed) { // the window hosting the hover is already gone, e.g. a closed secondary window: // its DOM must not be touched anymore; abandon the host and start from scratch this._hoverHost = undefined; return; } try { if (host.matches(':popover-open')) { host.hidePopover(); } } catch { // hidePopover throws for popovers in documents that are no longer fully active, // e.g. while the secondary window hosting the hover is being closed } host.remove(); host.replaceChildren(); // drop the classes added for the rendered hover (position and request classes): a render // aborted because it was superseded must not leak its classes into the next hover host.className = HoverService.hostClassName; // The host is reused across hovers; drop the transient hidden state set during measurement so a // hover cancelled before it was revealed does not leave the next one invisible. host.style.removeProperty('visibility'); if (host.ownerDocument !== document) { // never keep a host from another document: it must not outlive its window this._hoverHost = undefined; } } }