# 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="<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 滚动
