Notebook Views Insert Cells Modal (#16836)

* Add html-to-image package

* Add image card type
This commit is contained in:
Daniel Grajeda
2021-08-31 14:11:21 -06:00
committed by GitHub
parent a96bf181c0
commit e02ae0865a
14 changed files with 305 additions and 99 deletions

View File

@@ -396,7 +396,8 @@ export enum DeclarativeDataType {
export enum CardType {
VerticalButton = 'VerticalButton',
Details = 'Details',
ListItem = 'ListItem'
ListItem = 'ListItem',
Image = 'Image'
}
export enum Orientation {

View File

@@ -2,7 +2,7 @@
<div #cardDiv role="radio" *ngIf="label" [class]="getClass() + ' horizontal'" (click)="onCardClick()"
[attr.aria-checked]="selected" (mouseover)="onCardHoverChanged($event)" (mouseout)="onCardHoverChanged($event)"
tabIndex="0" [style.width]="width" [style.height]="height">
<ng-container *ngIf="isVerticalButton || isDetailsCard">
<ng-container *ngIf="isVerticalButton || isDetailsCard || isImageCard">
<span *ngIf="hasStatus" class="card-status">
<div class="status-content" [style.backgroundColor]="statusColor"></div>
</span>
@@ -47,6 +47,15 @@
</span>
</div>
</ng-container>
<ng-container *ngIf="isImageCard">
<div class="card-content">
<div [class]="'card-image ' + iconClass"></div>
<div *ngIf="label" class="card-label-overlay">
<h4 class="card-label">{{label}}</h4>
</div>
</div>
</ng-container>
</ng-container>
<ng-container *ngIf="isListItemCard">
<div class="list-item-content">

View File

@@ -18,8 +18,9 @@ import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { KeyCode } from 'vs/base/common/keyCodes';
import * as DOM from 'vs/base/browser/dom';
import { IComponent, IComponentDescriptor, IModelStore, ComponentEventType } from 'sql/platform/dashboard/browser/interfaces';
import { IColorTheme } from 'vs/platform/theme/common/themeService';
import { IColorTheme, ICssStyleCollector, registerThemingParticipant } from 'vs/platform/theme/common/themeService';
import { ILogService } from 'vs/platform/log/common/log';
import { CARD_OVERLAY_BACKGROUND, CARD_OVERLAY_FOREGROUND } from 'vs/workbench/common/theme';
export interface ActionDescriptor {
label: string;
@@ -52,7 +53,8 @@ export interface CardDescriptionItem {
export enum CardType {
VerticalButton = 'VerticalButton',
Details = 'Details',
ListItem = 'ListItem'
ListItem = 'ListItem',
Image = 'Image'
}
@Component({
@@ -119,6 +121,11 @@ export default class CardComponent extends ComponentWithIconBase<azdata.CardProp
public getClass(): string {
let cardClass = this.isListItemCard ? 'model-card-list-item-legacy' : 'model-card-legacy';
if (this.cardType === 'Image') {
cardClass += ' image-card';
}
return (this.selectable && this.selected || this._hasFocus) ? `${cardClass} selected` :
`${cardClass} unselected`;
}
@@ -151,7 +158,7 @@ export default class CardComponent extends ComponentWithIconBase<azdata.CardProp
}
private get selectable(): boolean {
return this.enabled && (this.cardType === 'VerticalButton' || this.cardType === 'ListItem');
return this.enabled && (this.cardType === 'VerticalButton' || this.cardType === 'ListItem' || this.cardType === 'Image');
}
// CSS-bound properties
@@ -184,6 +191,10 @@ export default class CardComponent extends ComponentWithIconBase<azdata.CardProp
return !this.cardType || this.cardType === 'ListItem';
}
public get isImageCard(): boolean {
return !this.cardType || this.cardType === 'Image';
}
public get isVerticalButton(): boolean {
return this.cardType === 'VerticalButton';
}
@@ -236,3 +247,23 @@ export default class CardComponent extends ComponentWithIconBase<azdata.CardProp
}
}
registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => {
const backgroundColor = theme.getColor(CARD_OVERLAY_BACKGROUND);
const foregroundColor = theme.getColor(CARD_OVERLAY_FOREGROUND);
if (backgroundColor) {
collector.addRule(`
.model-card-legacy .card-label-overlay {
background-color: ${backgroundColor.toString()};
}
`);
}
if (foregroundColor) {
collector.addRule(`
.model-card-legacy.image-card .card-label {
color: ${foregroundColor.toString()};
}
`);
}
});

View File

@@ -26,6 +26,47 @@
min-width: 30px;
}
.model-card-legacy.image-card {
height: auto;
margin: 0;
display: block;
}
.model-card-legacy.image-card .card-content {
display: block;
width: 100%;
height: 100%;
padding: 0;
min-height: 100px;
}
.model-card-legacy.image-card .card-image {
position: absolute;
height: 100%;
width: 100%;
background-size: contain;
background-position: initial;
background-repeat: no-repeat;
max-width: unset;
max-height: unset;
}
.model-card-legacy.image-card .card-label-overlay {
position: absolute;
bottom: 0;
width: 100%;
height: 25px;
line-height: 25px;
padding: 0;
margin: 0;
display: block;
}
.model-card-legacy.image-card .card-label {
margin: 0;
padding: 0px 10px
}
.model-card-legacy .card-vertical-button {
position: relative;
display: flex;
@@ -103,6 +144,7 @@
border-width: 1px;
border-color: rgb(0, 120, 215);
border-style: solid;
z-index: 1;
}
.model-card-list-item-legacy .selection-indicator-container, .model-card-legacy .selection-indicator-container {

View File

@@ -0,0 +1,57 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import {
ApplicationRef, ComponentFactoryResolver, NgModule,
Inject, forwardRef, Type
} from '@angular/core';
import { APP_BASE_HREF, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { providerIterator } from 'sql/workbench/services/bootstrap/browser/bootstrapService';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IBootstrapParams, ISelector } from 'sql/workbench/services/bootstrap/common/bootstrapParams';
import CardComponent from 'sql/workbench/browser/modelComponents/card.component';
import DivContainer from 'sql/workbench/browser/modelComponents/divContainer.component';
import { ModelComponentWrapper } from 'sql/workbench/browser/modelComponents/modelComponentWrapper.component';
import { InsertCellsScreenshots } from 'sql/workbench/contrib/notebook/browser/notebookViews/insertCellsScreenshots.component';
import ImageComponent from 'sql/workbench/browser/modelComponents/image.component';
// Insertcells main angular module
export const InsertCellsModule = (params: IBootstrapParams, selector: string, instantiationService: IInstantiationService): Type<any> => {
@NgModule({
declarations: [
CardComponent,
DivContainer,
ModelComponentWrapper,
InsertCellsScreenshots,
ImageComponent,
],
entryComponents: [InsertCellsScreenshots, CardComponent, ImageComponent],
imports: [
FormsModule,
CommonModule,
BrowserModule
],
providers: [
{ provide: APP_BASE_HREF, useValue: '/' },
{ provide: IBootstrapParams, useValue: params },
{ provide: ISelector, useValue: selector },
...providerIterator(instantiationService)
]
})
class ModuleClass {
constructor(
@Inject(forwardRef(() => ComponentFactoryResolver)) private _resolver: ComponentFactoryResolver,
@Inject(ISelector) private selector: string
) {
}
ngDoBootstrap(appRef: ApplicationRef) {
const factory = this._resolver.resolveComponentFactory(InsertCellsScreenshots);
(<any>factory).factory.selector = this.selector;
appRef.bootstrap(factory);
}
}
return ModuleClass;
};

View File

@@ -3,7 +3,6 @@
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import 'vs/css!./insertCellsModal';
import { Checkbox } from 'sql/base/browser/ui/checkbox/checkbox';
import { Button } from 'sql/base/browser/ui/button/button';
import { IClipboardService } from 'sql/platform/clipboard/common/clipboardService';
import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry';
@@ -13,22 +12,24 @@ import { ITextResourcePropertiesService } from 'vs/editor/common/services/textRe
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { ILayoutService } from 'vs/platform/layout/browser/layoutService';
import { ILogService } from 'vs/platform/log/common/log';
import { IColorTheme, ICssStyleCollector, IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService';
import * as DOM from 'vs/base/browser/dom';
import { attachCheckboxStyler } from 'sql/platform/theme/common/styler';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { ServiceOptionType } from 'sql/platform/connection/common/interfaces';
import { ServiceOption } from 'azdata';
import * as DialogHelper from 'sql/workbench/browser/modal/dialogHelper';
import { TextCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/textCell.component';
import { ComponentFactoryResolver, ViewContainerRef } from '@angular/core';
import { NgModuleRef, ComponentFactoryResolver, ViewContainerRef } from '@angular/core';
import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces';
import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel';
import { inputBorder, inputValidationInfoBorder } from 'vs/platform/theme/common/colorRegistry';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { bootstrapAngular } from 'sql/workbench/services/bootstrap/browser/bootstrapService';
import { localize } from 'vs/nls';
import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension';
import { InsertCellsModule } from 'sql/workbench/contrib/notebook/browser/notebookViews/insertCellsModal.module';
import { attachButtonStyler } from 'vs/platform/theme/common/styler';
import { truncate } from 'vs/base/common/strings';
import { toJpeg } from 'html-to-image';
import { IComponentEventArgs } from 'sql/platform/dashboard/browser/interfaces';
import { Thumbnail } from 'sql/workbench/contrib/notebook/browser/notebookViews/insertCellsScreenshots.component';
type CellOption = {
optionMetadata: ServiceOption,
@@ -54,6 +55,10 @@ export class CellOptionsModel {
});
}
public get checkedOptions(): CellOption[] {
return Object.values(this._optionsMap).filter(o => o.currentValue === true);
}
private getDisplayValue(optionMetadata: ServiceOption, optionValue: string): boolean {
let displayValue: boolean = false;
switch (optionMetadata.valueType) {
@@ -93,8 +98,8 @@ export class InsertCellsModal extends Modal {
private _submitButton: Button;
private _cancelButton: Button;
private _optionsMap: { [name: string]: Checkbox } = {};
private _maxTitleLength: number = 20;
private _moduleRef?: NgModuleRef<typeof InsertCellsModule>;
constructor(
private onInsert: (cell: ICellModel) => void,
@@ -107,6 +112,7 @@ export class InsertCellsModal extends Modal {
@IClipboardService clipboardService: IClipboardService,
@IContextKeyService contextKeyService: IContextKeyService,
@IAdsTelemetryService telemetryService: IAdsTelemetryService,
@IInstantiationService private _instantiationService: IInstantiationService,
@ITextResourcePropertiesService textResourcePropertiesService: ITextResourcePropertiesService,
) {
super(
@@ -127,21 +133,7 @@ export class InsertCellsModal extends Modal {
}
protected renderBody(container: HTMLElement): void {
const grid = DOM.$<HTMLDivElement>('div#insert-dialog-cell-grid');
grid.style.display = 'grid';
grid.style.gridTemplateColumns = '1fr 1fr';
grid.style.gap = '10px';
grid.style.padding = '10px';
grid.style.overflowY = 'auto';
grid.style.maxHeight = 'calc(100% - 40px)';
const gridTitle = DOM.$<HTMLHeadElement>('h2.grid-title');
gridTitle.title = localize("insertCellsModal.selectCells", "Select cell sources");
DOM.append(container, grid);
this.createOptions(grid)
this.createOptions(container)
.catch((e) => { this.setError(localize("insertCellsModal.thumbnailError", "Error: Unable to generate thumbnails.")); });
}
@@ -153,59 +145,48 @@ export class InsertCellsModal extends Modal {
const activeView = this._context.getActiveView();
const cellsAvailableToInsert = activeView.hiddenCells;
cellsAvailableToInsert.forEach(async (cell) => {
const optionWidget = this.createCheckBoxHelper(
container,
'<div class="loading-spinner-container"><div class="loading-spinner codicon in-progress"></div></div>',
false,
() => this.onOptionChecked(cell.cellGuid)
);
const thumbnails = await Promise.all(
cellsAvailableToInsert.map(async (cell) => {
return {
id: cell.cellGuid,
path: await this.generateScreenshot(cell),
title: localize("insertCellsModal.cellTitle", "Cell {0}", Number.parseInt(cell.id) + 1)
} as Thumbnail;
})
);
const img = await this.generateScreenshot(cell);
const wrapper = DOM.$<HTMLDivElement>('div.thumnail-wrapper');
const thumbnail = DOM.$<HTMLImageElement>('img.thumbnail');
thumbnail.src = img;
thumbnail.style.maxWidth = '100%';
DOM.append(wrapper, thumbnail);
optionWidget.label = wrapper.outerHTML;
this._optionsMap[cell.cellGuid] = optionWidget;
});
this.bootstrapAngular(container, thumbnails);
}
private createCheckBoxHelper(container: HTMLElement, label: string, isChecked: boolean, onCheck: (viaKeyboard: boolean) => void): Checkbox {
const checkbox = new Checkbox(DOM.append(container, DOM.$('.dialog-input-section')), {
label: label,
checked: isChecked,
onChange: onCheck,
ariaLabel: label
});
this._register(attachCheckboxStyler(checkbox, this._themeService));
return checkbox;
}
public onOptionChecked(optionName: string) {
this.viewModel.setOptionValue(optionName, (<Checkbox>this._optionsMap[optionName]).checked);
this.validate();
public onOptionChecked(e: IComponentEventArgs) {
if (e.args?.value) {
let optionName: string = e.args.value;
this.viewModel.setOptionValue(optionName, e.args.selected);
this.validate();
}
}
public async generateScreenshot(cell: ICellModel, screenshotWidth: number = 300, screenshowHeight: number = 300, backgroundColor: string = '#ffffff'): Promise<string> {
let componentFactory = this._componentFactoryResolver.resolveComponentFactory(TextCellComponent);
let component = this._containerRef.createComponent(componentFactory);
try {
let componentFactory = this._componentFactoryResolver.resolveComponentFactory(TextCellComponent);
let component = this._containerRef.createComponent(componentFactory);
component.instance.model = this._context.notebook as NotebookModel;
component.instance.cellModel = cell;
component.instance.model = this._context.notebook as NotebookModel;
component.instance.cellModel = cell;
component.instance.handleContentChanged();
component.instance.handleContentChanged();
const element: HTMLElement = component.instance.outputRef.nativeElement;
const element: HTMLElement = component.instance.outputRef.nativeElement;
const scale = element.clientWidth / screenshotWidth;
const canvasWidth = element.clientWidth / scale;
const canvasHeight = element.clientHeight / scale;
const scale = element.clientWidth / screenshotWidth;
const canvasWidth = element.clientWidth / scale;
const canvasHeight = element.clientHeight / scale;
return toJpeg(component.instance.outputRef.nativeElement, { quality: .6, canvasWidth, canvasHeight, backgroundColor });
return toJpeg(component.instance.outputRef.nativeElement, { quality: .6, canvasWidth, canvasHeight, backgroundColor });
} catch (e) {
this.logService.error(`Error generating screenshot: ${e}`);
return '';
}
}
private getOptions(): ServiceOption[] {
@@ -239,7 +220,7 @@ export class InsertCellsModal extends Modal {
}
private validate() {
if (Object.keys(this._optionsMap).length) {
if (this.viewModel.checkedOptions.length) {
this._submitButton.enabled = true;
} else {
this._submitButton.enabled = false;
@@ -265,34 +246,25 @@ export class InsertCellsModal extends Modal {
public override dispose(): void {
super.dispose();
for (let key in this._optionsMap) {
let widget = this._optionsMap[key];
widget.dispose();
delete this._optionsMap[key];
if (this._moduleRef) {
this._moduleRef.destroy();
}
}
/**
* Get the bootstrap params and perform the bootstrap
*/
private bootstrapAngular(bodyContainer: HTMLElement, thumbnails: Thumbnail[]) {
this._instantiationService.invokeFunction<void, any[]>(bootstrapAngular,
InsertCellsModule,
bodyContainer,
'insert-cells-screenshots-component',
{
thumbnails,
onClick: (e: IComponentEventArgs) => { this.onOptionChecked(e); }
},
undefined,
(moduleRef: NgModuleRef<typeof InsertCellsModule>) => this._moduleRef = moduleRef);
}
}
registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => {
const inputBorderColor = theme.getColor(inputBorder);
if (inputBorderColor) {
collector.addRule(`
#insert-dialog-cell-grid input[type="checkbox"] + label {
border: 2px solid;
border-color: ${inputBorderColor.toString()};
display: flex;
height: 125px;
overflow: hidden;
}
`);
}
const inputActiveOptionBorderColor = theme.getColor(inputValidationInfoBorder);
if (inputActiveOptionBorderColor) {
collector.addRule(`
#insert-dialog-cell-grid input[type="checkbox"]:checked + label {
border-color: ${inputActiveOptionBorderColor.toString()};
}
`);
}
});

View File

@@ -0,0 +1,54 @@
/*---------------------------------------------------------------------------------------------
* 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!./insertCellsScreenshots';
import { Component, AfterViewInit, forwardRef, Inject, ComponentFactoryResolver, ViewContainerRef, ViewChild } from '@angular/core';
import { IBootstrapParams } from 'sql/workbench/services/bootstrap/common/bootstrapParams';
import { IComponentEventArgs } from 'sql/platform/dashboard/browser/interfaces';
import CardComponent, { CardType } from 'sql/workbench/browser/modelComponents/card.component';
import { URI } from 'vs/base/common/uri';
export interface LayoutRequestParams {
modelViewId?: string;
alwaysRefresh?: boolean;
}
export interface Thumbnail {
id: string,
path: string,
title: string
}
export interface InsertCellsComponentParams extends IBootstrapParams {
thumbnails: Thumbnail[],
onClick: (e: IComponentEventArgs) => void
}
@Component({
selector: 'insert-cells-screenshots-component',
template: '<div class="insert-cells-screenshot-grid"><ng-container #divContainer></ng-container></div>'
})
export class InsertCellsScreenshots implements AfterViewInit {
@ViewChild('divContainer', { read: ViewContainerRef }) _containerRef: ViewContainerRef;
constructor(
@Inject(IBootstrapParams) private _params: InsertCellsComponentParams,
@Inject(forwardRef(() => ComponentFactoryResolver)) private _componentFactoryResolver: ComponentFactoryResolver
) { }
ngAfterViewInit(): void {
this._params.thumbnails.forEach((thumbnail: Thumbnail, idx: number) => {
const cellImageUri = URI.parse(thumbnail.path);
let cardComponentFactory = this._componentFactoryResolver.resolveComponentFactory(CardComponent);
let cardComponent = this._containerRef.createComponent(cardComponentFactory);
cardComponent.instance.setProperties({ iconPath: cellImageUri, label: thumbnail.title, value: thumbnail.id, cardType: CardType.Image });
cardComponent.instance.enabled = true;
cardComponent.instance.registerEventHandler(e => this._params.onClick(e));
});
}
}

View File

@@ -0,0 +1,8 @@
.insert-cells-screenshot-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
padding: 10px;
overflow-y: auto;
max-height: calc(100% - 40px);
}

View File

@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { Component, Input, ViewChildren, QueryList, ChangeDetectorRef, forwardRef, Inject, ViewChild, ElementRef, ViewContainerRef, ComponentFactoryResolver } from '@angular/core';
import { ICellModel, INotebookModel, ISingleNotebookEditOperation, NotebookContentChange } from 'sql/workbench/services/notebook/browser/models/modelInterfaces';
import 'vs/css!./notebookViewsGrid';
import { CodeCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/codeCell.component';
import { TextCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/textCell.component';
import { ICellEditorProvider, INotebookParams, INotebookService, INotebookEditor, NotebookRange, INotebookSection, DEFAULT_NOTEBOOK_PROVIDER, SQL_NOTEBOOK_PROVIDER } from 'sql/workbench/services/notebook/browser/notebookService';
@@ -260,6 +261,10 @@ export class NotebookViewComponent extends AngularDisposable implements INoteboo
let insertCellsAction = this._instantiationService.createInstance(InsertCellAction, this.insertCell.bind(this), this.views, this._containerRef, this._componentFactoryResolver);
// Hide the insert cell action button if no cells can be added
insertCellsAction.enabled = this.activeView?.hiddenCells.length > 0;
this.activeView.onCellVisibilityChanged(e => { insertCellsAction.enabled = this.activeView?.hiddenCells.length > 0; });
this._runAllCellsAction = this._instantiationService.createInstance(RunAllCellsAction, 'notebook.runAllCells', localize('runAllPreview', "Run all"), 'notebook-button masked-pseudo start-outline');
let spacerElement = document.createElement('li');

View File

@@ -770,6 +770,7 @@ export class NotebookViewStub implements INotebookView {
displayedCells: readonly ICellModel[];
onDeleted: vsEvent.Event<INotebookView>;
onCellVisibilityChanged: vsEvent.Event<ICellModel>;
initialize(): void {
throw new Error('Method not implemented.');
}

View File

@@ -21,10 +21,12 @@ function cellCollides(c1: INotebookViewCell, c2: INotebookViewCell): boolean {
export class NotebookViewModel implements INotebookView {
private _onDeleted = new Emitter<INotebookView>();
private _onCellVisibilityChanged = new Emitter<ICellModel>();
private _isNew: boolean = false;
public readonly guid: string;
public readonly onDeleted = this._onDeleted.event;
public readonly onCellVisibilityChanged = this._onCellVisibilityChanged.event;
constructor(
protected _name: string,
@@ -119,10 +121,12 @@ export class NotebookViewModel implements INotebookView {
public insertCell(cell: ICellModel) {
this.updateCell(cell, this, { hidden: false });
this._onCellVisibilityChanged.fire(cell);
}
public hideCell(cell: ICellModel) {
this.updateCell(cell, this, { hidden: true });
this._onCellVisibilityChanged.fire(cell);
}
public moveCell(cell: ICellModel, x: number, y: number) {

View File

@@ -26,8 +26,9 @@ export interface INotebookViews {
export interface INotebookView {
readonly guid: string;
readonly onDeleted: Event<INotebookView>;
isNew: boolean;
readonly onCellVisibilityChanged: Event<ICellModel>;
isNew: boolean;
cells: Readonly<ICellModel[]>;
hiddenCells: Readonly<ICellModel[]>;
displayedCells: Readonly<ICellModel[]>;