UNPKG

8.7 kBPlain TextView Raw
1// *****************************************************************************
2// Copyright (C) 2022 Ericsson and others.
3//
4// This program and the accompanying materials are made available under the
5// terms of the Eclipse Public License v. 2.0 which is available at
6// http://www.eclipse.org/legal/epl-2.0.
7//
8// This Source Code may also be made available under the following Secondary
9// Licenses when the conditions for such availability set forth in the Eclipse
10// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11// with the GNU Classpath Exception which is available at
12// https://www.gnu.org/software/classpath/license.html.
13//
14// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15// *****************************************************************************
16
17import { inject, injectable } from 'inversify';
18import { Disposable, DisposableCollection, disposableTimeout, isOSX } from '../common';
19import { MarkdownString } from '../common/markdown-rendering/markdown-string';
20import { animationFrame } from './browser';
21import { MarkdownRenderer, MarkdownRendererFactory } from './markdown-rendering/markdown-renderer';
22import { PreferenceService } from './preferences';
23
24import '../../src/browser/style/hover-service.css';
25
26export type HoverPosition = 'left' | 'right' | 'top' | 'bottom';
27
28export namespace HoverPosition {
29 export function invertIfNecessary(position: HoverPosition, target: DOMRect, host: DOMRect, totalWidth: number, totalHeight: number): HoverPosition {
30 if (position === 'left') {
31 if (target.left - host.width - 5 < 0) {
32 return 'right';
33 }
34 } else if (position === 'right') {
35 if (target.right + host.width + 5 > totalWidth) {
36 return 'left';
37 }
38 } else if (position === 'top') {
39 if (target.top - host.height - 5 < 0) {
40 return 'bottom';
41 }
42 } else if (position === 'bottom') {
43 if (target.bottom + host.height + 5 > totalHeight) {
44 return 'top';
45 }
46 }
47 return position;
48 }
49}
50
51export interface HoverRequest {
52 content: string | MarkdownString | HTMLElement
53 target: HTMLElement
54 /**
55 * The position where the hover should appear.
56 * Note that the hover service will try to invert the position (i.e. right -> left)
57 * if the specified content does not fit in the window next to the target element
58 */
59 position: HoverPosition
60 /**
61 * Additional css classes that should be added to the hover box.
62 * Used to style certain boxes different e.g. for the extended tab preview.
63 */
64 cssClasses?: string []
65}
66
67@injectable()
68export class HoverService {
69 protected static hostClassName = 'theia-hover';
70 protected static styleSheetId = 'theia-hover-style';
71 @inject(PreferenceService) protected readonly preferences: PreferenceService;
72 @inject(MarkdownRendererFactory) protected readonly markdownRendererFactory: MarkdownRendererFactory;
73
74 protected _markdownRenderer: MarkdownRenderer | undefined;
75 protected get markdownRenderer(): MarkdownRenderer {
76 this._markdownRenderer ||= this.markdownRendererFactory();
77 return this._markdownRenderer;
78 }
79
80 protected _hoverHost: HTMLElement | undefined;
81 protected get hoverHost(): HTMLElement {
82 if (!this._hoverHost) {
83 this._hoverHost = document.createElement('div');
84 this._hoverHost.classList.add(HoverService.hostClassName);
85 this._hoverHost.style.position = 'absolute';
86 }
87 return this._hoverHost;
88 }
89 protected pendingTimeout: Disposable | undefined;
90 protected hoverTarget: HTMLElement | undefined;
91 protected lastHidHover = Date.now();
92 protected readonly disposeOnHide = new DisposableCollection();
93
94 requestHover(request: HoverRequest): void {
95 if (request.target !== this.hoverTarget) {
96 this.cancelHover();
97 this.pendingTimeout = disposableTimeout(() => this.renderHover(request), this.getHoverDelay());
98 }
99 }
100
101 protected getHoverDelay(): number {
102 return Date.now() - this.lastHidHover < 200
103 ? 0
104 : this.preferences.get('workbench.hover.delay', isOSX ? 1500 : 500);
105 }
106
107 protected async renderHover(request: HoverRequest): Promise<void> {
108 const host = this.hoverHost;
109 const { target, content, position, cssClasses } = request;
110 if (cssClasses) {
111 host.classList.add(...cssClasses);
112 }
113 this.hoverTarget = target;
114 if (content instanceof HTMLElement) {
115 host.appendChild(content);
116 } else if (typeof content === 'string') {
117 host.textContent = content;
118 } else {
119 const renderedContent = this.markdownRenderer.render(content);
120 this.disposeOnHide.push(renderedContent);
121 host.appendChild(renderedContent.element);
122 }
123 // browsers might insert linebreaks when the hover appears at the edge of the window
124 // resetting the position prevents that
125 host.style.left = '0px';
126 host.style.top = '0px';
127 document.body.append(host);
128 await animationFrame(); // Allow the browser to size the host
129 const updatedPosition = this.setHostPosition(target, host, position);
130
131 this.disposeOnHide.push({
132 dispose: () => {
133 this.lastHidHover = Date.now();
134 host.classList.remove(updatedPosition);
135 if (cssClasses) {
136 host.classList.remove(...cssClasses);
137 }
138 }
139 });
140
141 this.listenForMouseOut();
142 }
143
144 protected setHostPosition(target: HTMLElement, host: HTMLElement, position: HoverPosition): HoverPosition {
145 const targetDimensions = target.getBoundingClientRect();
146 const hostDimensions = host.getBoundingClientRect();
147 const documentWidth = document.body.getBoundingClientRect().width;
148 // document.body.getBoundingClientRect().height doesn't work as expected
149 // scrollHeight will always be accurate here: https://stackoverflow.com/a/44077777
150 const documentHeight = document.documentElement.scrollHeight;
151 position = HoverPosition.invertIfNecessary(position, targetDimensions, hostDimensions, documentWidth, documentHeight);
152 if (position === 'top' || position === 'bottom') {
153 const targetMiddleWidth = targetDimensions.left + (targetDimensions.width / 2);
154 const middleAlignment = targetMiddleWidth - (hostDimensions.width / 2);
155 const furthestRight = Math.min(documentWidth - hostDimensions.width, middleAlignment);
156 const left = Math.max(0, furthestRight);
157 const top = position === 'top'
158 ? targetDimensions.top - hostDimensions.height - 5
159 : targetDimensions.bottom + 5;
160 host.style.setProperty('--theia-hover-before-position', `${targetMiddleWidth - left - 5}px`);
161 host.style.top = `${top}px`;
162 host.style.left = `${left}px`;
163 } else {
164 const targetMiddleHeight = targetDimensions.top + (targetDimensions.height / 2);
165 const middleAlignment = targetMiddleHeight - (hostDimensions.height / 2);
166 const furthestTop = Math.min(documentHeight - hostDimensions.height, middleAlignment);
167 const top = Math.max(0, furthestTop);
168 const left = position === 'left'
169 ? targetDimensions.left - hostDimensions.width - 5
170 : targetDimensions.right + 5;
171 host.style.setProperty('--theia-hover-before-position', `${targetMiddleHeight - top - 5}px`);
172 host.style.left = `${left}px`;
173 host.style.top = `${top}px`;
174 }
175 host.classList.add(position);
176 return position;
177 }
178
179 protected listenForMouseOut(): void {
180 const handleMouseMove = (e: MouseEvent) => {
181 if (e.target instanceof Node && !this.hoverHost.contains(e.target) && !this.hoverTarget?.contains(e.target)) {
182 this.cancelHover();
183 }
184 };
185 document.addEventListener('mousemove', handleMouseMove);
186 this.disposeOnHide.push({ dispose: () => document.removeEventListener('mousemove', handleMouseMove) });
187 }
188
189 cancelHover(): void {
190 this.pendingTimeout?.dispose();
191 this.unRenderHover();
192 this.disposeOnHide.dispose();
193 this.hoverTarget = undefined;
194 }
195
196 protected unRenderHover(): void {
197 this.hoverHost.remove();
198 this.hoverHost.replaceChildren();
199 }
200}