Path: blob/main/src/vs/platform/hover/browser/hoverService.ts
4777 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js';6import { registerThemingParticipant } from '../../theme/common/themeService.js';7import { editorHoverBorder } from '../../theme/common/colorRegistry.js';8import { IHoverService } from './hover.js';9import { IContextMenuService } from '../../contextview/browser/contextView.js';10import { IInstantiationService } from '../../instantiation/common/instantiation.js';11import { HoverWidget } from './hoverWidget.js';12import { IContextViewProvider, IDelegate } from '../../../base/browser/ui/contextview/contextview.js';13import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';14import { addDisposableListener, EventType, getActiveElement, isAncestorOfActiveElement, isAncestor, getWindow, isHTMLElement, isEditableElement } from '../../../base/browser/dom.js';15import { IKeybindingService } from '../../keybinding/common/keybinding.js';16import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js';17import { ResultKind } from '../../keybinding/common/keybindingResolver.js';18import { IAccessibilityService } from '../../accessibility/common/accessibility.js';19import { ILayoutService } from '../../layout/browser/layoutService.js';20import { mainWindow } from '../../../base/browser/window.js';21import { ContextViewHandler } from '../../contextview/browser/contextViewService.js';22import { HoverStyle, isManagedHoverTooltipMarkdownString, type IHoverLifecycleOptions, type IHoverOptions, type IHoverTarget, type IHoverWidget, type IManagedHover, type IManagedHoverContentOrFactory, type IManagedHoverOptions } from '../../../base/browser/ui/hover/hover.js';23import type { IHoverDelegate, IHoverDelegateTarget } from '../../../base/browser/ui/hover/hoverDelegate.js';24import { ManagedHoverWidget } from './updatableHoverWidget.js';25import { timeout, TimeoutTimer } from '../../../base/common/async.js';26import { IConfigurationService } from '../../configuration/common/configuration.js';27import { isNumber, isString } from '../../../base/common/types.js';28import { KeyChord, KeyCode, KeyMod } from '../../../base/common/keyCodes.js';29import { KeybindingsRegistry, KeybindingWeight } from '../../keybinding/common/keybindingsRegistry.js';30import { IMarkdownString } from '../../../base/common/htmlContent.js';31import { stripIcons } from '../../../base/common/iconLabels.js';3233export class HoverService extends Disposable implements IHoverService {34declare readonly _serviceBrand: undefined;3536private _contextViewHandler: IContextViewProvider;37private _currentHoverOptions: IHoverOptions | undefined;38private _currentHover: HoverWidget | undefined;39private _currentDelayedHover: HoverWidget | undefined;40private _currentDelayedHoverWasShown: boolean = false;41private _currentDelayedHoverGroupId: number | string | undefined;42private _lastHoverOptions: IHoverOptions | undefined;4344private _lastFocusedElementBeforeOpen: HTMLElement | undefined;4546private readonly _delayedHovers = new Map<HTMLElement, { show: (focus: boolean) => void }>();47private readonly _managedHovers = new Map<HTMLElement, IManagedHover>();4849constructor(50@IInstantiationService private readonly _instantiationService: IInstantiationService,51@IConfigurationService private readonly _configurationService: IConfigurationService,52@IContextMenuService contextMenuService: IContextMenuService,53@IKeybindingService private readonly _keybindingService: IKeybindingService,54@ILayoutService private readonly _layoutService: ILayoutService,55@IAccessibilityService private readonly _accessibilityService: IAccessibilityService56) {57super();5859this._register(contextMenuService.onDidShowContextMenu(() => this.hideHover()));60this._contextViewHandler = this._register(new ContextViewHandler(this._layoutService));6162this._register(KeybindingsRegistry.registerCommandAndKeybindingRule({63id: 'workbench.action.showHover',64weight: KeybindingWeight.EditorCore,65primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.CtrlCmd | KeyCode.KeyI),66handler: () => { this._showAndFocusHoverForActiveElement(); },67}));68}6970showInstantHover(options: IHoverOptions, focus?: boolean, skipLastFocusedUpdate?: boolean, dontShow?: boolean): IHoverWidget | undefined {71const hover = this._createHover(options, skipLastFocusedUpdate);72if (!hover) {73return undefined;74}75this._showHover(hover, options, focus);76return hover;77}7879showDelayedHover(80options: IHoverOptions,81lifecycleOptions: Pick<IHoverLifecycleOptions, 'groupId'>,82): IHoverWidget | undefined {83// Set `id` to default if it's undefined84if (options.id === undefined) {85options.id = getHoverIdFromContent(options.content);86}8788if (!this._currentDelayedHover || this._currentDelayedHoverWasShown) {89// Current hover is locked, reject90if (this._currentHover?.isLocked) {91return undefined;92}9394// Identity is the same, return current hover95if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {96return this._currentHover;97}9899// Check group identity, if it's the same skip the delay and show the hover immediately100if (this._currentHover && !this._currentHover.isDisposed && this._currentDelayedHoverGroupId !== undefined && this._currentDelayedHoverGroupId === lifecycleOptions?.groupId) {101return this.showInstantHover({102...options,103appearance: {104...options.appearance,105skipFadeInAnimation: true106}107});108}109} else if (this._currentDelayedHover && getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {110// If the hover is the same but timeout is not finished yet, return the current hover111return this._currentDelayedHover;112}113114const hover = this._createHover(options, undefined);115if (!hover) {116this._currentDelayedHover = undefined;117this._currentDelayedHoverWasShown = false;118this._currentDelayedHoverGroupId = undefined;119return undefined;120}121122this._currentDelayedHover = hover;123this._currentDelayedHoverWasShown = false;124this._currentDelayedHoverGroupId = lifecycleOptions?.groupId;125126timeout(this._configurationService.getValue<number>('workbench.hover.delay')).then(() => {127if (hover && !hover.isDisposed) {128this._currentDelayedHoverWasShown = true;129this._showHover(hover, options);130}131});132133return hover;134}135136setupDelayedHover(137target: HTMLElement,138options: (() => Omit<IHoverOptions, 'target'>) | Omit<IHoverOptions, 'target'>,139lifecycleOptions?: IHoverLifecycleOptions,140): IDisposable {141const resolveHoverOptions = (e?: MouseEvent) => {142const resolved: IHoverOptions = {143...typeof options === 'function' ? options() : options,144target145};146if (resolved.style === HoverStyle.Mouse && e) {147resolved.target = resolveMouseStyleHoverTarget(target, e);148}149return resolved;150};151return this._setupDelayedHover(target, resolveHoverOptions, lifecycleOptions);152}153154setupDelayedHoverAtMouse(155target: HTMLElement,156options: (() => Omit<IHoverOptions, 'target' | 'position'>) | Omit<IHoverOptions, 'target' | 'position'>,157lifecycleOptions?: IHoverLifecycleOptions,158): IDisposable {159const resolveHoverOptions = (e?: MouseEvent) => ({160...typeof options === 'function' ? options() : options,161target: e ? resolveMouseStyleHoverTarget(target, e) : target162} satisfies IHoverOptions);163return this._setupDelayedHover(target, resolveHoverOptions, lifecycleOptions);164}165166private _setupDelayedHover(167target: HTMLElement,168resolveHoverOptions: ((e?: MouseEvent) => IHoverOptions),169lifecycleOptions?: IHoverLifecycleOptions,170) {171const store = new DisposableStore();172store.add(addDisposableListener(target, EventType.MOUSE_OVER, e => {173this.showDelayedHover(resolveHoverOptions(e), {174groupId: lifecycleOptions?.groupId175});176}));177if (lifecycleOptions?.setupKeyboardEvents) {178store.add(addDisposableListener(target, EventType.KEY_DOWN, e => {179const evt = new StandardKeyboardEvent(e);180if (evt.equals(KeyCode.Space) || evt.equals(KeyCode.Enter)) {181this.showInstantHover(resolveHoverOptions(), true);182}183}));184}185186this._delayedHovers.set(target, { show: (focus: boolean) => { this.showInstantHover(resolveHoverOptions(), focus); } });187store.add(toDisposable(() => this._delayedHovers.delete(target)));188189return store;190}191192private _createHover(options: IHoverOptions, skipLastFocusedUpdate?: boolean): HoverWidget | undefined {193this._currentDelayedHover = undefined;194195if (options.content === '') {196return undefined;197}198199if (this._currentHover?.isLocked) {200return undefined;201}202203// Set `id` to default if it's undefined204if (options.id === undefined) {205options.id = getHoverIdFromContent(options.content);206}207208if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {209return undefined;210}211this._currentHoverOptions = options;212this._lastHoverOptions = options;213const trapFocus = options.trapFocus || this._accessibilityService.isScreenReaderOptimized();214const activeElement = getActiveElement();215// HACK, remove this check when #189076 is fixed216if (!skipLastFocusedUpdate) {217if (trapFocus && activeElement) {218if (!activeElement.classList.contains('monaco-hover')) {219this._lastFocusedElementBeforeOpen = activeElement as HTMLElement;220}221} else {222this._lastFocusedElementBeforeOpen = undefined;223}224}225226const hoverDisposables = new DisposableStore();227const hover = this._instantiationService.createInstance(HoverWidget, options);228if (options.persistence?.sticky) {229hover.isLocked = true;230}231232// Adjust target position when a mouse event is provided as the hover position233if (options.position?.hoverPosition && !isNumber(options.position.hoverPosition)) {234options.target = {235targetElements: isHTMLElement(options.target) ? [options.target] : options.target.targetElements,236x: options.position.hoverPosition.x + 10237};238}239240hover.onDispose(() => {241const hoverWasFocused = this._currentHover?.domNode && isAncestorOfActiveElement(this._currentHover.domNode);242if (hoverWasFocused) {243// Required to handle cases such as closing the hover with the escape key244this._lastFocusedElementBeforeOpen?.focus();245}246247// Only clear the current options if it's the current hover, the current options help248// reduce flickering when the same hover is shown multiple times249if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {250this.doHideHover();251}252hoverDisposables.dispose();253}, undefined, hoverDisposables);254// Set the container explicitly to enable aux window support255if (!options.container) {256const targetElement = isHTMLElement(options.target) ? options.target : options.target.targetElements[0];257options.container = this._layoutService.getContainer(getWindow(targetElement));258}259260hover.onRequestLayout(() => this._contextViewHandler.layout(), undefined, hoverDisposables);261if (options.persistence?.sticky) {262hoverDisposables.add(addDisposableListener(getWindow(options.container).document, EventType.MOUSE_DOWN, e => {263if (!isAncestor(e.target as HTMLElement, hover.domNode)) {264this.doHideHover();265}266}));267} else {268if ('targetElements' in options.target) {269for (const element of options.target.targetElements) {270hoverDisposables.add(addDisposableListener(element, EventType.CLICK, () => this.hideHover()));271}272} else {273hoverDisposables.add(addDisposableListener(options.target, EventType.CLICK, () => this.hideHover()));274}275const focusedElement = getActiveElement();276if (focusedElement) {277const focusedElementDocument = getWindow(focusedElement).document;278hoverDisposables.add(addDisposableListener(focusedElement, EventType.KEY_DOWN, e => this._keyDown(e, hover, !!options.persistence?.hideOnKeyDown)));279hoverDisposables.add(addDisposableListener(focusedElementDocument, EventType.KEY_DOWN, e => this._keyDown(e, hover, !!options.persistence?.hideOnKeyDown)));280hoverDisposables.add(addDisposableListener(focusedElement, EventType.KEY_UP, e => this._keyUp(e, hover)));281hoverDisposables.add(addDisposableListener(focusedElementDocument, EventType.KEY_UP, e => this._keyUp(e, hover)));282}283}284285if ('IntersectionObserver' in mainWindow) {286const observer = new IntersectionObserver(e => this._intersectionChange(e, hover), { threshold: 0 });287const firstTargetElement = 'targetElements' in options.target ? options.target.targetElements[0] : options.target;288observer.observe(firstTargetElement);289hoverDisposables.add(toDisposable(() => observer.disconnect()));290}291292this._currentHover = hover;293294return hover;295}296297private _showHover(hover: HoverWidget, options: IHoverOptions, focus?: boolean) {298this._contextViewHandler.showContextView(299new HoverContextViewDelegate(hover, focus),300options.container301);302}303304hideHover(force?: boolean): void {305if ((!force && this._currentHover?.isLocked) || !this._currentHoverOptions) {306return;307}308this.doHideHover();309}310311private doHideHover(): void {312this._currentHover = undefined;313this._currentHoverOptions = undefined;314this._contextViewHandler.hideContextView();315}316317private _intersectionChange(entries: IntersectionObserverEntry[], hover: IDisposable): void {318const entry = entries[entries.length - 1];319if (!entry.isIntersecting) {320hover.dispose();321}322}323324showAndFocusLastHover(): void {325if (!this._lastHoverOptions) {326return;327}328this.showInstantHover(this._lastHoverOptions, true, true);329}330331private _showAndFocusHoverForActiveElement(): void {332// TODO: if hover is visible, focus it to avoid flickering333334let activeElement = getActiveElement() as HTMLElement | null;335while (activeElement) {336const hover = this._delayedHovers.get(activeElement) ?? this._managedHovers.get(activeElement);337if (hover) {338hover.show(true);339return;340}341342activeElement = activeElement.parentElement;343}344}345346private _keyDown(e: KeyboardEvent, hover: HoverWidget, hideOnKeyDown: boolean) {347if (e.key === 'Alt') {348hover.isLocked = true;349return;350}351const event = new StandardKeyboardEvent(e);352const keybinding = this._keybindingService.resolveKeyboardEvent(event);353if (keybinding.getSingleModifierDispatchChords().some(value => !!value) || this._keybindingService.softDispatch(event, event.target).kind !== ResultKind.NoMatchingKb) {354return;355}356if (hideOnKeyDown && (!this._currentHoverOptions?.trapFocus || e.key !== 'Tab')) {357this.hideHover();358this._lastFocusedElementBeforeOpen?.focus();359}360}361362private _keyUp(e: KeyboardEvent, hover: HoverWidget) {363if (e.key === 'Alt') {364hover.isLocked = false;365// Hide if alt is released while the mouse is not over hover/target366if (!hover.isMouseIn) {367this.hideHover();368this._lastFocusedElementBeforeOpen?.focus();369}370}371}372373// TODO: Investigate performance of this function. There seems to be a lot of content created374// and thrown away on start up375setupManagedHover(hoverDelegate: IHoverDelegate, targetElement: HTMLElement, content: IManagedHoverContentOrFactory, options?: IManagedHoverOptions | undefined): IManagedHover {376if (hoverDelegate.showNativeHover) {377return setupNativeHover(targetElement, content);378}379380targetElement.setAttribute('custom-hover', 'true');381382if (targetElement.title !== '') {383console.warn('HTML element already has a title attribute, which will conflict with the custom hover. Please remove the title attribute.');384console.trace('Stack trace:', targetElement.title);385targetElement.title = '';386}387388let hoverPreparation: IDisposable | undefined;389let hoverWidget: ManagedHoverWidget | undefined;390391const hideHover = (disposeWidget: boolean, disposePreparation: boolean) => {392const hadHover = hoverWidget !== undefined;393if (disposeWidget) {394hoverWidget?.dispose();395hoverWidget = undefined;396}397if (disposePreparation) {398hoverPreparation?.dispose();399hoverPreparation = undefined;400}401if (hadHover) {402hoverDelegate.onDidHideHover?.();403hoverWidget = undefined;404}405};406407const triggerShowHover = (delay: number, focus?: boolean, target?: IHoverDelegateTarget, trapFocus?: boolean) => {408return new TimeoutTimer(async () => {409if (!hoverWidget || hoverWidget.isDisposed) {410hoverWidget = new ManagedHoverWidget(hoverDelegate, target || targetElement, delay > 0);411await hoverWidget.update(typeof content === 'function' ? content() : content, focus, { ...options, trapFocus });412}413}, delay);414};415416const store = new DisposableStore();417let isMouseDown = false;418store.add(addDisposableListener(targetElement, EventType.MOUSE_DOWN, () => {419isMouseDown = true;420hideHover(true, true);421}, true));422store.add(addDisposableListener(targetElement, EventType.MOUSE_UP, () => {423isMouseDown = false;424}, true));425store.add(addDisposableListener(targetElement, EventType.MOUSE_LEAVE, (e: MouseEvent) => {426isMouseDown = false;427// HACK: `fromElement` is a non-standard property. Not sure what to replace it with,428// `relatedTarget` is NOT equivalent.429interface MouseEventWithFrom extends MouseEvent {430fromElement: Element | null;431}432hideHover(false, (e as MouseEventWithFrom).fromElement === targetElement);433}, true));434store.add(addDisposableListener(targetElement, EventType.MOUSE_OVER, (e: MouseEvent) => {435if (hoverPreparation) {436return;437}438439const mouseOverStore: DisposableStore = new DisposableStore();440441const target: IHoverDelegateTarget = {442targetElements: [targetElement],443dispose: () => { }444};445if (hoverDelegate.placement === undefined || hoverDelegate.placement === 'mouse') {446// track the mouse position447const onMouseMove = (e: MouseEvent) => {448target.x = e.x + 10;449if (!eventIsRelatedToTarget(e, targetElement)) {450hideHover(true, true);451}452};453mouseOverStore.add(addDisposableListener(targetElement, EventType.MOUSE_MOVE, onMouseMove, true));454}455456hoverPreparation = mouseOverStore;457458if (!eventIsRelatedToTarget(e, targetElement)) {459return; // Do not show hover when the mouse is over another hover target460}461462mouseOverStore.add(triggerShowHover(typeof hoverDelegate.delay === 'function' ? hoverDelegate.delay(content) : hoverDelegate.delay, false, target));463}, true));464465const onFocus = (e: FocusEvent) => {466if (isMouseDown || hoverPreparation) {467return;468}469if (!eventIsRelatedToTarget(e, targetElement)) {470return; // Do not show hover when the focus is on another hover target471}472473const target: IHoverDelegateTarget = {474targetElements: [targetElement],475dispose: () => { }476};477const toDispose: DisposableStore = new DisposableStore();478const onBlur = () => hideHover(true, true);479toDispose.add(addDisposableListener(targetElement, EventType.BLUR, onBlur, true));480toDispose.add(triggerShowHover(typeof hoverDelegate.delay === 'function' ? hoverDelegate.delay(content) : hoverDelegate.delay, false, target));481hoverPreparation = toDispose;482};483484// Do not show hover when focusing an input or textarea485if (!isEditableElement(targetElement)) {486store.add(addDisposableListener(targetElement, EventType.FOCUS, onFocus, true));487}488489const hover: IManagedHover = {490show: focus => {491hideHover(false, true); // terminate a ongoing mouse over preparation492triggerShowHover(0, focus, undefined, focus); // show hover immediately493},494hide: () => {495hideHover(true, true);496},497update: async (newContent, hoverOptions) => {498content = newContent;499await hoverWidget?.update(content, undefined, hoverOptions);500},501dispose: () => {502this._managedHovers.delete(targetElement);503store.dispose();504hideHover(true, true);505}506};507this._managedHovers.set(targetElement, hover);508return hover;509}510511showManagedHover(target: HTMLElement): void {512const hover = this._managedHovers.get(target);513if (hover) {514hover.show(true);515}516}517518public override dispose(): void {519this._managedHovers.forEach(hover => hover.dispose());520super.dispose();521}522}523524function getHoverOptionsIdentity(options: IHoverOptions | undefined): IHoverOptions | number | string | undefined {525if (options === undefined) {526return undefined;527}528return options?.id ?? options;529}530531function getHoverIdFromContent(content: string | HTMLElement | IMarkdownString): string | undefined {532if (isHTMLElement(content)) {533return undefined;534}535if (typeof content === 'string') {536return content.toString();537}538return content.value;539}540541function getStringContent(contentOrFactory: IManagedHoverContentOrFactory): string | undefined {542const content = typeof contentOrFactory === 'function' ? contentOrFactory() : contentOrFactory;543if (isString(content)) {544// Icons don't render in the native hover so we strip them out545return stripIcons(content);546}547if (isManagedHoverTooltipMarkdownString(content)) {548return content.markdownNotSupportedFallback;549}550return undefined;551}552553function setupNativeHover(targetElement: HTMLElement, content: IManagedHoverContentOrFactory): IManagedHover {554function updateTitle(title: string | undefined) {555if (title) {556targetElement.setAttribute('title', title);557} else {558targetElement.removeAttribute('title');559}560}561562updateTitle(getStringContent(content));563return {564update: (content) => updateTitle(getStringContent(content)),565show: () => { },566hide: () => { },567dispose: () => updateTitle(undefined),568};569}570571class HoverContextViewDelegate implements IDelegate {572573// Render over all other context views574public readonly layer = 1;575576get anchorPosition() {577return this._hover.anchor;578}579580constructor(581private readonly _hover: HoverWidget,582private readonly _focus: boolean = false583) {584}585586render(container: HTMLElement) {587this._hover.render(container);588if (this._focus) {589this._hover.focus();590}591return this._hover;592}593594getAnchor() {595return {596x: this._hover.x,597y: this._hover.y598};599}600601layout() {602this._hover.layout();603}604}605606function eventIsRelatedToTarget(event: UIEvent, target: HTMLElement): boolean {607return isHTMLElement(event.target) && getHoverTargetElement(event.target, target) === target;608}609610function getHoverTargetElement(element: HTMLElement, stopElement?: HTMLElement): HTMLElement {611stopElement = stopElement ?? getWindow(element).document.body;612while (!element.hasAttribute('custom-hover') && element !== stopElement) {613element = element.parentElement!;614}615return element;616}617618function resolveMouseStyleHoverTarget(target: HTMLElement, e: MouseEvent): IHoverTarget {619return {620targetElements: [target],621x: e.x + 10622};623}624625registerSingleton(IHoverService, HoverService, InstantiationType.Delayed);626627registerThemingParticipant((theme, collector) => {628const hoverBorder = theme.getColor(editorHoverBorder);629if (hoverBorder) {630collector.addRule(`.monaco-hover.workbench-hover .hover-row:not(:first-child):not(:empty) { border-top: 1px solid ${hoverBorder.transparent(0.5)}; }`);631collector.addRule(`.monaco-hover.workbench-hover hr { border-top: 1px solid ${hoverBorder.transparent(0.5)}; }`);632}633});634635636