1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 | import { injectable, decorate, unmanaged } from 'inversify';
|
20 | import { Title, Widget } from '@phosphor/widgets';
|
21 | import { Message, MessageLoop } from '@phosphor/messaging';
|
22 | import { Emitter, Event, Disposable, DisposableCollection, MaybePromise, isObject } from '../../common';
|
23 | import { KeyCode, KeysOrKeyCodes } from '../keyboard/keys';
|
24 |
|
25 | import PerfectScrollbar from 'perfect-scrollbar';
|
26 |
|
27 | decorate(injectable(), Widget);
|
28 | decorate(unmanaged(), Widget, 0);
|
29 |
|
30 | export * from '@phosphor/widgets';
|
31 | export * from '@phosphor/messaging';
|
32 |
|
33 | export const ACTION_ITEM = 'action-label';
|
34 |
|
35 | export function codiconArray(name: string, actionItem = false): string[] {
|
36 | const array = ['codicon', `codicon-${name}`];
|
37 | if (actionItem) {
|
38 | array.push(ACTION_ITEM);
|
39 | }
|
40 | return array;
|
41 | }
|
42 |
|
43 | export function codicon(name: string, actionItem = false): string {
|
44 | return `codicon codicon-${name}${actionItem ? ` ${ACTION_ITEM}` : ''}`;
|
45 | }
|
46 |
|
47 | export const DISABLED_CLASS = 'theia-mod-disabled';
|
48 | export const EXPANSION_TOGGLE_CLASS = 'theia-ExpansionToggle';
|
49 | export const CODICON_TREE_ITEM_CLASSES = codiconArray('chevron-down');
|
50 | export const COLLAPSED_CLASS = 'theia-mod-collapsed';
|
51 | export const BUSY_CLASS = 'theia-mod-busy';
|
52 | export const CODICON_LOADING_CLASSES = codiconArray('loading');
|
53 | export const SELECTED_CLASS = 'theia-mod-selected';
|
54 | export const FOCUS_CLASS = 'theia-mod-focus';
|
55 | export const PINNED_CLASS = 'theia-mod-pinned';
|
56 | export const LOCKED_CLASS = 'theia-mod-locked';
|
57 | export const DEFAULT_SCROLL_OPTIONS: PerfectScrollbar.Options = {
|
58 | suppressScrollX: true,
|
59 | minScrollbarLength: 35,
|
60 | };
|
61 |
|
62 | /**
|
63 | * At a number of places in the code, we have effectively reimplemented Phosphor's Widget.attach and Widget.detach,
|
64 | * but omitted the checks that Phosphor expects to be performed for those operations. That is a bad idea, because it
|
65 | * means that we are telling widgets that they are attached or detached when not all the conditions that should apply
|
66 | * do apply. We should explicitly mark those locations so that we know where we should go fix them later.
|
67 | */
|
68 | export namespace UnsafeWidgetUtilities {
|
69 | /**
|
70 | * Ordinarily, the following checks should be performed before detaching a widget:
|
71 | * It should not be the child of another widget
|
72 | * It should be attached and it should be a child of document.body
|
73 | */
|
74 | export function detach(widget: Widget): void {
|
75 | MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
|
76 | widget.node.remove();
|
77 | MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
|
78 | };
|
79 | /**
|
80 | * @param ref The child of the host element to insert the widget before.
|
81 | * Ordinarily the following checks should be performed:
|
82 | * The widget should have no parent
|
83 | * The widget should not be attached, and its node should not be a child of document.body
|
84 | * The host should be a child of document.body
|
85 | * We often violate the last condition.
|
86 | */
|
87 | // eslint-disable-next-line no-null/no-null
|
88 | export function attach(widget: Widget, host: HTMLElement, ref: HTMLElement | null = null): void {
|
89 | MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
|
90 | host.insertBefore(widget.node, ref);
|
91 | MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
|
92 | };
|
93 | }
|
94 |
|
95 | @injectable()
|
96 | export class BaseWidget extends Widget {
|
97 |
|
98 | protected readonly onScrollYReachEndEmitter = new Emitter<void>();
|
99 | readonly onScrollYReachEnd: Event<void> = this.onScrollYReachEndEmitter.event;
|
100 | protected readonly onScrollUpEmitter = new Emitter<void>();
|
101 | readonly onScrollUp: Event<void> = this.onScrollUpEmitter.event;
|
102 | protected readonly onDidChangeVisibilityEmitter = new Emitter<boolean>();
|
103 | readonly onDidChangeVisibility = this.onDidChangeVisibilityEmitter.event;
|
104 | protected readonly onDidDisposeEmitter = new Emitter<void>();
|
105 | readonly onDidDispose = this.onDidDisposeEmitter.event;
|
106 |
|
107 | protected readonly toDispose = new DisposableCollection(
|
108 | this.onDidDisposeEmitter,
|
109 | Disposable.create(() => this.onDidDisposeEmitter.fire()),
|
110 | this.onScrollYReachEndEmitter,
|
111 | this.onScrollUpEmitter,
|
112 | this.onDidChangeVisibilityEmitter
|
113 | );
|
114 | protected readonly toDisposeOnDetach = new DisposableCollection();
|
115 | protected scrollBar?: PerfectScrollbar;
|
116 | protected scrollOptions?: PerfectScrollbar.Options;
|
117 |
|
118 | override dispose(): void {
|
119 | if (this.isDisposed) {
|
120 | return;
|
121 | }
|
122 | super.dispose();
|
123 | this.toDispose.dispose();
|
124 | }
|
125 |
|
126 | protected override onCloseRequest(msg: Message): void {
|
127 | super.onCloseRequest(msg);
|
128 | this.dispose();
|
129 | }
|
130 |
|
131 | protected override onBeforeAttach(msg: Message): void {
|
132 | if (this.title.iconClass === '') {
|
133 | this.title.iconClass = 'no-icon';
|
134 | }
|
135 | super.onBeforeAttach(msg);
|
136 | }
|
137 |
|
138 | protected override onAfterDetach(msg: Message): void {
|
139 | if (this.title.iconClass === 'no-icon') {
|
140 | this.title.iconClass = '';
|
141 | }
|
142 | super.onAfterDetach(msg);
|
143 | }
|
144 |
|
145 | protected override onBeforeDetach(msg: Message): void {
|
146 | this.toDisposeOnDetach.dispose();
|
147 | super.onBeforeDetach(msg);
|
148 | }
|
149 |
|
150 | protected override onAfterAttach(msg: Message): void {
|
151 | super.onAfterAttach(msg);
|
152 | if (this.scrollOptions) {
|
153 | (async () => {
|
154 | const container = await this.getScrollContainer();
|
155 | container.style.overflow = 'hidden';
|
156 | this.scrollBar = new PerfectScrollbar(container, this.scrollOptions);
|
157 | this.disableScrollBarFocus(container);
|
158 | this.toDisposeOnDetach.push(addEventListener(container, <any>'ps-y-reach-end', () => { this.onScrollYReachEndEmitter.fire(undefined); }));
|
159 | this.toDisposeOnDetach.push(addEventListener(container, <any>'ps-scroll-up', () => { this.onScrollUpEmitter.fire(undefined); }));
|
160 | this.toDisposeOnDetach.push(Disposable.create(() => {
|
161 | if (this.scrollBar) {
|
162 | this.scrollBar.destroy();
|
163 | this.scrollBar = undefined;
|
164 | }
|
165 | container.style.overflow = 'initial';
|
166 | }));
|
167 | })();
|
168 | }
|
169 | }
|
170 |
|
171 | protected getScrollContainer(): MaybePromise<HTMLElement> {
|
172 | return this.node;
|
173 | }
|
174 |
|
175 | protected disableScrollBarFocus(scrollContainer: HTMLElement): void {
|
176 | for (const thumbs of [scrollContainer.getElementsByClassName('ps__thumb-x'), scrollContainer.getElementsByClassName('ps__thumb-y')]) {
|
177 | for (let i = 0; i < thumbs.length; i++) {
|
178 | const element = thumbs.item(i);
|
179 | if (element) {
|
180 | element.removeAttribute('tabIndex');
|
181 | }
|
182 | }
|
183 | }
|
184 | }
|
185 |
|
186 | protected override onUpdateRequest(msg: Message): void {
|
187 | super.onUpdateRequest(msg);
|
188 | if (this.scrollBar) {
|
189 | this.scrollBar.update();
|
190 | }
|
191 | }
|
192 |
|
193 | protected addUpdateListener<K extends keyof HTMLElementEventMap>(element: HTMLElement, type: K, useCapture?: boolean): void {
|
194 | this.addEventListener(element, type, e => {
|
195 | this.update();
|
196 | e.preventDefault();
|
197 | }, useCapture);
|
198 | }
|
199 |
|
200 | protected addEventListener<K extends keyof HTMLElementEventMap>(element: HTMLElement, type: K, listener: EventListenerOrEventListenerObject<K>, useCapture?: boolean): void {
|
201 | this.toDisposeOnDetach.push(addEventListener(element, type, listener, useCapture));
|
202 | }
|
203 |
|
204 | protected addKeyListener<K extends keyof HTMLElementEventMap>(
|
205 | element: HTMLElement,
|
206 | keysOrKeyCodes: KeyCode.Predicate | KeysOrKeyCodes,
|
207 | action: (event: KeyboardEvent) => boolean | void | Object, ...additionalEventTypes: K[]): void {
|
208 | this.toDisposeOnDetach.push(addKeyListener(element, keysOrKeyCodes, action, ...additionalEventTypes));
|
209 | }
|
210 |
|
211 | protected addClipboardListener<K extends 'cut' | 'copy' | 'paste'>(element: HTMLElement, type: K, listener: EventListenerOrEventListenerObject<K>): void {
|
212 | this.toDisposeOnDetach.push(addClipboardListener(element, type, listener));
|
213 | }
|
214 |
|
215 | override setFlag(flag: Widget.Flag): void {
|
216 | super.setFlag(flag);
|
217 | if (flag === Widget.Flag.IsVisible) {
|
218 | this.onDidChangeVisibilityEmitter.fire(this.isVisible);
|
219 | }
|
220 | }
|
221 |
|
222 | override clearFlag(flag: Widget.Flag): void {
|
223 | super.clearFlag(flag);
|
224 | if (flag === Widget.Flag.IsVisible) {
|
225 | this.onDidChangeVisibilityEmitter.fire(this.isVisible);
|
226 | }
|
227 | }
|
228 | }
|
229 |
|
230 | export function setEnabled(element: HTMLElement, enabled: boolean): void {
|
231 | element.classList.toggle(DISABLED_CLASS, !enabled);
|
232 | element.tabIndex = enabled ? 0 : -1;
|
233 | }
|
234 |
|
235 | export function createIconButton(...classNames: string[]): HTMLSpanElement {
|
236 | const icon = document.createElement('i');
|
237 | icon.classList.add(...classNames);
|
238 | const button = document.createElement('span');
|
239 | button.tabIndex = 0;
|
240 | button.appendChild(icon);
|
241 | return button;
|
242 | }
|
243 |
|
244 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
245 | export type EventListener<K extends keyof HTMLElementEventMap> = (this: HTMLElement, event: HTMLElementEventMap[K]) => any;
|
246 | export interface EventListenerObject<K extends keyof HTMLElementEventMap> {
|
247 | handleEvent(evt: HTMLElementEventMap[K]): void;
|
248 | }
|
249 | export namespace EventListenerObject {
|
250 | export function is<K extends keyof HTMLElementEventMap>(listener: unknown): listener is EventListenerObject<K> {
|
251 | return isObject(listener) && 'handleEvent' in listener;
|
252 | }
|
253 | }
|
254 | export type EventListenerOrEventListenerObject<K extends keyof HTMLElementEventMap> = EventListener<K> | EventListenerObject<K>;
|
255 | export function addEventListener<K extends keyof HTMLElementEventMap>(
|
256 | element: HTMLElement, type: K, listener: EventListenerOrEventListenerObject<K>, useCapture?: boolean
|
257 | ): Disposable {
|
258 | element.addEventListener(type, listener, useCapture);
|
259 | return Disposable.create(() =>
|
260 | element.removeEventListener(type, listener, useCapture)
|
261 | );
|
262 | }
|
263 |
|
264 | export function addKeyListener<K extends keyof HTMLElementEventMap>(
|
265 | element: HTMLElement,
|
266 | keysOrKeyCodes: KeyCode.Predicate | KeysOrKeyCodes,
|
267 | action: (event: KeyboardEvent) => boolean | void | Object, ...additionalEventTypes: K[]): Disposable {
|
268 |
|
269 | const toDispose = new DisposableCollection();
|
270 | const keyCodePredicate = (() => {
|
271 | if (typeof keysOrKeyCodes === 'function') {
|
272 | return keysOrKeyCodes;
|
273 | } else {
|
274 | return (actual: KeyCode) => KeysOrKeyCodes.toKeyCodes(keysOrKeyCodes).some(k => k.equals(actual));
|
275 | }
|
276 | })();
|
277 | toDispose.push(addEventListener(element, 'keydown', e => {
|
278 | const kc = KeyCode.createKeyCode(e);
|
279 | if (keyCodePredicate(kc)) {
|
280 | const result = action(e);
|
281 | if (typeof result !== 'boolean' || result) {
|
282 | e.stopPropagation();
|
283 | e.preventDefault();
|
284 | }
|
285 | }
|
286 | }));
|
287 | for (const type of additionalEventTypes) {
|
288 | toDispose.push(addEventListener(element, type, e => {
|
289 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
290 | const event = (type as any)['keydown'];
|
291 | const result = action(event);
|
292 | if (typeof result !== 'boolean' || result) {
|
293 | e.stopPropagation();
|
294 | e.preventDefault();
|
295 | }
|
296 | }));
|
297 | }
|
298 | return toDispose;
|
299 | }
|
300 |
|
301 | export function addClipboardListener<K extends 'cut' | 'copy' | 'paste'>(element: HTMLElement, type: K, listener: EventListenerOrEventListenerObject<K>): Disposable {
|
302 | const documentListener = (e: ClipboardEvent) => {
|
303 | const activeElement = document.activeElement;
|
304 | if (activeElement && element.contains(activeElement)) {
|
305 | if (EventListenerObject.is(listener)) {
|
306 | listener.handleEvent(e);
|
307 | } else {
|
308 | listener.bind(element)(e);
|
309 | }
|
310 | }
|
311 | };
|
312 | document.addEventListener(type, documentListener);
|
313 | return Disposable.create(() =>
|
314 | document.removeEventListener(type, documentListener)
|
315 | );
|
316 | }
|
317 |
|
318 | /**
|
319 | * Resolves when the given widget is detached and hidden.
|
320 | */
|
321 | export function waitForClosed(widget: Widget): Promise<void> {
|
322 | return waitForVisible(widget, false, false);
|
323 | }
|
324 |
|
325 | /**
|
326 | * Resolves when the given widget is attached and visible.
|
327 | */
|
328 | export function waitForRevealed(widget: Widget): Promise<void> {
|
329 | return waitForVisible(widget, true, true);
|
330 | }
|
331 |
|
332 | /**
|
333 | * Resolves when the given widget is hidden regardless of attachment.
|
334 | */
|
335 | export function waitForHidden(widget: Widget): Promise<void> {
|
336 | return waitForVisible(widget, false);
|
337 | }
|
338 |
|
339 | function waitForVisible(widget: Widget, visible: boolean, attached?: boolean): Promise<void> {
|
340 | if ((typeof attached !== 'boolean' || widget.isAttached === attached) &&
|
341 | (widget.isVisible === visible || (widget.node.style.visibility !== 'hidden') === visible)
|
342 | ) {
|
343 | return new Promise(resolve => window.requestAnimationFrame(() => resolve()));
|
344 | }
|
345 | return new Promise(resolve => {
|
346 | const waitFor = () => window.requestAnimationFrame(() => {
|
347 | if ((typeof attached !== 'boolean' || widget.isAttached === attached) &&
|
348 | (widget.isVisible === visible || (widget.node.style.visibility !== 'hidden') === visible)) {
|
349 | window.requestAnimationFrame(() => resolve());
|
350 | } else {
|
351 | waitFor();
|
352 | }
|
353 | });
|
354 | waitFor();
|
355 | });
|
356 | }
|
357 |
|
358 | export function isPinned(title: Title<Widget>): boolean {
|
359 | const pinnedState = !title.closable && title.className.includes(PINNED_CLASS);
|
360 | return pinnedState;
|
361 | }
|
362 |
|
363 | export function unpin(title: Title<Widget>): void {
|
364 | title.closable = true;
|
365 | title.className = title.className.replace(PINNED_CLASS, '').trim();
|
366 | }
|
367 |
|
368 | export function pin(title: Title<Widget>): void {
|
369 | title.closable = false;
|
370 | if (!title.className.includes(PINNED_CLASS)) {
|
371 | title.className += ` ${PINNED_CLASS}`;
|
372 | }
|
373 | }
|
374 |
|
375 | export function lock(title: Title<Widget>): void {
|
376 | if (!title.className.includes(LOCKED_CLASS)) {
|
377 | title.className += ` ${LOCKED_CLASS}`;
|
378 | }
|
379 | }
|
380 |
|
381 | export function togglePinned(title?: Title<Widget>): void {
|
382 | if (title) {
|
383 | if (isPinned(title)) {
|
384 | unpin(title);
|
385 | } else {
|
386 | pin(title);
|
387 | }
|
388 | }
|
389 | }
|
390 |
|
\ | No newline at end of file |