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

This commit is contained in:
gengruilin committed 2026-09-15 16:52:46 +08:00
1 parent cb464f1894
commit eb59250ce9
10 files changed
+364 -56

No files matched your search

@@ -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
@@ -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.
@@ -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 有独立的拒绝处理器。
@@ -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;
}
.page-markdown-actions-controls :is(button, a) {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 36px;
padding: 4px 8px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
color: var(--vp-c-text-2);
border-radius: 12px;
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-actions-controls button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
}
.page-markdown-actions-controls :is(button, a):focus-visible {
.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);
}
.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 :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
View File
@@ -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 @@
<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 @@
<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 +1 @@
<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="true" 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 id="page-markdown-v-0" role="menu" aria-label="Page actions" class="page-markdown-menu"><button role="menuitem" type="button" tabindex="-1" aria-label="Copy page" aria-describedby="page-markdown-v-0-copy" aria-disabled="false"><span class="page-markdown-menu-icon"><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></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">Copy page</span><span id="page-markdown-v-0-copy" class="page-markdown-menu-description">Copy page as Markdown for LLMs</span></span></button><a role="menuitem" tabindex="-1" href="/en/guide/quickstart.md" target="_blank" rel="noopener" aria-label="View as Markdown (opens in a new tab)" aria-describedby="page-markdown-v-0-view"><span class="page-markdown-menu-icon"><svg class="page-markdown-icon page-markdown-icon-markdown" 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="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"></path></svg></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">View as Markdown<svg class="page-markdown-icon page-markdown-icon-external" 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="M7 17 17 7M7 7h10v10"></path></svg></span><span id="page-markdown-v-0-view" class="page-markdown-menu-description">View this page as plain text</span></span></a></div></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
@@ -0,0 +1 @@
<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="true" 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 id="page-markdown-v-0" role="menu" aria-label="页面操作" class="page-markdown-menu"><button role="menuitem" type="button" tabindex="-1" aria-label="复制页面" aria-describedby="page-markdown-v-0-copy" aria-disabled="false"><span class="page-markdown-menu-icon"><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></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">复制页面</span><span id="page-markdown-v-0-copy" class="page-markdown-menu-description">将页面以 Markdown 格式复制给 LLMs</span></span></button><a role="menuitem" tabindex="-1" href="/guide/quickstart.md" target="_blank" rel="noopener" aria-label="以 Markdown 格式查看(在新标签页打开)" aria-describedby="page-markdown-v-0-view"><span class="page-markdown-menu-icon"><svg class="page-markdown-icon page-markdown-icon-markdown" 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="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"></path></svg></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">以 Markdown 格式查看<svg class="page-markdown-icon page-markdown-icon-external" 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="M7 17 17 7M7 7h10v10"></path></svg></span><span id="page-markdown-v-0-view" class="page-markdown-menu-description">以纯文本查看此页面</span></span></a></div></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
+127 -13
View File
@@ -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.each(['/', '/deepseek-harness/'])('uses the manifest index route under base %s, independent of the visible URL', (base) => {
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', 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')
}