fix(desktop): 重构菜单并恢复 Shiki 高亮

This commit is contained in:
2026-09-07 23:35:43 +08:00
parent a4be2d98f4
commit acc0ba167d
10 changed files with 325 additions and 100 deletions
@@ -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')