Files
mnote/.codex/reasonix-proposals/2026-05-21-tab-keyboard-mru-proposal.md
T

340 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Proposal: MNote Main Tab 键盘导航与 MRU 行为
> 日期:2026-05-21
>
> 状态:proposed
>
> 来源:[Reasonix Worker B](../../.codex/reasonix-tasks/2026-05-21-sidex-worker-b-tab-keyboard-mru.md)
>
> 参考:Sidex `MultiEditorTabsControl`、MNote `activateMainEditorTab` / `bindMainEditorPageTab` / `resourceTabMru`
---
## 1. 目标
在现有主编辑区 tab strip 上增加轻量键盘导航和 MRU 回退行为,不引入完整 VSCode tab control 体系。
| 行为 | Sidex 做法 | MNote 对应 |
|------|-----------|-----------|
| 左右箭头 roving focus | `KEY_UP``ArrowLeft/Right` 计算相邻 editorIndex | tab strip `keydown` → 循环 `resourceTabRegistry` + page tab |
| Enter/Space 激活 | `KEY_UP``groupView.openEditor(editor)` | `activateMainEditorTab(objectIdentity)` |
| Home/End 跳转 | 计算 `editorIndex=0``count-1` | 跳转到 page tab (Home) 或最后一个 resource tab (End) |
| 关闭后 MRU 回退 | VSCode 的 `MRU` 排序 | 已有 `lastActiveResourceTabKey()`,需确认行为正确 |
| 关闭 active tab 后 focus 迁移 | 激活 MRU 中下一个 tab | `closeResourceTab` 最后调用 `activateMainEditorTab(lastActiveResourceTabKey())` |
---
## 2. 需要修改的源码函数
所有修改均位于 `rust/crates/mnote-web/src/routes/web_shell.rs`
### 2.1 新增函数:`bindMainEditorTabStrip()`
**位置**:紧接 `bindMainEditorPageTab`(约 line 3450)。
```javascript
const bindMainEditorTabStrip = () => {
const nodes = resourceTabHostNodes();
if (!(nodes.strip instanceof HTMLElement)) return;
if (nodes.strip.getAttribute('data-mnote-tab-strip-bound') === 'true') return;
nodes.strip.setAttribute('data-mnote-tab-strip-bound', 'true');
// Helper: collect all tab buttons in DOM order (page tab first, then resource tabs)
const collectTabs = () => {
const tabs = [];
if (nodes.pageTab instanceof HTMLElement && nodes.pageTab.isConnected) tabs.push(nodes.pageTab);
resourceTabRegistry.forEach((entry) => {
if (entry.tab instanceof HTMLElement && entry.tab.isConnected) tabs.push(entry.tab);
});
return tabs;
};
// Helper: find currently focused tab index
const findFocusedIndex = (tabs) => {
const active = document.activeElement;
return tabs.findIndex((tab) => tab === active);
};
nodes.strip.addEventListener('keydown', (event) => {
const tabs = collectTabs();
if (tabs.length === 0) return;
const focusedIndex = findFocusedIndex(tabs);
let targetIndex = -1;
switch (event.key) {
case 'ArrowRight': {
event.preventDefault();
targetIndex = focusedIndex >= 0
? (focusedIndex + 1) % tabs.length
: tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
if (targetIndex < 0) targetIndex = 0;
break;
}
case 'ArrowLeft': {
event.preventDefault();
targetIndex = focusedIndex >= 0
? (focusedIndex - 1 + tabs.length) % tabs.length
: tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
if (targetIndex < 0) targetIndex = 0;
break;
}
case 'Home': {
event.preventDefault();
targetIndex = 0;
break;
}
case 'End': {
event.preventDefault();
targetIndex = tabs.length - 1;
break;
}
case 'Enter':
case ' ': { // Space
event.preventDefault();
const activeTab = tabs[focusedIndex >= 0 ? focusedIndex
: tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true')];
if (activeTab) activeTab.click();
return;
}
default:
return; // don't re-focus
}
if (targetIndex >= 0 && targetIndex < tabs.length) {
tabs[targetIndex].focus();
}
});
};
```
**设计理由**
- 使用 `keydown` 而非 `keyup`Sidex 用 `KEY_UP` 是为了与 `StandardKeyboardEvent` 兼容,但 MNote 无此抽象):`keydown` 捕获更快、防止浏览器默认滚动。
- `collectTabs()` 每次实时查询 DOM 顺序,避免缓存过期。
- `Enter`/`Space` 复用已有 `click` 事件处理(`createResourceTabDom` 中已有 click handler)。
- 只处理 `strip` 上的事件,利用事件冒泡获取已在 DOM 内的所有 tab。
### 2.2 修改 `bindMainEditorPageTab()`(约 line 3450
当前函数只绑定 page tab 的 `click`。需在末尾追加:
```javascript
// 同时也绑定 tab strip 键盘导航
bindMainEditorTabStrip();
```
或在 `bindMainEditorPageTab` 返回前调用一次。
### 2.3 修改 `createResourceTabDom()`(约 line 3507
新创建的 resource tab 已有 `tabindex="-1"` 初始值。**无需额外修改**,因为 `activateMainEditorTab` 已正确设置 active tab 的 `tabindex="0"`,其他 tab `tabindex="-1"`
但需要确认:`activateMainEditorTab` 遍历 `resourceTabRegistry` 时,**page tab 和 resource tab 的 `tabindex` 一致性**。当前实现:
```javascript
// activateMainEditorTab 中(line 3428-3442
if (nodes.pageTab instanceof HTMLElement) {
nodes.pageTab.setAttribute('tabindex', activePage ? '0' : '-1');
}
// resource tabs:
entry.tab.setAttribute('tabindex', active ? '0' : '-1');
```
→ 已满足 roving tabindex 的前置条件。**无需修改**。
### 2.4 修改 `closeResourceTab()`(约 line 3461
当前关闭后通过 `activateMainEditorTab(lastActiveResourceTabKey())` 回退。但 focus 未迁移到新激活的 tab。需在 `activateMainEditorTab` 调用后**追加 focus**
```javascript
// closeResourceTab 末尾,将 focus 移到新激活的 tab
const nextKey = lastActiveResourceTabKey();
activateMainEditorTab(nextKey);
// 新激活 tab 获得 focus
const nextTab = nextKey
? resourceTabRegistry.get(nextKey)?.tab
: nodes.pageTab;
if (nextTab instanceof HTMLElement) nextTab.focus();
```
### 2.5 确认 MRU 行为(`touchResourceTabMru` + `lastActiveResourceTabKey`
现有 MRU 实现已足够:
```javascript
// touchResourceTabMru (line 3289-3295)
const touchResourceTabMru = (key) => {
const id = String(key || '').trim();
if (!id) return;
const index = resourceTabMru.indexOf(id);
if (index >= 0) resourceTabMru.splice(index, 1);
resourceTabMru.unshift(id);
if (resourceTabMru.length > resourceTabMruMax) resourceTabMru.length = resourceTabMruMax;
};
// lastActiveResourceTabKey (line 3309-3313)
const lastActiveResourceTabKey = () => {
for (const key of resourceTabMru) {
if (resourceTabRegistry.has(key)) return key;
}
return '';
};
```
**行为验证**
- 激活一个 tab → `touchResourceTabMru(key)` 将其移到队列头部
- 关闭当前 tab → `removeFromResourceTabMru(key)` 移除 → `lastActiveResourceTabKey()` 返回 MRU 中第一个仍存在 registry 的 key
- 如果没有任何 resource tab 存活 → 返回 `''``activateMainEditorTab('')` 激活 page tab
**可用,无需修改 MRU 核心逻辑**
---
## 3. 需新增的 CSS`styles.rs`
### 3.1 Focus 可见样式
当前 `mnote-main-tab` 没有 `:focus-visible` 样式。添加:
```css
.mnote-main-tab:focus-visible {
outline: 2px solid var(--atelier-primary, #006E28);
outline-offset: -2px;
}
```
### 3.2 可选:resource tab DOM 顺序保证
如果 resource tab 需要在 DOM 中保持 MRU 可见顺序(而非插入顺序),则 `createResourceTabDom``nodes.strip.append(tab)` 需改为按 MRU 顺序插入。但**当前不要求**——tabs 按打开顺序排列,MRU 只控制回退。
---
## 4. 需要更新的 contract test
文件:`rust/crates/mnote-web/src/routes/web_shell.rs` 末尾的 Rust 单元测试(约 line 4528+)。
### 4.1 新增 assert
```rust
assert!(html.contains("data-mnote-tab-strip-bound"));
assert!(html.contains("bindMainEditorTabStrip"));
```
即检查生成的 JS 中包含 `bindMainEditorTabStrip` 函数的定义字符串和 `data-mnote-tab-strip-bound` guard attribute。
### 4.2 已有断言确认
以下已有断言确保关键 tab DOM 结构存在:
```rust
assert!(html.contains("data-mnote-main-tab-strip")); // line 4538
assert!(html.contains("[data-mnote-main-tab=\"page\"] .mnote-main-tab-title")); // line 4541
```
---
## 5. 需要新增的 smoke test
新建 `scripts/task475-editor-tab-keyboard-mru-smoke.js`(参考 `task457` 的 Playwright harness 写法)。
### 5.1 测试断言
| # | 断言 | 方法 |
|---|------|------|
| 1 | tab strip 可接收 keyboard focus | 在 strip 上 `page.keyboard.press('ArrowRight')` 后检查 `document.activeElement` 属于 `[data-mnote-main-tab]` |
| 2 | ArrowRight 循环聚焦 | focus page tab → ArrowRight → focused tab 应为第一个 resource tab |
| 3 | ArrowLeft 反向循环 | focus 第二个 resource tab → ArrowLeft → focused tab 应为第一个 resource tab |
| 4 | Home 跳到第一个 tab | focus 任意 resource tab → Home → focused 应为 page tab |
| 5 | End 跳到最后一个 tab | focus page tab → End → focused 应为最后一个 resource tab |
| 6 | Enter 激活 focused tab | ArrowRight 到 resource tab → Enter → 该 tab 应 `aria-selected="true"` |
| 7 | Space 激活 focused tab | 同上,使用 Space |
| 8 | 关闭 active tab 后 focus 迁移 | 打开 2 个 resource tab,激活第二个 → close → focus 应回到 MRU 中的第一个 |
| 9 | 关闭最后一个 resource tab 后回退 page tab | 打开 1 个 resource tab → close → page tab 应激活且 focused |
| 10 | `resourceTabMru` 顺序正确 | 打开 tab A、B、C,激活 B → 检查 MRU 数组头部是 B |
### 5.2 辅助函数
```javascript
// 获取当前 focused tab 的 identity
const getActiveTabId = () => page.evaluate(() => {
const active = document.activeElement;
return active?.getAttribute('data-mnote-main-tab') || null;
});
// 检查 aria-selected
const getSelectedTabId = () => page.evaluate(() => {
const selected = document.querySelector('[data-mnote-main-tab][aria-selected="true"]');
return selected?.getAttribute('data-mnote-main-tab') || null;
});
// 获取 MRU 队列
const getMruQueue = () => page.evaluate(() => {
// 通过 window 对象暴露(需在 web_shell.rs 中给 window.__mnoteResourceTabMru 赋值)
return window.__mnoteResourceTabMru || [];
});
```
---
## 6. DOM 状态总结
执行后,main tab 的 DOM 状态契约:
| Attribute / Property | 用途 | 取值 |
|----------------------|------|------|
| `data-mnote-main-tab-strip` | tab strip 根元素 | `"true"` |
| `data-mnote-tab-strip-bound` | 防止重复绑定 keyboard handler | `"true"`JS 运行时设置) |
| `aria-selected="true"` | 当前激活 tab | 仅一个 tab 有 |
| `tabindex="0"` | 当前可聚焦 tabroving | 与 `aria-selected` 同步 |
| `tabindex="-1"` | 不可直接聚焦 tab | 其他所有 tab |
| `data-mnote-main-tab="page"` | page tab 标识 | SSR 初始 |
| `data-mnote-main-tab="<identity>"` | resource tab 标识 | JS 创建 |
| `class="mnote-main-tab"` | 所有 tab 共享 class | 全部 |
| `class="is-active"` | 当前激活 tab | 与 `aria-selected` 同步 |
| `role="tab"` | ARIA tab role | 全部 |
---
## 7. 风险与边界情况
| 风险 | 影响 | 缓解 |
|------|------|------|
| keyboard 事件与编辑器快捷键冲突 | ArrowLeft/Right 被编辑区拦截 | 事件只在 `focus` 位于 tab strip 内时触发(`document.activeElement` 检查) |
| `resourceTabRegistry` 遍历顺序不确定 | `collectTabs()` 中 tab 顺序可能乱序 | `collectTabs()` 顺序:page tab 固定首位 + `resourceTabRegistry.forEach`V8 中 `Map.forEach` 按插入顺序) |
| 关闭 tab 时 DOM 元素刚被 remove | `isConnected` 检查失败 | `closeResourceTab` 需要先查 nextTab,再删当前 tab 或重新查询 |
| 多个 resource tab 同时关闭 | MRU 队列清空后无回退 | `lastActiveResourceTabKey()` 返回 `''` 已正确处理 |
| 与 Worker A 的并行修改冲突 | `web_shell.rs` 中函数顺序和行号漂移 | 本 proposal 以函数名定位,不依赖行号 |
---
## 8. 实现顺序(最小安全步骤)
```
Step 1: 在 bindMainEditorPageTab 后新增 bindMainEditorTabStrip 函数
实现 keydown 事件委托 + collectTabs + 箭头/Home/End/Enter/Space
Step 2: 修改 closeResourceTab 末尾
在 activateMainEditorTab 后将 focus 移到新激活 tab
Step 3: 在 styles.rs 中补 :focus-visible 样式
Step 4: 更新 Rust contract test(新增 bindMainEditorTabStrip / data-mnote-tab-strip-bound 断言)
Step 5: 新建 scripts/task475-editor-tab-keyboard-mru-smoke.js
实现 10 个测试断言
Step 6: 验收
cargo test -p mnote-web web_shell -- --test-threads=1
node scripts/task475-editor-tab-keyboard-mru-smoke.js
```
---
## 9. 非目标(明确不做的)
- 不实现 tab 拖拽排序
- 不实现 `Ctrl+Tab` / `Ctrl+Shift+Tab` 的 MRU 循环(那是全局快捷键,不是 tab strip keyboard 行为)
- 不实现 close others / close right 菜单
- 不改变 page/resource tab identity 机制
- 不添加 tab overflow 滚动