@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.
- package/dist/core/fill.d.ts +34 -0
- package/dist/core/fill.js +320 -0
- package/dist/core/fill.js.map +1 -0
- package/dist/core/formula/dependency-graph.d.ts +1 -1
- package/dist/core/sheet.d.ts +13 -2
- package/dist/core/sheet.js +22 -0
- package/dist/core/sheet.js.map +1 -1
- package/dist/grid/sheet-grid.d.ts +23 -1
- package/dist/grid/sheet-grid.js +109 -8
- package/dist/grid/sheet-grid.js.map +1 -1
- package/dist/grid/vtable-theme.js +80 -0
- package/dist/grid/vtable-theme.js.map +1 -0
- package/dist/index.d.ts +7 -6
- package/dist/index.js +2 -1
- package/dist/tools/context.d.ts +2 -2
- package/dist/vue/sheet.js +27 -1
- package/dist/vue/sheet.js.map +1 -1
- package/dist/vue/style.d.ts +1 -1
- package/dist/vue/style.js +1 -0
- package/package.json +2 -1
- package/src/core/__test__/fill.test.ts +140 -0
- package/src/core/fill.ts +314 -0
- package/src/core/sheet.ts +25 -0
- package/src/env.d.ts +3 -0
- package/src/grid/__test__/sheet-grid.test.ts +98 -0
- package/src/grid/sheet-grid.ts +133 -9
- package/src/grid/vtable-theme.ts +61 -0
- package/src/index.ts +1 -0
- package/src/vue/sheet.vue +42 -2
- package/src/vue/style.ts +1 -0
|
@@ -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
|
+
})
|
package/src/core/fill.ts
ADDED
|
@@ -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
|
@@ -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 {
|