@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,75 @@
1
+ import { rangesEqual } from '../core/address'
2
+ import { registerTool } from './registry'
3
+
4
+ /**
5
+ * 内置工具(dogfood 扩展机制):undo/redo + 合并/取消合并。
6
+ *
7
+ * 与第三方工具走同一注册通道,可由宿主 unregisterTool 移除或同 id 覆盖。
8
+ * 本模块由 tools/index.ts 引入(与 core/command 的 default-registry 同构):
9
+ * 经包入口导入即完成注册,无头使用 core 深导入不涉及此全局副作用。
10
+ */
11
+
12
+ registerTool({
13
+ id: 'undo',
14
+ title: '撤销',
15
+ tooltip: '撤销(Ctrl/Cmd+Z)',
16
+ group: 'history',
17
+ order: 0,
18
+ disabled: (ctx) => !ctx.canUndo,
19
+ onClick: (ctx) => {
20
+ ctx.undo()
21
+ }
22
+ })
23
+
24
+ registerTool({
25
+ id: 'redo',
26
+ title: '重做',
27
+ tooltip: '重做(Ctrl/Cmd+Shift+Z 或 Ctrl+Y)',
28
+ group: 'history',
29
+ order: 1,
30
+ disabled: (ctx) => !ctx.canRedo,
31
+ onClick: (ctx) => {
32
+ ctx.redo()
33
+ }
34
+ })
35
+
36
+ registerTool({
37
+ id: 'merge',
38
+ title: '合并',
39
+ tooltip: '合并选中区域',
40
+ group: 'cell',
41
+ order: 0,
42
+ disabled: (ctx) => {
43
+ const { activeCell, ranges } = ctx.getSelection()
44
+ const range = ranges[0]
45
+ if (!activeCell || !range) return true
46
+ // 单格无可合并
47
+ if (range.start.row === range.end.row && range.start.col === range.end.col) return true
48
+ // 选区恰好等于既有合并区域 → 无操作必要
49
+ const mergeRange = ctx.getCellInfo(activeCell).mergeRange
50
+ return !!mergeRange && rangesEqual(mergeRange, range)
51
+ },
52
+ onClick: (ctx) => {
53
+ const range = ctx.getSelection().ranges[0]
54
+ if (!range) return
55
+ ctx.mergeCells(range)
56
+ }
57
+ })
58
+
59
+ registerTool({
60
+ id: 'unmerge',
61
+ title: '取消合并',
62
+ tooltip: '取消活动格所在合并',
63
+ group: 'cell',
64
+ order: 1,
65
+ disabled: (ctx) => {
66
+ const { activeCell } = ctx.getSelection()
67
+ if (!activeCell) return true
68
+ return !ctx.getCellInfo(activeCell).mergeRange
69
+ },
70
+ onClick: (ctx) => {
71
+ const { activeCell, ranges } = ctx.getSelection()
72
+ if (!activeCell || !ranges[0]) return
73
+ ctx.unmergeCells(ranges[0])
74
+ }
75
+ })
@@ -0,0 +1,106 @@
1
+ import type { CellAddress, CellRange } from '../core/address'
2
+ import type { CellData, CellValue } from '../core/cell-store'
3
+ import type { HistoryState } from '../core/command/history'
4
+ import type { SetCellValueItem } from '../core/command/set-cell-value'
5
+ import type { CellInfo } from '../core/merge-manager'
6
+ import type { SelectionState } from '../core/selection'
7
+ import type { Sheet } from '../core/sheet'
8
+
9
+ /**
10
+ * SheetContext:工具的唯一操作门面。
11
+ *
12
+ * 工具(内置与第三方)对表格的全部操作都经由此门面:
13
+ * - 读方法直达当前活动 sheet(语义与 Sheet 同名方法一致)
14
+ * - 写方法全部走命令系统,天然进入 undo 历史——扩展无法绕过命令系统
15
+ * - 不暴露 Sheet 实例本身,保证门面就是扩展能力的全部边界
16
+ * - USheet 切换 sheet tab 后,同一上下文自动指向新的活动 sheet
17
+ */
18
+ export interface SheetContext {
19
+ /** 当前活动 sheet 名 */
20
+ readonly sheetName: string
21
+
22
+ // ─── 选区 ────────────────────────────────────────────────
23
+ getSelection(): SelectionState
24
+ /** 选中单格(被覆盖格自动定位锚点) */
25
+ selectCell(addr: CellAddress): void
26
+ selectRange(range: CellRange): void
27
+
28
+ // ─── 读取 ────────────────────────────────────────────────
29
+ /** 原始存储语义读取(被合并覆盖格 → undefined) */
30
+ getCellData(addr: CellAddress): CellData | undefined
31
+ /** 锚点解析语义读取(被覆盖格 → 锚点的值) */
32
+ getDisplayValue(addr: CellAddress): CellValue | undefined
33
+ /** 合并语义下的单元格信息(普通格 / 合并锚点 / 被覆盖格) */
34
+ getCellInfo(addr: CellAddress): CellInfo
35
+
36
+ // ─── 写入(全部经命令系统,可 undo) ───────────────────────
37
+ setCellValue(addr: CellAddress, value: CellValue): void
38
+ setCellFormula(addr: CellAddress, formula: string): void
39
+ /** 批量写入(一次调用 = 一个 undo 单元) */
40
+ setCells(items: SetCellValueItem[]): void
41
+ mergeCells(range: CellRange): CellRange
42
+ unmergeCells(range: CellRange): void
43
+ /** 执行命令注册表中的自定义命令(高级扩展点) */
44
+ executeCommand<R = void>(commandId: string, params: unknown): R | undefined
45
+
46
+ // ─── 事务(事务内多次写入合并为一个 undo 单元) ─────────────
47
+ beginTransaction(): void
48
+ commit(): void
49
+ rollback(): void
50
+
51
+ // ─── 历史 ────────────────────────────────────────────────
52
+ undo(): boolean
53
+ redo(): boolean
54
+ readonly canUndo: boolean
55
+ readonly canRedo: boolean
56
+
57
+ // ─── 事件订阅(订阅时绑定到当前活动 sheet;tab 切换后需重新订阅) ──
58
+ onSelectionChange(handler: (state: SelectionState) => void): () => void
59
+ onHistoryChange(handler: (state: HistoryState) => void): () => void
60
+ }
61
+
62
+ /**
63
+ * 创建工具上下文。
64
+ * @param resolveSheet 活动 sheet 解析器(USheet 传 `() => activeSheet`,
65
+ * tab 切换后上下文自动指向当前 sheet);也可直接传 Sheet 实例(无头 / 测试场景)
66
+ */
67
+ export function createSheetContext(resolveSheet: Sheet | (() => Sheet)): SheetContext {
68
+ const sheet = typeof resolveSheet === 'function' ? resolveSheet : () => resolveSheet
69
+ return {
70
+ get sheetName() {
71
+ return sheet().name
72
+ },
73
+
74
+ getSelection: () => sheet().getSelection(),
75
+ selectCell: (addr) => sheet().selectCell(addr),
76
+ selectRange: (range) => sheet().selectRange(range),
77
+
78
+ getCellData: (addr) => sheet().getCellData(addr),
79
+ getDisplayValue: (addr) => sheet().getDisplayValue(addr),
80
+ getCellInfo: (addr) => sheet().getCellInfo(addr),
81
+
82
+ setCellValue: (addr, value) => sheet().setCellValue(addr, value),
83
+ setCellFormula: (addr, formula) => sheet().setCellFormula(addr, formula),
84
+ setCells: (items) => sheet().setCells(items),
85
+ mergeCells: (range) => sheet().mergeCells(range),
86
+ unmergeCells: (range) => sheet().unmergeCells(range),
87
+ executeCommand: <R = void>(commandId: string, params: unknown): R | undefined =>
88
+ sheet().executeCommand<R>(commandId, params),
89
+
90
+ beginTransaction: () => sheet().beginTransaction(),
91
+ commit: () => sheet().commit(),
92
+ rollback: () => sheet().rollback(),
93
+
94
+ undo: () => sheet().undo(),
95
+ redo: () => sheet().redo(),
96
+ get canUndo() {
97
+ return sheet().canUndo
98
+ },
99
+ get canRedo() {
100
+ return sheet().canRedo
101
+ },
102
+
103
+ onSelectionChange: (handler) => sheet().on('selection-change', handler),
104
+ onHistoryChange: (handler) => sheet().on('history-change', handler)
105
+ }
106
+ }
@@ -0,0 +1,2 @@
1
+ export * from './context'
2
+ export * from './registry'
@@ -0,0 +1,129 @@
1
+ import { TypedEventEmitter } from '../core/events'
2
+ import type { SheetContext } from './context'
3
+
4
+ /**
5
+ * 工具注册表:USheet 工具栏的扩展点。
6
+ *
7
+ * - 工具是框架无关的纯定义(icon 由 vue 层渲染,故此处类型为 unknown)
8
+ * - 分组渲染:同组工具连续排列,组间渲染分隔符;
9
+ * 组序 = 各组最早注册位置,组内按 (order, 注册序) 升序
10
+ * - 同 id 重复注册 = 替换定义并保留原注册位置(HMR / 覆盖内置工具友好)
11
+ * - 默认注册表全局共享:所有 USheet 实例渲染同一组工具
12
+ * (与 defaultCommandRegistry 同语义;每个实例的 SheetContext 各自绑定当前 sheet)
13
+ */
14
+
15
+ /** 工具定义 */
16
+ export interface SheetTool {
17
+ /** 唯一 id;同 id 重复注册视为替换(保留原位置) */
18
+ id: string
19
+ /** 按钮文本 */
20
+ title: string
21
+ /** 图标(Vue 组件,由 USheet 渲染;tools 层不依赖 vue 故为 unknown) */
22
+ icon?: unknown
23
+ /** 悬浮提示;缺省用 title */
24
+ tooltip?: string
25
+ /** 分组名(组间渲染分隔符);缺省 'default' */
26
+ group?: string
27
+ /** 组内排序(升序,相同 order 按注册先后);缺省 0 */
28
+ order?: number
29
+ /** 是否可见(工具栏状态刷新时求值);缺省可见 */
30
+ visible?(ctx: SheetContext): boolean
31
+ /** 是否禁用(工具栏状态刷新时求值);缺省可用 */
32
+ disabled?(ctx: SheetContext): boolean
33
+ /** 点击执行;ctx 是唯一操作入口(写操作全走命令系统,天然可撤销) */
34
+ onClick(ctx: SheetContext): void
35
+ }
36
+
37
+ /** 分组视图(供工具栏渲染) */
38
+ export interface SheetToolGroup {
39
+ name: string
40
+ tools: SheetTool[]
41
+ }
42
+
43
+ /** 缺省分组名 */
44
+ export const DEFAULT_TOOL_GROUP = 'default'
45
+
46
+ type ToolEntry = { tool: SheetTool; seq: number }
47
+
48
+ type ToolRegistryEvents = { change: null }
49
+
50
+ export class ToolRegistry {
51
+ private entries = new Map<string, ToolEntry>()
52
+ private seq = 0
53
+ private emitter = new TypedEventEmitter<ToolRegistryEvents>()
54
+
55
+ /** 注册工具;同 id 重复注册 = 替换定义并保留原注册位置 */
56
+ register(tool: SheetTool): void {
57
+ if (!tool.id) throw new Error('工具注册失败:id 不能为空')
58
+ if (!tool.title) throw new Error(`工具注册失败:${tool.id} 缺少 title`)
59
+ if (typeof tool.onClick !== 'function') {
60
+ throw new Error(`工具注册失败:${tool.id} 缺少 onClick`)
61
+ }
62
+ const prev = this.entries.get(tool.id)
63
+ this.entries.set(tool.id, { tool, seq: prev?.seq ?? this.seq++ })
64
+ this.emitter.emit('change', null)
65
+ }
66
+
67
+ /** 注销工具;id 不存在时返回 false(不触发 change) */
68
+ unregister(id: string): boolean {
69
+ const existed = this.entries.delete(id)
70
+ if (existed) this.emitter.emit('change', null)
71
+ return existed
72
+ }
73
+
74
+ get(id: string): SheetTool | undefined {
75
+ return this.entries.get(id)?.tool
76
+ }
77
+
78
+ has(id: string): boolean {
79
+ return this.entries.has(id)
80
+ }
81
+
82
+ get size(): number {
83
+ return this.entries.size
84
+ }
85
+
86
+ /** 分组视图:组序 = 各组最早注册位置;组内按 (order, 注册序) 升序 */
87
+ getGroups(): SheetToolGroup[] {
88
+ const groups = new Map<string, { entries: ToolEntry[]; firstSeq: number }>()
89
+ for (const entry of this.entries.values()) {
90
+ const name = entry.tool.group ?? DEFAULT_TOOL_GROUP
91
+ let group = groups.get(name)
92
+ if (!group) {
93
+ group = { entries: [], firstSeq: entry.seq }
94
+ groups.set(name, group)
95
+ }
96
+ group.entries.push(entry)
97
+ }
98
+ return [...groups.entries()]
99
+ .sort((a, b) => a[1].firstSeq - b[1].firstSeq)
100
+ .map(([name, group]) => ({
101
+ name,
102
+ tools: group.entries
103
+ .slice()
104
+ .sort((a, b) => (a.tool.order ?? 0) - (b.tool.order ?? 0) || a.seq - b.seq)
105
+ .map((entry) => entry.tool)
106
+ }))
107
+ }
108
+
109
+ /** 订阅注册表变化(register / unregister),返回取消订阅函数 */
110
+ onChange(handler: () => void): () => void {
111
+ return this.emitter.on('change', handler)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 全局默认注册表(USheet 默认渲染这里的工具;内置工具也注册于此)。
117
+ * 工具是应用级扩展:注册一次,所有 USheet 实例共享。
118
+ */
119
+ export const defaultToolRegistry = new ToolRegistry()
120
+
121
+ /** 注册工具到默认注册表 */
122
+ export function registerTool(tool: SheetTool): void {
123
+ defaultToolRegistry.register(tool)
124
+ }
125
+
126
+ /** 从默认注册表注销工具;id 不存在时返回 false */
127
+ export function unregisterTool(id: string): boolean {
128
+ return defaultToolRegistry.unregister(id)
129
+ }
@@ -0,0 +1 @@
1
+ export * from './sheet'
@@ -0,0 +1,41 @@
1
+ import type { DeconstructValue } from '@veltra/utils'
2
+ import type { ComputedRef } from 'vue'
3
+
4
+ import type { Sheet } from '../core/sheet'
5
+ import type { Workbook } from '../core/workbook'
6
+ import type { SheetGrid } from '../grid/sheet-grid'
7
+ import type { SheetContext } from '../tools/context'
8
+
9
+ /** 电子表格组件属性 */
10
+ export interface SheetProps {
11
+ /** 工作簿实例(多 sheet / 跨表公式的载体);缺省内部自建(单 sheet) */
12
+ workbook?: Workbook
13
+ /** 渲染行数,默认 100 */
14
+ rows?: number
15
+ /** 渲染列数,默认 26(A..Z) */
16
+ cols?: number
17
+ /** 是否显示工具栏,默认 true */
18
+ showToolbar?: boolean
19
+ /** 是否显示底部 sheet 标签栏,默认 true */
20
+ showTabs?: boolean
21
+ }
22
+
23
+ export interface SheetEmits {
24
+ /** 激活 sheet 切换(点击 tab 或宿主调用 workbook.activateSheet) */
25
+ (name: 'active-sheet-change', payload: { sheet: Sheet; index: number }): void
26
+ }
27
+
28
+ /** 在组件内部引用 */
29
+ export interface _SheetExposed {
30
+ /** 当前工作簿(props.workbook 缺省时为内部自建实例) */
31
+ workbook: ComputedRef<Workbook>
32
+ /** 当前活动 sheet */
33
+ getActiveSheet: () => Sheet
34
+ /** 工具上下文(与工具栏工具同一门面;tab 切换后自动指向当前 sheet) */
35
+ getContext: () => SheetContext
36
+ /** 底层 SheetGrid(调试/测试用) */
37
+ getGrid: () => SheetGrid | undefined
38
+ }
39
+
40
+ /** 电子表格组件暴露的属性和方法 */
41
+ export type SheetExposed = DeconstructValue<_SheetExposed>
@@ -0,0 +1,267 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { createApp, h, nextTick, ref, type App } from 'vue'
3
+
4
+ import { Workbook } from '../../core/workbook'
5
+ import { USheet } from '../../index'
6
+ import { registerTool, unregisterTool } from '../../tools/registry'
7
+ import type { SheetExposed } from '../../types'
8
+
9
+ const apps: App[] = []
10
+ const containers: HTMLElement[] = []
11
+
12
+ function mount(
13
+ props: () => Record<string, unknown> = () => ({}),
14
+ exposedRef?: { value: SheetExposed | undefined }
15
+ ) {
16
+ const el = document.createElement('div')
17
+ el.style.width = '800px'
18
+ el.style.height = '600px'
19
+ document.body.appendChild(el)
20
+ containers.push(el)
21
+ const app = createApp({
22
+ render: () =>
23
+ h(USheet, {
24
+ ...props(),
25
+ ref: (value: unknown) => {
26
+ if (exposedRef) exposedRef.value = value as SheetExposed | undefined
27
+ }
28
+ })
29
+ })
30
+ app.mount(el)
31
+ apps.push(app)
32
+ return { app, el }
33
+ }
34
+
35
+ function createWorkbook() {
36
+ const workbook = new Workbook()
37
+ workbook.addSheet('Sheet2')
38
+ return workbook
39
+ }
40
+
41
+ function toolButton(el: HTMLElement, title: string): HTMLButtonElement | undefined {
42
+ return [...el.querySelectorAll<HTMLButtonElement>('.u-sheet__tool')].find((button) =>
43
+ button.textContent?.includes(title)
44
+ )
45
+ }
46
+
47
+ function tabs(el: HTMLElement): HTMLButtonElement[] {
48
+ return [...el.querySelectorAll<HTMLButtonElement>('.u-sheet__tab')]
49
+ }
50
+
51
+ afterEach(() => {
52
+ while (apps.length) apps.pop()!.unmount()
53
+ while (containers.length) containers.pop()!.remove()
54
+ })
55
+
56
+ describe('USheet 组件', () => {
57
+ it('挂载:工具栏渲染内置工具(分组 + 分隔符),tabs 显示 sheet 列表,grid 挂载', async () => {
58
+ const { el } = mount(() => ({ workbook: createWorkbook(), rows: 10, cols: 6 }))
59
+ await nextTick()
60
+
61
+ const tools = [...el.querySelectorAll('.u-sheet__tool')].map((button) =>
62
+ button.textContent?.trim()
63
+ )
64
+ expect(tools).toEqual(['撤销', '重做', '合并', '取消合并'])
65
+ // history 与 cell 两组之间一个分隔符
66
+ expect(el.querySelectorAll('.u-sheet__toolbar-divider')).toHaveLength(1)
67
+
68
+ expect(tabs(el).map((tab) => tab.textContent?.trim())).toEqual(['Sheet1', 'Sheet2'])
69
+ expect(tabs(el)[0]!.classList.contains('is-active')).toBe(true)
70
+
71
+ // VTable 已挂载到 grid 容器
72
+ expect(el.querySelector('.u-sheet__grid canvas')).not.toBeNull()
73
+ })
74
+
75
+ it('内置 undo/redo 按钮随 history-change 置灰,点击等效快捷键', async () => {
76
+ const workbook = createWorkbook()
77
+ const sheet = workbook.activeSheet
78
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
79
+ await nextTick()
80
+
81
+ const undoButton = toolButton(el, '撤销')!
82
+ const redoButton = toolButton(el, '重做')!
83
+ expect(undoButton.disabled).toBe(true)
84
+ expect(redoButton.disabled).toBe(true)
85
+
86
+ sheet.setCellValue({ row: 0, col: 0 }, 'x')
87
+ await nextTick()
88
+ expect(undoButton.disabled).toBe(false)
89
+ expect(redoButton.disabled).toBe(true)
90
+
91
+ undoButton.click()
92
+ await nextTick()
93
+ expect(sheet.getCellData({ row: 0, col: 0 })).toBeUndefined()
94
+ expect(undoButton.disabled).toBe(true)
95
+ expect(redoButton.disabled).toBe(false)
96
+
97
+ redoButton.click()
98
+ await nextTick()
99
+ expect(sheet.getCellData({ row: 0, col: 0 })).toEqual({ v: 'x', t: 's' })
100
+ })
101
+
102
+ it('tab 切换:active 样式移动,工具上下文指向当前 sheet', async () => {
103
+ const workbook = createWorkbook()
104
+ const exposed: { value: SheetExposed | undefined } = { value: undefined }
105
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }), exposed)
106
+ await nextTick()
107
+
108
+ expect(exposed.value?.getActiveSheet().name).toBe('Sheet1')
109
+ expect(exposed.value?.getContext().sheetName).toBe('Sheet1')
110
+
111
+ tabs(el)[1]!.click()
112
+ await nextTick()
113
+
114
+ expect(tabs(el)[1]!.classList.contains('is-active')).toBe(true)
115
+ // grid 重建:旧 VTable 已 release,容器内只有一个 canvas
116
+ expect(el.querySelectorAll('.u-sheet__grid canvas')).toHaveLength(1)
117
+ expect(exposed.value?.getActiveSheet().name).toBe('Sheet2')
118
+ // 工具上下文(与工具按钮同一门面)指向切换后的 sheet
119
+ expect(exposed.value?.getContext().sheetName).toBe('Sheet2')
120
+
121
+ // grid 已重建到 Sheet2:写入 Sheet2 应在表格可见
122
+ workbook.getSheet('Sheet2')!.setCellValue({ row: 0, col: 0 }, 's2')
123
+ await nextTick()
124
+ expect(exposed.value?.getGrid()?.getTable().getCellValue(1, 1)).toBe('s2')
125
+ })
126
+
127
+ it('props 变更响应:showToolbar/showTabs 显隐,rows 变化重建 grid', async () => {
128
+ const workbook = createWorkbook()
129
+ const state = ref({ rows: 10, showToolbar: true, showTabs: true })
130
+ const exposed: { value: SheetExposed | undefined } = { value: undefined }
131
+ const { el } = mount(
132
+ () => ({
133
+ workbook,
134
+ rows: state.value.rows,
135
+ cols: 6,
136
+ showToolbar: state.value.showToolbar,
137
+ showTabs: state.value.showTabs
138
+ }),
139
+ exposed
140
+ )
141
+ await nextTick()
142
+
143
+ const gridBefore = exposed.value?.getGrid()
144
+ expect(gridBefore).toBeDefined()
145
+
146
+ state.value = { ...state.value, showToolbar: false, showTabs: false }
147
+ await nextTick()
148
+ expect(el.querySelector('.u-sheet__toolbar')).toBeNull()
149
+ expect(el.querySelector('.u-sheet__tabs')).toBeNull()
150
+
151
+ state.value = { ...state.value, rows: 20 }
152
+ await nextTick()
153
+ const gridAfter = exposed.value?.getGrid()
154
+ expect(gridAfter).toBeDefined()
155
+ expect(gridAfter).not.toBe(gridBefore)
156
+ })
157
+
158
+ it('workbook prop 变更:tabs 与 grid 切到新工作簿', async () => {
159
+ const workbook1 = createWorkbook()
160
+ const workbook2 = new Workbook()
161
+ workbook2.addSheet('数据表')
162
+ const current = ref(workbook1)
163
+ const { el } = mount(() => ({ workbook: current.value, rows: 10, cols: 6 }))
164
+ await nextTick()
165
+ expect(tabs(el).map((tab) => tab.textContent?.trim())).toEqual(['Sheet1', 'Sheet2'])
166
+
167
+ current.value = workbook2
168
+ await nextTick()
169
+ expect(tabs(el).map((tab) => tab.textContent?.trim())).toEqual(['Sheet1', '数据表'])
170
+ expect(el.querySelector('.u-sheet__grid canvas')).not.toBeNull()
171
+ })
172
+
173
+ it('注册表联动:运行时 register/unregister 工具即时增删按钮', async () => {
174
+ const { el } = mount(() => ({ workbook: createWorkbook(), rows: 10, cols: 6 }))
175
+ await nextTick()
176
+ expect(toolButton(el, '临时工具')).toBeUndefined()
177
+
178
+ registerTool({ id: 'temp-tool', title: '临时工具', onClick: () => {} })
179
+ await nextTick()
180
+ expect(toolButton(el, '临时工具')).toBeDefined()
181
+
182
+ expect(unregisterTool('temp-tool')).toBe(true)
183
+ await nextTick()
184
+ expect(toolButton(el, '临时工具')).toBeUndefined()
185
+ })
186
+
187
+ it('无 props 默认挂载:内部自建工作簿(单 sheet),工具栏/tabs 正常', async () => {
188
+ const { el } = mount()
189
+ await nextTick()
190
+ expect(tabs(el).map((tab) => tab.textContent?.trim())).toEqual(['Sheet1'])
191
+ expect(el.querySelectorAll('.u-sheet__tool').length).toBeGreaterThan(0)
192
+ expect(el.querySelector('.u-sheet__grid canvas')).not.toBeNull()
193
+ })
194
+
195
+ it('多实例并存:共享注册表,工具点击只作用于各自实例的活动 sheet', async () => {
196
+ const workbook1 = new Workbook()
197
+ const workbook2 = new Workbook()
198
+ const first = mount(() => ({ workbook: workbook1, rows: 10, cols: 6 }))
199
+ const second = mount(() => ({ workbook: workbook2, rows: 10, cols: 6 }))
200
+ await nextTick()
201
+
202
+ registerTool({
203
+ id: 'mark-active',
204
+ title: '标记当前格',
205
+ onClick: (ctx) => {
206
+ const active = ctx.getSelection().activeCell ?? { row: 0, col: 0 }
207
+ ctx.setCellValue(active, 'marked')
208
+ }
209
+ })
210
+ await nextTick()
211
+
212
+ // 两个实例都渲染该工具(注册表全局共享)
213
+ const button1 = toolButton(first.el, '标记当前格')
214
+ const button2 = toolButton(second.el, '标记当前格')
215
+ expect(button1).toBeDefined()
216
+ expect(button2).toBeDefined()
217
+
218
+ // 点击实例 2 的工具 → 只写实例 2 的 sheet(各自 SheetContext 绑定各自工作簿)
219
+ workbook2.activeSheet.selectCell({ row: 2, col: 1 })
220
+ await nextTick()
221
+ button2!.click()
222
+ await nextTick()
223
+ expect(workbook2.activeSheet.getCellData({ row: 2, col: 1 })).toEqual({ v: 'marked', t: 's' })
224
+ expect(workbook1.activeSheet.getCellData({ row: 2, col: 1 })).toBeUndefined()
225
+
226
+ // 该写入经命令系统:实例 2 的 undo 可用且可撤销
227
+ expect(workbook2.activeSheet.canUndo).toBe(true)
228
+ workbook2.activeSheet.undo()
229
+ expect(workbook2.activeSheet.getCellData({ row: 2, col: 1 })).toBeUndefined()
230
+
231
+ unregisterTool('mark-active')
232
+ })
233
+
234
+ it('合并/取消合并按钮随选区联动,点击生效且可 undo', async () => {
235
+ const workbook = new Workbook()
236
+ const sheet = workbook.activeSheet
237
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
238
+ await nextTick()
239
+
240
+ const mergeButton = toolButton(el, '合并')!
241
+ const unmergeButton = toolButton(el, '取消合并')!
242
+ // 无选区:两者禁用
243
+ expect(mergeButton.disabled).toBe(true)
244
+ expect(unmergeButton.disabled).toBe(true)
245
+
246
+ // 拖选区域(经模型 API 模拟 grid 拖选回写)
247
+ sheet.selectRange({ start: { row: 0, col: 0 }, end: { row: 1, col: 1 } })
248
+ await nextTick()
249
+ expect(mergeButton.disabled).toBe(false)
250
+
251
+ mergeButton.click()
252
+ await nextTick()
253
+ expect(sheet.getCellInfo({ row: 1, col: 1 }).kind).toBe('merged-covered')
254
+
255
+ // 选中合并锚点 → 取消合并可用
256
+ sheet.selectCell({ row: 0, col: 0 })
257
+ await nextTick()
258
+ expect(unmergeButton.disabled).toBe(false)
259
+
260
+ unmergeButton.click()
261
+ await nextTick()
262
+ expect(sheet.getCellInfo({ row: 1, col: 1 }).kind).toBe('normal')
263
+
264
+ sheet.undo()
265
+ expect(sheet.getCellInfo({ row: 1, col: 1 }).kind).toBe('merged-covered')
266
+ })
267
+ })
@@ -0,0 +1 @@
1
+ export { default as USheet } from './sheet.vue'