初始化

This commit is contained in:
李琦
2026-06-26 14:27:23 +08:00
parent aa37fc4119
commit 5fe4e512b9
33 changed files with 8520 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,950 @@
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 };

View File

@@ -0,0 +1,83 @@
import type { SpreadsheetHeaderConfig } from '../types';
import { DEFAULT_COL_WIDTH, ROW_NUMBER_WIDTH } from '../types';
export function getFixedColOffset(
col: number,
colWidths: number[],
fixedCols: number[],
): number {
let w = 0;
for (const c of fixedCols) {
if (c >= col) break;
w += colWidths[c] ?? DEFAULT_COL_WIDTH;
}
return w;
}
export function getFixedDataWidth(
col: number,
colWidths: number[],
fixedCols: number[],
): number {
return getFixedColOffset(col, colWidths, fixedCols);
}
export function isFixedCol(col: number, fixedCols: number[]) {
return fixedCols.includes(col);
}
export function getDataColX(
col: number,
colWidths: number[],
scrollLeft: number,
fixedCols: number[],
fixedWidth: number,
): number {
if (isFixedCol(col, fixedCols)) {
return ROW_NUMBER_WIDTH + getFixedColOffset(col, colWidths, fixedCols);
}
const scrollColOffset =
getColLeft(col, colWidths) - getFixedDataWidth(col, colWidths, fixedCols);
return fixedWidth + scrollColOffset - scrollLeft;
}
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;
}
export function computeFixedWidth(
colWidths: number[],
fixedCols: number[],
): number {
let w = ROW_NUMBER_WIDTH;
for (const col of fixedCols) {
w += colWidths[col] ?? DEFAULT_COL_WIDTH;
}
return w;
}
/** Keep only contiguous frozen columns starting from col 0 (Excel freeze semantics). */
export function normalizeFixedCols(fixedCols: number[]): number[] {
if (fixedCols.length === 0) return [];
const sorted = [...fixedCols].sort((a, b) => a - b);
const result: number[] = [];
for (let i = 0; i < sorted.length; i += 1) {
if (sorted[i] === i) result.push(i);
else break;
}
return result;
}
export function getFixedColIndices(
headers: SpreadsheetHeaderConfig[],
): number[] {
const raw = headers
.filter((h) => h.fixed)
.map((h) => h.col)
.sort((a, b) => a - b);
return normalizeFixedCols(raw);
}

View File

