diff --git a/web/src/app/QuickSwitcher.vue b/web/src/app/QuickSwitcher.vue index 0a2fc40..314e7eb 100644 --- a/web/src/app/QuickSwitcher.vue +++ b/web/src/app/QuickSwitcher.vue @@ -1,6 +1,6 @@ + + diff --git a/web/src/app/app.css b/web/src/app/app.css index 3ab03cb..5680cd1 100644 --- a/web/src/app/app.css +++ b/web/src/app/app.css @@ -538,3 +538,9 @@ button { .rm-font-input input { width: 80px; padding: 7px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); } .rm-tab-menu { right: auto; width: max-content; max-width: calc(100vw - 8px); } + +.rm-shortcut-settings { padding: 12px 20px; } +.rm-shortcut-row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); align-items: center; font-size: 13px; } +.rm-shortcut-bindings { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.rm-shortcut-chip { display: inline-flex; align-items: center; gap: 3px; padding-left: 6px; border: 1px solid var(--rm-divider); border-radius: 5px; } +.rm-shortcut-bindings input { width: 130px; padding: 8px; color: var(--rm-text); background: var(--rm-bg); border: 1px solid var(--rm-accent); } diff --git a/web/src/app/commands.ts b/web/src/app/commands.ts index 2f321d6..3a021b4 100644 --- a/web/src/app/commands.ts +++ b/web/src/app/commands.ts @@ -1,5 +1,8 @@ // 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令 import { shallowReactive } from 'vue' +import { settings } from './settings' +import { matchKeys, physicalKeys } from '../keys' +export { formatKeys, matchKeys } from '../keys' export interface Command { id: string @@ -42,30 +45,20 @@ export function runCommand(id: string) { if (cmd && (cmd.when?.() ?? true)) cmd.run() } -const isMac = /Mac|iPhone|iPad/.test(navigator.platform) - -export function matchKeys(e: KeyboardEvent, keys: string): boolean { - const parts = keys.split('+') - const key = parts.pop()!.toLowerCase() - return ( - e.key.toLowerCase() === key && - (isMac ? e.metaKey : e.ctrlKey) === parts.includes('Mod') && - e.shiftKey === parts.includes('Shift') && - e.altKey === parts.includes('Alt') - ) +export function commandKeys(command: Command): string[] { + return settings.shortcuts[command.id] ?? command.keys ?? [] } -export function formatKeys(keys: string): string { - const parts = keys.split('+') - if (isMac) return parts.map((p) => ({ Mod: '⌘', Shift: '⇧', Alt: '⌥' })[p] ?? p).join('') - return parts.map((p) => (p === 'Mod' ? 'Ctrl' : p)).join('+') +export function shortcutConflict(id: string, keys: string[]): Command | undefined { + return allCommands().find((command) => command.id !== id && commandKeys(command).some((existing) => keys.some((key) => physicalKeys(existing) === physicalKeys(key)))) } /** 全局快捷键分发;编辑器等已处理的按键(defaultPrevented)和输入法组字中的按键不处理 */ export function handleShortcut(e: KeyboardEvent) { - if (e.defaultPrevented || e.isComposing) return + if (e.defaultPrevented || e.isComposing || e.getModifierState('AltGraph') || (e.target instanceof Element && e.target.closest('[data-shortcut-recorder]'))) return for (const cmd of availableCommands()) { - if (cmd.keys?.some((k) => matchKeys(e, k))) { + if (cmd.id.startsWith('editor.') && !(e.target instanceof Element && e.target.closest('.cm-content'))) continue + if (commandKeys(cmd).some((k) => matchKeys(e, k))) { e.preventDefault() cmd.run() return diff --git a/web/src/app/settings.ts b/web/src/app/settings.ts index 34c0c7f..32bb569 100644 --- a/web/src/app/settings.ts +++ b/web/src/app/settings.ts @@ -2,6 +2,7 @@ // 本地缓存上次的值,启动时先套用,避免页面先按默认值显示再跳变 import { reactive } from 'vue' import { api } from './api' +import { normalizeKeys } from '../keys' import { applyAppearance, appearancePaused, sanitizeAppearance, type Appearance } from '../themes/appearance' export const TAB_SIZES = [2, 4, 8] @@ -16,7 +17,7 @@ export const CONTENT_WIDTHS = [ export const SIDEBAR_WIDTH = { min: 200, max: 480 } const DEFAULTS = { fontSize: 16, contentWidth: 760, sidebarWidth: 260, spellcheck: false, tabSize: 4 } -export type Settings = typeof DEFAULTS & Appearance +export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record } const CACHE_KEY = 'rawmark.settings' @@ -27,6 +28,7 @@ function sanitize(raw: Record): Settings { const pick = (v: unknown, allowed: T[], def: T) => (allowed.includes(v as T) ? (v as T) : def) return { fontSize: typeof raw.fontSize === 'number' && Number.isFinite(raw.fontSize) && raw.fontSize > 0 ? raw.fontSize : DEFAULTS.fontSize, + shortcuts: sanitizeShortcuts(raw.shortcuts), spellcheck: typeof raw.spellcheck === 'boolean' ? raw.spellcheck : DEFAULTS.spellcheck, tabSize: pick(raw.tabSize, TAB_SIZES, DEFAULTS.tabSize), ...sanitizeAppearance(raw), @@ -42,6 +44,17 @@ function sanitize(raw: Record): Settings { } } +function sanitizeShortcuts(raw: unknown): Record { + if (raw === undefined) return {} + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('快捷键设置格式无效') + const out: Record = {} + for (const [id, keys] of Object.entries(raw)) { + if (!Array.isArray(keys) || keys.some((key) => typeof key !== 'string')) throw new Error('快捷键设置格式无效') + out[id] = keys.map(normalizeKeys) + } + return out +} + let currentTheme = '' let currentCSS = '' let appearanceRequest = 0 diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index bd68353..a7d81a6 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -3,6 +3,7 @@ import { Annotation, EditorState, Transaction } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { nextTick, reactive, watch } from 'vue' +import { editorCommands } from '../editor/shortcuts' import { refreshImages } from '../editor/livepreview' import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment } from '../editor' import { handleShortcut, registerCommand } from './commands' @@ -181,6 +182,7 @@ function buildState(path: string, doc: string): EditorState { noteRefs.set(path, ref) return createEditorState({ doc, + shortcuts: [], spellcheck: settings.spellcheck, tabSize: settings.tabSize, resolveImage: (src) => { @@ -1021,7 +1023,7 @@ export async function startWorkspace() { window.addEventListener('beforeunload', (event) => { if (!readyToReload()) event.preventDefault() }) - window.addEventListener('keydown', handleShortcut) + window.addEventListener('keydown', handleShortcut, true) // 软键盘弹出、编辑区变矮后,让光标保持可见 trackVisualViewport(() => { if (view?.hasFocus) view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'nearest', yMargin: 24 }) }) @@ -1032,6 +1034,10 @@ export async function startWorkspace() { registerBuiltinCommands() function registerBuiltinCommands() { + for (const command of editorCommands) registerCommand({ ...command, + when: () => ws.settings === null && ws.active !== null && !!view, + run: () => { if (view) { command.run(view); view.focus() } }, + }) const hasNote = () => ws.active !== null const toggleSwitcher = (initial: string) => { ws.switcher = ws.switcher === null ? initial : null diff --git a/web/src/editor/codeblocks.ts b/web/src/editor/codeblocks.ts index dc68465..a39265a 100644 --- a/web/src/editor/codeblocks.ts +++ b/web/src/editor/codeblocks.ts @@ -1,6 +1,6 @@ import { syntaxTree } from '@codemirror/language' -import { Prec, StateEffect, StateField, type EditorState, type Range } from '@codemirror/state' -import { Decoration, EditorView, keymap, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view' +import { StateEffect, StateField, type EditorState, type Range } from '@codemirror/state' +import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view' import type { SyntaxNode } from '@lezer/common' import { cachedTokens, requestTokens, type TokenRun } from '../highlight/client' @@ -80,7 +80,7 @@ export function fencedCodeAt(state: EditorState, pos: number): SyntaxNode | null } // Ctrl/Cmd+A:光标在代码块内时先选中整块代码,已选中整块时交给默认的全选 -function selectCodeBlock(view: EditorView): boolean { +export function selectCodeBlock(view: EditorView): boolean { const { state } = view const main = state.selection.main const node = fencedCodeAt(state, main.head) @@ -169,5 +169,4 @@ const codeHighlighter = ViewPlugin.fromClass( export const codeBlockHighlight = [ codeTokenField, codeHighlighter, - Prec.high(keymap.of([{ key: 'Mod-a', run: selectCodeBlock }])), ] diff --git a/web/src/editor/format.ts b/web/src/editor/format.ts index 116e500..47d301f 100644 --- a/web/src/editor/format.ts +++ b/web/src/editor/format.ts @@ -70,7 +70,8 @@ function kindOf(prefix: string): PrefixKind | null { function editPrefixes({ state, dispatch }: FormatTarget, next: (kinds: (PrefixKind | null)[], index: number) => string): boolean { const lineNumbers = new Set() for (const r of state.selection.ranges) { - for (let l = state.doc.lineAt(r.from).number, end = state.doc.lineAt(r.to).number; l <= end; l++) lineNumbers.add(l) + const to = r.to > r.from && state.doc.lineAt(r.to).from === r.to ? r.to - 1 : r.to + for (let l = state.doc.lineAt(r.from).number, end = state.doc.lineAt(to).number; l <= end; l++) lineNumbers.add(l) } const lines = [...lineNumbers].sort((a, b) => a - b).map((l) => state.doc.line(l)) const matches = lines.map((line) => LINE_PREFIX.exec(line.text)!) @@ -111,6 +112,21 @@ export const cycleHeading: FormatCommand = (target) => { return editPrefixes(target, () => prefix) } +/** 全部选中行已是指定标题时恢复正文,否则设置为指定级别。 */ +export function toggleHeading(level: number): FormatCommand { + return (target) => { + const prefix = '#'.repeat(level) + ' ' + const ranges = target.state.selection.ranges + const lines = new Set() + for (const range of ranges) { + const end = range.to > range.from && target.state.doc.lineAt(range.to).from === range.to ? range.to - 1 : range.to + for (let n = target.state.doc.lineAt(range.from).number; n <= target.state.doc.lineAt(end).number; n++) lines.add(n) + } + const remove = [...lines].every((n) => LINE_PREFIX.exec(target.state.doc.line(n).text)![2] === prefix) + return editPrefixes(target, () => remove ? '' : prefix) + } +} + /** 插入链接:选中文字作为链接文字,光标放到地址处;没有选中时光标放在文字处 */ export const insertLink: FormatCommand = ({ state, dispatch }) => { const tr = state.changeByRange((r) => { diff --git a/web/src/editor/headings.test.ts b/web/src/editor/headings.test.ts new file mode 100644 index 0000000..a18bf4e --- /dev/null +++ b/web/src/editor/headings.test.ts @@ -0,0 +1,12 @@ +import { EditorState } from '@codemirror/state' +import { expect, it } from 'vitest' +import { toggleHeading } from './format' + +it('标题快捷键设置六级标题,同级再次执行恢复正文,选区末端不修改下一行', () => { + let state = EditorState.create({ doc: '第一行\n第二行', selection: { anchor: 0, head: 4 } }) + const target = { get state() { return state }, dispatch: (spec: Parameters[0]) => { state = state.update(spec).state } } + toggleHeading(6)(target) + expect(state.doc.toString()).toBe('###### 第一行\n第二行') + toggleHeading(6)(target) + expect(state.doc.toString()).toBe('第一行\n第二行') +}) diff --git a/web/src/editor/index.ts b/web/src/editor/index.ts index e0121b1..fc4146d 100644 --- a/web/src/editor/index.ts +++ b/web/src/editor/index.ts @@ -1,14 +1,14 @@ -import { defaultKeymap, history, historyKeymap, indentWithTab, redo } from '@codemirror/commands' +import { defaultKeymap, history, selectAll } from '@codemirror/commands' import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown' import { Language } from '@codemirror/language' import { Compartment, EditorState, Prec, type Extension } from '@codemirror/state' -import { drawSelection, EditorView, keymap, type ViewUpdate } from '@codemirror/view' +import { drawSelection, EditorView, keymap, type KeyBinding, type ViewUpdate } from '@codemirror/view' import type { MarkdownParser } from '@lezer/markdown' import { typoraExtensions } from '../markdown/extensions' import { renderedBlocks } from './blocks' import { codeBlockHighlight } from './codeblocks' import { editorFocus } from './focus' -import { insertLink, toggleBold, toggleItalic } from './format' +import { defaultEditorKeys } from './shortcuts' import { livePreview, resolveImageUrl } from './livepreview' import { tableEditing } from './table' import { markdownTheme } from './theme' @@ -33,6 +33,7 @@ export interface EditorStateOptions { doc: string spellcheck?: boolean tabSize?: number + shortcuts?: KeyBinding[] resolveImage?: (src: string) => string // 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新 onChange?: (update: ViewUpdate) => void @@ -51,23 +52,15 @@ export function createEditorState(opts: EditorStateOptions): EditorState { pluginCompartment.of(pluginEditorExtensions()), EditorView.exceptionSink.of(reportEditorFailure), Prec.high(keymap.of(markdownKeymap)), - // 与 Typora 一致的格式快捷键 - Prec.high( - keymap.of([ - { key: 'Mod-b', run: toggleBold, preventDefault: true }, - { key: 'Mod-i', run: toggleItalic, preventDefault: true }, - { key: 'Mod-k', run: insertLink, preventDefault: true }, - ]), - ), + Prec.high(keymap.of(opts.shortcuts ?? defaultEditorKeys)), markdownTheme, livePreview, codeBlockHighlight, editorFocus, tableEditing, renderedBlocks, - // CodeMirror 只在 Linux/Mac 上把 Ctrl/Cmd+Shift+Z 绑定为重做,Windows 上会退化成撤销; - // 与 Typora、VS Code 保持一致,所有平台都支持 - keymap.of([indentWithTab, { key: 'Mod-Shift-z', run: redo, preventDefault: true }, ...defaultKeymap, ...historyKeymap]), + // 可配置操作不再通过 CM 的默认绑定重复注册。 + keymap.of(defaultKeymap.filter((binding) => binding.run !== selectAll)), preferencesCompartment.of([ EditorState.tabSize.of(opts.tabSize ?? 4), EditorView.contentAttributes.of({ spellcheck: String(opts.spellcheck ?? false) }), diff --git a/web/src/editor/shortcuts.ts b/web/src/editor/shortcuts.ts new file mode 100644 index 0000000..9a0bacc --- /dev/null +++ b/web/src/editor/shortcuts.ts @@ -0,0 +1,18 @@ +import { indentLess, indentMore, redo, selectAll, undo } from '@codemirror/commands' +import type { Command, KeyBinding } from '@codemirror/view' +import { selectCodeBlock } from './codeblocks' +import { insertLink, toggleBold, toggleHeading, toggleItalic } from './format' + +export const editorCommands: { id: string; title: string; keys: string[]; run: Command }[] = [ + { id: 'editor.bold', title: '粗体', keys: ['Mod+B'], run: toggleBold }, + { id: 'editor.italic', title: '斜体', keys: ['Mod+I'], run: toggleItalic }, + { id: 'editor.link', title: '插入链接', keys: ['Mod+K'], run: insertLink }, + { id: 'editor.undo', title: '撤销', keys: ['Mod+Z'], run: undo }, + { id: 'editor.redo', title: '重做', keys: ['Mod+Shift+Z', 'Mod+Y'], run: redo }, + { id: 'editor.indent', title: '增加缩进', keys: ['Tab'], run: indentMore }, + { id: 'editor.outdent', title: '减少缩进', keys: ['Shift+Tab'], run: indentLess }, + { id: 'editor.selectAll', title: '全选(代码块内优先选中代码块)', keys: ['Mod+A'], run: (view) => selectCodeBlock(view) || selectAll(view) }, + ...Array.from({ length: 6 }, (_, i) => ({ id: `editor.heading${i + 1}`, title: `设置 ${i + 1} 级标题`, keys: [`Ctrl+Alt+${i + 1}`], run: toggleHeading(i + 1) })), +] + +export const defaultEditorKeys: KeyBinding[] = editorCommands.flatMap((command) => command.keys.map((key) => ({ key: key.replaceAll('+', '-').replace(/-[A-Z]$/, (letter) => letter.toLowerCase()), run: command.run, preventDefault: true }))) diff --git a/web/src/keys.test.ts b/web/src/keys.test.ts new file mode 100644 index 0000000..ef5f450 --- /dev/null +++ b/web/src/keys.test.ts @@ -0,0 +1,16 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { matchKeys, recordedKeys } from './keys' + +describe('快捷键匹配与录入', () => { + it('精确匹配 Ctrl+Alt 数字,允许 Alt 改变字符而不改变物理数字键', () => { + const event = new KeyboardEvent('keydown', { key: '¡', code: 'Digit1', ctrlKey: true, altKey: true }) + expect(matchKeys(event, 'Ctrl+Alt+1')).toBe(true) + expect(matchKeys(event, 'Ctrl+1')).toBe(false) + expect(matchKeys(new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, metaKey: true }), 'Ctrl+B')).toBe(false) + }) + it('不会把单独的修饰键录成快捷键', () => { + expect(recordedKeys(new KeyboardEvent('keydown', { key: 'Control', ctrlKey: true }))).toBeNull() + expect(recordedKeys(new KeyboardEvent('keydown', { key: '+', ctrlKey: true, shiftKey: true }))).toContain('Plus') + }) +}) diff --git a/web/src/keys.ts b/web/src/keys.ts new file mode 100644 index 0000000..e024237 --- /dev/null +++ b/web/src/keys.ts @@ -0,0 +1,39 @@ +// 应用、编辑器和快捷键录入共用同一套按键表示。 +const isMac = /Mac|iPhone|iPad/.test(navigator.platform) +const modifiers = ['Mod', 'Ctrl', 'Meta', 'Alt', 'Shift'] + +export function normalizeKeys(value: string): string { + const parts = value.split('+') + const key = parts.pop() + if (!key || parts.some((part) => !modifiers.includes(part)) || new Set(parts).size !== parts.length || modifiers.includes(key)) throw new Error('快捷键格式无效') + return [...modifiers.filter((part) => parts.includes(part)), key.length === 1 ? key.toUpperCase() : key].join('+') +} + +export function physicalKeys(value: string): string { + return normalizeKeys(value.replace(/\bMod\b/g, isMac ? 'Meta' : 'Ctrl')) +} + +export function matchKeys(event: KeyboardEvent, value: string): boolean { + const parts = physicalKeys(value).split('+') + const key = parts.pop()! + const actual = event.key === ' ' ? 'Space' : event.key === '+' ? 'Plus' : event.key + const digit = /^Digit[0-9]$/.test(event.code) ? event.code.slice(5) : null + return (actual.toUpperCase() === key.toUpperCase() || digit === key) && + event.ctrlKey === parts.includes('Ctrl') && event.metaKey === parts.includes('Meta') && + event.altKey === parts.includes('Alt') && event.shiftKey === parts.includes('Shift') +} + +export function recordedKeys(event: KeyboardEvent): string | null { + if (['Control', 'Meta', 'Alt', 'Shift', 'AltGraph', 'Dead', 'Process', 'Unidentified'].includes(event.key) || event.isComposing) return null + const parts: string[] = [] + if (event.ctrlKey) parts.push(isMac ? 'Ctrl' : 'Mod') + if (event.metaKey) parts.push(isMac ? 'Mod' : 'Meta') + if (event.altKey) parts.push('Alt') + if (event.shiftKey) parts.push('Shift') + const key = /^Digit[0-9]$/.test(event.code) ? event.code.slice(5) : event.key === ' ' ? 'Space' : event.key === '+' ? 'Plus' : event.key + return normalizeKeys([...parts, key].join('+')) +} + +export function formatKeys(value: string): string { + return value.split('+').map((part) => ({ Mod: isMac ? '⌘' : 'Ctrl', Meta: isMac ? '⌘' : 'Win', Ctrl: isMac ? '⌃' : 'Ctrl', Alt: isMac ? '⌥' : 'Alt', Shift: isMac ? '⇧' : 'Shift', Space: '空格', Plus: '+' })[part] ?? part).join(isMac ? '' : '+') +} diff --git a/web/src/plugins/host.ts b/web/src/plugins/host.ts index 764eb68..a5788b8 100644 --- a/web/src/plugins/host.ts +++ b/web/src/plugins/host.ts @@ -1,6 +1,6 @@ import { reactive } from 'vue' import { api, type InstalledPlugin } from '../app/api' -import { allCommands, registerCommand } from '../app/commands' +import { allCommands, registerCommand, shortcutConflict } from '../app/commands' import type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '../plugin-api' import { editorExtensions, markdownSyntax, pluginEvents, pluginsPaused, registerCodeRenderer, registerEntry, settingTabs, sidebarViews } from './registry' @@ -98,7 +98,7 @@ async function createAPI(instance: Instance): Promise { check() const commandID = localID(instance, command.id) if (!command.title.trim() || typeof command.run !== 'function' || allCommands().some((c) => c.id === commandID)) throw new Error('命令无效或重复注册') - if (command.keys?.some((key) => allCommands().some((c) => c.keys?.some((existing) => existing.toLowerCase() === key.toLowerCase())))) adapter.notice(`插件“${name}”部分快捷键与其他命令冲突,请调整`) + if (shortcutConflict(commandID, command.keys ?? [])) adapter.notice(`插件“${name}”部分快捷键与其他命令冲突,请调整`) return track(instance, registerCommand({ ...command, id: commandID, when: () => { if (instance.stopped) return false