@veltra/sheet 2.5.8 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/components/sheet/formula-bar.js +6 -1
  2. package/dist/components/sheet/sheet-context-menu.js +41 -5
  3. package/dist/components/sheet/sheet.js +12 -4
  4. package/dist/components/sheet/style2.css +1 -1
  5. package/dist/components/sheet/use-sheet-grid.js +17 -7
  6. package/dist/components/sheet/use-sheet-state.js +4 -1
  7. package/dist/components/sheet/use-tool-popup.js +1 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/tools/context.d.ts +15 -0
  10. package/dist/tools/context.js +22 -1
  11. package/dist/types/sheet.d.ts +15 -5
  12. package/package.json +5 -2
  13. package/src/components/sheet/__test__/formula-bar.test.ts +52 -42
  14. package/src/components/sheet/__test__/functions-popup.test.ts +1 -1
  15. package/src/components/sheet/__test__/import-file.test.ts +1 -1
  16. package/src/components/sheet/__test__/insert-count-menu-item.test.ts +1 -1
  17. package/src/components/sheet/__test__/insert-image-popup.test.ts +1 -1
  18. package/src/components/sheet/__test__/insert-image.test.ts +1 -1
  19. package/src/components/sheet/__test__/sheet-component.test.ts +311 -24
  20. package/src/components/sheet/__test__/sheet-context-menu.test.ts +32 -3
  21. package/src/components/sheet/__test__/use-formula-ref-select.test.ts +1 -1
  22. package/src/components/sheet/__test__/use-formula-suggest.test.ts +1 -1
  23. package/src/components/sheet/formula-bar.vue +14 -1
  24. package/src/components/sheet/sheet-context-menu.ts +61 -4
  25. package/src/components/sheet/sheet.vue +5 -2
  26. package/src/components/sheet/style.scss +2 -2
  27. package/src/components/sheet/use-sheet-grid.ts +31 -9
  28. package/src/components/sheet/use-sheet-state.ts +5 -1
  29. package/src/components/sheet/use-tool-popup.ts +5 -4
  30. package/src/index.ts +1 -1
  31. package/src/tools/__test__/apply-style.test.ts +1 -1
  32. package/src/tools/__test__/builtin.test.ts +1 -1
  33. package/src/tools/__test__/context.test.ts +66 -2
  34. package/src/tools/__test__/registry.test.ts +1 -1
  35. package/src/tools/context.ts +47 -0
  36. package/src/types/sheet.ts +17 -5
  37. package/src/components/sheet/parsing.ts +0 -11
@@ -1,10 +1,14 @@
1
+ import { createRange } from '@veltra/sheet-core/core/address.js'
1
2
  import { Workbook } from '@veltra/sheet-core/core/workbook.js'
2
- import { afterEach, describe, expect, it, vi } from 'vitest'
3
- import { createApp, h, nextTick, ref, type App } from 'vue'
3
+ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
4
+ import { createApp, h, nextTick, ref, type App, type Component } from 'vue'
4
5
 
6
+ // 引擎指针事件模拟与画布几何(cellX/cellY):setup 已跨包引用同目录 setup.ts
7
+ import { cellX, cellY, fire } from '../../../../../sheet-core/src/grid/__test__/grid-test-utils'
5
8
  import { USheet } from '../../../index'
6
9
  import { registerTool, unregisterTool } from '../../../tools/registry'
7
10
  import type { SheetExposed } from '../../../types'
11
+ import { buildColHeaderMenus, buildRowHeaderMenus } from '../sheet-context-menu'
8
12
 
9
13
  const apps: App[] = []
10
14
  const containers: HTMLElement[] = []
