@blueprintjs/table
Version:
Scalable interactive table component
313 lines (262 loc) • 12.2 kB
text/typescript
/*
* Copyright 2016 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as Classes from "./common/classes";
import { Grid } from "./common/grid";
import { Rect } from "./common/rect";
import { Utils } from "./common/utils";
export interface Locator {
/**
* Returns the width that a column must be to contain all the content of
* its cells without truncating or wrapping.
*/
getWidestVisibleCellInColumn: (columnIndex: number) => number;
/**
* Gets the viewport rect.
*/
getViewportRect(): Rect;
/**
* Returns the height of the tallest cell in a given column -- specifically,
* tallest as in how tall the cell would have to be to display all the content in it
*/
getTallestVisibleCellInColumn: (columnIndex: number) => number;
/**
* Locates a column's index given the client X coordinate. Returns -1 if
* the coordinate is not over a column.
* If `useMidpoint` is `true`, returns the index of the column whose left
* edge is closest, splitting on the midpoint of each column.
*/
convertPointToColumn: (clientX: number, useMidpoint?: boolean) => number;
/**
* Locates a row's index given the client Y coordinate. Returns -1 if
* the coordinate is not over a row.
* If `useMidpoint` is `true`, returns the index of the row whose top
* edge is closest, splitting on the midpoint of each row.
*/
convertPointToRow: (clientY: number, useMidpoint?: boolean) => number;
/**
* Locates a cell's row and column index given the client X and Y
* coordinates.
*/
convertPointToCell: (clientX: number, clientY: number) => { col: number; row: number };
/**
* Updates the grid.
*/
setGrid(grid: Grid): this;
setNumFrozenRows(numFrozenRows: number): this;
setNumFrozenColumns(numFrozenColumns: number): this;
/**
* @returns whether the rendered rows overflow the visible viewport vertically, helpful for scrolling calculations
*/
hasVerticalOverflowOrExactFit(columnHeaderHeight: number, viewportRect: Rect): boolean;
/**
* @returns whether the rendered columns overflow the visible viewport horizontally, helpful for scrolling calculations
*/
hasHorizontalOverflowOrExactFit(rowHeaderWidth: number, viewportRect: Rect): boolean;
}
export class LocatorImpl implements Locator {
public static CELL_HORIZONTAL_PADDING = 10;
private grid: Grid | undefined;
// these values affect how we map a mouse coordinate to a cell coordinate.
// for instance, a click at (0px,0px) in the grid could map to an arbitrary
// cell if table is scrolled, but it will always map to cell (0,0) if there
// are active frozen rows and columns.
private numFrozenRows: number;
private numFrozenColumns: number;
public constructor(
/* The root table element within which a click is deemed valid and relevant. */
private tableElement: HTMLElement,
/* The scrollable element that wraps the cell container. */
private scrollContainerElement: HTMLElement,
/* The element containing all body cells in the grid (excluding headers). */
private cellContainerElement: HTMLElement,
) {
this.numFrozenRows = 0;
this.numFrozenColumns = 0;
}
// Setters
// =======
public setGrid(grid: Grid) {
this.grid = grid;
return this;
}
public setNumFrozenRows(numFrozenRows: number) {
this.numFrozenRows = numFrozenRows;
return this;
}
public setNumFrozenColumns(numFrozenColumns: number) {
this.numFrozenColumns = numFrozenColumns;
return this;
}
// Getters
// =======
public getViewportRect() {
return new Rect(
this.scrollContainerElement.scrollLeft || 0,
this.scrollContainerElement.scrollTop || 0,
this.scrollContainerElement.clientWidth,
this.scrollContainerElement.clientHeight,
);
}
public getWidestVisibleCellInColumn(columnIndex: number): number {
const columnCellSelector = this.getColumnCellSelector(columnIndex);
const columnHeaderAndBodyCells = this.tableElement.querySelectorAll(columnCellSelector);
let maxWidth = 0;
for (let i = 0; i < columnHeaderAndBodyCells.length; i++) {
const contentWidth = Utils.measureElementTextContent(columnHeaderAndBodyCells.item(i)).width;
const cellWidth = Math.ceil(contentWidth) + LocatorImpl.CELL_HORIZONTAL_PADDING * 2;
if (cellWidth > maxWidth) {
maxWidth = cellWidth;
}
}
return maxWidth;
}
public getTallestVisibleCellInColumn(columnIndex: number): number {
// consider only body cells, hence the extra Classes.TABLE_CELL specificity
const columnCellSelector = this.getColumnCellSelector(columnIndex);
const columnBodyCells = this.tableElement.querySelectorAll(`${columnCellSelector}.${Classes.TABLE_CELL}`);
let maxHeight = 0;
for (let i = 0; i < columnBodyCells.length; i++) {
const cell = columnBodyCells.item(i);
const cellValue = cell.querySelector(`.${Classes.TABLE_TRUNCATED_VALUE}`);
const cellTruncatedFormatText = cell.querySelector(`.${Classes.TABLE_TRUNCATED_FORMAT_TEXT}`);
const cellTruncatedText = cell.querySelector(`.${Classes.TABLE_TRUNCATED_TEXT}`);
let height = 0;
if (cellValue != null) {
height = cellValue.scrollHeight;
} else if (cellTruncatedFormatText != null) {
height = cellTruncatedFormatText.scrollHeight;
} else if (cellTruncatedText != null) {
height = cellTruncatedText.scrollHeight;
} else {
// it's not anything we recognize, just use the current height of the cell
height = cell.scrollHeight;
}
if (height > maxHeight) {
maxHeight = height;
}
}
return maxHeight;
}
/**
* Pass in an already-computed viewport rect here, if available, to reduce DOM reads.
*
* @returns whether the rendered rows overflow or exactly fit the visible viewport vertically, helpful for scrolling calculations
*/
public hasVerticalOverflowOrExactFit(
columnHeaderHeight = Grid.MIN_COLUMN_HEADER_HEIGHT,
viewportRect = this.getViewportRect(),
) {
if (this.grid === undefined) {
return false;
}
return this.grid.getHeight() >= viewportRect.height - columnHeaderHeight;
}
/**
* Pass in an already-computed viewport rect here, if available, to reduce DOM reads.
*
* @returns whether the rendered columns overflow or exactly fit the visible viewport horizontally, helpful for scrolling calculations
*/
public hasHorizontalOverflowOrExactFit(
rowHeaderWidth = Grid.MIN_ROW_HEADER_WIDTH,
viewportRect = this.getViewportRect(),
) {
if (this.grid === undefined) {
return false;
}
return this.grid.getWidth() >= viewportRect.width - rowHeaderWidth;
}
// Converters
// ==========
public convertPointToColumn(clientX: number, useMidpoint?: boolean): number {
const tableRect = this.getTableRect();
if (this.grid === undefined || !tableRect.containsX(clientX)) {
return -1;
}
const gridX = this.toGridX(clientX);
const limit = useMidpoint ? this.grid.numCols : this.grid.numCols - 1;
const lookupFn = useMidpoint ? this.convertCellMidpointToClientX : this.convertCellIndexToClientX;
return Utils.binarySearch(gridX, limit, lookupFn);
}
public convertPointToRow(clientY: number, useMidpoint?: boolean): number {
const tableRect = this.getTableRect();
if (this.grid === undefined || !tableRect.containsY(clientY)) {
return -1;
}
const gridY = this.toGridY(clientY);
const limit = useMidpoint ? this.grid.numRows : this.grid.numRows - 1;
const lookupFn = useMidpoint ? this.convertCellMidpointToClientY : this.convertCellIndexToClientY;
return Utils.binarySearch(gridY, limit, lookupFn);
}
public convertPointToCell(clientX: number, clientY: number) {
const gridX = this.toGridX(clientX);
const gridY = this.toGridY(clientY);
const col = Utils.binarySearch(gridX, this.grid!.numCols - 1, this.convertCellIndexToClientX);
const row = Utils.binarySearch(gridY, this.grid!.numRows - 1, this.convertCellIndexToClientY);
return { col, row };
}
// Private helpers
// ===============
private getColumnCellSelector(columnIndex: number) {
// measure frozen columns in the LEFT quadrant; otherwise, they might
// have been scrolled out of view, leading to wonky measurements (#1561)
const isFrozenColumnIndex = columnIndex < this.numFrozenColumns;
const quadrantClass = isFrozenColumnIndex ? Classes.TABLE_QUADRANT_LEFT : Classes.TABLE_QUADRANT_MAIN;
const cellClass = Classes.columnCellIndexClass(columnIndex);
return `.${quadrantClass} .${cellClass}`;
}
private getTableRect() {
return Rect.wrap(this.tableElement.getBoundingClientRect());
}
private convertCellIndexToClientX = (index: number) => {
return this.grid!.getCumulativeWidthAt(index);
};
private convertCellMidpointToClientX = (index: number) => {
const cellLeft = this.grid!.getCumulativeWidthBefore(index);
const cellRight = this.grid!.getCumulativeWidthAt(index);
return (cellLeft + cellRight) / 2;
};
private convertCellIndexToClientY = (index: number) => {
return this.grid!.getCumulativeHeightAt(index);
};
private convertCellMidpointToClientY = (index: number) => {
const cellTop = this.grid!.getCumulativeHeightBefore(index);
const cellBottom = this.grid!.getCumulativeHeightAt(index);
return (cellTop + cellBottom) / 2;
};
private toGridX = (clientX: number) => {
const gridOffsetFromPageLeft = this.cellContainerElement.getBoundingClientRect().left;
const scrollOffsetFromGridLeft = this.scrollContainerElement.scrollLeft;
const cursorOffsetFromGridLeft = clientX - (gridOffsetFromPageLeft + scrollOffsetFromGridLeft);
const isCursorWithinFrozenColumns =
this.numFrozenColumns != null &&
this.numFrozenColumns > 0 &&
cursorOffsetFromGridLeft <= this.grid!.getCumulativeWidthBefore(this.numFrozenColumns);
// the frozen-column region doesn't scroll, so ignore the scroll distance in that case
return isCursorWithinFrozenColumns
? cursorOffsetFromGridLeft
: cursorOffsetFromGridLeft + scrollOffsetFromGridLeft;
};
private toGridY = (clientY: number) => {
const gridOffsetFromPageTop = this.cellContainerElement.getBoundingClientRect().top;
const scrollOffsetFromGridTop = this.scrollContainerElement.scrollTop;
const cursorOffsetFromGridTop = clientY - (gridOffsetFromPageTop + scrollOffsetFromGridTop);
const isCursorWithinFrozenRows =
this.numFrozenRows != null &&
this.numFrozenRows > 0 &&
cursorOffsetFromGridTop <= this.grid!.getCumulativeHeightBefore(this.numFrozenRows);
return isCursorWithinFrozenRows ? cursorOffsetFromGridTop : cursorOffsetFromGridTop + scrollOffsetFromGridTop;
};
}