@veltra/sheet 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/core/address.d.ts +60 -0
  2. package/dist/core/address.js +142 -0
  3. package/dist/core/address.js.map +1 -0
  4. package/dist/core/cell-store.d.ts +61 -0
  5. package/dist/core/cell-store.js +135 -0
  6. package/dist/core/cell-store.js.map +1 -0
  7. package/dist/core/command/default-registry.d.ts +10 -0
  8. package/dist/core/command/default-registry.js +18 -0
  9. package/dist/core/command/default-registry.js.map +1 -0
  10. package/dist/core/command/history.d.ts +64 -0
  11. package/dist/core/command/history.js +127 -0
  12. package/dist/core/command/history.js.map +1 -0
  13. package/dist/core/command/merge-cells.d.ts +21 -0
  14. package/dist/core/command/merge-cells.js +74 -0
  15. package/dist/core/command/merge-cells.js.map +1 -0
  16. package/dist/core/command/registry.d.ts +17 -0
  17. package/dist/core/command/registry.js +26 -0
  18. package/dist/core/command/registry.js.map +1 -0
  19. package/dist/core/command/set-cell-formula.d.ts +17 -0
  20. package/dist/core/command/set-cell-formula.js +29 -0
  21. package/dist/core/command/set-cell-formula.js.map +1 -0
  22. package/dist/core/command/set-cell-value.d.ts +22 -0
  23. package/dist/core/command/set-cell-value.js +35 -0
  24. package/dist/core/command/set-cell-value.js.map +1 -0
  25. package/dist/core/command/types.d.ts +68 -0
  26. package/dist/core/events.js +29 -0
  27. package/dist/core/events.js.map +1 -0
  28. package/dist/core/formula/ast.d.ts +58 -0
  29. package/dist/core/formula/ast.js +38 -0
  30. package/dist/core/formula/ast.js.map +1 -0
  31. package/dist/core/formula/dependency-graph.d.ts +70 -0
  32. package/dist/core/formula/dependency-graph.js +269 -0
  33. package/dist/core/formula/dependency-graph.js.map +1 -0
  34. package/dist/core/formula/errors.d.ts +25 -0
  35. package/dist/core/formula/errors.js +36 -0
  36. package/dist/core/formula/errors.js.map +1 -0
  37. package/dist/core/formula/evaluator.d.ts +41 -0
  38. package/dist/core/formula/evaluator.js +154 -0
  39. package/dist/core/formula/evaluator.js.map +1 -0
  40. package/dist/core/formula/functions.d.ts +30 -0
  41. package/dist/core/formula/functions.js +209 -0
  42. package/dist/core/formula/functions.js.map +1 -0
  43. package/dist/core/formula/parser.d.ts +7 -0
  44. package/dist/core/formula/parser.js +208 -0
  45. package/dist/core/formula/parser.js.map +1 -0
  46. package/dist/core/formula/tokenizer.d.ts +37 -0
  47. package/dist/core/formula/tokenizer.js +146 -0
  48. package/dist/core/formula/tokenizer.js.map +1 -0
  49. package/dist/core/merge-manager.d.ts +64 -0
  50. package/dist/core/merge-manager.js +111 -0
  51. package/dist/core/merge-manager.js.map +1 -0
  52. package/dist/core/selection.d.ts +31 -0
  53. package/dist/core/selection.js +62 -0
  54. package/dist/core/selection.js.map +1 -0
  55. package/dist/core/sheet.d.ts +105 -0
  56. package/dist/core/sheet.js +202 -0
  57. package/dist/core/sheet.js.map +1 -0
  58. package/dist/core/workbook.d.ts +43 -0
  59. package/dist/core/workbook.js +77 -0
  60. package/dist/core/workbook.js.map +1 -0
  61. package/dist/grid/sheet-grid.d.ts +59 -0
  62. package/dist/grid/sheet-grid.js +230 -0
  63. package/dist/grid/sheet-grid.js.map +1 -0
  64. package/dist/index.d.ts +28 -0
  65. package/dist/index.js +25 -0
  66. package/dist/tools/builtin.js +72 -0
  67. package/dist/tools/builtin.js.map +1 -0
  68. package/dist/tools/context.d.ts +57 -0
  69. package/dist/tools/context.js +43 -0
  70. package/dist/tools/context.js.map +1 -0
  71. package/dist/tools/registry.d.ts +68 -0
  72. package/dist/tools/registry.js +77 -0
  73. package/dist/tools/registry.js.map +1 -0
  74. package/dist/types/index.d.ts +2 -0
  75. package/dist/types/sheet.d.ts +43 -0
  76. package/dist/vue/index.d.ts +2 -0
  77. package/dist/vue/sheet.js +185 -0
  78. package/dist/vue/sheet.js.map +1 -0
  79. package/dist/vue/sheet.vue.d.ts +24 -0
  80. package/dist/vue/style.d.ts +1 -0
  81. package/dist/vue/style.js +1 -0
  82. package/dist/vue/style2.css +93 -0
  83. package/package.json +37 -0
  84. package/src/core/__test__/address.test.ts +113 -0
  85. package/src/core/__test__/cell-store.test.ts +97 -0
  86. package/src/core/__test__/command.test.ts +377 -0
  87. package/src/core/__test__/formula-adversarial.test.ts +265 -0
  88. package/src/core/__test__/formula-engine.test.ts +470 -0
  89. package/src/core/__test__/formula-functions.test.ts +178 -0
  90. package/src/core/__test__/formula-parser.test.ts +160 -0
  91. package/src/core/__test__/history.test.ts +225 -0
  92. package/src/core/__test__/merge-manager.test.ts +106 -0
  93. package/src/core/__test__/sheet.test.ts +142 -0
  94. package/src/core/address.ts +166 -0
  95. package/src/core/cell-store.ts +173 -0
  96. package/src/core/command/default-registry.ts +15 -0
  97. package/src/core/command/history.ts +167 -0
  98. package/src/core/command/index.ts +7 -0
  99. package/src/core/command/merge-cells.ts +74 -0
  100. package/src/core/command/registry.ts +30 -0
  101. package/src/core/command/set-cell-formula.ts +29 -0
  102. package/src/core/command/set-cell-value.ts +38 -0
  103. package/src/core/command/types.ts +73 -0
  104. package/src/core/events.ts +37 -0
  105. package/src/core/formula/ast.ts +66 -0
  106. package/src/core/formula/dependency-graph.ts +329 -0
  107. package/src/core/formula/errors.ts +41 -0
  108. package/src/core/formula/evaluator.ts +219 -0
  109. package/src/core/formula/functions.ts +266 -0
  110. package/src/core/formula/index.ts +7 -0
  111. package/src/core/formula/parser.ts +198 -0
  112. package/src/core/formula/tokenizer.ts +160 -0
  113. package/src/core/merge-manager.ts +158 -0
  114. package/src/core/selection.ts +66 -0
  115. package/src/core/sheet.ts +257 -0
  116. package/src/core/workbook.ts +99 -0
  117. package/src/env.d.ts +7 -0
  118. package/src/grid/__test__/canvas-mock.ts +49 -0
  119. package/src/grid/__test__/setup.ts +3 -0
  120. package/src/grid/__test__/sheet-grid-formula.test.ts +140 -0
  121. package/src/grid/__test__/sheet-grid.test.ts +177 -0
  122. package/src/grid/sheet-grid.ts +283 -0
  123. package/src/index.ts +16 -0
  124. package/src/tools/__test__/builtin.test.ts +128 -0
  125. package/src/tools/__test__/context.test.ts +157 -0
  126. package/src/tools/__test__/registry.test.ts +102 -0
  127. package/src/tools/builtin.ts +75 -0
  128. package/src/tools/context.ts +106 -0
  129. package/src/tools/index.ts +2 -0
  130. package/src/tools/registry.ts +129 -0
  131. package/src/types/index.ts +1 -0
  132. package/src/types/sheet.ts +41 -0
  133. package/src/vue/__test__/sheet-component.test.ts +267 -0
  134. package/src/vue/index.ts +1 -0
  135. package/src/vue/sheet.vue +195 -0
  136. package/src/vue/style.scss +94 -0
  137. package/src/vue/style.ts +1 -0
