Files
xk-admin/apps/web-antd/src/components/canvas-spreadsheet/canvas/SpreadsheetRenderer.ts
李琦 a38da5ce92
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
1. Excel交互组件封装优化
2026-06-26 12:52:26 +08:00

951 lines
26 KiB
TypeScript

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<string, unknown>[];
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<string>;
filterValues: Record<number, string>;
filteredRowIndices: number[];
showFillHandle: boolean;
theme: SpreadsheetTheme;
findMatches?: Array<{ row: number; col: number }>;
findActiveIndex?: number;
resolveDisplayValue?: (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) => unknown;
resolveCellStyle?: (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) => { 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<string, unknown> | 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<string, unknown> | undefined,
dataRow: number | undefined,
dirtyKeys: Set<string>,
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<string, unknown>[];
dirtyKeys: Set<string>;
resolveDisplayValue?: RenderOptions['resolveDisplayValue'];
resolveCellStyle?: RenderOptions['resolveCellStyle'];
}
export async function renderSelectionImage(
params: SelectionImageParams,
): Promise<Blob> {
const selRanges = params.selectionRanges.map((r) => normalizeRange(r));
if (!selRanges.length) throw new Error('No selection');
const rowSet = new Set<number>();
const colSet = new Set<number>();
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<string, unknown> | 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<string, unknown> | 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<string, unknown>[],
columns: SpreadsheetColumnConfig[],
filterValues: Record<number, string>,
): 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 };