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(`
`); - }); - 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('
${parsedCellData}
'); - 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(`
`); + }); + result.push('', ...rowValues, ''); + + if (rowIndex === rows.length - 1) { + result.push(''); + } + }); + result.push('
${parsedCellData}
'); + 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多选框 */