3355 lines
173 KiB
Diff
3355 lines
173 KiB
Diff
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 里的常规预期
|