feat(shortcuts): adopt Typora defaults for supported actions
This commit is contained in:
1 parent
830fabaa2d
commit
2b8f18f0db
14 files changed
+145
-37
No files matched your search
@@ -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`。
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 仅在切换到 Typora 默认快捷键的部署中执行一次;保留所有其他设置。
|
||||
BEGIN IMMEDIATE;
|
||||
UPDATE settings SET value = json_remove(value, '$.shortcuts') WHERE key = 'app.settings';
|
||||
COMMIT;
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { runCommand } from './commands'
|
||||
import Icon from './Icon.vue'
|
||||
import { toggleSourceMode, ws } from './workspace'
|
||||
</script>
|
||||
@@ -8,6 +9,6 @@ import { toggleSourceMode, ws } from './workspace'
|
||||
<button class="rm-icon-btn" title="即时预览" aria-label="即时预览" :aria-pressed="!ws.sourceMode" @mousedown.prevent @click="ws.sourceMode && toggleSourceMode()"><Icon name="eye" /></button>
|
||||
<button class="rm-icon-btn" title="源码模式" aria-label="源码模式" :aria-pressed="ws.sourceMode" @mousedown.prevent @click="!ws.sourceMode && toggleSourceMode()"><Icon name="source" /></button>
|
||||
<button class="rm-icon-btn" title="显示 / 隐藏大纲" aria-label="显示 / 隐藏大纲" :aria-pressed="ws.outlineOpen" @click="ws.outlineOpen = !ws.outlineOpen"><Icon name="outline" /></button>
|
||||
<button class="rm-icon-btn" title="专注写作" aria-label="专注写作" :aria-pressed="ws.focusMode" @click="ws.focusMode = !ws.focusMode"><Icon name="expand" /></button>
|
||||
<button class="rm-icon-btn" title="专注写作" aria-label="专注写作" :aria-pressed="ws.focusMode" @click="runCommand('view.focus')"><Icon name="expand" /></button>
|
||||
</section>
|
||||
</template>
|
||||
@@ -62,7 +62,8 @@ async function reset(command: Command) {
|
||||
<template>
|
||||
<div class="rm-shortcut-settings">
|
||||
<div class="rm-shortcut-toolbar"><input v-model="query" type="search" aria-label="搜索快捷键" placeholder="搜索命令或快捷键" /><button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">重置全部快捷键</button></div>
|
||||
<p class="rm-share-note">点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。设置在所有设备间同步。</p>
|
||||
<p class="rm-share-note">点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。默认跟随 Typora;无对应项(含插件)留空。自定义设置在所有设备间同步。</p>
|
||||
<p class="rm-share-note">浏览器可能保留 Ctrl/Cmd+N、W、数字键等组合,网页无法保证拦截;可自行重新绑定。</p>
|
||||
<p class="rm-settings-result-count" role="status">{{ filteredCommands.length }} / {{ commands.length }} 个命令{{ filteredCommands.length ? '' : ',没有匹配的快捷键。' }}</p>
|
||||
<p v-if="error" role="alert" class="rm-auth-error">{{ error }}</p>
|
||||
<div v-for="command in filteredCommands" :key="command.id" class="rm-shortcut-row">
|
||||
|
||||
+10
-10
@@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
})
|
||||
+17
-10
@@ -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 })))
|
||||
+11
-4
@@ -1,7 +1,15 @@
|
||||
// 应用、编辑器和快捷键录入共用同一套按键表示。
|
||||
const isMac = /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
export const isMac = /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
const punctuation: Record<string, string> = { 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('+'))
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
@@ -26,6 +26,7 @@ export interface HostModules {
|
||||
export interface PluginCommand {
|
||||
id: string
|
||||
title: string
|
||||
/** 保留声明兼容;默认不绑定,请用户在设置中添加快捷键。 */
|
||||
keys?: string[]
|
||||
when?: () => boolean
|
||||
run(): Awaitable<void>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<PluginAPI> {
|
||||
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 }
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
})
|
||||
@@ -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<string, string[]> = {
|
||||
'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<string, string[]> = {
|
||||
'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] ?? []
|
||||
}
|
||||
Reference in new issue
Block a user