@theia/core 1.75.0 → 1.76.0-next.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/browser/catalog.json +587 -21
- package/lib/browser/decorations-service.d.ts +29 -1
- package/lib/browser/decorations-service.d.ts.map +1 -1
- package/lib/browser/decorations-service.js +124 -36
- package/lib/browser/decorations-service.js.map +1 -1
- package/lib/browser/decorations-service.spec.d.ts +2 -0
- package/lib/browser/decorations-service.spec.d.ts.map +1 -0
- package/lib/browser/decorations-service.spec.js +177 -0
- package/lib/browser/decorations-service.spec.js.map +1 -0
- package/lib/browser/hover-service.d.ts +31 -0
- package/lib/browser/hover-service.d.ts.map +1 -1
- package/lib/browser/hover-service.js +141 -16
- package/lib/browser/hover-service.js.map +1 -1
- package/lib/browser/hover-service.spec.d.ts +2 -0
- package/lib/browser/hover-service.spec.d.ts.map +1 -0
- package/lib/browser/hover-service.spec.js +326 -0
- package/lib/browser/hover-service.spec.js.map +1 -0
- package/package.json +4 -4
- package/src/browser/decorations-service.spec.ts +213 -0
- package/src/browser/decorations-service.ts +159 -38
- package/src/browser/hover-service.spec.ts +367 -0
- package/src/browser/hover-service.ts +143 -16
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 Safi Seid-Ahmad, K2view 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-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { enableJSDOM } from './test/jsdom';
|
|
18
|
+
let disableJSDOM = enableJSDOM();
|
|
19
|
+
|
|
20
|
+
import { Container } from 'inversify';
|
|
21
|
+
import { expect } from 'chai';
|
|
22
|
+
import { HoverService } from './hover-service';
|
|
23
|
+
import { PreferenceService } from '../common';
|
|
24
|
+
import { CoreMarkdownRenderer } from './markdown-rendering/markdown-renderer';
|
|
25
|
+
import { OpenerService } from './opener-service';
|
|
26
|
+
|
|
27
|
+
disableJSDOM();
|
|
28
|
+
|
|
29
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
30
|
+
|
|
31
|
+
describe('HoverService', () => {
|
|
32
|
+
let container: Container;
|
|
33
|
+
let hoverService: HoverService;
|
|
34
|
+
let originalMatches: (selectors: string) => boolean;
|
|
35
|
+
|
|
36
|
+
before(() => {
|
|
37
|
+
disableJSDOM = enableJSDOM();
|
|
38
|
+
// The hover service positions its host after waiting for an animation frame.
|
|
39
|
+
// JSDOM (without pretendToBeVisual) does not provide requestAnimationFrame.
|
|
40
|
+
(global as any).requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 0);
|
|
41
|
+
// JSDOM implements neither the Popover API (showPopover/hidePopover) nor the
|
|
42
|
+
// `:popover-open` pseudo-class: stub them, tracking the open state in an attribute.
|
|
43
|
+
const elementPrototype = window.HTMLElement.prototype as any;
|
|
44
|
+
elementPrototype.showPopover = function (this: HTMLElement): void { this.setAttribute('data-test-popover-open', 'true'); };
|
|
45
|
+
elementPrototype.hidePopover = function (this: HTMLElement): void { this.removeAttribute('data-test-popover-open'); };
|
|
46
|
+
originalMatches = elementPrototype.matches;
|
|
47
|
+
elementPrototype.matches = function (this: HTMLElement, selectors: string): boolean {
|
|
48
|
+
return selectors === ':popover-open' ? this.hasAttribute('data-test-popover-open') : originalMatches.call(this, selectors);
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
after(() => {
|
|
53
|
+
const elementPrototype = window.HTMLElement.prototype as any;
|
|
54
|
+
delete elementPrototype.showPopover;
|
|
55
|
+
delete elementPrototype.hidePopover;
|
|
56
|
+
elementPrototype.matches = originalMatches;
|
|
57
|
+
delete (global as any).requestAnimationFrame;
|
|
58
|
+
disableJSDOM();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
container = new Container();
|
|
63
|
+
container.bind(HoverService).toSelf().inSingletonScope();
|
|
64
|
+
container.bind(PreferenceService).toConstantValue({ get: () => 0 } as any);
|
|
65
|
+
container.bind(CoreMarkdownRenderer).toConstantValue({ render: () => ({ element: document.createElement('div'), dispose: () => { } }) } as any);
|
|
66
|
+
container.bind(OpenerService).toConstantValue({} as any);
|
|
67
|
+
hoverService = container.get(HoverService);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
hoverService.cancelHover();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function waitForHover(): Promise<void> {
|
|
75
|
+
// hover delay (0ms timeout) + animation frame (0ms timeout stub)
|
|
76
|
+
return new Promise(resolve => setTimeout(resolve, 20));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function waitForMouseOutDismissal(): Promise<void> {
|
|
80
|
+
// the mouse-out handler re-checks the hover state after quickMouseThresholdMillis (200ms)
|
|
81
|
+
return new Promise(resolve => setTimeout(resolve, 250));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface FakeSecondaryWindow {
|
|
85
|
+
secondaryDocument: Document;
|
|
86
|
+
fireEvent(type: string): void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Creates a document simulating one hosted in a secondary window: unlike a document from
|
|
91
|
+
* `createHTMLDocument`, it has a `defaultView` window on which the hover service can listen
|
|
92
|
+
* for the window going away.
|
|
93
|
+
*/
|
|
94
|
+
function createSecondaryWindowDocument(options?: { closed?: boolean }): FakeSecondaryWindow {
|
|
95
|
+
const secondaryDocument = document.implementation.createHTMLDocument('secondary window');
|
|
96
|
+
const listeners = new Map<string, EventListener[]>();
|
|
97
|
+
const fakeWindow = {
|
|
98
|
+
closed: options?.closed ?? false,
|
|
99
|
+
requestAnimationFrame: (cb: FrameRequestCallback) => setTimeout(cb, 0),
|
|
100
|
+
addEventListener: (type: string, listener: EventListener) => {
|
|
101
|
+
const forType = listeners.get(type) ?? [];
|
|
102
|
+
forType.push(listener);
|
|
103
|
+
listeners.set(type, forType);
|
|
104
|
+
},
|
|
105
|
+
removeEventListener: (type: string, listener: EventListener) => {
|
|
106
|
+
const forType = listeners.get(type);
|
|
107
|
+
const index = forType?.indexOf(listener) ?? -1;
|
|
108
|
+
if (forType && index > -1) {
|
|
109
|
+
forType.splice(index, 1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
Object.defineProperty(secondaryDocument, 'defaultView', { value: fakeWindow, configurable: true });
|
|
114
|
+
return {
|
|
115
|
+
secondaryDocument,
|
|
116
|
+
fireEvent: type => [...(listeners.get(type) ?? [])].forEach(listener => listener({ type } as Event))
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
it('renders the hover in the document of the target element', async () => {
|
|
121
|
+
const target = document.createElement('div');
|
|
122
|
+
document.body.appendChild(target);
|
|
123
|
+
hoverService.requestHover({ content: 'main window hover', target, position: 'right', skipHoverDelay: true });
|
|
124
|
+
await waitForHover();
|
|
125
|
+
expect(document.querySelector('.theia-hover'), 'hover should be in the main document').to.exist;
|
|
126
|
+
target.remove();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('renders the hover in a secondary window document if the target lives there', async () => {
|
|
130
|
+
const { secondaryDocument } = createSecondaryWindowDocument();
|
|
131
|
+
const target = secondaryDocument.createElement('div');
|
|
132
|
+
secondaryDocument.body.appendChild(target);
|
|
133
|
+
hoverService.requestHover({ content: 'secondary window hover', target, position: 'right', skipHoverDelay: true });
|
|
134
|
+
await waitForHover();
|
|
135
|
+
expect(document.querySelector('.theia-hover'), 'hover should not be in the main document').to.not.exist;
|
|
136
|
+
expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be in the secondary document').to.exist;
|
|
137
|
+
target.remove();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('creates the hover host in the document of the target instead of adopting it across documents', async () => {
|
|
141
|
+
const mainTarget = document.createElement('div');
|
|
142
|
+
document.body.appendChild(mainTarget);
|
|
143
|
+
hoverService.requestHover({ content: 'main', target: mainTarget, position: 'right', skipHoverDelay: true });
|
|
144
|
+
await waitForHover();
|
|
145
|
+
const mainHost = document.querySelector('.theia-hover');
|
|
146
|
+
expect(mainHost, 'hover should be in the main document').to.exist;
|
|
147
|
+
|
|
148
|
+
const { secondaryDocument } = createSecondaryWindowDocument();
|
|
149
|
+
const secondaryTarget = secondaryDocument.createElement('div');
|
|
150
|
+
secondaryDocument.body.appendChild(secondaryTarget);
|
|
151
|
+
hoverService.requestHover({ content: 'secondary', target: secondaryTarget, position: 'right', skipHoverDelay: true });
|
|
152
|
+
await waitForHover();
|
|
153
|
+
const secondaryHost = secondaryDocument.querySelector('.theia-hover');
|
|
154
|
+
expect(secondaryHost, 'hover should be in the secondary document').to.exist;
|
|
155
|
+
// moving a host into another document would make it outlive its window; a host must be
|
|
156
|
+
// created in the document it is shown in
|
|
157
|
+
expect(secondaryHost, 'the secondary host must not be the adopted main host').to.not.equal(mainHost);
|
|
158
|
+
expect(secondaryHost!.ownerDocument).to.equal(secondaryDocument);
|
|
159
|
+
mainTarget.remove();
|
|
160
|
+
secondaryTarget.remove();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('cancels the hover when the window hosting it is closed', async () => {
|
|
164
|
+
const { secondaryDocument, fireEvent } = createSecondaryWindowDocument();
|
|
165
|
+
const target = secondaryDocument.createElement('div');
|
|
166
|
+
secondaryDocument.body.appendChild(target);
|
|
167
|
+
hoverService.requestHover({ content: 'secondary window hover', target, position: 'right', skipHoverDelay: true });
|
|
168
|
+
await waitForHover();
|
|
169
|
+
expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be in the secondary document').to.exist;
|
|
170
|
+
|
|
171
|
+
fireEvent('pagehide');
|
|
172
|
+
expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be removed when its window closes').to.not.exist;
|
|
173
|
+
|
|
174
|
+
// hovers in the main window must keep working afterwards
|
|
175
|
+
const mainTarget = document.createElement('div');
|
|
176
|
+
document.body.appendChild(mainTarget);
|
|
177
|
+
hoverService.requestHover({ content: 'after window close', target: mainTarget, position: 'right', skipHoverDelay: true });
|
|
178
|
+
await waitForHover();
|
|
179
|
+
expect(document.querySelector('.theia-hover'), 'hover should be rendered in the main document afterwards').to.exist;
|
|
180
|
+
target.remove();
|
|
181
|
+
mainTarget.remove();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('dismisses the hover when the pointer leaves its target in a secondary window', async () => {
|
|
185
|
+
const { secondaryDocument } = createSecondaryWindowDocument();
|
|
186
|
+
const target = secondaryDocument.createElement('div');
|
|
187
|
+
secondaryDocument.body.appendChild(target);
|
|
188
|
+
hoverService.requestHover({ content: 'secondary window hover', target, position: 'right', skipHoverDelay: true });
|
|
189
|
+
await waitForHover();
|
|
190
|
+
expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be shown in the secondary document').to.exist;
|
|
191
|
+
|
|
192
|
+
target.dispatchEvent(new window.Event('mouseout'));
|
|
193
|
+
await waitForMouseOutDismissal();
|
|
194
|
+
expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be dismissed after the pointer left its target').to.not.exist;
|
|
195
|
+
target.remove();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('dismisses a non-interactive hover on mousedown in a secondary window', async () => {
|
|
199
|
+
const { secondaryDocument } = createSecondaryWindowDocument();
|
|
200
|
+
const target = secondaryDocument.createElement('div');
|
|
201
|
+
secondaryDocument.body.appendChild(target);
|
|
202
|
+
hoverService.requestHover({ content: 'secondary window hover', target, position: 'right', skipHoverDelay: true });
|
|
203
|
+
await waitForHover();
|
|
204
|
+
expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be shown in the secondary document').to.exist;
|
|
205
|
+
|
|
206
|
+
secondaryDocument.body.dispatchEvent(new window.Event('mousedown'));
|
|
207
|
+
expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be dismissed on mousedown outside of it').to.not.exist;
|
|
208
|
+
target.remove();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('shows at most one hover box in a secondary window across repeated hovers', async () => {
|
|
212
|
+
const { secondaryDocument } = createSecondaryWindowDocument();
|
|
213
|
+
const target = secondaryDocument.createElement('div');
|
|
214
|
+
secondaryDocument.body.appendChild(target);
|
|
215
|
+
for (let i = 0; i < 2; i++) {
|
|
216
|
+
hoverService.requestHover({ content: `hover ${i}`, target, position: 'right', skipHoverDelay: true });
|
|
217
|
+
await waitForHover();
|
|
218
|
+
target.dispatchEvent(new window.Event('mouseout'));
|
|
219
|
+
await waitForMouseOutDismissal();
|
|
220
|
+
}
|
|
221
|
+
hoverService.requestHover({ content: 'final hover', target, position: 'right', skipHoverDelay: true });
|
|
222
|
+
await waitForHover();
|
|
223
|
+
expect(secondaryDocument.querySelectorAll('.theia-hover').length, 'stale hover hosts must not pile up').to.equal(1);
|
|
224
|
+
target.remove();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('does not render a hover for a target in an already closed window', async () => {
|
|
228
|
+
const { secondaryDocument } = createSecondaryWindowDocument({ closed: true });
|
|
229
|
+
const target = secondaryDocument.createElement('div');
|
|
230
|
+
secondaryDocument.body.appendChild(target);
|
|
231
|
+
hoverService.requestHover({ content: 'closed window hover', target, position: 'right', skipHoverDelay: true });
|
|
232
|
+
await waitForHover();
|
|
233
|
+
expect(secondaryDocument.querySelector('.theia-hover'), 'no hover should be rendered in a closed window').to.not.exist;
|
|
234
|
+
expect(document.querySelector('.theia-hover'), 'no hover should be rendered in the main document either').to.not.exist;
|
|
235
|
+
target.remove();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('keeps the hover host hidden until it has been positioned', async () => {
|
|
239
|
+
// the host is appended (and the popover shown) at (0, 0) first and only positioned after an
|
|
240
|
+
// animation frame; it must not be hittable in the meantime: a visible popover at (0, 0) can
|
|
241
|
+
// cover the target, kick it out of the hover chain and retrigger mouseenter hovers in an
|
|
242
|
+
// endless show/hide loop (e.g. for tabs at the top-left corner of a secondary window)
|
|
243
|
+
const target = document.createElement('div');
|
|
244
|
+
document.body.appendChild(target);
|
|
245
|
+
const rendering = (hoverService as any).renderHover({ content: 'positioning', target, position: 'right' }) as Promise<void>;
|
|
246
|
+
const host = document.querySelector('.theia-hover') as HTMLElement;
|
|
247
|
+
expect(host, 'hover should be appended synchronously').to.exist;
|
|
248
|
+
expect(host.style.visibility, 'hover must not be visible before it has been positioned').to.equal('hidden');
|
|
249
|
+
await rendering;
|
|
250
|
+
expect(host.style.visibility, 'hover should be revealed once positioned').to.not.equal('hidden');
|
|
251
|
+
target.remove();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('does not reveal a hover that was superseded while waiting to be positioned', async () => {
|
|
255
|
+
const target = document.createElement('div');
|
|
256
|
+
document.body.appendChild(target);
|
|
257
|
+
const service = hoverService as any;
|
|
258
|
+
const first = service.renderHover({ content: 'first', target, position: 'right' }) as Promise<void>;
|
|
259
|
+
const second = service.renderHover({ content: 'second', target, position: 'right' }) as Promise<void>;
|
|
260
|
+
await first;
|
|
261
|
+
const host = document.querySelector('.theia-hover') as HTMLElement;
|
|
262
|
+
expect(host.style.visibility, 'the superseded render must not reveal the host').to.equal('hidden');
|
|
263
|
+
await second;
|
|
264
|
+
expect(host.style.visibility, 'the latest render reveals the host').to.not.equal('hidden');
|
|
265
|
+
target.remove();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('does not leak css classes from a hover that was superseded while waiting to be positioned', async () => {
|
|
269
|
+
const service = hoverService as any;
|
|
270
|
+
// keep the first render stuck waiting for its animation frame so that a second hover supersedes it mid-render
|
|
271
|
+
const originalAnimationFrame = service.hostAnimationFrame.bind(service);
|
|
272
|
+
let releaseFirst: () => void;
|
|
273
|
+
let animationFrameCalls = 0;
|
|
274
|
+
service.hostAnimationFrame = (element: HTMLElement) => ++animationFrameCalls === 1
|
|
275
|
+
? new Promise<void>(resolve => { releaseFirst = resolve; })
|
|
276
|
+
: originalAnimationFrame(element);
|
|
277
|
+
const target = document.createElement('div');
|
|
278
|
+
document.body.appendChild(target);
|
|
279
|
+
hoverService.requestHover({ content: 'first', target, position: 'right', skipHoverDelay: true, cssClasses: ['first-hover-class'] });
|
|
280
|
+
await waitForHover();
|
|
281
|
+
hoverService.requestHover({ content: 'second', target, position: 'right', skipHoverDelay: true });
|
|
282
|
+
await waitForHover();
|
|
283
|
+
releaseFirst!(); // let the superseded render finish
|
|
284
|
+
await waitForHover();
|
|
285
|
+
const host = document.querySelector('.theia-hover');
|
|
286
|
+
expect(host, 'second hover should be rendered').to.exist;
|
|
287
|
+
expect(host!.classList.contains('first-hover-class'), 'the superseded hover must not leak its css classes').to.equal(false);
|
|
288
|
+
target.remove();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('recovers if the open hover can no longer be hidden', async () => {
|
|
292
|
+
// simulate a hover whose document is no longer fully active, e.g. because the secondary
|
|
293
|
+
// window hosting it was closed: hidePopover throws and must not break subsequent hovers
|
|
294
|
+
const target = document.createElement('div');
|
|
295
|
+
document.body.appendChild(target);
|
|
296
|
+
hoverService.requestHover({ content: 'first', target, position: 'right', skipHoverDelay: true });
|
|
297
|
+
await waitForHover();
|
|
298
|
+
const host = document.querySelector('.theia-hover') as HTMLElement;
|
|
299
|
+
expect(host, 'first hover should be rendered').to.exist;
|
|
300
|
+
(host as any).hidePopover = () => { throw new Error('InvalidStateError: not fully active'); };
|
|
301
|
+
|
|
302
|
+
const secondTarget = document.createElement('div');
|
|
303
|
+
document.body.appendChild(secondTarget);
|
|
304
|
+
hoverService.requestHover({ content: 'second', target: secondTarget, position: 'right', skipHoverDelay: true });
|
|
305
|
+
await waitForHover();
|
|
306
|
+
const secondHost = document.querySelector('.theia-hover');
|
|
307
|
+
expect(secondHost, 'hover should be rendered again in the main document').to.exist;
|
|
308
|
+
expect(secondHost!.textContent).to.equal('second');
|
|
309
|
+
target.remove();
|
|
310
|
+
secondTarget.remove();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('position fallback', () => {
|
|
314
|
+
// simulated window: 400px wide, 600px high
|
|
315
|
+
const windowWidth = 400;
|
|
316
|
+
const windowHeight = 600;
|
|
317
|
+
let target: HTMLElement;
|
|
318
|
+
let originalBodyRect: () => DOMRect;
|
|
319
|
+
|
|
320
|
+
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
|
321
|
+
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => '' };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
beforeEach(() => {
|
|
325
|
+
target = document.createElement('div');
|
|
326
|
+
document.body.appendChild(target);
|
|
327
|
+
const host: HTMLElement = (hoverService as any).hoverHost;
|
|
328
|
+
host.getBoundingClientRect = () => rect(0, 0, 300, 50);
|
|
329
|
+
originalBodyRect = document.body.getBoundingClientRect.bind(document.body);
|
|
330
|
+
document.body.getBoundingClientRect = () => rect(0, 0, windowWidth, windowHeight);
|
|
331
|
+
Object.defineProperty(document.documentElement, 'scrollHeight', { value: windowHeight, configurable: true });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
afterEach(() => {
|
|
335
|
+
document.body.getBoundingClientRect = originalBodyRect;
|
|
336
|
+
delete (document.documentElement as any).scrollHeight;
|
|
337
|
+
target.remove();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
function setHostPosition(position: 'left' | 'right' | 'top' | 'bottom'): string {
|
|
341
|
+
const service = hoverService as any;
|
|
342
|
+
return service.setHostPosition(target, service.hoverHost, position);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
it('keeps the requested position when the hover fits', () => {
|
|
346
|
+
target.getBoundingClientRect = () => rect(320, 100, 60, 20); // plenty of room on the left
|
|
347
|
+
expect(setHostPosition('left')).to.equal('left');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('falls back to bottom when a left hover fits on neither side of a full-width target', () => {
|
|
351
|
+
target.getBoundingClientRect = () => rect(0, 100, windowWidth, 20);
|
|
352
|
+
expect(setHostPosition('left')).to.equal('bottom');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('falls back to top when the full-width target is near the bottom of the window', () => {
|
|
356
|
+
target.getBoundingClientRect = () => rect(0, windowHeight - 30, windowWidth, 20);
|
|
357
|
+
expect(setHostPosition('right')).to.equal('top');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('keeps the requested direction when the perpendicular direction does not fit either', () => {
|
|
361
|
+
const host: HTMLElement = (hoverService as any).hoverHost;
|
|
362
|
+
host.getBoundingClientRect = () => rect(0, 0, 300, windowHeight); // hover as tall as the window
|
|
363
|
+
target.getBoundingClientRect = () => rect(0, 100, windowWidth, 20);
|
|
364
|
+
expect(setHostPosition('left')).to.equal('right');
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -51,6 +51,22 @@ export namespace HoverPosition {
|
|
|
51
51
|
}
|
|
52
52
|
return position;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Tests whether a hover of the given dimensions fits next to its target in the given
|
|
57
|
+
* position without extending beyond the window bounds.
|
|
58
|
+
*/
|
|
59
|
+
export function fits(position: HoverPosition, target: DOMRect, host: DOMRect, totalWidth: number, totalHeight: number): boolean {
|
|
60
|
+
if (position === 'left') {
|
|
61
|
+
return target.left - host.width - 5 >= 0;
|
|
62
|
+
} else if (position === 'right') {
|
|
63
|
+
return target.right + host.width + 5 <= totalWidth;
|
|
64
|
+
} else if (position === 'top') {
|
|
65
|
+
return target.top - host.height - 5 >= 0;
|
|
66
|
+
} else {
|
|
67
|
+
return target.bottom + host.height + 5 <= totalHeight;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
export interface HoverRequest {
|
|
@@ -102,9 +118,25 @@ export class HoverService {
|
|
|
102
118
|
@inject(OpenerService) protected readonly openerService: OpenerService;
|
|
103
119
|
|
|
104
120
|
protected _hoverHost: HTMLElement | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* The host of the current hover, which may live in a secondary window's document.
|
|
123
|
+
* Resolving against the main document here instead would silently replace the host
|
|
124
|
+
* whenever the current hover lives in another document, detaching the dismissal
|
|
125
|
+
* listeners and `unRenderHover` from the host that is actually rendered.
|
|
126
|
+
*/
|
|
105
127
|
protected get hoverHost(): HTMLElement {
|
|
106
|
-
|
|
107
|
-
|
|
128
|
+
return this._hoverHost ?? this.getOrCreateHoverHost(document);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Returns the host element to render hovers into for the given document, creating it if the
|
|
133
|
+
* current one belongs to a different document. A host is always created in the document it is
|
|
134
|
+
* shown in: adopting a host into another document would let it outlive its window, and touching
|
|
135
|
+
* it after that window was closed breaks (and in Electron crashes) the application.
|
|
136
|
+
*/
|
|
137
|
+
protected getOrCreateHoverHost(targetDocument: Document): HTMLElement {
|
|
138
|
+
if (!this._hoverHost || this._hoverHost.ownerDocument !== targetDocument) {
|
|
139
|
+
this._hoverHost = targetDocument.createElement('div');
|
|
108
140
|
this._hoverHost.classList.add(HoverService.hostClassName);
|
|
109
141
|
this._hoverHost.style.position = 'absolute';
|
|
110
142
|
this._hoverHost.setAttribute('popover', 'hint');
|
|
@@ -113,18 +145,44 @@ export class HoverService {
|
|
|
113
145
|
}
|
|
114
146
|
protected pendingTimeout: Disposable | undefined;
|
|
115
147
|
protected hoverTarget: HTMLElement | undefined;
|
|
148
|
+
/** Identifies the latest render so that superseded renders can detect they are stale. */
|
|
149
|
+
protected renderSequence = 0;
|
|
116
150
|
protected lastHidHover = Date.now();
|
|
117
151
|
protected readonly disposeOnHide = new DisposableCollection();
|
|
118
152
|
|
|
119
153
|
requestHover(request: HoverRequest): void {
|
|
120
154
|
this.cancelHover();
|
|
155
|
+
const targetWindow = request.target.ownerDocument.defaultView;
|
|
156
|
+
if (!targetWindow || targetWindow.closed) {
|
|
157
|
+
// the window hosting the target is already gone, e.g. a closed secondary window:
|
|
158
|
+
// its document must not be touched anymore
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
121
161
|
const delay = request.skipHoverDelay ? 0 : this.getHoverDelay();
|
|
122
162
|
this.pendingTimeout = disposableTimeout(() => this.renderHover(request), delay);
|
|
123
163
|
this.hoverTarget = request.target;
|
|
164
|
+
// resolve the host for the target's document up front so that the listeners below attach to the host that will be rendered
|
|
165
|
+
this.getOrCreateHoverHost(request.target.ownerDocument);
|
|
166
|
+
this.listenForWindowClose(request.target);
|
|
124
167
|
this.listenForMouseOut();
|
|
125
168
|
this.listenForMouseClick(request);
|
|
126
169
|
}
|
|
127
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Cancels the hover when the window hosting the target element goes away, e.g. when the
|
|
173
|
+
* secondary window containing the target is closed: neither the hover host nor any listeners
|
|
174
|
+
* may outlive the document they belong to.
|
|
175
|
+
*/
|
|
176
|
+
protected listenForWindowClose(target: HTMLElement): void {
|
|
177
|
+
const targetWindow = target.ownerDocument.defaultView;
|
|
178
|
+
if (!targetWindow || targetWindow === window) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const handlePageHide = () => this.cancelHover();
|
|
182
|
+
targetWindow.addEventListener('pagehide', handlePageHide);
|
|
183
|
+
this.disposeOnHide.push({ dispose: () => targetWindow.removeEventListener('pagehide', handlePageHide) });
|
|
184
|
+
}
|
|
185
|
+
|
|
128
186
|
protected getHoverDelay(): number {
|
|
129
187
|
return Date.now() - this.lastHidHover < quickMouseThresholdMillis
|
|
130
188
|
? 0
|
|
@@ -132,9 +190,16 @@ export class HoverService {
|
|
|
132
190
|
}
|
|
133
191
|
|
|
134
192
|
protected async renderHover(request: HoverRequest): Promise<void> {
|
|
135
|
-
const host = this.hoverHost;
|
|
136
|
-
let firstChild: HTMLElement | undefined;
|
|
137
193
|
const { target, content, position, cssClasses, interactive, onHide } = request;
|
|
194
|
+
const targetWindow = target.ownerDocument.defaultView;
|
|
195
|
+
if (!targetWindow || targetWindow.closed) {
|
|
196
|
+
// the window hosting the target is already gone, e.g. a closed secondary window:
|
|
197
|
+
// its document must not be touched anymore
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const host = this.getOrCreateHoverHost(target.ownerDocument);
|
|
201
|
+
const sequence = ++this.renderSequence;
|
|
202
|
+
let firstChild: HTMLElement | undefined;
|
|
138
203
|
if (onHide) {
|
|
139
204
|
this.disposeOnHide.push({ dispose: onHide.bind(request) });
|
|
140
205
|
}
|
|
@@ -163,7 +228,9 @@ export class HoverService {
|
|
|
163
228
|
// handler then cancels the hover, and the cycle repeats: the tooltip flickers and never settles.
|
|
164
229
|
// `visibility: hidden` still lays the host out (so it can be measured) but is not hit-tested.
|
|
165
230
|
host.style.visibility = 'hidden';
|
|
166
|
-
document
|
|
231
|
+
// Render the hover in the document of the target: it may be hosted in a secondary window,
|
|
232
|
+
// whose coordinate space is unrelated to the main window's.
|
|
233
|
+
target.ownerDocument.body.append(host);
|
|
167
234
|
if (!host.matches(':popover-open')) {
|
|
168
235
|
host.showPopover();
|
|
169
236
|
}
|
|
@@ -188,7 +255,12 @@ export class HoverService {
|
|
|
188
255
|
}
|
|
189
256
|
}
|
|
190
257
|
|
|
191
|
-
await
|
|
258
|
+
await this.hostAnimationFrame(target); // Allow the browser to size the host
|
|
259
|
+
if (sequence !== this.renderSequence || !host.isConnected) {
|
|
260
|
+
// this hover was cancelled or superseded by a newer one while waiting for the animation
|
|
261
|
+
// frame: it must neither reposition nor reveal the host
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
192
264
|
const updatedPosition = this.setHostPosition(target, host, position);
|
|
193
265
|
// Reveal the host only once it sits at its final position, so it never overlaps the target while
|
|
194
266
|
// parked at (0,0). Dropping the declaration rather than assigning a value hands `visibility` back
|
|
@@ -206,14 +278,40 @@ export class HoverService {
|
|
|
206
278
|
});
|
|
207
279
|
}
|
|
208
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Waits for an animation frame in the window hosting the given target element,
|
|
283
|
+
* which may be a secondary window whose rendering is independent of the main window's.
|
|
284
|
+
*/
|
|
285
|
+
protected hostAnimationFrame(target: HTMLElement): Promise<void> {
|
|
286
|
+
const targetWindow = target.ownerDocument.defaultView;
|
|
287
|
+
if (!targetWindow || targetWindow === window) {
|
|
288
|
+
return animationFrame();
|
|
289
|
+
}
|
|
290
|
+
return new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve()));
|
|
291
|
+
}
|
|
292
|
+
|
|
209
293
|
protected setHostPosition(target: HTMLElement, host: HTMLElement, position: HoverPosition): HoverPosition {
|
|
294
|
+
const hostDocument = target.ownerDocument;
|
|
210
295
|
const targetDimensions = target.getBoundingClientRect();
|
|
211
296
|
const hostDimensions = host.getBoundingClientRect();
|
|
212
|
-
const documentWidth =
|
|
297
|
+
const documentWidth = hostDocument.body.getBoundingClientRect().width;
|
|
213
298
|
// document.body.getBoundingClientRect().height doesn't work as expected
|
|
214
299
|
// scrollHeight will always be accurate here: https://stackoverflow.com/a/44077777
|
|
215
|
-
const documentHeight =
|
|
300
|
+
const documentHeight = hostDocument.documentElement.scrollHeight;
|
|
216
301
|
position = HoverPosition.invertIfNecessary(position, targetDimensions, hostDimensions, documentWidth, documentHeight);
|
|
302
|
+
if (!HoverPosition.fits(position, targetDimensions, hostDimensions, documentWidth, documentHeight)) {
|
|
303
|
+
// The hover fits on neither side of the target in the requested direction, e.g. when the
|
|
304
|
+
// target spans the full width of a narrow secondary window. Try the perpendicular direction
|
|
305
|
+
// so that the target and the hover both remain visible. If that does not fit either,
|
|
306
|
+
// keep the requested direction; the clamping below keeps the hover inside the viewport.
|
|
307
|
+
const fallback = HoverPosition.invertIfNecessary(
|
|
308
|
+
position === 'left' || position === 'right' ? 'bottom' : 'right',
|
|
309
|
+
targetDimensions, hostDimensions, documentWidth, documentHeight
|
|
310
|
+
);
|
|
311
|
+
if (HoverPosition.fits(fallback, targetDimensions, hostDimensions, documentWidth, documentHeight)) {
|
|
312
|
+
position = fallback;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
217
315
|
if (position === 'top' || position === 'bottom') {
|
|
218
316
|
const targetMiddleWidth = targetDimensions.left + (targetDimensions.width / 2);
|
|
219
317
|
const middleAlignment = targetMiddleWidth - (hostDimensions.width / 2);
|
|
@@ -230,9 +328,13 @@ export class HoverService {
|
|
|
230
328
|
const middleAlignment = targetMiddleHeight - (hostDimensions.height / 2);
|
|
231
329
|
const furthestTop = Math.min(documentHeight - hostDimensions.height, middleAlignment);
|
|
232
330
|
const top = Math.max(0, furthestTop);
|
|
233
|
-
const
|
|
331
|
+
const desiredLeft = position === 'left'
|
|
234
332
|
? targetDimensions.left - hostDimensions.width - 5
|
|
235
333
|
: targetDimensions.right + 5;
|
|
334
|
+
// the hover may not fit on either side of the target, e.g. when the target
|
|
335
|
+
// spans the full width of a narrow (secondary) window: keep it in the viewport
|
|
336
|
+
const furthestRight = Math.min(documentWidth - hostDimensions.width, desiredLeft);
|
|
337
|
+
const left = Math.max(0, furthestRight);
|
|
236
338
|
host.style.setProperty('--theia-hover-before-position', `${targetMiddleHeight - top - 5}px`);
|
|
237
339
|
host.style.left = `${left}px`;
|
|
238
340
|
host.style.top = `${top}px`;
|
|
@@ -280,18 +382,43 @@ export class HoverService {
|
|
|
280
382
|
this.cancelHover();
|
|
281
383
|
}
|
|
282
384
|
};
|
|
283
|
-
document
|
|
284
|
-
|
|
385
|
+
// Listen in the document of the target, which may be hosted in a secondary window
|
|
386
|
+
const targetDocument = request.target.ownerDocument;
|
|
387
|
+
targetDocument.addEventListener('mousedown', handleMouseDown, true);
|
|
388
|
+
this.disposeOnHide.push({ dispose: () => targetDocument.removeEventListener('mousedown', handleMouseDown, true) });
|
|
285
389
|
}
|
|
286
390
|
|
|
287
391
|
protected unRenderHover(): void {
|
|
288
|
-
|
|
289
|
-
|
|
392
|
+
const host = this._hoverHost;
|
|
393
|
+
if (!host) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const hostWindow = host.ownerDocument.defaultView;
|
|
397
|
+
if (!hostWindow || hostWindow.closed) {
|
|
398
|
+
// the window hosting the hover is already gone, e.g. a closed secondary window:
|
|
399
|
+
// its DOM must not be touched anymore; abandon the host and start from scratch
|
|
400
|
+
this._hoverHost = undefined;
|
|
401
|
+
return;
|
|
290
402
|
}
|
|
291
|
-
|
|
292
|
-
|
|
403
|
+
try {
|
|
404
|
+
if (host.matches(':popover-open')) {
|
|
405
|
+
host.hidePopover();
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
// hidePopover throws for popovers in documents that are no longer fully active,
|
|
409
|
+
// e.g. while the secondary window hosting the hover is being closed
|
|
410
|
+
}
|
|
411
|
+
host.remove();
|
|
412
|
+
host.replaceChildren();
|
|
413
|
+
// drop the classes added for the rendered hover (position and request classes): a render
|
|
414
|
+
// aborted because it was superseded must not leak its classes into the next hover
|
|
415
|
+
host.className = HoverService.hostClassName;
|
|
293
416
|
// The host is reused across hovers; drop the transient hidden state set during measurement so a
|
|
294
417
|
// hover cancelled before it was revealed does not leave the next one invisible.
|
|
295
|
-
|
|
418
|
+
host.style.removeProperty('visibility');
|
|
419
|
+
if (host.ownerDocument !== document) {
|
|
420
|
+
// never keep a host from another document: it must not outlive its window
|
|
421
|
+
this._hoverHost = undefined;
|
|
422
|
+
}
|
|
296
423
|
}
|
|
297
424
|
}
|