notificationsToasts.ts 13.6 KB
Newer Older
1 2 3 4 5 6 7 8
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

'use strict';

import 'vs/css!./media/notificationsToasts';
9
import { INotificationsModel, NotificationChangeType, INotificationChangeEvent, INotificationViewItem, NotificationViewItemLabelKind } from 'vs/workbench/common/notifications';
10
import { IDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle';
11
import { addClass, removeClass, isAncestor, addDisposableListener } from 'vs/base/browser/dom';
12 13 14 15 16
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { NotificationsList } from 'vs/workbench/browser/parts/notifications/notificationsList';
import { Dimension } from 'vs/base/browser/builder';
import { once } from 'vs/base/common/event';
import { IPartService, Parts } from 'vs/workbench/services/part/common/partService';
17
import { Themable, NOTIFICATIONS_TOAST_BORDER } from 'vs/workbench/common/theme';
18 19
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { widgetShadow } from 'vs/platform/theme/common/colorRegistry';
20
import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/editorService';
21
import { NotificationsToastsVisibleContext } from 'vs/workbench/browser/parts/notifications/notificationsCommands';
22
import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey';
23
import { localize } from 'vs/nls';
24
import { Severity } from 'vs/platform/notification/common/notification';
25
import { ScrollbarVisibility } from 'vs/base/common/scrollable';
B
Benjamin Pasero 已提交
26
import { ILifecycleService, LifecyclePhase } from 'vs/platform/lifecycle/common/lifecycle';
27 28

interface INotificationToast {
29
	item: INotificationViewItem;
30 31
	list: NotificationsList;
	container: HTMLElement;
32
	toast: HTMLElement;
33 34 35 36 37
	disposeables: IDisposable[];
}

export class NotificationsToasts extends Themable {

38
	private static MAX_WIDTH = 450;
39
	private static MAX_NOTIFICATIONS = 3;
40

41 42
	private static PURGE_TIMEOUT: { [severity: number]: number } = (() => {
		const intervals = Object.create(null);
43 44
		intervals[Severity.Info] = 5000;
		intervals[Severity.Warning] = 10000;
45 46 47 48 49
		intervals[Severity.Error] = 15000;

		return intervals;
	})();

50 51 52 53
	private notificationsToastsContainer: HTMLElement;
	private workbenchDimensions: Dimension;
	private isNotificationsCenterVisible: boolean;
	private mapNotificationToToast: Map<INotificationViewItem, INotificationToast>;
54
	private notificationsToastsVisibleContextKey: IContextKey<boolean>;
55 56 57 58 59 60

	constructor(
		private container: HTMLElement,
		private model: INotificationsModel,
		@IInstantiationService private instantiationService: IInstantiationService,
		@IPartService private partService: IPartService,
61
		@IThemeService themeService: IThemeService,
62
		@IWorkbenchEditorService private editorService: IWorkbenchEditorService,
B
Benjamin Pasero 已提交
63 64
		@IContextKeyService contextKeyService: IContextKeyService,
		@ILifecycleService private lifecycleService: ILifecycleService
65 66 67 68
	) {
		super(themeService);

		this.mapNotificationToToast = new Map<INotificationViewItem, INotificationToast>();
69
		this.notificationsToastsVisibleContextKey = NotificationsToastsVisibleContext.bindTo(contextKeyService);
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109

		// Show toast for initial notifications if any
		model.notifications.forEach(notification => this.addToast(notification));

		this.registerListeners();
	}

	private registerListeners(): void {
		this.toUnbind.push(this.model.onDidNotificationChange(e => this.onDidNotificationChange(e)));
	}

	private onDidNotificationChange(e: INotificationChangeEvent): void {
		switch (e.kind) {
			case NotificationChangeType.ADD:
				return this.addToast(e.item);
			case NotificationChangeType.REMOVE:
				return this.removeToast(e.item);
		}
	}

	private addToast(item: INotificationViewItem): void {
		if (this.isNotificationsCenterVisible) {
			return; // do not show toasts while notification center is visibles
		}

		// Lazily create toasts containers
		if (!this.notificationsToastsContainer) {
			this.notificationsToastsContainer = document.createElement('div');
			addClass(this.notificationsToastsContainer, 'notifications-toasts');

			this.container.appendChild(this.notificationsToastsContainer);
		}

		// Make Visible
		addClass(this.notificationsToastsContainer, 'visible');

		const itemDisposeables: IDisposable[] = [];

		// Container
		const notificationToastContainer = document.createElement('div');
110
		addClass(notificationToastContainer, 'notification-toast-container');
111 112 113 114 115 116 117 118

		const firstToast = this.notificationsToastsContainer.firstChild;
		if (firstToast) {
			this.notificationsToastsContainer.insertBefore(notificationToastContainer, firstToast); // always first
		} else {
			this.notificationsToastsContainer.appendChild(notificationToastContainer);
		}

119
		itemDisposeables.push(toDisposable(() => this.notificationsToastsContainer.removeChild(notificationToastContainer)));
120

121 122 123 124 125
		// Toast
		const notificationToast = document.createElement('div');
		addClass(notificationToast, 'notification-toast');
		notificationToastContainer.appendChild(notificationToast);

126
		// Create toast with item and show
127
		const notificationList = this.instantiationService.createInstance(NotificationsList, notificationToast, {
128 129 130
			ariaLabel: localize('notificationsToast', "Notification Toast"),
			verticalScrollMode: ScrollbarVisibility.Hidden
		});
131
		itemDisposeables.push(notificationList);
132
		this.mapNotificationToToast.set(item, { item, list: notificationList, container: notificationToastContainer, toast: notificationToast, disposeables: itemDisposeables });
133 134 135 136

		// Make visible
		notificationList.show();

137 138 139
		// Layout lists
		const maxDimensions = this.computeMaxDimensions();
		this.layoutLists(maxDimensions.width);
140 141 142 143

		// Show notification
		notificationList.updateNotificationsList(0, 0, [item]);

144 145 146 147
		// Layout container: only after we show the notification to ensure that
		// the height computation takes the content of it into account!
		this.layoutContainer(maxDimensions.height);

148 149
		// Update when item height changes due to expansion
		itemDisposeables.push(item.onDidExpansionChange(() => {
150 151 152
			notificationList.updateNotificationsList(0, 1, [item]);
		}));

153 154 155 156 157 158 159
		// Update when item height potentially changes due to label changes
		itemDisposeables.push(item.onDidLabelChange(e => {
			if (e.kind === NotificationViewItemLabelKind.ACTIONS || e.kind === NotificationViewItemLabelKind.MESSAGE) {
				notificationList.updateNotificationsList(0, 1, [item]);
			}
		}));

160 161 162 163 164
		// Remove when item gets disposed
		once(item.onDidDispose)(() => {
			this.removeToast(item);
		});

B
Benjamin Pasero 已提交
165 166
		// Automatically hide collapsed notifications
		if (!item.expanded) {
167 168 169
			let timeoutHandle: number;
			const hideAfterTimeout = () => {
				timeoutHandle = setTimeout(() => {
B
Benjamin Pasero 已提交
170 171
					if (!notificationList.hasFocus() && !item.expanded) {
						this.removeToast(item);
172
					} else {
B
Benjamin Pasero 已提交
173
						hideAfterTimeout(); // push out disposal if item has focus or is expanded
174 175 176 177 178
					}
				}, NotificationsToasts.PURGE_TIMEOUT[item.severity]);
			};

			hideAfterTimeout();
179 180 181 182

			itemDisposeables.push(toDisposable(() => clearTimeout(timeoutHandle)));
		}

183 184
		// Theming
		this.updateStyles();
185 186 187

		// Context Key
		this.notificationsToastsVisibleContextKey.set(true);
B
Benjamin Pasero 已提交
188

B
Benjamin Pasero 已提交
189 190
		// Animate In if we are in a running session (otherwise just show directly)
		if (this.lifecycleService.phase >= LifecyclePhase.Running) {
191 192 193 194
			addClass(notificationToast, 'notification-fade-in');
			itemDisposeables.push(addDisposableListener(notificationToast, 'transitionend', () => {
				removeClass(notificationToast, 'notification-fade-in');
				addClass(notificationToast, 'notification-fade-in-done');
B
Benjamin Pasero 已提交
195 196
			}));
		} else {
197
			addClass(notificationToast, 'notification-fade-in-done');
B
Benjamin Pasero 已提交
198
		}
199

B
Benjamin Pasero 已提交
200 201
		// Ensure maximum number of toasts
		const toasts = this.getToasts(false /* all, visible and hidden */);
202 203 204
		while (toasts.length > NotificationsToasts.MAX_NOTIFICATIONS) {
			this.removeToast(toasts.pop().item);
		}
205 206 207 208
	}

	private removeToast(item: INotificationViewItem): void {
		const notificationToast = this.mapNotificationToToast.get(item);
209
		let focusEditor = false;
210
		if (notificationToast) {
211 212 213 214
			const toastHasDOMFocus = isAncestor(document.activeElement, notificationToast.container);
			if (toastHasDOMFocus) {
				focusEditor = !(this.focusNext() || this.focusPrevious()); // focus next if any, otherwise focus editor
			}
215 216 217 218 219 220 221 222

			// Listeners
			dispose(notificationToast.disposeables);

			// Remove from Map
			this.mapNotificationToToast.delete(item);
		}

223 224 225
		// Layout if we still have toasts
		if (this.mapNotificationToToast.size > 0) {
			this.layout(this.workbenchDimensions);
226 227
		}

228 229 230
		// Otherwise hide if no more toasts to show
		else {
			this.doHide();
231

232 233 234
			// Move focus to editor as needed
			if (focusEditor) {
				this.focusEditor();
235 236
			}
		}
237 238
	}

239 240 241 242 243 244 245
	private focusEditor(): void {
		const editor = this.editorService.getActiveEditor();
		if (editor) {
			editor.focus();
		}
	}

246 247 248 249
	private removeToasts(): void {
		this.mapNotificationToToast.forEach(toast => dispose(toast.disposeables));
		this.mapNotificationToToast.clear();

250 251 252 253
		this.doHide();
	}

	private doHide(): void {
254
		removeClass(this.notificationsToastsContainer, 'visible');
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270

		// Context Key
		this.notificationsToastsVisibleContextKey.set(false);
	}

	public hide(): void {
		const focusEditor = isAncestor(document.activeElement, this.notificationsToastsContainer);

		this.removeToasts();

		if (focusEditor) {
			this.focusEditor();
		}
	}

	public focus(): boolean {
B
Benjamin Pasero 已提交
271
		const toasts = this.getToasts(true /* visible only */);
272 273 274 275 276 277 278 279 280 281
		if (toasts.length > 0) {
			toasts[0].list.focusFirst();

			return true;
		}

		return false;
	}

	public focusNext(): boolean {
B
Benjamin Pasero 已提交
282
		const toasts = this.getToasts(true /* visible only */);
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300
		for (let i = 0; i < toasts.length; i++) {
			const toast = toasts[i];
			if (toast.list.hasFocus()) {
				const nextToast = toasts[i + 1];
				if (nextToast) {
					nextToast.list.focusFirst();

					return true;
				}

				break;
			}
		}

		return false;
	}

	public focusPrevious(): boolean {
B
Benjamin Pasero 已提交
301
		const toasts = this.getToasts(true /* visible only */);
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316
		for (let i = 0; i < toasts.length; i++) {
			const toast = toasts[i];
			if (toast.list.hasFocus()) {
				const previousToast = toasts[i - 1];
				if (previousToast) {
					previousToast.list.focusFirst();

					return true;
				}

				break;
			}
		}

		return false;
317 318
	}

B
Benjamin Pasero 已提交
319
	public focusFirst(): boolean {
B
Benjamin Pasero 已提交
320
		const toast = this.getToasts(true /* visible only */)[0];
B
Benjamin Pasero 已提交
321 322 323 324 325 326 327 328 329 330
		if (toast) {
			toast.list.focusFirst();

			return true;
		}

		return false;
	}

	public focusLast(): boolean {
B
Benjamin Pasero 已提交
331
		const toasts = this.getToasts(true /* visible only */);
B
Benjamin Pasero 已提交
332 333 334 335 336 337 338 339 340
		if (toasts.length > 0) {
			toasts[toasts.length - 1].list.focusFirst();

			return true;
		}

		return false;
	}

341 342 343 344 345 346 347 348 349 350 351 352
	public update(isCenterVisible: boolean): void {
		if (this.isNotificationsCenterVisible !== isCenterVisible) {
			this.isNotificationsCenterVisible = isCenterVisible;

			// Hide all toasts when the notificationcenter gets visible
			if (this.isNotificationsCenterVisible) {
				this.removeToasts();
			}
		}
	}

	protected updateStyles(): void {
353
		this.mapNotificationToToast.forEach(t => {
354
			const widgetShadowColor = this.getColor(widgetShadow);
355
			t.toast.style.boxShadow = widgetShadowColor ? `0 0px 8px ${widgetShadowColor}` : null;
356 357 358

			const borderColor = this.getColor(NOTIFICATIONS_TOAST_BORDER);
			t.toast.style.border = borderColor ? `1px solid ${borderColor}` : null;
359 360 361
		});
	}

B
Benjamin Pasero 已提交
362
	private getToasts(visibleOnly: boolean): INotificationToast[] {
363
		let notificationToasts: INotificationToast[] = [];
B
Benjamin Pasero 已提交
364 365 366 367 368 369 370

		this.mapNotificationToToast.forEach(toast => {
			if (!visibleOnly || toast.container.style.display === 'block') {
				notificationToasts.push(toast);
			}
		});

371 372 373 374 375
		notificationToasts = notificationToasts.reverse(); // from newest to oldest

		return notificationToasts;
	}

376 377 378
	public layout(dimension: Dimension): void {
		this.workbenchDimensions = dimension;

379 380 381
		const maxDimensions = this.computeMaxDimensions();

		// Hide toasts that exceed height
382 383 384
		if (maxDimensions.height) {
			this.layoutContainer(maxDimensions.height);
		}
385 386 387

		// Layout all lists of toasts
		this.layoutLists(maxDimensions.width);
388 389 390
	}

	private computeMaxDimensions(): Dimension {
391
		let maxWidth = NotificationsToasts.MAX_WIDTH;
392 393

		let availableWidth = maxWidth;
394
		let availableHeight: number;
395 396 397 398 399

		if (this.workbenchDimensions) {

			// Make sure notifications are not exceding available width
			availableWidth = this.workbenchDimensions.width;
B
Benjamin Pasero 已提交
400
			availableWidth -= (2 * 8); // adjust for paddings left and right
401 402 403 404 405 406 407 408 409 410 411 412 413 414

			// Make sure notifications are not exceeding available height
			availableHeight = this.workbenchDimensions.height;
			if (this.partService.isVisible(Parts.STATUSBAR_PART)) {
				availableHeight -= 22; // adjust for status bar
			}

			if (this.partService.isVisible(Parts.TITLEBAR_PART)) {
				availableHeight -= 22; // adjust for title bar
			}

			availableHeight -= (2 * 12); // adjust for paddings top and bottom
		}

415
		return new Dimension(Math.min(maxWidth, availableWidth), availableHeight);
416
	}
417

418 419 420 421 422
	private layoutLists(width: number): void {
		this.mapNotificationToToast.forEach(toast => toast.list.layout(width));
	}

	private layoutContainer(heightToGive: number): void {
B
Benjamin Pasero 已提交
423
		this.getToasts(false /* all, visible and hidden */).forEach(toast => {
424 425 426 427 428

			// In order to measure the client height, the element cannot have display: none
			toast.container.style.opacity = '0';
			toast.container.style.display = 'block';

429
			heightToGive -= toast.container.offsetHeight;
430 431 432 433 434 435 436

			// Hide or show toast based on available height
			toast.container.style.display = heightToGive >= 0 ? 'block' : 'none';
			toast.container.style.opacity = null;
		});
	}
}