Path: blob/main/src/vs/platform/hover/browser/hoverWidget.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 './hover.css';6import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';7import { Event, Emitter } from '../../../base/common/event.js';8import * as dom from '../../../base/browser/dom.js';9import { IKeybindingService } from '../../keybinding/common/keybinding.js';10import { KeyCode } from '../../../base/common/keyCodes.js';11import { IConfigurationService } from '../../configuration/common/configuration.js';12import { HoverAction, HoverPosition, HoverWidget as BaseHoverWidget, getHoverAccessibleViewHint } from '../../../base/browser/ui/hover/hoverWidget.js';13import { Widget } from '../../../base/browser/ui/widget.js';14import { AnchorPosition } from '../../../base/browser/ui/contextview/contextview.js';15import { IMarkdownRendererService } from '../../markdown/browser/markdownRenderer.js';16import { isMarkdownString } from '../../../base/common/htmlContent.js';17import { localize } from '../../../nls.js';18import { isMacintosh } from '../../../base/common/platform.js';19import { IAccessibilityService } from '../../accessibility/common/accessibility.js';20import { status } from '../../../base/browser/ui/aria/aria.js';21import { HoverStyle, type IHoverOptions, type IHoverTarget, type IHoverWidget } from '../../../base/browser/ui/hover/hover.js';22import { TimeoutTimer } from '../../../base/common/async.js';23import { isNumber } from '../../../base/common/types.js';2425const $ = dom.$;26type TargetRect = {27left: number;28right: number;29top: number;30bottom: number;31width: number;32height: number;33center: { x: number; y: number };34};3536const enum Constants {37PointerSize = 3,38HoverBorderWidth = 2,39HoverWindowEdgeMargin = 2,40}4142export class HoverWidget extends Widget implements IHoverWidget {43private readonly _messageListeners = new DisposableStore();44private readonly _lockMouseTracker: CompositeMouseTracker;4546private readonly _hover: BaseHoverWidget;47private readonly _hoverPointer: HTMLElement | undefined;48private readonly _hoverContainer: HTMLElement;49private readonly _target: IHoverTarget;50private readonly _linkHandler: ((url: string) => void) | undefined;5152private _isDisposed: boolean = false;53private _hoverPosition: HoverPosition;54private _forcePosition: boolean = false;55private _x: number = 0;56private _y: number = 0;57private _isLocked: boolean = false;58private _enableFocusTraps: boolean = false;59private _addedFocusTrap: boolean = false;60private _maxHeightRatioRelativeToWindow: number = 0.5;6162private get _targetWindow(): Window {63return dom.getWindow(this._target.targetElements[0]);64}65private get _targetDocumentElement(): HTMLElement {66return dom.getWindow(this._target.targetElements[0]).document.documentElement;67}6869get isDisposed(): boolean { return this._isDisposed; }70get isMouseIn(): boolean { return this._lockMouseTracker.isMouseIn; }71get domNode(): HTMLElement { return this._hover.containerDomNode; }7273private readonly _onDispose = this._register(new Emitter<void>());74get onDispose(): Event<void> { return this._onDispose.event; }75private readonly _onRequestLayout = this._register(new Emitter<void>());76get onRequestLayout(): Event<void> { return this._onRequestLayout.event; }7778get anchor(): AnchorPosition { return this._hoverPosition === HoverPosition.BELOW ? AnchorPosition.BELOW : AnchorPosition.ABOVE; }79get x(): number { return this._x; }80get y(): number { return this._y; }8182/**83* Whether the hover is "locked" by holding the alt/option key. When locked, the hover will not84* hide and can be hovered regardless of whether the `hideOnHover` hover option is set.85*/86get isLocked(): boolean { return this._isLocked; }87set isLocked(value: boolean) {88if (this._isLocked === value) {89return;90}91this._isLocked = value;92this._hoverContainer.classList.toggle('locked', this._isLocked);93}9495constructor(96options: IHoverOptions,97@IKeybindingService private readonly _keybindingService: IKeybindingService,98@IConfigurationService private readonly _configurationService: IConfigurationService,99@IMarkdownRendererService private readonly _markdownRenderer: IMarkdownRendererService,100@IAccessibilityService private readonly _accessibilityService: IAccessibilityService101) {102super();103104this._linkHandler = options.linkHandler;105106this._target = 'targetElements' in options.target ? options.target : new ElementHoverTarget(options.target);107108if (options.style) {109switch (options.style) {110case HoverStyle.Pointer: {111options.appearance ??= {};112options.appearance.compact ??= true;113options.appearance.showPointer ??= true;114break;115}116case HoverStyle.Mouse: {117options.appearance ??= {};118options.appearance.compact ??= true;119break;120}121}122}123124this._hoverPointer = options.appearance?.showPointer ? $('div.workbench-hover-pointer') : undefined;125this._hover = this._register(new BaseHoverWidget(!options.appearance?.skipFadeInAnimation));126this._hover.containerDomNode.classList.add('workbench-hover');127if (options.appearance?.compact) {128this._hover.containerDomNode.classList.add('workbench-hover', 'compact');129}130if (options.additionalClasses) {131this._hover.containerDomNode.classList.add(...options.additionalClasses);132}133if (options.position?.forcePosition) {134this._forcePosition = true;135}136if (options.trapFocus) {137this._enableFocusTraps = true;138}139140const maxHeightRatio = options.appearance?.maxHeightRatio;141if (maxHeightRatio !== undefined && maxHeightRatio > 0 && maxHeightRatio <= 1) {142this._maxHeightRatioRelativeToWindow = maxHeightRatio;143}144145// Default to position above when the position is unspecified or a mouse event146this._hoverPosition = options.position?.hoverPosition === undefined147? HoverPosition.ABOVE148: isNumber(options.position.hoverPosition)149? options.position.hoverPosition150: HoverPosition.BELOW;151152// Don't allow mousedown out of the widget, otherwise preventDefault will call and text will153// not be selected.154this.onmousedown(this._hover.containerDomNode, e => e.stopPropagation());155156// Hide hover on escape157this.onkeydown(this._hover.containerDomNode, e => {158if (e.equals(KeyCode.Escape)) {159this.dispose();160}161});162163// Hide when the window loses focus164this._register(dom.addDisposableListener(this._targetWindow, 'blur', () => this.dispose()));165166const rowElement = $('div.hover-row.markdown-hover');167const contentsElement = $('div.hover-contents');168if (typeof options.content === 'string') {169contentsElement.textContent = options.content;170contentsElement.style.whiteSpace = 'pre-wrap';171172} else if (dom.isHTMLElement(options.content)) {173contentsElement.appendChild(options.content);174contentsElement.classList.add('html-hover-contents');175176} else {177const markdown = options.content;178179const { element } = this._register(this._markdownRenderer.render(markdown, {180actionHandler: this._linkHandler,181asyncRenderCallback: () => {182contentsElement.classList.add('code-hover-contents');183this.layout();184// This changes the dimensions of the hover so trigger a layout185this._onRequestLayout.fire();186}187}));188contentsElement.appendChild(element);189}190rowElement.appendChild(contentsElement);191this._hover.contentsDomNode.appendChild(rowElement);192193if (options.actions && options.actions.length > 0) {194const statusBarElement = $('div.hover-row.status-bar');195const actionsElement = $('div.actions');196options.actions.forEach(action => {197const keybinding = this._keybindingService.lookupKeybinding(action.commandId);198const keybindingLabel = keybinding ? keybinding.getLabel() : null;199this._register(HoverAction.render(actionsElement, {200label: action.label,201commandId: action.commandId,202run: e => {203action.run(e);204this.dispose();205},206iconClass: action.iconClass207}, keybindingLabel));208});209statusBarElement.appendChild(actionsElement);210this._hover.containerDomNode.appendChild(statusBarElement);211}212213this._hoverContainer = $('div.workbench-hover-container');214if (this._hoverPointer) {215this._hoverContainer.appendChild(this._hoverPointer);216}217this._hoverContainer.appendChild(this._hover.containerDomNode);218219// Determine whether to hide on hover220let hideOnHover: boolean;221if (options.actions && options.actions.length > 0) {222// If there are actions, require hover so they can be accessed223hideOnHover = false;224} else {225if (options.persistence?.hideOnHover === undefined) {226// When unset, will default to true when it's a string or when it's markdown that227// appears to have a link using a naive check for '](' and '</a>'228hideOnHover = typeof options.content === 'string' ||229isMarkdownString(options.content) && !options.content.value.includes('](') && !options.content.value.includes('</a>');230} else {231// It's set explicitly232hideOnHover = options.persistence.hideOnHover;233}234}235236// Show the hover hint if needed237if (options.appearance?.showHoverHint) {238const statusBarElement = $('div.hover-row.status-bar');239const infoElement = $('div.info');240infoElement.textContent = localize('hoverhint', 'Hold {0} key to mouse over', isMacintosh ? 'Option' : 'Alt');241statusBarElement.appendChild(infoElement);242this._hover.containerDomNode.appendChild(statusBarElement);243}244245const mouseTrackerTargets = [...this._target.targetElements];246if (!hideOnHover) {247mouseTrackerTargets.push(this._hoverContainer);248}249const mouseTracker = this._register(new CompositeMouseTracker(mouseTrackerTargets));250this._register(mouseTracker.onMouseOut(() => {251if (!this._isLocked) {252this.dispose();253}254}));255256// Setup another mouse tracker when hideOnHover is set in order to track the hover as well257// when it is locked. This ensures the hover will hide on mouseout after alt has been258// released to unlock the element.259if (hideOnHover) {260const mouseTracker2Targets = [...this._target.targetElements, this._hoverContainer];261this._lockMouseTracker = this._register(new CompositeMouseTracker(mouseTracker2Targets));262this._register(this._lockMouseTracker.onMouseOut(() => {263if (!this._isLocked) {264this.dispose();265}266}));267} else {268this._lockMouseTracker = mouseTracker;269}270}271272private addFocusTrap() {273if (!this._enableFocusTraps || this._addedFocusTrap) {274return;275}276this._addedFocusTrap = true;277278// Add a hover tab loop if the hover has at least one element with a valid tabIndex279const firstContainerFocusElement = this._hover.containerDomNode;280const lastContainerFocusElement = this.findLastFocusableChild(this._hover.containerDomNode);281if (lastContainerFocusElement) {282const beforeContainerFocusElement = dom.prepend(this._hoverContainer, $('div'));283const afterContainerFocusElement = dom.append(this._hoverContainer, $('div'));284beforeContainerFocusElement.tabIndex = 0;285afterContainerFocusElement.tabIndex = 0;286this._register(dom.addDisposableListener(afterContainerFocusElement, 'focus', (e) => {287firstContainerFocusElement.focus();288e.preventDefault();289}));290this._register(dom.addDisposableListener(beforeContainerFocusElement, 'focus', (e) => {291lastContainerFocusElement.focus();292e.preventDefault();293}));294}295}296297private findLastFocusableChild(root: Node): HTMLElement | undefined {298if (root.hasChildNodes()) {299for (let i = 0; i < root.childNodes.length; i++) {300const node = root.childNodes.item(root.childNodes.length - i - 1);301if (node.nodeType === node.ELEMENT_NODE) {302const parsedNode = node as HTMLElement;303if (typeof parsedNode.tabIndex === 'number' && parsedNode.tabIndex >= 0) {304return parsedNode;305}306}307const recursivelyFoundElement = this.findLastFocusableChild(node);308if (recursivelyFoundElement) {309return recursivelyFoundElement;310}311}312}313return undefined;314}315316public render(container: HTMLElement): void {317container.appendChild(this._hoverContainer);318const hoverFocused = this._hoverContainer.contains(this._hoverContainer.ownerDocument.activeElement);319const accessibleViewHint = hoverFocused && getHoverAccessibleViewHint(this._configurationService.getValue('accessibility.verbosity.hover') === true && this._accessibilityService.isScreenReaderOptimized(), this._keybindingService.lookupKeybinding('editor.action.accessibleView')?.getAriaLabel());320if (accessibleViewHint) {321322status(accessibleViewHint);323}324this.layout();325this.addFocusTrap();326}327328public layout() {329this._hover.containerDomNode.classList.remove('right-aligned');330this._hover.contentsDomNode.style.maxHeight = '';331332const getZoomAccountedBoundingClientRect = (e: HTMLElement) => {333const zoom = dom.getDomNodeZoomLevel(e);334335const boundingRect = e.getBoundingClientRect();336return {337top: boundingRect.top * zoom,338bottom: boundingRect.bottom * zoom,339right: boundingRect.right * zoom,340left: boundingRect.left * zoom,341};342};343344const targetBounds = this._target.targetElements.map(e => getZoomAccountedBoundingClientRect(e));345const { top, right, bottom, left } = targetBounds[0];346const width = right - left;347const height = bottom - top;348349const targetRect: TargetRect = {350top, right, bottom, left, width, height,351center: {352x: left + (width / 2),353y: top + (height / 2)354}355};356357// These calls adjust the position depending on spacing.358this.adjustHorizontalHoverPosition(targetRect);359this.adjustVerticalHoverPosition(targetRect);360// This call limits the maximum height of the hover.361this.adjustHoverMaxHeight(targetRect);362363// Offset the hover position if there is a pointer so it aligns with the target element364this._hoverContainer.style.padding = '';365this._hoverContainer.style.margin = '';366if (this._hoverPointer) {367switch (this._hoverPosition) {368case HoverPosition.RIGHT:369targetRect.left += Constants.PointerSize;370targetRect.right += Constants.PointerSize;371this._hoverContainer.style.paddingLeft = `${Constants.PointerSize}px`;372this._hoverContainer.style.marginLeft = `${-Constants.PointerSize}px`;373break;374case HoverPosition.LEFT:375targetRect.left -= Constants.PointerSize;376targetRect.right -= Constants.PointerSize;377this._hoverContainer.style.paddingRight = `${Constants.PointerSize}px`;378this._hoverContainer.style.marginRight = `${-Constants.PointerSize}px`;379break;380case HoverPosition.BELOW:381targetRect.top += Constants.PointerSize;382targetRect.bottom += Constants.PointerSize;383this._hoverContainer.style.paddingTop = `${Constants.PointerSize}px`;384this._hoverContainer.style.marginTop = `${-Constants.PointerSize}px`;385break;386case HoverPosition.ABOVE:387targetRect.top -= Constants.PointerSize;388targetRect.bottom -= Constants.PointerSize;389this._hoverContainer.style.paddingBottom = `${Constants.PointerSize}px`;390this._hoverContainer.style.marginBottom = `${-Constants.PointerSize}px`;391break;392}393394targetRect.center.x = targetRect.left + (width / 2);395targetRect.center.y = targetRect.top + (height / 2);396}397398this.computeXCordinate(targetRect);399this.computeYCordinate(targetRect);400401if (this._hoverPointer) {402// reset403this._hoverPointer.classList.remove('top');404this._hoverPointer.classList.remove('left');405this._hoverPointer.classList.remove('right');406this._hoverPointer.classList.remove('bottom');407408this.setHoverPointerPosition(targetRect);409}410this._hover.onContentsChanged();411}412413private computeXCordinate(target: TargetRect): void {414const hoverWidth = this._hover.containerDomNode.clientWidth + Constants.HoverBorderWidth;415416if (this._target.x !== undefined) {417this._x = this._target.x;418}419420else if (this._hoverPosition === HoverPosition.RIGHT) {421this._x = target.right;422}423424else if (this._hoverPosition === HoverPosition.LEFT) {425this._x = target.left - hoverWidth;426}427428else {429if (this._hoverPointer) {430this._x = target.center.x - (this._hover.containerDomNode.clientWidth / 2);431} else {432this._x = target.left;433}434435// Hover is going beyond window towards right end436if (this._x + hoverWidth >= this._targetDocumentElement.clientWidth) {437this._hover.containerDomNode.classList.add('right-aligned');438this._x = Math.max(this._targetDocumentElement.clientWidth - hoverWidth - Constants.HoverWindowEdgeMargin, this._targetDocumentElement.clientLeft);439}440}441442// Hover is going beyond window towards left end443if (this._x < this._targetDocumentElement.clientLeft) {444this._x = target.left + Constants.HoverWindowEdgeMargin;445}446447}448449private computeYCordinate(target: TargetRect): void {450if (this._target.y !== undefined) {451this._y = this._target.y;452}453454else if (this._hoverPosition === HoverPosition.ABOVE) {455this._y = target.top;456}457458else if (this._hoverPosition === HoverPosition.BELOW) {459this._y = target.bottom - 2;460}461462else {463if (this._hoverPointer) {464this._y = target.center.y + (this._hover.containerDomNode.clientHeight / 2);465} else {466this._y = target.bottom;467}468}469470// Hover on bottom is going beyond window471if (this._y > this._targetWindow.innerHeight) {472this._y = target.bottom;473}474}475476private adjustHorizontalHoverPosition(target: TargetRect): void {477// Do not adjust horizontal hover position if x cordiante is provided478if (this._target.x !== undefined) {479return;480}481482const hoverPointerOffset = (this._hoverPointer ? Constants.PointerSize : 0);483484// When force position is enabled, restrict max width485if (this._forcePosition) {486const padding = hoverPointerOffset + Constants.HoverBorderWidth;487if (this._hoverPosition === HoverPosition.RIGHT) {488this._hover.containerDomNode.style.maxWidth = `${this._targetDocumentElement.clientWidth - target.right - padding}px`;489} else if (this._hoverPosition === HoverPosition.LEFT) {490this._hover.containerDomNode.style.maxWidth = `${target.left - padding}px`;491}492return;493}494495// Position hover on right to target496if (this._hoverPosition === HoverPosition.RIGHT) {497const roomOnRight = this._targetDocumentElement.clientWidth - target.right;498// Hover on the right is going beyond window.499if (roomOnRight < this._hover.containerDomNode.clientWidth + hoverPointerOffset) {500const roomOnLeft = target.left;501// There's enough room on the left, flip the hover position502if (roomOnLeft >= this._hover.containerDomNode.clientWidth + hoverPointerOffset) {503this._hoverPosition = HoverPosition.LEFT;504}505// Hover on the left would go beyond window too506else {507this._hoverPosition = HoverPosition.BELOW;508}509}510}511// Position hover on left to target512else if (this._hoverPosition === HoverPosition.LEFT) {513514const roomOnLeft = target.left;515// Hover on the left is going beyond window.516if (roomOnLeft < this._hover.containerDomNode.clientWidth + hoverPointerOffset) {517const roomOnRight = this._targetDocumentElement.clientWidth - target.right;518// There's enough room on the right, flip the hover position519if (roomOnRight >= this._hover.containerDomNode.clientWidth + hoverPointerOffset) {520this._hoverPosition = HoverPosition.RIGHT;521}522// Hover on the right would go beyond window too523else {524this._hoverPosition = HoverPosition.BELOW;525}526}527// Hover on the left is going beyond window.528if (target.left - this._hover.containerDomNode.clientWidth - hoverPointerOffset <= this._targetDocumentElement.clientLeft) {529this._hoverPosition = HoverPosition.RIGHT;530}531}532}533534private adjustVerticalHoverPosition(target: TargetRect): void {535// Do not adjust vertical hover position if the y coordinate is provided536// or the position is forced537if (this._target.y !== undefined || this._forcePosition) {538return;539}540541const hoverPointerOffset = (this._hoverPointer ? Constants.PointerSize : 0);542543// Position hover on top of the target544if (this._hoverPosition === HoverPosition.ABOVE) {545// Hover on top is going beyond window546if (target.top - this._hover.containerDomNode.clientHeight - hoverPointerOffset < 0) {547this._hoverPosition = HoverPosition.BELOW;548}549}550551// Position hover below the target552else if (this._hoverPosition === HoverPosition.BELOW) {553// Hover on bottom is going beyond window554if (target.bottom + this._hover.containerDomNode.offsetHeight + hoverPointerOffset > this._targetWindow.innerHeight) {555this._hoverPosition = HoverPosition.ABOVE;556}557}558}559560private adjustHoverMaxHeight(target: TargetRect): void {561let maxHeight = this._targetWindow.innerHeight * this._maxHeightRatioRelativeToWindow;562563// When force position is enabled, restrict max height564if (this._forcePosition) {565const padding = (this._hoverPointer ? Constants.PointerSize : 0) + Constants.HoverBorderWidth;566if (this._hoverPosition === HoverPosition.ABOVE) {567maxHeight = Math.min(maxHeight, target.top - padding);568} else if (this._hoverPosition === HoverPosition.BELOW) {569maxHeight = Math.min(maxHeight, this._targetWindow.innerHeight - target.bottom - padding);570}571}572573this._hover.containerDomNode.style.maxHeight = `${maxHeight}px`;574if (this._hover.contentsDomNode.clientHeight < this._hover.contentsDomNode.scrollHeight) {575// Add padding for a vertical scrollbar576const extraRightPadding = `${this._hover.scrollbar.options.verticalScrollbarSize}px`;577if (this._hover.contentsDomNode.style.paddingRight !== extraRightPadding) {578this._hover.contentsDomNode.style.paddingRight = extraRightPadding;579}580}581}582583private setHoverPointerPosition(target: TargetRect): void {584if (!this._hoverPointer) {585return;586}587588switch (this._hoverPosition) {589case HoverPosition.LEFT:590case HoverPosition.RIGHT: {591this._hoverPointer.classList.add(this._hoverPosition === HoverPosition.LEFT ? 'right' : 'left');592const hoverHeight = this._hover.containerDomNode.clientHeight;593594// If hover is taller than target, then show the pointer at the center of target595if (hoverHeight > target.height) {596this._hoverPointer.style.top = `${target.center.y - (this._y - hoverHeight) - Constants.PointerSize}px`;597}598599// Otherwise show the pointer at the center of hover600else {601this._hoverPointer.style.top = `${Math.round((hoverHeight / 2)) - Constants.PointerSize}px`;602}603604break;605}606case HoverPosition.ABOVE:607case HoverPosition.BELOW: {608this._hoverPointer.classList.add(this._hoverPosition === HoverPosition.ABOVE ? 'bottom' : 'top');609const hoverWidth = this._hover.containerDomNode.clientWidth;610611// Position pointer at the center of the hover612let pointerLeftPosition = Math.round((hoverWidth / 2)) - Constants.PointerSize;613614// If pointer goes beyond target then position it at the center of the target615const pointerX = this._x + pointerLeftPosition;616if (pointerX < target.left || pointerX > target.right) {617pointerLeftPosition = target.center.x - this._x - Constants.PointerSize;618}619620this._hoverPointer.style.left = `${pointerLeftPosition}px`;621break;622}623}624}625626public focus() {627this._hover.containerDomNode.focus();628}629630public hide(): void {631this.dispose();632}633634public override dispose(): void {635if (!this._isDisposed) {636this._onDispose.fire();637this._target.dispose?.();638this._hoverContainer.remove();639this._messageListeners.dispose();640super.dispose();641}642this._isDisposed = true;643}644}645646class CompositeMouseTracker extends Widget {647private _isMouseIn: boolean = true;648private readonly _mouseTimer: MutableDisposable<TimeoutTimer> = this._register(new MutableDisposable());649650private readonly _onMouseOut = this._register(new Emitter<void>());651get onMouseOut(): Event<void> { return this._onMouseOut.event; }652653get isMouseIn(): boolean { return this._isMouseIn; }654655/**656* @param _elements The target elements to track mouse in/out events on.657* @param _eventDebounceDelay The delay in ms to debounce the event firing. This is used to658* allow a short period for the mouse to move into the hover or a nearby target element. For659* example hovering a scroll bar will not hide the hover immediately.660*/661constructor(662private _elements: HTMLElement[],663private _eventDebounceDelay: number = 200664) {665super();666667for (const element of this._elements) {668this.onmouseover(element, () => this._onTargetMouseOver());669this.onmouseleave(element, () => this._onTargetMouseLeave());670}671}672673private _onTargetMouseOver(): void {674this._isMouseIn = true;675this._mouseTimer.clear();676}677678private _onTargetMouseLeave(): void {679this._isMouseIn = false;680// Evaluate whether the mouse is still outside asynchronously such that other mouse targets681// have the opportunity to first their mouse in event.682this._mouseTimer.value = new TimeoutTimer(() => this._fireIfMouseOutside(), this._eventDebounceDelay);683}684685private _fireIfMouseOutside(): void {686if (!this._isMouseIn) {687this._onMouseOut.fire();688}689}690}691692class ElementHoverTarget implements IHoverTarget {693readonly targetElements: readonly HTMLElement[];694695constructor(696private _element: HTMLElement697) {698this.targetElements = [this._element];699}700701dispose(): void {702}703}704705706