feat(docs): present Markdown actions in a split-button menu
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user