From c5c7ca019d29ac36eceef8a2acba5e72f43717d3 Mon Sep 17 00:00:00 2001 From: Daniel Grajeda Date: Wed, 21 Jul 2021 21:46:58 -0600 Subject: [PATCH] Notebook Views autodash feature (#16238) The autodash feature in notebook views creates an initial grid layout for users when a view is created. It is intended to reduce the effort required by the user to start editing their view. Instead of displaying every cell and stacking them vertically like the default notebook layout, we use guidelines to determine which cells are worth displaying and how to arrange them. --- .../notebookViewsGrid.component.ts | 170 +++++++++++++++--- .../browser/notebookViews/autodash.ts | 118 ++++++++++++ .../notebookViews/notebookViewModel.ts | 66 ++++++- .../browser/notebookViews/notebookViews.d.ts | 4 + 4 files changed, 326 insertions(+), 32 deletions(-) create mode 100644 src/sql/workbench/services/notebook/browser/notebookViews/autodash.ts 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; }