diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.ts b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.ts index 2c75e78925..47af24fbd3 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.ts @@ -4,33 +4,48 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/css!./notebookViewsGrid'; -import { Component, OnInit, ViewChildren, QueryList, Input, Inject, forwardRef, ChangeDetectorRef } from '@angular/core'; +import { Component, OnInit, ViewChildren, QueryList, Input, Inject, forwardRef, ChangeDetectorRef, ViewEncapsulation, ChangeDetectionStrategy } from '@angular/core'; import { NotebookViewsCardComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component'; import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel'; import { GridStack, GridStackEvent, GridStackNode } from 'gridstack'; import { localize } from 'vs/nls'; import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; -import { CellChangeEvent, INotebookViewCell } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; +import { CellChangeEvent, INotebookView, INotebookViewCell } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; +import { AngularDisposable } from 'sql/base/browser/lifecycle'; +import { generateLayout } from 'sql/workbench/services/notebook/browser/notebookViews/autodash'; + +export interface INotebookViewsGridOptions { + cellHeight?: number; +} @Component({ selector: 'notebook-views-grid-component', - templateUrl: decodeURI(require.toUrl('./notebookViewsGrid.component.html')) + templateUrl: decodeURI(require.toUrl('./notebookViewsGrid.component.html')), + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None }) -export class NotebookViewsGridComponent implements OnInit { +export class NotebookViewsGridComponent extends AngularDisposable implements OnInit { @Input() cells: ICellModel[]; @Input() model: NotebookModel; + @Input() activeView: INotebookView; @Input() views: NotebookViewsExtension; @ViewChildren(NotebookViewsCardComponent) private _items: QueryList; protected _grid: GridStack; - public loaded: boolean; + protected _gridEnabled: boolean; + protected _loaded: boolean; + + protected _options: INotebookViewsGridOptions = { + cellHeight: 60 + };; constructor( @Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef, ) { - this.loaded = false; + super(); + this._loaded = false; } public get empty(): boolean { @@ -38,7 +53,7 @@ export class NotebookViewsGridComponent implements OnInit { } public get hiddenItems(): NotebookViewsCardComponent[] { - return this._items.filter(item => !item.display); + return this._items?.filter(item => !item.display) ?? []; } public get emptyText(): String { @@ -50,17 +65,115 @@ export class NotebookViewsGridComponent implements OnInit { ngAfterViewInit() { const self = this; - self._grid = GridStack.init({ - alwaysShowResizeHandle: false, - styleInHead: true - }); + this.createGrid(); - this.loaded = true; + this._loaded = true; this.detectChanges(); - self._grid.on('added', function (e: Event, items: GridStackNode[]) { self.persist('added', items, self._grid, self._items); }); - self._grid.on('removed', function (e: Event, items: GridStackNode[]) { self.persist('removed', items, self._grid, self._items); }); - self._grid.on('change', function (e: Event, items: GridStackNode[]) { self.persist('change', items, self._grid, self._items); }); + self._grid.on('added', function (e: Event, items: GridStackNode[]) { if (self._gridEnabled) { self.persist('added', items, self._grid, self._items); } }); + self._grid.on('removed', function (e: Event, items: GridStackNode[]) { if (self._gridEnabled) { self.persist('removed', items, self._grid, self._items); } }); + self._grid.on('change', function (e: Event, items: GridStackNode[]) { if (self._gridEnabled) { self.persist('change', items, self._grid, self._items); } }); + } + + ngAfterContentChecked() { + //If activeView has changed or not present, we will destroy the grid in order to rebuild it later. + if (!this.activeView || this.activeView.guid !== this.activeView.guid) { + if (this._grid) { + this.destroyGrid(); + this._grid = undefined; + } + } + } + + ngAfterViewChecked() { + // If activeView has changed, rebuild the grid + if (!this.activeView || this.activeView.guid !== this.activeView.guid) { + + if (!this._grid) { + this.createGrid(); + } + + this._loaded = true; + this.detectChanges(); + } + } + + override ngOnDestroy() { + this.destroyGrid(); + } + + private destroyGrid() { + this._gridEnabled = false; + this._grid.destroy(false); + } + + private createGrid() { + const isNew = this.activeView.isNew; + if (this._grid) { + this.destroyGrid(); + } + + if (isNew) { + this.runAutoLayout(this.activeView); + this.activeView.markAsViewed(); + } + + this._grid = GridStack.init({ + alwaysShowResizeHandle: false, + styleInHead: true, + margin: 2, + staticGrid: false, + }); + + this._gridEnabled = true; + + if (isNew) { + this.updateGrid(); + } + } + + /** + * Updates the grid layout based on changes to the view model + */ + private updateGrid(): void { + if (!this._grid || !this.activeView) { + return; + } + + this._grid.batchUpdate(); + this.activeView.cells.forEach(cell => { + const el = this._grid.getGridItems().find(x => x.getAttribute('data-cell-id') === cell.cellGuid); + const cellData = this.activeView.getCellMetadata(cell); + this._grid.update(el, { x: cellData.x, y: cellData.y, w: cellData.width, h: cellData.height }); + + if (cellData?.hidden) { + this._grid.removeWidget(el, false); // Do not trigger event for batch update + } + }); + this._grid.commit(); + } + + private resizeCells(): void { + this._items.forEach((i: NotebookViewsCardComponent) => { + if (i.elementRef) { + const cellHeight = this._options.cellHeight; + + const naturalHeight = i.elementRef.nativeElement.clientHeight; + const heightInCells = Math.ceil(naturalHeight / cellHeight); + + const update: INotebookViewCell = { + height: heightInCells + }; + + this.views.updateCell(i.cell, this.activeView, update); + } + }); + } + + private runAutoLayout(view: INotebookView): void { + //Resize the cells before regenerating layout so that we know the natural height of the cells + this.resizeCells(); + generateLayout(view); } private detectChanges(): void { @@ -70,28 +183,25 @@ export class NotebookViewsGridComponent implements OnInit { } async onCellChanged(e: CellChangeEvent): Promise { - const currentView = this.views.getActiveView(); - if (this._grid && currentView) { + if (this._grid && this.activeView) { const cellElem: HTMLElement = this._grid.el.querySelector(`[data-cell-id='${e.cell.cellGuid}']`); if (cellElem && e.event === 'hide') { this._grid.removeWidget(cellElem); - currentView.hideCell(e.cell); + this.activeView.hideCell(e.cell); } if (e.cell && e.event === 'insert') { - const component = this._items.find(x => x.cell.cellGuid === e.cell.cellGuid); - // Prevent an awkward movement on the grid by moving this out of view first - currentView.moveCell(e.cell, 9999, 0); - currentView.insertCell(e.cell); + this.activeView.insertCell(e.cell); + + this.detectChanges(); const el = this._grid.getGridItems().find(x => x.getAttribute('data-cell-id') === e.cell.cellGuid); this._grid.makeWidget(el); this._grid.update(el, { x: 0, y: 0 }); this._grid.resizable(el, true); this._grid.movable(el, true); - - component.detectChanges(); } + this.detectChanges(); } } @@ -99,15 +209,14 @@ export class NotebookViewsGridComponent implements OnInit { /** * Update the document model with the gridstack data as metadata */ - persist(action: GridStackEvent, changedItems: GridStackNode[], grid: GridStack, items: QueryList): void { + persist(action: GridStackEvent, changedItems: GridStackNode[] = [], grid: GridStack, items: QueryList): void { changedItems.forEach((changedItem) => { const cellId = changedItem.el.getAttribute('data-cell-id'); const item = items.toArray().find(item => item.cell.cellGuid === cellId); - const activeView = this.views.getActiveView(); - if (item && activeView) { + if (item && this.activeView) { const update: INotebookViewCell = { - guid: activeView.guid, + guid: this.activeView.guid, x: changedItem.x, y: changedItem.y, width: changedItem.w, @@ -120,9 +229,12 @@ export class NotebookViewsGridComponent implements OnInit { update.hidden = true; } - this.views.updateCell(item.cell, activeView, update); + this.views.updateCell(item.cell, this.activeView, update); } }); + } + public get loaded(): boolean { + return this._loaded; } } diff --git a/src/sql/workbench/services/notebook/browser/notebookViews/autodash.ts b/src/sql/workbench/services/notebook/browser/notebookViews/autodash.ts new file mode 100644 index 0000000000..253fc6395c --- /dev/null +++ b/src/sql/workbench/services/notebook/browser/notebookViews/autodash.ts @@ -0,0 +1,118 @@ +import { nb } from 'azdata'; +import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; +import { INotebookView } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; +import { CellTypes } from 'sql/workbench/services/notebook/common/contracts'; + +class VisInfo { + public width: number; + public height: number; + public orderRank: number; + public display: boolean; + public cell: T; +} + +class DisplayCell { + constructor(private _item: T) { } + + get item(): T { + return this._item; + } +} + +abstract class DisplayGroup { + public width: number; + public height: number; + public orderRank: number; + public display: boolean; + private _displayCells: DisplayCell[] = []; + private _visInfos: VisInfo[] = []; + + constructor() { } + + addCell(cell: T, initialView: INotebookView) { + const dCell = new DisplayCell(cell); + this._displayCells.push(dCell); + this._visInfos.push(this.evaluateCell(cell, initialView)); + } + + get visInfos(): VisInfo[] { + return this._visInfos; + } + + get displayCells(): DisplayCell[] { + return this._displayCells; + } + + abstract evaluateCell(cell: T, view: INotebookView): VisInfo; +} + +class CellDisplayGroup extends DisplayGroup { + evaluateCell(cell: ICellModel, view: INotebookView): VisInfo { + let meta = view.getCellMetadata(cell); + let visInfo = new VisInfo(); + visInfo.cell = cell; + + if (cell.cellType !== CellTypes.Code && !this.isHeader(cell)) { + visInfo.display = false; + return visInfo; + } + + if (cell.cellType === CellTypes.Code && (!cell.outputs || !cell.outputs.length)) { + visInfo.display = false; + return visInfo; + } + + //For headers + if (this.isHeader(cell)) { + visInfo.height = 1; + } + //For graphs + if (this.hasGraph(cell)) { + visInfo.width = 6; + visInfo.height = 4; + } + //For tables + else if (this.hasTable(cell)) { + visInfo.height = Math.min(meta?.height, 3); + } else { + visInfo.height = Math.min(meta?.height, 3); + } + + visInfo.display = true; + return visInfo; + } + + isHeader(cell: ICellModel): boolean { + return cell.cellType === 'markdown' && cell.source.length === 1 && cell.source[0].startsWith('#'); + } + + hasGraph(cell: ICellModel): boolean { + return !!cell.outputs.find((o: nb.IDisplayResult) => o?.output_type === 'display_data' && o?.data.hasOwnProperty('application/vnd.plotly.v1+json')); + } + + hasTable(cell: ICellModel): boolean { + return !!cell.outputs.find((o: nb.IDisplayResult) => o?.output_type === 'display_data' && o?.data.hasOwnProperty('application/vnd.dataresource+json')); + } +} + +export function generateLayout(initialView: INotebookView): void { + let displayGroup: CellDisplayGroup = new CellDisplayGroup(); + + const cells = initialView.cells; + + cells.forEach((cell, idx) => { + displayGroup.addCell(cell, initialView); + }); + + displayGroup.visInfos.forEach((v) => { + if (!v.display) { + initialView.hideCell(v.cell); + } + + if (v.width || v.height) { + initialView.resizeCell(v.cell, v.width, v.height); + } + }); + + initialView.compactCells(); +} diff --git a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewModel.ts b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewModel.ts index 3ae116bbfd..97c5bf3e1b 100644 --- a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewModel.ts +++ b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewModel.ts @@ -11,23 +11,31 @@ import { generateUuid } from 'vs/base/common/uuid'; export const DEFAULT_VIEW_CARD_HEIGHT = 4; export const DEFAULT_VIEW_CARD_WIDTH = 12; +export const GRID_COLUMNS = 12; export class ViewNameTakenError extends Error { } +function cellCollides(c1: INotebookViewCell, c2: INotebookViewCell): boolean { + return !((c1.y + c1.height <= c2.y) || (c1.x + c1.width <= c2.x) || (c1.x + c1.width <= c2.x) || (c2.x + c2.width <= c1.x)); +} + export class NotebookViewModel implements INotebookView { private _onDeleted = new Emitter(); + private _isNew: boolean = false; public readonly guid: string; public readonly onDeleted = this._onDeleted.event; constructor( protected _name: string, - private _notebookViews: NotebookViewsExtension + private _notebookViews: NotebookViewsExtension, + guid?: string ) { this.guid = generateUuid(); } public initialize(): void { + this._isNew = true; const cells = this._notebookViews.notebook.cells; cells.forEach((cell, idx) => { this.initializeCell(cell, idx); }); } @@ -45,6 +53,8 @@ export class NotebookViewModel implements INotebookView { hidden: false, y: idx * DEFAULT_VIEW_CARD_HEIGHT, x: 0, + width: DEFAULT_VIEW_CARD_WIDTH, + height: DEFAULT_VIEW_CARD_HEIGHT }); } @@ -76,6 +86,10 @@ export class NotebookViewModel implements INotebookView { return this._notebookViews.notebook.cells; } + public get displayedCells(): Readonly { + return this.cells.filter(cell => !this.getCellMetadata(cell)?.hidden); + } + public getCell(guid: string): Readonly { return this._notebookViews.notebook.cells.find(cell => cell.cellGuid === guid); } @@ -92,8 +106,46 @@ export class NotebookViewModel implements INotebookView { this._notebookViews.updateCell(cell, this, { x, y }); } - public resizeCell(cell: ICellModel, width: number, height: number) { - this._notebookViews.updateCell(cell, this, { width, height }); + public resizeCell(cell: ICellModel, width?: number, height?: number) { + let data: INotebookViewCell = {}; + + if (width) { + data.width = width; + } + + if (height) { + data.height = height; + } + + this._notebookViews.updateCell(cell, this, data); + } + + public getCellSize(cell: ICellModel): any { + const meta = this.getCellMetadata(cell); + return { width: meta.width, height: meta.height }; + } + + public compactCells() { + let cellsPlaced: INotebookViewCell[] = []; + + this.displayedCells.forEach((cell: ICellModel) => { + const c1 = this.getCellMetadata(cell); + + for (let i = 0; ; i++) { + const row = i % GRID_COLUMNS; + const column = Math.floor(i / GRID_COLUMNS); + + if (row + c1.width > GRID_COLUMNS) { + continue; + } + + if (!cellsPlaced.find((c2) => cellCollides(c2, { ...c1, x: row, y: column }))) { + this._notebookViews.updateCell(cell, this, { x: row, y: column }); + cellsPlaced.push({ ...c1, x: row, y: column }); + break; + } + } + }); } public save() { @@ -105,6 +157,14 @@ export class NotebookViewModel implements INotebookView { this._onDeleted.fire(this); } + public get isNew(): boolean { + return this._isNew; + } + + public markAsViewed() { + this._isNew = false; + } + public toJSON() { return { guid: this.guid, name: this._name } as NotebookViewModel; } diff --git a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViews.d.ts b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViews.d.ts index 9dc0fbd3ce..befac71e27 100644 --- a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViews.d.ts +++ b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViews.d.ts @@ -17,17 +17,21 @@ export interface INotebookView { readonly guid: string; readonly onDeleted: Event; + isNew: boolean; cells: Readonly; hiddenCells: Readonly; + displayedCells: Readonly; name: string; initialize(): void; nameAvailable(name: string): boolean; getCellMetadata(cell: ICellModel): INotebookViewCell; hideCell(cell: ICellModel): void; moveCell(cell: ICellModel, x: number, y: number): void; + compactCells(); resizeCell(cell: ICellModel, width: number, height: number): void; getCell(guid: string): Readonly; insertCell(cell: ICellModel): void; + markAsViewed(): void; save(): void; delete(): void; }