fix(desktop): 显示完整菜单栏并启用快捷键

This commit is contained in:
2026-09-07 19:55:38 +08:00
parent a189b89942
commit 1399b54780
9 changed files with 127 additions and 53 deletions
@@ -21,7 +21,9 @@
桌面客户端顶部菜单栏的 **段落 → 导入为笔记属性…** 预留元数据格式导入功能,与标题、正文、列表等段落操作归组。它处理笔记内容中的元数据,不是主题包安装入口。
稳定的前端命令标识为 `editor.import-note-properties`。属性转换处理器、Tauri 原生菜单事件和无边框窗口内的可见“段落”菜单均分发同一命令,没有维护第二套转换逻辑。快捷键待第三阶段统一分配,不抢占现有编辑快捷键
稳定的前端命令标识为 `editor.import-note-properties`。属性转换处理器、Tauri 原生菜单事件和无边框窗口内的可见“段落”菜单均分发同一命令,没有维护第二套转换逻辑。默认快捷键为 Windows/Linux 的 `Ctrl+Alt+P`、macOS 的 `⌘⌥P`Rust Host 使用 `CmdOrCtrl+Alt+P` 注册 accelerator,仍由活动编辑器能力检查决定是否可用
无边框桌面窗口在标题栏下方固定显示“文件 / 编辑 / 段落 / 视图”菜单行。保存使用 `Ctrl+S`;撤销和重做沿用编辑器已有的 `Ctrl+Z``Ctrl+Shift+Z`,菜单只调用相同编辑器命令,不额外注册按键监听,以免一次按键执行两次。
Markdown 格式(含警告框)与元数据共用 `editorCommandService` 的能力查询和命令分发接口,详见 [警告框与桌面编辑命令开发说明](../development/警告框与桌面编辑命令开发说明.md)。Host 根据 supported / enabled 显隐或禁用菜单,不能把已预留的命令 ID 当作已可执行能力;原生快捷键不绕过活动文档、只读和冲突检查。
@@ -56,7 +56,7 @@
目录还预留删除线、任务列表、引用、Mermaid、链接、图片、表格、分隔线、硬换行、引用式链接、HTML、撤销/重做,以及 `editor.import-note-properties``editor.metadata.edit``editor.metadata.title``editor.metadata.tags`。这些单独的处理器尚未接入,返回 unsupported;现阶段复杂格式可通过 insert-markdown 插入。元数据不得通过普通正文插入接口冒充属性导入。
第三阶段由 Host 将原生菜单/快捷键映射到上述 ID。快捷键表需支持平台差异与用户改键,过滤表单、输入法组合与弹窗焦点;不要重复绑定浏览器和 Milkdown 已有按键。宿主只能调用允许的命令,不执行任意脚本。元数据处理器须按已有桌面需求完成无损 YAML 合并、版本检查、冲突提示及属性/正文一并撤销后才可标记 supported。
第三阶段由 Host 将原生菜单/快捷键映射到上述 ID。`editor.import-note-properties` 默认使用 `CmdOrCtrl+Alt+P`,窗口保存使用 `Ctrl/Cmd+S`;撤销、重做等 Milkdown/CodeMirror 已有按键只在菜单中标示,不重复绑定。快捷键表需支持平台差异与用户改键,过滤输入法组合和不可用状态。宿主只能调用允许的命令,不执行任意脚本。元数据处理器须按已有桌面需求完成无损 YAML 合并、版本检查、冲突提示及属性/正文一并撤销后才可标记 supported。
## 3. 验证方法
+1 -1
View File
@@ -170,7 +170,7 @@ fn main() {
"editor.import-note-properties",
"导入为笔记属性…",
false,
None::<&str>,
Some("CmdOrCtrl+Alt+P"),
)?;
let paragraph = Submenu::with_items(app, "段落", true, &[&import])?;
app.manage(import);
+2
View File
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppShell from '@/components/common/AppShell.vue'
import TitleBar from '@/components/common/TitleBar.vue'
import TitleBarMenu from '@/components/common/TitleBarMenu.vue'
import { isDesktop } from '@/services/platform/desktop'
const route = useRoute()
@@ -13,6 +14,7 @@ const desktop = isDesktop()
<template>
<div v-if="isVaultEntry" class="entry-shell">
<TitleBar v-if="desktop" />
<TitleBarMenu v-if="desktop" />
<router-view />
</div>
<AppShell v-else>
@@ -9,8 +9,10 @@ import PrimarySidebar from './PrimarySidebar.vue'
import SecondarySidebar from './SecondarySidebar.vue'
import StatusBar from './StatusBar.vue'
import TitleBar from './TitleBar.vue'
import TitleBarMenu from './TitleBarMenu.vue'
import CommandPalette from './CommandPalette.vue'
import { getIndexStatus } from '@/services/indexService'
import { isDesktop } from '@/services/platform/desktop'
import { navigateToCitation } from '@/composables/useCitationNavigation'
import { useWorkspaceRefresh } from '@/composables/useWorkspaceRefresh'
useWorkspaceRefresh()
@@ -25,6 +27,7 @@ const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const route = useRoute()
const router = useRouter()
const desktop = isDesktop()
let statusTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false
@@ -75,6 +78,7 @@ defineExpose({ openCitation })
<template>
<div class="app-shell" :class="{ 'theme-dark': themeStore.isDark }">
<TitleBar />
<TitleBarMenu v-if="desktop" />
<div class="app-body">
<PrimarySidebar />
<SecondarySidebar v-if="secondaryComponent" :component="secondaryComponent" />
@@ -6,7 +6,6 @@ import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme'
import { Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue'
import TitleBarMenu from './TitleBarMenu.vue'
import { t } from '@/i18n'
import { isDesktop } from '@/services/platform/desktop'
import { minimizeWindow, requestWindowClose, toggleMaximizeWindow } from '@/services/platform/windowControls'
@@ -62,7 +61,6 @@ function toggleFromTitlebar(event: MouseEvent) {
{{ currentFileName }}
<span v-if="isDirty" class="dirty-dot" />
</span>
<TitleBarMenu />
</div>
<div class="titlebar-center" data-tauri-drag-region>
<span class="app-name" data-tauri-drag-region>NotesAgent</span>
@@ -6,11 +6,17 @@ import { useEditorStore } from '@/stores/editor'
import TitleBarMenu from './TitleBarMenu.vue'
const execute = vi.hoisted(() => vi.fn())
vi.mock('@/services/editorCommandService', () => ({ executeEditorCommand: execute }))
const capabilities = vi.hoisted(() => vi.fn())
vi.mock('@/services/editorCommandService', () => ({ executeEditorCommand: execute, getEditorCommandCapabilities: capabilities }))
beforeEach(() => {
setActivePinia(createPinia())
execute.mockReset().mockResolvedValue({ ok: true })
capabilities.mockReturnValue([
{ id: 'editor.paragraph', supported: true, enabled: true },
{ id: 'editor.heading', supported: true, enabled: true },
{ id: 'editor.import-note-properties', supported: true, enabled: true },
])
})
describe('桌面顶部段落菜单', () => {
@@ -20,8 +26,9 @@ describe('桌面顶部段落菜单', () => {
editor.currentFilePath = '/示例.md'
editor.saveStatus = 'saved'
const wrapper = mount(TitleBarMenu)
await wrapper.get('.menu-trigger').trigger('click')
const item = wrapper.get('[role="menuitem"]')
expect(wrapper.text()).toContain('文件编辑段落视图')
await wrapper.get('[data-menu="paragraph"] .menu-trigger').trigger('click')
const item = wrapper.get('.import-properties')
expect((item.element as HTMLButtonElement).disabled).toBe(false)
await item.trigger('click')
expect(execute).toHaveBeenCalledWith('editor.import-note-properties')
@@ -34,9 +41,21 @@ describe('桌面顶部段落菜单', () => {
editor.mode = 'wysiwyg'
editor.currentFilePath = '/示例.md'
const wrapper = mount(TitleBarMenu)
await wrapper.get('.menu-trigger').trigger('click')
expect((wrapper.get('[role="menuitem"]').element as HTMLButtonElement).disabled).toBe(true)
capabilities.mockReturnValue([])
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 源码笔记中使用')
wrapper.unmount()
})
it('标题命令传入对应级别并显示快捷键', async () => {
const wrapper = mount(TitleBarMenu)
await wrapper.get('[data-menu="paragraph"] .menu-trigger').trigger('click')
const headings = wrapper.findAll('[data-menu="paragraph"] [role="menuitem"]').filter(item => item.text().startsWith('标题'))
await headings[1].trigger('click')
expect(execute).toHaveBeenCalledWith('editor.heading', 2)
await wrapper.get('[data-menu="paragraph"] .menu-trigger').trigger('click')
expect(wrapper.get('.import-properties').text()).toContain('Ctrl+Alt+P')
wrapper.unmount()
})
})
+77 -42
View File
@@ -1,67 +1,102 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useEditorStore } from '@/stores/editor'
import { executeEditorCommand } from '@/services/editorCommandService'
import { useThemeStore } from '@/stores/theme'
import { executeEditorCommand, getEditorCommandCapabilities, type EditorCommandId } from '@/services/editorCommandService'
import { t } from '@/i18n'
type MenuName = 'file' | 'edit' | 'paragraph' | 'view'
const editor = useEditorStore()
const open = ref(false)
const menu = ref<HTMLElement | null>(null)
const trigger = ref<HTMLButtonElement | null>(null)
const importItem = ref<HTMLButtonElement | null>(null)
const theme = useThemeStore()
const open = ref<MenuName | null>(null)
const bar = ref<HTMLElement | null>(null)
const error = ref('')
const importEnabled = computed(() => editor.mode === 'source' && !!editor.currentFilePath
&& !['conflict', 'external_changed'].includes(editor.saveStatus))
const shortcut = computed(() => /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘⌥P' : 'Ctrl+Alt+P')
function dismiss(event: PointerEvent) {
if (!menu.value?.contains(event.target as Node)) open.value = false
function enabled(id: EditorCommandId) {
return getEditorCommandCapabilities().find(item => item.id === id)?.enabled ?? false
}
async function showAndFocus() {
open.value = true
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 }
async function focusFirst(name: MenuName) {
open.value = name
await nextTick()
importItem.value?.focus()
bar.value?.querySelector<HTMLElement>(`[data-menu="${name}"] [role="menuitem"]:not(:disabled)`)?.focus()
}
function closeAndFocus() {
open.value = false
trigger.value?.focus()
}
async function importProperties() {
if (!importEnabled.value) return
const result = await executeEditorCommand('editor.import-note-properties')
if (!result.ok) error.value = t('当前编辑器无法执行属性导入。', 'Property import is unavailable in the current editor.')
else { error.value = ''; open.value = false }
function close() { open.value = null }
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.')
}
async function save() { await editor.save(); close() }
function setMode(mode: 'source' | 'wysiwyg') { editor.setMode(mode); close() }
function toggleTheme() { theme.toggleTheme(); close() }
onMounted(() => window.addEventListener('pointerdown', dismiss))
onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
</script>
<template>
<div ref="menu" class="titlebar-menu">
<button ref="trigger" type="button" class="menu-trigger" aria-haspopup="menu" :aria-expanded="open" @click="open ? closeAndFocus() : showAndFocus()" @keydown.down.prevent="showAndFocus">
{{ t('段落', 'Paragraph') }}
</button>
<div v-if="open" class="menu-popover" role="menu" @keydown.esc.stop.prevent="closeAndFocus">
<button ref="importItem" type="button" role="menuitem" :disabled="!importEnabled" @click="importProperties">
{{ t('导入为笔记属性', 'Import as note properties') }}
</button>
<small v-if="!importEnabled">{{ t('请在无冲突的 Markdown 源码笔记中使用', 'Available in a conflict-free Markdown source note') }}</small>
<small v-if="error" role="alert">{{ error }}</small>
<nav ref="bar" class="desktop-menu-bar" :aria-label="t('应用菜单', 'Application menu')" @keydown.esc.stop.prevent="close">
<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" :disabled="!editor.currentFilePath || ['saving','conflict','external_changed'].includes(editor.saveStatus)" @click="save">
<span>{{ t('保存', 'Save') }}</span><kbd>Ctrl+S</kbd>
</button>
</div>
</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>
</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></button>
<button v-for="level in 3" :key="level" role="menuitem" :disabled="!enabled('editor.heading')" @click="command('editor.heading', level)"><span>{{ t(`标题 ${level}`, `Heading ${level}`) }}</span></button>
<span class="menu-separator" role="separator" />
<button role="menuitem" :disabled="!enabled('editor.bullet-list')" @click="command('editor.bullet-list')"><span>{{ t('无序列表', 'Bullet list') }}</span></button>
<button role="menuitem" :disabled="!enabled('editor.ordered-list')" @click="command('editor.ordered-list')"><span>{{ t('有序列表', 'Ordered list') }}</span></button>
<button role="menuitem" :disabled="!enabled('editor.task-list')" @click="command('editor.task-list')"><span>{{ t('任务列表', 'Task list') }}</span></button>
<button role="menuitem" :disabled="!enabled('editor.blockquote')" @click="command('editor.blockquote')"><span>{{ t('引用', 'Blockquote') }}</span></button>
<button role="menuitem" :disabled="!enabled('editor.code-block')" @click="command('editor.code-block')"><span>{{ t('代码块', 'Code block') }}</span></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')">
<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="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>
<span class="menu-separator" role="separator" />
<button role="menuitem" @click="toggleTheme"><span>{{ theme.isDark ? t('浅色主题', 'Light theme') : t('深色主题', 'Dark theme') }}</span></button>
</div>
</div>
<span v-if="error" class="menu-error" role="alert">{{ error }}</span>
</nav>
</template>
<style scoped>
.titlebar-menu { position: relative; flex: 0 0 auto; -webkit-app-region: no-drag; }
.menu-trigger { min-height: 28px; padding: 3px 10px; border: 1px solid transparent; border-radius: var(--radius-sm); color: var(--color-text-secondary); }
.menu-trigger:hover, .menu-trigger[aria-expanded='true'] { border-color: var(--color-border-default); background: var(--color-background-hover); color: var(--color-text-primary); }
.menu-popover { position: absolute; top: calc(100% + 5px); left: 0; z-index: calc(var(--z-titlebar) + 2); display: grid; min-width: 220px; 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); }
.menu-popover button { width: 100%; padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); text-align: left; white-space: nowrap; }
.desktop-menu-bar { position: relative; z-index: calc(var(--z-titlebar) - 1); display: flex; align-items: center; min-height: 32px; padding: 0 var(--space-md); border-bottom: 1px solid var(--color-border-default); background: var(--color-surface-primary); flex-shrink: 0; user-select: none; -webkit-app-region: no-drag; }
.menu-group { position: relative; height: 100%; display: flex; align-items: center; }
.menu-trigger { min-width: 52px; height: 28px; padding: 2px 12px; border-radius: var(--radius-sm); color: var(--color-text-secondary); }
.menu-trigger:hover, .menu-trigger[aria-expanded='true'] { background: var(--color-background-hover); color: var(--color-text-primary); }
.menu-popover { position: absolute; top: calc(100% + 2px); left: 0; z-index: calc(var(--z-titlebar) + 2); display: grid; min-width: 230px; 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); }
.paragraph-menu { min-width: 270px; }
.menu-popover button { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); width: 100%; padding: 7px var(--space-md); border-radius: var(--radius-sm); text-align: left; white-space: nowrap; }
.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: .5; cursor: not-allowed; }
.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); }
.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-popover small[role='alert'] { color: var(--color-error); }
.menu-error { margin-left: var(--space-md); color: var(--color-error); font-size: var(--font-size-xs); }
</style>
+15 -1
View File
@@ -9,8 +9,22 @@ import { isDesktop } from './desktop'
export async function installDesktopLifecycle() {
if (!isDesktop()) return
const activeEditor = useEditorStore()
// 保存属于窗口级命令;编辑区原生处理撤销/重做,避免重复派发。
window.addEventListener('keydown', event => {
if (event.defaultPrevented || event.isComposing || event.repeat || event.altKey || event.shiftKey) return
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return
const target = event.target as HTMLElement | null
if (target?.closest('input, textarea, select, dialog[open], [role="dialog"]')) return
if (!activeEditor.currentFilePath || ['saving', 'conflict', 'external_changed'].includes(activeEditor.saveStatus)) return
event.preventDefault()
void activeEditor.save()
})
watch(() => [activeEditor.currentFilePath, activeEditor.saveStatus, activeEditor.mode], updateNativeEditorMenu, { flush: 'post' })
await listen<string>('editor-command', event => { void executeEditorCommand(event.payload) })
await listen<string>('editor-command', event => {
const focused = document.activeElement as HTMLElement | null
if (focused?.closest('input, textarea, select, dialog[open], [role="dialog"]')) return
void executeEditorCommand(event.payload)
})
let closing = false
await listen('host-close-requested', async () => {
if (closing) return