From 3e7a6405a76b3f77946ad9577ee8ad5176d023c3 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Wed, 30 Oct 2019 13:39:08 +0000 Subject: [PATCH] [webview] fix #5521: emulate webview focus when something is focused in iframe Otherwise the webview is not detected as active and title actions are not available. Signed-off-by: Anton Kosyakov --- .../src/browser/shell/application-shell.ts | 5 ++ packages/plugin-ext/package.json | 2 + .../menus/menus-contribution-handler.ts | 24 +++++++- .../src/main/browser/webview/webview.ts | 57 ++++++++++++------- .../src/main/browser/webviews-main.ts | 22 +++++-- yarn.lock | 10 ++++ 6 files changed, 93 insertions(+), 27 deletions(-) diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index a391ed3da3598..27f63b3034c98 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -1005,6 +1005,11 @@ export class ApplicationShell extends Widget { private readonly toDisposeOnActivationCheck = new DisposableCollection(); private assertActivated(widget: Widget): void { this.toDisposeOnActivationCheck.dispose(); + + const onDispose = () => this.toDisposeOnActivationCheck.dispose(); + widget.disposed.connect(onDispose); + this.toDisposeOnActivationCheck.push(Disposable.create(() => widget.disposed.disconnect(onDispose))); + let start = 0; const step: FrameRequestCallback = timestamp => { if (document.activeElement && widget.node.contains(document.activeElement)) { diff --git a/packages/plugin-ext/package.json b/packages/plugin-ext/package.json index 50cf8539f5dd4..1f42bceaf3039 100644 --- a/packages/plugin-ext/package.json +++ b/packages/plugin-ext/package.json @@ -25,6 +25,7 @@ "@theia/terminal": "^0.12.0", "@theia/workspace": "^0.12.0", "@types/connect": "^3.4.32", + "@types/mime": "^2.0.1", "@types/serve-static": "^1.13.3", "connect": "^3.7.0", "decompress": "^4.2.0", @@ -32,6 +33,7 @@ "jsonc-parser": "^2.0.2", "lodash.clonedeep": "^4.5.0", "macaddress": "^0.2.9", + "mime": "^2.4.4", "ps-tree": "^1.2.0", "request": "^2.82.0", "serve-static": "^1.14.1", diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index fcd92dda94edd..108ee70b2f5a2 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -21,6 +21,7 @@ import { injectable, inject } from 'inversify'; import { MenuPath, ILogger, CommandRegistry, Command, Mutable, MenuAction, SelectionService, CommandHandler, Disposable, DisposableCollection } from '@theia/core'; import { EDITOR_CONTEXT_MENU, EditorWidget } from '@theia/editor/lib/browser'; import { MenuModelRegistry } from '@theia/core/lib/common'; +import { Emitter } from '@theia/core/lib/common/event'; import { TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { NAVIGATOR_CONTEXT_MENU } from '@theia/navigator/lib/browser/navigator-contribution'; import { QuickCommandService } from '@theia/core/lib/browser/quick-open/quick-command-service'; @@ -38,6 +39,7 @@ import { PluginViewWidget } from '../view/plugin-view-widget'; import { ViewContextKeyService } from '../view/view-context-key-service'; import { WebviewWidget } from '../webview/webview'; import { Navigatable } from '@theia/core/lib/browser/navigatable'; +import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; type CodeEditorWidget = EditorWidget | WebviewWidget; export namespace CodeEditorWidget { @@ -80,6 +82,9 @@ export class MenusContributionPointHandler { @inject(ViewContextKeyService) protected readonly viewContextKeys: ViewContextKeyService; + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + handle(contributions: PluginContribution): Disposable { const allMenus = contributions.menus; if (!allMenus) { @@ -194,6 +199,23 @@ export class MenusContributionPointHandler { toDispose.push(this.commands.registerCommand(command, handler)); const { when } = action; + const whenKeys = when && this.contextKeyService.parseKeys(when); + let onDidChange; + if (whenKeys && whenKeys.size) { + const onDidChangeEmitter = new Emitter(); + toDispose.push(onDidChangeEmitter); + onDidChange = onDidChangeEmitter.event; + this.contextKeyService.onDidChange.maxListeners = this.contextKeyService.onDidChange.maxListeners + 1; + toDispose.push(this.contextKeyService.onDidChange(event => { + if (event.affects(whenKeys)) { + onDidChangeEmitter.fire(undefined); + } + })); + toDispose.push(Disposable.create(() => { + this.contextKeyService.onDidChange.maxListeners = this.contextKeyService.onDidChange.maxListeners - 1; + })); + } + // handle group and priority // if group is empty or white space is will be set to navigation // ' ' => ['navigation', 0] @@ -202,7 +224,7 @@ export class MenusContributionPointHandler { // if priority is not a number it will be set to 0 // navigation@test => ['navigation', 0] const [group, sort] = (action.group || 'navigation').split('@'); - const item: Mutable = { id, command: id, group: group.trim() || 'navigation', priority: ~~sort || undefined, when }; + const item: Mutable = { id, command: id, group: group.trim() || 'navigation', priority: ~~sort || undefined, when, onDidChange }; toDispose.push(this.tabBarToolbar.registerItem(item)); toDispose.push(this.onDidRegisterCommand(action.command, pluginCommand => { diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 16fe1c1e2b3a7..fd619f388bc16 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -14,8 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import * as mime from 'mime'; import { injectable, inject, postConstruct } from 'inversify'; -import { ArrayExt } from '@phosphor/algorithm/lib/array'; import { WebviewPanelOptions, WebviewPortMapping } from '@theia/plugin'; import { BaseWidget, Message } from '@theia/core/lib/browser/widgets/widget'; import { Disposable } from '@theia/core/lib/common/disposable'; @@ -32,12 +32,15 @@ import { Emitter } from '@theia/core/lib/common/event'; import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; import { Schemes } from '../../../common/uri-components'; +import { JSONExt } from '@phosphor/coreutils'; // tslint:disable:no-any export const enum WebviewMessageChannels { onmessage = 'onmessage', didClickLink = 'did-click-link', + didFocus = 'did-focus', + didBlur = 'did-blur', doUpdateState = 'do-update-state', doReload = 'do-reload', loadResource = 'load-resource', @@ -71,7 +74,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { static FACTORY_ID = 'plugin-webview'; - protected element: HTMLIFrameElement; + protected element: HTMLIFrameElement | undefined; // tslint:disable-next-line:max-line-length // XXX This is a hack to be able to tack the mouse events when drag and dropping the widgets. @@ -106,8 +109,16 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { }; protected html = ''; - protected contentOptions: WebviewContentOptions = {}; - state: any; + + protected _contentOptions: WebviewContentOptions = {}; + get contentOptions(): WebviewContentOptions { + return this._contentOptions; + } + + protected _state: any; + get state(): any { + return this._state; + } viewType: string; options: WebviewPanelOptions = {}; @@ -132,12 +143,12 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.node.appendChild(this.transparentOverlay); this.toDispose.push(this.mouseTracker.onMousedown(() => { - if (this.element.style.display !== 'none') { + if (this.element && this.element.style.display !== 'none') { this.transparentOverlay.style.display = 'block'; } })); this.toDispose.push(this.mouseTracker.onMouseup(() => { - if (this.element.style.display !== 'none') { + if (this.element && this.element.style.display !== 'none') { this.transparentOverlay.style.display = 'none'; } })); @@ -151,6 +162,12 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { element.style.height = '100%'; this.element = element; this.node.appendChild(this.element); + this.toDispose.push(Disposable.create(() => { + if (this.element) { + this.element.remove(); + this.element = undefined; + } + })); const subscription = this.on(WebviewMessageChannels.webviewReady, () => { subscription.dispose(); @@ -160,7 +177,14 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.toDispose.push(this.on(WebviewMessageChannels.onmessage, (data: any) => this.onMessageEmitter.fire(data))); this.toDispose.push(this.on(WebviewMessageChannels.didClickLink, (uri: string) => this.openLink(new URI(uri)))); this.toDispose.push(this.on(WebviewMessageChannels.doUpdateState, (state: any) => { - this.state = state; + this._state = state; + })); + this.toDispose.push(this.on(WebviewMessageChannels.didFocus, () => + // emulate the webview focus without actually changing focus + this.node.dispatchEvent(new FocusEvent('focus')) + )); + this.toDispose.push(this.on(WebviewMessageChannels.didBlur, () => { + /* no-op: webview loses focus only if another element gains focus in the main window */ })); this.toDispose.push(this.on(WebviewMessageChannels.doReload, () => this.reload())); this.toDispose.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => { @@ -178,10 +202,10 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { } setContentOptions(contentOptions: WebviewContentOptions): void { - if (WebviewWidget.compareWebviewContentOptions(this.contentOptions, contentOptions)) { + if (JSONExt.deepEqual(this.contentOptions, contentOptions)) { return; } - this.contentOptions = contentOptions; + this._contentOptions = contentOptions; this.doUpdateContent(); } @@ -206,6 +230,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { protected onActivateRequest(msg: Message): void { super.onActivateRequest(msg); + this.node.focus(); this.focus(); } @@ -256,7 +281,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { return this.doSend('did-load-resource', { status: 200, path: requestPath, - mime: 'text/plain', // TODO detect mimeType from URI extension + mime: mime.getType(normalizedUri.path.toString()) || 'application/octet-stream', data: content }); } @@ -313,8 +338,8 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { this.viewType = viewType; this.title.label = title; this.options = options; - this.contentOptions = contentOptions; - this.state = state; + this._contentOptions = contentOptions; + this._state = state; } protected async doSend(channel: string, data?: any): Promise { @@ -360,12 +385,4 @@ export namespace WebviewWidget { state: any // TODO: preserve icon class } - export function compareWebviewContentOptions(a: WebviewContentOptions, b: WebviewContentOptions): boolean { - return a.enableCommandUris === b.enableCommandUris - && a.allowScripts === b.allowScripts && - ArrayExt.shallowEqual(a.localResourceRoots || [], b.localResourceRoots || [], (uri, uri2) => uri === uri2) && - ArrayExt.shallowEqual(a.portMapping || [], b.portMapping || [], (m, m2) => - m.extensionHostPort === m2.extensionHostPort && m.webviewPort === m2.webviewPort - ); - } } diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index 461bfc47523db..4e8c2c0a5a040 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -15,8 +15,9 @@ ********************************************************************************/ import debounce = require('lodash.debounce'); -import { WebviewsMain, MAIN_RPC_CONTEXT, WebviewsExt, WebviewPanelViewState } from '../../common/plugin-api-rpc'; +import URI from 'vscode-uri'; import { interfaces } from 'inversify'; +import { WebviewsMain, MAIN_RPC_CONTEXT, WebviewsExt, WebviewPanelViewState } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; import { WebviewOptions, WebviewPanelOptions, WebviewPanelShowOptions } from '@theia/plugin'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; @@ -27,6 +28,7 @@ import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; import { JSONExt } from '@phosphor/coreutils/lib/json'; import { Mutable } from '@theia/core/lib/common/types'; import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; + export class WebviewsMainImpl implements WebviewsMain, Disposable { private readonly proxy: WebviewsExt; @@ -75,10 +77,12 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { protected hookWebview(view: WebviewWidget): void { const handle = view.identifier.id; this.toDispose.push(view.onMessage(data => this.proxy.$onMessage(handle, data))); - - const onDispose = () => this.proxy.$onDidDisposeWebviewPanel(handle); - view.disposed.connect(onDispose); - this.toDispose.push(Disposable.create(() => view.disposed.disconnect(onDispose))); + view.disposed.connect(() => { + if (this.toDispose.disposed) { + return; + } + this.proxy.$onDidDisposeWebviewPanel(handle); + }); } private async addOrReattachWidget(handle: string, showOptions: WebviewPanelShowOptions): Promise { @@ -197,9 +201,15 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { const title = widget.title.label; const state = widget.state; const options = widget.options; + const { allowScripts, localResourceRoots, ...contentOptions } = widget.contentOptions; this.viewColumnService.updateViewColumns(); const position = this.viewColumnService.getViewColumn(widget.id) || 0; - await this.proxy.$deserializeWebviewPanel(handle, widget.viewType, title, state, position, options); + await this.proxy.$deserializeWebviewPanel(handle, widget.viewType, title, state, position, { + enableScripts: allowScripts, + localResourceRoots: localResourceRoots && localResourceRoots.map(root => URI.parse(root)), + ...contentOptions, + ...options + }); } protected readonly updateViewStates = debounce(() => { diff --git a/yarn.lock b/yarn.lock index 82bae81bcf251..ed40e362f71f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -989,6 +989,11 @@ version "2.0.0" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b" +"@types/mime@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" + integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== + "@types/minimatch@*", "@types/minimatch@3.0.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -7389,6 +7394,11 @@ mime@^2.0.3: version "2.3.1" resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369" +mime@^2.4.4: + version "2.4.4" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" + integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"