@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
425 lines (398 loc) • 20.7 kB
text/typescript
// *****************************************************************************
// 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;
}
export class HoverService {
protected static hostClassName = 'theia-hover';
protected static styleSheetId = 'theia-hover-style';
protected readonly preferences: PreferenceService;
// Inject CoreMarkdownRenderer rather than the MarkdownRenderer symbol,
// which is rebound by Monaco to a renderer that strips code block content.
protected readonly markdownRenderer: MarkdownRenderer;
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;
}
}
}