From 7be726fd8a2151869880e5876607917463e23695 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 27 May 2020 17:49:58 -0500 Subject: [PATCH] Support downloading blob URLs and data URLs when clicked in a notebook webview Fix #98101 --- .../view/renderers/backLayerWebView.ts | 40 ++++++++++++++++++- .../webview/browser/baseWebviewElement.ts | 12 +++++- .../contrib/webview/browser/pre/main.js | 29 ++++++++++++++ .../contrib/webview/browser/webview.ts | 6 +++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts index 71b28fb7510..80a15497c0a 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts @@ -18,12 +18,15 @@ import { INotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookB import { CodeCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel'; import { IProcessedOutput } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; -import { IWebviewService, WebviewElement } from 'vs/workbench/contrib/webview/browser/webview'; +import { IWebviewService, WebviewElement, IDataLinkClickEvent } from 'vs/workbench/contrib/webview/browser/webview'; import { asWebviewUri } from 'vs/workbench/contrib/webview/common/webviewUri'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; -import { dirname } from 'vs/base/common/resources'; +import { dirname, joinPath } from 'vs/base/common/resources'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { Schemas } from 'vs/base/common/network'; +import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { IFileService } from 'vs/platform/files/common/files'; +import { VSBuffer } from 'vs/base/common/buffer'; export interface WebviewIntialized { __vscode_notebook_message: boolean; @@ -160,6 +163,8 @@ export class BackLayerWebView extends Disposable { @IEnvironmentService private readonly environmentService: IEnvironmentService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IWorkbenchEnvironmentService private readonly workbenchEnvironmentService: IWorkbenchEnvironmentService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IFileService private readonly fileService: IFileService, ) { super(); this.element = document.createElement('div'); @@ -516,6 +521,10 @@ ${loaderJs} } })); + this._register(this.webview.onDidClickDataLink(event => { + this._onDidClickDataLink(event); + })); + this._register(this.webview.onDidReload(() => { this.preloadsCache.clear(); for (const [output, inset] of this.insetMapping.entries()) { @@ -586,6 +595,33 @@ ${loaderJs} })); } + private async _onDidClickDataLink(event: IDataLinkClickEvent): Promise { + const defaultDir = dirname(this.documentUri); + const defaultUri = joinPath(defaultDir, event.downloadName || 'download.png'); + + const newFileUri = await this.fileDialogService.showSaveDialog({ + defaultUri + }); + if (!newFileUri) { + return; + } + + const splitData = event.dataURL.split(';base64,')[1]; + if (!splitData) { + return; + } + + const decoded = atob(splitData); + const typedArray = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i++) { + typedArray[i] = decoded.charCodeAt(i); + } + + const buff = VSBuffer.wrap(typedArray); + await this.fileService.writeFile(newFileUri, buff); + await this.openerService.open(newFileUri); + } + async waitForInitialization() { await this._initalized; } diff --git a/src/vs/workbench/contrib/webview/browser/baseWebviewElement.ts b/src/vs/workbench/contrib/webview/browser/baseWebviewElement.ts index e7cbc9ecf75..ca1a6f69b24 100644 --- a/src/vs/workbench/contrib/webview/browser/baseWebviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/baseWebviewElement.ts @@ -6,12 +6,12 @@ import { addClass } from 'vs/base/browser/dom'; import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; import { Emitter } from 'vs/base/common/event'; -import { URI } from 'vs/base/common/uri'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { WebviewExtensionDescription, WebviewOptions, WebviewContentOptions } from 'vs/workbench/contrib/webview/browser/webview'; +import { IDataLinkClickEvent, WebviewContentOptions, WebviewExtensionDescription, WebviewOptions } from 'vs/workbench/contrib/webview/browser/webview'; import { areWebviewInputOptionsEqual } from 'vs/workbench/contrib/webview/browser/webviewWorkbenchService'; import { WebviewThemeDataProvider } from 'vs/workbench/contrib/webview/common/themeing'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; @@ -26,6 +26,7 @@ export const enum WebviewMessageChannels { doUpdateState = 'do-update-state', doReload = 'do-reload', loadResource = 'load-resource', + saveResource = 'save-resource', loadLocalhost = 'load-localhost', webviewReady = 'webview-ready', wheel = 'did-scroll-wheel' @@ -136,6 +137,10 @@ export abstract class BaseWebview extends Disposable { this.handleKeyDown(data); })); + this._register(this.on(WebviewMessageChannels.saveResource, (event: IDataLinkClickEvent) => { + this._onDidClickDataLink.fire(event); + })); + this.style(); this._register(webviewThemeDataProvider.onThemeDataChanged(this.style, this)); } @@ -155,6 +160,9 @@ export abstract class BaseWebview extends Disposable { private readonly _onDidClickLink = this._register(new Emitter()); public readonly onDidClickLink = this._onDidClickLink.event; + private readonly _onDidClickDataLink = this._register(new Emitter()); + public readonly onDidClickDataLink = this._onDidClickDataLink.event; + private readonly _onDidReload = this._register(new Emitter()); public readonly onDidReload = this._onDidReload.event; diff --git a/src/vs/workbench/contrib/webview/browser/pre/main.js b/src/vs/workbench/contrib/webview/browser/pre/main.js index e42932ecf9f..9208f510a33 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/main.js +++ b/src/vs/workbench/contrib/webview/browser/pre/main.js @@ -240,6 +240,10 @@ if (scrollTarget) { scrollTarget.scrollIntoView(); } + } else if (node.href.startsWith('blob:')) { + handleBlobUrlClick(node.href, node.download); + } else if (node.href.startsWith('data:')) { + handleDataUrlClick(node.href, node.download); } else { host.postMessage('did-click-link', node.href.baseVal || node.href); } @@ -250,6 +254,31 @@ } }; + const handleDataUrlClick = async (url, downloadName) => { + host.postMessage('save-resource', { + data: url, + downloadName + }); + }; + + const handleBlobUrlClick = async (url, downloadName) => { + try { + const response = await fetch(url); + const blob = await response.blob(); + const reader = new FileReader(); + reader.addEventListener('load', () => { + const data = reader.result; + host.postMessage('save-resource', { + data, + downloadName + }); + }); + reader.readAsDataURL(blob); + } catch (e) { + console.error(e.message); + } + }; + /** * @param {MouseEvent} event */ diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index 3ea6ec88720..6be3c64062d 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -73,6 +73,11 @@ export interface WebviewExtensionDescription { readonly id: ExtensionIdentifier; } +export interface IDataLinkClickEvent { + dataURL: string; + downloadName?: string; +} + export interface Webview extends IDisposable { html: string; contentOptions: WebviewContentOptions; @@ -84,6 +89,7 @@ export interface Webview extends IDisposable { readonly onDidFocus: Event; readonly onDidBlur: Event; readonly onDidClickLink: Event; + readonly onDidClickDataLink: Event; readonly onDidScroll: Event<{ scrollYPercentage: number }>; readonly onDidWheel: Event; readonly onDidUpdateState: Event; -- GitLab