@@ -152,7 +156,7 @@ describe('USheet 组件', () => {
152
156
  expect(tabs(el).map((tab) => tab.textContent?.trim())).toEqual(['Sheet1', 'Sheet2'])
153
157
  expect(tabs(el)[0]!.classList.contains('is-active')).toBe(true)
154
158
 
155
- // VTable 已挂载到 grid 容器
159
+ // 引擎表格已挂载到 grid 容器
156
160
  expect(el.querySelector('.u-sheet__grid canvas')).not.toBeNull()
157
161
 
158
162
  // 单行滚动结构:工具按钮全部位于滚动视口内的 list 容器
@@ -212,8 +216,9 @@ describe('USheet 组件', () => {
212
216
  await nextTick()
213
217
 
214
218
  expect(tabs(el)[1]!.classList.contains('is-active')).toBe(true)
215
- // grid 实例缓存:切换后旧实例保留(LRU,visibility:hidden),容器内有 2 个 canvas
216
- expect(el.querySelectorAll('.u-sheet__grid canvas')).toHaveLength(2)
219
+ // grid 实例缓存:切换后旧实例保留(LRU,visibility:hidden),容器内有 2 个实例
220
+ // (引擎画布为 [data-layer-kind] 分层栈,实例数以实例容器为准)
221
+ expect(el.querySelectorAll('.u-sheet__grid-instance')).toHaveLength(2)
217
222
  // 激活实例可见、旧实例隐藏
218
223
  const instances = el.querySelectorAll<HTMLElement>('.u-sheet__grid-instance')
219
224
  expect(instances[0]!.style.visibility).toBe('hidden')
@@ -222,10 +227,10 @@ describe('USheet 组件', () => {
222
227
  // 工具上下文(与工具按钮同一门面)指向切换后的 sheet
223
228
  expect(exposed.value?.getContext().sheetName).toBe('Sheet2')
224
229
 
225
- // grid 已重建到 Sheet2:写入 Sheet2 应在表格可见
230
+ // grid 已重建到 Sheet2:写入 Sheet2 应在表格可见(引擎数据坐标 (0,0) = 模型 A1)
226
231
  workbook.getSheet('Sheet2')!.setCellValue({ row: 0, col: 0 }, 's2')
227
232
  await nextTick()
228
- expect(exposed.value?.getGrid()?.getTable().getCellValue(1, 1)).toBe('s2')
233
+ expect(exposed.value?.getGrid()?.getTable().getCellText(0, 0)).toBe('s2')
229
234
  })
230
235
 
231
236
  it('props 变更响应:showToolbar/showTabs 显隐,rows 变化重建 grid', async () => {
@@ -271,11 +276,13 @@ describe('USheet 组件', () => {
271
276
  workbook.activeSheet.setCellValue({ row: 0, col: 0 }, 'A1')
272
277
  await nextTick()
273
278
  const table = exposed.value!.getGrid()!.getTable()
274
- expect(table.options.showHeader).toBe(false)
275
- expect(table.options.rowSeriesNumber).toBeUndefined()
276
- expect(table.columnHeaderLevelCount).toBe(0)
277
- expect(table.rowCount).toBe(8)
278
- expect(table.getCellValue(0, 0)).toBe('A1')
279
+ expect(table.options.showRowHeader).toBe(false)
280
+ expect(table.options.showColHeader).toBe(false)
281
+ // 行号列与列头折叠:内容原点回落 (0,0)
282
+ expect(table.getDrawRange().x).toBe(0)
283
+ expect(table.getDrawRange().y).toBe(0)
284
+ expect(table.getBodyVisibleCellRange().rows.end).toBe(8)
285
+ expect(table.getCellText(0, 0)).toBe('A1')
279
286
  })
280
287
 
281
288
  it('删除行后渲染行数跟随模型收缩(不被 props.rows 撑回)', async () => {
@@ -286,14 +293,15 @@ describe('USheet 组件', () => {
286
293
 
287
294
  const sheet = exposed.value!.getActiveSheet()
288
295
  expect(sheet.rows).toBe(10)
289
- expect(exposed.value!.getGrid()!.getTable().rowCount).toBe(11) // 列头 + 10
296
+ // 渲染行数 = 引擎数据行数(无列头 +1;窗口终点被引擎夹取到模型行数)
297
+ expect(exposed.value!.getGrid()!.getTable().getBodyVisibleCellRange().rows.end).toBe(10)
290
298
 
291
299
  sheet.deleteRows(0, 2)
292
300
  await nextTick()
293
301
  await nextTick()
294
302
 
295
303
  expect(sheet.rows).toBe(8)
296
- expect(exposed.value!.getGrid()!.getTable().rowCount).toBe(9) // 列头 + 8
304
+ expect(exposed.value!.getGrid()!.getTable().getBodyVisibleCellRange().rows.end).toBe(8)
297
305
  })
298
306
 
299
307
  it('resolveCellStyle prop 传入 SheetGrid 视口渲染', async () => {
@@ -325,11 +333,8 @@ describe('USheet 组件', () => {
325
333
 
326
334
  const table = exposed.value?.getGrid()?.getTable()
327
335
  expect(table).toBeDefined()
328
- const column = table!.getBodyColumnDefine(2, 2) as {
329
- style?: (arg: { row: number; col: number; table: unknown }) => Record<string, unknown>
330
- }
331
- const rendered = column.style!({ row: 2, col: 2, table })
332
- expect(rendered.bgColor).toBe('#FFE0E0')
336
+ // 引擎公开样式投影:格 (1,1) 命中 resolveCellStyle 补丁(fill → background 覆盖)
337
+ expect(table!.resolveStyle(1, 1).background).toBe('#FFE0E0')
333
338
  expect(sheet.getCellStyle(addr)?.fill?.color).toBe('#CCCCCC')
334
339
  })
335
340
 
@@ -596,7 +601,7 @@ describe('USheet 组件', () => {
596
601
  expect(sheet.getCellStyle({ row: 0, col: 0 })).toBeUndefined()
597
602
  })
598
603
 
599
- it('冻结:模型 setFrozen 驱动 VTable 冻结布局联动(工具栏入口已迁至右键菜单)', async () => {
604
+ it('冻结:模型 setFrozen 驱动引擎冻结布局联动(工具栏入口已迁至右键菜单)', async () => {
600
605
  const workbook = createWorkbook()
601
606
  const sheet = workbook.activeSheet
602
607
  const exposed: { value: SheetExposed | undefined } = { value: undefined }
@@ -606,17 +611,18 @@ describe('USheet 组件', () => {
606
611
  sheet.setFrozen(1, 0)
607
612
  await nextTick()
608
613
  expect(sheet.frozen).toEqual({ rows: 1, cols: 0 })
609
- // 模型 1 行 → frozenRowCount = 2:列头行 + 首行;列保持 1:行号列
610
- expect(exposed.value?.getGrid()?.getTable().frozenRowCount).toBe(2)
611
- expect(exposed.value?.getGrid()?.getTable().frozenColCount).toBe(1)
614
+ // 模型冻结数即引擎数据冻结数(行列头不计数,无 ±1)
615
+ expect(exposed.value?.getGrid()?.getTable().getFrozenRowCount()).toBe(1)
616
+ expect(exposed.value?.getGrid()?.getTable().getFrozenColCount()).toBe(0)
612
617
 
613
618
  sheet.setFrozen(1, 1)
614
619
  await nextTick()
615
- expect(exposed.value?.getGrid()?.getTable().frozenColCount).toBe(2)
620
+ expect(exposed.value?.getGrid()?.getTable().getFrozenColCount()).toBe(1)
616
621
 
617
622
  sheet.setFrozen(0, 0)
618
623
  await nextTick()
619
624
  expect(sheet.frozen).toEqual({ rows: 0, cols: 0 })
625
+ expect(exposed.value?.getGrid()?.getTable().getFrozenRowCount()).toBe(0)
620
626
  })
621
627
 
622
628
  it('导入:点击直接拉起系统文件选择,不打开弹层、不产生历史条目', async () => {
@@ -1029,3 +1035,284 @@ describe('tabs 栏', () => {
1029
1035
  expect(scrollLeft).toBeGreaterThan(0)
1030
1036
  })
1031
1037
  })
1038
+
1039
+ describe('USheet 行高/列宽右键菜单', () => {
1040
+ /**
1041
+ * 触发行号/列头右键:直接调引擎 contextMenuListeners(happy-dom 无法驱动
1042
+ * 引擎画布的 DOM contextmenu 命中),走 SheetGrid → handleContextMenu →
1043
+ * contextmenu.pop 的真实链路。坐标取行号带内 / 列头带内(行高 28 / 行号列宽 46)。
1044
+ */
1045
+ function popHeaderContextMenu(
1046
+ exposed: { value: SheetExposed | undefined },
1047
+ kind: 'row-header' | 'col-header'
1048
+ ): void {
1049
+ const table = exposed.value!.getGrid()!.getTable()
1050
+ const event = {
1051
+ cell: null,
1052
+ region: kind,
1053
+ x: kind === 'row-header' ? 20 : 100,
1054
+ y: kind === 'row-header' ? 40 : 10,
1055
+ originalEvent: new MouseEvent('contextmenu')
1056
+ }
1057
+ for (const listener of table.contextMenuListeners) {
1058
+ listener(event as Parameters<typeof listener>[0])
1059
+ }
1060
+ }
1061
+
1062
+ /**
1063
+ * 最新弹出菜单中含指定文案的菜单项。
1064
+ * happy-dom 无 transition:close 的 leave 不结束,旧弹层不卸载而在 body 累积,
1065
+ * 断言只认 body 中最后一份(每次 pop 追加新根节点)。
1066
+ */
1067
+ function menuItem(text: string): HTMLLIElement | undefined {
1068
+ const roots = document.body.querySelectorAll<HTMLElement>('.u-contextmenu')
1069
+ const latest = roots[roots.length - 1]
1070
+ return latest
1071
+ ? [...latest.querySelectorAll<HTMLLIElement>('.u-contextmenu__item')].find((item) =>
1072
+ item.textContent?.includes(text)
1073
+ )
1074
+ : undefined
1075
+ }
1076
+
1077
+ /** 数值输入项内输入并确认(UInput @input 同步 model,InsertCountMenuItem 点击本体提交) */
1078
+ async function confirmMenuNumber(root: HTMLElement, value: number): Promise<void> {
1079
+ const input = root.querySelector<HTMLInputElement>('input')!
1080
+ input.value = String(value)
1081
+ input.dispatchEvent(new Event('input', { bubbles: true }))
1082
+ await nextTick()
1083
+ root.querySelector<HTMLElement>('.u-sheet__insert-count-menu')!.click()
1084
+ await nextTick()
1085
+ }
1086
+
1087
+ /**
1088
+ * 独立挂载菜单项 render 组件(menuNumberRender → InsertCountMenuItem,与弹出菜单
1089
+ * 同一组合)。弹层 confirm 的 leave 在 happy-dom 不结束,迟到的 destroy 会卸载
1090
+ * 之后 pop 的新菜单,写入路径统一走独立挂载保证确定性。
1091
+ */
1092
+ function mountMenuRender(render: Component): HTMLElement {
1093
+ const host = document.createElement('div')
1094
+ document.body.appendChild(host)
1095
+ containers.push(host)
1096
+ const app = createApp({ render: () => h(render) })
1097
+ apps.push(app)
1098
+ app.mount(host)
1099
+ return host
1100
+ }
1101
+
1102
+ it('行号/列头菜单出现「行高」「列宽」数值项:输入确认写回选区覆盖行/列,重建 grid 后保留', async () => {
1103
+ const workbook = createWorkbook()
1104
+ const exposed: { value: SheetExposed | undefined } = { value: undefined }
1105
+ const state = ref({ rows: 10, cols: 6 })
1106
+ mount(() => ({ workbook, rows: state.value.rows, cols: state.value.cols }), exposed)
1107
+ await nextTick()
1108
+ const ctx = exposed.value!.getContext()
1109
+
1110
+ // 预选 R1C1:R2C2(右键落点在选区内 → 选区保留,不依赖 happy-dom 画布命中)
1111
+ ctx.selectRange(createRange({ row: 0, col: 0 }, { row: 1, col: 1 }))
1112
+
1113
+ // 行号右键(真实链路):菜单出现「行高」内嵌数值输入
1114
+ popHeaderContextMenu(exposed, 'row-header')
1115
+ await nextTick()
1116
+ const rowItem = menuItem('行高')!
1117
+ expect(rowItem.classList.contains('is-disabled')).toBe(false)
1118
+ expect(rowItem.querySelector('.u-contextmenu__custom')).toBeTruthy()
1119
+ expect(rowItem.querySelector('input')).toBeTruthy()
1120
+
1121
+ // 输入 40 确认(与弹层同一 render 组合):应用到选区覆盖行 0..1
1122
+ await confirmMenuNumber(
1123
+ mountMenuRender(buildRowHeaderMenus(ctx).find((m) => m.label === '行高')!.render!),
1124
+ 40
1125
+ )
1126
+ expect(workbook.activeSheet.getRowHeight(0)).toBe(40)
1127
+ expect(workbook.activeSheet.getRowHeight(1)).toBe(40)
1128
+ expect(workbook.activeSheet.getRowHeight(2)).toBeUndefined()
1129
+ // 确认后活动引擎即时生效(门面写模型 → 活动 grid 引擎 table 同步,无需重建)
1130
+ const activeTable = exposed.value!.getGrid()!.getTable()
1131
+ expect(activeTable.getRowHeight(0)).toBe(40)
1132
+ expect(activeTable.getRowHeight(1)).toBe(40)
1133
+ expect(activeTable.getRowHeight(2)).toBe(28)
1134
+ // 尺寸写入不进 undo(同 rowHeights/冻结先例)
1135
+ expect(ctx.canUndo).toBe(false)
1136
+
1137
+ // 列头右键(真实链路):菜单出现「列宽」内嵌数值输入。
1138
+ // 预选 cols 0..2 横跨画布命中可能落点——右键列头无论命中 0/1 或 undefined
1139
+ // 都保持选区(happy-dom 画布命中几何不定,不能依赖落点改选)
1140
+ ctx.selectRange(createRange({ row: 0, col: 0 }, { row: 1, col: 2 }))
1141
+ popHeaderContextMenu(exposed, 'col-header')
1142
+ await nextTick()
1143
+ const colItem = menuItem('列宽')!
1144
+ expect(colItem.querySelector('.u-contextmenu__custom')).toBeTruthy()
1145
+ // 输入 120 确认:应用到选区覆盖列 0..2,活动引擎即时生效
1146
+ await confirmMenuNumber(
1147
+ mountMenuRender(buildColHeaderMenus(ctx).find((m) => m.label === '列宽')!.render!),
1148
+ 120
1149
+ )
1150
+ expect(workbook.activeSheet.getColWidth(0)).toBe(120)
1151
+ expect(workbook.activeSheet.getColWidth(1)).toBe(120)
1152
+ expect(workbook.activeSheet.getColWidth(2)).toBe(120)
1153
+ expect(workbook.activeSheet.getColWidth(3)).toBeUndefined()
1154
+ expect(activeTable.getColWidth(0)).toBe(120)
1155
+ expect(activeTable.getColWidth(2)).toBe(120)
1156
+ expect(activeTable.getColWidth(3)).toBe(80)
1157
+
1158
+ // 重建 grid(props 变化触发)后从模型还原行列尺寸
1159
+ const gridBefore = exposed.value!.getGrid()
1160
+ state.value = { rows: 12, cols: 6 }
1161
+ await nextTick()
1162
+ expect(exposed.value!.getGrid()).not.toBe(gridBefore)
1163
+ const table = exposed.value!.getGrid()!.getTable()
1164
+ expect(table.getRowHeight(0)).toBe(40)
1165
+ expect(table.getColWidth(1)).toBe(120)
1166
+ })
1167
+
1168
+ it('readonly:「行高」「列宽」菜单项禁用(降级为禁用文本项,无内嵌输入)', async () => {
1169
+ const workbook = createWorkbook()
1170
+ const exposed: { value: SheetExposed | undefined } = { value: undefined }
1171
+ mount(() => ({ workbook, rows: 10, cols: 6, readonly: true }), exposed)
1172
+ await nextTick()
1173
+
1174
+ popHeaderContextMenu(exposed, 'row-header')
1175
+ await nextTick()
1176
+ const rowItem = menuItem('行高')!
1177
+ expect(rowItem.classList.contains('is-disabled')).toBe(true)
1178
+ expect(rowItem.querySelector('.u-contextmenu__custom')).toBeNull()
1179
+ expect(rowItem.querySelector('input')).toBeNull()
1180
+
1181
+ popHeaderContextMenu(exposed, 'col-header')
1182
+ await nextTick()
1183
+ const colItem = menuItem('列宽')!
1184
+ expect(colItem.classList.contains('is-disabled')).toBe(true)
1185
+ expect(colItem.querySelector('input')).toBeNull()
1186
+ })
1187
+ })
1188
+
1189
+ describe('fx 选区锚点', () => {
1190
+ const A1_ANCHOR = { minCol: 0, minRow: 0, maxCol: 0, maxRow: 0 }
1191
+
1192
+ function mountSheet(exposed: { value: SheetExposed | undefined }): HTMLElement {
1193
+ const workbook = createWorkbook()
1194
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }), exposed)
1195
+ return el
1196
+ }
1197
+
1198
+ function fxInputOf(el: HTMLElement): HTMLTextAreaElement {
1199
+ return el.querySelector<HTMLTextAreaElement>('.u-sheet__fx-input')!
1200
+ }
1201
+
1202
+ it('fx 焦点/插入函数置锚点:画布引用拾取期选区流动,锚点保持', async () => {
1203
+ const exposed: { value: SheetExposed | undefined } = { value: undefined }
1204
+ const el = mountSheet(exposed)
1205
+ await nextTick()
1206
+
1207
+ const table = exposed.value!.getGrid()!.getTable()
1208
+ const fxInput = fxInputOf(el)
1209
+ expect(table.selectionAnchor).toBeNull()
1210
+
1211
+ // fx 焦点进入编辑会话:被编辑格 A1 置锚点
1212
+ fxInput.dispatchEvent(new Event('focus'))
1213
+ await nextTick()
1214
+ expect(table.selectionAnchor).toEqual(A1_ANCHOR)
1215
+
1216
+ // 输入 =SUM( 进入引用拾取上下文,画布拖选 B2:D4:拾取段选区流动、
1217
+ // 抬手引用文本插入公式,锚点仍钉在被编辑格
1218
+ fxInput.value = '=SUM('
1219
+ fxInput.setSelectionRange(5, 5)
1220
+ fxInput.dispatchEvent(new Event('input', { bubbles: true }))
1221
+ const instance = el.querySelector<HTMLElement>('.u-sheet__grid-instance')!
1222
+ fire(instance, 'pointerdown', { clientX: cellX(1), clientY: cellY(1) })
1223
+ fire(instance, 'pointermove', { clientX: cellX(3), clientY: cellY(3) })
1224
+ fire(instance, 'pointerup', { clientX: cellX(3), clientY: cellY(3) })
1225
+ await nextTick()
1226
+ expect(fxInput.value).toBe('=SUM(B2:D4')
1227
+ // 拦截命中:拾取段选区流动被消费为引用(C3 已插入),不落模型选区,
1228
+ // 画布回推模型选区;锚点仍钉在被编辑格 A1
1229
+ expect(exposed.value!.getActiveSheet().getSelection().activeCell).toEqual({ row: 0, col: 0 })
1230
+ expect(table.selectionAnchor).toEqual(A1_ANCHOR)
1231
+
1232
+ // fx 按钮选函数(insertFunction 重置会话内容):锚点保持在目标格
1233
+ el.querySelector<HTMLButtonElement>('.u-sheet__fx-label')!.click()
1234
+ await flushPopup()
1235
+ const item = document.querySelector<HTMLElement>('.u-sheet__functions-item')
1236
+ expect(item).not.toBeNull()
1237
+ item!.click()
1238
+ await nextTick()
1239
+ expect(fxInput.value).toMatch(/^=\w+\(\)$/)
1240
+ expect(table.selectionAnchor).toEqual(A1_ANCHOR)
1241
+ })
1242
+
1243
+ it('提交(Enter/✓/失焦)与取消(Esc/✗)清锚点', async () => {
1244
+ const exposed: { value: SheetExposed | undefined } = { value: undefined }
1245
+ const el = mountSheet(exposed)
1246
+ await nextTick()
1247
+
1248
+ const table = exposed.value!.getGrid()!.getTable()
1249
+ const fxInput = fxInputOf(el)
1250
+ const commitButton = () => el.querySelectorAll<HTMLButtonElement>('.u-sheet__fx-btn')[0]!
1251
+ const cancelButton = () => el.querySelectorAll<HTMLButtonElement>('.u-sheet__fx-btn')[1]!
1252
+
1253
+ // Enter 提交
1254
+ fxInput.dispatchEvent(new Event('focus'))
1255
+ await nextTick()
1256
+ expect(table.selectionAnchor).toEqual(A1_ANCHOR)
1257
+ fxInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
1258
+ await nextTick()
1259
+ expect(table.selectionAnchor).toBeNull()
1260
+
1261
+ // ✓ 按钮
1262
+ fxInput.dispatchEvent(new Event('focus'))
1263
+ await nextTick()
1264
+ expect(table.selectionAnchor).toEqual(A1_ANCHOR)
1265
+ commitButton().click()
1266
+ await nextTick()
1267
+ expect(table.selectionAnchor).toBeNull()
1268
+
1269
+ // 失焦提交
1270
+ fxInput.dispatchEvent(new Event('focus'))
1271
+ await nextTick()
1272
+ expect(table.selectionAnchor).toEqual(A1_ANCHOR)
1273
+ fxInput.dispatchEvent(new Event('blur'))
1274
+ await nextTick()
1275
+ expect(table.selectionAnchor).toBeNull()
1276
+
1277
+ // Esc 取消
1278
+ fxInput.dispatchEvent(new Event('focus'))
1279
+ await nextTick()
1280
+ expect(table.selectionAnchor).toEqual(A1_ANCHOR)
1281
+ fxInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
1282
+ await nextTick()
1283
+ expect(table.selectionAnchor).toBeNull()
1284
+
1285
+ // ✗ 按钮
1286
+ fxInput.dispatchEvent(new Event('focus'))
1287
+ await nextTick()
1288
+ expect(table.selectionAnchor).toEqual(A1_ANCHOR)
1289
+ cancelButton().click()
1290
+ await nextTick()
1291
+ expect(table.selectionAnchor).toBeNull()
1292
+ })
1293
+
1294
+ it('切 sheet 清锚点:切回原表缓存实例无残留高亮', async () => {
1295
+ const exposed: { value: SheetExposed | undefined } = { value: undefined }
1296
+ const el = mountSheet(exposed)
1297
+ await nextTick()
1298
+
1299
+ const fxInput = fxInputOf(el)
1300
+ fxInput.dispatchEvent(new Event('focus'))
1301
+ await nextTick()
1302
+ const sheet1Table = exposed.value!.getGrid()!.getTable()
1303
+ expect(sheet1Table.selectionAnchor).toEqual(A1_ANCHOR)
1304
+
1305
+ // 切到 Sheet2:新实例无锚点,旧实例锚点已清
1306
+ tabs(el)[1]!.click()
1307
+ await nextTick()
1308
+ const sheet2Table = exposed.value!.getGrid()!.getTable()
1309
+ expect(sheet2Table).not.toBe(sheet1Table)
1310
+ expect(sheet2Table.selectionAnchor).toBeNull()
1311
+
1312
+ // 切回 Sheet1:缓存实例复用,无残留锚点
1313
+ tabs(el)[0]!.click()
1314
+ await nextTick()
1315
+ expect(exposed.value!.getGrid()!.getTable()).toBe(sheet1Table)
1316
+ expect(sheet1Table.selectionAnchor).toBeNull()
1317
+ })
1318
+ })
@@ -1,6 +1,6 @@
1
1
  import { createRange } from '@veltra/sheet-core/core/address.js'
2
2
  import { Sheet } from '@veltra/sheet-core/core/sheet.js'
3
- import { describe, expect, it } from 'vitest'
3
+ import { describe, expect, it } from 'vite-plus/test'
4
4
 
5
5
  import { createSheetContext } from '../../../tools/context'
6
6
  import {
@@ -102,7 +102,7 @@ describe('ensureContextMenuSelection', () => {
102
102
  })
103
103
 
104
104
  describe('build*Menus', () => {
105
- it('行号菜单:插入×2 / 删除 / divider / 冻结项;无合并/插入列', () => {
105
+ it('行号菜单:插入×2 / 删除 / 行高 / divider / 冻结项;无合并/插入列', () => {
106
106
  const sheet = new Sheet()
107
107
  const ctx = createSheetContext(sheet)
108
108
  sheet.selectRange(createRange({ row: 1, col: 0 }, { row: 3, col: 5 }))
@@ -112,11 +112,12 @@ describe('build*Menus', () => {
112
112
  '在上方插入行',
113
113
  '在下方插入行',
114
114
  '删除行',
115
+ '行高',
115
116
  '---',
116
117
  '冻结到当前行',
117
118
  '取消冻结'
118
119
  ])
119
- expect(menus.filter((m) => m.keepOpen)).toHaveLength(2)
120
+ expect(menus.filter((m) => m.keepOpen)).toHaveLength(3)
120
121
  expect(menus.some((m) => m.divider)).toBe(true)
121
122
  expect(menus.at(-1)?.disabled).toBe(true)
122
123
  expect(labels.join()).not.toContain('合并')
@@ -142,12 +143,40 @@ describe('build*Menus', () => {
142
143
  '在左侧插入列',
143
144
  '在右侧插入列',
144
145
  '删除列',
146
+ '列宽',
145
147
  '---',
146
148
  '冻结到当前列',
147
149
  '取消冻结'
148
150
  ])
149
151
  })
150
152
 
153
+ it('行高/列宽项:默认内嵌数值输入,readonly 降级为禁用文本项', () => {
154
+ const sheet = new Sheet()
155
+ const ctx = createSheetContext(sheet)
156
+ sheet.selectCell({ row: 0, col: 0 })
157
+
158
+ const rowItem = buildRowHeaderMenus(ctx).find((m) => m.label === '行高')!
159
+ expect(rowItem.keepOpen).toBe(true)
160
+ expect(rowItem.render).toBeTruthy()
161
+ expect(rowItem.disabled).toBeFalsy()
162
+
163
+ const colItem = buildColHeaderMenus(ctx).find((m) => m.label === '列宽')!
164
+ expect(colItem.keepOpen).toBe(true)
165
+ expect(colItem.render).toBeTruthy()
166
+
167
+ // readonly:render 内嵌组件自带点击路径,禁用态必须去掉 render 拦死输入
168
+ const readonlyRow = buildRowHeaderMenus(ctx, { readonly: true }).find(
169
+ (m) => m.label === '行高'
170
+ )!
171
+ expect(readonlyRow.disabled).toBe(true)
172
+ expect(readonlyRow.render).toBeUndefined()
173
+ const readonlyCol = buildColHeaderMenus(ctx, { readonly: true }).find(
174
+ (m) => m.label === '列宽'
175
+ )!
176
+ expect(readonlyCol.disabled).toBe(true)
177
+ expect(readonlyCol.render).toBeUndefined()
178
+ })
179
+
151
180
  it('body 菜单:合并/取消合并 + 设置数据格式子菜单 + 插入图片,不含行列插入删除', () => {
152
181
  const sheet = new Sheet()
153
182
  const ctx = createSheetContext(sheet)
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it } from 'vite-plus/test'
2
2
 
3
3
  import { insertRefText, isRefSelectContext } from '../use-formula-ref-select'
4
4
 
@@ -1,5 +1,5 @@
1
1
  import { registerFormulaFunction } from '@veltra/sheet-core/core/formula/functions.js'
2
- import { describe, expect, it } from 'vitest'
2
+ import { describe, expect, it } from 'vite-plus/test'
3
3
 
4
4
  import {
5
5
  applySuggest,
@@ -140,7 +140,12 @@ defineOptions({ name: 'UFormulaBar' })
140
140
  * - 引用选择:光标在运算符/`(`/`,` 后时,画布点选/拖选插入引用(blur 抑制防误提交)。
141
141
  * - 网格编辑镜像:只读显示网格编辑器文本。
142
142
  */
143
- const props = defineProps<{ sheet: Sheet; context: SheetContext }>()
143
+ const props = defineProps<{
144
+ sheet: Sheet
145
+ context: SheetContext
146
+ /** fx 编辑会话置/清引擎选区锚点(use-sheet-grid 经活动实例透传;缺省静默) */
147
+ setSelectionAnchor?: (addr: CellAddress | null) => void
148
+ }>()
144
149
 
145
150
  const cls = bem('sheet')
146
151
 
@@ -398,6 +403,8 @@ function handleFxFocus(): void {
398
403
  mirrorAddr = null
399
404
  editing.value = true
400
405
  editAddr = selection.value.activeCell
406
+ // 编辑会话开始:被编辑格置选区锚点,画布引用拾取期选区流动不丢目标格高亮
407
+ if (editAddr) props.setSelectionAnchor?.(editAddr)
401
408
  syncFxCursor()
402
409
  scheduleAutosize()
403
410
  }
@@ -459,6 +466,8 @@ function commitEdit(): void {
459
466
  editing.value = false
460
467
  editAddr = null
461
468
  suppressBlurCommit = false
469
+ // 会话结束清锚点(Enter / ✓ / 失焦同路径)
470
+ props.setSelectionAnchor?.(null)
462
471
  closeSuggest()
463
472
  if (addr) {
464
473
  props.context.setCellValue(addr, fxDraft.value)
@@ -476,6 +485,8 @@ function cancelEdit(): void {
476
485
  editing.value = false
477
486
  editAddr = null
478
487
  suppressBlurCommit = false
488
+ // 会话结束清锚点(Esc / ✗)
489
+ props.setSelectionAnchor?.(null)
479
490
  closeSuggest()
480
491
  refreshFx()
481
492
  }
@@ -590,6 +601,8 @@ function insertFunction(name: string): void {
590
601
  fxDraft.value = text
591
602
  fxCursor.value = cursor
592
603
  suppressBlurCommit = false
604
+ // 插入函数即进入引用拾取会话:目标格置选区锚点
605
+ props.setSelectionAnchor?.(addr)
593
606
  closeSuggest()
594
607
  void nextTick(() => {
595
608
  const el = fxRef.value
@@ -4,6 +4,7 @@ import type { NumFmt } from '@veltra/sheet-core/core/style/types.js'
4
4
  import { defineComponent, h } from 'vue'
5
5
 
6
6
  import type { SheetContext } from '../../tools/context'
7
+ import { MIN_ROW_COL_SIZE } from '../../tools/context'
7
8
  import { defaultToolRegistry } from '../../tools/registry'
8
9
  import InsertCountMenuItem from './insert-count-menu-item.vue'
9
10
  import { pickAndInsertImage } from './insert-image'
@@ -16,6 +17,13 @@ export const INSERT_COUNT_MAX = 1000
16
17
  export const DECIMAL_PLACES_MIN = 0
17
18
  export const DECIMAL_PLACES_MAX = 10
18
19
 
20
+ /** 行高/列宽数值项输入上限(下限复用门面 MIN_ROW_COL_SIZE,对齐引擎 resize 最小值) */
21
+ export const AXIS_SIZE_MAX = 1000
22
+
23
+ /** 数值项默认值回落:未设置自定义尺寸时展示引擎默认行高/列宽(对齐 sheet-core grid-theme) */
24
+ export const FALLBACK_ROW_HEIGHT = 28
25
+ export const FALLBACK_COL_WIDTH = 80
26
+
19
27
  /** 选区覆盖行/列数 → 插入默认 N(min 1 / max 1000) */
20
28
  export function defaultInsertCount(range: CellRange | null, axis: 'row' | 'col'): number {
21
29
  if (!range) return INSERT_COUNT_MIN
@@ -94,12 +102,46 @@ function insertCountRender(options: {
94
102
  return menuNumberRender({ ...options, min: INSERT_COUNT_MIN, max: INSERT_COUNT_MAX })
95
103
  }
96
104
 
105
+ /** 行号/列头菜单选项(readonly 时尺寸项禁用) */
106
+ export interface HeaderMenuOptions {
107
+ /** 只读模式:行高/列宽项禁用(尺寸写入属编辑操作) */
108
+ readonly?: boolean
109
+ }
110
+
111
+ /**
112
+ * 行高/列宽菜单项:内嵌数值输入,确认经门面批量应用到选区覆盖行/列。
113
+ * readonly 时降级为禁用文本项——render 内嵌组件自带点击路径,disabled 拦不住。
114
+ */
115
+ function axisSizeMenuItem(options: {
116
+ label: string
117
+ disabled: boolean
118
+ defaultValue: number
119
+ onConfirm: (n: number) => void
120
+ }): ContextmenuItem {
121
+ if (options.disabled) return { label: options.label, disabled: true }
122
+ return {
123
+ label: options.label,
124
+ keepOpen: true,
125
+ render: menuNumberRender({
126
+ prefix: options.label,
127
+ suffix: '像素',
128
+ defaultValue: options.defaultValue,
129
+ min: MIN_ROW_COL_SIZE,
130
+ max: AXIS_SIZE_MAX,
131
+ onConfirm: options.onConfirm
132
+ })
133
+ }
134
+ }
135
+
97
136
  function primaryRange(ctx: SheetContext): CellRange | null {
98
137
  return ctx.getSelection().ranges[0] ?? null
99
138
  }
100
139
 
101
- /** 行号右键菜单:插入×2 / 删除 / divider / 冻结 / 取消冻结 */
102
- export function buildRowHeaderMenus(ctx: SheetContext): ContextmenuItem[] {
140
+ /** 行号右键菜单:插入×2 / 删除 / 行高 / divider / 冻结 / 取消冻结 */
141
+ export function buildRowHeaderMenus(
142
+ ctx: SheetContext,
143
+ options?: HeaderMenuOptions
144
+ ): ContextmenuItem[] {
103
145
  const range = primaryRange(ctx)
104
146
  const startRow = range?.start.row ?? 0
105
147
  const endRow = range?.end.row ?? startRow
@@ -130,6 +172,12 @@ export function buildRowHeaderMenus(ctx: SheetContext): ContextmenuItem[] {
130
172
  })
131
173
  },
132
174
  { label: '删除行', callback: () => ctx.deleteRows(startRow, deleteCount) },
175
+ axisSizeMenuItem({
176
+ label: '行高',
177
+ disabled: options?.readonly === true,
178
+ defaultValue: ctx.getRowHeight(startRow) ?? FALLBACK_ROW_HEIGHT,
179
+ onConfirm: (n) => ctx.setRowHeightBySelection(n)
180
+ }),
133
181
  { divider: true },
134
182
  {
135
183
  label: freezeActive ? '✓ 冻结到当前行' : '冻结到当前行',
@@ -143,8 +191,11 @@ export function buildRowHeaderMenus(ctx: SheetContext): ContextmenuItem[] {
143
191
  ]
144
192
  }
145
193
 
146
- /** 列头右键菜单:插入×2 / 删除 / divider / 冻结 / 取消冻结 */
147
- export function buildColHeaderMenus(ctx: SheetContext): ContextmenuItem[] {
194
+ /** 列头右键菜单:插入×2 / 删除 / 列宽 / divider / 冻结 / 取消冻结 */
195
+ export function buildColHeaderMenus(
196
+ ctx: SheetContext,
197
+ options?: HeaderMenuOptions
198
+ ): ContextmenuItem[] {
148
199
  const range = primaryRange(ctx)
149
200
  const startCol = range?.start.col ?? 0
150
201
  const endCol = range?.end.col ?? startCol
@@ -175,6 +226,12 @@ export function buildColHeaderMenus(ctx: SheetContext): ContextmenuItem[] {
175
226
  })
176
227
  },
177
228
  { label: '删除列', callback: () => ctx.deleteCols(startCol, deleteCount) },
229
+ axisSizeMenuItem({
230
+ label: '列宽',
231
+ disabled: options?.readonly === true,
232
+ defaultValue: ctx.getColWidth(startCol) ?? FALLBACK_COL_WIDTH,
233
+ onConfirm: (n) => ctx.setColWidthBySelection(n)
234
+ }),
178
235
  { divider: true },
179
236
  {
180
237
  label: freezeActive ? '✓ 冻结到当前列' : '冻结到当前列',