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 @@ + + + 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 } }) }