align workbench open editors tabs

This commit is contained in:
lix-2026
2026-05-21 05:57:19 +08:00
parent eba1010191
commit fd94792ea6
12 changed files with 897 additions and 2 deletions
@@ -0,0 +1,339 @@
# 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 滚动