Add command palette and command registry
- Commands (with optional shortcuts and availability checks) are registered in one place; global shortcuts are dispatched from it, and plugins can use registerCommand later - The quick switcher lists commands when the input starts with '>' (VS Code style), opened with Ctrl+Shift+P or F1 (Firefox reserves Ctrl+Shift+P), plus buttons in the sidebar footer and the mobile top bar; recently used commands come first - Built-in commands: quick open, search, new note/folder, save, rename, delete, close tab, trash, log out - Mount the trash dialog at the workspace level so it is not positioned inside the transformed mobile drawer
This commit is contained in:
1 parent
d096f0da70
commit
919575f3e7
7 files changed
+200
-72
No files matched your search
@@ -14,6 +14,7 @@ const paths = {
|
|||||||
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
|
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
|
||||||
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
|
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
|
||||||
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
|
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
|
||||||
|
command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z',
|
||||||
}
|
}
|
||||||
defineProps<{ name: keyof typeof paths; size?: number }>()
|
defineProps<{ name: keyof typeof paths; size?: number }>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,30 +1,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { availableCommands, formatKeys } from './commands'
|
||||||
import { fuzzyMatch } from './fuzzy'
|
import { fuzzyMatch } from './fuzzy'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { dirname, NOTE_EXT, noteTitle } from './paths'
|
import { dirname, NOTE_EXT, noteTitle } from './paths'
|
||||||
import { createNoteAt, openAt, ws } from './workspace'
|
import { createNoteAt, openAt, ws } from './workspace'
|
||||||
|
|
||||||
const MAX_ITEMS = 50
|
const MAX_ITEMS = 50
|
||||||
|
const RECENT_COMMANDS_KEY = 'rawmark.recentCommands'
|
||||||
|
const RECENT_COMMANDS_MAX = 10
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
path: string
|
key: string
|
||||||
|
icon: 'note' | 'noteAdd' | 'command'
|
||||||
title: string
|
title: string
|
||||||
dir: string
|
// 笔记为所在目录,命令为快捷键
|
||||||
|
detail: string
|
||||||
// 命中字符在标题、目录中的下标
|
// 命中字符在标题、目录中的下标
|
||||||
titleHits: Set<number>
|
titleHits: Set<number>
|
||||||
dirHits: Set<number>
|
detailHits: Set<number>
|
||||||
|
run: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = ref<HTMLInputElement>()
|
const input = ref<HTMLInputElement>()
|
||||||
const list = ref<HTMLElement>()
|
const list = ref<HTMLElement>()
|
||||||
const query = ref('')
|
const query = ref(ws.switcher ?? '')
|
||||||
const selected = ref(0)
|
const selected = ref(0)
|
||||||
|
// 以 > 开头时列出命令(与 VS Code 一致),删掉 > 回到找笔记
|
||||||
|
const commandMode = computed(() => query.value.startsWith('>'))
|
||||||
|
|
||||||
// 取消时把焦点还给打开前的元素(通常是编辑器)
|
// 取消时把焦点还给打开前的元素(通常是编辑器)
|
||||||
const previousFocus = document.activeElement as HTMLElement | null
|
const previousFocus = document.activeElement as HTMLElement | null
|
||||||
function close() {
|
function close() {
|
||||||
ws.switcherOpen = false
|
ws.switcher = null
|
||||||
}
|
}
|
||||||
function cancel() {
|
function cancel() {
|
||||||
close()
|
close()
|
||||||
@@ -43,19 +51,20 @@ function segments(text: string, hits: Set<number>) {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = computed<Item[]>(() => {
|
function noteItems(q: string): Item[] {
|
||||||
const q = query.value.trim()
|
|
||||||
const recentRank = new Map(ws.recent.map((p, i) => [p, i]))
|
const recentRank = new Map(ws.recent.map((p, i) => [p, i]))
|
||||||
const notes = ws.entries.filter((e) => !e.dir)
|
const notes = ws.entries.filter((e) => !e.dir)
|
||||||
const toItem = (path: string, positions: number[] = []): Item => {
|
const toItem = (path: string, positions: number[] = []): Item => {
|
||||||
const dir = dirname(path)
|
const dir = dirname(path)
|
||||||
const offset = dir ? dir.length + 1 : 0
|
const offset = dir ? dir.length + 1 : 0
|
||||||
return {
|
return {
|
||||||
path,
|
key: path,
|
||||||
|
icon: 'note',
|
||||||
title: noteTitle(path),
|
title: noteTitle(path),
|
||||||
dir,
|
detail: dir,
|
||||||
titleHits: new Set(positions.filter((p) => p >= offset).map((p) => p - offset)),
|
titleHits: new Set(positions.filter((p) => p >= offset).map((p) => p - offset)),
|
||||||
dirHits: new Set(positions.filter((p) => p < offset)),
|
detailHits: new Set(positions.filter((p) => p < offset)),
|
||||||
|
run: () => void openAt(path),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!q) {
|
if (!q) {
|
||||||
@@ -78,38 +87,75 @@ const items = computed<Item[]>(() => {
|
|||||||
scored.sort(
|
scored.sort(
|
||||||
(a, b) => b.score - a.score || (recentRank.get(a.path) ?? Infinity) - (recentRank.get(b.path) ?? Infinity) || a.path.length - b.path.length,
|
(a, b) => b.score - a.score || (recentRank.get(a.path) ?? Infinity) - (recentRank.get(b.path) ?? Infinity) || a.path.length - b.path.length,
|
||||||
)
|
)
|
||||||
return scored.slice(0, MAX_ITEMS).map((s) => toItem(s.path, s.positions))
|
const items = scored.slice(0, MAX_ITEMS).map((s) => toItem(s.path, s.positions))
|
||||||
})
|
// 没有同名笔记时提供"新建";输入中的 / 表示目录
|
||||||
|
const name = q.replace(/\.md$/i, '')
|
||||||
|
const path = name + NOTE_EXT
|
||||||
|
if (
|
||||||
|
name.split('/').every((seg) => seg.trim() && !seg.startsWith('.')) &&
|
||||||
|
!ws.entries.some((e) => e.path.toLowerCase() === path.toLowerCase())
|
||||||
|
) {
|
||||||
|
items.push({
|
||||||
|
key: '\0create',
|
||||||
|
icon: 'noteAdd',
|
||||||
|
title: `新建笔记“${name}”`,
|
||||||
|
detail: '',
|
||||||
|
titleHits: new Set(),
|
||||||
|
detailHits: new Set(),
|
||||||
|
run: () =>
|
||||||
|
void createNoteAt(path).then((ok) => {
|
||||||
|
if (ok) void openAt(path)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
// 没有同名笔记时提供"新建";输入中的 / 表示目录
|
const recentCommands = ref<string[]>(JSON.parse(localStorage.getItem(RECENT_COMMANDS_KEY) ?? '[]'))
|
||||||
const createPath = computed(() => {
|
|
||||||
const q = query.value.trim().replace(/\.md$/i, '')
|
function commandItems(q: string): Item[] {
|
||||||
if (!q || !q.split('/').every((seg) => seg.trim() && !seg.startsWith('.'))) return null
|
const rank = (id: string) => {
|
||||||
const path = q + NOTE_EXT
|
const i = recentCommands.value.indexOf(id)
|
||||||
return ws.entries.some((e) => e.path.toLowerCase() === path.toLowerCase()) ? null : path
|
return i < 0 ? Infinity : i
|
||||||
})
|
}
|
||||||
const count = computed(() => items.value.length + (createPath.value ? 1 : 0))
|
const scored = availableCommands()
|
||||||
|
.filter((c) => c.id !== 'app.commandPalette')
|
||||||
|
.map((c, order) => ({ c, order, m: q ? fuzzyMatch(q, c.title, 0) : { score: 0, positions: [] } }))
|
||||||
|
.filter((x) => x.m)
|
||||||
|
// 无输入时最近用过的在前,其余保持注册顺序
|
||||||
|
scored.sort((a, b) => b.m!.score - a.m!.score || rank(a.c.id) - rank(b.c.id) || a.order - b.order)
|
||||||
|
return scored.map(({ c, m }) => ({
|
||||||
|
key: c.id,
|
||||||
|
icon: 'command',
|
||||||
|
title: c.title,
|
||||||
|
detail: c.keys ? formatKeys(c.keys[0]) : '',
|
||||||
|
titleHits: new Set(m!.positions),
|
||||||
|
detailHits: new Set(),
|
||||||
|
run: () => {
|
||||||
|
recentCommands.value = [c.id, ...recentCommands.value.filter((id) => id !== c.id)].slice(0, RECENT_COMMANDS_MAX)
|
||||||
|
localStorage.setItem(RECENT_COMMANDS_KEY, JSON.stringify(recentCommands.value))
|
||||||
|
c.run()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = computed(() => (commandMode.value ? commandItems(query.value.slice(1).trim()) : noteItems(query.value.trim())))
|
||||||
|
|
||||||
watch(query, () => (selected.value = 0))
|
watch(query, () => (selected.value = 0))
|
||||||
watch(selected, (i) => list.value?.children[i]?.scrollIntoView({ block: 'nearest' }))
|
watch(selected, (i) => list.value?.children[i]?.scrollIntoView({ block: 'nearest' }))
|
||||||
|
|
||||||
function choose(i: number) {
|
function choose(i: number) {
|
||||||
const item = items.value[i]
|
const item = items.value[i]
|
||||||
const create = createPath.value
|
|
||||||
close()
|
close()
|
||||||
if (item) void openAt(item.path)
|
item?.run()
|
||||||
else if (create) {
|
|
||||||
void createNoteAt(create).then((ok) => {
|
|
||||||
if (ok) void openAt(create)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
const n = items.value.length
|
||||||
if (e.key === 'Escape') cancel()
|
if (e.key === 'Escape') cancel()
|
||||||
else if (e.key === 'ArrowDown' && count.value) selected.value = (selected.value + 1) % count.value
|
else if (e.key === 'ArrowDown' && n) selected.value = (selected.value + 1) % n
|
||||||
else if (e.key === 'ArrowUp' && count.value) selected.value = (selected.value - 1 + count.value) % count.value
|
else if (e.key === 'ArrowUp' && n) selected.value = (selected.value - 1 + n) % n
|
||||||
else if (e.key === 'Enter' && !e.isComposing && count.value) choose(selected.value)
|
else if (e.key === 'Enter' && !e.isComposing && n) choose(selected.value)
|
||||||
else return
|
else return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
@@ -119,48 +165,38 @@ onMounted(() => input.value?.focus())
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rm-switcher-backdrop" @mousedown.self="cancel">
|
<div class="rm-switcher-backdrop" @mousedown.self="cancel">
|
||||||
<div class="rm-switcher" role="dialog" aria-label="快速打开">
|
<div class="rm-switcher" role="dialog" :aria-label="commandMode ? '命令面板' : '快速打开'">
|
||||||
<div class="rm-search-box">
|
<div class="rm-search-box">
|
||||||
<Icon name="search" />
|
<Icon :name="commandMode ? 'command' : 'search'" />
|
||||||
<input
|
<input
|
||||||
ref="input"
|
ref="input"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
class="rm-search-input"
|
class="rm-search-input"
|
||||||
placeholder="输入笔记名称,回车打开或新建"
|
:placeholder="commandMode ? '输入命令名称' : '输入笔记名称,回车打开或新建;输入 > 执行命令'"
|
||||||
enterkeyhint="go"
|
enterkeyhint="go"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
@blur="close"
|
@blur="close"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="count" ref="list" class="rm-switcher-list" @mousedown.prevent>
|
<ul v-if="items.length" ref="list" class="rm-switcher-list" @mousedown.prevent>
|
||||||
<li
|
<li
|
||||||
v-for="(item, i) in items"
|
v-for="(item, i) in items"
|
||||||
:key="item.path"
|
:key="item.key"
|
||||||
class="rm-switcher-item"
|
class="rm-switcher-item"
|
||||||
:class="{ active: i === selected }"
|
:class="{ active: i === selected }"
|
||||||
@click="choose(i)"
|
@click="choose(i)"
|
||||||
@mousemove="selected = i"
|
@mousemove="selected = i"
|
||||||
>
|
>
|
||||||
<Icon name="note" />
|
<Icon :name="item.icon" />
|
||||||
<span class="rm-switcher-title">
|
<span class="rm-switcher-title">
|
||||||
<span v-for="(s, j) in segments(item.title, item.titleHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
<span v-for="(s, j) in segments(item.title, item.titleHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="item.dir" class="rm-switcher-dir">
|
<span v-if="item.detail" class="rm-switcher-dir" :class="{ 'rm-switcher-keys': item.icon === 'command' }">
|
||||||
<span v-for="(s, j) in segments(item.dir, item.dirHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
<span v-for="(s, j) in segments(item.detail, item.detailHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li
|
|
||||||
v-if="createPath"
|
|
||||||
class="rm-switcher-item"
|
|
||||||
:class="{ active: selected === items.length }"
|
|
||||||
@click="choose(items.length)"
|
|
||||||
@mousemove="selected = items.length"
|
|
||||||
>
|
|
||||||
<Icon name="noteAdd" />
|
|
||||||
<span class="rm-switcher-title">新建笔记“{{ createPath.slice(0, -NOTE_EXT.length) }}”</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="rm-tree-empty">还没有笔记</p>
|
<p v-else class="rm-tree-empty">{{ commandMode ? '没有匹配的命令' : '还没有笔记' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -2,14 +2,12 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { basename } from './paths'
|
import { basename } from './paths'
|
||||||
import TrashDialog from './TrashDialog.vue'
|
|
||||||
import { tree } from './tree'
|
import { tree } from './tree'
|
||||||
import TreeNode from './TreeNode.vue'
|
import TreeNode from './TreeNode.vue'
|
||||||
import SearchPanel from './SearchPanel.vue'
|
import SearchPanel from './SearchPanel.vue'
|
||||||
import { createFolder, createNote, move, openSearch, ws } from './workspace'
|
import { createFolder, createNote, move, openSearch, ws } from './workspace'
|
||||||
|
|
||||||
const emit = defineEmits<{ logout: [] }>()
|
const emit = defineEmits<{ logout: [] }>()
|
||||||
const trashOpen = ref(false)
|
|
||||||
const rootDrop = ref(false)
|
const rootDrop = ref(false)
|
||||||
|
|
||||||
function onRootDragOver(e: DragEvent) {
|
function onRootDragOver(e: DragEvent) {
|
||||||
@@ -45,9 +43,9 @@ function onRootDrop(e: DragEvent) {
|
|||||||
<p v-if="!tree.length" class="rm-tree-empty">还没有笔记</p>
|
<p v-if="!tree.length" class="rm-tree-empty">还没有笔记</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rm-sidebar-foot">
|
<div class="rm-sidebar-foot">
|
||||||
<button class="rm-foot-btn" @click="trashOpen = true"><Icon name="trash" />回收站</button>
|
<button class="rm-foot-btn" title="命令面板(F1)" @click="ws.switcher = '>'"><Icon name="command" />命令</button>
|
||||||
|
<button class="rm-foot-btn" @click="ws.trashOpen = true"><Icon name="trash" />回收站</button>
|
||||||
<button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button>
|
<button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button>
|
||||||
</div>
|
</div>
|
||||||
<TrashDialog v-if="trashOpen" @close="trashOpen = false" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
|
import { registerCommand } from './commands'
|
||||||
import EditorPane from './EditorPane.vue'
|
import EditorPane from './EditorPane.vue'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import NoteBanner from './NoteBanner.vue'
|
import NoteBanner from './NoteBanner.vue'
|
||||||
@@ -8,6 +9,7 @@ import QuickSwitcher from './QuickSwitcher.vue'
|
|||||||
import { noteTitle } from './paths'
|
import { noteTitle } from './paths'
|
||||||
import Sidebar from './Sidebar.vue'
|
import Sidebar from './Sidebar.vue'
|
||||||
import TabBar from './TabBar.vue'
|
import TabBar from './TabBar.vue'
|
||||||
|
import TrashDialog from './TrashDialog.vue'
|
||||||
import { activeTab, flushAll, startWorkspace, stopWorkspace, toast, ws } from './workspace'
|
import { activeTab, flushAll, startWorkspace, stopWorkspace, toast, ws } from './workspace'
|
||||||
|
|
||||||
const emit = defineEmits<{ logout: [] }>()
|
const emit = defineEmits<{ logout: [] }>()
|
||||||
@@ -38,8 +40,13 @@ function onTouchMove(e: TouchEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unregisterLogout = registerCommand({ id: 'app.logout', title: '退出登录', run: () => void logout() })
|
||||||
|
|
||||||
onMounted(() => void startWorkspace())
|
onMounted(() => void startWorkspace())
|
||||||
onBeforeUnmount(stopWorkspace)
|
onBeforeUnmount(() => {
|
||||||
|
unregisterLogout()
|
||||||
|
stopWorkspace()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -51,12 +58,14 @@ onBeforeUnmount(stopWorkspace)
|
|||||||
<button class="rm-icon-btn" title="目录" @click="ws.sidebarOpen = true"><Icon name="menu" :size="20" /></button>
|
<button class="rm-icon-btn" title="目录" @click="ws.sidebarOpen = true"><Icon name="menu" :size="20" /></button>
|
||||||
<span class="rm-mobile-title">{{ title }}</span>
|
<span class="rm-mobile-title">{{ title }}</span>
|
||||||
<span class="rm-save-state" :class="{ on: saving }" />
|
<span class="rm-save-state" :class="{ on: saving }" />
|
||||||
<button class="rm-icon-btn" title="快速打开" @click="ws.switcherOpen = true"><Icon name="search" :size="20" /></button>
|
<button class="rm-icon-btn" title="快速打开" @click="ws.switcher = ''"><Icon name="search" :size="20" /></button>
|
||||||
|
<button class="rm-icon-btn" title="命令" @click="ws.switcher = '>'"><Icon name="command" :size="20" /></button>
|
||||||
</header>
|
</header>
|
||||||
<TabBar />
|
<TabBar />
|
||||||
<NoteBanner />
|
<NoteBanner />
|
||||||
<EditorPane />
|
<EditorPane />
|
||||||
</main>
|
</main>
|
||||||
<QuickSwitcher v-if="ws.switcherOpen" />
|
<QuickSwitcher v-if="ws.switcher !== null" />
|
||||||
|
<TrashDialog v-if="ws.trashOpen" @close="ws.trashOpen = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -217,6 +217,7 @@ button {
|
|||||||
.rm-switcher-item.active { background: var(--rm-active); }
|
.rm-switcher-item.active { background: var(--rm-active); }
|
||||||
.rm-switcher-title { color: var(--rm-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.rm-switcher-title { color: var(--rm-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.rm-switcher-dir { flex: 1; min-width: 0; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
|
.rm-switcher-dir { flex: 1; min-width: 0; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
|
||||||
|
.rm-switcher-keys span { padding: 1px 6px; border: 1px solid var(--rm-divider); border-radius: 4px; font-family: inherit; }
|
||||||
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
|
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
|
||||||
.rm-foot-btn {
|
.rm-foot-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令
|
||||||
|
import { shallowReactive } from 'vue'
|
||||||
|
|
||||||
|
export interface Command {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
/** 快捷键,如 'Mod+Shift+F'、'F1';Mod 在 Mac 上为 Cmd,其他平台为 Ctrl。第一个用于显示 */
|
||||||
|
keys?: string[]
|
||||||
|
/** 返回 false 时命令不可用:不在面板中列出,快捷键也不触发 */
|
||||||
|
when?: () => boolean
|
||||||
|
run: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = shallowReactive(new Map<string, Command>())
|
||||||
|
|
||||||
|
/** 注册命令,返回注销函数;id 重复时后注册的覆盖先注册的 */
|
||||||
|
export function registerCommand(cmd: Command): () => void {
|
||||||
|
commands.set(cmd.id, cmd)
|
||||||
|
return () => {
|
||||||
|
if (commands.get(cmd.id) === cmd) commands.delete(cmd.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function availableCommands(): Command[] {
|
||||||
|
return [...commands.values()].filter((c) => c.when?.() ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runCommand(id: string) {
|
||||||
|
const cmd = commands.get(id)
|
||||||
|
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 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('+')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局快捷键分发;编辑器等已处理的按键(defaultPrevented)和输入法组字中的按键不处理 */
|
||||||
|
export function handleShortcut(e: KeyboardEvent) {
|
||||||
|
if (e.defaultPrevented || e.isComposing) return
|
||||||
|
for (const cmd of availableCommands()) {
|
||||||
|
if (cmd.keys?.some((k) => matchKeys(e, k))) {
|
||||||
|
e.preventDefault()
|
||||||
|
cmd.run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-16
@@ -4,6 +4,7 @@ import { Annotation, Transaction, type EditorState } from '@codemirror/state'
|
|||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { nextTick, reactive, watch } from 'vue'
|
import { nextTick, reactive, watch } from 'vue'
|
||||||
import { createEditorState, pasteImages } from '../editor'
|
import { createEditorState, pasteImages } from '../editor'
|
||||||
|
import { handleShortcut, registerCommand } from './commands'
|
||||||
import { api, ApiError, type Entry } from './api'
|
import { api, ApiError, type Entry } from './api'
|
||||||
import { drafts } from './drafts'
|
import { drafts } from './drafts'
|
||||||
import { connectEvents, type VaultEvent } from './events'
|
import { connectEvents, type VaultEvent } from './events'
|
||||||
@@ -42,8 +43,9 @@ export const ws = reactive({
|
|||||||
sidebarOpen: false,
|
sidebarOpen: false,
|
||||||
// 侧栏显示搜索而非目录树
|
// 侧栏显示搜索而非目录树
|
||||||
searchOpen: false,
|
searchOpen: false,
|
||||||
// 快速切换对话框
|
// 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
|
||||||
switcherOpen: false,
|
switcher: null as string | null,
|
||||||
|
trashOpen: false,
|
||||||
// 最近打开的笔记,最新的在前
|
// 最近打开的笔记,最新的在前
|
||||||
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
|
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
|
||||||
toasts: [] as Toast[],
|
toasts: [] as Toast[],
|
||||||
@@ -732,21 +734,40 @@ export async function startWorkspace() {
|
|||||||
if (document.visibilityState === 'hidden') void flushAll(true)
|
if (document.visibilityState === 'hidden') void flushAll(true)
|
||||||
})
|
})
|
||||||
window.addEventListener('pagehide', () => void flushAll(true))
|
window.addEventListener('pagehide', () => void flushAll(true))
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', handleShortcut)
|
||||||
if (!(e.ctrlKey || e.metaKey)) return
|
}
|
||||||
const key = e.key.toLowerCase()
|
|
||||||
if (key === 's' && !e.shiftKey) {
|
// 内置命令不依赖工作区状态,模块载入时注册,排在组件注册的命令(如退出登录)之前
|
||||||
e.preventDefault()
|
registerBuiltinCommands()
|
||||||
if (ws.active) void flush(ws.active)
|
|
||||||
} else if (key === 'f' && e.shiftKey) {
|
function registerBuiltinCommands() {
|
||||||
e.preventDefault()
|
const hasNote = () => ws.active !== null
|
||||||
openSearch()
|
const toggleSwitcher = (initial: string) => {
|
||||||
} else if (key === 'p' && !e.shiftKey) {
|
ws.switcher = ws.switcher === null ? initial : null
|
||||||
// 覆盖浏览器的打印快捷键
|
|
||||||
e.preventDefault()
|
|
||||||
ws.switcherOpen = !ws.switcherOpen
|
|
||||||
}
|
}
|
||||||
})
|
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: '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.rename', title: '重命名当前笔记', when: hasNote, run: () => withSidebar(() => (ws.renaming = ws.active)) },
|
||||||
|
{ id: 'note.delete', title: '删除当前笔记', when: hasNote, run: () => void remove(ws.active!) },
|
||||||
|
// 浏览器不允许网页覆盖 Ctrl+W
|
||||||
|
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
|
||||||
|
{ id: 'app.trash', title: '打开回收站', run: () => (ws.trashOpen = true) },
|
||||||
|
]) {
|
||||||
|
registerCommand(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要在目录树中操作的命令:切回目录树,移动端打开侧栏
|
||||||
|
function withSidebar(fn: () => unknown) {
|
||||||
|
ws.searchOpen = false
|
||||||
|
ws.sidebarOpen = true
|
||||||
|
void fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openSearch() {
|
export function openSearch() {
|
||||||
|
|||||||
Reference in new issue
Block a user