Appearance
电子表格
基于飞书数据结构实现的电子表格示例,支持选区、剪贴板、合并单元格与右键菜单等能力。
使用的 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(),
];
}示例
微应用尚未挂载。
源码
点击查看源码
ts
import {
createSpreadsheetClipboardHandlers,
renderSpreadsheetCellHtml,
setSpreadsheetCellData,
VirtTable,
type MergeCell,
type SpreadsheetCellData,
type SpreadsheetClipboardPayload,
type VirtTableColumn,
} from '@virt-table/vanilla';
import {
vtContextMenu,
vtClipboard,
vtSearch,
type ContextMenuContext,
type ContextMenuItem,
vtCellEditor,
vtCellSelection,
} from '@virt-table/vanilla/plugins';
interface RichTextSegment {
text: string;
style?: {
font_size?: number;
bold?: boolean;
italic?: boolean;
color?: string;
underline?: boolean;
strikethrough?: boolean;
};
}
interface FeishuCell {
value?: unknown;
rich_text?: RichTextSegment[];
style?: {
bold?: boolean;
italic?: boolean;
font_size?: number;
color?: string;
bg_color?: string;
align?: string;
valign?: string;
};
}
interface FeishuMergeInfo {
start_row: number;
start_col: number;
end_row: number;
end_col: number;
}
interface FeishuSheet {
col_count: number;
row_count: number;
col_widths?: Record<string, number>;
merge_info_list?: FeishuMergeInfo[];
cells: Record<string, Record<string, FeishuCell>>;
}
interface SpreadsheetRow extends Record<string, any> {
id: number;
_sheetCells?: Record<string, SpreadsheetCellData>;
}
interface CellSnapshot {
rowIndex: number;
colKey: string;
displayValue: unknown;
cellData: SpreadsheetCellData | null;
}
interface UndoEntry {
label: string;
cellsBefore: CellSnapshot[];
cellsAfter: CellSnapshot[];
mergesBefore: MergeCell[];
mergesAfter: MergeCell[];
}
function rangesOverlap(
r1s: number,
c1s: number,
r1e: number,
c1e: number,
r2s: number,
c2s: number,
r2e: number,
c2e: number,
): boolean {
return r1s <= r2e && r1e >= r2s && c1s <= c2e && c1e >= c2s;
}
function expandSelectionWithMerges(
startRow: number,
startCol: number,
endRow: number,
endCol: number,
existingMerges: MergeCell[],
): { startRow: number; startCol: number; endRow: number; endCol: number } {
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, startRow, startCol, endRow, endCol)) {
if (m.rowIndex < startRow) { startRow = m.rowIndex; changed = true; }
if (mr2 > endRow) { endRow = mr2; changed = true; }
if (m.colIndex < startCol) { startCol = m.colIndex; changed = true; }
if (mc2 > endCol) { endCol = mc2; changed = true; }
}
}
}
return { startRow, startCol, endRow, endCol };
}
function escapeHtml(s: unknown): string {
return String(s ?? '')
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
function richTextToHtml(segments: RichTextSegment[]): string {
return segments.map((seg) => {
const parts = [];
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 = [];
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 = [];
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;
}
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 convertFeishuSheet(sheet: FeishuSheet): {
columns: VirtTableColumn[];
list: SpreadsheetRow[];
merges: MergeCell[];
cellStyle: (col: VirtTableColumn, row: SpreadsheetRow) => string;
colCount: number;
} {
const colCount = sheet.col_count;
const columns: VirtTableColumn[] = [
{ 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 }) => {
const cell = row._sheetCells?.[column.key];
return cell ? renderSpreadsheetCellHtml(cell) : escapeHtml(String(row[column.key] ?? ''));
},
};
}),
];
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 = (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: VirtTableColumn, row: SpreadsheetRow): string => {
const cell = row._sheetCells?.[_col.key];
if (!cell) return '';
const parts = [];
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 };
}
const FEISHU_DATA = {
spreadsheet_id: 'demo_feishu_001',
title: '项目排期台账',
sheets: [{
sheet_id: 'sheet_1',
title: '数据总表',
row_count: 500,
col_count: 26,
frozen_row_count: 2,
frozen_col_count: 0,
col_widths: { '0': 160, '1': 80, '2': 200, '3': 120, '4': 120, '5': 180, '6': 100, '7': 120, '8': 100 },
row_heights: { '0': 44, '1': 32 },
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' } },
],
},
},
},
}],
};
export function bootstrapTableSpreadsheet(root: HTMLElement): () => void {
const tableData = convertFeishuSheet(FEISHU_DATA.sheets[0]);
let currentColCount = tableData.colCount;
let merges: MergeCell[] = [...tableData.merges];
let tableRef: VirtTable<any> | null = null;
let setStatusFn: ((text: string) => void) | null = null;
// undo/redo
const undoStack: UndoEntry[] = [];
const redoStack: UndoEntry[] = [];
const MAX_UNDO = 50;
function snapshotCell(row: SpreadsheetRow, colKey: string): CellSnapshot {
return {
rowIndex: row.id,
colKey,
displayValue: row[colKey],
cellData: row._sheetCells?.[colKey] ? { ...row._sheetCells[colKey] } : null,
};
}
function restoreCells(snapshots: CellSnapshot[], list: SpreadsheetRow[]): void {
for (const snap of snapshots) {
const row = 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): void {
undoStack.push(entry);
if (undoStack.length > MAX_UNDO) undoStack.shift();
redoStack.length = 0;
updateUndoButtons();
}
function applyUndoRedo(entry: UndoEntry, direction: 'undo' | 'redo'): void {
const cells = direction === 'undo' ? entry.cellsBefore : entry.cellsAfter;
const m = direction === 'undo' ? entry.mergesBefore : entry.mergesAfter;
restoreCells(cells, tableData.list);
merges = [...m];
tableRef?.setMerges([...merges]);
tableRef?.setList([...tableData.list]);
tableRef?.forceUpdate();
}
function undo() {
const entry = undoStack.pop();
if (!entry) return;
applyUndoRedo(entry, 'undo');
redoStack.push(entry);
setStatusFn?.(`撤销: ${entry.label}`);
updateUndoButtons();
}
function redo() {
const entry = redoStack.pop();
if (!entry) return;
applyUndoRedo(entry, 'redo');
undoStack.push(entry);
setStatusFn?.(`重做: ${entry.label}`);
updateUndoButtons();
}
let undoBtn: HTMLButtonElement | null = null;
let redoBtn: HTMLButtonElement | null = null;
function updateUndoButtons() {
if (undoBtn) undoBtn.textContent = `撤销 (${undoStack.length})`;
if (redoBtn) redoBtn.textContent = `重做 (${redoStack.length})`;
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
}
// renderEditor for all data columns
function makeRenderEditor() {
return ({ value, row, column }) => {
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, colKey);
setSpreadsheetCellData(row, colKey, { type: 'text', value: newValue });
const cellAfter = snapshotCell(row, colKey);
pushUndo({ label: '编辑', cellsBefore: [cellBefore], cellsAfter: [cellAfter], mergesBefore: [...merges], mergesAfter: [...merges] });
tableRef?.setList([...tableData.list]);
tableRef?.forceUpdate();
}
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
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;
};
}
for (const col of tableData.columns) {
if (col.key !== '__index') {
col.renderEditor = makeRenderEditor();
}
}
function createNewColumn(colIdx: number): VirtTableColumn {
const key = colIndexToKey(colIdx);
return {
key,
title: key,
width: 120,
resizable: true,
render: ({ row, column }) => renderSpreadsheetCellHtml(row._sheetCells?.[column.key]) || escapeHtml(String(row[column.key] ?? '')),
renderEditor: makeRenderEditor(),
};
}
const clipboardHooks = createSpreadsheetClipboardHandlers({
columns: tableData.columns,
getMerges: () => merges,
setMerges: (next) => {
merges = next;
tableRef?.setMerges([...merges]);
},
createRow: (idx) => {
const row = { id: idx, _sheetCells: {} };
for (let c = 0; c < currentColCount; c++) row[colIndexToKey(c)] = '';
return row;
},
mergeColumnOffset: 1,
addColumns: (count) => {
const newCols = [];
for (let i = 0; i < count; i++) {
const col = createNewColumn(currentColCount + i);
newCols.push(col);
tableData.columns.push(col);
for (const row of tableData.list) {
row[col.key] = '';
}
}
currentColCount += count;
tableRef?.setColumns(tableData.columns);
return newCols;
},
});
const originalPaste = clipboardHooks.onPaste;
clipboardHooks.onPaste = (ctx) => {
const payload = ctx.payload as SpreadsheetClipboardPayload | null | undefined;
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[colIdx]?.key;
if (!colKey) continue;
cellsBefore.push(snapshotCell(row, colKey));
}
}
const mergesBefore = [...merges];
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[colIdx]?.key;
if (!colKey) continue;
cellsAfter.push(snapshotCell(row, colKey));
}
}
pushUndo({
label: '粘贴',
cellsBefore,
cellsAfter,
mergesBefore,
mergesAfter: [...merges],
});
return true;
};
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);
root.innerHTML = `
<div class="virt-table-controls">
<button class="virt-table-btn" id="btnUndo">撤销 (0)</button>
<button class="virt-table-btn" id="btnRedo">重做 (0)</button>
<button class="virt-table-btn virt-table-btn-primary" id="btnToggleMerge">切换合并单元格</button>
</div>
<div id="status" class="status-text"></div>
<div style="width:100%;height:800px;" class="demo-container" id="virtTableContainer"></div>
`;
const container = root.querySelector('#virtTableContainer') as HTMLElement;
const status = root.querySelector('#status') as HTMLElement;
const listeners: (() => void)[] = [];
let mergeEnabled = true;
const setStatus = (text: string) => {
status.textContent = text;
};
setStatusFn = setStatus;
const on = (id: string, handler: () => void) => {
const el = root.querySelector(`#${id}`);
if (!el) return;
el.addEventListener('click', handler);
listeners.push(() => el.removeEventListener('click', handler));
};
/** 右键菜单项:按当前选区给出「合并 / 取消合并」 */
const contextMenuProvider = (ctx: ContextMenuContext<SpreadsheetRow>): ContextMenuItem[] | null => {
if (!tableRef) return null;
const sel = ctx.selection;
if (!sel) return null;
const leftFixed = tableRef.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, merges);
const rowspan = expanded.endRow - expanded.startRow + 1;
const colspan = expanded.endCol - expanded.startCol + 1;
const items: ContextMenuItem[] = [];
const overlapping = merges.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 = [...merges];
merges = merges.filter((m) => !overlapping.includes(m));
merges.push({ rowIndex: expanded.startRow, colIndex: expanded.startCol, rowspan, colspan });
tableRef!.setMerges([...merges]);
tableRef!.clearCellSelection();
pushUndo({ label: '合并单元格', cellsBefore: [], cellsAfter: [], mergesBefore, mergesAfter: [...merges] });
setStatusFn?.(`已合并: 行 ${expanded.startRow}~${expanded.endRow}, 列 ${CHARS[expanded.startCol]}~${CHARS[expanded.endCol]}`);
},
});
}
if (overlapping.length > 0) {
items.push({
label: `取消合并 (${overlapping.length} 个)`,
action: () => {
const mergesBefore = [...merges];
const removeSet = new Set(overlapping);
merges = merges.filter((m) => !removeSet.has(m));
tableRef!.setMerges([...merges]);
tableRef!.clearCellSelection();
pushUndo({ label: '取消合并', cellsBefore: [], cellsAfter: [], mergesBefore, mergesAfter: [...merges] });
setStatusFn?.(`已取消 ${overlapping.length} 个合并区域`);
},
});
}
return items.length > 0 ? items : null;
};
const table = new VirtTable(container, {
list: tableData.list,
columns: tableData.columns,
itemKey: 'id',
estimatedSize: 40,
buffer: 4,
merges,
border: true,
cellStyle: tableData.cellStyle,
plugins: [vtCellSelection(), vtCellEditor({ trigger: 'dblclick' }),
vtContextMenu<SpreadsheetRow>(contextMenuProvider),
vtClipboard<SpreadsheetRow>(clipboardHooks),
vtSearch<SpreadsheetRow>(),
],
});
tableRef = table;
undoBtn = root.querySelector('#btnUndo') as HTMLButtonElement;
redoBtn = root.querySelector('#btnRedo') as HTMLButtonElement;
updateUndoButtons();
on('btnUndo', undo);
on('btnRedo', redo);
on('btnToggleMerge', () => {
mergeEnabled = !mergeEnabled;
table.setMerges(mergeEnabled ? merges : []);
setStatus(mergeEnabled ? '合并单元格已启用' : '合并单元格已关闭');
});
const sheet = FEISHU_DATA.sheets[0];
setStatus(`电子表格(飞书数据结构):${sheet.row_count} 行 × ${sheet.col_count} 列(飞书数据结构,支持选区复制粘贴 + 撤销重做)`);
return () => {
document.removeEventListener('keydown', onKeyDown);
table.destroy();
listeners.forEach((off) => off());
root.innerHTML = '';
};
}