@@ -0,0 +1,283 @@
1
+ import { ListTable, register } from '@visactor/vtable'
2
+ import type { ListTableConstructorOptions } from '@visactor/vtable'
3
+ import { InputEditor } from '@visactor/vtable-editors'
4
+ import type { EditContext } from '@visactor/vtable-editors'
5
+
6
+ import type { CellAddress } from '../core/address'
7
+ import { cellKey, colIndexToName, createRange } from '../core/address'
8
+ import type { CellValue } from '../core/cell-store'
9
+ import type { Sheet } from '../core/sheet'
10
+
11
+ /**
12
+ * VTable 适配层:数据模型完全自持有,ListTable 只做渲染与输入。
13
+ *
14
+ * - 模型 → VTable:records 由 store 行视图桥接;customMergeCell 闭包直读 MergeManager
15
+ * (VTable 逐格动态求值、无缓存,合并变更后 setRecords 重建场景树即生效)
16
+ * - VTable → 模型:change_cell_value 回写 store;selected_cell 经 resolveAnchor 更新选区
17
+ * - 公式:公式格 record 存计算缓存(显示值);进入编辑时编辑器显示公式原文('=f')。
18
+ * 编辑提交期间模型变更(含公式重算派生格)先入待同步队列,提交结束统一回推表格——
19
+ * VTable 自己只更新了被编辑格的 record(还是输入文本),派生格它不知道
20
+ * - 键盘:容器 keydown 绑定 undo/redo(Cmd/Ctrl+Z、Cmd/Ctrl+Shift+Z、Ctrl+Y),
21
+ * 编辑器 input 打开时不拦截
22
+ * - 坐标换算:行号列不计入 rowHeaderLevelCount,偏移量在首个表格实例上
23
+ * 用 columnHeaderLevelCount + isSeriesNumber 实测并缓存(见 getOffsets)
24
+ */
25
+
26
+ /** 公式感知编辑器:进入编辑时公式格显示原文(同 Excel),其余格显示当前值 */
27
+ class FormulaAwareInputEditor extends InputEditor {
28
+ /** 由 SheetGrid 注入:返回进入编辑时应显示的文本;undefined = 用 VTable 默认值 */
29
+ resolveEditText?: (col: number, row: number) => string | undefined
30
+
31
+ override onStart(context: EditContext<string>): void {
32
+ const text = this.resolveEditText?.(context.col, context.row)
33
+ super.onStart(text === undefined ? context : { ...context, value: text })
34
+ }
35
+ }
36
+
37
+ /** 编辑器按 grid 实例注册(hook 闭包各自 sheet),名称递增防冲突 */
38
+ let editorSeq = 0
39
+
40
+ export interface SheetGridOptions {
41
+ container: HTMLElement
42
+ sheet: Sheet
43
+ /** 渲染行数,默认 100 */
44
+ rows?: number
45
+ /** 渲染列数,默认 26(A..Z) */
46
+ cols?: number
47
+ }
48
+
49
+ export class SheetGrid {
50
+ private readonly sheet: Sheet
51
+ private readonly table: ListTable
52
+ private readonly container: HTMLElement
53
+ private readonly rows: number
54
+ private readonly cols: number
55
+ private readonly editorName: string
56
+ private readonly disposers: (() => void)[] = []
57
+ /** 编辑提交期间累积的模型变更(提交结束统一回推表格,覆盖公式派生格) */
58
+ private pendingTableSync: Map<number, CellAddress> | null = null
59
+ /** 实测坐标偏移(行号列数 / 列头行数),首次使用时测量 */
60
+ private offsets?: { colOffset: number; rowOffset: number }
61
+
62
+ constructor(options: SheetGridOptions) {
63
+ this.sheet = options.sheet
64
+ this.container = options.container
65
+ this.rows = options.rows ?? 100
66
+ this.cols = options.cols ?? 26
67
+
68
+ // 每个 grid 实例注册自己的编辑器(hook 闭包本实例的 sheet 与坐标换算)
69
+ this.editorName = `veltra-sheet-input-${editorSeq++}`
70
+ const editor = new FormulaAwareInputEditor()
71
+ editor.resolveEditText = (col, row) => {
72
+ const addr = this.toSheetAddr(this.table, col, row)
73
+ if (!addr) return undefined
74
+ const data = this.sheet.getCellData(this.sheet.merges.resolveAnchor(addr))
75
+ return data?.f ? `=${data.f}` : undefined
76
+ }
77
+ register.editor(this.editorName, editor)
78
+
79
+ this.table = new ListTable(options.container, this.buildOptions())
80
+ this.bindTableEvents()
81
+ this.bindSheetEvents()
82
+ this.bindKeyboard()
83
+ }
84
+
85
+ /** 底层 ListTable 实例(调试与测试用) */
86
+ getTable(): ListTable {
87
+ return this.table
88
+ }
89
+
90
+ /** 全量刷新(合并结构变化、批量数据变更后调用) */
91
+ refresh(): void {
92
+ this.table.setRecords(this.buildRecords())
93
+ }
94
+
95
+ /** 撤销一步(模型命令历史) */
96
+ undo(): boolean {
97
+ return this.sheet.undo()
98
+ }
99
+
100
+ /** 重做一步(模型命令历史) */
101
+ redo(): boolean {
102
+ return this.sheet.redo()
103
+ }
104
+
105
+ release(): void {
106
+ for (const dispose of this.disposers) dispose()
107
+ this.disposers.length = 0
108
+ this.table.release()
109
+ }
110
+
111
+ // ─── 坐标换算 ─────────────────────────────────────────────
112
+
113
+ /**
114
+ * 实测偏移:列头行数取 columnHeaderLevelCount;
115
+ * 行号列不被计入 rowHeaderLevelCount,用 isSeriesNumber 逐列探测。
116
+ * 结果缓存(表格实例不变,布局不变)。
117
+ */
118
+ private getOffsets(table: ListTable): { colOffset: number; rowOffset: number } {
119
+ if (!this.offsets) {
120
+ const rowOffset = table.columnHeaderLevelCount
121
+ let colOffset = 0
122
+ while (colOffset < table.colCount && table.isSeriesNumber(colOffset, rowOffset)) colOffset++
123
+ this.offsets = { colOffset, rowOffset }
124
+ }
125
+ return this.offsets
126
+ }
127
+
128
+ /** VTable 坐标 → 模型地址;行号列/列头返回 null */
129
+ private toSheetAddr(table: ListTable, col: number, row: number): CellAddress | null {
130
+ const { colOffset, rowOffset } = this.getOffsets(table)
131
+ const addr = { row: row - rowOffset, col: col - colOffset }
132
+ if (addr.row < 0 || addr.col < 0) return null
133
+ return addr
134
+ }
135
+
136
+ /** 模型地址 → VTable 坐标 */
137
+ private toTableCoord(table: ListTable, addr: CellAddress): { col: number; row: number } {
138
+ const { colOffset, rowOffset } = this.getOffsets(table)
139
+ return { col: addr.col + colOffset, row: addr.row + rowOffset }
140
+ }
141
+
142
+ // ─── 模型 → VTable ────────────────────────────────────────
143
+
144
+ private buildColumns() {
145
+ return Array.from({ length: this.cols }, (_, col) => ({
146
+ field: String(col),
147
+ title: colIndexToName(col)
148
+ }))
149
+ }
150
+
151
+ private buildRecords(): Record<string, CellValue>[] {
152
+ const records: Record<string, CellValue>[] = Array.from({ length: this.rows }, () => ({}))
153
+ for (const [addr, data] of this.sheet.store.entries()) {
154
+ if (addr.row < this.rows && addr.col < this.cols && data.v != null) {
155
+ records[addr.row]![String(addr.col)] = data.v
156
+ }
157
+ }
158
+ return records
159
+ }
160
+
161
+ private buildOptions(): ListTableConstructorOptions {
162
+ return {
163
+ records: this.buildRecords(),
164
+ columns: this.buildColumns(),
165
+ widthMode: 'standard',
166
+ rowSeriesNumber: { width: 46 },
167
+ editor: this.editorName,
168
+ editCellTrigger: 'doubleclick',
169
+ keyboardOptions: {
170
+ moveFocusCellOnTab: true,
171
+ editCellOnEnter: true,
172
+ moveFocusCellOnEnter: true,
173
+ moveEditCellOnArrowKeys: true,
174
+ selectAllOnCtrlA: true
175
+ },
176
+ customMergeCell: (col, row, table) => {
177
+ const addr = this.toSheetAddr(table as ListTable, col, row)
178
+ if (!addr) return undefined
179
+ const merge = this.sheet.merges.getMergeAt(addr)
180
+ if (!merge) return undefined
181
+ const anchorCoord = this.toTableCoord(table as ListTable, merge.start)
182
+ const recordValue = (table as ListTable).getCellOriginValue(
183
+ anchorCoord.col,
184
+ anchorCoord.row
185
+ )
186
+ return {
187
+ range: { start: anchorCoord, end: this.toTableCoord(table as ListTable, merge.end) },
188
+ // 关键 1:必须携带 text。VTable 的 getCellRange 仅在 text/customLayout/
189
+ // customRender 有效时才认这个自定义合并——没有 text 时选区与编辑不会
190
+ // 扩展为整个合并区域,且合并格渲染为空。
191
+ // 关键 2:text 必须读 VTable records 而非模型。编辑提交的顺序是
192
+ // 先更新 record → 重绘 → 最后才发 change_cell_value 回写模型;
193
+ // 若此时读模型,拿到的还是回写前的旧值(合并格显示旧文本)。
194
+ text: recordValue == null ? '' : String(recordValue)
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ // ─── 事件桥接 ─────────────────────────────────────────────
201
+
202
+ private bindTableEvents(): void {
203
+ this.table.on(ListTable.EVENT_TYPE.CHANGE_CELL_VALUE, (args) => {
204
+ const addr = this.toSheetAddr(this.table, args.col, args.row)
205
+ if (addr == null) return
206
+ // 编辑提交期间模型变更(含公式重算派生格)先入队列,提交结束统一回推。
207
+ // 被编辑格自身也入队:VTable 已把输入文本写进 record,公式格需回推计算值
208
+ this.pendingTableSync = new Map([[cellKey(addr), addr]])
209
+ try {
210
+ this.sheet.setCellValue(addr, args.changedValue ?? null)
211
+ } finally {
212
+ const pending = this.pendingTableSync
213
+ this.pendingTableSync = null
214
+ if (pending) {
215
+ for (const pendingAddr of pending.values()) this.pushCellToTable(pendingAddr)
216
+ }
217
+ }
218
+ })
219
+
220
+ this.table.on(ListTable.EVENT_TYPE.SELECTED_CELL, (args) => {
221
+ const addr = this.toSheetAddr(this.table, args.col, args.row)
222
+ if (addr == null) return
223
+ this.sheet.selectCell(addr)
224
+ })
225
+
226
+ // 拖选结束 → 选区同步为区域(合并等区域操作的前提)
227
+ this.table.on(ListTable.EVENT_TYPE.DRAG_SELECT_END, () => {
228
+ const range = this.table.getSelectedCellRanges()[0]
229
+ if (!range) return
230
+ const start = this.toSheetAddr(this.table, range.start.col, range.start.row)
231
+ const end = this.toSheetAddr(this.table, range.end.col, range.end.row)
232
+ if (!start || !end) return
233
+ this.sheet.selectRange(createRange(start, end))
234
+ })
235
+ }
236
+
237
+ private bindSheetEvents(): void {
238
+ this.disposers.push(
239
+ this.sheet.on('cell-change', ({ addr }) => {
240
+ if (this.pendingTableSync) {
241
+ this.pendingTableSync.set(cellKey(addr), addr)
242
+ return
243
+ }
244
+ this.pushCellToTable(addr)
245
+ })
246
+ )
247
+
248
+ this.disposers.push(
249
+ this.sheet.on('merge-change', () => {
250
+ this.refresh()
251
+ })
252
+ )
253
+ }
254
+
255
+ /** 模型格 → 表格 record(显示值;公式格为计算缓存) */
256
+ private pushCellToTable(addr: CellAddress): void {
257
+ const { col, row } = this.toTableCoord(this.table, addr)
258
+ const value = this.sheet.getDisplayValue(addr)
259
+ this.table.changeCellValue(col, row, value as string | number | null, false, false)
260
+ }
261
+
262
+ /**
263
+ * 键盘绑定:Cmd/Ctrl+Z undo,Cmd/Ctrl+Shift+Z 与 Ctrl+Y redo。
264
+ * 编辑器打开时(事件来自编辑器 input)不拦截,保留文本编辑自身的撤销行为。
265
+ */
266
+ private bindKeyboard(): void {
267
+ const onKeyDown = (event: KeyboardEvent): void => {
268
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
269
+ return
270
+ }
271
+ const mod = event.metaKey || event.ctrlKey
272
+ if (!mod) return
273
+ const key = event.key.toLowerCase()
274
+ if (key === 'z' && !event.shiftKey) {
275
+ if (this.undo()) event.preventDefault()
276
+ } else if ((key === 'z' && event.shiftKey) || (key === 'y' && event.ctrlKey)) {
277
+ if (this.redo()) event.preventDefault()
278
+ }
279
+ }
280
+ this.container.addEventListener('keydown', onKeyDown)
281
+ this.disposers.push(() => this.container.removeEventListener('keydown', onKeyDown))
282
+ }
283
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ // 内置工具注册(undo/redo/合并/取消合并):随包入口完成,
2
+ // 与 core/command 的 default-registry 同构;深导入 core 子路径的无头场景不涉及
3
+ import './tools/builtin'
4
+
5
+ export * from './core/address'
6
+ export * from './core/cell-store'
7
+ export * from './core/merge-manager'
8
+ export * from './core/selection'
9
+ export * from './core/sheet'
10
+ export * from './core/workbook'
11
+ export * from './core/command'
12
+ export * from './core/formula'
13
+ export * from './grid/sheet-grid'
14
+ export * from './tools'
15
+ export * from './types'
16
+ export * from './vue'
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import '../builtin'
4
+ import { createRange } from '../../core/address'
5
+ import { Sheet } from '../../core/sheet'
6
+ import { createSheetContext } from '../context'
7
+ import { defaultToolRegistry, type SheetTool } from '../registry'
8
+
9
+ function mustGet(id: string): SheetTool {
10
+ const tool = defaultToolRegistry.get(id)
11
+ if (!tool) throw new Error(`内置工具未注册: ${id}`)
12
+ return tool
13
+ }
14
+
15
+ function isDisabled(tool: SheetTool, sheet: Sheet): boolean {
16
+ return tool.disabled?.(createSheetContext(sheet)) ?? false
17
+ }
18
+
19
+ describe('内置工具(dogfood 扩展机制)', () => {
20
+ it('注册布局:history 组(undo/redo)在前,cell 组(merge/unmerge)在后', () => {
21
+ const groups = defaultToolRegistry.getGroups()
22
+ expect(groups.map((group) => group.name)).toEqual(['history', 'cell'])
23
+ expect(groups[0]!.tools.map((tool) => tool.id)).toEqual(['undo', 'redo'])
24
+ expect(groups[1]!.tools.map((tool) => tool.id)).toEqual(['merge', 'unmerge'])
25
+ })
26
+
27
+ it('undo/redo 的 disabled 随 history 状态联动', () => {
28
+ const sheet = new Sheet()
29
+ const ctx = createSheetContext(sheet)
30
+ const undo = mustGet('undo')
31
+ const redo = mustGet('redo')
32
+
33
+ // 空历史:两者禁用
34
+ expect(undo.disabled?.(ctx)).toBe(true)
35
+ expect(redo.disabled?.(ctx)).toBe(true)
36
+
37
+ sheet.setCellValue({ row: 0, col: 0 }, 1)
38
+ expect(undo.disabled?.(ctx)).toBe(false)
39
+ expect(redo.disabled?.(ctx)).toBe(true)
40
+
41
+ undo.onClick(ctx)
42
+ expect(sheet.getCellData({ row: 0, col: 0 })).toBeUndefined()
43
+ expect(undo.disabled?.(ctx)).toBe(true)
44
+ expect(redo.disabled?.(ctx)).toBe(false)
45
+
46
+ redo.onClick(ctx)
47
+ expect(sheet.getCellData({ row: 0, col: 0 })).toEqual({ v: 1, t: 'n' })
48
+ })
49
+
50
+ it('merge:无选区/单格禁用,拖选区域可用,合并已有合并区域时禁用', () => {
51
+ const sheet = new Sheet()
52
+ const ctx = createSheetContext(sheet)
53
+ const merge = mustGet('merge')
54
+
55
+ // 无选区
56
+ expect(merge.disabled?.(ctx)).toBe(true)
57
+
58
+ // 单格选区
59
+ ctx.selectCell({ row: 0, col: 0 })
60
+ expect(merge.disabled?.(ctx)).toBe(true)
61
+
62
+ // 区域选区 → 可用
63
+ const range = createRange({ row: 0, col: 0 }, { row: 1, col: 1 })
64
+ ctx.selectRange(range)
65
+ expect(merge.disabled?.(ctx)).toBe(false)
66
+
67
+ merge.onClick(ctx)
68
+ expect(sheet.merges.getMergeAt({ row: 0, col: 0 })).toEqual(range)
69
+
70
+ // 选区恰好等于既有合并区域 → 禁用(避免空操作历史条目)
71
+ expect(merge.disabled?.(ctx)).toBe(true)
72
+
73
+ // 合并可 undo
74
+ ctx.undo()
75
+ expect(sheet.merges.getMergeAt({ row: 0, col: 0 })).toBeUndefined()
76
+ })
77
+
78
+ it('unmerge:活动格不在合并内禁用,在合并内可用且可 undo', () => {
79
+ const sheet = new Sheet()
80
+ const ctx = createSheetContext(sheet)
81
+ const unmerge = mustGet('unmerge')
82
+
83
+ const range = createRange({ row: 0, col: 0 }, { row: 1, col: 2 })
84
+ sheet.mergeCells(range)
85
+ sheet.history.clear()
86
+
87
+ // 无选区
88
+ expect(unmerge.disabled?.(ctx)).toBe(true)
89
+
90
+ // 普通格
91
+ ctx.selectCell({ row: 5, col: 5 })
92
+ expect(unmerge.disabled?.(ctx)).toBe(true)
93
+
94
+ // 被覆盖格 → 解析锚点后在合并内
95
+ ctx.selectCell({ row: 1, col: 2 })
96
+ expect(ctx.getSelection().activeCell).toEqual({ row: 0, col: 0 })
97
+ expect(unmerge.disabled?.(ctx)).toBe(false)
98
+
99
+ unmerge.onClick(ctx)
100
+ expect(sheet.merges.getMergeAt({ row: 0, col: 0 })).toBeUndefined()
101
+
102
+ ctx.undo()
103
+ expect(sheet.merges.getMergeAt({ row: 0, col: 0 })).toEqual(range)
104
+ })
105
+
106
+ it('visible 随选区联动(自定义工具)', () => {
107
+ const sheet = new Sheet()
108
+ const ctx = createSheetContext(sheet)
109
+ sheet.mergeCells(createRange({ row: 0, col: 0 }, { row: 1, col: 1 }))
110
+
111
+ // 只在活动格位于合并区域内时可见的工具
112
+ const tool: SheetTool = {
113
+ id: 'merged-only',
114
+ title: '合并格工具',
115
+ visible: (toolCtx) => {
116
+ const active = toolCtx.getSelection().activeCell
117
+ return !!active && !!toolCtx.getCellInfo(active).mergeRange
118
+ },
119
+ onClick: () => {}
120
+ }
121
+
122
+ expect(tool.visible?.(ctx)).toBe(false)
123
+ ctx.selectCell({ row: 1, col: 1 })
124
+ expect(tool.visible?.(ctx)).toBe(true)
125
+ ctx.selectCell({ row: 3, col: 3 })
126
+ expect(tool.visible?.(ctx)).toBe(false)
127
+ })
128
+ })
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { createRange } from '../../core/address'
4
+ import { Sheet } from '../../core/sheet'
5
+ import { createSheetContext } from '../context'
6
+ import type { SheetTool } from '../registry'
7
+
8
+ describe('SheetContext', () => {
9
+ it('读写直达目标 sheet;写入经命令系统可 undo/redo', () => {
10
+ const sheet = new Sheet()
11
+ const ctx = createSheetContext(sheet)
12
+
13
+ ctx.setCellValue({ row: 0, col: 0 }, 'hello')
14
+ expect(sheet.getCellData({ row: 0, col: 0 })).toEqual({ v: 'hello', t: 's' })
15
+ expect(ctx.getDisplayValue({ row: 0, col: 0 })).toBe('hello')
16
+ expect(ctx.canUndo).toBe(true)
17
+
18
+ expect(ctx.undo()).toBe(true)
19
+ expect(sheet.getCellData({ row: 0, col: 0 })).toBeUndefined()
20
+ expect(ctx.canRedo).toBe(true)
21
+
22
+ expect(ctx.redo()).toBe(true)
23
+ expect(sheet.getCellData({ row: 0, col: 0 })).toEqual({ v: 'hello', t: 's' })
24
+ })
25
+
26
+ it('公式、合并、取消合并均经门面且可 undo', () => {
27
+ const sheet = new Sheet()
28
+ const ctx = createSheetContext(sheet)
29
+
30
+ ctx.setCellValue({ row: 0, col: 0 }, 2)
31
+ ctx.setCellFormula({ row: 0, col: 1 }, '=A1*3')
32
+ expect(ctx.getDisplayValue({ row: 0, col: 1 })).toBe(6)
33
+
34
+ const range = createRange({ row: 1, col: 0 }, { row: 2, col: 1 })
35
+ ctx.mergeCells(range)
36
+ expect(ctx.getCellInfo({ row: 2, col: 1 }).kind).toBe('merged-covered')
37
+
38
+ ctx.unmergeCells(range)
39
+ expect(ctx.getCellInfo({ row: 2, col: 1 }).kind).toBe('normal')
40
+
41
+ ctx.undo() // undo unmerge → 恢复合并
42
+ expect(ctx.getCellInfo({ row: 2, col: 1 }).kind).toBe('merged-covered')
43
+ ctx.undo() // undo merge
44
+ ctx.undo() // undo 公式
45
+ expect(sheet.getCellData({ row: 0, col: 1 })).toBeUndefined()
46
+ })
47
+
48
+ it('选区读写与 selection-change / history-change 订阅', () => {
49
+ const sheet = new Sheet()
50
+ const ctx = createSheetContext(sheet)
51
+
52
+ const onSelection = vi.fn()
53
+ const onHistory = vi.fn()
54
+ const offSelection = ctx.onSelectionChange(onSelection)
55
+ ctx.onHistoryChange(onHistory)
56
+
57
+ ctx.selectRange(createRange({ row: 0, col: 0 }, { row: 1, col: 2 }))
58
+ expect(onSelection).toHaveBeenCalledTimes(1)
59
+ expect(ctx.getSelection().activeCell).toEqual({ row: 0, col: 0 })
60
+ expect(ctx.getSelection().ranges[0]).toEqual({
61
+ start: { row: 0, col: 0 },
62
+ end: { row: 1, col: 2 }
63
+ })
64
+
65
+ ctx.setCellValue({ row: 0, col: 0 }, 1)
66
+ expect(onHistory).toHaveBeenCalledWith({ canUndo: true, canRedo: false })
67
+
68
+ offSelection()
69
+ ctx.selectCell({ row: 3, col: 3 })
70
+ expect(onSelection).toHaveBeenCalledTimes(1)
71
+ })
72
+
73
+ it('批量写入 + 事务 = 一个 undo 单元', () => {
74
+ const sheet = new Sheet()
75
+ const ctx = createSheetContext(sheet)
76
+
77
+ ctx.setCells([
78
+ { addr: { row: 0, col: 0 }, data: { v: 1, t: 'n' } },
79
+ { addr: { row: 0, col: 1 }, data: { v: 2, t: 'n' } }
80
+ ])
81
+ ctx.undo()
82
+ expect(sheet.getCellData({ row: 0, col: 0 })).toBeUndefined()
83
+ expect(sheet.getCellData({ row: 0, col: 1 })).toBeUndefined()
84
+
85
+ ctx.beginTransaction()
86
+ ctx.setCellValue({ row: 1, col: 0 }, 'a')
87
+ ctx.setCellValue({ row: 1, col: 1 }, 'b')
88
+ ctx.commit()
89
+ ctx.undo()
90
+ expect(sheet.getCellData({ row: 1, col: 0 })).toBeUndefined()
91
+ expect(sheet.getCellData({ row: 1, col: 1 })).toBeUndefined()
92
+ })
93
+
94
+ it('动态解析器:tab 切换后同一上下文指向新的活动 sheet', () => {
95
+ const sheet1 = new Sheet('Sheet1')
96
+ const sheet2 = new Sheet('Sheet2')
97
+ let active = sheet1
98
+ const ctx = createSheetContext(() => active)
99
+
100
+ expect(ctx.sheetName).toBe('Sheet1')
101
+ ctx.setCellValue({ row: 0, col: 0 }, 'one')
102
+
103
+ active = sheet2 // 模拟 tab 切换
104
+ expect(ctx.sheetName).toBe('Sheet2')
105
+ ctx.setCellValue({ row: 0, col: 0 }, 'two')
106
+
107
+ expect(sheet1.getCellData({ row: 0, col: 0 })).toEqual({ v: 'one', t: 's' })
108
+ expect(sheet2.getCellData({ row: 0, col: 0 })).toEqual({ v: 'two', t: 's' })
109
+
110
+ // undo 作用于当前活动 sheet
111
+ expect(ctx.undo()).toBe(true)
112
+ expect(sheet2.getCellData({ row: 0, col: 0 })).toBeUndefined()
113
+ expect(sheet1.getCellData({ row: 0, col: 0 })).toEqual({ v: 'one', t: 's' })
114
+ })
115
+
116
+ it('executeCommand 执行自定义命令', () => {
117
+ const sheet = new Sheet()
118
+ const ctx = createSheetContext(sheet)
119
+ // 内置命令走默认注册表
120
+ ctx.executeCommand('sheet.command.set-cell-value', {
121
+ items: [{ addr: { row: 0, col: 0 }, data: { v: 'x', t: 's' } }]
122
+ })
123
+ expect(sheet.getCellData({ row: 0, col: 0 })).toEqual({ v: 'x', t: 's' })
124
+ expect(ctx.canUndo).toBe(true)
125
+ })
126
+
127
+ it('自定义工具 onClick 内执行命令 → 可被 undo(扩展不绕过命令系统)', () => {
128
+ const sheet = new Sheet()
129
+ const ctx = createSheetContext(sheet)
130
+ sheet.setCellValue({ row: 0, col: 0 }, 'keep')
131
+ sheet.history.clear()
132
+
133
+ // 第三方工具:清空选区(批量写,一个 undo 单元)
134
+ const clearTool: SheetTool = {
135
+ id: 'clear-selection',
136
+ title: '清空选区',
137
+ onClick: (toolCtx) => {
138
+ const range = toolCtx.getSelection().ranges[0]
139
+ if (!range) return
140
+ const items = []
141
+ for (let row = range.start.row; row <= range.end.row; row++) {
142
+ for (let col = range.start.col; col <= range.end.col; col++) {
143
+ items.push({ addr: { row, col }, data: undefined })
144
+ }
145
+ }
146
+ toolCtx.setCells(items)
147
+ }
148
+ }
149
+
150
+ ctx.selectRange(createRange({ row: 0, col: 0 }, { row: 0, col: 0 }))
151
+ clearTool.onClick(ctx)
152
+ expect(sheet.getCellData({ row: 0, col: 0 })).toBeUndefined()
153
+
154
+ expect(ctx.undo()).toBe(true)
155
+ expect(sheet.getCellData({ row: 0, col: 0 })).toEqual({ v: 'keep', t: 's' })
156
+ })
157
+ })
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import type { SheetTool } from '../registry'
4
+ import { DEFAULT_TOOL_GROUP, ToolRegistry } from '../registry'
5
+
6
+ function tool(partial: Partial<SheetTool> & { id: string }): SheetTool {
7
+ return { title: partial.id, onClick: () => {}, ...partial }
8
+ }
9
+
10
+ describe('ToolRegistry', () => {
11
+ it('注册工具 → 出现在指定分组/顺序位;缺省进 default 组', () => {
12
+ const registry = new ToolRegistry()
13
+ registry.register(tool({ id: 'a', group: 'g1', order: 2 }))
14
+ registry.register(tool({ id: 'b' })) // 缺省分组
15
+ registry.register(tool({ id: 'c', group: 'g1', order: 1 }))
16
+ registry.register(tool({ id: 'd', group: 'g1' })) // 缺省 order 0
17
+ registry.register(tool({ id: 'e', group: 'g2' }))
18
+
19
+ const groups = registry.getGroups()
20
+ // 组序 = 各组最早注册位置:g1(a)→ default(b)→ g2(e)
21
+ expect(groups.map((group) => group.name)).toEqual(['g1', DEFAULT_TOOL_GROUP, 'g2'])
22
+ // g1 组内按 (order, 注册序):d(0) → c(1) → a(2)
23
+ expect(groups[0]!.tools.map((t) => t.id)).toEqual(['d', 'c', 'a'])
24
+ expect(groups[1]!.tools.map((t) => t.id)).toEqual(['b'])
25
+ expect(groups[2]!.tools.map((t) => t.id)).toEqual(['e'])
26
+ })
27
+
28
+ it('同组同 order 按注册先后排序', () => {
29
+ const registry = new ToolRegistry()
30
+ registry.register(tool({ id: 'x' }))
31
+ registry.register(tool({ id: 'y' }))
32
+ registry.register(tool({ id: 'z' }))
33
+ expect(registry.getGroups()[0]!.tools.map((t) => t.id)).toEqual(['x', 'y', 'z'])
34
+ })
35
+
36
+ it('重复 id 注册 = 替换定义并保留原位置,触发 change', () => {
37
+ const registry = new ToolRegistry()
38
+ const onChange = vi.fn()
39
+ registry.onChange(onChange)
40
+ registry.register(tool({ id: 'a', title: 'A1' }))
41
+ registry.register(tool({ id: 'b' }))
42
+ onChange.mockClear()
43
+
44
+ registry.register(tool({ id: 'a', title: 'A2', order: 9 }))
45
+ expect(registry.size).toBe(2)
46
+ expect(registry.get('a')?.title).toBe('A2')
47
+ // 替换只保留注册序(同 order 时的次序);order 变更仍按新值排序
48
+ expect(registry.getGroups()[0]!.tools.map((t) => t.id)).toEqual(['b', 'a'])
49
+ expect(onChange).toHaveBeenCalledTimes(1)
50
+ })
51
+
52
+ it('重复 id 且 order 不变时严格原位替换', () => {
53
+ const registry = new ToolRegistry()
54
+ registry.register(tool({ id: 'a' }))
55
+ registry.register(tool({ id: 'b' }))
56
+ registry.register(tool({ id: 'a', title: 'A2' }))
57
+ expect(registry.getGroups()[0]!.tools.map((t) => t.id)).toEqual(['a', 'b'])
58
+ expect(registry.get('a')?.title).toBe('A2')
59
+ })
60
+
61
+ it('unregister:存在 → true + change;不存在 → false 且不触发 change', () => {
62
+ const registry = new ToolRegistry()
63
+ const onChange = vi.fn()
64
+ registry.onChange(onChange)
65
+ registry.register(tool({ id: 'a' }))
66
+ onChange.mockClear()
67
+
68
+ expect(registry.unregister('a')).toBe(true)
69
+ expect(registry.has('a')).toBe(false)
70
+ expect(onChange).toHaveBeenCalledTimes(1)
71
+
72
+ expect(registry.unregister('a')).toBe(false)
73
+ expect(registry.unregister('ghost')).toBe(false)
74
+ expect(onChange).toHaveBeenCalledTimes(1)
75
+ })
76
+
77
+ it('注册校验:缺 id / title / onClick 抛错', () => {
78
+ const registry = new ToolRegistry()
79
+ expect(() => registry.register(tool({ id: '' }))).toThrow('id 不能为空')
80
+ expect(() => registry.register({ id: 'x', title: '', onClick: () => {} })).toThrow('title')
81
+ expect(() => registry.register({ id: 'x', title: 'X' } as unknown as SheetTool)).toThrow(
82
+ 'onClick'
83
+ )
84
+ })
85
+
86
+ it('onChange 返回取消订阅函数', () => {
87
+ const registry = new ToolRegistry()
88
+ const onChange = vi.fn()
89
+ const off = registry.onChange(onChange)
90
+ off()
91
+ registry.register(tool({ id: 'a' }))
92
+ expect(onChange).not.toHaveBeenCalled()
93
+ })
94
+
95
+ it('getGroups 返回副本:调用方改数组不影响注册表', () => {
96
+ const registry = new ToolRegistry()
97
+ registry.register(tool({ id: 'a' }))
98
+ const groups = registry.getGroups()
99
+ groups[0]!.tools.length = 0
100
+ expect(registry.getGroups()[0]!.tools).toHaveLength(1)
101
+ })
102
+ })