diff --git a/README.md b/README.md index e4c91ae..3d8ff0f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ docker compose --env-file /docker/main/.env up -d --build --wait 持久化数据位于 `deploy/data/`,与 `.dev-data/` 独立。首次打开网页时直接设置登录密码。 升级已有实例移除分享时,先备份数据与旧镜像、停止旧服务,再对 `rawmark.db` 执行一次 `scripts/remove-sharing.sql`,然后启动新版本。脚本删除分享表、专用密钥及分享快捷键;不会删除笔记或图片。旧分享页面和接口返回 404。回滚必须配套恢复数据库和旧镜像,不能只恢复旧程序。 + +升级到 Typora 默认快捷键时,先停止服务并备份数据与旧镜像,再对 `rawmark.db` 执行一次 `scripts/reset-shortcuts.sql`。该脚本按本次需求清空已有快捷键覆盖,其他设置保留;程序启动不会再次重置快捷键。 宿主机 Nginx 配置为 `deploy/nginx/raw.134333.xyz.conf`,复用现有通配证书及 WebSocket 升级映射;安装到 `/etc/nginx/conf.d/134333.xyz/` 后,先执行 `nginx -t` 再重载。 域名 `raw.134333.xyz` 在 DNSPod 设置 CNAME 指向 `cdn.134333.xyz`(与 AlphaPulse 一致);还需在 Cloudflare for SaaS 的 `unino.top` 区域添加该自定义域名,并等待域名与托管证书均为 `active`。仅设置 CNAME 或旧区域中的 A 记录不足以完成 CDN 接入。访问地址为 `https://raw.134333.xyz`。修改端口时需同步端口登记和 Nginx 上游。 @@ -40,7 +42,7 @@ docker run -d --name rawmark \ - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 - 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。 - 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。 -- 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入,编辑器字号随有效输入即时生效。 +- 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认跟随 [Typora](https://support.typora.io/Shortcut-Keys/) 的 Windows/Linux 或 macOS 组合,无对应项和插件默认留空;浏览器保留键可自行重新绑定。Ctrl/Cmd+1~6 设置标题,同级再次触发恢复正文;字号可直接输入,编辑器字号随有效输入即时生效。 - 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。 - 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 - 自动化:「API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。 diff --git a/scripts/reset-shortcuts.sql b/scripts/reset-shortcuts.sql new file mode 100644 index 0000000..0c89adf --- /dev/null +++ b/scripts/reset-shortcuts.sql @@ -0,0 +1,4 @@ +-- 仅在切换到 Typora 默认快捷键的部署中执行一次;保留所有其他设置。 +BEGIN IMMEDIATE; +UPDATE settings SET value = json_remove(value, '$.shortcuts') WHERE key = 'app.settings'; +COMMIT; diff --git a/web/src/app/EditorChrome.vue b/web/src/app/EditorChrome.vue index 6362069..94f0a12 100644 --- a/web/src/app/EditorChrome.vue +++ b/web/src/app/EditorChrome.vue @@ -1,4 +1,5 @@ @@ -8,6 +9,6 @@ import { toggleSourceMode, ws } from './workspace' - + diff --git a/web/src/app/ShortcutSettings.vue b/web/src/app/ShortcutSettings.vue index bd698d7..6ebd6a2 100644 --- a/web/src/app/ShortcutSettings.vue +++ b/web/src/app/ShortcutSettings.vue @@ -62,7 +62,8 @@ async function reset(command: Command) { 重置全部快捷键 - 点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。设置在所有设备间同步。 + 点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。默认跟随 Typora;无对应项(含插件)留空。自定义设置在所有设备间同步。 + 浏览器可能保留 Ctrl/Cmd+N、W、数字键等组合,网页无法保证拦截;可自行重新绑定。 {{ filteredCommands.length }} / {{ commands.length }} 个命令{{ filteredCommands.length ? '' : ',没有匹配的快捷键。' }} {{ error }} diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index e35e102..37c6f3f 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -4,6 +4,7 @@ import { decodeLinkDestination } from '../markdown/links' import { Annotation, EditorState, Transaction } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { nextTick, reactive, watch } from 'vue' +import { typoraKeys } from '../typoraKeys' import { editorCommands } from '../editor/shortcuts' import { refreshImages } from '../editor/livepreview' import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from '../editor' @@ -1085,28 +1086,27 @@ function registerBuiltinCommands() { ws.switcher = ws.switcher === null ? initial : null } for (const cmd of [ - // Mod+P 覆盖浏览器的打印;Firefox 不允许网页覆盖 Ctrl+Shift+P(隐私窗口),F1 作为通用的替代 - { id: 'app.quickOpen', title: '快速打开笔记', keys: ['Mod+P'], run: () => toggleSwitcher('') }, - { id: 'app.commandPalette', title: '命令面板', keys: ['Mod+Shift+P', 'F1'], run: () => toggleSwitcher('>') }, - { id: 'app.search', title: '全文搜索', keys: ['Mod+Shift+F'], run: openSearch }, + { id: 'app.quickOpen', title: '快速打开笔记', run: () => toggleSwitcher('') }, + { id: 'app.commandPalette', title: '命令面板', run: () => toggleSwitcher('>') }, + { id: 'app.search', title: '全文搜索', run: openSearch }, { id: 'note.new', title: '新建笔记', run: () => withSidebar(() => createNote('')) }, { id: 'folder.new', title: '新建文件夹', run: () => withSidebar(() => createFolder('')) }, - { id: 'note.save', title: '保存当前笔记', keys: ['Mod+S'], when: hasNote, run: () => void flush(ws.active!) }, + { id: 'note.save', title: '保存当前笔记', when: hasNote, run: () => void flush(ws.active!) }, { id: 'note.rename', title: '重命名当前笔记', when: hasNote, run: () => withSidebar(() => (ws.renaming = ws.active)) }, { id: 'note.move', title: '移动当前笔记到…', when: hasNote, run: () => (ws.moving = ws.active) }, { id: 'note.delete', title: '删除当前笔记', when: hasNote, run: () => void remove(ws.active!) }, { id: 'note.insertImage', title: '插入图片…', when: hasNote, run: insertImagesFromPicker }, - // 浏览器不允许网页覆盖 Ctrl+W { id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) }, - { id: 'app.settings', title: '设置', keys: ['Mod+,'], run: () => (ws.settings = 'index') }, - { id: 'view.sidebar', title: '显示/隐藏侧栏', keys: ['Mod+\\'], run: toggleSidebar }, - { id: 'view.outline', title: '显示/隐藏大纲', keys: ['Mod+Shift+O'], when: hasNote, run: () => (ws.outlineOpen = !ws.outlineOpen) }, + { id: 'app.settings', title: '设置', run: () => (ws.settings = 'index') }, + { id: 'view.sidebar', title: '显示/隐藏侧栏', run: toggleSidebar }, + { id: 'view.outline', title: '显示/隐藏大纲', when: hasNote, run: () => (ws.outlineOpen = !ws.outlineOpen) }, { id: 'view.source', title: '开启/关闭源码编辑', when: hasNote, run: toggleSourceMode }, + { id: 'view.focus', title: '开启/关闭专注写作', when: hasNote, run: () => (ws.focusMode = !ws.focusMode) }, { id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') }, { id: 'app.images', title: '管理根目录图片', run: () => { ws.imagesAll = false; ws.images = '' } }, { id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'tokens') }, ]) { - registerCommand(cmd) + registerCommand({ ...cmd, keys: typoraKeys(cmd.id) }) } } diff --git a/web/src/editor/index.ts b/web/src/editor/index.ts index a622f7d..852642f 100644 --- a/web/src/editor/index.ts +++ b/web/src/editor/index.ts @@ -1,4 +1,4 @@ -import { defaultKeymap, history, selectAll } from '@codemirror/commands' +import { defaultKeymap, history, indentLess, indentMore, selectAll, selectParentSyntax, toggleComment } from '@codemirror/commands' import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown' import { Language } from '@codemirror/language' import { Compartment, EditorState, Prec, type Extension } from '@codemirror/state' @@ -64,7 +64,7 @@ export function createEditorState(opts: EditorStateOptions): EditorState { editorFocus, previewCompartment.of(previewExtensions(opts.sourceMode ?? false)), // 可配置操作不再通过 CM 的默认绑定重复注册。 - keymap.of(defaultKeymap.filter((binding) => binding.run !== selectAll)), + keymap.of(defaultKeymap.filter((binding) => binding.run !== selectAll && binding.run !== indentLess && binding.run !== indentMore && binding.run !== selectParentSyntax && binding.run !== toggleComment)), preferencesCompartment.of([ EditorState.tabSize.of(opts.tabSize ?? 4), EditorView.contentAttributes.of({ spellcheck: String(opts.spellcheck ?? false) }), diff --git a/web/src/editor/keymap.test.ts b/web/src/editor/keymap.test.ts index d0c03ea..1aa6ae4 100644 --- a/web/src/editor/keymap.test.ts +++ b/web/src/editor/keymap.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' describe('快捷键', () => { - it('Windows 上 Ctrl+Shift+Z 为重做', async () => { + it('Windows 上 Ctrl+Y 为重做', async () => { // CodeMirror 在模块加载时读取平台,需先设置再导入 Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true }) const { undo } = await import('@codemirror/commands') @@ -12,9 +12,20 @@ describe('快捷键', () => { view.dispatch({ changes: { from: 0, insert: 'abc' } }) undo(view) expect(view.state.doc.toString()).toBe('') - const event = new KeyboardEvent('keydown', { key: 'Z', code: 'KeyZ', keyCode: 90, ctrlKey: true, shiftKey: true }) + const event = new KeyboardEvent('keydown', { key: 'y', code: 'KeyY', keyCode: 89, ctrlKey: true }) expect(runScopeHandlers(view, event, 'editor')).toBe(true) expect(view.state.doc.toString()).toBe('abc') view.destroy() }) + it('工作区自定义快捷键清空后,不回落到 CM 的缩进或其他冲突绑定', async () => { + const { EditorView, runScopeHandlers } = await import('@codemirror/view') + const { createEditorState } = await import('./index') + const view = new EditorView({ parent: document.body, state: createEditorState({ doc: 'text', shortcuts: [] }) }) + try { + for (const [key, code] of [['[', 'BracketLeft'], [']', 'BracketRight'], ['i', 'KeyI'], ['/', 'Slash']]) { + expect(runScopeHandlers(view, new KeyboardEvent('keydown', { key, code, ctrlKey: true }), 'editor')).toBe(false) + } + expect(view.state.doc.toString()).toBe('text') + } finally { view.destroy() } + }) }) diff --git a/web/src/editor/shortcuts.ts b/web/src/editor/shortcuts.ts index 9a0bacc..93094bc 100644 --- a/web/src/editor/shortcuts.ts +++ b/web/src/editor/shortcuts.ts @@ -1,18 +1,25 @@ 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' +import { insertLink, toggleBlock, toggleBold, toggleCode, toggleHeading, toggleItalic } from './format' +import { toggleSelectionMark, toggleUnderline } from './inlineStyles' +import { typoraKeys } from '../typoraKeys' 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) })), + { id: 'editor.bold', title: '粗体', keys: typoraKeys('editor.bold'), run: toggleBold }, + { id: 'editor.italic', title: '斜体', keys: typoraKeys('editor.italic'), run: toggleItalic }, + { id: 'editor.underline', title: '下划线', keys: typoraKeys('editor.underline'), run: toggleUnderline }, + { id: 'editor.strike', title: '删除线', keys: typoraKeys('editor.strike'), run: toggleSelectionMark('Strikethrough') }, + { id: 'editor.highlight', title: '高亮', keys: [], run: toggleSelectionMark('Highlight') }, + { id: 'editor.code', title: '行内代码', keys: typoraKeys('editor.code'), run: toggleCode }, + ...(['task', 'bullet', 'ordered', 'quote'] as const).map(kind => ({ id: `editor.${kind}`, title: { task: '任务列表', bullet: '无序列表', ordered: '有序列表', quote: '引用' }[kind], keys: typoraKeys(`editor.${kind}`), run: toggleBlock(kind) })), + { id: 'editor.link', title: '插入链接', keys: typoraKeys('editor.link'), run: insertLink }, + { id: 'editor.undo', title: '撤销', keys: typoraKeys('editor.undo'), run: undo }, + { id: 'editor.redo', title: '重做', keys: typoraKeys('editor.redo'), run: redo }, + { id: 'editor.indent', title: '增加缩进', keys: typoraKeys('editor.indent'), run: indentMore }, + { id: 'editor.outdent', title: '减少缩进', keys: typoraKeys('editor.outdent'), run: indentLess }, + { id: 'editor.selectAll', title: '全选(代码块内优先选中代码块)', keys: typoraKeys('editor.selectAll'), run: (view) => selectCodeBlock(view) || selectAll(view) }, + ...Array.from({ length: 6 }, (_, i) => ({ id: `editor.heading${i + 1}`, title: `设置 ${i + 1} 级标题`, keys: typoraKeys(`editor.heading${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.ts b/web/src/keys.ts index 91e6add..38548b1 100644 --- a/web/src/keys.ts +++ b/web/src/keys.ts @@ -1,7 +1,15 @@ // 应用、编辑器和快捷键录入共用同一套按键表示。 -const isMac = /Mac|iPhone|iPad/.test(navigator.platform) +export const isMac = /Mac|iPhone|iPad/.test(navigator.platform) +const punctuation: Record = { BracketLeft: '[', BracketRight: ']', Backquote: '`' } const modifiers = ['Mod', 'Ctrl', 'Meta', 'Alt', 'Shift'] +function physicalCharacter(event: KeyboardEvent): string | undefined { + if (/^Digit[0-9]$/.test(event.code)) return event.code.slice(5) + // macOS Option 会把 Q/O/U 等变成其他字符,快捷键仍按原物理字母匹配。 + if (isMac && event.altKey && /^Key[A-Z]$/.test(event.code)) return event.code.slice(3) + return punctuation[event.code] +} + export function normalizeKeys(value: string): string { const parts = value.split('+') const key = parts.pop() @@ -18,8 +26,7 @@ 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) && + return (actual.toUpperCase() === key.toUpperCase() || physicalCharacter(event) === key) && event.ctrlKey === parts.includes('Ctrl') && event.metaKey === parts.includes('Meta') && event.altKey === parts.includes('Alt') && event.shiftKey === parts.includes('Shift') } @@ -32,7 +39,7 @@ export function recordedKeys(event: KeyboardEvent): string | null { if (event.metaKey) parts.push(!isMac || event.ctrlKey ? 'Meta' : 'Mod') 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 + const key = physicalCharacter(event) ?? (event.key === ' ' ? 'Space' : event.key === '+' ? 'Plus' : event.key) return normalizeKeys([...parts, key].join('+')) } diff --git a/web/src/plugin-api/index.d.ts b/web/src/plugin-api/index.d.ts index 95b5371..7d24d7e 100644 --- a/web/src/plugin-api/index.d.ts +++ b/web/src/plugin-api/index.d.ts @@ -26,6 +26,7 @@ export interface HostModules { export interface PluginCommand { id: string title: string + /** 保留声明兼容;默认不绑定,请用户在设置中添加快捷键。 */ keys?: string[] when?: () => boolean run(): Awaitable diff --git a/web/src/plugins/host.test.ts b/web/src/plugins/host.test.ts index c1b2a15..9fbe311 100644 --- a/web/src/plugins/host.test.ts +++ b/web/src/plugins/host.test.ts @@ -1,13 +1,14 @@ // @vitest-environment happy-dom import { afterEach, expect, it, vi } from 'vitest' import { api } from '../app/api' -import { allCommands } from '../app/commands' +import { settings } from '../app/settings' +import { allCommands, commandKeys } from '../app/commands' import type { PluginAPI } from '../plugin-api' import { pluginState, startPlugins, stopPlugins } from './host' const { cause } = vi.hoisted(() => ({ cause: new Error('插件命令的原始原因') })) vi.mock('/api/v1/plugins/fault/assets/rev/main.js', () => ({ - default: (sdk: PluginAPI) => sdk.addCommand({ id: 'fail', title: '失败命令', run() { throw cause } }), + default: (sdk: PluginAPI) => sdk.addCommand({ id: 'fail', title: '失败命令', keys: ['Mod+B'], run() { throw cause } }), })) afterEach(() => { stopPlugins(); vi.restoreAllMocks() }) @@ -16,7 +17,12 @@ it('命令失败保留原始异常并清理注册,正常插件列表读取不 const notices: string[] = [] startPlugins({ notice: (message) => notices.push(message), getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) }) await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active')) - allCommands().find((command) => command.id === 'plugin:fault:fail')!.run() + const command = allCommands().find((command) => command.id === 'plugin:fault:fail')! + expect(commandKeys(command)).toEqual([]) + settings.shortcuts[command.id] = ['Mod+Alt+F'] + expect(commandKeys(command)).toEqual(['Mod+Alt+F']) + delete settings.shortcuts[command.id] + command.run() await vi.waitFor(() => expect(pluginState.status.fault?.cause).toBe(cause)) expect(pluginState.status.fault?.phase).toBe('error') expect(allCommands().some((command) => command.id === 'plugin:fault:fail')).toBe(false) diff --git a/web/src/plugins/host.ts b/web/src/plugins/host.ts index a5788b8..c6aafb9 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, shortcutConflict } from '../app/commands' +import { allCommands, registerCommand } 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,8 +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 (shortcutConflict(commandID, command.keys ?? [])) adapter.notice(`插件“${name}”部分快捷键与其他命令冲突,请调整`) - return track(instance, registerCommand({ ...command, id: commandID, + return track(instance, registerCommand({ ...command, id: commandID, keys: [], when: () => { if (instance.stopped) return false try { return command.when?.() ?? true } catch (cause) { fail(instance, '命令条件', cause); return false } diff --git a/web/src/typoraKeys.test.ts b/web/src/typoraKeys.test.ts new file mode 100644 index 0000000..cbf606f --- /dev/null +++ b/web/src/typoraKeys.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { typoraKeys } from './typoraKeys' + +describe('Typora 默认快捷键', () => { + it.each([false, true])('平台 mac=%s:标题、侧栏及无对应命令', mac => { + expect(typoraKeys('editor.heading3', mac)).toEqual(['Mod+3']) + expect(typoraKeys('view.sidebar', mac)).toEqual(['Mod+Shift+L']) + for (const id of ['app.search', 'app.commandPalette', 'folder.new', 'editor.highlight', 'editor.task', 'plugin:test:command']) expect(typoraKeys(id, mac)).toEqual([]) + }) + it('平台差异遵循 Typora', () => { + expect(typoraKeys('app.quickOpen', false)).toEqual(['Mod+P']) + expect(typoraKeys('app.quickOpen', true)).toEqual(['Mod+Shift+O']) + expect(typoraKeys('editor.redo', false)).toEqual(['Ctrl+Y']) + expect(typoraKeys('editor.redo', true)).toEqual(['Mod+Shift+Z']) + expect(typoraKeys('editor.strike', false)).toEqual(['Alt+Shift+5']) + expect(typoraKeys('editor.strike', true)).toEqual(['Ctrl+Shift+`']) + expect(typoraKeys('note.insertImage', true)).toEqual(['Mod+Ctrl+I']) + }) + it.each(['MacIntel', 'Win32'])('%s 的默认组合没有物理冲突,Shift 标点可匹配并录入', async platform => { + vi.stubGlobal('navigator', { platform }) + vi.resetModules() + try { + const { typoraKeys } = await import('./typoraKeys') + const { physicalKeys, matchKeys, recordedKeys } = await import('./keys') + const { editorCommands } = await import('./editor/shortcuts') + const ids = [...editorCommands.map(c => c.id), 'note.new', 'note.save', 'tab.close', 'app.settings', 'view.sidebar', 'view.source', 'view.focus', 'app.quickOpen', 'view.outline', 'note.insertImage'] + const keys = ids.flatMap(id => typoraKeys(id).map(physicalKeys)) + expect(new Set(keys).size).toBe(keys.length) + const mac = platform === 'MacIntel' + const event = new KeyboardEvent('keydown', { key: '~', code: 'Backquote', ctrlKey: !mac, metaKey: mac, shiftKey: true }) + expect(matchKeys(event, 'Mod+Shift+`')).toBe(true) + expect(recordedKeys(event)).toBe('Mod+Shift+`') + if (mac) { + const option = new KeyboardEvent('keydown', { key: 'œ', code: 'KeyQ', metaKey: true, altKey: true }) + expect(matchKeys(option, 'Mod+Alt+Q')).toBe(true) + expect(recordedKeys(option)).toBe('Mod+Alt+Q') + } + if (!mac) expect(matchKeys(new KeyboardEvent('keydown', { key: '{', code: 'BracketLeft', ctrlKey: true, shiftKey: true }), 'Ctrl+Shift+[')).toBe(true) + } finally { vi.unstubAllGlobals(); vi.resetModules() } + }) +}) diff --git a/web/src/typoraKeys.ts b/web/src/typoraKeys.ts new file mode 100644 index 0000000..092a5cf --- /dev/null +++ b/web/src/typoraKeys.ts @@ -0,0 +1,27 @@ +import { isMac } from './keys' + +// https://support.typora.io/Shortcut-Keys/;仅为已有功能提供默认值。 +export function typoraKeys(id: string, mac = isMac): string[] { + if (/^editor\.heading[1-6]$/.test(id)) return [`Mod+${id.at(-1)}`] + const common: Record = { + 'note.new': ['Mod+N'], 'note.save': ['Mod+S'], 'tab.close': ['Mod+W'], + 'app.settings': ['Mod+,'], 'view.sidebar': ['Mod+Shift+L'], + 'view.source': ['Mod+/'], 'view.focus': ['F8'], + 'editor.bold': ['Mod+B'], 'editor.italic': ['Mod+I'], 'editor.underline': ['Mod+U'], + 'editor.link': ['Mod+K'], 'editor.code': ['Mod+Shift+`'], + 'editor.undo': ['Mod+Z'], 'editor.selectAll': ['Mod+A'], + 'editor.indent': ['Mod+[', 'Tab'], + 'editor.outdent': ['Mod+]', 'Shift+Tab'], + } + const platform: Record = { + 'app.quickOpen': mac ? ['Mod+Shift+O'] : ['Mod+P'], + 'view.outline': mac ? ['Mod+Ctrl+1'] : ['Ctrl+Shift+1'], + 'note.insertImage': mac ? ['Mod+Ctrl+I'] : ['Ctrl+Shift+I'], + 'editor.redo': mac ? ['Mod+Shift+Z'] : ['Ctrl+Y'], + 'editor.strike': mac ? ['Ctrl+Shift+`'] : ['Alt+Shift+5'], + 'editor.quote': mac ? ['Mod+Alt+Q'] : ['Ctrl+Shift+Q'], + 'editor.ordered': mac ? ['Mod+Alt+O'] : ['Ctrl+Shift+['], + 'editor.bullet': mac ? ['Mod+Alt+U'] : ['Ctrl+Shift+]'], + } + return common[id] ?? platform[id] ?? [] +}
点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。设置在所有设备间同步。
点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。默认跟随 Typora;无对应项(含插件)留空。自定义设置在所有设备间同步。
浏览器可能保留 Ctrl/Cmd+N、W、数字键等组合,网页无法保证拦截;可自行重新绑定。
{{ filteredCommands.length }} / {{ commands.length }} 个命令{{ filteredCommands.length ? '' : ',没有匹配的快捷键。' }}
{{ error }}