Insert local/online images using image call out (#15238)

* changes from Chris's branch and cell model updates

* get base64 value

* handle spaces in image names

* add comments

* add tests for imageCallOut dialog

* format document for hygiene errors

* address comments

* check base64 validity using regex

* replace space with regex

* add parameter and return type

* split into two functions

* move functions to fileUtilities

* correct import

* fix for layering issue

* revert file function changes
This commit is contained in:
Maddy
2021-04-30 19:43:55 -07:00
committed by GitHub
parent 587237673b
commit ef8b26b7ae
7 changed files with 244 additions and 18 deletions

View File

@@ -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<IImageCalloutDialogOptions> = new Deferred<IImageCalloutDialogOptions>();
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 &#32; 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, '&#32;')})`,
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;
}
}

View File

@@ -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 = /\[(?<text>.+)\]\((?<url>[^ ]+)(?: "(?<title>.+)")?\)/;
@@ -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;
}
}

View File

@@ -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(' ', '&#32;')})`, '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');
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -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;
}

View File

@@ -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 {