diff --git a/README.md b/README.md index a5fe273..720a436 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ docker run -d --name rawmark \ | 环境变量 | 默认值 | 说明 | | --- | --- | --- | -| `RAWMARK_DATA` | `/data` | 数据目录(数据库、默认笔记仓库) | +| `RAWMARK_DATA` | `/data` | 数据目录(数据库、可重建的搜索索引 `index.db`、默认笔记仓库) | | `RAWMARK_VAULT` | `$RAWMARK_DATA/vault` | 笔记仓库目录,可指向已有的笔记文件夹 | | `RAWMARK_ADDR` | `:8080` | 监听地址 | diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go index fe4b05f..ec822c2 100644 --- a/cmd/rawmark/main.go +++ b/cmd/rawmark/main.go @@ -1,6 +1,6 @@ // RawMark 服务入口。配置全部来自环境变量: // -// RAWMARK_DATA 数据目录,默认 /data(存放 rawmark.db 与默认仓库) +// RAWMARK_DATA 数据目录,默认 /data(存放 rawmark.db、可重建的搜索索引 index.db 与默认仓库) // RAWMARK_VAULT 笔记仓库目录,默认 $RAWMARK_DATA/vault;可指向已有的 Typora 笔记目录 // RAWMARK_ADDR 监听地址,默认 :8080 package main @@ -22,6 +22,7 @@ import ( "rawmark/internal/api" "rawmark/internal/auth" "rawmark/internal/events" + "rawmark/internal/search" "rawmark/internal/store" "rawmark/internal/vault" "rawmark/internal/watch" @@ -80,13 +81,20 @@ func run(addr string) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + idx, err := search.Open(filepath.Join(dataDir, "index.db"), v) + if err != nil { + return err + } + defer idx.Close() + go idx.Run(ctx) + hub := events.NewHub() - if err := watch.Start(ctx, v, hub); err != nil { + if err := watch.Start(ctx, v, hub, idx.Update); err != nil { return fmt.Errorf("启动文件监听失败: %w", err) } go cleanupSessions(ctx, st) - srv := api.New(v, am, hub) + srv := api.New(v, am, hub, idx) srv.OnSetup = func() { os.Remove(codeFile) } httpServer := &http.Server{ Addr: addr, diff --git a/internal/api/api.go b/internal/api/api.go index 51a2e8c..3edb024 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -16,6 +16,7 @@ import ( "rawmark/internal/auth" "rawmark/internal/events" + "rawmark/internal/search" "rawmark/internal/vault" ) @@ -30,12 +31,13 @@ type Server struct { vault *vault.Vault auth *auth.Manager hub *events.Hub + index *search.Index // OnSetup 在首次初始化完成后调用(用于删除启动码文件) OnSetup func() } -func New(v *vault.Vault, a *auth.Manager, hub *events.Hub) *Server { - return &Server{vault: v, auth: a, hub: hub} +func New(v *vault.Vault, a *auth.Manager, hub *events.Hub, index *search.Index) *Server { + return &Server{vault: v, auth: a, hub: hub, index: index} } func (s *Server) Handler(static fs.FS) http.Handler { @@ -59,6 +61,7 @@ func (s *Server) Handler(static fs.FS) http.Handler { "POST /api/v1/trash/{id}/restore": s.trashRestore, "DELETE /api/v1/trash/{id}": s.trashPurge, "GET /api/v1/events": s.events, + "GET /api/v1/search": s.search, } for pattern, h := range protected { mux.Handle(pattern, s.requireAuth(h)) @@ -211,6 +214,17 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"entries": entries}) } +const searchLimit = 50 + +func (s *Server) search(w http.ResponseWriter, r *http.Request) { + results, err := s.index.Search(r.URL.Query().Get("q"), searchLimit) + if err != nil { + internalError(w, r, err) + return + } + writeJSON(w, 200, map[string]any{"results": results}) +} + func (s *Server) readFile(w http.ResponseWriter, r *http.Request) { data, rev, err := s.vault.Read(r.PathValue("path")) if err != nil { diff --git a/internal/search/search.go b/internal/search/search.go new file mode 100644 index 0000000..cf9196d --- /dev/null +++ b/internal/search/search.go @@ -0,0 +1,310 @@ +// Package search 维护笔记的全文索引。索引存放在独立的数据库文件中,内容完全由仓库文件派生, +// 删除后会在下次启动时重建;结构变化时也直接重建而不做迁移。 +package search + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "unicode" + "unicode/utf8" + + _ "modernc.org/sqlite" + + "rawmark/internal/vault" +) + +// 修改表结构时递增,旧索引会被丢弃重建 +const schemaVersion = 1 + +const schema = ` +CREATE TABLE docs ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + rev TEXT NOT NULL +); +-- trigram 分词不依赖空格断词,中文可按任意片段检索;不足 3 个字符的词改用 LIKE +CREATE VIRTUAL TABLE docs_fts USING fts5(path, body, tokenize = 'trigram'); +` + +const ( + maxTerms = 8 + snippetRunes = 160 + snippetBefore = 40 +) + +type Result struct { + Path string `json:"path"` + // 第一处匹配所在行(从 1 开始)与该行的摘录;只有文件名匹配时 Line 为 0 + Line int `json:"line"` + Snippet string `json:"snippet"` +} + +type doc struct { + id int64 + rev string +} + +type Index struct { + db *sql.DB + v *vault.Vault + + mu sync.Mutex + pending map[string]string // 待同步的最新快照(路径 -> rev) + wake chan struct{} + + known map[string]doc // 已索引的笔记,仅由同步协程访问 +} + +func Open(path string, v *vault.Vault) (*Index, error) { + db, err := sql.Open("sqlite", "file:"+path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + x := &Index{db: db, v: v, wake: make(chan struct{}, 1), known: map[string]doc{}} + if err := x.init(); err != nil { + db.Close() + return nil, fmt.Errorf("初始化搜索索引失败: %w", err) + } + return x, nil +} + +func (x *Index) init() error { + var version int + if err := x.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { + return err + } + if version != schemaVersion { + stmts := []string{`DROP TABLE IF EXISTS docs`, `DROP TABLE IF EXISTS docs_fts`, schema, fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion)} + for _, s := range stmts { + if _, err := x.db.Exec(s); err != nil { + return err + } + } + } + rows, err := x.db.Query(`SELECT id, path, rev FROM docs`) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var d doc + var p string + if err := rows.Scan(&d.id, &p, &d.rev); err != nil { + return err + } + x.known[p] = d + } + return rows.Err() +} + +func (x *Index) Close() error { return x.db.Close() } + +// Update 提交仓库的最新快照(笔记路径 -> rev),立即返回;同步在后台进行,多次提交只处理最新一次 +func (x *Index) Update(files map[string]string) { + x.mu.Lock() + x.pending = files + x.mu.Unlock() + select { + case x.wake <- struct{}{}: + default: + } +} + +// Run 执行后台同步,直到 ctx 结束 +func (x *Index) Run(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-x.wake: + x.mu.Lock() + files := x.pending + x.mu.Unlock() + if err := x.sync(files); err != nil { + slog.Error("更新搜索索引失败", "err", err) + } + } + } +} + +// sync 让索引与快照一致:只读取新增或 rev 变化的笔记 +func (x *Index) sync(files map[string]string) error { + tx, err := x.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + next := make(map[string]doc, len(x.known)) + for p, d := range x.known { + next[p] = d + } + for p, d := range x.known { + if _, ok := files[p]; !ok { + if err := remove(tx, d.id); err != nil { + return err + } + delete(next, p) + } + } + for p, rev := range files { + old, ok := x.known[p] + if ok && old.rev == rev { + continue + } + content, rev, err := x.v.Read(p) + if errors.Is(err, vault.ErrNotFound) { + continue // 快照之后被删除,下一次快照会处理 + } + if err != nil { + return err + } + if ok { + if err := remove(tx, old.id); err != nil { + return err + } + } + res, err := tx.Exec(`INSERT INTO docs (path, rev) VALUES (?, ?)`, p, rev) + if err != nil { + return err + } + id, err := res.LastInsertId() + if err != nil { + return err + } + if _, err := tx.Exec(`INSERT INTO docs_fts (rowid, path, body) VALUES (?, ?, ?)`, id, p, string(content)); err != nil { + return err + } + next[p] = doc{id: id, rev: rev} + } + if err := tx.Commit(); err != nil { + return err + } + x.known = next + return nil +} + +func remove(tx *sql.Tx, id int64) error { + if _, err := tx.Exec(`DELETE FROM docs WHERE id = ?`, id); err != nil { + return err + } + _, err := tx.Exec(`DELETE FROM docs_fts WHERE rowid = ?`, id) + return err +} + +// Search 查找同时包含所有词(空白分隔,不区分大小写)的笔记,词可出现在路径或正文中。 +// 路径(文件名或目录名)命中第一个词的排在前面,其余按相关度排序 +func (x *Index) Search(query string, limit int) ([]Result, error) { + terms := strings.Fields(query) + if len(terms) == 0 { + return []Result{}, nil + } + if len(terms) > maxTerms { + terms = terms[:maxTerms] + } + var where, phrases []string + var args []any + for _, t := range terms { + if utf8.RuneCountInString(t) >= 3 { + phrases = append(phrases, `"`+strings.ReplaceAll(t, `"`, `""`)+`"`) + } else { + like := "%" + escapeLike(t) + "%" + where = append(where, `(path LIKE ? ESCAPE '\' OR body LIKE ? ESCAPE '\')`) + args = append(args, like, like) + } + } + order := "path" + if len(phrases) > 0 { + where = append(where, `docs_fts MATCH ?`) + args = append(args, strings.Join(phrases, " AND ")) + order = "rank" + } + name := "%" + escapeLike(terms[0]) + "%" + q := `SELECT path, body FROM docs_fts WHERE ` + strings.Join(where, " AND ") + + ` ORDER BY path LIKE ? ESCAPE '\' DESC, ` + order + ` LIMIT ?` + args = append(args, name, limit) + rows, err := x.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + results := []Result{} + for rows.Next() { + var r Result + var body string + if err := rows.Scan(&r.Path, &body); err != nil { + return nil, err + } + r.Line, r.Snippet = snippet(body, terms) + results = append(results, r) + } + return results, rows.Err() +} + +func escapeLike(s string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) +} + +// snippet 返回正文中第一处匹配所在的行号与摘录(过长的行截取匹配附近的一段) +func snippet(body string, terms []string) (int, string) { + folded := make([][]rune, len(terms)) + for i, t := range terms { + folded[i] = fold([]rune(t)) + } + for n, line := range strings.Split(body, "\n") { + runes := []rune(strings.TrimRight(line, "\r")) + lower := fold(runes) + at := -1 + for _, t := range folded { + if i := indexRunes(lower, t); i >= 0 && (at < 0 || i < at) { + at = i + } + } + if at < 0 { + continue + } + start := 0 + if len(runes) > snippetRunes { + start = max(0, min(at-snippetBefore, len(runes)-snippetRunes)) + } + end := min(len(runes), start+snippetRunes) + text := strings.TrimSpace(string(runes[start:end])) + if start > 0 { + text = "…" + text + } + if end < len(runes) { + text += "…" + } + return n + 1, text + } + return 0, "" +} + +// fold 逐字符转小写,保持字符数不变,便于在原文中按位置截取 +func fold(rs []rune) []rune { + out := make([]rune, len(rs)) + for i, r := range rs { + out[i] = unicode.ToLower(r) + } + return out +} + +func indexRunes(s, sub []rune) int { + for i := 0; i+len(sub) <= len(s); i++ { + match := true + for j := range sub { + if s[i+j] != sub[j] { + match = false + break + } + } + if match { + return i + } + } + return -1 +} diff --git a/internal/search/search_test.go b/internal/search/search_test.go new file mode 100644 index 0000000..e12e267 --- /dev/null +++ b/internal/search/search_test.go @@ -0,0 +1,181 @@ +package search + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "rawmark/internal/vault" +) + +type fixture struct { + t *testing.T + v *vault.Vault + idx *Index + db string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + v, err := vault.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + f := &fixture{t: t, v: v, db: filepath.Join(t.TempDir(), "index.db")} + f.reopen() + return f +} + +func (f *fixture) reopen() { + if f.idx != nil { + f.idx.Close() + } + idx, err := Open(f.db, f.v) + if err != nil { + f.t.Fatal(err) + } + f.idx = idx + f.t.Cleanup(func() { idx.Close() }) +} + +func (f *fixture) write(p, content string) { + abs := filepath.Join(f.v.Root(), p) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + f.t.Fatal(err) + } +} + +// sync 模拟监听器提交快照 +func (f *fixture) sync() { + f.t.Helper() + entries, err := f.v.Tree() + if err != nil { + f.t.Fatal(err) + } + files := map[string]string{} + for _, e := range entries { + if !e.Dir { + data, _, err := f.v.Read(e.Path) + if err != nil { + f.t.Fatal(err) + } + files[e.Path] = vault.Rev(data) + } + } + if err := f.idx.sync(files); err != nil { + f.t.Fatal(err) + } +} + +func (f *fixture) paths(q string) string { + f.t.Helper() + rs, err := f.idx.Search(q, 50) + if err != nil { + f.t.Fatalf("搜索 %q: %v", q, err) + } + var out []string + for _, r := range rs { + out = append(out, r.Path) + } + return strings.Join(out, ",") +} + +func TestSearchTerms(t *testing.T) { + f := newFixture(t) + f.write("a.md", "# 标题\n\n今天学习 CodeMirror 的装饰器\n") + f.write("b.md", "第二篇笔记,讲 SQLite 全文检索\n") + f.write("目录/c.md", "装饰器和检索都提到\n") + f.sync() + + cases := map[string]string{ + "装饰器": "a.md,目录/c.md", // 3 个字符以上走 FTS + "检索": "b.md,目录/c.md", // 不足 3 个字符走 LIKE + "codemirror": "a.md", // 不区分大小写 + "装饰器 检索": "目录/c.md", // 多个词同时满足 + "目录": "目录/c.md", // 命中路径 + "不存在的词": "", + "100%": "", + `"引号"`: "", + " ": "", + } + for q, want := range cases { + if got := f.paths(q); got != want && !(strings.Contains(want, ",") && sameSet(got, want)) { + t.Errorf("搜索 %q = %q,期望 %q", q, got, want) + } + } +} + +func sameSet(a, b string) bool { + as, bs := strings.Split(a, ","), strings.Split(b, ",") + if len(as) != len(bs) { + return false + } + seen := map[string]bool{} + for _, s := range as { + seen[s] = true + } + for _, s := range bs { + if !seen[s] { + return false + } + } + return true +} + +func TestSearchRanksNameMatchFirstAndReturnsSnippet(t *testing.T) { + f := newFixture(t) + f.write("其他.md", "第一行\n这里提到部署流程\n") + f.write("部署流程.md", "正文无关\n") + f.write("长行.md", strings.Repeat("前", 300)+"关键字"+strings.Repeat("后", 300)) + f.sync() + + rs, err := f.idx.Search("部署流程", 50) + if err != nil { + t.Fatal(err) + } + if len(rs) != 2 || rs[0].Path != "部署流程.md" || rs[0].Line != 0 { + t.Fatalf("文件名命中应排第一且无正文摘录: %+v", rs) + } + if rs[1].Line != 2 || rs[1].Snippet != "这里提到部署流程" { + t.Fatalf("摘录错误: %+v", rs[1]) + } + + rs, _ = f.idx.Search("关键字", 50) + s := rs[0].Snippet + if !strings.HasPrefix(s, "…") || !strings.HasSuffix(s, "…") || !strings.Contains(s, "关键字") || len([]rune(s)) > snippetRunes+2 { + t.Fatalf("长行摘录应截取匹配附近: %q", s) + } +} + +func TestSyncTracksChanges(t *testing.T) { + f := newFixture(t) + f.write("a.md", "旧内容") + f.write("b.md", "保留") + f.sync() + + f.write("a.md", "新内容") + if err := os.Remove(filepath.Join(f.v.Root(), "b.md")); err != nil { + t.Fatal(err) + } + f.write("c.md", "新增") + f.sync() + for q, want := range map[string]string{"旧内容": "", "新内容": "a.md", "保留": "", "新增": "c.md"} { + if got := f.paths(q); got != want { + t.Errorf("搜索 %q = %q,期望 %q", q, got, want) + } + } + + // 重启后沿用已有索引,只处理变化 + f.reopen() + if len(f.idx.known) != 2 { + t.Fatalf("重启后应载入已索引笔记: %v", f.idx.known) + } + f.sync() + if got := f.paths("新内容"); got != "a.md" { + t.Errorf("重启后搜索 = %q", got) + } +} diff --git a/internal/watch/watch.go b/internal/watch/watch.go index 43fdf7d..334cb7a 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -34,20 +34,23 @@ type Watcher struct { fw *fsnotify.Watcher snap map[string]fileState watched map[string]bool + onScan func(notes map[string]string) } -func Start(ctx context.Context, v *vault.Vault, hub *events.Hub) error { +// Start 开始监听。onScan 在首次扫描及之后每次有变化的扫描后调用,参数为全部笔记的路径 -> rev(用于更新搜索索引) +func Start(ctx context.Context, v *vault.Vault, hub *events.Hub, onScan func(notes map[string]string)) error { fw, err := fsnotify.NewWatcher() if err != nil { return err } - w := &Watcher{v: v, hub: hub, fw: fw, snap: map[string]fileState{}, watched: map[string]bool{}} + w := &Watcher{v: v, hub: hub, fw: fw, snap: map[string]fileState{}, watched: map[string]bool{}, onScan: onScan} snap, err := w.scan() if err != nil { fw.Close() return err } w.snap = snap + w.onScan(notes(snap)) go w.loop(ctx) return nil } @@ -90,9 +93,22 @@ func (w *Watcher) rescan() { } evs := diff(w.snap, next) w.snap = next + if len(evs) > 0 { + w.onScan(notes(next)) + } w.hub.Broadcast(evs...) } +func notes(snap map[string]fileState) map[string]string { + out := make(map[string]string, len(snap)) + for p, st := range snap { + if !st.dir { + out[p] = st.rev + } + } + return out +} + // scan 读取当前目录树;内容版本只对新出现或大小/修改时间变化的文件重新计算 func (w *Watcher) scan() (map[string]fileState, error) { entries, err := w.v.Tree() diff --git a/web/src/app/EditorPane.vue b/web/src/app/EditorPane.vue index 6d1194d..b548114 100644 --- a/web/src/app/EditorPane.vue +++ b/web/src/app/EditorPane.vue @@ -1,16 +1,23 @@ diff --git a/web/src/app/SearchPanel.vue b/web/src/app/SearchPanel.vue new file mode 100644 index 0000000..c86622d --- /dev/null +++ b/web/src/app/SearchPanel.vue @@ -0,0 +1,112 @@ + + + diff --git a/web/src/app/Sidebar.vue b/web/src/app/Sidebar.vue index e3e0780..7fb48a7 100644 --- a/web/src/app/Sidebar.vue +++ b/web/src/app/Sidebar.vue @@ -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) {
RawMark +
-
+ +
diff --git a/web/src/app/api.ts b/web/src/app/api.ts index 6989af5..a0a6430 100644 --- a/web/src/app/api.ts +++ b/web/src/app/api.ts @@ -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)) diff --git a/web/src/app/app.css b/web/src/app/app.css index e675a5f..f3a0bfc 100644 --- a/web/src/app/app.css +++ b/web/src/app/app.css @@ -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; } } diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index 414920f..02493d5 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -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('.rm-search-input') + input?.focus() + input?.select() + }) +} + export function stopWorkspace() { disconnect?.() disconnect = undefined