Files
workspace/table-two-state-keyboard-2025.patch
T
2026-10-16 17:30:17 +08:00

3355 lines
173 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/code/g3soft-libs/docs/content/ui/data/table.md b/code/g3soft-libs/docs/content/ui/data/table.md
index 3afa1b17..c044a2c7 100644
--- a/code/g3soft-libs/docs/content/ui/data/table.md
+++ b/code/g3soft-libs/docs/content/ui/data/table.md
@@ -81,7 +81,7 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
## 行内编辑
-列上加 `editor` 即可编辑:**单击**单元格进入(不需要双击)。完整键位见下方「键盘:单击即编辑,方向键分工」。
+列上加 `editor` 即可编辑:**单击**选中单元格、**双击**(或 `Enter` / `F2` / 直接打字)进入编辑。完整键位见下方「两种状态:选中态与编辑态」。
### 跳格
@@ -103,33 +103,91 @@ Tab / Shift+Tab 只在**可编辑格**之间走动:没有 `editor` 的列、
| `select` | 用 [Select](/ui/form/select) 渲染;`options` 可以是数组,也可以是接收当前行的函数 |
| `checkbox` | 点击即切换,无需先激活整行;约定 `1` = 选中、`0` = 未选中(与后端 tinyint 对齐)。切换后**该格同时成为当前格**(行高亮 + 选中框 + 进入该行编辑),与点击其它单元格行为一致 |
-编辑器的输入值先写进内部草稿,**失焦才落库并抛出 `change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。
+编辑器的输入值先写进内部草稿,**提交时才落库并抛出 `change` / `value-change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。
-### 键盘:单击即编辑,方向键分工
+### 两种状态:选中态与编辑态
-**单击单元格即进入编辑**(挂上编辑器并自动聚焦,可以直接打字;同行切列时重新挂载并全选,输入即替换整格内容)。这是本组件的既有交互:少一次点击。
+表格里的键盘行为由**两种状态**决定,先认清状态,再看键位就不会困惑:
-在这个前提下,方向键必须**显式分工** —— 否则会全部被输入框吃掉,人在表格里走不动:
+| 状态 | 长什么样 | 怎么进入 | 说明 |
+| --- | --- | --- | --- |
+| **选中态** | 单元格有选中框,**格子里没有输入框** | 单击单元格、方向键 / `Tab` 移动 | 默认状态;此时方向键只挪选中框,**不会**进编辑 |
+| **编辑态** | 格子内有 `input` / `textarea`,正在改值 | 双击、`Enter`、`F2`、直接打字、`editRow()` | 方向键归输入框,到边界才放行换格 |
-| 按键 | 归谁 | 为什么 |
-| --- | --- | --- |
-| **左右** | 输入框(移动光标 / `Shift+左右` 选中文本) | 编辑文本的刚需:想改中间一位、想选中再覆盖 |
-| **上下**(单行) | **表格**(切到上 / 下一行的同一列,并进入编辑) | 单行输入框里上下键**没有任何原生用途**,让出去是零损失 |
-| `Shift+上下` | 输入框(选中到开头 / 结尾) | 原生的有效操作 |
-| **上下**(`textarea`) | 输入框(跨行移动光标) | 多行里那是有效操作 |
+一句话概括:**单击选中,双击编辑,打字覆盖;非编辑态方向键归表格,编辑态方向键先归输入框(到边界才放行)。**
-其它键:
+### 选中态键位
| 按键 | 行为 |
| --- | --- |
-| `Enter`(单行) | 确认 + 移到下一行同列(表尾即停,不循环) |
+| `↑` `↓` `←` `→` | 切换选中单元格(上下换行 / 左右换列),**不进入编辑** |
+| `Tab` / `Shift+Tab` | 向右 / 向左切换选中格(跳过只读列,行尾接下一行并可循环) |
+| `Enter` | 进入编辑态,**全选**内容(直接输入即替换整格) |
+| `F2` | 进入编辑态,**光标在末尾**(想接着改末尾时用它) |
+| 可打印字符(`a-z`、`0-9`、中文…) | 进入编辑态,**清空原值、只填入该字符**(Excel 的覆盖语义) |
+| `Esc` | 无操作 |
+
+`Space` 刻意**不算**可打印字符(它在表格里的通行语义是翻页 / 触发,也是复选框的切换键)。
+
+### 编辑态键位
+
+| 按键 | 行为 |
+| --- | --- |
+| `←` `→` | **先让输入框移动光标**;仅当**值为空**、或光标已在该方向**边界**时,才切到相邻单元格 |
+| `↑` `↓`(单行) | 提交当前格,切到上 / 下行的同列并**进入编辑** |
+| `Shift+↑/↓/←/→` | 归输入框(选中到首尾 / 收缩选区),不切格 |
+| `↑` `↓`(`textarea`) | 归输入框(跨行移动光标),不切格 |
+| `Tab` / `Shift+Tab` | 提交当前格,切到右 / 左单元格并**进入编辑**(跳过只读列、行尾接下一行、两端循环) |
+| `Enter`(单行) | 提交当前格,切到**下一行同列**并进入编辑(表尾即停,不循环) |
| `Enter`(`textarea`) | 换行(浏览器原生语义) |
-| `Esc` | 放弃本次编辑(回滚到进入前的值),并**把焦点还给该格** —— 可以接着用上下键走 |
-| `Tab` / `Shift+Tab` | 确认并跳到下一个**可编辑**格(跳过只读列与按行禁用的格、行尾自动接下一行、两端循环) |
-| `Space`(`checkbox`) | 切换勾选(原生 checkbox 的切换键);`Enter` 同样有效 —— 连续录入时手不用离开 Enter |
-| 失焦(点别处 / 用上下键走开) | **提交**并抛出 `change` |
+| `F2` | 光标移到末尾(已在编辑态时的合理响应) |
+| `Esc` | 取消编辑,**还原为原值**,回到选中态(焦点交给表格,可接着用方向键走) |
+| `Space`(`checkbox`) | 切换勾选(原生 checkbox 的切换键);`Enter` 同样有效 |
+| 失焦(点别处 / 走开) | **提交**并抛出 `change` |
+
+⚠️ **左右键的"边界才放行"是本组件最容易做错的一处**:若一律让给输入框,编辑态里就永远无法横向换格(只能靠 `Tab`,手感是"跳"而不是"逐格走");若一律给表格,就没法改金额中间那一位。判据因此取"输入框还能不能消费这一记按键"——有选区、或光标不在边界时归输入框,否则归表格。
+
+⚠️ 横向移动**不跨行**:在行末按 `→` 停在原地(要跨行请用 `Tab` / `Enter`)。纵向到表尾同理即停,不循环。
+
+⚠️ **`Esc` 对下拉 / 日历是两段式的**:`select` 展开时第一记 `Esc` 关下拉,第二记才取消整格编辑;`date` 面板同理(第一记关面板、第二记取消编辑)。这是刻意的——面板开着时用户按 `Esc` 想关的多半是面板,一次按键同时关面板又退编辑会让人措手不及。
+
+⚠️ `Esc` 取消对**所有**编辑器都有效,但"还原"的实现按值的存放位置分两类:`text` / `number` / `textarea` 的值在草稿里、提交前不曾通知父级,取消即丢弃草稿;`select` / `date` 的值是**立即写回并已抛过 `change`** 的,取消会把这一格改回进入时的值**并再抛一次 `change`**(父级据此回退,两侧数据不会分叉)。`Esc` 只回滚**当前这一格**,不会牵连同一行里先前改过的其它格。
+
+#### 输入法(IME)
+
+组合态(`event.isComposing` 或 `keyCode === 229`)下**一律不拦截任何键**。这是中文录入的硬要求:候选框上按 `Enter` 确认候选词时,`key` 同样是 `'Enter'`——不挡就会被当成"确认并下移",表现为**候选词上不了屏、光标还跳到了下一行**。同理候选框里的 `↑↓` 选字也不会被当成切行。
+
+### 鼠标
+
+| 操作 | 行为 |
+| --- | --- |
+| 单击 | **选中**单元格(进入选中态,不出现输入框) |
+| 双击 | **进入编辑态**,光标定位到双击处的字符 |
+| 单击已选中的单元格 | 保持选中,不进编辑 |
+| 点页面别处 | 退出编辑并**提交**当前格 |
+
+### 事件
+
+| 事件 | 触发时机 |
+| --- | --- |
+| `edit-start` | 进入编辑态**之后**(编辑器已挂载、已聚焦、光标已就位) |
+| `edit-end` | 编辑完成、值已提交(移动 / 失焦都算),随后离开这一格 |
+| `edit-cancel` | 取消编辑(`Esc`),值**已还原** |
+| `value-change` | 值实际发生变化(提交后新旧值不同) |
+| `change` | 与 `value-change` 同时抛出,语义完全相同(既有名字,保留兼容) |
+
+```vue
+<G3Table
+ :columns="columns"
+ :data-source="data"
+ @edit-start="(row, col) => console.log('开始编辑', col.dataIndex)"
+ @edit-end="(row, col) => console.log('编辑结束', col.dataIndex)"
+ @edit-cancel="(row, col) => console.log('取消编辑', col.dataIndex)"
+ @value-change="(row, col, value) => console.log('值变了', value)"
+/>
+```
-⚠️ **左右键不能让给表格**:它一旦用来切列,就没法在输入框里移动光标 —— 同一个键不可能既移光标又切格。横向切换请用 `Tab`(它还会跳过只读列,比按列 ±1 更贴合录入语境)。
+⚠️ **`edit-start` 只在"用户刚激活这一格"时抛一次**,重渲染引起的重复聚焦不会再抛——否则按它做埋点会收到一串重复事件。同理 `edit-end` / `edit-cancel` 对同一次会话各只抛一次。
### 编辑器形态:单元格内无外间距、无常驻边框
diff --git a/code/g3soft-libs/docs/examples/table/editable.vue b/code/g3soft-libs/docs/examples/table/editable.vue
index 2e73d38c..a37e5738 100644
--- a/code/g3soft-libs/docs/examples/table/editable.vue
+++ b/code/g3soft-libs/docs/examples/table/editable.vue
@@ -3,7 +3,9 @@ import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
-// 行内编辑:列上加 editor 即可。单击单元格进入编辑,Enter 确认、Esc 取消。
+// 行内编辑:列上加 editor 即可。
+// 两种状态:单击**选中**单元格(不出现输入框);双击 / Enter / F2 / 直接打字进入编辑。
+// 编辑态下 Enter 提交并下移、Esc 取消还原、Tab 提交并跳格、上下键换行、左右键到边界才换格。
//
// 编辑器类型:
// text / number / password / textarea → 用 G3Input 渲染
@@ -17,7 +19,7 @@ import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 传 :editor-seamless="false" 则回到「格内留一圈间距 + 自身描边」的悬浮卡片观感。
// 两种形态下编辑器的盒高都等于行高,切换不会改变行高、也不会让同行其它格跳动。
//
-// 关键行为:输入值先写进内部草稿,**失焦才落库并抛出 change**。
+// 关键行为:输入值先写进内部草稿,**提交时**才落库并抛出 change / value-change。
// 这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 200, editor: { type: 'text' } },
@@ -82,7 +84,8 @@ function onChange(row: TableRow, col: G3TableColumn<TableRow>, value: unknown) {
最近一次 change:{{ lastChange || '(点击单元格试试)' }}
</p>
<p style="margin: 0 0 8px; color: #999; font-size: 12px">
- 单击进入编辑 · Enter 确认 · Esc 取消 · Tab / 方向键可换格 ·
+ 单击选中 · 双击(或 Enter / F2 / 直接打字)进入编辑 · 编辑态里 Enter 提交并下移 · Esc 取消 ·
+ Tab / 方向键换格 ·
单元格内无外间距、无常驻边框,焦点时才出现主色焦点环
</p>
<div style="height: 240px">
diff --git a/code/g3soft-libs/packages/ui/e2e/table.spec.ts b/code/g3soft-libs/packages/ui/e2e/table.spec.ts
index 32a03ae1..4c34e6f5 100644
--- a/code/g3soft-libs/packages/ui/e2e/table.spec.ts
+++ b/code/g3soft-libs/packages/ui/e2e/table.spec.ts
@@ -1,11 +1,47 @@
import { expect, test } from '@playwright/test'
+import type { Locator } from '@playwright/test'
+
+/**
+ * 让某一格进入**编辑态**(双击 —— 规格 §四:单击只选中,双击才编辑)。
+ *
+ * 为什么抽成公共函数而不是每处写 `.click()`:进编辑的路径换了(单击 → 双击),
+ * 而用例里有几十处 `await cell.click()` 本意都是"进编辑"。集中成一个语义化的名字,
+ * 下次交互再变时只需改这一处,而不是靠 grep 猜哪些 click 是"进编辑"、哪些是"纯点击"。
+ *
+ * ⚠️ 不要改成两次 `click()`:Playwright 的 `dblclick` 会带上正确的 `detail: 2` 与
+ * 双击间隔,手写两次 click 在部分内核上不会被识别成双击。
+ */
+async function enterEdit(cell: Locator) {
+ await cell.dblclick()
+}
+
+/**
+ * 当前**被选中**的那一格(选中态)。
+ *
+ * 两态模型下,"哪一格是当前格"在选中态里由**库的选区**表达(`td[data-cs-s]`),
+ * 而不是焦点 —— 选中态下焦点在表格容器上(`td` 没有 tabindex、不可聚焦),
+ * 所以 `focusedCell` 那套读法在选中态里恒为 null。两个 helper 各管一个状态。
+ */
+async function selectedCell(page: import('@playwright/test').Page) {
+ return page.evaluate(() => {
+ const td = document.querySelector('td[data-cs-s]') as HTMLElement | null
+ if (!td) return null
+ return {
+ colKey: td.getAttribute('data-col-key'),
+ rowIndex: td.closest('tr')?.getAttribute('data-row-i') ?? null,
+ }
+ })
+}
/**
* G3Table 的几何 / 命中类断言 —— happy-dom 没有布局引擎,这些只能在真浏览器里量。
*
* 用例只按 data-testid 找元素(不依赖文案与结构),宿主页在 playground/main.ts。
+ *
+ * ⚠️ **进入编辑态用 `enterEdit`(双击),不要用单次 `.click()`**。
+ * 表格是**两态模型**:单击只选中(不出现输入框),双击 / `Enter` / `F2` / 打字才进编辑。
+ * 早先的用例普遍用 `.click()` 直接进编辑,那是旧的"单击即编辑"交互。
*/
-
test.describe('G3Table 表格', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
@@ -216,7 +252,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
const before = await rowHeights(page, '[data-testid="table-wrap"]')
- await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click()
+ await enterEdit(await cellByTitle(page, '[data-testid="table-wrap"]', '备注'))
const editor = wrap.locator(EDITOR).first()
await expect(editor).toBeVisible()
@@ -254,7 +290,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
- await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click()
+ await enterEdit(await cellByTitle(page, '[data-testid="table-wrap"]', '备注'))
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
const padding = await wrap
@@ -282,7 +318,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
const idle = await target.evaluate((el) => getComputedStyle(el).boxShadow)
expect(idle).not.toContain('inset')
- await target.click()
+ await enterEdit(target)
await expect(wrap.locator(EDITOR).first()).toBeVisible()
// 焦点环画在**被点的那一格**上(限定 target,避免匹配到同行其它格)
@@ -298,7 +334,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
const before = await rowHeights(page, '[data-testid="table-wrap"]')
// 双击进编辑(表格是「单击选中、进编辑另有其键」的两态模型,见 startEdit 的说明)
- await (await cellByTitle(page, '[data-testid="table-wrap"]', '状态')).click()
+ await enterEdit(await cellByTitle(page, '[data-testid="table-wrap"]', '状态'))
const select = wrap.locator('.g3-table__cell-editor-root.g3-select').first()
await expect(select).toBeVisible()
@@ -360,7 +396,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
await wrap.scrollIntoViewIfNeeded()
const cell = await cellByTitle(page, '[data-testid="table-wrap"]', '状态')
- await cell.click()
+ await enterEdit(cell)
// 前置条件:浮层确实拿到了焦点,且焦点已不在表格里(这正是修前的失效条件)
await expect(page.locator('.g3-select__dropdown')).toBeVisible()
@@ -550,12 +586,12 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
await expect(cell.locator('.g3-table__cell-display')).toHaveText('¥12,000.00')
/**
- * ② 输入一个超精度的值,再点**另一个数据格**让它失焦提交。
+ * ② 输入一个超精度的值(双击进入编辑),再点**另一个数据格**让它提交并退出编辑。
*
* 这里不能点表头:编辑态的退出是由单元格选区变化驱动的,点表头不会退出编辑 ——
* 那样这一格还挂着编辑器,下面针对「展示态 span」的断言会直接找不到元素。
*/
- await cell.click()
+ await enterEdit(cell)
await cell.locator('input').fill('1.2345')
await wrap.locator('.stk-tbody-main tr').nth(2).locator('td[data-col-key="amount"]').click()
await page.waitForTimeout(200)
@@ -571,8 +607,8 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
*/
await expect(cell.locator('.g3-table__cell-display')).toHaveText('1.2345')
- // ⑤ 值也没被四舍五入:再点开编辑,输入框里还是用户敲的那串
- await cell.click()
+ // ⑤ 值也没被四舍五入:再双击回到编辑,输入框里还是用户敲的那串
+ await enterEdit(cell)
await expect(cell.locator('input')).toHaveValue('1.2345')
/**
@@ -624,15 +660,15 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
.evaluate((el) => getComputedStyle(el).paddingRight)
expect(await displayPad(1)).toBe(await displayPad(0))
- // ② 进入编辑:底色 = 基色(亮色主题即 #fff),只剩语义色焦点环
- await errCell.click()
+ // ② 进入编辑(双击):底色 = 基色(亮色主题即 #fff),只剩语义色焦点环
+ await enterEdit(errCell)
await expect(wrap.locator('.is-cell-editing')).toHaveCount(1)
const editing = await read()
expect(editing.bg).toBe('rgb(255, 255, 255)')
expect(editing.badge).toBe('none')
expect(editing.shadow).toContain('2px')
- // ③ 换到别的格 → 它不再是编辑格,底色回来
+ // ③ 单击别的格 → 退出编辑,它不再是编辑格,底色回来
await wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="amount"]').click()
await expect.poll(async () => (await read()).bg).not.toBe('rgb(255, 255, 255)')
})
@@ -670,7 +706,7 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
expect(after).toBeGreaterThan(before)
})
- test('点击行进入编辑时,当前行高亮(active)跟随', async ({ page }) => {
+ test('单击可编辑格:当前行高亮(active)跟随', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
@@ -682,7 +718,13 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
wrap.locator('.stk-tbody-main tr.active').count()
expect(await activeCount()).toBe(0)
- // 点第 2 行的一个可编辑格(金额列),进入编辑并高亮
+ /**
+ * 单击第 2 行的一个可编辑格(金额列)→ 选中它并高亮该行。
+ *
+ * ⚠️ 单击**只选中**(两态模型),不进入编辑 —— 但当前行高亮必须照样跟上:
+ * 键盘不产生 click,调用方的 `@row-click` 收不到消息,所以"哪一行是当前行"
+ * 只能由组件在选区变化时同步(见 handleAreaSelectionChange 里那段注释)。
+ */
const ths = await wrap.locator('thead th').evaluateAll((all) =>
all.map((t) => (t.textContent || '').trim()),
)
@@ -961,9 +1003,9 @@ test.describe('G3Table 固定列 + 校验态', () => {
const firstRow = rows.first()
await expect(firstRow).not.toHaveClass(/active/)
- // 「名称(校验列)」是这张表里的可编辑列(带 editor),点它进入编辑
+ // 「名称(校验列)」是这张表里的可编辑列(带 editor),双击它进入编辑
const editableCell = await cellByTitleIn(page, WRAP, '名称(校验列)')
- await editableCell.click()
+ await enterEdit(editableCell)
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
// 当前行必须高亮 —— 这正是调用方靠 row-click 拿不到的那次点击
@@ -1015,7 +1057,7 @@ test.describe('G3Table 固定列 + 校验态', () => {
const firstRow = wrap.locator('.stk-tbody-main tr').first()
const nameCell = await cellByTitleIn(page, WRAP, '名称(校验列)')
- await nameCell.click()
+ await enterEdit(nameCell)
/**
* 编辑格:应当只有「一圈 2px 内描边」(焦点环),**不带**行状态的大范围叠色。
@@ -1101,7 +1143,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
await wrap.scrollIntoViewIfNeeded()
// 进入第一行「金额」格编辑(number 编辑器,焦点在原生 input 上)
- await wrap.locator('td[data-col-key="amount"]').first().click()
+ await enterEdit(wrap.locator('td[data-col-key="amount"]').first())
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' })
@@ -1137,7 +1179,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
await wrap.scrollIntoViewIfNeeded()
// 从第二行「金额」往回:先跨过只读的「操作」列落到上一行「备注」
- await wrap.locator('td[data-col-key="amount"]').nth(1).click()
+ await enterEdit(wrap.locator('td[data-col-key="amount"]').nth(1))
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '1' })
@@ -1169,7 +1211,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
- await wrap.locator('td[data-col-key="amount"]').first().click()
+ await enterEdit(wrap.locator('td[data-col-key="amount"]').first())
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' })
@@ -1204,7 +1246,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
const big = page.locator(BIG)
await big.scrollIntoViewIfNeeded()
- await big.locator('.stk-tbody-main tr[data-row-i="0"] td[data-col-key="name"]').click()
+ await enterEdit(big.locator('.stk-tbody-main tr[data-row-i="0"] td[data-col-key="name"]'))
await expect(big.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'name', rowIndex: '0' })
@@ -1229,21 +1271,26 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
* 用户实测 100% 复现的手势:在 ERP 里点「订舱号」(只读、没有编辑器)那一格,
* 然后一直按着 Tab 往右走,走到「起运港」时就掉出表格了。
*
- * 成因是两条叠加出来的同一个状态 ——「编辑会话在,但 DOM 里一个编辑器都没有」:
- * · 只读列起步:编辑态是 `editing=true` 而 `focusCol=null`,没有任何编辑器被挂载;
- * · 底座的**连按 / 长按优化**:处理 repeat 事件时会 `clearActiveEditing()`(把
- * `editing` 也置 false),长按期间刻意保持 0 编辑器,等松手再挂回来。
+ * 成因是两条叠加出来的同一个状态 ——「会话在,但 DOM 里一个编辑器都没有」:
+ * · 只读列起步:没有任何编辑器会被挂载;
+ * · 底座的**连按 / 长按优化**:处理 repeat 事件时会 `clearActiveEditing()`,
+ * 长按期间刻意保持 0 编辑器,等松手再挂回来。
* 旧判据只认 `editing === true`,于是从第 3 下起就认为「不是给表格的 Tab」而放行,
* 浏览器按文档顺序把焦点送出表格。
+ *
+ * ⚠️ 两态模型下这条路径落在**选中态**(点只读列只选中、不进编辑),
+ * 所以断言的是**选区**(`selectedCell`)而不是焦点 —— 选中态下焦点在表格容器上,
+ * 没有"聚焦在某格输入框"可言。要点不变:Tab 必须被接管、不许掉出表格。
*/
test('按住 Tab(autoRepeat)从只读列起步:一格一格前进,不掉出表格', async ({ page }) => {
await page.goto('/')
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
- // 「名称」列没有 editor(对应 ERP 的「订舱号」),点它建立编辑会话
+ // 「名称」列没有 editor(对应 ERP 的「订舱号」)—— 点它只选中,不产生编辑会话
await wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="name"]').click()
- await expect(wrap.locator('.is-cell-editing')).toHaveCount(0)
+ await expect.poll(() => selectedCell(page)).toEqual({ colKey: 'name', rowIndex: '0' })
+ expect(await wrap.locator('.is-cell-editing').count()).toBe(0)
// 按住不放:Playwright 对**已按下**的键会把后续 keydown 标成 autoRepeat,与真实长按一致
await page.keyboard.down('Tab')
@@ -1251,26 +1298,27 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
await page.keyboard.down('Tab')
await page.waitForTimeout(250)
}
- // 长按期间底座刻意保持 0 编辑器 —— 这正是「会话在但没编辑器」那个状态
- expect(await wrap.locator('.is-cell-editing').count()).toBe(0)
await page.keyboard.up('Tab')
- // 四下 = 名称(只读) → 金额 → 状态 → 启用 → 备注;松手后编辑器落到最后一格
- await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
+ // 四下 = 名称(只读) → 金额 → 状态 → 启用 → 备注(一格一格走,没有掉出表格)
+ await expect.poll(() => selectedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
+ // 焦点仍在表格里(修前会掉到页面其它元素上)
expect(
await page.evaluate(() => !!document.activeElement?.closest('[data-testid="table-wrap"]')),
).toBe(true)
})
/**
- * 复选框与其它单元格**行为一致**:点一下就切换(不需要先激活整行),
- * 并且这一格随之成为当前格(行高亮 + 选中框 + 编辑会话),键盘也能从这里继续走。
+ * 复选框:点一下就切换(不需要先进编辑),并且这一格随之成为**当前格**。
*
* 回归的 bug:为了「一次点击就切换」,createCheckbox 把 mousedown 停掉了(浏览器在
- * 节点被替换时不派发 click,不停就会变成「第一次点击只进编辑、第二次才切换」)。
- * 但停了之后底座收不到 mousedown,也就不会像点击其它格那样设选区、激活该行编辑、
- * 置当前行 —— 实测后果是:值改了、`change` 也抛了,可整行不高亮、格子没有选中框,
+ * 节点被替换时不派发 click,不停就会变成「第一次点击进编辑、第二次才切换」)。
+ * 但停了之后底座收不到 mousedown,也就不会像点击其它格那样设选区、置当前行 ——
+ * 实测后果是:值改了、`change` 也抛了,可整行不高亮、格子没有选中框,
* 而且表格里**没有任何选区**,接着按 Tab 会从 (0,0) 起步、焦点掉到 `body`。
+ *
+ * ⚠️ 两态模型下点复选框与点其它格一致:**只选中**(`onEditorInteract` 把它设为当前格),
+ * 不再顺带激活整行编辑 —— 复选框自己始终可点,不需要编辑态。
*/
test('点复选框:一次点击即切换,且该格成为当前格(键盘可继续走)', async ({ page }) => {
await page.goto('/')
@@ -1286,14 +1334,13 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
// ① 一次点击就切换(这条是原本就正确的行为,别改坏)
expect(await cell.locator('input').isChecked()).toBe(!before)
- // ② 这一格成为当前格:行高亮 + 选中框 + 编辑格标记
+ // ② 这一格成为当前格:行高亮 + 选中框
await expect(firstRow).toHaveClass(/active/)
await expect(cell).toHaveAttribute('data-cs-s')
- expect(await cell.evaluate((el) => !!el.querySelector('.is-cell-editing'))).toBe(true)
// ③ 于是键盘可以从这里继续走:启用 → 备注(同行下一个可编辑列)
await page.keyboard.press('Tab')
- await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
+ await expect.poll(() => selectedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
})
})
@@ -1323,37 +1370,116 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
/**
* 方向键的分工(编辑器 vs 表格)。
*
- * 本组件的交互是**单击即进入行编辑**(点一格就能直接打字,少一次点击)。在这个前提下,
- * 方向键必须显式分工 —— 否则会全部被输入框吃掉,人在表格里根本走不动:
+ * 表格是**两态模型**(见 docs 的「两种状态:选中态与编辑态」)。方向键的归属跟着状态走:
*
- * · **左右** → 交给输入框移动光标 / `Shift+左右` 选中文本(「想改中间一位」的刚需);
- * · **上下** → 让给表格导航,切到上 / 下一行的同一列
- * (单行输入框里上下键**没有任何原生用途**,让出去是零损失);
- * · `Shift+上下` → 仍归输入框(原生语义是「选中到首尾」);
- * · `textarea` 里的上下 → 也归输入框(要跨行移动光标)。
+ * · **选中态**:方向键只挪选中框,不进编辑(本组前半段);
+ * · **编辑态**:`←` `→` **先归输入框**(有选区、或光标不在边界),
+ * 到边界 / 值为空才**交给表格切格**;`↑` `↓` 单行归表格(切上 / 下行同列并继续编辑);
+ * · `Shift+左右/上下` → 永远归输入框(选中文本 / 收缩选区,是有效操作);
+ * · `textarea` 里的 `↑↓` → 归输入框(跨行移动光标)。
*
* 宿主是 playground 的 `table-keys-wrap`:text / number / textarea 三列。
*/
-test.describe('G3Table 方向键分工:左右归输入框、上下给导航', () => {
- test('单击即进入编辑:挂上编辑器并自动聚焦', async ({ page }) => {
+test.describe('G3Table 方向键分工:边界放行、上下给导航', () => {
+ test('单击只选中:不挂编辑器、不聚焦输入框', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
await cell.click()
+
+ // 选中态:格子被选中(库的 data-cs-s),但**没有** input
+ await expect(cell).toHaveAttribute('data-cs-s')
+ await expect(cell.locator('input')).toHaveCount(0)
+ })
+
+ test('双击进入编辑:挂上编辑器并自动聚焦', async ({ page }) => {
+ await page.goto('/')
+ const wrap = page.locator('[data-testid="table-keys-wrap"]')
+ await wrap.scrollIntoViewIfNeeded()
+ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
+
+ await enterEdit(cell)
await expect(cell.locator('input')).toHaveValue('K-001')
await expect(cell.locator('input')).toBeFocused()
})
- test('上下键:切到上 / 下一行的同一列(并且进编辑,可以直接接着改)', async ({ page }) => {
+ /**
+ * 选中态下按 Enter 进编辑并**全选**(规格 §二)。
+ *
+ * 这是"打字即覆盖"的前置条件:全选之后敲任何字符都会替换整格内容。
+ */
+ test('选中态按 Enter:进入编辑并全选内容', async ({ page }) => {
+ await page.goto('/')
+ const wrap = page.locator('[data-testid="table-keys-wrap"]')
+ await wrap.scrollIntoViewIfNeeded()
+ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
+
+ await cell.click()
+ await page.keyboard.press('Enter')
+
+ const input = cell.locator('input')
+ await expect(input).toBeFocused()
+ // 全选:selectionEnd - selectionStart 等于文本长度
+ const selected = await input.evaluate((el) => {
+ const node = el as HTMLInputElement
+ return { len: node.value.length, span: (node.selectionEnd ?? 0) - (node.selectionStart ?? 0) }
+ })
+ expect(selected.span).toBe(selected.len)
+ })
+
+ /**
+ * 选中态按 F2 进编辑,**光标在末尾**(规格 §二)。
+ *
+ * 与 Enter 的差别就在插入符落点上:F2 是"接着改末尾",Enter 是"整体替换"。
+ */
+ test('选中态按 F2:进入编辑且光标在末尾', async ({ page }) => {
+ await page.goto('/')
+ const wrap = page.locator('[data-testid="table-keys-wrap"]')
+ await wrap.scrollIntoViewIfNeeded()
+ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
+
+ await cell.click()
+ await page.keyboard.press('F2')
+
+ const input = cell.locator('input')
+ await expect(input).toBeFocused()
+ const caret = await input.evaluate((el) => {
+ const node = el as HTMLInputElement
+ return { start: node.selectionStart, end: node.selectionEnd, len: node.value.length }
+ })
+ // 光标贴在末尾且没有选区(不是全选)
+ expect(caret.start).toBe(caret.len)
+ expect(caret.end).toBe(caret.len)
+ })
+
+ /**
+ * 选中态直接打字:进入编辑,**清空原值、只留这个字符**(规格 §二,Excel 的覆盖语义)。
+ */
+ test('选中态直接打字:进入编辑并只保留新字符(覆盖原值)', async ({ page }) => {
+ await page.goto('/')
+ const wrap = page.locator('[data-testid="table-keys-wrap"]')
+ await wrap.scrollIntoViewIfNeeded()
+ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
+
+ await cell.click()
+ await page.keyboard.press('X')
+
+ const input = cell.locator('input')
+ await expect(input).toBeFocused()
+ // 原值 'K-001' 被覆盖,只剩用户敲的那个字符
+ await expect(input).toHaveValue('X')
+ })
+
+ test('上下键(编辑态):切到上 / 下一行的同一列,并继续编辑', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const rows = wrap.locator('.stk-tbody-main tr')
const first = rows.nth(0).locator('td[data-col-key="code"]')
- await first.click()
+ await enterEdit(first)
await page.keyboard.press('ArrowDown')
await expect(rows.nth(1).locator('td[data-col-key="code"] input')).toBeFocused()
@@ -1362,13 +1488,13 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航',
await expect(first.locator('input')).toBeFocused()
})
- test('左右键:在输入框内移动光标,不切格', async ({ page }) => {
+ test('左右键(编辑态,光标在中间):在输入框内移动光标,不切格', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="qty"]')
- await cell.click()
+ await enterEdit(cell)
const input = cell.locator('input')
await input.fill('12345')
@@ -1379,17 +1505,44 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航',
expect(after).toBe((before ?? 0) - 1)
await expect(input).toBeFocused()
- // 还在同一格:左右键没有冒到表格导航上
+ // 还在同一格:光标没到边界,左右键没有移交给表格
await expect(cell.locator('input')).toHaveCount(1)
})
+ /**
+ * 左右键的**边界放行**(规格 §三 核心原则):光标已贴在最右时按 `→`,
+ * 输入框再也动不了 → 这一记按键交给表格,切到右边那一格。
+ *
+ * 这是与旧行为(左右键一律归输入框、永不移交)最关键的一处差别:
+ * 少了它,编辑态里就没法用 `→` 逐格往右走。
+ */
+ test('左右键(编辑态,光标在边界):放行给表格,切到相邻格', async ({ page }) => {
+ await page.goto('/')
+ const wrap = page.locator('[data-testid="table-keys-wrap"]')
+ await wrap.scrollIntoViewIfNeeded()
+ const rows = wrap.locator('.stk-tbody-main tr')
+ const codeCell = rows.nth(0).locator('td[data-col-key="code"]')
+
+ await enterEdit(codeCell)
+ const input = codeCell.locator('input')
+ // 把光标放到末尾('K-001' → 位置 5)
+ await input.evaluate((el) => {
+ const node = el as HTMLInputElement
+ node.setSelectionRange(node.value.length, node.value.length)
+ })
+
+ // 末尾再按 → :交给表格,切到右列「数量」
+ await input.press('ArrowRight')
+ await expect(rows.nth(0).locator('td[data-col-key="qty"] input')).toBeFocused()
+ })
+
test('Shift+左右:留给输入框选中文本,不切格', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
- await cell.click()
+ await enterEdit(cell)
const input = cell.locator('input')
// 进入编辑时编辑器会**全选**内容(为「直接输入即替换」服务),所以先记下初始选区长度
const before = await input.evaluate((el) => {
@@ -1414,7 +1567,7 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航',
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="note"]')
- await cell.click()
+ await enterEdit(cell)
const textarea = cell.locator('textarea')
await textarea.fill('第一行')
await textarea.press('Enter')
@@ -1436,7 +1589,7 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航',
* · Enter(多行)→ 换行(浏览器原生;多行与单行共用一段 onKeydown,早先没按类型分流,
* 按回车会把整段文本全选掉、根本换不了行)。
*
- * 方向键的分工(左右归输入框、上下给导航)在另一个 describe 里;宿主相同 ——
+ * 方向键的分工(边界放行、上下给导航)在另一个 describe 里;宿主相同 ——
* `table-wrap` 的列序被 Tab 跳格用例逐格锁着,而且它里面没有多行编辑器,
* 锁不住「单行 / 多行 Enter 语义不同」这条区分。
*/
@@ -1448,7 +1601,7 @@ test.describe('G3Table 键盘行为:Enter 与多行编辑器', () => {
const rows = wrap.locator('.stk-tbody-main tr')
const firstCell = rows.nth(0).locator('td[data-col-key="code"]')
- await firstCell.click()
+ await enterEdit(firstCell)
await firstCell.locator('input').fill('K-999')
await firstCell.locator('input').press('Enter')
await page.waitForTimeout(300)
@@ -1467,7 +1620,7 @@ test.describe('G3Table 键盘行为:Enter 与多行编辑器', () => {
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="note"]')
- await cell.click()
+ await enterEdit(cell)
const textarea = cell.locator('textarea')
await textarea.fill('第一行')
await textarea.press('Enter')
@@ -1491,13 +1644,13 @@ test.describe('G3Table 日期编辑器(editor.type = date)', () => {
await expect(cell.locator('.g3-table__cell-display')).toHaveText('2026-03-15')
/**
- * ① 点格 → 自动聚焦 **且自动弹开日历面板**。
+ * ① 双击进编辑 → 自动聚焦 **且自动弹开日历面板**。
*
- * 这是 `type: 'date'` 的核心交互:点格的那次点击落在 `td` 上,触发不到 DatePicker
+ * 这是 `type: 'date'` 的核心交互:进编辑的点击落在 `td` 上,触发不到 DatePicker
* 自己的「点输入框才展开」,所以要由挂载钩子主动调它的 `openPanel()`
* (见 `utils/editable.ts` 的 openDatePanel)。手感与 ERP 里的日期格一致。
*/
- await cell.click()
+ await enterEdit(cell)
/**
* 焦点**落在面板里**(CalendarPanel 的日期格是 `<button>` 且带 tabindex),
* 这是日历组件的常规行为:一打开就能用键盘直接选日期。
@@ -1518,7 +1671,7 @@ test.describe('G3Table 日期编辑器(editor.type = date)', () => {
await expect(panel).toBeHidden()
await expect(cell.locator('input')).toHaveValue('2026-03-20')
- // 切到别的格退出编辑 → 展示态显示的是刚写回行数据的字符串(证明值真的落了库)
+ // 单击别的格退出编辑 → 展示态显示的是刚写回行数据的字符串(证明值真的落了库)
await wrap.locator('.stk-tbody-main tr').nth(1).locator('td[data-col-key="etd"]').click()
await expect(cell.locator('.g3-table__cell-display')).toHaveText('2026-03-20')
})
@@ -1537,11 +1690,11 @@ test.describe('G3Table 日期编辑器(editor.type = date)', () => {
await empty.hover()
await expect(page.locator('.g3-table__cell-tip')).toHaveText('请选择开航日')
- // 手输一个合法日期,再点**另一行的日期格**让它失焦提交(DatePicker 只在「确定的时刻」emit)
- await empty.click()
+ // 手输一个合法日期,再点**另一行的日期格**让它提交(DatePicker 只在「确定的时刻」emit)
+ await enterEdit(empty)
await empty.locator('input').fill('2026-05-01')
/**
- * ⚠️ 这里必须点**可编辑格**。点只读列(订舱号)不会让编辑器失焦 ——
+ * ⚠️ 这里必须点**可编辑格**。点只读列(订舱号)不会退出编辑 ——
* 库在 mousedown 阶段就把只读格的事件吞了,input 一直持有焦点,
* 表现是「输了值但没落库」(实测踩到)。
*/
@@ -1568,7 +1721,7 @@ test.describe('G3Table 日期编辑器(editor.type = date)', () => {
const rows = wrap.locator('.stk-tbody-main tr')
const cell = rows.first().locator('td[data-col-key="etd"]')
- await cell.click()
+ await enterEdit(cell)
await cell.locator('input').fill('2026-13-45')
await rows.nth(2).locator('td[data-col-key="etd"]').click()
await page.waitForTimeout(300)
diff --git a/code/g3soft-libs/packages/ui/src/date/DatePicker.vue b/code/g3soft-libs/packages/ui/src/date/DatePicker.vue
index 8816072b..723a5add 100644
--- a/code/g3soft-libs/packages/ui/src/date/DatePicker.vue
+++ b/code/g3soft-libs/packages/ui/src/date/DatePicker.vue
@@ -165,8 +165,23 @@ function stepSegment(delta: number, event: KeyboardEvent) {
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
+ /**
+ * 关面板时**认领这一记 Esc**(`preventDefault`),让祖先知道它已被消费。
+ *
+ * 用途:表格的行内编辑用「`defaultPrevented` 为真 ⇒ 这一记 Esc 归浮层」来判断
+ * 该关浮层还是该取消整格编辑,从而形成两段式(第一记关面板、第二记取消编辑)。
+ * 没有这个标记,一次 Esc 会同时被面板和表格处理掉,两段式塌成一段。
+ *
+ * ⚠️ 只在**真的关掉了一个打开的面板**时才认领:面板本来就没开时 Esc 不属于这里,
+ * 否则表格侧永远收不到"取消编辑"的那一记(光标在日期格里按 Esc 会毫无反应)。
+ *
+ * 对本组件自身无副作用:Escape 没有需要保留的浏览器默认行为
+ * (退出全屏 / 中止加载都与输入框无关),Popover 那条 document 监听也不看这个标记。
+ */
+ const wasOpen = open.value
editingText.value = null
open.value = false
+ if (wasOpen) event.preventDefault()
return
}
if (event.key === 'Enter') {
diff --git a/code/g3soft-libs/packages/ui/src/table/G3Table.vue b/code/g3soft-libs/packages/ui/src/table/G3Table.vue
index 165db8dd..5f30d267 100644
--- a/code/g3soft-libs/packages/ui/src/table/G3Table.vue
+++ b/code/g3soft-libs/packages/ui/src/table/G3Table.vue
@@ -68,8 +68,14 @@ import {
resolveSeqConfig,
stripInternalColumns,
} from './utils/column'
-import { findAdjacentEditableCell, findCellBelow } from './utils/editable'
-import type { EditableContext } from './utils/editable'
+import {
+ commitPendingDraft,
+ findAdjacentCell,
+ findAdjacentEditableCell,
+ isEditorDisabled,
+ setInputDraft,
+} from './utils/editable'
+import type { EditCaret, EditableContext } from './utils/editable'
import type {
G3TableColumn,
TableRow,
@@ -219,7 +225,29 @@ const props = withDefaults(
const emit = defineEmits<{
(e: 'update:columns', columns: G3TableColumn<TableRow>[]): void
(e: 'update:selectedRowKeys', keys: Array<string | number>): void
+ /**
+ * **值实际发生变化**(提交后新旧值不同)。
+ *
+ * 去重由 `shouldNotify` 按「进入编辑前的快照」完成 —— 所以「点进去又点出来」
+ * 不会产生无意义的通知。
+ *
+ * ⚠️ `value-change` 是规格里的正式名字,`change` 是既有名字,**两个都抛**:
+ * 改名会让所有既有调用方的 `@change` 静默失效(Vue 不会为未声明的监听报错),
+ * 属于最危险的一类破坏性变更。等业务侧迁移完再考虑移除旧名。
+ */
(e: 'change', row: TableRow, col: G3TableColumn<TableRow>, value: unknown): void
+ (e: 'value-change', row: TableRow, col: G3TableColumn<TableRow>, value: unknown): void
+ /**
+ * 进入编辑态**之后**触发:编辑器已挂载、已聚焦,`caret` 也已按触发源摆好。
+ *
+ * 「之后」是刻意的:业务在这个回调里读 DOM(拿焦点、量尺寸)才有意义。
+ * 重渲染引起的重复聚焦**不算**一次新的 edit-start。
+ */
+ (e: 'edit-start', row: TableRow, col: G3TableColumn<TableRow>): void
+ /** 编辑完成、**值已提交**,随后离开这一格(Enter / Tab / 方向键 / 失焦)。 */
+ (e: 'edit-end', row: TableRow, col: G3TableColumn<TableRow>): void
+ /** 取消编辑(Esc):值**已还原**为进入编辑前的样子,已回到选中态。 */
+ (e: 'edit-cancel', row: TableRow, col: G3TableColumn<TableRow>): void
(e: 'row-order-change', rows: TableRow[], sourceIndex: number, targetIndex: number): void
(e: 'area-selection-change', ranges: unknown[]): void
/**
@@ -433,7 +461,7 @@ let resizeObserver: ResizeObserver | null = null
/**
* 单行的编辑状态。
*
- * `focused` 在 `activateEditing` 里刻意不重置(fms 同此):它记录的是「重渲染前
+ * `focused` 在激活编辑时刻意不重置(fms 同此):它记录的是「重渲染前
* 编辑器是否持有焦点」,供编辑器焦点恢复判定用,不是 UI 状态。
*/
interface RowEditingState {
@@ -442,6 +470,46 @@ interface RowEditingState {
focused: boolean
/** 编辑开始时的各可编辑列取值快照,供 shouldNotify 去重 */
initialValues: Map<string, unknown> | null
+ /**
+ * **当前编辑格**在「进入这一格时」的值 —— 只读,永不改写,`Esc` 取消用它还原。
+ *
+ * 只记一格而不是整行 / 整张 Map:`revertCell` 只会被**当前编辑格**调用
+ * (那时编辑器的 owner 必然是 focusCol 那一格),多记没有消费者。
+ *
+ * 为什么不能复用 `initialValues`:那一份的职责是**去重**("上次通知父级的值"),
+ * `shouldNotify` 每次真正变化都会把它 `set` 成新值 —— 快照被一路推进。
+ * · 对 `input` 无碍:它在提交前不通知,快照一直停在进入值;
+ * · 对 `select` / `date` 是致命的:它们**立即写回 + 立即抛 change**,
+ * 快照当场被推到新值,"回滚"于是回滚到**刚改的值**,等于没回滚。
+ * 两者本就是不同概念("进入时的值" vs "上次通知的值"),分开存才各自成立。
+ */
+ entry: { dataIndex: string; value: unknown } | null
+ /**
+ * 本次激活的**插入符意图**(`'all'` / `'end'` / 下标 / null),由触发源决定,
+ * 编辑器挂载时消费一次后清空。
+ *
+ * 放在行状态里而不是模块级变量:它与「哪一行、哪一列」是同一件事的三半 ——
+ * 分开存会出现「A 格设了 'end',B 格挂载时读走了它」这种串台。
+ */
+ caret: EditCaret | null
+ /**
+ * 本次编辑会话是否**已经报过结束**(`edit-end` 或 `edit-cancel`)。
+ *
+ * 为什么需要这个去重:一次"离开这一格"会触发**多条**路径 ——
+ * 显式提交(Enter / Tab / 方向键)之后,被卸载的输入框往往还会再补一次 `blur`;
+ * 而 `blur` 那条路也认为"编辑结束了"。不去重的话业务侧会收到两次 `edit-end`,
+ * 按它做记账 / 埋点就会翻倍。
+ */
+ editEndReported: boolean
+ /**
+ * 本次编辑会话是否**已经报过开始**(`edit-start`)。
+ *
+ * 为什么需要这个去重:编辑器侧判定"该抛 edit-start"用的是 `pending`
+ * (= 用户刚激活这一格)。而**编辑态下按 F2 / 双击已在编辑的格子**也会重新置
+ * `pendingFocusKey`(否则插入符意图送不进编辑器),那条路径同样满足 `pending` ——
+ * 但会话早就开始了,再抛一次会让业务侧的"开始埋点 / 加锁"重复执行。
+ */
+ editStartReported: boolean
}
/**
@@ -453,6 +521,19 @@ let activeEditingRow: TableRow | null = null
let pendingEditingTarget: { row: TableRow; focusCol: string | null } | null = null
const pressedNavigationKeys = new Set<string>()
+/**
+ * 「这一次选区变化要**顺便进入编辑**吗」—— 由发起方置位、`handleAreaSelectionChange` 消费。
+ *
+ * 为什么必须显式传这个意图,而不能从别的状态推出来:
+ * 选区变化有**两个来源**,它们的后续动作不同(规格 §二 / §三):
+ * · **编辑态**里按 Enter / Tab / 方向键 → 移到目标格**并继续编辑**(连续录入的主路径);
+ * · **选中态**里按方向键 / 单击 → 只移动选中框,**不进编辑**。
+ * 两者最终都走底座的 `setAreaSelection`,事件长得一模一样 ——
+ * 若不显式区分,就只能靠"当前是否在编辑态"猜,而 Enter 那条路径正是在
+ * 编辑态里发起的、Tab 那条可能跨行把编辑态清掉,猜必然出错。
+ */
+let selectionChangeEntersEdit = false
+
/**
* 是否存在活动编辑行。
*
@@ -467,16 +548,32 @@ function getRowKey(row: TableRow): string | number {
return row?.[key] as string | number
}
+/**
+ * 「未在编辑」的行状态。
+ *
+ * 抽成工厂而不是到处写字面量:这个对象有 6 个字段,而"回到空闲态"要写在
+ * 4 处(deactivateRowEditing / clearActiveEditing / rebind / 初始化)。
+ * 手写会漏字段 —— 漏 `caret` 会让下一格莫名继承上一格的光标位置,
+ * 漏 `editEndReported` 会让第二次编辑收不到 `edit-end`。都属"看起来能用"的静默故障。
+ */
+function idleEditingState(): RowEditingState {
+ return {
+ editing: false,
+ focusCol: null,
+ focused: false,
+ initialValues: null,
+ entry: null,
+ caret: null,
+ editEndReported: false,
+ editStartReported: false,
+ }
+}
+
function getRowEditingState(row: TableRow): ShallowRef<RowEditingState> | null {
if (!row || typeof row !== 'object') return null
let state = rowEditingStates.get(row)
if (!state) {
- state = shallowRef<RowEditingState>({
- editing: false,
- focusCol: null,
- focused: false,
- initialValues: null,
- })
+ state = shallowRef<RowEditingState>(idleEditingState())
rowEditingStates.set(row, state)
}
return state
@@ -498,7 +595,7 @@ function captureEditableValues(row: TableRow): Map<string, unknown> {
function deactivateRowEditing(row: TableRow) {
const state = rowEditingStates.get(row)
if (state && state.value.editing) {
- state.value = { editing: false, focusCol: null, focused: false, initialValues: null }
+ state.value = idleEditingState()
}
}
@@ -510,7 +607,7 @@ function clearActiveEditing() {
// 仅在确实处于编辑态时写 ref:长按方向键滚动期间每键都会调用本函数,
// 跳过无变化的写入可避免活动行单元格每键重复渲染。
if (state && state.value.editing) {
- state.value = { editing: false, focusCol: null, focused: false, initialValues: null }
+ state.value = idleEditingState()
}
}
@@ -543,6 +640,20 @@ function rebindActiveEditingRow(rows: TableRow[]) {
focusCol: previousState.focusCol,
focused: previousState.focused,
initialValues: captureEditableValues(replacement),
+ /**
+ * `entry` **原样保留**,不重新采集。
+ *
+ * 与 `initialValues` 刻意不同:那一份是"上次通知的值",行对象换新后要从新对象
+ * 重新采(否则去重会拿旧对象的值跟新对象比,判据失真);
+ * 而这一份是**"用户进入这一格时的值"**,语义上就该固定不变 ——
+ * 重新采集会把父级回灌进来的**新值**当成"原值",Esc 就再也回不去了。
+ */
+ entry: previousState.entry,
+ // 行对象换新时插入符意图已经消费过了,不迁移(否则新行挂载会被再摆一次光标)
+ caret: null,
+ // 会话本身没结束,两个"已报过"标记都跟着会话走
+ editEndReported: previousState.editEndReported,
+ editStartReported: previousState.editStartReported,
}
}
}
@@ -581,13 +692,40 @@ function warnIfDataSourceNotReactive(row: TableRow) {
)
}
-function activateEditing(row: TableRow, focusCol: string | null) {
+/**
+ * **进入编辑态的唯一入口**(规格 §六)。所有触发源都调它:
+ * 双击、F2、Enter、打字覆盖、Tab / 方向键跳格、程序化 `editRow`。
+ *
+ * 参数 `caret` 是**触发源的意图**,不是组件自己猜的:
+ * · `'all'` —— 全选(Enter / 打字覆盖 / 双击落在空白处);
+ * · `'end'` —— 光标到末尾(F2);
+ * · 数字 —— 落到指定下标(双击落在文字中间);
+ * · `null` —— 不干预(select / date 这类非文本框编辑器,或程序化调用)。
+ *
+ * ── 为什么"退出上一个编辑"这一步**不在这里提交** ──
+ * 规格 §六 的伪代码写的是「退出之前的编辑(自动提交)」。这里刻意不这么做,
+ * 因为提交的正确时机**不是"进入新格时"**,而是"离开旧格时",两者在
+ * **同行换列**与**换行换列**两种情况下不同:
+ * · 换行:必须**先提交再激活**(旧编辑器会被卸载,浏览器不派发 blur);
+ * · 同行换列:新编辑器**接管焦点**时旧 input 自然失焦,`onBlur` 会提交;
+ * 此时若在这里抢着提交,会与随后的 blur 提交**重复**一次(应当通知两次)。
+ * 所以提交统一交给 `commitEditingCell`(离开路径)与编辑器的 `onBlur`(焦点路径),
+ * 本函数只负责状态与插入符 —— 这也让 `startEdit` 保持纯状态操作、可被安全地重复调用。
+ */
+function startEdit(row: TableRow, focusCol: string | null, caret: EditCaret | null = null) {
if (!props.editable || !row) return
warnIfDataSourceNotReactive(row)
const state = getRowEditingState(row)
if (!state) return
if (activeEditingRow !== row) {
+ /**
+ * 换行:旧行的编辑会话就地结束。
+ *
+ * ⚠️ 这里**不**走 `emit('edit-end')` —— 那个事件由「离开路径」统一抛
+ * (见 commitEditingCell)。否则同一次换行会抛两次 edit-end
+ * (一次在这里、一次在真正提交处),业务侧记账会对不上。
+ */
if (activeEditingRow) deactivateRowEditing(activeEditingRow)
activeEditingRow = row
editingActive.value = true
@@ -596,18 +734,62 @@ function activateEditing(row: TableRow, focusCol: string | null) {
focusCol,
focused: false,
initialValues: captureEditableValues(row),
+ // 进入这一格时的值:Esc 取消要还原到它(只读格 / 无编辑器时为 null)
+ entry: captureCellEntry(row, focusCol),
+ caret,
+ // 新会话:两个"已报过"标记都归零
+ editEndReported: false,
+ editStartReported: false,
}
pendingFocusKey = String(getRowKey(row))
return
}
const current = state.value
- if (current.editing && current.focusCol === focusCol) return
- state.value = { ...current, editing: true, focusCol }
- // 同行切换列:编辑器会因 focusCol 变化重挂载,标记待聚焦以恢复焦点+全选
+ if (current.editing && current.focusCol === focusCol) {
+ /**
+ * 已经是这一格在编辑:**只更新插入符意图**,不重挂编辑器。
+ *
+ * 这条分支接住的是"编辑态下按 F2"(光标移到末尾)与"双击已在编辑的格子"。
+ * 不能提前 return:那会让 F2 毫无反应(当前实现就是这样,规格要求它生效)。
+ * 插入符的落点由编辑器的 `onVnodeUpdated` 消费(见 editable.ts 的 focus 处理)。
+ *
+ * ⚠️ 这里**不重新采集 `entry`**:用户已经改过值了,重采会把"改后的值"当成原值,
+ * Esc 就回不到真正进入时的值。这正是"同一格只采一次"的意义。
+ */
+ if (caret !== null) {
+ state.value = { ...current, caret }
+ pendingFocusKey = String(getRowKey(row))
+ }
+ return
+ }
+
+ state.value = {
+ ...current,
+ editing: true,
+ focusCol,
+ // 换到**另一格**了:原值快照必须重新采(这是新一格的"进入值")
+ entry: captureCellEntry(row, focusCol),
+ caret,
+ }
+ // 同行切换列:编辑器会因 focusCol 变化重挂载,标记待聚焦以恢复焦点+插入符
pendingFocusKey = String(getRowKey(row))
}
+/**
+ * 采集「进入这一格时的值」,供 `Esc` 取消还原。
+ *
+ * 返回 `null` 表示没有可还原的目标(`focusCol` 为空 = 落在只读格 / 点空白处),
+ * 此时 `revertCell` 直接跳过 —— 没有编辑器就没有"取消编辑"可言。
+ */
+function captureCellEntry(
+ row: TableRow,
+ focusCol: string | null,
+): { dataIndex: string; value: unknown } | null {
+ if (!focusCol) return null
+ return { dataIndex: focusCol, value: row[focusCol] }
+}
+
/**
* 值比较:select / checkbox 用 Object.is,其余按字符串比(Input 落库的是 string)。
* 用于 shouldNotify 去重——值没真变就不 emit,否则「点进去又点出来」也会触发 change,
@@ -620,6 +802,61 @@ function editorValuesEqual(col: G3TableColumn<TableRow>, previous: unknown, curr
return String(previous ?? '') === String(current ?? '')
}
+/** 按 dataIndex 找列定义(内部列不影响:编辑格一定是业务列) */
+function findColumn(dataIndex: string): G3TableColumn<TableRow> | undefined {
+ return displayColumns.value.find((col) => col.dataIndex === dataIndex)
+}
+
+/**
+ * **离开这一格时的提交**(规格 §三:Enter / Tab / 方向键都是"提交并移走")。
+ *
+ * 取「尚未落库的草稿」提交,而不是读 DOM input 的 value:本函数也会在
+ * **编辑器已经卸载之后**被调用(跨行跳格),那时 DOM 节点已经没了。
+ * 草稿是唯一在两段时间里都可靠的来源(见 editable.ts 的 commitPendingDraft)。
+ *
+ * ⚠️ 只提交**当前编辑列**这一格。同行的其它列各自有自己的编辑器与失焦时机,
+ * 在这里一起提交会打破"每格自己负责落库"的分工(也拿不到它们的 DOM 值)。
+ *
+ * 返回是否真的提交了东西(没草稿时返回 false —— 例如 select 已经是即时写回的)。
+ */
+function commitEditingCell(
+ row: TableRow | null,
+ col: G3TableColumn<TableRow> | null | undefined,
+): boolean {
+ if (!row || !col) return false
+ const { committed, value } = commitPendingDraft(row, col.dataIndex)
+ if (!committed) return false
+ /**
+ * 通知仍然走 `ctx.shouldNotify` 的去重:用户在格子里打了字又删回去(值没变),
+ * 不该抛 change/value-change —— 与失焦提交是同一条判据。
+ */
+ if (editingCtx.shouldNotify(row, col, value)) {
+ col.editor?.onChange?.(value, row, col)
+ emit('change', row, col, value)
+ emit('value-change', row, col, value)
+ }
+ return true
+}
+
+/**
+ * 抛出一次 `edit-end`(同一会话只抛一次)。
+ *
+ * 两个调用点:主动移动前的 `commitEditingCell`、以及编辑器失焦。
+ * 一次"离开"通常**两条都会走到**(显式提交后,被卸载的输入框往往再补一次 blur),
+ * 去重靠 `editEndReported`。
+ */
+function reportEditEnd(row: TableRow, col?: G3TableColumn<TableRow> | null | undefined) {
+ const state = getRowEditingState(row)
+ if (!state) return
+ const current = state.value
+ // 已经报过(或压根不在编辑态)就不再抛
+ if (current.editEndReported || !current.editing) return
+
+ const targetCol = col ?? (current.focusCol ? findColumn(current.focusCol) : null)
+ state.value = { ...current, editEndReported: true }
+ if (targetCol) emit('edit-end', row, targetCol)
+}
+
/**
* 最近激活、等待编辑器挂载后聚焦的行 key。用于区分「用户激活单元格(点击/键盘/程序化)」
* 与「数据变更引发的重挂载」:前者需要自动聚焦+全选,后者不应聚焦——否则失焦引发的
@@ -660,17 +897,44 @@ const editingCtx: EditableContext<TableRow> = {
return true
},
/**
- * Esc:退出编辑态回到就绪态。
+ * 消费一次插入符意图。
+ *
+ * 与 `consumePendingFocus` **成对消费**(后者返回 true 时前者才有意义),
+ * 所以这里不再判 `pendingFocusKey`:编辑器已经把"这是用户刚激活的那一次"问过了。
+ * 取走即清空 —— 留着会让下一次重渲染(`onVnodeUpdated` 也会走焦点处理)
+ * 把光标又摆一次,表现为"打字打一半光标突然跳回末尾"。
+ */
+ consumePendingCaret: (row, col) => {
+ const state = getRowEditingState(row)
+ if (!state?.value.caret) return null
+ // 列要对上:同行换列时旧格的编辑器可能还在卸载中,别把新意图喂给它
+ if (state.value.focusCol !== col.dataIndex) return null
+ const caret = state.value.caret
+ state.value = { ...state.value, caret: null }
+ return caret
+ },
+ /**
+ * Esc:退出编辑态回到**选中态**。
*
- * 顺带把焦点**还给单元格**:Esc 之后用户往往还想用方向键接着走(Excel 同此)。
- * 不还的话焦点会掉到 `body` —— 下一次按键压根不经过表格,表现就是「Esc 之后
- * 方向键失灵、连打字也进不了编辑」。用 `td[data-cs-s]`(库标在选中格上的属性)
- * 找回那一格,它带 `tabindex="-1"`,可以编程聚焦。
+ * 顺带把焦点交还给表格容器:Esc 之后用户往往还想用方向键接着走(Excel 同此)。
+ * 不还的话焦点会掉到 `body` —— 下一次按键压根不经过表格,表现就是
+ * 「Esc 之后方向键失灵、连打字也进不了编辑」。
+ *
+ * ⚠️ 焦点要交给**内层容器**(`.stk-table`,库给了它 `tabindex="0"`),
+ * **不能交给格子**。这里原先写的是 `td[data-cs-s]?.focus()`,注释还声称
+ * "它带 tabindex=-1,可以编程聚焦" —— 那是**错的**:库渲染表体 `td` 时
+ * 给的属性只有 `data-col-key` / `style` / `class`(见 StkTable.js 的 getTDProps),
+ * 唯一带 `tabindex="-1"` 的是它**内层**的 `.table-cell-wrapper`。
+ * 对没有 tabindex 的 `td` 调 `focus()` 是**静默无效**的,于是焦点一直留在 body ——
+ * Esc 之后的方向键 / 打字全部失效(这也正是"选中态键位"此前形同虚设的原因之一)。
+ *
+ * 交给容器还有个好处:底座的键盘导航监听器就挂在容器上,焦点在容器里,
+ * 选中态的方向键能被底座正常接管。
*/
exitEditing: () => {
clearActiveEditing()
nextTick(() => {
- rootRef.value?.querySelector<HTMLElement>('td[data-cs-s]')?.focus?.({ preventScroll: true })
+ rootRef.value?.querySelector<HTMLElement>(`.${e('inner')}`)?.focus?.({ preventScroll: true })
})
},
onEditorFocus: (row) => {
@@ -680,6 +944,38 @@ const editingCtx: EditableContext<TableRow> = {
onEditorBlur: (row) => {
const state = getRowEditingState(row)
if (state?.value) state.value = { ...state.value, focused: false }
+ /**
+ * 失焦 = 这一格的编辑结束(规格 §三:走到别处、点别处都算提交)。
+ *
+ * 在这里补 `edit-end`:**点页面别处 / 用上下键走开**这类"离开"没有经过
+ * `commitEditingCell`(那是我们主动发起的移动才走的路),blur 是唯一的信号。
+ * 去重交给 `editEndReported`,重复触发不会抛第二次。
+ */
+ reportEditEnd(row)
+ },
+ /**
+ * `edit-start`:编辑器**已挂载、已聚焦、插入符已就位**之后才抛。
+ *
+ * 由 editable.ts 在焦点处理里调用,且只在"用户刚激活这一格"那一次 ——
+ * 重渲染引起的重复聚焦不抛,否则业务侧按它做埋点会收到一串重复事件。
+ *
+ * ⚠️ 这里再兜一道「同一会话只抛一次」:编辑态下按 F2 / 双击已在编辑的格子会
+ * 重新置 `pendingFocusKey`(为了把插入符意图送进编辑器),那条路径在编辑器侧
+ * 同样满足 `pending` —— 但会话早就开始了,再抛一次会让调用方的
+ * "开始埋点 / 加锁"重复执行。
+ */
+ onEditStart: (row, col) => {
+ const state = getRowEditingState(row)
+ if (!state) return
+ if (state.value.editStartReported) return
+ state.value = { ...state.value, editStartReported: true }
+ emit('edit-start', row, col)
+ },
+ /** `edit-cancel`:Esc,值已还原、已回到选中态。 */
+ onEditCancel: (row, col) => {
+ const state = getRowEditingState(row)
+ if (state?.value) state.value = { ...state.value, editEndReported: true }
+ emit('edit-cancel', row, col)
},
isSeamless: () => props.editorSeamless,
shouldMarkReadonly,
@@ -692,6 +988,8 @@ const editingCtx: EditableContext<TableRow> = {
},
onChange: (row, col, value) => {
emit('change', row, col, value)
+ // 规格里的正式名字。两个都抛的理由见 defineEmits 里 value-change 的注释。
+ emit('value-change', row, col, value)
},
/**
* checkbox 被操作后,把这一格同步成**当前格** —— 与点击其它单元格行为一致。
@@ -702,7 +1000,7 @@ const editingCtx: EditableContext<TableRow> = {
* 但整行不高亮、格子没有选中框;且表格里**没有任何选区** —— 接着按 Tab / 方向键会从
* (0,0) 起步、焦点掉到 `body`,键盘用户从复选框出发就走不动了。
*
- * 实现刻意**走选区**而不是直接 activateEditing:鼠标点击(handleAreaSelectionChange)
+ * 实现刻意**走选区**而不是直接 startEdit:鼠标点击(handleAreaSelectionChange)
* 与键盘跳格已经是同一条链路,这里接进同一条,避免出现第三套行为。
* `scrollToView: false`:复选框本来就在眼前,不该动滚动位置。
*/
@@ -720,16 +1018,20 @@ const editingCtx: EditableContext<TableRow> = {
)
},
/**
- * Enter 确认后往下走一格 —— 移到**下一行的同一列**(跳过不可编辑的行)。
+ * 编辑态的四向移动(方向键 / Enter 共用)。
+ *
+ * 与 Tab 跳格同一种做法:**改选区**而不是直接 startEdit,让底座的
+ * `area-selection-change` 链路去负责「滚动进视口、同步当前行」。
+ * 鼠标双击 / Tab / Enter / 方向键四条路径因此共用同一条移动链路,不会各写一套而行为分叉。
*
- * 与 Tab 跳格同一种做法:**改选区**而不是直接 activateEditing,让底座的
- * `area-selection-change` 链路去负责激活编辑、定位并聚焦编辑器、滚动进视口。
- * 鼠标点击 / Tab / Enter 三条路径因此共用同一条链路,不会各写一套而行为分叉。
+ * ⚠️ 与"只选中"的区别靠 `selectionChangeEntersEdit` 显式传达:
+ * 编辑态下移动要**继续编辑**(连续录入的主路径),选中态下移动只挪选中框。
+ * 两者最终都落到底座的同一个 API、抛同一个事件,不显式区分就无从判定。
*
- * `scrollToView` 保持默认(要滚):回车是「往下一行走」,目标可能已经在视口外,
- * 这与复选框那条「本来就在眼前」的场景不同。
+ * `scrollToView` 保持默认(要滚):目标可能已经在视口外 —— 与复选框那条
+ * 「本来就在眼前」的场景不同。
*/
- moveToCellBelow: (row, col) => {
+ moveToAdjacent: (row, col, direction) => {
if (!props.editable) return false
const rows = (tableRef.value?.getTableData?.() ?? props.dataSource) as TableRow[]
const rowIndex = rows.findIndex(
@@ -738,31 +1040,47 @@ const editingCtx: EditableContext<TableRow> = {
const colIndex = displayColumns.value.findIndex((item) => item.dataIndex === col.dataIndex)
if (rowIndex < 0 || colIndex < 0) return false
- const target = findCellBelow(rows, displayColumns.value, { rowIndex, colIndex })
- // 表尾 / 下面全是不可编辑行:返回 false,编辑器原地不动(不循环回表头)
+ const target = findAdjacentCell(rows, displayColumns.value, { rowIndex, colIndex }, direction)
+ /**
+ * 边界(最后一列 / 表尾 / 后面全是不可编辑格):返回 false,**编辑器原地不动**。
+ *
+ * ⚠️ 必须在提交**之前**返回:原地不动意味着这一格的编辑**没有结束**,
+ * 若先提交、先抛 edit-end,就会出现"按了一下没反应的键,编辑却被判定结束了"
+ * —— 之后继续改这一格、再离开时不会再抛 edit-end(`editEndReported` 已经置位)。
+ */
if (!target) return false
/**
- * 先改选区(让库把「滚动进视口、当前行、命中测试」都处理好),**再显式进编辑**。
+ * **先提交再移动** —— 这是"跨行移动不抛 change / 不抛 edit-end"那个 bug 的修复点。
+ *
+ * 移动会改选区 → 当前编辑行的编辑器 vnode 被卸载,而 DOM 元素被移除时
+ * 浏览器**不派发 blur**,于是 `onBlur` 那条提交路径整个不会执行:
+ * 值只剩 `preserveInputDraft` 的兜底(写 raw、不响应式、**不抛事件**)。
+ *
+ * 显式提交一次即可闭合,且对同行移动同样安全:那一刻输入框还没失焦,
+ * `commitPendingDraft` 取走草稿后 `onBlur` 会因草稿已清而不再重复提交
+ * (草稿是唯一判据,见 editable.ts)。
+ */
+ commitEditingCell(row, col)
+ reportEditEnd(row, col)
+
+ /**
+ * 标记"这次选区变化要顺便进入编辑",然后**只改选区**。
*
- * 为什么不能只靠选区那一步:就绪态模型下 `handleAreaSelectionChange` 对鼠标 / 方向键
- * 只负责「选中」,不再激活编辑。而 Enter 的语义是「确认,并去下一行**继续改**」——
- * 它是唯一一个「移动 + 进编辑」的键,所以这一步得自己补上
- * (方向键是纯移动,Tab 由它自己的链路处理)。
+ * 激活编辑交给 `handleAreaSelectionChange` 统一处理(它要按
+ * `pressedNavigationKeys` 决定"立即激活"还是"长按期间先不挂编辑器")——
+ * 这里再调一次 `startEdit` 会与那条防抖逻辑打架,出现"同一格被激活两次"。
*/
- tableRef.value?.setAreaSelection?.({ begin: { row: target.rowIndex, col: target.colIndex } })
- const nextRow = rows[target.rowIndex]
- const nextCol = displayColumns.value[target.colIndex]
- if (nextRow && nextCol?.editor) {
- activateEditing(nextRow, nextCol.dataIndex)
- tableRef.value?.setCurrentRow?.(nextRow, { silent: true })
- }
+ selectionChangeEntersEdit = true
+ tableRef.value?.setAreaSelection?.({
+ begin: { row: target.rowIndex, col: target.colIndex },
+ })
return true
},
/**
* Esc 回滚:把该行所有可编辑列恢复到进入编辑态时的值。
*
- * 快照就是 initialValues(activateEditing 时用 captureEditableValues 存的),
+ * 快照就是 initialValues(startEdit 时用 captureEditableValues 存的),
* 与 shouldNotify 去重共用同一份 Map,所以这里**只回滚数据、不改快照**:
* 快照代表的就是「编辑前的原值」,回滚到原值后二者自然一致,
* 之后用户再编辑时去重逻辑仍然正确。
@@ -783,6 +1101,28 @@ const editingCtx: EditableContext<TableRow> = {
}
return changed
},
+ /**
+ * Esc 取消:把**当前这一格**还原为「进入这一格时的值」。
+ *
+ * 为什么不能用 `revertRow`(select / date 走的正是这一条):
+ * · `revertRow` 回滚**整行**,而这两类编辑器是"立即写回 + 立即抛 change"的 ——
+ * 同一行先前改过的格早已通知过父级(父级可能已落库),一起回滚会让界面与
+ * 父级数据分叉;
+ * · 更要紧的是 `revertRow` 读的 `initialValues` 会被 `shouldNotify` **就地推进**
+ * (它要记"上次通知的值"来去重)。对 input 无碍(提交前不通知),
+ * 但 select / date 一改就通知,快照当场变成新值 —— "回滚"于是回滚到刚改的值,
+ * **等于没回滚**。所以这里读的是只写一次的 `entry` 快照。
+ *
+ * 返回是否真的发生了回滚(值本来就没变则返回 false)。
+ */
+ revertCell: (row, col) => {
+ const entry = getRowEditingState(row)?.value.entry
+ // 没有进入值(只读格 / 已切走)/ 不是这一格:不动
+ if (!entry || entry.dataIndex !== col.dataIndex) return false
+ if (Object.is(row[col.dataIndex], entry.value)) return false
+ ;(row as TableRow)[col.dataIndex] = entry.value
+ return true
+ },
}
/* ---------- 列 ---------- */
@@ -1079,7 +1419,19 @@ async function editRow(rowKey: string | number, focusCol: string | null = null)
* 则要克制(见 handleAreaSelectionChange / commitPendingEditing 里的判断)。
*/
tableRef.value?.setCurrentRow?.(row, { silent: true })
- activateEditing(row, focusCol)
+ editRowInternal(row, focusCol)
+}
+
+/**
+ * `editRow` 的内部实现:先退出旧会话,再进入新格。
+ *
+ * ⚠️ 程序化入口也要走"退出旧会话"这一步(与鼠标点击、键盘移动一致)——
+ * 否则在 A 行编辑时调 `editRow(B)`,A 行那份未提交的草稿会**静默丢失**
+ * (`startEdit` 只清状态、不提交,见它的注释)。
+ */
+function editRowInternal(row: TableRow, focusCol: string | null) {
+ if (activeEditingRow && activeEditingRow !== row) exitEditingSession(activeEditingRow)
+ startEdit(row, focusCol)
}
function initVirtualScroll() {
@@ -1132,7 +1484,7 @@ function commitPendingEditing() {
(item) => item === target.row || String(getRowKey(item)) === String(getRowKey(target.row)),
)
if (row) {
- activateEditing(row, target.focusCol)
+ startEdit(row, target.focusCol, 'all')
// 键盘防抖路径的收尾:与另外两处一致,当前行无条件同步
// (键盘不产生 click,调用方的 @row-click 收不到消息,见 handleAreaSelectionChange)。
tableRef.value?.setCurrentRow?.(row, { silent: true })
@@ -1156,6 +1508,27 @@ function handleKeydownCapture(event: KeyboardEvent) {
if (event.repeat) isRepeatNavigation = true
}
+/**
+ * 表格根节点上的 keydown 总入口(**捕获**阶段)。
+ *
+ * 为什么要一个总入口而不是在模板上挂两个 `@keydown.capture`:
+ * 两组处理器的**关注范围完全不同**,但都必须挂在根节点、且必须走捕获:
+ * · `handleKeydownCapture` —— 只关心导航键(方向键 / Tab),记录"正在按住哪个键",
+ * 供长按防抖用。它**不消费**事件,只是记账;
+ * · `handleReadyStateKeydown` —— 只关心"选中态下 Enter / F2 / 可打印字符进编辑",
+ * 会 `preventDefault`(否则回车会提交外层表单)。
+ * 分成两个函数是为了各自可读;合成一个入口是因为**顺序有约束**:
+ * 必须先记账、再判"要不要进编辑" —— 否则同一记按键在两个函数里各判一次状态,
+ * 极易出现一处判编辑态、另一处判选中态的分叉。
+ *
+ * ⚠️ 不能给两个独立监听器加 `.self` 修饰:选中态下焦点落在**内层容器**
+ * (`.stk-table`,它是 rootRef 的后代),`.self` 会因为 target 不等于绑定元素而永不触发。
+ */
+function handleRootKeydown(event: KeyboardEvent) {
+ handleKeydownCapture(event)
+ handleReadyStateKeydown(event)
+}
+
function handleKeyupCapture(event: KeyboardEvent) {
if (!NAVIGATION_KEYS.has(event.key)) return
if (!pressedNavigationKeys.delete(event.key) || pressedNavigationKeys.size) return
@@ -1183,13 +1556,19 @@ function handleWindowBlur() {
}
/**
- * 选区变化后,目标行进入编辑,活动单元格获得焦点。
+ * 选区变化:**选中态**下只更新当前格,**编辑态**下(由发起方标记)才进入编辑。
+ *
+ * 这是"两态模型"的枢纽。规格把状态分成两种:
+ * · 选中态 —— 格子有选中框,**没有** input;方向键 / Tab 只挪选中框(规格 §二);
+ * · 编辑态 —— 格子里有 input;方向键 / Tab 提交并挪到下一格**继续编辑**(规格 §三)。
*
- * 三档策略(照搬 fms):
- * - 按住方向键滚动(event.repeat):全程保持 0 编辑器,避免长按下反复创建组件导致卡顿/闪烁;
- * 松手后再激活到最终行。
- * - 慢速离散点按(非 repeat):立即激活,编辑器逐行跟随、跟手。
- * - 快速连按(间隔 <150ms):防抖合并,避免连按时每键挂载掉帧。
+ * 两种状态最终都调底座的 `setAreaSelection`、都抛同一个 `area-selection-change` 事件,
+ * 长得一模一样 —— 所以**必须由发起方显式声明意图**(`selectionChangeEntersEdit`),
+ * 不能从"当前是否在编辑态"猜:Enter 是在编辑态里发起的、Tab 可能跨行把编辑态清掉,
+ * 猜必然出错(见那个变量的注释)。
+ *
+ * 键盘导航的防抖(长按合并)只对**进入编辑**那条路径有意义 ——
+ * 选中态没有编辑器要挂载,逐格移动本来就是轻量的,再防抖反而变成"按住方向键不动"。
*/
function handleAreaSelectionChange(ranges: unknown[]) {
if (!props.editable) return
@@ -1206,6 +1585,80 @@ function handleAreaSelectionChange(ranges: unknown[]) {
}
const focusCol = col?.editor ? col.dataIndex : null
+ /** 本次选区变化是否**顺便进入编辑**(发起方声明,消费即清) */
+ const entersEdit = selectionChangeEntersEdit
+ selectionChangeEntersEdit = false
+
+ /* ---------- 选中态:只挪选中框,不进编辑 ---------- */
+ if (!entersEdit) {
+ pendingEditingTarget = null
+ clearEditActivateTimer()
+
+ /**
+ * 点到**别处**就意味着离开当前编辑格 → 提交 + 结束这次编辑。
+ *
+ * 判据必须同时比较行与列:点同一格(想用鼠标挪光标)不能提交,否则
+ * 「点一下输入框中间想改一个字」会立刻提交并把输入框卸掉。
+ */
+ if (activeEditingRow) {
+ const editingState = getRowEditingState(activeEditingRow)?.value
+ const sameCell =
+ activeEditingRow === row &&
+ editingState?.focusCol != null &&
+ editingState.focusCol === focusCol
+ if (!sameCell) exitEditingSession(activeEditingRow)
+ }
+
+ /**
+ * 让表格容器拿到焦点,方向键才有效(这是"选中态键盘可用"的前提)。
+ *
+ * 底座的键盘导航挂在**容器**上(`.stk-table`,带 `tabindex="0"`),而 `td`
+ * **没有** tabindex、不可聚焦(我核对过库的 td 渲染属性:只有 data-col-key /
+ * style / class)。所以点完一格若不把焦点交给容器,接着按方向键
+ * **什么都不会发生** —— 事件压根到不了底座的监听器。
+ *
+ * ⚠️ 不能无条件抢焦点:点 checkbox 时焦点已经在它自己的 input 上(那是用户
+ * 接下来要按空格的目标),抢过来会让空格失去落点。所以在"焦点已经落在
+ * 本表格内的可聚焦控件上"时不动它。
+ */
+ const active = document.activeElement as HTMLElement | null
+ const focusAlreadyInside = Boolean(
+ active &&
+ active !== rootRef.value &&
+ rootRef.value?.contains(active) &&
+ active.closest('input, textarea, select, button, a[href], [contenteditable="true"]'),
+ )
+ if (!focusAlreadyInside) {
+ rootRef.value?.querySelector<HTMLElement>(`.${e('inner')}`)?.focus?.({ preventScroll: true })
+ }
+
+ /**
+ * 当前行**无条件**同步。
+ *
+ * 这里的判据曾经是「只有调用方没绑 `activeRowKey` 时才写」,结果是:
+ * **受控调用方(fms 那套 `:active-row-key` + `@row-click`)点格时永远不高亮**。
+ *
+ * 原因是浏览器行为,不是我们的状态管理:
+ * 点可编辑格 → 单元格内容从 `.cell-display` 那个 span 换成 `G3Input`,
+ * **mousedown 命中的节点在 mouseup 之前就被移除了**。Chromium 在这种情况下
+ * **根本不派发 click 事件**(实测:document 捕获阶段连一条 click 都没有,
+ * 而点只读格/已挂编辑器的格时 click 正常)。库的 `row-click` 由 click 驱动,
+ * 于是永远不触发,调用方那个「唯一写入者」永远等不到消息。
+ *
+ * 换句话说:对「点格」这个手势,**本方法是唯一能知道发生了什么的代码**,
+ * 内部这次写入不是「第二个写入者」,而是这种情况下**唯一的写入者** ——
+ * 受控调用方那套绑定在这里拿不到任何事件,谈不上竞争。
+ * 真正踩过的竞态是「watcher 把当前行清掉」(已由 `activeRowKeyBound` 判断挡掉),
+ * 与这里无关。
+ *
+ * `silent: true` —— 只更新高亮,不触发库的选中事件(不干扰业务)。
+ */
+ tableRef.value?.setCurrentRow?.(row, { silent: true })
+ emit('area-selection-change', ranges)
+ return
+ }
+
+ /* ---------- 编辑态:移动并继续编辑(长按 / 连按防抖在这里才必要) ---------- */
if (pressedNavigationKeys.size > 0) {
if (isRepeatNavigation) {
pendingEditingTarget = { row, focusCol }
@@ -1215,7 +1668,7 @@ function handleAreaSelectionChange(ranges: unknown[]) {
if (now - lastEditActivateAt >= 150) {
pendingEditingTarget = null
clearEditActivateTimer()
- activateEditing(row, focusCol)
+ startEdit(row, focusCol, 'all')
// 与鼠标路径同源:当前行**无条件**同步。
// 键盘导航同样不产生 click,调用方的 @row-click 一样等不到消息
// (两处判据必须一致,否则会变成「键盘改了当前行、鼠标不改」的新不一致)。
@@ -1230,46 +1683,40 @@ function handleAreaSelectionChange(ranges: unknown[]) {
}
/**
- * 鼠标点击:**立即进入编辑**(本组件的既有交互:点一格就能直接打字,少一次点击)。
+ * 显式移动(Enter / 方向键 / Tab):**立即**进入编辑。
*
- * 编辑器挂上并自动聚焦;同行切列时重挂载并全选(直接输入即替换整格内容);
- * 点击已聚焦的编辑器内部则由原生行为定位光标。
- *
- * 方向键的分工不在这一层,而在编辑器自己的 `onKeydown`:
- * **左右归输入框移光标、上下让给表格导航**(见 editable.ts 的 createInput)。
+ * 编辑器挂上并自动聚焦,插入符按调用方给的意图摆好(`startEdit` 里消费)。
*/
pendingEditingTarget = null
clearEditActivateTimer()
- activateEditing(row, focusCol)
- /**
- * 当前行**无条件**同步。
- *
- * 这里的判据曾经是「只有调用方没绑 `activeRowKey` 时才写」,结果是:
- * **受控调用方(fms 那套 `:active-row-key` + `@row-click`)点格进入编辑时永远不高亮**。
- *
- * 原因是浏览器行为,不是我们的状态管理:
- * 点可编辑格 → 编辑器挂载 → 单元格内容从 `.cell-display` 那个 span 换成 `G3Input`,
- * **mousedown 命中的节点在 mouseup 之前就被移除了**。Chromium 在这种情况下
- * **根本不派发 click 事件**(实测:document 捕获阶段连一条 click 都没有,
- * 而点只读格/已挂编辑器的格时 click 正常)。库的 `row-click` 由 click 驱动,
- * 于是永远不触发,调用方那个「唯一写入者」永远等不到消息。
- *
- * 换句话说:对「点格进编辑」这个手势,**本方法是唯一能知道发生了什么的代码**,
- * 内部这次写入不是「第二个写入者」,而是这种情况下**唯一的写入者** ——
- * 受控调用方那套绑定在这里拿不到任何事件,谈不上竞争。
- * 真正踩过的竞态是「watcher 把当前行清掉」(已由 `activeRowKeyBound` 判断挡掉),
- * 与这里无关。
- *
- * 若调用方的 `@row-click` 也写了一次(点只读格、或点已挂编辑器的格),
- * 两次写的是**同一行**,幂等,不会互相覆盖。
- *
- * `silent: true` —— 只更新高亮,不触发库的选中事件(不干扰业务)。
- */
+ startEdit(row, focusCol, 'all')
tableRef.value?.setCurrentRow?.(row, { silent: true })
lastEditActivateAt = performance.now()
emit('area-selection-change', ranges)
}
+/**
+ * 结束一次编辑会话:**提交 + 抛 edit-end + 回到选中态**。
+ *
+ * 三个动作必须打包在一起,否则会出现"值提交了但 edit-end 没抛"
+ * 或"退出编辑了但值丢了"这类半途状态。调用点:
+ * · 点到别的格子 / 页面别处(`handleAreaSelectionChange` 的选中态分支);
+ * · 程序化 `editRow` 切到另一行;
+ * · 数据源整体替换导致编辑行消失。
+ */
+function exitEditingSession(row: TableRow, col?: G3TableColumn<TableRow> | null) {
+ const state = getRowEditingState(row)
+ const targetCol = col ?? (state?.value.focusCol ? findColumn(state.value.focusCol) : null)
+ /**
+ * ⚠️ 顺序:**先提交后清态**。
+ * 反过来的话 `commitEditingCell` 读到的 `initialValues` 已经被清空,
+ * 去重判据失效 → 值没变也会抛一次 `change`。
+ */
+ commitEditingCell(row, targetCol)
+ reportEditEnd(row, targetCol)
+ clearActiveEditing()
+}
+
/* ---------- Tab / Shift+Tab:跳格 ---------- */
/**
@@ -1335,23 +1782,40 @@ function resolveTabOrigin(
*/
const pending = pendingEditingTarget
const row = activeEditingRow ?? pending?.row ?? null
- if (!row) return null
- const state = getRowEditingState(row)?.value
- /**
- * 「编辑会话在进行」有**两种**形态,缺一不可:
- * · `state.editing` —— 正常的编辑态;
- * · `pendingEditingTarget != null` —— 底座的**连按 / 长按优化**把编辑器全卸载了
- * (按住 Tab 时它刻意保持 0 编辑器,等松手再挂回来,见 handleAreaSelectionChange)。
- * 此时 `state.editing` 是 false,但用户明明还在表格里用键盘走 ——
- * 少了这一支,**按住 Tab 走两格之后就会掉出表格**(实测:从只读列起步,
- * 第 3 下起 focus 被浏览器按文档顺序送出表格,100% 复现)。
- */
- if (!state?.editing && !pending) return null
+ const state = row ? getRowEditingState(row)?.value : null
const el = target as HTMLElement | null
const jumpInFlight = performance.now() < tabJumpPendingUntil
const selectionEnd = readSelectionEnd()
+ /**
+ * 焦点既不在本行的编辑器里、也不是下面两种「其实还是我们」的情形 → 这记 Tab 不是给我们的,
+ * 放行(页面 / 表单的 Tab 序不能被表格吃掉)。
+ *
+ * 「焦点没有落在别的可交互控件上」而不是「恰好等于 body」:换格那几十毫秒里焦点可能
+ * 落在 body,也可能被底座收回到表格容器上,两者都说明「此刻没人接管键盘」。
+ */
+ const focusTakenOver = Boolean(
+ el?.closest?.('input, textarea, select, button, a[href], [contenteditable="true"]'),
+ )
+
+ /* ---------- 选中态:没有编辑会话,Tab 只挪选中框(规格 §二) ---------- */
+ if (!row || (!state?.editing && !pending)) {
+ /**
+ * 归属判定只看一件事:**焦点此刻在不在本表格的容器上**。
+ *
+ * 选中态下没有编辑器可以当锚点,所以锚点只能是"焦点在表格里"这个事实。
+ * 点击 / Esc 之后我们都会把焦点交给容器(见 handleAreaSelectionChange 与 exitEditing),
+ * 所以这是可靠信号;而页面上的其它元素按 Tab 时焦点不在我们这里,自然放行。
+ *
+ * `jumpInFlight` 那一支同样要保留:连按 Tab 时中间有一瞬焦点不在容器上,
+ * 没有它就会出现"按快一点就跳出表格"(与编辑态同一个成因)。
+ */
+ const focusInTable = Boolean(el && (el === rootRef.value || rootRef.value?.contains(el)))
+ if (!focusInTable && !(jumpInFlight && !focusTakenOver)) return null
+ return selectionEnd
+ }
+
// 焦点在**本表格的编辑器**里(含 select 浮层):那是最可靠的「用户在编辑哪一行」的信号
const editorEl = el?.closest?.<HTMLElement>(`.${e('cell-editor-root')}`) ?? null
let cellEl = editorEl?.closest<HTMLElement>('td[data-col-key]') ?? null
@@ -1382,7 +1846,7 @@ function resolveTabOrigin(
let focusedCell: { rowIndex: number; colIndex: number } | null = null
if (cellEl) {
const tr = cellEl.closest<HTMLElement>('tr[data-row-i]')
- if (tr && tr.dataset.rowKey === String(getRowKey(row))) {
+ if (tr && tr.dataset.rowKey === String(getRowKey(row as TableRow))) {
const colIndex = displayColumns.value.findIndex(
(col) => col.dataIndex === cellEl?.getAttribute('data-col-key'),
)
@@ -1391,17 +1855,6 @@ function resolveTabOrigin(
}
}
- /**
- * 焦点既不在本行的编辑器里、也不是下面两种「其实还是我们」的情形 → 这记 Tab 不是给我们的,
- * 放行(页面 / 表单的 Tab 序不能被表格吃掉)。
- *
- * 「焦点没有落在别的可交互控件上」而不是「恰好等于 body」:换格那几十毫秒里焦点可能
- * 落在 body,也可能被底座收回到表格容器上,两者都说明「此刻没人接管键盘」。
- */
- const focusTakenOver = Boolean(
- el?.closest?.('input, textarea, select, button, a[href], [contenteditable="true"]'),
- )
-
/**
* 情形二:编辑会话还在,但**此刻 DOM 里一个编辑器都没有**。两种成因:
* · 编辑行 / 编辑列被**虚拟滚动滚出渲染窗口**,编辑器被卸载(用户滚走表格再按 Tab);
@@ -1439,9 +1892,11 @@ function resolveTabOrigin(
* 2. 底座的规则是「列 ±1」,会把焦点送到只读格甚至表格外 ——
* 而编辑语境里要的是「下一个可编辑格」(见 utils/editable.ts 的说明)。
*
- * 移动方式选择「改选区」而不是直接 activateEditing:底座的 `area-selection-change`
- * 那条链路已经处理好了激活编辑、定位并聚焦编辑器、滚动进视口,
- * 复用它可以保证鼠标点击与键盘跳格走的是**同一条**路径(不会各写一套而行为分叉)。
+ * 移动方式选择「改选区」(配合 `selectionChangeEntersEdit` 标记)而不是直接调 `startEdit`:
+ * 底座的 `area-selection-change` 那条链路已经处理好了滚动进视口与命中测试,
+ * 复用它可以保证鼠标双击与键盘跳格走的是**同一条**路径(不会各写一套而行为分叉)。
+ * 编辑态的激活统一由 `handleAreaSelectionChange` 按 `pressedNavigationKeys` 决定
+ * 「立即激活」还是「长按期间先不挂编辑器」—— 在这里直接调 `startEdit` 会绕过那层防抖。
*
* 只拦**本表格自己的编辑器**(含它的浮层),其余 Tab 一律放行:
* 组件不能影响页面/表单的 Tab 序。
@@ -1462,6 +1917,31 @@ function handleDocumentTab(event: KeyboardEvent) {
*/
if (!target) return
+ /**
+ * 当前是否在编辑态 —— 决定 Tab 的语义(规格 §二 / §三):
+ * · 编辑态:**提交并跳格**,到了新格继续编辑(连续录入的主路径);
+ * · 选中态:只挪选中框,**不进编辑**(Tab 与方向键在选中态行为一致)。
+ */
+ const editingState = activeEditingRow ? getRowEditingState(activeEditingRow)?.value : null
+ const wasEditing = Boolean(editingState?.editing)
+
+ /**
+ * ⚠️ **必须在这里显式提交** —— 这是"跨行 Tab 不提交、不抛 change"的修复点。
+ *
+ * Enter / 方向键走 `moveToAdjacent` 时会提交,但 Tab 走的是**本函数**(挂在 document 上,
+ * 因为事件源可能在 Teleport 出去的 select 浮层里,见上)。它直接调 `setAreaSelection`,
+ * 绕过了底座的 `blurCellElement()` —— 于是:
+ * · 同行跳格:新旧 input 交接,浏览器派发 blur → `onBlur` 提交,正常;
+ * · 跨行跳格:旧行编辑器被**卸载**,而 DOM 元素被移除时浏览器**不派发 blur**,
+ * 只剩 `preserveInputDraft` 的兜底(写 raw、不响应式、**不抛 change**)。
+ * 结果就是**同一个 Tab 键、跨行与同行行为不一致**:屏幕上看着改了(下次重渲染才显示),
+ * 业务侧却收不到任何通知。显式提交一次即闭合。
+ */
+ if (wasEditing && activeEditingRow) {
+ commitEditingCell(activeEditingRow, editingState?.focusCol ? findColumn(editingState.focusCol) : null)
+ reportEditEnd(activeEditingRow, editingState?.focusCol ? findColumn(editingState.focusCol) : null)
+ }
+
/**
* 复用「键盘导航中」那段状态机(handleKeydownCapture:按住连发时合并、松手后再挂编辑器)。
* 不喂给它的话,按住 Tab 连发会每 30ms 挂载/卸载一次编辑器 —— 这正是那段逻辑存在的理由。
@@ -1475,12 +1955,258 @@ function handleDocumentTab(event: KeyboardEvent) {
event.stopPropagation()
// 下一格没落地(新编辑器还没聚焦)之前,body 上的 Tab 也算我们的:见 TAB_JUMP_GRACE_MS
tabJumpPendingUntil = performance.now() + TAB_JUMP_GRACE_MS
+ // 编辑态下 Tab 要"接着编辑";选中态下只挪选中框
+ selectionChangeEntersEdit = wasEditing
tableRef.value?.setAreaSelection?.(
{ begin: { row: target.rowIndex, col: target.colIndex } },
{ scrollToView: true },
)
}
+/* ---------- 选中态键盘:Enter / F2 / 打字进编辑(规格 §二) ---------- */
+
+/**
+ * 挂在表格根节点的**捕获**阶段,只处理"选中态进编辑"这一组键。
+ *
+ * 为什么不复用底座的键盘导航:底座只认方向键 / Tab / Esc / Ctrl+C,
+ * Enter / F2 / 可打印字符它一概不管,事件会直接冒到页面上
+ * (在表单里就是"在表格里按回车把整个表单提交了")。
+ *
+ * 为什么只处理**选中态**:编辑态下这些键归输入框(Enter 提交、F2 定位光标、
+ * 打字就是打字),在捕获阶段抢过来会把输入框废掉。判据取 `activeEditingRow` 的
+ * 编辑标志 —— 与其它所有分支同一口径。
+ */
+function handleReadyStateKeydown(event: KeyboardEvent) {
+ if (!props.editable) return
+ // 编辑态:这些键全归输入框,一概不碰(含 IME 组合态)
+ if (isEditingNow() || event.isComposing || event.keyCode === 229) return
+ // 组合键交给浏览器 / 系统(Ctrl+C 复制选区、Cmd+R 刷新……)
+ if (event.ctrlKey || event.metaKey || event.altKey) return
+ /**
+ * 焦点必须在本表格里,且**不在**某个可交互控件上。
+ *
+ * 后半句不能省:表格里的 checkbox 编辑器是常驻可聚焦的,用户在它上面按 Enter
+ * 是"切换勾选"(createCheckbox 自己处理),不该被这里当成"进入编辑"。
+ */
+ const active = document.activeElement as HTMLElement | null
+ if (!active || !rootRef.value?.contains(active)) return
+ /**
+ * 判断焦点是否落在表格内部的"其它可交互控件"上。
+ *
+ * ⚠️ 必须排除**本表格的容器自身**:选中态下我们把焦点交给容器(见
+ * handleAreaSelectionChange),若把容器也算作"被接管",这一整段就永远不会执行。
+ * `active !== rootRef.value` 不够 —— 容器是 rootRef 的**后代**(`.stk-table`),
+ * 所以要显式判"命中的是容器自己而不是别的控件"。
+ */
+ const inner = rootRef.value.querySelector<HTMLElement>(`.${e('inner')}`)
+ const focusOnContainer = active === inner || active === rootRef.value
+ if (!focusOnContainer && active.closest('input, textarea, select, button, a[href], [contenteditable="true"]')) {
+ return
+ }
+
+ const cell = readSelectedCell()
+ if (!cell) return
+
+ /* F2:进入编辑,光标在末尾(规格 §二) */
+ if (event.key === 'F2') {
+ event.preventDefault()
+ /**
+ * `caret: 'end'` 只对**文本框类**编辑器有意义。
+ *
+ * select / checkbox 的"焦点"是触发器或原生框,摆光标要么无从谈起、要么会
+ * 动到筛选输入框;date 的插入符位置由它自己的草稿管理。给它们传 `null`
+ * ("不干预"),把落点交给编辑器自己决定 —— 比强行塞一个 `'end'` 更确定。
+ */
+ startSelectedCellEdit(cell, isTextLikeEditor(cell.col) ? 'end' : null)
+ return
+ }
+
+ /* Enter:进入编辑,**全选**(规格 §二) */
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ startSelectedCellEdit(cell, isTextLikeEditor(cell.col) ? 'all' : null)
+ return
+ }
+
+ /**
+ * 可打印字符:进入编辑,**清空原值、只留这个字符**(规格 §二,与 Excel 一致)。
+ *
+ * 判据用 `event.key.length === 1` 而不是 `keyCode` 范围:后者在输入法 / 小键盘
+ * 下不可靠,而 `key` 对可打印字符一定是单个字符(`'a'`、`'中'`、`'5'`、`' '`)。
+ * 组合键已在上面排除,所以这里剩下的就是"用户想打字"。
+ *
+ * 空格要**排除**:它在表格里的通行语义是"翻页 / 触发",而且复选框的切换键也是空格。
+ * 把空格当成"输入一个空格进编辑"会让翻页彻底失效。
+ */
+ if (event.key.length === 1 && event.key !== ' ') {
+ /**
+ * ⚠️ **"覆盖原值"只对文本框类编辑器成立**。
+ *
+ * select / date / checkbox 的值不是一段可替换的文本:
+ * · 它们的编辑器读的是**行数据**(不走 inputDrafts),所以那个字符既不会被
+ * 显示、也不会生效 —— 用户敲了字什么也没发生;
+ * · 但草稿却被写进了 WeakMap。对 select/date/checkbox 来说它是个**永不回收的
+ * 残留项**(那三条渲染路径都没有 `onVnodeBeforeUnmount` 去清理它,只有
+ * createInput 有 `preserveInputDraft`)。
+ * 所以这里只对文本框类写入草稿;其它类型**只进编辑**,不碰值。
+ * 语义上也更合理:在一个下拉格里敲 `a`,用户期待的是"打开下拉让我选",
+ * 而不是"把这一格的值改成 a"。
+ */
+ event.preventDefault()
+ if (isTextLikeEditor(cell.col)) {
+ /**
+ * 先写草稿再进编辑:编辑器挂载时读到的就是这一个字符。
+ * 覆盖语义靠"草稿即全部内容"实现 —— 草稿已经只有这一个字符了,
+ * 插入符落哪都一样(放末尾最自然)。
+ */
+ setInputDraft(cell.row, cell.col.dataIndex, event.key)
+ startSelectedCellEdit(cell, 'end')
+ return
+ }
+ startSelectedCellEdit(cell, null)
+ }
+}
+
+/**
+ * 这一格的编辑器是不是"一段可编辑的文本"。
+ *
+ * 判据就是 `TableEditor.type`:只有这四种的编辑器是一个真正的 `input` / `textarea`,
+ * 也才有"全选 / 光标到末尾 / 打字覆盖"这些**文本编辑**语义。
+ * select / checkbox / date 的值由选项或日历面板决定,不接受直接打字覆盖。
+ */
+function isTextLikeEditor(col: G3TableColumn<TableRow>): boolean {
+ const type = col.editor?.type
+ return type === 'text' || type === 'number' || type === 'password' || type === 'textarea'
+}
+
+/**
+ * 从当前**选区**解析出「这一格」的行与列定义。
+ *
+ * 为什么以选区为准、而不是焦点:选中态下焦点在表格容器上(不在具体格子里),
+ * 唯一能表达"当前是哪一格"的就是底座的选区。`td[data-cs-s]` 同样可靠,
+ * 但选区是库的**状态**、不依赖 DOM 是否渲染(虚拟滚动下目标格可能不在 DOM 里)。
+ */
+function readSelectedCell(): { row: TableRow; col: G3TableColumn<TableRow> } | null {
+ const end = readSelectionEnd()
+ if (!end) return null
+ const rows = (tableRef.value?.getTableData?.() ?? props.dataSource) as TableRow[]
+ const row = rows[end.rowIndex]
+ const col = displayColumns.value[end.colIndex]
+ if (!row || !col) return null
+ return { row, col }
+}
+
+/**
+ * 让"当前选中的那一格"进入编辑,插入符按 `caret` 落位。
+ *
+ * 只对**真的可编辑**的格子生效:只读列、`editor.disabled` 为真的格子不该被
+ * Enter / F2 / 打字带进编辑态(规格 §七:方向键仍可以在它上面停留,见 APG 的 grid pattern)。
+ * 这里静默忽略而不是报错 —— 用户在只读格上按回车是很常见的("确认一下")。
+ *
+ * `caret` 传 `null` 表示"不干预插入符"(select / checkbox / date 这类非文本框编辑器)。
+ */
+function startSelectedCellEdit(
+ cell: { row: TableRow; col: G3TableColumn<TableRow> },
+ caret: EditCaret | null,
+) {
+ const editor = cell.col.editor
+ if (!editor || isEditorDisabled(editor, cell.row)) return
+ /**
+ * 先结束上一格(可能是同一行的另一列):提交 + 抛 edit-end。
+ * 同行换列时新编辑器会接管焦点、旧 input 随之失焦,但那发生在**下一帧**,
+ * 而这里的 `startEdit` 会立刻改 `focusCol` —— 提前提交更确定。
+ */
+ if (activeEditingRow && activeEditingRow !== cell.row) exitEditingSession(activeEditingRow)
+ startEdit(cell.row, cell.col.dataIndex, caret)
+ tableRef.value?.setCurrentRow?.(cell.row, { silent: true })
+}
+
+/** 此刻是否处于编辑态(全组件统一的判据) */
+function isEditingNow(): boolean {
+ return Boolean(activeEditingRow && getRowEditingState(activeEditingRow)?.value.editing)
+}
+
+/* ---------- 鼠标:单击选中、双击编辑(规格 §四) ---------- */
+
+/**
+ * 双击:进入编辑,**光标定位到双击位置**(规格 §四)。
+ *
+ * 底座会抛 `row-dblclick`,但它**不带列**(见 StkTable.js 的 onRowDblclick:
+ * 只 emit (e, row, { rowIndex})),而"哪一格"对表格是必需的。所以这里不用那个事件,
+ * 改在根节点上挂 `dblclick` 自己做命中测试 —— 与校验浮层的 `mouseover` 委托同一套路子,
+ * 全表一个监听器。
+ *
+ * 插入符位置用 `document.caretRangeFromPoint` 取**双击落在哪个字符上**:
+ * 这是唯一能对应"用户点的是这个字"的 API(用 `offsetX` 自己量字符宽度在
+ * 等宽字体之外全是错的)。取不到时回落 `'all'`(全选)—— 宁可全选也不要瞎猜一个下标。
+ */
+function handleCellDblClick(event: MouseEvent) {
+ if (!props.editable) return
+ const target = event.target as HTMLElement | null
+ const td = target?.closest?.<HTMLElement>('td[data-col-key]')
+ if (!td || !rootRef.value?.contains(td)) return
+ // 命中复选框 / 按钮这类常驻控件时不接管:它们有自己的双击语义(连点两次 = 切两次)
+ if (target?.closest('input, textarea, select, button, label, a[href]')) return
+
+ const colKey = td.getAttribute('data-col-key')
+ const colIndex = displayColumns.value.findIndex((col) => col.dataIndex === colKey)
+ const tr = td.closest<HTMLElement>('tr[data-row-i]')
+ const rowIndex = Number(tr?.dataset.rowI)
+ if (colIndex < 0 || !Number.isInteger(rowIndex)) return
+
+ const rows = (tableRef.value?.getTableData?.() ?? props.dataSource) as TableRow[]
+ const row = rows[rowIndex]
+ const col = displayColumns.value[colIndex]
+ if (!row || !col) return
+ const editor = col.editor
+ // 只读格 / 按行禁用:双击不进入编辑(与 Enter / F2 同一判据)
+ if (!editor || isEditorDisabled(editor, row)) return
+
+ // 双击时同步选中这一格:否则"双击 B 格"会让选区停在 A 格,后续方向键从错误的起点走
+ selectionChangeEntersEdit = false
+ tableRef.value?.setAreaSelection?.({ begin: { row: rowIndex, col: colIndex } })
+
+ if (activeEditingRow && activeEditingRow !== row) exitEditingSession(activeEditingRow)
+ /**
+ * 插入符落点同样只对**文本框类**有意义(见 isTextLikeEditor):
+ * select 的光标落点会动到它的筛选输入框、date 的位置由自己的草稿管,
+ * 两者都交给编辑器自己决定(传 `null`)。
+ */
+ const caret = isTextLikeEditor(col) ? caretFromPoint(event, row[col.dataIndex]) : null
+ startEdit(row, col.dataIndex, caret)
+ tableRef.value?.setCurrentRow?.(row, { silent: true })
+}
+
+/**
+ * 求"双击落在第几个字符上"。取不到就返回 `'all'`(全选)。
+ *
+ * `caretRangeFromPoint` 是 WebKit/Blink 的;Firefox 用 `caretPositionFromPoint`。
+ * 两者都拿不到时(happy-dom、老浏览器)回落全选 —— 让插入符位置不精确,
+ * 而不是让双击完全没反应。
+ */
+function caretFromPoint(event: MouseEvent, value: unknown): EditCaret {
+ const text = value == null ? '' : String(value)
+ if (!text) return 'all'
+ try {
+ const doc = document as Document & {
+ caretRangeFromPoint?: (x: number, y: number) => Range | null
+ caretPositionFromPoint?: (x: number, y: number) => { offset: number } | null
+ }
+ const range = doc.caretRangeFromPoint?.(event.clientX, event.clientY)
+ if (range && typeof range.startOffset === 'number') {
+ // 下标不得超过文本长度(命中的可能是单元格里的其它节点)
+ return Math.min(range.startOffset, text.length)
+ }
+ const position = doc.caretPositionFromPoint?.(event.clientX, event.clientY)
+ if (position && typeof position.offset === 'number') {
+ return Math.min(position.offset, text.length)
+ }
+ } catch {
+ // 取不到就是"位置不精确",不该连累双击进编辑这件事本身
+ }
+ return 'all'
+}
+
/* ---------- 校验原因浮层(全表共用一个) ---------- */
/**
@@ -1833,7 +2559,8 @@ const fixedPatchOff = (() => {
bodyFillsHeight ? is('body-fill') : '',
]"
data-slot="table"
- @keydown.capture="handleKeydownCapture"
+ @keydown.capture="handleRootKeydown"
+ @dblclick="handleCellDblClick"
@mouseover="handleCellTipMouseOver"
@mouseleave="closeCellTip"
>
diff --git a/code/g3soft-libs/packages/ui/src/table/types.ts b/code/g3soft-libs/packages/ui/src/table/types.ts
index 5668903f..a3f71261 100644
--- a/code/g3soft-libs/packages/ui/src/table/types.ts
+++ b/code/g3soft-libs/packages/ui/src/table/types.ts
@@ -366,7 +366,27 @@ export interface TableProps<T extends TableRow = TableRow> {
export interface TableEmits<T extends TableRow = TableRow> {
(e: 'update:columns', columns: G3TableColumn<T>[]): void
(e: 'update:selectedRowKeys', keys: Array<string | number>): void
+ /**
+ * 值实际发生变化(提交后新旧值不同)。
+ *
+ * 去重由组件按「进入编辑前的快照」完成 —— 所以「点进去又点出来」不会触发。
+ *
+ * ⚠️ `change` 与 `value-change` **语义完全相同、同时抛出**。
+ * 后者是正式名字,前者是既有名字,保留是为了不破坏已上线的 `@change` 绑定
+ * (Vue 对未声明的监听不报错,改名会让它们**静默失效**)。
+ */
(e: 'change', row: T, col: G3TableColumn<T>, value: unknown): void
+ (e: 'value-change', row: T, col: G3TableColumn<T>, value: unknown): void
+ /**
+ * 进入编辑态**之后**触发:编辑器已挂载、已聚焦、光标已按触发源就位。
+ *
+ * 重渲染引起的重复聚焦**不算**一次新的 edit-start,因此按它做埋点不会收到重复事件。
+ */
+ (e: 'edit-start', row: T, col: G3TableColumn<T>): void
+ /** 编辑完成、值已提交,随后离开这一格(Enter / Tab / 方向键 / 失焦都会走到)。 */
+ (e: 'edit-end', row: T, col: G3TableColumn<T>): void
+ /** 取消编辑(`Esc`):值已还原为进入编辑前的样子,已回到选中态。 */
+ (e: 'edit-cancel', row: T, col: G3TableColumn<T>): void
(e: 'row-order-change', rows: T[], sourceIndex: number, targetIndex: number): void
(e: 'area-selection-change', ranges: TableAreaRange[]): void
/**
diff --git a/code/g3soft-libs/packages/ui/src/table/utils/editable.test.ts b/code/g3soft-libs/packages/ui/src/table/utils/editable.test.ts
index 2ac05dc6..2ba54462 100644
--- a/code/g3soft-libs/packages/ui/src/table/utils/editable.test.ts
+++ b/code/g3soft-libs/packages/ui/src/table/utils/editable.test.ts
@@ -1,5 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
-import { createEditableCell, findAdjacentEditableCell, type EditableContext } from './editable'
+import {
+ commitPendingDraft,
+ createEditableCell,
+ findAdjacentCell,
+ findAdjacentEditableCell,
+ inputCanConsumeHorizontal,
+ setInputDraft,
+ type EditableContext,
+} from './editable'
import type { G3TableColumn, TableRow } from '../types'
interface Row extends TableRow {
@@ -23,6 +31,11 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
blurred: boolean[]
/** revertRow 被调用的行 id 列表(Esc 回滚) */
reverted: number[]
+ /** revertCell 被调用的 (行 id, 列) 列表(select / date 的单格回滚) */
+ revertedCells: Array<{ id: number; col: string }>
+ /** exitEditing / onEditCancel 的调用次数(Esc 退出编辑态) */
+ exited: number
+ canceled: Array<{ id: number; col: string }>
/** onEditorInteract 收到的 (行, 列) —— checkbox 切换后要把该格同步成当前格 */
interacted: Array<{ row: Row; col: string }>
notified: number
@@ -31,6 +44,9 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
const focused: number[] = []
const blurred: number[] = []
const reverted: number[] = []
+ const revertedCells: Array<{ id: number; col: string }> = []
+ const canceled: Array<{ id: number; col: string }> = []
+ let exited = 0
const interacted: Array<{ row: Row; col: string }> = []
let notified = 0
const ctx: EditableContext<Row> = {
@@ -38,6 +54,14 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
shouldFocus: () => true,
wasFocused: () => false,
consumePendingFocus: () => true,
+ /**
+ * 插入符意图的桩:默认返回 `null`("没指定,交给编辑器自己决定")。
+ *
+ * 取 null 而不是 'all' 是刻意的 —— 那些测「激活即全选」的既有用例走的正是
+ * `selectText` 回落分支,与生产里 `startEdit` 不传 caret 时的行为一致。
+ * 要测显式意图的用例自行 override 这个成员。
+ */
+ consumePendingCaret: () => null,
onEditorFocus: (row) => focused.push(row.id),
onEditorBlur: (row) => blurred.push(row.id),
onEditorInteract: (row, col) => interacted.push({ row, col: col.dataIndex }),
@@ -65,6 +89,14 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
reverted.push(row.id)
return true
},
+ revertCell: (row, col) => {
+ revertedCells.push({ id: row.id, col: col.dataIndex })
+ return true
+ },
+ exitEditing: () => {
+ exited += 1
+ },
+ onEditCancel: (row, col) => canceled.push({ id: row.id, col: col.dataIndex }),
...overrides,
}
return {
@@ -73,8 +105,13 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
focused,
blurred,
reverted,
+ revertedCells,
+ canceled,
interacted,
// 用 getter 读实时值
+ get exited() {
+ return exited
+ },
get notified() {
return notified
},
@@ -737,16 +774,57 @@ describe('键盘:Enter 与 Esc', () => {
return vnode.props.onKeydown as (e: unknown) => void
}
- /** 造一个足够真实的 KeyboardEvent 替身(编辑器只用到 key / target / preventDefault / stopPropagation) */
- function makeKeyEvent(key: string, value: string) {
+ /**
+ * 造一个足够真实的 KeyboardEvent 替身。
+ *
+ * 编辑器用到:`key` / `target`(读 value 与 selection)/ `preventDefault` /
+ * `stopPropagation` / `isComposing` / `shiftKey` 等修饰键。
+ *
+ * `caret` 参数决定光标位置 —— 左右键的**边界放行**判据全看它
+ * (见 utils/editable.ts 的 inputCanConsumeHorizontal),默认落在文本中间,
+ * 也就是"输入框消费得了"的那一侧。
+ */
+ function makeKeyEvent(
+ key: string,
+ value: string,
+ options: {
+ caret?: number
+ /** 选区终点(与 caret 不同即为"存在选区") */
+ selectionEnd?: number
+ shiftKey?: boolean
+ ctrlKey?: boolean
+ metaKey?: boolean
+ altKey?: boolean
+ isComposing?: boolean
+ keyCode?: number
+ /**
+ * 这一记按键是否**已被别人(浮层)消费**。
+ *
+ * select / date 的 Esc 取消靠它区分两段式:`true` = 浮层已经用掉了这一记
+ * (第一记 Esc 关下拉 / 关日历),取消逻辑必须放手。
+ */
+ defaultPrevented?: boolean
+ } = {},
+ ) {
+ const caret = options.caret ?? Math.floor(value.length / 2)
const target = {
value,
+ selectionStart: caret,
+ selectionEnd: options.selectionEnd ?? caret,
select: vi.fn(),
blur: vi.fn(),
+ setSelectionRange: vi.fn(),
}
return {
key,
target,
+ shiftKey: options.shiftKey ?? false,
+ ctrlKey: options.ctrlKey ?? false,
+ metaKey: options.metaKey ?? false,
+ altKey: options.altKey ?? false,
+ isComposing: options.isComposing ?? false,
+ keyCode: options.keyCode ?? 0,
+ defaultPrevented: options.defaultPrevented ?? false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
}
@@ -757,7 +835,7 @@ describe('键盘:Enter 与 Esc', () => {
* 的通行约定。
*
* 旧行为是「阻止默认 + 全选内容、且**不提交**」,它既不是浏览器行为、也不是表格软件的行为,
- * 而且「确认」这个动作整个缺失:改完按回车值还没落库。见 G3Table 的 moveToCellBelow。
+ * 而且「确认」这个动作整个缺失:改完按回车值还没落库。见 G3Table 的 moveToAdjacent。
*/
it('Enter(单行):提交当前值,并要求往下走一格(不再是「全选」)', () => {
const col = makeColumn({ editor: { type: 'text' } } as never)
@@ -788,32 +866,171 @@ describe('键盘:Enter 与 Esc', () => {
expect(event.target.select).not.toHaveBeenCalled()
})
- it('左右键:只 stopPropagation 交给输入框,不 preventDefault', () => {
+ /**
+ * 左右键(规格 §三 的核心原则):**input 先消费,消费不了才切格**。
+ *
+ * 这一组锁的是三档判据,缺任何一档都会退化成"永远不切格"(改前的行为)
+ * 或"永远切格"(光标没法移动):
+ * ① 光标在文本中间 → 让输入框移光标;
+ * ② 光标已贴在该方向的边界 → 交给表格切格;
+ * ③ 值为空 → 交给表格切格(没有光标位置可移)。
+ */
+ describe('左右键:input 先消费,边界才放行切格', () => {
+ it('光标在文本中间:不 preventDefault,留给输入框移光标', () => {
+ const col = makeColumn({ editor: { type: 'text' } } as never)
+ const { ctx } = makeCtx()
+ // 'abc' + 光标在 1 → 左边还有字符,能往左移
+ const event = makeKeyEvent('ArrowLeft', 'abc', { caret: 1 })
+
+ keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event)
+
+ /**
+ * 阻止冒泡是为了**不让它冒到底座的表格导航**(否则光标被吃掉、整格跳走);
+ * 不动默认行为是为了让浏览器照常移动光标。
+ */
+ expect(event.stopPropagation).toHaveBeenCalled()
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ })
+
+ it('光标贴在最左:放行给表格切格(preventDefault + 不动输入框)', () => {
+ const col = makeColumn({ editor: { type: 'text' } } as never)
+ const { ctx } = makeCtx()
+ const event = makeKeyEvent('ArrowLeft', 'abc', { caret: 0 })
+
+ keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event)
+
+ // 左边已经没有字符可去 → 这一记按键属于表格
+ expect(event.stopPropagation).toHaveBeenCalled()
+ expect(event.preventDefault).toHaveBeenCalled()
+ })
+
+ it('光标贴在最右:放行给表格切格', () => {
+ const col = makeColumn({ editor: { type: 'text' } } as never)
+ const { ctx } = makeCtx()
+ // 'abc' 长度 3,光标在 3 = 末尾
+ const event = makeKeyEvent('ArrowRight', 'abc', { caret: 3 })
+
+ keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event)
+
+ expect(event.preventDefault).toHaveBeenCalled()
+ })
+
+ it('值为空:放行给表格切格(没有光标位置可移)', () => {
+ const col = makeColumn({ editor: { type: 'text' } } as never)
+ const { ctx } = makeCtx()
+ const event = makeKeyEvent('ArrowRight', '', { caret: 0 })
+
+ keydownOf(col, ctx, { id: 1, name: '', status: 1 })(event)
+
+ expect(event.preventDefault).toHaveBeenCalled()
+ })
+
+ it('存在选区时永远留给输入框(按方向键是"收起选区",属有效操作)', () => {
+ const col = makeColumn({ editor: { type: 'text' } } as never)
+ const { ctx } = makeCtx()
+ // 光标在末尾、但有选区 → 按 → 的原生语义是"把光标收到选区右端"
+ const event = makeKeyEvent('ArrowRight', 'abc', { caret: 3, selectionEnd: 1 })
+
+ keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event)
+
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ })
+
+ it('Shift+左右:即便在边界也留给输入框(选中文本是有效操作)', () => {
+ const col = makeColumn({ editor: { type: 'text' } } as never)
+ const { ctx } = makeCtx()
+ const event = makeKeyEvent('ArrowLeft', 'abc', { caret: 0, shiftKey: true })
+
+ keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event)
+
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ })
+ })
+
+ /**
+ * 上下键(单行):**提交 + 切到上 / 下一行的同列**。
+ *
+ * 改前这里是"不拦、放行给底座导航",靠底座在移动前 `blurCellElement()` 间接触发提交。
+ * 现在改为显式 `stopPropagation` + `preventDefault`,提交与移动都由本层主动发起 ——
+ * 移动的那条链路与 Enter / Tab 共用(见 G3Table 的 moveToAdjacent)。
+ */
+ it('上下键(单行):提交当前值,并要求移动到相邻行', () => {
const col = makeColumn({ editor: { type: 'text' } } as never)
const { ctx } = makeCtx()
- const event = makeKeyEvent('ArrowLeft', 'abc')
+ const event = makeKeyEvent('ArrowDown', 'abc')
keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event)
- /**
- * 阻止冒泡是为了**不让它冒到底座的表格导航**(否则光标被吃掉、整格跳走);
- * 不动默认行为是为了让浏览器照常移动光标 / 用 Shift 选文本 ——
- * 与底座内置编辑器(custom-cells/EditableCell)的处理逐字一致。
- */
expect(event.stopPropagation).toHaveBeenCalled()
- expect(event.preventDefault).not.toHaveBeenCalled()
+ expect(event.preventDefault).toHaveBeenCalled()
})
- it('上下键(单行):放行给表格导航,本层不做任何拦截', () => {
+ it('上下键(单行)+ Shift:留给输入框(原生语义是"选中到首尾")', () => {
const col = makeColumn({ editor: { type: 'text' } } as never)
const { ctx } = makeCtx()
- const event = makeKeyEvent('ArrowDown', 'abc')
+ const event = makeKeyEvent('ArrowDown', 'abc', { shiftKey: true })
keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event)
- // 单行输入框里上下键没有原生用途 → 让给表格切换单元格(换行)
expect(event.preventDefault).not.toHaveBeenCalled()
- expect(event.stopPropagation).not.toHaveBeenCalled()
+ })
+
+ /**
+ * IME 输入法(规格 §七.1)。
+ *
+ * 这是"中文 ERP 必踩"的一类 bug:候选框上按 Enter 确认候选词时
+ * `key` 同样是 `'Enter'`,不挡就会被当成"确认并下移" —— 候选词上不了屏、
+ * 光标还跳到了下一行。
+ */
+ describe('IME 组合态:一律不拦', () => {
+ it('isComposing 为真时,Enter 不下移、不 preventDefault', () => {
+ const col = makeColumn({ editor: { type: 'text' } } as never)
+ const { ctx } = makeCtx()
+ const event = makeKeyEvent('Enter', 'zhong', { isComposing: true })
+
+ keydownOf(col, ctx, { id: 1, name: 'zhong', status: 1 })(event)
+
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ expect(event.stopPropagation).not.toHaveBeenCalled()
+ })
+
+ it('keyCode 229(部分浏览器确认候选时 isComposing 已复位)同样不拦', () => {
+ const col = makeColumn({ editor: { type: 'text' } } as never)
+ const { ctx } = makeCtx()
+ const event = makeKeyEvent('Enter', 'zhong', { isComposing: false, keyCode: 229 })
+
+ keydownOf(col, ctx, { id: 1, name: 'zhong', status: 1 })(event)
+
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ expect(event.stopPropagation).not.toHaveBeenCalled()
+ })
+
+ it('isComposing 为真时,候选框里按 ↑↓ 选字不会被当成切格', () => {
+ const col = makeColumn({ editor: { type: 'text' } } as never)
+ const { ctx } = makeCtx()
+ const event = makeKeyEvent('ArrowDown', 'zhong', { isComposing: true })
+
+ keydownOf(col, ctx, { id: 1, name: 'zhong', status: 1 })(event)
+
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ expect(event.stopPropagation).not.toHaveBeenCalled()
+ })
+ })
+
+ /**
+ * F2(规格 §三):已在编辑态时**光标移到末尾**。
+ *
+ * 没有原生行为可冲突,所以不 preventDefault;改前这一支压根不存在(按 F2 没反应)。
+ */
+ it('F2(编辑态):把光标移到末尾', () => {
+ const col = makeColumn({ editor: { type: 'text' } } as never)
+ const { ctx } = makeCtx()
+ const event = makeKeyEvent('F2', 'abc', { caret: 0 })
+
+ keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event)
+
+ // 末尾 = 文本长度
+ expect(event.target.setSelectionRange).toHaveBeenCalledWith(3, 3)
})
it('上下键(多行):必须 stopPropagation,否则会冒到表格导航上切走格子', () => {
@@ -883,6 +1100,117 @@ describe('键盘:Enter 与 Esc', () => {
})
})
+/**
+ * `Esc` 取消对 **select / date** 这一类「立即写回」编辑器同样成立。
+ *
+ * 这一组锁的是两个容易做错的地方:
+ * 1. 取消必须是**两段式** —— 浮层开着时第一记 Esc 归浮层(关下拉 / 关日历),
+ * 第二记才取消整格编辑。判据是 `event.defaultPrevented`(浮层认领了这一记)。
+ * 2. 取消要**只回滚这一格**(`revertCell`),不能整行回滚 —— 这两类编辑器立即
+ * 写回并立即抛 change,同行先前改过的格早已通知过父级,一起回滚会让两侧分叉。
+ */
+describe('键盘:select / date 的 Esc 取消', () => {
+ const selectCol = () =>
+ ({
+ dataIndex: 'status',
+ title: '状态',
+ editor: { type: 'select', options: [{ label: '启用', value: 1 }] },
+ }) as G3TableColumn<Row>
+
+ const dateCol = () =>
+ ({
+ dataIndex: 'name',
+ title: '开航日',
+ editor: { type: 'date' },
+ }) as G3TableColumn<Row>
+
+ /** 造一个只带 Esc 语义需要的字段的按键事件替身 */
+ const escEvent = (defaultPrevented = false) => ({
+ key: 'Escape',
+ target: { value: '', blur: vi.fn() },
+ shiftKey: false,
+ ctrlKey: false,
+ metaKey: false,
+ altKey: false,
+ isComposing: false,
+ keyCode: 0,
+ defaultPrevented,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ })
+
+ const keydownOf = (col: G3TableColumn<Row>, ctx: EditableContext<Row>, row: Row) =>
+ render(col, ctx, row).props.onKeydown as (e: unknown) => void
+
+ it('select:Esc 回滚这一格、退出编辑态、抛 edit-cancel,并把回退值通知父级', () => {
+ const row = { id: 1, name: 'x', status: 1 }
+ const { ctx, revertedCells, changes, canceled } = makeCtx({
+ // 真实场景:revertCell 把这一格改回进入时的值(0)
+ revertCell: (r: Row, col: G3TableColumn<Row>) => {
+ revertedCells.push({ id: r.id, col: col.dataIndex })
+ r[col.dataIndex] = 0 as never
+ return true
+ },
+ })
+ const event = escEvent()
+
+ keydownOf(selectCol(), ctx, row)(event)
+
+ expect(revertedCells).toEqual([{ id: 1, col: 'status' }])
+ expect(row.status).toBe(0)
+ // 父级必须被告知回退,否则它手里留着用户刚选的新值
+ expect(changes.at(-1)?.value).toBe(0)
+ expect(canceled).toEqual([{ id: 1, col: 'status' }])
+ expect(event.preventDefault).toHaveBeenCalled()
+ })
+
+ it('select:值没真变时回滚是空操作,不白抛一次 change', () => {
+ const row = { id: 1, name: 'x', status: 1 }
+ const { ctx, changes } = makeCtx({ revertCell: () => false })
+
+ keydownOf(selectCol(), ctx, row)(escEvent())
+
+ expect(changes).toHaveLength(0)
+ })
+
+ it('select:浮层已用掉这一记 Esc(defaultPrevented)时放手,不取消编辑', () => {
+ const row = { id: 1, name: 'x', status: 1 }
+ const { ctx, revertedCells, canceled } = makeCtx()
+
+ keydownOf(selectCol(), ctx, row)(escEvent(true))
+
+ // 第一记 Esc 只关下拉,编辑态原样保留
+ expect(revertedCells).toHaveLength(0)
+ expect(canceled).toHaveLength(0)
+ })
+
+ it('date:面板已用掉这一记 Esc 时放手;第二记才取消编辑', () => {
+ const row = { id: 1, name: '2026-03-15', status: 1 }
+ const { ctx, revertedCells, canceled } = makeCtx()
+
+ // 第一记:面板开着 → 被面板认领 → 表格不动作
+ keydownOf(dateCol(), ctx, row)(escEvent(true))
+ expect(revertedCells).toHaveLength(0)
+
+ // 第二记:面板已关 → 表格取消编辑
+ keydownOf(dateCol(), ctx, row)(escEvent(false))
+ expect(revertedCells).toEqual([{ id: 1, col: 'name' }])
+ expect(canceled).toEqual([{ id: 1, col: 'name' }])
+ })
+
+ it('date:左右键只 stopPropagation,不取消编辑', () => {
+ const row = { id: 1, name: '2026-03-15', status: 1 }
+ const { ctx, canceled } = makeCtx()
+ const event = { ...escEvent(), key: 'ArrowLeft' }
+
+ keydownOf(dateCol(), ctx, row)(event)
+
+ expect(event.stopPropagation).toHaveBeenCalled()
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ expect(canceled).toHaveLength(0)
+ })
+})
+
/**
* Tab 跳格的目标计算。
*
@@ -979,3 +1307,197 @@ describe('Tab 跳格:findAdjacentEditableCell', () => {
).toBeNull()
})
})
+
+/**
+ * 「左右键这一记,输入框自己消费得了吗」的判据。
+ *
+ * 这是规格 §三 核心原则("input 先消费,消费不了才切格")的**唯一裁决点**:
+ * 判错一侧的后果都是明确的坏体验 ——
+ * · 判成"消费得了"太多 → 在边界按 → 切不了格,横向走不动(改前的行为);
+ * · 判成"消费不了"太多 → 光标无法移动,改不了文本中间那一位。
+ */
+describe('左右键边界判据:inputCanConsumeHorizontal', () => {
+ /** 造一个只带判据所需字段的 input 替身 */
+ const makeInput = (value: string, start: number | null, end: number | null = start) =>
+ ({ value, selectionStart: start, selectionEnd: end }) as unknown as HTMLInputElement
+
+ it('光标在中间:能消费(两个方向都是)', () => {
+ expect(inputCanConsumeHorizontal(makeInput('abc', 1), 'ArrowLeft')).toBe(true)
+ expect(inputCanConsumeHorizontal(makeInput('abc', 1), 'ArrowRight')).toBe(true)
+ })
+
+ it('光标已在最左:← 消费不了,→ 还能往右', () => {
+ expect(inputCanConsumeHorizontal(makeInput('abc', 0), 'ArrowLeft')).toBe(false)
+ expect(inputCanConsumeHorizontal(makeInput('abc', 0), 'ArrowRight')).toBe(true)
+ })
+
+ it('光标已在最右:→ 消费不了,← 还能往左', () => {
+ expect(inputCanConsumeHorizontal(makeInput('abc', 3), 'ArrowRight')).toBe(false)
+ expect(inputCanConsumeHorizontal(makeInput('abc', 3), 'ArrowLeft')).toBe(true)
+ })
+
+ it('值为空:两个方向都消费不了(没有光标位置可移 → 交给表格切格)', () => {
+ expect(inputCanConsumeHorizontal(makeInput('', 0), 'ArrowLeft')).toBe(false)
+ expect(inputCanConsumeHorizontal(makeInput('', 0), 'ArrowRight')).toBe(false)
+ })
+
+ it('存在选区:永远能消费(按方向键是"收起选区",属有效操作)', () => {
+ // 光标贴在末尾、但有选区:原生语义是"把光标收到选区右端",不能被夺走
+ expect(inputCanConsumeHorizontal(makeInput('abc', 3, 1), 'ArrowRight')).toBe(true)
+ expect(inputCanConsumeHorizontal(makeInput('abc', 0, 2), 'ArrowLeft')).toBe(true)
+ })
+
+ it('取不到 selection(旧内核的 number 类型):判为消费不了 → 让切格生效', () => {
+ // 宁可切格,也不要出现"按了完全没反应的死键"
+ expect(inputCanConsumeHorizontal(makeInput('123', null), 'ArrowLeft')).toBe(false)
+ expect(inputCanConsumeHorizontal(makeInput('123', null), 'ArrowRight')).toBe(false)
+ })
+})
+
+/**
+ * 编辑态四向移动的目标计算(规格 §三:方向键「提交 + 切到相邻格并进入编辑」)。
+ *
+ * 与 Tab 的 `findAdjacentEditableCell` **刻意不同**,这一组锁的正是那些差别:
+ * · 纵向(↑↓)走**同列**上下行,跳过不可编辑的行;
+ * · 横向(←→)走**同行**相邻的可编辑列,跳过只读列;
+ * · 横向**到行边界即停**(不回绕)—— 回绕会让一记水平按键产生纵向位移;
+ * · 纵向到表尾 / 表头即停,不循环回另一端。
+ */
+describe('编辑态四向移动:findAdjacentCell', () => {
+ const textCol = (dataIndex: string) =>
+ ({ dataIndex, title: dataIndex, editor: { type: 'text' } }) as G3TableColumn<Row>
+ const readonlyCol = (dataIndex: string) => ({ dataIndex, title: dataIndex }) as G3TableColumn<Row>
+ const rows: Row[] = [
+ { id: 1, name: 'a', status: 1 },
+ { id: 2, name: 'b', status: 0 },
+ { id: 3, name: 'c', status: 1 },
+ ]
+
+ it('down:下一行的同一列', () => {
+ const cols = [textCol('id'), textCol('name'), textCol('status')]
+ expect(findAdjacentCell(rows, cols, { rowIndex: 0, colIndex: 1 }, 'down')).toEqual({
+ rowIndex: 1,
+ colIndex: 1,
+ })
+ })
+
+ it('up:上一行的同一列', () => {
+ const cols = [textCol('id'), textCol('name'), textCol('status')]
+ expect(findAdjacentCell(rows, cols, { rowIndex: 2, colIndex: 1 }, 'up')).toEqual({
+ rowIndex: 1,
+ colIndex: 1,
+ })
+ })
+
+ it('down:跳过该列「按行禁用」的行(落到下一行能改的那一格)', () => {
+ const cols = [
+ textCol('name'),
+ {
+ dataIndex: 'status',
+ title: '状态',
+ editor: { type: 'select', options: [], disabled: (row: Row) => row.status === 0 },
+ } as G3TableColumn<Row>,
+ ]
+ // 第二行 status=0 被禁用 → 从第一行往下应落到第三行
+ expect(findAdjacentCell(rows, cols, { rowIndex: 0, colIndex: 1 }, 'down')).toEqual({
+ rowIndex: 2,
+ colIndex: 1,
+ })
+ })
+
+ it('right / left:同行相邻的可编辑列,跳过只读列', () => {
+ const cols = [textCol('id'), readonlyCol('name'), textCol('status')]
+ expect(findAdjacentCell(rows, cols, { rowIndex: 0, colIndex: 0 }, 'right')).toEqual({
+ rowIndex: 0,
+ colIndex: 2,
+ })
+ expect(findAdjacentCell(rows, cols, { rowIndex: 0, colIndex: 2 }, 'left')).toEqual({
+ rowIndex: 0,
+ colIndex: 0,
+ })
+ })
+
+ it('right 到行尾即停(不回绕到下一行 —— 那是 Tab 的语义)', () => {
+ const cols = [textCol('name'), textCol('status')]
+ expect(findAdjacentCell(rows, cols, { rowIndex: 0, colIndex: 1 }, 'right')).toBeNull()
+ })
+
+ it('left 到行首即停(不回绕到上一行)', () => {
+ const cols = [textCol('name'), textCol('status')]
+ expect(findAdjacentCell(rows, cols, { rowIndex: 1, colIndex: 0 }, 'left')).toBeNull()
+ })
+
+ it('down 到表尾即停(不循环回表头)', () => {
+ const cols = [textCol('name')]
+ expect(findAdjacentCell(rows, cols, { rowIndex: 2, colIndex: 0 }, 'down')).toBeNull()
+ })
+
+ it('up 到表头即停', () => {
+ const cols = [textCol('name')]
+ expect(findAdjacentCell(rows, cols, { rowIndex: 0, colIndex: 0 }, 'up')).toBeNull()
+ })
+
+ it('当前列不是可编辑列时,纵向移动返回 null(没有"同列"可言)', () => {
+ const cols = [readonlyCol('name'), textCol('status')]
+ expect(findAdjacentCell(rows, cols, { rowIndex: 0, colIndex: 0 }, 'down')).toBeNull()
+ })
+
+ it('行 / 列越界时不抛错,返回 null', () => {
+ const cols = [textCol('name')]
+ expect(findAdjacentCell(rows, cols, { rowIndex: 99, colIndex: 0 }, 'down')).toBeNull()
+ expect(findAdjacentCell(rows, cols, { rowIndex: 0, colIndex: 99 }, 'right')).toBeNull()
+ expect(findAdjacentCell([], cols, { rowIndex: 0, colIndex: 0 }, 'up')).toBeNull()
+ })
+})
+
+/**
+ * 草稿的「显式提交」—— 跨行移动不丢通知的修复点。
+ *
+ * 为什么需要它:编辑器只挂在**当前编辑行**上,跨行移动时旧编辑器是**被卸载**的,
+ * 而 DOM 元素被移除时浏览器**不派发 blur**,`onBlur` 那条提交路径整个不执行。
+ * 于是值只剩 `preserveInputDraft` 的兜底(写 raw、不响应式、**不抛事件**)——
+ * 表现为"同一行内跳格会保存、跨行跳格静默丢失通知"。
+ */
+describe('草稿显式提交:commitPendingDraft', () => {
+ it('有草稿时:把值落到行数据并报告已提交', () => {
+ const row: Row = { id: 1, name: '原值', status: 1 }
+ setInputDraft(row, 'name', '改过的')
+
+ const result = commitPendingDraft(row, 'name')
+
+ expect(result).toEqual({ committed: true, value: '改过的' })
+ expect(row.name).toBe('改过的')
+ })
+
+ it('提交后草稿被清掉:再提交一次报告未提交(避免重复抛 change)', () => {
+ const row: Row = { id: 1, name: '原值', status: 1 }
+ setInputDraft(row, 'name', '改过的')
+
+ commitPendingDraft(row, 'name')
+ const second = commitPendingDraft(row, 'name')
+
+ // 第二次没有草稿可提交 —— 调用方据此跳过通知
+ expect(second.committed).toBe(false)
+ })
+
+ it('从未写过草稿时:报告未提交,且不动行数据', () => {
+ const row: Row = { id: 1, name: '原值', status: 1 }
+ const result = commitPendingDraft(row, 'name')
+
+ expect(result.committed).toBe(false)
+ expect(row.name).toBe('原值')
+ })
+
+ it('只提交指定列:同一行的其它列草稿不受影响', () => {
+ const row: Row = { id: 1, name: '原值', status: 1 }
+ setInputDraft(row, 'name', '新名字')
+ setInputDraft(row, 'status', 9)
+
+ commitPendingDraft(row, 'name')
+
+ expect(row.name).toBe('新名字')
+ // status 的草稿还在(下一格自己会提交),行数据此刻不该被改
+ expect(row.status).toBe(1)
+ expect(commitPendingDraft(row, 'status').committed).toBe(true)
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/table/utils/editable.ts b/code/g3soft-libs/packages/ui/src/table/utils/editable.ts
index 4817bd76..2ab1c72f 100644
--- a/code/g3soft-libs/packages/ui/src/table/utils/editable.ts
+++ b/code/g3soft-libs/packages/ui/src/table/utils/editable.ts
@@ -33,6 +33,20 @@ import type {
const stop = (event: Event) => event.stopPropagation()
+/**
+ * 进入编辑态时**插入符该落在哪**(两种状态的交界处,由触发源决定)。
+ *
+ * · `'all'` —— 全选内容。Enter / 打字覆盖 / 双击 走它("直接输入即替换整格");
+ * · `'end'` —— 光标在末尾。F2 走它(Excel 的 F2 语义:改末尾,不覆盖);
+ * · `number` —— 光标落在指定下标。双击落在文字中间时用它;
+ * · `null` —— 不改动插入符,交回编辑器自己决定(select / date / 程序化 editRow)。
+ *
+ * 为什么要有 `null` 这一档:select / date 的"焦点"是触发器而不是文本框,
+ * 对它们做全选没有意义(还会把触发器里的文本选蓝)。让触发源显式表达意图,
+ * 比在每种编辑器里各判一次"我要不要全选"更不容易漏。
+ */
+export type EditCaret = 'all' | 'end' | number
+
/** Input 受控值草稿:row -> (dataIndex -> value) */
const inputDrafts = new WeakMap<object, Map<string, unknown>>()
@@ -46,6 +60,14 @@ export interface EditableContext<T extends TableRow> {
wasFocused: (row: T) => boolean
/** 消费一次「待聚焦」标记(用户激活时置位) */
consumePendingFocus: (row: T) => boolean
+ /**
+ * 消费一次「这一格的插入符该落在哪」(与 consumePendingFocus 同时置位、同时消费)。
+ *
+ * 返回 null 表示"没有指定,交给编辑器自己决定"。与待聚焦标记分开返回、
+ * 一起消费:两者是**同一次激活**的两半信息,用两个独立的消费型 API 容易只消费掉一个,
+ * 于是"第二次进编辑时插入符还停在上一格的位置"。
+ */
+ consumePendingCaret: (row: T, col: G3TableColumn<T>) => EditCaret | null
onEditorFocus: (row: T) => void
onEditorBlur: (row: T) => void
/**
@@ -68,11 +90,27 @@ export interface EditableContext<T extends TableRow> {
/**
* 把该行恢复到「进入编辑态时」的值(Esc 用)。
*
- * 快照由 G3Table 在 activateEditing 时用 captureEditableValues 存下,
+ * 快照由 G3Table 在 `startEdit` 时用 captureEditableValues 存下,
* 与 shouldNotify 去重共用同一份 initialValues。
* 返回是否真的发生了回滚(值本来就没变则返回 false,避免无谓 emit)。
*/
revertRow: (row: T) => boolean
+ /**
+ * 把**当前这一格**恢复成「进入这一格时」的值(Esc 取消,供即时写回类编辑器)。
+ *
+ * 为什么 select / date 不能复用 `revertRow`:
+ * · `revertRow` 回滚的是**整行**,而这两类编辑器是**立即写回 + 立即抛 change** 的 ——
+ * 同一行里先前改过的格早已通知过父级(父级可能已落库),再把它们一起回滚会让
+ * 界面与父级数据分叉;
+ * · 更要紧的是 `revertRow` 读的快照 `initialValues` 会被 `shouldNotify`
+ * **就地更新**(去重要用"上次通知过的值")。对 input 无碍(它在提交前不通知,
+ * 快照还停在进入值),但 select / date 一改就通知,快照当场就被推进到新值 ——
+ * 于是"回滚"会回滚到**刚改的值**,等于没回滚。
+ * 所以这一格单独记一份**不会被去重逻辑改写**的进入值,专供取消使用。
+ *
+ * 返回是否真的发生了回滚(值本来就没变则返回 false)。
+ */
+ revertCell?: (row: T, col: G3TableColumn<T>) => boolean
/**
* 「用户用了这一格的编辑器」→ 把该格同步成表格的当前格(选区 + 激活编辑 + 当前行)。
*
@@ -85,17 +123,26 @@ export interface EditableContext<T extends TableRow> {
*/
onEditorInteract?: (row: T, col: G3TableColumn<T>) => void
/**
- * 「Enter 确认后往下走一格」:移到**下一行的同一列**(跳过不可编辑的行),
- * 返回是否真的移动了(没有下一行可编辑格时返回 false,调用方原地不动)。
+ * 「确认后往某个方向走一格」:编辑态的方向键 / Enter / Tab 都用它。
+ *
+ * 方向语义:
+ * · `down` / `up` —— **同一列的下一行 / 上一行**(跳过该列按行禁用的格子);
+ * · `right` / `left` —— **同一行内相邻的可编辑格**(跳过只读列,不跨行)。
+ *
+ * 返回是否真的移动了(没有可去的格子时返回 false,调用方原地不动)。
*
* 交给 G3Table 实现,是因为移动必须走**改选区**的方式 —— 底座的
* `area-selection-change` 那条链路已经处理好「激活编辑、定位并聚焦编辑器、
- * 滚动进视口」,复用它与 Tab 跳格 / 鼠标点击走同一条路,
+ * 滚动进视口」,复用它与 Tab 跳格 / 鼠标双击走同一条路,
* 避免出现第三套行为(见 G3Table 里 handleDocumentTab 的同一段理由)。
*/
- moveToCellBelow?: (row: T, col: G3TableColumn<T>) => boolean
+ moveToAdjacent?: (
+ row: T,
+ col: G3TableColumn<T>,
+ direction: 'up' | 'down' | 'left' | 'right',
+ ) => boolean
/**
- * 退出编辑态(回到就绪态)。Esc 用。
+ * 退出编辑态(回到选中态)。Esc 用。
*
* 为什么 blur 不够:本组件的编辑态是**行级**的(`activeEditingRow`),
* 输入框失焦只会让编辑器不再持有焦点、**不会**退出编辑。于是 Esc 之后那一行仍在编辑 —
@@ -104,6 +151,24 @@ export interface EditableContext<T extends TableRow> {
* 新设置的草稿读不进去。
*/
exitEditing?: () => void
+ /**
+ * 编辑态**真正开始**(规格 §五 的 `edit-start`):编辑器已挂载且已拿到焦点。
+ *
+ * 只在「用户刚激活这一格」那一刻触发(由 `consumePendingFocus` 判定),
+ * 重渲染引起的重复聚焦不算 —— 否则用户每敲一个字符都可能收到一次 edit-start。
+ */
+ onEditStart?: (row: T, col: G3TableColumn<T>) => void
+ /**
+ * 编辑结束且**值已提交**(规格 §五 的 `edit-end`)。
+ *
+ * 触发点是「这一格不再是当前编辑格」:失焦提交、Enter / Tab / 方向键走开。
+ * 注意与 `value-change` 的区别:选中日期面板里的一天也会改值、但编辑格没变,
+ * 那是 `value-change` 而不是 `edit-end` —— 两个事件回答的是不同问题
+ * (「值变了吗」vs「这一格的编辑结束了吗」)。
+ */
+ onEditEnd?: (row: T, col: G3TableColumn<T>) => void
+ /** 取消编辑(规格 §五 的 `edit-cancel`):Esc,值已还原。 */
+ onEditCancel?: (row: T, col: G3TableColumn<T>) => void
}
/* ---------- 草稿 ---------- */
@@ -114,9 +179,9 @@ function getInputDraft<T extends TableRow>(row: T, dataIndex: string): unknown {
}
/**
- * 写入编辑器草稿。**导出**是因为「就绪态下打字进编辑」要用它:
+ * 写入编辑器草稿。**导出**是因为「选中态下打字进编辑」要用它:
* 表格在进入编辑之前先把用户敲的那个字符放进草稿,编辑器挂载时读到的就是它
- * (覆盖语义,与 Excel 一致)。见 G3Table 的 enterEditingAt。
+ * (覆盖语义,与 Excel 一致)。见 G3Table 的 `startEdit` / `enterEditingWith`.
*/
export function setInputDraft<T extends TableRow>(row: T, dataIndex: string, value: unknown) {
let drafts = inputDrafts.get(row)
@@ -127,6 +192,34 @@ export function setInputDraft<T extends TableRow>(row: T, dataIndex: string, val
drafts.set(dataIndex, value)
}
+/**
+ * 提交某一格尚未落库的草稿(**导出**给 G3Table 用)。
+ *
+ * 为什么需要它 —— 这是「跨行跳格不提交」那个 bug 的修复点:
+ * 编辑器只在**当前编辑行**上挂载,跳格到另一行时旧编辑器是**被卸载**的,
+ * 而 DOM 元素被移除时浏览器**不派发 blur**。于是 `onBlur` 那条提交路径根本不会执行,
+ * 只剩 `preserveInputDraft` 的兜底:它把值写回 `toRaw(row)`,既不触发响应式更新、
+ * 也**不抛 `change`**。
+ *
+ * 表现就是**同一个 Tab 键、行为不一致**:同行跳格正常提交并抛 `change`,
+ * 行尾回绕到下一行时值静默写进 raw 对象 —— 屏幕上看着改了(下次重渲染才显示),
+ * 业务侧却收不到任何通知。
+ *
+ * 所以跳格前必须由 G3Table 显式调一次本函数,把「正在编辑那一格」的输入取出来提交掉。
+ *
+ * 返回值 = 实际从草稿里提交出去的值(没有草稿时返回 `undefined`,调用方据此跳过通知)。
+ */
+export function commitPendingDraft<T extends TableRow>(
+ row: T,
+ dataIndex: string,
+): { committed: boolean; value: unknown } {
+ const drafts = inputDrafts.get(row)
+ if (!drafts?.has(dataIndex)) return { committed: false, value: undefined }
+ const value = drafts.get(dataIndex)
+ commitInputDraft(row, dataIndex, value)
+ return { committed: true, value }
+}
+
function clearInputDraft<T extends TableRow>(row: T, dataIndex: string) {
const drafts = inputDrafts.get(row)
if (!drafts) return
@@ -166,7 +259,7 @@ function resolveOptions<T extends TableRow>(
/**
* 这一格现在改不了吗(`editor.disabled`,可传按行函数)。
*
- * **导出**供 G3Table 用:「就绪态下进编辑」要先判这一条 —— 只读 / 按行禁用的格子
+ * **导出**供 G3Table 用:「选中态下进编辑」要先判这一条 —— 只读 / 按行禁用的格子
* 不该被 Enter / 双击 / 打字带进编辑态(方向键仍可以在它上面停留,见 APG 的 grid pattern)。
*/
export function isEditorDisabled<T extends TableRow>(editor: TableEditor<T>, row: T): boolean {
@@ -440,6 +533,32 @@ function displayValue<T extends TableRow>(
/* ---------- 焦点 ---------- */
+/**
+ * 把插入符摆到指定位置。
+ *
+ * ⚠️ 必须在**聚焦之后**调用:对未聚焦的 input 设置 selection 会被浏览器丢弃
+ * (某些内核上还会抛),先设再聚焦等于没设。
+ *
+ * `setSelectionRange` 在部分 input 类型(旧内核的 `type="number"`)上不存在或抛错,
+ * 所以整段 try/catch 兜住 —— 降级结果只是"光标位置不精确",
+ * 不该连累整格渲染(那是比"光标没到位"严重得多的故障)。
+ */
+function applyCaret<T extends TableRow>(input: HTMLInputElement | null | undefined, caret: EditCaret) {
+ if (!input) return
+ const at = caret === 'all' ? 0 : caret === 'end' ? (input.value?.length ?? 0) : caret
+ if (typeof at !== 'number') return
+ if (caret === 'all') {
+ input.select?.()
+ return
+ }
+ if (typeof input.setSelectionRange !== 'function') return
+ try {
+ input.setSelectionRange(at, at)
+ } catch {
+ // 见上:拿不到精确落点不值得中断
+ }
+}
+
function createFocusHandler<T extends TableRow>(
ctx: EditableContext<T>,
row: T,
@@ -460,19 +579,61 @@ function createFocusHandler<T extends TableRow>(
const wasFocused = Boolean(ctx.wasFocused?.(row))
if (!pending && !wasFocused) return false
- const input = el?.matches?.('input') ? el : el?.querySelector?.('input')
- // 焦点已经在这一格:不算「这次交给它」,否则日期面板会被重复弹开
- if (document.activeElement === input) return false
+ const input = (el?.matches?.('input') ? el : el?.querySelector?.('input')) as
+ | HTMLInputElement
+ | null
+ | undefined
+
+ /**
+ * 插入符落点 = **触发源说了算**。
+ *
+ * `consumePendingCaret` 只在 `pending`(用户刚激活这一格)时问一次:
+ * 重渲染引起的重复聚焦不能改插入符 —— 用户正在打字,光标被挪走比"没全选"严重得多。
+ * 拿不到显式意图时回落到 `selectText`(createInput 传 true)保持既有行为:
+ * 激活即全选,直接输入即替换整格。
+ *
+ * ⚠️ 消费必须发生在下面那个"已聚焦就返回"的**之前**:否则焦点本来就在格子里时
+ * (编辑态下按 F2、双击已在编辑的格子)意图会留在 state 里不被取走,
+ * 等下一次真正重挂编辑器时被消费掉,表现为"光标莫名其妙跳了一下"。
+ */
+ const caret: EditCaret | null = pending
+ ? (ctx.consumePendingCaret?.(row, col) ?? (selectText ? 'all' : null))
+ : null
+
+ /**
+ * 焦点已经在这一格:不算「这次交给它」(否则日期面板会被重复弹开),
+ * 但**显式的插入符意图仍要落实** —— F2 / 双击已在编辑的格子走的正是这条路:
+ * 焦点一动没动,要改的只有光标位置。
+ */
+ if (document.activeElement === input) {
+ if (caret !== null) applyCaret(input, caret)
+ return false
+ }
const instance = component?.exposed || component?.proxy
- const shouldSelect = selectText && (pending || wasFocused)
+ const shouldSelect = caret === 'all' || (caret === null && selectText && wasFocused)
+
+ // 'all' 交给组件自己的 focus({cursor:'all'}):它同时处理"聚焦 + 全选"的时序
const opts = shouldSelect ? { cursor: 'all', preventScroll: true } : { preventScroll: true }
- if (instance?.focus) {
- instance.focus(opts)
- return true
- }
- input?.focus(opts)
- if (shouldSelect) input?.select()
+ if (instance?.focus) instance.focus(opts)
+ else input?.focus(opts)
+
+ // 'end' / 数字落点在聚焦之后写(见 applyCaret 的说明)
+ if (caret !== null && caret !== 'all') applyCaret(input, caret)
+ else if (shouldSelect && !instance?.focus) input?.select()
+
+ /**
+ * `edit-start`:**只有用户刚激活这一格**那一刻才算编辑开始。
+ *
+ * 判定用 `pending`(而不是"这次聚焦成功"):重渲染也会走到这里(`wasFocused` 那支),
+ * 若那时也抛 edit-start,用户每敲一个字符引发的重渲染都会再抛一次 ——
+ * 业务侧按它做"开始记录日志/加锁"就会被反复触发。
+ *
+ * 「同一会话只抛一次」由 G3Table 侧的 `editStartReported` 兜底:
+ * 编辑态下按 F2 会重新置 `pendingFocusKey`(为了把插入符意图送进来),
+ * 那条路径同样满足 `pending`,但会话早就开始了,不该再报一次。
+ */
+ if (pending) ctx.onEditStart?.(row, col)
return true
}
}
@@ -559,32 +720,97 @@ function activeCellClass<T extends TableRow>(
return ctx.shouldFocus(row, col) ? is('cell-editing') : ''
}
-/* 方向键的分工写在 createInput / createDate 的 onKeydown 里(左右归输入框、上下给导航),
+/* 方向键的分工写在 createInput / createDate 的 onKeydown 里(左右按边界放行、上下给导航),
没有共用常量 —— 两处的条件都带类型判断(textarea 例外),抽成集合反而更绕。 */
/**
- * 求「Enter 确认后该去哪一格」:**下一行的同一列**,跳过不可编辑的行。
+ * 「这一记左右键,输入框自己消费得了吗?」—— 消费不了才交给表格切格(见 createInput)。
*
- * 为什么跳过,而不是像 Excel 那样「不管下一格能不能改都落下去」:本组件的
- * 「选中」与「进入编辑」是一体的(点格即挂载编辑器),落到只读格上表现成
- * 「编辑态凭空消失」,比停在原地更让人困惑 —— 与 Tab 跳格跳过只读格同一条理由。
+ * 三种情况**消费不了**(返回 false → 放行给表格):
+ * 1. **值为空**:没有可移的光标位置,按左右键的唯一合理解释就是"去隔壁那格";
+ * 2. **光标已贴在最左 / 最右**(`←` 在 0、`→` 在 length):再按也不会动,等于消费不了;
+ * 3. **取不到 selection**(旧内核的 `type="number"`、或焦点还没落到 input 上):
+ * 此时无法判断边界,宁可让切格生效 —— 否则会出现"按了完全没反应"的死键。
*
- * 扫到表尾没有可编辑格就返回 null(调用方原地不动):与 Excel 到表尾即停一致,
- * 不循环回表头(回车一路按到底突然跳回第一行会很意外)。
+ * ⚠️ 判据必须是 `selectionStart === selectionEnd === 边界` 与**有选区**分开看:
+ * 存在选区(`start !== end`)时按 `→` 的原生语义是"把光标收到选区右端",
+ * 那是有效操作,绝不能夺走 —— 否则"Shift 选中一段再按 → 想取消选区"会变成跳格。
*/
-export function findCellBelow<T extends TableRow>(
+export function inputCanConsumeHorizontal(input: HTMLInputElement, key: 'ArrowLeft' | 'ArrowRight'): boolean {
+ const value = input.value ?? ''
+ // 1) 空值:没有任何光标位置可停,直接让表格接管
+ if (value.length === 0) return false
+
+ const start = input.selectionStart
+ const end = input.selectionEnd
+ // 3) 拿不到 selection(number 类型 / 未聚焦):无法判断边界 → 放行切格
+ if (typeof start !== 'number' || typeof end !== 'number') return false
+
+ // 有选区时按方向键是"收起选区",属有效操作,永远留给输入框
+ if (start !== end) return true
+
+ // 2) 光标贴边:这个方向上再也动不了 → 让表格切格
+ return key === 'ArrowLeft' ? start > 0 : start < value.length
+}
+
+/**
+ * 求「确认 / 方向键之后该去哪一格」—— 编辑态的四向移动。
+ *
+ * 方向语义(规格 §三):
+ * · `up` / `down` —— **同一列的上一行 / 下一行**,跳过该列按行禁用或没有编辑器的格子;
+ * · `left` / `right` —— **同一行内相邻的可编辑格**,跳过只读列。
+ *
+ * ── 为什么 up/down 要跳过「不可编辑的行」,而 left/right 只跳只读列 ──
+ * 目标格接下来要**进入编辑**(挂上编辑器并聚焦)。落到一个没有编辑器的格子上,
+ * 表现就是「编辑态凭空消失、焦点掉到 body」—— 比原地不动更让人困惑。
+ * 所以两个方向都只落在真能接住焦点的格上;差别只在跳过的维度。
+ *
+ * ── 为什么横向**不回绕**(行尾即停)──
+ * Tab 已经承担了「行尾接到下一行」的回绕语义(见 findAdjacentEditableCell)。
+ * 方向键若也回绕,在最后一列按 → 会突然跳到下一行开头 —— 一个水平方向的按键
+ * 产生纵向位移,是纯粹的意外。所以横向到行边界就停住(Excel 在表格区域内同此)。
+ * 纵向到表尾同理即停(不循环回表头,回车一路按到底突然跳回第一行会很意外)。
+ */
+export function findAdjacentCell<T extends TableRow>(
rows: T[],
columns: G3TableColumn<T>[],
from: { rowIndex: number; colIndex: number },
+ direction: 'up' | 'down' | 'left' | 'right',
): { rowIndex: number; colIndex: number } | null {
- const editor = columns[from.colIndex]?.editor as TableEditor<T> | undefined
- if (!editor) return null
- for (let rowIndex = from.rowIndex + 1; rowIndex < rows.length; rowIndex += 1) {
- if (!isEditorDisabled(editor, rows[rowIndex])) return { rowIndex, colIndex: from.colIndex }
+ if (direction === 'up' || direction === 'down') {
+ const editor = columns[from.colIndex]?.editor as TableEditor<T> | undefined
+ if (!editor) return null
+ const step = direction === 'down' ? 1 : -1
+ for (let rowIndex = from.rowIndex + step; rowIndex >= 0 && rowIndex < rows.length; rowIndex += step) {
+ if (!isEditorDisabled(editor, rows[rowIndex])) return { rowIndex, colIndex: from.colIndex }
+ }
+ return null
+ }
+
+ const row = rows[from.rowIndex]
+ if (!row) return null
+ const step = direction === 'right' ? 1 : -1
+ for (let colIndex = from.colIndex + step; colIndex >= 0 && colIndex < columns.length; colIndex += step) {
+ const editor = columns[colIndex]?.editor as TableEditor<T> | undefined
+ if (editor && !isEditorDisabled(editor, row)) return { rowIndex: from.rowIndex, colIndex }
}
return null
}
+/**
+ * 「Enter 确认后该去哪一格」的兼容别名 —— 等价于 `findAdjacentCell(..., 'down')`。
+ *
+ * 保留它是因为这个语义("回车往下走一格")在文档与注释里被反复引用,
+ * 留一个名字自解释的入口比让每处都写 `'down'` 更好读。
+ */
+export function findCellBelow<T extends TableRow>(
+ rows: T[],
+ columns: G3TableColumn<T>[],
+ from: { rowIndex: number; colIndex: number },
+): { rowIndex: number; colIndex: number } | null {
+ return findAdjacentCell(rows, columns, from, 'down')
+}
+
/** 编辑器到 G3Input 的 type 映射:fms 用 antdv 的 inputType,我们用自己的枚举 */
const INPUT_TYPE_MAP: Record<string, 'default' | 'password' | 'number' | 'textarea'> = {
text: 'default',
@@ -659,6 +885,21 @@ function createInput<T extends TableRow>(
* 内部受控值同步,容易与组件的受控渲染打架。
*/
let canceling = false
+ /**
+ * 提交当前输入框的值(落库 + 通知父级)。
+ *
+ * 抽成一个局部函数是因为**四个出口**都要走它:Enter / 上下键 / 左右键边界 /
+ * 失焦。早先 Enter 那条是内联的两行,其余出口各写一遍的后果已经出现过:
+ * 某个出口漏调 `notifyChange`,值落了库但 `change` 没抛出去,业务侧的数据与
+ * 表格显示就此分叉,且极难查("有时候会保存、有时候不会")。
+ *
+ * ⚠️ 必须**显式提交**,不能指望随之而来的失焦:移动会改选区 → 编辑器 vnode
+ * 被卸载,而 DOM 元素被移除时浏览器**不派发 blur**(做日期编辑器时踩过这个坑)。
+ */
+ const commitValue = (value: string) => {
+ commitInputDraft(row, col.dataIndex, value)
+ notifyChange(ctx, editor, row, col, value)
+ }
return h(G3Input, {
type: INPUT_TYPE_MAP[editor.type] ?? 'default',
modelValue: (getInputDraft(row, col.dataIndex) ?? '') as string,
@@ -694,36 +935,70 @@ function createInput<T extends TableRow>(
const input = event.target as HTMLInputElement
/**
- * 左右键:**交给输入框**移动光标 / 选中文本(含 `Shift+左右`)。
+ * 0) 输入法组合期间**一律不拦**(规格 §七.1)。
+ *
+ * 中文 / 日文输入法在候选框上按 Enter 确认候选词时,keydown 是
+ * `key: 'Enter', isComposing: true, keyCode: 229`。不挡的话会命中下面的 Enter 分支:
+ * preventDefault 吃掉这次确认 → 候选词上不了屏,同时提交并把光标跳到下一行。
+ * 同理候选框里的 ↑↓(选字)也会被当成"切行"。
+ *
+ * 两个判据都要:`isComposing` 是标准字段,但**部分浏览器在"确认候选"那一下
+ * 已经把 isComposing 置回 false**,此时只剩 keyCode 229 可用(这也是
+ * Vue / Element Plus / AG Grid 都同时判这两个的原因)。
+ *
+ * 直接 return(不 stopPropagation):组合期间这一记按键本就该归输入法,
+ * 表格的选区导航也不该动 —— 但不阻止冒泡,免得连带影响页面上的组合态处理。
+ */
+ if (event.isComposing || event.keyCode === 229) return
+
+ /**
+ * 1) 左右键:**input 先消费;消费不了才切格**(规格 §三 核心原则)。
+ *
+ * 与早先"一律让给输入框"的差别在**边界放行**上:
+ * · 输入框能消费(有选区,或光标不在边界)→ 让浏览器移光标 / 收缩选区;
+ * · 输入框消费不了(**值为空**,或光标已贴在**最左 / 最右**)→ 移交给表格切格,
+ * 即"在单元格之间横向走",与 Excel 一致(在末尾按 → 直接去右边那格)。
*
- * 这是编辑文本的刚需 —— 想改金额中间一位、想 Shift 选中再覆盖,都离不开它。
- * 所以这两个键 `stopPropagation`,不让事件冒到表格的选区导航上。
+ * 为什么必须有"边界"这一档:早期实现把左右键无条件吞掉,结果是
+ * **在编辑态里永远无法横向换格**(同一个键不可能既移光标又切格 → 当时的选择是
+ * 只移光标,靠 Tab 换列)。但 Tab 要跨行回绕、还会跳过只读列,横向连改几列时
+ * 手感是"跳",而 → 是逐格走。按"光标到边界才放行"能把两种意图都保住:
+ * 文本中间按 → 移光标(不切格),末尾再按 → 去右格。
+ *
+ * `Shift+左右`(选中文本)与 `Ctrl/Alt/Meta+左右`(按词跳 / 系统手势)**永远**
+ * 留给输入框:前者是有效选区操作,后者是平台约定,都不能被切格打断。
*/
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.stopPropagation()
+ if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
+ if (inputCanConsumeHorizontal(input, event.key)) {
+ // 交给浏览器移光标:**不 preventDefault**
+ return
+ }
+ event.preventDefault()
+ commitValue(input.value)
+ ctx.moveToAdjacent?.(row, col, event.key === 'ArrowLeft' ? 'left' : 'right')
return
}
/**
- * 上下键:**单行交给表格导航**(切到上 / 下一行的同一列),**多行留给输入框**。
+ * 2) 上下键:**单行交给表格**(提交 + 上/下行的同列并进入编辑),
+ * **多行留给输入框**(规格 §七.2)。
*
- * 单行输入框里上下键**没有任何原生用途**(不移动光标、也不滚动),拿它做「换行」
- * 是零损失的:改完按 ↓ 直接走到下一行同一列,手不用离开键盘。
- * 左右键则相反(见上)—— 它在输入框里有明确用途,不能让出去。
+ * 单行输入框里上下键没有任何原生用途(不移动光标、也不滚动),让出去零损失;
+ * `textarea` 里它是跨行移动光标,是有效操作。
*
- * 两种去向的条件:
- * · **放行给表格**(`return`,不 stopPropagation):单行、且没按 `Shift`
- * —— 按下 `Shift` 时原生语义是「选中到开头 / 结尾」,那是有效操作;
- * · **留给输入框**(`stopPropagation`):`textarea` 要跨行移动光标、
- * 或单行按了 `Shift`。
- *
- * ⚠️ 第二条的 `stopPropagation` 不能省:只「不主动放行」是不够的 ——
- * 事件照样会冒泡到底座的选区导航上,把格子切走(实测踩到:
- * `textarea` 里按 ↑ 变成跳到上一行的格子里,光标没动)。
+ * ⚠️ 一律 `stopPropagation`:多行那条**只"不主动放行"是不够的** —— 事件照样会
+ * 冒泡到底座的选区导航上把格子切走(实测:`textarea` 里按 ↑ 跳到上一行,光标没动)。
*/
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
- if (editor.type !== 'textarea' && !event.shiftKey) return
event.stopPropagation()
+ if (editor.type === 'textarea') return
+ // 带 Shift / Ctrl 等组合键时是"选中到开头结尾"/平台手势,留给输入框
+ if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
+ event.preventDefault()
+ commitValue(input.value)
+ ctx.moveToAdjacent?.(row, col, event.key === 'ArrowUp' ? 'up' : 'down')
return
}
@@ -744,13 +1019,12 @@ function createInput<T extends TableRow>(
* ⚠️ 必须**显式提交**,不能依赖随之而来的失焦:移动会改选区 → 编辑器 vnode
* 被卸载,而 DOM 元素被移除时浏览器**不派发 blur**(做日期编辑器时踩过这个坑),
* 只靠 onBlur 的话,敲完按回车值就没了。
- * ⚠️ 移动交给 `moveToCellBelow`(内部改选区),与 Tab 跳格同一条链路。
+ * ⚠️ 移动交给 `moveToAdjacent`(内部改选区),与 Tab 跳格、上下键同一条链路。
* 早先这里是 `select()` 全选 —— 那既不是浏览器行为、也不是表格软件的行为,
* 而且**不提交**(改完按回车,值还没落库)。
*/
- commitInputDraft(row, col.dataIndex, input.value)
- notifyChange(ctx, editor, row, col, input.value)
- ctx.moveToCellBelow?.(row, col)
+ commitValue(input.value)
+ ctx.moveToAdjacent?.(row, col, 'down')
} else if (event.key === 'Escape') {
// Esc = **放弃本次编辑**(恢复到进入编辑前的值),符合 Excel / AG Grid /
// Handsontable / antd 的通用约定(Enter 提交、Esc 取消)。
@@ -763,9 +1037,21 @@ function createInput<T extends TableRow>(
ctx.revertRow(row)
clearInputDraft(row, col.dataIndex)
;(event.target as HTMLInputElement).blur()
- // 4) 退出编辑态(回到就绪态)—— 与 Excel 的「Esc 退出编辑」一致。
+ // 4) 退出编辑态(回到选中态)—— 与 Excel 的「Esc 退出编辑」一致。
// blur 本身做不到这件事:编辑态是**行级**的,失焦不等于退出编辑。
ctx.exitEditing?.()
+ // 5) 通知外部:值已还原、编辑被取消(规格 §五 的 edit-cancel)
+ ctx.onEditCancel?.(row, col)
+ } else if (event.key === 'F2') {
+ /**
+ * 编辑态下的 F2:**光标移到末尾**(规格 §三:忽略,或光标移到末尾)。
+ *
+ * 取"移到末尾"而不是彻底忽略:按 F2 的用户意图明确是"我要改这个值",
+ * 让光标落到末尾比"按了没反应"更符合预期,也与 Excel 编辑态下 F2 的手感接近。
+ * 输入框里 F2 没有原生行为,所以不 preventDefault 也不会冲突。
+ */
+ const end = input.value?.length ?? 0
+ input.setSelectionRange?.(end, end)
}
},
onClick: stop,
@@ -775,6 +1061,50 @@ function createInput<T extends TableRow>(
})
}
+/**
+ * Esc 取消编辑 —— 用于**即时写回**类编辑器(`select` / `date`)。
+ *
+ * 为什么与 createInput 的取消不是同一段代码:两者的"值在哪"根本不同。
+ * · `input` 的值在**草稿**里(DOM 专有),取消要先把草稿丢掉、再置 `canceling`
+ * 让随后的 blur 跳过提交 —— 全程没通知过父级,所以**不需要**补通知;
+ * · `select` / `date` 的值**已经写进行数据、并且已经抛过 change 了**
+ * (它们在"确定的时刻"立即回写)。所以取消要做两件事:
+ * 把这一格改回进入时的值,**并且把这次回退也通知父级** ——
+ * 不通知的话父级手里留着用户刚选的新值,界面与父级数据就此分叉。
+ * (`shouldNotify` 的去重不会挡住这次通知:它比的是"上次通知过的值",
+ * 回退值与之不同,判定为真。)
+ *
+ * `event.defaultPrevented` 是**关键判据**:组件自己(G3Select 在下拉展开时、
+ * G3DatePicker 在面板开着时)会 `preventDefault` 认领这一记 Esc ——
+ * 那说明用户此刻要关的是浮层,不是退出编辑。于是天然形成两段式:
+ * **第一记 Esc 关浮层,第二记 Esc 才取消整格编辑**(与 Excel / AG Grid 一致)。
+ */
+function cancelImmediateEdit<T extends TableRow>(
+ ctx: EditableContext<T>,
+ editor: TableEditor<T>,
+ row: T,
+ col: G3TableColumn<T>,
+ event: KeyboardEvent,
+) {
+ if (event.key !== 'Escape' || event.defaultPrevented) return
+ event.preventDefault()
+ event.stopPropagation()
+ /**
+ * 还原这一格。
+ *
+ * 优先走"只回滚这一格"(`revertCell`);上下文没提供时退回整行回滚(向后兼容)。
+ * 两者的取舍见 `revertCell` 的注释 —— 关键是不能用会被去重逻辑推进的快照。
+ */
+ const changed = ctx.revertCell
+ ? ctx.revertCell(row, col)
+ : ctx.revertRow(row)
+ // 只有真的改变了才通知:值本来就没动时抛一次"回退"会让父级白记一笔
+ if (changed) notifyChange(ctx, editor, row, col, row[col.dataIndex])
+ // 退出编辑态(回到选中态)。blur 做不到这件事:编辑态是行级的。
+ ctx.exitEditing?.()
+ ctx.onEditCancel?.(row, col)
+}
+
function createSelect<T extends TableRow>(
row: T,
col: G3TableColumn<T>,
@@ -821,6 +1151,15 @@ function createSelect<T extends TableRow>(
row[col.dataIndex] = resolved as T[keyof T & string]
notifyChange(ctx, editor, row, col, resolved)
},
+ /**
+ * Esc 取消这一格的编辑(规格 §三:取消编辑、还原为原值)。
+ *
+ * 形状天然是**两段式**,不需要额外判据 —— G3Select 在**下拉展开时**会
+ * `preventDefault()` + `stopPropagation()` 认领这记 Esc(关浮层),
+ * 事件因此到不了这里;只有下拉已关时才会冒泡到这个根节点上的处理函数。
+ * 于是:**第一记 Esc 关下拉,第二记 Esc 取消整格编辑**。
+ */
+ onKeydown: (event: KeyboardEvent) => cancelImmediateEdit(ctx, editor, row, col, event),
onClick: stop,
onVnodeMounted: focus,
onVnodeUpdated: focus,
@@ -876,12 +1215,32 @@ function createDate<T extends TableRow>(
},
onClick: stop,
/**
- * 与 createInput 同一套分工:左右归输入框(日期是文本,用户会想改其中一段),
- * 上下不拦、让给表格导航(切行)。
- * 面板打开时焦点在面板里,事件源不在这个输入框上,走不到这里 —— 无需特判。
+ * Esc 取消这一格的编辑(规格 §三),以及左右键的分工。
+ *
+ * ── 左右键 ──
+ * 与 createInput 同一套分工:日期也是**文本**,用户会想改其中一段,
+ * 所以左右键让给输入框移光标(只 stopPropagation)。
+ *
+ * ── Esc 的两段式:第一记关面板,第二记才取消整格编辑 ──
+ * 与 select **刻意不同**:G3Select 在下拉展开时会自己 `stopPropagation()`,
+ * 事件根本到不了这里,两段式是白送的;而 G3DatePicker 的 Esc 分支不拦事件,
+ * 所以它改成了「关掉面板时 `preventDefault()` 认领这一记 Esc」——
+ * 本函数通过 `event.defaultPrevented` 就能知道"这一记已经被面板用掉了"。
+ *
+ * 时序是可靠的:面板的 keydown 处理器挂在**内层 input** 上,本函数挂在组件**根节点**
+ * (`inheritAttrs` 的默认落点),事件先经过内层、再冒泡到根,所以本函数看到的
+ * `defaultPrevented` 一定是面板处理完之后的状态。
+ *
+ * 用"谁认领了这一记按键"而不是"去 DOM 上嗅探面板开着没有"(`is-open` 之类):
+ * 后者要拼出组件内部的类名(跨组件耦合、改样式就悄悄失效),
+ * 而且判不出"焦点在输入框、面板也开着"这种时序窗口。
*/
onKeydown: (event: KeyboardEvent) => {
- if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') event.stopPropagation()
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
+ event.stopPropagation()
+ return
+ }
+ cancelImmediateEdit(ctx, editor, row, col, event)
},
/**
* 挂载时聚焦,**并且**顺手把日历弹开 —— 「点日期格就出日历」是 ERP 里的常规预期