diff --git a/src/vs/workbench/parts/html/browser/html.contribution.ts b/src/vs/workbench/parts/html/browser/html.contribution.ts index b3cbcb96782..65ea8acda8c 100644 --- a/src/vs/workbench/parts/html/browser/html.contribution.ts +++ b/src/vs/workbench/parts/html/browser/html.contribution.ts @@ -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 diff --git a/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts b/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts index c509a8b91de..d8194ec97e7 100644 --- a/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts +++ b/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts @@ -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; + 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 = 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(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 = 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 { - 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 { - - this._model = undefined; - this._modelChangeSubscription.dispose(); - this._lastModelVersion = -1; - - if (!(input instanceof HtmlInput)) { - return TPromise.wrapError('Invalid input'); - } - - return this._editorService.resolveEditorModel({ resource: (input).getResource() }).then(model => { - if (model instanceof BaseTextEditorModel) { - this._model = model.textEditorModel; - } - - if (!this._model) { - return TPromise.wrapError(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 { + 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 { + + if (this.input === input) { + return TPromise.as(undefined); + } + + this._model = undefined; + this._modelChangeSubscription.dispose(); + + if (!(input instanceof HtmlInput)) { + return TPromise.wrapError('Invalid input'); + } + + return this._editorService.resolveEditorModel({ resource: (input).getResource() }).then(model => { + if (model instanceof BaseTextEditorModel) { + this._model = model.textEditorModel; + } + if (!this._model) { + return TPromise.wrapError(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); + }); } } diff --git a/src/vs/workbench/parts/html/browser/webview.html b/src/vs/workbench/parts/html/browser/webview.html new file mode 100644 index 00000000000..90b4580c811 --- /dev/null +++ b/src/vs/workbench/parts/html/browser/webview.html @@ -0,0 +1,77 @@ + + + + Virtual Document + + + + + + \ No newline at end of file