@@ -0,0 +1,146 @@
export interface SpreadsheetTheme {
headerBg: string;
headerText: string;
cellBg: string;
border: string;
text: string;
textMuted: string;
selectionBg: string;
selectionBorder: string;
dirtyBg: string;
rowNumBg: string;
fillHandle: string;
fixedShadow: string;
imagePlaceholder: string;
findMatchBg: string;
findActiveMatchBg: string;
findActiveBorder: string;
}
export function getSpreadsheetTheme(isDark: boolean): SpreadsheetTheme {
if (isDark) {
return {
headerBg: '#1f1f1f',
headerText: 'rgba(255, 255, 255, 0.85)',
cellBg: '#141414',
border: '#424242',
text: 'rgba(255, 255, 255, 0.85)',
textMuted: 'rgba(255, 255, 255, 0.45)',
selectionBg: 'rgba(22, 93, 255, 0.25)',
selectionBorder: '#1668dc',
dirtyBg: 'rgba(255, 125, 0, 0.2)',
rowNumBg: '#1a1a1a',
fillHandle: '#1668dc',
fixedShadow: 'rgba(0, 0, 0, 0.45)',
imagePlaceholder: '#303030',
findMatchBg: 'rgba(255, 212, 0, 0.35)',
findActiveMatchBg: 'rgba(255, 212, 0, 0.55)',
findActiveBorder: '#faad14',
};
}
return {
headerBg: '#f5f7fa',
headerText: '#1d2129',
cellBg: '#ffffff',
border: '#e5e6eb',
text: '#1d2129',
textMuted: '#86909c',
selectionBg: 'rgba(22, 93, 255, 0.08)',
selectionBorder: '#165dff',
dirtyBg: 'rgba(255, 125, 0, 0.12)',
rowNumBg: '#fafafa',
fillHandle: '#165dff',
fixedShadow: 'rgba(0, 0, 0, 0.12)',
imagePlaceholder: '#f2f3f5',
findMatchBg: '#fff566',
findActiveMatchBg: '#ffe58f',
findActiveBorder: '#faad14',
};
}
export const HEADER_COLOR_PRESETS = [
'#f0f5ff',
'#fff7e6',
'#f6ffed',
'#fff1f0',
'#f9f0ff',
'#e6fffb',
];
function parseHexColor(hex: string): { r: number; g: number; b: number } | null {
const normalized = hex.trim().replace('#', '');
if (normalized.length === 3) {
return {
r: Number.parseInt(normalized[0]! + normalized[0], 16),
g: Number.parseInt(normalized[1]! + normalized[1], 16),
b: Number.parseInt(normalized[2]! + normalized[2], 16),
};
}
if (normalized.length === 6) {
return {
r: Number.parseInt(normalized.slice(0, 2), 16),
g: Number.parseInt(normalized.slice(2, 4), 16),
b: Number.parseInt(normalized.slice(4, 6), 16),
};
}
return null;
}
function rgbToHsl(r: number, g: number, b: number) {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) {
return { h: 0, s: 0, l };
}
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / d + 4) / 6;
return { h, s, l };
}
function hslToHex(h: number, s: number, l: number): string {
if (s === 0) {
const v = Math.round(l * 255);
const hex = v.toString(16).padStart(2, '0');
return `#${hex}${hex}${hex}`;
}
const hue2rgb = (p: number, q: number, t: number) => {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
if (tt < 1 / 2) return q;
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255);
const g = Math.round(hue2rgb(p, q, h) * 255);
const b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
export function resolveHeaderColor(
color: string | undefined,
isDark: boolean,
fallback: string,
): string {
if (!color) return fallback;
if (!isDark) return color;
const rgb = parseHexColor(color);
if (!rgb) return fallback;
const { h, s, l } = rgbToHsl(rgb.r, rgb.g, rgb.b);
const darkL = Math.min(0.28, Math.max(0.16, l * 0.35 + 0.12));
const darkS = Math.min(0.55, s * 0.75 + 0.08);
return hslToHex(h, darkS, darkL);
}

View File

@@ -0,0 +1,64 @@
import type { CellRange, SpreadsheetCommand } from '../types';
export function createFillRangeCommand(
persistBatch: (
changes: Array<{ row: number; col: number; value: unknown }>,
) => void | Promise<void>,
before: Array<{ row: number; col: number; value: unknown }>,
after: Array<{ row: number; col: number; value: unknown }>,
): SpreadsheetCommand {
return {
label: 'fillRange',
undo: () => persistBatch(before),
redo: () => persistBatch(after),
};
}
export function normalizeRange(range: CellRange): CellRange {
return {
startRow: Math.min(range.startRow, range.endRow),
startCol: Math.min(range.startCol, range.endCol),
endRow: Math.max(range.startRow, range.endRow),
endCol: Math.max(range.startCol, range.endCol),
};
}
export function iterateRange(
range: CellRange,
fn: (row: number, col: number) => void,
) {
const r = normalizeRange(range);
for (let row = r.startRow; row <= r.endRow; row += 1) {
for (let col = r.startCol; col <= r.endCol; col += 1) {
fn(row, col);
}
}
}
export function iterateRanges(
ranges: CellRange[],
fn: (row: number, col: number) => void,
) {
for (const range of ranges) {
iterateRange(range, fn);
}
}
export function rangesContainCell(
ranges: CellRange[],
row: number,
col: number,
): boolean {
for (const range of ranges) {
const r = normalizeRange(range);
if (
row >= r.startRow &&
row <= r.endRow &&
col >= r.startCol &&
col <= r.endCol
) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,15 @@
import type { SpreadsheetCommand } from '../types';
export function createPasteRangeCommand(
persistBatch: (
changes: Array<{ row: number; col: number; value: unknown }>,
) => void | Promise<void>,
before: Array<{ row: number; col: number; value: unknown }>,
after: Array<{ row: number; col: number; value: unknown }>,
): SpreadsheetCommand {
return {
label: 'pasteRange',
undo: () => persistBatch(before),
redo: () => persistBatch(after),
};
}

View File

@@ -0,0 +1,12 @@
import type { SpreadsheetCommand } from '../types';
export function createSetCellValueCommand(
undo: () => void | Promise<void>,
redo: () => void | Promise<void>,
): SpreadsheetCommand {

View File

@@ -0,0 +1,55 @@
import type {
ConditionalFormatRule,
SpreadsheetColumnConfig,
} from '../types';
import { formatCellDisplay } from './useAutoMeasure';
export function shouldShowSuffix(displayText: string): boolean {
if (!displayText) return false;
if (displayText === '#ERROR' || displayText === '-') return false;
return true;
}
export function formatCellWithSuffix(
value: unknown,
colCfg?: SpreadsheetColumnConfig,
): string {
const cellType =
colCfg?.cellType === 'formula' ? 'number' : (colCfg?.cellType ?? 'text');
const base = formatCellDisplay(value, cellType);
if (!shouldShowSuffix(base) || !colCfg?.suffix) return base;
return base + colCfg.suffix;
}
function resolveRuleStyle(
rule: ConditionalFormatRule,
): { bgColor?: string; textColor?: string } {
const mode = rule.colorMode ?? 'background';
if (mode === 'text') {
return rule.textColor ? { textColor: rule.textColor } : {};
}
if (mode === 'both') {
return {
bgColor: rule.bgColor,
textColor: rule.textColor,
};
}
return rule.bgColor ? { bgColor: rule.bgColor } : {};
}
export function resolveConditionalStyle(
colCfg: SpreadsheetColumnConfig,
numericValue: number | null,
): { bgColor?: string; textColor?: string } | undefined {
const rules = colCfg.conditionalFormat;
if (rules == null || numericValue == null || Number.isNaN(numericValue)) {
return undefined;
}
if (rules.gt && numericValue > rules.gt.threshold) {
return resolveRuleStyle(rules.gt);
}
if (rules.lt && numericValue < rules.lt.threshold) {
return resolveRuleStyle(rules.lt);
}
return undefined;
}

View File

@@ -0,0 +1,13 @@
import type {
SpreadsheetColumnConfig,
SpreadsheetTextAlign,
} from '../types';
export function resolveColumnTextAlign(
colCfg: SpreadsheetColumnConfig | undefined,
defaultAlign: SpreadsheetTextAlign = 'left',
): SpreadsheetTextAlign {
const a = colCfg?.textAlign;
if (!a || a === 'inherit') return defaultAlign;
return a;
}

View File

@@ -0,0 +1,155 @@
import { formatCellWithSuffix } from './cellDisplay';
import type {
SpreadsheetColumnConfig,
SpreadsheetColumnFilter,
SpreadsheetViewState,
} from '../types';
type ResolveDisplayValue = (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) => unknown;
function formatDisplayText(value: unknown, colCfg: SpreadsheetColumnConfig): string {
if (value === null || value === undefined) return '';
const cellType = colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;
if (colCfg.suffix) {
return formatCellWithSuffix(value, colCfg);
}
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 getCellDisplayText(
row: Record<string, unknown>,
dataRowIndex: number,
colCfg: SpreadsheetColumnConfig,
resolveDisplayValue: ResolveDisplayValue,
): string {
const raw = resolveDisplayValue(dataRowIndex, colCfg, row);
return formatDisplayText(raw, colCfg);
}
export function collectColumnUniqueValues(
rows: Record<string, unknown>[],
col: number,
columns: SpreadsheetColumnConfig[],
resolveDisplayValue: ResolveDisplayValue,
): string[] {
const colCfg = columns.find((c) => c.col === col);
if (!colCfg) return [];
const set = new Set<string>();
rows.forEach((row, dataRowIndex) => {
set.add(getCellDisplayText(row, dataRowIndex, colCfg, resolveDisplayValue));
});
return [...set].sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
export function isColumnFilterActive(filter?: SpreadsheetColumnFilter): boolean {
return filter?.checkedValues !== undefined;
}
export function isColumnFilterMatching(
displayText: string,
filter?: SpreadsheetColumnFilter,
allValues?: string[],
): boolean {
if (!filter || filter.checkedValues === undefined) return true;
if (filter.checkedValues.length === 0) return false;
if (allValues && filter.checkedValues.length >= allValues.length) return true;
return filter.checkedValues.includes(displayText);
}
function compareRows(
a: number,
b: number,
sortCol: number,
rows: Record<string, unknown>[],
columns: SpreadsheetColumnConfig[],
resolveDisplayValue: ResolveDisplayValue,
): number {
const colCfg = columns.find((c) => c.col === sortCol);
if (!colCfg) return 0;
const rowA = rows[a];
const rowB = rows[b];
if (!rowA || !rowB) return 0;
const textA = getCellDisplayText(rowA, a, colCfg, resolveDisplayValue);
const textB = getCellDisplayText(rowB, b, colCfg, resolveDisplayValue);
if (colCfg.cellType === 'number' || colCfg.cellType === 'formula') {
const nA = Number(textA);
const nB = Number(textB);
const aValid = Number.isFinite(nA);
const bValid = Number.isFinite(nB);
if (!aValid && !bValid) return 0;
if (!aValid) return 1;
if (!bValid) return -1;
return nA - nB;
}
return textA.localeCompare(textB, 'zh-CN');
}
export function applyViewState(
rows: Record<string, unknown>[],
columns: SpreadsheetColumnConfig[],
viewState: SpreadsheetViewState,
resolveDisplayValue: ResolveDisplayValue,
): number[] {
const activeFilterCols = Object.entries(viewState.filters).filter(([, f]) =>
isColumnFilterActive(f),
);
let indices = rows.map((_, i) => i);
if (activeFilterCols.length > 0) {
const uniqueValuesMap = new Map<number, string[]>();
for (const [colStr] of activeFilterCols) {
const col = Number(colStr);
if (!uniqueValuesMap.has(col)) {
uniqueValuesMap.set(
col,
collectColumnUniqueValues(
rows,
col,
columns,
resolveDisplayValue,
),
);
}
}
indices = indices.filter((dataRowIndex) => {
const row = rows[dataRowIndex];
if (!row) return false;
return activeFilterCols.every(([colStr, filter]) => {
const col = Number(colStr);
const colCfg = columns.find((c) => c.col === col);
if (!colCfg) return true;
const allValues = uniqueValuesMap.get(col);
const text = getCellDisplayText(
row,
dataRowIndex,
colCfg,
resolveDisplayValue,
);
return isColumnFilterMatching(text, filter, allValues);
});
});
}
if (viewState.sort) {
const { col, direction } = viewState.sort;
indices = [...indices].sort((a, b) => {
const cmp = compareRows(a, b, col, rows, columns, resolveDisplayValue);
return direction === 'asc' ? cmp : -cmp;
});
}
return indices;
}

View File

@@ -0,0 +1,219 @@
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<string, unknown>[],
defaultWidth: number,
resolveDisplayValue?: (
dataRowIndex: number,
colCfg: SpreadsheetColumnConfig,
row: Record<string, unknown>,
) => 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<number>(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 };

View File

@@ -0,0 +1,60 @@
import { ref } from 'vue';
import type { ClipboardMatrix } from '../types';
export function useClipboard() {
const internal = ref<ClipboardMatrix | null>(null);
function setMatrix(matrix: ClipboardMatrix) {
internal.value = matrix;
}
function getMatrix() {
return internal.value;
}
function hasContent() {
return !!internal.value?.rows?.length;
}
function matrixToTsv(matrix: ClipboardMatrix): string {
return matrix.rows.map((row) => row.map(String).join('\t')).join('\n');
}
function tsvToMatrix(text: string): unknown[][] {
return text
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split('\n')
.filter((line, i, arr) => line.length > 0 || i < arr.length - 1)
.map((line) => line.split('\t'));
}
async function writeSystemClipboard(matrix: ClipboardMatrix) {
const text = matrixToTsv(matrix);
try {
await navigator.clipboard.writeText(text);
} catch {
// ignore
}
}
async function readSystemClipboard(): Promise<string | null> {
try {
return await navigator.clipboard.readText();
} catch {
return null;
}
}
return {
internal,
setMatrix,
getMatrix,
hasContent,
matrixToTsv,
tsvToMatrix,
writeSystemClipboard,
readSystemClipboard,
};
}

View File

@@ -0,0 +1,67 @@
import { useUserStore } from '@vben/stores';
import type { SpreadsheetHistoryEntry } from '../types';
import {
appendLocalHistory,
listUnsyncedHistory,
markHistorySynced,
} from '../history/localHistoryStore';
import { saveHistoryBatch } from '../history/remoteHistoryApi';
let syncTimer: ReturnType<typeof setTimeout> | null = null;
function uuid() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
export function useEditHistory(
tableName: string,
sheetKey?: string,
enabled = true,
) {
const userStore = useUserStore();
async function recordChange(
payload: Omit<
SpreadsheetHistoryEntry,
'id' | 'tableName' | 'sheetKey' | 'userId' | 'userName' | 'synced' | 'createdAt'
>,
) {
if (!enabled) return;
const entry: SpreadsheetHistoryEntry = {
id: uuid(),
tableName,
sheetKey,
userId: Number(userStore.userInfo?.userId ?? 0),
userName: String(
userStore.userInfo?.nick_name ??
userStore.userInfo?.username ??
'未知用户',
),
createdAt: new Date().toISOString(),
synced: false,
...payload,
};
await appendLocalHistory(entry);
scheduleSync();
return entry;
}
function scheduleSync() {
if (syncTimer) clearTimeout(syncTimer);
syncTimer = setTimeout(async () => {
try {
const pending = await listUnsyncedHistory();
if (!pending.length) return;
const res = await saveHistoryBatch(pending);
await markHistorySynced(res.saved_ids ?? pending.map((p) => p.id));
} catch {
// retry on next change
}
}, 2000);
}
return { recordChange, scheduleSync };
}

View File

@@ -0,0 +1,49 @@
import { ref } from 'vue';
export function useFormulaReferenceMode() {
const active = ref(false);
const pickedCell = ref<{ row: number; col: number } | null>(null);
const caret = ref(0);
function setCaret(pos: number) {
caret.value = Math.max(0, pos);
}
function enterIfNeeded(draft: string, focused: boolean, editable: boolean) {
if (!focused || !editable) {
active.value = false;
pickedCell.value = null;
return;
}
const on = draft.trimStart().startsWith('=');
active.value = on;
if (!on) pickedCell.value = null;
}
function exit() {
active.value = false;
pickedCell.value = null;
}
function insertAtCaret(draft: string, text: string): string {
const pos = caret.value;
const next = draft.slice(0, pos) + text + draft.slice(pos);
caret.value = pos + text.length;
return next;
}
function pickCell(row: number, col: number) {
pickedCell.value = { row, col };
}
return {
active,
pickedCell,
caret,
setCaret,
enterIfNeeded,
exit,
insertAtCaret,
pickCell,
};
}

View File

@@ -0,0 +1,268 @@
import { ref } from 'vue';
import type { CellRange } from '../types';
import {
normalizeRange,
rangesContainCell,
} from '../commands/FillRangeCommand';
export interface SelectionModifiers {
shift?: boolean;
ctrl?: boolean;
}
function fullRowRange(row: number, maxCol: number): CellRange {
return normalizeRange({
startRow: row,
endRow: row,
startCol: 0,
endCol: maxCol,
});
}
function fullColRange(col: number, maxRow: number): CellRange {
return normalizeRange({
startRow: 0,
endRow: maxRow,
startCol: col,
endCol: col,
});
}
function rowRange(r0: number, r1: number, maxCol: number): CellRange {
return normalizeRange({
startRow: r0,
endRow: r1,
startCol: 0,
endCol: maxCol,
});
}
function colRange(c0: number, c1: number, maxRow: number): CellRange {
return normalizeRange({
startRow: 0,
endRow: maxRow,
startCol: c0,
endCol: c1,
});
}
function isFullRowRange(range: CellRange, maxCol: number): boolean {
const r = normalizeRange(range);
return r.startCol === 0 && r.endCol === maxCol;
}
function isFullColRange(range: CellRange, maxRow: number): boolean {
const r = normalizeRange(range);
return r.startRow === 0 && r.endRow === maxRow;
}
function rangeContainsRow(range: CellRange, row: number, maxCol: number): boolean {
const r = normalizeRange(range);
return isFullRowRange(r, maxCol) && row >= r.startRow && row <= r.endRow;
}
function rangeContainsCol(range: CellRange, col: number, maxRow: number): boolean {
const r = normalizeRange(range);
return isFullColRange(r, maxRow) && col >= r.startCol && col <= r.endCol;
}
export function useSelection() {
const ranges = ref<CellRange[]>([]);
const anchor = ref<{ row: number; col: number } | null>(null);
const activeCell = ref<{ row: number; col: number } | null>(null);
const isFillDragging = ref(false);
/** @deprecated use ranges */
const selection = ranges;
function selectCell(row: number, col: number) {
activeCell.value = { row, col };
anchor.value = { row, col };
ranges.value = [
{ startRow: row, startCol: col, endRow: row, endCol: col },
];
}
function extendSelection(row: number, col: number) {
if (!anchor.value) {
selectCell(row, col);
return;
}
ranges.value = [
{
startRow: anchor.value.row,
startCol: anchor.value.col,
endRow: row,
endCol: col,
},
];
activeCell.value = { row, col };
}
function selectRange(range: CellRange) {
const normalized = normalizeRange(range);
ranges.value = [normalized];
activeCell.value = {
row: normalized.startRow,
col: normalized.startCol,
};
anchor.value = { ...activeCell.value };
}
function getSelectionRanges(): CellRange[] {
return ranges.value.map((r) => normalizeRange(r));
}
function getNormalizedSelection(): CellRange | null {
const primary = getPrimaryRange();
return primary;
}
function getPrimaryRange(): CellRange | null {
if (!ranges.value.length) return null;
if (!activeCell.value) return normalizeRange(ranges.value[0]!);
const { row, col } = activeCell.value;
for (const range of ranges.value) {
const r = normalizeRange(range);
if (
row >= r.startRow &&
row <= r.endRow &&
col >= r.startCol &&
col <= r.endCol
) {
return r;
}
}
return normalizeRange(ranges.value[ranges.value.length - 1]!);
}
function containsCell(row: number, col: number): boolean {
return rangesContainCell(ranges.value, row, col);
}
function selectAll(maxRow: number, maxCol: number) {
const range = normalizeRange({
startRow: 0,
endRow: maxRow,
startCol: 0,
endCol: maxCol,
});
ranges.value = [range];
activeCell.value = { row: 0, col: 0 };
anchor.value = { row: 0, col: 0 };
}
function selectEntireRow(
row: number,
maxCol: number,
opts: SelectionModifiers = {},
) {
const { shift, ctrl } = opts;
if (ctrl) {
const next = [...ranges.value];
const idx = next.findIndex((r) => rangeContainsRow(r, row, maxCol));
if (idx >= 0) {
next.splice(idx, 1);
} else {
next.push(fullRowRange(row, maxCol));
}
ranges.value = next.length ? next : [];
if (ranges.value.length) {
activeCell.value = { row, col: 0 };
if (!shift) anchor.value = { row, col: 0 };
} else {
activeCell.value = null;
}
return;
}
if (shift && anchor.value) {
const range = rowRange(anchor.value.row, row, maxCol);
ranges.value = [range];
activeCell.value = { row, col: 0 };
return;
}
ranges.value = [fullRowRange(row, maxCol)];
activeCell.value = { row, col: 0 };
anchor.value = { row, col: 0 };
}
function selectEntireColumn(
col: number,
maxRow: number,
opts: SelectionModifiers = {},
) {
const { shift, ctrl } = opts;
if (ctrl) {
const next = [...ranges.value];
const idx = next.findIndex((r) => rangeContainsCol(r, col, maxRow));
if (idx >= 0) {
next.splice(idx, 1);
} else {
next.push(fullColRange(col, maxRow));
}
ranges.value = next.length ? next : [];
if (ranges.value.length) {
activeCell.value = { row: 0, col };
if (!shift) anchor.value = { row: 0, col };
} else {
activeCell.value = null;
}
return;
}
if (shift && anchor.value) {
const range = colRange(anchor.value.col, col, maxRow);
ranges.value = [range];
activeCell.value = { row: 0, col };
return;
}
ranges.value = [fullColRange(col, maxRow)];
activeCell.value = { row: 0, col };
anchor.value = { row: 0, col };
}
function extendRowSelection(row: number, maxCol: number) {
if (!anchor.value) {
selectEntireRow(row, maxCol);
return;
}
ranges.value = [rowRange(anchor.value.row, row, maxCol)];
activeCell.value = { row, col: 0 };
}
function extendColumnSelection(col: number, maxRow: number) {
if (!anchor.value) {
selectEntireColumn(col, maxRow);
return;
}
ranges.value = [colRange(anchor.value.col, col, maxRow)];
activeCell.value = { row: 0, col };
}
function clearSelection() {
ranges.value = [];
anchor.value = null;
activeCell.value = null;
}
return {
ranges,
selection,
anchor,
activeCell,
isFillDragging,
selectCell,
extendSelection,
selectRange,
selectAll,
selectEntireRow,
selectEntireColumn,
extendRowSelection,
extendColumnSelection,
getSelectionRanges,
getNormalizedSelection,
getPrimaryRange,
containsCell,
clearSelection,
};
}

View File

@@ -0,0 +1,270 @@
import type { ContextMenuItem } from '#/components/context-menu';
import { menuDivider, showContextMenu } from '#/components/context-menu';
import { message } from 'ant-design-vue';
import type {
CellEditContext,
HeaderContextMenuContext,
SpreadsheetColumnConfig,
SpreadsheetEngineExpose,
SpreadsheetHeaderConfig,
SpreadsheetTableConfig,
} from '../types';
export interface HeaderLayoutActions {
toggleFixed: (col: number) => void;
setHeaderColor: (col: number, color?: string) => void;
isFixed: (col: number) => boolean;
getHeaderColor: (col: number) => string | undefined;
}
export interface SpreadsheetAdminMenuActions {
onOpenConfig: () => void;
onAddColumn: () => void;
onSetColumn: (col: number) => void;
}
export interface SpreadsheetMenuActions {
col: number;
layoutActions?: HeaderLayoutActions;
onOpenHeaderColorPicker: (col: number) => void;
admin?: SpreadsheetAdminMenuActions;
}
function resolveCustomItems<T>(
config:
| ContextMenuItem[]
| ((ctx: T) => ContextMenuItem[])
| undefined,
ctx: T,
): ContextMenuItem[] {
if (!config) return [];
return typeof config === 'function' ? config(ctx) : config;
}
function buildCellBuiltinItems(
ctx: CellEditContext,
engine: SpreadsheetEngineExpose,
onStartEdit: (row: number, col: number) => void,
): ContextMenuItem[] {
const canEditCell = engine.canEdit(ctx.rowIndex, ctx.colIndex);
const sel = ctx.selection;
return [
{
key: 'edit',
label: '编辑',
iconName: 'ant-design:edit-outlined',
disabled: !canEditCell,
handler: () => onStartEdit(ctx.rowIndex, ctx.colIndex),
},
{
key: 'copy',
label: '复制',
iconName: 'ant-design:copy-outlined',
disabled: !sel,
handler: () => {
if (sel) {
engine.copy();
message.success('复制成功');
}
},
},
{
key: 'copy-image',
label: '复制为图片',
iconName: 'ant-design:picture-outlined',
disabled: !sel,
handler: () => {
if (!sel) return;
void engine
.copyAsImage()
.then(() => {
message.success('已复制为图片');
})
.catch(() => {
message.warning('复制图片失败,请检查浏览器剪贴板权限');
});
},
},
{
key: 'paste',
label: '粘贴',
iconName: 'ant-design:snippets-outlined',
disabled: !engine.canPaste(),
handler: () => {
void engine.paste();
},
},
{
key: 'undo',
label: '撤回',
iconName: 'ant-design:undo-outlined',
disabled: !engine.canUndo(),
handler: () => {
void engine.undo();
},
},
];
}
function buildFixedItem(
col: number,
layoutActions?: HeaderLayoutActions,
): ContextMenuItem | null {
if (!layoutActions) return null;
const fixed = layoutActions.isFixed(col);
return {
key: 'toggle-fixed',
label: fixed ? '取消固定' : '固定此列',
iconName: 'ant-design:pushpin-outlined',
handler: () => layoutActions.toggleFixed(col),
};
}
function buildHeaderColorItem(
col: number,
onOpen: (col: number) => void,
): ContextMenuItem {
return {
key: 'header-color',
label: '表头颜色',
iconName: 'ant-design:bg-colors-outlined',
handler: () => onOpen(col),
};
}
function buildAdminFlatItemsForCol(
col: number,
admin: SpreadsheetAdminMenuActions,
): ContextMenuItem[] {
return [
{
key: 'admin-config',
label: 'Excel 配置',
iconName: 'mdi:table-cog',
handler: () => admin.onOpenConfig(),
},
{
key: 'admin-add-col',
label: '新增一列',
iconName: 'ant-design:plus-outlined',
handler: () => admin.onAddColumn(),
},
{
key: 'admin-set-col',
label: '设置列',
iconName: 'ant-design:setting-outlined',
handler: () => admin.onSetColumn(col),
},
];
}
function buildColumnSubmenu(menuActions: SpreadsheetMenuActions): ContextMenuItem {
const { col, layoutActions, onOpenHeaderColorPicker, admin } = menuActions;
const children: ContextMenuItem[] = [];
if (admin) {
children.push({
key: 'admin-add-col',
label: '新增一列',
iconName: 'ant-design:plus-outlined',
handler: () => admin.onAddColumn(),
});
children.push({
key: 'admin-set-col',
label: '设置列',
iconName: 'ant-design:setting-outlined',
handler: () => admin.onSetColumn(col),
});
}
const fixedItem = buildFixedItem(col, layoutActions);
if (fixedItem) children.push(fixedItem);
children.push(buildHeaderColorItem(col, onOpenHeaderColorPicker));
return {
key: 'column-submenu',
label: '列',
iconName: 'ant-design:table-outlined',
children,
};
}
function buildHeaderMenuItems(menuActions: SpreadsheetMenuActions): ContextMenuItem[] {
const { col, layoutActions, onOpenHeaderColorPicker, admin } = menuActions;
const items: ContextMenuItem[] = [];
const fixedItem = buildFixedItem(col, layoutActions);
if (fixedItem) items.push(fixedItem);
items.push(buildHeaderColorItem(col, onOpenHeaderColorPicker));
if (admin) {
items.push(...buildAdminFlatItemsForCol(col, admin));
}
return items;
}
function buildCellAdminItems(menuActions: SpreadsheetMenuActions): ContextMenuItem[] {
if (!menuActions.admin) return [];
return [
{
key: 'admin-config',
label: 'Excel 配置',
iconName: 'mdi:table-cog',
handler: () => menuActions.admin!.onOpenConfig(),
},
buildColumnSubmenu(menuActions),
];
}
function mergeMenus(
builtin: ContextMenuItem[],
custom: ContextMenuItem[],
): ContextMenuItem[] {
if (!custom.length) return builtin;
return [...builtin, menuDivider('__divider__'), ...custom];
}
function appendAdminBlock(
items: ContextMenuItem[],
adminItems: ContextMenuItem[],
): ContextMenuItem[] {
if (!adminItems.length) return items;
return [...items, menuDivider('__admin_divider__'), ...adminItems];
}
export function openCellContextMenu(
e: MouseEvent,
ctx: CellEditContext,
column: SpreadsheetColumnConfig | undefined,
engine: SpreadsheetEngineExpose,
onStartEdit: (row: number, col: number) => void,
menuActions?: SpreadsheetMenuActions,
) {
const builtin = buildCellBuiltinItems(ctx, engine, onStartEdit);
const custom = resolveCustomItems(column?.contextMenu, ctx);
const adminItems = menuActions ? buildCellAdminItems(menuActions) : [];
showContextMenu(
e,
appendAdminBlock(mergeMenus(builtin, custom), adminItems),
ctx,
);
}
export function openHeaderContextMenu(
e: MouseEvent,
ctx: HeaderContextMenuContext,
header: SpreadsheetHeaderConfig | undefined,
tableConfig: SpreadsheetTableConfig,
menuActions: SpreadsheetMenuActions,
) {
const builtin = buildHeaderMenuItems(menuActions);
let custom = resolveCustomItems(header?.contextMenu, ctx);
if (!custom.length) {
custom = resolveCustomItems(tableConfig.headerContextMenu, ctx);
}
showContextMenu(e, mergeMenus(builtin, custom), ctx);
}

View File

@@ -0,0 +1,784 @@
import { computed, ref, shallowRef, watch, type Ref } from 'vue';
import { createFillRangeCommand, iterateRange, iterateRanges, normalizeRange, rangesContainCell } from '../commands/FillRangeCommand';
import { createPasteRangeCommand } from '../commands/PasteRangeCommand';
import { createSetCellValueCommand } from '../commands/SetCellValueCommand';
import {
computeFixedWidth,
getContentSize,
getFixedColIndices,
renderSelectionImage,
} from '../canvas/SpreadsheetRenderer';
import { applyViewState, isColumnFilterActive } from './filterSort';
import {
getRowHeight,
hitTestCell,
hitTestHeaderCol,
hitTestRowNumber,
hitTestHeaderArea,
measureColumnWidths,
} from '../composables/useAutoMeasure';
import { useClipboard } from '../composables/useClipboard';
import { useEditHistory } from '../composables/useEditHistory';
import { useSelection } from '../composables/useSelection';
import { useUndoRedo } from '../composables/useUndoRedo';
import type {
CellEditContext,
CellRange,
SpreadsheetBatchCellChange,
SpreadsheetColumnConfig,
SpreadsheetEngineExpose,
SpreadsheetHeaderConfig,
SpreadsheetHistoryEntry,
SpreadsheetTableConfig,
SpreadsheetViewState,
SortDirection,
} from '../types';
import {
createDefaultViewState,
DEFAULT_COL_WIDTH,
DEFAULT_ROW_HEIGHT,
HEADER_HEIGHT,
ROW_NUMBER_WIDTH,
} from '../types';
import {
evaluateFormula,
formatFormulaResult,
type CellValueReader,
type FormulaResult,
} from '../formula/evaluator';
import {
formatCellWithSuffix,
resolveConditionalStyle,
} from '../composables/cellDisplay';
export function useSpreadsheetEngine(options: {
tableConfig: Ref<SpreadsheetTableConfig>;
mergedHeaders: Ref<SpreadsheetHeaderConfig[]>;
columns: Ref<SpreadsheetColumnConfig[]>;
data: Ref<Record<string, unknown>[]>;
rowKeyField: Ref<string>;
layoutColWidths: Ref<Record<number, number>>;
scrollViewportHeight: Ref<number>;
isDark: Ref<boolean>;
onChange: (data: Record<string, unknown>[]) => void;
}) {
const sortedColumns = computed(() =>
[...options.columns.value].sort((a, b) => a.col - b.col),
);
const colWidths = ref<number[]>([]);
const rowHeight = ref(DEFAULT_ROW_HEIGHT);
const scrollTop = ref(0);
const scrollLeft = ref(0);
const viewportWidth = ref(800);
const viewportHeight = ref(600);
const filterValues = ref<Record<number, string>>({});
const viewState = ref<SpreadsheetViewState>(createDefaultViewState());
const initialSnapshot = shallowRef<Map<string, unknown>>(new Map());
const dirtyKeys = ref<Set<string>>(new Set());
const editing = ref<{ row: number; col: number } | null>(null);
const measureCanvas = ref<HTMLCanvasElement | null>(null);
const selectionApi = useSelection();
const undoRedo = useUndoRedo();
const clipboard = useClipboard();
const history = useEditHistory(
options.tableConfig.value.tableName,
options.tableConfig.value.sheetKey,
options.tableConfig.value.enableHistory !== false,
);
const filteredRowIndices = computed(() =>
applyViewState(
options.data.value,
sortedColumns.value,
viewState.value,
(dataRowIndex, colCfg, row) =>
resolveDisplayValueForDataRow(dataRowIndex, colCfg, row),
),
);
function resolveDisplayValueForDataRow(
dataRowIndex: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
): unknown {
if (colCfg.cellType === 'formula' && colCfg.formula) {
const result = evaluateFormula(
colCfg.formula,
dataRowIndex,
(viewRow, col) => {
const cfg = colConfig(col);
if (!cfg || cfg.cellType === 'formula') return 0;
const dr = options.data.value[viewRow];
return dr ? getCellValue(viewRow, cfg.fieldId) : 0;
},
);
return formatFormulaResult(result);
}
const rowData = row ?? options.data.value[dataRowIndex];
if (!rowData) return undefined;
return rowData[colCfg.fieldId];
}
const fixedCols = computed(() =>
getFixedColIndices(options.mergedHeaders.value),
);
const fixedWidth = computed(() =>
computeFixedWidth(colWidths.value, fixedCols.value),
);
const displayRowCount = computed(() => {
const minRows = Math.max(
1,
Math.ceil(options.scrollViewportHeight.value / rowHeight.value),
);
return Math.max(filteredRowIndices.value.length, minRows);
});
const colCount = computed(
() => Math.max(...sortedColumns.value.map((c) => c.col), 0) + 1,
);
const defaultColWidth = computed(
() => options.tableConfig.value.defaultColWidth ?? DEFAULT_COL_WIDTH,
);
const dataContentWidth = computed(
() => ROW_NUMBER_WIDTH + colWidths.value.reduce((s, w) => s + w, 0),
);
const displayContentWidth = computed(() =>
Math.max(dataContentWidth.value, viewportWidth.value),
);
const fillerColCount = computed(() => {
const remaining = displayContentWidth.value - dataContentWidth.value;
if (remaining <= 0) return 0;
return Math.ceil(remaining / defaultColWidth.value);
});
const displayColCount = computed(
() => colCount.value + fillerColCount.value,
);
const paddedColWidths = computed(() => {
const defaultW = defaultColWidth.value;
const result: number[] = [];
for (let c = 0; c < displayColCount.value; c += 1) {
result.push(colWidths.value[c] ?? defaultW);
}
return result;
});
function colConfig(col: number) {
return sortedColumns.value.find((c) => c.col === col);
}
function dataRowFromViewRow(viewRow: number) {
return filteredRowIndices.value[viewRow] ?? viewRow;
}
function viewRowFromDataRow(dataRow: number) {
return filteredRowIndices.value.indexOf(dataRow);
}
function isEditable(colCfg?: SpreadsheetColumnConfig) {
if (!colCfg) return false;
if (colCfg.cellType === 'readonly' || colCfg.cellType === 'formula') {
return false;
}
return colCfg.editable !== false;
}
const readCellForFormula: CellValueReader = (viewRow, col) => {
const cfg = colConfig(col);
if (!cfg || cfg.cellType === 'formula') return 0;
const dataRow = dataRowFromViewRow(viewRow);
return getCellValue(dataRow, cfg.fieldId);
};
function resolveNumericValue(
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
): number | null {
if (colCfg.cellType === 'formula' && colCfg.formula) {
const result: FormulaResult = evaluateFormula(
colCfg.formula,
viewRow,
readCellForFormula,
);
if (result === 'DIV_ZERO' || result == null) return null;
return result;
}
if (colCfg.cellType === 'number' && row) {
const n = Number(row[colCfg.fieldId]);
return Number.isFinite(n) ? n : null;
}
return null;
}
function resolveCellStyle(
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) {
return resolveConditionalStyle(
colCfg,
resolveNumericValue(viewRow, colCfg, row),
);
}
function resolveDisplayValue(
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
): unknown {
if (colCfg.cellType === 'formula' && colCfg.formula) {
const result = evaluateFormula(
colCfg.formula,
viewRow,
readCellForFormula,
);
return formatFormulaResult(result);
}
if (!row) return undefined;
return row[colCfg.fieldId];
}
function getCellValue(dataRow: number, fieldId: string) {
return options.data.value[dataRow]?.[fieldId];
}
function buildCellContext(viewRow: number, col: number): CellEditContext | null {
const colCfg = colConfig(col);
if (!colCfg) return null;
const dataRow = dataRowFromViewRow(viewRow);
const rowData = options.data.value[dataRow];
if (!rowData) return null;
return {
fieldId: colCfg.fieldId,
fieldValue: rowData[colCfg.fieldId],
rowIndex: viewRow,
colIndex: col,
rowKey: rowData[options.rowKeyField.value] as string | number,
rowData,
editParams: colCfg.editParams ?? {},
selection: selectionApi.getPrimaryRange() ?? undefined,
};
}
function snapshotKey(dataRow: number, fieldId: string) {
const row = options.data.value[dataRow];
const rowKey = row?.[options.rowKeyField.value];
return `${rowKey}:${fieldId}`;
}
function markDirty(dataRow: number, fieldId: string, value: unknown) {
const key = snapshotKey(dataRow, fieldId);
const initial = initialSnapshot.value.get(key);
const next = new Set(dirtyKeys.value);
if (initial !== undefined && initial !== value) {
next.add(`${dataRow}:${fieldId}`);
} else {
next.delete(`${dataRow}:${fieldId}`);
}
dirtyKeys.value = next;
}
function applyLocalValue(dataRow: number, fieldId: string, value: unknown) {
const rows = options.data.value.map((r, i) =>
i === dataRow ? { ...r, [fieldId]: value } : r,
);
options.onChange(rows);
markDirty(dataRow, fieldId, value);
}
async function persistChanges(
items: Array<{ viewRow: number; col: number; value: unknown }>,
opts: {
recordHistory?: boolean;
action?: SpreadsheetHistoryEntry['action'];
} = {},
) {
const batchChanges: SpreadsheetBatchCellChange[] = [];
for (const item of items) {
const colCfg = colConfig(item.col);
if (!colCfg || !isEditable(colCfg)) continue;
const dataRow = dataRowFromViewRow(item.viewRow);
const ctx = buildCellContext(item.viewRow, item.col);
if (!ctx) continue;
const before = getCellValue(dataRow, colCfg.fieldId);
applyLocalValue(dataRow, colCfg.fieldId, item.value);
batchChanges.push({
ctx: { ...ctx, fieldValue: item.value },
before,
after: item.value,
col: item.col,
viewRow: item.viewRow,
});
}
if (batchChanges.length === 0) return;
const onBatchEdit = options.tableConfig.value.onBatchEdit;
if (onBatchEdit) {
await onBatchEdit(batchChanges);
} else {
for (const ch of batchChanges) {
const cfg = colConfig(ch.col);
if (cfg?.onEdit) {
await cfg.onEdit({ ...ch.ctx, fieldValue: ch.after });
}
}
}
if (
opts.recordHistory !== false &&
options.tableConfig.value.enableHistory !== false
) {
for (const ch of batchChanges) {
await history.recordChange({
action: opts.action ?? 'edit',
rowKey: ch.ctx.rowKey,
fieldId: ch.ctx.fieldId,
beforeSnapshot: ch.before,
afterSnapshot: ch.after,
});
}
}
}
function toPersistItems(
batch: Array<{ row: number; col: number; value: unknown }>,
) {
return batch.map((ch) => ({
viewRow: ch.row,
col: ch.col,
value: ch.value,
}));
}
async function commitCellChange(
viewRow: number,
col: number,
newValue: unknown,
recordHistory = true,
action: SpreadsheetHistoryEntry['action'] = 'edit',
) {
const colCfg = colConfig(col);
if (!colCfg || !isEditable(colCfg)) return;
const dataRow = dataRowFromViewRow(viewRow);
const before = getCellValue(dataRow, colCfg.fieldId);
if (before === newValue) return;
await persistChanges([{ viewRow, col, value: newValue }], {
recordHistory,
action,
});
undoRedo.push(
createSetCellValueCommand(
() =>
persistChanges([{ viewRow, col, value: before }], {
recordHistory: false,
}),
() =>
persistChanges([{ viewRow, col, value: newValue }], {
recordHistory: false,
}),
),
);
}
async function setCellValue(
viewRow: number,
col: number,
value: unknown,
recordHistory = true,
action: SpreadsheetHistoryEntry['action'] = 'edit',
) {
await commitCellChange(viewRow, col, value, recordHistory, action);
}
async function fillRange(source: CellRange, target: CellRange) {
const src = normalizeRange(source);
const tgt = normalizeRange(target);
const before: Array<{ row: number; col: number; value: unknown }> = [];
const after: Array<{ row: number; col: number; value: unknown }> = [];
const srcRows = src.endRow - src.startRow + 1;
const srcCols = src.endCol - src.startCol + 1;
iterateRange(tgt, (row, col) => {
const colCfg = colConfig(col);
if (!colCfg || !isEditable(colCfg)) return;
const relRow = (row - tgt.startRow) % srcRows;
const relCol = (col - tgt.startCol) % srcCols;
const srcRow = src.startRow + relRow;
const srcCol = src.startCol + relCol;
const srcColCfg = colConfig(srcCol);
if (!srcColCfg) return;
const dataRow = dataRowFromViewRow(row);
const beforeVal = getCellValue(dataRow, colCfg.fieldId);
const afterVal = getCellValue(
dataRowFromViewRow(srcRow),
srcColCfg.fieldId,
);
before.push({ row, col, value: beforeVal });
after.push({ row, col, value: afterVal });
});
await persistChanges(toPersistItems(after), {
recordHistory: true,
action: 'fill',
});
undoRedo.push(
createFillRangeCommand(
(batch) => persistChanges(toPersistItems(batch), { recordHistory: false }),
before,
after,
),
);
}
function copy(selectionOverride?: CellRange | CellRange[]) {
const selRanges = Array.isArray(selectionOverride)
? selectionOverride.map((r) => normalizeRange(r))
: selectionOverride
? [normalizeRange(selectionOverride)]
: selectionApi.getSelectionRanges();
if (!selRanges.length) return;
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) return;
const rows: unknown[][] = uniqueRows.map((viewRow) =>
uniqueCols.map((col) => {
if (!rangesContainCell(selRanges, viewRow, col)) return '';
const colCfg = colConfig(col);
if (!colCfg) return '';
const dataRow = dataRowFromViewRow(viewRow);
const row = options.data.value[dataRow];
const raw = resolveDisplayValue(viewRow, colCfg, row);
return formatCellWithSuffix(raw, colCfg);
}),
);
const matrix = {
rows,
startRow: uniqueRows[0]!,
startCol: uniqueCols[0]!,
};
clipboard.setMatrix(matrix);
void clipboard.writeSystemClipboard(matrix);
}
async function copyAsImage(selectionOverride?: CellRange | CellRange[]) {
const selRanges = Array.isArray(selectionOverride)
? selectionOverride.map((r) => normalizeRange(r))
: selectionOverride
? [normalizeRange(selectionOverride)]
: selectionApi.getSelectionRanges();
if (!selRanges.length) return;
const blob = await renderSelectionImage({
selectionRanges: selRanges,
headers: options.mergedHeaders.value,
columns: sortedColumns.value,
colWidths: paddedColWidths.value,
rowHeight: rowHeight.value,
defaultColWidth: defaultColWidth.value,
defaultTextAlign: options.tableConfig.value.defaultTextAlign ?? 'left',
isDark: options.isDark.value,
filteredRowIndices: filteredRowIndices.value,
rows: options.data.value,
dirtyKeys: dirtyKeys.value,
resolveDisplayValue,
resolveCellStyle,
});
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
}
async function paste() {
let matrix = clipboard.getMatrix();
if (!matrix) {
const text = await clipboard.readSystemClipboard();
if (!text) return;
matrix = {
rows: clipboard.tsvToMatrix(text),
startRow: 0,
startCol: 0,
};
}
const start = selectionApi.activeCell.value ?? {
row: 0,
col: sortedColumns.value[0]?.col ?? 0,
};
const before: Array<{ row: number; col: number; value: unknown }> = [];
const after: Array<{ row: number; col: number; value: unknown }> = [];
matrix.rows.forEach((rowVals, rOff) => {
rowVals.forEach((val, cOff) => {
const viewRow = start.row + rOff;
const col = start.col + cOff;
const colCfg = colConfig(col);
if (!colCfg || !isEditable(colCfg)) return;
const dataRow = dataRowFromViewRow(viewRow);
if (dataRow >= options.data.value.length) return;
before.push({
row: viewRow,
col,
value: getCellValue(dataRow, colCfg.fieldId),
});
after.push({ row: viewRow, col, value: val });
});
});
const applyBatch = (
changes: Array<{ row: number; col: number; value: unknown }>,
) => persistChanges(toPersistItems(changes), { recordHistory: false });
await persistChanges(toPersistItems(after), {
recordHistory: true,
action: 'paste',
});
undoRedo.push(createPasteRangeCommand(applyBatch, before, after));
}
function remeasure() {
if (!measureCanvas.value) return;
const defaultW =
options.tableConfig.value.defaultColWidth ?? DEFAULT_COL_WIDTH;
const measured = measureColumnWidths(
measureCanvas.value,
options.mergedHeaders.value,
sortedColumns.value,
options.data.value,
defaultW,
(dataRowIdx, colCfg, row) =>
resolveDisplayValue(dataRowIdx, colCfg, row),
formatCellWithSuffix,
);
const overrides = options.layoutColWidths.value;
colWidths.value = measured.map((w, i) => overrides[i] ?? w);
const firstType = sortedColumns.value[0]?.cellType ?? 'text';
rowHeight.value = getRowHeight(
firstType,
options.tableConfig.value.rowHeight,
);
}
function initSnapshot() {
const map = new Map<string, unknown>();
options.data.value.forEach((row, dataRow) => {
sortedColumns.value.forEach((col) => {
map.set(snapshotKey(dataRow, col.fieldId), row[col.fieldId]);
});
});
initialSnapshot.value = map;
dirtyKeys.value = new Set();
}
watch(
() => options.data.value,
() => {
if (initialSnapshot.value.size === 0) initSnapshot();
remeasure();
},
{ deep: true, immediate: true },
);
watch(
() => options.mergedHeaders.value,
() => remeasure(),
{ deep: true },
);
watch(
() => options.layoutColWidths.value,
() => remeasure(),
{ deep: true },
);
const contentSize = computed(() =>
getContentSize(
colWidths.value,
displayRowCount.value,
rowHeight.value,
displayContentWidth.value,
),
);
function setColumnFilter(col: number, checkedValues: string[] | undefined) {
const filters = { ...viewState.value.filters };
if (checkedValues === undefined) {
delete filters[col];
} else {
filters[col] = { checkedValues };
}
viewState.value = { ...viewState.value, filters };
}
function clearColumnFilter(col: number) {
const next = { ...viewState.value.filters };
delete next[col];
viewState.value = { ...viewState.value, filters: next };
}
function setSort(col: number, direction: SortDirection) {
viewState.value = { ...viewState.value, sort: { col, direction } };
}
function clearSort() {
viewState.value = { ...viewState.value, sort: null };
}
const engine: SpreadsheetEngineExpose = {
copy,
copyAsImage,
paste,
undo: undoRedo.undo,
redo: undoRedo.redo,
canUndo: undoRedo.canUndo,
canRedo: undoRedo.canRedo,
canCopy: () => selectionApi.getSelectionRanges().length > 0,
canCopyAsImage: () => selectionApi.getSelectionRanges().length > 0,
canPaste: () => clipboard.hasContent() || true,
canEdit: (_row, col) => {
const cfg = colConfig(col);
return !!cfg && isEditable(cfg);
},
getSelection: selectionApi.getSelectionRanges,
getCellEditContext: (row, col) => buildCellContext(row, col),
getHeaderContext: (col) => {
const header =
options.mergedHeaders.value.find((h) => h.col === col) ?? {
title: '',
col,
};
return {
colIndex: col,
fieldId: header.fieldId ?? colConfig(col)?.fieldId,
headerTitle: header.title,
filterValue: isColumnFilterActive(viewState.value.filters[col])
? 'active'
: '',
};
},
setCellValue,
fillRange,
getColumnConfig: colConfig,
getFilterValue: (col) =>
isColumnFilterActive(viewState.value.filters[col]) ? 'active' : '',
};
return {
engine,
sortedColumns,
colWidths,
rowHeight,
scrollTop,
scrollLeft,
viewportWidth,
viewportHeight,
filterValues,
viewState,
setColumnFilter,
clearColumnFilter,
setSort,
clearSort,
dirtyKeys,
editing,
measureCanvas,
filteredRowIndices,
contentSize,
displayRowCount,
displayColCount,
displayContentWidth,
defaultColWidth,
paddedColWidths,
fixedCols,
fixedWidth,
colCount,
selectionApi,
undoRedo,
dataRowFromViewRow,
viewRowFromDataRow,
colConfig,
isEditable,
resolveDisplayValue,
resolveCellStyle,
buildCellContext,
commitCellChange,
remeasure,
initSnapshot,
hitTestCell: (x: number, y: number) =>
hitTestCell(
x,
y,
scrollTop.value,
scrollLeft.value,
paddedColWidths.value,
rowHeight.value,
displayRowCount.value,
colCount.value,
fixedCols.value,
fixedWidth.value,
),
hitTestHeaderCol: (x: number) =>
hitTestHeaderCol(
x,
scrollLeft.value,
paddedColWidths.value,
displayColCount.value,
fixedCols.value,
fixedWidth.value,
),
hitTestRowNumber: (x: number, y: number) =>
hitTestRowNumber(
x,
y,
scrollTop.value,
rowHeight.value,
displayRowCount.value,
),
hitTestHeaderArea: (x: number, y: number) =>
hitTestHeaderArea(
x,
y,
scrollLeft.value,
paddedColWidths.value,
displayColCount.value,
fixedCols.value,
fixedWidth.value,
),
HEADER_HEIGHT,
ROW_NUMBER_WIDTH,
};
}
export type SpreadsheetEngine = ReturnType<typeof useSpreadsheetEngine>;

View File

@@ -0,0 +1,125 @@
import { computed, ref, watch, type Ref } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import { formatCellWithSuffix } from './cellDisplay';
import type { SpreadsheetColumnConfig } from '../types';
export interface FindMatch {
row: number;
col: number;
}
export function useSpreadsheetFind(options: {
filteredRowIndices: Ref<number[]>;
columns: Ref<SpreadsheetColumnConfig[]>;
rows: Ref<Record<string, unknown>[]>;
resolveDisplayValue: (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) => unknown;
}) {
const open = ref(false);
const query = ref('');
const matches = ref<FindMatch[]>([]);
const activeIndex = ref(-1);
function computeMatches(keyword: string): FindMatch[] {
const q = keyword.trim().toLowerCase();
if (!q) return [];
const result: FindMatch[] = [];
const cols = [...options.columns.value].sort((a, b) => a.col - b.col);
const rowCount = options.filteredRowIndices.value.length;
for (let viewRow = 0; viewRow < rowCount; viewRow += 1) {
const dataRow = options.filteredRowIndices.value[viewRow];
const row =
dataRow !== undefined ? options.rows.value[dataRow] : undefined;
if (!row) continue;
for (const colCfg of cols) {
const raw = options.resolveDisplayValue(viewRow, colCfg, row);
const text = formatCellWithSuffix(raw, colCfg).toLowerCase();
if (text.includes(q)) {
result.push({ row: viewRow, col: colCfg.col });
}
}
}
return result;
}
const debouncedSearch = useDebounceFn((keyword: string) => {
matches.value = computeMatches(keyword);
activeIndex.value = matches.value.length > 0 ? 0 : -1;
}, 150);
watch(query, (v) => {
if (!open.value) return;
void debouncedSearch(v);
});
const matchCount = computed(() => matches.value.length);
const activeMatch = computed(() => {
if (activeIndex.value < 0 || activeIndex.value >= matches.value.length) {
return null;
}
return matches.value[activeIndex.value] ?? null;
});
function openFind(initialQuery?: string) {
open.value = true;
if (initialQuery !== undefined) {
query.value = initialQuery;
}
matches.value = computeMatches(query.value);
activeIndex.value = matches.value.length > 0 ? 0 : -1;
}
function closeFind() {
open.value = false;
query.value = '';
matches.value = [];
activeIndex.value = -1;
}
function toggleFind() {
if (open.value) closeFind();
else openFind();
}
function goNext(): FindMatch | null {
if (!matches.value.length) return null;
activeIndex.value =
activeIndex.value < 0
? 0
: (activeIndex.value + 1) % matches.value.length;
return matches.value[activeIndex.value] ?? null;
}
function goPrev(): FindMatch | null {
if (!matches.value.length) return null;
activeIndex.value =
activeIndex.value < 0
? matches.value.length - 1
: (activeIndex.value - 1 + matches.value.length) %
matches.value.length;
return matches.value[activeIndex.value] ?? null;
}
return {
open,
query,
matches,
activeIndex,
matchCount,
activeMatch,
openFind,
closeFind,
toggleFind,
goNext,
goPrev,
};
}

View File

@@ -0,0 +1,47 @@
import { ref } from 'vue';
import type { SpreadsheetCommand } from '../types';
const MAX_STACK = 100;
export function useUndoRedo() {
const undoStack = ref<SpreadsheetCommand[]>([]);
const redoStack = ref<SpreadsheetCommand[]>([]);
function push(cmd: SpreadsheetCommand) {
undoStack.value.push(cmd);
if (undoStack.value.length > MAX_STACK) {
undoStack.value.shift();
}
redoStack.value = [];
}
async function undo() {
const cmd = undoStack.value.pop();
if (!cmd) return;
await cmd.undo();
redoStack.value.push(cmd);
}
async function redo() {
const cmd = redoStack.value.pop();
if (!cmd) return;
await cmd.redo();
undoStack.value.push(cmd);
}
function canUndo() {
return undoStack.value.length > 0;
}
function canRedo() {
return redoStack.value.length > 0;
}
function clear() {
undoStack.value = [];
redoStack.value = [];
}
return { push, undo, redo, canUndo, canRedo, clear };
}

View File

@@ -0,0 +1,238 @@
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue';
import { Input, InputNumber } from 'ant-design-vue';
import { getDataColScreenX } from '../composables/useAutoMeasure';
import type { SpreadsheetColumnConfig } from '../types';
const BLUR_GUARD_MS = 150;
const props = defineProps<{
visible: boolean;
viewRow: number;
col: number;
colWidths: number[];
rowHeight: number;
scrollTop: number;
scrollLeft: number;
fixedCols: number[];
fixedWidth: number;
isDark?: boolean;
column?: SpreadsheetColumnConfig;
value: unknown;
draftSeed?: string | number | null;
selectAllOnFocus?: boolean;
}>();
const emit = defineEmits<{
commit: [value: unknown];
cancel: [];
}>();
const inputRef = ref<{ focus?: () => void; select?: () => void } | null>(null);
const draft = ref<string | number>('');
const ignoreBlurUntil = ref(0);
const suppressBlurCommit = ref(false);
const isCommitting = ref(false);
const style = computed(() => {
const left = getDataColScreenX(
props.col,
props.colWidths,
props.scrollLeft,
props.fixedCols,
props.fixedWidth,
);
const top = props.viewRow * props.rowHeight - props.scrollTop;
const width = props.colWidths[props.col] ?? 100;
return {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${props.rowHeight}px`,
};
});
function getNativeInput(): HTMLInputElement | null {
const el = inputRef.value as
| { focus?: () => void; select?: () => void; $el?: HTMLElement }
| null;
if (!el) return null;
if (el.$el) {
return el.$el.querySelector('input') as HTMLInputElement | null;
}
return null;
}
function focusInput(options?: { selectAll?: boolean }) {
const el = inputRef.value as
| { focus?: () => void; select?: () => void; $el?: HTMLElement }
| null;
const native = getNativeInput();
if (native) {
native.focus({ preventScroll: true });
if (options?.selectAll) {
native.select();
} else {
const len = String(native.value ?? '').length;
native.setSelectionRange(len, len);
}
return;
}
if (!el) return;
if (typeof el.focus === 'function') {
el.focus();
if (options?.selectAll) {
el.select?.();
}
}
}
function blurInput() {
getNativeInput()?.blur();
}
function getDraftValue(): unknown {
return props.column?.cellType === 'number'
? Number(draft.value)
: draft.value;
}
watch(
() => props.visible,
async (v, _old, onCleanup) => {
let cancelled = false;
onCleanup(() => {
cancelled = true;
});
if (!v) {
suppressBlurCommit.value = false;
return;
}
ignoreBlurUntil.value = Date.now() + BLUR_GUARD_MS;
if (props.draftSeed != null) {
draft.value =
props.column?.cellType === 'number'
? Number(props.draftSeed)
: String(props.draftSeed);
} else {
draft.value =
props.column?.cellType === 'number'
? Number(props.value ?? 0)
: String(props.value ?? '');
}
await nextTick();
if (cancelled || !props.visible) return;
focusInput({ selectAll: props.selectAllOnFocus !== false });
},
{ immediate: true },
);
function commitFromEnter() {
if (isCommitting.value) return;
isCommitting.value = true;
suppressBlurCommit.value = true;
submit();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
commitFromEnter();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
suppressBlurCommit.value = true;
blurInput();
emit('cancel');
}
}
function submit() {
emit('commit', getDraftValue());
}
function onBlur() {
if (Date.now() < ignoreBlurUntil.value) return;
if (suppressBlurCommit.value) return;
submit();
}
function stopPointer(e: MouseEvent) {
e.stopPropagation();
}
defineExpose({
getDraftValue,
blurInput,
});
</script>
<template>
<div
class="cell-editor-overlay"
:class="{ 'is-dark': isDark }"
:style="style"
@mousedown.stop="stopPointer"
@click.stop="stopPointer"
>
<InputNumber
v-if="column?.cellType === 'number'"
ref="inputRef"
v-model:value="draft as number"
class="cell-editor-input"
size="small"
:controls="false"
@keydown="onKeydown"
@blur="onBlur"
/>
<Input
v-else
ref="inputRef"
v-model:value="draft as string"
class="cell-editor-input"
size="small"
@keydown="onKeydown"
@blur="onBlur"
/>
</div>
</template>
<style scoped lang="scss">
.cell-editor-overlay {
position: absolute;
z-index: 20;
box-sizing: border-box;
border: 2px solid #165dff;
background: #fff;
&.is-dark {
border-color: #1668dc;
background: #141414;
}
}
.cell-editor-input {
width: 100%;
height: 100%;
:deep(.ant-input-number),
:deep(.ant-input) {
width: 100%;
height: 100%;
border: none;
border-radius: 0;
box-shadow: none;
background: transparent;
}
}
</style>

View File

@@ -0,0 +1,191 @@
export type CellValueReader = (viewRow: number, col: number) => unknown;
export type FormulaResult = number | 'DIV_ZERO' | null;
class DivZeroError extends Error {
constructor() {
super('Division by zero');
this.name = 'DivZeroError';
}
}
export function colLettersToIndex(letters: string): number {
let n = 0;
for (const ch of letters.toUpperCase()) {
n = n * 26 + (ch.charCodeAt(0) - 64);
}
return n - 1;
}
export function colIndexToLetters(index: number): string {
let n = index + 1;
let result = '';
while (n > 0) {
n -= 1;
result = String.fromCharCode(65 + (n % 26)) + result;
n = Math.floor(n / 26);
}
return result;
}
function toNumber(value: unknown): number {
if (value == null || value === '') return 0;
const n = Number(value);
return Number.isFinite(n) ? n : Number.NaN;
}
function tokenize(expr: string): string[] {
const tokens: string[] = [];
let i = 0;
while (i < expr.length) {
const ch = expr[i]!;
if (ch === ' ' || ch === '\t') {
i += 1;
continue;
}
if ('+-*/()'.includes(ch)) {
tokens.push(ch);
i += 1;
continue;
}
if (/[0-9.]/.test(ch)) {
let num = ch;
i += 1;
while (i < expr.length && /[0-9.]/.test(expr[i]!)) {
num += expr[i];
i += 1;
}
tokens.push(num);
continue;
}
if (/[A-Za-z]/.test(ch)) {
let ref = ch;
i += 1;
while (i < expr.length && /[A-Za-z0-9]/.test(expr[i]!)) {
ref += expr[i];
i += 1;
}
tokens.push(ref);
continue;
}
throw new Error(`Unexpected character: ${ch}`);
}
return tokens;
}
function parseFactor(
tokens: string[],
pos: { i: number },
readCell: CellValueReader,
viewRow: number,
): number {
const token = tokens[pos.i];
if (token === undefined) throw new Error('Unexpected end');
if (token === '(') {
pos.i += 1;
const value = parseExpression(tokens, pos, readCell, viewRow);
if (tokens[pos.i] !== ')') throw new Error('Missing )');
pos.i += 1;
return value;
}
if (token === '+') {
pos.i += 1;
return parseFactor(tokens, pos, readCell, viewRow);
}
if (token === '-') {
pos.i += 1;
return -parseFactor(tokens, pos, readCell, viewRow);
}
if (/^[0-9]+(\.[0-9]+)?$/.test(token)) {
pos.i += 1;
return Number(token);
}
const match = /^([A-Za-z]+)(\d+)$/.exec(token);
if (match) {
pos.i += 1;
const col = colLettersToIndex(match[1]!);
return toNumber(readCell(viewRow, col));
}
throw new Error(`Invalid token: ${token}`);
}
function parseTerm(
tokens: string[],
pos: { i: number },
readCell: CellValueReader,
viewRow: number,
): number {
let value = parseFactor(tokens, pos, readCell, viewRow);
while (pos.i < tokens.length) {
const op = tokens[pos.i];
if (op === '*' || op === '/') {
pos.i += 1;
const rhs = parseFactor(tokens, pos, readCell, viewRow);
if (op === '/') {
if (rhs === 0) throw new DivZeroError();
value /= rhs;
} else {
value *= rhs;
}
} else {
break;
}
}
return value;
}
function parseExpression(
tokens: string[],
pos: { i: number },
readCell: CellValueReader,
viewRow: number,
): number {
let value = parseTerm(tokens, pos, readCell, viewRow);
while (pos.i < tokens.length) {
const op = tokens[pos.i];
if (op === '+' || op === '-') {
pos.i += 1;
const rhs = parseTerm(tokens, pos, readCell, viewRow);
value = op === '+' ? value + rhs : value - rhs;
} else {
break;
}
}
return value;
}
export function evaluateFormula(
formula: string,
viewRow: number,
readCell: CellValueReader,
): FormulaResult {
if (!formula?.trim()) return null;
let expr = formula.trim();
if (expr.startsWith('=')) expr = expr.slice(1);
if (!expr) return null;
try {
const tokens = tokenize(expr);
const pos = { i: 0 };
const result = parseExpression(tokens, pos, readCell, viewRow);
if (pos.i < tokens.length) throw new Error('Trailing tokens');
if (!Number.isFinite(result)) return null;
return result;
} catch (err) {
if (err instanceof DivZeroError) return 'DIV_ZERO';
return null;
}
}
export function formatFormulaResult(value: FormulaResult): string {
if (value === 'DIV_ZERO') return '-';
if (value == null || Number.isNaN(value)) return '#ERROR';
if (Number.isInteger(value)) return String(value);
return String(Math.round(value * 100) / 100);
}

View File

@@ -0,0 +1,297 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import {
Button,
Checkbox,
Divider,
Dropdown,
Input,
Menu,
MenuItem,
SubMenu,
} from 'ant-design-vue';
import MIcon from '#/components/icon/icon.vue';
import type { SortDirection } from '../types';
const props = defineProps<{
col: number;
title: string;
left: number;
width: number;
top: number;
active: boolean;
sortDirection?: SortDirection | null;
uniqueValues: string[];
checkedValues?: string[];
disabled?: boolean;
isDark?: boolean;
loadingValues?: boolean;
}>();
const emit = defineEmits<{
sort: [direction: SortDirection];
clearSort: [];
clearFilter: [];
open: [];
'update:checkedValues': [values: string[] | undefined];
}>();
const open = ref(false);
const search = ref('');
const effectiveChecked = computed(() => {
if (props.checkedValues === undefined) {
return new Set(props.uniqueValues);
}
return new Set(props.checkedValues);
});
const filteredValues = computed(() => {
const q = search.value.trim().toLowerCase();
const list = props.uniqueValues;
if (!q) return list.slice(0, 200);
return list.filter((v) => v.toLowerCase().includes(q)).slice(0, 200);
});
const allChecked = computed(
() =>
props.uniqueValues.length > 0 &&
props.uniqueValues.every((v) => effectiveChecked.value.has(v)),
);
const indeterminate = computed(() => {
if (allChecked.value) return false;
return props.uniqueValues.some((v) => effectiveChecked.value.has(v));
});
function toggleValue(value: string, checked: boolean) {
const next = new Set(effectiveChecked.value);
if (checked) next.add(value);
else next.delete(value);
emit('update:checkedValues', [...next]);
}
function toggleAll(checked: boolean) {
if (checked) {
emit('update:checkedValues', undefined);
} else {
emit('update:checkedValues', []);
}
}
watch(open, (v) => {
if (v) {
emit('open');
} else {
search.value = '';
}
});
</script>
<template>
<div
class="header-column-filter"
:class="{ 'is-dark': isDark }"
:style="{ left: `${left}px`, top: `${top}px`, width: `${width}px` }"
>
<Dropdown
v-model:open="open"
:trigger="['click']"
placement="bottomRight"
:disabled="disabled"
:overlay-class-name="isDark ? 'sheet-header-filter-dropdown is-dark' : 'sheet-header-filter-dropdown'"
>
<Button
class="filter-btn"
:class="{
'is-active': active,
'is-sorted': !!sortDirection,
}"
size="small"
type="text"
@click.stop
>
<MIcon
:icon="
sortDirection === 'asc'
? 'ant-design:sort-ascending-outlined'
: sortDirection === 'desc'
? 'ant-design:sort-descending-outlined'
: 'ant-design:filter-outlined'
"
/>
</Button>
<template #overlay>
<div class="filter-panel" :class="{ 'is-dark': isDark }" @click.stop>
<Menu mode="vertical">
<SubMenu key="sort" title="排序">
<MenuItem @click="emit('sort', 'asc')">升序</MenuItem>
<MenuItem @click="emit('sort', 'desc')">降序</MenuItem>
<MenuItem @click="emit('clearSort')">清除排序</MenuItem>
</SubMenu>
<MenuItem @click="emit('clearFilter')">清除筛选</MenuItem>
</Menu>
<Divider style="margin: 8px 0" />
<Input
v-model:value="search"
allow-clear
placeholder="搜索"
size="small"
/>
<div v-if="loadingValues" class="value-loading">加载中...</div>
<div v-else class="value-list">
<Checkbox
:checked="allChecked"
:indeterminate="indeterminate"
@change="(e) => toggleAll(!!e.target.checked)"
>
全选
</Checkbox>
<Checkbox
v-for="val in filteredValues"
:key="val || '__empty__'"
:checked="effectiveChecked.has(val)"
@change="(e) => toggleValue(val, !!e.target.checked)"
>
{{ val || '(空白)' }}
</Checkbox>
<div
v-if="uniqueValues.length > 200 && !search.trim()"
class="value-hint"
>
值过多请搜索缩小范围
</div>
</div>
</div>
</template>
</Dropdown>
</div>
</template>
<style scoped lang="scss">
.header-column-filter {
position: absolute;
height: 36px;
pointer-events: none;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 2px;
}
.filter-btn {
pointer-events: auto;
width: 24px;
height: 24px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: #86909c;
&.is-active,
&.is-sorted {
color: #1668dc;
background: rgba(22, 104, 220, 0.08);
}
}
.header-column-filter.is-dark .filter-btn {
color: rgba(255, 255, 255, 0.45);
&:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.08);
}
&.is-active,
&.is-sorted {
color: #3c89e8;
background: rgba(22, 104, 220, 0.25);
}
}
.filter-panel {
width: 240px;
padding: 8px;
background: #fff;
border-radius: 8px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
&.is-dark {
background: #1f1f1f;
border: 1px solid #424242;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
:deep(.ant-menu) {
background: transparent;
color: rgba(255, 255, 255, 0.85);
}
:deep(.ant-menu-item),
:deep(.ant-menu-submenu-title) {
color: rgba(255, 255, 255, 0.85);
&:hover {
background: #303030 !important;
color: rgba(255, 255, 255, 0.95);
}
}
:deep(.ant-divider) {
border-color: #424242;
}
:deep(.ant-input) {
background: #141414;
border-color: #424242;
color: rgba(255, 255, 255, 0.85);
&::placeholder {
color: rgba(255, 255, 255, 0.35);
}
}
:deep(.ant-input-clear-icon) {
color: rgba(255, 255, 255, 0.45);
}
:deep(.ant-checkbox-wrapper) {
color: rgba(255, 255, 255, 0.85);
}
.value-hint,
.value-loading {
color: rgba(255, 255, 255, 0.45);
}
}
}
.value-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow: auto;
margin-top: 8px;
}
.value-hint,
.value-loading {
font-size: 12px;
color: #86909c;
padding: 4px 0;
}
</style>
<style lang="scss">
.sheet-header-filter-dropdown.is-dark {
.ant-dropdown-menu {
background: #1f1f1f;
box-shadow: none;
}
}
</style>

View File

@@ -0,0 +1,150 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { getDataColScreenX } from '../composables/useAutoMeasure';
import {
collectColumnUniqueValues,
isColumnFilterActive,
} from '../composables/filterSort';
import type {
SortDirection,
SpreadsheetColumnConfig,
SpreadsheetHeaderConfig,
SpreadsheetViewState,
} from '../types';
import { COLUMN_LETTER_HEIGHT, HEADER_TITLE_HEIGHT } from '../types';
import HeaderColumnFilter from './HeaderColumnFilter.vue';
const props = defineProps<{
headers: SpreadsheetHeaderConfig[];
columns: SpreadsheetColumnConfig[];
rows: Record<string, unknown>[];
colWidths: number[];
scrollLeft: number;
fixedCols: number[];
fixedWidth: number;
viewportWidth: number;
viewState: SpreadsheetViewState;
enableFilter?: boolean;
isDark?: boolean;
resolveDisplayValue: (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) => unknown;
}>();
const emit = defineEmits<{
sort: [col: number, direction: SortDirection];
clearSort: [];
clearFilter: [col: number];
'update:columnFilter': [col: number, checkedValues: string[] | undefined];
}>();
const uniqueCache = ref<Record<number, string[]>>({});
const loadingCols = ref<Set<number>>(new Set());
const rowsVersion = ref(0);
const filterableHeaders = computed(() =>
props.headers.filter((h) => h.filterable !== false),
);
function invalidateCache() {
uniqueCache.value = {};
rowsVersion.value += 1;
}
watch(
() => props.rows.length,
invalidateCache,
);
function ensureUniqueValues(col: number) {
if (uniqueCache.value[col]?.length !== undefined) return;
loadingCols.value = new Set([...loadingCols.value, col]);
uniqueCache.value[col] = collectColumnUniqueValues(
props.rows,
col,
props.columns,
props.resolveDisplayValue,
);
const next = new Set(loadingCols.value);
next.delete(col);
loadingCols.value = next;
}
function onFilterOpen(col: number) {
ensureUniqueValues(col);
}
function getUniqueValues(col: number): string[] {
return uniqueCache.value[col] ?? [];
}
function isLoadingCol(col: number): boolean {
return loadingCols.value.has(col);
}
function headerLeft(col: number): number {
return getDataColScreenX(
col,
props.colWidths,
props.scrollLeft,
props.fixedCols,
props.fixedWidth,
);
}
function isFilterActive(col: number): boolean {
return isColumnFilterActive(props.viewState.filters[col]);
}
function sortDirection(col: number): SortDirection | null {
if (props.viewState.sort?.col === col) {
return props.viewState.sort.direction;
}
return null;
}
</script>
<template>
<div
v-if="enableFilter !== false"
class="header-filter-layer"
:style="{ width: `${viewportWidth}px`, height: `${COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT}px` }"
>
<HeaderColumnFilter
v-for="header in filterableHeaders"
:key="header.col"
:active="isFilterActive(header.col)"
:checked-values="viewState.filters[header.col]?.checkedValues"
:col="header.col"
:is-dark="isDark"
:left="headerLeft(header.col)"
:loading-values="isLoadingCol(header.col)"
:sort-direction="sortDirection(header.col)"
:title="header.title"
:top="COLUMN_LETTER_HEIGHT"
:unique-values="getUniqueValues(header.col)"
:width="colWidths[header.col] ?? 100"
@clear-filter="emit('clearFilter', header.col)"
@clear-sort="emit('clearSort')"
@open="onFilterOpen(header.col)"
@sort="(dir) => emit('sort', header.col, dir)"
@update:checked-values="
(v) => emit('update:columnFilter', header.col, v)
"
/>
</div>
</template>
<style scoped lang="scss">
.header-filter-layer {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: 3;
}
</style>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import { onUnmounted, ref, watch } from 'vue';
import ColorPicker from '#/components/form/components/color-picker.vue';
const props = defineProps<{
open: boolean;
x: number;
y: number;
col: number;
color: string;
}>();
const emit = defineEmits<{
'update:color': [value: string];
apply: [col: number, color: string | undefined];
close: [];
}>();
const draftColor = ref(props.color);
watch(
() => props.color,
(v) => {
draftColor.value = v;
},
);
watch(
() => props.open,
(open) => {
if (open) {
draftColor.value = props.color;
document.addEventListener('keydown', onKeyDown);
document.addEventListener('pointerdown', onPointerDown, true);
} else {
removeListeners();
}
},
);
function removeListeners() {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('pointerdown', onPointerDown, true);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close');
}
}
function onPointerDown(e: PointerEvent) {
const target = e.target as Node | null;
const panel = panelRef.value;
if (panel && target && !panel.contains(target)) {
emit('close');
}
}
const panelRef = ref<HTMLElement | null>(null);
function onColorUpdate(value: string) {
draftColor.value = value;
emit('update:color', value);
emit('apply', props.col, value || undefined);
}
onUnmounted(removeListeners);
</script>
<template>
<Teleport to="body">
<div
v-if="open"
ref="panelRef"
class="header-color-picker-popover"
:style="{ left: `${x}px`, top: `${y}px` }"
@contextmenu.prevent
>
<div class="popover-title">表头颜色</div>
<ColorPicker
:value="draftColor"
placeholder="选择颜色"
@update:value="onColorUpdate"
/>
</div>
</Teleport>
</template>
<style scoped lang="scss">
.header-color-picker-popover {
position: fixed;
z-index: 10000;
padding: 12px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 8px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.popover-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
color: #1d2129;
}
.is-dark .header-color-picker-popover {
background: #1f1f1f;
border-color: #424242;
.popover-title {
color: rgba(255, 255, 255, 0.85);
}
}
</style>

View File

@@ -0,0 +1,41 @@
import type { SpreadsheetLayoutState } from '../types';
function storageKey(tableName: string, sheetKey?: string) {
return `xk_spreadsheet_layout_${tableName}_${sheetKey ?? 'default'}`;
}
export function readSpreadsheetLayout(
tableName: string,
sheetKey?: string,
): SpreadsheetLayoutState | null {
try {
const raw = localStorage.getItem(storageKey(tableName, sheetKey));
if (!raw) return null;
return JSON.parse(raw) as SpreadsheetLayoutState;
} catch {
return null;
}
}
export function writeSpreadsheetLayout(
tableName: string,
sheetKey: string | undefined,
state: SpreadsheetLayoutState,
) {
localStorage.setItem(storageKey(tableName, sheetKey), JSON.stringify(state));
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
export function debouncedWriteSpreadsheetLayout(
tableName: string,
sheetKey: string | undefined,
state: SpreadsheetLayoutState,
delayMs = 400,
) {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
writeSpreadsheetLayout(tableName, sheetKey, state);
saveTimer = null;
}, delayMs);
}

View File

@@ -0,0 +1,94 @@
import type { SpreadsheetHistoryEntry } from '../types';
const DB_NAME = 'xk_spreadsheet_history';
const STORE_NAME = 'entries';
const DB_VERSION = 1;
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
store.createIndex('tableName', 'tableName', { unique: false });
store.createIndex('tableSheet', ['tableName', 'sheetKey'], {
unique: false,
});
store.createIndex('createdAt', 'createdAt', { unique: false });
store.createIndex('synced', 'synced', { unique: false });
}
};
});
}
export async function appendLocalHistory(entry: SpreadsheetHistoryEntry) {
const db = await openDb();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(entry);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function listLocalHistory(
tableName: string,
sheetKey?: string,
limit = 100,
): Promise<SpreadsheetHistoryEntry[]> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const index = store.index('tableName');
const req = index.getAll(tableName);
req.onsuccess = () => {
let items = (req.result as SpreadsheetHistoryEntry[]) ?? [];
if (sheetKey) {
items = items.filter((i) => i.sheetKey === sheetKey);
}
items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
resolve(items.slice(0, limit));
};
req.onerror = () => reject(req.error);
});
}
export async function listUnsyncedHistory(): Promise<SpreadsheetHistoryEntry[]> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => {
const items = ((req.result as SpreadsheetHistoryEntry[]) ?? []).filter(
(i) => !i.synced,
);
resolve(items);
};
req.onerror = () => reject(req.error);
});
}
export async function markHistorySynced(ids: string[]) {
if (!ids.length) return;
const db = await openDb();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const id of ids) {
const getReq = store.get(id);
getReq.onsuccess = () => {
const entry = getReq.result as SpreadsheetHistoryEntry | undefined;
if (entry) {
entry.synced = true;
store.put(entry);
}
};
}
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}

View File

@@ -0,0 +1,54 @@
import { requestClient } from '#/api/request';
import type { SpreadsheetHistoryEntry } from '../types';
const prefix = 'spreadsheet-history/';
export interface RemoteHistoryItem {
id: number;
table_name: string;
sheet_key?: string;
admin_user_id: number;
admin_user_name: string;
row_key: string;
field_id: string;
action: string;
before_snapshot: unknown;
after_snapshot: unknown;
client_entry_id?: string;
created_at: string;
}
export async function saveHistoryBatch(
entries: SpreadsheetHistoryEntry[],
) {
return requestClient.post<{ saved_ids: string[] }>(`${prefix}save-batch`, {
entries: entries.map((e) => ({
client_entry_id: e.id,
table_name: e.tableName,
sheet_key: e.sheetKey,
row_key: String(e.rowKey),
field_id: e.fieldId,
action: e.action,
before_snapshot: e.beforeSnapshot,
after_snapshot: e.afterSnapshot,
created_at: e.createdAt,
})),
});
}
export async function listRemoteHistory(params: {
table_name: string;
sheet_key?: string;
page?: number;
pageSize?: number;
}) {
return requestClient.get<{
items: RemoteHistoryItem[];
total: number;
}>(`${prefix}list`, { params });
}
export async function rollbackRemoteHistory(id: number) {
return requestClient.post<{ success: boolean }>(`${prefix}rollback`, { id });
}

View File

@@ -0,0 +1,4 @@
export { default as CanvasSpreadsheet } from './CanvasSpreadsheet.vue';
export * from './types';
export { resolveColumnTextAlign } from './composables/columnTextAlign';
export { colIndexToLetters, colLettersToIndex } from './formula/evaluator';

View File

@@ -0,0 +1,161 @@
<script lang="ts" setup>
import { nextTick, ref, watch } from 'vue';
import { Button, Input } from 'ant-design-vue';
import MIcon from '#/components/icon/icon.vue';
const props = defineProps<{
open: boolean;
query: string;
matchCount: number;
activeIndex: number;
isDark?: boolean;
}>();
const emit = defineEmits<{
'update:query': [value: string];
close: [];
next: [];
prev: [];
}>();
const inputRef = ref<InstanceType<typeof Input> | null>(null);
watch(
() => props.open,
(v) => {
if (v) {
void nextTick(() => {
const el = inputRef.value?.$el?.querySelector?.('input') as
| HTMLInputElement
| undefined;
el?.focus();
el?.select();
});
}
},
);
function onInput(v: string) {
emit('update:query', v);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
emit('close');
return;
}
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) emit('prev');
else emit('next');
}
}
const countLabel = () => {
if (!props.query.trim()) return '';
if (props.matchCount === 0) return '无匹配';
const current = props.activeIndex >= 0 ? props.activeIndex + 1 : 0;
return `${current} / ${props.matchCount}`;
};
</script>
<template>
<div
v-if="open"
class="spreadsheet-find-bar"
:class="{ 'is-dark': isDark }"
@mousedown.stop
>
<Input
ref="inputRef"
class="find-input"
:value="query"
placeholder="查找"
size="small"
allow-clear
@update:value="onInput"
@keydown="onKeydown"
/>
<span class="find-count">{{ countLabel() }}</span>
<Button
class="find-nav-btn"
size="small"
type="text"
title="上一个 (Shift+Enter)"
:disabled="matchCount === 0"
@click="emit('prev')"
>
<MIcon icon="mdi:chevron-up" />
</Button>
<Button
class="find-nav-btn"
size="small"
type="text"
title="下一个 (Enter)"
:disabled="matchCount === 0"
@click="emit('next')"
>
<MIcon icon="mdi:chevron-down" />
</Button>
<Button
class="find-close-btn"
size="small"
type="text"
title="关闭 (Esc)"
@click="emit('close')"
>
<MIcon icon="mdi:close" />
</Button>
</div>
</template>
<style scoped lang="scss">
.spreadsheet-find-bar {
position: absolute;
top: 8px;
right: 8px;
z-index: 20;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
&.is-dark {
background: #1f1f1f;
border-color: #424242;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
}
.find-input {
width: 180px;
}
.find-count {
min-width: 52px;
font-size: 12px;
color: #86909c;
text-align: center;
white-space: nowrap;
.is-dark & {
color: rgba(255, 255, 255, 0.45);
}
}
.find-nav-btn,
.find-close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
</style>

View File

@@ -0,0 +1,292 @@
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue';
import { Button, Input, Popover } from 'ant-design-vue';
import type { SpreadsheetFormulaItem } from '#/api/spreadsheet-formula';
const props = withDefaults(
defineProps<{
cellAddress: string;
sourceValue: string;
editable: boolean;
formulas: SpreadsheetFormulaItem[];
isDark?: boolean;
referenceMode?: boolean;
caretPosition?: number;
}>(),
{
isDark: false,
referenceMode: false,
caretPosition: 0,
},
);
const emit = defineEmits<{
commit: [value: string];
'draft-change': [value: string];
'caret-change': [position: number];
}>();
const draft = ref('');
const fxOpen = ref(false);
const focused = ref(false);
const inputRef = ref<{ input: HTMLInputElement } | null>(null);
function syncDraftFromProps() {
if (focused.value) return;
draft.value = props.sourceValue;
}
watch(
() => props.cellAddress,
() => syncDraftFromProps(),
{ immediate: true },
);
watch(
() => props.sourceValue,
() => syncDraftFromProps(),
);
watch(
() => props.caretPosition,
async (pos) => {
if (!focused.value || pos == null) return;
await nextTick();
const el = inputRef.value?.input;
if (el) {
el.setSelectionRange(pos, pos);
}
},
);
function syncCaret() {
const el = inputRef.value?.input;
if (!el) return;
emit('caret-change', el.selectionStart ?? draft.value.length);
}
function onFocus() {
focused.value = true;
syncCaret();
}
function onBlur() {
focused.value = false;
if (!props.editable || props.referenceMode) return;
emit('commit', draft.value);
}
function onEnter() {
focused.value = false;
if (!props.editable) return;
emit('commit', draft.value);
}
function onInput() {
emit('draft-change', draft.value);
syncCaret();
}
function insertFormula(item: SpreadsheetFormulaItem) {
draft.value = item.expression_template.replace(/REF(\d+)/g, (_, n) => {
return `${String.fromCharCode(65 + Number(n))}1`;
});
emit('draft-change', draft.value);
fxOpen.value = false;
}
function insertAtCaret(text: string) {
const pos = props.caretPosition ?? draft.value.length;
draft.value = draft.value.slice(0, pos) + text + draft.value.slice(pos);
const nextCaret = pos + text.length;
emit('draft-change', draft.value);
emit('caret-change', nextCaret);
void nextTick(() => {
const el = inputRef.value?.input;
if (el) {
el.focus();
el.setSelectionRange(nextCaret, nextCaret);
}
});
}
function focusInput() {
inputRef.value?.input?.focus();
}
const formulaList = computed(() => props.formulas);
defineExpose({
insertAtCaret,
focusInput,
getDraft: () => draft.value,
setDraft: (v: string) => {
draft.value = v;
emit('draft-change', v);
},
});
</script>
<template>
<div
class="spreadsheet-formula-bar"
:class="{ 'is-dark': isDark }"
>
<div class="cell-address">{{ cellAddress || ' ' }}</div>
<Popover
v-model:open="fxOpen"
placement="bottomLeft"
trigger="click"
:overlay-class-name="
isDark ? 'sheet-fx-popover is-dark' : 'sheet-fx-popover'
"
>
<template #content>
<div class="fx-list" :class="{ 'is-dark': isDark }">
<div
v-for="item in formulaList"
:key="item.id"
class="fx-item"
@click="insertFormula(item)"
>
<strong>{{ item.name }}</strong>
<span>{{ item.expression_template }}</span>
<small v-if="item.description">{{ item.description }}</small>
</div>
<div v-if="!formulaList.length" class="fx-empty">暂无公式</div>
</div>
</template>
<Button class="fx-btn" size="small" type="text">fx</Button>
</Popover>
<Input
ref="inputRef"
v-model:value="draft"
class="formula-input"
:readonly="!editable"
@blur="onBlur"
@click="syncCaret"
@focus="onFocus"
@input="onInput"
@keydown.enter.prevent="onEnter"
@keyup="syncCaret"
/>
</div>
</template>
<style scoped lang="scss">
.spreadsheet-formula-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-top: 1px solid #e5e6eb;
background: #fafafa;
&.is-dark {
background: #1f1f1f;
border-top-color: #424242;
.cell-address {
background: #141414;
border-color: #424242;
color: rgba(255, 255, 255, 0.85);
}
.fx-btn {
color: #3c89e8;
}
}
}
.cell-address {
min-width: 48px;
height: 28px;
line-height: 28px;
padding: 0 8px;
text-align: center;
font-size: 12px;
font-weight: 600;
border: 1px solid #e5e6eb;
border-radius: 4px;
background: #fff;
}
.fx-btn {
font-style: italic;
font-weight: 600;
color: #1668dc;
}
.formula-input {
flex: 1;
}
.fx-list {
width: 320px;
max-height: 280px;
overflow: auto;
}
.fx-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px;
cursor: pointer;
border-radius: 4px;
&:hover {
background: #f2f3f5;
}
span {
font-size: 12px;
color: #4e5969;
font-family: monospace;
}
small {
font-size: 11px;
color: #86909c;
}
}
.fx-list.is-dark {
.fx-item {
color: rgba(255, 255, 255, 0.85);
&:hover {
background: #303030;
}
span {
color: rgba(255, 255, 255, 0.65);
}
small {
color: rgba(255, 255, 255, 0.45);
}
}
.fx-empty {
color: rgba(255, 255, 255, 0.45);
}
}
.fx-empty {
padding: 12px;
color: #86909c;
text-align: center;
}
</style>
<style lang="scss">
.sheet-fx-popover.is-dark {
.ant-popover-inner {
background: #1f1f1f;
border: 1px solid #424242;
}
}
</style>

View File

@@ -0,0 +1,194 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Button,
Input,
message,
Popconfirm,
Space,
Table,
} from 'ant-design-vue';
import {
createSpreadsheetFormula,
deleteSpreadsheetFormula,
listSpreadsheetFormula,
type SpreadsheetFormulaItem,
updateSpreadsheetFormula,
} from '#/api/spreadsheet-formula';
const emit = defineEmits<{
updated: [];
}>();
const items = ref<SpreadsheetFormulaItem[]>([]);
const loading = ref(false);
const editingId = ref<number | null>(null);
const formCode = ref('');
const formName = ref('');
const formDesc = ref('');
const formExpression = ref('');
const formRefSlots = ref('[{"slot":"REF0","label":"参数1"}]');
const [Modal, modalApi] = useVbenModal({
title: '全局公式库',
class: 'w-[720px]',
onOpenChange(isOpen) {
if (isOpen) void loadList();
},
});
async function loadList() {
loading.value = true;
try {
items.value = await listSpreadsheetFormula();
} finally {
loading.value = false;
}
}
function resetForm() {
editingId.value = null;
formCode.value = '';
formName.value = '';
formDesc.value = '';
formExpression.value = '';
formRefSlots.value = '[{"slot":"REF0","label":"参数1"}]';
}
function startEdit(item: SpreadsheetFormulaItem) {
editingId.value = item.id;
formCode.value = item.code;
formName.value = item.name;
formDesc.value = item.description;
formExpression.value = item.expression_template;
formRefSlots.value = JSON.stringify(item.ref_slots, null, 2);
}
async function saveForm() {
let refSlots: { slot: string; label: string }[];
try {
refSlots = JSON.parse(formRefSlots.value);
if (!Array.isArray(refSlots)) throw new Error('invalid');
} catch {
message.error('ref_slots 必须是 JSON 数组');
return;
}
try {
if (editingId.value) {
await updateSpreadsheetFormula({
id: editingId.value,
code: formCode.value,
name: formName.value,
description: formDesc.value,
expression_template: formExpression.value,
ref_slots: refSlots,
});
message.success('已更新');
} else {
await createSpreadsheetFormula({
code: formCode.value,
name: formName.value,
description: formDesc.value,
expression_template: formExpression.value,
ref_slots: refSlots,
});
message.success('已创建');
}
resetForm();
await loadList();
emit('updated');
} catch (err) {
message.error(String(err));
}
}
async function removeItem(id: number) {
await deleteSpreadsheetFormula(id);
message.success('已删除');
await loadList();
emit('updated');
}
const columns = computed(() => [
{ title: '编码', dataIndex: 'code', width: 120 },
{ title: '名称', dataIndex: 'name', width: 120 },
{ title: '表达式', dataIndex: 'expression_template' },
{
title: '操作',
key: 'action',
width: 120,
},
]);
defineExpose({
open: () => modalApi.open(),
});
</script>
<template>
<Modal>
<div class="formula-library">
<Table
:columns="columns"
:data-source="items"
:loading="loading"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<Space>
<Button size="small" type="link" @click="startEdit(record)">
编辑
</Button>
<Popconfirm title="确认删除?" @confirm="removeItem(record.id)">
<Button danger size="small" type="link">删除</Button>
</Popconfirm>
</Space>
</template>
</template>
</Table>
<div class="formula-form">
<Space direction="vertical" style="width: 100%">
<Input v-model:value="formCode" placeholder="编码 code" />
<Input v-model:value="formName" placeholder="显示名称" />
<Input v-model:value="formDesc" placeholder="说明" />
<Input
v-model:value="formExpression"
placeholder="表达式模板,如 =(REF0-REF1)/REF0"
/>
<Input.TextArea
v-model:value="formRefSlots"
:rows="4"
placeholder='ref_slots JSON如 [{"slot":"REF0","label":"被减数"}]'
/>
<Space>
<Button type="primary" @click="saveForm">
{{ editingId ? '保存' : '新建' }}
</Button>
<Button @click="resetForm">清空</Button>
</Space>
</Space>
</div>
</div>
</Modal>
</template>
<style scoped lang="scss">
.formula-library {
display: flex;
flex-direction: column;
gap: 16px;
}
.formula-form {
padding-top: 12px;
border-top: 1px solid #e5e6eb;
}
</style>

View File

@@ -0,0 +1,304 @@
<script lang="ts" setup>
import { ref } from 'vue';
import {
Button,
Drawer,
Empty,
Space,
Tag,
Tooltip,
} from 'ant-design-vue';
import MIcon from '#/components/icon/icon.vue';
import { listLocalHistory } from '../history/localHistoryStore';
import { listRemoteHistory } from '../history/remoteHistoryApi';
import type { SpreadsheetHistoryEntry, SpreadsheetTableConfig } from '../types';
import SpreadsheetFormulaLibraryModal from './SpreadsheetFormulaLibraryModal.vue';
const props = defineProps<{
tableConfig: SpreadsheetTableConfig;
canUndo: boolean;
canRedo: boolean;
isDark?: boolean;
refreshing?: boolean;
showConfigButton?: boolean;
showFormulaLibrary?: boolean;
workbookName?: string;
}>();
const emit = defineEmits<{
undo: [];
redo: [];
refresh: [];
openConfig: [];
rollback: [entry: SpreadsheetHistoryEntry];
formulaLibraryUpdated: [];
}>();
const historyOpen = ref(false);
const historyItems = ref<SpreadsheetHistoryEntry[]>([]);
const loadingHistory = ref(false);
const formulaLibraryRef = ref<InstanceType<
typeof SpreadsheetFormulaLibraryModal
> | null>(null);
let historyLoadToken = 0;
function invalidateHistoryLoad() {
historyLoadToken += 1;
loadingHistory.value = false;
}
async function openHistory() {
historyOpen.value = true;
const token = ++historyLoadToken;
loadingHistory.value = true;
try {
const local = await listLocalHistory(
props.tableConfig.tableName,
props.tableConfig.sheetKey,
);
if (token !== historyLoadToken) return;
try {
const remote = await listRemoteHistory({
table_name: props.tableConfig.tableName,
sheet_key: props.tableConfig.sheetKey,
pageSize: 50,
});
if (token !== historyLoadToken) return;
const merged = [...local];
for (const r of remote.items ?? []) {
if (!merged.some((m) => m.id === r.client_entry_id)) {
merged.push({
id: r.client_entry_id ?? String(r.id),
tableName: r.table_name,
sheetKey: r.sheet_key,
userId: r.admin_user_id,
userName: r.admin_user_name,
action: r.action as SpreadsheetHistoryEntry['action'],
rowKey: r.row_key,
fieldId: r.field_id,
beforeSnapshot: r.before_snapshot,
afterSnapshot: r.after_snapshot,
createdAt: r.created_at,
synced: true,
});
}
}
merged.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
historyItems.value = merged.slice(0, 100);
} catch {
if (token !== historyLoadToken) return;
historyItems.value = local;
}
} finally {
if (token === historyLoadToken) {
loadingHistory.value = false;
}
}
}
async function handleRollback(entry: SpreadsheetHistoryEntry) {
emit('rollback', entry);
historyOpen.value = false;
}
function openFormulaLibrary() {
formulaLibraryRef.value?.open();
}
</script>
<template>
<div class="spreadsheet-toolbar" :class="{ 'is-dark': isDark }">
<div class="toolbar-row toolbar-main">
<Tag v-if="workbookName" class="workbook-tag" color="processing">
{{ workbookName }}
</Tag>
<span v-else class="workbook-tag plain">{{ tableConfig.tableName }}</span>
<span class="toolbar-divider" />
<Space :size="4">
<Tooltip title="撤回 (Ctrl+Z)">
<Button size="small" :disabled="!canUndo" @click="emit('undo')">
<MIcon icon="ant-design:undo-outlined" />
</Button>
</Tooltip>
<Tooltip title="重做 (Ctrl+Y)">
<Button size="small" :disabled="!canRedo" @click="emit('redo')">
<MIcon icon="ant-design:redo-outlined" />
</Button>
</Tooltip>
<Tooltip title="刷新">
<Button
size="small"
:disabled="refreshing"
:loading="refreshing"
@click="emit('refresh')"
>
<MIcon icon="ant-design:reload-outlined" />
</Button>
</Tooltip>
<Button
v-if="tableConfig.enableHistory !== false"
size="small"
@click="openHistory"
>
<MIcon icon="ant-design:history-outlined" />
历史
</Button>
</Space>
<span class="toolbar-divider" />
<Space :size="4">
<Button
v-if="showConfigButton"
size="small"
@click="emit('openConfig')"
>
<MIcon icon="mdi:table-cog" />
Excel 配置
</Button>
<Button
v-if="showFormulaLibrary"
size="small"
@click="openFormulaLibrary"
>
<MIcon icon="mdi:function-variant" />
公式库
</Button>
</Space>
</div>
<slot name="formula-bar" />
<SpreadsheetFormulaLibraryModal
ref="formulaLibraryRef"
@updated="emit('formulaLibraryUpdated')"
/>
<Drawer
v-model:open="historyOpen"
:title="`${workbookName || tableConfig.tableName} - 编辑历史`"
width="480"
@after-open-change="(open: boolean) => { if (!open) invalidateHistoryLoad(); }"
>
<div v-if="loadingHistory" class="history-loading">加载中...</div>
<Empty v-else-if="!historyItems.length" description="暂无历史记录" />
<div v-else class="history-list">
<div v-for="item in historyItems" :key="item.id" class="history-item">
<div class="history-meta">
<strong>{{ item.userName }}</strong>
<span>{{ item.createdAt }}</span>
<Tag size="small">{{ item.action }}</Tag>
</div>
<div class="history-diff">
行 {{ item.rowKey }} · {{ item.fieldId }}
<br />
{{ item.beforeSnapshot }} → {{ item.afterSnapshot }}
</div>
<Button size="small" type="link" @click="handleRollback(item)">
回滚到此版本
</Button>
</div>
</div>
</Drawer>
</div>
</template>
<style scoped lang="scss">
.spreadsheet-toolbar {
display: flex;
flex-direction: column;
background: #fff;
}
.toolbar-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 8px 10px;
}
.toolbar-main {
border-bottom: 1px solid #e5e6eb;
}
.workbook-tag {
font-weight: 600;
margin: 0;
&.plain {
font-size: 13px;
color: #1d2129;
}
}
.toolbar-divider {
width: 1px;
height: 20px;
background: #e5e6eb;
margin: 0 4px;
}
.history-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.history-item {
padding: 12px;
border: 1px solid #e5e6eb;
border-radius: 8px;
}
.history-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
color: #86909c;
}
.history-diff {
font-size: 13px;
margin-bottom: 4px;
}
.history-loading {
text-align: center;
padding: 24px;
color: #86909c;
}
.spreadsheet-toolbar.is-dark {
background: #141414;
.toolbar-main {
border-bottom-color: #424242;
}
.workbook-tag.plain {
color: rgba(255, 255, 255, 0.85);
}
.toolbar-divider {
background: #424242;
}
.history-item {
border-color: #424242;
}
.history-meta,
.history-loading {
color: rgba(255, 255, 255, 0.45);
}
}
</style>

View File

@@ -0,0 +1,206 @@
import type { ContextMenuItem } from '#/components/context-menu';
export type CellType =
| 'text'
| 'number'
| 'image'
| 'video'
| 'audio'
| 'file'
| 'readonly'
| 'formula';
export interface CellRange {
startRow: number;
startCol: number;
endRow: number;
endCol: number;
}
export interface CellEditContext {
fieldId: string;
fieldValue: unknown;
rowIndex: number;
colIndex: number;
rowKey: string | number;
rowData: Record<string, unknown>;
editParams: Record<string, unknown>;
selection?: CellRange;
}
export interface HeaderContextMenuContext {
colIndex: number;
fieldId?: string;
headerTitle: string;
filterValue?: string;
}
export type ConditionalColorMode = 'background' | 'text' | 'both';
export type SpreadsheetTextAlign = 'left' | 'center' | 'right';
/** undefined | 'inherit' 均表示跟随全局 defaultTextAlign */
export type ColumnTextAlign = SpreadsheetTextAlign | 'inherit';
export interface ConditionalFormatRule {
threshold: number;
bgColor?: string;
textColor?: string;
colorMode?: ConditionalColorMode;
}
export interface ColumnConditionalFormat {
gt?: ConditionalFormatRule;
lt?: ConditionalFormatRule;
}
export interface SpreadsheetColumnConfig {
fieldId: string;
fieldName: string;
cellType: CellType;
col: number;
editable?: boolean;
formula?: string;
suffix?: string;
textAlign?: ColumnTextAlign;
conditionalFormat?: ColumnConditionalFormat;
onEdit?: (ctx: CellEditContext) => void | Promise<void>;
onDoubleClick?: (ctx: CellEditContext) => void;
editParams?: Record<string, unknown>;
contextMenu?:
| ContextMenuItem[]
| ((ctx: CellEditContext) => ContextMenuItem[]);
}
export interface SpreadsheetHeaderConfig {
title: string;
col: number;
fieldId?: string;
filterable?: boolean;
width?: number | 'auto';
headerColor?: string;
fixed?: boolean;
contextMenu?:
| ContextMenuItem[]
| ((ctx: HeaderContextMenuContext) => ContextMenuItem[]);
}
export interface SpreadsheetLayoutState {
colWidths?: Record<number, number>;
headers?: Array<{
col: number;
headerColor?: string;
fixed?: boolean;
}>;
}
export interface SpreadsheetBatchCellChange {
ctx: CellEditContext;
before: unknown;
after: unknown;
col: number;
viewRow: number;
}
export interface SpreadsheetTableConfig {
tableName: string;
sheetKey?: string;
headers: SpreadsheetHeaderConfig[];
enableFilter?: boolean;
enableHistory?: boolean;
rowHeight?: number | 'auto';
defaultColWidth?: number;
defaultTextAlign?: SpreadsheetTextAlign;
onBatchEdit?: (changes: SpreadsheetBatchCellChange[]) => Promise<void>;
onRefresh?: () => void | Promise<void>;
headerContextMenu?:
| ContextMenuItem[]
| ((ctx: HeaderContextMenuContext) => ContextMenuItem[]);
}
export type SortDirection = 'asc' | 'desc';
export interface SpreadsheetColumnSort {
col: number;
direction: SortDirection;
}
/** checkedValues === undefined means all selected (no filter) */
export interface SpreadsheetColumnFilter {
checkedValues?: string[];
}
export interface SpreadsheetViewState {
filters: Record<number, SpreadsheetColumnFilter>;
sort: SpreadsheetColumnSort | null;
}
export function createDefaultViewState(): SpreadsheetViewState {
return { filters: {}, sort: null };
}
export interface SpreadsheetHistoryEntry {
id: string;
tableName: string;
sheetKey?: string;
userId: number;
userName: string;
action: 'edit' | 'fill' | 'paste' | 'rollback';
rowKey: string | number;
fieldId: string;
beforeSnapshot: unknown;
afterSnapshot: unknown;
createdAt: string;
synced: boolean;
}
export interface SpreadsheetCommand {
label: string;
undo: () => void | Promise<void>;
redo: () => void | Promise<void>;
}
export interface ClipboardMatrix {
rows: unknown[][];
startRow: number;
startCol: number;
}
export interface SpreadsheetEngineExpose {
copy: (selectionOverride?: CellRange | CellRange[]) => void;
copyAsImage: (selectionOverride?: CellRange | CellRange[]) => Promise<void>;
paste: () => Promise<void>;
undo: () => Promise<void>;
redo: () => Promise<void>;
canUndo: () => boolean;
canRedo: () => boolean;
canCopy: () => boolean;
canCopyAsImage: () => boolean;
canPaste: () => boolean;
canEdit: (row: number, col: number) => boolean;
getSelection: () => CellRange[];
getCellEditContext: (row: number, col: number) => CellEditContext | null;
getHeaderContext: (col: number) => HeaderContextMenuContext;
setCellValue: (
row: number,
col: number,
value: unknown,
recordHistory?: boolean,
action?: SpreadsheetHistoryEntry['action'],
) => Promise<void>;
fillRange: (
source: CellRange,
target: CellRange,
) => Promise<void>;
getColumnConfig: (col: number) => SpreadsheetColumnConfig | undefined;
getFilterValue: (col: number) => string;
}
export const COLUMN_LETTER_HEIGHT = 22;
export const HEADER_TITLE_HEIGHT = 36;
export const HEADER_HEIGHT = COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT;
export const ROW_NUMBER_WIDTH = 48;
export const DEFAULT_ROW_HEIGHT = 32;
export const DEFAULT_COL_WIDTH = 100;
export const MIN_COL_WIDTH = 60;
export const SCROLL_BUFFER_ROWS = 8;