diff --git a/src/sql/platform/dashboard/common/modelComponentRegistry.ts b/src/sql/platform/dashboard/common/modelComponentRegistry.ts index 0f43f96c9a..d3ad66f8aa 100644 --- a/src/sql/platform/dashboard/common/modelComponentRegistry.ts +++ b/src/sql/platform/dashboard/common/modelComponentRegistry.ts @@ -6,8 +6,6 @@ import { Type } from '@angular/core'; import { ModelComponentTypes } from 'sql/workbench/api/common/sqlExtHostTypes'; import * as platform from 'vs/platform/registry/common/platform'; -import { IJSONSchema } from 'vs/base/common/jsonSchema'; -import * as nls from 'vs/nls'; import { IComponent } from 'sql/workbench/electron-browser/modelComponents/interfaces'; export type ComponentIdentifier = string; diff --git a/src/sql/workbench/parts/notebook/cellViews/output.component.html b/src/sql/workbench/parts/notebook/cellViews/output.component.html index de4a1a3416..8946a84479 100644 --- a/src/sql/workbench/parts/notebook/cellViews/output.component.html +++ b/src/sql/workbench/parts/notebook/cellViews/output.component.html @@ -6,6 +6,10 @@ -->
-
+
+ + + +
diff --git a/src/sql/workbench/parts/notebook/cellViews/output.component.ts b/src/sql/workbench/parts/notebook/cellViews/output.component.ts index 636d4b7977..1568e653ed 100644 --- a/src/sql/workbench/parts/notebook/cellViews/output.component.ts +++ b/src/sql/workbench/parts/notebook/cellViews/output.component.ts @@ -5,52 +5,70 @@ import 'vs/css!./code'; import 'vs/css!./media/output'; -import { OnInit, Component, Input, Inject, ElementRef, ViewChild, SimpleChange } from '@angular/core'; +import { OnInit, Component, Input, Inject, ElementRef, ViewChild, SimpleChange, AfterViewInit, forwardRef, ChangeDetectorRef, ComponentRef, ComponentFactoryResolver } from '@angular/core'; import { AngularDisposable } from 'sql/base/node/lifecycle'; import { Event } from 'vs/base/common/event'; import { nb } from 'azdata'; import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces'; -import { INotebookService } from 'sql/workbench/services/notebook/common/notebookService'; -import { MimeModel } from 'sql/workbench/parts/notebook/outputs/common/mimemodel'; import * as outputProcessor from 'sql/workbench/parts/notebook/outputs/common/outputProcessor'; -import { RenderMimeRegistry } from 'sql/workbench/parts/notebook/outputs/registry'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IThemeService, ITheme } from 'vs/platform/theme/common/themeService'; import * as DOM from 'vs/base/browser/dom'; +import { ComponentHostDirective } from 'sql/workbench/parts/dashboard/common/componentHost.directive'; +import { Extensions, IMimeComponent, IMimeComponentRegistry } from 'sql/workbench/parts/notebook/outputs/mimeRegistry'; +import * as colors from 'vs/platform/theme/common/colorRegistry'; +import * as themeColors from 'vs/workbench/common/theme'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { localize } from 'vs/nls'; +import * as types from 'vs/base/common/types'; +import { getErrorMessage } from 'sql/workbench/parts/notebook/notebookUtils'; export const OUTPUT_SELECTOR: string = 'output-component'; const USER_SELECT_CLASS = 'actionselect'; +const componentRegistry = Registry.as(Extensions.MimeComponentContribution); + @Component({ selector: OUTPUT_SELECTOR, templateUrl: decodeURI(require.toUrl('./output.component.html')) }) -export class OutputComponent extends AngularDisposable implements OnInit { +export class OutputComponent extends AngularDisposable implements OnInit, AfterViewInit { @ViewChild('output', { read: ElementRef }) private outputElement: ElementRef; + @ViewChild(ComponentHostDirective) componentHost: ComponentHostDirective; @Input() cellOutput: nb.ICellOutput; @Input() cellModel: ICellModel; + private _trusted: boolean; private _initialized: boolean = false; private _activeCellId: string; - registry: RenderMimeRegistry; - + private _componentInstance: IMimeComponent; + public errorText: string; constructor( - @Inject(INotebookService) private _notebookService: INotebookService, - @Inject(IThemeService) private _themeService: IThemeService + @Inject(IThemeService) private _themeService: IThemeService, + @Inject(forwardRef(() => ChangeDetectorRef)) private _changeref: ChangeDetectorRef, + @Inject(forwardRef(() => ElementRef)) private _ref: ElementRef, + @Inject(forwardRef(() => ComponentFactoryResolver)) private _componentFactoryResolver: ComponentFactoryResolver ) { super(); - this.registry = this._notebookService.getMimeRegistry(); } ngOnInit() { - this.renderOutput(); + this._register(this._themeService.onThemeChange(event => this.updateTheme(event))); + this.layout(); this._initialized = true; this._register(Event.debounce(this.cellModel.notebookModel.layoutChanged, (l, e) => e, 50, /*leading=*/false) - (() => this.renderOutput())); + (() => this.layout())); + } + + ngAfterViewInit() { + this.updateTheme(this._themeService.getTheme()); + if (this.componentHost) { + this.loadComponent(); + } + this._changeref.detectChanges(); } ngOnChanges(changes: { [propKey: string]: SimpleChange }) { - for (let propName in changes) { if (propName === 'activeCellId') { this.toggleUserSelect(this.isActive()); @@ -60,24 +78,31 @@ export class OutputComponent extends AngularDisposable implements OnInit { } private toggleUserSelect(userSelect: boolean): void { - if (!this.outputElement) { + if (!this.nativeOutputElement) { return; } if (userSelect) { - DOM.addClass(this.outputElement.nativeElement, USER_SELECT_CLASS); + DOM.addClass(this.nativeOutputElement, USER_SELECT_CLASS); } else { - DOM.removeClass(this.outputElement.nativeElement, USER_SELECT_CLASS); + DOM.removeClass(this.nativeOutputElement, USER_SELECT_CLASS); } } - private renderOutput(): void { - let options = outputProcessor.getBundleOptions({ value: this.cellOutput, trusted: this.trustedMode }); - options.themeService = this._themeService; - // TODO handle safe/unsafe mapping - this.createRenderedMimetype(options, this.outputElement.nativeElement); + private get nativeOutputElement() { + return this.outputElement ? this.outputElement.nativeElement : undefined; } public layout(): void { + if (this.componentInstance && this.componentInstance.layout) { + this.componentInstance.layout(); + } + } + + private get componentInstance(): IMimeComponent { + if (!this._componentInstance) { + this.loadComponent(); + } + return this._componentInstance; } get trustedMode(): boolean { @@ -87,7 +112,7 @@ export class OutputComponent extends AngularDisposable implements OnInit { @Input() set trustedMode(value: boolean) { this._trusted = value; if (this._initialized) { - this.renderOutput(); + this.layout(); } } @@ -99,36 +124,67 @@ export class OutputComponent extends AngularDisposable implements OnInit { return this._activeCellId; } - protected createRenderedMimetype(options: MimeModel.IOptions, node: HTMLElement): void { - let mimeType = this.registry.preferredMimeType( - options.data, - options.trusted ? 'any' : 'ensure' - ); - if (mimeType) { - let output = this.registry.createRenderer(mimeType); - output.node = node; - let model = new MimeModel(options); - output.renderModel(model).catch(error => { - // Manually append error message to output - output.node.innerHTML = `
Javascript Error: ${error.message}
`; - // Remove mime-type-specific CSS classes - output.node.className = 'p-Widget jp-RenderedText'; - output.node.setAttribute( - 'data-mime-type', - 'application/vnd.jupyter.stderr' - ); - }); - //this.setState({ node: node }); - } else { - // TODO Localize - node.innerHTML = - `No ${options.trusted ? '' : '(safe) '}renderer could be ` + - 'found for output. It has the following MIME types: ' + - Object.keys(options.data).join(', '); - //this.setState({ node: node }); - } - } protected isActive() { return this.cellModel && this.cellModel.id === this.activeCellId; } + + public hasError(): boolean { + return !types.isUndefinedOrNull(this.errorText); + } + private updateTheme(theme: ITheme): void { + let el = this._ref.nativeElement; + let backgroundColor = theme.getColor(colors.editorBackground, true); + let foregroundColor = theme.getColor(themeColors.SIDE_BAR_FOREGROUND, true); + + if (backgroundColor) { + el.style.backgroundColor = backgroundColor.toString(); + } + if (foregroundColor) { + el.style.color = foregroundColor.toString(); + } + } + + private loadComponent(): void { + let options = outputProcessor.getBundleOptions({ value: this.cellOutput, trusted: this.trustedMode }); + options.themeService = this._themeService; + let mimeType = componentRegistry.getPreferredMimeType( + options.data, + options.trusted ? 'any' : 'ensure' + ); + this.errorText = undefined; + if (!mimeType) { + this.errorText = localize('noMimeTypeFound', "No {0}renderer could be found for output. It has the following MIME types: {1}", + options.trusted ? '' : localize('safe', 'safe '), + Object.keys(options.data).join(', ')); + return; + } + let selector = componentRegistry.getCtorFromMimeType(mimeType); + if (!selector) { + this.errorText = localize('noSelectorFound', "No component could be found for selector {0}", mimeType); + return; + } + + let componentFactory = this._componentFactoryResolver.resolveComponentFactory(selector); + + let viewContainerRef = this.componentHost.viewContainerRef; + viewContainerRef.clear(); + + let componentRef: ComponentRef; + try { + componentRef = viewContainerRef.createComponent(componentFactory, 0); + this._componentInstance = componentRef.instance; + this._componentInstance.mimeType = mimeType; + this._componentInstance.cellModel = this.cellModel; + this._componentInstance.bundleOptions = options; + this._changeref.detectChanges(); + let el = componentRef.location.nativeElement; + + // set widget styles to conform to its box + el.style.overflow = 'hidden'; + el.style.position = 'relative'; + } catch (e) { + this.errorText = localize('componentRenderError', "Error rendering component: {0}", getErrorMessage(e)); + return; + } + } } diff --git a/src/sql/workbench/parts/notebook/cellViews/textCell.component.ts b/src/sql/workbench/parts/notebook/cellViews/textCell.component.ts index de0f4d4839..c34b0999bd 100644 --- a/src/sql/workbench/parts/notebook/cellViews/textCell.component.ts +++ b/src/sql/workbench/parts/notebook/cellViews/textCell.component.ts @@ -6,7 +6,7 @@ import 'vs/css!./textCell'; import 'vs/css!./media/markdown'; import 'vs/css!./media/highlight'; -import { OnInit, Component, Input, Inject, forwardRef, ElementRef, ChangeDetectorRef, ViewChild, OnChanges, SimpleChange, HostListener, AfterContentInit } from '@angular/core'; +import { OnInit, Component, Input, Inject, forwardRef, ElementRef, ChangeDetectorRef, ViewChild, OnChanges, SimpleChange, HostListener } from '@angular/core'; import * as path from 'path'; import { localize } from 'vs/nls'; @@ -24,6 +24,7 @@ import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces' import { ISanitizer, defaultSanitizer } from 'sql/workbench/parts/notebook/outputs/sanitizer'; import { NotebookModel } from 'sql/workbench/parts/notebook/models/notebookModel'; import { CellToggleMoreActions } from 'sql/workbench/parts/notebook/cellToggleMoreActions'; +import { convertVscodeResourceToFileInSubDirectories } from 'sql/workbench/parts/notebook/notebookUtils'; export const TEXT_SELECTOR: string = 'text-cell-component'; const USER_SELECT_CLASS = 'actionselect'; @@ -153,7 +154,7 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges { } this._commandService.executeCommand('notebook.showPreview', this.cellModel.notebookModel.notebookUri, this._content).then((htmlcontent) => { - htmlcontent = this.convertVscodeResourceToFileInSubDirectories(htmlcontent); + htmlcontent = convertVscodeResourceToFileInSubDirectories(htmlcontent, this.cellModel); htmlcontent = this.sanitizeContent(htmlcontent); let outputElement = this.output.nativeElement; outputElement.innerHTML = htmlcontent; @@ -169,24 +170,6 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges { } return content; } - // Only replace vscode-resource with file when in the same (or a sub) directory - // This matches Jupyter Notebook viewer behavior - private convertVscodeResourceToFileInSubDirectories(htmlContent: string): string { - let htmlContentCopy = htmlContent; - while (htmlContentCopy.search('(?<=img src=\"vscode-resource:)') > 0) { - let pathStartIndex = htmlContentCopy.search('(?<=img src=\"vscode-resource:)'); - let pathEndIndex = htmlContentCopy.indexOf('\" ', pathStartIndex); - let filePath = htmlContentCopy.substring(pathStartIndex, pathEndIndex); - // If the asset is in the same folder or a subfolder, replace 'vscode-resource:' with 'file:', so the image is visible - if (!path.relative(path.dirname(this.cellModel.notebookModel.notebookUri.fsPath), filePath).includes('..')) { - // ok to change from vscode-resource: to file: - htmlContent = htmlContent.replace('vscode-resource:' + filePath, 'file:' + filePath); - } - htmlContentCopy = htmlContentCopy.slice(pathEndIndex); - } - return htmlContent; - } - // Todo: implement layout public layout() { diff --git a/src/sql/workbench/parts/notebook/notebook.contribution.ts b/src/sql/workbench/parts/notebook/notebook.contribution.ts index 076857504e..2aad27327e 100644 --- a/src/sql/workbench/parts/notebook/notebook.contribution.ts +++ b/src/sql/workbench/parts/notebook/notebook.contribution.ts @@ -13,6 +13,9 @@ import { NotebookEditor } from 'sql/workbench/parts/notebook/notebookEditor'; import { NewNotebookAction } from 'sql/workbench/parts/notebook/notebookActions'; import { KeyMod } from 'vs/editor/common/standalone/standaloneBase'; import { KeyCode } from 'vs/base/common/keyCodes'; +import { registerComponentType } from 'sql/workbench/parts/notebook/outputs/mimeRegistry'; +import { MimeRendererComponent as MimeRendererComponent } from 'sql/workbench/parts/notebook/outputs/mimeRenderer.component'; +import { MarkdownOutputComponent } from 'sql/workbench/parts/notebook/outputs/markdownOutput.component'; // Model View editor registration const viewModelEditorDescriptor = new EditorDescriptor( @@ -36,4 +39,107 @@ actionRegistry.registerWorkbenchAction( ), NewNotebookAction.LABEL -); \ No newline at end of file +); + +/* *************** Output components *************** */ +// Note: most existing types use the same component to render. In order to +// preserve correct rank order, we register it once for each different rank of +// MIME types. + +/** + * A mime renderer component for raw html. + */ +registerComponentType({ + mimeTypes: ['text/html'], + rank: 50, + safe: true, + ctor: MimeRendererComponent, + selector: MimeRendererComponent.SELECTOR +}); + +/** + * A mime renderer component for images. + */ +registerComponentType({ + mimeTypes: ['image/bmp', 'image/png', 'image/jpeg', 'image/gif'], + rank: 90, + safe: true, + ctor: MimeRendererComponent, + selector: MimeRendererComponent.SELECTOR +}); + +/** + * A mime renderer component for svg. + */ +registerComponentType({ + mimeTypes: ['image/svg+xml'], + rank: 80, + safe: false, + ctor: MimeRendererComponent, + selector: MimeRendererComponent.SELECTOR +}); + +/** + * A mime renderer component for plain and jupyter console text data. + */ +registerComponentType({ + mimeTypes: [ + 'text/plain', + 'application/vnd.jupyter.stdout', + 'application/vnd.jupyter.stderr' + ], + rank: 120, + safe: true, + ctor: MimeRendererComponent, + selector: MimeRendererComponent.SELECTOR +}); + +/** + * A placeholder component for deprecated rendered JavaScript. + */ +registerComponentType({ + mimeTypes: ['text/javascript', 'application/javascript'], + rank: 110, + safe: false, + ctor: MimeRendererComponent, + selector: MimeRendererComponent.SELECTOR +}); + +/** + * A mime renderer component for grid data. + * This will be replaced by a dedicated component in the future + */ +registerComponentType({ + mimeTypes: [ + 'application/vnd.dataresource+json', + 'application/vnd.dataresource' + ], + rank: 40, + safe: true, + ctor: MimeRendererComponent, + selector: MimeRendererComponent.SELECTOR +}); + +/** + * A mime renderer component for LaTeX. + * This will be replaced by a dedicated component in the future + */ +registerComponentType({ + mimeTypes: ['text/latex'], + rank: 70, + safe: true, + ctor: MimeRendererComponent, + selector: MimeRendererComponent.SELECTOR +}); + +/** + * A mime renderer component for Markdown. + * This will be replaced by a dedicated component in the future + */ +registerComponentType({ + mimeTypes: ['text/markdown'], + rank: 60, + safe: true, + ctor: MarkdownOutputComponent, + selector: MarkdownOutputComponent.SELECTOR +}); diff --git a/src/sql/workbench/parts/notebook/notebook.module.ts b/src/sql/workbench/parts/notebook/notebook.module.ts index 71e07b5c3e..071dca73bf 100644 --- a/src/sql/workbench/parts/notebook/notebook.module.ts +++ b/src/sql/workbench/parts/notebook/notebook.module.ts @@ -25,9 +25,13 @@ import LoadingSpinner from 'sql/workbench/electron-browser/modelComponents/loadi import { Checkbox } from 'sql/base/electron-browser/ui/checkbox/checkbox.component'; import { SelectBox } from 'sql/platform/ui/electron-browser/selectBox/selectBox.component'; import { InputBox } from 'sql/base/electron-browser/ui/inputBox/inputBox.component'; +import { IMimeComponentRegistry, Extensions } from 'sql/workbench/parts/notebook/outputs/mimeRegistry'; +import { Registry } from 'vs/platform/registry/common/platform'; import { LinkHandlerDirective } from 'sql/workbench/parts/notebook/cellViews/linkHandler.directive'; export const NotebookModule = (params, selector: string, instantiationService: IInstantiationService): any => { + let outputComponents = Registry.as(Extensions.MimeComponentContribution).getAllCtors(); + @NgModule({ declarations: [ Checkbox, @@ -44,9 +48,13 @@ export const NotebookModule = (params, selector: string, instantiationService: I OutputAreaComponent, OutputComponent, StdInComponent, - LinkHandlerDirective + LinkHandlerDirective, + ...outputComponents + ], + entryComponents: [ + NotebookComponent, + ...outputComponents ], - entryComponents: [NotebookComponent], imports: [ FormsModule, CommonModule, diff --git a/src/sql/workbench/parts/notebook/notebookUtils.ts b/src/sql/workbench/parts/notebook/notebookUtils.ts index 6b3f04e20a..c66843c4c9 100644 --- a/src/sql/workbench/parts/notebook/notebookUtils.ts +++ b/src/sql/workbench/parts/notebook/notebookUtils.ts @@ -11,6 +11,7 @@ import { localize } from 'vs/nls'; import { DEFAULT_NOTEBOOK_PROVIDER, DEFAULT_NOTEBOOK_FILETYPE, INotebookService } from 'sql/workbench/services/notebook/common/notebookService'; import { ConnectionProfile } from 'sql/platform/connection/common/connectionProfile'; import { IOutputChannel } from 'vs/workbench/contrib/output/common/output'; +import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces'; /** @@ -121,3 +122,23 @@ export async function asyncForEach(array: any, callback: any): Promise { await callback(array[index], index, array); } } + +/** + * Only replace vscode-resource with file when in the same (or a sub) directory + * This matches Jupyter Notebook viewer behavior + */ +export function convertVscodeResourceToFileInSubDirectories(htmlContent: string, cellModel: ICellModel): string { + let htmlContentCopy = htmlContent; + while (htmlContentCopy.search('(?<=img src=\"vscode-resource:)') > 0) { + let pathStartIndex = htmlContentCopy.search('(?<=img src=\"vscode-resource:)'); + let pathEndIndex = htmlContentCopy.indexOf('\" ', pathStartIndex); + let filePath = htmlContentCopy.substring(pathStartIndex, pathEndIndex); + // If the asset is in the same folder or a subfolder, replace 'vscode-resource:' with 'file:', so the image is visible + if (!path.relative(path.dirname(cellModel.notebookModel.notebookUri.fsPath), filePath).includes('..')) { + // ok to change from vscode-resource: to file: + htmlContent = htmlContent.replace('vscode-resource:' + filePath, 'file:' + filePath); + } + htmlContentCopy = htmlContentCopy.slice(pathEndIndex); + } + return htmlContent; +} diff --git a/src/sql/workbench/parts/notebook/outputs/common/renderMimeInterfaces.ts b/src/sql/workbench/parts/notebook/outputs/common/renderMimeInterfaces.ts index db4e0aafd9..e47396b3ea 100644 --- a/src/sql/workbench/parts/notebook/outputs/common/renderMimeInterfaces.ts +++ b/src/sql/workbench/parts/notebook/outputs/common/renderMimeInterfaces.ts @@ -60,151 +60,6 @@ export namespace IRenderMime { metadata?: ReadonlyJSONObject; } - /** - * The options used to initialize a document widget factory. - * - * This interface is intended to be used by mime renderer extensions - * to define a document opener that uses its renderer factory. - */ - export interface IDocumentWidgetFactoryOptions { - /** - * The name of the widget to display in dialogs. - */ - readonly name: string; - - /** - * The name of the document model type. - */ - readonly modelName?: string; - - /** - * The primary file type of the widget. - */ - readonly primaryFileType: string; - - /** - * The file types the widget can view. - */ - readonly fileTypes: ReadonlyArray; - - /** - * The file types for which the factory should be the default. - */ - readonly defaultFor?: ReadonlyArray; - - /** - * The file types for which the factory should be the default for rendering, - * if that is different than the default factory (which may be for editing) - * If undefined, then it will fall back on the default file type. - */ - readonly defaultRendered?: ReadonlyArray; - } - - /** - * A file type to associate with the renderer. - */ - export interface IFileType { - /** - * The name of the file type. - */ - readonly name: string; - - /** - * The mime types associated the file type. - */ - readonly mimeTypes: ReadonlyArray; - - /** - * The extensions of the file type (e.g. `".txt"`). Can be a compound - * extension (e.g. `".table.json`). - */ - readonly extensions: ReadonlyArray; - - /** - * An optional display name for the file type. - */ - readonly displayName?: string; - - /** - * An optional pattern for a file name (e.g. `^Dockerfile$`). - */ - readonly pattern?: string; - - /** - * The icon class name for the file type. - */ - readonly iconClass?: string; - - /** - * The icon label for the file type. - */ - readonly iconLabel?: string; - - /** - * The file format for the file type ('text', 'base64', or 'json'). - */ - readonly fileFormat?: string; - } - - /** - * An interface for using a RenderMime.IRenderer for output and read-only documents. - */ - export interface IExtension { - /** - * The ID of the extension. - * - * #### Notes - * The convention for extension IDs in JupyterLab is the full NPM package - * name followed by a colon and a unique string token, e.g. - * `'@jupyterlab/apputils-extension:settings'` or `'foo-extension:bar'`. - */ - readonly id: string; - - /** - * A renderer factory to be registered to render the MIME type. - */ - readonly rendererFactory: IRendererFactory; - - /** - * The rank passed to `RenderMime.addFactory`. If not given, - * defaults to the `defaultRank` of the factory. - */ - readonly rank?: number; - - /** - * The timeout after user activity to re-render the data. - */ - readonly renderTimeout?: number; - - /** - * Preferred data type from the model. Defaults to `string`. - */ - readonly dataType?: 'string' | 'json'; - - /** - * The options used to open a document with the renderer factory. - */ - readonly documentWidgetFactoryOptions?: - | IDocumentWidgetFactoryOptions - | ReadonlyArray; - - /** - * The optional file type associated with the extension. - */ - readonly fileTypes?: ReadonlyArray; - } - - /** - * The interface for a module that exports an extension or extensions as - * the default value. - */ - export interface IExtensionModule { - /** - * The default export. - */ - readonly default: IExtension | ReadonlyArray; - } - /** * A widget which displays the contents of a mime model. */ @@ -279,17 +134,17 @@ export namespace IRenderMime { /** * An optional url resolver. */ - resolver: IResolver | null; + resolver?: IResolver | null; /** * An optional link handler. */ - linkHandler: ILinkHandler | null; + linkHandler?: ILinkHandler | null; /** * The LaTeX typesetter. */ - latexTypesetter: ILatexTypesetter | null; + latexTypesetter?: ILatexTypesetter | null; } /** diff --git a/src/sql/workbench/parts/notebook/outputs/factories.ts b/src/sql/workbench/parts/notebook/outputs/factories.ts index 6853a4c172..5346cd19ec 100644 --- a/src/sql/workbench/parts/notebook/outputs/factories.ts +++ b/src/sql/workbench/parts/notebook/outputs/factories.ts @@ -36,16 +36,6 @@ export const imageRendererFactory: IRenderMime.IRendererFactory = { // createRenderer: options => new widgets.RenderedLatex(options) // }; -// /** -// * A mime renderer factory for Markdown. -// */ -// export const markdownRendererFactory: IRenderMime.IRendererFactory = { -// safe: true, -// mimeTypes: ['text/markdown'], -// defaultRank: 60, -// createRenderer: options => new widgets.RenderedMarkdown(options) -// }; - /** * A mime renderer factory for svg. */ @@ -95,7 +85,6 @@ export const dataResourceRendererFactory: IRenderMime.IRendererFactory = { */ export const standardRendererFactories: ReadonlyArray = [ htmlRendererFactory, - // markdownRendererFactory, // latexRendererFactory, svgRendererFactory, imageRendererFactory, @@ -103,3 +92,4 @@ export const standardRendererFactories: ReadonlyArray +
+
+
+ diff --git a/src/sql/workbench/parts/notebook/outputs/markdownOutput.component.ts b/src/sql/workbench/parts/notebook/outputs/markdownOutput.component.ts new file mode 100644 index 0000000000..9fa41adc49 --- /dev/null +++ b/src/sql/workbench/parts/notebook/outputs/markdownOutput.component.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!../cellViews/textCell'; +import 'vs/css!../cellViews/media/markdown'; +import 'vs/css!../cellViews/media/highlight'; + +import { OnInit, Component, Input, Inject, forwardRef, ElementRef, ChangeDetectorRef, ViewChild } from '@angular/core'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { ISanitizer, defaultSanitizer } from 'sql/workbench/parts/notebook/outputs/sanitizer'; +import { AngularDisposable } from 'sql/base/node/lifecycle'; +import { IMimeComponent } from 'sql/workbench/parts/notebook/outputs/mimeRegistry'; +import { INotebookService } from 'sql/workbench/services/notebook/common/notebookService'; +import { MimeModel } from 'sql/workbench/parts/notebook/outputs/common/mimemodel'; +import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces'; +import { convertVscodeResourceToFileInSubDirectories } from 'sql/workbench/parts/notebook/notebookUtils'; + +@Component({ + selector: MarkdownOutputComponent.SELECTOR, + templateUrl: decodeURI(require.toUrl('./markdownOutput.component.html')) +}) +export class MarkdownOutputComponent extends AngularDisposable implements IMimeComponent, OnInit { + public static readonly SELECTOR: string = 'markdown-output'; + + @ViewChild('output', { read: ElementRef }) private output: ElementRef; + + private _sanitizer: ISanitizer; + private _lastTrustedMode: boolean; + + private _bundleOptions: MimeModel.IOptions; + private _initialized: boolean = false; + public loading: boolean = false; + private _cellModel: ICellModel; + + constructor( + @Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef, + @Inject(ICommandService) private _commandService: ICommandService, + @Inject(INotebookService) private _notebookService: INotebookService + + ) { + super(); + this._sanitizer = this._notebookService.getMimeRegistry().sanitizer; + } + + @Input() set bundleOptions(value: MimeModel.IOptions) { + this._bundleOptions = value; + if (this._initialized) { + this.updatePreview(); + } + } + + @Input() mimeType: string; + + get cellModel(): ICellModel { + return this._cellModel; + } + + @Input() set cellModel(value: ICellModel) { + this._cellModel = value; + } + + public get isTrusted(): boolean { + return this._bundleOptions && this._bundleOptions.trusted; + } + + //Gets sanitizer from ISanitizer interface + private get sanitizer(): ISanitizer { + if (this._sanitizer) { + return this._sanitizer; + } + return this._sanitizer = defaultSanitizer; + } + + private setLoading(isLoading: boolean): void { + this.loading = isLoading; + this._changeRef.detectChanges(); + } + + ngOnInit() { + this.updatePreview(); + } + + /** + * Updates the preview of markdown component with latest changes + * If content is empty and in non-edit mode, default it to 'Double-click to edit' + * Sanitizes the data to be shown in markdown cell + */ + private updatePreview() { + if (!this._bundleOptions || !this._cellModel) { + return; + } + let trustedChanged = this._bundleOptions && this._lastTrustedMode !== this.isTrusted; + if (trustedChanged || !this._initialized) { + this._lastTrustedMode = this.isTrusted; + let content = this._bundleOptions.data['text/markdown']; + if (!content) { + + } else { + this._commandService.executeCommand('notebook.showPreview', this._cellModel.notebookModel.notebookUri, content).then((htmlcontent) => { + htmlcontent = convertVscodeResourceToFileInSubDirectories(htmlcontent, this._cellModel); + htmlcontent = this.sanitizeContent(htmlcontent); + let outputElement = this.output.nativeElement; + outputElement.innerHTML = htmlcontent; + this.setLoading(false); + }); + } + this._initialized = true; + } + } + + //Sanitizes the content based on trusted mode of Cell Model + private sanitizeContent(content: string): string { + if (this.isTrusted) { + content = this.sanitizer.sanitize(content); + } + return content; + } + + + public layout() { + // Do we need to update on layout changed? + } + + public handleContentChanged(): void { + this.updatePreview(); + } +} \ No newline at end of file diff --git a/src/sql/workbench/parts/notebook/outputs/mimeRegistry.ts b/src/sql/workbench/parts/notebook/outputs/mimeRegistry.ts new file mode 100644 index 0000000000..79396fc9fe --- /dev/null +++ b/src/sql/workbench/parts/notebook/outputs/mimeRegistry.ts @@ -0,0 +1,195 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Type } from '@angular/core'; + +import * as platform from 'vs/platform/registry/common/platform'; +import { ReadonlyJSONObject } from 'sql/workbench/parts/notebook/models/jsonext'; +import { MimeModel } from 'sql/workbench/parts/notebook/outputs/common/mimemodel'; +import * as types from 'vs/base/common/types'; +import { IRenderMime } from 'sql/workbench/parts/notebook/outputs/common/renderMimeInterfaces'; +import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces'; + +export type FactoryIdentifier = string; + +export const Extensions = { + MimeComponentContribution: 'notebook.contributions.mimecomponents' +}; + +export interface IMimeComponent { + bundleOptions: MimeModel.IOptions; + mimeType: string; + cellModel?: ICellModel; + layout(): void; +} + +export interface IMimeComponentDefinition { + /** + * Whether the component is a "safe" component. + * + * #### Notes + * A "safe" component produces renderer widgets which can render + * untrusted model data in a usable way. *All* renderers must + * handle untrusted data safely, but some may simply failover + * with a "Run cell to view output" message. A "safe" renderer + * is an indication that its sanitized output will be useful. + */ + readonly safe: boolean; + + /** + * The mime types handled by this component. + */ + readonly mimeTypes: ReadonlyArray; + + /** + * The angular selector for this component + */ + readonly selector: string; + /** + * The default rank of the factory. If not given, defaults to 100. + */ + readonly rank?: number; + + readonly ctor: Type; +} + +export type SafetyLevel = 'ensure' | 'prefer' | 'any'; +type RankPair = { readonly id: number; readonly rank: number }; + +/** + * A type alias for a mapping of mime type -> rank pair. + */ +type RankMap = { [key: string]: RankPair }; + +/** + * A type alias for a mapping of mime type -> ordered factories. + */ +export type ComponentMap = { [key: string]: IMimeComponentDefinition }; + +export interface IMimeComponentRegistry { + + /** + * Add a MIME component to the registry. + * + * @param componentDefinition - The definition of this component including + * the constructor to initialize it, supported `mimeTypes`, and `rank` order + * of preference vs. other mime types. + * If no `rank` is given, it will default to 100. + * + * #### Notes + * The renderer will replace an existing renderer for the given + * mimeType. + */ + registerComponentType(componentDefinition: IMimeComponentDefinition): void; + + /** + * Find the preferred mime type for a mime bundle. + * + * @param bundle - The bundle of mime data. + * + * @param safe - How to consider safe/unsafe factories. If 'ensure', + * it will only consider safe factories. If 'any', any factory will be + * considered. If 'prefer', unsafe factories will be considered, but + * only after the safe options have been exhausted. + * + * @returns The preferred mime type from the available factories, + * or `undefined` if the mime type cannot be rendered. + */ + getPreferredMimeType(bundle: ReadonlyJSONObject, safe: SafetyLevel): string; + getCtorFromMimeType(mimeType: string): Type; + getAllCtors(): Array>; + getAllMimeTypes(): Array; +} + +class MimeComponentRegistry implements IMimeComponentRegistry { + private _id = 0; + private _ranks: RankMap = {}; + private _types: string[] | null = null; + private _componentDefinitions: ComponentMap = {}; + + registerComponentType(componentDefinition: IMimeComponentDefinition): void { + let rank = !types.isUndefinedOrNull(componentDefinition.rank) ? componentDefinition.rank : 100; + for (let mt of componentDefinition.mimeTypes) { + this._componentDefinitions[mt] = componentDefinition; + this._ranks[mt] = { rank, id: this._id++ }; + } + this._types = null; + } + + public getPreferredMimeType(bundle: ReadonlyJSONObject, safe: SafetyLevel = 'ensure'): string | undefined { + // Try to find a safe factory first, if preferred. + if (safe === 'ensure' || safe === 'prefer') { + for (let mt of this.mimeTypes) { + if (mt in bundle && this._componentDefinitions[mt].safe) { + return mt; + } + } + } + + if (safe !== 'ensure') { + // Otherwise, search for the best factory among all factories. + for (let mt of this.mimeTypes) { + if (mt in bundle) { + return mt; + } + } + } + + // Otherwise, no matching mime type exists. + return undefined; + } + + public getCtorFromMimeType(mimeType: string): Type { + let componentDescriptor = this._componentDefinitions[mimeType]; + return componentDescriptor ? componentDescriptor.ctor : undefined; + } + + public getAllCtors(): Array> { + let addedCtors = []; + let ctors = Object.values(this._componentDefinitions) + .map((c: IMimeComponentDefinition) => c.ctor) + .filter(ctor => { + let shouldAdd = !addedCtors.find((ctor2) => ctor === ctor2); + if (shouldAdd) { + addedCtors.push(ctor); + } + return shouldAdd; + }); + return ctors; + } + + public getAllMimeTypes(): Array { + return Object.keys(this._componentDefinitions); + } + + /** + * The ordered list of mimeTypes. + */ + get mimeTypes(): ReadonlyArray { + return this._types || (this._types = sortedTypes(this._ranks)); + } + +} + +const componentRegistry = new MimeComponentRegistry(); +platform.Registry.add(Extensions.MimeComponentContribution, componentRegistry); + +export function registerComponentType(componentDefinition: IMimeComponentDefinition): void { + componentRegistry.registerComponentType(componentDefinition); +} + + +/** + * Get the mime types in the map, ordered by rank. + */ +function sortedTypes(map: RankMap): string[] { + return Object.keys(map).sort((a, b) => { + let p1 = map[a]; + let p2 = map[b]; + if (p1.rank !== p2.rank) { + return p1.rank - p2.rank; + } + return p1.id - p2.id; + }); +} \ No newline at end of file diff --git a/src/sql/workbench/parts/notebook/outputs/mimeRenderer.component.ts b/src/sql/workbench/parts/notebook/outputs/mimeRenderer.component.ts new file mode 100644 index 0000000000..9677c758f5 --- /dev/null +++ b/src/sql/workbench/parts/notebook/outputs/mimeRenderer.component.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IMimeComponent } from 'sql/workbench/parts/notebook/outputs/mimeRegistry'; +import { AngularDisposable } from 'sql/base/node/lifecycle'; +import { ElementRef, forwardRef, Inject, Component, OnInit, Input } from '@angular/core'; +import { MimeModel } from 'sql/workbench/parts/notebook/outputs/common/mimemodel'; +import { INotebookService } from 'sql/workbench/services/notebook/common/notebookService'; +import { RenderMimeRegistry } from 'sql/workbench/parts/notebook/outputs/registry'; +import { localize } from 'vs/nls'; + +@Component({ + selector: MimeRendererComponent.SELECTOR, + template: `` +}) +export class MimeRendererComponent extends AngularDisposable implements IMimeComponent, OnInit { + public static readonly SELECTOR = 'mime-output'; + private _bundleOptions: MimeModel.IOptions; + private registry: RenderMimeRegistry; + private _initialized: boolean = false; + + constructor( + @Inject(forwardRef(() => ElementRef)) private el: ElementRef, + @Inject(INotebookService) private _notebookService: INotebookService, + ) { + super(); + this.registry = this._notebookService.getMimeRegistry(); + } + + @Input() set bundleOptions(value: MimeModel.IOptions) { + this._bundleOptions = value; + if (this._initialized) { + this.renderOutput(); + } + } + + @Input() mimeType: string; + + ngOnInit(): void { + this.renderOutput(); + this._initialized = true; + } + + layout(): void { + // Re-layout the output when layout is requested + this.renderOutput(); + } + + private renderOutput(): void { + // TODO handle safe/unsafe mapping + this.createRenderedMimetype(this._bundleOptions, this.el.nativeElement); + } + + protected createRenderedMimetype(options: MimeModel.IOptions, node: HTMLElement): void { + if (this.mimeType) { + let renderer = this.registry.createRenderer(this.mimeType); + renderer.node = node; + let model = new MimeModel(options); + renderer.renderModel(model).catch(error => { + // Manually append error message to output + renderer.node.innerHTML = `
Javascript Error: ${error.message}
`; + // Remove mime-type-specific CSS classes + renderer.node.className = 'p-Widget jp-RenderedText'; + renderer.node.setAttribute( + 'data-mime-type', + 'application/vnd.jupyter.stderr' + ); + }); + } else { + node.innerHTML = localize('noRendererFound', + "No {0} renderer could be found for output. It has the following MIME types: {1}", + options.trusted ? '' : localize('safe', "(safe) "), + Object.keys(options.data).join(', ')); + } + } +} \ No newline at end of file diff --git a/src/sql/workbench/parts/notebook/outputs/widgets.ts b/src/sql/workbench/parts/notebook/outputs/widgets.ts index 3e622783c0..f7a08ebc81 100644 --- a/src/sql/workbench/parts/notebook/outputs/widgets.ts +++ b/src/sql/workbench/parts/notebook/outputs/widgets.ts @@ -7,7 +7,6 @@ import * as renderers from './renderers'; import { IRenderMime } from './common/renderMimeInterfaces'; import { ReadonlyJSONObject } from '../models/jsonext'; import * as tableRenderers from 'sql/workbench/parts/notebook/outputs/tableRenderers'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; /** * A common base class for mime renderers. @@ -376,4 +375,4 @@ export class RenderedDataResource extends RenderedCommon { themeService: model.themeService }); } -} \ No newline at end of file +}