fix(desktop): 重构菜单并恢复 Shiki 高亮

This commit is contained in:
2026-09-07 23:35:43 +08:00
parent a4be2d98f4
commit acc0ba167d
10 changed files with 325 additions and 100 deletions
@@ -7,7 +7,11 @@ import TitleBarMenu from './TitleBarMenu.vue'
const execute = vi.hoisted(() => vi.fn())
const capabilities = vi.hoisted(() => vi.fn())
vi.mock('@/services/editorCommandService', () => ({ executeEditorCommand: execute, getEditorCommandCapabilities: capabilities }))
vi.mock('@/services/editorCommandService', () => ({
executeEditorCommand: execute,
getEditorCommandCapabilities: capabilities,
subscribeEditorCommandCapabilities: () => () => undefined,
}))
beforeEach(() => {
setActivePinia(createPinia())
@@ -15,6 +19,7 @@ beforeEach(() => {
capabilities.mockReturnValue([
{ id: 'editor.paragraph', supported: true, enabled: true },
{ id: 'editor.heading', supported: true, enabled: true },
{ id: 'editor.callout', supported: true, enabled: true },
{ id: 'editor.import-note-properties', supported: true, enabled: true },
])
})
@@ -40,8 +45,8 @@ describe('桌面顶部段落菜单', () => {
const editor = useEditorStore()
editor.mode = 'wysiwyg'
editor.currentFilePath = '/示例.md'
const wrapper = mount(TitleBarMenu)
capabilities.mockReturnValue([])
const wrapper = mount(TitleBarMenu)
await wrapper.get('[data-menu="paragraph"] .menu-trigger').trigger('click')
expect((wrapper.get('.import-properties').element as HTMLButtonElement).disabled).toBe(true)
expect(wrapper.text()).toContain('请在无冲突的 Markdown 源码笔记中使用')
@@ -62,9 +67,29 @@ describe('桌面顶部段落菜单', () => {
it('格式菜单列出十四种警告框和元数据快捷键', async () => {
const wrapper = mount(TitleBarMenu)
await wrapper.get('[data-menu="format"] .menu-trigger').trigger('click')
expect(wrapper.findAll('.callout-options button')).toHaveLength(14)
expect(wrapper.get('.submenu-heading').text()).toContain('Ctrl+Alt+C')
const calloutTrigger = wrapper.get('.submenu-trigger')
expect(calloutTrigger.text()).toContain('Ctrl+Alt+C')
await calloutTrigger.trigger('click')
expect(wrapper.findAll('.submenu-popover button')).toHaveLength(14)
expect(wrapper.get('.metadata-command').text()).toContain('Ctrl+Alt+P')
wrapper.unmount()
})
it('支持菜单栏方向键与警告框子菜单键盘访问', async () => {
capabilities.mockReturnValue([{ id: 'editor.callout', supported: true, enabled: true }])
const wrapper = mount(TitleBarMenu, { attachTo: document.body })
const file = wrapper.get('[data-menu="file"] .menu-trigger')
;(file.element as HTMLElement).focus()
await file.trigger('keydown', { key: 'ArrowRight' })
expect(document.activeElement).toBe(wrapper.get('[data-menu="edit"] .menu-trigger').element)
await wrapper.get('[data-menu="format"] .menu-trigger').trigger('click')
const trigger = wrapper.get('.submenu-trigger')
;(trigger.element as HTMLElement).focus()
await trigger.trigger('keydown', { key: 'ArrowRight' })
await new Promise(resolve => setTimeout(resolve, 0))
expect(wrapper.findAll('.submenu-popover [role="menuitem"]')).toHaveLength(14)
expect((document.activeElement as HTMLElement).closest('.submenu-popover')).not.toBeNull()
wrapper.unmount()
})
})
+144 -67
View File
@@ -2,7 +2,8 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme'
import { executeEditorCommand, getEditorCommandCapabilities, type EditorCommandId } from '@/services/editorCommandService'
import { executeEditorCommand, getEditorCommandCapabilities, subscribeEditorCommandCapabilities, type EditorCommandId } from '@/services/editorCommandService'
import { calloutMenuCommands, formatMenuSections, paragraphMenuSections, type EditorMenuCommand } from '@/services/editorMenu'
import { t } from '@/i18n'
import { useRouter } from 'vue-router'
@@ -11,31 +12,42 @@ const editor = useEditorStore()
const theme = useThemeStore()
const router = useRouter()
const open = ref<MenuName | null>(null)
const nestedOpen = ref(false)
const bar = ref<HTMLElement | null>(null)
const error = ref('')
const capabilities = ref(new Map<EditorCommandId, boolean>())
const shortcut = computed(() => /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘⌥P' : 'Ctrl+Alt+P')
const callouts = [
['note', '笔记'], ['abstract', '摘要'], ['info', '信息'], ['todo', '待办'], ['tip', '技巧'],
['important', '重要'], ['success', '成功'], ['question', '问题'], ['warning', '警告'],
['failure', '失败'], ['danger', '危险'], ['bug', '缺陷'], ['example', '示例'], ['quote', '引用'],
] as const
const menuOrder: readonly MenuName[] = ['file', 'edit', 'paragraph', 'format', 'view', 'theme', 'help']
function enabled(id: EditorCommandId) {
return getEditorCommandCapabilities().find(item => item.id === id)?.enabled ?? false
return capabilities.value.get(id) ?? false
}
function toggle(name: MenuName) { open.value = open.value === name ? null : name; error.value = '' }
function dismiss(event: PointerEvent) { if (!bar.value?.contains(event.target as Node)) open.value = null }
function refreshCapabilities() {
capabilities.value = new Map(getEditorCommandCapabilities().map(item => [item.id, item.enabled]))
}
function toggle(name: MenuName) { open.value = open.value === name ? null : name; nestedOpen.value = false; error.value = '' }
function dismiss(event: PointerEvent) { if (!bar.value?.contains(event.target as Node)) close() }
async function focusFirst(name: MenuName) {
open.value = name
nestedOpen.value = false
await nextTick()
bar.value?.querySelector<HTMLElement>(`[data-menu="${name}"] [role="menuitem"]:not(:disabled)`)?.focus()
}
function close() { open.value = null }
function close() { open.value = null; nestedOpen.value = false }
async function command(id: EditorCommandId, params?: unknown) {
const result = params === undefined ? await executeEditorCommand(id) : await executeEditorCommand(id, params)
if (result.ok) close()
else error.value = t('当前编辑器无法执行此命令。', 'The active editor cannot run this command.')
}
function commandItem(item: EditorMenuCommand) {
const params = item.id === 'editor.callout' && item.params && typeof item.params === 'object'
? { ...item.params, body: t('提示内容', 'Callout content') }
: item.params
return command(item.id, params)
}
function calloutType(item: EditorMenuCommand) {
return String((item.params as { type?: string } | undefined)?.type ?? '')
}
async function metadataCommand() {
const imported = await executeEditorCommand('editor.import-note-properties')
if (imported.ok) { close(); return }
@@ -47,93 +59,155 @@ function toggleTheme() { theme.toggleTheme(); close() }
function applyTheme(id: string) { theme.applyTheme(id); close() }
function navigate(path: string) { void router.push(path); close() }
onMounted(() => window.addEventListener('pointerdown', dismiss))
onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
async function switchMenu(offset: number, expand = open.value !== null) {
const current = open.value ? menuOrder.indexOf(open.value) : 0
const name = menuOrder[(current + offset + menuOrder.length) % menuOrder.length]!
if (expand) await focusFirst(name)
else bar.value?.querySelector<HTMLElement>(`[data-menu="${name}"] > .menu-trigger`)?.focus()
}
function menuItems(menu: Element) {
return [...menu.querySelectorAll<HTMLElement>('[data-menu-item]:not(:disabled)')]
.filter(item => item.closest('[role="menu"]') === menu)
}
function handleKeys(event: KeyboardEvent) {
const target = event.target as HTMLElement
const menu = target.closest('[role="menu"]')
if (event.key === 'Escape') {
event.preventDefault()
if (menu?.classList.contains('submenu-popover')) {
nestedOpen.value = false
bar.value?.querySelector<HTMLElement>('.submenu-trigger')?.focus()
} else {
const name = open.value
close()
if (name) bar.value?.querySelector<HTMLElement>(`[data-menu="${name}"] > .menu-trigger`)?.focus()
}
return
}
if (!menu && ['ArrowLeft', 'ArrowRight', 'ArrowDown'].includes(event.key)) {
event.preventDefault()
if (event.key === 'ArrowDown') void focusFirst((target.closest<HTMLElement>('[data-menu]')?.dataset.menu ?? 'file') as MenuName)
else void switchMenu(event.key === 'ArrowRight' ? 1 : -1, false)
return
}
if (!menu) return
if (target.classList.contains('submenu-trigger') && event.key === 'ArrowRight') {
event.preventDefault(); nestedOpen.value = true
void nextTick(() => bar.value?.querySelector<HTMLElement>('.submenu-popover [data-menu-item]:not(:disabled)')?.focus())
return
}
if (menu.classList.contains('submenu-popover') && event.key === 'ArrowLeft') {
event.preventDefault(); nestedOpen.value = false; bar.value?.querySelector<HTMLElement>('.submenu-trigger')?.focus(); return
}
if (menu.classList.contains('submenu-popover') && event.key === 'ArrowRight') {
event.preventDefault(); return
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault(); void switchMenu(event.key === 'ArrowRight' ? 1 : -1); return
}
const items = menuItems(menu)
const index = Math.max(0, items.indexOf(target))
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
items[(index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length]?.focus()
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault(); items[event.key === 'Home' ? 0 : items.length - 1]?.focus()
} else if (event.key === 'Tab') close()
}
let unsubscribeCapabilities: (() => void) | undefined
onMounted(() => {
refreshCapabilities()
unsubscribeCapabilities = subscribeEditorCommandCapabilities(refreshCapabilities)
window.addEventListener('pointerdown', dismiss)
})
onBeforeUnmount(() => {
unsubscribeCapabilities?.()
window.removeEventListener('pointerdown', dismiss)
})
</script>
<template>
<nav ref="bar" class="desktop-menu-bar" :aria-label="t('应用菜单', 'Application menu')" @keydown.esc.stop.prevent="close">
<nav ref="bar" class="desktop-menu-bar" role="menubar" :aria-label="t('应用菜单', 'Application menu')" @keydown="handleKeys">
<div class="menu-group" data-menu="file">
<button class="menu-trigger" :aria-expanded="open === 'file'" aria-haspopup="menu" @click="toggle('file')" @keydown.down.prevent="focusFirst('file')">{{ t('文件', 'File') }}</button>
<div v-if="open === 'file'" class="menu-popover" role="menu">
<button role="menuitem" @click="navigate('/')"><span>{{ t('选择知识库…', 'Choose knowledge base…') }}</span></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'file'" aria-haspopup="menu" @click="toggle('file')" @pointerenter="open && focusFirst('file')">{{ t('文件', 'File') }}</button>
<div v-if="open === 'file'" class="menu-popover" role="menu" :aria-label="t('文件', 'File')">
<button data-menu-item role="menuitem" @click="navigate('/')"><span>{{ t('选择知识库…', 'Choose knowledge base…') }}</span></button>
<span class="menu-separator" role="separator" />
<button role="menuitem" :disabled="!editor.currentFilePath || ['saving','conflict','external_changed'].includes(editor.saveStatus)" @click="save">
<button data-menu-item role="menuitem" :disabled="!editor.currentFilePath || ['saving','conflict','external_changed'].includes(editor.saveStatus)" @click="save">
<span>{{ t('保存', 'Save') }}</span><kbd>Ctrl+S</kbd>
</button>
</div>
</div>
<div class="menu-group" data-menu="edit">
<button class="menu-trigger" :aria-expanded="open === 'edit'" aria-haspopup="menu" @click="toggle('edit')" @keydown.down.prevent="focusFirst('edit')">{{ t('编辑', 'Edit') }}</button>
<div v-if="open === 'edit'" class="menu-popover" role="menu">
<button role="menuitem" :disabled="!enabled('editor.undo')" @click="command('editor.undo')"><span>{{ t('撤销', 'Undo') }}</span><kbd>Ctrl+Z</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.redo')" @click="command('editor.redo')"><span>{{ t('重做', 'Redo') }}</span><kbd>Ctrl+Shift+Z</kbd></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'edit'" aria-haspopup="menu" @click="toggle('edit')" @pointerenter="open && focusFirst('edit')">{{ t('编辑', 'Edit') }}</button>
<div v-if="open === 'edit'" class="menu-popover" role="menu" :aria-label="t('编辑', 'Edit')">
<button data-menu-item role="menuitem" :disabled="!enabled('editor.undo')" @click="command('editor.undo')"><span>{{ t('撤销', 'Undo') }}</span><kbd>Ctrl+Z</kbd></button>
<button data-menu-item role="menuitem" :disabled="!enabled('editor.redo')" @click="command('editor.redo')"><span>{{ t('重做', 'Redo') }}</span><kbd>Ctrl+Shift+Z</kbd></button>
</div>
</div>
<div class="menu-group" data-menu="paragraph">
<button class="menu-trigger" :aria-expanded="open === 'paragraph'" aria-haspopup="menu" @click="toggle('paragraph')" @keydown.down.prevent="focusFirst('paragraph')">{{ t('段落', 'Paragraph') }}</button>
<div v-if="open === 'paragraph'" class="menu-popover paragraph-menu" role="menu">
<button role="menuitem" :disabled="!enabled('editor.paragraph')" @click="command('editor.paragraph')"><span>{{ t('正文', 'Body text') }}</span><kbd>Ctrl+0</kbd></button>
<button v-for="level in 6" :key="level" role="menuitem" :disabled="!enabled('editor.heading')" @click="command('editor.heading', level)"><span>{{ t(`${level} 级标题`, `Heading ${level}`) }}</span><kbd>Ctrl+{{ level }}</kbd></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'paragraph'" aria-haspopup="menu" @click="toggle('paragraph')" @pointerenter="open && focusFirst('paragraph')">{{ t('段落', 'Paragraph') }}</button>
<div v-if="open === 'paragraph'" class="menu-popover paragraph-menu" role="menu" :aria-label="t('段落', 'Paragraph')">
<template v-for="(section, sectionIndex) in paragraphMenuSections" :key="sectionIndex">
<button v-for="item in section" :key="`${item.id}-${String(item.params ?? '')}`" data-menu-item role="menuitem" :disabled="!enabled(item.id)" @click="commandItem(item)"><span>{{ t(...item.label) }}</span><kbd v-if="item.shortcut">{{ item.shortcut }}</kbd></button>
<span v-if="sectionIndex < paragraphMenuSections.length - 1" class="menu-separator" role="separator" />
</template>
<span class="menu-separator" role="separator" />
<button role="menuitem" :disabled="!enabled('editor.bullet-list')" @click="command('editor.bullet-list')"><span>{{ t('无序列表', 'Bullet list') }}</span><kbd>Ctrl+Shift+]</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.ordered-list')" @click="command('editor.ordered-list')"><span>{{ t('有序列表', 'Ordered list') }}</span><kbd>Ctrl+Shift+[</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.task-list')" @click="command('editor.task-list')"><span>{{ t('任务列表', 'Task list') }}</span><kbd>Ctrl+Shift+X</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.blockquote')" @click="command('editor.blockquote')"><span>{{ t('引用', 'Blockquote') }}</span><kbd>Ctrl+Shift+Q</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.code-block')" @click="command('editor.code-block')"><span>{{ t('代码块', 'Code block') }}</span><kbd>Ctrl+Shift+K</kbd></button>
<span class="menu-separator" role="separator" />
<button class="import-properties" role="menuitem" :disabled="!enabled('editor.import-note-properties')" @click="command('editor.import-note-properties')">
<button class="import-properties" data-menu-item role="menuitem" :disabled="!enabled('editor.import-note-properties')" @click="command('editor.import-note-properties')">
<span>{{ t('导入为笔记属性…', 'Import as note properties…') }}</span><kbd>{{ shortcut }}</kbd>
</button>
<small v-if="!enabled('editor.import-note-properties')">{{ t('请在无冲突的 Markdown 源码笔记中使用', 'Available in a conflict-free Markdown source note') }}</small>
</div>
</div>
<div class="menu-group" data-menu="format">
<button class="menu-trigger" :aria-expanded="open === 'format'" aria-haspopup="menu" @click="toggle('format')" @keydown.down.prevent="focusFirst('format')">{{ t('格式', 'Format') }}</button>
<div v-if="open === 'format'" class="menu-popover format-menu" role="menu">
<button role="menuitem" :disabled="!enabled('editor.bold')" @click="command('editor.bold')"><span>{{ t('加粗', 'Bold') }}</span><kbd>Ctrl+B</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.italic')" @click="command('editor.italic')"><span>{{ t('斜体', 'Italic') }}</span><kbd>Ctrl+I</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.strikethrough')" @click="command('editor.strikethrough')"><span>{{ t('删除线', 'Strikethrough') }}</span><kbd>Alt+Shift+5</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.inline-code')" @click="command('editor.inline-code')"><span>{{ t('行内代码', 'Inline code') }}</span><kbd>Ctrl+`</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.link')" @click="command('editor.link')"><span>{{ t('链接', 'Link') }}</span><kbd>Ctrl+K</kbd></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'format'" aria-haspopup="menu" @click="toggle('format')" @pointerenter="open && focusFirst('format')">{{ t('格式', 'Format') }}</button>
<div v-if="open === 'format'" class="menu-popover format-menu" role="menu" :aria-label="t('格式', 'Format')">
<template v-for="item in formatMenuSections[0]" :key="item.id">
<button data-menu-item role="menuitem" :disabled="!enabled(item.id)" @click="commandItem(item)"><span>{{ t(...item.label) }}</span><kbd v-if="item.shortcut">{{ item.shortcut }}</kbd></button>
</template>
<span class="menu-separator" role="separator" />
<button role="menuitem" :disabled="!enabled('editor.code-block')" @click="command('editor.code-block')"><span>{{ t('代码块', 'Code block') }}</span><kbd>Ctrl+Shift+K</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.math-block')" @click="command('editor.math-block')"><span>{{ t('公式块', 'Math block') }}</span><kbd>Ctrl+Shift+M</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.inline-math')" @click="command('editor.inline-math')"><span>{{ t('行内公式', 'Inline math') }}</span><kbd>Ctrl+Shift+L</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.table')" @click="command('editor.table')"><span>{{ t('表格', 'Table') }}</span><kbd>Ctrl+Alt+T</kbd></button>
<div class="submenu-heading"><span>{{ t('警告框样式', 'Callout styles') }}</span><kbd>Ctrl+Alt+C</kbd></div>
<div class="callout-options">
<button v-for="item in callouts" :key="item[0]" role="menuitem" :disabled="!enabled('editor.callout')" @click="command('editor.callout', { type: item[0], body: t('提示内容', 'Callout content') })"><span>{{ item[1] }}</span><code>{{ item[0] }}</code></button>
<template v-for="item in formatMenuSections[1]" :key="item.id">
<button data-menu-item role="menuitem" :disabled="!enabled(item.id)" @click="commandItem(item)"><span>{{ t(...item.label) }}</span><kbd v-if="item.shortcut">{{ item.shortcut }}</kbd></button>
</template>
<div class="submenu" @pointerleave="nestedOpen = false">
<button class="submenu-trigger" data-menu-item role="menuitem" aria-haspopup="menu" :aria-expanded="nestedOpen" :disabled="!enabled('editor.callout')" @click="nestedOpen = !nestedOpen" @pointerenter="nestedOpen = true"><span>{{ t('警告框样式', 'Callout styles') }}</span><span><kbd>Ctrl+Alt+C</kbd><span class="submenu-arrow"></span></span></button>
<div v-if="nestedOpen" class="submenu-popover" role="menu" :aria-label="t('警告框样式', 'Callout styles')">
<button v-for="item in calloutMenuCommands" :key="calloutType(item)" data-menu-item role="menuitem" @click="commandItem(item)"><span>{{ t(...item.label) }}</span><code>{{ calloutType(item) }}</code></button>
</div>
</div>
<button role="menuitem" :disabled="!enabled('editor.horizontal-rule')" @click="command('editor.horizontal-rule')"><span>{{ t('水平分割线', 'Horizontal rule') }}</span><kbd>Ctrl+Shift+H</kbd></button>
<span class="menu-separator" role="separator" />
<button class="metadata-command" role="menuitem" :disabled="!enabled('editor.import-note-properties') && !enabled('editor.metadata.edit')" @click="metadataCommand"><span>{{ t('元数据 / YAML Front Matter…', 'Metadata / YAML Front Matter…') }}</span><kbd>{{ shortcut }}</kbd></button>
<template v-for="item in formatMenuSections[2]" :key="item.id">
<button data-menu-item role="menuitem" :disabled="!enabled(item.id)" @click="commandItem(item)"><span>{{ t(...item.label) }}</span><kbd v-if="item.shortcut">{{ item.shortcut }}</kbd></button>
</template>
<span class="menu-separator" role="separator" />
<button class="metadata-command" data-menu-item role="menuitem" :disabled="!enabled('editor.import-note-properties') && !enabled('editor.metadata.edit')" @click="metadataCommand"><span>{{ t('元数据 / YAML Front Matter…', 'Metadata / YAML Front Matter…') }}</span><kbd>{{ shortcut }}</kbd></button>
</div>
</div>
<div class="menu-group" data-menu="view">
<button class="menu-trigger" :aria-expanded="open === 'view'" aria-haspopup="menu" @click="toggle('view')" @keydown.down.prevent="focusFirst('view')">{{ t('视图', 'View') }}</button>
<div v-if="open === 'view'" class="menu-popover" role="menu">
<button role="menuitemradio" :aria-checked="editor.mode === 'wysiwyg'" @click="setMode('wysiwyg')"><span>✓ {{ t('写作模式', 'Writing mode') }}</span></button>
<button role="menuitemradio" :aria-checked="editor.mode === 'source'" @click="setMode('source')"><span>✓ {{ t('源码模式', 'Source mode') }}</span></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'view'" aria-haspopup="menu" @click="toggle('view')" @pointerenter="open && focusFirst('view')">{{ t('视图', 'View') }}</button>
<div v-if="open === 'view'" class="menu-popover" role="menu" :aria-label="t('视图', 'View')">
<button data-menu-item role="menuitemradio" :aria-checked="editor.mode === 'wysiwyg'" @click="setMode('wysiwyg')"><span>{{ editor.mode === 'wysiwyg' ? '✓ ' : '' }}{{ t('写作模式', 'Writing mode') }}</span></button>
<button data-menu-item role="menuitemradio" :aria-checked="editor.mode === 'source'" @click="setMode('source')"><span>{{ editor.mode === 'source' ? '✓ ' : '' }}{{ t('源码模式', 'Source mode') }}</span></button>
<span class="menu-separator" role="separator" />
<button role="menuitem" :disabled="!enabled('editor.heading.fold-all')" @click="command('editor.heading.fold-all')"><span>{{ t('全部折叠标题', 'Fold all headings') }}</span></button>
<button role="menuitem" :disabled="!enabled('editor.heading.unfold-all')" @click="command('editor.heading.unfold-all')"><span>{{ t('全部展开标题', 'Unfold all headings') }}</span></button>
<button data-menu-item role="menuitem" :disabled="!enabled('editor.heading.fold-all')" @click="command('editor.heading.fold-all')"><span>{{ t('全部折叠标题', 'Fold all headings') }}</span></button>
<button data-menu-item role="menuitem" :disabled="!enabled('editor.heading.unfold-all')" @click="command('editor.heading.unfold-all')"><span>{{ t('全部展开标题', 'Unfold all headings') }}</span></button>
</div>
</div>
<div class="menu-group" data-menu="theme">
<button class="menu-trigger" :aria-expanded="open === 'theme'" aria-haspopup="menu" @click="toggle('theme')" @keydown.down.prevent="focusFirst('theme')">{{ t('主题', 'Theme') }}</button>
<div v-if="open === 'theme'" class="menu-popover" role="menu">
<button v-for="item in theme.allThemes" :key="item.theme_id" role="menuitemradio" :aria-checked="theme.currentThemeId === item.theme_id" @click="applyTheme(item.theme_id)"><span>{{ theme.currentThemeId === item.theme_id ? '✓ ' : '' }}{{ item.name }}</span></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'theme'" aria-haspopup="menu" @click="toggle('theme')" @pointerenter="open && focusFirst('theme')">{{ t('主题', 'Theme') }}</button>
<div v-if="open === 'theme'" class="menu-popover" role="menu" :aria-label="t('主题', 'Theme')">
<button v-for="item in theme.allThemes" :key="item.theme_id" data-menu-item role="menuitemradio" :aria-checked="theme.currentThemeId === item.theme_id" @click="applyTheme(item.theme_id)"><span>{{ theme.currentThemeId === item.theme_id ? '✓ ' : '' }}{{ item.name }}</span></button>
<span class="menu-separator" role="separator" />
<button role="menuitem" @click="toggleTheme"><span>{{ theme.isDark ? t('切换为浅色', 'Switch to light') : t('切换为深色', 'Switch to dark') }}</span></button>
<button role="menuitem" @click="navigate('/themes')"><span>{{ t('管理主题…', 'Manage themes…') }}</span></button>
<button data-menu-item role="menuitem" @click="toggleTheme"><span>{{ theme.isDark ? t('切换为浅色', 'Switch to light') : t('切换为深色', 'Switch to dark') }}</span></button>
<button data-menu-item role="menuitem" @click="navigate('/themes')"><span>{{ t('管理主题…', 'Manage themes…') }}</span></button>
</div>
</div>
<div class="menu-group" data-menu="help">
<button class="menu-trigger" :aria-expanded="open === 'help'" aria-haspopup="menu" @click="toggle('help')" @keydown.down.prevent="focusFirst('help')">{{ t('帮助', 'Help') }}</button>
<div v-if="open === 'help'" class="menu-popover" role="menu">
<button role="menuitem" @click="navigate('/logs')"><span>{{ t('运行日志', 'Operation logs') }}</span></button>
<button role="menuitem" @click="navigate('/settings')"><span>{{ t('设置与诊断', 'Settings and diagnostics') }}</span></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'help'" aria-haspopup="menu" @click="toggle('help')" @pointerenter="open && focusFirst('help')">{{ t('帮助', 'Help') }}</button>
<div v-if="open === 'help'" class="menu-popover" role="menu" :aria-label="t('帮助', 'Help')">
<button data-menu-item role="menuitem" @click="navigate('/logs')"><span>{{ t('运行日志', 'Operation logs') }}</span></button>
<button data-menu-item role="menuitem" @click="navigate('/settings')"><span>{{ t('设置与诊断', 'Settings and diagnostics') }}</span></button>
</div>
</div>
<span v-if="error" class="menu-error" role="alert">{{ error }}</span>
@@ -152,11 +226,14 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
.menu-popover button:hover:not(:disabled), .menu-popover button:focus-visible { background: var(--color-accent-soft); color: var(--color-accent-primary); }
.menu-popover button:disabled { opacity: .45; cursor: not-allowed; }
.menu-popover kbd { color: var(--color-text-tertiary); font: inherit; font-size: var(--font-size-xs); }
.submenu-heading { display: flex; justify-content: space-between; padding: 7px var(--space-md) 4px; color: var(--color-text-secondary); font-weight: 650; }
.callout-options { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; padding: 0 var(--space-xs) var(--space-xs); }
.callout-options button { gap: var(--space-sm); padding: 5px var(--space-sm); }
.callout-options code { color: var(--color-text-tertiary); font-size: 10px; }
.submenu { position: relative; }
.submenu-trigger > span:last-child { display: inline-flex; align-items: center; gap: var(--space-sm); }
.submenu-arrow { color: var(--color-text-tertiary); font-size: 18px; line-height: 1; }
.submenu-popover { position: absolute; bottom: -5px; left: calc(100% + 4px); display: grid; min-width: 210px; max-height: calc(100vh - 96px); overflow: auto; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-elevated); box-shadow: var(--shadow-lg); }
.submenu-popover button { padding: 6px var(--space-md); }
.submenu-popover code { color: var(--color-text-tertiary); font-size: 10px; }
.menu-popover { max-height: calc(100vh - 110px); overflow: auto; }
.menu-popover.format-menu { max-height: none; overflow: visible; }
.menu-separator { height: 1px; margin: var(--space-xs); background: var(--color-border-subtle); }
.menu-popover small { padding: var(--space-xs) var(--space-md); color: var(--color-text-tertiary); white-space: normal; }
.menu-error { margin-left: var(--space-md); color: var(--color-error); font-size: var(--font-size-xs); }