feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -25,6 +25,8 @@
|
||||
|
||||
无边框桌面窗口在标题栏下方固定显示“文件 / 编辑 / 段落 / 格式 / 视图 / 主题 / 帮助”菜单行。段落菜单提供正文、六级标题、列表、引用、代码块和属性导入;格式菜单复用编辑器已有的加粗、斜体、行内代码、代码块、公式及警告框命令;主题菜单列出当前已安装主题并可进入主题管理。保存使用 `Ctrl+S`;撤销和重做沿用编辑器已有的 `Ctrl+Z` 与 `Ctrl+Shift+Z`,菜单只调用相同编辑器命令,不额外注册按键监听,以免一次按键执行两次。
|
||||
|
||||
除编辑器原有的加粗、斜体、撤销和重做外,桌面键表还注册正文/标题 `Ctrl+0…6`、删除线 `Alt+Shift+5`、行内代码 `Ctrl+反引号`、链接 `Ctrl+K`、代码块 `Ctrl+Shift+K`、公式块 `Ctrl+Shift+M`、行内公式 `Ctrl+Shift+L`、表格 `Ctrl+Alt+T`、默认警告框 `Ctrl+Alt+C`、分割线 `Ctrl+Shift+H`、三类列表和引用。`Ctrl+Alt+P` 在源码模式执行属性导入,在写作模式创建或聚焦 YAML Front Matter。警告框菜单列出 `note`、`abstract`、`info`、`todo`、`tip`、`important`、`success`、`question`、`warning`、`failure`、`danger`、`bug`、`example`、`quote` 十四种样式。
|
||||
|
||||
Markdown 格式(含警告框)与元数据共用 `editorCommandService` 的能力查询和命令分发接口,详见 [警告框与桌面编辑命令开发说明](../development/警告框与桌面编辑命令开发说明.md)。Host 根据 supported / enabled 显隐或禁用菜单,不能把已预留的命令 ID 当作已可执行能力;原生快捷键不绕过活动文档、只读和冲突检查。
|
||||
|
||||
### 2.2 输入与转换规则
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [{"label": "main", "title": "NotesAgent Preview", "width": 1200, "height": 800, "minWidth": 640, "minHeight": 480, "decorations": false}],
|
||||
"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'",
|
||||
"capabilities": ["main"]
|
||||
|
||||
@@ -58,4 +58,13 @@ describe('桌面顶部段落菜单', () => {
|
||||
expect(wrapper.get('.import-properties').text()).toContain('Ctrl+Alt+P')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
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')
|
||||
expect(wrapper.get('.metadata-command').text()).toContain('Ctrl+Alt+P')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,11 @@ const open = ref<MenuName | null>(null)
|
||||
const bar = ref<HTMLElement | null>(null)
|
||||
const error = ref('')
|
||||
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
|
||||
|
||||
function enabled(id: EditorCommandId) {
|
||||
return getEditorCommandCapabilities().find(item => item.id === id)?.enabled ?? false
|
||||
@@ -31,6 +36,11 @@ async function command(id: EditorCommandId, params?: unknown) {
|
||||
if (result.ok) close()
|
||||
else error.value = t('当前编辑器无法执行此命令。', 'The active editor cannot run this command.')
|
||||
}
|
||||
async function metadataCommand() {
|
||||
const imported = await executeEditorCommand('editor.import-note-properties')
|
||||
if (imported.ok) { close(); return }
|
||||
await command('editor.metadata.edit')
|
||||
}
|
||||
async function save() { await editor.save(); close() }
|
||||
function setMode(mode: 'source' | 'wysiwyg') { editor.setMode(mode); close() }
|
||||
function toggleTheme() { theme.toggleTheme(); close() }
|
||||
@@ -63,14 +73,14 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
|
||||
<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></button>
|
||||
<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>
|
||||
<span class="menu-separator" role="separator" />
|
||||
<button role="menuitem" :disabled="!enabled('editor.bullet-list')" @click="command('editor.bullet-list')"><span>{{ t('无序列表', 'Bullet list') }}</span></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.ordered-list')" @click="command('editor.ordered-list')"><span>{{ t('有序列表', 'Ordered list') }}</span></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.task-list')" @click="command('editor.task-list')"><span>{{ t('任务列表', 'Task list') }}</span></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.blockquote')" @click="command('editor.blockquote')"><span>{{ t('引用', 'Blockquote') }}</span></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.code-block')" @click="command('editor.code-block')"><span>{{ t('代码块', 'Code block') }}</span></button>
|
||||
<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')">
|
||||
<span>{{ t('导入为笔记属性…', 'Import as note properties…') }}</span><kbd>{{ shortcut }}</kbd>
|
||||
@@ -80,16 +90,24 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
|
||||
</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" role="menu">
|
||||
<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></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.inline-code')" @click="command('editor.inline-code')"><span>{{ t('行内代码', 'Inline code') }}</span></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>
|
||||
<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.callout')" @click="command('editor.callout', { type: 'NOTE', body: t('提示内容', 'Callout content') })"><span>{{ t('警告框', 'Callout') }}</span></button>
|
||||
<button role="menuitem" :disabled="!enabled('editor.horizontal-rule')" @click="command('editor.horizontal-rule')"><span>{{ t('水平分割线', 'Horizontal rule') }}</span></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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-group" data-menu="view">
|
||||
@@ -129,10 +147,16 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', dismiss))
|
||||
.menu-trigger:hover, .menu-trigger[aria-expanded='true'] { background: var(--color-background-hover); color: var(--color-text-primary); }
|
||||
.menu-popover { position: absolute; top: calc(100% + 2px); left: 0; z-index: calc(var(--z-titlebar) + 2); display: grid; min-width: 230px; 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); }
|
||||
.paragraph-menu { min-width: 270px; }
|
||||
.format-menu { min-width: 330px; }
|
||||
.menu-popover button { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); width: 100%; padding: 7px var(--space-md); border-radius: var(--radius-sm); text-align: left; white-space: nowrap; }
|
||||
.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; }
|
||||
.menu-popover { max-height: calc(100vh - 110px); overflow: auto; }
|
||||
.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); }
|
||||
|
||||
@@ -196,6 +196,16 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
useEditorStore().saveStatus = 'conflict'
|
||||
expect(await executeEditorCommand('editor.bold')).toEqual({ ok: false, reason: 'unavailable' })
|
||||
})
|
||||
it('creates editable front matter when the metadata shortcut has no existing block', async () => {
|
||||
const store = useEditorStore(); store.currentFilePath = '/notes/示例.md'
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '正文' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
await waitForEditor(wrapper)
|
||||
expect(await executeEditorCommand('editor.metadata.edit')).toEqual({ ok: true })
|
||||
expect(wrapper.find('.note-metadata').exists()).toBe(true)
|
||||
expect(store.content).toContain('title: "示例"')
|
||||
expect(store.content).toContain('tags: []')
|
||||
})
|
||||
it('keeps code examples as ordinary quotes and renders newly typed markers', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '> `[!NOTE]`\n\n> text' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
|
||||
@@ -3,7 +3,7 @@ import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
const { actionDialog, resolveAction, askPrompt } = useActionDialog()
|
||||
import DiagramInteractions from '@/components/common/DiagramInteractions.vue'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Link, Fold, Expand } from '@element-plus/icons-vue'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
||||
@@ -28,9 +28,11 @@ import {
|
||||
toggleStrongCommand,
|
||||
turnIntoTextCommand,
|
||||
wrapInBulletListCommand,
|
||||
wrapInBlockquoteCommand,
|
||||
wrapInHeadingCommand,
|
||||
wrapInOrderedListCommand,
|
||||
} from '@milkdown/kit/preset/commonmark'
|
||||
import { toggleStrikethroughCommand } from '@milkdown/kit/preset/gfm'
|
||||
import { commandsCtx, editorViewCtx, parserCtx, remarkStringifyOptionsCtx } from '@milkdown/kit/core'
|
||||
import { Slice } from '@milkdown/kit/prose/model'
|
||||
import { registerEditorCommands, type CommandHandler, type EditorCommandId } from '@/services/editorCommandService'
|
||||
@@ -113,6 +115,27 @@ function installCommands() {
|
||||
handlers[`editor.${command}`] = () => { runCommand(command); return { ok: true } }
|
||||
}
|
||||
handlers['editor.paragraph'] = () => { crepe!.editor.action(callCommand(turnIntoTextCommand.key)); return { ok: true } }
|
||||
handlers['editor.strikethrough'] = () => { crepe!.editor.action(callCommand(toggleStrikethroughCommand.key)); return { ok: true } }
|
||||
handlers['editor.blockquote'] = () => { crepe!.editor.action(callCommand(wrapInBlockquoteCommand.key)); return { ok: true } }
|
||||
handlers['editor.task-list'] = () => { insertMarkdown('- [ ] '); return { ok: true } }
|
||||
handlers['editor.horizontal-rule'] = () => { insertMarkdown('\n---\n'); return { ok: true } }
|
||||
handlers['editor.hard-break'] = () => { insertMarkdown(' \n'); return { ok: true } }
|
||||
handlers['editor.table'] = () => { insertMarkdown('| 列 1 | 列 2 |\n| --- | --- |\n| | |'); return { ok: true } }
|
||||
handlers['editor.link'] = async () => { await applyLink(); return { ok: true } }
|
||||
handlers['editor.metadata.edit'] = async () => {
|
||||
if (!metadata.value) {
|
||||
const body = crepe!.editor.action(getMarkdown())
|
||||
const filename = (editorStore.currentFilePath?.split('/').at(-1) ?? '未命名').replace(/\.md$/i, '')
|
||||
const source = `---\ntitle: ${JSON.stringify(filename)}\ntags: []\n---\n${body}`
|
||||
metadata.value = splitNoteMetadata(source)
|
||||
editorStore.updateContent(source)
|
||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||
await nextTick()
|
||||
}
|
||||
const input = editorRoot.value?.closest<HTMLElement>('.milkdown-host')?.querySelector<HTMLElement>('.note-metadata input')
|
||||
if (!input) return { ok: false, reason: 'unavailable' }
|
||||
input.scrollIntoView({ block: 'center' }); input.focus(); return { ok: true }
|
||||
}
|
||||
handlers['editor.heading'] = params => {
|
||||
if (!Number.isInteger(params) || Number(params) < 1 || Number(params) > 6) return { ok: false, reason: 'invalid-params' }
|
||||
crepe!.editor.action(callCommand(wrapInHeadingCommand.key, Number(params)))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveEditorShortcut } from './lifecycle'
|
||||
|
||||
const key = (code: string, options: Partial<Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>> = {}) =>
|
||||
resolveEditorShortcut({ code, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, ...options })
|
||||
|
||||
describe('桌面编辑快捷键', () => {
|
||||
it('映射正文与六级标题', () => {
|
||||
expect(key('Digit0', { ctrlKey: true })).toEqual({ id: 'editor.paragraph' })
|
||||
expect(key('Digit6', { ctrlKey: true })).toEqual({ id: 'editor.heading', params: 6 })
|
||||
})
|
||||
|
||||
it('映射格式、列表与警告框', () => {
|
||||
expect(key('Digit5', { altKey: true, shiftKey: true })?.id).toBe('editor.strikethrough')
|
||||
expect(key('BracketRight', { ctrlKey: true, shiftKey: true })?.id).toBe('editor.bullet-list')
|
||||
expect(key('KeyC', { ctrlKey: true, altKey: true })).toMatchObject({ id: 'editor.callout', params: { type: 'note' } })
|
||||
expect(key('KeyK', { ctrlKey: true })?.id).toBe('editor.link')
|
||||
expect(key('KeyT', { ctrlKey: true, altKey: true })?.id).toBe('editor.table')
|
||||
})
|
||||
})
|
||||
@@ -6,24 +6,70 @@ import { executeEditorCommand, updateNativeEditorMenu } from '@/services/editorC
|
||||
import { watch } from 'vue'
|
||||
import { isDesktop } from './desktop'
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
async function executeMetadataCommand() {
|
||||
const result = await executeEditorCommand('editor.import-note-properties')
|
||||
if (!result.ok) await executeEditorCommand('editor.metadata.edit')
|
||||
}
|
||||
|
||||
export async function installDesktopLifecycle() {
|
||||
if (!isDesktop()) return
|
||||
const activeEditor = useEditorStore()
|
||||
// 保存属于窗口级命令;编辑区原生处理撤销/重做,避免重复派发。
|
||||
window.addEventListener('keydown', event => {
|
||||
if (event.defaultPrevented || event.isComposing || event.repeat || event.altKey || event.shiftKey) return
|
||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return
|
||||
if (event.defaultPrevented || event.isComposing || event.repeat) return
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target?.closest('input, textarea, select, dialog[open], [role="dialog"]')) return
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 's') {
|
||||
if (!activeEditor.currentFilePath || ['saving', 'conflict', 'external_changed'].includes(activeEditor.saveStatus)) return
|
||||
event.preventDefault()
|
||||
void activeEditor.save()
|
||||
return
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey && event.code === 'KeyP') {
|
||||
event.preventDefault()
|
||||
void executeMetadataCommand()
|
||||
return
|
||||
}
|
||||
const shortcut = resolveEditorShortcut(event)
|
||||
if (!shortcut) return
|
||||
if (!activeEditor.currentFilePath || ['saving', 'conflict', 'external_changed'].includes(activeEditor.saveStatus)) return
|
||||
event.preventDefault()
|
||||
void activeEditor.save()
|
||||
void executeEditorCommand(shortcut.id, shortcut.params)
|
||||
})
|
||||
watch(() => [activeEditor.currentFilePath, activeEditor.saveStatus, activeEditor.mode], updateNativeEditorMenu, { flush: 'post' })
|
||||
await listen<string>('editor-command', event => {
|
||||
const focused = document.activeElement as HTMLElement | null
|
||||
if (focused?.closest('input, textarea, select, dialog[open], [role="dialog"]')) return
|
||||
void executeEditorCommand(event.payload)
|
||||
if (event.payload === 'editor.import-note-properties') void executeMetadataCommand()
|
||||
else void executeEditorCommand(event.payload)
|
||||
})
|
||||
let closing = false
|
||||
await listen('host-close-requested', async () => {
|
||||
|
||||
Reference in New Issue
Block a user