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 当作已可执行能力;原生快捷键不绕过活动文档、只读和冲突检查。 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 插入。元数据不得通过普通正文插入接口冒充属性导入。 目录还预留删除线、任务列表、引用、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. 验证方法 ## 3. 验证方法
+1 -1
View File
@@ -170,7 +170,7 @@ fn main() {
"editor.import-note-properties", "editor.import-note-properties",
"导入为笔记属性…", "导入为笔记属性…",
false, false,
None::<&str>, Some("CmdOrCtrl+Alt+P"),
)?; )?;
let paragraph = Submenu::with_items(app, "段落", true, &[&import])?; let paragraph = Submenu::with_items(app, "段落", true, &[&import])?;
app.manage(import); app.manage(import);
+2
View File
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import AppShell from '@/components/common/AppShell.vue' import AppShell from '@/components/common/AppShell.vue'
import TitleBar from '@/components/common/TitleBar.vue' import TitleBar from '@/components/common/TitleBar.vue'
import TitleBarMenu from '@/components/common/TitleBarMenu.vue'
import { isDesktop } from '@/services/platform/desktop' import { isDesktop } from '@/services/platform/desktop'
const route = useRoute() const route = useRoute()
@@ -13,6 +14,7 @@ const desktop = isDesktop()
<template> <template>
<div v-if="isVaultEntry" class="entry-shell"> <div v-if="isVaultEntry" class="entry-shell">
<TitleBar v-if="desktop" /> <TitleBar v-if="desktop" />
<TitleBarMenu v-if="desktop" />
<router-view /> <router-view />
</div> </div>
<AppShell v-else> <AppShell v-else>
@@ -9,8 +9,10 @@ import PrimarySidebar from './PrimarySidebar.vue'
import SecondarySidebar from './SecondarySidebar.vue' import SecondarySidebar from './SecondarySidebar.vue'
import StatusBar from './StatusBar.vue' import StatusBar from './StatusBar.vue'
import TitleBar from './TitleBar.vue' import TitleBar from './TitleBar.vue'
import TitleBarMenu from './TitleBarMenu.vue'
import CommandPalette from './CommandPalette.vue' import CommandPalette from './CommandPalette.vue'
import { getIndexStatus } from '@/services/indexService' import { getIndexStatus } from '@/services/indexService'
import { isDesktop } from '@/services/platform/desktop'
import { navigateToCitation } from '@/composables/useCitationNavigation' import { navigateToCitation } from '@/composables/useCitationNavigation'
import { useWorkspaceRefresh } from '@/composables/useWorkspaceRefresh' import { useWorkspaceRefresh } from '@/composables/useWorkspaceRefresh'
useWorkspaceRefresh() useWorkspaceRefresh()
@@ -25,6 +27,7 @@ const editorStore = useEditorStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const desktop = isDesktop()
let statusTimer: ReturnType<typeof setTimeout> | undefined let statusTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false let disposed = false
@@ -75,6 +78,7 @@ defineExpose({ openCitation })
<template> <template>
<div class="app-shell" :class="{ 'theme-dark': themeStore.isDark }"> <div class="app-shell" :class="{ 'theme-dark': themeStore.isDark }">
<TitleBar /> <TitleBar />
<TitleBarMenu v-if="desktop" />
<div class="app-body"> <div class="app-body">
<PrimarySidebar /> <PrimarySidebar />
<SecondarySidebar v-if="secondaryComponent" :component="secondaryComponent" /> <SecondarySidebar v-if="secondaryComponent" :component="secondaryComponent" />
@@ -6,7 +6,6 @@ import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { Moon, Sunny } from '@element-plus/icons-vue' import { Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue' import AppIcon from './AppIcon.vue'
import TitleBarMenu from './TitleBarMenu.vue'
import { t } from '@/i18n' import { t } from '@/i18n'
import { isDesktop } from '@/services/platform/desktop' import { isDesktop } from '@/services/platform/desktop'
import { minimizeWindow, requestWindowClose, toggleMaximizeWindow } from '@/services/platform/windowControls' import { minimizeWindow, requestWindowClose, toggleMaximizeWindow } from '@/services/platform/windowControls'
@@ -62,7 +61,6 @@ function toggleFromTitlebar(event: MouseEvent) {
{{ currentFileName }} {{ currentFileName }}
<span v-if="isDirty" class="dirty-dot" /> <span v-if="isDirty" class="dirty-dot" />
</span> </span>
<TitleBarMenu />
</div> </div>
<div class="titlebar-center" data-tauri-drag-region> <div class="titlebar-center" data-tauri-drag-region>
<span class="app-name" data-tauri-drag-region>NotesAgent</span> <span class="app-name" data-tauri-drag-region>NotesAgent</span>
@@ -6,11 +6,17 @@ import { useEditorStore } from '@/stores/editor'
import TitleBarMenu from './TitleBarMenu.vue' import TitleBarMenu from './TitleBarMenu.vue'
const execute = vi.hoisted(() => vi.fn()) 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(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
execute.mockReset().mockResolvedValue({ ok: true }) 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('桌面顶部段落菜单', () => { describe('桌面顶部段落菜单', () => {
@@ -20,8 +26,9 @@ describe('桌面顶部段落菜单', () => {
editor.currentFilePath = '/示例.md' editor.currentFilePath = '/示例.md'
editor.saveStatus = 'saved' editor.saveStatus = 'saved'
const wrapper = mount(TitleBarMenu) const wrapper = mount(TitleBarMenu)
await wrapper.get('.menu-trigger').trigger('click') expect(wrapper.text()).toContain('文件编辑段落视图')
const item = wrapper.get('[role="menuitem"]') await wrapper.get('[data-menu="paragraph"] .menu-trigger').trigger('click')
const item = wrapper.get('.import-properties')
expect((item.element as HTMLButtonElement).disabled).toBe(false) expect((item.element as HTMLButtonElement).disabled).toBe(false)
await item.trigger('click') await item.trigger('click')
expect(execute).toHaveBeenCalledWith('editor.import-note-properties') expect(execute).toHaveBeenCalledWith('editor.import-note-properties')
@@ -34,9 +41,21 @@ describe('桌面顶部段落菜单', () => {
editor.mode = 'wysiwyg' editor.mode = 'wysiwyg'
editor.currentFilePath = '/示例.md' editor.currentFilePath = '/示例.md'
const wrapper = mount(TitleBarMenu) const wrapper = mount(TitleBarMenu)
await wrapper.get('.menu-trigger').trigger('click') capabilities.mockReturnValue([])
expect((wrapper.get('[role="menuitem"]').element as HTMLButtonElement).disabled).toBe(true) 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 源码笔记中使用') expect(wrapper.text()).toContain('请在无冲突的 Markdown 源码笔记中使用')
wrapper.unmount() 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()
})
}) })
+75 -40
View File
@@ -1,67 +1,102 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useEditorStore } from '@/stores/editor' 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' import { t } from '@/i18n'
type MenuName = 'file' | 'edit' | 'paragraph' | 'view'
const editor = useEditorStore() const editor = useEditorStore()
const open = ref(false) const theme = useThemeStore()
const menu = ref<HTMLElement | null>(null) const open = ref<MenuName | null>(null)
const trigger = ref<HTMLButtonElement | null>(null) const bar = ref<HTMLElement | null>(null)
const importItem = ref<HTMLButtonElement | null>(null)
const error = ref('') const error = ref('')
const importEnabled = computed(() => editor.mode === 'source' && !!editor.currentFilePath const shortcut = computed(() => /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘⌥P' : 'Ctrl+Alt+P')
&& !['conflict', 'external_changed'].includes(editor.saveStatus))
function dismiss(event: PointerEvent) { function enabled(id: EditorCommandId) {
if (!menu.value?.contains(event.target as Node)) open.value = false return getEditorCommandCapabilities().find(item => item.id === id)?.enabled ?? false
} }
function toggle(name: MenuName) { open.value = open.value === name ? null : name; error.value = '' }
async function showAndFocus() { function dismiss(event: PointerEvent) { if (!bar.value?.contains(event.target as Node)) open.value = null }
open.value = true async function focusFirst(name: MenuName) {
open.value = name
await nextTick() await nextTick()
importItem.value?.focus() bar.value?.querySelector<HTMLElement>(`[data-menu="${name}"] [role="menuitem"]:not(:disabled)`)?.focus()
} }
function close() { open.value = null }
function closeAndFocus() { async function command(id: EditorCommandId, params?: unknown) {
open.value = false const result = params === undefined ? await executeEditorCommand(id) : await executeEditorCommand(id, params)
trigger.value?.focus() if (result.ok) close()
} else error.value = t('当前编辑器无法执行此命令。', 'The active editor cannot run this command.')
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 }
} }
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)) onMounted(() => window.addEventListener('pointerdown', dismiss))
onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss)) onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
</script> </script>
<template> <template>
<div ref="menu" class="titlebar-menu"> <nav ref="bar" class="desktop-menu-bar" :aria-label="t('应用菜单', 'Application menu')" @keydown.esc.stop.prevent="close">
<button ref="trigger" type="button" class="menu-trigger" aria-haspopup="menu" :aria-expanded="open" @click="open ? closeAndFocus() : showAndFocus()" @keydown.down.prevent="showAndFocus"> <div class="menu-group" data-menu="file">
{{ t('段落', 'Paragraph') }} <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> </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>
</div> </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> </template>
<style scoped> <style scoped>
.titlebar-menu { position: relative; flex: 0 0 auto; -webkit-app-region: no-drag; } .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-trigger { min-height: 28px; padding: 3px 10px; border: 1px solid transparent; border-radius: var(--radius-sm); color: var(--color-text-secondary); } .menu-group { position: relative; height: 100%; display: flex; align-items: center; }
.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-trigger { min-width: 52px; height: 28px; padding: 2px 12px; border-radius: var(--radius-sm); color: var(--color-text-secondary); }
.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-trigger:hover, .menu-trigger[aria-expanded='true'] { background: var(--color-background-hover); color: var(--color-text-primary); }
.menu-popover button { width: 100%; padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); text-align: left; white-space: nowrap; } .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: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 { 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> </style>
+15 -1
View File
@@ -9,8 +9,22 @@ import { isDesktop } from './desktop'
export async function installDesktopLifecycle() { export async function installDesktopLifecycle() {
if (!isDesktop()) return if (!isDesktop()) return
const activeEditor = useEditorStore() 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' }) 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 let closing = false
await listen('host-close-requested', async () => { await listen('host-close-requested', async () => {
if (closing) return if (closing) return