fix(desktop): 显示完整菜单栏并启用快捷键
This commit is contained in:
@@ -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. 验证方法
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user