فهرست منبع

feat(docs): present Markdown actions in a split-button menu

gengruilin 1 هفته پیش
والد
کامیت
eb59250ce9

+ 2 - 2
.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md
-2026-09-15-docs-page-markdown-actions.md: cd8791f9491a0cbbe653d09753a4c0c7594970f4
-2026-09-15-docs-page-markdown-actions.zh.md: 211c0c069e4a4c8556458db2d4d226426f419af5
+2026-09-15-docs-page-markdown-actions.md: c7b250d2b5b8285d947f9a5308e938119cc59064
+2026-09-15-docs-page-markdown-actions.zh.md: a9af5150649407efb30de02cc227ce40e77b5fd6

+ 2 - 0
.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md

@@ -12,6 +12,8 @@ Readers need a visible way to obtain one documentation page as Markdown. The pub
 
 The [projector](../../../../scripts/project-doc-site.ts) supplies ordinary content pages with a `rawMarkdownPath` from the publication manifest. The [theme](../../../../website/.vitepress/theme/index.ts) combines that path with the site base and exposes copy and view actions above the document. Directory pages use their full `index.md` route, which preserves the same relative-link location in development and static builds. Redirect homes and missing pages have no actions. Raw output excludes projection metadata.
 
+The primary button copies the page directly. Its adjacent toggle opens a menu with icons, action titles, and explanatory text, keeping the common action visible while grouping Markdown options. Menu items support arrow keys, Home, End, and activation; Escape restores toggle focus, while outside pointers and focus leaving dismiss the menu. Menu state and its outside-pointer listener belong to the page instance.
+
 The [development middleware](../../../../website/raw-markdown.ts) admits explicitly marked `?dsh-raw=1` browser fetches and returns 404 for unpublished raw routes. Script imports always reach Vite. The view link opens the ordinary raw URL in a new tab; copy reads that same projected body on demand. Emitted Markdown files carry a UTF-8 BOM because static hosts can omit the response charset, causing direct browser navigation to misdecode Chinese and other non-ASCII characters. Fetch decoding removes the BOM before copying. Neither operation reconstructs Markdown from the rendered DOM.
 
 The [copy component](../../../../website/.vitepress/theme/page-markdown-actions.ts) calls `clipboard.write` within the click gesture and supplies a promise-backed `text/plain` ClipboardItem. Awaiting the network first would lose user activation in browsers that require it. Each route and language gets its own keyed component instance; disposal aborts unfinished data reads and prevents old writes from changing new-page feedback. A system clipboard write that has already consumed its data cannot be withdrawn. Request and clipboard failures provide localized manual-copy guidance, and success follows the completed write. Denied writes may never consume their data promise, so that promise has its own rejection observer.

+ 2 - 0
.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md

@@ -12,6 +12,8 @@ Status: implemented
 
 [投影器](../../../../scripts/project-doc-site.ts) 根据发布 manifest(元数据清单)为普通内容页提供 `rawMarkdownPath`。[主题](../../../../website/.vitepress/theme/index.ts) 将该路径与站点 base 组合,在正文上方提供复制和查看操作。目录页使用完整的 `index.md` 路由,使相对链接在开发环境和静态构建中保持相同的位置。首页重定向和缺失页面不显示操作。原文输出不包含投影元数据。
 
+主按钮直接复制页面,旁边的展开按钮打开带图标、操作标题和说明文字的菜单,使常用操作保持可见,并将 Markdown 选项放在一起。菜单项支持方向键、Home、End 和激活操作;Escape 将焦点恢复到展开按钮,外部指针操作和焦点离开会关闭菜单。菜单状态及其外部指针监听器归属于页面实例。
+
 [开发中间件](../../../../website/raw-markdown.ts) 接受带有显式 `?dsh-raw=1` 标记的浏览器请求,对未发布的原文路由返回 404。脚本导入始终交给 Vite。查看链接在新标签页打开普通原文 URL;复制按需读取相同的投影正文。生成的 Markdown 文件携带 UTF-8 BOM,因为静态托管可能省略响应字符集,导致浏览器直接访问时错误解码中文等非 ASCII 字符。Fetch 解码会在复制前移除 BOM。两种操作都不从渲染后的 DOM 重建 Markdown。
 
 [复制组件](../../../../website/.vitepress/theme/page-markdown-actions.ts) 在点击手势内调用 `clipboard.write`,并提供由 Promise 承载数据的 `text/plain` ClipboardItem。先等待网络会在要求用户激活的浏览器中丢失该激活状态。每个路由和语言组合都有独立的、带 key 的组件实例;释放时取消未完成的数据读取,防止旧写入改变新页面的反馈。已经消费数据的系统剪贴板写入无法撤回。请求失败和剪贴板失败提供本地化的手动复制指引,成功提示在写入完成后显示。被拒绝的写入可能完全不消费数据 Promise,因此该 Promise 有独立的拒绝处理器。

