diff --git a/src/sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog.ts b/src/sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog.ts index fe6ae17f42..1bd7af32d6 100644 --- a/src/sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog.ts +++ b/src/sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog.ts @@ -9,7 +9,7 @@ import * as styler from 'vs/platform/theme/common/styler'; import * as TelemetryKeys from 'sql/platform/telemetry/common/telemetryKeys'; import * as constants from 'sql/workbench/contrib/notebook/browser/calloutDialog/common/constants'; import { URI } from 'vs/base/common/uri'; -import { Modal, IDialogProperties } from 'sql/workbench/browser/modal/modal'; +import { Modal, IDialogProperties, DialogPosition, DialogWidth } from 'sql/workbench/browser/modal/modal'; import { IFileDialogService, IOpenDialogOptions } from 'vs/platform/dialogs/common/dialogs'; import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; @@ -24,9 +24,8 @@ import { Deferred } from 'sql/base/common/promise'; import { InputBox } from 'sql/base/browser/ui/inputBox/inputBox'; import { Checkbox } from 'sql/base/browser/ui/checkbox/checkbox'; import { RadioButton } from 'sql/base/browser/ui/radioButton/radioButton'; -import { DialogPosition, DialogWidth } from 'sql/workbench/api/common/sqlExtHostTypes'; import { attachCalloutDialogStyler } from 'sql/workbench/common/styler'; -import { escapeUrl } from 'sql/workbench/contrib/notebook/browser/calloutDialog/common/utils'; +import * as path from 'vs/base/common/path'; export interface IImageCalloutDialogOptions { insertTitle?: string, @@ -37,6 +36,7 @@ export interface IImageCalloutDialogOptions { const DEFAULT_DIALOG_WIDTH: DialogWidth = 452; +const IMAGE_Extensions: string[] = ['jpg', 'jpeg', 'png', 'gif']; export class ImageCalloutDialog extends Modal { private _selectionComplete: Deferred = new Deferred(); private _imageLocationLabel: HTMLElement; @@ -163,16 +163,6 @@ export class ImageCalloutDialog extends Modal { } }, true)); - this._register(this._imageRemoteRadioButton.onClicked(e => { - this._imageBrowseButton.style.display = 'none'; - this._imageUrlLabel.innerText = constants.urlPlaceholder; - this._imageUrlInputBox.setPlaceHolder(constants.urlPlaceholder); - })); - this._register(this._imageLocalRadioButton.onClicked(e => { - this._imageBrowseButton.style.display = 'block'; - this._imageUrlLabel.innerText = constants.pathPlaceholder; - this._imageUrlInputBox.setPlaceHolder(constants.pathPlaceholder); - })); DOM.append(pathRow, inputContainer); let embedRow = DOM.$('.row'); @@ -187,6 +177,19 @@ export class ImageCalloutDialog extends Modal { ariaLabel: constants.embedImageLabel }); DOM.append(embedRow, this._imageEmbedLabel); + + this._register(this._imageRemoteRadioButton.onClicked(e => { + this._imageBrowseButton.style.display = 'none'; + this._imageEmbedCheckbox.enabled = false; + this._imageUrlLabel.innerText = constants.urlPlaceholder; + this._imageUrlInputBox.setPlaceHolder(constants.urlPlaceholder); + })); + this._register(this._imageLocalRadioButton.onClicked(e => { + this._imageBrowseButton.style.display = 'block'; + this._imageEmbedCheckbox.enabled = true; + this._imageUrlLabel.innerText = constants.pathPlaceholder; + this._imageUrlInputBox.setPlaceHolder(constants.pathPlaceholder); + })); } private registerListeners(): void { @@ -196,10 +199,14 @@ export class ImageCalloutDialog extends Modal { public insert(): void { this.hide('ok'); + let imgPath = this._imageUrlInputBox.value; + let imageName = path.basename(imgPath); this._selectionComplete.resolve({ - insertEscapedMarkdown: `![](${escapeUrl(this._imageUrlInputBox.value)})`, - imagePath: this._imageUrlInputBox.value, - embedImage: this._imageEmbedCheckbox.checked + embedImage: this._imageEmbedCheckbox.checked, + // check for spaces and remove them in imageName. + // if spaces in image path replace with as per https://github.com/microsoft/vscode/issues/11933#issuecomment-249987377 + insertEscapedMarkdown: this._imageEmbedCheckbox.checked ? `![${imageName}](attachment:${imageName.replace(/\s/g, '')})` : `![](${imgPath.replace(/\s/g, ' ')})`, + imagePath: imgPath }); this.dispose(); } @@ -225,7 +232,8 @@ export class ImageCalloutDialog extends Modal { canSelectFolders: false, canSelectMany: false, defaultUri: URI.file(await this.getUserHome()), - title: undefined + title: undefined, + filters: [{ extensions: IMAGE_Extensions, name: 'images' }] }; let imageUri: URI[] = await this._fileDialogService.showOpenDialog(options); if (imageUri.length > 0) { @@ -234,4 +242,12 @@ export class ImageCalloutDialog extends Modal { return undefined; } } + + public set imagePath(val: string) { + this._imageUrlInputBox.value = val; + } + + public set embedImage(val: boolean) { + this._imageEmbedCheckbox.checked = val; + } } diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts index b24a3bd907..e9a549ab7c 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts @@ -22,6 +22,7 @@ import { IEditor } from 'vs/editor/common/editorCommon'; import * as path from 'vs/base/common/path'; import { URI } from 'vs/base/common/uri'; import { escape } from 'vs/base/common/strings'; +import { IImageCalloutDialogOptions, ImageCalloutDialog } from 'sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog'; export const MARKDOWN_TOOLBAR_SELECTOR: string = 'markdown-toolbar-component'; const linksRegex = /\[(?.+)\]\((?[^ ]+)(?: "(?.+)")?\)/; @@ -225,6 +226,7 @@ export class MarkdownToolbarComponent extends AngularDisposable { let triggerElement = event.target as HTMLElement; let needsTransform = true; let linkCalloutResult: ILinkCalloutDialogOptions; + let imageCalloutResult: IImageCalloutDialogOptions; if (type === MarkdownButtonType.LINK_PREVIEW) { linkCalloutResult = await this.createCallout(type, triggerElement); @@ -251,6 +253,12 @@ export class MarkdownToolbarComponent extends AngularDisposable { document.execCommand('insertHTML', false, `<a href="${escape(linkUrl)}">${escape(linkCalloutResult?.insertUnescapedLinkLabel)}</a>`); return; } + } else if (type === MarkdownButtonType.IMAGE_PREVIEW) { + imageCalloutResult = await this.createCallout(type, triggerElement); + // If cell edit mode isn't WYSIWYG, use result from callout. No need for further transformation. + if (this.cellModel.currentMode !== CellEditModes.WYSIWYG) { + needsTransform = false; + } } const transformer = new MarkdownTextTransformer(this._notebookService, this.cellModel); @@ -259,6 +267,14 @@ export class MarkdownToolbarComponent extends AngularDisposable { } else if (!needsTransform) { if (type === MarkdownButtonType.LINK_PREVIEW) { await insertFormattedMarkdown(linkCalloutResult?.insertEscapedMarkdown, this.getCellEditorControl()); + } else if (type === MarkdownButtonType.IMAGE_PREVIEW) { + if (imageCalloutResult.embedImage) { + let base64String = await this.getFileContentBase64(URI.file(imageCalloutResult.imagePath)); + let mimeType = await this.getFileMimeType(URI.file(imageCalloutResult.imagePath)); + this.cellModel.addAttachment(mimeType, base64String, path.basename(imageCalloutResult.imagePath).replace(' ', '')); + await insertFormattedMarkdown(imageCalloutResult.insertEscapedMarkdown, this.getCellEditorControl()); + } + await insertFormattedMarkdown(imageCalloutResult.insertEscapedMarkdown, this.getCellEditorControl()); } } } @@ -304,6 +320,10 @@ export class MarkdownToolbarComponent extends AngularDisposable { this._linkCallout = this._instantiationService.createInstance(LinkCalloutDialog, this.insertLinkHeading, dialogPosition, dialogProperties, defaultLabel, defaultLinkUrl); this._linkCallout.render(); calloutOptions = await this._linkCallout.open(); + } else if (type === MarkdownButtonType.IMAGE_PREVIEW) { + const imageCallout = this._instantiationService.createInstance(ImageCalloutDialog, this.insertImageHeading, dialogPosition, dialogProperties); + imageCallout.render(); + calloutOptions = await imageCallout.open(); } return calloutOptions; } @@ -354,4 +374,26 @@ export class MarkdownToolbarComponent extends AngularDisposable { } return undefined; } + + public async getFileContentBase64(fileUri: URI): Promise<string> { + return new Promise<string>(async resolve => { + let response = await fetch(fileUri.toString()); + let blob = await response.blob(); + + let file = new File([blob], fileUri.toString()); + let reader = new FileReader(); + // Read file content on file loaded event + reader.onload = function (event) { + resolve(event.target.result.toString()); + }; + // Convert data to base64 + reader.readAsDataURL(file); + }); + } + + public async getFileMimeType(fileUri: URI): Promise<string> { + let response = await fetch(fileUri.toString()); + let blob = await response.blob(); + return blob.type; + } } diff --git a/src/sql/workbench/contrib/notebook/test/calloutDialog/imageCalloutDialog.test.ts b/src/sql/workbench/contrib/notebook/test/calloutDialog/imageCalloutDialog.test.ts new file mode 100644 index 0000000000..e5d0f4c796 --- /dev/null +++ b/src/sql/workbench/contrib/notebook/test/calloutDialog/imageCalloutDialog.test.ts @@ -0,0 +1,147 @@ +import * as assert from 'assert'; + +import { TestFileDialogService, TestLayoutService, TestPathService } from 'vs/workbench/test/browser/workbenchTestServices'; +import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService'; +import { NullAdsTelemetryService } from 'sql/platform/telemetry/common/adsTelemetryService'; +import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService'; +import { Deferred } from 'sql/base/common/promise'; +import { IDialogProperties } from 'sql/workbench/browser/modal/modal'; +import { IImageCalloutDialogOptions, ImageCalloutDialog } from 'sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog'; +import { IPathService } from 'vs/workbench/services/path/common/pathService'; +import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import * as path from 'vs/base/common/path'; + +suite('Image Callout Dialog', function (): void { + let pathService: IPathService; + let fileDialogService: IFileDialogService; + let layoutService: ILayoutService; + let themeService: IThemeService; + let telemetryService: IAdsTelemetryService; + let contextKeyService: IContextKeyService; + + const defaultDialogProperties: IDialogProperties = { xPos: 0, yPos: 0, height: 250, width: 100 }; + + setup(() => { + pathService = new TestPathService(); + fileDialogService = new TestFileDialogService(pathService); + layoutService = new TestLayoutService(); + themeService = new TestThemeService(); + telemetryService = new NullAdsTelemetryService(); + contextKeyService = new MockContextKeyService(); + }); + + test('Should return empty markdown on cancel', async function (): Promise<void> { + let imageCalloutDialog = new ImageCalloutDialog('Title', 'below', defaultDialogProperties, pathService, fileDialogService, + undefined, themeService, layoutService, telemetryService, contextKeyService, undefined, undefined, undefined); + imageCalloutDialog.render(); + + let deferred = new Deferred<IImageCalloutDialogOptions>(); + // When I first open the callout dialog + imageCalloutDialog.open().then(value => { + deferred.resolve(value); + }); + // And cancel the dialog + imageCalloutDialog.cancel(); + let result = await deferred.promise; + + assert.equal(result.imagePath, undefined, 'ImagePath must be undefined'); + assert.equal(result.embedImage, undefined, 'EmbedImage must be undefined'); + assert.equal(result.insertEscapedMarkdown, '', 'Markdown not returned correctly'); + }); + + test('Should return expected values on insert', async function (): Promise<void> { + const sampleImageFileUrl = await pathService.fileURI('../resources/extension.png'); + let imageCalloutDialog = new ImageCalloutDialog('Title', 'below', defaultDialogProperties, pathService, fileDialogService, + undefined, themeService, layoutService, telemetryService, contextKeyService, undefined, undefined, undefined); + imageCalloutDialog.render(); + + let deferred = new Deferred<IImageCalloutDialogOptions>(); + // When I first open the callout dialog + imageCalloutDialog.open().then(value => { + deferred.resolve(value); + }); + + imageCalloutDialog.imagePath = sampleImageFileUrl.fsPath; + // And insert the dialog + imageCalloutDialog.insert(); + let result = await deferred.promise; + assert.equal(result.imagePath, sampleImageFileUrl.fsPath, 'ImagePath not returned correctly'); + assert.equal(result.embedImage, false, 'EmbedImage not returned correctly'); + assert.equal(result.insertEscapedMarkdown, `![](${result.imagePath})`, 'Markdown not returned correctly'); + }); + + test('Should return expected values on insert when imageName has space', async function (): Promise<void> { + const sampleImageFileUrl = await pathService.fileURI('../resources/extension 2.png'); + let imageCalloutDialog = new ImageCalloutDialog('Title', 'below', defaultDialogProperties, pathService, fileDialogService, + undefined, themeService, layoutService, telemetryService, contextKeyService, undefined, undefined, undefined); + imageCalloutDialog.render(); + + let deferred = new Deferred<IImageCalloutDialogOptions>(); + // When I first open the callout dialog + imageCalloutDialog.open().then(value => { + deferred.resolve(value); + }); + + imageCalloutDialog.imagePath = sampleImageFileUrl.fsPath; + + // And insert the dialog + imageCalloutDialog.insert(); + let result = await deferred.promise; + assert.equal(result.imagePath, sampleImageFileUrl.fsPath, 'imagePath not returned correctly'); + assert.equal(result.embedImage, false, 'embedImage not returned correctly'); + assert.equal(result.insertEscapedMarkdown, `![](${result.imagePath.replace(' ', ' ')})`, 'Markdown not returned correctly'); + }); + + test('Should return expected values on insert when add as attachment is set', async function (): Promise<void> { + const sampleImageFileUrl = await pathService.fileURI('../resources/extension.png'); + let imageCalloutDialog = new ImageCalloutDialog('Title', 'below', defaultDialogProperties, pathService, fileDialogService, + undefined, themeService, layoutService, telemetryService, contextKeyService, undefined, undefined, undefined); + imageCalloutDialog.render(); + + let deferred = new Deferred<IImageCalloutDialogOptions>(); + // When I first open the callout dialog + imageCalloutDialog.open().then(value => { + deferred.resolve(value); + }); + + imageCalloutDialog.imagePath = sampleImageFileUrl.fsPath; + imageCalloutDialog.embedImage = true; + let imageName = path.basename(sampleImageFileUrl.fsPath); + + // And insert the dialog + imageCalloutDialog.insert(); + let result = await deferred.promise; + assert.equal(result.imagePath, sampleImageFileUrl.fsPath, 'imagePath not returned correctly'); + assert.equal(result.embedImage, true, 'embedImage not returned correctly'); + assert.equal(result.insertEscapedMarkdown, `![${imageName}](attachment:${imageName})`, 'Markdown not returned correctly'); + }); + + test('Should return expected values on insert when imageName has space and add attachment is set', async function (): Promise<void> { + const sampleImageFileUrl = await pathService.fileURI('../resources/extension 2.png'); + let imageCalloutDialog = new ImageCalloutDialog('Title', 'below', defaultDialogProperties, pathService, fileDialogService, + undefined, themeService, layoutService, telemetryService, contextKeyService, undefined, undefined, undefined); + imageCalloutDialog.render(); + + let deferred = new Deferred<IImageCalloutDialogOptions>(); + // When I first open the callout dialog + imageCalloutDialog.open().then(value => { + deferred.resolve(value); + }); + + imageCalloutDialog.imagePath = sampleImageFileUrl.fsPath; + imageCalloutDialog.embedImage = true; + let imageName = path.basename(sampleImageFileUrl.fsPath); + + // And insert the dialog + imageCalloutDialog.insert(); + let result = await deferred.promise; + assert.equal(result.imagePath, sampleImageFileUrl.fsPath, 'imagePath not returned correctly'); + assert.equal(result.embedImage, true, 'embedImage not returned correctly'); + assert.equal(result.insertEscapedMarkdown, `![${imageName}](attachment:${imageName.replace(' ', '')})`, 'Markdown not returned correctly'); + }); + +}); diff --git a/src/sql/workbench/contrib/notebook/test/resources/extension 2.png b/src/sql/workbench/contrib/notebook/test/resources/extension 2.png new file mode 100644 index 0000000000..c86d6d1e00 Binary files /dev/null and b/src/sql/workbench/contrib/notebook/test/resources/extension 2.png differ diff --git a/src/sql/workbench/contrib/notebook/test/resources/extension.png b/src/sql/workbench/contrib/notebook/test/resources/extension.png new file mode 100644 index 0000000000..c86d6d1e00 Binary files /dev/null and b/src/sql/workbench/contrib/notebook/test/resources/extension.png differ diff --git a/src/sql/workbench/services/notebook/browser/models/cell.ts b/src/sql/workbench/services/notebook/browser/models/cell.ts index 9c74c30c18..a78040814e 100644 --- a/src/sql/workbench/services/notebook/browser/models/cell.ts +++ b/src/sql/workbench/services/notebook/browser/models/cell.ts @@ -34,7 +34,7 @@ import { IInsightOptions } from 'sql/workbench/common/editor/query/chartState'; let modelId = 0; const ads_execute_command = 'ads_execute_command'; - +const validBase64OctetStreamRegex = /^data:application\/octet-stream;base64,(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})/; export interface QueryResultId { batchId: number; id: number; @@ -149,6 +149,26 @@ export class CellModel extends Disposable implements ICellModel { return this._attachments; } + addAttachment(mimeType: string, base64Encoding: string, name: string): void { + // base64Encoded value looks like: data:application/octet-stream;base64,<base64Value> + // get the <base64Value> from the string + let index = base64Encoding.indexOf('base64,'); + if (this.isValidBase64OctetStream(base64Encoding)) { + base64Encoding = base64Encoding.substring(index + 7); + let attachment: nb.ICellAttachment = {}; + attachment[mimeType] = base64Encoding; + if (!this._attachments) { + this._attachments = {}; + } + // TO DO: Check if name already exists and message the user? + this._attachments[name] = attachment; + } + } + + private isValidBase64OctetStream(base64Image: string): boolean { + return base64Image && validBase64OctetStreamRegex.test(base64Image); + } + public get isEditMode(): boolean { return this._isEditMode; } diff --git a/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts b/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts index 48a1d36d4a..55aeaec619 100644 --- a/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts +++ b/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts @@ -535,6 +535,7 @@ export interface ICellModel { readonly savedConnectionName: string | undefined; readonly attachments: nb.ICellAttachments; readonly currentMode: CellEditModes; + addAttachment(mimeType: string, base64Encoding: string, name: string): void; } export interface IModelFactory {