From 3e41b2d6011515b3d892abad4c1b76fd11a79a21 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Tue, 8 Mar 2016 15:30:46 +0100 Subject: [PATCH] tackle #3089 and #2912 --- .../parts/html/browser/htmlPreviewPart.ts | 192 +++++++++--------- 1 file changed, 92 insertions(+), 100 deletions(-) diff --git a/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts b/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts index 870a67eab3b16..7e45b697315e4 100644 --- a/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts +++ b/src/vs/workbench/parts/html/browser/htmlPreviewPart.ts @@ -5,16 +5,18 @@ 'use strict'; -// import 'vs/css!./media/iframeeditor'; 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 {cAll} from 'vs/base/common/lifecycle'; +import {empty as EmptyDisposable} 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 {IStorageService, StorageEventType, StorageScope} from 'vs/platform/storage/common/storage'; import {IWorkbenchEditorService} from 'vs/workbench/services/editor/common/editorService'; import {BaseTextEditorModel} from 'vs/workbench/common/editor/textEditorModel'; @@ -22,6 +24,7 @@ import {Preferences} from 'vs/workbench/common/constants'; import {HtmlInput} from 'vs/workbench/parts/html/common/htmlInput'; import {isLightTheme} from 'vs/platform/theme/common/themes'; import {DEFAULT_THEME_ID} from 'vs/workbench/services/themes/common/themeService'; + /** * An implementation of editor for showing HTML content in an IFrame by leveraging the IFrameEditorInput. */ @@ -32,33 +35,37 @@ export class HtmlPreviewPart extends BaseEditor { private _editorService: IWorkbenchEditorService; private _storageService: IStorageService; private _iFrameElement: HTMLIFrameElement; + private _iFrameMessageSubscription = EmptyDisposable; + private _iFrameBase: URI; private _model: IModel; - private _modelChangeUnbind: Function; private _lastModelVersion: number; - private _themeChangeUnbind: Function; + private _modelChangeSubscription = EmptyDisposable; + private _themeChangeSubscription = EmptyDisposable; constructor( @ITelemetryService telemetryService: ITelemetryService, @IWorkbenchEditorService editorService: IWorkbenchEditorService, - @IStorageService storageService: IStorageService + @IStorageService storageService: IStorageService, + @IWorkspaceContextService contextService: IWorkspaceContextService ) { super(HtmlPreviewPart.ID, telemetryService); this._editorService = editorService; this._storageService = storageService; + this._iFrameBase = contextService.toResource('/'); } dispose(): void { - // remove from dome + // remove from dom const element = this._iFrameElement.parentElement; element.parentElement.removeChild(element); // unhook from model - this._modelChangeUnbind = cAll(this._modelChangeUnbind); + this._modelChangeSubscription.dispose(); this._model = undefined; - this._themeChangeUnbind = cAll(this._themeChangeUnbind); + this._themeChangeSubscription.dispose(); } public createEditor(parent: Builder): void { @@ -77,11 +84,39 @@ export class HtmlPreviewPart extends BaseEditor { parent.getHTMLElement().appendChild(iFrameContainerElement); - this._themeChangeUnbind = this._storageService.addListener(StorageEventType.STORAGE, event => { + this._themeChangeSubscription = this._storageService.addListener2(StorageEventType.STORAGE, event => { if (event.key === Preferences.THEME && 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); + }); } public layout(dimension: Dimension): void { @@ -109,10 +144,10 @@ export class HtmlPreviewPart extends BaseEditor { public setVisible(visible: boolean, position?: Position): TPromise { return super.setVisible(visible, position).then(() => { if (visible && this._model) { - this._modelChangeUnbind = this._model.addListener(EventType.ModelContentChanged2, () => this._updateIFrameContent()); + this._modelChangeSubscription = this._model.addListener2(EventType.ModelContentChanged2, () => this._updateIFrameContent()); this._updateIFrameContent(); } else { - this._modelChangeUnbind = cAll(this._modelChangeUnbind); + this._modelChangeSubscription.dispose(); } }); } @@ -131,7 +166,7 @@ export class HtmlPreviewPart extends BaseEditor { public setInput(input: EditorInput, options: EditorOptions): TPromise { this._model = undefined; - this._modelChangeUnbind = cAll(this._modelChangeUnbind); + this._modelChangeSubscription.dispose(); this._lastModelVersion = -1; if (!(input instanceof HtmlInput)) { @@ -147,7 +182,7 @@ export class HtmlPreviewPart extends BaseEditor { return TPromise.wrapError(localize('html.voidInput', "Invalid editor input.")); } - this._modelChangeUnbind = this._model.addListener(EventType.ModelContentChanged2, () => this._updateIFrameContent()); + this._modelChangeSubscription = this._model.addListener2(EventType.ModelContentChanged2, () => this._updateIFrameContent()); this._updateIFrameContent(); return super.setInput(input, options); @@ -169,30 +204,28 @@ export class HtmlPreviewPart extends BaseEditor { return; } - // the very first time we load just our script - // to integrate with the outside world - if ((iFrameDocument.firstChild).innerHTML === '') { - iFrameDocument.open('text/html', 'replace'); - iFrameDocument.write(Integration.defaultHtml()); - iFrameDocument.close(); - } - - // diff a little against the current input and the new state const parser = new DOMParser(); const newDocument = parser.parseFromString(html, 'text/html'); + // ensure styles const styleElement = Integration.defaultStyle(this._iFrameElement.parentElement, this._storageService.get(Preferences.THEME, StorageScope.GLOBAL, DEFAULT_THEME_ID)); if (newDocument.head.hasChildNodes()) { newDocument.head.insertBefore(styleElement, newDocument.head.firstChild); } else { newDocument.head.appendChild(styleElement); } - - if (newDocument.head.innerHTML !== iFrameDocument.head.innerHTML) { - iFrameDocument.head.innerHTML = newDocument.head.innerHTML; - } - if (newDocument.body.innerHTML !== iFrameDocument.body.innerHTML) { - iFrameDocument.body.innerHTML = newDocument.body.innerHTML; + // 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(); } @@ -202,78 +235,36 @@ namespace Integration { 'use strict'; - const scriptSource = [ - '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) {', // Listen to keydown events in the iframe - ' try {', - ' if (ignoredKeys.some(function(i) { return i === event.keyCode; })) {', - ' if (!event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) {', - ' return;', // we want some single keys to be supported (e.g. Page Down for scrolling) - ' }', - ' }', - '', - ' if (ignoredCtrlCmdKeys.some(function(i) { return i === event.keyCode; })) {', - ' if (event.ctrlKey || event.metaKey) {', - ' return;', // we want some ctrl/cmd keys to be supported (e.g. Ctrl+C for copy) - ' }', - ' }', - '', - ' if (ignoredShiftKeys.some(function(i) { return i === event.keyCode; })) {', - ' if (event.shiftKey) {', - ' return;', // we want some shift keys to be supported (e.g. Shift+Tab for copy) - ' }', - ' }', - '', - ' event.preventDefault();', // very important to not get duplicate actions when this one bubbles up! - '', - ' var fakeEvent = document.createEvent("KeyboardEvent");', // create a keyboard event - ' Object.defineProperty(fakeEvent, "keyCode", {', // we need to set some properties that Chrome wants - ' get : function() {', - ' return event.keyCode;', - ' }', - ' });', - ' Object.defineProperty(fakeEvent, "which", {', - ' get : function() {', - ' return event.keyCode;', - ' }', - ' });', - ' Object.defineProperty(fakeEvent, "target", {', - ' get : function() {', - ' return window && window.parent.document.body;', - ' }', - ' });', - '', - ' fakeEvent.initKeyboardEvent("keydown", true, true, document.defaultView, null, null, event.ctrlKey, event.altKey, event.shiftKey, event.metaKey);', // the API shape of this method is not clear to me, but it works ;) - '', - ' window.parent.document.dispatchEvent(fakeEvent);', // dispatch the event onto the parent - ' } catch (error) {}', - '});', - - // disable dropping into iframe! - 'window.document.addEventListener("dragover", function (e) {', - ' e.preventDefault();', - '});', - 'window.document.addEventListener("drop", function (e) {', - ' e.preventDefault();', - '});', - 'window.document.body.addEventListener("dragover", function (e) {', - ' e.preventDefault();', - '});', - 'window.document.body.addEventListener("drop", function (e) {', - ' e.preventDefault();', - '});' - ]; - - export function defaultHtml() { - let all = [ - '', - ]; - return all.join('\n'); - } + // 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 {', @@ -327,6 +318,7 @@ namespace Integration { ${isLightTheme(themeId) ? defaultLightScrollbarStyle : defaultDarkScrollbarStyle}`; + return styleElement; } }