+ 104 - 18
website/.vitepress/theme/page-markdown-actions.css

@@ -1,45 +1,131 @@
 .page-markdown-actions {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
   margin-bottom: 16px;
-  font-size: 13px;
+  font-size: 14px;
   line-height: 1.5;
-  color: var(--vp-c-text-2);
+  color: var(--vp-c-text-1);
+}
+
+.page-markdown-actions-dropdown {
+  position: relative;
 }
 
 .page-markdown-actions-controls {
-  display: flex;
-  flex-wrap: wrap;
-  justify-content: flex-end;
-  gap: 4px 12px;
+  display: inline-flex;
+  border: 1px solid var(--vp-c-divider);
+  border-radius: 12px;
+  background: var(--vp-c-bg);
 }
 
-.page-markdown-actions-controls :is(button, a) {
+.page-markdown-actions-controls button {
   display: inline-flex;
   align-items: center;
-  gap: 4px;
+  justify-content: center;
   min-height: 36px;
-  padding: 4px 8px;
-  border: 1px solid var(--vp-c-divider);
-  border-radius: 6px;
+}
+
+.page-markdown-copy {
+  gap: 8px;
+  padding: 6px 12px;
+  border-radius: 11px 0 0 11px;
+  font-weight: 500;
+}
+
+.page-markdown-toggle {
+  width: 36px;
+  border-left: 1px solid var(--vp-c-divider);
+  border-radius: 0 11px 11px 0;
   color: var(--vp-c-text-2);
-  background: var(--vp-c-bg);
 }
 
-.page-markdown-actions-controls :is(button, a):hover {
-  color: var(--vp-c-brand-1);
-  border-color: var(--vp-c-brand-1);
+.page-markdown-toggle[aria-expanded="true"] .page-markdown-icon {
+  transform: rotate(180deg);
+}
+
+.page-markdown-icon {
+  width: 18px;
+  height: 18px;
+  flex-shrink: 0;
 }
 
-.page-markdown-actions-controls :is(button, a):focus-visible {
+.page-markdown-actions :is(button, a):focus-visible {
   outline: 2px solid var(--vp-c-brand-1);
-  outline-offset: 3px;
+  outline-offset: 2px;
 }
 
-.page-markdown-actions-controls button:disabled {
+.page-markdown-actions-controls button:hover,
+.page-markdown-menu > :is(button, a):is(:hover, :focus) {
+  background: var(--vp-c-default-soft);
+}
+
+.page-markdown-actions [disabled],
+.page-markdown-actions [aria-disabled="true"] {
   cursor: wait;
   opacity: 0.65;
 }
 
+.page-markdown-menu {
+  position: absolute;
+  z-index: 30;
+  top: calc(100% + 6px);
+  right: 0;
+  width: min(360px, calc(100vw - 48px));
+  padding: 6px;
+  border: 1px solid var(--vp-c-divider);
+  border-radius: 18px;
+  background: var(--vp-c-bg);
+  box-shadow: 0 8px 24px rgb(0 0 0 / 8%);
+}
+
+.page-markdown-menu > :is(button, a) {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  width: 100%;
+  padding: 10px;
+  border-radius: 12px;
+  text-align: left;
+}
+
+.page-markdown-menu-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 36px;
+  height: 36px;
+  flex-shrink: 0;
+  border: 1px solid var(--vp-c-divider);
+  border-radius: 8px;
+}
+
+.page-markdown-menu-text {
+  min-width: 0;
+}
+
+.page-markdown-menu-title {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  font-weight: 600;
+}
+
+.page-markdown-icon-external {
+  width: 14px;
+  height: 14px;
+}
+
+.page-markdown-menu-description {
+  display: block;
+  color: var(--vp-c-text-2);
+  font-size: 13px;
+  line-height: 20px;
+}
+
 .page-markdown-actions-status {
+  color: var(--vp-c-text-2);
+  font-size: 13px;
   text-align: right;
 }
 

+ 120 - 18
website/.vitepress/theme/page-markdown-actions.ts

@@ -1,28 +1,49 @@
 /** Current-page Markdown actions; each mounted instance owns one page's copy request. */
-import { defineComponent, h, onBeforeUnmount, ref } from 'vue'
+import { defineComponent, h, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
 
 type CopyState = 'idle' | 'copying' | 'copied' | 'requestFailed' | 'clipboardFailed'
 
 const messages = {
   en: {
-    copy: 'Copy Markdown',
-    view: 'View Markdown',
-    newTab: 'View Markdown (opens in a new tab)',
+    copy: 'Copy page',
+    copyDescription: 'Copy page as Markdown for LLMs',
+    view: 'View as Markdown',
+    viewDescription: 'View this page as plain text',
+    newTab: 'View as Markdown (opens in a new tab)',
+    menu: 'Page actions',
+    more: 'More page actions',
     copying: 'Copying…',
     copied: 'Markdown copied.',
-    requestFailed: 'Could not load Markdown. Open View Markdown to copy it manually.',
-    clipboardFailed: 'Could not copy. Open View Markdown to copy it manually.',
+    requestFailed: 'Could not load Markdown. Open the page actions menu and choose View as Markdown to copy it manually.',
+    clipboardFailed: 'Could not copy. Open the page actions menu and choose View as Markdown to copy it manually.',
   },
   zh: {
-    copy: '复制 Markdown',
-    view: '查看 Markdown',
-    newTab: '查看 Markdown(在新标签页打开)',
+    copy: '复制页面',
+    copyDescription: '将页面以 Markdown 格式复制给 LLMs',
+    view: '以 Markdown 格式查看',
+    viewDescription: '以纯文本查看此页面',
+    newTab: '以 Markdown 格式查看(在新标签页打开)',
+    menu: '页面操作',
+    more: '更多页面操作',
     copying: '正在复制…',
     copied: '已复制 Markdown。',
-    requestFailed: '无法加载 Markdown,请打开“查看 Markdown”手动复制。',
-    clipboardFailed: '复制失败,请打开“查看 Markdown”手动复制。',
+    requestFailed: '无法加载 Markdown,请打开页面操作菜单,选择“以 Markdown 格式查看”后手动复制。',
+    clipboardFailed: '复制失败,请打开页面操作菜单,选择“以 Markdown 格式查看”后手动复制。',
   },
-} satisfies Record<'en' | 'zh', Record<Exclude<CopyState, 'idle'> | 'copy' | 'view' | 'newTab', string>>
+} satisfies Record<'en' | 'zh', Record<Exclude<CopyState, 'idle'> | 'copy' | 'copyDescription' | 'view' | 'viewDescription' | 'newTab' | 'menu' | 'more', string>>
+
+const icons = {
+  copy: 'M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11',
+  markdown: 'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm2 12V8l3 4 3-4v8m5-8v8m-2-2 2 2 2-2',
+  chevron: 'm6 9 6 6 6-6',
+  external: 'M7 17 17 7M7 7h10v10',
+}
+
+function icon(name: keyof typeof icons) {
+  return h('svg', { class: `page-markdown-icon page-markdown-icon-${name}`, viewBox: '0 0 24 24', fill: 'none',
+    stroke: 'currentColor', 'stroke-width': 1.7, 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
+    'aria-hidden': 'true', focusable: 'false' }, [h('path', { d: icons[name] })])
+}
 
 /**
  * Actions keyed by the owning layout to the current route and language.
@@ -36,15 +57,60 @@ export const PageMarkdownActions = defineComponent({
   },
   setup(props) {
     const state = ref<CopyState>('idle')
+    const open = ref(false)
+    const root = ref<HTMLElement>()
+    const toggle = ref<HTMLButtonElement>()
+    const menuCopy = ref<HTMLButtonElement>()
+    const menuView = ref<HTMLAnchorElement>()
+    const menuId = `page-markdown-${useId()}`
+
+    function closeMenu(restoreFocus = false) {
+      open.value = false
+      if (restoreFocus) toggle.value?.focus()
+    }
+
+    function openMenu(last = false) {
+      open.value = true
+      void nextTick(() => {
+        if (open.value) (last ? menuView.value : menuCopy.value)?.focus()
+      })
+    }
+
+    watch(open, (visible, _previous, onCleanup) => {
+      if (!visible) return
+      const dismiss = (event: PointerEvent) => {
+        if (event.target instanceof Node && !root.value?.contains(event.target)) closeMenu()
+      }
+      document.addEventListener('pointerdown', dismiss, true)
+      onCleanup(() => { document.removeEventListener('pointerdown', dismiss, true) })
+    })
+
+    function menuKeydown(event: KeyboardEvent) {
+      const items = [menuCopy.value, menuView.value]
+      const index = items.findIndex(item => item === event.target)
+      const movement: Partial<Record<string, number>> = {
+        ArrowDown: (index + 1) % items.length, ArrowUp: (index + items.length - 1) % items.length,
+        Home: 0, End: items.length - 1 }
+      const target = movement[event.key]
+      if (target !== undefined) {
+        event.preventDefault()
+        items[target]?.focus()
+      } else if (event.key === ' ' && event.target === menuView.value) {
+        event.preventDefault()
+        menuView.value.click()
+      }
+    }
     let controller: AbortController | undefined
     let disposed = false
     onBeforeUnmount(() => {
       disposed = true
+      open.value = false
       controller?.abort()
     })
 
     async function copy(): Promise<void> {
       if (state.value === 'copying') return
+      if (open.value) closeMenu(true)
       state.value = 'copying'
       const clipboard: Partial<Clipboard> | undefined = (navigator as Partial<Navigator>).clipboard
       if (typeof ClipboardItem === 'undefined' || clipboard?.write === undefined) {
@@ -85,13 +151,49 @@ export const PageMarkdownActions = defineComponent({
 
     return () => {
       const text = messages[props.lang.startsWith('zh') ? 'zh' : 'en']
-      return h('div', { class: 'page-markdown-actions' }, [
-        h('div', { class: 'page-markdown-actions-controls' }, [
-          h('button', { type: 'button', disabled: state.value === 'copying', onClick: copy },
-            state.value === 'copying' ? text.copying : text.copy),
-          h('a', { href: props.path, target: '_blank', rel: 'noopener', 'aria-label': text.newTab }, [
-            text.view, h('span', { 'aria-hidden': 'true' }, ' ↗'),
+      return h('div', {
+        ref: root, class: 'page-markdown-actions',
+        onFocusout: (event: FocusEvent) => {
+          if (!(event.relatedTarget instanceof Node) || !root.value?.contains(event.relatedTarget)) closeMenu()
+        },
+        onKeydown: (event: KeyboardEvent) => {
+          if (open.value && event.key === 'Escape') {
+            event.preventDefault()
+            event.stopPropagation()
+            closeMenu(true)
+          }
+        },
+      }, [
+        h('div', { class: 'page-markdown-actions-dropdown' }, [
+          h('div', { class: 'page-markdown-actions-controls' }, [
+            h('button', { class: 'page-markdown-copy', type: 'button', disabled: state.value === 'copying', onClick: copy },
+              [icon('copy'), state.value === 'copying' ? text.copying : text.copy]),
+            h('button', { ref: toggle, class: 'page-markdown-toggle', type: 'button', 'aria-label': text.more,
+              'aria-haspopup': 'menu', 'aria-expanded': open.value, 'aria-controls': menuId,
+              onClick: () => { if (open.value) closeMenu(); else openMenu() },
+              onKeydown: (event: KeyboardEvent) => {
+                if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+                  event.preventDefault()
+                  openMenu(event.key === 'ArrowUp')
+                }
+              },
+            }, [icon('chevron')]),
           ]),
+          open.value ? h('div', { id: menuId, role: 'menu', 'aria-label': text.menu,
+            class: 'page-markdown-menu', onKeydown: menuKeydown }, [
+            h('button', { ref: menuCopy, role: 'menuitem', type: 'button', tabindex: -1,
+              'aria-label': text.copy, 'aria-describedby': `${menuId}-copy`, 'aria-disabled': state.value === 'copying', onClick: copy }, [
+              h('span', { class: 'page-markdown-menu-icon' }, [icon('copy')]),
+              h('span', { class: 'page-markdown-menu-text' }, [h('span', { class: 'page-markdown-menu-title' }, text.copy),
+                h('span', { id: `${menuId}-copy`, class: 'page-markdown-menu-description' }, text.copyDescription)]),
+            ]),
+            h('a', { ref: menuView, role: 'menuitem', tabindex: -1, href: props.path, target: '_blank', rel: 'noopener',
+              'aria-label': text.newTab, 'aria-describedby': `${menuId}-view`, onClick: () => { closeMenu(true) } }, [
+              h('span', { class: 'page-markdown-menu-icon' }, [icon('markdown')]),
+              h('span', { class: 'page-markdown-menu-text' }, [h('span', { class: 'page-markdown-menu-title' }, [text.view, icon('external')]),
+                h('span', { id: `${menuId}-view`, class: 'page-markdown-menu-description' }, text.viewDescription)]),
+            ]),
+          ]) : null,
         ]),
         h('p', { class: 'page-markdown-actions-status', role: 'status', 'aria-atomic': 'true' },
           state.value === 'idle' ? '' : text[state.value]),

+ 1 - 1
website/tests/expected/page-markdown-actions.en-US.html

@@ -1 +1 @@
-<main><div class="page-markdown-actions"><div class="page-markdown-actions-controls"><button type="button">Copy Markdown</button><a href="/en/guide/quickstart.md" target="_blank" rel="noopener" aria-label="View Markdown (opens in a new tab)">View Markdown<span aria-hidden="true"> ↗</span></a></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
+<main><div class="page-markdown-actions"><div class="page-markdown-actions-dropdown"><div class="page-markdown-actions-controls"><button class="page-markdown-copy" type="button"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg>Copy page</button><button class="page-markdown-toggle" type="button" aria-label="More page actions" aria-haspopup="menu" aria-expanded="false" aria-controls="page-markdown-v-0"><svg class="page-markdown-icon page-markdown-icon-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg></button></div><!----></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>

+ 1 - 1
website/tests/expected/page-markdown-actions.zh-CN.html

@@ -1 +1 @@
-<main><div class="page-markdown-actions"><div class="page-markdown-actions-controls"><button type="button">复制 Markdown</button><a href="/guide/quickstart.md" target="_blank" rel="noopener" aria-label="查看 Markdown(在新标签页打开)">查看 Markdown<span aria-hidden="true"> ↗</span></a></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
+<main><div class="page-markdown-actions"><div class="page-markdown-actions-dropdown"><div class="page-markdown-actions-controls"><button class="page-markdown-copy" type="button"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg>复制页面</button><button class="page-markdown-toggle" type="button" aria-label="更多页面操作" aria-haspopup="menu" aria-expanded="false" aria-controls="page-markdown-v-0"><svg class="page-markdown-icon page-markdown-icon-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg></button></div><!----></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
website/tests/expected/page-markdown-menu.en-US.html


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
website/tests/expected/page-markdown-menu.zh-CN.html


+ 127 - 13
website/tests/page-markdown-actions.spec.ts

@@ -50,6 +50,13 @@ function mount() {
   app.mount(host)
 }
 
+async function openMenu() {
+  const toggle = getByRole(host, 'button', { name: 'More page actions' })
+  fireEvent.click(toggle)
+  await nextTick()
+  return toggle
+}
+
 beforeEach(() => {
   host = document.createElement('div')
   document.body.append(host)
@@ -89,14 +96,120 @@ describe('page Markdown actions', () => {
     data.frontmatter.value = { rawMarkdownPath: `${lang === 'en-US' ? 'en/' : ''}guide/quickstart.md` }
     mount()
     await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-actions.${lang}.html`)
+    fireEvent.click(getByRole(host, 'button', { name: lang === 'en-US' ? 'More page actions' : '更多页面操作' }))
+    await nextTick()
+    await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-menu.${lang}.html`)
+  })
+
+  it('opens from the keyboard, moves between actions, and restores focus on Escape', async () => {
+    mount()
+    const toggle = getByRole(host, 'button', { name: 'More page actions' })
+    toggle.focus()
+    fireEvent.keyDown(toggle, { key: 'ArrowDown' })
+    await nextTick()
+    const copy = getByRole(host, 'menuitem', { name: 'Copy page' })
+    const view = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
+    expect(document.activeElement).toBe(copy)
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+    fireEvent.keyDown(copy, { key: 'ArrowUp' })
+    expect(document.activeElement).toBe(view)
+    fireEvent.keyDown(view, { key: 'ArrowDown' })
+    expect(document.activeElement).toBe(copy)
+    fireEvent.keyDown(copy, { key: 'End' })
+    expect(document.activeElement).toBe(view)
+    fireEvent.keyDown(view, { key: 'Home' })
+    expect(document.activeElement).toBe(copy)
+    fireEvent.keyDown(copy, { key: 'Escape' })
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+    expect(document.activeElement).toBe(toggle)
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.keyDown(toggle, { key: 'ArrowUp' })
+    await nextTick()
+    expect(document.activeElement).toBe(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }))
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('dismisses on an outside pointer, focus leaving, and a second toggle click', async () => {
+    mount()
+    const toggle = await openMenu()
+    fireEvent.pointerDown(document.body)
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+    await openMenu()
+    fireEvent.focusOut(getByRole(host, 'menuitem', { name: 'Copy page' }), { relatedTarget: document.body })
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+    await openMenu()
+    fireEvent.click(toggle)
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+  })
+
+  it('copies from the menu in the same click and prevents another copy while it is pending', async () => {
+    const pending = deferred<Response>()
+    fetchMock.mockReturnValueOnce(pending.promise)
+    mount()
+    const toggle = await openMenu()
+    fireEvent.click(getByRole(host, 'menuitem', { name: 'Copy page' }))
+    expect(write).toHaveBeenCalledOnce()
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+    expect(document.activeElement).toBe(toggle)
+    await openMenu()
+    const copy = getByRole(host, 'menuitem', { name: 'Copy page' })
+    expect(copy.getAttribute('aria-disabled')).toBe('true')
+    fireEvent.click(copy)
+    expect(write).toHaveBeenCalledOnce()
+    expect(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }).getAttribute('href')).toBe('/en/guide/quickstart.md')
+    pending.resolve(response())
+    await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
+  })
+
+  it('opens the raw link from the menu and returns focus to the toggle', async () => {
+    mount()
+    const toggle = await openMenu()
+    const view = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
+    view.addEventListener('click', (event) => { event.preventDefault() }, { once: true })
+    fireEvent.keyDown(view, { key: ' ' })
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+    expect(document.activeElement).toBe(toggle)
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('removes outside-pointer listeners and closes its menu when the route changes', async () => {
+    mount()
+    const add = vi.spyOn(document, 'addEventListener')
+    const remove = vi.spyOn(document, 'removeEventListener')
+    try {
+      await openMenu()
+      const listener = add.mock.calls.find(([name]) => name === 'pointerdown')?.[1]
+      expect(listener).toBeTypeOf('function')
+      route.path = '/en/reference/'
+      data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
+      await nextTick()
+      expect(queryByRole(host, 'menu')).toBeNull()
+      expect(remove).toHaveBeenCalledWith('pointerdown', listener, true)
+      await openMenu()
+      app?.unmount()
+      app = undefined
+      expect(remove.mock.calls.filter(([name]) => name === 'pointerdown')).toHaveLength(2)
+      fireEvent.pointerDown(document.body)
+      expect(host.textContent).toBe('')
+    } finally {
+      add.mockRestore()
+      remove.mockRestore()
+    }
   })
 
-  it.each(['/', '/deepseek-harness/'])('uses the manifest index route under base %s, independent of the visible URL', (base) => {
+  it.each(['/', '/deepseek-harness/'])('uses the manifest index route under base %s, independent of the visible URL', async (base) => {
     data.site.value = { base }
     data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
     route.path = `${base}en/reference/index.html?from=nav#api`
     mount()
-    const link = getByRole(host, 'link', { name: 'View Markdown (opens in a new tab)' })
+    await openMenu()
+    const link = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
     expect(link.getAttribute('href')).toBe(`${base}en/reference/index.md`)
     expect(link.getAttribute('target')).toBe('_blank')
     expect(fetchMock).not.toHaveBeenCalled()
@@ -114,7 +227,7 @@ describe('page Markdown actions', () => {
     const pending = deferred<Response>()
     fetchMock.mockReturnValue(pending.promise)
     mount()
-    const button = getByRole(host, 'button', { name: 'Copy Markdown' })
+    const button = getByRole(host, 'button', { name: 'Copy page' })
     fireEvent.click(button)
     expect(write).toHaveBeenCalledOnce()
     expect(copied).toBeUndefined()
@@ -140,17 +253,17 @@ describe('page Markdown actions', () => {
     if (failure === 'network') fetchMock.mockRejectedValueOnce(new Error('offline'))
     else fetchMock.mockResolvedValueOnce(response('not Markdown', failure === '404' ? 404 : 200, failure === 'html' ? 'text/html' : 'text/markdown'))
     mount()
-    fireEvent.click(getByRole(host, 'button'))
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
     await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
     expect(copied).toBeUndefined()
-    fireEvent.click(getByRole(host, 'button'))
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
     await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
   })
 
   it('accepts static hosting that serves Markdown as plain text', async () => {
     fetchMock.mockResolvedValueOnce(response('# Plain text hosting\n', 200, 'text/plain'))
     mount()
-    fireEvent.click(getByRole(host, 'button'))
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
     await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
   })
 
@@ -159,7 +272,7 @@ describe('page Markdown actions', () => {
     vi.spyOn(raw, 'text').mockRejectedValueOnce(new Error('Connection closed'))
     fetchMock.mockResolvedValueOnce(raw)
     mount()
-    fireEvent.click(getByRole(host, 'button'))
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
     await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
     expect(copied).toBeUndefined()
   })
@@ -168,7 +281,7 @@ describe('page Markdown actions', () => {
     const writeDone = deferred<void>()
     write.mockImplementationOnce(() => writeDone.promise)
     mount()
-    fireEvent.click(getByRole(host, 'button'))
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
     route.path = '/en/reference/'
     data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
     await nextTick()
@@ -182,10 +295,11 @@ describe('page Markdown actions', () => {
     if (missing === 'item') vi.stubGlobal('ClipboardItem', undefined)
     else vi.stubGlobal('navigator', {})
     mount()
-    fireEvent.click(getByRole(host, 'button'))
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
     await nextTick()
     expect(getByRole(host, 'status').textContent).toContain('Could not copy.')
-    expect(getByRole(host, 'link')).toBeTruthy()
+    await openMenu()
+    expect(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })).toBeTruthy()
     expect(fetchMock).not.toHaveBeenCalled()
   })
 
@@ -194,7 +308,7 @@ describe('page Markdown actions', () => {
     fetchMock.mockReturnValue(pending.promise)
     write.mockRejectedValueOnce(new DOMException('Denied', 'NotAllowedError'))
     mount()
-    fireEvent.click(getByRole(host, 'button'))
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
     await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not copy.') })
     expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
     pending.reject(new DOMException('Aborted', 'AbortError'))
@@ -207,7 +321,7 @@ describe('page Markdown actions', () => {
     const pending = deferred<Response>()
     fetchMock.mockReturnValueOnce(pending.promise)
     mount()
-    fireEvent.click(getByRole(host, 'button'))
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
     const oldWrite = activeWrite
     assert(oldWrite !== undefined)
     const signal = fetchMock.mock.calls[0]?.[1]?.signal
@@ -226,7 +340,7 @@ describe('page Markdown actions', () => {
     if (change === 'unmount') expect(host.textContent).toBe('')
     else {
       expect(getByRole(host, 'status').textContent).toBe('')
-      fireEvent.click(getByRole(host, 'button', { name: change === 'route' ? 'Copy Markdown' : '复制 Markdown' }))
+      fireEvent.click(getByRole(host, 'button', { name: change === 'route' ? 'Copy page' : '复制页面' }))
       await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe(change === 'route' ? 'Markdown copied.' : '已复制 Markdown。') })
       expect(fetchMock.mock.calls[1]?.[0]).toBe(change === 'route' ? '/en/reference/index.md?dsh-raw=1' : '/guide/quickstart.md?dsh-raw=1')
     }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است