feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45

Merged
Kronecker merged 151 commits from feat/phase3-completion into main 2026-09-10 01:16:31 +08:00
10 changed files with 325 additions and 100 deletions
Showing only changes of commit acc0ba167d - Show all commits
@@ -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` 颜色。
+9
View File
@@ -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 与任意地址转发。
+1 -1
View File
@@ -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"]
}
},
@@ -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()
})
})
+144 -67
View File
@@ -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<MenuName | null>(null)
const nestedOpen = ref(false)
const bar = ref<HTMLElement | null>(null)
const error = ref('')
const capabilities = ref(new Map<EditorCommandId, boolean>())
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<HTMLElement>(`[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<HTMLElement>(`[data-menu="${name}"] > .menu-trigger`)?.focus()
}
function menuItems(menu: Element) {
return [...menu.querySelectorAll<HTMLElement>('[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<HTMLElement>('.submenu-trigger')?.focus()
} else {
const name = open.value
close()
if (name) bar.value?.querySelector<HTMLElement>(`[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<HTMLElement>('[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<HTMLElement>('.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<HTMLElement>('.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)
})
</script>
<template>
<nav ref="bar" class="desktop-menu-bar" :aria-label="t('应用菜单', 'Application menu')" @keydown.esc.stop.prevent="close">
<nav ref="bar" class="desktop-menu-bar" role="menubar" :aria-label="t('应用菜单', 'Application menu')" @keydown="handleKeys">
<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" @click="navigate('/')"><span>{{ t('选择知识库…', 'Choose knowledge base…') }}</span></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'file'" aria-haspopup="menu" @click="toggle('file')" @pointerenter="open && focusFirst('file')">{{ t('文件', 'File') }}</button>
<div v-if="open === 'file'" class="menu-popover" role="menu" :aria-label="t('文件', 'File')">
<button data-menu-item role="menuitem" @click="navigate('/')"><span>{{ t('选择知识库…', 'Choose knowledge base…') }}</span></button>
<span class="menu-separator" role="separator" />
<button role="menuitem" :disabled="!editor.currentFilePath || ['saving','conflict','external_changed'].includes(editor.saveStatus)" @click="save">
<button data-menu-item 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 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>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'edit'" aria-haspopup="menu" @click="toggle('edit')" @pointerenter="open && focusFirst('edit')">{{ t('编辑', 'Edit') }}</button>
<div v-if="open === 'edit'" class="menu-popover" role="menu" :aria-label="t('编辑', 'Edit')">
<button data-menu-item role="menuitem" :disabled="!enabled('editor.undo')" @click="command('editor.undo')"><span>{{ t('撤销', 'Undo') }}</span><kbd>Ctrl+Z</kbd></button>
<button data-menu-item 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><kbd>Ctrl+0</kbd></button>
<button v-for="level in 6" :key="level" role="menuitem" :disabled="!enabled('editor.heading')" @click="command('editor.heading', level)"><span>{{ t(`${level} 级标题`, `Heading ${level}`) }}</span><kbd>Ctrl+{{ level }}</kbd></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'paragraph'" aria-haspopup="menu" @click="toggle('paragraph')" @pointerenter="open && focusFirst('paragraph')">{{ t('段落', 'Paragraph') }}</button>
<div v-if="open === 'paragraph'" class="menu-popover paragraph-menu" role="menu" :aria-label="t('段落', 'Paragraph')">
<template v-for="(section, sectionIndex) in paragraphMenuSections" :key="sectionIndex">
<button v-for="item in section" :key="`${item.id}-${String(item.params ?? '')}`" data-menu-item role="menuitem" :disabled="!enabled(item.id)" @click="commandItem(item)"><span>{{ t(...item.label) }}</span><kbd v-if="item.shortcut">{{ item.shortcut }}</kbd></button>
<span v-if="sectionIndex < paragraphMenuSections.length - 1" class="menu-separator" role="separator" />
</template>
<span class="menu-separator" role="separator" />
<button role="menuitem" :disabled="!enabled('editor.bullet-list')" @click="command('editor.bullet-list')"><span>{{ t('无序列表', 'Bullet list') }}</span><kbd>Ctrl+Shift+]</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.ordered-list')" @click="command('editor.ordered-list')"><span>{{ t('有序列表', 'Ordered list') }}</span><kbd>Ctrl+Shift+[</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.task-list')" @click="command('editor.task-list')"><span>{{ t('任务列表', 'Task list') }}</span><kbd>Ctrl+Shift+X</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.blockquote')" @click="command('editor.blockquote')"><span>{{ t('引用', 'Blockquote') }}</span><kbd>Ctrl+Shift+Q</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.code-block')" @click="command('editor.code-block')"><span>{{ t('代码块', 'Code block') }}</span><kbd>Ctrl+Shift+K</kbd></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')">
<button class="import-properties" data-menu-item 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="format">
<button class="menu-trigger" :aria-expanded="open === 'format'" aria-haspopup="menu" @click="toggle('format')" @keydown.down.prevent="focusFirst('format')">{{ t('格式', 'Format') }}</button>
<div v-if="open === 'format'" class="menu-popover format-menu" role="menu">
<button role="menuitem" :disabled="!enabled('editor.bold')" @click="command('editor.bold')"><span>{{ t('加粗', 'Bold') }}</span><kbd>Ctrl+B</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.italic')" @click="command('editor.italic')"><span>{{ t('斜体', 'Italic') }}</span><kbd>Ctrl+I</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.strikethrough')" @click="command('editor.strikethrough')"><span>{{ t('删除线', 'Strikethrough') }}</span><kbd>Alt+Shift+5</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.inline-code')" @click="command('editor.inline-code')"><span>{{ t('行内代码', 'Inline code') }}</span><kbd>Ctrl+`</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.link')" @click="command('editor.link')"><span>{{ t('链接', 'Link') }}</span><kbd>Ctrl+K</kbd></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'format'" aria-haspopup="menu" @click="toggle('format')" @pointerenter="open && focusFirst('format')">{{ t('格式', 'Format') }}</button>
<div v-if="open === 'format'" class="menu-popover format-menu" role="menu" :aria-label="t('格式', 'Format')">
<template v-for="item in formatMenuSections[0]" :key="item.id">
<button data-menu-item role="menuitem" :disabled="!enabled(item.id)" @click="commandItem(item)"><span>{{ t(...item.label) }}</span><kbd v-if="item.shortcut">{{ item.shortcut }}</kbd></button>
</template>
<span class="menu-separator" role="separator" />
<button role="menuitem" :disabled="!enabled('editor.code-block')" @click="command('editor.code-block')"><span>{{ t('代码块', 'Code block') }}</span><kbd>Ctrl+Shift+K</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.math-block')" @click="command('editor.math-block')"><span>{{ t('公式块', 'Math block') }}</span><kbd>Ctrl+Shift+M</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.inline-math')" @click="command('editor.inline-math')"><span>{{ t('行内公式', 'Inline math') }}</span><kbd>Ctrl+Shift+L</kbd></button>
<button role="menuitem" :disabled="!enabled('editor.table')" @click="command('editor.table')"><span>{{ t('表格', 'Table') }}</span><kbd>Ctrl+Alt+T</kbd></button>
<div class="submenu-heading"><span>{{ t('警告框样式', 'Callout styles') }}</span><kbd>Ctrl+Alt+C</kbd></div>
<div class="callout-options">
<button v-for="item in callouts" :key="item[0]" role="menuitem" :disabled="!enabled('editor.callout')" @click="command('editor.callout', { type: item[0], body: t('提示内容', 'Callout content') })"><span>{{ item[1] }}</span><code>{{ item[0] }}</code></button>
<template v-for="item in formatMenuSections[1]" :key="item.id">
<button data-menu-item role="menuitem" :disabled="!enabled(item.id)" @click="commandItem(item)"><span>{{ t(...item.label) }}</span><kbd v-if="item.shortcut">{{ item.shortcut }}</kbd></button>
</template>
<div class="submenu" @pointerleave="nestedOpen = false">
<button class="submenu-trigger" data-menu-item role="menuitem" aria-haspopup="menu" :aria-expanded="nestedOpen" :disabled="!enabled('editor.callout')" @click="nestedOpen = !nestedOpen" @pointerenter="nestedOpen = true"><span>{{ t('警告框样式', 'Callout styles') }}</span><span><kbd>Ctrl+Alt+C</kbd><span class="submenu-arrow"></span></span></button>
<div v-if="nestedOpen" class="submenu-popover" role="menu" :aria-label="t('警告框样式', 'Callout styles')">
<button v-for="item in calloutMenuCommands" :key="calloutType(item)" data-menu-item role="menuitem" @click="commandItem(item)"><span>{{ t(...item.label) }}</span><code>{{ calloutType(item) }}</code></button>
</div>
</div>
<button role="menuitem" :disabled="!enabled('editor.horizontal-rule')" @click="command('editor.horizontal-rule')"><span>{{ t('水平分割线', 'Horizontal rule') }}</span><kbd>Ctrl+Shift+H</kbd></button>
<span class="menu-separator" role="separator" />
<button class="metadata-command" role="menuitem" :disabled="!enabled('editor.import-note-properties') && !enabled('editor.metadata.edit')" @click="metadataCommand"><span>{{ t('元数据 / YAML Front Matter…', 'Metadata / YAML Front Matter…') }}</span><kbd>{{ shortcut }}</kbd></button>
<template v-for="item in formatMenuSections[2]" :key="item.id">
<button data-menu-item role="menuitem" :disabled="!enabled(item.id)" @click="commandItem(item)"><span>{{ t(...item.label) }}</span><kbd v-if="item.shortcut">{{ item.shortcut }}</kbd></button>
</template>
<span class="menu-separator" role="separator" />
<button class="metadata-command" data-menu-item role="menuitem" :disabled="!enabled('editor.import-note-properties') && !enabled('editor.metadata.edit')" @click="metadataCommand"><span>{{ t('元数据 / YAML Front Matter…', 'Metadata / YAML Front Matter…') }}</span><kbd>{{ shortcut }}</kbd></button>
</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>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'view'" aria-haspopup="menu" @click="toggle('view')" @pointerenter="open && focusFirst('view')">{{ t('视图', 'View') }}</button>
<div v-if="open === 'view'" class="menu-popover" role="menu" :aria-label="t('视图', 'View')">
<button data-menu-item role="menuitemradio" :aria-checked="editor.mode === 'wysiwyg'" @click="setMode('wysiwyg')"><span>{{ editor.mode === 'wysiwyg' ? '✓ ' : '' }}{{ t('写作模式', 'Writing mode') }}</span></button>
<button data-menu-item role="menuitemradio" :aria-checked="editor.mode === 'source'" @click="setMode('source')"><span>{{ editor.mode === 'source' ? '✓ ' : '' }}{{ t('源码模式', 'Source mode') }}</span></button>
<span class="menu-separator" role="separator" />
<button role="menuitem" :disabled="!enabled('editor.heading.fold-all')" @click="command('editor.heading.fold-all')"><span>{{ t('全部折叠标题', 'Fold all headings') }}</span></button>
<button role="menuitem" :disabled="!enabled('editor.heading.unfold-all')" @click="command('editor.heading.unfold-all')"><span>{{ t('全部展开标题', 'Unfold all headings') }}</span></button>
<button data-menu-item role="menuitem" :disabled="!enabled('editor.heading.fold-all')" @click="command('editor.heading.fold-all')"><span>{{ t('全部折叠标题', 'Fold all headings') }}</span></button>
<button data-menu-item role="menuitem" :disabled="!enabled('editor.heading.unfold-all')" @click="command('editor.heading.unfold-all')"><span>{{ t('全部展开标题', 'Unfold all headings') }}</span></button>
</div>
</div>
<div class="menu-group" data-menu="theme">
<button class="menu-trigger" :aria-expanded="open === 'theme'" aria-haspopup="menu" @click="toggle('theme')" @keydown.down.prevent="focusFirst('theme')">{{ t('主题', 'Theme') }}</button>
<div v-if="open === 'theme'" class="menu-popover" role="menu">
<button v-for="item in theme.allThemes" :key="item.theme_id" role="menuitemradio" :aria-checked="theme.currentThemeId === item.theme_id" @click="applyTheme(item.theme_id)"><span>{{ theme.currentThemeId === item.theme_id ? '✓ ' : '' }}{{ item.name }}</span></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'theme'" aria-haspopup="menu" @click="toggle('theme')" @pointerenter="open && focusFirst('theme')">{{ t('主题', 'Theme') }}</button>
<div v-if="open === 'theme'" class="menu-popover" role="menu" :aria-label="t('主题', 'Theme')">
<button v-for="item in theme.allThemes" :key="item.theme_id" data-menu-item role="menuitemradio" :aria-checked="theme.currentThemeId === item.theme_id" @click="applyTheme(item.theme_id)"><span>{{ theme.currentThemeId === item.theme_id ? '✓ ' : '' }}{{ item.name }}</span></button>
<span class="menu-separator" role="separator" />
<button role="menuitem" @click="toggleTheme"><span>{{ theme.isDark ? t('切换为浅色', 'Switch to light') : t('切换为深色', 'Switch to dark') }}</span></button>
<button role="menuitem" @click="navigate('/themes')"><span>{{ t('管理主题…', 'Manage themes…') }}</span></button>
<button data-menu-item role="menuitem" @click="toggleTheme"><span>{{ theme.isDark ? t('切换为浅色', 'Switch to light') : t('切换为深色', 'Switch to dark') }}</span></button>
<button data-menu-item role="menuitem" @click="navigate('/themes')"><span>{{ t('管理主题…', 'Manage themes…') }}</span></button>
</div>
</div>
<div class="menu-group" data-menu="help">
<button class="menu-trigger" :aria-expanded="open === 'help'" aria-haspopup="menu" @click="toggle('help')" @keydown.down.prevent="focusFirst('help')">{{ t('帮助', 'Help') }}</button>
<div v-if="open === 'help'" class="menu-popover" role="menu">
<button role="menuitem" @click="navigate('/logs')"><span>{{ t('运行日志', 'Operation logs') }}</span></button>
<button role="menuitem" @click="navigate('/settings')"><span>{{ t('设置与诊断', 'Settings and diagnostics') }}</span></button>
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'help'" aria-haspopup="menu" @click="toggle('help')" @pointerenter="open && focusFirst('help')">{{ t('帮助', 'Help') }}</button>
<div v-if="open === 'help'" class="menu-popover" role="menu" :aria-label="t('帮助', 'Help')">
<button data-menu-item role="menuitem" @click="navigate('/logs')"><span>{{ t('运行日志', 'Operation logs') }}</span></button>
<button data-menu-item role="menuitem" @click="navigate('/settings')"><span>{{ t('设置与诊断', 'Settings and diagnostics') }}</span></button>
</div>
</div>
<span v-if="error" class="menu-error" role="alert">{{ error }}</span>
@@ -152,11 +226,14 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
.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: .45; cursor: not-allowed; }
.menu-popover kbd { color: var(--color-text-tertiary); font: inherit; font-size: var(--font-size-xs); }
.submenu-heading { display: flex; justify-content: space-between; padding: 7px var(--space-md) 4px; color: var(--color-text-secondary); font-weight: 650; }
.callout-options { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; padding: 0 var(--space-xs) var(--space-xs); }
.callout-options button { gap: var(--space-sm); padding: 5px var(--space-sm); }
.callout-options code { color: var(--color-text-tertiary); font-size: 10px; }
.submenu { position: relative; }
.submenu-trigger > span:last-child { display: inline-flex; align-items: center; gap: var(--space-sm); }
.submenu-arrow { color: var(--color-text-tertiary); font-size: 18px; line-height: 1; }
.submenu-popover { position: absolute; bottom: -5px; left: calc(100% + 4px); display: grid; min-width: 210px; max-height: calc(100vh - 96px); overflow: auto; 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); }
.submenu-popover button { padding: 6px var(--space-md); }
.submenu-popover code { color: var(--color-text-tertiary); font-size: 10px; }
.menu-popover { max-height: calc(100vh - 110px); overflow: auto; }
.menu-popover.format-menu { max-height: none; overflow: visible; }
.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-error { margin-left: var(--space-md); color: var(--color-error); font-size: var(--font-size-xs); }
@@ -365,6 +365,25 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
expect(string?.style.color.toUpperCase()).toBe(theme === 'github-dark' ? '#9ECBFF' : '#032F62')
} finally { cm.destroy() }
})
it.each(['github-light', 'github-dark'] as const)('renders Shiki %s tokens in the mounted Milkdown code block', async theme => {
useThemeStore().codeBlockTheme = theme
const wrapper = mount(VisualMarkdownEditor, {
props: { initialContent: '```python\ndef average(scores: list[float]) -> float | None:\n return sum(scores) / len(scores)\n```' },
attachTo: document.body,
})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
editor.action(ctx => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, 0)))
})
for (let attempt = 0; attempt < 100 && wrapper.findAll('.shiki-token').length < 4; attempt++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
const tokens = wrapper.findAll<HTMLElement>('.shiki-token')
expect(tokens.length).toBeGreaterThan(3)
expect(new Set(tokens.map(token => token.element.style.color).filter(Boolean)).size).toBeGreaterThan(2)
})
it('applies bold from the top toolbar to the selected text', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
mounted.push(wrapper)
@@ -1,5 +1,5 @@
import { afterEach, expect, it, vi } from 'vitest'
import { executeEditorCommand, registerEditorCommands, getEditorCommandCapabilities } from './editorCommandService'
import { executeEditorCommand, registerEditorCommands, getEditorCommandCapabilities, subscribeEditorCommandCapabilities, updateNativeEditorMenu } from './editorCommandService'
let dispose: (() => void) | undefined
afterEach(() => dispose?.())
it('reports unsupported, disabled and invalid commands without side effects', async () => {
@@ -23,3 +23,14 @@ it('old editor disposal cannot unregister the replacement editor', async () => {
dispose()
expect(await executeEditorCommand('editor.bold')).toEqual({ ok: false, reason: 'unavailable' })
})
it('notifies the visible menu when the active editor capability changes', () => {
const listener = vi.fn()
const unsubscribe = subscribeEditorCommandCapabilities(listener)
dispose = registerEditorCommands({ available: () => true, handlers: { 'editor.bold': () => ({ ok: true }) } })
expect(listener).toHaveBeenCalledTimes(1)
updateNativeEditorMenu()
expect(listener).toHaveBeenCalledTimes(2)
unsubscribe()
updateNativeEditorMenu()
expect(listener).toHaveBeenCalledTimes(2)
})
@@ -17,6 +17,16 @@ export type CommandResult = { ok: true } | { ok: false; reason: 'unsupported' |
export type CommandHandler = (params: unknown) => CommandResult | Promise<CommandResult>
type Target = { available: () => boolean; handlers: Partial<Record<EditorCommandId, CommandHandler>> }
let active: Target | undefined
const capabilityListeners = new Set<() => void>()
export function subscribeEditorCommandCapabilities(listener: () => void) {
capabilityListeners.add(listener)
return () => capabilityListeners.delete(listener)
}
function notifyCapabilityListeners() {
capabilityListeners.forEach(listener => listener())
}
export function registerEditorCommands(target: Target) {
active = target
@@ -24,6 +34,7 @@ export function registerEditorCommands(target: Target) {
return () => { if (active === target) { active = undefined; updateNativeEditorMenu() } }
}
export function updateNativeEditorMenu() {
notifyCapabilityListeners()
if (isDesktop()) void hostInvoke('editor_capabilities', { importEnabled: !!active?.handlers['editor.import-note-properties'] && active.available() }).catch(() => undefined)
}
export function getEditorCommandCapabilities() {
+88
View File
@@ -0,0 +1,88 @@
import type { EditorCommandId } from './editorCommandService'
export type EditorMenuCommand = {
id: EditorCommandId
label: readonly [zh: string, en: string]
shortcut?: string
params?: unknown
}
export type ShortcutEvent = Pick<KeyboardEvent, 'code' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>
export type ShortcutCommand = Pick<EditorMenuCommand, 'id' | 'params'>
export const headingMenuCommands: readonly EditorMenuCommand[] = [1, 2, 3, 4, 5, 6].map(level => ({
id: 'editor.heading',
label: [`${level} 级标题`, `Heading ${level}`],
shortcut: `Ctrl+${level}`,
params: level,
}))
export const paragraphMenuSections: readonly (readonly EditorMenuCommand[])[] = [
[{ id: 'editor.paragraph', label: ['正文', 'Body text'], shortcut: 'Ctrl+0' }, ...headingMenuCommands],
[
{ id: 'editor.bullet-list', label: ['无序列表', 'Bullet list'], shortcut: 'Ctrl+Shift+]' },
{ id: 'editor.ordered-list', label: ['有序列表', 'Ordered list'], shortcut: 'Ctrl+Shift+[' },
{ id: 'editor.task-list', label: ['任务列表', 'Task list'], shortcut: 'Ctrl+Shift+X' },
{ id: 'editor.blockquote', label: ['引用', 'Blockquote'], shortcut: 'Ctrl+Shift+Q' },
{ id: 'editor.code-block', label: ['代码块', 'Code block'], shortcut: 'Ctrl+Shift+K' },
],
]
export const formatMenuSections: readonly (readonly EditorMenuCommand[])[] = [
[
{ id: 'editor.bold', label: ['加粗', 'Bold'], shortcut: 'Ctrl+B' },
{ id: 'editor.italic', label: ['斜体', 'Italic'], shortcut: 'Ctrl+I' },
{ id: 'editor.strikethrough', label: ['删除线', 'Strikethrough'], shortcut: 'Alt+Shift+5' },
{ id: 'editor.inline-code', label: ['行内代码', 'Inline code'], shortcut: 'Ctrl+`' },
{ id: 'editor.link', label: ['链接', 'Link'], shortcut: 'Ctrl+K' },
],
[
{ id: 'editor.code-block', label: ['代码块', 'Code block'], shortcut: 'Ctrl+Shift+K' },
{ id: 'editor.math-block', label: ['公式块', 'Math block'], shortcut: 'Ctrl+Shift+M' },
{ id: 'editor.inline-math', label: ['行内公式', 'Inline math'], shortcut: 'Ctrl+Shift+L' },
{ id: 'editor.table', label: ['表格', 'Table'], shortcut: 'Ctrl+Alt+T' },
],
[
{ id: 'editor.horizontal-rule', label: ['水平分割线', 'Horizontal rule'], shortcut: 'Ctrl+Shift+H' },
{ id: 'editor.hard-break', label: ['硬换行', 'Hard break'] },
],
]
export const calloutMenuCommands: readonly EditorMenuCommand[] = [
['note', '笔记', 'Note'], ['abstract', '摘要', 'Abstract'], ['info', '信息', 'Info'],
['todo', '待办', 'Todo'], ['tip', '技巧', 'Tip'], ['important', '重要', 'Important'],
['success', '成功', 'Success'], ['question', '问题', 'Question'], ['warning', '警告', 'Warning'],
['failure', '失败', 'Failure'], ['danger', '危险', 'Danger'], ['bug', '缺陷', 'Bug'],
['example', '示例', 'Example'], ['quote', '引用', 'Quote'],
].map(([type, zh, en]) => ({
id: 'editor.callout',
label: [zh, en],
params: { type, body: '提示内容' },
}))
const shortcutCommands: Readonly<Record<string, ShortcutCommand>> = {
'AS:Digit5': { id: 'editor.strikethrough' },
'M:Backquote': { id: 'editor.inline-code' },
'M:KeyK': { id: 'editor.link' },
'MS:KeyK': { id: 'editor.code-block' },
'MS:KeyM': { id: 'editor.math-block' },
'MA:KeyC': { id: 'editor.callout', params: { type: 'note', body: '提示内容' } },
'MA:KeyT': { id: 'editor.table' },
'MS:KeyL': { id: 'editor.inline-math' },
'MS:KeyH': { id: 'editor.horizontal-rule' },
'MS:BracketLeft': { id: 'editor.ordered-list' },
'MS:BracketRight': { id: 'editor.bullet-list' },
'MS:KeyX': { id: 'editor.task-list' },
'MS:KeyQ': { id: 'editor.blockquote' },
}
/** 唯一桌面快捷键解析表;菜单文字与生命周期分发都引用本模块。 */
export function resolveEditorShortcut(event: ShortcutEvent): ShortcutCommand | undefined {
const mod = event.ctrlKey || event.metaKey
if (mod && !event.altKey && !event.shiftKey && /^Digit[0-6]$/.test(event.code)) {
const level = Number(event.code.at(-1))
return level === 0 ? { id: 'editor.paragraph' } : { id: 'editor.heading', params: level }
}
const key = `${mod ? 'M' : ''}${event.altKey ? 'A' : ''}${event.shiftKey ? 'S' : ''}:${event.code}`
return shortcutCommands[key]
}
+2 -27
View File
@@ -5,34 +5,9 @@ import { useEditorStore } from '@/stores/editor'
import { executeEditorCommand, updateNativeEditorMenu } from '@/services/editorCommandService'
import { watch } from 'vue'
import { isDesktop } from './desktop'
import { resolveEditorShortcut } from '@/services/editorMenu'
type ShortcutCommand = { id: Parameters<typeof executeEditorCommand>[0]; params?: unknown }
/** 桌面菜单快捷键表;使用 code 避免数字键受键盘布局影响。 */
export function resolveEditorShortcut(event: Pick<KeyboardEvent, 'code' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): ShortcutCommand | undefined {
const mod = event.ctrlKey || event.metaKey
if (mod && !event.altKey && !event.shiftKey && /^Digit[0-6]$/.test(event.code)) {
const level = Number(event.code.at(-1))
return level === 0 ? { id: 'editor.paragraph' } : { id: 'editor.heading', params: level }
}
const key = `${mod ? 'M' : ''}${event.altKey ? 'A' : ''}${event.shiftKey ? 'S' : ''}:${event.code}`
const commands: Record<string, ShortcutCommand> = {
'AS:Digit5': { id: 'editor.strikethrough' },
'M:Backquote': { id: 'editor.inline-code' },
'M:KeyK': { id: 'editor.link' },
'MS:KeyK': { id: 'editor.code-block' },
'MS:KeyM': { id: 'editor.math-block' },
'MA:KeyC': { id: 'editor.callout', params: { type: 'note', body: '提示内容' } },
'MA:KeyT': { id: 'editor.table' },
'MS:KeyL': { id: 'editor.inline-math' },
'MS:KeyH': { id: 'editor.horizontal-rule' },
'MS:BracketLeft': { id: 'editor.ordered-list' },
'MS:BracketRight': { id: 'editor.bullet-list' },
'MS:KeyX': { id: 'editor.task-list' },
'MS:KeyQ': { id: 'editor.blockquote' },
}
return commands[key]
}
export { resolveEditorShortcut } from '@/services/editorMenu'
async function executeMetadataCommand() {
const result = await executeEditorCommand('editor.import-note-properties')