Create IHoverDelegate2.setUpdatableHover

This commit is contained in:
Daniel Imms 2024-04-03 06:29:25 -07:00
parent c6a7ca7668
commit 85fb8cb6fd
No known key found for this signature in database
GPG key ID: E5CF412B63651C69
5 changed files with 257 additions and 3 deletions

View file

@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate';
import type { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget';
import type { CancellationToken } from 'vs/base/common/cancellation';
import type { IMarkdownString } from 'vs/base/common/htmlContent';
@ -39,6 +40,9 @@ export interface IHoverDelegate2 {
* simultaneously. #188822
*/
showAndFocusLastHover(): void;
// TODO: Change hoverDelegate arg to exclude the actual delegate and instead use the new options
setupUpdatableHover(hoverDelegate: IHoverDelegate, htmlElement: HTMLElement, content: IUpdatableHoverContentOrFactory, options?: IUpdatableHoverOptions): IUpdatableHover;
}
export interface IHoverWidget extends IDisposable {
@ -237,6 +241,7 @@ export interface IUpdatableHoverTooltipMarkdownString {
}
export type IUpdatableHoverContent = string | IUpdatableHoverTooltipMarkdownString | HTMLElement | undefined;
export type IUpdatableHoverContentOrFactory = IUpdatableHoverContent | (() => IUpdatableHoverContent);
export interface IUpdatableHoverOptions {
actions?: IHoverAction[];

View file

@ -9,6 +9,7 @@ let baseHoverDelegate: IHoverDelegate2 = {
showHover: () => undefined,
hideHover: () => undefined,
showAndFocusLastHover: () => undefined,
setupUpdatableHover: () => null!,
};
/**

View file

@ -13,9 +13,8 @@ import { stripIcons } from 'vs/base/common/iconLabels';
import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
import { isFunction, isString } from 'vs/base/common/types';
import { localize } from 'vs/nls';
import type { IHoverWidget, IUpdatableHover, IUpdatableHoverContent, IUpdatableHoverOptions, IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/hover/hover';
import type { IHoverWidget, IUpdatableHover, IUpdatableHoverContent, IUpdatableHoverContentOrFactory, IUpdatableHoverOptions, IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/hover/hover';
type IUpdatableHoverContentOrFactory = IUpdatableHoverContent | (() => IUpdatableHoverContent);
type IUpdatableHoverResolvedContent = IMarkdownString | string | HTMLElement | undefined;
export function setupNativeHover(htmlElement: HTMLElement, tooltip: string | IUpdatableHoverTooltipMarkdownString | undefined): void {

View file

@ -20,7 +20,10 @@ import { IAccessibilityService } from 'vs/platform/accessibility/common/accessib
import { ILayoutService } from 'vs/platform/layout/browser/layoutService';
import { mainWindow } from 'vs/base/browser/window';
import { ContextViewHandler } from 'vs/platform/contextview/browser/contextViewService';
import type { IHoverOptions, IHoverWidget } from 'vs/base/browser/ui/hover/hover';
import type { IHoverOptions, IHoverWidget, IUpdatableHover, IUpdatableHoverContentOrFactory, IUpdatableHoverOptions } from 'vs/base/browser/ui/hover/hover';
import type { IHoverDelegate, IHoverDelegateTarget } from 'vs/base/browser/ui/hover/hoverDelegate';
import { UpdatableHoverWidget } from 'vs/editor/browser/services/hoverService/updatableHoverWidget';
import { TimeoutTimer } from 'vs/base/common/async';
export class HoverService extends Disposable implements IHoverService {
declare readonly _serviceBrand: undefined;
@ -183,6 +186,135 @@ export class HoverService extends Disposable implements IHoverService {
}
}
}
setupUpdatableHover(hoverDelegate: IHoverDelegate, htmlElement: HTMLElement, content: IUpdatableHoverContentOrFactory, options?: IUpdatableHoverOptions | undefined): IUpdatableHover {
htmlElement.setAttribute('custom-hover', 'true');
if (htmlElement.title !== '') {
console.warn('HTML element already has a title attribute, which will conflict with the custom hover. Please remove the title attribute.');
console.trace('Stack trace:', htmlElement.title);
htmlElement.title = '';
}
let hoverPreparation: IDisposable | undefined;
let hoverWidget: UpdatableHoverWidget | undefined;
const hideHover = (disposeWidget: boolean, disposePreparation: boolean) => {
const hadHover = hoverWidget !== undefined;
if (disposeWidget) {
hoverWidget?.dispose();
hoverWidget = undefined;
}
if (disposePreparation) {
hoverPreparation?.dispose();
hoverPreparation = undefined;
}
if (hadHover) {
hoverDelegate.onDidHideHover?.();
hoverWidget = undefined;
}
};
const triggerShowHover = (delay: number, focus?: boolean, target?: IHoverDelegateTarget) => {
return new TimeoutTimer(async () => {
if (!hoverWidget || hoverWidget.isDisposed) {
hoverWidget = new UpdatableHoverWidget(hoverDelegate, target || htmlElement, delay > 0);
await hoverWidget.update(typeof content === 'function' ? content() : content, focus, options);
}
}, delay);
};
let isMouseDown = false;
const mouseDownEmitter = addDisposableListener(htmlElement, EventType.MOUSE_DOWN, () => {
isMouseDown = true;
hideHover(true, true);
}, true);
const mouseUpEmitter = addDisposableListener(htmlElement, EventType.MOUSE_UP, () => {
isMouseDown = false;
}, true);
const mouseLeaveEmitter = addDisposableListener(htmlElement, EventType.MOUSE_LEAVE, (e: MouseEvent) => {
isMouseDown = false;
hideHover(false, (<any>e).fromElement === htmlElement);
}, true);
const onMouseOver = (e: MouseEvent) => {
if (hoverPreparation) {
return;
}
const toDispose: DisposableStore = new DisposableStore();
const target: IHoverDelegateTarget = {
targetElements: [htmlElement],
dispose: () => { }
};
if (hoverDelegate.placement === undefined || hoverDelegate.placement === 'mouse') {
// track the mouse position
const onMouseMove = (e: MouseEvent) => {
target.x = e.x + 10;
if ((e.target instanceof HTMLElement) && getHoverTargetElement(e.target, htmlElement) !== htmlElement) {
hideHover(true, true);
}
};
toDispose.add(addDisposableListener(htmlElement, EventType.MOUSE_MOVE, onMouseMove, true));
}
hoverPreparation = toDispose;
if ((e.target instanceof HTMLElement) && getHoverTargetElement(e.target as HTMLElement, htmlElement) !== htmlElement) {
return; // Do not show hover when the mouse is over another hover target
}
toDispose.add(triggerShowHover(hoverDelegate.delay, false, target));
};
const mouseOverDomEmitter = addDisposableListener(htmlElement, EventType.MOUSE_OVER, onMouseOver, true);
const onFocus = () => {
if (isMouseDown || hoverPreparation) {
return;
}
const target: IHoverDelegateTarget = {
targetElements: [htmlElement],
dispose: () => { }
};
const toDispose: DisposableStore = new DisposableStore();
const onBlur = () => hideHover(true, true);
toDispose.add(addDisposableListener(htmlElement, EventType.BLUR, onBlur, true));
toDispose.add(triggerShowHover(hoverDelegate.delay, false, target));
hoverPreparation = toDispose;
};
// Do not show hover when focusing an input or textarea
let focusDomEmitter: undefined | IDisposable;
const tagName = htmlElement.tagName.toLowerCase();
if (tagName !== 'input' && tagName !== 'textarea') {
focusDomEmitter = addDisposableListener(htmlElement, EventType.FOCUS, onFocus, true);
}
const hover: IUpdatableHover = {
show: focus => {
hideHover(false, true); // terminate a ongoing mouse over preparation
triggerShowHover(0, focus); // show hover immediately
},
hide: () => {
hideHover(true, true);
},
update: async (newContent, hoverOptions) => {
content = newContent;
await hoverWidget?.update(content, undefined, hoverOptions);
},
dispose: () => {
mouseOverDomEmitter.dispose();
mouseLeaveEmitter.dispose();
mouseDownEmitter.dispose();
mouseUpEmitter.dispose();
focusDomEmitter?.dispose();
hideHover(true, true);
}
};
return hover;
}
}
function getHoverOptionsIdentity(options: IHoverOptions | undefined): IHoverOptions | number | string | undefined {
@ -227,6 +359,14 @@ class HoverContextViewDelegate implements IDelegate {
}
}
function getHoverTargetElement(element: HTMLElement, stopElement?: HTMLElement): HTMLElement {
stopElement = stopElement ?? getWindow(element).document.body;
while (!element.hasAttribute('custom-hover') && element !== stopElement) {
element = element.parentElement!;
}
return element;
}
registerSingleton(IHoverService, HoverService, InstantiationType.Delayed);
registerThemingParticipant((theme, collector) => {

View file

@ -0,0 +1,109 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { IHoverWidget, IUpdatableHoverContent, IUpdatableHoverOptions } from 'vs/base/browser/ui/hover/hover';
import type { IHoverDelegate, IHoverDelegateOptions, IHoverDelegateTarget } from 'vs/base/browser/ui/hover/hoverDelegate';
import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget';
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { isMarkdownString, type IMarkdownString } from 'vs/base/common/htmlContent';
import { IDisposable } from 'vs/base/common/lifecycle';
import { isFunction, isString } from 'vs/base/common/types';
import { localize } from 'vs/nls';
type IUpdatableHoverResolvedContent = IMarkdownString | string | HTMLElement | undefined;
export class UpdatableHoverWidget implements IDisposable {
private _hoverWidget: IHoverWidget | undefined;
private _cancellationTokenSource: CancellationTokenSource | undefined;
constructor(private hoverDelegate: IHoverDelegate, private target: IHoverDelegateTarget | HTMLElement, private fadeInAnimation: boolean) {
}
async update(content: IUpdatableHoverContent, focus?: boolean, options?: IUpdatableHoverOptions): Promise<void> {
if (this._cancellationTokenSource) {
// there's an computation ongoing, cancel it
this._cancellationTokenSource.dispose(true);
this._cancellationTokenSource = undefined;
}
if (this.isDisposed) {
return;
}
let resolvedContent;
if (content === undefined || isString(content) || content instanceof HTMLElement) {
resolvedContent = content;
} else if (!isFunction(content.markdown)) {
resolvedContent = content.markdown ?? content.markdownNotSupportedFallback;
} else {
// compute the content, potentially long-running
// show 'Loading' if no hover is up yet
if (!this._hoverWidget) {
this.show(localize('iconLabel.loading', "Loading..."), focus);
}
// compute the content
this._cancellationTokenSource = new CancellationTokenSource();
const token = this._cancellationTokenSource.token;
resolvedContent = await content.markdown(token);
if (resolvedContent === undefined) {
resolvedContent = content.markdownNotSupportedFallback;
}
if (this.isDisposed || token.isCancellationRequested) {
// either the widget has been closed in the meantime
// or there has been a new call to `update`
return;
}
}
this.show(resolvedContent, focus, options);
}
private show(content: IUpdatableHoverResolvedContent, focus?: boolean, options?: IUpdatableHoverOptions): void {
const oldHoverWidget = this._hoverWidget;
if (this.hasContent(content)) {
const hoverOptions: IHoverDelegateOptions = {
content,
target: this.target,
appearance: {
showPointer: this.hoverDelegate.placement === 'element',
skipFadeInAnimation: !this.fadeInAnimation || !!oldHoverWidget, // do not fade in if the hover is already showing
},
position: {
hoverPosition: HoverPosition.BELOW,
},
...options
};
this._hoverWidget = this.hoverDelegate.showHover(hoverOptions, focus);
}
oldHoverWidget?.dispose();
}
private hasContent(content: IUpdatableHoverResolvedContent): content is NonNullable<IUpdatableHoverResolvedContent> {
if (!content) {
return false;
}
if (isMarkdownString(content)) {
return !!content.value;
}
return true;
}
get isDisposed() {
return this._hoverWidget?.isDisposed;
}
dispose(): void {
this._hoverWidget?.dispose();
this._cancellationTokenSource?.dispose(true);
this._cancellationTokenSource = undefined;
}
}