import type { SpreadsheetTheme } from './theme'; import { getSpreadsheetTheme, resolveHeaderColor } from './theme'; import type { CellRange, CellType, SpreadsheetColumnConfig, SpreadsheetHeaderConfig, SpreadsheetTextAlign, } from '../types'; import { COLUMN_LETTER_HEIGHT, DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, HEADER_HEIGHT, HEADER_TITLE_HEIGHT, ROW_NUMBER_WIDTH, SCROLL_BUFFER_ROWS, } from '../types'; import { getRowTop } from '../composables/useAutoMeasure'; import { formatCellWithSuffix } from '../composables/cellDisplay'; import { resolveColumnTextAlign } from '../composables/columnTextAlign'; import { colIndexToLetters } from '../formula/evaluator'; import { iterateRanges, normalizeRange, rangesContainCell } from '../commands/FillRangeCommand'; import { computeFixedWidth, getDataColX, getFixedColIndices, isFixedCol, } from './columnLayout'; export { computeFixedWidth, getDataColX, getFixedColIndices, } from './columnLayout'; export interface RenderOptions { headers: SpreadsheetHeaderConfig[]; columns: SpreadsheetColumnConfig[]; rows: Record[]; colWidths: number[]; rowHeight: number; scrollTop: number; scrollLeft: number; viewportWidth: number; viewportHeight: number; displayRowCount: number; colCount: number; displayColCount: number; defaultColWidth: number; defaultTextAlign: SpreadsheetTextAlign; isDark: boolean; fixedCols: number[]; fixedWidth: number; selectionRanges: CellRange[]; activeCell: { row: number; col: number } | null; dirtyKeys: Set; filterValues: Record; filteredRowIndices: number[]; showFillHandle: boolean; theme: SpreadsheetTheme; findMatches?: Array<{ row: number; col: number }>; findActiveIndex?: number; resolveDisplayValue?: ( viewRow: number, colCfg: SpreadsheetColumnConfig, row?: Record, ) => unknown; resolveCellStyle?: ( viewRow: number, colCfg: SpreadsheetColumnConfig, row?: Record, ) => { bgColor?: string; textColor?: string } | undefined; referenceCell?: { row: number; col: number } | null; } function colByIndex(columns: SpreadsheetColumnConfig[], col: number) { return columns.find((c) => c.col === col); } function isInSelection( row: number, col: number, ranges: CellRange[], ): boolean { return rangesContainCell(ranges, row, col); } function isFindMatch( row: number, col: number, matches?: Array<{ row: number; col: number }>, ): boolean { if (!matches?.length) return false; return matches.some((m) => m.row === row && m.col === col); } function isActiveFindMatch( row: number, col: number, matches?: Array<{ row: number; col: number }>, activeIndex?: number, ): boolean { if (activeIndex === undefined || activeIndex < 0 || !matches?.length) { return false; } const m = matches[activeIndex]; return !!m && m.row === row && m.col === col; } function isRowInSelection( row: number, ranges: CellRange[], maxCol: number, ): boolean { for (const range of ranges) { const r = normalizeRange(range); if (r.startCol === 0 && r.endCol === maxCol && row >= r.startRow && row <= r.endRow) { return true; } } return false; } function isColInSelection( col: number, ranges: CellRange[], maxRow: number, ): boolean { for (const range of ranges) { const r = normalizeRange(range); if (r.startRow === 0 && r.endRow === maxRow && col >= r.startCol && col <= r.endCol) { return true; } } return false; } function isAllSelected( ranges: CellRange[], maxRow: number, maxCol: number, ): boolean { if (ranges.length !== 1) return false; const r = normalizeRange(ranges[0]!); return r.startRow === 0 && r.endRow === maxRow && r.startCol === 0 && r.endCol === maxCol; } function formatCellDisplay(value: unknown, cellType: 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); } function drawAlignedText( ctx: CanvasRenderingContext2D, text: string, x: number, y: number, w: number, align: SpreadsheetTextAlign, ) { ctx.textBaseline = 'middle'; if (align === 'center') { ctx.textAlign = 'center'; ctx.fillText(text, x + w / 2, y); } else if (align === 'right') { ctx.textAlign = 'right'; ctx.fillText(text, x + w - 8, y); } else { ctx.textAlign = 'left'; ctx.fillText(text, x + 8, y, w - 16); } ctx.textAlign = 'left'; } function drawCellContent( ctx: CanvasRenderingContext2D, value: unknown, cellType: CellType, colCfg: SpreadsheetColumnConfig | undefined, x: number, y: number, w: number, h: number, theme: SpreadsheetTheme, textColor: string | undefined, defaultTextAlign: SpreadsheetTextAlign, ) { ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip(); const align = resolveColumnTextAlign(colCfg, defaultTextAlign); if (cellType === 'image' && value) { ctx.fillStyle = theme.imagePlaceholder; ctx.fillRect(x + 4, y + 4, h - 8, h - 8); ctx.fillStyle = theme.textMuted; ctx.font = '11px sans-serif'; drawAlignedText(ctx, 'IMG', x, y + h / 2, w, align); } else { ctx.fillStyle = textColor ?? theme.text; ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; const text = colCfg ? formatCellWithSuffix(value, colCfg) : formatCellDisplay(value, cellType); drawAlignedText(ctx, text, x, y + h / 2, w, align); } ctx.restore(); } function drawRowNumber( ctx: CanvasRenderingContext2D, y: number, rowHeight: number, label: string, theme: SpreadsheetTheme, selected = false, ) { ctx.fillStyle = selected ? theme.selectionBg : theme.rowNumBg; ctx.fillRect(0, y, ROW_NUMBER_WIDTH, rowHeight); ctx.strokeStyle = theme.border; ctx.strokeRect(0, y, ROW_NUMBER_WIDTH, rowHeight); ctx.fillStyle = theme.textMuted; ctx.font = '12px sans-serif'; ctx.textBaseline = 'middle'; ctx.fillText(label, ROW_NUMBER_WIDTH / 2 - 4, y + rowHeight / 2); } function drawHeaderCorner( ctx: CanvasRenderingContext2D, theme: SpreadsheetTheme, allSelected = false, ) { ctx.fillStyle = allSelected ? theme.selectionBg : theme.rowNumBg; ctx.fillRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT); ctx.strokeStyle = theme.border; ctx.strokeRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT); ctx.fillStyle = theme.textMuted; ctx.font = '12px sans-serif'; ctx.textBaseline = 'middle'; ctx.fillText( '#', ROW_NUMBER_WIDTH / 2 - 4, COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT / 2, ); } function drawDataCell( ctx: CanvasRenderingContext2D, options: { x: number; y: number; cw: number; rowHeight: number; bg: string; theme: SpreadsheetTheme; value?: unknown; cellType?: CellType; colCfg?: SpreadsheetColumnConfig; textColor?: string; empty?: boolean; defaultTextAlign: SpreadsheetTextAlign; }, ) { const { x, y, cw, rowHeight, bg, theme, value, cellType, colCfg, textColor, empty, defaultTextAlign, } = options; ctx.fillStyle = bg; ctx.fillRect(x, y, cw, rowHeight); ctx.strokeStyle = theme.border; ctx.strokeRect(x, y, cw, rowHeight); if (!empty && cellType && value !== undefined) { drawCellContent( ctx, value, cellType, colCfg, x, y, cw, rowHeight, theme, textColor, defaultTextAlign, ); } } function resolveCellBg( options: RenderOptions, vi: number, col: number, colCfg: SpreadsheetColumnConfig | undefined, row: Record | undefined, isEmpty: boolean, dataRow: number | undefined, ): string { const { theme } = options; const selected = isInSelection(vi, col, options.selectionRanges); const findActive = isActiveFindMatch( vi, col, options.findMatches, options.findActiveIndex, ); const findMatch = isFindMatch(vi, col, options.findMatches); if (isEmpty || !colCfg) { if (findActive) return theme.findActiveMatchBg; if (findMatch) return theme.findMatchBg; return selected ? theme.selectionBg : theme.cellBg; } const dirtyKey = dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : ''; const cellStyle = options.resolveCellStyle?.(vi, colCfg, row); if (options.dirtyKeys.has(dirtyKey)) return theme.dirtyBg; if (findActive) return theme.findActiveMatchBg; if (findMatch) return theme.findMatchBg; if (cellStyle?.bgColor) return cellStyle.bgColor; if (selected) return theme.selectionBg; return theme.cellBg; } function drawHeaderCell( ctx: CanvasRenderingContext2D, options: RenderOptions, col: number, cw: number, x: number, title: string, headerColor?: string, ) { const { theme } = options; const letterY = 0; const titleY = COLUMN_LETTER_HEIGHT; const maxRow = Math.max(0, options.filteredRowIndices.length - 1); const colSelected = isColInSelection(col, options.selectionRanges, maxRow); ctx.fillStyle = colSelected ? theme.selectionBg : theme.headerBg; ctx.fillRect(x, letterY, cw, COLUMN_LETTER_HEIGHT); ctx.strokeStyle = theme.border; ctx.strokeRect(x, letterY, cw, COLUMN_LETTER_HEIGHT); ctx.fillStyle = theme.textMuted; ctx.font = '11px sans-serif'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillText(colIndexToLetters(col), x + cw / 2, letterY + COLUMN_LETTER_HEIGHT / 2); ctx.textAlign = 'left'; const bg = resolveHeaderColor( headerColor, options.isDark, theme.headerBg, ); ctx.fillStyle = bg; ctx.fillRect(x, titleY, cw, HEADER_TITLE_HEIGHT); ctx.strokeStyle = theme.border; ctx.strokeRect(x, titleY, cw, HEADER_TITLE_HEIGHT); ctx.fillStyle = theme.headerText; ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; const colCfg = colByIndex(options.columns, col); const align = resolveColumnTextAlign(colCfg, options.defaultTextAlign); drawAlignedText( ctx, title, x, titleY + HEADER_TITLE_HEIGHT / 2, cw, align, ); } function drawHeaderTitleOnly( ctx: CanvasRenderingContext2D, x: number, y: number, cw: number, title: string, headerColor: string | undefined, col: number, columns: SpreadsheetColumnConfig[], defaultTextAlign: SpreadsheetTextAlign, isDark: boolean, theme: SpreadsheetTheme, ) { const bg = resolveHeaderColor(headerColor, isDark, theme.headerBg); ctx.fillStyle = bg; ctx.fillRect(x, y, cw, HEADER_TITLE_HEIGHT); ctx.strokeStyle = theme.border; ctx.strokeRect(x, y, cw, HEADER_TITLE_HEIGHT); ctx.fillStyle = theme.headerText; ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; const colCfg = colByIndex(columns, col); const align = resolveColumnTextAlign(colCfg, defaultTextAlign); drawAlignedText(ctx, title, x, y + HEADER_TITLE_HEIGHT / 2, cw, align); } function resolveExportCellBg( theme: SpreadsheetTheme, viewRow: number, col: number, colCfg: SpreadsheetColumnConfig | undefined, row: Record | undefined, dataRow: number | undefined, dirtyKeys: Set, resolveCellStyle?: RenderOptions['resolveCellStyle'], ): string { if (!colCfg) return theme.cellBg; const dirtyKey = dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : ''; const cellStyle = resolveCellStyle?.(viewRow, colCfg, row); if (dirtyKeys.has(dirtyKey)) return theme.dirtyBg; if (cellStyle?.bgColor) return cellStyle.bgColor; return theme.cellBg; } export interface SelectionImageParams { selectionRanges: CellRange[]; headers: SpreadsheetHeaderConfig[]; columns: SpreadsheetColumnConfig[]; colWidths: number[]; rowHeight: number; defaultColWidth: number; defaultTextAlign: SpreadsheetTextAlign; isDark: boolean; filteredRowIndices: number[]; rows: Record[]; dirtyKeys: Set; resolveDisplayValue?: RenderOptions['resolveDisplayValue']; resolveCellStyle?: RenderOptions['resolveCellStyle']; } export async function renderSelectionImage( params: SelectionImageParams, ): Promise { const selRanges = params.selectionRanges.map((r) => normalizeRange(r)); if (!selRanges.length) throw new Error('No selection'); const rowSet = new Set(); const colSet = new Set(); iterateRanges(selRanges, (viewRow, col) => { rowSet.add(viewRow); colSet.add(col); }); const uniqueRows = [...rowSet].sort((a, b) => a - b); const uniqueCols = [...colSet].sort((a, b) => a - b); if (!uniqueRows.length || !uniqueCols.length) { throw new Error('No selection'); } const theme = getSpreadsheetTheme(params.isDark); let width = 0; for (const col of uniqueCols) { width += params.colWidths[col] ?? params.defaultColWidth; } const height = HEADER_TITLE_HEIGHT + uniqueRows.length * params.rowHeight; const canvas = document.createElement('canvas'); const dpr = window.devicePixelRatio || 1; canvas.width = Math.ceil(width * dpr); canvas.height = Math.ceil(height * dpr); const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas not supported'); ctx.scale(dpr, dpr); ctx.fillStyle = theme.cellBg; ctx.fillRect(0, 0, width, height); let headerX = 0; for (const col of uniqueCols) { const cw = params.colWidths[col] ?? params.defaultColWidth; const header = params.headers.find((h) => h.col === col); const colCfg = colByIndex(params.columns, col); const title = header?.title ?? colCfg?.fieldName ?? ''; drawHeaderTitleOnly( ctx, headerX, 0, cw, title, header?.headerColor, col, params.columns, params.defaultTextAlign, params.isDark, theme, ); headerX += cw; } uniqueRows.forEach((viewRow, rowIndex) => { const dataRow = params.filteredRowIndices[viewRow]; const row = dataRow !== undefined ? params.rows[dataRow] : undefined; const y = HEADER_TITLE_HEIGHT + rowIndex * params.rowHeight; let cellX = 0; for (const col of uniqueCols) { const cw = params.colWidths[col] ?? params.defaultColWidth; const colCfg = colByIndex(params.columns, col); const inSel = rangesContainCell(selRanges, viewRow, col); const isEmpty = !inSel || dataRow === undefined || !row || !colCfg; const bg = inSel ? resolveExportCellBg( theme, viewRow, col, colCfg, row, dataRow, params.dirtyKeys, params.resolveCellStyle, ) : theme.cellBg; const cellStyle = inSel && !isEmpty && colCfg ? params.resolveCellStyle?.(viewRow, colCfg, row) : undefined; const displayCellType = colCfg?.cellType === 'formula' ? 'number' : colCfg?.cellType; drawDataCell(ctx, { x: cellX, y, cw, rowHeight: params.rowHeight, bg, theme, empty: isEmpty, value: inSel && !isEmpty && colCfg ? params.resolveDisplayValue?.(viewRow, colCfg, row) : undefined, cellType: displayCellType, colCfg: inSel ? colCfg : undefined, textColor: cellStyle?.textColor, defaultTextAlign: params.defaultTextAlign, }); cellX += cw; } }); return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (blob) resolve(blob); else reject(new Error('Failed to create image')); }, 'image/png'); }); } function drawHeaderFillerCell( ctx: CanvasRenderingContext2D, options: RenderOptions, col: number, cw: number, x: number, ) { const { theme } = options; const maxRow = Math.max(0, options.filteredRowIndices.length - 1); const colSelected = isColInSelection(col, options.selectionRanges, maxRow); const letterBg = colSelected ? theme.selectionBg : theme.headerBg; ctx.fillStyle = letterBg; ctx.fillRect(x, 0, cw, COLUMN_LETTER_HEIGHT); ctx.strokeStyle = theme.border; ctx.strokeRect(x, 0, cw, COLUMN_LETTER_HEIGHT); ctx.fillStyle = theme.textMuted; ctx.font = '11px sans-serif'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillText(colIndexToLetters(col), x + cw / 2, COLUMN_LETTER_HEIGHT / 2); ctx.textAlign = 'left'; ctx.fillRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT); ctx.strokeRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT); } function drawScrollableHeaders( ctx: CanvasRenderingContext2D, options: RenderOptions, w: number, ) { const { fixedCols, fixedWidth } = options; ctx.save(); ctx.beginPath(); ctx.rect(fixedWidth, 0, w - fixedWidth, HEADER_HEIGHT); ctx.clip(); for (const header of options.headers) { if (isFixedCol(header.col, fixedCols)) continue; const x = getDataColX( header.col, options.colWidths, options.scrollLeft, fixedCols, fixedWidth, ); const cw = options.colWidths[header.col] ?? options.defaultColWidth; if (x + cw <= fixedWidth || x > w) continue; drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor); } for (let col = options.colCount; col < options.displayColCount; col += 1) { if (isFixedCol(col, fixedCols)) continue; const x = getDataColX( col, options.colWidths, options.scrollLeft, fixedCols, fixedWidth, ); const cw = options.defaultColWidth; if (x + cw <= fixedWidth || x > w) continue; drawHeaderFillerCell(ctx, options, col, cw, x); } ctx.restore(); } function drawFrozenHeaders( ctx: CanvasRenderingContext2D, options: RenderOptions, w: number, ) { const { fixedCols, fixedWidth, theme } = options; const maxRow = Math.max(0, options.filteredRowIndices.length - 1); const maxCol = options.colCount - 1; drawHeaderCorner( ctx, theme, isAllSelected(options.selectionRanges, maxRow, maxCol), ); for (const header of options.headers) { if (!isFixedCol(header.col, fixedCols)) continue; const x = getDataColX( header.col, options.colWidths, options.scrollLeft, fixedCols, fixedWidth, ); const cw = options.colWidths[header.col] ?? options.defaultColWidth; if (x + cw <= ROW_NUMBER_WIDTH) continue; drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor); } if (fixedCols.length > 0 && fixedWidth < w) { ctx.fillStyle = theme.fixedShadow; ctx.fillRect(fixedWidth, 0, 1, HEADER_HEIGHT); } } export function renderHeader(canvas: HTMLCanvasElement, options: RenderOptions) { const ctx = canvas.getContext('2d'); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth; const h = HEADER_HEIGHT; canvas.width = w * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h); ctx.fillStyle = options.theme.cellBg; ctx.fillRect(0, 0, w, h); drawScrollableHeaders(ctx, options, w); drawFrozenHeaders(ctx, options, w); } function drawScrollableBodyRow( ctx: CanvasRenderingContext2D, options: RenderOptions, vi: number, y: number, dataRow: number | undefined, row: Record | undefined, w: number, ) { const { theme, rowHeight, fixedCols, fixedWidth } = options; const isEmpty = dataRow === undefined || !row; ctx.save(); ctx.beginPath(); ctx.rect(fixedWidth, y, w - fixedWidth, rowHeight); ctx.clip(); for (const colCfg of options.columns) { const col = colCfg.col; if (isFixedCol(col, fixedCols)) continue; const x = getDataColX( col, options.colWidths, options.scrollLeft, fixedCols, fixedWidth, ); const cw = options.colWidths[col] ?? options.defaultColWidth; if (x + cw <= fixedWidth || x > options.viewportWidth) continue; const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow); const cellStyle = !isEmpty ? options.resolveCellStyle?.(vi, colCfg, row) : undefined; const displayCellType = colCfg.cellType === 'formula' ? 'number' : colCfg.cellType; drawDataCell(ctx, { x, y, cw, rowHeight, bg, theme, empty: isEmpty, value: options.resolveDisplayValue ? options.resolveDisplayValue(vi, colCfg, row) : row?.[colCfg.fieldId], cellType: displayCellType, colCfg, textColor: cellStyle?.textColor, defaultTextAlign: options.defaultTextAlign, }); } for (let col = options.colCount; col < options.displayColCount; col += 1) { if (isFixedCol(col, fixedCols)) continue; const x = getDataColX( col, options.colWidths, options.scrollLeft, fixedCols, fixedWidth, ); const cw = options.defaultColWidth; if (x + cw <= fixedWidth || x > options.viewportWidth) continue; const bg = resolveCellBg(options, vi, col, undefined, undefined, true, dataRow); drawDataCell(ctx, { x, y, cw, rowHeight, bg, theme, empty: true, defaultTextAlign: options.defaultTextAlign, }); } ctx.restore(); } function drawFrozenBodyRow( ctx: CanvasRenderingContext2D, options: RenderOptions, vi: number, y: number, dataRow: number | undefined, row: Record | undefined, ) { const { theme, rowHeight, fixedCols, fixedWidth } = options; const isEmpty = dataRow === undefined || !row; const maxCol = options.colCount - 1; const rowSelected = isRowInSelection(vi, options.selectionRanges, maxCol); drawRowNumber(ctx, y, rowHeight, String(vi + 1), theme, rowSelected); for (const colCfg of options.columns) { const col = colCfg.col; if (!isFixedCol(col, fixedCols)) continue; const x = getDataColX( col, options.colWidths, options.scrollLeft, fixedCols, fixedWidth, ); const cw = options.colWidths[col] ?? options.defaultColWidth; const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow); const cellStyle = !isEmpty ? options.resolveCellStyle?.(vi, colCfg, row) : undefined; const displayCellType = colCfg.cellType === 'formula' ? 'number' : colCfg.cellType; drawDataCell(ctx, { x, y, cw, rowHeight, bg, theme, empty: isEmpty, value: options.resolveDisplayValue ? options.resolveDisplayValue(vi, colCfg, row) : row?.[colCfg.fieldId], cellType: displayCellType, colCfg, textColor: cellStyle?.textColor, defaultTextAlign: options.defaultTextAlign, }); } } export function renderBody(canvas: HTMLCanvasElement, options: RenderOptions) { const ctx = canvas.getContext('2d'); if (!ctx) return; const { theme, rowHeight, scrollTop, fixedWidth, fixedCols } = options; const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth; const h = canvas.clientHeight; canvas.width = w * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h); const visibleCount = Math.ceil(h / rowHeight) + SCROLL_BUFFER_ROWS; const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 2); const endRow = Math.min(options.displayRowCount - 1, startRow + visibleCount); for (let vi = startRow; vi <= endRow; vi += 1) { const dataRow = options.filteredRowIndices[vi]; const row = dataRow !== undefined ? options.rows[dataRow] : undefined; const y = getRowTop(vi, rowHeight) - scrollTop; drawScrollableBodyRow(ctx, options, vi, y, dataRow, row, w); } for (let vi = startRow; vi <= endRow; vi += 1) { const dataRow = options.filteredRowIndices[vi]; const row = dataRow !== undefined ? options.rows[dataRow] : undefined; const y = getRowTop(vi, rowHeight) - scrollTop; drawFrozenBodyRow(ctx, options, vi, y, dataRow, row); } if (fixedCols.length > 0 && fixedWidth < w) { const bodyH = Math.min(h, options.displayRowCount * rowHeight - scrollTop); ctx.fillStyle = theme.fixedShadow; ctx.fillRect(fixedWidth, 0, 1, Math.max(0, bodyH)); } if (options.activeCell) { const { activeCell } = options; const x = getDataColX( activeCell.col, options.colWidths, options.scrollLeft, fixedCols, fixedWidth, ); const y = getRowTop(activeCell.row, rowHeight) - scrollTop; const cw = options.colWidths[activeCell.col] ?? options.defaultColWidth; const findActive = isActiveFindMatch( activeCell.row, activeCell.col, options.findMatches, options.findActiveIndex, ); ctx.strokeStyle = findActive ? theme.findActiveBorder : theme.selectionBorder; ctx.lineWidth = 2; ctx.setLineDash([]); ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2); if (options.showFillHandle) { const fh = 6; ctx.fillStyle = theme.fillHandle; ctx.fillRect(x + cw - fh - 2, y + rowHeight - fh - 2, fh, fh); } } if (options.referenceCell) { const { row, col } = options.referenceCell; const x = getDataColX( col, options.colWidths, options.scrollLeft, fixedCols, fixedWidth, ); const y = getRowTop(row, rowHeight) - scrollTop; const cw = options.colWidths[col] ?? options.defaultColWidth; ctx.strokeStyle = theme.selectionBorder; ctx.lineWidth = 2; ctx.setLineDash([4, 4]); ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2); ctx.setLineDash([]); } } export function getVisibleRowIndices( rows: Record[], columns: SpreadsheetColumnConfig[], filterValues: Record, ): number[] { const activeFilters = Object.entries(filterValues).filter(([, v]) => v.trim()); if (!activeFilters.length) { return rows.map((_, i) => i); } return rows .map((row, index) => ({ row, index })) .filter(({ row }) => activeFilters.every(([colStr, keyword]) => { const col = Number(colStr); const colCfg = colByIndex(columns, col); if (!colCfg) return true; const val = formatCellDisplay(row[colCfg.fieldId], colCfg.cellType); return val.toLowerCase().includes(keyword.trim().toLowerCase()); }), ) .map(({ index }) => index); } export function getContentSize( colWidths: number[], displayRowCount: number, rowHeight: number, displayContentWidth?: number, ): { width: number; height: number } { const dataWidth = ROW_NUMBER_WIDTH + colWidths.reduce((s, w) => s + w, 0); const width = displayContentWidth ?? dataWidth; return { width, height: displayRowCount * rowHeight }; } export { ROW_NUMBER_WIDTH, HEADER_HEIGHT, DEFAULT_ROW_HEIGHT };