TableNG: Simplify row height calculation and make more extensible (#108624)

* TableNG: Simplify row height calculation and make more extensible

* add a cache for the results of rowHeight when it's a function

* JSDoc comment for util

* from the other branch, copy the related code and tests

* rework the line counters a bit, limit line counting to string fields

* add test for string case for buildRowLineCounters

* add the concept of estimates vs. counts

* add a comment

* ceil, not floor

* try to be as terse as possible

* test for estimates

* comment the type

* more comment in test

* swap

* fix #108804

* convert em letter spacing to px for avgCharWidth calculation

* tweak whee em-to-px math happens, and force count to occur on every row when wrap is on to avoid short row issues

* update test

* update to clamp single-line estimation using a hardcoded value (0.85)

* add assertion for not calling counter in that case

* uwrap 0.1.2

* fix betterer issues

* fix typography ctx extra import

---------

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Paul Marbach
2025-07-28 17:03:55 -04:00
committed by GitHub
parent 8b940f210f
commit 4b9e03e7c0
10 changed files with 853 additions and 339 deletions

View File

@ -131,7 +131,7 @@
"tslib": "2.8.1",
"uplot": "1.6.32",
"uuid": "11.1.0",
"uwrap": "0.1.1"
"uwrap": "0.1.2"
},
"devDependencies": {
"@babel/core": "7.28.0",

View File

@ -8,11 +8,8 @@ import { TableCellDisplayMode } from '../../types';
import { MaybeWrapWithLink } from '../MaybeWrapWithLink';
import { ImageCellProps } from '../types';
const DATALINKS_HEIGHT_OFFSET = 10;
export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => {
const calculatedHeight = height - DATALINKS_HEIGHT_OFFSET;
const styles = useStyles2(getStyles, calculatedHeight, justifyContent);
const styles = useStyles2(getStyles, height, justifyContent);
const { text } = field.display!(value);
const { alt, title } =
@ -27,7 +24,7 @@ export const ImageCell = ({ cellOptions, field, height, justifyContent, value, r
);
};
const getStyles = (theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({
const getStyles = (_theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({
image: css({
height,
width: 'auto',

View File

@ -26,7 +26,7 @@ import {
ReducerID,
} from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { FieldColorModeId, TableCellHeight } from '@grafana/schema';
import { FieldColorModeId } from '@grafana/schema';
import { useStyles2, useTheme2 } from '../../../themes/ThemeContext';
import { ContextMenu } from '../../ContextMenu/ContextMenu';
@ -52,29 +52,30 @@ import {
useRowHeight,
useScrollbarWidth,
useSortedRows,
useTypographyCtx,
} from './hooks';
import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types';
import {
applySort,
computeColWidths,
createTypographyContext,
displayJsonValue,
extractPixelValue,
frameToRecords,
getAlignment,
getApplyToRowBgFn,
getCellColors,
getCellLinks,
getCellOptions,
getDefaultRowHeight,
getDisplayName,
getIsNestedTable,
getVisibleFields,
shouldTextOverflow,
getApplyToRowBgFn,
computeColWidths,
applySort,
getCellColors,
getCellOptions,
shouldTextWrap,
isCellInspectEnabled,
getCellLinks,
withDataLinksActionsTooltip,
displayJsonValue,
getAlignment,
getJustifyContent,
getVisibleFields,
isCellInspectEnabled,
shouldTextOverflow,
shouldTextWrap,
TextAlign,
withDataLinksActionsTooltip,
} from './utils';
type CellRootRenderer = (key: React.Key, props: CellRendererProps<TableRow, TableSummaryRow>) => React.ReactNode;
@ -160,7 +161,6 @@ export function TableNG(props: TableNGProps) {
} = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy });
const defaultRowHeight = getDefaultRowHeight(theme, cellHeight);
const defaultHeaderHeight = getDefaultRowHeight(theme, TableCellHeight.Sm);
const [isInspecting, setIsInspecting] = useState(false);
const [expandedRows, setExpandedRows] = useState(() => new Set<number>());
@ -172,13 +172,20 @@ export function TableNG(props: TableNGProps) {
() => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width) - scrollbarWidth,
[width, hasNestedFrames, scrollbarWidth]
);
const typographyCtx = useTypographyCtx();
const typographyCtx = useMemo(
() =>
createTypographyContext(
theme.typography.fontSize,
theme.typography.fontFamily,
extractPixelValue(theme.typography.body.letterSpacing!) * theme.typography.fontSize
),
[theme]
);
const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]);
const headerHeight = useHeaderHeight({
columnWidths: widths,
fields: visibleFields,
enabled: hasHeader,
defaultHeight: defaultHeaderHeight,
sortColumns,
showTypeIcons: showTypeIcons ?? false,
typographyCtx,
@ -285,7 +292,6 @@ export function TableNG(props: TableNGProps) {
};
let lastRowIdx = -1;
let _rowHeight = 0;
// shared when whole row will be styled by a single cell's color
let rowCellStyle: Partial<CSSProperties> = {
color: undefined,
@ -381,7 +387,6 @@ export function TableNG(props: TableNGProps) {
// meh, this should be cached by the renderRow() call?
if (rowIdx !== lastRowIdx) {
_rowHeight = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
lastRowIdx = rowIdx;
rowCellStyle.color = undefined;
@ -420,6 +425,9 @@ export function TableNG(props: TableNGProps) {
const renderCellContent = (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
const rowIdx = props.row.__index;
const value = props.row[props.column.key];
// TODO: it would be nice to get rid of passing height down as a prop. but this value
// is cached so the cost of calling for every cell is low.
const height = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
const frame = data;
return (
@ -428,7 +436,7 @@ export function TableNG(props: TableNGProps) {
cellOptions,
frame,
field,
height: _rowHeight,
height,
justifyContent,
rowIdx,
theme,
@ -580,7 +588,7 @@ export function TableNG(props: TableNGProps) {
{...commonDataGridProps}
className={clsx(styles.grid, styles.gridNested)}
headerRowClass={clsx(styles.headerRow, { [styles.displayNone]: !hasNestedHeaders })}
headerRowHeight={hasNestedHeaders ? defaultHeaderHeight : 0}
headerRowHeight={hasNestedHeaders ? TABLE.HEADER_HEIGHT : 0}
columns={nestedColumns}
rows={expandedRecords}
renderers={{ renderRow, renderCell: renderCellRoot }}
@ -599,7 +607,6 @@ export function TableNG(props: TableNGProps) {
crossFilterOrder,
crossFilterRows,
data,
defaultHeaderHeight,
defaultRowHeight,
enableSharedCrosshair,
expandedRows,

View File

@ -13,7 +13,9 @@ export const TABLE = {
PAGINATION_LIMIT: 750,
SCROLL_BAR_WIDTH: 8,
SCROLL_BAR_MARGIN: 2,
FONT_SIZE: 14,
LINE_HEIGHT: 22,
HEADER_HEIGHT: 28,
NESTED_NO_DATA_HEIGHT: 60,
BORDER_RIGHT: 0.666667,
BORDER_RIGHT: 1,
};

View File

@ -1,23 +1,19 @@
import { act, renderHook } from '@testing-library/react';
import { varPreLine } from 'uwrap';
import { cacheFieldDisplayNames, createDataFrame, Field, FieldType } from '@grafana/data';
import { TableCellDisplayMode } from '@grafana/schema';
import { TABLE } from './constants';
import {
useFilteredRows,
usePaginatedRows,
useSortedRows,
useFooterCalcs,
useHeaderHeight,
useTypographyCtx,
useRowHeight,
} from './hooks';
jest.mock('uwrap', () => ({
// ...jest.requireActual('uwrap'),
varPreLine: jest.fn(() => ({
count: jest.fn(() => 1),
})),
}));
import { TableRow } from './types';
import { createTypographyContext } from './utils';
describe('TableNG hooks', () => {
function setupData() {
@ -28,21 +24,21 @@ describe('TableNG hooks', () => {
type: FieldType.string,
display: (v) => ({ text: v as string, numeric: NaN }),
config: {},
values: [],
values: ['Alice', 'Bob', 'Charlie'],
},
{
name: 'age',
type: FieldType.number,
display: (v) => ({ text: (v as number).toString(), numeric: v as number }),
config: {},
values: [],
values: [30, 25, 35],
},
{
name: 'active',
type: FieldType.boolean,
display: (v) => ({ text: (v as boolean).toString(), numeric: NaN }),
config: {},
values: [],
values: [true, false, true],
},
];
@ -149,7 +145,7 @@ describe('TableNG hooks', () => {
height: 300,
width: 800,
enabled: false,
headerHeight: 28,
headerHeight: TABLE.HEADER_HEIGHT,
footerHeight: 0,
})
);
@ -201,7 +197,7 @@ describe('TableNG hooks', () => {
height: 140,
width: 800,
rowHeight: 10,
headerHeight: 28,
headerHeight: TABLE.HEADER_HEIGHT,
footerHeight: 45,
})
);
@ -429,16 +425,16 @@ describe('TableNG hooks', () => {
});
describe('useHeaderHeight', () => {
const typographyCtx = createTypographyContext(14, 'sans-serif');
it('should return 0 when no header is present', () => {
const { fields } = setupData();
const { result } = renderHook(() => {
const typographyCtx = useTypographyCtx();
return useHeaderHeight({
fields,
columnWidths: [],
enabled: false,
typographyCtx,
defaultHeight: 28,
sortColumns: [],
});
});
@ -448,31 +444,20 @@ describe('TableNG hooks', () => {
it('should return the default height when wrap is disabled', () => {
const { fields } = setupData();
const { result } = renderHook(() => {
const typographyCtx = useTypographyCtx();
return useHeaderHeight({
fields,
columnWidths: [],
enabled: true,
typographyCtx,
defaultHeight: 28,
sortColumns: [],
});
});
expect(result.current).toBe(22);
expect(result.current).toBe(28);
});
it('should return the appropriate height for wrapped text', () => {
// Simulate 2 lines of text
jest.mocked(varPreLine).mockReturnValue({
count: jest.fn(() => 2),
each: jest.fn(),
split: jest.fn(),
test: jest.fn(),
});
const { fields } = setupData();
const { result } = renderHook(() => {
const typographyCtx = useTypographyCtx();
return useHeaderHeight({
fields: fields.map((field) => {
if (field.name === 'name') {
@ -492,8 +477,7 @@ describe('TableNG hooks', () => {
}),
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, avgCharWidth: 5 },
defaultHeight: 28,
typographyCtx: { ...typographyCtx, avgCharWidth: 5, wrappedCount: jest.fn(() => 2) },
sortColumns: [],
});
});
@ -504,19 +488,9 @@ describe('TableNG hooks', () => {
it('should calculate the available width for a header cell based on the icons rendered within it', () => {
const countFn = jest.fn(() => 1);
// Simulate 2 lines of text
jest.mocked(varPreLine).mockReturnValue({
count: countFn,
each: jest.fn(),
split: jest.fn(),
test: jest.fn(),
});
const { fields } = setupData();
renderHook(() => {
const typographyCtx = useTypographyCtx();
return useHeaderHeight({
fields: fields.map((field) => {
if (field.name === 'name') {
@ -536,17 +510,15 @@ describe('TableNG hooks', () => {
}),
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, avgCharWidth: 10 },
defaultHeight: 28,
typographyCtx: { ...typographyCtx, wrappedCount: countFn },
sortColumns: [],
showTypeIcons: false,
});
});
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 87);
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 86);
renderHook(() => {
const typographyCtx = useTypographyCtx();
return useHeaderHeight({
fields: fields.map((field) => {
if (field.name === 'name') {
@ -567,14 +539,233 @@ describe('TableNG hooks', () => {
}),
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, avgCharWidth: 10 },
defaultHeight: 28,
typographyCtx: { ...typographyCtx, wrappedCount: countFn },
sortColumns: [{ columnKey: 'Longer name that needs wrapping', direction: 'ASC' }],
showTypeIcons: true,
});
});
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 27);
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 26);
});
});
describe('useRowHeight', () => {
const typographyCtx = createTypographyContext(14, 'sans-serif');
it('returns the default height if there are no wrapped columns or nested frames', () => {
const { fields } = setupData();
const defaultHeight = 40;
expect(
renderHook(() => {
return useRowHeight({
fields,
columnWidths: [100, 100, 100],
defaultHeight,
typographyCtx: typographyCtx,
hasNestedFrames: false,
expandedRows: new Set(),
});
}).result.current
).toBe(defaultHeight);
});
describe('nested frames', () => {
it('returns 0 if the parent row is not expanded', () => {
const { fields } = setupData();
expect(
renderHook(() => {
const rowHeight = useRowHeight({
fields: [
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
],
columnWidths: [100, 100, 100],
defaultHeight: 40,
typographyCtx: typographyCtx,
hasNestedFrames: true,
expandedRows: new Set(),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight({ __depth: 1, data: createDataFrame({ fields }), __index: 0 });
}).result.current
).toBe(0);
});
it('returns a static height if there are no rows in the nested frame', () => {
const { fields } = setupData();
expect(
renderHook(() => {
const rowHeight = useRowHeight({
fields: [
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
],
columnWidths: [100, 100, 100],
defaultHeight: 40,
typographyCtx: typographyCtx,
hasNestedFrames: true,
expandedRows: new Set([0]),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight({
__depth: 1,
data: undefined,
__index: 0,
});
}).result.current
).toBe(TABLE.NESTED_NO_DATA_HEIGHT + TABLE.CELL_PADDING * 2);
});
it('calculates the height to return based on the number of rows in the nested frame', () => {
const { fields } = setupData();
const defaultHeight = 40;
expect(
renderHook(() => {
const rowHeight = useRowHeight({
fields: [
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
],
columnWidths: [100, 100, 100],
defaultHeight,
typographyCtx: typographyCtx,
hasNestedFrames: true,
expandedRows: new Set([0]),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight({
__index: 0,
__depth: 1,
data: createDataFrame({ fields }),
});
}).result.current
).toBe(defaultHeight * 4 + TABLE.CELL_PADDING * 2); // 3 rows + header + padding
});
it('removes the header if configured', () => {
const { fields } = setupData();
const defaultHeight = 40;
expect(
renderHook(() => {
const rowHeight = useRowHeight({
fields: [
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
],
columnWidths: [100, 100, 100],
defaultHeight,
typographyCtx: typographyCtx,
hasNestedFrames: true,
expandedRows: new Set([0]),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight({
__index: 0,
__depth: 1,
data: createDataFrame({ fields, meta: { custom: { noHeader: true } } }),
});
}).result.current
).toBe(defaultHeight * 3 + TABLE.CELL_PADDING * 2); // 3 rows + padding (no header)
});
});
// we test the lineCounters and getRowHeight directly to check that all of that
// math is working correctly. we mainly want to confirm here that the
// cache is clearing and that the local logic in this hook works.
describe('wrapped columns', () => {
let rows: TableRow[];
let fieldsWithWrappedText: Field[];
beforeEach(() => {
const { fields, rows: _rows } = setupData();
rows = _rows;
fieldsWithWrappedText = fields.map((field) => {
if (field.name === 'name') {
return {
...field,
name: 'Longer name that needs wrapping',
config: {
...field.config,
custom: {
...field.config?.custom,
cellOptions: {
cellType: TableCellDisplayMode.Auto,
wrapText: true,
},
},
},
};
}
return field;
});
});
it('handles changes to default height on re-render', () => {
const { result, rerender } = renderHook(
({ defaultHeight }) => {
const rowHeight = useRowHeight({
fields: fieldsWithWrappedText,
columnWidths: [100, 100, 100],
defaultHeight,
typographyCtx: typographyCtx,
hasNestedFrames: false,
expandedRows: new Set(),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight;
},
{
initialProps: { defaultHeight: 40 },
}
);
expect(result.current(rows[0])).toBe(40);
// change the column widths
rerender({ defaultHeight: 50 });
expect(result.current(rows[0])).toBe(50);
});
it('adjusts the width of the columns based on the cell padding and border', () => {
fieldsWithWrappedText[0].values[0] = 'Annie Lennox';
const wrappedCountFn = jest.fn(() => 2);
const estimateLinesFn = jest.fn(() => 2);
const { result } = renderHook(() => {
const rowHeight = useRowHeight({
fields: fieldsWithWrappedText,
columnWidths: [100, 100, 100],
defaultHeight: 40,
typographyCtx: { ...typographyCtx, wrappedCount: wrappedCountFn, estimateLines: estimateLinesFn },
hasNestedFrames: false,
expandedRows: new Set(),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight;
});
expect(result.current(rows[0])).toEqual(expect.any(Number));
expect(estimateLinesFn).toHaveBeenCalledWith('Annie Lennox', 100 - TABLE.CELL_PADDING * 2 - TABLE.BORDER_RIGHT);
});
});
});
});

View File

@ -1,22 +1,20 @@
import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect, RefObject } from 'react';
import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-grid';
import { varPreLine } from 'uwrap';
import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data';
import { useTheme2 } from '../../../themes/ThemeContext';
import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types';
import { TableColumnResizeActionCallback } from '../types';
import { TABLE } from './constants';
import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow } from './types';
import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
import {
getDisplayName,
processNestedTableRows,
applySort,
getCellOptions,
getColumnTypes,
GetMaxWrapCellOptions,
getMaxWrapCell,
getRowHeight,
buildHeaderLineCounters,
buildRowLineCounters,
} from './utils';
// Helper function to get displayed value
@ -314,49 +312,6 @@ export function useFooterCalcs(
}, [fields, enabled, footerOptions, isCountRowsSet, rows]);
}
interface TypographyCtx {
ctx: CanvasRenderingContext2D;
font: string;
avgCharWidth: number;
calcRowHeight: (text: string, cellWidth: number, defaultHeight: number) => number;
}
export function useTypographyCtx(): TypographyCtx {
const theme = useTheme2();
const typographyCtx = useMemo((): TypographyCtx => {
const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// set in grafana/data in createTypography.ts
const letterSpacing = 0.15;
ctx.letterSpacing = `${letterSpacing}px`;
ctx.font = font;
const txt =
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s";
const txtWidth = ctx.measureText(txt).width;
const avgCharWidth = txtWidth / txt.length + letterSpacing;
const { count } = varPreLine(ctx);
const calcRowHeight = (text: string, cellWidth: number, defaultHeight: number) => {
if (text === '') {
return defaultHeight;
}
const numLines = count(text, cellWidth);
const totalHeight = numLines * TABLE.LINE_HEIGHT + 2 * TABLE.CELL_PADDING;
return Math.max(totalHeight, defaultHeight);
};
return {
calcRowHeight,
ctx,
font,
avgCharWidth,
};
}, [theme.typography.fontSize, theme.typography.fontFamily]);
return typographyCtx;
}
const ICON_WIDTH = 16;
const ICON_GAP = 4;
@ -364,7 +319,6 @@ interface UseHeaderHeightOptions {
enabled: boolean;
fields: Field[];
columnWidths: number[];
defaultHeight: number;
sortColumns: SortColumn[];
typographyCtx: TypographyCtx;
showTypeIcons?: boolean;
@ -374,12 +328,14 @@ export function useHeaderHeight({
fields,
enabled,
columnWidths,
defaultHeight,
sortColumns,
typographyCtx: { calcRowHeight, avgCharWidth },
typographyCtx,
showTypeIcons = false,
}: UseHeaderHeightOptions): number {
const perIconSpace = ICON_WIDTH + ICON_GAP;
const lineCounters = useMemo(() => buildHeaderLineCounters(fields, typographyCtx), [fields, typographyCtx]);
const columnAvailableWidths = useMemo(
() =>
columnWidths.map((c, idx) => {
@ -396,46 +352,26 @@ export function useHeaderHeight({
if (showTypeIcons) {
width -= perIconSpace;
}
return Math.floor(width);
// sadly, the math for this is off by exactly 1 pixel. shrug.
return Math.floor(width) - 1;
}),
[fields, columnWidths, sortColumns, showTypeIcons, perIconSpace]
);
const [wrappedColHeaderIdxs, hasWrappedColHeaders] = useMemo(() => {
let hasWrappedColHeaders = false;
return [
fields.map((field) => {
const wrapText = field.config?.custom?.wrapHeaderText ?? false;
if (wrapText) {
hasWrappedColHeaders = true;
}
return wrapText;
}),
hasWrappedColHeaders,
];
}, [fields]);
const maxWrapCellOptions = useMemo<GetMaxWrapCellOptions>(
() => ({
colWidths: columnAvailableWidths,
avgCharWidth,
wrappedColIdxs: wrappedColHeaderIdxs,
}),
[columnAvailableWidths, avgCharWidth, wrappedColHeaderIdxs]
);
// TODO: is there a less clunky way to subtract the top padding value?
const headerHeight = useMemo(() => {
if (!enabled) {
return 0;
}
if (!hasWrappedColHeaders) {
return defaultHeight - TABLE.CELL_PADDING;
}
const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, -1, maxWrapCellOptions);
return calcRowHeight(maxLinesText, columnAvailableWidths[maxLinesIdx], defaultHeight) - TABLE.CELL_PADDING;
}, [fields, enabled, hasWrappedColHeaders, maxWrapCellOptions, calcRowHeight, columnAvailableWidths, defaultHeight]);
return getRowHeight(
fields,
-1,
columnAvailableWidths,
TABLE.HEADER_HEIGHT,
lineCounters,
TABLE.LINE_HEIGHT,
TABLE.CELL_PADDING
);
}, [fields, enabled, columnAvailableWidths, lineCounters]);
return headerHeight;
}
@ -455,42 +391,15 @@ export function useRowHeight({
hasNestedFrames,
defaultHeight,
expandedRows,
typographyCtx: { calcRowHeight, avgCharWidth },
typographyCtx,
}: UseRowHeightOptions): number | ((row: TableRow) => number) {
const [wrappedColIdxs, hasWrappedCols] = useMemo(() => {
let hasWrappedCols = false;
return [
fields.map((field) => {
if (field.type !== FieldType.string) {
return false;
}
const lineCounters = useMemo(() => buildRowLineCounters(fields, typographyCtx), [fields, typographyCtx]);
const hasWrappedCols = useMemo(() => lineCounters?.length ?? 0 > 0, [lineCounters]);
const cellOptions = getCellOptions(field);
const wrapText = 'wrapText' in cellOptions && cellOptions.wrapText;
const type = cellOptions.type;
const result = !!wrapText && type !== TableCellDisplayMode.Image;
if (result === true) {
hasWrappedCols = true;
}
return result;
}),
hasWrappedCols,
];
}, [fields]);
const colWidths = useMemo(
() => columnWidths.map((c) => c - 2 * TABLE.CELL_PADDING - TABLE.BORDER_RIGHT),
[columnWidths]
);
const maxWrapCellOptions = useMemo<GetMaxWrapCellOptions>(
() => ({
colWidths,
avgCharWidth,
wrappedColIdxs,
}),
[colWidths, avgCharWidth, wrappedColIdxs]
);
const colWidths = useMemo(() => {
const columnWidthAffordance = 2 * TABLE.CELL_PADDING + TABLE.BORDER_RIGHT;
return columnWidths.map((c) => c - columnWidthAffordance);
}, [columnWidths]);
const rowHeight = useMemo(() => {
// row height is only complicated when there are nested frames or wrapped columns.
@ -498,6 +407,9 @@ export function useRowHeight({
return defaultHeight;
}
// this cache should get blown away on resize, data refresh, updated fields, etc.
// caching by __index is ok because sorting does not modify the __index.
const cache: Array<number | undefined> = Array(fields[0].values.length);
return (row: TableRow) => {
// nested rows
if (row.__depth > 0) {
@ -512,23 +424,25 @@ export function useRowHeight({
}
const nestedHeaderHeight = row.data?.meta?.custom?.noHeader ? 0 : defaultHeight;
return Math.max(defaultHeight, defaultHeight * rowCount + nestedHeaderHeight + TABLE.CELL_PADDING * 2);
return defaultHeight * rowCount + nestedHeaderHeight + TABLE.CELL_PADDING * 2;
}
// regular rows
const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, row.__index, maxWrapCellOptions);
return calcRowHeight(maxLinesText, colWidths[maxLinesIdx], defaultHeight);
let result = cache[row.__index];
if (!result) {
result = cache[row.__index] = getRowHeight(
fields,
row.__index,
colWidths,
defaultHeight,
lineCounters,
TABLE.LINE_HEIGHT,
TABLE.CELL_PADDING * 2
);
}
return result;
};
}, [
calcRowHeight,
defaultHeight,
expandedRows,
fields,
hasNestedFrames,
hasWrappedCols,
maxWrapCellOptions,
colWidths,
]);
}, [hasNestedFrames, hasWrappedCols, defaultHeight, fields, colWidths, lineCounters, expandedRows]);
return rowHeight;
}

View File

@ -261,3 +261,29 @@ export interface ScrollPosition {
x: number;
y: number;
}
export interface TypographyCtx {
ctx: CanvasRenderingContext2D;
font: string;
avgCharWidth: number;
estimateLines: LineCounter;
wrappedCount: LineCounter;
}
export type LineCounter = (value: unknown, width: number) => number;
export interface LineCounterEntry {
/**
* given a values and the available width, returns the line count for that value
*/
counter: LineCounter;
/**
* if getting an accurate line count is expensive, you can provide an estimate method
* which will be used when looping over the row. the counter method will only be invoked
* for the cell which is the maximum line count for the row.
*/
estimate?: LineCounter;
/**
* indicates which field indexes of the visible fields this line counter applies to.
*/
fieldIdxs: number[];
}

View File

@ -16,7 +16,8 @@ import { BarGaugeDisplayMode, TableCellBackgroundDisplayMode, TableCellHeight }
import { TableCellDisplayMode } from '../types';
import { TABLE } from './constants';
import { COLUMN, TABLE } from './constants';
import { LineCounterEntry } from './types';
import {
extractPixelValue,
frameToRecords,
@ -31,8 +32,14 @@ import {
getJustifyContent,
migrateTableDisplayModeToCellOptions,
getColumnTypes,
getMaxWrapCell,
computeColWidths,
getRowHeight,
buildRowLineCounters,
buildHeaderLineCounters,
getTextLineEstimator,
createTypographyContext,
applySort,
SINGLE_LINE_ESTIMATE_THRESHOLD,
} from './utils';
describe('TableNG utils', () => {
@ -975,117 +982,345 @@ describe('TableNG utils', () => {
});
});
describe('getMaxWrapCell', () => {
it('should return the maximum wrap cell length from field state', () => {
const field1: Field = {
name: 'field1',
type: FieldType.string,
config: {},
values: ['beep boop', 'foo bar baz', 'lorem ipsum dolor sit amet'],
};
describe('createTypographyCtx', () => {
// we can't test the effectiveness of this typography context in unit tests, only that it
// actually executed the JS correctly. If you called `count` with a sensible value and width,
// it wouldn't give you a very reasonable answer in Jest's DOM environment for some reason.
it('creates the context using uwrap', () => {
const ctx = createTypographyContext(14, 'sans-serif', 0.15);
expect(ctx).toEqual(
expect.objectContaining({
font: '14px sans-serif',
ctx: expect.any(CanvasRenderingContext2D),
wrappedCount: expect.any(Function),
estimateLines: expect.any(Function),
avgCharWidth: expect.any(Number),
})
);
expect(ctx.wrappedCount('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number));
expect(ctx.estimateLines('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number));
});
});
const field2: Field = {
name: 'field2',
type: FieldType.string,
config: {},
values: ['asdfasdf asdfasdf asdfasdf', 'asdf asdf asdf asdf asdf', ''],
};
describe('getTextLineEstimator', () => {
const counter = getTextLineEstimator(10);
const field3: Field = {
name: 'field3',
type: FieldType.string,
config: {},
values: ['foo', 'bar', 'baz'],
// No alignmentFactors in state
};
const fields = [field1, field2, field3];
const result = getMaxWrapCell(fields, 0, {
colWidths: [30, 50, 100],
avgCharWidth: 5,
wrappedColIdxs: [true, true, true],
});
expect(result).toEqual({
text: 'asdfasdf asdfasdf asdfasdf',
idx: 1,
numLines: 2.6,
});
it('returns -1 if there are no strings or dashes within the string', () => {
expect(counter('asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf', 5)).toBe(-1);
});
it('should take colWidths into account when calculating max wrap cell', () => {
it('calculates an approximate rendered height for the text based on the width and avgCharWidth', () => {
expect(counter('asdfas dfasdfasdf asdfasdfasdfa sdfasdfasdfasdf 23', 200)).toBe(2.5);
});
});
describe('buildHeaderLineCounters', () => {
const ctx = {
font: '14px sans-serif',
ctx: {} as CanvasRenderingContext2D,
count: jest.fn(() => 2),
avgCharWidth: 7,
wrappedCount: jest.fn(() => 2),
estimateLines: jest.fn(() => 2),
};
it('returns an array of line counters for each column', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: { wrapHeaderText: true } } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } },
];
const counters = buildHeaderLineCounters(fields, ctx);
expect(counters![0].counter).toEqual(expect.any(Function));
expect(counters![0].fieldIdxs).toEqual([0, 1]);
});
it('does not return the index of columns which are not wrapped', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } },
];
const counters = buildHeaderLineCounters(fields, ctx);
expect(counters![0].fieldIdxs).toEqual([1]);
});
it('returns undefined if no columns are wrapped', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: {} } },
];
const counters = buildHeaderLineCounters(fields, ctx);
expect(counters).toBeUndefined();
});
});
describe('buildRowLineCounters', () => {
const ctx = {
font: '14px sans-serif',
ctx: {} as CanvasRenderingContext2D,
count: jest.fn(() => 2),
wrappedCount: jest.fn(() => 2),
estimateLines: jest.fn(() => 2),
avgCharWidth: 7,
};
it('returns an array of line counters for each column', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: { cellOptions: { wrapText: true } } } },
{
name: 'field',
name: 'Address',
type: FieldType.string,
config: {},
values: ['short', 'a bit longer text'],
values: [],
config: { custom: { cellOptions: { wrapText: true } } },
},
];
const counters = buildRowLineCounters(fields, ctx);
expect(counters![0].counter).toEqual(expect.any(Function));
expect(counters![0].fieldIdxs).toEqual([0, 1]);
});
it('does not return the index of columns which are not wrapped', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
{
name: 'field',
name: 'Address',
type: FieldType.string,
config: {},
values: ['short', 'quite a bit longer text'],
},
{
name: 'field',
type: FieldType.string,
config: {},
values: ['short', 'less text'],
values: [],
config: { custom: { cellOptions: { wrapText: true } } },
},
];
// Simulate a narrow column width that would cause wrapping
const colWidths = [50, 1000, 30]; // 50px width
const avgCharWidth = 5; // Assume average character width is 5px
const result = getMaxWrapCell(fields, 1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] });
// With a 50px width and 5px per character, we can fit 10 characters per line
// "the longest text in this field" has 31 characters, so it should wrap to 4 lines
expect(result).toEqual({
idx: 0,
numLines: 1.7,
text: 'a bit longer text',
});
const counters = buildRowLineCounters(fields, ctx);
expect(counters![0].fieldIdxs).toEqual([1]);
});
it('should use the display name if the rowIdx is -1 (which is used to calc header height in wrapped rows)', () => {
it('does not enable text counting for non-string fields', () => {
const fields: Field[] = [
{
name: 'Field with a very long name',
type: FieldType.string,
config: {},
values: ['short', 'a bit longer text'],
},
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { cellOptions: { wrapText: true } } } },
];
const counters = buildRowLineCounters(fields, ctx);
// empty array - we had one column that indicated it wraps, but it was numeric, so we just ignore it
expect(counters).toEqual([]);
});
it('returns an undefined if no columns are wrapped', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: {} } },
];
const counters = buildRowLineCounters(fields, ctx);
expect(counters).toBeUndefined();
});
});
describe('getRowHeight', () => {
let fields: Field[];
let counters: LineCounterEntry[];
beforeEach(() => {
fields = [
{
name: 'Name',
type: FieldType.string,
config: {},
values: ['short', 'quite a bit longer text'],
values: ['foo', 'bar', 'baz', 'longer one here', 'shorter'],
config: { custom: { cellOptions: { wrapText: true } } },
},
{
name: 'Another field',
type: FieldType.string,
config: {},
values: ['short', 'less text'],
name: 'Age',
type: FieldType.number,
values: [1, 2, 3, 123456, 789122349932],
config: { custom: { cellOptions: { wrapText: true } } },
},
];
// Simulate a narrow column width that would cause wrapping
const colWidths = [50, 1000, 30]; // 50px width
const avgCharWidth = 5; // Assume average character width is 5px
const result = getMaxWrapCell(fields, -1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] });
// With a 50px width and 5px per character, we can fit 10 characters per line
// "the longest text in this field" has 31 characters, so it should wrap to 4 lines
expect(result).toEqual({ idx: 0, numLines: 2.7, text: 'Field with a very long name' });
counters = [
{ counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines
{ counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)), fieldIdxs: [1] }, // Mocked to return a line for every 3 digits of a number
];
});
it.todo('should ignore columns which are not wrapped');
it('should use the default height for single-line rows', () => {
// 1 line @ 20px, 10px vertical padding = 30, minimum is 36
expect(getRowHeight(fields, 0, [30, 30], 36, counters, 20, 10)).toBe(36);
});
it.todo('should only apply wrapping on idiomatic break characters (space, -, etc)');
it('should use the default height for multi-line rows which are shorter than the default height', () => {
// 3 lines @ 5px, 5px vertical padding = 20, minimum is 36
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 5, 5)).toBe(36);
});
it('should return the row height using line counters for multi-line', () => {
// 3 lines @ 20px ('longer', 'one', 'here'), 10px vertical padding
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(70);
// 4 lines @ 15px (789 122 349 932), 15px vertical padding
expect(getRowHeight(fields, 4, [30, 30], 36, counters, 15, 15)).toBe(75);
});
it('should take colWidths into account when calculating max wrap cell', () => {
getRowHeight(fields, 3, [50, 60], 36, counters, 20, 10);
expect(counters[0].counter).toHaveBeenCalledWith('longer one here', 50);
expect(counters[1].counter).toHaveBeenCalledWith(123456, 60);
});
// this is used to calc wrapped header height
it('should use the display name if the rowIdx is -1', () => {
getRowHeight(fields, -1, [50, 60], 36, counters, 20, 10);
expect(counters[0].counter).toHaveBeenCalledWith('Name', 50);
expect(counters[1].counter).toHaveBeenCalledWith('Age', 60);
});
it('should ignore columns which do not have line counters', () => {
const height = getRowHeight(fields, 3, [30, 30], 36, [counters[1]], 20, 10);
// 2 lines @ 20px, 10px vertical padding (not 3 lines, since we don't line count Name)
expect(height).toBe(50);
});
it('should return the default height if there are no counters to apply', () => {
const height = getRowHeight(fields, 3, [30, 30], 36, [], 20, 10);
expect(height).toBe(36);
});
describe('estimations vs. precise counts', () => {
beforeEach(() => {
counters = [
{ counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines
{
estimate: jest.fn((value) => String(value).length), // Mocked to return a line for every digits of a number
counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)),
fieldIdxs: [1],
},
];
});
// 2 lines @ 20px (123,456), 10px vertical padding. when we did this before, 'longer one here' would win, making it 70px.
// the `estimate` function is picking `123456` as the longer one now (6 lines), then the `counter` function is used
// to calculate the height (2 lines). this is a very forced case, but we just want to prove that it actually works.
it('uses the estimate value rather than the precise value to select the row height', () => {
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50);
});
it('returns doesnt bother getting the precise count if the estimates are all below the threshold', () => {
jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3);
jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.1);
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(36);
// this is what we really care about - we want to save on performance by not calling the counter in this case.
expect(counters[1].counter).not.toHaveBeenCalled();
});
it('uses the precise count if the estimate is above the threshold, even if its below 1', () => {
// NOTE: if this fails, just change the test to use a different value besides 0.1
expect(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1).toBeLessThan(1);
jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3);
jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1);
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50);
});
});
});
describe('computeColWidths', () => {
it('returns the configured widths if all columns set them', () => {
expect(
computeColWidths(
[
{
name: 'A',
type: FieldType.string,
values: [],
config: { custom: { width: 100 } },
},
{
name: 'B',
type: FieldType.string,
values: [],
config: { custom: { width: 200 } },
},
],
500
)
).toEqual([100, 200]);
});
it('fills the available space if a column has no width set', () => {
expect(
computeColWidths(
[
{
name: 'A',
type: FieldType.string,
values: [],
config: {},
},
{
name: 'B',
type: FieldType.string,
values: [],
config: { custom: { width: 200 } },
},
],
500
)
).toEqual([300, 200]);
});
it('applies minimum width when auto width would dip below it', () => {
expect(
computeColWidths(
[
{
name: 'A',
type: FieldType.string,
values: [],
config: { custom: { minWidth: 100 } },
},
{
name: 'B',
type: FieldType.string,
values: [],
config: { custom: { minWidth: 100 } },
},
],
100
)
).toEqual([100, 100]);
});
it('should use the global column default width when nothing is set', () => {
expect(
computeColWidths(
[
{
name: 'A',
type: FieldType.string,
values: [],
config: {},
},
{
name: 'B',
type: FieldType.string,
values: [],
config: {},
},
],
// we have two columns but have set the table to the width of one default column.
COLUMN.DEFAULT_WIDTH
)
).toEqual([COLUMN.DEFAULT_WIDTH, COLUMN.DEFAULT_WIDTH]);
});
});
describe('displayJsonValue', () => {
it.todo('should parse and then stringify string values');
it.todo('should not throw for non-serializable string values');
it.todo('should stringify non-string values');
it.todo('should not throw for non-serializable non-string values');
});
describe('applySort', () => {

View File

@ -1,6 +1,7 @@
import { Property } from 'csstype';
import { SortColumn } from 'react-data-grid';
import tinycolor from 'tinycolor2';
import { Count, varPreLine } from 'uwrap';
import {
FieldType,
@ -25,7 +26,16 @@ import { getTextColorForAlphaBackground } from '../../../utils/colors';
import { TableCellOptions } from '../types';
import { COLUMN, TABLE } from './constants';
import { CellColors, TableRow, ColumnTypes, FrameToRowsConverter, Comparator } from './types';
import {
CellColors,
TableRow,
ColumnTypes,
FrameToRowsConverter,
Comparator,
TypographyCtx,
LineCounter,
LineCounterEntry,
} from './types';
/* ---------------------------- Cell calculations --------------------------- */
export type CellNumLinesCalculator = (text: string, cellWidth: number) => number;
@ -71,58 +81,190 @@ export function shouldTextWrap(field: Field): boolean {
return Boolean(cellOptions?.wrapText);
}
// matches characters which CSS
const spaceRegex = /[\s-]/;
/**
* @internal creates a typography context based on a font size and family. used to measure text
* and estimate size of text in cells.
*/
export function createTypographyContext(fontSize: number, fontFamily: string, letterSpacing = 0.15): TypographyCtx {
const font = `${fontSize}px ${fontFamily}`;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
export interface GetMaxWrapCellOptions {
colWidths: number[];
avgCharWidth: number;
wrappedColIdxs: boolean[];
ctx.letterSpacing = `${letterSpacing}px`;
ctx.font = font;
const txt =
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s.";
const txtWidth = ctx.measureText(txt).width;
const avgCharWidth = txtWidth / txt.length + letterSpacing;
const { count } = varPreLine(ctx);
return {
ctx,
font,
avgCharWidth,
estimateLines: getTextLineEstimator(avgCharWidth),
wrappedCount: wrapUwrapCount(count),
};
}
/**
* @internal
* loop through the fields and their values, determine which cell is going to determine the
* height of the row based on its content and width, and then return the text, index, and number of lines for that cell.
*/
export function getMaxWrapCell(
export function wrapUwrapCount(count: Count): LineCounter {
return (value, width) => {
if (value == null) {
return 1;
}
return count(String(value), width);
};
}
/**
* @internal returns a line counter which guesstimates a number of lines in a text cell based on the typography context's avgCharWidth.
*/
export function getTextLineEstimator(avgCharWidth: number): LineCounter {
return (value, width) => {
if (!value) {
return -1;
}
// we don't have string breaking enabled in the table,
// so an unbroken string is by definition a single line.
const strValue = String(value);
if (!spaceRegex.test(strValue)) {
return -1;
}
const charsPerLine = width / avgCharWidth;
return strValue.length / charsPerLine;
};
}
/**
* @internal return a text line counter for every field which has wrapHeaderText enabled.
*/
export function buildHeaderLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined {
const wrappedColIdxs = fields.reduce((acc: number[], field, idx) => {
if (field.config?.custom?.wrapHeaderText) {
acc.push(idx);
}
return acc;
}, []);
if (wrappedColIdxs.length === 0) {
return undefined;
}
// don't bother with estimating the line counts for the headers, because it's punishing
// when we get it wrong and there won't be that many compared to how many rows a table might contain.
return [{ counter: typographyCtx.wrappedCount, fieldIdxs: wrappedColIdxs }];
}
const spaceRegex = /[\s-]/;
/**
* @internal return a text line counter for every field which has wrapHeaderText enabled. we do this once as we're rendering
* the table, and then getRowHeight uses the output of this to caluclate the height of each row.
*/
export function buildRowLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined {
const result: Record<string, LineCounterEntry> = {};
let wrappedFields = 0;
for (let fieldIdx = 0; fieldIdx < fields.length; fieldIdx++) {
const field = fields[fieldIdx];
if (shouldTextWrap(field)) {
wrappedFields++;
// TODO: Pills, DataLinks, and JSON will have custom line counters here.
// for string fields, we really want to find the longest field ahead of time to reduce the number of calls to `count`.
// calling `count` is going to get a perfectly accurate line count, but it is expensive, so we'd rather estimate the line
// count and call the counter only for the field which will take up the most space based on its
if (field.type === FieldType.string) {
result.textCounter = result.textCounter ?? {
counter: typographyCtx.wrappedCount,
estimate: typographyCtx.estimateLines,
fieldIdxs: [],
};
result.textCounter.fieldIdxs.push(fieldIdx);
}
}
}
if (wrappedFields === 0) {
return undefined;
}
return Object.values(result);
}
// in some cases, the estimator might return a value that is less than 1, but when measured by the counter, it actually
// realizes that it's a multi-line cell. to avoid this, we want to give a little buffer away from 1 before we fully trust
// the estimator to have told us that a cell is single-line.
export const SINGLE_LINE_ESTIMATE_THRESHOLD = 0.85;
/**
* @internal
* loop through the fields and their values, determine which cell is going to determine the height of the row based
* on its content and width, and return the height in pixels of that row, with vertial padding applied.
*/
export function getRowHeight(
fields: Field[],
rowIdx: number,
{ colWidths, avgCharWidth, wrappedColIdxs }: GetMaxWrapCellOptions
): {
text: string;
idx: number;
numLines: number;
} {
let maxLines = 1;
let maxLinesIdx = -1;
let maxLinesText = '';
columnWidths: number[],
defaultHeight: number,
lineCounters?: LineCounterEntry[],
lineHeight = TABLE.LINE_HEIGHT,
verticalPadding = 0
): number {
if (!lineCounters?.length) {
return defaultHeight;
}
// TODO: consider changing how we store this, using a record by column key instead of an array
for (let i = 0; i < colWidths.length; i++) {
if (wrappedColIdxs[i]) {
const field = fields[i];
let maxLines = -1;
let maxValue = '';
let maxWidth = 0;
let preciseCounter: LineCounter | undefined;
for (const { estimate, counter, fieldIdxs } of lineCounters) {
// for some of the line counters, getting the precise count of the lines is expensive. those line counters
// set both an "estimate" and a "counter" function. if the cell we find to be the max was estimated, we will
// get the "true" value right before calculating the row height by hanging onto a reference to the counter fn.
const count = estimate ?? counter;
const isEstimating = estimate !== undefined;
for (const fieldIdx of fieldIdxs) {
const field = fields[fieldIdx];
// special case: for the header, provide `-1` as the row index.
const cellTextRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx];
if (cellTextRaw != null) {
const cellText = String(cellTextRaw);
if (spaceRegex.test(cellText)) {
const charsPerLine = colWidths[i] / avgCharWidth;
const approxLines = cellText.length / charsPerLine;
if (approxLines > maxLines) {
maxLines = approxLines;
maxLinesIdx = i;
maxLinesText = cellText;
}
const cellValueRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx];
if (cellValueRaw != null) {
const colWidth = columnWidths[fieldIdx];
const approxLines = count(cellValueRaw, colWidth);
if (approxLines > maxLines) {
maxLines = approxLines;
maxValue = cellValueRaw;
maxWidth = colWidth;
preciseCounter = isEstimating ? counter : undefined;
}
}
}
}
return { text: maxLinesText, idx: maxLinesIdx, numLines: maxLines };
// if the value is -1 or the estimate for the max cell was less than the SINGLE_LINE_ESTIMATE_THRESHOLD, we trust
// that the estimator correctly identified that no text wrapping is needed for this row, skipping the preciseCounter.
if (maxLines < SINGLE_LINE_ESTIMATE_THRESHOLD) {
return defaultHeight;
}
// if we finished this row height loop with an estimate, we need to call
// the `preciseCounter` method to get the exact line count.
if (preciseCounter !== undefined) {
maxLines = preciseCounter(maxValue, maxWidth);
}
// we want a round number of lines for rendering
const totalHeight = Math.ceil(maxLines) * lineHeight + verticalPadding;
return Math.max(totalHeight, defaultHeight);
}
/**

View File

@ -3869,7 +3869,7 @@ __metadata:
typescript: "npm:5.8.3"
uplot: "npm:1.6.32"
uuid: "npm:11.1.0"
uwrap: "npm:0.1.1"
uwrap: "npm:0.1.2"
webpack: "npm:5.97.1"
peerDependencies:
react: ^18.0.0
@ -31787,10 +31787,10 @@ __metadata:
languageName: node
linkType: hard
"uwrap@npm:0.1.1":
version: 0.1.1
resolution: "uwrap@npm:0.1.1"
checksum: 10/d5d02cb2f0e7fd997862913458d67e0c7fa9fd5bc1025baca9e183ac87046be9148942e59440fef8d01a34d5674c0395bb46b13e00359602ea3155b305466090
"uwrap@npm:0.1.2":
version: 0.1.2
resolution: "uwrap@npm:0.1.2"
checksum: 10/621d9d148d903410ef555739baca1ba84500d59a5028611bc2fca53313ffd6c9b870e25dc5cad73b41f18fe72879a3d94a6b8ebc5e141c84a94188ee1c483492
languageName: node
linkType: hard