@veltra/sheet 2.5.6 → 2.5.7

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.
@@ -120,6 +120,7 @@ describe('USheet 组件', () => {
120
120
  'valign-bottom',
121
121
  'wrap-text',
122
122
  'find',
123
+ 'functions',
123
124
  'insert-image',
124
125
  'import',
125
126
  'export'
@@ -46,15 +46,16 @@ describe('getSuggestContext', () => {
46
46
  })
47
47
 
48
48
  describe('filterFormulaSuggestions / applySuggest / moveSuggestIndex', () => {
49
- it('空前缀优先常用函数(含 SUM),至多 10 条;SU 过滤到 SUM', () => {
49
+ it('空前缀优先常用函数(含 SUM),至多 10 条;SU 过滤到 SUBSTITUTE / SUM(名称升序)', () => {
50
50
  const all = filterFormulaSuggestions('')
51
51
  expect(all.length).toBeGreaterThan(0)
52
52
  expect(all.length).toBeLessThanOrEqual(FORMULA_SUGGEST_LIMIT)
53
53
  expect(all[0]!.name).toBe('SUM')
54
54
  expect(all.map((s) => s.name)).toContain('SUM')
55
55
  const su = filterFormulaSuggestions('SU')
56
- expect(su.map((s) => s.name)).toEqual(['SUM'])
57
- expect(su[0]!.signature).toBe(formatFunctionSignature('SUM', su[0]!.params))
56
+ expect(su.map((s) => s.name)).toEqual(['SUBSTITUTE', 'SUM'])
57
+ const sum = su.find((s) => s.name === 'SUM')!
58
+ expect(sum.signature).toBe(formatFunctionSignature('SUM', sum.params))
58
59
  })
59
60
 
60
61
  it('无 meta 函数签名仅为名称', () => {
@@ -10,7 +10,33 @@
10
10
  @blur="handleNameBlur"
11
11
  @keydown="handleNameKeydown"
12
12
  />
13
- <span :class="cls.e('fx-label')">fx</span>
13
+ <button
14
+ ref="fxLabelRef"
15
+ type="button"
16
+ :class="cls.e('fx-label')"
17
+ :disabled="fxDisabled"
18
+ title="插入函数"
19
+ @mousedown.prevent
20
+ @click.stop="toggleFunctions"
21
+ >
22
+ fx
23
+ </button>
24
+ <!-- 函数弹框:锚定 fx 按钮下方(UDropdown Teleport 到 #pop-container,
25
+ 同工具栏弹层机制);打开 / 关闭不触碰选区与草稿 -->
26
+ <u-dropdown
27
+ ref="functionsDropdownRef"
28
+ trigger="custom"
29
+ :visible="functionsOpen"
30
+ width="auto"
31
+ @keydown="handleFunctionsKeydown"
32
+ @update:visible="syncFunctionsOpen"
33
+ >
34
+ <template #content>
35
+ <div v-if="functionsOpen" :class="cls.e('functions-popover')" @click.stop>
36
+ <u-sheet-functions-popup @select="handleFunctionSelect" />
37
+ </div>
38
+ </template>
39
+ </u-dropdown>
14
40
  <div
15
41
  :class="cls.e('fx-editor')"
16
42
  @mouseenter="handleFxMouseEnter"
@@ -68,7 +94,7 @@
68
94
  </template>
69
95
 
70
96
  <script lang="ts" setup>
71
- import { message } from '@veltra/desktop'
97
+ import { message, UDropdown } from '@veltra/desktop'
72
98
  import {
73
99
  formatAddress,
74
100
  formatRange,
@@ -92,6 +118,7 @@ import {
92
118
 
93
119
  import type { SheetContext } from '../../tools/context'
94
120
  import UFormulaSuggestList from './formula-suggest-list.vue'
121
+ import USheetFunctionsPopup from './popups/functions-popup.vue'
95
122
  import { insertRefText, isRefSelectContext } from './use-formula-ref-select'
96
123
  import {
97
124
  applySuggest,
@@ -107,6 +134,7 @@ defineOptions({ name: 'UFormulaBar' })
107
134
  * 公式栏(名称框 + fx 输入栏),USheet 顶部结构之一。
108
135
  *
109
136
  * - 名称框:显示当前选区(单格 A1 / 区域 A1:B2);合法地址回车跳转。
137
+ * - fx 按钮:打开函数弹框(分类导航 + 搜索),选中进入编辑态输出 `=NAME()`。
110
138
  * - fx 输入栏:活动格内容;Enter/✓ 提交、Esc/✗ 取消、失焦提交。
111
139
  * - 函数补全:`=` 开头且光标在函数名 token 上时弹出候选(↑↓ / Tab / Enter / 点击)。
112
140
  * - 引用选择:光标在运算符/`(`/`,` 后时,画布点选/拖选插入引用(blur 抑制防误提交)。
@@ -155,9 +183,9 @@ function beginBlurSuppress(): void {
155
183
  suppressBlurCommit = true
156
184
  }
157
185
 
158
- /** 选区拦截回调:插入 A1 / A1:B2 到光标处 */
186
+ /** 选区拦截回调:插入 A1 / A1:B2 到光标处(仅引用选择模式;弹框打开但非引用上下文时不插入) */
159
187
  function handleRefSelect(range: CellRange): void {
160
- if (!editing.value || mirroring.value) return
188
+ if (!isRefSelecting()) return
161
189
  const rangeText = formatRange(range)
162
190
  const el = fxRef.value
163
191
  const selEnd = el?.selectionEnd ?? fxCursor.value
@@ -432,7 +460,14 @@ function commitEdit(): void {
432
460
  editAddr = null
433
461
  suppressBlurCommit = false
434
462
  closeSuggest()
435
- if (addr) props.context.setCellValue(addr, fxDraft.value)
463
+ if (addr) {
464
+ props.context.setCellValue(addr, fxDraft.value)
465
+ // 选区离开目标格(如编辑期间名称框跳转):回推模型选区,grid 层随之恢复目标格高亮
466
+ const active = selection.value.activeCell
467
+ if (!active || active.row !== addr.row || active.col !== addr.col) {
468
+ props.context.selectCell(addr)
469
+ }
470
+ }
436
471
  refreshFx()
437
472
  }
438
473
 
@@ -490,7 +525,94 @@ function exitMirror(addr: CellAddress): void {
490
525
  }
491
526
  }
492
527
 
493
- defineExpose({ mirrorGridEdit, exitMirror, isRefSelecting, beginBlurSuppress, handleRefSelect })
528
+ // ─── 函数弹框(fx 按钮)────────────────────────────────────
529
+
530
+ const fxLabelRef = useTemplateRef<HTMLButtonElement>('fxLabelRef')
531
+ const functionsDropdownRef = useTemplateRef<InstanceType<typeof UDropdown>>('functionsDropdownRef')
532
+ const functionsOpen = ref(false)
533
+
534
+ function toggleFunctions(): void {
535
+ if (fxDisabled.value) return
536
+ if (functionsOpen.value) {
537
+ functionsOpen.value = false
538
+ return
539
+ }
540
+ // 焦点即将移向弹框搜索框:抑制 fx blur 提交,草稿与编辑态原样保留
541
+ if (editing.value && fxFocused.value) suppressBlurCommit = true
542
+ functionsOpen.value = true
543
+ }
544
+
545
+ // 弹框打开必须同步调 open() 传 fx 按钮作锚点(同 sheet.vue 工具弹层时序)
546
+ watch(functionsOpen, () => {
547
+ const dropdown = functionsDropdownRef.value
548
+ if (!dropdown) return
549
+ if (functionsOpen.value) {
550
+ dropdown.open({ trigger: fxLabelRef.value ?? undefined })
551
+ } else {
552
+ dropdown.close()
553
+ }
554
+ })
555
+
556
+ /** UDropdown 内部关闭(触发元素位移等)→ 同步本地开合状态 */
557
+ function syncFunctionsOpen(visible: boolean): void {
558
+ if (!visible) functionsOpen.value = false
559
+ }
560
+
561
+ /** 弹框内 Esc 关闭(关闭不还原草稿、不退出挂起的编辑态) */
562
+ function handleFunctionsKeydown(event: KeyboardEvent): void {
563
+ if (event.key !== 'Escape') return
564
+ event.preventDefault()
565
+ functionsOpen.value = false
566
+ }
567
+
568
+ /** 点击弹框外关闭(弹框内容与 fx 按钮均 @click.stop,到达这里的必是外部点击) */
569
+ function closeFunctionsOnOuterClick(): void {
570
+ if (!functionsOpen.value) return
571
+ functionsOpen.value = false
572
+ }
573
+
574
+ /** 选中函数:关弹框并进入编辑态输出 =NAME()(fx / 工具栏共用路径) */
575
+ function handleFunctionSelect(name: string): void {
576
+ functionsOpen.value = false
577
+ insertFunction(name)
578
+ }
579
+
580
+ /** 插入函数:编辑目标 = 当前活动格,内容覆盖为 =NAME(),光标落在括号内 */
581
+ function insertFunction(name: string): void {
582
+ const addr = selection.value.activeCell
583
+ if (!addr) return
584
+ const text = `=${name}()`
585
+ const cursor = text.length - 1
586
+ editing.value = true
587
+ editAddr = addr
588
+ mirroring.value = false
589
+ mirrorAddr = null
590
+ fxDraft.value = text
591
+ fxCursor.value = cursor
592
+ suppressBlurCommit = false
593
+ closeSuggest()
594
+ void nextTick(() => {
595
+ const el = fxRef.value
596
+ if (!el) return
597
+ el.focus()
598
+ // focus 事件里 syncFxCursor 会读到置尾光标,这里用局部值落位覆盖
599
+ el.setSelectionRange(cursor, cursor)
600
+ fxCursor.value = cursor
601
+ autosizeFx()
602
+ // `=NAME()` 光标在括号内:处于引用选择上下文,不再弹补全
603
+ refreshSuggest()
604
+ })
605
+ }
606
+
607
+ defineExpose({
608
+ mirrorGridEdit,
609
+ exitMirror,
610
+ isRefSelecting,
611
+ isFunctionPopupOpen: () => functionsOpen.value,
612
+ beginBlurSuppress,
613
+ handleRefSelect,
614
+ insertFunction
615
+ })
494
616
 
495
617
  // ─── 生命周期 ──────────────────────────────────────────────
496
618
 
@@ -512,6 +634,7 @@ watch(
512
634
  mirroring.value = false
513
635
  mirrorAddr = null
514
636
  suppressBlurCommit = false
637
+ functionsOpen.value = false
515
638
  closeSuggest()
516
639
  bindSheet(sheet)
517
640
  selection.value = sheet.getSelection()
@@ -525,10 +648,12 @@ onMounted(() => {
525
648
  selection.value = props.sheet.getSelection()
526
649
  refreshName()
527
650
  refreshFx()
651
+ window.addEventListener('click', closeFunctionsOnOuterClick)
528
652
  })
529
653
 
530
654
  onBeforeUnmount(() => {
531
655
  for (const off of dispose) off()
532
656
  dispose = []
657
+ window.removeEventListener('click', closeFunctionsOnOuterClick)
533
658
  })
534
659
  </script>
@@ -0,0 +1,141 @@
1
+ <template>
2
+ <div ref="panelRef" :class="cls.e('functions-panel')" @keydown="handleKeydown">
3
+ <nav :class="cls.e('functions-nav')">
4
+ <button
5
+ v-for="cat in FUNCTION_POPUP_CATEGORIES"
6
+ :key="cat"
7
+ type="button"
8
+ :class="[cls.e('functions-nav-item'), bem.is('active', cat === activeCategory)]"
9
+ @click="selectCategory(cat)"
10
+ >
11
+ {{ cat }}
12
+ </button>
13
+ </nav>
14
+ <div :class="cls.e('functions-main')">
15
+ <u-input
16
+ v-model="keyword"
17
+ :placeholder="'搜索函数名或描述'"
18
+ size="small"
19
+ :class="cls.e('functions-search')"
20
+ />
21
+ <!-- 滚动用 desktop UScroll(自定义滚动条),同字号面板 -->
22
+ <u-scroll :height="320" :class="cls.e('functions-scroll')">
23
+ <div v-if="visible.length > 0" :class="cls.e('functions-list')">
24
+ <div
25
+ v-for="(fn, index) in visible"
26
+ :key="fn.name"
27
+ :class="[cls.e('functions-item'), bem.is('active', index === activeIndex)]"
28
+ @click="emit('select', fn.name)"
29
+ @mouseenter="activeIndex = index"
30
+ >
31
+ <span :class="cls.e('functions-signature')">{{
32
+ formatFunctionSignature(fn.name, fn.params)
33
+ }}</span>
34
+ <span v-if="fn.description" :class="cls.e('functions-description')">{{
35
+ fn.description
36
+ }}</span>
37
+ </div>
38
+ </div>
39
+ <div v-else :class="cls.e('functions-empty')">无匹配函数</div>
40
+ </u-scroll>
41
+ </div>
42
+ </div>
43
+ </template>
44
+
45
+ <script lang="ts" setup>
46
+ import { UInput, UScroll } from '@veltra/desktop'
47
+ import {
48
+ listFormulaFunctions,
49
+ type FormulaFunctionMeta
50
+ } from '@veltra/sheet-core/core/formula/functions'
51
+ import { bem } from '@veltra/utils'
52
+ import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
53
+
54
+ import {
55
+ COMMON_FORMULA_NAMES,
56
+ formatFunctionSignature,
57
+ FUNCTION_POPUP_CATEGORIES,
58
+ moveSuggestIndex,
59
+ type FunctionPopupCategory
60
+ } from '../use-formula-suggest'
61
+
62
+ defineOptions({ name: 'USheetFunctionsPopup' })
63
+
64
+ /**
65
+ * 函数弹框(分类导航 + 搜索 + 可选择):fx 按钮与工具栏「函数」共用。
66
+ * 列出全部已注册函数(内置 + 宿主经 registerFormulaFunction 注册的自定义函数),
67
+ * 选中仅 emit select(name),由公式栏接管(进入编辑态输出 =NAME()),不写模型。
68
+ * 弹框随 v-if 挂载 / 销毁,每次打开都是全新状态(关键词清空、回到「常用」)。
69
+ */
70
+
71
+ type FunctionItem = { name: string } & FormulaFunctionMeta
72
+
73
+ const emit = defineEmits<{ select: [name: string] }>()
74
+
75
+ const cls = bem('sheet')
76
+
77
+ const panelRef = useTemplateRef<HTMLElement>('panelRef')
78
+
79
+ const keyword = ref('')
80
+ const activeCategory = ref<FunctionPopupCategory>('常用')
81
+ /** 键盘 / 悬停高亮下标(点击即确认不高亮) */
82
+ const activeIndex = ref(0)
83
+
84
+ /** 打开时快照一次注册表(弹框存活期间注册表变化不追踪,重开即刷新) */
85
+ const all: FunctionItem[] = listFormulaFunctions()
86
+
87
+ /** 关键词大小写不敏感子串匹配:名称或中文描述;跨分类(未分类函数也可搜到) */
88
+ function searchResult(key: string): FunctionItem[] {
89
+ return all.filter(
90
+ (fn) => fn.name.toLowerCase().includes(key) || fn.description.toLowerCase().includes(key)
91
+ )
92
+ }
93
+
94
+ /** 当前展示列表:搜索优先;否则按分类导航过滤 */
95
+ const visible = computed<FunctionItem[]>(() => {
96
+ const key = keyword.value.trim().toLowerCase()
97
+ if (key) return searchResult(key)
98
+ if (activeCategory.value === '全部') return all
99
+ if (activeCategory.value === '常用') {
100
+ // 固定清单(顺序即 COMMON_FORMULA_NAMES 顺序);注册表中缺失的名称跳过
101
+ const byName = new Map(all.map((fn) => [fn.name, fn]))
102
+ return COMMON_FORMULA_NAMES.map((name) => byName.get(name)).filter(
103
+ (fn): fn is FunctionItem => fn != null
104
+ )
105
+ }
106
+ // 分类项只列声明了该 category 的函数(未分类函数不进任何分类)
107
+ return all.filter((fn) => fn.category === activeCategory.value)
108
+ })
109
+
110
+ watch(visible, () => {
111
+ activeIndex.value = 0
112
+ })
113
+
114
+ function selectCategory(cat: FunctionPopupCategory): void {
115
+ activeCategory.value = cat
116
+ }
117
+
118
+ /** 键盘路径:↑↓ 移动高亮(循环),Enter 确认选中(Esc 交由外层弹框关闭) */
119
+ function handleKeydown(event: KeyboardEvent): void {
120
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
121
+ event.preventDefault()
122
+ activeIndex.value = moveSuggestIndex(
123
+ activeIndex.value,
124
+ visible.value.length,
125
+ event.key === 'ArrowDown' ? 1 : -1
126
+ )
127
+ return
128
+ }
129
+ if (event.key === 'Enter') {
130
+ const fn = visible.value[activeIndex.value]
131
+ if (!fn) return
132
+ event.preventDefault()
133
+ emit('select', fn.name)
134
+ }
135
+ }
136
+
137
+ // 打开即聚焦搜索框,键盘可直接检索 / 导航(查 DOM 而非组件 expose,stub 环境同样可用)
138
+ onMounted(() => {
139
+ panelRef.value?.querySelector('input')?.focus()
140
+ })
141
+ </script>
@@ -3,7 +3,7 @@
3
3
  <div :class="cls.e('toolbar-wrap')">
4
4
  <u-sheet-toolbar v-if="showToolbar" :groups="toolGroups" @tool-click="handleToolClick" />
5
5
 
6
- <!-- 弹层型工具面板(填充/边框/字体色/字号/查找/插入图片/导出):UDropdown(Teleport 到
6
+ <!-- 弹层型工具面板(填充/边框/字体色/字号/查找/函数/插入图片/导出):UDropdown(Teleport 到
7
7
  #pop-container + floating-ui 定位:锚点跟随触发按钮、自动翻转/边界位移;
8
8
  触发元素滚动/窗口缩放时自动关闭)。面板交互走 SheetContext 命令入口。
9
9
  导入无弹层:点击直接系统文件选择(见 handleToolClick → pickAndImportFile) -->
@@ -32,6 +32,10 @@
32
32
  :context="context"
33
33
  @close="closePopup"
34
34
  />
35
+ <u-sheet-functions-popup
36
+ v-else-if="popupTool.popup === 'functions'"
37
+ @select="handleFunctionSelect"
38
+ />
35
39
  <u-sheet-insert-image-popup
36
40
  v-else-if="popupTool.popup === 'insert-image'"
37
41
  :context="context"
@@ -87,6 +91,7 @@ import USheetFillColorPopup from './popups/fill-color-popup.vue'
87
91
  import USheetFindPopup from './popups/find-popup.vue'
88
92
  import USheetFontColorPopup from './popups/font-color-popup.vue'
89
93
  import USheetFontSizePopup from './popups/font-size-popup.vue'
94
+ import USheetFunctionsPopup from './popups/functions-popup.vue'
90
95
  import USheetInsertImagePopup from './popups/insert-image-popup.vue'
91
96
  import USheetTabs from './sheet-tabs.vue'
92
97
  import USheetToolbar from './sheet-toolbar.vue'
@@ -162,6 +167,12 @@ function handleToolClick(tool: SheetTool, event?: MouseEvent): void {
162
167
  openOrRunTool(tool, event)
163
168
  }
164
169
 
170
+ /** 工具栏「函数」弹框选中 → 关弹层并走公式栏统一插入路径(与 fx 按钮一致) */
171
+ function handleFunctionSelect(name: string): void {
172
+ closePopup()
173
+ formulaBarRef.value?.insertFunction(name)
174
+ }
175
+
165
176
  // ─── UDropdown 面板:锚点跟随触发按钮(floating-ui 定位)──────────
166
177
 
167
178
  const popupDropdownRef = useTemplateRef<InstanceType<typeof UDropdown>>('popupDropdownRef')
@@ -199,7 +210,9 @@ const { rebuildGrid, activateGrid, pruneCache, getGrid } = useSheetGrid({
199
210
  gridRef,
200
211
  getActiveSheet: () => activeSheet.value,
201
212
  context,
202
- formulaBarRef
213
+ formulaBarRef,
214
+ // 工具栏「函数」弹框打开期间与 fx 入口一致:画布点击不清除目标格高亮
215
+ isFunctionPopupOpen: () => popupTool.value?.popup === 'functions'
203
216
  })
204
217
 
205
218
  const exposed: _SheetExposed = {
@@ -246,10 +246,25 @@
246
246
  flex-shrink: 0;
247
247
  align-items: center;
248
248
  height: fn.use-var(form-component-height, small);
249
+ padding: 0 4px;
250
+ border: none;
251
+ border-radius: fn.use-var(radius, small);
252
+ background: transparent;
249
253
  color: fn.use-var(text-color, second);
250
254
  font-size: fn.use-var(font-size-main, small);
251
255
  font-style: italic;
252
256
  user-select: none;
257
+ cursor: pointer;
258
+
259
+ &:hover:not(:disabled) {
260
+ background-color: fn.use-var(bg-color, hover);
261
+ color: fn.use-var(text-color, main);
262
+ }
263
+
264
+ &:disabled {
265
+ color: fn.use-var(text-color, disabled);
266
+ cursor: not-allowed;
267
+ }
253
268
  }
254
269
 
255
270
  @include m.e(fx-input) {
@@ -531,6 +546,108 @@
531
546
  }
532
547
  }
533
548
 
549
+ /* ─── 函数弹框(分类导航 + 搜索 + 可选择;fx 按钮与工具栏共用)── */
550
+
551
+ @include m.e(functions-popover) {
552
+ display: flex;
553
+ flex-direction: column;
554
+ }
555
+
556
+ @include m.e(functions-panel) {
557
+ display: flex;
558
+ gap: fn.use-var(gap, small);
559
+ }
560
+
561
+ @include m.e(functions-nav) {
562
+ display: flex;
563
+ flex-direction: column;
564
+ flex-shrink: 0;
565
+ gap: 2px;
566
+ padding: 4px;
567
+ border-right: 1px solid fn.use-var(border, muted-color);
568
+ min-width: 96px;
569
+ }
570
+
571
+ @include m.e(functions-nav-item) {
572
+ padding: 4px 8px;
573
+ border: none;
574
+ border-radius: fn.use-var(radius, small);
575
+ background: transparent;
576
+ color: fn.use-var(text-color, second);
577
+ font-size: fn.use-var(font-size-main, small);
578
+ text-align: left;
579
+ white-space: nowrap;
580
+ cursor: pointer;
581
+
582
+ &:hover {
583
+ background-color: fn.use-var(bg-color, hover);
584
+ }
585
+
586
+ @include m.is(active) {
587
+ background-color: fn.use-var(bg-color, hover);
588
+ color: fn.use-var(color, primary);
589
+ font-weight: 600;
590
+ }
591
+ }
592
+
593
+ @include m.e(functions-main) {
594
+ display: flex;
595
+ flex-direction: column;
596
+ gap: 6px;
597
+ flex: 1;
598
+ min-width: 0;
599
+ }
600
+
601
+ @include m.e(functions-search) {
602
+ width: 320px;
603
+ }
604
+
605
+ @include m.e(functions-scroll) {
606
+ width: 320px;
607
+ }
608
+
609
+ @include m.e(functions-list) {
610
+ display: flex;
611
+ flex-direction: column;
612
+ gap: 2px;
613
+ }
614
+
615
+ @include m.e(functions-item) {
616
+ display: flex;
617
+ flex-direction: column;
618
+ gap: 2px;
619
+ padding: 6px 8px;
620
+ border-radius: fn.use-var(radius, small);
621
+ cursor: pointer;
622
+
623
+ &:hover {
624
+ background-color: fn.use-var(bg-color, hover);
625
+ }
626
+
627
+ @include m.is(active) {
628
+ background-color: fn.use-var(bg-color, hover);
629
+ }
630
+ }
631
+
632
+ @include m.e(functions-signature) {
633
+ color: fn.use-var(text-color, main);
634
+ font-size: fn.use-var(font-size-main, small);
635
+ font-weight: 600;
636
+ font-family: 'JetBrains Mono', Consolas, 'Courier New', monospace;
637
+ }
638
+
639
+ @include m.e(functions-description) {
640
+ color: fn.use-var(text-color, second);
641
+ font-size: fn.use-var(font-size-assist, small);
642
+ }
643
+
644
+ @include m.e(functions-empty) {
645
+ padding: 16px 0;
646
+ color: fn.use-var(text-color, second);
647
+ font-size: fn.use-var(font-size-main, small);
648
+ text-align: center;
649
+ }
650
+
534
651
  @include m.e(insert-image-hint) {
535
652
  padding: 8px 10px;
536
653
  border: 1px dashed fn.use-var(border, muted-color);
@@ -23,6 +23,25 @@ export const COMMON_FORMULA_NAMES = [
23
23
  'OR'
24
24
  ] as const
25
25
 
26
+ /**
27
+ * 函数弹框分类导航固定集合(顺序即展示顺序)。
28
+ * 「常用」「全部」是导航概念(非注册表分类):常用 = COMMON_FORMULA_NAMES 固定清单,
29
+ * 全部 = 全部已注册函数;其余与 sheet-core `FormulaFunctionCategory` 对应。
30
+ */
31
+ export const FUNCTION_POPUP_CATEGORIES = [
32
+ '常用',
33
+ '全部',
34
+ '财务',
35
+ '日期与时间',
36
+ '数学',
37
+ '统计',
38
+ '查找与引用',
39
+ '文本',
40
+ '逻辑'
41
+ ] as const
42
+
43
+ export type FunctionPopupCategory = (typeof FUNCTION_POPUP_CATEGORIES)[number]
44
+
26
45
  /** 函数名 token 前合法的「触发」字符(= / 运算符 / ( / ,) */
27
46
  const SUGGEST_TRIGGER_CHARS = new Set(['=', '(', ',', '+', '-', '*', '/', '^', '&', '<', '>'])
28
47
 
@@ -23,6 +23,8 @@ export interface FormulaBarMirror {
23
23
  exitMirror: (addr: CellAddress) => void
24
24
  /** fx 编辑中且处于可插入引用位置 */
25
25
  isRefSelecting: () => boolean
26
+ /** 函数弹框打开中(fx 入口)——画布点击不清除目标格高亮 */
27
+ isFunctionPopupOpen: () => boolean
26
28
  /** 网格 pointerdown → 挂起 blur 提交 */
27
29
  beginBlurSuppress: () => void
28
30
  /** 画布选区 → 插入引用文本 */
@@ -35,6 +37,8 @@ interface UseSheetGridOptions {
35
37
  getActiveSheet: () => Sheet
36
38
  context: SheetContext
37
39
  formulaBarRef: ElRef<FormulaBarMirror>
40
+ /** 函数弹框打开中(宿主侧入口,如工具栏「函数」按钮) */
41
+ isFunctionPopupOpen?: () => boolean
38
42
  }
39
43
 
40
44
  /** 缓存实例(LRU 淘汰):每个 sheet 一个独立容器 div,非激活容器 visibility:hidden 堆叠 */
@@ -125,8 +129,12 @@ export function useSheetGrid(options: UseSheetGridOptions) {
125
129
  onContextMenu: handleContextMenu,
126
130
  onEditStart: (addr) => formulaBarRef.value?.mirrorGridEdit(addr),
127
131
  onEditEnd: (addr) => formulaBarRef.value?.exitMirror(addr),
128
- // 引用选择:不回写模型选区,序列化为 A1 / A1:B2 交给公式栏
129
- interceptSelection: () => formulaBarRef.value?.isRefSelecting() ?? false,
132
+ // 引用选择:不回写模型选区,序列化为 A1 / A1:B2 交给公式栏;
133
+ // 函数弹框打开期间(fx / 工具栏两入口)同样拦截,画布点击不离开目标格
134
+ interceptSelection: () =>
135
+ (formulaBarRef.value?.isRefSelecting() ?? false) ||
136
+ (formulaBarRef.value?.isFunctionPopupOpen() ?? false) ||
137
+ (options.isFunctionPopupOpen?.() ?? false),
130
138
  onSelectionIntercept: (range) => formulaBarRef.value?.handleRefSelect(range)
131
139
  })
132
140
  // 结构变更订阅(vue 层只绑定激活 sheet;隐藏实例必须自持标记,切回时判定过期)
@@ -9,7 +9,8 @@ type ElRef = { readonly value: HTMLElement | null | undefined }
9
9
  /**
10
10
  * 面板打开期间的写入是否合并为一个 undo 单元(关闭时提交):
11
11
  * 填充颜色 / 边框 / 字体颜色 / 字号参与事务;查找(每次替换独立 undo)、
12
- * 导出(下载侧效应,无模型写入)不参与。导入无弹层,不经此路径。
12
+ * 函数(选中仅进入公式栏编辑态,模型写入待 fx 提交)、导出(下载侧效应,
13
+ * 无模型写入)不参与。导入无弹层,不经此路径。
13
14
  */
14
15
  function joinsTransaction(tool: SheetTool): boolean {
15
16
  return (
@@ -21,12 +22,13 @@ function joinsTransaction(tool: SheetTool): boolean {
21
22
  }
22
23
 
23
24
  /**
24
- * 弹层型工具编排(填充颜色 / 边框 / 查找 / 插入图片 / 导出):
25
+ * 弹层型工具编排(填充颜色 / 边框 / 查找 / 函数 / 插入图片 / 导出):
25
26
  * - popupTool:当前打开的弹层工具(null = 未打开)
26
27
  * - popupAnchor:触发按钮元素(面板定位参照,见 sheet.vue 的 left 计算)
27
28
  * - 打开 / 关闭时的事务包裹(面板期间写入合并为一个 undo 单元,关闭时提交)
28
29
  * - 点击面板外关闭(面板内 @click.stop 不冒泡到 window)
29
30
  * - Ctrl/Cmd+F 开合查找条(与工具按钮同一 toggle 逻辑)
31
+ * 函数面板选中只进入公式栏编辑态(无直接模型写入),与查找 / 导出一样不参与事务。
30
32
  * 导入在 sheet.vue 特殊处理(直接系统文件选择),不经弹层。
31
33
  */
32
34
  export function useToolPopup(context: SheetContext, rootEl: ElRef) {
@@ -52,7 +52,7 @@ describe('内置工具(dogfood 扩展机制)', () => {
52
52
  'valign-bottom',
53
53
  'wrap-text'
54
54
  ])
55
- expect(groups[3]!.tools.map((tool) => tool.id)).toEqual(['find'])
55
+ expect(groups[3]!.tools.map((tool) => tool.id)).toEqual(['find', 'functions'])
56
56
  expect(groups[4]!.tools.map((tool) => tool.id)).toEqual(['insert-image'])
57
57
  expect(groups[5]!.tools.map((tool) => tool.id)).toEqual(['import', 'export'])
58
58
 
@@ -214,6 +214,14 @@ describe('内置工具(dogfood 扩展机制)', () => {
214
214
  expect(find.disabled).toBeUndefined()
215
215
  })
216
216
 
217
+ it('函数工具:弹层型声明(popup: functions),组 edit;纯查看不依赖选区', () => {
218
+ const tool = mustGet('functions')
219
+ expect(tool.popup).toBe('functions')
220
+ expect(tool.group).toBe('edit')
221
+ expect(tool.icon).toBeTruthy()
222
+ expect(tool.disabled).toBeUndefined()
223
+ })
224
+
217
225
  it('插入图片工具:弹层型声明(popup: insert-image),组 insert;无选区禁用', () => {
218
226
  const sheet = new Sheet()
219
227
  const ctx = createSheetContext(sheet)