@veltra/sheet 2.0.0 → 2.1.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.
Files changed (203) hide show
  1. package/dist/index.d.ts +4 -25
  2. package/dist/index.js +3 -22
  3. package/dist/tools/builtin.js +279 -8
  4. package/dist/tools/context.d.ts +54 -11
  5. package/dist/tools/context.js +28 -5
  6. package/dist/tools/download.js +68 -0
  7. package/dist/tools/export.worker.d.ts +16 -0
  8. package/dist/tools/export.worker.js +29 -0
  9. package/dist/tools/registry.d.ts +7 -4
  10. package/dist/tools/registry.js +1 -3
  11. package/dist/types/index.d.ts +1 -0
  12. package/dist/types/sheet.d.ts +6 -5
  13. package/dist/vue/formula-bar.js +393 -0
  14. package/dist/vue/formula-suggest-list.js +66 -0
  15. package/dist/vue/import-file.js +140 -0
  16. package/dist/vue/insert-count-menu-item.js +68 -0
  17. package/dist/vue/insert-image.js +79 -0
  18. package/dist/vue/popup-helpers.js +73 -0
  19. package/dist/vue/popups/border-popup.js +75 -0
  20. package/dist/vue/popups/border-preset-glyph.js +106 -0
  21. package/dist/vue/popups/export-popup.js +51 -0
  22. package/dist/vue/popups/fill-color-popup.js +37 -0
  23. package/dist/vue/popups/find-popup.js +113 -0
  24. package/dist/vue/popups/font-color-popup.js +37 -0
  25. package/dist/vue/popups/font-size-popup.js +63 -0
  26. package/dist/vue/popups/import.worker.d.ts +21 -0
  27. package/dist/vue/popups/import.worker.js +41 -0
  28. package/dist/vue/popups/insert-image-popup.js +38 -0
  29. package/dist/vue/sheet-context-menu.js +200 -0
  30. package/dist/vue/sheet-tabs.js +162 -0
  31. package/dist/vue/sheet-toolbar.js +106 -0
  32. package/dist/vue/sheet.js +144 -138
  33. package/dist/vue/style.d.ts +11 -1
  34. package/dist/vue/style.js +11 -0
  35. package/dist/vue/style2.css +564 -3
  36. package/dist/vue/use-find-replace.js +161 -0
  37. package/dist/vue/use-formula-ref-select.js +37 -0
  38. package/dist/vue/use-formula-suggest.js +126 -0
  39. package/dist/vue/use-sheet-grid.js +180 -0
  40. package/dist/vue/use-sheet-state.js +122 -0
  41. package/dist/vue/use-sheet-tabs-bar.js +90 -0
  42. package/dist/vue/use-tool-groups.js +42 -0
  43. package/dist/vue/use-tool-popup.js +123 -0
  44. package/dist/vue/use-toolbar-scroll.js +72 -0
  45. package/package.json +12 -6
  46. package/src/env.d.ts +14 -0
  47. package/src/index.ts +203 -13
  48. package/src/tools/__test__/builtin.test.ts +208 -66
  49. package/src/tools/__test__/context.test.ts +62 -2
  50. package/src/tools/builtin.ts +350 -6
  51. package/src/tools/context.ts +89 -11
  52. package/src/tools/download.ts +82 -0
  53. package/src/tools/export.worker.ts +61 -0
  54. package/src/tools/registry.ts +16 -1
  55. package/src/types/index.ts +6 -0
  56. package/src/types/sheet.ts +5 -3
  57. package/src/vue/__test__/formula-bar.test.ts +455 -0
  58. package/src/vue/__test__/import-file.test.ts +183 -0
  59. package/src/vue/__test__/insert-count-menu-item.test.ts +123 -0
  60. package/src/vue/__test__/insert-image-popup.test.ts +89 -0
  61. package/src/vue/__test__/insert-image.test.ts +100 -0
  62. package/src/vue/__test__/sheet-component.test.ts +716 -27
  63. package/src/vue/__test__/sheet-context-menu.test.ts +206 -0
  64. package/src/vue/__test__/use-formula-ref-select.test.ts +35 -0
  65. package/src/vue/__test__/use-formula-suggest.test.ts +83 -0
  66. package/src/vue/formula-bar.vue +473 -0
  67. package/src/vue/formula-suggest-list.vue +64 -0
  68. package/src/vue/import-file.ts +173 -0
  69. package/src/vue/insert-count-menu-item.vue +69 -0
  70. package/src/vue/insert-image.ts +90 -0
  71. package/src/vue/parsing.ts +11 -0
  72. package/src/vue/popup-helpers.ts +50 -0
  73. package/src/vue/popups/border-popup.vue +93 -0
  74. package/src/vue/popups/border-preset-glyph.vue +111 -0
  75. package/src/vue/popups/export-popup.vue +52 -0
  76. package/src/vue/popups/fill-color-popup.vue +39 -0
  77. package/src/vue/popups/find-popup.vue +111 -0
  78. package/src/vue/popups/font-color-popup.vue +39 -0
  79. package/src/vue/popups/font-size-popup.vue +52 -0
  80. package/src/vue/popups/import.worker.ts +66 -0
  81. package/src/vue/popups/insert-image-popup.vue +39 -0
  82. package/src/vue/sheet-context-menu.ts +229 -0
  83. package/src/vue/sheet-tabs.vue +162 -0
  84. package/src/vue/sheet-toolbar.vue +91 -0
  85. package/src/vue/sheet.vue +163 -148
  86. package/src/vue/style.scss +646 -3
  87. package/src/vue/style.ts +12 -0
  88. package/src/vue/use-find-replace.ts +200 -0
  89. package/src/vue/use-formula-ref-select.ts +34 -0
  90. package/src/vue/use-formula-suggest.ts +150 -0
  91. package/src/vue/use-sheet-grid.ts +243 -0
  92. package/src/vue/use-sheet-state.ts +161 -0
  93. package/src/vue/use-sheet-tabs-bar.ts +115 -0
  94. package/src/vue/use-tool-groups.ts +61 -0
  95. package/src/vue/use-tool-popup.ts +150 -0
  96. package/src/vue/use-toolbar-scroll.ts +95 -0
  97. package/dist/core/address.d.ts +0 -60
  98. package/dist/core/address.js +0 -142
  99. package/dist/core/address.js.map +0 -1
  100. package/dist/core/cell-store.d.ts +0 -61
  101. package/dist/core/cell-store.js +0 -135
  102. package/dist/core/cell-store.js.map +0 -1
  103. package/dist/core/command/default-registry.d.ts +0 -10
  104. package/dist/core/command/default-registry.js +0 -18
  105. package/dist/core/command/default-registry.js.map +0 -1
  106. package/dist/core/command/history.d.ts +0 -64
  107. package/dist/core/command/history.js +0 -127
  108. package/dist/core/command/history.js.map +0 -1
  109. package/dist/core/command/merge-cells.d.ts +0 -21
  110. package/dist/core/command/merge-cells.js +0 -74
  111. package/dist/core/command/merge-cells.js.map +0 -1
  112. package/dist/core/command/registry.d.ts +0 -17
  113. package/dist/core/command/registry.js +0 -26
  114. package/dist/core/command/registry.js.map +0 -1
  115. package/dist/core/command/set-cell-formula.d.ts +0 -17
  116. package/dist/core/command/set-cell-formula.js +0 -29
  117. package/dist/core/command/set-cell-formula.js.map +0 -1
  118. package/dist/core/command/set-cell-value.d.ts +0 -22
  119. package/dist/core/command/set-cell-value.js +0 -35
  120. package/dist/core/command/set-cell-value.js.map +0 -1
  121. package/dist/core/command/types.d.ts +0 -68
  122. package/dist/core/events.js +0 -29
  123. package/dist/core/events.js.map +0 -1
  124. package/dist/core/formula/ast.d.ts +0 -58
  125. package/dist/core/formula/ast.js +0 -38
  126. package/dist/core/formula/ast.js.map +0 -1
  127. package/dist/core/formula/dependency-graph.d.ts +0 -70
  128. package/dist/core/formula/dependency-graph.js +0 -269
  129. package/dist/core/formula/dependency-graph.js.map +0 -1
  130. package/dist/core/formula/errors.d.ts +0 -25
  131. package/dist/core/formula/errors.js +0 -36
  132. package/dist/core/formula/errors.js.map +0 -1
  133. package/dist/core/formula/evaluator.d.ts +0 -41
  134. package/dist/core/formula/evaluator.js +0 -154
  135. package/dist/core/formula/evaluator.js.map +0 -1
  136. package/dist/core/formula/functions.d.ts +0 -30
  137. package/dist/core/formula/functions.js +0 -209
  138. package/dist/core/formula/functions.js.map +0 -1
  139. package/dist/core/formula/parser.d.ts +0 -7
  140. package/dist/core/formula/parser.js +0 -208
  141. package/dist/core/formula/parser.js.map +0 -1
  142. package/dist/core/formula/tokenizer.d.ts +0 -37
  143. package/dist/core/formula/tokenizer.js +0 -146
  144. package/dist/core/formula/tokenizer.js.map +0 -1
  145. package/dist/core/merge-manager.d.ts +0 -64
  146. package/dist/core/merge-manager.js +0 -111
  147. package/dist/core/merge-manager.js.map +0 -1
  148. package/dist/core/selection.d.ts +0 -31
  149. package/dist/core/selection.js +0 -62
  150. package/dist/core/selection.js.map +0 -1
  151. package/dist/core/sheet.d.ts +0 -105
  152. package/dist/core/sheet.js +0 -202
  153. package/dist/core/sheet.js.map +0 -1
  154. package/dist/core/workbook.d.ts +0 -43
  155. package/dist/core/workbook.js +0 -77
  156. package/dist/core/workbook.js.map +0 -1
  157. package/dist/grid/sheet-grid.d.ts +0 -59
  158. package/dist/grid/sheet-grid.js +0 -230
  159. package/dist/grid/sheet-grid.js.map +0 -1
  160. package/dist/tools/builtin.js.map +0 -1
  161. package/dist/tools/context.js.map +0 -1
  162. package/dist/tools/registry.js.map +0 -1
  163. package/dist/vue/index.d.ts +0 -2
  164. package/dist/vue/sheet.js.map +0 -1
  165. package/dist/vue/sheet.vue.d.ts +0 -24
  166. package/src/core/__test__/address.test.ts +0 -113
  167. package/src/core/__test__/cell-store.test.ts +0 -97
  168. package/src/core/__test__/command.test.ts +0 -377
  169. package/src/core/__test__/formula-adversarial.test.ts +0 -265
  170. package/src/core/__test__/formula-engine.test.ts +0 -470
  171. package/src/core/__test__/formula-functions.test.ts +0 -178
  172. package/src/core/__test__/formula-parser.test.ts +0 -160
  173. package/src/core/__test__/history.test.ts +0 -225
  174. package/src/core/__test__/merge-manager.test.ts +0 -106
  175. package/src/core/__test__/sheet.test.ts +0 -142
  176. package/src/core/address.ts +0 -166
  177. package/src/core/cell-store.ts +0 -173
  178. package/src/core/command/default-registry.ts +0 -15
  179. package/src/core/command/history.ts +0 -167
  180. package/src/core/command/index.ts +0 -7
  181. package/src/core/command/merge-cells.ts +0 -74
  182. package/src/core/command/registry.ts +0 -30
  183. package/src/core/command/set-cell-formula.ts +0 -29
  184. package/src/core/command/set-cell-value.ts +0 -38
  185. package/src/core/command/types.ts +0 -73
  186. package/src/core/events.ts +0 -37
  187. package/src/core/formula/ast.ts +0 -66
  188. package/src/core/formula/dependency-graph.ts +0 -329
  189. package/src/core/formula/errors.ts +0 -41
  190. package/src/core/formula/evaluator.ts +0 -219
  191. package/src/core/formula/functions.ts +0 -266
  192. package/src/core/formula/index.ts +0 -7
  193. package/src/core/formula/parser.ts +0 -198
  194. package/src/core/formula/tokenizer.ts +0 -160
  195. package/src/core/merge-manager.ts +0 -158
  196. package/src/core/selection.ts +0 -66
  197. package/src/core/sheet.ts +0 -257
  198. package/src/core/workbook.ts +0 -99
  199. package/src/grid/__test__/canvas-mock.ts +0 -49
  200. package/src/grid/__test__/setup.ts +0 -3
  201. package/src/grid/__test__/sheet-grid-formula.test.ts +0 -140
  202. package/src/grid/__test__/sheet-grid.test.ts +0 -177
  203. package/src/grid/sheet-grid.ts +0 -283
