13 KiB
Proposal: MNote Main Tab 键盘导航与 MRU 行为
日期:2026-05-21
状态:proposed
参考:Sidex
MultiEditorTabsControl、MNoteactivateMainEditorTab/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)。
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。需在末尾追加:
// 同时也绑定 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 一致性。当前实现:
// 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:
// 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 实现已足够:
// 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 样式。添加:
.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
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 结构存在:
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 辅助函数
// 获取当前 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="<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 滚动