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
951 lines
26 KiB
TypeScript
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 };
|