Path: blob/main/src/vs/editor/browser/services/hoverService/hoverService.ts
3296 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 '../../../../platform/instantiation/common/extensions.js';6import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js';7import { editorHoverBorder } from '../../../../platform/theme/common/colorRegistry.js';8import { IHoverService } from '../../../../platform/hover/browser/hover.js';9import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';10import { IInstantiationService } from '../../../../platform/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 '../../../../platform/keybinding/common/keybinding.js';16import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';17import { ResultKind } from '../../../../platform/keybinding/common/keybindingResolver.js';18import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';19import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js';20import { mainWindow } from '../../../../base/browser/window.js';21import { ContextViewHandler } from '../../../../platform/contextview/browser/contextViewService.js';22import { isManagedHoverTooltipMarkdownString, type IHoverLifecycleOptions, type IHoverOptions, 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 '../../../../platform/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 '../../../../platform/keybinding/common/keybindingsRegistry.js';30import { EditorContextKeys } from '../../../common/editorContextKeys.js';31import { IMarkdownString } from '../../../../base/common/htmlContent.js';32import { stripIcons } from '../../../../base/common/iconLabels.js';3334export class HoverService extends Disposable implements IHoverService {35declare readonly _serviceBrand: undefined;3637private _contextViewHandler: IContextViewProvider;38private _currentHoverOptions: IHoverOptions | undefined;39private _currentHover: HoverWidget | undefined;40private _currentDelayedHover: HoverWidget | undefined;41private _currentDelayedHoverWasShown: boolean = false;42private _currentDelayedHoverGroupId: number | string | undefined;43private _lastHoverOptions: IHoverOptions | undefined;4445private _lastFocusedElementBeforeOpen: HTMLElement | undefined;4647private readonly _delayedHovers = new Map<HTMLElement, { show: (focus: boolean) => void }>();48private readonly _managedHovers = new Map<HTMLElement, IManagedHover>();4950constructor(51@IInstantiationService private readonly _instantiationService: IInstantiationService,52@IConfigurationService private readonly _configurationService: IConfigurationService,53@IContextMenuService contextMenuService: IContextMenuService,54@IKeybindingService private readonly _keybindingService: IKeybindingService,55@ILayoutService private readonly _layoutService: ILayoutService,56@IAccessibilityService private readonly _accessibilityService: IAccessibilityService57) {58super();5960this._register(contextMenuService.onDidShowContextMenu(() => this.hideHover()));61this._contextViewHandler = this._register(new ContextViewHandler(this._layoutService));6263this._register(KeybindingsRegistry.registerCommandAndKeybindingRule({64id: 'workbench.action.showHover',65weight: KeybindingWeight.WorkbenchContrib - 1,66when: EditorContextKeys.editorTextFocus.negate(),67primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.CtrlCmd | KeyCode.KeyI),68handler: () => { this._showAndFocusHoverForActiveElement(); },69}));70}7172showInstantHover(options: IHoverOptions, focus?: boolean, skipLastFocusedUpdate?: boolean, dontShow?: boolean): IHoverWidget | undefined {73const hover = this._createHover(options, skipLastFocusedUpdate);74if (!hover) {75return undefined;76}77this._showHover(hover, options, focus);78return hover;79}8081showDelayedHover(82options: IHoverOptions,83lifecycleOptions: Pick<IHoverLifecycleOptions, 'groupId'>,84): IHoverWidget | undefined {85// Set `id` to default if it's undefined86if (options.id === undefined) {87options.id = getHoverIdFromContent(options.content);88}8990if (!this._currentDelayedHover || this._currentDelayedHoverWasShown) {91// Current hover is locked, reject92if (this._currentHover?.isLocked) {93return undefined;94}9596// Identity is the same, return current hover97if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {98return this._currentHover;99}100101// Check group identity, if it's the same skip the delay and show the hover immediately102if (this._currentHover && !this._currentHover.isDisposed && this._currentDelayedHoverGroupId !== undefined && this._currentDelayedHoverGroupId === lifecycleOptions?.groupId) {103return this.showInstantHover({104...options,105appearance: {106...options.appearance,107skipFadeInAnimation: true108}109});110}111} else if (this._currentDelayedHover && getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {112// If the hover is the same but timeout is not finished yet, return the current hover113return this._currentDelayedHover;114}115116const hover = this._createHover(options, undefined);117if (!hover) {118this._currentDelayedHover = undefined;119this._currentDelayedHoverWasShown = false;120this._currentDelayedHoverGroupId = undefined;121return undefined;122}123124this._currentDelayedHover = hover;125this._currentDelayedHoverWasShown = false;126this._currentDelayedHoverGroupId = lifecycleOptions?.groupId;127128timeout(this._configurationService.getValue<number>('workbench.hover.delay')).then(() => {129if (hover && !hover.isDisposed) {130this._currentDelayedHoverWasShown = true;131this._showHover(hover, options);132}133});134135return hover;136}137138setupDelayedHover(139target: HTMLElement,140options: (() => Omit<IHoverOptions, 'target'>) | Omit<IHoverOptions, 'target'>,141lifecycleOptions?: IHoverLifecycleOptions,142): IDisposable {143const resolveHoverOptions = () => ({144...typeof options === 'function' ? options() : options,145target146} satisfies IHoverOptions);147return this._setupDelayedHover(target, resolveHoverOptions, lifecycleOptions);148}149150setupDelayedHoverAtMouse(151target: HTMLElement,152options: (() => Omit<IHoverOptions, 'target' | 'position'>) | Omit<IHoverOptions, 'target' | 'position'>,153lifecycleOptions?: IHoverLifecycleOptions,154): IDisposable {155const resolveHoverOptions = (e?: MouseEvent) => ({156...typeof options === 'function' ? options() : options,157target: {158targetElements: [target],159x: e !== undefined ? e.x + 10 : undefined,160}161} satisfies IHoverOptions);162return this._setupDelayedHover(target, resolveHoverOptions, lifecycleOptions);163}164165private _setupDelayedHover(166target: HTMLElement,167resolveHoverOptions: ((e?: MouseEvent) => IHoverOptions),168lifecycleOptions?: IHoverLifecycleOptions,169) {170const store = new DisposableStore();171store.add(addDisposableListener(target, EventType.MOUSE_OVER, e => {172this.showDelayedHover(resolveHoverOptions(e), {173groupId: lifecycleOptions?.groupId174});175}));176if (lifecycleOptions?.setupKeyboardEvents) {177store.add(addDisposableListener(target, EventType.KEY_DOWN, e => {178const evt = new StandardKeyboardEvent(e);179if (evt.equals(KeyCode.Space) || evt.equals(KeyCode.Enter)) {180this.showInstantHover(resolveHoverOptions(), true);181}182}));183}184185this._delayedHovers.set(target, { show: (focus: boolean) => { this.showInstantHover(resolveHoverOptions(), focus); } });186store.add(toDisposable(() => this._delayedHovers.delete(target)));187188return store;189}190191private _createHover(options: IHoverOptions, skipLastFocusedUpdate?: boolean): HoverWidget | undefined {192this._currentDelayedHover = undefined;193194if (this._currentHover?.isLocked) {195return undefined;196}197198// Set `id` to default if it's undefined199if (options.id === undefined) {200options.id = getHoverIdFromContent(options.content);201}202203if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {204return undefined;205}206this._currentHoverOptions = options;207this._lastHoverOptions = options;208const trapFocus = options.trapFocus || this._accessibilityService.isScreenReaderOptimized();209const activeElement = getActiveElement();210// HACK, remove this check when #189076 is fixed211if (!skipLastFocusedUpdate) {212if (trapFocus && activeElement) {213if (!activeElement.classList.contains('monaco-hover')) {214this._lastFocusedElementBeforeOpen = activeElement as HTMLElement;215}216} else {217this._lastFocusedElementBeforeOpen = undefined;218}219}220221const hoverDisposables = new DisposableStore();222const hover = this._instantiationService.createInstance(HoverWidget, options);223if (options.persistence?.sticky) {224hover.isLocked = true;225}226227// Adjust target position when a mouse event is provided as the hover position228if (options.position?.hoverPosition && !isNumber(options.position.hoverPosition)) {229options.target = {230targetElements: isHTMLElement(options.target) ? [options.target] : options.target.targetElements,231x: options.position.hoverPosition.x + 10232};233}234235hover.onDispose(() => {236const hoverWasFocused = this._currentHover?.domNode && isAncestorOfActiveElement(this._currentHover.domNode);237if (hoverWasFocused) {238// Required to handle cases such as closing the hover with the escape key239this._lastFocusedElementBeforeOpen?.focus();240}241242// Only clear the current options if it's the current hover, the current options help243// reduce flickering when the same hover is shown multiple times244if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {245this.doHideHover();246}247hoverDisposables.dispose();248}, undefined, hoverDisposables);249// Set the container explicitly to enable aux window support250if (!options.container) {251const targetElement = isHTMLElement(options.target) ? options.target : options.target.targetElements[0];252options.container = this._layoutService.getContainer(getWindow(targetElement));253}254255hover.onRequestLayout(() => this._contextViewHandler.layout(), undefined, hoverDisposables);256if (options.persistence?.sticky) {257hoverDisposables.add(addDisposableListener(getWindow(options.container).document, EventType.MOUSE_DOWN, e => {258if (!isAncestor(e.target as HTMLElement, hover.domNode)) {259this.doHideHover();260}261}));262} else {263if ('targetElements' in options.target) {264for (const element of options.target.targetElements) {265hoverDisposables.add(addDisposableListener(element, EventType.CLICK, () => this.hideHover()));266}267} else {268hoverDisposables.add(addDisposableListener(options.target, EventType.CLICK, () => this.hideHover()));269}270const focusedElement = getActiveElement();271if (focusedElement) {272const focusedElementDocument = getWindow(focusedElement).document;273hoverDisposables.add(addDisposableListener(focusedElement, EventType.KEY_DOWN, e => this._keyDown(e, hover, !!options.persistence?.hideOnKeyDown)));274hoverDisposables.add(addDisposableListener(focusedElementDocument, EventType.KEY_DOWN, e => this._keyDown(e, hover, !!options.persistence?.hideOnKeyDown)));275hoverDisposables.add(addDisposableListener(focusedElement, EventType.KEY_UP, e => this._keyUp(e, hover)));276hoverDisposables.add(addDisposableListener(focusedElementDocument, EventType.KEY_UP, e => this._keyUp(e, hover)));277}278}279280if ('IntersectionObserver' in mainWindow) {281const observer = new IntersectionObserver(e => this._intersectionChange(e, hover), { threshold: 0 });282const firstTargetElement = 'targetElements' in options.target ? options.target.targetElements[0] : options.target;283observer.observe(firstTargetElement);284hoverDisposables.add(toDisposable(() => observer.disconnect()));285}286287this._currentHover = hover;288289return hover;290}291292private _showHover(hover: HoverWidget, options: IHoverOptions, focus?: boolean) {293this._contextViewHandler.showContextView(294new HoverContextViewDelegate(hover, focus),295options.container296);297}298299hideHover(force?: boolean): void {300if ((!force && this._currentHover?.isLocked) || !this._currentHoverOptions) {301return;302}303this.doHideHover();304}305306private doHideHover(): void {307this._currentHover = undefined;308this._currentHoverOptions = undefined;309this._contextViewHandler.hideContextView();310}311312private _intersectionChange(entries: IntersectionObserverEntry[], hover: IDisposable): void {313const entry = entries[entries.length - 1];314if (!entry.isIntersecting) {315hover.dispose();316}317}318319showAndFocusLastHover(): void {320if (!this._lastHoverOptions) {321return;322}323this.showInstantHover(this._lastHoverOptions, true, true);324}325326private _showAndFocusHoverForActiveElement(): void {327// TODO: if hover is visible, focus it to avoid flickering328329let activeElement = getActiveElement() as HTMLElement | null;330while (activeElement) {331const hover = this._delayedHovers.get(activeElement) ?? this._managedHovers.get(activeElement);332if (hover) {333hover.show(true);334return;335}336337activeElement = activeElement.parentElement;338}339}340341private _keyDown(e: KeyboardEvent, hover: HoverWidget, hideOnKeyDown: boolean) {342if (e.key === 'Alt') {343hover.isLocked = true;344return;345}346const event = new StandardKeyboardEvent(e);347const keybinding = this._keybindingService.resolveKeyboardEvent(event);348if (keybinding.getSingleModifierDispatchChords().some(value => !!value) || this._keybindingService.softDispatch(event, event.target).kind !== ResultKind.NoMatchingKb) {349return;350}351if (hideOnKeyDown && (!this._currentHoverOptions?.trapFocus || e.key !== 'Tab')) {352this.hideHover();353this._lastFocusedElementBeforeOpen?.focus();354}355}356357private _keyUp(e: KeyboardEvent, hover: HoverWidget) {358if (e.key === 'Alt') {359hover.isLocked = false;360// Hide if alt is released while the mouse is not over hover/target361if (!hover.isMouseIn) {362this.hideHover();363this._lastFocusedElementBeforeOpen?.focus();364}365}366}367368// TODO: Investigate performance of this function. There seems to be a lot of content created369// and thrown away on start up370setupManagedHover(hoverDelegate: IHoverDelegate, targetElement: HTMLElement, content: IManagedHoverContentOrFactory, options?: IManagedHoverOptions | undefined): IManagedHover {371if (hoverDelegate.showNativeHover) {372return setupNativeHover(targetElement, content);373}374375targetElement.setAttribute('custom-hover', 'true');376377if (targetElement.title !== '') {378console.warn('HTML element already has a title attribute, which will conflict with the custom hover. Please remove the title attribute.');379console.trace('Stack trace:', targetElement.title);380targetElement.title = '';381}382383let hoverPreparation: IDisposable | undefined;384let hoverWidget: ManagedHoverWidget | undefined;385386const hideHover = (disposeWidget: boolean, disposePreparation: boolean) => {387const hadHover = hoverWidget !== undefined;388if (disposeWidget) {389hoverWidget?.dispose();390hoverWidget = undefined;391}392if (disposePreparation) {393hoverPreparation?.dispose();394hoverPreparation = undefined;395}396if (hadHover) {397hoverDelegate.onDidHideHover?.();398hoverWidget = undefined;399}400};401402const triggerShowHover = (delay: number, focus?: boolean, target?: IHoverDelegateTarget, trapFocus?: boolean) => {403return new TimeoutTimer(async () => {404if (!hoverWidget || hoverWidget.isDisposed) {405hoverWidget = new ManagedHoverWidget(hoverDelegate, target || targetElement, delay > 0);406await hoverWidget.update(typeof content === 'function' ? content() : content, focus, { ...options, trapFocus });407}408}, delay);409};410411const store = new DisposableStore();412let isMouseDown = false;413store.add(addDisposableListener(targetElement, EventType.MOUSE_DOWN, () => {414isMouseDown = true;415hideHover(true, true);416}, true));417store.add(addDisposableListener(targetElement, EventType.MOUSE_UP, () => {418isMouseDown = false;419}, true));420store.add(addDisposableListener(targetElement, EventType.MOUSE_LEAVE, (e: MouseEvent) => {421isMouseDown = false;422hideHover(false, (<any>e).fromElement === targetElement);423}, true));424store.add(addDisposableListener(targetElement, EventType.MOUSE_OVER, (e: MouseEvent) => {425if (hoverPreparation) {426return;427}428429const mouseOverStore: DisposableStore = new DisposableStore();430431const target: IHoverDelegateTarget = {432targetElements: [targetElement],433dispose: () => { }434};435if (hoverDelegate.placement === undefined || hoverDelegate.placement === 'mouse') {436// track the mouse position437const onMouseMove = (e: MouseEvent) => {438target.x = e.x + 10;439if ((isHTMLElement(e.target)) && getHoverTargetElement(e.target, targetElement) !== targetElement) {440hideHover(true, true);441}442};443mouseOverStore.add(addDisposableListener(targetElement, EventType.MOUSE_MOVE, onMouseMove, true));444}445446hoverPreparation = mouseOverStore;447448if ((isHTMLElement(e.target)) && getHoverTargetElement(e.target as HTMLElement, targetElement) !== targetElement) {449return; // Do not show hover when the mouse is over another hover target450}451452mouseOverStore.add(triggerShowHover(typeof hoverDelegate.delay === 'function' ? hoverDelegate.delay(content) : hoverDelegate.delay, false, target));453}, true));454455const onFocus = () => {456if (isMouseDown || hoverPreparation) {457return;458}459const target: IHoverDelegateTarget = {460targetElements: [targetElement],461dispose: () => { }462};463const toDispose: DisposableStore = new DisposableStore();464const onBlur = () => hideHover(true, true);465toDispose.add(addDisposableListener(targetElement, EventType.BLUR, onBlur, true));466toDispose.add(triggerShowHover(typeof hoverDelegate.delay === 'function' ? hoverDelegate.delay(content) : hoverDelegate.delay, false, target));467hoverPreparation = toDispose;468};469470// Do not show hover when focusing an input or textarea471if (!isEditableElement(targetElement)) {472store.add(addDisposableListener(targetElement, EventType.FOCUS, onFocus, true));473}474475const hover: IManagedHover = {476show: focus => {477hideHover(false, true); // terminate a ongoing mouse over preparation478triggerShowHover(0, focus, undefined, focus); // show hover immediately479},480hide: () => {481hideHover(true, true);482},483update: async (newContent, hoverOptions) => {484content = newContent;485await hoverWidget?.update(content, undefined, hoverOptions);486},487dispose: () => {488this._managedHovers.delete(targetElement);489store.dispose();490hideHover(true, true);491}492};493this._managedHovers.set(targetElement, hover);494return hover;495}496497showManagedHover(target: HTMLElement): void {498const hover = this._managedHovers.get(target);499if (hover) {500hover.show(true);501}502}503504public override dispose(): void {505this._managedHovers.forEach(hover => hover.dispose());506super.dispose();507}508}509510function getHoverOptionsIdentity(options: IHoverOptions | undefined): IHoverOptions | number | string | undefined {511if (options === undefined) {512return undefined;513}514return options?.id ?? options;515}516517function getHoverIdFromContent(content: string | HTMLElement | IMarkdownString): string | undefined {518if (isHTMLElement(content)) {519return undefined;520}521if (typeof content === 'string') {522return content.toString();523}524return content.value;525}526527function getStringContent(contentOrFactory: IManagedHoverContentOrFactory): string | undefined {528const content = typeof contentOrFactory === 'function' ? contentOrFactory() : contentOrFactory;529if (isString(content)) {530// Icons don't render in the native hover so we strip them out531return stripIcons(content);532}533if (isManagedHoverTooltipMarkdownString(content)) {534return content.markdownNotSupportedFallback;535}536return undefined;537}538539function setupNativeHover(targetElement: HTMLElement, content: IManagedHoverContentOrFactory): IManagedHover {540function updateTitle(title: string | undefined) {541if (title) {542targetElement.setAttribute('title', title);543} else {544targetElement.removeAttribute('title');545}546}547548updateTitle(getStringContent(content));549return {550update: (content) => updateTitle(getStringContent(content)),551show: () => { },552hide: () => { },553dispose: () => updateTitle(undefined),554};555}556557class HoverContextViewDelegate implements IDelegate {558559// Render over all other context views560public readonly layer = 1;561562get anchorPosition() {563return this._hover.anchor;564}565566constructor(567private readonly _hover: HoverWidget,568private readonly _focus: boolean = false569) {570}571572render(container: HTMLElement) {573this._hover.render(container);574if (this._focus) {575this._hover.focus();576}577return this._hover;578}579580getAnchor() {581return {582x: this._hover.x,583y: this._hover.y584};585}586587layout() {588this._hover.layout();589}590}591592function getHoverTargetElement(element: HTMLElement, stopElement?: HTMLElement): HTMLElement {593stopElement = stopElement ?? getWindow(element).document.body;594while (!element.hasAttribute('custom-hover') && element !== stopElement) {595element = element.parentElement!;596}597return element;598}599600registerSingleton(IHoverService, HoverService, InstantiationType.Delayed);601602registerThemingParticipant((theme, collector) => {603const hoverBorder = theme.getColor(editorHoverBorder);604if (hoverBorder) {605collector.addRule(`.monaco-workbench .workbench-hover .hover-row:not(:first-child):not(:empty) { border-top: 1px solid ${hoverBorder.transparent(0.5)}; }`);606collector.addRule(`.monaco-workbench .workbench-hover hr { border-top: 1px solid ${hoverBorder.transparent(0.5)}; }`);607}608});609610611