Appearance
React 电子表格
基于飞书数据结构实现的电子表格示例,支持选区、剪贴板、合并单元格与右键菜单等能力。
使用的 API
ts
type Column = {
key: string; // 列标识(必填)
title: string; // 列标题(必填)
width: number; // 列宽(必填)
}
type Options = {
list: T[]; // 数据列表
itemKey: string; // 行唯一标识字段名
estimatedSize: number; // 行预估高度(px)
buffer: number; // 缓冲区行数
border: boolean; // 是否显示边框
plugins: [vtCellSelection()]; // 单元格选区(插件)
merges: MergeCell[]; // 表身合并单元格
// 编辑触发方式改为插件配置:vtCellEditor({ trigger: 'dblclick' })
cellStyle: string | ((column, row) => string); // 单元格额外 style
plugins: [ // 插件:右键菜单 + 剪贴板 + 搜索
vtContextMenu(provider),
vtClipboard({ mimeType, onCopy, onPaste }),
vtSearch(),
];
}示例
微应用尚未挂载。
源码
点击查看源码
tsx
import React from 'react';
import {
VirtTableReact,
vtContextMenu,
vtClipboard,
vtSearch,
type ReactTableColumn,
type VirtTableRef,
type ContextMenuContext,
type ContextMenuItem,
vtCellEditor,
vtCellSelection,
} from '@virt-table/react';
import {
type MergeCell,
type SpreadsheetCellData,
type SpreadsheetClipboardPayload,
createSpreadsheetClipboardHandlers,
renderSpreadsheetCellHtml,
setSpreadsheetCellData,
} from '@virt-table/vanilla';
type SpreadsheetRow = Record<string, unknown> & {
id: number | string;
_sheetCells?: Record<string, SpreadsheetCellData>;
};
// ─── Feishu types ───
interface FeishuRichTextSegment {
text: string;
style?: {
font_size?: number;
bold?: boolean;
italic?: boolean;
color?: string;
underline?: boolean;
strikethrough?: boolean;
};
}
interface FeishuCellStyle {
align?: 'LEFT' | 'CENTER' | 'RIGHT';
valign?: 'TOP' | 'MIDDLE' | 'BOTTOM';
bg_color?: string;
bold?: boolean;
italic?: boolean;
font_size?: number;
color?: string;
}
interface FeishuCell {
value?: string | number | boolean | null;
rich_text?: FeishuRichTextSegment[];
style?: FeishuCellStyle;
}
interface FeishuMergeInfo {
start_row: number;
start_col: number;
end_row: number;
end_col: number;
}
interface FeishuSheet {
sheet_id: string;
title: string;
row_count: number;
col_count: number;
col_widths?: Record<string, number>;
merge_info_list?: FeishuMergeInfo[];
cells: Record<string, Record<string, FeishuCell>>;
}
// ─── helpers ───
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
function colIndexToKey(i: number): string {
let name = '';
let n = i;
while (n >= 0) {
name = CHARS[n % 26] + name;
n = Math.floor(n / 26) - 1;
}
return name;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
function richTextToHtml(segments: FeishuRichTextSegment[]): string {
return segments.map((seg) => {
const parts: string[] = [];
const s = seg.style;
if (s?.font_size) parts.push(`font-size:${s.font_size}px`);
if (s?.bold) parts.push('font-weight:700');
if (s?.italic) parts.push('font-style:italic');
if (s?.color) parts.push(`color:${s.color}`);
const deco: string[] = [];
if (s?.underline) deco.push('underline');
if (s?.strikethrough) deco.push('line-through');
if (deco.length) parts.push(`text-decoration:${deco.join(' ')}`);
const style = parts.length ? ` style="${parts.join(';')}"` : '';
return `<span${style}>${escapeHtml(seg.text)}</span>`;
}).join('');
}
function feishuCellToSpreadsheetCell(cell: FeishuCell): SpreadsheetCellData {
let result: SpreadsheetCellData;
if (cell.rich_text?.length) {
result = {
type: 'rich-text',
value: cell.value != null ? String(cell.value) : cell.rich_text.map((s) => s.text).join(''),
richTextHtml: richTextToHtml(cell.rich_text),
};
} else {
const v = cell.value;
if (v == null) {
result = { type: 'text', value: '' };
} else {
const s = cell.style;
if (s && (s.bold || s.italic || s.font_size || s.color)) {
const parts: string[] = [];
if (s.bold) parts.push('font-weight:700');
if (s.italic) parts.push('font-style:italic');
if (s.font_size) parts.push(`font-size:${s.font_size}px`);
if (s.color) parts.push(`color:${s.color}`);
result = {
type: 'rich-text',
value: String(v),
richTextHtml: `<span style="${parts.join(';')}">${escapeHtml(String(v))}</span>`,
};
} else {
result = { type: 'text', value: String(v) };
}
}
}
const s = cell.style;
if (s?.bg_color) result.bgColor = s.bg_color;
if (s?.align) result.textAlign = s.align.toLowerCase();
if (s?.valign) result.vAlign = s.valign.toLowerCase() as SpreadsheetCellData['vAlign'];
return result;
}
function rangesOverlap(r1s: number, c1s: number, r1e: number, c1e: number, r2s: number, c2s: number, r2e: number, c2e: number) {
return r1s <= r2e && r1e >= r2s && c1s <= c2e && c1e >= c2s;
}
function expandSelectionWithMerges(startRow: number, startCol: number, endRow: number, endCol: number, existingMerges: MergeCell[]) {
let sr = startRow; let sc = startCol; let er = endRow; let ec = endCol;
let changed = true;
while (changed) {
changed = false;
for (const m of existingMerges) {
const mr2 = m.rowIndex + m.rowspan - 1;
const mc2 = m.colIndex + m.colspan - 1;
if (rangesOverlap(m.rowIndex, m.colIndex, mr2, mc2, sr, sc, er, ec)) {
if (m.rowIndex < sr) { sr = m.rowIndex; changed = true; }
if (mr2 > er) { er = mr2; changed = true; }
if (m.colIndex < sc) { sc = m.colIndex; changed = true; }
if (mc2 > ec) { ec = mc2; changed = true; }
}
}
}
return { startRow: sr, startCol: sc, endRow: er, endCol: ec };
}
// ─── data ───
const FEISHU_SHEET: FeishuSheet = {
sheet_id: 'sheet_1',
title: '数据总表',
row_count: 500,
col_count: 26,
col_widths: { '0': 160, '1': 80, '2': 200, '3': 120, '4': 120, '5': 180, '6': 100, '7': 120, '8': 100 },
merge_info_list: [
{ start_row: 0, start_col: 0, end_row: 0, end_col: 8 },
{ start_row: 2, start_col: 0, end_row: 4, end_col: 0 },
{ start_row: 5, start_col: 0, end_row: 7, end_col: 0 },
{ start_row: 8, start_col: 0, end_row: 9, end_col: 0 },
{ start_row: 11, start_col: 1, end_row: 11, end_col: 8 },
],
cells: {
'0': {
'0': {
value: '2026年Q2 项目排期台账',
rich_text: [
{ text: '2026年Q2', style: { font_size: 16, bold: true, color: '#1F2937' } },
{ text: ' 项目排期台账', style: { font_size: 14, color: '#2563EB' } },
],
style: { align: 'CENTER', valign: 'MIDDLE', bg_color: '#F3F4F6' },
},
},
'1': {
'0': { value: '需求模块', style: { bold: true, bg_color: '#EFF6FF', align: 'CENTER' } },
'1': { value: '优先级', style: { bold: true, bg_color: '#EFF6FF', align: 'CENTER' } },
'2': { value: '任务分解', style: { bold: true, bg_color: '#EFF6FF' } },
'3': { value: '产研负责人', style: { bold: true, bg_color: '#EFF6FF' } },
'4': { value: '研发负责人', style: { bold: true, bg_color: '#EFF6FF' } },
'5': { value: '需求/技术文档', style: { bold: true, bg_color: '#EFF6FF' } },
'6': { value: '是否验收', style: { bold: true, bg_color: '#EFF6FF', align: 'CENTER' } },
'7': { value: '截止时间', style: { bold: true, bg_color: '#EFF6FF' } },
'8': { value: '进度', style: { bold: true, bg_color: '#EFF6FF', align: 'CENTER' } },
},
'2': {
'0': { value: '北单专项 1', style: { bold: true, bg_color: '#F0F9FF' } },
'1': { value: 'P0', rich_text: [{ text: 'P0', style: { bold: true, color: '#DC2626' } }], style: { align: 'CENTER', bg_color: '#FEF2F2' } },
'2': { value: '渠道改造 V2.0', rich_text: [{ text: '渠道改造', style: { bold: true, color: '#059669' } }, { text: ' V2.0', style: { color: '#6B7280' } }] },
'3': { value: '李炎杰' },
'4': { value: '余兴达' },
'5': { value: '需求评审通过,接口文档已同步', rich_text: [{ text: '需求评审通过', style: { color: '#059669' } }, { text: ',接口文档已同步', style: { color: '#6B7280' } }] },
'6': { value: '✓', style: { color: '#059669', align: 'CENTER' } },
'7': { value: '2026-12-15' },
'8': { value: '75%', style: { align: 'CENTER', color: '#059669' } },
},
'3': {
'1': { value: 'P1', rich_text: [{ text: 'P1', style: { bold: true, color: '#D97706' } }], style: { align: 'CENTER', bg_color: '#FFFBEB' } },
'2': { value: '能力校验模块' },
'3': { value: '马小波' },
'4': { value: '罗小龙' },
'5': { value: '接口已联调', style: { color: '#6B7280' } },
'6': { value: '✗', style: { color: '#DC2626', align: 'CENTER' } },
'7': { value: '2026-12-20' },
'8': { value: '40%', style: { align: 'CENTER', color: '#D97706' } },
},
'4': {
'1': { value: 'P0', rich_text: [{ text: 'P0', style: { bold: true, color: '#DC2626' } }], style: { align: 'CENTER', bg_color: '#FEF2F2' } },
'2': { value: '生产发布与灰度', rich_text: [{ text: '生产发布', style: { bold: true } }, { text: '与灰度', style: { color: '#9CA3AF', italic: true } }] },
'3': { value: '曾伟杰' },
'4': { value: '马国涛' },
'5': { value: '发布窗口已确认' },
'6': { value: '✓', style: { color: '#059669', align: 'CENTER' } },
'7': { value: '2026-12-27' },
'8': { value: '60%', style: { align: 'CENTER' } },
},
'5': {
'0': { value: '北单专项 2', style: { bold: true, bg_color: '#F0FDF4' } },
'1': { value: 'P0', rich_text: [{ text: 'P0', style: { bold: true, color: '#DC2626' } }], style: { align: 'CENTER', bg_color: '#FEF2F2' } },
'2': { value: '主流程改造' },
'3': { value: '曾伟杰' },
'4': { value: '马国涛' },
'5': { value: '流程图已确认,PRD 评审中', rich_text: [{ text: '流程图已确认', style: { color: '#059669' } }, { text: ',PRD 评审中', style: { color: '#D97706' } }] },
'6': { value: '✓', style: { color: '#059669', align: 'CENTER' } },
'7': { value: '2026-12-25' },
'8': { value: '90%', style: { align: 'CENTER', color: '#059669', bold: true } },
},
'6': {
'1': { value: 'P2', rich_text: [{ text: 'P2', style: { bold: true, color: '#2563EB' } }], style: { align: 'CENTER', bg_color: '#EFF6FF' } },
'2': { value: '体验优化' },
'3': { value: '万明悦' },
'4': { value: '马国涛' },
'5': { value: '交互稿已评审', style: { color: '#6B7280' } },
'6': { value: '✗', style: { color: '#DC2626', align: 'CENTER' } },
'7': { value: '2027-01-10' },
'8': { value: '15%', style: { align: 'CENTER', color: '#DC2626' } },
},
'7': {
'1': { value: 'P1', rich_text: [{ text: 'P1', style: { bold: true, color: '#D97706' } }], style: { align: 'CENTER', bg_color: '#FFFBEB' } },
'2': { value: '压测与回归' },
'3': { value: '贾小铭' },
'4': { value: '余兴达' },
'5': { value: '压测报告待同步', style: { color: '#D97706' } },
'6': { value: '✗', style: { color: '#DC2626', align: 'CENTER' } },
'7': { value: '2026-12-28' },
'8': { value: '50%', style: { align: 'CENTER' } },
},
'8': {
'0': { value: '北单专项 3', style: { bold: true, bg_color: '#FFF7ED' } },
'1': { value: 'P0', rich_text: [{ text: 'P0', style: { bold: true, color: '#DC2626' } }], style: { align: 'CENTER', bg_color: '#FEF2F2' } },
'2': { value: '新功能接入', rich_text: [{ text: '新功能接入', style: { bold: true, color: '#7C3AED' } }] },
'3': { value: '曾伟杰' },
'4': { value: '马国涛' },
'5': { value: '方案评审完成,排期确认', rich_text: [{ text: '方案评审完成', style: { color: '#059669', bold: true } }, { text: ',排期确认', style: { color: '#6B7280' } }] },
'6': { value: '✓', style: { color: '#059669', align: 'CENTER' } },
'7': { value: '2026-12-30' },
'8': { value: '85%', style: { align: 'CENTER', color: '#059669' } },
},
'9': {
'1': { value: 'P1', rich_text: [{ text: 'P1', style: { bold: true, color: '#D97706' } }], style: { align: 'CENTER', bg_color: '#FFFBEB' } },
'2': { value: '埋点验收' },
'3': { value: '罗小龙' },
'4': { value: '余兴达' },
'5': { value: '埋点字典待确认', style: { color: '#D97706' } },
'6': { value: '✗', style: { color: '#DC2626', align: 'CENTER' } },
'7': { value: '2027-01-05' },
'8': { value: '20%', style: { align: 'CENTER', color: '#DC2626' } },
},
'11': {
'0': { value: '说明', rich_text: [{ text: '💡 说明', style: { bold: true, color: '#2563EB' } }] },
'1': {
value: '本表格数据基于飞书电子表格数据结构',
rich_text: [
{ text: '本表格数据基于', style: { color: '#6B7280' } },
{ text: '飞书电子表格数据结构', style: { bold: true, color: '#2563EB' } },
{ text: '(稀疏 cells + rich_text + merge_info_list),', style: { color: '#6B7280' } },
{ text: '支持选区复制粘贴', style: { bold: true, color: '#059669' } },
{ text: '并保留单元格类型与合并信息。', style: { color: '#6B7280' } },
],
},
},
},
};
function convertFeishuSheet(sheet: FeishuSheet) {
const colCount = sheet.col_count;
const columns: ReactTableColumn[] = [
{ key: '__index', title: '', width: 50, type: 'index', fixed: 'left', align: 'center' },
...Array.from({ length: colCount }, (_, i) => {
const key = colIndexToKey(i);
const w = sheet.col_widths?.[String(i)];
return {
key,
title: key,
width: w ?? 120,
resizable: true,
render: ({ row, column }: { row: Record<string, unknown>; column: { key: string } }) => {
const cell = (row as SpreadsheetRow)._sheetCells?.[column.key];
return cell ? renderSpreadsheetCellHtml(cell) : escapeHtml(String(row[column.key] ?? ''));
},
} satisfies ReactTableColumn;
}),
];
const list: SpreadsheetRow[] = Array.from({ length: sheet.row_count }, (_, r) => {
const row: SpreadsheetRow = { id: r, _sheetCells: {} };
for (let c = 0; c < colCount; c++) row[colIndexToKey(c)] = '';
return row;
});
for (const [rowStr, cols] of Object.entries(sheet.cells)) {
const r = Number(rowStr);
const row = list[r];
if (!row) continue;
for (const [colStr, cell] of Object.entries(cols)) {
const c = Number(colStr);
const key = colIndexToKey(c);
setSpreadsheetCellData(row, key, feishuCellToSpreadsheetCell(cell));
}
}
const merges: MergeCell[] = (sheet.merge_info_list ?? []).map((m) => ({
rowIndex: m.start_row,
colIndex: m.start_col,
rowspan: m.end_row - m.start_row + 1,
colspan: m.end_col - m.start_col + 1,
}));
const cellStyle = (_col: ReactTableColumn, row: Record<string, unknown>) => {
const cell = (row as SpreadsheetRow)._sheetCells?.[_col.key];
if (!cell) return '';
const parts: string[] = [];
if (cell.bgColor) parts.push(`background-color:${cell.bgColor}`);
if (cell.textAlign) parts.push(`text-align:${cell.textAlign}`);
if (cell.vAlign) parts.push(`vertical-align:${cell.vAlign}`);
return parts.join(';');
};
return { columns, list, merges, cellStyle, colCount };
}
// ─── undo/redo ───
interface CellSnapshot {
rowIndex: number;
colKey: string;
displayValue: unknown;
cellData: SpreadsheetCellData | null;
}
interface UndoEntry {
label: string;
cellsBefore: CellSnapshot[];
cellsAfter: CellSnapshot[];
mergesBefore: MergeCell[];
mergesAfter: MergeCell[];
}
const tableData = convertFeishuSheet(FEISHU_SHEET);
export default function SpreadsheetTable() {
const tableRef = React.useRef<VirtTableRef>(null);
const mergeStateRef = React.useRef<MergeCell[]>([...tableData.merges]);
const undoStackRef = React.useRef<UndoEntry[]>([]);
const redoStackRef = React.useRef<UndoEntry[]>([]);
const [status, setStatus] = React.useState(
`飞书数据结构电子表格:${FEISHU_SHEET.row_count} 行 × ${FEISHU_SHEET.col_count} 列(支持选区复制粘贴 + 撤销重做)`,
);
const [, forceRender] = React.useReducer((x: number) => x + 1, 0);
function snapshotCell(row: SpreadsheetRow, colKey: string): CellSnapshot {
return {
rowIndex: row.id as number,
colKey,
displayValue: row[colKey],
cellData: row._sheetCells?.[colKey] ? { ...row._sheetCells[colKey] } : null,
};
}
function restoreCells(snapshots: CellSnapshot[]) {
for (const snap of snapshots) {
const row = tableData.list[snap.rowIndex];
if (!row) continue;
if (snap.cellData) {
setSpreadsheetCellData(row, snap.colKey, snap.cellData);
} else {
row[snap.colKey] = snap.displayValue;
if (row._sheetCells) delete row._sheetCells[snap.colKey];
}
}
}
function pushUndo(entry: UndoEntry) {
undoStackRef.current.push(entry);
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
redoStackRef.current = [];
forceRender();
}
function applyUndoRedo(entry: UndoEntry, direction: 'undo' | 'redo') {
const cells = direction === 'undo' ? entry.cellsBefore : entry.cellsAfter;
const m = direction === 'undo' ? entry.mergesBefore : entry.mergesAfter;
restoreCells(cells);
mergeStateRef.current = [...m];
tableRef.current?.setMerges([...m]);
const t = tableRef.current?.getTable() as { setList?: (l: SpreadsheetRow[]) => void; forceUpdate?: () => void } | null;
t?.setList?.([...tableData.list]);
t?.forceUpdate?.();
}
const undo = React.useCallback(() => {
const entry = undoStackRef.current.pop();
if (!entry) return;
applyUndoRedo(entry, 'undo');
redoStackRef.current.push(entry);
setStatus(`撤销: ${entry.label}`);
forceRender();
}, []);
const redo = React.useCallback(() => {
const entry = redoStackRef.current.pop();
if (!entry) return;
applyUndoRedo(entry, 'redo');
undoStackRef.current.push(entry);
setStatus(`重做: ${entry.label}`);
forceRender();
}, []);
React.useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const isMod = e.metaKey || e.ctrlKey;
if (!isMod || e.key.toLowerCase() !== 'z') return;
e.preventDefault();
if (e.shiftKey) redo();
else undo();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [undo, redo]);
const currentColCountRef = React.useRef(tableData.colCount);
const makeRenderEditor = React.useCallback(() => {
return ({ value, row, column }: any) => {
const input = document.createElement('input');
input.type = 'text';
input.value = String(value ?? '');
input.style.cssText = 'width:100%;height:100%;box-sizing:border-box;border:2px solid #2563eb;outline:none;padding:0 6px;font-size:inherit;';
const oldValue = String(value ?? '');
const commit = () => {
const newValue = input.value;
if (newValue !== oldValue) {
const colKey = column.key;
const cellBefore = snapshotCell(row as SpreadsheetRow, colKey);
setSpreadsheetCellData(row as SpreadsheetRow, colKey, { type: 'text', value: newValue });
const cellAfter = snapshotCell(row as SpreadsheetRow, colKey);
pushUndo({ label: '编辑', cellsBefore: [cellBefore], cellsAfter: [cellAfter], mergesBefore: [...mergeStateRef.current], mergesAfter: [...mergeStateRef.current] });
const t = tableRef.current?.getTable() as { setList?: (l: SpreadsheetRow[]) => void; forceUpdate?: () => void } | null;
t?.setList?.([...tableData.list]);
t?.forceUpdate?.();
}
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { input.value = oldValue; input.blur(); }
e.stopPropagation();
});
setTimeout(() => input.focus(), 0);
return input;
};
}, []);
React.useEffect(() => {
for (const col of tableData.columns) {
if (col.key !== '__index' && !(col as any).renderEditor) {
(col as any).renderEditor = makeRenderEditor();
}
}
}, [makeRenderEditor]);
const clipboardHooks = React.useMemo(() => {
const hooks = createSpreadsheetClipboardHandlers<SpreadsheetRow>({
columns: tableData.columns as any[],
getMerges: () => mergeStateRef.current,
setMerges: (next) => {
mergeStateRef.current = next;
tableRef.current?.setMerges([...mergeStateRef.current]);
},
createRow: (idx) => {
const row: SpreadsheetRow = { id: idx, _sheetCells: {} };
for (let c = 0; c < currentColCountRef.current; c++) row[colIndexToKey(c)] = '';
return row;
},
mergeColumnOffset: 1,
addColumns: (count: number) => {
const newCols: ReactTableColumn[] = [];
for (let i = 0; i < count; i++) {
const colIdx = currentColCountRef.current + i;
const key = colIndexToKey(colIdx);
const col: ReactTableColumn = {
key,
title: key,
width: 120,
resizable: true,
render: ({ row, column }: { row: Record<string, unknown>; column: { key: string } }) => {
const cell = (row as SpreadsheetRow)._sheetCells?.[column.key];
return cell ? renderSpreadsheetCellHtml(cell) : escapeHtml(String(row[column.key] ?? ''));
},
};
(col as any).renderEditor = makeRenderEditor();
newCols.push(col);
tableData.columns.push(col);
for (const row of tableData.list) {
row[key] = '';
}
}
currentColCountRef.current += count;
const t = tableRef.current?.getTable() as { setColumns?: (c: any[]) => void } | null;
t?.setColumns?.(tableData.columns as any[]);
return newCols as any[];
},
});
const originalPaste = hooks.onPaste;
hooks.onPaste = (ctx: any): boolean => {
const payload = ctx.payload as SpreadsheetClipboardPayload | null;
if (!payload || payload.kind !== 'virt-spreadsheet') return false;
const cellsBefore: CellSnapshot[] = [];
const list = ctx.list;
const selectable = ctx.columnIndexes;
const startPos = selectable.indexOf(ctx.selection.startCol);
if (startPos < 0) return false;
for (let r = 0; r < payload.cells.length; r++) {
const rowIdx = ctx.selection.startRow + r;
const row = list[rowIdx];
if (!row) continue;
for (let c = 0; c < payload.cells[r]!.length; c++) {
const colPos = startPos + c;
if (colPos >= selectable.length) break;
const colIdx = selectable[colPos]!;
const colKey = (tableData.columns as any[])[colIdx]?.key;
if (!colKey) continue;
cellsBefore.push(snapshotCell(row, colKey));
}
}
const mergesBefore = [...mergeStateRef.current];
const ok = originalPaste(ctx);
if (!ok) return false;
const cellsAfter: CellSnapshot[] = [];
for (let r = 0; r < payload.cells.length; r++) {
const rowIdx = ctx.selection.startRow + r;
const row = ctx.list[rowIdx];
if (!row) continue;
for (let c = 0; c < payload.cells[r]!.length; c++) {
const colPos = startPos + c;
if (colPos >= selectable.length) break;
const colIdx = selectable[colPos]!;
const colKey = (tableData.columns as any[])[colIdx]?.key;
if (!colKey) continue;
cellsAfter.push(snapshotCell(row, colKey));
}
}
pushUndo({
label: '粘贴',
cellsBefore,
cellsAfter,
mergesBefore,
mergesAfter: [...mergeStateRef.current],
});
return true;
};
return hooks;
}, []);
/** 右键菜单项:按当前选区给出「合并 / 取消合并」 */
const contextMenuProvider = (ctx: ContextMenuContext<SpreadsheetRow>): ContextMenuItem[] | null => {
const t = tableRef.current?.getTable() as { leftFixedCount: number; setMerges: (m: MergeCell[]) => void; clearCellSelection: () => void } | null | undefined;
if (!t) return null;
const sel = ctx.selection;
if (!sel) return null;
const leftFixed = t.leftFixedCount;
const rawStartCol = Math.max(sel.startCol, leftFixed) - leftFixed;
const rawEndCol = sel.endCol - leftFixed;
if (rawEndCol < 0 || rawStartCol > rawEndCol) return null;
const expanded = expandSelectionWithMerges(sel.startRow, rawStartCol, sel.endRow, rawEndCol, mergeStateRef.current);
const rowspan = expanded.endRow - expanded.startRow + 1;
const colspan = expanded.endCol - expanded.startCol + 1;
const items: { label: string; divider?: boolean; action: () => void }[] = [];
const overlapping = mergeStateRef.current.filter((m) =>
rangesOverlap(m.rowIndex, m.colIndex, m.rowIndex + m.rowspan - 1, m.colIndex + m.colspan - 1,
expanded.startRow, expanded.startCol, expanded.endRow, expanded.endCol));
if (rowspan > 1 || colspan > 1) {
items.push({
label: `合并单元格 (${rowspan} × ${colspan})`,
action: () => {
const mergesBefore = [...mergeStateRef.current];
const next = mergeStateRef.current.filter((m) => !overlapping.includes(m));
next.push({ rowIndex: expanded.startRow, colIndex: expanded.startCol, rowspan, colspan });
mergeStateRef.current = next;
tableRef.current?.setMerges([...mergeStateRef.current]);
tableRef.current?.clearCellSelection();
pushUndo({ label: '合并单元格', cellsBefore: [], cellsAfter: [], mergesBefore, mergesAfter: [...mergeStateRef.current] });
setStatus(`已合并: 行 ${expanded.startRow}~${expanded.endRow}, 列 ${CHARS[expanded.startCol]}~${CHARS[expanded.endCol]}`);
},
});
}
if (overlapping.length > 0) {
items.push({
label: `取消合并 (${overlapping.length} 个)`,
action: () => {
const mergesBefore = [...mergeStateRef.current];
const removeSet = new Set(overlapping);
mergeStateRef.current = mergeStateRef.current.filter((m) => !removeSet.has(m));
tableRef.current?.setMerges([...mergeStateRef.current]);
tableRef.current?.clearCellSelection();
pushUndo({ label: '取消合并', cellsBefore: [], cellsAfter: [], mergesBefore, mergesAfter: [...mergeStateRef.current] });
setStatus(`已取消 ${overlapping.length} 个合并区域`);
},
});
}
return items.length > 0 ? items : null;
};
const options = React.useMemo(
() => ({
list: tableData.list,
itemKey: 'id',
estimatedSize: 40,
buffer: 4,
border: true,
cellStyle: tableData.cellStyle,
merges: mergeStateRef.current,
plugins: [vtCellSelection(), vtCellEditor(), vtCellSelection(), vtCellEditor({ trigger: 'dblclick' }),
vtContextMenu<SpreadsheetRow>(contextMenuProvider),
vtClipboard<SpreadsheetRow>(clipboardHooks),
vtSearch<SpreadsheetRow>(),
],
}),
[clipboardHooks],
);
return (
<div className="demo-wrapper">
<h3 className="demo-title">React 电子表格(飞书数据结构)</h3>
<div className="virt-table-controls">
<button type="button" onClick={() => { tableRef.current?.scrollToTop(); setStatus('已滚动到顶部'); }}>滚动到顶部</button>
<button type="button" onClick={() => { tableRef.current?.scrollToBottom(); setStatus('已滚动到底部'); }}>滚动到底部</button>
<button
type="button"
onClick={() => {
const index = Math.floor(Math.random() * FEISHU_SHEET.row_count);
tableRef.current?.scrollToIndex(index);
setStatus(`随机滚动到第 ${index + 1} 行`);
}}
>
随机滚动
</button>
<button type="button" onClick={() => { tableRef.current?.scrollToIndex(9); setStatus('已定位到第 10 行'); }}>定位到第 10 行</button>
<button type="button" disabled={undoStackRef.current.length === 0} onClick={undo}>
撤销 ({undoStackRef.current.length})
</button>
<button type="button" disabled={redoStackRef.current.length === 0} onClick={redo}>
重做 ({redoStackRef.current.length})
</button>
</div>
<div className="status-text">{status}</div>
<div style={{ width: '100%', height: 800 }} className="demo-container">
<VirtTableReact ref={tableRef} columns={tableData.columns} options={options as any} />
</div>
</div>
);
}