UNPKG

15.3 kBPlain TextView Raw
1// *****************************************************************************
2// Copyright (C) 2017 TypeFox 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
17/* eslint-disable @typescript-eslint/no-explicit-any */
18
19import { injectable, decorate, unmanaged } from 'inversify';
20import { Title, Widget } from '@phosphor/widgets';
21import { Message, MessageLoop } from '@phosphor/messaging';
22import { Emitter, Event, Disposable, DisposableCollection, MaybePromise, isObject } from '../../common';
23import { KeyCode, KeysOrKeyCodes } from '../keyboard/keys';
24
25import PerfectScrollbar from 'perfect-scrollbar';
26
27decorate(injectable(), Widget);
28decorate(unmanaged(), Widget, 0);
29
30export * from '@phosphor/widgets';
31export * from '@phosphor/messaging';
32
33export const ACTION_ITEM = 'action-label';
34
35export 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
43export function codicon(name: string, actionItem = false): string {
44 return `codicon codicon-${name}${actionItem ? ` ${ACTION_ITEM}` : ''}`;
45}
46
47export const DISABLED_CLASS = 'theia-mod-disabled';
48export const EXPANSION_TOGGLE_CLASS = 'theia-ExpansionToggle';
49export const CODICON_TREE_ITEM_CLASSES = codiconArray('chevron-down');
50export const COLLAPSED_CLASS = 'theia-mod-collapsed';
51export const BUSY_CLASS = 'theia-mod-busy';
52export const CODICON_LOADING_CLASSES = codiconArray('loading');
53export const SELECTED_CLASS = 'theia-mod-selected';
54export const FOCUS_CLASS = 'theia-mod-focus';
55export const PINNED_CLASS = 'theia-mod-pinned';
56export const LOCKED_CLASS = 'theia-mod-locked';
57export 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 */
68export 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()
96export 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
230export function setEnabled(element: HTMLElement, enabled: boolean): void {
231 element.classList.toggle(DISABLED_CLASS, !enabled);
232 element.tabIndex = enabled ? 0 : -1;
233}
234
235export 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
245export type EventListener<K extends keyof HTMLElementEventMap> = (this: HTMLElement, event: HTMLElementEventMap[K]) => any;
246export interface EventListenerObject<K extends keyof HTMLElementEventMap> {
247 handleEvent(evt: HTMLElementEventMap[K]): void;
248}
249export 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}
254export type EventListenerOrEventListenerObject<K extends keyof HTMLElementEventMap> = EventListener<K> | EventListenerObject<K>;
255export 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
264export 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
301export 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 */
321export 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 */
328export 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 */
335export function waitForHidden(widget: Widget): Promise<void> {
336 return waitForVisible(widget, false);
337}
338
339function 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
358export function isPinned(title: Title<Widget>): boolean {
359 const pinnedState = !title.closable && title.className.includes(PINNED_CLASS);
360 return pinnedState;
361}
362
363export function unpin(title: Title<Widget>): void {
364 title.closable = true;
365 title.className = title.className.replace(PINNED_CLASS, '').trim();
366}
367
368export 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
375export function lock(title: Title<Widget>): void {
376 if (!title.className.includes(LOCKED_CLASS)) {
377 title.className += ` ${LOCKED_CLASS}`;
378 }
379}
380
381export 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