import type { SpreadsheetColumnConfig } from '../types'; import { COLUMN_LETTER_HEIGHT, DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, MIN_COL_WIDTH, ROW_NUMBER_WIDTH, } from '../types'; import { getDataColX } from '../canvas/columnLayout'; const CELL_PADDING = 16; const SAMPLE_ROWS = 50; function measureTextWidth( ctx: CanvasRenderingContext2D, text: string, ): number { return ctx.measureText(text).width + CELL_PADDING; } function formatCellDisplay(value: unknown, cellType: import('../types').CellType): string { if (value === null || value === undefined) return ''; if (cellType === 'number') { const n = Number(value); return Number.isFinite(n) ? String(n) : String(value); } if (typeof value === 'object') return JSON.stringify(value); return String(value); } export function measureColumnWidths( canvas: HTMLCanvasElement, headers: Array<{ title: string; col: number }>, columns: SpreadsheetColumnConfig[], rows: Record[], defaultWidth: number, resolveDisplayValue?: ( dataRowIndex: number, colCfg: SpreadsheetColumnConfig, row: Record, ) => unknown, formatWithSuffix?: ( value: unknown, colCfg: SpreadsheetColumnConfig, ) => string, ): number[] { const ctx = canvas.getContext('2d'); if (!ctx) return headers.map(() => defaultWidth); ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; const maxCol = Math.max(...headers.map((h) => h.col), 0); const widths = new Array(maxCol + 1).fill(defaultWidth); for (const header of headers) { widths[header.col] = Math.max( widths[header.col] ?? defaultWidth, measureTextWidth(ctx, header.title), MIN_COL_WIDTH, ); } const sample = rows.slice(0, SAMPLE_ROWS); for (const colCfg of columns) { let maxW = widths[colCfg.col] ?? defaultWidth; sample.forEach((row, dataRowIdx) => { const raw = resolveDisplayValue ? resolveDisplayValue(dataRowIdx, colCfg, row) : row[colCfg.fieldId]; const text = formatWithSuffix ? formatWithSuffix(raw, colCfg) : formatCellDisplay(raw, colCfg.cellType === 'formula' ? 'number' : colCfg.cellType); maxW = Math.max(maxW, measureTextWidth(ctx, text)); }); widths[colCfg.col] = Math.max(maxW, MIN_COL_WIDTH); } return widths; } export function getRowHeight( cellType: import('../types').CellType, configured: number | 'auto' | undefined, ): number { if (typeof configured === 'number') return configured; switch (cellType) { case 'image': case 'video': return 48; default: return DEFAULT_ROW_HEIGHT; } } export function getTotalWidth(colWidths: number[]): number { return colWidths.reduce((sum, w) => sum + w, 0); } export function getRowTop(rowIndex: number, rowHeight: number): number { return rowIndex * rowHeight; } export function getColLeft(colIndex: number, colWidths: number[]): number { let left = 0; for (let i = 0; i < colIndex; i += 1) { left += colWidths[i] ?? DEFAULT_COL_WIDTH; } return left; } function getDataColScreenX( col: number, colWidths: number[], scrollLeft: number, fixedCols: number[], fixedWidth: number, ): number { return getDataColX(col, colWidths, scrollLeft, fixedCols, fixedWidth); } export function hitTestCell( x: number, y: number, scrollTop: number, scrollLeft: number, colWidths: number[], rowHeight: number, rowCount: number, colCount: number, fixedCols: number[] = [], fixedWidth = ROW_NUMBER_WIDTH, ): { row: number; col: number } | null { const bodyY = y + scrollTop; if (bodyY < 0) return null; const row = Math.floor(bodyY / rowHeight); if (row < 0 || row >= rowCount) return null; for (let col = 0; col < colCount; col += 1) { const cellX = getDataColScreenX( col, colWidths, scrollLeft, fixedCols, fixedWidth, ); const w = colWidths[col] ?? DEFAULT_COL_WIDTH; if (x >= cellX && x < cellX + w) { return { row, col }; } } return null; } export function hitTestRowNumber( x: number, y: number, scrollTop: number, rowHeight: number, rowCount: number, ): number | null { if (x < 0 || x >= ROW_NUMBER_WIDTH) return null; const bodyY = y + scrollTop; if (bodyY < 0) return null; const row = Math.floor(bodyY / rowHeight); if (row < 0 || row >= rowCount) return null; return row; } export type HeaderHitArea = 'corner' | 'columnLetter' | 'columnTitle'; export function hitTestHeaderArea( x: number, y: number, scrollLeft: number, colWidths: number[], colCount: number, fixedCols: number[] = [], fixedWidth = ROW_NUMBER_WIDTH, ): { area: HeaderHitArea; col?: number } | null { if (x >= 0 && x < ROW_NUMBER_WIDTH) { return { area: 'corner' }; } const col = hitTestHeaderCol( x, scrollLeft, colWidths, colCount, fixedCols, fixedWidth, ); if (col === null) return null; if (y < COLUMN_LETTER_HEIGHT) { return { area: 'columnLetter', col }; } return { area: 'columnTitle', col }; } export function hitTestHeaderCol( x: number, scrollLeft: number, colWidths: number[], colCount: number, fixedCols: number[] = [], fixedWidth = ROW_NUMBER_WIDTH, ): number | null { for (let col = 0; col < colCount; col += 1) { const cellX = getDataColScreenX( col, colWidths, scrollLeft, fixedCols, fixedWidth, ); const w = colWidths[col] ?? DEFAULT_COL_WIDTH; if (x >= cellX && x < cellX + w) return col; } return null; } export { getDataColScreenX }; export { formatCellDisplay, CELL_PADDING };