/*--------------------------------------------------------------------------------------------- * 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!./table'; import { IDisposable, dispose, combinedDisposable } from 'vs/base/common/lifecycle'; import { ScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import { Event } from 'vs/base/common/event'; import { ScrollEvent, ScrollbarVisibility, INewScrollDimensions } from 'vs/base/common/scrollable'; import * as DOM from 'vs/base/browser/dom'; import { domEvent } from 'vs/base/browser/event'; import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; import { isWindows } from 'vs/base/common/platform'; import * as browser from 'vs/base/browser/browser'; import { Range, IRange } from 'vs/base/common/range'; import { getOrDefault } from 'vs/base/common/objects'; import { memoize } from 'vs/base/common/decorators'; import { Sash, Orientation, ISashEvent as IBaseSashEvent } from 'vs/base/browser/ui/sash/sash'; import { CellCache, ICell } from 'sql/base/browser/ui/table/highPerf/cellCache'; import { ITableRenderer, ITableDataSource, ITableMouseEvent, IStaticTableRenderer, ITableColumn } from 'sql/base/browser/ui/table/highPerf/table'; import { GridPosition } from 'sql/base/common/gridPosition'; export interface IAriaSetProvider { getSetSize(element: T, index: number, listLength: number): number; getPosInSet(element: T, index: number): number; } export interface ITableViewOptions { rowHeight?: number; mouseSupport?: boolean; initialLength?: number; rowCountColumn?: boolean; headerHeight?: number; } const DefaultOptions = { rowHeight: 22, columnWidth: 120, minWidth: 20, resizeable: true, headerHeight: 22 }; interface IInternalColumn extends ITableColumn { domNode?: HTMLElement; left?: number; } interface IInternalStaticColumn extends IInternalColumn { renderer: IStaticTableRenderer; } interface ISashItem { sash: Sash; disposable: IDisposable; } interface ISashDragState { current: number; index: number; start: number; sizes: Array; lefts: Array; } interface ISashEvent { sash: Sash; column: IInternalColumn; start: number; current: number; } function removeFromParent(element: HTMLElement): void { try { if (element.parentElement) { element.parentElement.removeChild(element); } } catch (e) { // this will throw if this happens due to a blur event, nasty business } } interface IAsyncRowItem { readonly id: string; element: T | undefined; row: HTMLElement | null; cells: ICell[] | null; size: number; datapromise: CancelablePromise | null; } export class TableView implements IDisposable { private static InstanceCount = 0; readonly domId = `table_id_${++TableView.InstanceCount}`; readonly domNode = DOM.$('.monaco-perftable'); private visibleRows: IAsyncRowItem[] = []; private cache: CellCache; private renderers = new Map>(); private lastRenderTop = 0; private lastRenderHeight = 0; private readonly rowsContainer = DOM.$('.monaco-perftable-rows'); private scrollableElement: ScrollableElement; private _scrollHeight: number = 0; private _scrollWidth: number = 0; private scrollableElementUpdateDisposable: IDisposable | null = null; // private ariaSetProvider: IAriaSetProvider; private canUseTranslate3d: boolean | undefined = undefined; public readonly rowHeight: number; private _length: number = 0; private columns: IInternalColumn[]; private staticColumns: IInternalStaticColumn[]; private columnSashs: ISashItem[] = []; private sashDragState?: ISashDragState; private headerContainer!: HTMLElement; private scheduledRender?: IDisposable; private bigNumberDelta = 0; private headerHeight: number; private disposables: IDisposable[]; get contentHeight(): number { return this.length * this.rowHeight; } get onDidScroll(): Event { return this.scrollableElement.onScroll; } private _orthogonalStartSash: Sash | undefined; get orthogonalStartSash(): Sash | undefined { return this._orthogonalStartSash; } set orthogonalStartSash(sash: Sash | undefined) { for (const sashItem of this.columnSashs) { sashItem.sash.orthogonalStartSash = sash; } this._orthogonalStartSash = sash; } private _orthogonalEndSash: Sash | undefined; get orthogonalEndSash(): Sash | undefined { return this._orthogonalEndSash; } set orthogonalEndSash(sash: Sash | undefined) { for (const sashItem of this.columnSashs) { sashItem.sash.orthogonalEndSash = sash; } this._orthogonalEndSash = sash; } get sashes(): Sash[] { return this.columnSashs.map(s => s.sash); } constructor( container: HTMLElement, columns: ITableColumn[], private readonly dataSource: ITableDataSource, options: ITableViewOptions = DefaultOptions as ITableViewOptions, ) { for (const column of columns) { this.renderers.set(column.id, column.renderer); } this.columns = columns.slice(); this.staticColumns = this.columns.filter(c => c.static); this.cache = new CellCache(this.renderers); this.domNode.setAttribute('role', 'grid'); this.domNode.setAttribute('aria-rowcount', '0'); this.domNode.setAttribute('aria-readonly', 'true'); this.domNode.classList.add(this.domId); this.domNode.tabIndex = 0; this.domNode.classList.toggle('mouse-support', typeof options.mouseSupport === 'boolean' ? options.mouseSupport : true); // this.ariaSetProvider = { getSetSize: (e, i, length) => length, getPosInSet: (_, index) => index + 1 }; this.rowHeight = getOrDefault(options, o => o.rowHeight, DefaultOptions.rowHeight); this.headerHeight = getOrDefault(options, o => o.headerHeight, DefaultOptions.headerHeight); let left = 0; this.columns = this.columns.map(c => { c.width = getOrDefault(c, o => o.width, DefaultOptions.columnWidth); c.minWidth = getOrDefault(c, o => o.minWidth, DefaultOptions.minWidth); c.resizeable = getOrDefault(c, c => c.resizeable, DefaultOptions.resizeable); c.left = left; left += c.width; return c; }); this.rowsContainer.setAttribute('role', 'rowgroup'); this.scrollableElement = new ScrollableElement(this.rowsContainer, { horizontal: ScrollbarVisibility.Auto, vertical: ScrollbarVisibility.Auto, useShadows: true }); this.renderHeader(this.domNode); this.domNode.appendChild(this.scrollableElement.getDomNode()); container.appendChild(this.domNode); this.disposables = [/*this.gesture,*/ this.scrollableElement, this.cache]; this.scrollableElement.onScroll(this.onScroll, this, this.disposables); // Prevent the monaco-scrollable-element from scrolling // https://github.com/Microsoft/vscode/issues/44181 domEvent(this.scrollableElement.getDomNode(), 'scroll') (e => (e.target as HTMLElement).scrollTop = 0, null, this.disposables); this.updateScrollWidth(); this.layout(); } private renderHeader(container: HTMLElement): void { this.headerContainer = DOM.append(container, DOM.$('.monaco-perftable-header')); const sashContainer = DOM.append(this.headerContainer, DOM.$('.sash-container')); this.headerContainer.style.height = this.headerHeight + 'px'; this.headerContainer.style.lineHeight = this.headerHeight + 'px'; this.headerContainer.setAttribute('role', 'rowgroup'); const headerCellContainer = DOM.append(this.headerContainer, DOM.$('.monaco-perftable-header-cell-container')); headerCellContainer.setAttribute('role', 'row'); for (const column of this.columns) { this.createHeaderSash(sashContainer, column); const domNode = DOM.append(headerCellContainer, DOM.$('.monaco-perftable-header-cell')); domNode.setAttribute('role', 'columnheader'); domNode.style.width = column.width + 'px'; domNode.style.left = column.left + 'px'; domNode.innerText = column.name; column.domNode = domNode; } } private createHeaderSash(sashContainer: HTMLElement, column: ITableColumn): void { const layoutProvider = { getVerticalSashLeft: (sash: Sash) => { let left = 0; for (const c of this.columns) { left += c.width!; if (column === c) { break; } } return left; } }; const sash = new Sash(sashContainer, layoutProvider, { orientation: Orientation.VERTICAL, orthogonalStartSash: this.orthogonalStartSash, orthogonalEndSash: this.orthogonalEndSash }); const sashEventMapper = (e: IBaseSashEvent) => ({ sash, column, start: e.startX, current: e.currentX }); const onStart = Event.map(sash.onDidStart, sashEventMapper); const onStartDisposable = onStart(this.onSashStart, this); const onChange = Event.map(sash.onDidChange, sashEventMapper); const onChangeDisposable = onChange(this.onSashChange, this); // const onEnd = Event.map(sash.onDidEnd, () => firstIndex(this.columnSashs, item => item.sash === sash)); // const onEndDisposable = onEnd(this.onSashEnd, this); // const onDidReset = Event.map(sash.onDidEnd, () => firstIndex(this.columnSashs, item => item.sash === sash)); // const onDidResetDisposable = onDidReset(this.onDidSashReset, this); const disposable = combinedDisposable(onStartDisposable, onChangeDisposable, /*onEndDisposable, onDidResetDisposable, */sash); const sashItem: ISashItem = { sash, disposable }; this.columnSashs.push(sashItem); if (!column.resizeable) { sash.hide(); } } private onSashStart({ sash, start }: ISashEvent): void { const index = this.columnSashs.findIndex(item => item.sash === sash); const sizes = this.columns.map(i => i.width!); const lefts = this.columns.map(i => i.left!); this.sashDragState = { start, current: start, index, sizes, lefts }; } private onSashChange({ column, current }: ISashEvent): void { const { index, start, sizes, lefts } = this.sashDragState!; this.sashDragState!.current = current; const delta = current - start; const adjustedDelta = sizes[index] + delta < column.minWidth! ? column.minWidth! - sizes[index] : delta; column.width = sizes[index] + adjustedDelta; column.domNode!.style.width = column.width + 'px'; for (let i = index + 1; i < this.columns.length; i++) { const resizeColumn = this.columns[i]; resizeColumn.left = lefts[i] + adjustedDelta; resizeColumn.domNode!.style.left = resizeColumn.left + 'px'; } for (const [index, row] of this.visibleRows.entries()) { if (row) { this.updateRowInDOM(row, index); } } this.updateScrollWidth(); this.layout(); } private eventuallyUpdateScrollDimensions(): void { this._scrollHeight = this.contentHeight; this.rowsContainer.style.height = `${this._scrollHeight}px`; if (!this.scrollableElementUpdateDisposable) { this.scrollableElementUpdateDisposable = DOM.scheduleAtNextAnimationFrame(() => { this.scrollableElement.setScrollDimensions({ scrollHeight: this.scrollHeight }); this.updateScrollWidth(); this.scrollableElementUpdateDisposable = null; }); } } private updateScrollWidth(): void { this._scrollWidth = this.columns.reduce((p, c) => p += c.width!, 0); this.rowsContainer.style.width = `${this.scrollWidth}px`; this.headerContainer.style.width = `${this.scrollWidth}px`; this.scrollableElement.setScrollDimensions({ scrollWidth: this.scrollWidth }); } private onScroll(e: ScrollEvent): void { if (this.scheduledRender) { this.scheduledRender.dispose(); } this.scheduledRender = DOM.runAtThisOrScheduleAtNextAnimationFrame(() => { this.render(e.scrollTop, e.height, e.scrollLeft, e.scrollWidth); }); } private getRenderRange(renderTop: number, renderHeight: number): IRange { const start = Math.floor(renderTop / this.rowHeight); const end = Math.ceil((renderTop + renderHeight) / this.rowHeight); return { start, end }; } private getNextToLastElement(ranges: IRange[]): HTMLElement | null { const lastRange = ranges[ranges.length - 1]; if (!lastRange) { return null; } const nextToLastItem = this.visibleRows[lastRange.end]; if (!nextToLastItem) { return null; } if (!nextToLastItem.row) { return null; } return nextToLastItem.row; } private render(renderTop: number, renderHeight: number, renderLeft: number, scrollWidth: number): void { const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); const renderRange = this.getRenderRange(renderTop, renderHeight); renderRange.end = renderRange.end > this.length ? this.length : renderRange.end; // IE (all versions) cannot handle units above about 1,533,908 px, so every 500k pixels bring numbers down const STEP_SIZE = 500000; this.bigNumberDelta = 0; if (renderTop >= STEP_SIZE) { // Compute a delta that guarantees that lines are positioned at `lineHeight` increments this.bigNumberDelta = Math.floor(renderTop / STEP_SIZE) * STEP_SIZE; this.bigNumberDelta = Math.floor(this.bigNumberDelta / this.rowHeight) * this.rowHeight; const rangesToUpdate = Range.intersect(previousRenderRange, renderRange); for (let i = rangesToUpdate.start; i < rangesToUpdate.end; i++) { this.updateRowInDOM(this.visibleRows[i], i); } } const rangesToInsert = Range.relativeComplement(renderRange, previousRenderRange); const rangesToRemove = Range.relativeComplement(previousRenderRange, renderRange); const beforeElement = this.getNextToLastElement(rangesToInsert); for (const range of rangesToInsert) { for (let i = range.start; i < range.end; i++) { this.insertRowInDOM(i, beforeElement); } } for (const range of rangesToRemove) { for (let i = range.start; i < range.end; i++) { this.removeRowFromDOM(i); } } const canUseTranslate3d = !isWindows && !browser.isFirefox && browser.getZoomLevel() === 0; if (canUseTranslate3d) { const transform = `translate3d(-${renderLeft}px, -${renderTop - this.bigNumberDelta}px, 0px)`; this.rowsContainer.style.transform = transform; this.rowsContainer.style.webkitTransform = transform; this.headerContainer.style.transform = `translate3d(-${renderLeft}px, 0px, 0px)`; this.headerContainer.style.webkitTransform = `translate3d(-${renderLeft}px, 0px, 0px)`; if (canUseTranslate3d !== this.canUseTranslate3d) { this.rowsContainer.style.left = '0'; this.headerContainer.style.left = '0'; this.rowsContainer.style.top = '0'; } } else { this.rowsContainer.style.left = `-${renderLeft}px`; this.headerContainer.style.left = `-${renderLeft}px`; this.rowsContainer.style.top = `-${renderTop - this.bigNumberDelta}px`; if (canUseTranslate3d !== this.canUseTranslate3d) { this.rowsContainer.style.transform = ''; this.rowsContainer.style.webkitTransform = ''; } } this.canUseTranslate3d = canUseTranslate3d; this.lastRenderTop = renderTop; this.lastRenderHeight = renderHeight; } public layout(height?: number, width?: number): void { const scrollDimensions: INewScrollDimensions = { height: typeof height === 'number' ? height : DOM.getContentHeight(this.domNode) }; scrollDimensions.height = scrollDimensions.height! - this.headerHeight; if (this.scrollableElementUpdateDisposable) { this.scrollableElementUpdateDisposable.dispose(); this.scrollableElementUpdateDisposable = null; scrollDimensions.scrollHeight = this.scrollHeight; } this.scrollableElement.setScrollDimensions(scrollDimensions); this.scrollableElement.setScrollDimensions({ width: typeof width === 'number' ? width : DOM.getContentWidth(this.domNode) }); this.columnSashs.forEach(s => s.sash.layout()); } getScrollTop(): number { const scrollPosition = this.scrollableElement.getScrollPosition(); return scrollPosition.scrollTop; } getScrollLeft(): number { const scrollPosition = this.scrollableElement.getScrollPosition(); return scrollPosition.scrollLeft; } setScrollTop(scrollTop: number): void { if (this.scrollableElementUpdateDisposable) { this.scrollableElementUpdateDisposable.dispose(); this.scrollableElementUpdateDisposable = null; this.scrollableElement.setScrollDimensions({ scrollHeight: this.scrollHeight }); } this.scrollableElement.setScrollPosition({ scrollTop }); } get scrollTop(): number { return this.getScrollTop(); } set scrollTop(scrollTop: number) { this.setScrollTop(scrollTop); } get scrollHeight(): number { return this._scrollHeight + 10; } get scrollWidth(): number { return this._scrollWidth + 10; } get scrollLeft(): number { return this.getScrollLeft(); } domElement(index: number, column: number): HTMLElement | null { const row = this.visibleRows[index]; const cell = row && row.cells && row.cells[column]; return cell && cell.domNode; } element(index: number): T | undefined { return this.visibleRows[index].element; } column(index: number): ITableColumn | undefined { return this.columns[index]; } indexOfColumn(columnId: string): number | undefined { return this.columns.findIndex(v => v.id === columnId); } get renderHeight(): number { const scrollDimensions = this.scrollableElement.getScrollDimensions(); return scrollDimensions.height; } private insertRowInDOM(index: number, beforeElement: HTMLElement | null): void { let row = this.visibleRows[index]; if (!row) { row = { id: String(index), element: undefined, row: null, size: this.rowHeight, cells: null, datapromise: null }; row.datapromise = createCancelablePromise(token => { return this.dataSource.getRow(index).then(d => { row.element = d; }); }); row.datapromise.finally(() => row.datapromise = null); this.visibleRows[index] = row; } if (!row.row) { this.allocRow(row, index); row.row!.setAttribute('role', 'treeitem'); } if (!row.row!.parentElement) { if (beforeElement) { this.rowsContainer.insertBefore(row.row!, beforeElement); } else { this.rowsContainer.appendChild(row.row!); } } this.updateRowInDOM(row, index); if (row.datapromise) { row.datapromise.then(() => this.renderRow(row, index)); // in this case we can special case the row count column for (const [i, column] of this.staticColumns.entries()) { const cell = row.cells![i]; column.renderer.renderCell(undefined, index, i, column.id, cell.templateData, column.width); } } else { this.renderRow(row, index); } } private allocRow(row: IAsyncRowItem, index: number): void { row.cells = new Array(); row.row = DOM.$('.monaco-perftable-row'); for (const [index, column] of this.columns.entries()) { const cell = this.cache.alloc(column.id); row.cells[index] = cell; if (column.cellClass) { cell.domNode!.classList.add(column.cellClass); } row.row.appendChild(cell.domNode!); } } private renderRow(row: IAsyncRowItem, index: number): void { for (const [i, column] of this.columns.entries()) { const cell = row.cells![i]; column.renderer.renderCell(row.element!, index, i, column.id, cell.templateData, column.width); } } private updateRowInDOM(row: IAsyncRowItem, index: number): void { row.row!.style.top = `${this.elementTop(index)}px`; row.row!.style.height = `${row.size}px`; for (const [columnIndex, column] of this.columns.entries()) { const cell = row.cells![columnIndex].domNode; cell!.style.width = `${column.width}px`; cell!.style.left = `${column.left}px`; cell!.style.height = `${row.size}px`; cell!.style.lineHeight = `${row.size}px`; cell!.setAttribute('data-column-id', `${columnIndex}`); cell!.setAttribute('role', 'gridcell'); row.row!.setAttribute('id', this.getElementDomId(index, columnIndex)); } row.row!.setAttribute('data-index', `${index}`); row.row!.setAttribute('data-last-element', index === this.length - 1 ? 'true' : 'false'); row.row!.setAttribute('role', 'row'); // row.row!.setAttribute('aria-setsize', String(this.ariaSetProvider.getSetSize(row.element, index, this.length))); // row.row!.setAttribute('aria-posinset', String(this.ariaSetProvider.getPosInSet(row.element, index))); row.row!.setAttribute('id', this.getElementDomId(index)); } private removeRowFromDOM(index: number): void { const item = this.visibleRows[index]; if (!item) { return; } let canceled = false; if (item.datapromise) { item.datapromise.cancel(); canceled = true; } for (const [i, column] of this.columns.entries()) { const cell = item.cells![i]; if (!canceled) { const renderer = column.renderer; if (renderer && renderer.disposeCell) { renderer.disposeCell(item.element!, index, i, column.id, cell.templateData, column.width); } } this.cache.release(cell!); } removeFromParent(item.row!); delete this.visibleRows[index]; } @memoize get onMouseClick(): Event> { return Event.map(domEvent(this.domNode, 'click'), e => this.toMouseEvent(e)); } @memoize get onMouseDblClick(): Event> { return Event.map(domEvent(this.domNode, 'dblclick'), e => this.toMouseEvent(e)); } @memoize get onMouseMiddleClick(): Event> { return Event.filter(Event.map(domEvent(this.domNode, 'auxclick'), e => this.toMouseEvent(e as MouseEvent)), e => e.browserEvent.button === 1); } @memoize get onMouseUp(): Event> { return Event.map(domEvent(this.domNode, 'mouseup'), e => this.toMouseEvent(e)); } @memoize get onMouseDown(): Event> { return Event.map(domEvent(this.domNode, 'mousedown'), e => this.toMouseEvent(e)); } @memoize get onMouseOver(): Event> { return Event.map(domEvent(this.domNode, 'mouseover'), e => this.toMouseEvent(e)); } @memoize get onMouseMove(): Event> { return Event.map(domEvent(this.domNode, 'mousemove'), e => this.toMouseEvent(e)); } @memoize get onMouseOut(): Event> { return Event.map(domEvent(this.domNode, 'mouseout'), e => this.toMouseEvent(e)); } @memoize get onContextMenu(): Event> { return Event.map(domEvent(this.domNode, 'contextmenu'), e => this.toMouseEvent(e)); } public toMouseEvent(browserEvent: MouseEvent): ITableMouseEvent { const index = this.getItemIndexFromEventTarget(browserEvent.target || null); const item = typeof index === 'undefined' ? undefined : this.visibleRows[index.row]; const element = item && item.element; const buttons = browserEvent.buttons; return { browserEvent, buttons, index, element }; } public getItemIndexFromEventTarget(target: EventTarget | null): GridPosition | undefined { let element: HTMLElement | null = target as (HTMLElement | null); while (element instanceof HTMLElement && element !== this.rowsContainer) { const rawColumn = element.getAttribute('data-column-id'); if (rawColumn) { const column = Number(rawColumn); if (!isNaN(column)) { while (element instanceof HTMLElement && element !== this.rowsContainer) { const rawIndex = element.getAttribute('data-index'); if (rawIndex) { const row = Number(rawIndex); if (!isNaN(row)) { return new GridPosition(row, column); } } element = element.parentElement; } } } element = element!.parentElement; } return undefined; } elementTop(index: number): number { return Math.floor(index * this.rowHeight) - this.bigNumberDelta; } getElementDomId(index: number, column?: number): string { if (column) { return `${this.domId}_${index}_${column}`; } else { return `${this.domId}_${index}`; } } get length(): number { return this._length; } set length(length: number) { this.domNode.setAttribute('aria-rowcount', `${length}`); const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); const potentialRerenderRange = { start: this.length, end: length }; const rerenderRange = Range.intersect(potentialRerenderRange, previousRenderRange); this._length = length; for (let i = rerenderRange.start; i < rerenderRange.end; i++) { if (this.visibleRows[i]) { this.removeRowFromDOM(i); } this.insertRowInDOM(i, null); } this.eventuallyUpdateScrollDimensions(); } get columnLength(): number { return this.columns.length; } dispose(): void { dispose(this.disposables); } }