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

13 KiB
Raw Blame History

Proposal: MNote Main Tab 键盘导航与 MRU 行为

日期:2026-05-21

状态:proposed

来源:Reasonix Worker B

参考:Sidex MultiEditorTabsControl、MNote activateMainEditorTab / bindMainEditorPageTab / resourceTabMru


1. 目标

在现有主编辑区 tab strip 上增加轻量键盘导航和 MRU 回退行为,不引入完整 VSCode tab control 体系。

行为 Sidex 做法 MNote 对应
左右箭头 roving focus KEY_UPArrowLeft/Right 计算相邻 editorIndex tab strip keydown → 循环 resourceTabRegistry + page tab
Enter/Space 激活 KEY_UPgroupView.openEditor(editor) activateMainEditorTab(objectIdentity)
Home/End 跳转 计算 editorIndex=0count-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 而非 keyupSidex 用 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. 需新增的 CSSstyles.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 可见顺序(而非插入顺序),则 createResourceTabDomnodes.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" 当前可聚焦 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.forEachV8 中 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 滚动