From cb123f43171ca4503e415701d8c7f8ead4073c47 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 --- 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 +-- 9 files changed, 315 insertions(+), 100 deletions(-) create mode 100644 frontend/src/services/editorMenu.ts 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) +})