/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { addClass } from 'vs/base/browser/dom'; import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; import { Emitter } from 'vs/base/common/event'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { ILogService } from 'vs/platform/log/common/log'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { WebviewThemeDataProvider } from 'vs/workbench/contrib/webview/browser/themeing'; import { WebviewContentOptions, WebviewExtensionDescription, WebviewOptions } from 'vs/workbench/contrib/webview/browser/webview'; import { areWebviewInputOptionsEqual } from 'vs/workbench/contrib/webview/browser/webviewWorkbenchService'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; export const enum WebviewMessageChannels { onmessage = 'onmessage', didClickLink = 'did-click-link', didScroll = 'did-scroll', didFocus = 'did-focus', didBlur = 'did-blur', didLoad = 'did-load', doUpdateState = 'do-update-state', doReload = 'do-reload', loadResource = 'load-resource', loadLocalhost = 'load-localhost', webviewReady = 'webview-ready', wheel = 'did-scroll-wheel', fatalError = 'fatal-error', } interface IKeydownEvent { key: string; keyCode: number; code: string; shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean; repeat: boolean; } interface WebviewContent { readonly html: string; readonly options: WebviewContentOptions; readonly state: string | undefined; } namespace WebviewState { export const enum Type { Initializing, Ready } export class Initializing { readonly type = Type.Initializing; constructor( public readonly pendingMessages: Array<{ readonly channel: string, readonly data?: any }> ) { } } export const Ready = { type: Type.Ready } as const; export type State = typeof Ready | Initializing; } export abstract class BaseWebview extends Disposable { private _element: T | undefined; protected get element(): T | undefined { return this._element; } private _focused: boolean | undefined; public get isFocused(): boolean { return !!this._focused; } private _state: WebviewState.State = new WebviewState.Initializing([]); protected content: WebviewContent; constructor( public readonly id: string, options: WebviewOptions, contentOptions: WebviewContentOptions, public readonly extension: WebviewExtensionDescription | undefined, private readonly webviewThemeDataProvider: WebviewThemeDataProvider, @INotificationService notificationService: INotificationService, @ILogService private readonly _logService: ILogService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IWorkbenchEnvironmentService protected readonly environmentService: IWorkbenchEnvironmentService ) { super(); this.content = { html: '', options: contentOptions, state: undefined }; this._element = this.createElement(options, contentOptions); const subscription = this._register(this.on(WebviewMessageChannels.webviewReady, () => { this._logService.debug(`Webview(${this.id}): webview ready`); if (this.element) { addClass(this.element, 'ready'); } if (this._state.type === WebviewState.Type.Initializing) { this._state.pendingMessages.forEach(({ channel, data }) => this.doPostMessage(channel, data)); } this._state = WebviewState.Ready; subscription.dispose(); })); this._register(this.on('no-csp-found', () => { this.handleNoCspFound(); })); this._register(this.on(WebviewMessageChannels.didClickLink, (uri: string) => { this._onDidClickLink.fire(uri); })); this._register(this.on(WebviewMessageChannels.onmessage, (data: any) => { this._onMessage.fire(data); })); this._register(this.on(WebviewMessageChannels.didScroll, (scrollYPercentage: number) => { this._onDidScroll.fire({ scrollYPercentage: scrollYPercentage }); })); this._register(this.on(WebviewMessageChannels.doReload, () => { this.reload(); })); this._register(this.on(WebviewMessageChannels.doUpdateState, (state: any) => { this.state = state; this._onDidUpdateState.fire(state); })); this._register(this.on(WebviewMessageChannels.didFocus, () => { this.handleFocusChange(true); })); this._register(this.on(WebviewMessageChannels.wheel, (event: IMouseWheelEvent) => { this._onDidWheel.fire(event); })); this._register(this.on(WebviewMessageChannels.didBlur, () => { this.handleFocusChange(false); })); this._register(this.on<{ message: string }>(WebviewMessageChannels.fatalError, (e) => { notificationService.error(localize('fatalErrorMessage', "Error loading webview: {0}", e.message)); })); this._register(this.on('did-keydown', (data: KeyboardEvent) => { // Electron: workaround for https://github.com/electron/electron/issues/14258 // We have to detect keyboard events in the and dispatch them to our // keybinding service because these events do not bubble to the parent window anymore. this.handleKeyDown(data); })); this.style(); this._register(webviewThemeDataProvider.onThemeDataChanged(this.style, this)); } dispose(): void { if (this.element) { this.element.remove(); } this._element = undefined; super.dispose(); } private readonly _onMissingCsp = this._register(new Emitter()); public readonly onMissingCsp = this._onMissingCsp.event; private readonly _onDidClickLink = this._register(new Emitter()); public readonly onDidClickLink = this._onDidClickLink.event; private readonly _onDidReload = this._register(new Emitter()); public readonly onDidReload = this._onDidReload.event; private readonly _onMessage = this._register(new Emitter()); public readonly onMessage = this._onMessage.event; private readonly _onDidScroll = this._register(new Emitter<{ readonly scrollYPercentage: number; }>()); public readonly onDidScroll = this._onDidScroll.event; private readonly _onDidWheel = this._register(new Emitter()); public readonly onDidWheel = this._onDidWheel.event; private readonly _onDidUpdateState = this._register(new Emitter()); public readonly onDidUpdateState = this._onDidUpdateState.event; private readonly _onDidFocus = this._register(new Emitter()); public readonly onDidFocus = this._onDidFocus.event; private readonly _onDidBlur = this._register(new Emitter()); public readonly onDidBlur = this._onDidBlur.event; public postMessage(data: any): void { this._send('message', data); } protected _send(channel: string, data?: any): void { if (this._state.type === WebviewState.Type.Initializing) { this._state.pendingMessages.push({ channel, data }); } else { this.doPostMessage(channel, data); } } protected abstract readonly extraContentOptions: { readonly [key: string]: string }; protected abstract createElement(options: WebviewOptions, contentOptions: WebviewContentOptions): T; protected abstract on(channel: string, handler: (data: T) => void): IDisposable; protected abstract doPostMessage(channel: string, data?: any): void; private _hasAlertedAboutMissingCsp = false; private handleNoCspFound(): void { if (this._hasAlertedAboutMissingCsp) { return; } this._hasAlertedAboutMissingCsp = true; if (this.extension && this.extension.id) { if (this.environmentService.isExtensionDevelopment) { this._onMissingCsp.fire(this.extension.id); } type TelemetryClassification = { extension?: { classification: 'SystemMetaData', purpose: 'FeatureInsight'; }; }; type TelemetryData = { extension?: string, }; this._telemetryService.publicLog2('webviewMissingCsp', { extension: this.extension.id.value }); } } public reload(): void { this.doUpdateContent(this.content); const subscription = this._register(this.on(WebviewMessageChannels.didLoad, () => { this._onDidReload.fire(); subscription.dispose(); })); } public set html(value: string) { this.doUpdateContent({ html: value, options: this.content.options, state: this.content.state, }); } public set contentOptions(options: WebviewContentOptions) { this._logService.debug(`Webview(${this.id}): will update content options`); if (areWebviewInputOptionsEqual(options, this.content.options)) { this._logService.debug(`Webview(${this.id}): skipping content options update`); return; } this.doUpdateContent({ html: this.content.html, options: options, state: this.content.state, }); } public set localResourcesRoot(resources: URI[]) { /** no op */ } public set state(state: string | undefined) { this.content = { html: this.content.html, options: this.content.options, state, }; } public set initialScrollProgress(value: number) { this._send('initial-scroll-position', value); } private doUpdateContent(newContent: WebviewContent) { this._logService.debug(`Webview(${this.id}): will update content`); this.content = newContent; this._send('content', { contents: this.content.html, options: this.content.options, state: this.content.state, ...this.extraContentOptions }); } protected style(): void { const { styles, activeTheme, themeLabel } = this.webviewThemeDataProvider.getWebviewThemeData(); this._send('styles', { styles, activeTheme, themeName: themeLabel }); } protected handleFocusChange(isFocused: boolean): void { this._focused = isFocused; if (isFocused) { this._onDidFocus.fire(); } else { this._onDidBlur.fire(); } } private handleKeyDown(event: IKeydownEvent) { // Create a fake KeyboardEvent from the data provided const emulatedKeyboardEvent = new KeyboardEvent('keydown', event); // Force override the target Object.defineProperty(emulatedKeyboardEvent, 'target', { get: () => this.element, }); // And re-dispatch window.dispatchEvent(emulatedKeyboardEvent); } windowDidDragStart(): void { // Webview break drag and droping around the main window (no events are generated when you are over them) // Work around this by disabling pointer events during the drag. // https://github.com/electron/electron/issues/18226 if (this.element) { this.element.style.pointerEvents = 'none'; } } windowDidDragEnd(): void { if (this.element) { this.element.style.pointerEvents = ''; } } public selectAll() { this.execCommand('selectAll'); } public copy() { this.execCommand('copy'); } public paste() { this.execCommand('paste'); } public cut() { this.execCommand('cut'); } public undo() { this.execCommand('undo'); } public redo() { this.execCommand('redo'); } private execCommand(command: string) { if (this.element) { this._send('execCommand', command); } } }