mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-11 17:09:49 +08:00
@@ -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);
|
||||
|
||||
46
frontend/src/components/dataGridAutoWidth.test.ts
Normal file
46
frontend/src/components/dataGridAutoWidth.test.ts
Normal 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)]');
|
||||
});
|
||||
});
|
||||
108
frontend/src/components/dataGridAutoWidth.ts
Normal file
108
frontend/src/components/dataGridAutoWidth.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user