diff --git a/common/changes/@visactor/vtable/feat-sheet-cell-copyFormula_2025-11-26-11-27.json b/common/changes/@visactor/vtable/feat-sheet-cell-copyFormula_2025-11-26-11-27.json
new file mode 100644
index 0000000000..b4d73eb8f6
--- /dev/null
+++ b/common/changes/@visactor/vtable/feat-sheet-cell-copyFormula_2025-11-26-11-27.json
@@ -0,0 +1,11 @@
+{
+ "changes": [
+ {
+ "comment": "feat: copy formula to paste cell\n\n",
+ "type": "none",
+ "packageName": "@visactor/vtable"
+ }
+ ],
+ "packageName": "@visactor/vtable",
+ "email": "892739385@qq.com"
+}
\ No newline at end of file
diff --git a/docs/assets/guide/en/sheet/formula.md b/docs/assets/guide/en/sheet/formula.md
index fea2becd4f..1919ba20f8 100644
--- a/docs/assets/guide/en/sheet/formula.md
+++ b/docs/assets/guide/en/sheet/formula.md
@@ -115,12 +115,23 @@ Cell reference input and range selection:
-### Formula Copying (TODO)
+### Formula Copying
When copying cells containing formulas, references are automatically adjusted:
- Relative references adjust according to position offset
- Absolute references remain unchanged
+
+

+
+
+### Formula Auto Filling
+
+When a region is selected and contains formulas, the formula is automatically filled using the fill handle.
+
+

+
+
## 6. Advanced Features
### Multi-Sheet Support (TODO)
diff --git a/docs/assets/guide/zh/sheet/formula.md b/docs/assets/guide/zh/sheet/formula.md
index a025b08330..0aa77f2292 100644
--- a/docs/assets/guide/zh/sheet/formula.md
+++ b/docs/assets/guide/zh/sheet/formula.md
@@ -114,12 +114,21 @@ VTableSheet 自身开发了 FormulaEngine 模块 作为核心的计算引擎:
-### 公式复制(TODO)
+### 公式复制
当复制包含公式的单元格时,引用会自动调整:
- 相对引用会根据位置偏移调整
- 绝对引用保持不变
+
+

+
+
+### 填充柄自动填充
+ 当选中区域存在公式的时候,利用填充柄拖拽自动填充单元格的公式。
+
+

