Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/hover/browser/hoverService.ts
5230 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js';
7
import { registerThemingParticipant } from '../../theme/common/themeService.js';
8
import { editorHoverBorder } from '../../theme/common/colorRegistry.js';
9
import { IHoverService } from './hover.js';
10
import { IContextMenuService } from '../../contextview/browser/contextView.js';
11
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
12
import { HoverWidget } from './hoverWidget.js';
13
import { ContextView, ContextViewDOMPosition, IDelegate } from '../../../base/browser/ui/contextview/contextview.js';
14
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
15
import { addDisposableListener, EventType, getActiveElement, isAncestorOfActiveElement, isAncestor, getWindow, isHTMLElement, isEditableElement } from '../../../base/browser/dom.js';
16
import { IKeybindingService } from '../../keybinding/common/keybinding.js';
17
import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js';
18
import { ResultKind } from '../../keybinding/common/keybindingResolver.js';
19
import { IAccessibilityService } from '../../accessibility/common/accessibility.js';
20
import { ILayoutService } from '../../layout/browser/layoutService.js';
21
import { mainWindow } from '../../../base/browser/window.js';
22
import { HoverStyle, isManagedHoverTooltipMarkdownString, type IHoverLifecycleOptions, type IHoverOptions, type IHoverTarget, type IHoverWidget, type IManagedHover, type IManagedHoverContentOrFactory, type IManagedHoverOptions } from '../../../base/browser/ui/hover/hover.js';
23
import type { IHoverDelegate, IHoverDelegateTarget } from '../../../base/browser/ui/hover/hoverDelegate.js';
24
import { ManagedHoverWidget } from './updatableHoverWidget.js';
25
import { timeout, TimeoutTimer } from '../../../base/common/async.js';
26
import { IConfigurationService } from '../../configuration/common/configuration.js';
27
import { isNumber, isString } from '../../../base/common/types.js';
28
import { KeyChord, KeyCode, KeyMod } from '../../../base/common/keyCodes.js';
29
import { KeybindingsRegistry, KeybindingWeight } from '../../keybinding/common/keybindingsRegistry.js';
30
import { IMarkdownString } from '../../../base/common/htmlContent.js';
31
import { stripIcons } from '../../../base/common/iconLabels.js';
32
33
/**
34
* Maximum nesting depth for hovers. This prevents runaway nesting.
35
*/
36
const MAX_HOVER_NESTING_DEPTH = 3;
37
38
/**
39
* An entry in the hover stack, representing a single hover and its associated state.
40
*/
41
interface IHoverStackEntry {
42
readonly hover: HoverWidget;
43
readonly options: IHoverOptions;
44
readonly contextView: ContextView;
45
readonly lastFocusedElementBeforeOpen: HTMLElement | undefined;
46
}
47
48
/**
49
* Result of creating a hover, containing the hover widget and associated state.
50
*/
51
interface ICreateHoverResult {
52
readonly hover: HoverWidget;
53
readonly store: DisposableStore;
54
readonly lastFocusedElementBeforeOpen: HTMLElement | undefined;
55
}
56
57
export class HoverService extends Disposable implements IHoverService {
58
declare readonly _serviceBrand: undefined;
59
60
/**
61
* Stack of currently visible hovers. The last entry is the topmost hover.
62
* This enables nested hovers where hovering inside a hover can show another hover.
63
*/
64
private readonly _hoverStack: IHoverStackEntry[] = [];
65
66
private _currentDelayedHover: HoverWidget | undefined;
67
private _currentDelayedHoverWasShown: boolean = false;
68
private _currentDelayedHoverGroupId: number | string | undefined;
69
private _lastHoverOptions: IHoverOptions | undefined;
70
private readonly _delayedHovers = new Map<HTMLElement, { show: (focus: boolean) => void }>();
71
private readonly _managedHovers = new Map<HTMLElement, IManagedHover>();
72
73
/**
74
* Gets the current (topmost) hover from the stack, if any.
75
*/
76
private get _currentHover(): HoverWidget | undefined {
77
return this._hoverStack.at(-1)?.hover;
78
}
79
80
/**
81
* Gets the current (topmost) hover options from the stack, if any.
82
*/
83
private get _currentHoverOptions(): IHoverOptions | undefined {
84
return this._hoverStack.at(-1)?.options;
85
}
86
87
/**
88
* Returns whether the target element is inside any of the hovers in the stack.
89
* If it is, returns the index of the containing hover, otherwise returns -1.
90
*/
91
private _getContainingHoverIndex(target: HTMLElement | IHoverTarget): number {
92
const targetElements = isHTMLElement(target) ? [target] : target.targetElements;
93
// Search from top of stack to bottom (most recent hover first)
94
for (let i = this._hoverStack.length - 1; i >= 0; i--) {
95
for (const targetElement of targetElements) {
96
if (isAncestor(targetElement, this._hoverStack[i].hover.domNode)) {
97
return i;
98
}
99
}
100
}
101
return -1;
102
}
103
104
constructor(
105
@IInstantiationService private readonly _instantiationService: IInstantiationService,
106
@IConfigurationService private readonly _configurationService: IConfigurationService,
107
@IContextMenuService contextMenuService: IContextMenuService,
108
@IKeybindingService private readonly _keybindingService: IKeybindingService,
109
@ILayoutService private readonly _layoutService: ILayoutService,
110
@IAccessibilityService private readonly _accessibilityService: IAccessibilityService
111
) {
112
super();
113
114
this._register(contextMenuService.onDidShowContextMenu(() => this.hideHover()));
115
116
this._register(KeybindingsRegistry.registerCommandAndKeybindingRule({
117
id: 'workbench.action.showHover',
118
weight: KeybindingWeight.EditorCore,
119
primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.CtrlCmd | KeyCode.KeyI),
120
handler: () => { this._showAndFocusHoverForActiveElement(); },
121
}));
122
}
123
124
showInstantHover(options: IHoverOptions, focus?: boolean, skipLastFocusedUpdate?: boolean, dontShow?: boolean): IHoverWidget | undefined {
125
const hover = this._createHover(options, skipLastFocusedUpdate);
126
if (!hover) {
127
return undefined;
128
}
129
this._showHover(hover, options, focus);
130
return hover.hover;
131
}
132
133
showDelayedHover(
134
options: IHoverOptions,
135
lifecycleOptions: Pick<IHoverLifecycleOptions, 'groupId' | 'reducedDelay'>,
136
): IHoverWidget | undefined {
137
// Set `id` to default if it's undefined
138
if (options.id === undefined) {
139
options.id = getHoverIdFromContent(options.content);
140
}
141
142
if (!this._currentDelayedHover || this._currentDelayedHoverWasShown) {
143
// Current hover is locked, reject
144
if (this._currentHover?.isLocked) {
145
return undefined;
146
}
147
148
// Identity is the same, return current hover
149
if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {
150
return this._currentHover;
151
}
152
153
// Check group identity, if it's the same skip the delay and show the hover immediately
154
if (this._currentHover && !this._currentHover.isDisposed && this._currentDelayedHoverGroupId !== undefined && this._currentDelayedHoverGroupId === lifecycleOptions?.groupId) {
155
return this.showInstantHover({
156
...options,
157
appearance: {
158
...options.appearance,
159
skipFadeInAnimation: true
160
}
161
});
162
}
163
} else if (this._currentDelayedHover && getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {
164
// If the hover is the same but timeout is not finished yet, return the current hover
165
return this._currentDelayedHover;
166
}
167
168
const hover = this._createHover(options, undefined);
169
if (!hover) {
170
this._currentDelayedHover = undefined;
171
this._currentDelayedHoverWasShown = false;
172
this._currentDelayedHoverGroupId = undefined;
173
return undefined;
174
}
175
176
this._currentDelayedHover = hover.hover;
177
this._currentDelayedHoverWasShown = false;
178
this._currentDelayedHoverGroupId = lifecycleOptions?.groupId;
179
180
const delay = lifecycleOptions?.reducedDelay
181
? this._configurationService.getValue<number>('workbench.hover.reducedDelay')
182
: this._configurationService.getValue<number>('workbench.hover.delay');
183
timeout(delay).then(() => {
184
if (hover.hover && !hover.hover.isDisposed) {
185
this._currentDelayedHoverWasShown = true;
186
this._showHover(hover, options);
187
}
188
});
189
190
return hover.hover;
191
}
192
193
setupDelayedHover(
194
target: HTMLElement,
195
options: (() => Omit<IHoverOptions, 'target'>) | Omit<IHoverOptions, 'target'>,
196
lifecycleOptions?: IHoverLifecycleOptions,
197
): IDisposable {
198
const resolveHoverOptions = (e?: MouseEvent) => {
199
const resolved: IHoverOptions = {
200
...typeof options === 'function' ? options() : options,
201
target
202
};
203
if (resolved.style === HoverStyle.Mouse && e) {
204
resolved.target = resolveMouseStyleHoverTarget(target, e);
205
}
206
return resolved;
207
};
208
return this._setupDelayedHover(target, resolveHoverOptions, lifecycleOptions);
209
}
210
211
setupDelayedHoverAtMouse(
212
target: HTMLElement,
213
options: (() => Omit<IHoverOptions, 'target' | 'position'>) | Omit<IHoverOptions, 'target' | 'position'>,
214
lifecycleOptions?: IHoverLifecycleOptions,
215
): IDisposable {
216
const resolveHoverOptions = (e?: MouseEvent) => ({
217
...typeof options === 'function' ? options() : options,
218
target: e ? resolveMouseStyleHoverTarget(target, e) : target
219
} satisfies IHoverOptions);
220
return this._setupDelayedHover(target, resolveHoverOptions, lifecycleOptions);
221
}
222
223
private _setupDelayedHover(
224
target: HTMLElement,
225
resolveHoverOptions: ((e?: MouseEvent) => IHoverOptions),
226
lifecycleOptions?: IHoverLifecycleOptions,
227
) {
228
const store = new DisposableStore();
229
store.add(addDisposableListener(target, EventType.MOUSE_OVER, e => {
230
this.showDelayedHover(resolveHoverOptions(e), {
231
groupId: lifecycleOptions?.groupId,
232
reducedDelay: lifecycleOptions?.reducedDelay,
233
});
234
}));
235
if (lifecycleOptions?.setupKeyboardEvents) {
236
store.add(addDisposableListener(target, EventType.KEY_DOWN, e => {
237
const evt = new StandardKeyboardEvent(e);
238
if (evt.equals(KeyCode.Space) || evt.equals(KeyCode.Enter)) {
239
this.showInstantHover(resolveHoverOptions(), true);
240
}
241
}));
242
}
243
244
this._delayedHovers.set(target, { show: (focus: boolean) => { this.showInstantHover(resolveHoverOptions(), focus); } });
245
store.add(toDisposable(() => this._delayedHovers.delete(target)));
246
247
return store;
248
}
249
250
private _createHover(options: IHoverOptions, skipLastFocusedUpdate?: boolean): ICreateHoverResult | undefined {
251
this._currentDelayedHover = undefined;
252
253
if (options.content === '') {
254
return undefined;
255
}
256
257
// Set `id` to default if it's undefined
258
if (options.id === undefined) {
259
options.id = getHoverIdFromContent(options.content);
260
}
261
262
// Check if the target is inside an existing hover (nesting scenario)
263
const containingHoverIndex = this._getContainingHoverIndex(options.target);
264
const isNesting = containingHoverIndex >= 0;
265
266
if (isNesting) {
267
// Check max nesting depth
268
if (this._hoverStack.length >= MAX_HOVER_NESTING_DEPTH) {
269
return undefined;
270
}
271
// When nesting, don't check if the parent is locked - we allow nested hovers inside locked parents
272
} else {
273
// Not nesting: check if current top-level hover is locked
274
if (this._currentHover?.isLocked) {
275
return undefined;
276
}
277
278
// Check if identity is the same as current hover
279
if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {
280
return undefined;
281
}
282
}
283
284
this._lastHoverOptions = options;
285
const trapFocus = options.trapFocus || this._accessibilityService.isScreenReaderOptimized();
286
const activeElement = getActiveElement();
287
let lastFocusedElementBeforeOpen: HTMLElement | undefined;
288
// HACK, remove this check when #189076 is fixed
289
if (!skipLastFocusedUpdate) {
290
if (trapFocus && activeElement) {
291
if (!activeElement.classList.contains('monaco-hover')) {
292
lastFocusedElementBeforeOpen = activeElement as HTMLElement;
293
}
294
}
295
}
296
297
const hoverDisposables = new DisposableStore();
298
const hover = this._instantiationService.createInstance(HoverWidget, options);
299
if (options.persistence?.sticky) {
300
hover.isLocked = true;
301
}
302
303
// Adjust target position when a mouse event is provided as the hover position
304
if (options.position?.hoverPosition && !isNumber(options.position.hoverPosition)) {
305
options.target = {
306
targetElements: isHTMLElement(options.target) ? [options.target] : options.target.targetElements,
307
x: options.position.hoverPosition.x + 10
308
};
309
}
310
311
hover.onDispose(() => {
312
// Pop this hover from the stack if it's still there
313
const stackIndex = this._hoverStack.findIndex(entry => entry.hover === hover);
314
if (stackIndex >= 0) {
315
const entry = this._hoverStack[stackIndex];
316
// Restore focus if this hover was focused
317
const hoverWasFocused = isAncestorOfActiveElement(hover.domNode);
318
if (hoverWasFocused && entry.lastFocusedElementBeforeOpen) {
319
entry.lastFocusedElementBeforeOpen.focus();
320
}
321
// Also dispose all nested hovers (hovers at higher indices in the stack)
322
// Dispose from end to avoid index shifting issues
323
while (this._hoverStack.length > stackIndex + 1) {
324
const nestedEntry = this._hoverStack.pop()!;
325
nestedEntry.contextView.dispose();
326
nestedEntry.hover.dispose();
327
}
328
// Remove this hover from stack and dispose its context view
329
this._hoverStack.splice(stackIndex, 1);
330
entry.contextView.dispose();
331
}
332
hoverDisposables.dispose();
333
}, undefined, hoverDisposables);
334
335
// Set the container explicitly to enable aux window support
336
if (!options.container) {
337
const targetElement = isHTMLElement(options.target) ? options.target : options.target.targetElements[0];
338
options.container = this._layoutService.getContainer(getWindow(targetElement));
339
}
340
341
if (options.persistence?.sticky) {
342
hoverDisposables.add(addDisposableListener(getWindow(options.container).document, EventType.MOUSE_DOWN, e => {
343
if (!isAncestor(e.target as HTMLElement, hover.domNode)) {
344
this._hideHoverAndDescendants(hover);
345
}
346
}));
347
} else {
348
if ('targetElements' in options.target) {
349
for (const element of options.target.targetElements) {
350
hoverDisposables.add(addDisposableListener(element, EventType.CLICK, () => this._hideHoverAndDescendants(hover)));
351
}
352
} else {
353
hoverDisposables.add(addDisposableListener(options.target, EventType.CLICK, () => this._hideHoverAndDescendants(hover)));
354
}
355
const focusedElement = getActiveElement();
356
if (focusedElement) {
357
const focusedElementDocument = getWindow(focusedElement).document;
358
hoverDisposables.add(addDisposableListener(focusedElement, EventType.KEY_DOWN, e => this._keyDown(e, hover, !!options.persistence?.hideOnKeyDown)));
359
hoverDisposables.add(addDisposableListener(focusedElementDocument, EventType.KEY_DOWN, e => this._keyDown(e, hover, !!options.persistence?.hideOnKeyDown)));
360
hoverDisposables.add(addDisposableListener(focusedElement, EventType.KEY_UP, e => this._keyUp(e, hover)));
361
hoverDisposables.add(addDisposableListener(focusedElementDocument, EventType.KEY_UP, e => this._keyUp(e, hover)));
362
}
363
}
364
365
if ('IntersectionObserver' in mainWindow) {
366
const observer = new IntersectionObserver(e => this._intersectionChange(e, hover), { threshold: 0 });
367
const firstTargetElement = 'targetElements' in options.target ? options.target.targetElements[0] : options.target;
368
observer.observe(firstTargetElement);
369
hoverDisposables.add(toDisposable(() => observer.disconnect()));
370
}
371
372
return { hover, lastFocusedElementBeforeOpen, store: hoverDisposables };
373
}
374
375
private _showHover(result: ICreateHoverResult, options: IHoverOptions, focus?: boolean) {
376
const { hover, lastFocusedElementBeforeOpen, store } = result;
377
378
// Check if the target is inside an existing hover (nesting scenario)
379
const containingHoverIndex = this._getContainingHoverIndex(options.target);
380
const isNesting = containingHoverIndex >= 0;
381
382
// If not nesting, close all existing hovers first
383
if (!isNesting) {
384
this._hideAllHovers();
385
} else {
386
// When nesting, close any sibling hovers (hovers at the same level or deeper
387
// than the containing hover). This ensures hovers within the same container
388
// are exclusive.
389
for (let i = this._hoverStack.length - 1; i > containingHoverIndex; i--) {
390
this._hoverStack[i].hover.dispose();
391
}
392
this._hoverStack.length = containingHoverIndex + 1;
393
}
394
395
// When nesting, add the new hover's container to all parent hovers' mouse trackers.
396
// This makes the parent hovers treat the nested hover as part of themselves,
397
// so they won't close when the mouse moves into the nested hover.
398
if (isNesting) {
399
for (let i = 0; i <= containingHoverIndex; i++) {
400
store.add(this._hoverStack[i].hover.addMouseTrackingElement(hover.domNode));
401
}
402
}
403
404
// Create a new ContextView for this hover with higher z-index for nested hovers
405
const container = options.container ?? this._layoutService.getContainer(getWindow(isHTMLElement(options.target) ? options.target : options.target.targetElements[0]));
406
const contextView = new ContextView(container, ContextViewDOMPosition.ABSOLUTE);
407
408
// Push to stack
409
const stackEntry: IHoverStackEntry = {
410
hover,
411
options,
412
contextView,
413
lastFocusedElementBeforeOpen
414
};
415
this._hoverStack.push(stackEntry);
416
417
// Show the hover in its context view
418
const delegate = new HoverContextViewDelegate(hover, focus, this._hoverStack.length);
419
contextView.show(delegate);
420
421
// Set up layout handling
422
store.add(hover.onRequestLayout(() => contextView.layout()));
423
424
options.onDidShow?.();
425
}
426
427
/**
428
* Hides a specific hover and all hovers nested inside it.
429
*/
430
private _hideHoverAndDescendants(hover: HoverWidget): void {
431
const stackIndex = this._hoverStack.findIndex(entry => entry.hover === hover);
432
if (stackIndex < 0) {
433
return;
434
}
435
436
// Dispose all hovers from this index onwards (including nested ones)
437
for (let i = this._hoverStack.length - 1; i >= stackIndex; i--) {
438
this._hoverStack[i].hover.dispose();
439
}
440
this._hoverStack.length = stackIndex;
441
}
442
443
/**
444
* Hides all hovers in the stack.
445
*/
446
private _hideAllHovers(): void {
447
for (let i = this._hoverStack.length - 1; i >= 0; i--) {
448
this._hoverStack[i].hover.dispose();
449
}
450
this._hoverStack.length = 0;
451
}
452
453
hideHover(force?: boolean): void {
454
if (this._hoverStack.length === 0) {
455
return;
456
}
457
458
// If not forcing and the topmost hover is locked, don't hide
459
if (!force && this._currentHover?.isLocked) {
460
return;
461
}
462
463
// Hide only the topmost hover (pop from stack)
464
this.doHideHover();
465
}
466
467
private doHideHover(): void {
468
// Pop and dispose the topmost hover
469
const length = this._hoverStack.length;
470
this._hoverStack[length - 1]?.hover.dispose();
471
this._hoverStack.length = length - 1;
472
473
// After popping a nested hover, unlock the parent if it was locked due to nesting
474
// (Note: the parent may have been explicitly locked via sticky, so we only unlock
475
// if there are remaining hovers and they're not sticky)
476
// For simplicity, we don't auto-unlock here - the parent remains in its current lock state
477
}
478
479
private _intersectionChange(entries: IntersectionObserverEntry[], hover: IDisposable): void {
480
const entry = entries[entries.length - 1];
481
if (!entry.isIntersecting) {
482
hover.dispose();
483
}
484
}
485
486
showAndFocusLastHover(): void {
487
if (!this._lastHoverOptions) {
488
return;
489
}
490
this.showInstantHover(this._lastHoverOptions, true, true);
491
}
492
493
private _showAndFocusHoverForActiveElement(): void {
494
// TODO: if hover is visible, focus it to avoid flickering
495
496
let activeElement = getActiveElement() as HTMLElement | null;
497
while (activeElement) {
498
const hover = this._delayedHovers.get(activeElement) ?? this._managedHovers.get(activeElement);
499
if (hover) {
500
hover.show(true);
501
return;
502
}
503
504
activeElement = activeElement.parentElement;
505
}
506
}
507
508
private _keyDown(e: KeyboardEvent, hover: HoverWidget, hideOnKeyDown: boolean) {
509
if (e.key === 'Alt') {
510
// Lock all hovers in the stack when Alt is pressed
511
for (const entry of this._hoverStack) {
512
entry.hover.isLocked = true;
513
}
514
return;
515
}
516
const event = new StandardKeyboardEvent(e);
517
const keybinding = this._keybindingService.resolveKeyboardEvent(event);
518
if (keybinding.getSingleModifierDispatchChords().some(value => !!value) || this._keybindingService.softDispatch(event, event.target).kind !== ResultKind.NoMatchingKb) {
519
return;
520
}
521
if (hideOnKeyDown && (!this._currentHoverOptions?.trapFocus || e.key !== 'Tab')) {
522
// Find the entry for this hover to get its lastFocusedElementBeforeOpen
523
const stackEntry = this._hoverStack.find(entry => entry.hover === hover);
524
this._hideHoverAndDescendants(hover);
525
stackEntry?.lastFocusedElementBeforeOpen?.focus();
526
}
527
}
528
529
private _keyUp(e: KeyboardEvent, hover: HoverWidget) {
530
if (e.key === 'Alt') {
531
// Unlock all hovers in the stack when Alt is released
532
for (const entry of this._hoverStack) {
533
// Only unlock if not sticky
534
if (!entry.options.persistence?.sticky) {
535
entry.hover.isLocked = false;
536
}
537
}
538
// Hide all hovers if the mouse is not over any of them
539
const anyMouseIn = this._hoverStack.some(entry => entry.hover.isMouseIn);
540
if (!anyMouseIn) {
541
const topEntry = this._hoverStack[this._hoverStack.length - 1];
542
this._hideAllHovers();
543
topEntry?.lastFocusedElementBeforeOpen?.focus();
544
}
545
}
546
}
547
548
// TODO: Investigate performance of this function. There seems to be a lot of content created
549
// and thrown away on start up
550
setupManagedHover(hoverDelegate: IHoverDelegate, targetElement: HTMLElement, content: IManagedHoverContentOrFactory, options?: IManagedHoverOptions | undefined): IManagedHover {
551
if (hoverDelegate.showNativeHover) {
552
return setupNativeHover(targetElement, content);
553
}
554
555
targetElement.setAttribute('custom-hover', 'true');
556
557
if (targetElement.title !== '') {
558
console.warn('HTML element already has a title attribute, which will conflict with the custom hover. Please remove the title attribute.');
559
console.trace('Stack trace:', targetElement.title);
560
targetElement.title = '';
561
}
562
563
let hoverPreparation: IDisposable | undefined;
564
let hoverWidget: ManagedHoverWidget | undefined;
565
566
const hideHover = (disposeWidget: boolean, disposePreparation: boolean) => {
567
const hadHover = hoverWidget !== undefined;
568
if (disposeWidget) {
569
hoverWidget?.dispose();
570
hoverWidget = undefined;
571
}
572
if (disposePreparation) {
573
hoverPreparation?.dispose();
574
hoverPreparation = undefined;
575
}
576
if (hadHover) {
577
hoverDelegate.onDidHideHover?.();
578
hoverWidget = undefined;
579
}
580
};
581
582
const triggerShowHover = (delay: number, focus?: boolean, target?: IHoverDelegateTarget, trapFocus?: boolean) => {
583
return new TimeoutTimer(async () => {
584
if (!hoverWidget || hoverWidget.isDisposed) {
585
hoverWidget = new ManagedHoverWidget(hoverDelegate, target || targetElement, delay > 0);
586
await hoverWidget.update(typeof content === 'function' ? content() : content, focus, { ...options, trapFocus });
587
}
588
}, delay);
589
};
590
591
const store = new DisposableStore();
592
let isMouseDown = false;
593
store.add(addDisposableListener(targetElement, EventType.MOUSE_DOWN, () => {
594
isMouseDown = true;
595
hideHover(true, true);
596
}, true));
597
store.add(addDisposableListener(targetElement, EventType.MOUSE_UP, () => {
598
isMouseDown = false;
599
}, true));
600
store.add(addDisposableListener(targetElement, EventType.MOUSE_LEAVE, (e: MouseEvent) => {
601
isMouseDown = false;
602
// HACK: `fromElement` is a non-standard property. Not sure what to replace it with,
603
// `relatedTarget` is NOT equivalent.
604
interface MouseEventWithFrom extends MouseEvent {
605
fromElement: Element | null;
606
}
607
hideHover(false, (e as MouseEventWithFrom).fromElement === targetElement);
608
}, true));
609
store.add(addDisposableListener(targetElement, EventType.MOUSE_OVER, (e: MouseEvent) => {
610
if (hoverPreparation) {
611
return;
612
}
613
614
const mouseOverStore: DisposableStore = new DisposableStore();
615
616
const target: IHoverDelegateTarget = {
617
targetElements: [targetElement],
618
dispose: () => { }
619
};
620
if (hoverDelegate.placement === undefined || hoverDelegate.placement === 'mouse') {
621
// track the mouse position
622
const onMouseMove = (e: MouseEvent) => {
623
target.x = e.x + 10;
624
if (!eventIsRelatedToTarget(e, targetElement)) {
625
hideHover(true, true);
626
}
627
};
628
mouseOverStore.add(addDisposableListener(targetElement, EventType.MOUSE_MOVE, onMouseMove, true));
629
}
630
631
hoverPreparation = mouseOverStore;
632
633
if (!eventIsRelatedToTarget(e, targetElement)) {
634
return; // Do not show hover when the mouse is over another hover target
635
}
636
637
mouseOverStore.add(triggerShowHover(typeof hoverDelegate.delay === 'function' ? hoverDelegate.delay(content) : hoverDelegate.delay, false, target));
638
}, true));
639
640
const onFocus = (e: FocusEvent) => {
641
if (isMouseDown || hoverPreparation) {
642
return;
643
}
644
if (!eventIsRelatedToTarget(e, targetElement)) {
645
return; // Do not show hover when the focus is on another hover target
646
}
647
648
const target: IHoverDelegateTarget = {
649
targetElements: [targetElement],
650
dispose: () => { }
651
};
652
const toDispose: DisposableStore = new DisposableStore();
653
const onBlur = () => hideHover(true, true);
654
toDispose.add(addDisposableListener(targetElement, EventType.BLUR, onBlur, true));
655
toDispose.add(triggerShowHover(typeof hoverDelegate.delay === 'function' ? hoverDelegate.delay(content) : hoverDelegate.delay, false, target));
656
hoverPreparation = toDispose;
657
};
658
659
// Do not show hover when focusing an input or textarea
660
if (!isEditableElement(targetElement)) {
661
store.add(addDisposableListener(targetElement, EventType.FOCUS, onFocus, true));
662
}
663
664
const hover: IManagedHover = {
665
show: focus => {
666
hideHover(false, true); // terminate a ongoing mouse over preparation
667
triggerShowHover(0, focus, undefined, focus); // show hover immediately
668
},
669
hide: () => {
670
hideHover(true, true);
671
},
672
update: async (newContent, hoverOptions) => {
673
content = newContent;
674
await hoverWidget?.update(content, undefined, hoverOptions);
675
},
676
dispose: () => {
677
this._managedHovers.delete(targetElement);
678
store.dispose();
679
hideHover(true, true);
680
}
681
};
682
this._managedHovers.set(targetElement, hover);
683
return hover;
684
}
685
686
showManagedHover(target: HTMLElement): void {
687
const hover = this._managedHovers.get(target);
688
if (hover) {
689
hover.show(true);
690
}
691
}
692
693
public override dispose(): void {
694
this._managedHovers.forEach(hover => hover.dispose());
695
super.dispose();
696
}
697
}
698
699
function getHoverOptionsIdentity(options: IHoverOptions | undefined): IHoverOptions | number | string | undefined {
700
if (options === undefined) {
701
return undefined;
702
}
703
return options?.id ?? options;
704
}
705
706
function getHoverIdFromContent(content: string | HTMLElement | IMarkdownString): string | undefined {
707
if (isHTMLElement(content)) {
708
return undefined;
709
}
710
if (typeof content === 'string') {
711
return content.toString();
712
}
713
return content.value;
714
}
715
716
function getStringContent(contentOrFactory: IManagedHoverContentOrFactory): string | undefined {
717
const content = typeof contentOrFactory === 'function' ? contentOrFactory() : contentOrFactory;
718
if (isString(content)) {
719
// Icons don't render in the native hover so we strip them out
720
return stripIcons(content);
721
}
722
if (isManagedHoverTooltipMarkdownString(content)) {
723
return content.markdownNotSupportedFallback;
724
}
725
return undefined;
726
}
727
728
function setupNativeHover(targetElement: HTMLElement, content: IManagedHoverContentOrFactory): IManagedHover {
729
function updateTitle(title: string | undefined) {
730
if (title) {
731
targetElement.setAttribute('title', title);
732
} else {
733
targetElement.removeAttribute('title');
734
}
735
}
736
737
updateTitle(getStringContent(content));
738
return {
739
update: (content) => updateTitle(getStringContent(content)),
740
show: () => { },
741
hide: () => { },
742
dispose: () => updateTitle(undefined),
743
};
744
}
745
746
class HoverContextViewDelegate implements IDelegate {
747
748
// Render over all other context views, with higher layers for nested hovers
749
public readonly layer: number;
750
751
get anchorPosition() {
752
return this._hover.anchor;
753
}
754
755
constructor(
756
private readonly _hover: HoverWidget,
757
private readonly _focus: boolean = false,
758
stackDepth: number = 1
759
) {
760
// Base layer is 1, nested hovers get higher layers
761
this.layer = stackDepth;
762
}
763
764
render(container: HTMLElement) {
765
this._hover.render(container);
766
if (this._focus) {
767
this._hover.focus();
768
}
769
return this._hover;
770
}
771
772
getAnchor() {
773
return {
774
x: this._hover.x,
775
y: this._hover.y
776
};
777
}
778
779
layout() {
780
this._hover.layout();
781
}
782
}
783
784
function eventIsRelatedToTarget(event: UIEvent, target: HTMLElement): boolean {
785
return isHTMLElement(event.target) && getHoverTargetElement(event.target, target) === target;
786
}
787
788
function getHoverTargetElement(element: HTMLElement, stopElement?: HTMLElement): HTMLElement {
789
stopElement = stopElement ?? getWindow(element).document.body;
790
while (!element.hasAttribute('custom-hover') && element !== stopElement) {
791
element = element.parentElement!;
792
}
793
return element;
794
}
795
796
function resolveMouseStyleHoverTarget(target: HTMLElement, e: MouseEvent): IHoverTarget {
797
return {
798
targetElements: [target],
799
x: e.x + 10
800
};
801
}
802
803
registerSingleton(IHoverService, HoverService, InstantiationType.Delayed);
804
805
registerThemingParticipant((theme, collector) => {
806
const hoverBorder = theme.getColor(editorHoverBorder);
807
if (hoverBorder) {
808
collector.addRule(`.monaco-hover.workbench-hover .hover-row:not(:first-child):not(:empty) { border-top: 1px solid ${hoverBorder.transparent(0.5)}; }`);
809
collector.addRule(`.monaco-hover.workbench-hover hr { border-top: 1px solid ${hoverBorder.transparent(0.5)}; }`);
810
}
811
});
812
813