Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generic hover service support #11869

Merged
merged 3 commits into from
Nov 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/core/src/browser/frontend-application-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_


export { bindResourceProvider, bindMessageService, bindPreferenceService };

Expand Down Expand Up @@ -186,7 +187,8 @@ export const frontendApplicationModule = new ContainerModule((bind, _unbind, _is
const selectionService = container.get(SelectionService);
const commandService = container.get<CommandService>(CommandService);
const corePreferences = container.get<CorePreferences>(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>(CorePreferences);
Expand Down Expand Up @@ -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);
Expand Down
189 changes: 189 additions & 0 deletions packages/core/src/browser/hover-service.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +96 to +100
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@msujew, I notice that in Theia with this change, if any hover has recently been rendered, then any other hover, of the same or different kind, will be rendered immediately, but in VSCode, it appears to differ by source. So e.g. if you hover over an extension tree node, get the hover, then hover over a sidebar tabbar icon, the tabbar icon's title hover will appear immediately, but in VSCode, it will only hover after a delay.


protected async renderHover(request: HoverRequest): Promise<void> {
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();
}
}
1 change: 1 addition & 0 deletions packages/core/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ export * from './breadcrumbs';
export * from './tooltip-service';
export * from './decoration-style';
export * from './styling-service';
export * from './hover-service';
Loading