fix(desktop): 重构菜单并恢复 Shiki 高亮
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user