@veltra/sheet 2.0.0 → 2.0.1

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.
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { parseRange } from '../address'
4
+ import { computeFillTargetRange, generateFill, shiftFormulaRefs } from '../fill'
5
+ import { Sheet } from '../sheet'
6
+
7
+ describe('shiftFormulaRefs', () => {
8
+ it('相对引用随行列位移', () => {
9
+ expect(shiftFormulaRefs('A1+B2', 1, 0)).toBe('A2+B3')
10
+ expect(shiftFormulaRefs('A1+B2', 0, 1)).toBe('B1+C2')
11
+ expect(shiftFormulaRefs('SUM(A1:B2)', 2, 1)).toBe('SUM(B3:C4)')
12
+ })
13
+
14
+ it('尊重 $ 绝对行列', () => {
15
+ expect(shiftFormulaRefs('$A$1+A1', 1, 1)).toBe('$A$1+B2')
16
+ expect(shiftFormulaRefs('$A1+A$1', 2, 3)).toBe('$A3+D$1')
17
+ expect(shiftFormulaRefs('Sheet2!$B2+C$3', 1, 1)).toBe('Sheet2!$B3+D$3')
18
+ })
19
+
20
+ it('出界变为 #REF!;不改写字符串字面量', () => {
21
+ expect(shiftFormulaRefs('A1', 0, -1)).toBe('#REF!')
22
+ expect(shiftFormulaRefs('A1', -1, 0)).toBe('#REF!')
23
+ expect(shiftFormulaRefs('"A1"&B1', 1, 0)).toBe('"A1"&B2')
24
+ expect(shiftFormulaRefs('LOG10(A1)', 1, 0)).toBe('LOG10(A2)')
25
+ })
26
+ })
27
+
28
+ describe('computeFillTargetRange', () => {
29
+ it('按方向裁剪扩展选区中的新增区域', () => {
30
+ const source = parseRange('A1:B2')!
31
+ expect(computeFillTargetRange(source, 'bottom', parseRange('A1:B5')!)).toEqual(
32
+ parseRange('A3:B5')
33
+ )
34
+ expect(computeFillTargetRange(source, 'right', parseRange('A1:D2')!)).toEqual(
35
+ parseRange('C1:D2')
36
+ )
37
+ expect(computeFillTargetRange(source, 'top', parseRange('A1:B2')!)).toBeNull()
38
+ })
39
+ })
40
+
41
+ describe('generateFill', () => {
42
+ it('非数字 → tile 复制(含空格覆盖清空)', () => {
43
+ const sheet = new Sheet()
44
+ sheet.setCellValue({ row: 0, col: 0 }, 'a')
45
+ sheet.setCellValue({ row: 1, col: 0 }, 'b')
46
+
47
+ const items = generateFill({
48
+ source: parseRange('A1:A2')!,
49
+ target: parseRange('A3:A6')!,
50
+ direction: 'bottom',
51
+ getCellData: (addr) => sheet.getCellData(addr)
52
+ })
53
+ expect(items).toEqual([
54
+ { addr: { row: 2, col: 0 }, data: { v: 'a', t: 's' } },
55
+ { addr: { row: 3, col: 0 }, data: { v: 'b', t: 's' } },
56
+ { addr: { row: 4, col: 0 }, data: { v: 'a', t: 's' } },
57
+ { addr: { row: 5, col: 0 }, data: { v: 'b', t: 's' } }
58
+ ])
59
+
60
+ // 空源覆盖:源 B1 空 → 目标格被清
61
+ sheet.setCellValue({ row: 2, col: 1 }, 'keep')
62
+ const clearItems = generateFill({
63
+ source: parseRange('B1')!,
64
+ target: parseRange('B3')!,
65
+ direction: 'bottom',
66
+ getCellData: (addr) => sheet.getCellData(addr)
67
+ })
68
+ expect(clearItems).toEqual([{ addr: { row: 2, col: 1 }, data: undefined }])
69
+ })
70
+
71
+ it('数字等差:单格步长 1;多格按 delta 延续', () => {
72
+ const sheet = new Sheet()
73
+ sheet.setCellValue({ row: 0, col: 0 }, 1)
74
+
75
+ const single = generateFill({
76
+ source: parseRange('A1')!,
77
+ target: parseRange('A2:A4')!,
78
+ direction: 'bottom',
79
+ getCellData: (addr) => sheet.getCellData(addr)
80
+ })
81
+ expect(single.map((item) => item.data?.v)).toEqual([2, 3, 4])
82
+
83
+ sheet.setCellValue({ row: 0, col: 1 }, 1)
84
+ sheet.setCellValue({ row: 1, col: 1 }, 3)
85
+ const multi = generateFill({
86
+ source: parseRange('B1:B2')!,
87
+ target: parseRange('B3:B4')!,
88
+ direction: 'bottom',
89
+ getCellData: (addr) => sheet.getCellData(addr)
90
+ })
91
+ expect(multi.map((item) => item.data?.v)).toEqual([5, 7])
92
+ })
93
+
94
+ it('日期类型(t=d)按数值序列延续', () => {
95
+ const sheet = new Sheet()
96
+ sheet.setCell({ row: 0, col: 0 }, { v: 100, t: 'd' })
97
+ sheet.setCell({ row: 1, col: 0 }, { v: 101, t: 'd' })
98
+
99
+ const items = generateFill({
100
+ source: parseRange('A1:A2')!,
101
+ target: parseRange('A3')!,
102
+ direction: 'bottom',
103
+ getCellData: (addr) => sheet.getCellData(addr)
104
+ })
105
+ expect(items).toEqual([{ addr: { row: 2, col: 0 }, data: { v: 102, t: 'd' } }])
106
+ })
107
+
108
+ it('公式格:相对引用位移,绝对引用锁定', () => {
109
+ const sheet = new Sheet()
110
+ sheet.setCellFormula({ row: 0, col: 0 }, '=$B$1+C1')
111
+
112
+ const items = generateFill({
113
+ source: parseRange('A1')!,
114
+ target: parseRange('A2:A3')!,
115
+ direction: 'bottom',
116
+ getCellData: (addr) => sheet.getCellData(addr)
117
+ })
118
+ expect(items.map((item) => item.data?.f)).toEqual(['$B$1+C2', '$B$1+C3'])
119
+ })
120
+
121
+ it('经 setCells 写入后可一次 undo', () => {
122
+ const sheet = new Sheet()
123
+ sheet.setCellValue({ row: 0, col: 0 }, 10)
124
+ sheet.history.clear()
125
+
126
+ const items = generateFill({
127
+ source: parseRange('A1')!,
128
+ target: parseRange('A2:A3')!,
129
+ direction: 'bottom',
130
+ getCellData: (addr) => sheet.getCellData(addr)
131
+ })
132
+ sheet.setCells(items)
133
+ expect(sheet.getCellData({ row: 1, col: 0 })?.v).toBe(11)
134
+ expect(sheet.getCellData({ row: 2, col: 0 })?.v).toBe(12)
135
+
136
+ expect(sheet.undo()).toBe(true)
137
+ expect(sheet.getCellData({ row: 1, col: 0 })).toBeUndefined()
138
+ expect(sheet.getCellData({ row: 2, col: 0 })).toBeUndefined()
139
+ })
140
+ })
@@ -0,0 +1,314 @@
1
+ import type { CellAddress, CellRange } from './address'
2
+ import { colIndexToName, colNameToIndex, createRange, iterateRange } from './address'
3
+ import type { CellData, CellType } from './cell-store'
4
+ import type { SetCellValueItem } from './command/set-cell-value'
5
+
6
+ /** 填充柄拖拽方向(与 VTable drag_fill_handle_end 一致) */
7
+ export type FillDirection = 'top' | 'bottom' | 'left' | 'right'
8
+
9
+ export interface GenerateFillOptions {
10
+ /** 拖拽前的源选区(模型坐标) */
11
+ source: CellRange
12
+ /** 需要写入的目标区(不含源区,模型坐标) */
13
+ target: CellRange
14
+ /** 填充方向 */
15
+ direction: FillDirection
16
+ /** 读取源区单元格(原始存储语义) */
17
+ getCellData: (addr: CellAddress) => CellData | undefined
18
+ }
19
+
20
+ /**
21
+ * 由源选区 + 拖拽后扩展选区 + 方向,计算需要填充的目标区(模型坐标)。
22
+ * 算法对齐 VTable 官方 fill-handle 示例(扩展选区含源区,目标为扩展部分)。
23
+ */
24
+ export function computeFillTargetRange(
25
+ source: CellRange,
26
+ direction: FillDirection,
27
+ expanded: CellRange
28
+ ): CellRange | null {
29
+ const src = createRange(source.start, source.end)
30
+ const exp = createRange(expanded.start, expanded.end)
31
+
32
+ if (direction === 'bottom') {
33
+ if (exp.end.row <= src.end.row) return null
34
+ return {
35
+ start: { row: src.end.row + 1, col: src.start.col },
36
+ end: { row: exp.end.row, col: src.end.col }
37
+ }
38
+ }
39
+ if (direction === 'top') {
40
+ if (exp.start.row >= src.start.row) return null
41
+ return {
42
+ start: { row: exp.start.row, col: src.start.col },
43
+ end: { row: src.start.row - 1, col: src.end.col }
44
+ }
45
+ }
46
+ if (direction === 'right') {
47
+ if (exp.end.col <= src.end.col) return null
48
+ return {
49
+ start: { row: src.start.row, col: src.end.col + 1 },
50
+ end: { row: src.end.row, col: exp.end.col }
51
+ }
52
+ }
53
+ // left
54
+ if (exp.start.col >= src.start.col) return null
55
+ return {
56
+ start: { row: src.start.row, col: exp.start.col },
57
+ end: { row: src.end.row, col: src.start.col - 1 }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 生成填充写入项(tile / 数字日期等差 / 公式相对引用位移)。
63
+ * 调用方一次 `sheet.setCells(items)` 即可(单 undo 单元)。
64
+ */
65
+ export function generateFill(options: GenerateFillOptions): SetCellValueItem[] {
66
+ const { source, target, direction, getCellData } = options
67
+ const src = createRange(source.start, source.end)
68
+ const tgt = createRange(target.start, target.end)
69
+ const srcRows = src.end.row - src.start.row + 1
70
+ const srcCols = src.end.col - src.start.col + 1
71
+ const vertical = direction === 'top' || direction === 'bottom'
72
+
73
+ const seriesByLane = new Map<number, SeriesInfo | null>()
74
+ if (vertical) {
75
+ for (let col = src.start.col; col <= src.end.col; col++) {
76
+ const values: number[] = []
77
+ let cellType: CellType | undefined
78
+ let ok = true
79
+ for (let row = src.start.row; row <= src.end.row; row++) {
80
+ const data = getCellData({ row, col })
81
+ const num = readSeriesNumber(data)
82
+ if (num == null) {
83
+ ok = false
84
+ break
85
+ }
86
+ values.push(num.value)
87
+ cellType = num.t
88
+ }
89
+ seriesByLane.set(col, ok ? makeSeriesInfo(values, cellType) : null)
90
+ }
91
+ } else {
92
+ for (let row = src.start.row; row <= src.end.row; row++) {
93
+ const values: number[] = []
94
+ let cellType: CellType | undefined
95
+ let ok = true
96
+ for (let col = src.start.col; col <= src.end.col; col++) {
97
+ const data = getCellData({ row, col })
98
+ const num = readSeriesNumber(data)
99
+ if (num == null) {
100
+ ok = false
101
+ break
102
+ }
103
+ values.push(num.value)
104
+ cellType = num.t
105
+ }
106
+ seriesByLane.set(row, ok ? makeSeriesInfo(values, cellType) : null)
107
+ }
108
+ }
109
+
110
+ const items: SetCellValueItem[] = []
111
+ for (const addr of iterateRange(tgt)) {
112
+ const rowOffset = positiveMod(addr.row - src.start.row, srcRows)
113
+ const colOffset = positiveMod(addr.col - src.start.col, srcCols)
114
+ const srcAddr: CellAddress = { row: src.start.row + rowOffset, col: src.start.col + colOffset }
115
+ const srcData = getCellData(srcAddr)
116
+
117
+ if (srcData?.f != null && srcData.f !== '') {
118
+ const deltaRow = addr.row - srcAddr.row
119
+ const deltaCol = addr.col - srcAddr.col
120
+ items.push({ addr, data: { f: shiftFormulaRefs(srcData.f, deltaRow, deltaCol) } })
121
+ continue
122
+ }
123
+
124
+ const lane = vertical ? srcAddr.col : srcAddr.row
125
+ const series = seriesByLane.get(lane)
126
+ if (series) {
127
+ const index = vertical ? addr.row - src.start.row : addr.col - src.start.col
128
+ const value = series.first + index * series.delta
129
+ items.push({ addr, data: { v: value, t: series.t ?? 'n' } })
130
+ continue
131
+ }
132
+
133
+ // tile 复制(含空格 → 清除)
134
+ items.push({ addr, data: cloneCellData(srcData) })
135
+ }
136
+ return items
137
+ }
138
+
139
+ interface SeriesInfo {
140
+ first: number
141
+ delta: number
142
+ t?: CellType
143
+ }
144
+
145
+ function makeSeriesInfo(values: number[], t?: CellType): SeriesInfo {
146
+ const first = values[0]!
147
+ if (values.length === 1) {
148
+ return { first, delta: 1, t }
149
+ }
150
+ const delta = (values[values.length - 1]! - first) / (values.length - 1)
151
+ return { first, delta, t }
152
+ }
153
+
154
+ /** 可参与等差序列的数字/日期格;公式格与其它类型返回 null */
155
+ function readSeriesNumber(data: CellData | undefined): { value: number; t?: CellType } | null {
156
+ if (!data) return null
157
+ if (data.f != null && data.f !== '') return null
158
+ if (typeof data.v !== 'number' || !Number.isFinite(data.v)) return null
159
+ if (data.t != null && data.t !== 'n' && data.t !== 'd') return null
160
+ return { value: data.v, t: data.t }
161
+ }
162
+
163
+ function cloneCellData(data: CellData | undefined): CellData | undefined {
164
+ if (!data) return undefined
165
+ const cloned: CellData = {}
166
+ if (data.v !== undefined) cloned.v = data.v
167
+ if (data.t !== undefined) cloned.t = data.t
168
+ if (data.f !== undefined) cloned.f = data.f
169
+ return cloned
170
+ }
171
+
172
+ function positiveMod(n: number, mod: number): number {
173
+ return ((n % mod) + mod) % mod
174
+ }
175
+
176
+ /**
177
+ * 公式引用字符串级位移:尊重 `$` 绝对行列;出界 → `#REF!`。
178
+ * 跳过双引号字符串字面量,避免改写文本中的 A1 片段。
179
+ */
180
+ export function shiftFormulaRefs(formula: string, deltaRow: number, deltaCol: number): string {
181
+ if (deltaRow === 0 && deltaCol === 0) return formula
182
+
183
+ let result = ''
184
+ let i = 0
185
+ while (i < formula.length) {
186
+ const ch = formula[i]!
187
+ if (ch === '"') {
188
+ const end = skipExcelString(formula, i)
189
+ result += formula.slice(i, end)
190
+ i = end
191
+ continue
192
+ }
193
+
194
+ const sheetMatch = matchSheetPrefix(formula, i)
195
+ const addrStart = sheetMatch ? sheetMatch.end : i
196
+ const addrMatch = matchCellOrRange(formula, addrStart)
197
+ if (addrMatch && (sheetMatch || isRefBoundary(formula, i - 1))) {
198
+ const sheetPrefix = sheetMatch ? formula.slice(sheetMatch.start, sheetMatch.end) : ''
199
+ // 有 sheet 前缀时,前缀前也需是边界
200
+ if (sheetMatch && !isRefBoundary(formula, sheetMatch.start - 1)) {
201
+ result += ch
202
+ i++
203
+ continue
204
+ }
205
+ const shifted = shiftAddrPart(addrMatch.text, deltaRow, deltaCol)
206
+ result += sheetPrefix + shifted
207
+ i = addrMatch.end
208
+ continue
209
+ }
210
+
211
+ result += ch
212
+ i++
213
+ }
214
+ return result
215
+ }
216
+
217
+ function skipExcelString(text: string, start: number): number {
218
+ // start 指向开引号;Excel 字符串内 "" 为转义
219
+ let i = start + 1
220
+ while (i < text.length) {
221
+ if (text[i] === '"') {
222
+ if (text[i + 1] === '"') {
223
+ i += 2
224
+ continue
225
+ }
226
+ return i + 1
227
+ }
228
+ i++
229
+ }
230
+ return text.length
231
+ }
232
+
233
+ function matchSheetPrefix(text: string, start: number): { start: number; end: number } | null {
234
+ if (text[start] === "'") {
235
+ let i = start + 1
236
+ while (i < text.length) {
237
+ if (text[i] === "'") {
238
+ if (text[i + 1] === "'") {
239
+ i += 2
240
+ continue
241
+ }
242
+ if (text[i + 1] === '!') {
243
+ return { start, end: i + 2 }
244
+ }
245
+ return null
246
+ }
247
+ i++
248
+ }
249
+ return null
250
+ }
251
+
252
+ // 简单表名:字母/下划线开头
253
+ if (!/[A-Za-z_]/.test(text[start] ?? '')) return null
254
+ let i = start + 1
255
+ while (i < text.length && /[\w.]/.test(text[i]!)) i++
256
+ if (text[i] === '!') return { start, end: i + 1 }
257
+ return null
258
+ }
259
+
260
+ const CELL_REF_RE = /^(\$?)([A-Za-z]+)(\$?)([1-9]\d*)/
261
+
262
+ function matchCellOrRange(text: string, start: number): { text: string; end: number } | null {
263
+ const first = matchOneCell(text, start)
264
+ if (!first) return null
265
+ if (text[first.end] === ':') {
266
+ const second = matchOneCell(text, first.end + 1)
267
+ if (!second) return { text: first.text, end: first.end }
268
+ return { text: text.slice(start, second.end), end: second.end }
269
+ }
270
+ return first
271
+ }
272
+
273
+ function matchOneCell(text: string, start: number): { text: string; end: number } | null {
274
+ const slice = text.slice(start)
275
+ const m = CELL_REF_RE.exec(slice)
276
+ if (!m) return null
277
+ const end = start + m[0].length
278
+ const next = text[end]
279
+ // 行号后紧跟字母 → 不是 A1;紧跟 `(` → 函数名(如 LOG10()
280
+ if (next && (/[A-Za-z]/.test(next) || next === '(')) return null
281
+ return { text: m[0], end }
282
+ }
283
+
284
+ function isRefBoundary(text: string, index: number): boolean {
285
+ if (index < 0) return true
286
+ const ch = text[index]!
287
+ // 引用前不能是字母/数字(避免 FOOA1);`!` 已由 sheet 前缀消费
288
+ return !/[A-Za-z0-9_$]/.test(ch)
289
+ }
290
+
291
+ function shiftAddrPart(part: string, deltaRow: number, deltaCol: number): string {
292
+ if (part.includes(':')) {
293
+ const [a, b] = part.split(':')
294
+ const left = shiftOneRef(a!, deltaRow, deltaCol)
295
+ const right = shiftOneRef(b!, deltaRow, deltaCol)
296
+ if (left === '#REF!' || right === '#REF!') return '#REF!'
297
+ return `${left}:${right}`
298
+ }
299
+ return shiftOneRef(part, deltaRow, deltaCol)
300
+ }
301
+
302
+ function shiftOneRef(ref: string, deltaRow: number, deltaCol: number): string {
303
+ const m = CELL_REF_RE.exec(ref)
304
+ if (!m) return ref
305
+ const colAbs = m[1] === '$'
306
+ const rowAbs = m[3] === '$'
307
+ let col = colNameToIndex(m[2]!)
308
+ let row = Number.parseInt(m[4]!, 10) - 1
309
+ if (col < 0 || row < 0) return '#REF!'
310
+ if (!colAbs) col += deltaCol
311
+ if (!rowAbs) row += deltaRow
312
+ if (col < 0 || row < 0) return '#REF!'
313
+ return `${colAbs ? '$' : ''}${colIndexToName(col)}${rowAbs ? '$' : ''}${row + 1}`
314
+ }
package/src/core/sheet.ts CHANGED
@@ -43,6 +43,11 @@ export class Sheet {
43
43
  readonly history: HistoryManager
44
44
  /** 公式依赖图(工作簿内多 sheet 共享;独立 Sheet 自建) */
45
45
  readonly formulaGraph: DependencyGraph
46
+ /**
47
+ * 稀疏行高(模型行号 → 像素高度)。
48
+ * 供 SheetGrid 在 tab 切换重建时还原;本期不进 undo。
49
+ */
50
+ private readonly rowHeights = new Map<number, number>()
46
51
 
47
52
  name: string
48
53
 
@@ -66,6 +71,26 @@ export class Sheet {
66
71
  return this.store.colCount
67
72
  }
68
73
 
74
+ /** 读取自定义行高;未设置返回 undefined(由视图层用默认行高) */
75
+ getRowHeight(row: number): number | undefined {
76
+ return this.rowHeights.get(row)
77
+ }
78
+
79
+ /** 设置自定义行高(不进 undo);height ≤ 0 时清除自定义高度 */
80
+ setRowHeight(row: number, height: number): void {
81
+ if (!Number.isInteger(row) || row < 0) return
82
+ if (!Number.isFinite(height) || height <= 0) {
83
+ this.rowHeights.delete(row)
84
+ return
85
+ }
86
+ this.rowHeights.set(row, height)
87
+ }
88
+
89
+ /** 遍历已设置的自定义行高(SheetGrid 重建还原用) */
90
+ getRowHeights(): ReadonlyMap<number, number> {
91
+ return this.rowHeights
92
+ }
93
+
69
94
  // ─── 单元格数据 ────────────────────────────────────────────
70
95
 
71
96
  /** 原始存储语义读取(被覆盖格 → undefined) */
package/src/env.d.ts CHANGED
@@ -5,3 +5,6 @@ declare module '*.vue' {
5
5
  }
6
6
 
7
7
  declare module '*.scss' {}
8
+
9
+ // 跨包样式副作用子路径(veltra-dev → src/* 无扩展名,TS 不做扩展探测)
10
+ declare module '@veltra/desktop/components/*/style' {}
@@ -31,6 +31,104 @@ describe('SheetGrid(happy-dom smoke)', () => {
31
31
  }
32
32
  })
33
33
 
34
+ it('主题与交互选项:fillHandle / rowResize / clip / 编辑态方向键不换格', () => {
35
+ const { grid, table } = createGrid()
36
+ try {
37
+ const options = table.options
38
+ expect(options.excelOptions?.fillHandle).toBe(true)
39
+ expect(options.resize?.columnResizeMode).toBe('header')
40
+ expect(options.resize?.rowResizeMode).toBe('all')
41
+ // 列宽:仅列头;行高:仅行号列(body 格不可拖,避免干扰选区/编辑)
42
+ expect(table._canResizeColumn(1, 0)).toBe(true) // 列头 A
43
+ expect(table._canResizeColumn(1, 1)).toBe(false) // body
44
+ expect(table.isSeriesNumber(0, 1)).toBe(true)
45
+ expect(table._canResizeRow(0, 1)).toBe(true) // 行号列
46
+ expect(table._canResizeRow(1, 1)).toBe(false) // body
47
+ expect(options.keyboardOptions?.moveEditCellOnArrowKeys).toBe(false)
48
+ expect(options.defaultRowHeight).toBe(28)
49
+ expect(options.eventOptions?.preventDefaultContextMenu).toBe(true)
50
+ // theme 为 TableTheme 实例(DEFAULT.extends),读运行时解析值
51
+ const theme = table.theme
52
+ expect(theme.bodyStyle?.bgColor).toBe('#FFF')
53
+ expect(theme.bodyStyle?.textOverflow).toBe('clip')
54
+ expect(theme.defaultStyle?.textOverflow).toBe('clip')
55
+ expect(theme.defaultStyle?.borderColor).toBe('#E1E4E8')
56
+ // VTable DEFAULT 为 [10,16,10,16];收紧后贴近 Excel
57
+ expect(theme.defaultStyle?.padding).toEqual([2, 6, 2, 6])
58
+ expect(theme.bodyStyle?.padding).toEqual([2, 6, 2, 6])
59
+ expect(theme.frameStyle?.borderColor).toBe('#E1E4E8')
60
+ expect(theme.selectionStyle?.cellBorderColor).toBe('#2170E7')
61
+ expect(theme.selectionStyle?.cellBorderColor).not.toBe('#000')
62
+ expect(options.rowSeriesNumber).toMatchObject({
63
+ width: 46,
64
+ style: { bgColor: '#F5F5F5', padding: [2, 6, 2, 6], textOverflow: 'clip' }
65
+ })
66
+ } finally {
67
+ grid.release()
68
+ }
69
+ })
70
+
71
+ it('右键 CONTEXTMENU_CELL → onContextMenu(client 坐标 + 模型地址)', async () => {
72
+ const sheet = new Sheet()
73
+ const container = createContainer()
74
+ const calls: Array<{ x: number; y: number; addr: { row: number; col: number } | null }> = []
75
+ const grid = new SheetGrid({
76
+ container,
77
+ sheet,
78
+ rows: 20,
79
+ cols: 6,
80
+ onContextMenu: (info) => calls.push(info)
81
+ })
82
+ try {
83
+ const table = grid.getTable()
84
+ // 表格坐标 (2,3) → 模型 B3(colOffset/rowOffset 各 1)
85
+ table.fireListeners(ListTable.EVENT_TYPE.CONTEXTMENU_CELL, {
86
+ col: 2,
87
+ row: 3,
88
+ event: { clientX: 120, clientY: 80, preventDefault() {} }
89
+ })
90
+ await Promise.resolve() // queueMicrotask
91
+ expect(calls).toHaveLength(1)
92
+ expect(calls[0]).toMatchObject({ x: 120, y: 80, addr: { row: 2, col: 1 } })
93
+ } finally {
94
+ grid.release()
95
+ }
96
+ })
97
+
98
+ it('行高:RESIZE_ROW_END 写入 Sheet,重建后还原', () => {
99
+ const container = createContainer()
100
+ const sheet = new Sheet()
101
+ let grid = new SheetGrid({ container, sheet, rows: 20, cols: 6 })
102
+ try {
103
+ grid.getTable().fireListeners(ListTable.EVENT_TYPE.RESIZE_ROW_END, { row: 2, rowHeight: 48 })
104
+ expect(sheet.getRowHeight(1)).toBe(48)
105
+
106
+ grid.release()
107
+ grid = new SheetGrid({ container, sheet, rows: 20, cols: 6 })
108
+ expect(grid.getTable().getRowHeight(2)).toBe(48)
109
+ } finally {
110
+ grid.release()
111
+ }
112
+ })
113
+
114
+ it('填充柄:DRAG_FILL_HANDLE_END 数字序列写入模型', () => {
115
+ const { sheet, grid, table } = createGrid()
116
+ try {
117
+ sheet.setCellValue({ row: 0, col: 0 }, 1)
118
+ sheet.selectRange({ start: { row: 0, col: 0 }, end: { row: 0, col: 0 } })
119
+ // 模拟源选区(表格坐标含偏移)
120
+ table.getSelectedCellRanges = () => [{ start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }]
121
+ table.fireListeners(ListTable.EVENT_TYPE.MOUSEDOWN_FILL_HANDLE, {})
122
+ // 拖到底部扩展至 A1:A3
123
+ table.getSelectedCellRanges = () => [{ start: { col: 1, row: 1 }, end: { col: 1, row: 3 } }]
124
+ table.fireListeners(ListTable.EVENT_TYPE.DRAG_FILL_HANDLE_END, { direction: 'bottom' })
125
+ expect(sheet.getCellData({ row: 1, col: 0 })?.v).toBe(2)
126
+ expect(sheet.getCellData({ row: 2, col: 0 })?.v).toBe(3)
127
+ } finally {
128
+ grid.release()
129
+ }
130
+ })
131
+
34
132
  it('模型 → 表格:setCellValue 后表格可见', () => {
35
133
  const { sheet, grid, table } = createGrid()
36
134
  try {