From 535a30a14bb822c45ba9a9bc56f7c389c49c2caf Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Tue, 15 Nov 2022 01:46:48 +0000 Subject: [PATCH 1/3] Generalize hover service --- .../browser/frontend-application-module.ts | 6 +- packages/core/src/browser/hover-service.ts | 189 ++++++++++++++++++ packages/core/src/browser/index.ts | 1 + .../src/browser/shell/sidebar-menu-widget.tsx | 172 ++++++++-------- packages/core/src/browser/shell/tab-bars.ts | 35 +++- packages/core/src/browser/status-bar/index.ts | 3 - .../status-bar/status-bar-hover-manager.ts | 113 ----------- .../src/browser/status-bar/status-bar.tsx | 10 +- .../core/src/browser/style/hover-service.css | 94 +++++++++ .../core/src/browser/style/status-bar.css | 49 ----- .../main/browser/view/tree-view-widget.tsx | 57 +++--- .../src/browser/vsx-extension.tsx | 34 ++-- 12 files changed, 469 insertions(+), 294 deletions(-) create mode 100644 packages/core/src/browser/hover-service.ts delete mode 100644 packages/core/src/browser/status-bar/status-bar-hover-manager.ts create mode 100644 packages/core/src/browser/style/hover-service.css diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index fe220c2f64b90..9a53a96a74eee 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -135,6 +135,7 @@ import { bindStatusBar } from './status-bar'; import { MarkdownRenderer, MarkdownRendererFactory, MarkdownRendererImpl } from './markdown-rendering/markdown-renderer'; import { StylingParticipant, StylingService } from './styling-service'; import { bindCommonStylingParticipants } from './common-styling-participants'; +import { HoverService } from './hover-service'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -186,7 +187,8 @@ export const frontendApplicationModule = new ContainerModule((bind, _unbind, _is const selectionService = container.get(SelectionService); const commandService = container.get(CommandService); const corePreferences = container.get(CorePreferences); - return new TabBarRenderer(contextMenuRenderer, tabBarDecoratorService, iconThemeService, selectionService, commandService, corePreferences); + const hoverService = container.get(HoverService); + return new TabBarRenderer(contextMenuRenderer, tabBarDecoratorService, iconThemeService, selectionService, commandService, corePreferences, hoverService); }); bind(TheiaDockPanel.Factory).toFactory(({ container }) => options => { const corePreferences = container.get(CorePreferences); @@ -433,6 +435,8 @@ export const frontendApplicationModule = new ContainerModule((bind, _unbind, _is bind(SaveResourceService).toSelf().inSingletonScope(); bind(UserWorkingDirectoryProvider).toSelf().inSingletonScope(); + bind(HoverService).toSelf().inSingletonScope(); + bind(StylingService).toSelf().inSingletonScope(); bindContributionProvider(bind, StylingParticipant); bind(FrontendApplicationContribution).toService(StylingService); diff --git a/packages/core/src/browser/hover-service.ts b/packages/core/src/browser/hover-service.ts new file mode 100644 index 0000000000000..1747dfeadd11f --- /dev/null +++ b/packages/core/src/browser/hover-service.ts @@ -0,0 +1,189 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { inject, injectable } from 'inversify'; +import { Disposable, DisposableCollection, disposableTimeout, isOSX } from '../common'; +import { MarkdownString } from '../common/markdown-rendering/markdown-string'; +import { animationFrame } from './browser'; +import { MarkdownRenderer, MarkdownRendererFactory } from './markdown-rendering/markdown-renderer'; +import { PreferenceService } from './preferences'; + +import '../../src/browser/style/hover-service.css'; + +export type HoverPosition = 'left' | 'right' | 'top' | 'bottom'; + +export namespace HoverPosition { + export function invertIfNecessary(position: HoverPosition, target: DOMRect, host: DOMRect, totalWidth: number, totalHeight: number): HoverPosition { + if (position === 'left') { + if (target.left - host.width - 5 < 0) { + return 'right'; + } + } else if (position === 'right') { + if (target.right + host.width + 5 > totalWidth) { + return 'left'; + } + } else if (position === 'top') { + if (target.top - host.height - 5 < 0) { + return 'bottom'; + } + } else if (position === 'bottom') { + if (target.bottom + host.height + 5 > totalHeight) { + return 'top'; + } + } + return position; + } +} + +export interface HoverRequest { + content: string | MarkdownString | HTMLElement + target: HTMLElement + /** + * The position where the hover should appear. + * Note that the hover service will try to invert the position (i.e. right -> left) + * if the specified content does not fit in the window next to the target element + */ + position: HoverPosition +} + +@injectable() +export class HoverService { + protected static hostClassName = 'theia-hover'; + protected static styleSheetId = 'theia-hover-style'; + @inject(PreferenceService) protected readonly preferences: PreferenceService; + @inject(MarkdownRendererFactory) protected readonly markdownRendererFactory: MarkdownRendererFactory; + + protected _markdownRenderer: MarkdownRenderer | undefined; + protected get markdownRenderer(): MarkdownRenderer { + this._markdownRenderer ||= this.markdownRendererFactory(); + return this._markdownRenderer; + } + + protected _hoverHost: HTMLElement | undefined; + protected get hoverHost(): HTMLElement { + if (!this._hoverHost) { + this._hoverHost = document.createElement('div'); + this._hoverHost.classList.add(HoverService.hostClassName); + this._hoverHost.style.position = 'absolute'; + } + return this._hoverHost; + } + protected pendingTimeout: Disposable | undefined; + protected hoverTarget: HTMLElement | undefined; + protected lastHidHover = Date.now(); + protected readonly disposeOnHide = new DisposableCollection(); + + requestHover(request: HoverRequest): void { + if (request.target !== this.hoverTarget) { + this.cancelHover(); + this.pendingTimeout = disposableTimeout(() => this.renderHover(request), this.getHoverDelay()); + } + } + + protected getHoverDelay(): number { + return Date.now() - this.lastHidHover < 200 + ? 0 + : this.preferences.get('workbench.hover.delay', isOSX ? 1500 : 500); + } + + protected async renderHover(request: HoverRequest): Promise { + const host = this.hoverHost; + const { target, content, position } = request; + this.hoverTarget = target; + if (content instanceof HTMLElement) { + host.appendChild(content); + } else if (typeof content === 'string') { + host.textContent = content; + } else { + const renderedContent = this.markdownRenderer.render(content); + this.disposeOnHide.push(renderedContent); + host.appendChild(renderedContent.element); + } + // browsers might insert linebreaks when the hover appears at the edge of the window + // resetting the position prevents that + host.style.left = '0px'; + host.style.top = '0px'; + document.body.append(host); + await animationFrame(); // Allow the browser to size the host + const updatedPosition = this.setHostPosition(target, host, position); + + this.disposeOnHide.push({ + dispose: () => { + this.lastHidHover = Date.now(); + host.classList.remove(updatedPosition); + } + }); + + this.listenForMouseOut(); + } + + protected setHostPosition(target: HTMLElement, host: HTMLElement, position: HoverPosition): HoverPosition { + const targetDimensions = target.getBoundingClientRect(); + const hostDimensions = host.getBoundingClientRect(); + const documentWidth = document.body.getBoundingClientRect().width; + // document.body.getBoundingClientRect().height doesn't work as expected + // scrollHeight will always be accurate here: https://stackoverflow.com/a/44077777 + const documentHeight = document.documentElement.scrollHeight; + position = HoverPosition.invertIfNecessary(position, targetDimensions, hostDimensions, documentWidth, documentHeight); + if (position === 'top' || position === 'bottom') { + const targetMiddleWidth = targetDimensions.left + (targetDimensions.width / 2); + const middleAlignment = targetMiddleWidth - (hostDimensions.width / 2); + const furthestRight = Math.min(documentWidth - hostDimensions.width, middleAlignment); + const left = Math.max(0, furthestRight); + const top = position === 'top' + ? targetDimensions.top - hostDimensions.height - 5 + : targetDimensions.bottom + 5; + host.style.setProperty('--theia-hover-before-position', `${targetMiddleWidth - left - 5}px`); + host.style.top = `${top}px`; + host.style.left = `${left}px`; + } else { + const targetMiddleHeight = targetDimensions.top + (targetDimensions.height / 2); + const middleAlignment = targetMiddleHeight - (hostDimensions.height / 2); + const furthestTop = Math.min(documentHeight - hostDimensions.height, middleAlignment); + const top = Math.max(0, furthestTop); + const left = position === 'left' + ? targetDimensions.left - hostDimensions.width - 5 + : targetDimensions.right + 5; + host.style.setProperty('--theia-hover-before-position', `${targetMiddleHeight - top - 5}px`); + host.style.left = `${left}px`; + host.style.top = `${top}px`; + } + host.classList.add(position); + return position; + } + + protected listenForMouseOut(): void { + const handleMouseMove = (e: MouseEvent) => { + if (e.target instanceof Node && !this.hoverHost.contains(e.target) && !this.hoverTarget?.contains(e.target)) { + this.cancelHover(); + } + }; + document.addEventListener('mousemove', handleMouseMove); + this.disposeOnHide.push({ dispose: () => document.removeEventListener('mousemove', handleMouseMove) }); + } + + cancelHover(): void { + this.pendingTimeout?.dispose(); + this.unRenderHover(); + this.disposeOnHide.dispose(); + this.hoverTarget = undefined; + } + + protected unRenderHover(): void { + this.hoverHost.remove(); + this.hoverHost.replaceChildren(); + } +} diff --git a/packages/core/src/browser/index.ts b/packages/core/src/browser/index.ts index 13f83fb694540..6ddc2c5d1812c 100644 --- a/packages/core/src/browser/index.ts +++ b/packages/core/src/browser/index.ts @@ -43,3 +43,4 @@ export * from './breadcrumbs'; export * from './tooltip-service'; export * from './decoration-style'; export * from './styling-service'; +export * from './hover-service'; diff --git a/packages/core/src/browser/shell/sidebar-menu-widget.tsx b/packages/core/src/browser/shell/sidebar-menu-widget.tsx index 491c27ed7d663..16776beba4268 100644 --- a/packages/core/src/browser/shell/sidebar-menu-widget.tsx +++ b/packages/core/src/browser/shell/sidebar-menu-widget.tsx @@ -19,19 +19,20 @@ import * as React from 'react'; import { ReactWidget } from '../widgets'; import { ContextMenuRenderer } from '../context-menu-renderer'; import { MenuPath } from '../../common/menu'; +import { HoverService } from '../hover-service'; export const SidebarTopMenuWidgetFactory = Symbol('SidebarTopMenuWidgetFactory'); export const SidebarBottomMenuWidgetFactory = Symbol('SidebarBottomMenuWidgetFactory'); export interface SidebarMenu { - id: string; - iconClass: string; - title: string; - menuPath: MenuPath; - /* - * Used to sort menus. The lower the value the lower they are placed in the sidebar. - */ - order: number; + id: string; + iconClass: string; + title: string; + menuPath: MenuPath; + /* + * Used to sort menus. The lower the value the lower they are placed in the sidebar. + */ + order: number; } /** @@ -39,88 +40,101 @@ export interface SidebarMenu { */ @injectable() export class SidebarMenuWidget extends ReactWidget { - protected readonly menus: SidebarMenu[]; - /** - * The element that had focus when a menu rendered by this widget was activated. - */ - protected preservedContext: HTMLElement | undefined; - /** - * Flag indicating whether a context menu is open. While a context menu is open, the `preservedContext` should not be cleared. - */ - protected preservingContext = false; + protected readonly menus: SidebarMenu[]; + /** + * The element that had focus when a menu rendered by this widget was activated. + */ + protected preservedContext: HTMLElement | undefined; + /** + * Flag indicating whether a context menu is open. While a context menu is open, the `preservedContext` should not be cleared. + */ + protected preservingContext = false; - @inject(ContextMenuRenderer) - protected readonly contextMenuRenderer: ContextMenuRenderer; + @inject(ContextMenuRenderer) + protected readonly contextMenuRenderer: ContextMenuRenderer; - constructor() { - super(); - this.menus = []; - } + @inject(HoverService) + protected readonly hoverService: HoverService; - addMenu(menu: SidebarMenu): void { - const exists = this.menus.find(m => m.id === menu.id); - if (exists) { - return; + constructor() { + super(); + this.menus = []; } - this.menus.push(menu); - this.menus.sort((a, b) => a.order - b.order); - this.update(); - } - removeMenu(menuId: string): void { - const menu = this.menus.find(m => m.id === menuId); - if (menu) { - const index = this.menus.indexOf(menu); - if (index !== -1) { - this.menus.splice(index, 1); + addMenu(menu: SidebarMenu): void { + const exists = this.menus.find(m => m.id === menu.id); + if (exists) { + return; + } + this.menus.push(menu); + this.menus.sort((a, b) => a.order - b.order); this.update(); - } } - } - protected readonly onMouseDown = () => { - const { activeElement } = document; - if (activeElement instanceof HTMLElement && !this.node.contains(activeElement)) { - this.preservedContext = activeElement; + removeMenu(menuId: string): void { + const menu = this.menus.find(m => m.id === menuId); + if (menu) { + const index = this.menus.indexOf(menu); + if (index !== -1) { + this.menus.splice(index, 1); + this.update(); + } + } } - }; - protected readonly onMouseOut = () => { - if (!this.preservingContext) { - this.preservedContext = undefined; - } - }; + protected readonly onMouseDown = () => { + const { activeElement } = document; + if (activeElement instanceof HTMLElement && !this.node.contains(activeElement)) { + this.preservedContext = activeElement; + } + }; - protected onClick(e: React.MouseEvent, menuPath: MenuPath): void { - this.preservingContext = true; - const button = e.currentTarget.getBoundingClientRect(); - this.contextMenuRenderer.render({ - menuPath, - includeAnchorArg: false, - anchor: { - x: button.left + button.width, - y: button.top, - }, - onHide: () => { - this.preservingContext = false; - if (this.preservedContext) { - this.preservedContext.focus({ preventScroll: true }); - this.preservedContext = undefined; + protected readonly onMouseOut = () => { + if (!this.preservingContext) { + this.preservedContext = undefined; } - } - }); - } + }; - protected render(): React.ReactNode { - return - {this.menus.map(menu => this.onClick(e, menu.menuPath)} - onMouseDown={this.onMouseDown} - onMouseOut={this.onMouseOut} - />)} - ; - } + protected readonly onMouseEnter = (event: React.MouseEvent, title: string) => { + if (title && event.nativeEvent.currentTarget) { + this.hoverService.requestHover({ + content: title, + target: event.currentTarget, + position: 'right' + }); + } + }; + + protected onClick(e: React.MouseEvent, menuPath: MenuPath): void { + this.preservingContext = true; + const button = e.currentTarget.getBoundingClientRect(); + this.contextMenuRenderer.render({ + menuPath, + includeAnchorArg: false, + anchor: { + x: button.left + button.width, + y: button.top, + }, + onHide: () => { + this.preservingContext = false; + if (this.preservedContext) { + this.preservedContext.focus({ preventScroll: true }); + this.preservedContext = undefined; + } + } + }); + } + + protected render(): React.ReactNode { + return + {this.menus.map(menu => this.onClick(e, menu.menuPath)} + onMouseDown={this.onMouseDown} + onMouseEnter={e => this.onMouseEnter(e, menu.title)} + onMouseLeave={this.onMouseOut} + />)} + ; + } } diff --git a/packages/core/src/browser/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index d7eba93f1c8f7..f0237776da929 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -33,6 +33,7 @@ import { NavigatableWidget } from '../navigatable-types'; import { IDragEvent } from '@phosphor/dragdrop'; import { PINNED_CLASS } from '../widgets/widget'; import { CorePreferences } from '../core-preferences'; +import { HoverService } from '../hover-service'; /** The class name added to hidden content nodes, which are required to render vertical side bars. */ const HIDDEN_CONTENT_CLASS = 'theia-TabBar-hidden-content'; @@ -90,7 +91,8 @@ export class TabBarRenderer extends TabBar.Renderer { protected readonly iconThemeService?: IconThemeService, protected readonly selectionService?: SelectionService, protected readonly commandService?: CommandService, - protected readonly corePreferences?: CorePreferences + protected readonly corePreferences?: CorePreferences, + protected readonly hoverService?: HoverService ) { super(); if (this.decoratorService) { @@ -146,7 +148,7 @@ export class TabBarRenderer extends TabBar.Renderer { */ override renderTab(data: SideBarRenderData, isInSidePanel?: boolean, isPartOfHiddenTabBar?: boolean): VirtualElement { const title = data.title; - const id = this.createTabId(data.title, isPartOfHiddenTabBar); + const id = this.createTabId(title, isPartOfHiddenTabBar); const key = this.createTabKey(data); const style = this.createTabStyle(data); const className = this.createTabClass(data); @@ -154,9 +156,17 @@ export class TabBarRenderer extends TabBar.Renderer { const closeIconTitle = data.title.className.includes(PINNED_CLASS) ? nls.localizeByDefault('Unpin') : nls.localizeByDefault('Close'); + + const hover = this.tabBar && this.tabBar.orientation === 'horizontal' ? { + title: title.caption + } : { + onmouseenter: this.handleMouseEnterEvent + }; + return h.li( { - key, className, id, title: title.caption, style, dataset, + ...hover, + key, className, id, style, dataset, oncontextmenu: this.handleContextMenuEvent, ondblclick: this.handleDblClickEvent, onauxclick: (e: MouseEvent) => { @@ -457,6 +467,20 @@ export class TabBarRenderer extends TabBar.Renderer { return h.div({ className: baseClassName, style }, data.title.iconLabel); } + protected handleMouseEnterEvent = (event: MouseEvent) => { + if (this.tabBar && this.hoverService && event.currentTarget instanceof HTMLElement) { + const id = event.currentTarget.id; + const title = this.tabBar.titles.find(t => this.createTabId(t) === id); + if (title) { + this.hoverService.requestHover({ + content: title.caption, + target: event.currentTarget, + position: 'right' + }); + } + } + }; + protected handleContextMenuEvent = (event: MouseEvent) => { if (this.contextMenuRenderer && this.contextMenuPath && event.currentTarget instanceof HTMLElement) { event.stopPropagation(); @@ -501,9 +525,8 @@ export class TabBarRenderer extends TabBar.Renderer { } if (this.tabBar && event.currentTarget instanceof HTMLElement) { const id = event.currentTarget.id; - // eslint-disable-next-line no-null/no-null - const title = this.tabBar.titles.find(t => this.createTabId(t) === id) || null; - const area = title && title.owner.parent; + const title = this.tabBar.titles.find(t => this.createTabId(t) === id); + const area = title?.owner.parent; if (area instanceof TheiaDockPanel && (area.id === BOTTOM_AREA_ID || area.id === MAIN_AREA_ID)) { area.toggleMaximized(); } diff --git a/packages/core/src/browser/status-bar/index.ts b/packages/core/src/browser/status-bar/index.ts index 255a0a4dae82c..96ff0ae447826 100644 --- a/packages/core/src/browser/status-bar/index.ts +++ b/packages/core/src/browser/status-bar/index.ts @@ -16,17 +16,14 @@ import { interfaces } from 'inversify'; import { StatusBarImpl } from './status-bar'; -import { StatusBarHoverManager } from './status-bar-hover-manager'; import { StatusBar } from './status-bar-types'; import { StatusBarViewModel } from './status-bar-view-model'; export * from './status-bar'; export * from './status-bar-types'; export * from './status-bar-view-model'; -export * from './status-bar-hover-manager'; export function bindStatusBar(bind: interfaces.Bind): void { bind(StatusBarImpl).toSelf().inSingletonScope(); bind(StatusBar).to(StatusBarImpl).inSingletonScope(); bind(StatusBarViewModel).toSelf().inSingletonScope(); - bind(StatusBarHoverManager).toSelf().inSingletonScope(); } diff --git a/packages/core/src/browser/status-bar/status-bar-hover-manager.ts b/packages/core/src/browser/status-bar/status-bar-hover-manager.ts deleted file mode 100644 index e3b8214cbf3f5..0000000000000 --- a/packages/core/src/browser/status-bar/status-bar-hover-manager.ts +++ /dev/null @@ -1,113 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2022 Ericsson and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { inject, injectable } from 'inversify'; -import { Disposable, DisposableCollection, disposableTimeout, isOSX } from '../../common'; -import { MarkdownString } from '../../common/markdown-rendering/markdown-string'; -import { MarkdownRenderer, MarkdownRendererFactory } from '../markdown-rendering/markdown-renderer'; -import { PreferenceService } from '../preferences'; - -@injectable() -export class StatusBarHoverManager { - protected static hostClassName = 'theia-status-bar-hover'; - protected static styleSheetId = 'theia-status-bar-hover-style'; - @inject(PreferenceService) protected readonly preferences: PreferenceService; - @inject(MarkdownRendererFactory) protected readonly markdownRendererFactory: MarkdownRendererFactory; - - protected _markdownRenderer: MarkdownRenderer | undefined; - protected get markdownRenderer(): MarkdownRenderer { - this._markdownRenderer ||= this.markdownRendererFactory(); - return this._markdownRenderer; - } - - protected _hoverHost: HTMLElement | undefined; - protected get hoverHost(): HTMLElement { - if (!this._hoverHost) { - this._hoverHost = document.createElement('div'); - this._hoverHost.classList.add(StatusBarHoverManager.hostClassName); - this._hoverHost.style.position = 'absolute'; - } - return this._hoverHost; - } - protected pendingTimeout: Disposable | undefined; - protected hoverTarget: HTMLElement | undefined; - protected lastHidHover = Date.now(); - protected readonly disposeOnHide = new DisposableCollection(); - - requestHover(hover: string | MarkdownString | HTMLElement, target: HTMLElement): void { - if (target !== this.hoverTarget) { - this.cancelHover(); - this.pendingTimeout = disposableTimeout(() => this.renderHover(hover, target), this.getHoverDelay()); - } - } - - protected getHoverDelay(): number { - return Date.now() - this.lastHidHover < 200 - ? 0 - : this.preferences.get('workbench.hover.delay', isOSX ? 1500 : 500); - } - - protected async renderHover(hover: string | MarkdownString | HTMLElement, target: HTMLElement): Promise { - const host = this.hoverHost; - this.hoverTarget = target; - if (hover instanceof HTMLElement) { - host.appendChild(hover); - } else if (typeof hover === 'string') { - host.textContent = hover; - } else { - const content = this.markdownRenderer.render(hover); - this.disposeOnHide.push(content); - host.appendChild(content.element); - } - this.disposeOnHide.push({ dispose: () => this.lastHidHover = Date.now() }); - document.body.append(host); - await new Promise(resolve => requestAnimationFrame(resolve)); // Allow the browser to size the host - const targetDimensions = target.getBoundingClientRect(); - const targetMiddle = targetDimensions.left + (targetDimensions.width / 2); - const hostDimensions = host.getBoundingClientRect(); - const documentWidth = document.body.getBoundingClientRect().width; - const middleAlignment = targetMiddle - (hostDimensions.width / 2); - const furthestRight = Math.min(documentWidth - hostDimensions.width, middleAlignment); - const left = Math.max(0, furthestRight); - host.style.setProperty('--theia-status-bar-hover-before-left', `${targetMiddle - left - 5}px`); // Centered on the status bar element. - host.style.bottom = `${targetDimensions.height + 5}px`; - host.style.left = `${left}px`; - - this.listenForMouseOut(); - } - - protected listenForMouseOut(): void { - const handleMouseMove = (e: MouseEvent) => { - if (e.target instanceof Node && !this.hoverHost.contains(e.target) && !this.hoverTarget?.contains(e.target)) { - this.cancelHover(); - } - }; - document.addEventListener('mousemove', handleMouseMove); - this.disposeOnHide.push({ dispose: () => document.removeEventListener('mousemove', handleMouseMove) }); - } - - cancelHover(): void { - this.pendingTimeout?.dispose(); - this.unRenderHover(); - this.disposeOnHide.dispose(); - this.hoverTarget = undefined; - } - - protected unRenderHover(): void { - this.hoverHost.remove(); - this.hoverHost.replaceChildren(); - } -} diff --git a/packages/core/src/browser/status-bar/status-bar.tsx b/packages/core/src/browser/status-bar/status-bar.tsx index 4d24eb4a8e130..31d5740f51fee 100644 --- a/packages/core/src/browser/status-bar/status-bar.tsx +++ b/packages/core/src/browser/status-bar/status-bar.tsx @@ -24,7 +24,7 @@ import { LabelParser, LabelIcon } from '../label-parser'; import { PreferenceService } from '../preferences'; import { StatusBar, StatusBarEntry, StatusBarAlignment, StatusBarViewEntry } from './status-bar-types'; import { StatusBarViewModel } from './status-bar-view-model'; -import { StatusBarHoverManager } from './status-bar-hover-manager'; +import { HoverService } from '../hover-service'; import { codicon } from '../widgets'; export { StatusBar, StatusBarAlignment, StatusBarEntry }; @@ -40,7 +40,7 @@ export class StatusBarImpl extends ReactWidget implements StatusBar { @inject(FrontendApplicationStateService) protected readonly applicationStateService: FrontendApplicationStateService, @inject(PreferenceService) protected readonly preferences: PreferenceService, @inject(StatusBarViewModel) protected readonly viewModel: StatusBarViewModel, - @inject(StatusBarHoverManager) protected readonly hoverManager: StatusBarHoverManager, + @inject(HoverService) protected readonly hoverService: HoverService, ) { super(); delete this.scrollOptions; @@ -136,7 +136,11 @@ export class StatusBarImpl extends ReactWidget implements StatusBar { } if (entry.tooltip) { - attrs.onMouseEnter = e => this.hoverManager.requestHover(entry.tooltip!, e.currentTarget); + attrs.onMouseEnter = e => this.hoverService.requestHover({ + content: entry.tooltip!, + target: e.currentTarget, + position: 'top' + }); } if (entry.className) { attrs.className += ' ' + entry.className; diff --git a/packages/core/src/browser/style/hover-service.css b/packages/core/src/browser/style/hover-service.css new file mode 100644 index 0000000000000..2cf9415f163af --- /dev/null +++ b/packages/core/src/browser/style/hover-service.css @@ -0,0 +1,94 @@ +/******************************************************************************** + * Copyright (C) 2022 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/* Adapted from https://github.com/microsoft/vscode/blob/7d9b1c37f8e5ae3772782ba3b09d827eb3fdd833/src/vs/workbench/services/hover/browser/hoverService.ts */ + +:root { + --theia-hover-max-width: 500px; +} + +.theia-hover { + font-family: var(--theia-ui-font-family); + font-size: var(--theia-ui-font-size1); + color: var(--theia-editorHoverWidget-foreground); + background-color: var(--theia-editorHoverWidget-background); + border: 1px solid var(--theia-editorHoverWidget-border); + padding: var(--theia-ui-padding); + max-width: var(--theia-hover-max-width); +} + +.theia-hover .hover-row:not(:first-child):not(:empty) { + border-top: 1px solid var(--theia-editorHoverWidgetInternalBorder); +} + +.theia-hover hr { + border-top: 1px solid var(--theia-editorHoverWidgetInternalBorder); + border-bottom: 0px solid var(--theia-editorHoverWidgetInternalBorder); +} + +.theia-hover a { + color: var(--theia-textLink-foreground); +} + +.theia-hover a:hover { + color: var(--theia-textLink-active-foreground); +} + +.theia-hover .hover-row .actions { + background-color: var(--theia-editorHoverWidget-statusBarBackground); +} + +.theia-hover code { + background-color: var(--theia-textCodeBlock-background); + font-family: var(--theia-code-font-family); +} + +.theia-hover::before { + content: ''; + position: absolute; +} + +.theia-hover.top::before { + left: var(--theia-hover-before-position); + bottom: -5px; + border-top: 5px solid var(--theia-editorHoverWidget-border); + border-left: 5px solid transparent; + border-right: 5px solid transparent; +} + +.theia-hover.bottom::before { + left: var(--theia-hover-before-position); + top: -5px; + border-bottom: 5px solid var(--theia-editorHoverWidget-border); + border-left: 5px solid transparent; + border-right: 5px solid transparent; +} + +.theia-hover.left::before { + top: var(--theia-hover-before-position); + right: -5px; + border-left: 5px solid var(--theia-editorHoverWidget-border); + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; +} + +.theia-hover.right::before { + top: var(--theia-hover-before-position); + left: -5px; + border-right: 5px solid var(--theia-editorHoverWidget-border); + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; +} diff --git a/packages/core/src/browser/style/status-bar.css b/packages/core/src/browser/style/status-bar.css index ecfeedde3d8f1..81a581770e647 100644 --- a/packages/core/src/browser/style/status-bar.css +++ b/packages/core/src/browser/style/status-bar.css @@ -116,52 +116,3 @@ body.theia-no-open-workspace #theia-statusBar { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } - -/* Adapted from https://github.com/microsoft/vscode/blob/7d9b1c37f8e5ae3772782ba3b09d827eb3fdd833/src/vs/workbench/services/hover/browser/hoverService.ts */ - -.theia-status-bar-hover { - font-family: var(--theia-ui-font-family); - font-size: var(--theia-ui-font-size1); - color: var(--theia-editorHoverWidget-foreground); - background-color: var(--theia-editorHoverWidget-background); - border: 1px solid var(--theia-editorHoverWidget-border); - padding: var(--theia-ui-padding); - -} - -.theia-status-bar-hover .hover-row:not(:first-child):not(:empty) { - border-top: 1px solid var(--theia-editorHoverWidgetInternalBorder); -} - -.theia-status-bar-hover hr { - border-top: 1px solid var(--theia-editorHoverWidgetInternalBorder); - border-bottom: 0px solid var(--theia-editorHoverWidgetInternalBorder); -} - -.theia-status-bar-hover a { - color: var(--theia-textLink-foreground); -} - -.theia-status-bar-hover a:hover { - color: var(--theia-textLink-active-foreground); -} - -.theia-status-bar-hover .hover-row .actions { - background-color: var(--theia-editorHoverWidget-statusBarBackground); -} - -.theia-status-bar-hover code { - background-color: var(--theia-textCodeBlock-background); - font-family: var(--theia-code-font-family); -} - -.theia-status-bar-hover::before { - content: ''; - position: absolute; - /* Set by the StatusBarHoverManager */ - left: var(--theia-status-bar-hover-before-left); - bottom: -5px; - border-top: 5px solid var(--theia-editorHoverWidget-border); - border-left: 5px solid transparent; - border-right: 5px solid transparent; -} diff --git a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx index c64284be982e5..39a9bafa2726c 100644 --- a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx +++ b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx @@ -30,9 +30,9 @@ import { TREE_NODE_TAIL_CLASS, TreeModelImpl, TreeViewWelcomeWidget, - TooltipService, TooltipAttributes, - TreeSelection + TreeSelection, + HoverService } from '@theia/core/lib/browser'; import { MenuPath, MenuModelRegistry, ActionMenuNode } from '@theia/core/lib/common/menu'; import * as React from '@theia/core/shared/react'; @@ -409,8 +409,8 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; - @inject(TooltipService) - protected readonly tooltipService: TooltipService; + @inject(HoverService) + protected readonly hoverService: HoverService; @inject(LabelParser) protected readonly labelParser: LabelParser; @@ -481,16 +481,21 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { }; } - const elementRef = React.createRef>(); if (!node.tooltip && node instanceof ResolvableTreeViewNode) { let configuredTip = false; let source: CancellationTokenSource | undefined; attrs = { ...attrs, - 'data-for': this.tooltipService.tooltipId, onMouseLeave: () => source?.cancel(), - onMouseEnter: async () => { + onMouseEnter: async event => { if (configuredTip) { + if (MarkdownString.is(node.tooltip)) { + this.hoverService.requestHover({ + content: node.tooltip, + target: event.currentTarget, + position: 'right' + }); + } return; } if (!node.resolved) { @@ -501,27 +506,31 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { return; } } - if (elementRef.current) { - // Set the resolved tooltip. After an HTML element was created data-* properties must be accessed via the dataset - elementRef.current.dataset.tip = MarkdownString.is(node.tooltip) ? this.markdownIt.render(node.tooltip.value) : node.tooltip; - this.tooltipService.update(); - configuredTip = true; - // Manually fire another mouseenter event to get react-tooltip to update the tooltip content. - // Without this, the resolved tooltip is only shown after re-entering the tree item with the mouse. - elementRef.current.dispatchEvent(new MouseEvent('mouseenter')); + if (MarkdownString.is(node.tooltip)) { + this.hoverService.requestHover({ + content: node.tooltip, + target: event.currentTarget, + position: 'right' + }); } else { - console.error(`Could not set resolved tooltip for tree node '${node.id}' because its React Ref was not set.`); + const title = node.tooltip || + (node.resourceUri && this.labelProvider.getLongName(new CoreURI(node.resourceUri))) + || this.toNodeName(node); + event.currentTarget.title = title; } + configuredTip = true; } }; } else if (MarkdownString.is(node.tooltip)) { - // Render markdown in custom tooltip - const tooltip = this.markdownIt.render(node.tooltip.value); - attrs = { ...attrs, - 'data-tip': tooltip, - 'data-for': this.tooltipService.tooltipId + onMouseEnter: event => { + this.hoverService.requestHover({ + content: node.tooltip!, + target: event.currentTarget, + position: 'right' + }); + } }; } else { const title = node.tooltip || @@ -550,7 +559,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { if (description) { children.push({description}); } - return
{...children}
; + return
{...children}
; } protected override renderTailDecorations(node: TreeViewNode, props: NodeProps): React.ReactNode { @@ -667,9 +676,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { } protected override render(): React.ReactNode { - const node = React.createElement('div', this.createContainerAttributes(), this.renderSearchInfo(), this.renderTree(this.model)); - this.tooltipService.update(); - return node; + return React.createElement('div', this.createContainerAttributes(), this.renderSearchInfo(), this.renderTree(this.model)); } protected renderSearchInfo(): React.ReactNode { diff --git a/packages/vsx-registry/src/browser/vsx-extension.tsx b/packages/vsx-registry/src/browser/vsx-extension.tsx index b8e08ebfc165c..d48c590efa42c 100644 --- a/packages/vsx-registry/src/browser/vsx-extension.tsx +++ b/packages/vsx-registry/src/browser/vsx-extension.tsx @@ -14,7 +14,6 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import * as markdownit from '@theia/core/shared/markdown-it'; import * as React from '@theia/core/shared/react'; import * as DOMPurify from '@theia/core/shared/dompurify'; import { injectable, inject } from '@theia/core/shared/inversify'; @@ -29,9 +28,10 @@ import { Endpoint } from '@theia/core/lib/browser/endpoint'; import { VSXEnvironment } from '../common/vsx-environment'; import { VSXExtensionsSearchModel } from './vsx-extensions-search-model'; import { CommandRegistry, MenuPath, nls } from '@theia/core/lib/common'; -import { codicon, ContextMenuRenderer, TooltipService, TreeWidget } from '@theia/core/lib/browser'; +import { codicon, ContextMenuRenderer, HoverService, TreeWidget } from '@theia/core/lib/browser'; import { VSXExtensionNamespaceAccess, VSXUser } from '@theia/ovsx-client/lib/ovsx-types'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; +import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering'; export const EXTENSIONS_CONTEXT_MENU: MenuPath = ['extensions_context_menu']; @@ -124,8 +124,8 @@ export class VSXExtension implements VSXExtensionData, TreeElement { @inject(VSXExtensionsSearchModel) readonly search: VSXExtensionsSearchModel; - @inject(TooltipService) - readonly tooltipService: TooltipService; + @inject(HoverService) + protected readonly hoverService: HoverService; @inject(WindowService) readonly windowService: WindowService; @@ -266,10 +266,6 @@ export class VSXExtension implements VSXExtensionData, TreeElement { return this.getData('publishedBy'); } - get tooltipId(): string { - return this.tooltipService.tooltipId; - } - get tooltip(): string { let md = `__${this.displayName}__ ${VSXExtension.formatVersion(this.version)}\n\n${this.description}\n_____\n\n${nls.localizeByDefault('Publisher: {0}', this.publisher)}`; @@ -285,7 +281,7 @@ export class VSXExtension implements VSXExtensionData, TreeElement { md += ` \r${getAverageRatingTitle(this.averageRating)}`; } - return markdownit().render(md); + return md; } protected _busy = 0; @@ -364,9 +360,7 @@ export class VSXExtension implements VSXExtensionData, TreeElement { } render(host: TreeWidget): React.ReactNode { - const node = ; - this.tooltipService.update(); - return node; + return ; } } @@ -451,14 +445,24 @@ const getAverageRatingTitle = (averageRating: number): string => export namespace VSXExtensionComponent { export interface Props extends AbstractVSXExtensionComponent.Props { host: TreeWidget; + hoverService: HoverService; } } export class VSXExtensionComponent extends AbstractVSXExtensionComponent { override render(): React.ReactNode { - const { iconUrl, publisher, displayName, description, version, downloadCount, averageRating, tooltipId, tooltip } = this.props.extension; - - return
+ const { iconUrl, publisher, displayName, description, version, downloadCount, averageRating, tooltip } = this.props.extension; + + return
{ + this.props.hoverService.requestHover({ + content: new MarkdownStringImpl(tooltip), + target: event.currentTarget, + position: 'right' + }); + }} + > {iconUrl ? :
} From 6fb9025c784ac97a59638a87312e4c144700765f Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Tue, 15 Nov 2022 14:42:47 +0000 Subject: [PATCH 2/3] Fix target in plugin tree view --- .../plugin-ext/src/main/browser/view/tree-view-widget.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx index 39a9bafa2726c..2de2dea566fb4 100644 --- a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx +++ b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx @@ -492,7 +492,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { if (MarkdownString.is(node.tooltip)) { this.hoverService.requestHover({ content: node.tooltip, - target: event.currentTarget, + target: event.target as HTMLElement, position: 'right' }); } @@ -509,7 +509,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { if (MarkdownString.is(node.tooltip)) { this.hoverService.requestHover({ content: node.tooltip, - target: event.currentTarget, + target: event.target as HTMLElement, position: 'right' }); } else { @@ -527,7 +527,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { onMouseEnter: event => { this.hoverService.requestHover({ content: node.tooltip!, - target: event.currentTarget, + target: event.target as HTMLElement, position: 'right' }); } From 25543d35cee8b25e8b04bffdd447b5699a9918aa Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Mon, 21 Nov 2022 11:59:40 +0000 Subject: [PATCH 3/3] Address review comments --- .../core/src/browser/style/hover-service.css | 1 + .../src/main/browser/view/tree-view-widget.tsx | 18 +----------------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/core/src/browser/style/hover-service.css b/packages/core/src/browser/style/hover-service.css index 2cf9415f163af..8ed6a774418e5 100644 --- a/packages/core/src/browser/style/hover-service.css +++ b/packages/core/src/browser/style/hover-service.css @@ -37,6 +37,7 @@ .theia-hover hr { border-top: 1px solid var(--theia-editorHoverWidgetInternalBorder); border-bottom: 0px solid var(--theia-editorHoverWidgetInternalBorder); + margin: var(--theia-ui-padding) calc(var(--theia-ui-padding) * -1); } .theia-hover a { diff --git a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx index 2de2dea566fb4..9355365f4047f 100644 --- a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx +++ b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx @@ -37,13 +37,12 @@ import { import { MenuPath, MenuModelRegistry, ActionMenuNode } from '@theia/core/lib/common/menu'; import * as React from '@theia/core/shared/react'; import { PluginSharedStyle } from '../plugin-shared-style'; -import { ACTION_ITEM, codicon, Widget } from '@theia/core/lib/browser/widgets/widget'; +import { ACTION_ITEM, Widget } from '@theia/core/lib/browser/widgets/widget'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { MessageService } from '@theia/core/lib/common/message-service'; import { View } from '../../../common/plugin-protocol'; import CoreURI from '@theia/core/lib/common/uri'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; -import * as markdownit from '@theia/core/shared/markdown-it'; import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; import { LabelParser } from '@theia/core/lib/browser/label-parser'; import { AccessibilityInformation } from '@theia/plugin'; @@ -418,33 +417,18 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { @inject(ColorRegistry) protected readonly colorRegistry: ColorRegistry; - protected readonly markdownIt = markdownit(); - @postConstruct() protected override init(): void { super.init(); this.id = this.identifier.id; this.addClass('theia-tree-view'); this.node.style.height = '100%'; - this.markdownItPlugin(); this.model.onDidChangeWelcomeState(this.update, this); this.toDispose.push(this.model.onDidChangeWelcomeState(this.update, this)); this.toDispose.push(this.onDidChangeVisibilityEmitter); this.toDispose.push(this.contextKeyService.onDidChange(() => this.update())); } - protected markdownItPlugin(): void { - this.markdownIt.renderer.rules.text = (tokens, idx) => { - const content = tokens[idx].content; - return this.labelParser.parse(content).map(chunk => { - if (typeof chunk === 'string') { - return chunk; - } - return ``; - }).join(''); - }; - } - protected override renderIcon(node: TreeNode, props: NodeProps): React.ReactNode { const icon = this.toNodeIcon(node); if (icon) {