feat(data-grid): 支持双击列边界自适应宽度

Fixes #330
This commit is contained in:
Syngnat
2026-04-11 22:05:53 +08:00
parent ca76440981
commit 632e57ea60
4 changed files with 243 additions and 3 deletions

View File

@@ -48,6 +48,7 @@ import {
normalizeTemporalLiteralText,
resolveUniqueKeyGroupsFromIndexes,
} from './dataGridCopyInsert';
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -392,7 +393,7 @@ const coerceJsonEditorValueForStorage = (currentValue: any, editedValue: any): a
// --- Resizable Header (Native Implementation) ---
const ResizableTitle = React.forwardRef<HTMLTableCellElement, any>((props, ref) => {
const { onResizeStart, width, ...restProps } = props;
const { onResizeStart, onResizeAutoFit, width, ...restProps } = props;
const nextStyle = { ...(restProps.style || {}) } as React.CSSProperties;
if (width) {
@@ -415,12 +416,20 @@ const ResizableTitle = React.forwardRef<HTMLTableCellElement, any>((props, ref)
// Pass the header element reference implicitly via event target
onResizeStart(e);
}}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (typeof onResizeAutoFit === 'function') {
onResizeAutoFit(e);
}
}}
onPointerDown={(e) => {
// 阻止 pointerdown 冒泡到 @dnd-kit 的 PointerSensor
// 避免调整列宽时意外触发列拖拽排序
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
title="拖动调整列宽,双击按内容自适应"
style={{
position: 'absolute',
right: 0, // Align to right edge
@@ -2886,6 +2895,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const resizeRafRef = useRef<number | null>(null);
const latestClientXRef = useRef<number | null>(null);
const isResizingRef = useRef(false); // Lock for sorting
const autoFitCanvasRef = useRef<HTMLCanvasElement | null>(null);
const flushGhostPosition = useCallback(() => {
resizeRafRef.current = null;
@@ -2947,6 +2957,74 @@ const DataGrid: React.FC<DataGridProps> = ({
}, [columnWidths, dataTableColumnWidthMode]);
const measureTextWidth = useCallback((text: string, font: string) => {
if (typeof document === 'undefined') {
return text.length * 8;
}
if (!autoFitCanvasRef.current) {
autoFitCanvasRef.current = document.createElement('canvas');
}
const context = autoFitCanvasRef.current.getContext('2d');
if (!context) {
return text.length * 8;
}
context.font = font;
return context.measureText(text).width;
}, []);
const buildAutoFitMeasurer = useCallback((element: HTMLElement | null, fallbackFont: string) => {
let font = fallbackFont;
if (typeof window !== 'undefined' && element) {
const computed = window.getComputedStyle(element);
const weight = computed.fontWeight || '400';
const size = computed.fontSize || '13px';
const family = computed.fontFamily || 'sans-serif';
font = `${weight} ${size} ${family}`;
}
return (text: string) => measureTextWidth(text, font);
}, [measureTextWidth]);
const handleResizeAutoFit = useCallback((key: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const handleEl = e.currentTarget as HTMLElement | null;
const headerEl = handleEl?.closest('th') as HTMLElement | null;
const sampleCell = Array.from(
containerRef.current?.querySelectorAll('.ant-table-cell[data-col-name]') || []
).find((node) => (node as HTMLElement).getAttribute('data-col-name') === key) as HTMLElement | undefined;
const meta = columnMetaMap[key] || columnMetaMapByLowerName[key.toLowerCase()];
const headerTexts = [key];
if (showColumnType && meta?.type) headerTexts.push(meta.type);
if (showColumnComment && meta?.comment) headerTexts.push(meta.comment);
const defaultWidth = resolveDataTableColumnWidth({
manualWidth: columnWidths[key],
widthMode: dataTableColumnWidthMode,
});
const containerWidth = containerRef.current?.clientWidth ?? 0;
const nextWidth = calculateAutoFitColumnWidth({
headerTexts,
valueTexts: displayDataRef.current.map((row) => row?.[key]),
measureHeaderText: buildAutoFitMeasurer(headerEl, '600 13px sans-serif'),
measureCellText: buildAutoFitMeasurer(sampleCell ?? null, '400 13px sans-serif'),
defaultWidth,
minWidth: 80,
maxWidth: Math.max(720, Math.floor(containerWidth * 0.85)),
});
setColumnWidths((prev) => ({ ...prev, [key]: nextWidth }));
}, [
buildAutoFitMeasurer,
columnMetaMap,
columnMetaMapByLowerName,
columnWidths,
dataTableColumnWidthMode,
showColumnComment,
showColumnType,
]);
// 2. Drag Move (Global)
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!draggingRef.current) return;
@@ -3411,6 +3489,7 @@ const DataGrid: React.FC<DataGridProps> = ({
width: column.width,
className: 'gonavi-sortable-header-cell',
onResizeStart: handleResizeStart(key), // Only need start
onResizeAutoFit: handleResizeAutoFit(key),
onClickCapture: (event: React.MouseEvent<HTMLElement>) => {
if (!onSort) return;
const headerCell = event.currentTarget as HTMLElement;
@@ -3433,7 +3512,7 @@ const DataGrid: React.FC<DataGridProps> = ({
},
}),
}));
}, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode]);
}, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode]);
const mergedColumns = useMemo(() => columns.map((col): ColumnType<any> => {
const dataIndex = String(col.dataIndex);

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
calculateAutoFitColumnWidth,
normalizeAutoFitCellText,
} from './dataGridAutoWidth';
const measure = (text: string) => text.length * 8;
describe('dataGridAutoWidth helpers', () => {
it('prefers the widest header or sampled value and adds padding', () => {
const width = calculateAutoFitColumnWidth({
headerTexts: ['user_name'],
valueTexts: ['alice', 'very_long_username_value'],
measureHeaderText: measure,
measureCellText: measure,
padding: 32,
minWidth: 80,
maxWidth: 720,
defaultWidth: 140,
});
expect(width).toBe('very_long_username_value'.length * 8 + 32);
});
it('measures multiline content by the longest visible line and clamps to max width', () => {
const width = calculateAutoFitColumnWidth({
headerTexts: ['notes'],
valueTexts: ['short\nmuch much longer line here'],
measureHeaderText: measure,
measureCellText: measure,
padding: 24,
minWidth: 80,
maxWidth: 160,
defaultWidth: 140,
});
expect(width).toBe(160);
});
it('normalizes null and oversized object values into stable preview text', () => {
expect(normalizeAutoFitCellText(null)).toBe('NULL');
expect(normalizeAutoFitCellText({ a: 1, b: 2 })).toBe('{"a":1,"b":2}');
expect(normalizeAutoFitCellText(Array.from({ length: 81 }, (_, index) => index))).toBe('[Array(81)]');
});
});

View File

@@ -0,0 +1,108 @@
const AUTO_FIT_DEFAULT_MIN_WIDTH = 80;
const AUTO_FIT_DEFAULT_MAX_WIDTH = 720;
const AUTO_FIT_DEFAULT_PADDING = 40;
const AUTO_FIT_DEFAULT_SAMPLE_LIMIT = 200;
const AUTO_FIT_MAX_PREVIEW_CHARS = 120;
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return Object.prototype.toString.call(value) === '[object Object]';
};
const clampWidth = (value: number, minWidth: number, maxWidth: number) => {
const safeMin = Math.max(1, Math.floor(minWidth));
const safeMax = Math.max(safeMin, Math.floor(maxWidth));
return Math.min(safeMax, Math.max(safeMin, Math.ceil(value)));
};
const normalizePreviewLine = (value: string): string => {
const normalized = String(value ?? '').replace(/\r\n/g, '\n');
if (normalized.length <= AUTO_FIT_MAX_PREVIEW_CHARS) {
return normalized;
}
return `${normalized.slice(0, AUTO_FIT_MAX_PREVIEW_CHARS)}`;
};
const splitPreviewLines = (value: string): string[] => {
return normalizePreviewLine(value)
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.length > 0);
};
export const normalizeAutoFitCellText = (value: unknown): string => {
if (value === null || value === undefined) {
return 'NULL';
}
if (typeof value === 'string') {
return normalizePreviewLine(value);
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
if (Array.isArray(value)) {
if (value.length > 80) {
return `[Array(${value.length})]`;
}
try {
return normalizePreviewLine(JSON.stringify(value));
} catch {
return '[Array]';
}
}
if (isPlainObject(value)) {
const topLevelSize = Object.keys(value).length;
if (topLevelSize > 80) {
return `{Object(${topLevelSize})}`;
}
try {
return normalizePreviewLine(JSON.stringify(value));
} catch {
return '[Object]';
}
}
return normalizePreviewLine(String(value));
};
export const calculateAutoFitColumnWidth = ({
headerTexts,
valueTexts,
measureHeaderText,
measureCellText,
minWidth = AUTO_FIT_DEFAULT_MIN_WIDTH,
maxWidth = AUTO_FIT_DEFAULT_MAX_WIDTH,
padding = AUTO_FIT_DEFAULT_PADDING,
sampleLimit = AUTO_FIT_DEFAULT_SAMPLE_LIMIT,
defaultWidth,
}: {
headerTexts: Array<string | null | undefined>;
valueTexts: unknown[];
measureHeaderText: (text: string) => number;
measureCellText: (text: string) => number;
minWidth?: number;
maxWidth?: number;
padding?: number;
sampleLimit?: number;
defaultWidth: number;
}): number => {
const safePadding = Math.max(0, Math.ceil(padding));
let widestTextWidth = Math.max(0, Number(defaultWidth) - safePadding);
headerTexts.forEach((text) => {
splitPreviewLines(normalizeAutoFitCellText(text ?? '')).forEach((line) => {
widestTextWidth = Math.max(widestTextWidth, measureHeaderText(line));
});
});
valueTexts.slice(0, Math.max(1, sampleLimit)).forEach((value) => {
splitPreviewLines(normalizeAutoFitCellText(value)).forEach((line) => {
widestTextWidth = Math.max(widestTextWidth, measureCellText(line));
});
});
return clampWidth(widestTextWidth + safePadding, minWidth, maxWidth);
};