Merge pull request #5031 from Microsoft/joh/webview

Joh/webview
This commit is contained in:
Johannes Rieken
2016-04-06 15:08:11 +02:00
3 changed files with 297 additions and 265 deletions
@@ -14,8 +14,6 @@ import {HtmlInput} from '../common/htmlInput';
import {HtmlPreviewPart} from 'vs/workbench/parts/html/browser/htmlPreviewPart';
import {Registry} from 'vs/platform/platform';
import {EditorDescriptor, IEditorRegistry, Extensions as EditorExtensions} from 'vs/workbench/browser/parts/editor/baseEditor';
import {SyncDescriptor} from 'vs/platform/instantiation/common/descriptors';
// --- Register Editor
@@ -5,298 +5,122 @@
'use strict';
import 'vs/text!./webview.html';
import {localize} from 'vs/nls';
import URI from 'vs/base/common/uri';
import {TPromise} from 'vs/base/common/winjs.base';
import {IModel, EventType} from 'vs/editor/common/editorCommon';
import {Dimension, Builder} from 'vs/base/browser/builder';
import {empty as EmptyDisposable} from 'vs/base/common/lifecycle';
import {empty as EmptyDisposable, IDisposable, dispose} from 'vs/base/common/lifecycle';
import {addDisposableListener} from 'vs/base/browser/dom';
import {EditorOptions, EditorInput} from 'vs/workbench/common/editor';
import {BaseEditor} from 'vs/workbench/browser/parts/editor/baseEditor';
import {Position} from 'vs/platform/editor/common/editor';
import {ITelemetryService} from 'vs/platform/telemetry/common/telemetry';
import {IWorkspaceContextService} from 'vs/platform/workspace/common/workspace';
import {isLightTheme} from 'vs/platform/theme/common/themes';
import {IWorkbenchEditorService} from 'vs/workbench/services/editor/common/editorService';
import {BaseTextEditorModel} from 'vs/workbench/common/editor/textEditorModel';
import {HtmlInput} from 'vs/workbench/parts/html/common/htmlInput';
import {isLightTheme} from 'vs/platform/theme/common/themes';
import {IThemeService} from 'vs/workbench/services/themes/common/themeService';
/**
* An implementation of editor for showing HTML content in an IFrame by leveraging the IFrameEditorInput.
*/
export class HtmlPreviewPart extends BaseEditor {
declare interface Webview extends HTMLElement {
src: string;
autoSize: 'on';
nodeintegration: 'on';
disablewebsecurity: 'on';
static ID: string = 'workbench.editor.htmlPreviewPart';
getURL(): string;
getTitle(): string;
executeJavaScript(code: string, userGesture?: boolean, callback?: (result: any) => any);
send(channel: string, ...args: any[]);
openDevTools(): any;
closeDevTools(): any;
}
private _editorService: IWorkbenchEditorService;
private _themeService: IThemeService;
private _iFrameElement: HTMLIFrameElement;
private _iFrameMessageSubscription = EmptyDisposable;
private _iFrameBase: URI;
class ManagedWebview {
private _model: IModel;
private _lastModelVersion: number;
private _modelChangeSubscription = EmptyDisposable;
private _themeChangeSubscription = EmptyDisposable;
private _webview: Webview;
private _ready: TPromise<this>;
private _disposables: IDisposable[];
constructor(
@ITelemetryService telemetryService: ITelemetryService,
@IWorkbenchEditorService editorService: IWorkbenchEditorService,
@IWorkspaceContextService contextService: IWorkspaceContextService,
@IThemeService themeService: IThemeService
) {
super(HtmlPreviewPart.ID, telemetryService);
constructor(private _parent: HTMLElement, private _layoutParent: HTMLElement, private _styleElement) {
this._webview = <Webview>document.createElement('webview');
this._webview.style.zIndex = '1';
this._webview.style.position = 'absolute';
this._webview.style.left = '-1e10px'; // visible but far away
this._webview.autoSize = 'on';
this._webview.nodeintegration = 'on';
this._webview.src = require.toUrl('./webview.html');
this._editorService = editorService;
this._themeService = themeService;
this._iFrameBase = contextService.toResource('/');
this._ready = new TPromise<this>(resolve => {
const subscription = addDisposableListener(this._webview, 'ipc-message', (event) => {
if (event.channel === 'webview-ready') {
// this._webview.openDevTools();
// console.info('[PID Webview] ' + event.args[0]);
subscription.dispose();
resolve(this);
}
});
});
this._disposables = [
addDisposableListener(this._webview, 'console-message', function (e: { level: number; message: string; line: number; sourceId: string; }) {
console.log(`[Embedded Page] ${e.message}`);
}),
addDisposableListener(this._webview, 'crashed', function () {
console.error('embedded page crashed');
})
];
this._parent.appendChild(this._webview);
}
dispose(): void {
// remove from dom
const element = this._iFrameElement.parentElement;
element.parentElement.removeChild(element);
// unhook from model
this._modelChangeSubscription.dispose();
this._model = undefined;
this._themeChangeSubscription.dispose();
this._disposables = dispose(this._disposables);
this._webview.parentElement.removeChild(this._webview);
}
public createEditor(parent: Builder): void {
// IFrame
// this.iframeBuilder.removeProperty(IFrameEditor.RESOURCE_PROPERTY);
this._iFrameElement = document.createElement('iframe');
this._iFrameElement.setAttribute('frameborder', '0');
this._iFrameElement.className = 'iframe';
// Container for IFrame
const iFrameContainerElement = document.createElement('div');
iFrameContainerElement.className = 'iframe-container monaco-editor-background'; // Inherit the background color from selected theme
iFrameContainerElement.tabIndex = 0; // enable focus support from the editor part (do not remove)
iFrameContainerElement.appendChild(this._iFrameElement);
parent.getHTMLElement().appendChild(iFrameContainerElement);
this._themeChangeSubscription = this._themeService.onDidThemeChange(themeId => {
if (this.isVisible()) {
this._updateIFrameContent(true);
}
});
this._iFrameMessageSubscription = addDisposableListener(window, 'message', e => {
if (e.source !== this._iFrameElement.contentWindow) {
return;
}
const fakeEvent = <any>document.createEvent('KeyboardEvent'); // create a keyboard event
Object.defineProperty(fakeEvent, 'keyCode', { // we need to set some properties that Chrome wants
get: function() {
return e.data.keyCode;
}
});
Object.defineProperty(fakeEvent, 'which', {
get: function() {
return e.data.keyCode;
}
});
Object.defineProperty(fakeEvent, 'target', {
get: function() {
return window && window.parent.document.body;
}
});
fakeEvent.initKeyboardEvent('keydown', true, true, document.defaultView, null, null,
e.data.ctrlKey, e.data.altKey, e.data.shiftKey, e.data.metaKey); // the API shape of this method is not clear to me, but it works
document.dispatchEvent(fakeEvent);
});
private _send(channel: string, ...args: any[]): void {
this._ready
.then(() => this._webview.send(channel, ...args))
.done(void 0, console.error);
}
public layout(dimension: Dimension): void {
let {width, height} = dimension;
this._iFrameElement.parentElement.style.width = `${width}px`;
this._iFrameElement.parentElement.style.height = `${height}px`;
this._iFrameElement.style.width = `${width}px`;
this._iFrameElement.style.height = `${height}px`;
set contents(value: string[]) {
this._send('content', value);
}
public focus(): void {
// this.iframeContainer.domFocus();
this._iFrameElement.focus();
set baseUrl(value: string) {
this._send('baseUrl', value);
}
// --- input
focus(): void {
this._send('focus');
}
public getTitle(): string {
if (!this.input) {
return localize('iframeEditor', 'Preview Html');
layout(): void {
const {top, left, width, height} = this._layoutParent.getBoundingClientRect();
this._webview.style.top = `${top}px`;
this._webview.style.left = `${left}px`;
this._webview.style.width = `${width}px`;
this._webview.style.height = `${height}px`;
this._send('layout', width, height);
}
style(themeId: string): void {
const {color, backgroundColor, fontFamily, fontSize} = window.getComputedStyle(this._styleElement);
let value = `
body {
margin: 0;
}
return this.input.getName();
}
public setVisible(visible: boolean, position?: Position): TPromise<void> {
return super.setVisible(visible, position).then(() => {
if (visible && this._model) {
this._modelChangeSubscription = this._model.addListener2(EventType.ModelContentChanged2, () => this._updateIFrameContent());
this._updateIFrameContent();
} else {
this._modelChangeSubscription.dispose();
}
});
}
public changePosition(position: Position): void {
super.changePosition(position);
// reparenting an IFRAME into another DOM element yields weird results when the contents are made
// of a string and not a URL. to be on the safe side we reload the iframe when the position changes
// and we do it using a timeout of 0 to reload only after the position has been changed in the DOM
setTimeout(() => {
this._updateIFrameContent(true);
}, 0);
}
public setInput(input: EditorInput, options: EditorOptions): TPromise<void> {
this._model = undefined;
this._modelChangeSubscription.dispose();
this._lastModelVersion = -1;
if (!(input instanceof HtmlInput)) {
return TPromise.wrapError<void>('Invalid input');
}
return this._editorService.resolveEditorModel({ resource: (<HtmlInput>input).getResource() }).then(model => {
if (model instanceof BaseTextEditorModel) {
this._model = model.textEditorModel;
}
if (!this._model) {
return TPromise.wrapError<void>(localize('html.voidInput', "Invalid editor input."));
}
this._modelChangeSubscription = this._model.addListener2(EventType.ModelContentChanged2, () => this._updateIFrameContent());
this._updateIFrameContent();
return super.setInput(input, options);
});
}
private _updateIFrameContent(refresh: boolean = false): void {
if (!this._model || (!refresh && this._lastModelVersion === this._model.getVersionId())) {
// nothing to do
return;
}
const html = this._model.getValue();
const iFrameDocument = this._iFrameElement.contentDocument;
if (!iFrameDocument) {
// not visible anymore
return;
}
const parser = new DOMParser();
const newDocument = parser.parseFromString(html, 'text/html');
// ensure styles
const styleElement = Integration.defaultStyle(this._iFrameElement.parentElement, this._themeService.getTheme());
if (newDocument.head.hasChildNodes()) {
newDocument.head.insertBefore(styleElement, newDocument.head.firstChild);
} else {
newDocument.head.appendChild(styleElement);
}
// set baseurl if possible
if (this._iFrameBase) {
const baseElement = document.createElement('base');
baseElement.href = this._iFrameBase.toString();
newDocument.head.appendChild(baseElement);
}
// propagate key events
newDocument.body.appendChild(Integration.bubbleKeybindings);
// write new content to iframe
iFrameDocument.open('text/html', 'replace');
iFrameDocument.write(newDocument.documentElement.innerHTML);
iFrameDocument.close();
this._lastModelVersion = this._model.getVersionId();
}
}
namespace Integration {
'use strict';
// scripts
export const bubbleKeybindings = document.createElement('script');
bubbleKeybindings.innerHTML = `
var ignoredKeys = [9 /* tab */, 32 /* space */, 33 /* page up */, 34 /* page down */, 38 /* up */, 40 /* down */];
var ignoredCtrlCmdKeys = [65 /* a */, 67 /* c */];
var ignoredShiftKeys = [9 /* tab */];
window.document.body.addEventListener("keydown", function(event) {
try {
if (!event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey && ignoredKeys.some(function(i) {return i === event.keyCode;})) {
return;
}
if ((event.ctrlKey || event.metaKey) && ignoredCtrlCmdKeys.some(function(i) { return i === event.keyCode; })) {
return;
}
if (event.shiftKey && ignoredShiftKeys.some(function(i) { return i === event.keyCode; })) {
return;
}
event.preventDefault();
window.parent.postMessage({ which: event.which, keyCode: event.keyCode, charCode: event.charCode, metaKey: event.metaKey, altKey: event.altKey, shiftKey: event.shiftKey, ctrlKey: event.ctrlKey }, "*");
} catch (error) { }
});
function defaultPreventHandler(e) { e.preventDefault(); };
window.document.addEventListener("dragover", defaultPreventHandler);
window.document.addEventListener("drop", defaultPreventHandler);
window.document.body.addEventListener("dragover", defaultPreventHandler);
window.document.body.addEventListener("drop", defaultPreventHandler);
`;
// styles
const defaultLightScrollbarStyle = [
'::-webkit-scrollbar-thumb {',
' background-color: rgba(100, 100, 100, 0.4);',
'}',
'::-webkit-scrollbar-thumb:hover {',
' background-color: rgba(100, 100, 100, 0.7);',
'}',
'::-webkit-scrollbar-thumb:active {',
' background-color: rgba(0, 0, 0, 0.6);',
'}'
].join('\n');
const defaultDarkScrollbarStyle = [
'::-webkit-scrollbar-thumb {',
' background-color: rgba(121, 121, 121, 0.4);',
'}',
'::-webkit-scrollbar-thumb:hover {',
' background-color: rgba(100, 100, 100, 0.7);',
'}',
'::-webkit-scrollbar-thumb:active {',
' background-color: rgba(85, 85, 85, 0.8);',
'}'
].join('\n');
export function defaultStyle(element: HTMLElement, themeId: string): HTMLStyleElement {
const styles = window.getComputedStyle(element);
const styleElement = document.createElement('style');
styleElement.innerHTML = `* {
color: ${styles.color};
background: ${styles.background};
font-family: ${styles.fontFamily};
font-size: ${styles.fontSize};
* {
color: ${color};
background-color: ${backgroundColor};
font-family: ${fontFamily};
font-size: ${fontSize};
}
img {
max-width: 100%;
@@ -311,12 +135,145 @@ namespace Integration {
}
::-webkit-scrollbar {
width: 14px;
height: 14px;
height: 10px;
}
${isLightTheme(themeId)
? defaultLightScrollbarStyle
: defaultDarkScrollbarStyle}`;
::-webkit-scrollbar-thumb:hover {
background-color: rgba(100, 100, 100, 0.7);
}`;
return styleElement;
if (isLightTheme(themeId)) {
value += `
::-webkit-scrollbar-thumb {
background-color: rgba(100, 100, 100, 0.4);
}
::-webkit-scrollbar-thumb:active {
background-color: rgba(0, 0, 0, 0.6);
}`;
} else {
value += `
::-webkit-scrollbar-thumb {
background-color: rgba(121, 121, 121, 0.4);
}
::-webkit-scrollbar-thumb:active {
background-color: rgba(85, 85, 85, 0.8);
}`;
}
this._send('styles', value);
}
}
/**
* An implementation of editor for showing HTML content in an IFrame by leveraging the IFrameEditorInput.
*/
export class HtmlPreviewPart extends BaseEditor {
static ID: string = 'workbench.editor.htmlPreviewPart';
private _editorService: IWorkbenchEditorService;
private _themeService: IThemeService;
private _webview: ManagedWebview;
private _container: HTMLDivElement;
private _baseUrl: URI;
private _model: IModel;
private _modelChangeSubscription = EmptyDisposable;
private _themeChangeSubscription = EmptyDisposable;
constructor(
@ITelemetryService telemetryService: ITelemetryService,
@IWorkbenchEditorService editorService: IWorkbenchEditorService,
@IThemeService themeService: IThemeService,
@IWorkspaceContextService contextService: IWorkspaceContextService
) {
super(HtmlPreviewPart.ID, telemetryService);
this._editorService = editorService;
this._themeService = themeService;
this._baseUrl = contextService.toResource('/');
}
dispose(): void {
// remove from dom
this._webview.dispose();
// unhook listeners
this._themeChangeSubscription.dispose();
this._modelChangeSubscription.dispose();
this._model = undefined;
super.dispose();
}
public createEditor(parent: Builder): void {
this._container = document.createElement('div');
parent.getHTMLElement().appendChild(this._container);
}
private get webview(): ManagedWebview {
if (!this._webview) {
this._webview = new ManagedWebview(document.getElementById('workbench.main.container'),
this._container,
document.querySelector('.monaco-editor-background'));
this._webview.baseUrl = this._baseUrl && this._baseUrl.toString();
}
return this._webview;
}
public setVisible(visible: boolean, position?: Position): TPromise<void> {
if (!visible) {
this._themeChangeSubscription.dispose();
this._modelChangeSubscription.dispose();
this._webview.dispose();
this._webview = undefined;
} else {
this._themeChangeSubscription = this._themeService.onDidThemeChange(themeId => this.webview.style(themeId));
this.webview.style(this._themeService.getTheme());
this.webview.layout();
if (this._model) {
this._modelChangeSubscription = this._model.addListener2(EventType.ModelContentChanged2, () => this.webview.contents = this._model.getLinesContent());
this.webview.contents = this._model.getLinesContent();
}
}
return super.setVisible(visible, position);
}
public layout(dimension: Dimension): void {
const {width, height} = dimension;
this._container.style.width = `${width}px`;
this._container.style.height = `${height}px`;
this.webview.layout();
}
public focus(): void {
this.webview.focus();
}
public setInput(input: EditorInput, options: EditorOptions): TPromise<void> {
if (this.input === input) {
return TPromise.as(undefined);
}
this._model = undefined;
this._modelChangeSubscription.dispose();
if (!(input instanceof HtmlInput)) {
return TPromise.wrapError<void>('Invalid input');
}
return this._editorService.resolveEditorModel({ resource: (<HtmlInput>input).getResource() }).then(model => {
if (model instanceof BaseTextEditorModel) {
this._model = model.textEditorModel;
}
if (!this._model) {
return TPromise.wrapError<void>(localize('html.voidInput', "Invalid editor input."));
}
this._modelChangeSubscription = this._model.addListener2(EventType.ModelContentChanged2, () => this.webview.contents = this._model.getLinesContent());
this.webview.contents = this._model.getLinesContent();
return super.setInput(input, options);
});
}
}
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Virtual Document</title>
</head>
<body style="margin: 0; overflow: hidden;">
<iframe id="_target" frameborder="0"></iframe>
<script>
'use strict';
const ipcRenderer = require('electron').ipcRenderer;
const target = document.getElementById('_target');
let initData = {};
ipcRenderer.on('baseUrl', function(event, value) {
initData.baseUrl = value;
});
// forward layout calls
ipcRenderer.on('layout', function(event, width, height) {
document.body.style.width = target.style.width = width + 'px';
document.body.style.height = target.style.height = height + 'px';
});
ipcRenderer.on('styles', function(event, value) {
initData.styles = value;
const defaultStyles = target.contentDocument.getElementById('_defaultStyles');
if(defaultStyles) {
defaultStyles.innerHTML = value;
}
});
// propagate focus
ipcRenderer.on('focus', function() {
target.contentDocument.body.focus();
});
// update iframe-contents
ipcRenderer.on('content', function(event, value) {
const parser = new DOMParser();
const newDocument = parser.parseFromString(value.join('\n'), 'text/html');
// set base-url if possible
if(initData.baseUrl) {
const baseElement = document.createElement('base');
baseElement.href = initData.baseUrl;
newDocument.head.appendChild(baseElement);
}
// apply default styles
const defaultStyles = newDocument.createElement('style');
defaultStyles.id = '_defaultStyles';
defaultStyles.innerHTML = initData.styles;
if (newDocument.head.hasChildNodes()) {
newDocument.head.insertBefore(defaultStyles, newDocument.head.firstChild);
} else {
newDocument.head.appendChild(defaultStyles);
}
// write new content onto iframe
target.contentDocument.open('text/html', 'replace');
target.contentDocument.write(newDocument.documentElement.innerHTML);
target.contentDocument.close();
});
// signal ready, needs a short timeout for an
// unknown reason
setTimeout(function() {
ipcRenderer.sendToHost('webview-ready', process.pid);
}, 10);
</script>
</body>
</html>