diff --git a/packages/table/preview/index.html b/packages/table/preview/index.html index e8ccfab06b..795dd13f48 100644 --- a/packages/table/preview/index.html +++ b/packages/table/preview/index.html @@ -126,6 +126,10 @@ margin-left: 15px; } + .sidebar > .pt-button + .pt-button { + margin-top: 3px; + } + label.tbl-select-label { margin-top: -3px; margin-bottom: 7px; diff --git a/packages/table/preview/mutableTable.tsx b/packages/table/preview/mutableTable.tsx index b43caaae6f..79167420b5 100644 --- a/packages/table/preview/mutableTable.tsx +++ b/packages/table/preview/mutableTable.tsx @@ -647,6 +647,9 @@ export class MutableTable extends React.Component<{}, IMutableTableState> { {this.renderButton("Resize rows by tallest cell", { onClick: this.handleResizeRowsByTallestCellButtonClick, })} + {this.renderButton("Resize rows by approx height", { + onClick: this.handleResizeRowsByApproxHeightButtonClick, + })}

Cells

Display
@@ -989,6 +992,15 @@ export class MutableTable extends React.Component<{}, IMutableTableState> { this.tableInstance.resizeRowsByTallestCell(); }; + private handleResizeRowsByApproxHeightButtonClick = () => { + this.tableInstance.resizeRowsByApproximateHeight(this.getCellText); + }; + + private getCellText = (rowIndex: number, columnIndex: number) => { + const content = this.store.get(rowIndex, columnIndex); + return this.state.cellContent === CellContent.LARGE_JSON ? JSON.stringify(content) : content; + }; + // State updates // ============= diff --git a/packages/table/src/locator.ts b/packages/table/src/locator.ts index 915045e87e..63020df07c 100644 --- a/packages/table/src/locator.ts +++ b/packages/table/src/locator.ts @@ -47,7 +47,7 @@ export interface ILocator { } export class Locator implements ILocator { - private static CELL_HORIZONTAL_PADDING = 10; + public static CELL_HORIZONTAL_PADDING = 10; private grid: Grid; diff --git a/packages/table/src/table.tsx b/packages/table/src/table.tsx index e9bd02167f..473d3567a5 100644 --- a/packages/table/src/table.tsx +++ b/packages/table/src/table.tsx @@ -16,7 +16,7 @@ import * as Classes from "./common/classes"; import { Clipboard } from "./common/clipboard"; import { Direction } from "./common/direction"; import * as Errors from "./common/errors"; -import { Grid, IColumnIndices, IRowIndices } from "./common/grid"; +import { Grid, ICellMapper, IColumnIndices, IRowIndices } from "./common/grid"; import * as FocusedCellUtils from "./common/internal/focusedCellUtils"; import * as ScrollUtils from "./common/internal/scrollUtils"; import * as SelectionUtils from "./common/internal/selectionUtils"; @@ -46,6 +46,36 @@ import { } from "./regions"; import { TableBody } from "./tableBody"; +export interface IResizeRowsByApproximateHeightOptions { + /** + * Approximate width (in pixels) of an average character of text. + */ + getApproximateCharWidth?: number | ICellMapper; + + /** + * Approximate height (in pixels) of an average line of text. + */ + getApproximateLineHeight?: number | ICellMapper; + + /** + * Sum of horizontal paddings (in pixels) from the left __and__ right sides + * of the cell. + */ + getCellHorizontalPadding?: number | ICellMapper; + + /** + * Number of extra lines to add in case the calculation is imperfect. + */ + getNumBufferLines?: number | ICellMapper; +} + +interface IResizeRowsByApproximateHeightResolvedOptions { + getApproximateCharWidth?: number; + getApproximateLineHeight?: number; + getCellHorizontalPadding?: number; + getNumBufferLines?: number; +} + export interface ITableProps extends IProps, IRowHeights, IColumnWidths { /** * If `false`, only a single region of a single column/row/cell may be @@ -396,6 +426,18 @@ export class Table extends AbstractComponent { selectionModes: SelectionModes.ALL, }; + // these default values for `resizeRowsByApproximateHeight` have been + // fine-tuned to work well with default Table font styles. + private static resizeRowsByApproximateHeightDefaults: Record< + keyof IResizeRowsByApproximateHeightOptions, + number + > = { + getApproximateCharWidth: 8, + getApproximateLineHeight: 18, + getCellHorizontalPadding: 2 * Locator.CELL_HORIZONTAL_PADDING, + getNumBufferLines: 1, + }; + private static SHALLOW_COMPARE_PROP_KEYS_BLACKLIST = [ "selectedRegions", // (intentionally omitted; can be deeply compared to save on re-renders in controlled mode) ] as Array; @@ -490,6 +532,63 @@ export class Table extends AbstractComponent { // Instance methods // ================ + /** + * __Experimental!__ Resizes all rows in the table to the approximate + * maximum height of wrapped cell content in each row. Works best when each + * cell contains plain text of a consistent font style (though font style + * may vary between cells). Since this function uses approximate + * measurements, results may not be perfect. + * + * Approximation parameters can be configured for the entire table or on a + * per-cell basis. Default values are fine-tuned to work well with default + * Table font styles. + */ + public resizeRowsByApproximateHeight( + getCellText: ICellMapper, + options?: IResizeRowsByApproximateHeightOptions, + ) { + const { numRows } = this.props; + const { columnWidths } = this.state; + const numColumns = columnWidths.length; + + const rowHeights: number[] = []; + + for (let rowIndex = 0; rowIndex < numRows; rowIndex++) { + let maxCellHeightInRow = 0; + + // iterate through each cell in the row + for (let columnIndex = 0; columnIndex < numColumns; columnIndex++) { + // resolve all parameters to raw values + const { + getApproximateCharWidth: approxCharWidth, + getApproximateLineHeight: approxLineHeight, + getCellHorizontalPadding: horizontalPadding, + getNumBufferLines: numBufferLines, + } = this.resolveResizeRowsByApproximateHeightOptions(options, rowIndex, columnIndex); + + const cellText = getCellText(rowIndex, columnIndex); + const numCharsInCell = cellText == null ? 0 : cellText.length; + + const actualCellWidth = columnWidths[columnIndex]; + const availableCellWidth = actualCellWidth - horizontalPadding; + const approxCharsPerLine = availableCellWidth / approxCharWidth; + const approxNumLinesDesired = Math.ceil(numCharsInCell / approxCharsPerLine) + numBufferLines; + + const approxCellHeight = approxNumLinesDesired * approxLineHeight; + + if (approxCellHeight > maxCellHeightInRow) { + maxCellHeightInRow = approxCellHeight; + } + } + + rowHeights.push(maxCellHeightInRow); + } + + this.invalidateGrid(); + this.didUpdateColumnOrRowSizes = true; + this.setState({ rowHeights }); + } + /** * Resize all rows in the table to the height of the tallest visible cell in the specified columns. * If no indices are provided, default to using the tallest visible cell from all columns in view. @@ -2023,6 +2122,30 @@ export class Table extends AbstractComponent { private handleRowResizeGuide = (horizontalGuides: number[]) => { this.setState({ horizontalGuides }); }; + + /** + * Returns an object with option keys mapped to their resolved values + * (falling back to default values as necessary). + */ + private resolveResizeRowsByApproximateHeightOptions( + options: IResizeRowsByApproximateHeightOptions | null | undefined, + rowIndex: number, + columnIndex: number, + ) { + const optionKeys = Object.keys(Table.resizeRowsByApproximateHeightDefaults); + const optionReducer = ( + agg: IResizeRowsByApproximateHeightResolvedOptions, + key: keyof IResizeRowsByApproximateHeightOptions, + ) => { + agg[key] = + options != null && options[key] != null + ? CoreUtils.safeInvokeOrValue(options[key], rowIndex, columnIndex) + : Table.resizeRowsByApproximateHeightDefaults[key]; + return agg; + }; + const resolvedOptions: IResizeRowsByApproximateHeightResolvedOptions = optionKeys.reduce(optionReducer, {}); + return resolvedOptions; + } } function clampNumFrozenColumns(props: ITableProps) { diff --git a/packages/table/test/formatsTests.tsx b/packages/table/test/formatsTests.tsx index 5dc7fef7ba..4854157783 100644 --- a/packages/table/test/formatsTests.tsx +++ b/packages/table/test/formatsTests.tsx @@ -11,6 +11,7 @@ import { JSONFormat } from "../src/cell/formats/jsonFormat"; import { TruncatedFormat, TruncatedPopoverMode } from "../src/cell/formats/truncatedFormat"; import * as Classes from "../src/common/classes"; import { ReactHarness } from "./harness"; +import { createStringOfLength } from "./mocks/table"; describe("Formats", () => { const harness = new ReactHarness(); @@ -183,7 +184,3 @@ describe("Formats", () => { }); }); }); - -function createStringOfLength(length: number) { - return new Array(length).fill("a").join(""); -} diff --git a/packages/table/test/mocks/table.tsx b/packages/table/test/mocks/table.tsx index 5a06e7096e..0f90347a79 100644 --- a/packages/table/test/mocks/table.tsx +++ b/packages/table/test/mocks/table.tsx @@ -9,6 +9,10 @@ import * as React from "react"; import { Cell, Column, IColumnProps, ITableProps, RenderMode, Table, Utils } from "../../src"; +export function createStringOfLength(length: number) { + return new Array(length).fill("a").join(""); +} + export function createTableOfSize(numColumns: number, numRows: number, columnProps?: any, tableProps?: any) { const columns = Utils.times(numColumns, Utils.toBase26Alpha); const data = Utils.times(numRows, (row: number) => { diff --git a/packages/table/test/tableTests.tsx b/packages/table/test/tableTests.tsx index fd2c33bb10..5aaa636c17 100644 --- a/packages/table/test/tableTests.tsx +++ b/packages/table/test/tableTests.tsx @@ -26,7 +26,7 @@ import { IRegion, Regions } from "../src/regions"; import { ITableState } from "../src/table"; import { CellType, expectCellLoading } from "./cellTestUtils"; import { ElementHarness, ReactHarness } from "./harness"; -import { createTableOfSize } from "./mocks/table"; +import { createStringOfLength, createTableOfSize } from "./mocks/table"; describe("", () => { const COLUMN_HEADER_SELECTOR = `.${Classes.TABLE_QUADRANT_MAIN} .${Classes.TABLE_COLUMN_HEADERS} .${Classes.TABLE_HEADER}`; @@ -146,6 +146,53 @@ describe("
", () => { }); describe("Instance methods", () => { + describe("resizeRowsByApproximateHeight", () => { + const STR_LENGTH_SHORT = 10; + const STR_LENGTH_LONG = 100; + const NUM_ROWS = 4; + + const cellTextShort = createStringOfLength(STR_LENGTH_SHORT); + const cellTextLong = createStringOfLength(STR_LENGTH_LONG); + + const getCellText = (rowIndex: number) => { + return rowIndex === 0 ? cellTextShort : cellTextLong; + }; + const renderCell = (rowIndex: number) => { + return {getCellText(rowIndex)}; + }; + + let table: Table; + const saveTable = (t: Table) => (table = t); + + beforeEach(() => { + harness.mount( +
+ + +
, + ); + }); + + afterEach(() => { + table = undefined; + }); + + it("resizes each row to fit its respective tallest cell", () => { + table.resizeRowsByApproximateHeight(getCellText); + expect(table.state.rowHeights).to.deep.equal([36, 144, 144, 144]); + }); + + it("still uses defaults if an empty `options` object is passed", () => { + table.resizeRowsByApproximateHeight(getCellText, {}); + expect(table.state.rowHeights).to.deep.equal([36, 144, 144, 144]); + }); + + it("can customize options", () => { + table.resizeRowsByApproximateHeight(getCellText, { getNumBufferLines: 2 }); + expect(table.state.rowHeights).to.deep.equal([54, 162, 162, 162]); + }); + }); + describe("resizeRowsByTallestCell", () => { it("Gets and sets the tallest cell by columns correctly", () => { const DEFAULT_RESIZE_HEIGHT = 20;