diff --git a/web/src/app/QuickSwitcher.vue b/web/src/app/QuickSwitcher.vue
new file mode 100644
index 0000000..83e0ee0
--- /dev/null
+++ b/web/src/app/QuickSwitcher.vue
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+ {{ s.text }}
+
+
+ {{ s.text }}
+
+
+ -
+
+ 新建笔记“{{ createPath.slice(0, -NOTE_EXT.length) }}”
+
+
+
还没有笔记
+
+
+
diff --git a/web/src/app/Workspace.vue b/web/src/app/Workspace.vue
index 03c5adf..938a853 100644
--- a/web/src/app/Workspace.vue
+++ b/web/src/app/Workspace.vue
@@ -4,6 +4,7 @@ import { api } from './api'
import EditorPane from './EditorPane.vue'
import Icon from './Icon.vue'
import NoteBanner from './NoteBanner.vue'
+import QuickSwitcher from './QuickSwitcher.vue'
import { noteTitle } from './paths'
import Sidebar from './Sidebar.vue'
import TabBar from './TabBar.vue'
@@ -50,10 +51,12 @@ onBeforeUnmount(stopWorkspace)
{{ title }}
+
+
diff --git a/web/src/app/app.css b/web/src/app/app.css
index f3a0bfc..89d6d12 100644
--- a/web/src/app/app.css
+++ b/web/src/app/app.css
@@ -199,6 +199,24 @@ button {
word-break: break-all;
}
.rm-search-hit { background: rgba(66, 184, 131, 0.28); color: var(--rm-text); border-radius: 2px; }
+/* ---- 快速切换 ---- */
+.rm-switcher-backdrop { position: fixed; inset: 0; z-index: 50; display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: rgba(0, 0, 0, 0.18); }
+.rm-switcher {
+ display: flex;
+ flex-direction: column;
+ width: min(560px, calc(100vw - 24px));
+ max-height: 60vh;
+ padding-top: 10px;
+ border-radius: 10px;
+ background: var(--rm-bg);
+ box-shadow: var(--rm-shadow);
+ overflow: hidden;
+}
+.rm-switcher-list { flex: 1; overflow: auto; list-style: none; margin: 0; padding: 0 6px 6px; }
+.rm-switcher-item { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px; border-radius: 6px; font-size: 14px; color: var(--rm-text-muted); cursor: pointer; }
+.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-dir { flex: 1; min-width: 0; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
.rm-foot-btn {
flex: 1;
@@ -392,5 +410,9 @@ button {
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
.rm-search-input { height: 40px; font-size: 16px; }
.rm-search-item { padding: 10px 8px; }
+ /* 手机上从顶部弹出,给软键盘留出空间 */
+ .rm-switcher-backdrop { padding-top: calc(8px + env(safe-area-inset-top)); }
+ .rm-switcher { max-height: 50vh; }
+ .rm-switcher-item { height: 44px; font-size: 15px; }
.rm-trash-list li { flex-wrap: wrap; }
}
diff --git a/web/src/app/fuzzy.test.ts b/web/src/app/fuzzy.test.ts
new file mode 100644
index 0000000..6355a51
--- /dev/null
+++ b/web/src/app/fuzzy.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { fuzzyMatch } from './fuzzy'
+
+const match = (q: string, path: string) => fuzzyMatch(q, path, path.lastIndexOf('/') + 1)
+const rank = (q: string, paths: string[]) =>
+ paths
+ .map((p) => ({ p, m: match(q, p) }))
+ .filter((x) => x.m)
+ .sort((a, b) => b.m!.score - a.m!.score)
+ .map((x) => x.p)
+
+describe('fuzzyMatch', () => {
+ it('不区分大小写,记录命中位置', () => {
+ expect(match('READ', 'docs/readme')?.positions).toEqual([5, 6, 7, 8])
+ })
+
+ it('按顺序零散命中,顺序不对则不匹配', () => {
+ expect(match('dpl', 'deploy')?.positions).toEqual([0, 2, 3])
+ expect(match('lpd', 'deploy')).toBeNull()
+ })
+
+ it('多个词都要命中,可分别落在目录和标题', () => {
+ expect(match('项目 部署', '项目/部署笔记')).not.toBeNull()
+ expect(match('项目 发布', '项目/部署笔记')).toBeNull()
+ })
+
+ it('标题完全一致 > 标题开头 > 标题中间 > 目录 > 零散命中', () => {
+ expect(rank('部署', ['部署/其他', '笔记/安装部署', '部署说明', '部 x 署', '部署'])).toEqual(['部署', '部署说明', '笔记/安装部署', '部署/其他', '部 x 署'])
+ })
+
+ it('零散命中时紧凑、命中词首的排前', () => {
+ expect(rank('rm', ['rawmark', 'readme', 'r-m'])[0]).toBe('r-m')
+ })
+})
diff --git a/web/src/app/fuzzy.ts b/web/src/app/fuzzy.ts
new file mode 100644
index 0000000..f36f5a7
--- /dev/null
+++ b/web/src/app/fuzzy.ts
@@ -0,0 +1,55 @@
+// 快速切换的模糊匹配。目标形如"目录/标题",空白分隔的每个词都要命中;
+// 连续命中标题 > 连续命中目录 > 按顺序零散命中(如用首字母找笔记)
+
+export interface FuzzyMatch {
+ score: number
+ // 命中字符在目标中的下标,用于高亮
+ positions: number[]
+}
+
+const BOUNDARY = new Set(['/', ' ', '-', '_', '.'])
+
+function isBoundary(target: string, i: number): boolean {
+ return i === 0 || BOUNDARY.has(target[i - 1])
+}
+
+function matchTerm(term: string, target: string, lower: string, titleStart: number): FuzzyMatch | null {
+ // 取最后一处连续命中:标题在末尾,优先落在标题上
+ const at = lower.lastIndexOf(term)
+ if (at >= 0) {
+ let score = at >= titleStart ? 100 : 50
+ if (at === titleStart) score += 30
+ else if (isBoundary(target, at)) score += 10
+ return { score, positions: Array.from(term, (_, k) => at + k) }
+ }
+ const positions: number[] = []
+ let from = 0
+ for (const ch of term) {
+ const i = lower.indexOf(ch, from)
+ if (i < 0) return null
+ positions.push(i)
+ from = i + 1
+ }
+ // 越分散得分越低,命中词首的字符加分
+ const spread = positions[positions.length - 1] - positions[0] + 1 - positions.length
+ const boundaries = positions.filter((i) => isBoundary(target, i)).length
+ return { score: Math.max(1, 20 - spread + boundaries * 3), positions }
+}
+
+/** titleStart 为标题在 target 中的起始下标;不匹配时返回 null */
+export function fuzzyMatch(query: string, target: string, titleStart: number): FuzzyMatch | null {
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
+ if (!terms.length) return null
+ const lower = target.toLowerCase()
+ let score = 0
+ const positions = new Set()
+ for (const term of terms) {
+ const m = matchTerm(term, target, lower, titleStart)
+ if (!m) return null
+ score += m.score
+ m.positions.forEach((p) => positions.add(p))
+ }
+ // 标题与输入完全一致时排在最前
+ if (lower.slice(titleStart) === query.trim().toLowerCase()) score += 1000
+ return { score, positions: [...positions].sort((a, b) => a - b) }
+}
diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts
index 02493d5..97440f1 100644
--- a/web/src/app/workspace.ts
+++ b/web/src/app/workspace.ts
@@ -29,6 +29,9 @@ export interface Toast {
action?: { label: string; run: () => void }
}
+const RECENT_KEY = 'rawmark.recent'
+const RECENT_MAX = 30
+
export const ws = reactive({
entries: [] as Entry[],
tabs: [] as Tab[],
@@ -39,6 +42,10 @@ export const ws = reactive({
sidebarOpen: false,
// 侧栏显示搜索而非目录树
searchOpen: false,
+ // 快速切换对话框
+ switcherOpen: false,
+ // 最近打开的笔记,最新的在前
+ recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
toasts: [] as Toast[],
})
@@ -279,30 +286,33 @@ export async function keepLocalVersion(path: string) {
}
}
-// ---- 搜索结果跳转 ----
+// ---- 打开并聚焦(快速切换、搜索结果跳转) ----
interface RevealTarget {
path: string
line: number
terms: string[]
}
-// 待定位的位置:笔记可能尚未载入,由编辑器面板在显示该笔记时执行
+// 笔记可能尚未载入或编辑器尚未显示,由编辑器面板在显示该笔记后执行
let pendingReveal: RevealTarget | null = null
-/** 打开笔记并定位到第 line 行(从 1 开始,0 表示不定位),选中该行第一个搜索词 */
-export async function openAt(path: string, line: number, terms: string[]) {
- pendingReveal = line > 0 ? { path, line, terms } : null
- if (pendingReveal && path === ws.active && view && editorStates.has(path)) {
+/** 打开笔记并聚焦编辑器;line > 0 时定位到该行(从 1 开始)并选中该行第一个搜索词 */
+export async function openAt(path: string, line = 0, terms: string[] = []) {
+ pendingReveal = { path, line, terms }
+ if (path === ws.active && view && editorStates.has(path)) {
ws.sidebarOpen = false
revealPending(view, path)
} else await openNote(path)
}
-/** 编辑器面板显示笔记后调用;返回 true 表示已定位(不应再恢复原来的滚动位置) */
+/** 编辑器面板显示笔记后调用;返回 true 表示已定位到指定行(不应再恢复原来的滚动位置) */
export function revealPending(v: EditorView, path: string): boolean {
const r = pendingReveal
if (!r || r.path !== path) return false
pendingReveal = null
+ // 触屏设备上聚焦会弹出软键盘遮住内容,只在有鼠标的设备上聚焦
+ if (!matchMedia('(pointer: coarse)').matches) v.focus()
+ if (r.line === 0) return false
const line = v.state.doc.line(Math.min(r.line, v.state.doc.lines))
const text = line.text.toLowerCase()
let at = -1
@@ -313,8 +323,6 @@ export function revealPending(v: EditorView, path: string): boolean {
}
const from = line.from + Math.max(at, 0)
v.dispatch({ selection: { anchor: from, head: from + len }, effects: EditorView.scrollIntoView(from, { y: 'center' }) })
- // 触屏设备上聚焦会弹出软键盘遮住内容,只在有鼠标的设备上聚焦
- if (!matchMedia('(pointer: coarse)').matches) v.focus()
return true
}
@@ -388,28 +396,64 @@ function remapOpenNotes(from: string, to: string) {
}
}
+function remapRecent(from: string, to: string) {
+ ws.recent = ws.recent.map((p) => remapPath(p, from, to) ?? p)
+}
+
function affected(path: string): string[] {
return ws.tabs.map((t) => t.path).filter((p) => p === path || p.startsWith(path + '/'))
}
// ---- 目录树 ----
+// 目录刷新:同一时间只有一个请求;请求期间又有刷新请求或本地操作时,结果可能已过时,丢弃后重新获取。
+// 本地操作会先乐观修改目录,过时的结果会抹掉刚加入的条目(例如新建笔记的重命名框随之消失)。
+// 等待中的调用者在下一次被采用的结果(或请求失败)后统一完成
let treeTimer: ReturnType | undefined
+let treeFetching = false
+let treeAgain = false
+let localTreeEdits = 0
+const treeWaiters: (() => void)[] = []
+
export function refreshTree(): Promise {
- clearTimeout(treeTimer)
return new Promise((resolve) => {
- treeTimer = setTimeout(async () => {
- try {
- ws.entries = await api.tree()
- if (opsInFlight === 0) markMissingTabsDeleted()
- } catch (err) {
- toast('刷新目录失败:' + errorText(err))
- }
- resolve()
- }, 50)
+ treeWaiters.push(resolve)
+ scheduleTreeRefresh()
})
}
+function scheduleTreeRefresh() {
+ if (treeFetching) {
+ treeAgain = true
+ return
+ }
+ clearTimeout(treeTimer)
+ treeTimer = setTimeout(() => void fetchTree(), 50)
+}
+
+async function fetchTree() {
+ treeFetching = true
+ treeAgain = false
+ const edits = localTreeEdits
+ let entries: Entry[] | null = null
+ try {
+ entries = await api.tree()
+ } catch (err) {
+ toast('刷新目录失败:' + errorText(err))
+ }
+ treeFetching = false
+ if (entries && (treeAgain || edits !== localTreeEdits || opsInFlight > 0)) {
+ // 有操作进行中时由 withOp 在全部结束后重新调度
+ if (opsInFlight === 0) scheduleTreeRefresh()
+ return
+ }
+ if (entries) {
+ ws.entries = entries
+ markMissingTabsDeleted()
+ }
+ for (const resolve of treeWaiters.splice(0)) resolve()
+}
+
function markMissingTabsDeleted() {
const exists = new Set(ws.entries.map((e) => e.path))
for (const tab of ws.tabs) {
@@ -430,27 +474,52 @@ function uniqueName(dir: string, base: string, ext: string): string {
async function withOp(fn: () => Promise): Promise {
opsInFlight++
+ localTreeEdits++
try {
return await fn()
} finally {
- opsInFlight--
+ localTreeEdits++
+ if (--opsInFlight === 0 && treeWaiters.length) scheduleTreeRefresh()
}
}
export async function createNote(dir: string) {
const path = uniqueName(dir, '未命名', NOTE_EXT)
- ws.entries.push({ path, dir: false, size: 0, mtime: Date.now() })
ws.renaming = path
- await withOp(async () => {
+ await createNoteAt(path)
+}
+
+// 进行中的新建:新建后立即出现重命名输入框,重命名须等文件真正创建后再执行
+const creating = new Map>()
+
+/** 在指定路径新建空笔记并打开,返回是否成功;不存在的上级目录由服务器创建 */
+export async function createNoteAt(path: string): Promise {
+ const done = createNoteFile(path)
+ creating.set(path, done)
+ try {
+ return await done
+ } finally {
+ creating.delete(path)
+ }
+}
+
+async function createNoteFile(path: string): Promise {
+ const exists = new Set(ws.entries.map((e) => e.path))
+ const added = [path]
+ for (let d = dirname(path); d && !exists.has(d); d = dirname(d)) added.push(d)
+ ws.entries.push(...added.map((p, i) => ({ path: p, dir: i > 0, size: 0, mtime: Date.now() })))
+ return withOp(async () => {
try {
const rev = await api.writeFile(path, '', '')
ws.tabs.push({ path, rev, status: 'ready', dirty: false })
editorStates.set(path, buildState(path, ''))
ws.active = path
+ return true
} catch (err) {
- ws.entries = ws.entries.filter((e) => e.path !== path)
+ ws.entries = ws.entries.filter((e) => !added.includes(e.path))
if (ws.renaming === path) ws.renaming = null
toast('新建笔记失败:' + errorText(err))
+ return false
}
})
}
@@ -480,6 +549,7 @@ export function renameTarget(path: string, name: string): string | null {
export async function move(from: string, to: string) {
if (from === to) return
+ if (creating.has(from) && !(await creating.get(from))) return
if (ws.entries.some((e) => e.path === to)) {
toast(`“${basename(to)}”已存在`)
return
@@ -492,11 +562,13 @@ export async function move(from: string, to: string) {
const snapshot = ws.entries
ws.entries = ws.entries.map((e) => ({ ...e, path: remapPath(e.path, from, to) ?? e.path }))
remapOpenNotes(from, to)
+ remapRecent(from, to)
try {
await api.move(from, to)
} catch (err) {
ws.entries = snapshot
remapOpenNotes(to, from)
+ remapRecent(to, from)
toast('移动失败:' + errorText(err))
return
} finally {
@@ -575,6 +647,7 @@ function handleEvents(evs: VaultEvent[]) {
if (tab && tab.rev !== ev.rev) void syncOpenTab(tab)
} else if (ev.type === 'moved') {
if (tabOf(ev.from!) && !tabOf(ev.path!)) remapOpenNotes(ev.from!, ev.path!)
+ remapRecent(ev.from!, ev.path!)
} else if (ev.type === 'deleted') {
const tab = tabOf(ev.path!)
if (tab && tab.status === 'ready' && opsInFlight === 0) {
@@ -640,6 +713,19 @@ export async function startWorkspace() {
watch(
() => [ws.tabs.map((t) => t.path), ws.active] as const,
([paths, active]) => localStorage.setItem(TABS_KEY, JSON.stringify({ paths, active })),
+ // 启动期间(如等待目录刷新时)打开的标签页也要立即记录
+ { immediate: true },
+ )
+ watch(
+ () => ws.active,
+ (p) => {
+ if (p) ws.recent = [p, ...ws.recent.filter((r) => r !== p)].slice(0, RECENT_MAX)
+ },
+ { immediate: true },
+ )
+ watch(
+ () => ws.recent,
+ (r) => localStorage.setItem(RECENT_KEY, JSON.stringify(r)),
)
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
document.addEventListener('visibilitychange', () => {
@@ -655,6 +741,10 @@ export async function startWorkspace() {
} else if (key === 'f' && e.shiftKey) {
e.preventDefault()
openSearch()
+ } else if (key === 'p' && !e.shiftKey) {
+ // 覆盖浏览器的打印快捷键
+ e.preventDefault()
+ ws.switcherOpen = !ws.switcherOpen
}
})
}