Add full-text search

- Index notes in a separate, rebuildable SQLite database (index.db) using FTS5 trigram,
  synced from watcher snapshots so external edits are picked up; terms under 3 characters
  fall back to LIKE
- GET /api/v1/search?q= returns matching notes with the first matching line and a snippet;
  path matches rank first
- Sidebar search panel (Ctrl+Shift+F) with highlighted terms and keyboard navigation;
  opening a result jumps to and selects the match
- Restore scroll position on tab switch with scroll snapshots; restoring scrollTop drifted
  by a few lines after line heights were re-estimated
This commit is contained in:
eezd committed 2026-10-16 20:17:24 +08:00
1 parent a11e0ec498
commit 55f0ee174e
13 files changed
+792 -20

No files matched your search

+19 -7
View File
@@ -1,16 +1,23 @@
<script setup lang="ts">
import { EditorState } from '@codemirror/state'
import { EditorState, type StateEffect, type Text } from '@codemirror/state'
import type { EditorView } from '@codemirror/view'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { createEditorView } from '../editor'
import { activeTab, attachView, editorStateOf, noteRefOf, storeViewState, ws } from './workspace'
import { activeTab, attachView, editorStateOf, noteRefOf, revealPending, storeViewState, ws } from './workspace'
const host = ref<HTMLElement>()
const tab = computed(activeTab)
let view: EditorView
// 当前显示的笔记(以身份对象标识,重命名后不变)及各笔记的滚动位置
// 当前显示的笔记(以身份对象标识,重命名后不变)及各笔记的滚动位置。
// 优先用滚动快照(锚定文档位置):重设状态后视口外的行高会重新估算,单靠 scrollTop 会偏移几行;
// 快照只对同一文档有效,笔记在后台被外部修改时退回 scrollTop
interface ScrollPosition {
doc: Text
snapshot: StateEffect<unknown>
top: number
}
let shown: { path: string } | undefined
const scrollTops = new WeakMap<object, number>()
const scrollPositions = new WeakMap<object, ScrollPosition>()
function show() {
const path = ws.active
@@ -18,7 +25,7 @@ function show() {
const state = path ? editorStateOf(path) : undefined
if (shown && shown !== ref) {
storeViewState(shown.path, view.state)
scrollTops.set(shown, view.scrollDOM.scrollTop)
scrollPositions.set(shown, { doc: view.state.doc, snapshot: view.scrollSnapshot(), top: view.scrollDOM.scrollTop })
}
if (!ref || !state) {
shown = undefined
@@ -27,8 +34,13 @@ function show() {
// 切换笔记,或该笔记的状态在别处被整体替换(如恢复草稿)时才重设
if (shown !== ref || state.doc !== view.state.doc) {
view.setState(state)
const top = scrollTops.get(ref) ?? 0
requestAnimationFrame(() => (view.scrollDOM.scrollTop = top))
const pos = scrollPositions.get(ref)
// 等一帧:v-show 在此回调之后才取消 display:none,隐藏状态下无法滚动和聚焦
requestAnimationFrame(() => {
if (shown !== ref || revealPending(view, ref.path)) return
if (pos?.doc === view.state.doc) view.dispatch({ effects: pos.snapshot })
else view.scrollDOM.scrollTop = pos?.top ?? 0
})
}
shown = ref
}
+1
View File
@@ -13,6 +13,7 @@ const paths = {
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
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',
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
}
defineProps<{ name: keyof typeof paths; size?: number }>()
</script>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { api, type SearchResult } from './api'
import Icon from './Icon.vue'
import { dirname, noteTitle } from './paths'
import { openAt, toast, ws } from './workspace'
const SEARCH_DELAY = 150
const input = ref<HTMLInputElement>()
const query = ref('')
const results = ref<SearchResult[] | null>(null)
const selected = ref(0)
const terms = computed(() => query.value.split(/\s+/).filter(Boolean))
// 只采用最后一次请求的结果,避免慢请求覆盖新结果
let seq = 0
let timer: ReturnType<typeof setTimeout> | undefined
watch(query, (q) => {
clearTimeout(timer)
timer = setTimeout(() => void run(q), SEARCH_DELAY)
})
async function run(q: string) {
const id = ++seq
if (!q.trim()) {
results.value = null
return
}
try {
const r = await api.search(q)
if (id !== seq) return
results.value = r
selected.value = 0
} catch {
if (id === seq) toast('搜索失败')
}
}
/** 把文本按搜索词(不区分大小写)切分,命中部分标记为高亮 */
function segments(text: string): { text: string; hit: boolean }[] {
const lower = text.toLowerCase()
const words = terms.value.map((t) => t.toLowerCase())
const out: { text: string; hit: boolean }[] = []
let plain = ''
for (let i = 0; i < text.length; ) {
const len = Math.max(0, ...words.filter((w) => lower.startsWith(w, i)).map((w) => w.length))
if (len) {
if (plain) out.push({ text: plain, hit: false })
out.push({ text: text.slice(i, i + len), hit: true })
plain = ''
i += len
} else plain += text[i++]
}
if (plain) out.push({ text: plain, hit: false })
return out
}
function open(r: SearchResult) {
void openAt(r.path, r.line, terms.value)
}
function onKeydown(e: KeyboardEvent) {
const list = results.value ?? []
if (e.key === 'Escape') ws.searchOpen = false
else if (e.key === 'ArrowDown' && list.length) selected.value = (selected.value + 1) % list.length
else if (e.key === 'ArrowUp' && list.length) selected.value = (selected.value - 1 + list.length) % list.length
else if (e.key === 'Enter' && list[selected.value]) open(list[selected.value])
else return
e.preventDefault()
}
onMounted(() => input.value?.focus())
</script>
<template>
<div class="rm-search">
<div class="rm-search-box">
<Icon name="search" />
<input
ref="input"
v-model="query"
class="rm-search-input"
type="search"
placeholder="搜索笔记"
enterkeyhint="search"
@keydown="onKeydown"
/>
<button class="rm-icon-btn" title="关闭搜索" @click="ws.searchOpen = false"><Icon name="close" /></button>
</div>
<ul v-if="results?.length" class="rm-search-results">
<li
v-for="(r, i) in results"
:key="r.path"
class="rm-search-item"
:class="{ active: i === selected, current: r.path === ws.active }"
@click="open(r)"
@mousemove="selected = i"
>
<div class="rm-search-title">
<span v-for="(s, j) in segments(noteTitle(r.path))" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
<span v-if="dirname(r.path)" class="rm-search-dir">{{ dirname(r.path) }}</span>
</div>
<div v-if="r.snippet" class="rm-search-snippet">
<span v-for="(s, j) in segments(r.snippet)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
</div>
</li>
</ul>
<p v-else-if="results" class="rm-tree-empty">没有找到匹配的笔记</p>
<p v-else class="rm-tree-empty">输入关键词,多个词用空格分隔</p>
</div>
</template>
+7 -2
View File
@@ -5,7 +5,8 @@ import { basename } from './paths'
import TrashDialog from './TrashDialog.vue'
import { tree } from './tree'
import TreeNode from './TreeNode.vue'
import { createFolder, createNote, move } from './workspace'
import SearchPanel from './SearchPanel.vue'
import { createFolder, createNote, move, openSearch, ws } from './workspace'
const emit = defineEmits<{ logout: [] }>()
const trashOpen = ref(false)
@@ -30,10 +31,14 @@ function onRootDrop(e: DragEvent) {
<div class="rm-sidebar-inner">
<div class="rm-sidebar-head">
<span class="rm-brand">RawMark</span>
<button class="rm-icon-btn" :class="{ on: ws.searchOpen }" title="搜索(Ctrl+Shift+F)" @click="ws.searchOpen ? (ws.searchOpen = false) : openSearch()">
<Icon name="search" />
</button>
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /></button>
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /></button>
</div>
<div class="rm-tree" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
<SearchPanel v-if="ws.searchOpen" />
<div v-else class="rm-tree" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
<ul class="rm-tree-list">
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
</ul>
+8
View File
@@ -14,6 +14,13 @@ export interface TrashItem {
deletedAt: number
}
export interface SearchResult {
path: string
// 第一处匹配所在行(从 1 开始)及其摘录;只有路径匹配时 line 为 0
line: number
snippet: string
}
export class ApiError extends Error {
constructor(
readonly status: number,
@@ -68,6 +75,7 @@ export const api = {
logout: () => request('POST', '/api/v1/auth/logout'),
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
async readFile(path: string): Promise<{ content: string; rev: string }> {
const res = await request('GET', '/api/v1/files/' + encodePath(path))
+48
View File
@@ -154,6 +154,51 @@ button {
.rm-tree-row:hover .rm-tree-more,
.rm-tree-row.menu-open .rm-tree-more { opacity: 1; }
.rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: 13px; text-align: center; }
/* ---- 搜索 ---- */
.rm-sidebar-head .rm-icon-btn.on { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-search { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.rm-search-box {
display: flex;
align-items: center;
gap: 6px;
margin: 0 10px 8px;
padding: 0 4px 0 8px;
border: 1px solid var(--rm-divider);
border-radius: 6px;
background: var(--rm-bg);
color: var(--rm-text-muted);
}
.rm-search-box:focus-within { border-color: var(--rm-accent); }
.rm-search-input {
flex: 1;
min-width: 0;
height: 30px;
border: 0;
background: none;
color: var(--rm-text);
font: inherit;
font-size: 14px;
outline: none;
}
.rm-search-input::-webkit-search-cancel-button { display: none; }
.rm-search-results { flex: 1; overflow: auto; list-style: none; margin: 0; padding: 0 6px 12px; }
.rm-search-item { padding: 6px 8px; border-radius: 6px; cursor: pointer; }
.rm-search-item.active { background: var(--rm-hover); }
.rm-search-item.current .rm-search-title { color: var(--rm-accent-strong); }
.rm-search-title { display: flex; align-items: baseline; gap: 6px; font-size: 14px; overflow: hidden; white-space: nowrap; }
.rm-search-dir { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--rm-text-muted); font-size: 12px; }
.rm-search-snippet {
margin-top: 2px;
color: var(--rm-text-muted);
font-size: 12px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.rm-search-hit { background: rgba(66, 184, 131, 0.28); color: var(--rm-text); border-radius: 2px; }
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
.rm-foot-btn {
flex: 1;
@@ -344,5 +389,8 @@ button {
.rm-tree-row { height: 40px; font-size: 15px; }
.rm-tree-more { opacity: 1; width: 36px; height: 36px; }
.rm-sidebar-head .rm-icon-btn { width: 40px; height: 40px; }
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
.rm-search-input { height: 40px; font-size: 16px; }
.rm-search-item { padding: 10px 8px; }
.rm-trash-list li { flex-wrap: wrap; }
}
+60 -3
View File
@@ -1,8 +1,8 @@
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
import { Annotation, Transaction, type EditorState } from '@codemirror/state'
import type { EditorView } from '@codemirror/view'
import { reactive, watch } from 'vue'
import { EditorView } from '@codemirror/view'
import { nextTick, reactive, watch } from 'vue'
import { createEditorState, pasteImages } from '../editor'
import { api, ApiError, type Entry } from './api'
import { drafts } from './drafts'
@@ -37,6 +37,8 @@ export const ws = reactive({
renaming: null as string | null,
// 移动端侧栏抽屉
sidebarOpen: false,
// 侧栏显示搜索而非目录树
searchOpen: false,
toasts: [] as Toast[],
})
@@ -277,6 +279,45 @@ 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)) {
ws.sidebarOpen = false
revealPending(view, path)
} else await openNote(path)
}
/** 编辑器面板显示笔记后调用;返回 true 表示已定位(不应再恢复原来的滚动位置) */
export function revealPending(v: EditorView, path: string): boolean {
const r = pendingReveal
if (!r || r.path !== path) return false
pendingReveal = null
const line = v.state.doc.line(Math.min(r.line, v.state.doc.lines))
const text = line.text.toLowerCase()
let at = -1
let len = 0
for (const t of r.terms) {
const i = text.indexOf(t.toLowerCase())
if (i >= 0 && (at < 0 || i < at)) [at, len] = [i, t.length]
}
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
}
// ---- 标签页 ----
export async function openNote(path: string) {
@@ -606,13 +647,29 @@ export async function startWorkspace() {
})
window.addEventListener('pagehide', () => void flushAll(true))
window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
if (!(e.ctrlKey || e.metaKey)) return
const key = e.key.toLowerCase()
if (key === 's' && !e.shiftKey) {
e.preventDefault()
if (ws.active) void flush(ws.active)
} else if (key === 'f' && e.shiftKey) {
e.preventDefault()
openSearch()
}
})
}
export function openSearch() {
ws.searchOpen = true
ws.sidebarOpen = true
// 已打开时重新聚焦并全选,便于直接输入新的搜索词
void nextTick(() => {
const input = document.querySelector<HTMLInputElement>('.rm-search-input')
input?.focus()
input?.select()
})
}
export function stopWorkspace() {
disconnect?.()
disconnect = undefined