# 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"` | 当前可聚焦 tab(roving) | 与 `aria-selected` 同步 | | `tabindex="-1"` | 不可直接聚焦 tab | 其他所有 tab | | `data-mnote-main-tab="page"` | page tab 标识 | SSR 初始 | | `data-mnote-main-tab=""` | 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 滚动