+
## 6. 高级功能
### 多工作表支持(TODO)
diff --git a/packages/vtable-plugins/src/excel-edit-cell-keyboard.ts b/packages/vtable-plugins/src/excel-edit-cell-keyboard.ts
index 68c2cf54b7..278ca67855 100644
--- a/packages/vtable-plugins/src/excel-edit-cell-keyboard.ts
+++ b/packages/vtable-plugins/src/excel-edit-cell-keyboard.ts
@@ -70,7 +70,7 @@ export class ExcelEditCellKeyboardPlugin implements pluginsDefinition.IVTablePlu
}
handleKeyDown(event: KeyboardEvent) {
// this.pluginOptions?.keyDown_before?.(event);
- if (this.table.editorManager && this.isExcelShortcutKey(event)) {
+ if (this.table?.editorManager && this.isExcelShortcutKey(event)) {
const eventKey = event.key.toLowerCase() as ExcelEditCellKeyboardResponse;
//判断是键盘触发编辑单元格的情况下,那么在编辑状态中切换方向需要选中下一个继续编辑
if (this.table.editorManager.editingEditor && this.table.editorManager.beginTriggerEditCellMode === 'keydown') {
diff --git a/packages/vtable-sheet/__tests__/copy-source-position.test.ts b/packages/vtable-sheet/__tests__/copy-source-position.test.ts
new file mode 100644
index 0000000000..cff5a98b4d
--- /dev/null
+++ b/packages/vtable-sheet/__tests__/copy-source-position.test.ts
@@ -0,0 +1,168 @@
+/**
+ * 复制源位置记录测试
+ * 验证复制时记录的源位置是否正确用于粘贴时的公式调整
+ */
+
+describe('复制源位置记录测试', () => {
+ it('应该正确记录复制时的源位置', () => {
+ // 模拟EventManager的行为
+ const mockEventManager = {
+ copySourceRange: null as { startCol: number; startRow: number } | null,
+
+ // 模拟handleCopy中记录源位置的逻辑
+ recordCopySourceRange(ranges: any[]) {
+ if (ranges && ranges.length > 0) {
+ const sourceRange = ranges[0];
+ this.copySourceRange = {
+ startCol: Math.min(sourceRange.start.col, sourceRange.end.col),
+ startRow: Math.min(sourceRange.start.row, sourceRange.end.row)
+ };
+ }
+ },
+
+ // 模拟粘贴时获取源位置的逻辑
+ getCopySourcePosition() {
+ return this.copySourceRange;
+ },
+
+ // 模拟清除复制源位置的逻辑
+ clearCopySourceRange() {
+ this.copySourceRange = null;
+ }
+ };
+
+ // 测试1:复制C5时的位置记录
+ const copyRanges1 = [
+ {
+ start: { col: 2, row: 4 }, // C5
+ end: { col: 2, row: 4 }
+ }
+ ];
+
+ mockEventManager.recordCopySourceRange(copyRanges1);
+
+ console.log('复制C5后的源位置:', mockEventManager.getCopySourcePosition());
+ expect(mockEventManager.getCopySourcePosition()).toEqual({
+ startCol: 2, // C列
+ startRow: 4 // 第5行
+ });
+
+ // 测试2:复制A1:B2区域时的位置记录
+ const copyRanges2 = [
+ {
+ start: { col: 0, row: 0 }, // A1
+ end: { col: 1, row: 1 } // B2
+ }
+ ];
+
+ mockEventManager.recordCopySourceRange(copyRanges2);
+
+ console.log('复制A1:B2后的源位置:', mockEventManager.getCopySourcePosition());
+ expect(mockEventManager.getCopySourcePosition()).toEqual({
+ startCol: 0, // A列
+ startRow: 0 // 第1行
+ });
+
+ // 测试3:清除源位置
+ mockEventManager.clearCopySourceRange();
+ console.log('清除后的源位置:', mockEventManager.getCopySourcePosition());
+ expect(mockEventManager.getCopySourcePosition()).toBeNull();
+ });
+
+ it('应该正确处理相对位置计算', () => {
+ // 模拟processFormulaPaste的逻辑
+ const calculateRelativeOffset = (
+ sourceStartCol: number,
+ sourceStartRow: number,
+ targetStartCol: number,
+ targetStartRow: number
+ ) => {
+ return {
+ colOffset: targetStartCol - sourceStartCol,
+ rowOffset: targetStartRow - sourceStartRow
+ };
+ };
+
+ // 测试场景:C5复制到D5
+ const sourcePos = { startCol: 2, startRow: 4 }; // C5
+ const targetPos = { startCol: 3, startRow: 4 }; // D5
+
+ const offset = calculateRelativeOffset(
+ sourcePos.startCol,
+ sourcePos.startRow,
+ targetPos.startCol,
+ targetPos.startRow
+ );
+
+ console.log('C5->D5的相对偏移:', offset);
+ expect(offset).toEqual({
+ colOffset: 1, // 右移1列
+ rowOffset: 0 // 行不变
+ });
+
+ // 测试场景:A1复制到C3
+ const offset2 = calculateRelativeOffset(0, 0, 2, 2);
+
+ console.log('A1->C3的相对偏移:', offset2);
+ expect(offset2).toEqual({
+ colOffset: 2, // 右移2列
+ rowOffset: 2 // 下移2行
+ });
+ });
+
+ it('应该验证完整的复制粘贴流程', () => {
+ // 完整的流程测试
+ const mockWorkSheet = {
+ copySourceRange: null as { startCol: number; startRow: number } | null,
+
+ // 复制时记录源位置
+ handleCopy(ranges: any[]) {
+ if (ranges && ranges.length > 0) {
+ const sourceRange = ranges[0];
+ this.copySourceRange = {
+ startCol: Math.min(sourceRange.start.col, sourceRange.end.col),
+ startRow: Math.min(sourceRange.start.row, sourceRange.end.row)
+ };
+ }
+ },
+
+ // 粘贴时使用记录的源位置
+ processFormulaPaste(formulas: string[][], targetStartCol: number, targetStartRow: number): string[][] {
+ if (!this.copySourceRange) {
+ return formulas; // 没有源位置,不处理
+ }
+
+ const colOffset = targetStartCol - this.copySourceRange.startCol;
+ const rowOffset = targetStartRow - this.copySourceRange.startRow;
+
+ return formulas.map(row =>
+ row.map(cell => {
+ if (cell.startsWith('=')) {
+ return cell.replace(/([A-Z]+)(\d+)/g, (match, col, row) => {
+ const colNum = col.charCodeAt(0) - 'A'.charCodeAt(0) + colOffset;
+ const rowNum = parseInt(row, 10) + rowOffset;
+ return String.fromCharCode('A'.charCodeAt(0) + colNum) + rowNum;
+ });
+ }
+ return cell;
+ })
+ );
+ }
+ };
+
+ // 步骤1:复制C5(公式=A2)
+ mockWorkSheet.handleCopy([
+ {
+ start: { col: 2, row: 4 }, // C5
+ end: { col: 2, row: 4 }
+ }
+ ]);
+
+ // 步骤2:粘贴到D5
+ const sourceFormula = [['=A2']];
+ const result = mockWorkSheet.processFormulaPaste(sourceFormula, 3, 4); // D5
+
+ console.log('完整流程结果:', result);
+ expect(result).toEqual([['=B2']]); // C5的=A2粘贴到D5应该变成=B2
+ });
+});
diff --git a/packages/vtable-sheet/__tests__/debug-formula-paste.test.ts b/packages/vtable-sheet/__tests__/debug-formula-paste.test.ts
new file mode 100644
index 0000000000..73cfed3362
--- /dev/null
+++ b/packages/vtable-sheet/__tests__/debug-formula-paste.test.ts
@@ -0,0 +1,38 @@
+import { FormulaPasteProcessor } from '../src/formula/formula-paste-processor';
+import { FormulaReferenceAdjustor } from '../src/formula/formula-reference-adjustor';
+
+describe('Debug Formula Paste', () => {
+ it('should debug formula adjustment', () => {
+ const originalFormula = '=A1+B1';
+ const colOffset = 1; // 右移1列
+ const rowOffset = 1; // 下移1行
+
+ console.log('Original formula:', originalFormula);
+ console.log('Column offset:', colOffset);
+ console.log('Row offset:', rowOffset);
+
+ const adjustedFormula = FormulaReferenceAdjustor.adjustFormulaReferences(originalFormula, colOffset, rowOffset);
+
+ console.log('Adjusted formula:', adjustedFormula);
+ expect(adjustedFormula).toBe('=B2+C2');
+ });
+
+ it('should debug batch formula adjustment', () => {
+ const formulas = [
+ ['=A1', '=B1'],
+ ['=A2', '=B2']
+ ];
+ const colOffset = 2;
+ const rowOffset = 1;
+
+ console.log('Original formulas:', formulas);
+
+ const adjustedFormulas = FormulaPasteProcessor.adjustFormulasForPasteWithOffset(formulas, colOffset, rowOffset);
+
+ console.log('Adjusted formulas:', adjustedFormulas);
+ expect(adjustedFormulas).toEqual([
+ ['=C2', '=D2'],
+ ['=C3', '=D3']
+ ]);
+ });
+});
diff --git a/packages/vtable-sheet/__tests__/end-to-end-copy-paste.test.ts b/packages/vtable-sheet/__tests__/end-to-end-copy-paste.test.ts
new file mode 100644
index 0000000000..45fe76b7e4
--- /dev/null
+++ b/packages/vtable-sheet/__tests__/end-to-end-copy-paste.test.ts
@@ -0,0 +1,190 @@
+/**
+ * 端到端复制粘贴测试
+ * 模拟完整的用户操作流程
+ */
+
+describe('端到端复制粘贴测试', () => {
+ it('应该正确处理C5到D5的公式复制粘贴流程', () => {
+ // 模拟完整的EventManager行为
+ const mockEventManager = {
+ copySourceRange: null as { startCol: number; startRow: number } | null,
+
+ // 模拟handleCopy - 记录复制时的源位置
+ handleCopy(sourceRanges: any[]) {
+ if (sourceRanges && sourceRanges.length > 0) {
+ const sourceRange = sourceRanges[0];
+ this.copySourceRange = {
+ startCol: Math.min(sourceRange.start.col, sourceRange.end.col),
+ startRow: Math.min(sourceRange.start.row, sourceRange.end.row)
+ };
+ }
+ },
+
+ // 模拟粘贴时的处理
+ handlePaste(targetRanges: any[], values: string[][]) {
+ if (!this.copySourceRange) {
+ return values; // 没有源位置,返回原始值
+ }
+
+ if (targetRanges && targetRanges.length > 0) {
+ const targetRange = targetRanges[0];
+ const targetCol = Math.min(targetRange.start.col, targetRange.end.col);
+ const targetRow = Math.min(targetRange.start.row, targetRange.end.row);
+
+ // 使用记录的源位置进行公式调整
+ return this.processFormulaPaste(values, targetCol, targetRow);
+ }
+
+ return values;
+ },
+
+ // 模拟processFormulaPaste
+ processFormulaPaste(formulas: string[][], targetCol: number, targetRow: number): string[][] {
+ if (!this.copySourceRange) {
+ return formulas;
+ }
+
+ const colOffset = targetCol - this.copySourceRange.startCol;
+ const rowOffset = targetRow - this.copySourceRange.startRow;
+
+ return formulas.map(row =>
+ row.map(cell => {
+ if (cell.startsWith('=')) {
+ return cell.replace(/([A-Z]+)(\d+)/g, (match, col, row) => {
+ const colNum = col.charCodeAt(0) - 'A'.charCodeAt(0) + colOffset;
+ const rowNum = parseInt(row, 10) + rowOffset;
+ return String.fromCharCode('A'.charCodeAt(0) + colNum) + rowNum;
+ });
+ }
+ return cell;
+ })
+ );
+ }
+ };
+
+ // 步骤1:用户复制C5(选中C5,公式为=A2)
+ const copySelection = [
+ {
+ start: { col: 2, row: 4 }, // C5
+ end: { col: 2, row: 4 }
+ }
+ ];
+
+ mockEventManager.handleCopy(copySelection);
+
+ console.log('复制C5后的源位置:', mockEventManager.copySourceRange);
+ expect(mockEventManager.copySourceRange).toEqual({
+ startCol: 2, // C列
+ startRow: 4 // 第5行
+ });
+
+ // 步骤2:用户选择D5进行粘贴
+ const pasteSelection = [
+ {
+ start: { col: 3, row: 4 }, // D5
+ end: { col: 3, row: 4 }
+ }
+ ];
+
+ // 步骤3:粘贴处理
+ const sourceFormula = [['=A2']]; // C5的内容
+ const result = mockEventManager.handlePaste(pasteSelection, sourceFormula);
+
+ console.log('粘贴到D5的结果:', result);
+
+ // 验证:C5的=A2粘贴到D5应该变成=B2
+ expect(result).toEqual([['=B2']]);
+ });
+
+ it('应该处理多步骤复制粘贴', () => {
+ const mockEventManager = {
+ copySourceRange: null as { startCol: number; startRow: number } | null,
+
+ handleCopy(sourceRanges: any[]) {
+ if (sourceRanges && sourceRanges.length > 0) {
+ const sourceRange = sourceRanges[0];
+ this.copySourceRange = {
+ startCol: Math.min(sourceRange.start.col, sourceRange.end.col),
+ startRow: Math.min(sourceRange.start.row, sourceRange.end.row)
+ };
+ }
+ },
+
+ handlePaste(targetRanges: any[], values: string[][]) {
+ if (!this.copySourceRange) {
+ return values;
+ }
+
+ if (targetRanges && targetRanges.length > 0) {
+ const targetRange = targetRanges[0];
+ const targetCol = Math.min(targetRange.start.col, targetRange.end.col);
+ const targetRow = Math.min(targetRange.start.row, targetRange.end.row);
+
+ return this.processFormulaPaste(values, targetCol, targetRow);
+ }
+
+ return values;
+ },
+
+ processFormulaPaste(formulas: string[][], targetCol: number, targetRow: number): string[][] {
+ if (!this.copySourceRange) {
+ return formulas;
+ }
+
+ const colOffset = targetCol - this.copySourceRange.startCol;
+ const rowOffset = targetRow - this.copySourceRange.startRow;
+
+ return formulas.map(row =>
+ row.map(cell => {
+ if (cell.startsWith('=')) {
+ return cell.replace(/([A-Z]+)(\d+)/g, (match, col, row) => {
+ const colNum = col.charCodeAt(0) - 'A'.charCodeAt(0) + colOffset;
+ const rowNum = parseInt(row, 10) + rowOffset;
+ return String.fromCharCode('A'.charCodeAt(0) + colNum) + rowNum;
+ });
+ }
+ return cell;
+ })
+ );
+ }
+ };
+
+ // 步骤1:复制A1:B2区域
+ mockEventManager.handleCopy([
+ {
+ start: { col: 0, row: 0 }, // A1
+ end: { col: 1, row: 1 } // B2
+ }
+ ]);
+
+ // 步骤2:粘贴到C3(第一次粘贴)
+ const firstPaste = mockEventManager.handlePaste(
+ [{ start: { col: 2, row: 2 }, end: { col: 3, row: 3 } }], // C3:D4
+ [
+ ['=A1', '=B1'],
+ ['=A2', '=B2']
+ ]
+ );
+
+ console.log('第一次粘贴到C3:D4:', firstPaste);
+ expect(firstPaste).toEqual([
+ ['=C3', '=D3'],
+ ['=C4', '=D4']
+ ]);
+
+ // 步骤3:粘贴到E5(第二次粘贴,使用相同的源位置)
+ const secondPaste = mockEventManager.handlePaste(
+ [{ start: { col: 4, row: 4 }, end: { col: 5, row: 5 } }], // E5:F6
+ [
+ ['=A1', '=B1'],
+ ['=A2', '=B2']
+ ]
+ );
+
+ console.log('第二次粘贴到E5:F6:', secondPaste);
+ expect(secondPaste).toEqual([
+ ['=E5', '=F5'],
+ ['=E6', '=F6']
+ ]);
+ });
+});
diff --git a/packages/vtable-sheet/__tests__/formula-copy-paste.test.ts b/packages/vtable-sheet/__tests__/formula-copy-paste.test.ts
new file mode 100644
index 0000000000..6dedcd4bdf
--- /dev/null
+++ b/packages/vtable-sheet/__tests__/formula-copy-paste.test.ts
@@ -0,0 +1,222 @@
+/**
+ * 公式复制粘贴功能测试
+ */
+
+import { FormulaReferenceAdjustor } from '../src/formula/formula-reference-adjustor';
+import { FormulaPasteProcessor } from '../src/formula/formula-paste-processor';
+
+describe('FormulaReferenceAdjustor', () => {
+ describe('parseCellReference', () => {
+ it('应该正确解析相对引用', () => {
+ const ref = FormulaReferenceAdjustor.parseCellReference('A1');
+ expect(ref).toEqual({
+ type: 'relative',
+ col: 0,
+ row: 0,
+ originalColRef: 'A',
+ originalRowRef: '1',
+ fullReference: 'A1'
+ });
+ });
+
+ it('应该正确解析绝对引用', () => {
+ const ref = FormulaReferenceAdjustor.parseCellReference('$A$1');
+ expect(ref).toEqual({
+ type: 'absolute',
+ col: 0,
+ row: 0,
+ originalColRef: '$A',
+ originalRowRef: '$1',
+ fullReference: '$A$1'
+ });
+ });
+
+ it('应该正确解析混合引用(列绝对)', () => {
+ const ref = FormulaReferenceAdjustor.parseCellReference('$A1');
+ expect(ref).toEqual({
+ type: 'mixed_col',
+ col: 0,
+ row: 0,
+ originalColRef: '$A',
+ originalRowRef: '1',
+ fullReference: '$A1'
+ });
+ });
+
+ it('应该正确解析混合引用(行绝对)', () => {
+ const ref = FormulaReferenceAdjustor.parseCellReference('A$1');
+ expect(ref).toEqual({
+ type: 'mixed_row',
+ col: 0,
+ row: 0,
+ originalColRef: 'A',
+ originalRowRef: '$1',
+ fullReference: 'A$1'
+ });
+ });
+ });
+
+ describe('adjustCellReference', () => {
+ it('应该正确调整相对引用', () => {
+ const ref = FormulaReferenceAdjustor.parseCellReference('A1')!;
+ const adjusted = FormulaReferenceAdjustor.adjustCellReference(ref, { colOffset: 1, rowOffset: 1 });
+ expect(adjusted).toBe('B2');
+ });
+
+ it('应该保持绝对引用不变', () => {
+ const ref = FormulaReferenceAdjustor.parseCellReference('$A$1')!;
+ const adjusted = FormulaReferenceAdjustor.adjustCellReference(ref, { colOffset: 1, rowOffset: 1 });
+ expect(adjusted).toBe('$A$1');
+ });
+
+ it('应该正确调整混合引用(列绝对)', () => {
+ const ref = FormulaReferenceAdjustor.parseCellReference('$A1')!;
+ const adjusted = FormulaReferenceAdjustor.adjustCellReference(ref, { colOffset: 1, rowOffset: 1 });
+ expect(adjusted).toBe('$A2');
+ });
+
+ it('应该正确调整混合引用(行绝对)', () => {
+ const ref = FormulaReferenceAdjustor.parseCellReference('A$1')!;
+ const adjusted = FormulaReferenceAdjustor.adjustCellReference(ref, { colOffset: 1, rowOffset: 1 });
+ expect(adjusted).toBe('B$1');
+ });
+ });
+
+ describe('adjustFormulaReferences', () => {
+ it('应该正确调整简单公式 - 右移1列', () => {
+ const formula = '=A1+B1';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 0);
+ expect(adjusted).toBe('=B1+C1');
+ });
+
+ it('应该正确调整简单公式 - 下移1行', () => {
+ const formula = '=A1+B1';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 0, 1);
+ expect(adjusted).toBe('=A2+B2');
+ });
+
+ it('应该正确调整简单公式 - 右移1列下移1行', () => {
+ const formula = '=A1+B1';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 1);
+ expect(adjusted).toBe('=B2+C2');
+ });
+
+ it('应该正确处理绝对引用', () => {
+ const formula = '=$A$1+B1';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 1);
+ expect(adjusted).toBe('=$A$1+C2');
+ });
+
+ it('应该正确处理范围引用', () => {
+ const formula = '=SUM(A1:B2)';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 1);
+ expect(adjusted).toBe('=SUM(B2:C3)');
+ });
+
+ it('应该正确处理混合引用', () => {
+ const formula = '=$A1+B$1';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 1);
+ expect(adjusted).toBe('=$A2+C$1');
+ });
+ });
+
+ describe('column conversion', () => {
+ it('应该正确转换列字母到数字', () => {
+ expect(FormulaReferenceAdjustor.columnToNumber('A')).toBe(0);
+ expect(FormulaReferenceAdjustor.columnToNumber('Z')).toBe(25);
+ expect(FormulaReferenceAdjustor.columnToNumber('AA')).toBe(26);
+ expect(FormulaReferenceAdjustor.columnToNumber('AB')).toBe(27);
+ });
+
+ it('应该正确转换数字到列字母', () => {
+ expect(FormulaReferenceAdjustor.numberToColumn(0)).toBe('A');
+ expect(FormulaReferenceAdjustor.numberToColumn(25)).toBe('Z');
+ expect(FormulaReferenceAdjustor.numberToColumn(26)).toBe('AA');
+ expect(FormulaReferenceAdjustor.numberToColumn(27)).toBe('AB');
+ });
+ });
+});
+
+describe('FormulaPasteProcessor', () => {
+ describe('adjustFormulaForPaste', () => {
+ it('应该正确调整单个公式 - 右移1列', () => {
+ const formula = '=A1+B1';
+ const context = {
+ sourceRange: { startCol: 0, startRow: 0, endCol: 1, endRow: 1 },
+ targetRange: { startCol: 1, startRow: 0, endCol: 2, endRow: 1 },
+ sourceCell: { col: 0, row: 0 },
+ targetCell: { col: 1, row: 0 }
+ };
+
+ const adjusted = FormulaPasteProcessor.adjustFormulaForPaste(formula, context);
+ expect(adjusted).toBe('=B1+C1');
+ });
+
+ it('应该正确调整单个公式 - 下移1行', () => {
+ const formula = '=A1+B1';
+ const context = {
+ sourceRange: { startCol: 0, startRow: 0, endCol: 1, endRow: 1 },
+ targetRange: { startCol: 0, startRow: 1, endCol: 1, endRow: 2 },
+ sourceCell: { col: 0, row: 0 },
+ targetCell: { col: 0, row: 1 }
+ };
+
+ const adjusted = FormulaPasteProcessor.adjustFormulaForPaste(formula, context);
+ expect(adjusted).toBe('=A2+B2');
+ });
+ });
+
+ describe('adjustFormulasForPaste', () => {
+ it('应该正确处理公式数组 - 右移1列', () => {
+ const formulas = [
+ ['=A1', '=B1'],
+ ['=A2', '=B2']
+ ];
+
+ const context = {
+ sourceRange: { startCol: 0, startRow: 0, endCol: 1, endRow: 1 },
+ targetRange: { startCol: 1, startRow: 0, endCol: 2, endRow: 1 },
+ sourceCell: { col: 0, row: 0 },
+ targetCell: { col: 1, row: 0 }
+ };
+
+ const adjusted = FormulaPasteProcessor.adjustFormulasForPaste(formulas, context);
+ expect(adjusted).toEqual([
+ ['=B1', '=C1'],
+ ['=B2', '=C2']
+ ]);
+ });
+
+ it('应该正确处理混合内容 - 下移1行', () => {
+ const formulas = [
+ ['=A1', '普通文本'],
+ ['100', '=B2']
+ ];
+
+ const context = {
+ sourceRange: { startCol: 0, startRow: 0, endCol: 1, endRow: 1 },
+ targetRange: { startCol: 0, startRow: 1, endCol: 1, endRow: 2 },
+ sourceCell: { col: 0, row: 0 },
+ targetCell: { col: 0, row: 1 }
+ };
+
+ const adjusted = FormulaPasteProcessor.adjustFormulasForPaste(formulas, context);
+ expect(adjusted).toEqual([
+ ['=A2', '普通文本'],
+ ['100', '=B3']
+ ]);
+ });
+ });
+
+ describe('createPasteContext', () => {
+ it('应该正确创建粘贴上下文', () => {
+ const context = FormulaPasteProcessor.createPasteContext(0, 0, 2, 2, 2, 2, 2, 2);
+ expect(context).toEqual({
+ sourceRange: { startCol: 0, startRow: 0, endCol: 1, endRow: 1 },
+ targetRange: { startCol: 2, startRow: 2, endCol: 3, endRow: 3 },
+ sourceCell: { col: 0, row: 0 },
+ targetCell: { col: 2, row: 2 }
+ });
+ });
+ });
+});
diff --git a/packages/vtable-sheet/__tests__/formula-relative-reference.test.ts b/packages/vtable-sheet/__tests__/formula-relative-reference.test.ts
new file mode 100644
index 0000000000..46ce672b81
--- /dev/null
+++ b/packages/vtable-sheet/__tests__/formula-relative-reference.test.ts
@@ -0,0 +1,64 @@
+/**
+ * 测试公式复制粘贴的相对引用调整
+ * 模拟从C5(公式=A2)复制粘贴到D5,应该变成=B2
+ */
+
+import { FormulaPasteProcessor } from '../src/formula/formula-paste-processor';
+
+describe('公式复制粘贴相对引用测试', () => {
+ it('应该正确处理从C5到D5的公式复制粘贴', () => {
+ // 模拟C5单元格的公式
+ const sourceFormula = '=A2';
+
+ // 源位置:C5 (col: 2, row: 4)
+ const sourceCol = 2;
+ const sourceRow = 4;
+
+ // 目标位置:D5 (col: 3, row: 4)
+ const targetCol = 3;
+ const targetRow = 4;
+
+ // 计算偏移量
+ const colOffset = targetCol - sourceCol; // 1
+ const rowOffset = targetRow - sourceRow; // 0
+
+ console.log('源公式:', sourceFormula);
+ console.log('源位置:', { col: sourceCol, row: sourceRow });
+ console.log('目标位置:', { col: targetCol, row: targetRow });
+ console.log('偏移量:', { colOffset, rowOffset });
+
+ // 使用公式处理器调整
+ const adjustedFormula = FormulaPasteProcessor.adjustFormulasForPasteWithOffset(
+ [[sourceFormula]],
+ colOffset,
+ rowOffset
+ )[0][0];
+
+ console.log('调整后公式:', adjustedFormula);
+
+ // 验证:C5的=A2复制到D5应该变成=B2
+ expect(adjustedFormula).toBe('=B2');
+ });
+
+ it('应该处理多单元格区域的复制粘贴', () => {
+ // 模拟复制一个区域
+ const formulas = [
+ ['=A1', '=B1'],
+ ['=A2', '=B2']
+ ];
+
+ // 从A1:B2复制到C3:D4
+ const colOffset = 2; // C - A = 2
+ const rowOffset = 2; // 3 - 1 = 2
+
+ const adjustedFormulas = FormulaPasteProcessor.adjustFormulasForPasteWithOffset(formulas, colOffset, rowOffset);
+
+ console.log('原始公式:', formulas);
+ console.log('调整后公式:', adjustedFormulas);
+
+ expect(adjustedFormulas).toEqual([
+ ['=C3', '=D3'],
+ ['=C4', '=D4']
+ ]);
+ });
+});
diff --git a/packages/vtable-sheet/__tests__/formula-simple-copy-paste.test.ts b/packages/vtable-sheet/__tests__/formula-simple-copy-paste.test.ts
new file mode 100644
index 0000000000..7cde84eb2a
--- /dev/null
+++ b/packages/vtable-sheet/__tests__/formula-simple-copy-paste.test.ts
@@ -0,0 +1,76 @@
+/**
+ * 简单公式复制粘贴测试 - 验证具体用例
+ * C5的公式是=A2,复制到D5应该变成=B2
+ */
+
+import { FormulaReferenceAdjustor } from '../src/formula/formula-reference-adjustor';
+
+describe('简单公式复制粘贴测试', () => {
+ describe('基本相对引用调整', () => {
+ it('C5的公式=A2,复制到D5应该变成=B2', () => {
+ // 源:C5 (col=2, row=4) 到 目标:D5 (col=3, row=4)
+ // 位移:colOffset = 3-2 = +1, rowOffset = 4-4 = 0
+ const formula = '=A2';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 0);
+ expect(adjusted).toBe('=B2');
+ });
+
+ it('C5的公式=A2,复制到C6应该变成=A3', () => {
+ // 源:C5 (col=2, row=4) 到 目标:C6 (col=2, row=5)
+ // 位移:colOffset = 2-2 = 0, rowOffset = 5-4 = +1
+ const formula = '=A2';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 0, 1);
+ expect(adjusted).toBe('=A3');
+ });
+
+ it('C5的公式=A2,复制到D6应该变成=B3', () => {
+ // 源:C5 (col=2, row=4) 到 目标:D6 (col=3, row=5)
+ // 位移:colOffset = 3-2 = +1, rowOffset = 5-4 = +1
+ const formula = '=A2';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 1);
+ expect(adjusted).toBe('=B3');
+ });
+ });
+
+ describe('复杂公式测试', () => {
+ it('C5的公式=A2+B2,复制到D5应该变成=B2+C2', () => {
+ const formula = '=A2+B2';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 0);
+ expect(adjusted).toBe('=B2+C2');
+ });
+
+ it('C5的公式=SUM(A2:B3),复制到D5应该变成=SUM(B2:C3)', () => {
+ const formula = '=SUM(A2:B3)';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 0);
+ expect(adjusted).toBe('=SUM(B2:C3)');
+ });
+ });
+
+ describe('绝对引用测试', () => {
+ it('C5的公式=$A$2,复制到D5应该保持=$A$2', () => {
+ const formula = '=$A$2';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 0);
+ expect(adjusted).toBe('=$A$2');
+ });
+
+ it('C5的公式=$A2,复制到D5应该变成=$A2', () => {
+ const formula = '=$A2';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 0);
+ expect(adjusted).toBe('=$A2'); // 列绝对,行相对
+ });
+
+ it('C5的公式=A$2,复制到D5应该变成=B$2', () => {
+ const formula = '=A$2';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 0);
+ expect(adjusted).toBe('=B$2'); // 行绝对,列相对
+ });
+ });
+
+ describe('混合公式测试', () => {
+ it('C5的公式=$A2+B$2,复制到D5应该变成=$A2+C$2', () => {
+ const formula = '=$A2+B$2';
+ const adjusted = FormulaReferenceAdjustor.adjustFormulaReferences(formula, 1, 0);
+ expect(adjusted).toBe('=$A2+C$2');
+ });
+ });
+});
diff --git a/packages/vtable-sheet/__tests__/integration-formula-copy-paste.test.ts b/packages/vtable-sheet/__tests__/integration-formula-copy-paste.test.ts
new file mode 100644
index 0000000000..706862dc3f
--- /dev/null
+++ b/packages/vtable-sheet/__tests__/integration-formula-copy-paste.test.ts
@@ -0,0 +1,132 @@
+/**
+ * 公式复制粘贴集成测试
+ * 模拟实际使用场景:从C5复制公式=A2到D5,应该变成=B2
+ */
+
+describe('公式复制粘贴集成测试', () => {
+ it('应该正确处理C5到D5的公式复制粘贴', () => {
+ // 模拟vtable-sheet环境
+ const mockTable = {
+ stateManager: {
+ select: {
+ ranges: [
+ {
+ start: { col: 2, row: 4 }, // C5
+ end: { col: 2, row: 4 }
+ }
+ ]
+ }
+ },
+ processFormulaPaste: (
+ values: string[][],
+ sourceStartCol: number,
+ sourceStartRow: number,
+ targetStartCol: number,
+ targetStartRow: number
+ ) => {
+ // 模拟WorkSheet的processFormulaPaste方法
+ const colOffset = targetStartCol - sourceStartCol;
+ const rowOffset = targetStartRow - sourceStartRow;
+
+ // 简单的公式调整逻辑(实际应该使用FormulaPasteProcessor)
+ return values.map(row =>
+ row.map(cell => {
+ if (cell.startsWith('=')) {
+ // 简单的相对引用调整
+ return cell.replace(/([A-Z]+)(\d+)/g, (match, col, row) => {
+ const colNum = col.charCodeAt(0) - 'A'.charCodeAt(0) + colOffset;
+ const rowNum = parseInt(row, 10) + rowOffset;
+ return String.fromCharCode('A'.charCodeAt(0) + colNum) + rowNum;
+ });
+ }
+ return cell;
+ })
+ );
+ },
+ getCopyData: () => [['=A2']], // C5的公式
+ changeCellValues: jest.fn()
+ };
+
+ // 模拟粘贴操作
+ const sourceData = [['=A2']]; // C5的内容
+ const sourceCol = 2; // C列
+ const sourceRow = 4; // 第5行
+ const targetCol = 3; // D列
+ const targetRow = 4; // 第5行
+
+ // 使用processFormulaPaste处理
+ const processedData = mockTable.processFormulaPaste(sourceData, sourceCol, sourceRow, targetCol, targetRow);
+
+ console.log('源数据:', sourceData);
+ console.log('目标位置:', { col: targetCol, row: targetRow });
+ console.log('处理后数据:', processedData);
+
+ // 验证:C5的=A2复制到D5应该变成=B2
+ expect(processedData).toEqual([['=B2']]);
+ });
+
+ it('应该处理多单元格区域的复制粘贴', () => {
+ const mockTable = {
+ stateManager: {
+ select: {
+ ranges: [
+ {
+ start: { col: 0, row: 0 }, // A1:B2
+ end: { col: 1, row: 1 }
+ }
+ ]
+ }
+ },
+ processFormulaPaste: (
+ values: string[][],
+ sourceStartCol: number,
+ sourceStartRow: number,
+ targetStartCol: number,
+ targetStartRow: number
+ ) => {
+ const colOffset = targetStartCol - sourceStartCol;
+ const rowOffset = targetStartRow - sourceStartRow;
+
+ return values.map((row, rowIndex) =>
+ row.map((cell, colIndex) => {
+ if (cell.startsWith('=')) {
+ return cell.replace(/([A-Z]+)(\d+)/g, (match, col, row) => {
+ const colNum = col.charCodeAt(0) - 'A'.charCodeAt(0) + colOffset;
+ const rowNum = parseInt(row, 10) + rowOffset;
+ return String.fromCharCode('A'.charCodeAt(0) + colNum) + rowNum;
+ });
+ }
+ return cell;
+ })
+ );
+ },
+ getCopyData: () => [
+ ['=A1', '=B1'],
+ ['=A2', '=B2']
+ ],
+ changeCellValues: jest.fn()
+ };
+
+ // 从A1:B2复制到C3:D4
+ const sourceData = [
+ ['=A1', '=B1'],
+ ['=A2', '=B2']
+ ];
+ const sourceCol = 0; // A列
+ const sourceRow = 0; // 第1行
+ const targetCol = 2; // C列
+ const targetRow = 2; // 第3行
+
+ const processedData = mockTable.processFormulaPaste(sourceData, sourceCol, sourceRow, targetCol, targetRow);
+
+ console.log('多单元格复制测试:');
+ console.log('源数据:', sourceData);
+ console.log('目标位置:', { col: targetCol, row: targetRow });
+ console.log('处理后数据:', processedData);
+
+ expect(processedData).toEqual([
+ ['=C3', '=D3'],
+ ['=C4', '=D4']
+ ]);
+ });
+});
diff --git a/packages/vtable-sheet/__tests__/real-copy-paste-scenario.test.ts b/packages/vtable-sheet/__tests__/real-copy-paste-scenario.test.ts
new file mode 100644
index 0000000000..aed4049287
--- /dev/null
+++ b/packages/vtable-sheet/__tests__/real-copy-paste-scenario.test.ts
@@ -0,0 +1,183 @@
+/**
+ * 真实复制粘贴场景测试 - 修复版本
+ * 模拟用户从C5复制公式=A2粘贴到D5,应该变成=B2
+ */
+
+describe('真实复制粘贴场景测试', () => {
+ it('应该处理C5到D5的公式复制粘贴', () => {
+ // 模拟真实的vtable-sheet环境
+ const mockWorkSheet = {
+ getMultipleSelections: () => [
+ {
+ startCol: 2, // C列
+ startRow: 4, // 第5行
+ endCol: 2,
+ endRow: 4
+ }
+ ],
+ getData: () => [
+ ['数据1', '数据2', '数据3'],
+ ['数据4', '数据5', '数据6'],
+ ['数据7', '数据8', '数据9'],
+ ['数据10', '数据11', '数据12'],
+ ['数据13', '数据14', '=A2'] // C5的公式
+ ],
+ vtableSheet: {
+ formulaManager: {
+ isCellFormula: (cell: any) => cell.sheet === 'sheet1' && cell.row === 4 && cell.col === 2,
+ getCellFormula: (cell: any) => '=A2'
+ }
+ },
+ setCellFormula: jest.fn(),
+ setCellValue: jest.fn(),
+ getKey: () => 'sheet1'
+ };
+
+ // 模拟getCopyData方法 - 使用箭头函数保持this上下文
+ const getCopyData = () => {
+ const selections = mockWorkSheet.getMultipleSelections();
+ if (selections.length === 0) {
+ return [];
+ }
+
+ const data = mockWorkSheet.getData();
+ const result: string[][] = [];
+ const selection = selections[0];
+ const rows = selection.endRow - selection.startRow + 1;
+ const cols = selection.endCol - selection.startCol + 1;
+
+ for (let row = 0; row < rows; row++) {
+ const rowData: string[] = [];
+ for (let col = 0; col < cols; col++) {
+ const actualRow = selection.startRow + row;
+ const actualCol = selection.startCol + col;
+
+ if (data[actualRow] && data[actualRow][actualCol] !== undefined) {
+ // 检查是否是公式
+ if (
+ mockWorkSheet.vtableSheet.formulaManager.isCellFormula({
+ sheet: mockWorkSheet.getKey(),
+ row: actualRow,
+ col: actualCol
+ })
+ ) {
+ const formula = mockWorkSheet.vtableSheet.formulaManager.getCellFormula({
+ sheet: mockWorkSheet.getKey(),
+ row: actualRow,
+ col: actualCol
+ });
+ rowData.push(formula);
+ } else {
+ rowData.push(data[actualRow][actualCol]);
+ }
+ } else {
+ rowData.push('');
+ }
+ }
+ result.push(rowData);
+ }
+ return result;
+ };
+
+ // 测试复制数据
+ const copiedData = getCopyData();
+ console.log('从C5复制的数据:', copiedData);
+ expect(copiedData).toEqual([['=A2']]);
+
+ // 模拟processFormulaPaste方法
+ const processFormulaPaste = (
+ formulas: string[][],
+ sourceStartCol: number,
+ sourceStartRow: number,
+ targetStartCol: number,
+ targetStartRow: number
+ ): string[][] => {
+ if (!formulas || formulas.length === 0) {
+ return formulas;
+ }
+
+ // 计算偏移量
+ const colOffset = targetStartCol - sourceStartCol;
+ const rowOffset = targetStartRow - sourceStartRow;
+
+ // 调整公式引用
+ return formulas.map(row =>
+ row.map(cell => {
+ if (cell.startsWith('=')) {
+ return cell.replace(/([A-Z]+)(\d+)/g, (match, col, row) => {
+ const colNum = col.charCodeAt(0) - 'A'.charCodeAt(0) + colOffset;
+ const rowNum = parseInt(row, 10) + rowOffset;
+ return String.fromCharCode('A'.charCodeAt(0) + colNum) + rowNum;
+ });
+ }
+ return cell;
+ })
+ );
+ };
+
+ // 模拟粘贴到D5(从C5复制到D5)
+ const targetCol = 3; // D列
+ const targetRow = 4; // 第5行
+
+ const processedData = processFormulaPaste(
+ copiedData,
+ 2, // 源C列
+ 4, // 源第5行
+ targetCol,
+ targetRow
+ );
+
+ console.log('粘贴到D5的处理结果:', processedData);
+
+ // 验证:C5的=A2复制到D5应该变成=B2
+ expect(processedData).toEqual([['=B2']]);
+ });
+
+ it('应该处理多单元格区域的复制粘贴', () => {
+ // 模拟A1:B2区域的公式
+ const sourceData = [
+ ['=A1', '=B1'],
+ ['=A2', '=B2']
+ ];
+
+ // 从A1:B2复制到C3:D4
+ const processFormulaPaste = (
+ formulas: string[][],
+ sourceStartCol: number,
+ sourceStartRow: number,
+ targetStartCol: number,
+ targetStartRow: number
+ ): string[][] => {
+ const colOffset = targetStartCol - sourceStartCol;
+ const rowOffset = targetStartRow - sourceStartRow;
+
+ return formulas.map(row =>
+ row.map(cell => {
+ if (cell.startsWith('=')) {
+ return cell.replace(/([A-Z]+)(\d+)/g, (match, col, row) => {
+ const colNum = col.charCodeAt(0) - 'A'.charCodeAt(0) + colOffset;
+ const rowNum = parseInt(row, 10) + rowOffset;
+ return String.fromCharCode('A'.charCodeAt(0) + colNum) + rowNum;
+ });
+ }
+ return cell;
+ })
+ );
+ };
+
+ const result = processFormulaPaste(
+ sourceData,
+ 0, // A列
+ 0, // 第1行
+ 2, // C列
+ 2 // 第3行
+ );
+
+ console.log('多单元格复制结果:', result);
+
+ expect(result).toEqual([
+ ['=C3', '=D3'],
+ ['=C4', '=D4']
+ ]);
+ });
+});
diff --git a/packages/vtable-sheet/__tests__/simple-copy-paste-scenario.test.ts b/packages/vtable-sheet/__tests__/simple-copy-paste-scenario.test.ts
new file mode 100644
index 0000000000..bdebad5adb
--- /dev/null
+++ b/packages/vtable-sheet/__tests__/simple-copy-paste-scenario.test.ts
@@ -0,0 +1,136 @@
+/**
+ * 简单复制粘贴场景测试
+ * 验证公式相对引用调整
+ */
+
+describe('简单复制粘贴场景测试', () => {
+ it('C5的=A2复制到D5应该变成=B2', () => {
+ // 模拟processFormulaPaste的核心逻辑
+ const processFormulaPaste = (
+ formulas: string[][],
+ sourceStartCol: number,
+ sourceStartRow: number,
+ targetStartCol: number,
+ targetStartRow: number
+ ): string[][] => {
+ const colOffset = targetStartCol - sourceStartCol;
+ const rowOffset = targetStartRow - sourceStartRow;
+
+ return formulas.map(row =>
+ row.map(cell => {
+ if (cell.startsWith('=')) {
+ return cell.replace(/([A-Z]+)(\d+)/g, (match, col, row) => {
+ const colNum = col.charCodeAt(0) - 'A'.charCodeAt(0) + colOffset;
+ const rowNum = parseInt(row, 10) + rowOffset;
+ return String.fromCharCode('A'.charCodeAt(0) + colNum) + rowNum;
+ });
+ }
+ return cell;
+ })
+ );
+ };
+
+ // 测试数据:C5的公式=A2
+ const sourceData = [['=A2']];
+ const sourceCol = 2; // C列
+ const sourceRow = 4; // 第5行
+ const targetCol = 3; // D列
+ const targetRow = 4; // 第5行
+
+ console.log('复制粘贴测试:');
+ console.log('源数据:', sourceData);
+ console.log(
+ `从${String.fromCharCode(65 + sourceCol)}${sourceRow + 1}复制到${String.fromCharCode(65 + targetCol)}${
+ targetRow + 1
+ }`
+ );
+
+ const result = processFormulaPaste(sourceData, sourceCol, sourceRow, targetCol, targetRow);
+
+ console.log('结果:', result);
+
+ // 验证:C5的=A2复制到D5应该变成=B2
+ expect(result).toEqual([['=B2']]);
+ });
+
+ it('A1:B2区域复制到C3:D4', () => {
+ const processFormulaPaste = (
+ formulas: string[][],
+ sourceStartCol: number,
+ sourceStartRow: number,
+ targetStartCol: number,
+ targetStartRow: number
+ ): string[][] => {
+ const colOffset = targetStartCol - sourceStartCol;
+ const rowOffset = targetStartRow - sourceStartRow;
+
+ return formulas.map(row =>
+ row.map(cell => {
+ if (cell.startsWith('=')) {
+ return cell.replace(/([A-Z]+)(\d+)/g, (match, col, row) => {
+ const colNum = col.charCodeAt(0) - 'A'.charCodeAt(0) + colOffset;
+ const rowNum = parseInt(row, 10) + rowOffset;
+ return String.fromCharCode('A'.charCodeAt(0) + colNum) + rowNum;
+ });
+ }
+ return cell;
+ })
+ );
+ };
+
+ const sourceData = [
+ ['=A1', '=B1'],
+ ['=A2', '=B2']
+ ];
+
+ const result = processFormulaPaste(sourceData, 0, 0, 2, 2); // A1:B2 -> C3:D4
+
+ console.log('区域复制结果:', result);
+
+ expect(result).toEqual([
+ ['=C3', '=D3'],
+ ['=C4', '=D4']
+ ]);
+ });
+
+ it('复杂公式测试', () => {
+ const processFormulaPaste = (
+ formulas: string[][],
+ sourceStartCol: number,
+ sourceStartRow: number,
+ targetStartCol: number,
+ targetStartRow: number
+ ): string[][] => {
+ const colOffset = targetStartCol - sourceStartCol;
+ const rowOffset = targetStartRow - sourceStartRow;
+
+ return formulas.map(row =>
+ row.map(cell => {
+ if (cell.startsWith('=')) {
+ return cell.replace(/([A-Z]+)(\d+)/g, (match, col, row) => {
+ const colNum = col.charCodeAt(0) - 'A'.charCodeAt(0) + colOffset;
+ const rowNum = parseInt(row, 10) + rowOffset;
+ return String.fromCharCode('A'.charCodeAt(0) + colNum) + rowNum;
+ });
+ }
+ return cell;
+ })
+ );
+ };
+
+ // 测试复杂公式
+ const complexFormulas = [
+ ['=A1+B1*C1', '=SUM(A1:B1)'],
+ ['=A2/B2', '=MAX(A1:A2)']
+ ];
+
+ const result = processFormulaPaste(complexFormulas, 0, 0, 1, 1); // A1:B2 -> B2:C3
+
+ console.log('复杂公式结果:', result);
+
+ expect(result).toEqual([
+ ['=B2+C2*D2', '=SUM(B2:C2)'],
+ ['=B3/C3', '=MAX(B2:B3)']
+ ]);
+ });
+});
diff --git a/packages/vtable-sheet/src/core/WorkSheet.ts b/packages/vtable-sheet/src/core/WorkSheet.ts
index f74f41b6a3..28100f1a75 100644
--- a/packages/vtable-sheet/src/core/WorkSheet.ts
+++ b/packages/vtable-sheet/src/core/WorkSheet.ts
@@ -18,6 +18,7 @@ import type { TYPES, VTableSheet } from '..';
import { isPropertyWritable } from '../tools';
import { VTableThemes } from '../ts-types';
import { detectFunctionParameterPosition } from '../formula/formula-helper';
+import { FormulaPasteProcessor } from '../formula/formula-paste-processor';
/**
* Sheet constructor options. 内部类型Sheet的构造函数参数类型
@@ -188,6 +189,10 @@ export class WorkSheet extends EventTarget implements IWorkSheetAPI {
const keyboardOptions = {
...this.options.keyboardOptions,
copySelected: true,
+ getCopyCellValue: {
+ html: this.getCellValueConsiderFormula.bind(this)
+ },
+ processFormulaBeforePaste: this.processFormulaPaste.bind(this),
pasteValueToCell: true,
showCopyCellBorder: true,
cutSelected: true
@@ -679,10 +684,10 @@ export class WorkSheet extends EventTarget implements IWorkSheetAPI {
}
/**
* 获取指定坐标的单元格值
- * @param row 行索引
* @param col 列索引
+ * @param row 行索引
*/
- getCellValue(row: number, col: number): any {
+ getCellValue(col: number, row: number): any {
if (this.tableInstance) {
try {
const value = this.tableInstance.getCellValue(col, row);
@@ -698,14 +703,42 @@ export class WorkSheet extends EventTarget implements IWorkSheetAPI {
}
return null;
}
+ /**
+ * 获取指定坐标的单元格值
+ * @param col 列索引
+ * @param row 行索引
+ */
+ getCellValueConsiderFormula(col: number, row: number): any {
+ if (this.tableInstance) {
+ try {
+ if (
+ this.vtableSheet.formulaManager.isCellFormula({
+ sheet: this.getKey(),
+ row,
+ col
+ })
+ ) {
+ return this.vtableSheet.formulaManager.getCellFormula({
+ sheet: this.getKey(),
+ row,
+ col
+ });
+ }
+ return this.getCellValue(col, row);
+ } catch (error) {
+ console.warn('Failed to get cell value from VTable:', error);
+ }
+ return null;
+ }
+ }
/**
* 设置指定坐标的单元格值
- * @param row 行索引
* @param col 列索引
+ * @param row 行索引
* @param value 新值
*/
- setCellValue(row: number, col: number, value: any): void {
+ setCellValue(col: number, row: number, value: any): void {
const data = this.getData();
if (data && data[row]) {
const oldValue = data[row][col];
@@ -736,7 +769,7 @@ export class WorkSheet extends EventTarget implements IWorkSheetAPI {
const coord = this.coordFromAddress(address);
return {
coord,
- value: this.getCellValue(coord.row, coord.col)
+ value: this.getCellValue(coord.col, coord.row)
};
}
@@ -745,21 +778,21 @@ export class WorkSheet extends EventTarget implements IWorkSheetAPI {
* @param coord 坐标
*/
addressFromCoord(coord: CellCoord): string;
- addressFromCoord(row: number, col: number): string;
- addressFromCoord(coordOrRow: CellCoord | number, col?: number): string {
- let row: number;
- let colNum: number;
-
- if (typeof coordOrRow === 'object') {
- row = coordOrRow.row;
- colNum = coordOrRow.col;
+ addressFromCoord(col: number, row: number): string;
+ addressFromCoord(coordOrCol: CellCoord | number, row?: number): string {
+ let col: number;
+ let rowNum: number;
+
+ if (typeof coordOrCol === 'object') {
+ col = coordOrCol.col;
+ rowNum = coordOrCol.row;
} else {
- row = coordOrRow;
- colNum = col!;
+ col = coordOrCol;
+ rowNum = row!;
}
let colStr = '';
- let tempCol = colNum + 1;
+ let tempCol = col + 1;
do {
tempCol -= 1;
@@ -767,7 +800,7 @@ export class WorkSheet extends EventTarget implements IWorkSheetAPI {
tempCol = Math.floor(tempCol / 26);
} while (tempCol > 0);
- return `${colStr}${row + 1}`;
+ return `${colStr}${rowNum + 1}`;
}
/**
@@ -852,6 +885,148 @@ export class WorkSheet extends EventTarget implements IWorkSheetAPI {
});
}
+ /**
+ * 处理公式粘贴 - 调整公式中的单元格引用
+ * @param formulas 要粘贴的公式数组
+ * @param sourceStartCol 源起始列
+ * @param sourceStartRow 源起始行
+ * @param targetStartCol 目标起始列
+ * @param targetStartRow 目标起始行
+ */
+ processFormulaPaste(
+ formulas: string[][],
+ sourceStartCol: number,
+ sourceStartRow: number,
+ targetStartCol: number,
+ targetStartRow: number
+ ): (string | number)[][] {
+ if (!formulas || formulas.length === 0) {
+ return formulas;
+ }
+
+ // 计算整个范围的相对位移
+ const colOffset = targetStartCol - sourceStartCol;
+ const rowOffset = targetStartRow - sourceStartRow;
+
+ // 使用计算出的位移来调整公式
+ return FormulaPasteProcessor.adjustFormulasForPasteWithOffset(formulas, colOffset, rowOffset);
+ }
+
+ /**
+ * 获取复制数据,包括公式处理
+ */
+ getCopyData(): (string | number)[][] {
+ const selections = this.getMultipleSelections();
+ if (selections.length === 0) {
+ return [];
+ }
+
+ const data = this.getData();
+ const result: string[][] = [];
+
+ // 获取第一个选择范围
+ const selection = selections[0];
+ const rows = selection.endRow - selection.startRow + 1;
+ const cols = selection.endCol - selection.startCol + 1;
+
+ for (let row = 0; row < rows; row++) {
+ const rowData: string[] = [];
+ for (let col = 0; col < cols; col++) {
+ const actualRow = selection.startRow + row;
+ const actualCol = selection.startCol + col;
+
+ if (data[actualRow] && data[actualRow][actualCol] !== undefined) {
+ // 如果是公式,返回公式字符串;否则返回值
+ if (
+ this.vtableSheet.formulaManager.isCellFormula({
+ sheet: this.getKey(),
+ row: actualRow,
+ col: actualCol
+ })
+ ) {
+ const formula = this.vtableSheet.formulaManager.getCellFormula({
+ sheet: this.getKey(),
+ row: actualRow,
+ col: actualCol
+ });
+ rowData.push(formula);
+ } else {
+ rowData.push(data[actualRow][actualCol]);
+ }
+ } else {
+ rowData.push('');
+ }
+ }
+ result.push(rowData);
+ }
+
+ return result;
+ }
+
+ /**
+ * 粘贴数据,包括公式处理
+ */
+ pasteData(data: string[][], targetStartCol: number, targetStartRow: number): void {
+ if (!data || data.length === 0) {
+ return;
+ }
+
+ const selections = this.getMultipleSelections();
+ if (selections.length === 0) {
+ return;
+ }
+
+ // 获取源数据范围(从当前选择范围推断)
+ const sourceSelection = selections[0];
+ const sourceStartCol = sourceSelection.startCol;
+ const sourceStartRow = sourceSelection.startRow;
+
+ // 处理公式粘贴
+ const processedData = this.processFormulaPaste(
+ data,
+ sourceStartCol,
+ sourceStartRow,
+ targetStartCol,
+ targetStartRow
+ );
+
+ // 应用处理后的数据
+ const dataArray = this.getData();
+ for (let row = 0; row < processedData.length; row++) {
+ for (let col = 0; col < processedData[row].length; col++) {
+ const targetRow = targetStartRow + row;
+ const targetCol = targetStartCol + col;
+
+ if (targetRow < dataArray.length && targetCol < dataArray[targetRow].length) {
+ const value = processedData[row][col];
+
+ // 如果是公式,设置公式;否则设置普通值
+ if (FormulaPasteProcessor.needsFormulaAdjustment(value)) {
+ this.setCellFormula(targetRow, targetCol, value);
+ } else {
+ this.setCellValue(targetRow, targetCol, value);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * 设置单元格公式
+ */
+ setCellFormula(row: number, col: number, formula: string): void {
+ if (this.vtableSheet.formulaManager) {
+ this.vtableSheet.formulaManager.setCellContent(
+ {
+ sheet: this.getKey(),
+ row,
+ col
+ },
+ formula
+ );
+ }
+ }
+
/**
* 释放资源
*/
diff --git a/packages/vtable-sheet/src/formula/formula-paste-processor.ts b/packages/vtable-sheet/src/formula/formula-paste-processor.ts
new file mode 100644
index 0000000000..97a6195548
--- /dev/null
+++ b/packages/vtable-sheet/src/formula/formula-paste-processor.ts
@@ -0,0 +1,153 @@
+/**
+ * 公式粘贴处理器 - 处理公式在复制粘贴时的引用调整
+ */
+
+import { FormulaReferenceAdjustor } from './formula-reference-adjustor';
+
+export interface FormulaPasteContext {
+ sourceRange: {
+ startCol: number;
+ startRow: number;
+ endCol: number;
+ endRow: number;
+ };
+ targetRange: {
+ startCol: number;
+ startRow: number;
+ endCol: number;
+ endRow: number;
+ };
+ sourceCell: {
+ col: number;
+ row: number;
+ };
+ targetCell: {
+ col: number;
+ row: number;
+ };
+}
+
+export class FormulaPasteProcessor {
+ /**
+ * 处理单个公式的粘贴调整
+ */
+ static adjustFormulaForPaste(formula: string | number, context: FormulaPasteContext): string | number {
+ if (!FormulaReferenceAdjustor.isFormula(formula)) {
+ return formula;
+ }
+
+ // 计算相对偏移:目标位置相对于源位置的位移
+ const colOffset = context.targetCell.col - context.sourceCell.col;
+ const rowOffset = context.targetCell.row - context.sourceCell.row;
+
+ // 调整公式引用
+ return FormulaReferenceAdjustor.adjustFormulaReferences(formula, colOffset, rowOffset);
+ }
+
+ /**
+ * 批量处理公式粘贴
+ */
+ static adjustFormulasForPaste(formulas: (string | number)[][], context: FormulaPasteContext): (string | number)[][] {
+ // 计算整个范围的相对位移
+ const colOffset = context.targetRange.startCol - context.sourceRange.startCol;
+ const rowOffset = context.targetRange.startRow - context.sourceRange.startRow;
+
+ return this.adjustFormulasForPasteWithOffset(formulas, colOffset, rowOffset);
+ }
+
+ /**
+ * 使用指定偏移批量处理公式粘贴
+ */
+ static adjustFormulasForPasteWithOffset(
+ formulas: (string | number)[][],
+ colOffset: number,
+ rowOffset: number
+ ): (string | number)[][] {
+ const result: (string | number)[][] = [];
+
+ for (let row = 0; row < formulas.length; row++) {
+ const newRow: (string | number)[] = [];
+ for (let col = 0; col < formulas[row].length; col++) {
+ const formula = formulas[row][col];
+
+ if (FormulaReferenceAdjustor.isFormula(formula)) {
+ // 对整个公式应用相同的相对位移
+ const adjustedFormula = FormulaReferenceAdjustor.adjustFormulaReferences(formula, colOffset, rowOffset);
+ newRow.push(adjustedFormula);
+ } else {
+ // 非公式内容保持不变
+ newRow.push(formula);
+ }
+ }
+ result.push(newRow);
+ }
+
+ return result;
+ }
+
+ /**
+ * 检查是否需要公式调整
+ */
+ static needsFormulaAdjustment(value: any): boolean {
+ return FormulaReferenceAdjustor.isFormula(value);
+ }
+
+ /**
+ * 获取公式中的引用信息
+ */
+ static getFormulaReferences(formula: string) {
+ return FormulaReferenceAdjustor.extractReferences(formula);
+ }
+
+ /**
+ * 验证公式引用是否在有效范围内
+ */
+ static validateFormulaReferences(formula: string, maxCol: number, maxRow: number): boolean {
+ const references = FormulaReferenceAdjustor.extractReferences(formula);
+
+ for (const ref of references) {
+ if (ref.col < 0 || ref.col > maxCol || ref.row < 0 || ref.row > maxRow) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * 创建粘贴上下文
+ */
+ static createPasteContext(
+ sourceStartCol: number,
+ sourceStartRow: number,
+ targetStartCol: number,
+ targetStartRow: number,
+ sourceCols: number,
+ sourceRows: number,
+ targetCols: number,
+ targetRows: number
+ ): FormulaPasteContext {
+ return {
+ sourceRange: {
+ startCol: sourceStartCol,
+ startRow: sourceStartRow,
+ endCol: sourceStartCol + sourceCols - 1,
+ endRow: sourceStartRow + sourceRows - 1
+ },
+ targetRange: {
+ startCol: targetStartCol,
+ startRow: targetStartRow,
+ endCol: targetStartCol + targetCols - 1,
+ endRow: targetStartRow + targetRows - 1
+ },
+ sourceCell: {
+ col: sourceStartCol,
+ row: sourceStartRow
+ },
+ targetCell: {
+ col: targetStartCol,
+ row: targetStartRow
+ }
+ };
+ }
+}
diff --git a/packages/vtable-sheet/src/formula/formula-range-selector.ts b/packages/vtable-sheet/src/formula/formula-range-selector.ts
index 63f5952705..14ca902ddd 100644
--- a/packages/vtable-sheet/src/formula/formula-range-selector.ts
+++ b/packages/vtable-sheet/src/formula/formula-range-selector.ts
@@ -33,8 +33,8 @@ export class FormulaRangeSelector {
const ranges: string[] = [];
for (const range of selections) {
- const startAddr = addressFromCoord(range.startRow, range.startCol);
- const endAddr = addressFromCoord(range.endRow, range.endCol);
+ const startAddr = addressFromCoord(range.startCol, range.startRow);
+ const endAddr = addressFromCoord(range.endCol, range.endRow);
// 如果是单个单元格(start和end相同)
if (range.startRow === range.endRow && range.startCol === range.endCol) {
@@ -348,7 +348,7 @@ export class FormulaRangeSelector {
if (typeof newValue === 'string' && newValue.startsWith('=') && newValue.length > 1) {
try {
// 检查是否包含循环引用
- const currentCellAddress = activeWorkSheet.addressFromCoord(event.row, event.col);
+ const currentCellAddress = activeWorkSheet.addressFromCoord(event.col, event.row);
// 使用正则表达式来精确匹配单元格引用
const cellRegex = new RegExp(`(^|[^A-Za-z0-9])${currentCellAddress}([^A-Za-z0-9]|$)`);
if (cellRegex.test(newValue)) {
@@ -509,8 +509,8 @@ export class FormulaRangeSelector {
editCell?.col || 0
);
- this.handleSelectionChanged([safeSelections], formulaInput, isCtrlAddSelection, (row: number, col: number) =>
- activeWorkSheet!.addressFromCoord(row, col)
+ this.handleSelectionChanged([safeSelections], formulaInput, isCtrlAddSelection, (col: number, row: number) =>
+ activeWorkSheet!.addressFromCoord(col, row)
);
// 写入后不再刷新公式栏,以免覆盖刚插入的引用
diff --git a/packages/vtable-sheet/src/formula/formula-reference-adjustor.ts b/packages/vtable-sheet/src/formula/formula-reference-adjustor.ts
new file mode 100644
index 0000000000..d839d202e2
--- /dev/null
+++ b/packages/vtable-sheet/src/formula/formula-reference-adjustor.ts
@@ -0,0 +1,245 @@
+/**
+ * 公式引用调整器 - 实现Excel风格的公式复制粘贴功能
+ * 处理公式中单元格引用的相对调整
+ */
+
+export interface CellReference {
+ type: 'absolute' | 'relative' | 'mixed_row' | 'mixed_col';
+ col: number; // 0-based column index
+ row: number; // 0-based row index
+ originalColRef: string; // 原始列引用,如 "A", "$B"
+ originalRowRef: string; // 原始行引用,如 "1", "$2"
+ fullReference: string; // 完整引用,如 "A1", "$B$2", "A$1"
+}
+
+export interface ReferenceOffset {
+ colOffset: number;
+ rowOffset: number;
+}
+
+export class FormulaReferenceAdjustor {
+ private static readonly CELL_REF_REGEX = /(\$?[A-Z]+\$?\d+(?::\$?[A-Z]+\$?\d+)?)/gi;
+ private static readonly SINGLE_CELL_REGEX = /(\$?)([A-Z]+)(\$?)(\d+)/i;
+
+ /**
+ * 解析单元格引用
+ */
+ static parseCellReference(ref: string): CellReference | null {
+ const match = ref.match(this.SINGLE_CELL_REGEX);
+ if (!match) {
+ return null;
+ }
+
+ const [, colAbsolute, colStr, rowAbsolute, rowStr] = match;
+ const col = this.columnToNumber(colStr);
+ const row = parseInt(rowStr, 10) - 1; // Convert to 0-based
+
+ let type: CellReference['type'];
+ if (colAbsolute && rowAbsolute) {
+ type = 'absolute';
+ } else if (colAbsolute && !rowAbsolute) {
+ type = 'mixed_col';
+ } else if (!colAbsolute && rowAbsolute) {
+ type = 'mixed_row';
+ } else {
+ type = 'relative';
+ }
+
+ return {
+ type,
+ col,
+ row,
+ originalColRef: colAbsolute + colStr,
+ originalRowRef: rowAbsolute + rowStr,
+ fullReference: ref
+ };
+ }
+
+ /**
+ * 将列字母转换为数字 (A -> 0, B -> 1, ..., Z -> 25, AA -> 26, etc.)
+ */
+ static columnToNumber(col: string): number {
+ let result = 0;
+ for (let i = 0; i < col.length; i++) {
+ result = result * 26 + (col.charCodeAt(i) - 'A'.charCodeAt(0) + 1);
+ }
+ return result - 1; // Convert to 0-based
+ }
+
+ /**
+ * 将数字转换为列字母 (0 -> A, 1 -> B, ..., 25 -> Z, 26 -> AA, etc.)
+ */
+ static numberToColumn(num: number): string {
+ let result = '';
+ let n = num + 1; // Convert to 1-based
+ while (n > 0) {
+ n--;
+ result = String.fromCharCode('A'.charCodeAt(0) + (n % 26)) + result;
+ n = Math.floor(n / 26);
+ }
+ return result;
+ }
+
+ /**
+ * 调整单元格引用
+ */
+ static adjustCellReference(ref: CellReference, offset: ReferenceOffset): string {
+ let newCol = ref.col;
+ let newRow = ref.row;
+
+ switch (ref.type) {
+ case 'relative':
+ newCol += offset.colOffset;
+ newRow += offset.rowOffset;
+ break;
+ case 'mixed_row':
+ // 行绝对,列相对
+ newCol += offset.colOffset;
+ newRow = ref.row; // 行绝对引用,不改变
+ break;
+ case 'mixed_col':
+ // 列绝对,行相对
+ newCol = ref.col; // 列绝对引用,不改变
+ newRow += offset.rowOffset;
+ break;
+ case 'absolute':
+ // 绝对引用,不改变任何值
+ newCol = ref.col;
+ newRow = ref.row;
+ break;
+ }
+
+ // 确保坐标在有效范围内
+ if (newCol < 0) {
+ newCol = 0;
+ }
+ if (newRow < 0) {
+ newRow = 0;
+ }
+
+ // 构建新的引用字符串
+ let result = '';
+ if (ref.type === 'absolute' || ref.type === 'mixed_col') {
+ result += '$';
+ }
+ result += this.numberToColumn(newCol);
+ if (ref.type === 'absolute' || ref.type === 'mixed_row') {
+ result += '$';
+ }
+ result += newRow + 1; // Convert back to 1-based
+
+ return result;
+ }
+
+ /**
+ * 调整公式中的引用
+ * @param formula 原始公式
+ * @param colOffset 列位移(目标列 - 源列)
+ * @param rowOffset 行位移(目标行 - 源行)
+ */
+ static adjustFormulaReferences(formula: string | number, colOffset: number, rowOffset: number): string | number {
+ const offset = {
+ colOffset: colOffset,
+ rowOffset: rowOffset
+ };
+
+ return typeof formula === 'string'
+ ? formula.replace(this.CELL_REF_REGEX, match => {
+ // 处理范围引用(如 A1:B2)
+ if (match.includes(':')) {
+ const parts = match.split(':');
+ const startRef = this.parseCellReference(parts[0]);
+ const endRef = this.parseCellReference(parts[1]);
+
+ if (startRef && endRef) {
+ const newStart = this.adjustCellReference(startRef, offset);
+ const newEnd = this.adjustCellReference(endRef, offset);
+ return `${newStart}:${newEnd}`;
+ }
+ return match; // 如果解析失败,保持原样
+ }
+
+ // 处理单个单元格引用
+ const ref = this.parseCellReference(match);
+ if (ref) {
+ return this.adjustCellReference(ref, offset);
+ }
+ return match; // 如果解析失败,保持原样
+ })
+ : formula;
+ }
+
+ /**
+ * 检查是否为公式
+ */
+ static isFormula(value: any): boolean {
+ return typeof value === 'string' && value.startsWith('=');
+ }
+
+ /**
+ * 提取公式中的引用
+ */
+ static extractReferences(formula: string): CellReference[] {
+ const references: CellReference[] = [];
+ const matches = formula.match(this.CELL_REF_REGEX);
+
+ if (matches) {
+ matches.forEach(match => {
+ // 处理范围引用
+ if (match.includes(':')) {
+ const parts = match.split(':');
+ const startRef = this.parseCellReference(parts[0]);
+ const endRef = this.parseCellReference(parts[1]);
+
+ if (startRef) {
+ references.push(startRef);
+ }
+ if (endRef) {
+ references.push(endRef);
+ }
+ } else {
+ const ref = this.parseCellReference(match);
+ if (ref) {
+ references.push(ref);
+ }
+ }
+ });
+ }
+
+ return references;
+ }
+
+ /**
+ * 获取公式中的引用范围
+ */
+ static getFormulaReferenceBounds(formula: string): {
+ minCol: number;
+ maxCol: number;
+ minRow: number;
+ maxRow: number;
+ } | null {
+ const references = this.extractReferences(formula);
+ if (references.length === 0) {
+ return null;
+ }
+
+ let minCol = Infinity;
+ let maxCol = -Infinity;
+ let minRow = Infinity;
+ let maxRow = -Infinity;
+
+ references.forEach(ref => {
+ minCol = Math.min(minCol, ref.col);
+ maxCol = Math.max(maxCol, ref.col);
+ minRow = Math.min(minRow, ref.row);
+ maxRow = Math.max(maxRow, ref.row);
+ });
+
+ return {
+ minCol,
+ maxCol,
+ minRow,
+ maxRow
+ };
+ }
+}
diff --git a/packages/vtable-sheet/src/formula/formula-ui-manager.ts b/packages/vtable-sheet/src/formula/formula-ui-manager.ts
index 8f5ee9a739..47f43272d6 100644
--- a/packages/vtable-sheet/src/formula/formula-ui-manager.ts
+++ b/packages/vtable-sheet/src/formula/formula-ui-manager.ts
@@ -204,12 +204,12 @@ export class FormulaUIManager {
if (value.startsWith('=') && value.length > 1) {
try {
// 检查是否包含循环引用
- const currentCellAddress = activeWorkSheet.addressFromCoord(selection.row, selection.col);
+ const currentCellAddress = activeWorkSheet.addressFromCoord(selection.col, selection.row);
// 使用正则表达式来精确匹配单元格引用
const cellRegex = new RegExp(`(^|[^A-Za-z0-9])${currentCellAddress}([^A-Za-z0-9]|$)`);
if (cellRegex.test(value)) {
console.warn('Circular reference detected:', value, 'contains', currentCellAddress);
- activeWorkSheet.setCellValue(selection.row, selection.col, '#CYCLE!');
+ activeWorkSheet.setCellValue(selection.col, selection.row, '#CYCLE!');
activeWorkSheet.tableInstance?.changeCellValue(selection.col, selection.row, '#CYCLE!');
formulaInput.value = '';
formulaInput.blur();
@@ -233,14 +233,14 @@ export class FormulaUIManager {
col: selection.col
});
- activeWorkSheet.setCellValue(selection.row, selection.col, result.value);
+ activeWorkSheet.setCellValue(selection.col, selection.row, result.value);
} catch (error) {
console.warn('Formula confirmation error:', error);
// 显示错误状态
- activeWorkSheet.setCellValue(selection.row, selection.col, '#ERROR!');
+ activeWorkSheet.setCellValue(selection.col, selection.row, '#ERROR!');
}
} else {
- activeWorkSheet.setCellValue(selection.row, selection.col, value);
+ activeWorkSheet.setCellValue(selection.col, selection.row, value);
}
}
}
@@ -284,7 +284,7 @@ export class FormulaUIManager {
// 更新单元格地址
const cellAddressBox = this.formulaBarElement.querySelector('.vtable-sheet-cell-address');
if (cellAddressBox) {
- cellAddressBox.textContent = activeWorkSheet.addressFromCoord(selection.startRow, selection.startCol);
+ cellAddressBox.textContent = activeWorkSheet.addressFromCoord(selection.startCol, selection.startRow);
}
// 更新公式输入框
@@ -315,7 +315,7 @@ export class FormulaUIManager {
const displayFormula = formula.startsWith('=') ? formula : '=' + formula;
formulaInput.value = displayFormula;
} else {
- const cellValue = activeWorkSheet.getCellValue(selection.startRow, selection.startCol);
+ const cellValue = activeWorkSheet.getCellValue(selection.startCol, selection.startRow);
formulaInput.value = cellValue !== undefined && cellValue !== null ? String(cellValue) : '';
}
} catch (e) {
@@ -423,12 +423,12 @@ export class FormulaUIManager {
if (value.startsWith('=') && value.length > 1) {
try {
// 检查是否包含循环引用
- const currentCellAddress = activeWorkSheet.addressFromCoord(editingCell.row, editingCell.col);
+ const currentCellAddress = activeWorkSheet.addressFromCoord(editingCell.col, editingCell.row);
// 使用正则表达式来精确匹配单元格引用
const cellRegex = new RegExp(`(^|[^A-Za-z0-9])${currentCellAddress}([^A-Za-z0-9]|$)`);
if (cellRegex.test(value)) {
console.warn('Circular reference detected:', value, 'contains', currentCellAddress);
- activeWorkSheet.setCellValue(editingCell.row, editingCell.col, '#CYCLE!');
+ activeWorkSheet.setCellValue(editingCell.col, editingCell.row, '#CYCLE!');
activeWorkSheet.tableInstance?.changeCellValue(editingCell.col, editingCell.row, '#CYCLE!');
this.sheet.formulaManager.formulaWorkingOnCell = null;
input.value = '';
@@ -472,13 +472,13 @@ export class FormulaUIManager {
} catch (error) {
console.warn('Formula evaluation error:', error);
// 显示错误状态
- activeWorkSheet.setCellValue(editingCell.row, editingCell.col, '#ERROR!');
+ activeWorkSheet.setCellValue(editingCell.col, editingCell.row, '#ERROR!');
activeWorkSheet.tableInstance?.changeCellValue(editingCell.col, editingCell.row, '#ERROR!');
this.sheet.formulaManager.formulaWorkingOnCell = null;
}
} else {
// 普通值,直接设置
- activeWorkSheet.setCellValue(editingCell.row, editingCell.col, value);
+ activeWorkSheet.setCellValue(editingCell.col, editingCell.row, value);
activeWorkSheet.tableInstance?.changeCellValue(editingCell.col, editingCell.row, value);
this.sheet.formulaManager.formulaWorkingOnCell = null;
}
diff --git a/packages/vtable-sheet/src/formula/index.ts b/packages/vtable-sheet/src/formula/index.ts
index dfa44668ba..b1f16cd827 100644
--- a/packages/vtable-sheet/src/formula/index.ts
+++ b/packages/vtable-sheet/src/formula/index.ts
@@ -3,3 +3,7 @@ export * from './formula-autocomplete';
export * from './formula-range-selector';
export * from './formula-ui-manager';
export * from './cell-highlight-manager';
+export { FormulaReferenceAdjustor } from './formula-reference-adjustor';
+export { FormulaPasteProcessor } from './formula-paste-processor';
+export type { CellReference, ReferenceOffset } from './formula-reference-adjustor';
+export type { FormulaPasteContext } from './formula-paste-processor';
diff --git a/packages/vtable-sheet/src/ts-types/sheet.ts b/packages/vtable-sheet/src/ts-types/sheet.ts
index 416db27674..57acf4a7e3 100644
--- a/packages/vtable-sheet/src/ts-types/sheet.ts
+++ b/packages/vtable-sheet/src/ts-types/sheet.ts
@@ -25,10 +25,10 @@ export interface IWorkSheetOptions extends Omit CellValue;
+ getCellValue: (col: number, row: number) => CellValue;
/** 设置单元格值 */
- setCellValue: (row: number, col: number, value: CellValue) => void;
+ setCellValue: (col: number, row: number, value: CellValue) => void;
/** 根据地址获取单元格 */
getCellByAddress: (address: string) => { coord: CellCoord; value: CellValue };
diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts
index 9e714b33ce..6d31ccd782 100644
--- a/packages/vtable/src/core/BaseTable.ts
+++ b/packages/vtable/src/core/BaseTable.ts
@@ -4200,7 +4200,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI {
}
/**获取选中区域的内容 作为复制内容 */
- getCopyValue(): string | null {
+ getCopyValue(getCellValueFunction?: (col: number, row: number) => string | number): string | null {
if (this.stateManager.select?.ranges?.length > 0) {
const ranges = this.stateManager.select.ranges;
let minCol = Math.min(ranges[0].start.col, ranges[0].end.col);
@@ -4265,7 +4265,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI {
return '';
}
- const value = this.getCellValue(col, row);
+ const value = getCellValueFunction ? getCellValueFunction(col, row) : this.getCellValue(col, row);
return value;
};
let copyValue = '';
diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts
index f42f45da91..db1d87b0d1 100644
--- a/packages/vtable/src/event/event.ts
+++ b/packages/vtable/src/event/event.ts
@@ -17,7 +17,7 @@ import { bindGesture, bindTableGroupListener } from './listener/table-group';
import { bindScrollBarListener } from './listener/scroll-bar';
import { bindContainerDomListener } from './listener/container-dom';
import { bindTouchListener } from './listener/touch';
-import { getCellEventArgsSet, type SceneEvent } from './util';
+import { getCellEventArgsSet, setDataToHTML, type SceneEvent } from './util';
import { bindAxisClickEvent } from './self-event-listener/pivot-chart/axis-click';
import { bindAxisHoverEvent } from './self-event-listener/pivot-chart/axis-hover';
import type { PivotTable } from '../PivotTable';
@@ -83,6 +83,8 @@ export class EventManager {
private cutOperationTime: number = 0; // 记录剪切操作的时间
lastClipboardContent: string = ''; // 最后一次复制/剪切的内容
cutCellRange: CellInfo[][] | null = null;
+ /** 复制时的源位置信息(用于公式相对引用调整) */
+ copySourceRange: { startCol: number; startRow: number } | null = null;
constructor(table: BaseTableAPI) {
this.table = table;
this.handleTextStickBindId = [];
@@ -758,82 +760,159 @@ export class EventManager {
async handleCopy(e: KeyboardEvent, isCut: boolean = false) {
const table = this.table;
!isCut && (this.cutWaitPaste = false);
- const data = this.table.getCopyValue();
+ this.copySourceRange = null;
+ // 记录复制时的源位置(用于公式相对引用调整)
+ const sourceRanges = table.stateManager.select.ranges;
+ if (sourceRanges && sourceRanges.length === 1) {
+ // 只有一个选区的时候才需要采取解析公式(和excel一致),才需要记录源位置
+ const sourceRange = sourceRanges[0];
+ this.copySourceRange = {
+ startCol: Math.min(sourceRange.start.col, sourceRange.end.col),
+ startRow: Math.min(sourceRange.start.row, sourceRange.end.row)
+ };
+ } else if (!sourceRanges?.length) {
+ this.copySourceRange = null;
+ // 没有选中区域,直接返回,不进行复制操作
+ return;
+ }
+
+ const data = this.table.getCopyValue(
+ table.options.keyboardOptions?.getCopyCellValue?.value as (col: number, row: number) => string | number
+ );
if (isValid(data)) {
e.preventDefault();
- //检查是否有权限
- const permissionState = await navigator.permissions.query({ name: 'clipboard-write' as PermissionName });
- if (navigator.clipboard?.write && permissionState.state === 'granted') {
- // 将复制的数据转为html格式
- const setDataToHTML = (data: string) => {
- const result = [''];
- const META_HEAD = [
- '', // 后面可用于vtable之间的快速复制粘贴
- //white-space:normal,连续的空白字符会被合并为一个空格,并且文本会根据容器的宽度自动换行显示
- //mso-data-placement:same-cell,excel专用, 在同一个单元格中显示所有数据,而不是默认情况下将数据分散到多个单元格中显示
- ''
- ].join('');
- const rows = data.split('\r\n'); // 将数据拆分为行
- rows.forEach(function (rowCells: any, rowIndex: number) {
- const cells = rowCells.split('\t'); // 将行数据拆分为单元格
- const rowValues: string[] = [];
- if (rowIndex === 0) {
- result.push('');
+
+ // 确保表格元素获得焦点,避免Document is not focused错误
+ const element = table.getElement();
+ if (element && element !== document.activeElement) {
+ element.focus();
+ // 短暂延迟,确保焦点设置完成
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+
+ try {
+ // 优先使用现代剪贴板API
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ // 尝试获取权限(如果支持)
+ let hasPermission = true;
+ if (navigator.permissions && navigator.permissions.query) {
+ try {
+ const permissionState = await navigator.permissions.query({
+ name: 'clipboard-write' as PermissionName
+ });
+ hasPermission = permissionState.state === 'granted';
+ } catch (permissionError) {
+ // 权限查询失败,继续尝试写入
+ console.warn('无法查询剪贴板权限:', permissionError);
+ hasPermission = true; // 假设有权限,让写入操作自己失败
}
- cells.forEach(function (cell: string, cellIndex: number) {
- // 单元格数据处理
- const parsedCellData = !cell
- ? ' '
- : cell
- .toString()
- .replace(/&/g, '&') // replace & with & to prevent XSS attacks
- .replace(/'/g, ''') // replace ' with ' to prevent XSS attacks
- .replace(//g, '>') // replace > with > to prevent XSS attacks
- .replace(/\n/g, '
') // replace \n with
to prevent XSS attacks
- .replace(/(
(\r\n|\n)?|\r\n|\n)/g, '
\r\n') // replace
with
\r\n to prevent XSS attacks
- .replace(/\x20{2,}/gi, (substring: string | any[]) => {
- // excel连续空格序列化
- return `${' '.repeat(substring.length - 1)} `;
- }) // replace 2 or more spaces with to prevent XSS attacks
- .replace(/\t/gi, ' '); // replace \t with to prevent XSS attacks
-
- rowValues.push(`${parsedCellData} | `);
- });
- result.push('', ...rowValues, '
');
+ }
- if (rowIndex === rows.length - 1) {
- result.push('');
+ if (hasPermission) {
+ // 将复制的数据转为html格式
+
+ try {
+ // 尝试使用 ClipboardItem(支持富文本)
+ if (window.ClipboardItem) {
+ let htmlValues = data;
+ if (
+ table.stateManager.select.ranges.length === 1 && //只有一个选区的时候采取解析公式(和excel一致)
+ table.options.keyboardOptions?.getCopyCellValue?.html
+ ) {
+ htmlValues = this.table.getCopyValue(
+ table.options.keyboardOptions?.getCopyCellValue.html as (
+ col: number,
+ row: number
+ ) => string | number
+ );
+ }
+ const dataHTML = setDataToHTML(htmlValues);
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ 'text/html': new Blob([dataHTML], { type: 'text/html' }),
+ 'text/plain': new Blob([data], { type: 'text/plain' })
+ })
+ ]);
+ } else {
+ // 降级到纯文本
+ await navigator.clipboard.writeText(data);
+ }
+ } catch (clipboardError) {
+ console.warn('剪贴板写入失败,使用降级方案:', clipboardError);
+ // 降级到传统方法
+ this.fallbackCopyToClipboard(data, e);
}
- });
- result.push('
');
- return [META_HEAD, result.join('')].join('');
- };
- const dataHTML = setDataToHTML(data);
- navigator.clipboard.write([
- new ClipboardItem({
- 'text/html': new Blob([dataHTML], { type: 'text/html' }),
- 'text/plain': new Blob([data], { type: 'text/plain' })
- })
- ]);
- } else {
- if (browser.IE) {
- (window as any).clipboardData.setData('Text', data); // IE
+ } else {
+ // 没有权限,使用降级方案
+ this.fallbackCopyToClipboard(data, e);
+ }
} else {
- (e as any).clipboardData.setData('text/plain', data); // Chrome, Firefox
+ // 不支持现代剪贴板API,使用降级方案
+ this.fallbackCopyToClipboard(data, e);
}
+
+ table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, {
+ cellRange: table.stateManager.select.ranges,
+ copyData: data,
+ isCut
+ });
+ } catch (error) {
+ console.error('复制操作失败:', error);
+ // 最后的降级方案
+ this.fallbackCopyToClipboard(data, e);
}
- table.fireListeners(TABLE_EVENT_TYPE.COPY_DATA, {
- cellRange: table.stateManager.select.ranges,
- copyData: data,
- isCut
- });
}
if (table.keyboardOptions?.showCopyCellBorder) {
setActiveCellRangeState(table);
table.clearSelected();
}
}
+
+ // 降级复制方案
+ private fallbackCopyToClipboard(data: string, e: KeyboardEvent): void {
+ try {
+ // 尝试使用旧的 clipboardData API (在事件处理函数中直接设置)
+ if ((e as any).clipboardData) {
+ (e as any).clipboardData.setData('text/plain', data);
+ return;
+ }
+
+ // 确保当前文档有焦点
+ if (document.activeElement && document.activeElement !== document.body) {
+ (document.activeElement as HTMLElement).blur();
+ }
+
+ // 尝试使用 document.execCommand
+ const textArea = document.createElement('textarea');
+ textArea.value = data;
+ textArea.style.position = 'fixed';
+ textArea.style.left = '-999999px';
+ textArea.style.top = '-999999px';
+ textArea.style.opacity = '0';
+ textArea.setAttribute('readonly', '');
+ textArea.setAttribute('aria-hidden', 'true');
+ document.body.appendChild(textArea);
+
+ // 强制聚焦并选中文本
+ textArea.focus();
+ textArea.select();
+ textArea.setSelectionRange(0, data.length);
+
+ try {
+ const successful = document.execCommand('copy');
+ if (!successful) {
+ console.warn('execCommand复制返回false,可能不被支持');
+ }
+ } catch (execError) {
+ console.warn('execCommand复制失败:', execError);
+ } finally {
+ document.body.removeChild(textArea);
+ }
+ } catch (error) {
+ console.error('降级复制方案失败:', error);
+ }
+ }
+
async handleCut(e: KeyboardEvent) {
this.handleCopy(e, true);
this.cutWaitPaste = true;
@@ -900,63 +979,133 @@ export class EventManager {
}
private async executePaste(e: any) {
const table = this.table;
- if ((table as ListTableAPI).changeCellValues) {
- if ((table as ListTableAPI).editorManager?.editingEditor) {
- return;
- }
- if (table.stateManager.select.ranges?.length > 0) {
- if (navigator.clipboard?.read) {
- // 读取剪切板数据
- navigator.clipboard.read().then(clipboardItems => {
+ if ((table as ListTableAPI).editorManager?.editingEditor) {
+ return;
+ }
+ if ((table as ListTableAPI).changeCellValues && table.stateManager.select.ranges?.length > 0) {
+ try {
+ // 优先使用现代剪贴板API
+ if (navigator.clipboard && navigator.clipboard.read) {
+ try {
+ // 读取剪切板数据
+ const clipboardItems = await navigator.clipboard.read();
+ let handled = false;
+
for (const item of clipboardItems) {
// 优先处理 html 格式数据
if (item.types.includes('text/html')) {
- this.pasteHtmlToTable(item);
- } else if (item.types.length === 1 && item.types[0] === 'text/plain') {
- this.pasteTextToTable(item);
- } else {
- // 其他情况
+ await this.pasteHtmlToTable(item);
+ handled = true;
+ break;
+ } else if (item.types.includes('text/plain')) {
+ await this.pasteTextToTable(item);
+ handled = true;
+ break;
}
}
- });
- } else {
- const ranges = table.stateManager.select.ranges;
- const col = Math.min(ranges[0].start.col, ranges[0].end.col);
- const row = Math.min(ranges[0].start.row, ranges[0].end.row);
-
- const clipboardData = e.clipboardData || window.Clipboard;
- const pastedData = clipboardData.getData('text');
- const rows = pastedData.split('\n'); // 将数据拆分为行
- const values: (string | number)[][] = [];
- rows.forEach(function (rowCells: any, rowIndex: number) {
- const cells = rowCells.split('\t'); // 将行数据拆分为单元格
- const rowValues: (string | number)[] = [];
- values.push(rowValues);
- cells.forEach(function (cell: string, cellIndex: number) {
- // 去掉单元格数据末尾的 '\r'
- if (cellIndex === cells.length - 1) {
- cell = cell.trim();
- }
- rowValues.push(cell);
- });
- });
- // 保持与 navigator.clipboard.read 中的操作一致
- const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, values, true);
- if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) {
- table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, {
- col,
- row,
- pasteData: values,
- changedCellResults
- });
+
+ if (!handled) {
+ // 如果没有处理任何数据,使用降级方案
+ await this.fallbackPasteFromClipboard(e);
+ }
+ } catch (clipboardError) {
+ console.warn('现代剪贴板API读取失败,使用降级方案:', clipboardError);
+ // 降级到传统方法
+ await this.fallbackPasteFromClipboard(e);
}
+ } else {
+ // 不支持现代剪贴板API,使用降级方案
+ await this.fallbackPasteFromClipboard(e);
}
+ } catch (error) {
+ console.error('粘贴操作失败:', error);
+ // 最后的降级方案
+ await this.fallbackPasteFromClipboard(e);
}
}
if (table.keyboardOptions?.showCopyCellBorder) {
clearActiveCellRangeState(table);
}
}
+
+ // 降级粘贴方案
+ private async fallbackPasteFromClipboard(e: any): Promise {
+ const table = this.table;
+ const ranges = table.stateManager.select.ranges;
+ const col = Math.min(ranges[0].start.col, ranges[0].end.col);
+ const row = Math.min(ranges[0].start.row, ranges[0].end.row);
+
+ try {
+ // 确保表格元素获得焦点
+ const element = table.getElement();
+ if (element && element !== document.activeElement) {
+ element.focus();
+ // 短暂延迟,确保焦点设置完成
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+
+ // 尝试从事件对象获取剪贴板数据
+ const clipboardData = e.clipboardData || (window as any).clipboardData || window.Clipboard;
+
+ if (clipboardData) {
+ const pastedData = clipboardData.getData('text') || clipboardData.getData('Text');
+ if (pastedData) {
+ await this.processPastedText(pastedData, col, row);
+ return;
+ }
+ }
+ } catch (error) {
+ console.error('降级粘贴方案失败:', error);
+ }
+ }
+
+ // 处理粘贴的文本数据
+ private async processPastedText(pastedData: string, col: number, row: number): Promise {
+ const table = this.table;
+ const rows = pastedData.split('\n'); // 将数据拆分为行
+ const values: (string | number)[][] = [];
+
+ rows.forEach(function (rowCells: any) {
+ const cells = rowCells.split('\t'); // 将行数据拆分为单元格
+ const rowValues: (string | number)[] = [];
+ values.push(rowValues);
+ cells.forEach(function (cell: string, cellIndex: number) {
+ // 去掉单元格数据末尾的 '\r'
+ if (cellIndex === cells.length - 1) {
+ cell = cell.trim();
+ }
+ rowValues.push(cell);
+ });
+ });
+ let processedValues;
+ // 检查是否支持公式处理(针对vtable-sheet)
+ if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) {
+ // 利用复制时记录的源位置,对粘贴的数据进行公式处理
+ processedValues = table.options.keyboardOptions.processFormulaBeforePaste(
+ values,
+ this.copySourceRange.startCol,
+ this.copySourceRange.startRow,
+ col,
+ row
+ );
+ }
+
+ // 保持与 navigator.clipboard.read 中的操作一致
+ const changedCellResults = await (table as ListTableAPI).changeCellValues(
+ col,
+ row,
+ processedValues ? processedValues : values,
+ true
+ );
+ if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) {
+ table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, {
+ col,
+ row,
+ pasteData: processedValues ? processedValues : values,
+ changedCellResults
+ });
+ }
+ }
// 清空选中区域的内容
private clearCutArea(table: ListTableAPI): void {
try {
@@ -1067,13 +1216,30 @@ export class EventManager {
maxRow - row + 1,
maxCol - col + 1
);
+ let processedValues;
+ // 检查是否支持公式处理(针对vtable-sheet)
+ if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) {
+ // 使用复制时记录的源位置(而不是当前的选中位置)
+ processedValues = table.options.keyboardOptions.processFormulaBeforePaste(
+ values,
+ this.copySourceRange.startCol,
+ this.copySourceRange.startRow,
+ col,
+ row
+ );
+ }
- const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, values, true);
+ const changedCellResults = await (table as ListTableAPI).changeCellValues(
+ col,
+ row,
+ processedValues ? processedValues : values,
+ true
+ );
if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) {
table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, {
col,
row,
- pasteData: values,
+ pasteData: processedValues ? processedValues : values,
changedCellResults
});
}
@@ -1125,17 +1291,35 @@ export class EventManager {
maxRow - row + 1,
maxCol - col + 1
);
- const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, values, true);
+ let processedValues;
+ // 检查是否支持公式处理(针对vtable-sheet)
+ if (table.options.keyboardOptions?.processFormulaBeforePaste && this.copySourceRange) {
+ // 利用复制时记录的源位置,对粘贴的数据进行公式处理
+ processedValues = table.options.keyboardOptions.processFormulaBeforePaste(
+ values,
+ this.copySourceRange.startCol,
+ this.copySourceRange.startRow,
+ col,
+ row
+ );
+ }
+ // 保持与 navigator.clipboard.read 中的操作一致
+ const changedCellResults = await (table as ListTableAPI).changeCellValues(
+ col,
+ row,
+ processedValues ? processedValues : values,
+ true
+ );
if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) {
table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, {
col,
row,
- pasteData: values,
+ pasteData: processedValues ? processedValues : values,
changedCellResults
});
}
}
- private pasteTextToTable(item: ClipboardItem) {
+ private async pasteTextToTable(item: ClipboardItem) {
const table = this.table;
// 如果只有 'text/plain'
const ranges = table.stateManager.select.ranges;
@@ -1144,49 +1328,67 @@ export class EventManager {
const row = Math.min(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row);
const maxCol = Math.max(ranges[selectRangeLength - 1].start.col, ranges[selectRangeLength - 1].end.col);
const maxRow = Math.max(ranges[selectRangeLength - 1].start.row, ranges[selectRangeLength - 1].end.row);
- let pasteValuesColCount = 0;
- let pasteValuesRowCount = 0;
- // const values: (string | number)[][] = [];
- item.getType('text/plain').then((blob: any) => {
- blob.text().then(async (pastedData: any) => {
- const rows = pastedData.replace(/\r(?!\n)/g, '\r\n').split('\r\n'); // 文本中的换行符格式进行统一处理
- let values: (string | number)[][] = [];
- if (rows.length > 1 && rows[rows.length - 1] === '') {
- rows.pop();
- }
- rows.forEach(function (rowCells: any, rowIndex: number) {
- const cells = rowCells.split('\t'); // 将行数据拆分为单元格
- const rowValues: (string | number)[] = [];
- values.push(rowValues);
- cells.forEach(function (cell: string, cellIndex: number) {
- if (cell.includes('\n')) {
- cell = cell
- .replace(/^"(.*)"$/, '$1') // 将字符串开头和结尾的双引号去除,并保留双引号内的内容
- .replace(/["]*/g, match => new Array(Math.floor(match.length / 2)).fill('"').join('')); // 连续出现的双引号替换为一半数量的双引号
- }
- rowValues.push(cell);
- });
- pasteValuesColCount = Math.max(pasteValuesColCount, rowValues?.length ?? 0);
+
+ try {
+ const blob = await item.getType('text/plain');
+ const pastedData = await blob.text();
+ const values = this.parsePastedData(pastedData);
+
+ const pasteValuesRowCount = values.length;
+ const pasteValuesColCount = Math.max(...values.map(row => row.length), 0);
+
+ const processedValues = this.handlePasteValues(
+ values,
+ pasteValuesRowCount,
+ pasteValuesColCount,
+ maxRow - row + 1,
+ maxCol - col + 1
+ );
+
+ const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, processedValues, true);
+
+ if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) {
+ table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, {
+ col,
+ row,
+ pasteData: processedValues,
+ changedCellResults
});
- pasteValuesRowCount = values.length ?? 0;
- values = this.handlePasteValues(
- values,
- pasteValuesRowCount,
- pasteValuesColCount,
- maxRow - row + 1,
- maxCol - col + 1
- );
- const changedCellResults = await (table as ListTableAPI).changeCellValues(col, row, values, true);
- if (table.hasListeners(TABLE_EVENT_TYPE.PASTED_DATA)) {
- table.fireListeners(TABLE_EVENT_TYPE.PASTED_DATA, {
- col,
- row,
- pasteData: values,
- changedCellResults
- });
- }
- });
+ }
+ } catch (error) {
+ // 静默处理粘贴错误,保持原有行为
+ console.warn('Paste operation failed:', error);
+ }
+ }
+
+ private parsePastedData(pastedData: string): (string | number)[][] {
+ const rows = pastedData.replace(/\r(?!\n)/g, '\r\n').split('\r\n'); // 文本中的换行符格式进行统一处理
+ const values: (string | number)[][] = [];
+
+ // 移除最后一行空行
+ if (rows.length > 1 && rows[rows.length - 1] === '') {
+ rows.pop();
+ }
+
+ rows.forEach((rowCells: string) => {
+ const cells = rowCells.split('\t'); // 将行数据拆分为单元格
+ const rowValues: (string | number)[] = cells.map(cell => this.processCellValue(cell));
+ values.push(rowValues);
});
+
+ return values;
+ }
+
+ private processCellValue(cell: string): string | number {
+ if (cell.includes('\n')) {
+ cell = cell
+ .replace(/^"(.*)"$/, '$1') // 将字符串开头和结尾的双引号去除,并保留双引号内的内容
+ .replace(/["]*/g, match => new Array(Math.floor(match.length / 2)).fill('"').join('')); // 连续出现的双引号替换为一半数量的双引号
+ }
+
+ // 尝试转换为数字
+ const numValue = Number(cell);
+ return isNaN(numValue) ? cell : numValue;
}
private handlePasteValues(
values: (string | number)[][],
diff --git a/packages/vtable/src/event/util.ts b/packages/vtable/src/event/util.ts
index 437ccd64f3..b0ec78d6b7 100644
--- a/packages/vtable/src/event/util.ts
+++ b/packages/vtable/src/event/util.ts
@@ -74,3 +74,48 @@ function getMergeCellInfo(cellGroup: Group): MergeCellInfo | undefined {
}
export const regIndexReg = /radio-\d+-\d+-(\d+)/;
+
+export function setDataToHTML(data: string) {
+ const result = [''];
+ const META_HEAD = [
+ '', // 后面可用于vtable之间的快速复制粘贴
+ //white-space:normal,连续的空白字符会被合并为一个空格,并且文本会根据容器的宽度自动换行显示
+ //mso-data-placement:same-cell,excel专用, 在同一个单元格中显示所有数据,而不是默认情况下将数据分散到多个单元格中显示
+ ''
+ ].join('');
+ const rows = data.split('\r\n'); // 将数据拆分为行
+ rows.forEach(function (rowCells: any, rowIndex: number) {
+ const cells = rowCells.split('\t'); // 将行数据拆分为单元格
+ const rowValues: string[] = [];
+ if (rowIndex === 0) {
+ result.push('');
+ }
+ cells.forEach(function (cell: string, cellIndex: number) {
+ // 单元格数据处理
+ const parsedCellData = !cell
+ ? ' '
+ : cell
+ .toString()
+ .replace(/&/g, '&') // replace & with & to prevent XSS attacks
+ .replace(/'/g, ''') // replace ' with ' to prevent XSS attacks
+ .replace(//g, '>') // replace > with > to prevent XSS attacks
+ .replace(/\n/g, '
') // replace \n with
to prevent XSS attacks
+ .replace(/(
(\r\n|\n)?|\r\n|\n)/g, '
\r\n') // replace
with
\r\n to prevent XSS attacks
+ .replace(/\x20{2,}/gi, (substring: string | any[]) => {
+ // excel连续空格序列化
+ return `${' '.repeat(substring.length - 1)} `;
+ }) // replace 2 or more spaces with to prevent XSS attacks
+ .replace(/\t/gi, ' '); // replace \t with to prevent XSS attacks
+
+ rowValues.push(`${parsedCellData} | `);
+ });
+ result.push('', ...rowValues, '
');
+
+ if (rowIndex === rows.length - 1) {
+ result.push('');
+ }
+ });
+ result.push('
');
+ return [META_HEAD, result.join('')].join('');
+}
diff --git a/packages/vtable/src/ts-types/base-table.ts b/packages/vtable/src/ts-types/base-table.ts
index 1c56cbeb0e..0e36a0964f 100644
--- a/packages/vtable/src/ts-types/base-table.ts
+++ b/packages/vtable/src/ts-types/base-table.ts
@@ -939,7 +939,7 @@ export interface BaseTableAPI {
isRowHeader: (col: number, row: number) => boolean;
- getCopyValue: () => string;
+ getCopyValue: (getCellValueFunction?: (col: number, row: number) => string | number) => string;
getSelectedCellInfos: () => CellInfo[][];
getSelectedCellRanges: () => CellRange[];
diff --git a/packages/vtable/src/ts-types/table-engine.ts b/packages/vtable/src/ts-types/table-engine.ts
index 909f660776..5deba19fc9 100644
--- a/packages/vtable/src/ts-types/table-engine.ts
+++ b/packages/vtable/src/ts-types/table-engine.ts
@@ -113,10 +113,25 @@ export interface TableKeyboardOptions {
cutSelected?: boolean; //这个copy是和浏览器的快捷键一致的
/** 快捷键复制 默认:false*/
copySelected?: boolean; //这个copy是和浏览器的快捷键一致的
+ /** 获取单元格的复制值, 替代内部获取单元格的复制值的接口getCellValue。当用户需要自定义单元格的复制值时,可以配置这个选项。 */
+ getCopyCellValue?: {
+ /** 因为复制到系统剪切板的时候需要兼容"text/plain"格式,这个函数返回值会放到"text/plain"的blob中。*/
+ value?: (col: number, row: number) => string | number;
+ /** 因为复制到系统剪切板的时候需要兼容"text/html"格式,这个函数返回值会放到"text/html"的blob中。例如vtable-sheet中的公式处理需要用到 */
+ html?: (col: number, row: number) => string;
+ };
/** 被复制单元格是否显示虚线框,默认:false */
showCopyCellBorder?: boolean;
/** 快捷键粘贴,默认:false 。粘贴内容到指定位置(即粘贴前要有选中的单元格);支持批量粘贴;粘贴生效仅针对配置了编辑 editor 的单元格;*/
pasteValueToCell?: boolean; //paste是和浏览器的快捷键一致的
+ /** 粘贴指到表格时候针对公式进行处理,用于处理公式依赖关系的调整,引用关系需要考虑相对位置,比如A4=A2,将A4粘贴到B4,则B4=B2*/
+ processFormulaBeforePaste?: (
+ values: (string | number)[][],
+ sourceStartCol: number,
+ sourceStartRow: number,
+ targetStartCol: number,
+ targetStartRow: number
+ ) => (string | number)[][];
/** 方向键是否可以更改选中单元格位置,默认:true */
moveSelectedCellOnArrowKeys?: boolean;
/** 是否启用ctrl多选框 */