@@ -0,0 +1,473 @@
1
+ <template>
2
+ <div :class="cls.e('formula-bar')">
3
+ <input
4
+ v-model="nameDraft"
5
+ :class="cls.e('name-box')"
6
+ spellcheck="false"
7
+ autocomplete="off"
8
+ title="单元格地址或区域(如 B3 或 B3:D5),回车跳转"
9
+ @focus="nameFocused = true"
10
+ @blur="handleNameBlur"
11
+ @keydown="handleNameKeydown"
12
+ />
13
+ <span :class="cls.e('fx-label')">fx</span>
14
+ <div :class="cls.e('fx-editor')">
15
+ <!-- 文档流只占单行;面板绝对定位向下浮起,不撑开公式栏 -->
16
+ <div :class="[cls.e('fx-input-panel'), bem.is('expanded', fxExpanded)]">
17
+ <textarea
18
+ ref="fxRef"
19
+ v-model="fxDraft"
20
+ :class="cls.e('fx-input')"
21
+ :disabled="fxDisabled"
22
+ :readonly="mirroring"
23
+ rows="1"
24
+ spellcheck="false"
25
+ autocomplete="off"
26
+ title="活动单元格内容('=' 开头为公式);Enter 提交,Esc 取消"
27
+ @focus="handleFxFocus"
28
+ @blur="handleFxBlur"
29
+ @keydown="handleFxKeydown"
30
+ @input="handleFxInput"
31
+ @click="syncFxCursor"
32
+ @keyup="syncFxCursor"
33
+ @select="syncFxCursor"
34
+ />
35
+ <u-formula-suggest-list
36
+ :items="suggestItems"
37
+ :active-index="suggestIndex"
38
+ @select="confirmSuggest"
39
+ @hover="(i) => (suggestIndex = i)"
40
+ />
41
+ </div>
42
+ </div>
43
+ <button
44
+ v-if="editing"
45
+ type="button"
46
+ :class="cls.e('fx-btn')"
47
+ title="提交(Enter)"
48
+ @mousedown.prevent
49
+ @click="commitEdit"
50
+ >
51
+ ✓
52
+ </button>
53
+ <button
54
+ v-if="editing"
55
+ type="button"
56
+ :class="cls.e('fx-btn')"
57
+ title="取消(Esc)"
58
+ @mousedown.prevent
59
+ @click="cancelEdit"
60
+ >
61
+ ✗
62
+ </button>
63
+ </div>
64
+ </template>
65
+
66
+ <script lang="ts" setup>
67
+ import { message } from '@veltra/desktop'
68
+ import {
69
+ formatAddress,
70
+ formatRange,
71
+ parseRange,
72
+ type CellAddress,
73
+ type CellRange
74
+ } from '@veltra/sheet-core/core/address'
75
+ import type { SelectionState } from '@veltra/sheet-core/core/selection'
76
+ import type { Sheet } from '@veltra/sheet-core/core/sheet'
77
+ import { bem } from '@veltra/utils'
78
+ import {
79
+ computed,
80
+ nextTick,
81
+ onBeforeUnmount,
82
+ onMounted,
83
+ ref,
84
+ shallowRef,
85
+ useTemplateRef,
86
+ watch
87
+ } from 'vue'
88
+
89
+ import type { SheetContext } from '../tools/context'
90
+ import UFormulaSuggestList from './formula-suggest-list.vue'
91
+ import { insertRefText, isRefSelectContext } from './use-formula-ref-select'
92
+ import {
93
+ applySuggest,
94
+ filterFormulaSuggestions,
95
+ getSuggestContext,
96
+ moveSuggestIndex,
97
+ type FormulaSuggestItem
98
+ } from './use-formula-suggest'
99
+
100
+ defineOptions({ name: 'UFormulaBar' })
101
+
102
+ /**
103
+ * 公式栏(名称框 + fx 输入栏),USheet 顶部结构之一。
104
+ *
105
+ * - 名称框:显示当前选区(单格 A1 / 区域 A1:B2);合法地址回车跳转。
106
+ * - fx 输入栏:活动格内容;Enter/✓ 提交、Esc/✗ 取消、失焦提交。
107
+ * - 函数补全:`=` 开头且光标在函数名 token 上时弹出候选(↑↓ / Tab / Enter / 点击)。
108
+ * - 引用选择:光标在运算符/`(`/`,` 后时,画布点选/拖选插入引用(blur 抑制防误提交)。
109
+ * - 网格编辑镜像:只读显示网格编辑器文本。
110
+ */
111
+ const props = defineProps<{ sheet: Sheet; context: SheetContext }>()
112
+
113
+ const cls = bem('sheet')
114
+
115
+ const fxRef = useTemplateRef<HTMLTextAreaElement>('fxRef')
116
+
117
+ const selection = shallowRef<SelectionState>({ activeCell: null, ranges: [] })
118
+ const nameDraft = ref('')
119
+ const nameFocused = ref(false)
120
+ const editing = ref(false)
121
+ let editAddr: CellAddress | null = null
122
+ const mirroring = ref(false)
123
+ let mirrorAddr: CellAddress | null = null
124
+ const fxDraft = ref('')
125
+ /** 输入面板是否因内容增高而浮出(文档流仍保持单行) */
126
+ const fxExpanded = ref(false)
127
+ /** textarea 光标(selectionStart);输入/点击/方向键后同步 */
128
+ const fxCursor = ref(0)
129
+
130
+ /** 画布 pointerdown 置位:下一帧 blur 跳过提交(引用选择模式) */
131
+ let suppressBlurCommit = false
132
+
133
+ const suggestItems = shallowRef<FormulaSuggestItem[]>([])
134
+ const suggestIndex = ref(0)
135
+ const suggestOpen = computed(() => suggestItems.value.length > 0)
136
+
137
+ const fxDisabled = computed(() => selection.value.activeCell == null)
138
+
139
+ /** 编排层查询:fx 编辑中且光标处于可插入引用位置 */
140
+ function isRefSelecting(): boolean {
141
+ return editing.value && !mirroring.value && isRefSelectContext(fxDraft.value, fxCursor.value)
142
+ }
143
+
144
+ /** 网格 pointerdown(capture)→ 挂起即将到来的 blur 提交 */
145
+ function beginBlurSuppress(): void {
146
+ if (!isRefSelecting()) return
147
+ suppressBlurCommit = true
148
+ }
149
+
150
+ /** 选区拦截回调:插入 A1 / A1:B2 到光标处 */
151
+ function handleRefSelect(range: CellRange): void {
152
+ if (!editing.value || mirroring.value) return
153
+ const rangeText = formatRange(range)
154
+ const el = fxRef.value
155
+ const selEnd = el?.selectionEnd ?? fxCursor.value
156
+ const result = insertRefText(fxDraft.value, fxCursor.value, rangeText, selEnd)
157
+ fxDraft.value = result.text
158
+ fxCursor.value = result.cursor
159
+ closeSuggest()
160
+ void nextTick(() => {
161
+ const input = fxRef.value
162
+ if (!input) return
163
+ input.focus()
164
+ input.setSelectionRange(result.cursor, result.cursor)
165
+ autosizeFx()
166
+ })
167
+ }
168
+
169
+ // ─── 显示文本 ──────────────────────────────────────────────
170
+
171
+ function nameText(state: SelectionState): string {
172
+ const range = state.ranges[0]
173
+ if (range) return formatRange(range)
174
+ return state.activeCell ? formatAddress(state.activeCell) : ''
175
+ }
176
+
177
+ function cellText(addr: CellAddress): string {
178
+ const data = props.context.getCellData(addr)
179
+ if (data?.f != null) return `=${data.f}`
180
+ if (data?.v != null) return String(data.v)
181
+ return ''
182
+ }
183
+
184
+ function refreshName(): void {
185
+ nameDraft.value = nameText(selection.value)
186
+ }
187
+
188
+ function refreshFx(): void {
189
+ const active = selection.value.activeCell
190
+ fxDraft.value = active ? cellText(active) : ''
191
+ fxCursor.value = fxDraft.value.length
192
+ closeSuggest()
193
+ scheduleAutosize()
194
+ }
195
+
196
+ /** 每帧合并一次的 autosizeFx(批量 cell-change 同帧 N 次 nextTick → 1 次强制布局) */
197
+ let autosizePending = false
198
+ function scheduleAutosize(): void {
199
+ if (autosizePending) return
200
+ autosizePending = true
201
+ nextTick(() => {
202
+ autosizePending = false
203
+ autosizeFx()
204
+ })
205
+ }
206
+
207
+ /**
208
+ * 按内容增高浮出面板,不超过 CSS max-height。
209
+ * 增高走绝对定位,公式栏文档流高度始终单行。
210
+ */
211
+ function autosizeFx(): void {
212
+ const el = fxRef.value
213
+ if (!el) return
214
+ el.style.height = '0px'
215
+ const styles = getComputedStyle(el)
216
+ const max = Number.parseFloat(styles.maxHeight)
217
+ const min = Number.parseFloat(styles.minHeight)
218
+ const content = el.scrollHeight
219
+ const next = Number.isFinite(max) && max > 0 ? Math.min(content, max) : content
220
+ el.style.height = `${next}px`
221
+ fxExpanded.value = Number.isFinite(min) ? next > min + 1 : next > 1
222
+ }
223
+
224
+ function syncFxCursor(): void {
225
+ const el = fxRef.value
226
+ if (!el) return
227
+ fxCursor.value = el.selectionStart
228
+ refreshSuggest()
229
+ }
230
+
231
+ function handleFxInput(): void {
232
+ syncFxCursor()
233
+ autosizeFx()
234
+ }
235
+
236
+ // ─── 函数补全 ──────────────────────────────────────────────
237
+
238
+ function closeSuggest(): void {
239
+ suggestItems.value = []
240
+ suggestIndex.value = 0
241
+ }
242
+
243
+ function refreshSuggest(): void {
244
+ if (!editing.value || mirroring.value) {
245
+ closeSuggest()
246
+ return
247
+ }
248
+ const ctx = getSuggestContext(fxDraft.value, fxCursor.value)
249
+ if (!ctx) {
250
+ closeSuggest()
251
+ return
252
+ }
253
+ const items = filterFormulaSuggestions(ctx.prefix)
254
+ suggestItems.value = items
255
+ if (suggestIndex.value >= items.length) suggestIndex.value = 0
256
+ }
257
+
258
+ function confirmSuggest(item?: FormulaSuggestItem): void {
259
+ const pick = item ?? suggestItems.value[suggestIndex.value]
260
+ if (!pick) return
261
+ const ctx = getSuggestContext(fxDraft.value, fxCursor.value)
262
+ if (!ctx) return
263
+ const result = applySuggest(fxDraft.value, ctx.start, ctx.end, pick.name)
264
+ fxDraft.value = result.text
265
+ fxCursor.value = result.cursor
266
+ closeSuggest()
267
+ void nextTick(() => {
268
+ const el = fxRef.value
269
+ if (!el) return
270
+ el.focus()
271
+ el.setSelectionRange(result.cursor, result.cursor)
272
+ autosizeFx()
273
+ // 替换为 NAME() 后通常进入引用选择上下文,不再弹补全
274
+ refreshSuggest()
275
+ })
276
+ }
277
+
278
+ // ─── 名称框 ────────────────────────────────────────────────
279
+
280
+ function handleNameKeydown(event: KeyboardEvent): void {
281
+ if (event.key === 'Enter') {
282
+ event.preventDefault()
283
+ commitName()
284
+ } else if (event.key === 'Escape') {
285
+ event.preventDefault()
286
+ refreshName()
287
+ }
288
+ }
289
+
290
+ function handleNameBlur(): void {
291
+ nameFocused.value = false
292
+ refreshName()
293
+ }
294
+
295
+ function commitName(): void {
296
+ const range = parseRange(nameDraft.value)
297
+ if (!range) {
298
+ message.warn('无效的单元格地址')
299
+ refreshName()
300
+ return
301
+ }
302
+ if (range.start.row === range.end.row && range.start.col === range.end.col) {
303
+ props.context.selectCell(range.start)
304
+ } else {
305
+ props.context.selectRange(range)
306
+ }
307
+ refreshName()
308
+ }
309
+
310
+ // ─── fx 输入栏 ─────────────────────────────────────────────
311
+
312
+ function handleFxFocus(): void {
313
+ if (fxDisabled.value) return
314
+ mirroring.value = false
315
+ mirrorAddr = null
316
+ editing.value = true
317
+ editAddr = selection.value.activeCell
318
+ syncFxCursor()
319
+ }
320
+
321
+ /**
322
+ * 失焦提交;引用选择期间(画布 pointerdown 已挂起)跳过。
323
+ * ✓/✗ / 候选列表用 mousedown.prevent 拦截失焦。
324
+ */
325
+ function handleFxBlur(): void {
326
+ if (!editing.value) return
327
+ if (suppressBlurCommit) {
328
+ suppressBlurCommit = false
329
+ return
330
+ }
331
+ commitEdit()
332
+ }
333
+
334
+ function handleFxKeydown(event: KeyboardEvent): void {
335
+ if (suggestOpen.value) {
336
+ if (event.key === 'ArrowDown') {
337
+ event.preventDefault()
338
+ suggestIndex.value = moveSuggestIndex(suggestIndex.value, suggestItems.value.length, 1)
339
+ return
340
+ }
341
+ if (event.key === 'ArrowUp') {
342
+ event.preventDefault()
343
+ suggestIndex.value = moveSuggestIndex(suggestIndex.value, suggestItems.value.length, -1)
344
+ return
345
+ }
346
+ if (event.key === 'Enter' || event.key === 'Tab') {
347
+ event.preventDefault()
348
+ confirmSuggest()
349
+ return
350
+ }
351
+ if (event.key === 'Escape') {
352
+ event.preventDefault()
353
+ closeSuggest()
354
+ return
355
+ }
356
+ }
357
+
358
+ if (event.key === 'Enter' && !event.shiftKey) {
359
+ event.preventDefault()
360
+ commitEdit()
361
+ } else if (event.key === 'Escape') {
362
+ event.preventDefault()
363
+ cancelEdit()
364
+ }
365
+ }
366
+
367
+ function commitEdit(): void {
368
+ if (!editing.value) return
369
+ const addr = editAddr
370
+ editing.value = false
371
+ editAddr = null
372
+ suppressBlurCommit = false
373
+ closeSuggest()
374
+ if (addr) props.context.setCellValue(addr, fxDraft.value)
375
+ refreshFx()
376
+ }
377
+
378
+ function cancelEdit(): void {
379
+ if (!editing.value) return
380
+ editing.value = false
381
+ editAddr = null
382
+ suppressBlurCommit = false
383
+ closeSuggest()
384
+ refreshFx()
385
+ }
386
+
387
+ // ─── 双向同步 ──────────────────────────────────────────────
388
+
389
+ function handleSelectionChange(state: SelectionState): void {
390
+ selection.value = state
391
+ if (!nameFocused.value) refreshName()
392
+ if (editing.value) return
393
+ mirroring.value = false
394
+ mirrorAddr = null
395
+ refreshFx()
396
+ }
397
+
398
+ function handleCellChange({ addr }: { addr: CellAddress }): void {
399
+ if (editing.value) return
400
+ if (mirroring.value) {
401
+ if (mirrorAddr && addr.row === mirrorAddr.row && addr.col === mirrorAddr.col) {
402
+ mirroring.value = false
403
+ mirrorAddr = null
404
+ refreshFx()
405
+ }
406
+ return
407
+ }
408
+ // 公式栏只显示活动格:非活动格变更(批量写入的绝大多数补丁)直接忽略,
409
+ // 避免每个 cell-change 都 refreshFx + nextTick(autosizeFx) 强制同步布局(#5)
410
+ const active = selection.value.activeCell
411
+ if (!active || addr.row !== active.row || addr.col !== active.col) return
412
+ refreshFx()
413
+ }
414
+
415
+ function mirrorGridEdit(addr: CellAddress): void {
416
+ if (editing.value || fxDisabled.value) return
417
+ mirroring.value = true
418
+ mirrorAddr = addr
419
+ fxDraft.value = cellText(addr)
420
+ closeSuggest()
421
+ }
422
+
423
+ function exitMirror(addr: CellAddress): void {
424
+ if (!mirroring.value) return
425
+ if (mirrorAddr && addr.row === mirrorAddr.row && addr.col === mirrorAddr.col) {
426
+ mirroring.value = false
427
+ mirrorAddr = null
428
+ refreshFx()
429
+ }
430
+ }
431
+
432
+ defineExpose({ mirrorGridEdit, exitMirror, isRefSelecting, beginBlurSuppress, handleRefSelect })
433
+
434
+ // ─── 生命周期 ──────────────────────────────────────────────
435
+
436
+ let dispose: (() => void)[] = []
437
+ function bindSheet(sheet: Sheet): void {
438
+ for (const off of dispose) off()
439
+ dispose = [
440
+ sheet.on('selection-change', handleSelectionChange),
441
+ sheet.on('cell-change', handleCellChange)
442
+ ]
443
+ }
444
+
445
+ watch(
446
+ () => props.sheet,
447
+ (sheet, prev) => {
448
+ if (sheet === prev) return
449
+ editing.value = false
450
+ editAddr = null
451
+ mirroring.value = false
452
+ mirrorAddr = null
453
+ suppressBlurCommit = false
454
+ closeSuggest()
455
+ bindSheet(sheet)
456
+ selection.value = sheet.getSelection()
457
+ refreshName()
458
+ refreshFx()
459
+ }
460
+ )
461
+
462
+ onMounted(() => {
463
+ bindSheet(props.sheet)
464
+ selection.value = props.sheet.getSelection()
465
+ refreshName()
466
+ refreshFx()
467
+ })
468
+
469
+ onBeforeUnmount(() => {
470
+ for (const off of dispose) off()
471
+ dispose = []
472
+ })
473
+ </script>
@@ -0,0 +1,64 @@
1
+ <template>
2
+ <div v-if="items.length > 0" :class="cls.e('fx-suggest')" role="listbox" @mousedown.prevent>
3
+ <!-- 滚动用 desktop UScroll(自定义滚动条;原生 overflow 滚动条在浮层里常看不见) -->
4
+ <u-scroll :height="scrollHeight" :class="cls.e('fx-suggest-scroll')">
5
+ <ul :class="cls.e('fx-suggest-list')">
6
+ <li
7
+ v-for="(item, index) in items"
8
+ :key="item.name"
9
+ :ref="(el) => setItemRef(index, el)"
10
+ :class="[cls.e('fx-suggest-item'), bem.is('active', index === activeIndex)]"
11
+ role="option"
12
+ :aria-selected="index === activeIndex"
13
+ @mousedown.prevent="emit('select', item)"
14
+ @mouseenter="emit('hover', index)"
15
+ >
16
+ <span :class="cls.e('fx-suggest-sig')">{{ item.signature }}</span>
17
+ <span v-if="item.description" :class="cls.e('fx-suggest-desc')">{{
18
+ item.description
19
+ }}</span>
20
+ </li>
21
+ </ul>
22
+ </u-scroll>
23
+ </div>
24
+ </template>
25
+
26
+ <script lang="ts" setup>
27
+ import { UScroll } from '@veltra/desktop'
28
+ import { bem } from '@veltra/utils'
29
+ import { computed, nextTick, watch } from 'vue'
30
+
31
+ import type { FormulaSuggestItem } from './use-formula-suggest'
32
+
33
+ defineOptions({ name: 'UFormulaSuggestList' })
34
+
35
+ const props = defineProps<{ items: FormulaSuggestItem[]; activeIndex: number }>()
36
+
37
+ const emit = defineEmits<{ select: [item: FormulaSuggestItem]; hover: [index: number] }>()
38
+
39
+ const cls = bem('sheet')
40
+ // bem.is 挂在工厂函数上,与 cls.e 分开用
41
+
42
+ /** 候选列表最大可视高度;内容更短时收缩,避免空前缀少量项时大片留白 */
43
+ const SUGGEST_MAX_HEIGHT = 240
44
+ /** 单项约高(含 padding + 签名/描述两行);用于估算 UScroll height */
45
+ const SUGGEST_ITEM_HEIGHT = 48
46
+
47
+ const scrollHeight = computed(() =>
48
+ Math.min(Math.max(props.items.length, 1) * SUGGEST_ITEM_HEIGHT, SUGGEST_MAX_HEIGHT)
49
+ )
50
+
51
+ const itemEls: (HTMLElement | null)[] = []
52
+
53
+ function setItemRef(index: number, el: unknown): void {
54
+ itemEls[index] = el instanceof HTMLElement ? el : null
55
+ }
56
+
57
+ watch(
58
+ () => [props.activeIndex, props.items] as const,
59
+ async ([index]) => {
60
+ await nextTick()
61
+ itemEls[index]?.scrollIntoView({ block: 'nearest' })
62
+ }
63
+ )
64
+ </script>
@@ -0,0 +1,173 @@
1
+ import { message, messageConfirm } from '@veltra/desktop'
2
+ import {
3
+ importCsv,
4
+ importXlsx,
5
+ replaceWorkbookWithSnapshots
6
+ } from '@veltra/sheet-core/core/io/import'
7
+ import type { Sheet, SheetSnapshot } from '@veltra/sheet-core/core/sheet'
8
+ import type { Workbook } from '@veltra/sheet-core/core/workbook'
9
+ import { nextFrame } from '@veltra/utils'
10
+ import type { Ref } from 'vue'
11
+
12
+ import type { ImportWorkerResponse } from './popups/import.worker'
13
+
14
+ /** 导入文件 accept(工具栏直接选文件) */
15
+ export const IMPORT_FILE_ACCEPT = '.xlsx,.csv'
16
+
17
+ /** 导入所需上下文(vue 层编排;tools 层不 import vue) */
18
+ export interface ImportFileOptions {
19
+ workbook: Workbook
20
+ activeSheet: Sheet
21
+ /** xlsx worker 解析期 → grid 遮罩 */
22
+ parsing?: Ref<boolean>
23
+ /** worker 分片构建进度 */
24
+ parseProgress?: Ref<{ done: number; total: number }>
25
+ /** csv 导入完成(可能扩张尺寸,宿主需重建网格) */
26
+ onCsvImported?: () => void
27
+ /** xlsx 已替换工作簿(宿主需同步 tabs / 重绑 / 重建) */
28
+ onWorkbookReplaced?: () => void
29
+ }
30
+
31
+ type ParsedXlsx = { sheets: { name: string; snapshot: SheetSnapshot }[]; activeIndex: number }
32
+
33
+ /**
34
+ * xlsx 解析(worker 优先):importXlsx 是同步重活,主线程直接跑会冻结 UI。
35
+ * worker 返回纯数据快照;不可用时回退主线程解析。
36
+ */
37
+ async function parseXlsxAsync(
38
+ buffer: ArrayBuffer,
39
+ parseProgress?: Ref<{ done: number; total: number }>
40
+ ): Promise<ParsedXlsx> {
41
+ let worker: Worker | undefined
42
+ try {
43
+ // new URL 模式:dev(vite)与 build(rolldown)都支持把 worker 提为独立 chunk
44
+ const url = import.meta.env?.DEV
45
+ ? new URL('./popups/import.worker.ts', import.meta.url)
46
+ : new URL('./popups/import.worker.js', import.meta.url)
47
+ worker = new Worker(url, { type: 'module' })
48
+ } catch {
49
+ return toSnapshotArray(await importXlsx(new Uint8Array(buffer)))
50
+ }
51
+ return await new Promise<ParsedXlsx>((resolve, reject) => {
52
+ let settled = false
53
+ const fallback = (): void => {
54
+ if (settled) return
55
+ settled = true
56
+ worker!.terminate()
57
+ void importXlsx(new Uint8Array(buffer))
58
+ .then((wb) => resolve(toSnapshotArray(wb)))
59
+ .catch((err: unknown) => reject(err))
60
+ }
61
+ worker!.onmessage = (e: MessageEvent<ImportWorkerResponse>) => {
62
+ const data = e.data
63
+ if (data.type === 'progress') {
64
+ if (parseProgress) parseProgress.value = { done: data.done, total: data.total }
65
+ return
66
+ }
67
+ if (settled) return
68
+ settled = true
69
+ if (!data.ok) {
70
+ worker!.terminate()
71
+ reject(new Error(data.error ?? 'worker 解析失败'))
72
+ return
73
+ }
74
+ worker!.terminate()
75
+ resolve({ sheets: data.sheets ?? [], activeIndex: data.activeIndex ?? 0 })
76
+ }
77
+ worker!.onerror = () => fallback()
78
+ // transfer 一份拷贝给 worker;主线程保留原 buffer 供降级解析
79
+ const transfer = buffer.slice(0)
80
+ worker!.postMessage({ buffer: transfer }, [transfer])
81
+ })
82
+ }
83
+
84
+ function toSnapshotArray(wb: Workbook): ParsedXlsx {
85
+ return {
86
+ sheets: wb.getSheets().map((s) => ({ name: s.name, snapshot: s.snapshot() })),
87
+ activeIndex: wb.activeSheetIndex
88
+ }
89
+ }
90
+
91
+ /**
92
+ * 从本地 File 导入:
93
+ * - .csv → importCsv 写入当前活动表
94
+ * - .xlsx → 解析后经 messageConfirm 确认再 replaceWorkbookWithSnapshots
95
+ */
96
+ export function importFromFile(file: File, options: ImportFileOptions): void {
97
+ const { workbook, activeSheet, parsing, parseProgress, onCsvImported, onWorkbookReplaced } =
98
+ options
99
+
100
+ if (file.name.toLowerCase().endsWith('.csv')) {
101
+ void file.text().then((text) => {
102
+ importCsv(text, activeSheet)
103
+ onCsvImported?.()
104
+ message.success(`已从 ${file.name} 导入到工作表「${activeSheet.name}」`)
105
+ })
106
+ return
107
+ }
108
+
109
+ void file.arrayBuffer().then((buffer) => {
110
+ if (parsing) parsing.value = true
111
+ if (parseProgress) parseProgress.value = { done: 0, total: 0 }
112
+ void parseXlsxAsync(buffer, parseProgress)
113
+ .then((imported) => {
114
+ if (parsing) parsing.value = false
115
+ messageConfirm.danger(
116
+ `导入将替换当前工作簿(共 ${imported.sheets.length} 个工作表),确定吗?`,
117
+ {
118
+ confirmButtonText: '导入',
119
+ onClosed: (action) => {
120
+ if (action !== 'confirm') return
121
+ const loading = message({ message: '正在导入…', duration: 0 })
122
+ try {
123
+ replaceWorkbookWithSnapshots(workbook, imported.sheets, imported.activeIndex)
124
+ onWorkbookReplaced?.()
125
+ nextFrame(() => {
126
+ message.success('导入完成')
127
+ loading.close()
128
+ })
129
+ } catch (err) {
130
+ console.error('[sheet] 导入失败:', err)
131
+ message.error(`导入失败:${err instanceof Error ? err.message : String(err)}`)
132
+ loading.close()
133
+ }
134
+ }
135
+ }
136
+ )
137
+ })
138
+ .catch((err: unknown) => {
139
+ if (parsing) parsing.value = false
140
+ message.error(`文件解析失败:${err instanceof Error ? err.message : String(err)}`)
141
+ })
142
+ })
143
+ }
144
+
145
+ /**
146
+ * 拉起系统文件选择框(工具栏导入用;不用 UFilePicker——无编程式打开 API)。
147
+ * 选中文件后走 importFromFile。
148
+ */
149
+ export function pickAndImportFile(options: ImportFileOptions): void {
150
+ const input = document.createElement('input')
151
+ input.type = 'file'
152
+ input.accept = IMPORT_FILE_ACCEPT
153
+ input.hidden = true
154
+ const cleanup = (): void => {
155
+ input.remove()
156
+ }
157
+ input.addEventListener('change', () => {
158
+ const file = input.files?.[0]
159
+ cleanup()
160
+ if (file) importFromFile(file, options)
161
+ })
162
+ window.addEventListener(
163
+ 'focus',
164
+ () => {
165
+ setTimeout(() => {
166
+ if (document.body.contains(input) && !input.files?.length) cleanup()
167
+ }, 300)
168
+ },
169
+ { once: true }
170
+ )
171
+ document.body.appendChild(input)
172
+ input.click()
173
+ }