From acc0ba167d827aec0265140c11f3c3e6ab6e9cd7 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Mon, 7 Sep 2026 23:35:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20=E9=87=8D=E6=9E=84=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E5=B9=B6=E6=81=A2=E5=A4=8D=20Shiki=20=E9=AB=98?= =?UTF-8?q?=E4=BA=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../警告框与桌面编辑命令开发说明.md | 10 + frontend/src-tauri/src/main.rs | 9 + frontend/src-tauri/tauri.conf.json | 2 +- .../components/common/TitleBarMenu.spec.ts | 33 ++- .../src/components/common/TitleBarMenu.vue | 211 ++++++++++++------ .../editor/VisualMarkdownEditor.spec.ts | 19 ++ .../src/services/editorCommandService.spec.ts | 13 +- frontend/src/services/editorCommandService.ts | 11 + frontend/src/services/editorMenu.ts | 88 ++++++++ frontend/src/services/platform/lifecycle.ts | 29 +-- 10 files changed, 325 insertions(+), 100 deletions(-) create mode 100644 frontend/src/services/editorMenu.ts diff --git a/docs/development/警告框与桌面编辑命令开发说明.md b/docs/development/警告框与桌面编辑命令开发说明.md index 3c59354..37b39b5 100644 --- a/docs/development/警告框与桌面编辑命令开发说明.md +++ b/docs/development/警告框与桌面编辑命令开发说明.md @@ -88,3 +88,13 @@ npm run build 修正工作区基础选择器覆盖类型颜色的问题,默认色使用低优先级规则。社区预览 iframe 同步载入共享 callouts CSS,并展示 14 种规范类型、默认展开/折叠及嵌套样例。主题安装与预览使用同一 CSS 来源,纸间时光下载包的清单和样式同步更新;已安装旧版可通过主题页既有更新入口升级。 验证增加六主题 × 六配色在实际 8% 混色背景上的标题对比度检查(至少 4.5:1),工作区选择器覆盖回归,以及六主题预览结构检查。此检查针对不透明 sRGB 配色,不代替每个平台的字体与截图验收。 + +## 5. 第三阶段桌面菜单接入 + +日期:2026-09-07。 + +无边框窗口的“文件 / 编辑 / 段落 / 格式 / 视图 / 主题 / 帮助”菜单使用 `TitleBarMenu.vue` 渲染。段落、格式、十四种警告框及快捷键标签集中定义在 `editorMenu.ts`;桌面生命周期的按键分发引用同一映射。加粗、斜体、撤销和重做继续由编辑器原生键位处理,避免顶层监听重复执行。菜单项通过 `editorCommandService` 的订阅接口随活动编辑器、模式和可用状态即时更新。 + +警告框使用二级菜单,支持方向键、Home、End、Esc、Tab 和子菜单左右键。菜单采用 `menubar`、`menu`、`menuitem` / `menuitemradio` 语义,主题颜色全部来自现有设计变量。 + +桌面 WebView 的 CSP 在 `script-src` 中仅增加 `'wasm-unsafe-eval'`,用于初始化 Shiki 的 Oniguruma WASM;普通 `'unsafe-eval'` 仍保持禁用。回归测试同时检查发行配置的 CSP 约束,以及真实挂载的 Milkdown 代码块在 GitHub Light / Dark 下生成多种 `.shiki-token` 颜色。 diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 24ba4d7..3d2fd1a 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -70,6 +70,15 @@ mod core_proxy_tests { assert!(core_url("https://example.com/api/status").is_err()); assert!(core_url("/api/../secret").is_err()); } + + #[test] + fn desktop_csp_allows_shiki_wasm_without_general_eval() { + let config: serde_json::Value = + serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + let csp = config["app"]["security"]["csp"].as_str().unwrap(); + assert!(csp.contains("'wasm-unsafe-eval'")); + assert!(!csp.split_whitespace().any(|token| token == "'unsafe-eval'")); + } } /// 预览版只代理固定回环地址,避免 WebView CORS 与任意地址转发。 diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index f9c874e..2612a0b 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -12,7 +12,7 @@ "app": { "windows": [{"label": "main", "title": "NotesAgent Preview", "width": 1280, "height": 960, "minWidth": 720, "minHeight": 700, "center": true, "decorations": false}], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:8000; font-src 'self' data:; connect-src ipc: http://ipc.localhost http://127.0.0.1:8000; frame-src 'self' blob:; object-src 'none'; base-uri 'self'", + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:8000; font-src 'self' data:; connect-src ipc: http://ipc.localhost http://127.0.0.1:8000; frame-src 'self' blob:; object-src 'none'; base-uri 'self'", "capabilities": ["main"] } }, diff --git a/frontend/src/components/common/TitleBarMenu.spec.ts b/frontend/src/components/common/TitleBarMenu.spec.ts index 33e5085..1cb7bb4 100644 --- a/frontend/src/components/common/TitleBarMenu.spec.ts +++ b/frontend/src/components/common/TitleBarMenu.spec.ts @@ -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() + }) }) diff --git a/frontend/src/components/common/TitleBarMenu.vue b/frontend/src/components/common/TitleBarMenu.vue index 23a649e..0acb56b 100644 --- a/frontend/src/components/common/TitleBarMenu.vue +++ b/frontend/src/components/common/TitleBarMenu.vue @@ -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(null) +const nestedOpen = ref(false) const bar = ref(null) const error = ref('') +const capabilities = ref(new Map()) 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(`[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(`[data-menu="${name}"] > .menu-trigger`)?.focus() +} +function menuItems(menu: Element) { + return [...menu.querySelectorAll('[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('.submenu-trigger')?.focus() + } else { + const name = open.value + close() + if (name) bar.value?.querySelector(`[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('[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('.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('.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) +})