Browse Source

feat(tauri): hybrid search, deterministic block IDs, read-only blocks

- Rust compute_embedding + rank_by_similarity commands (fastembed)
- FTS5 + vector search with RRF merge in TypeScript composable
- Right sidebar search tab with debounced input and result cards
- Block highlight animation and scroll-to-block navigation
- Deterministic content-based block IDs via MurmurHash3
- EditorBlock readonly prop for rendering (skips editing plugins)
- Schema: embedding BLOB + embedding_hash columns with migration
- Tests: 17 frontend search tests, 12 Rust cosine/ranking tests
Zander Hawke 3 giờ trước cách đây
mục cha
commit
80f65a6292

+ 0 - 2
apps/tauri/nuxt.config.ts

@@ -1,5 +1,3 @@
-import { resolve } from "node:path"
-
 export default defineNuxtConfig({
   ssr: false,
   spaLoadingTemplate: true,

+ 1 - 1
apps/tauri/package.json

@@ -9,7 +9,7 @@
     "generate": "nuxt generate",
     "preview": "nuxt preview",
     "tauri": "tauri",
-    "test": "vitest run"
+    "test": "vitest run && cargo test -p [email protected] --manifest-path src-tauri/Cargo.toml --lib"
   },
   "dependencies": {
     "@enesis/editor": "workspace:*",

+ 3 - 0
apps/tauri/src-tauri/.gitignore

@@ -5,3 +5,6 @@
 # Generated by Tauri
 # will have schema files for capabilities auto-completion
 /gen/schemas
+
+# fastembed model cache (downloaded on first search, ~23 MB)
+.fastembed_cache/

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 644 - 11
apps/tauri/src-tauri/Cargo.lock


+ 3 - 0
apps/tauri/src-tauri/Cargo.toml

@@ -27,4 +27,7 @@ serde_json = "1"
 tauri-plugin-fs = "2"
 tauri-plugin-sql = { version = "2", features = ["sqlite"] }
 notify = "8.2.0"
+tokio = { version = "1.52.3", features = ["rt"] }
+fastembed = "5.17.2"
+rayon = "1.12.0"
 

+ 4 - 13
apps/tauri/src-tauri/src/lib.rs

@@ -1,3 +1,5 @@
+mod search;
+
 use std::fs;
 use std::path::Path;
 use std::sync::atomic::{AtomicBool, Ordering};
@@ -57,24 +59,12 @@ struct FsEvent {
 }
 
 /// Exit the application process.
-///
-/// Uses `std::process::exit(0)` rather than Tauri's graceful `app.exit()` /
-/// `app.terminate()` to avoid depending on `tauri::AppHandle` in the command
-/// signature (which complicates testing). The downside is that `process::exit`
-/// bypasses `applicationWillTerminate` on macOS, so any in-flight `fs::write`
-/// or pending auto-saves are dropped. This is acceptable for a notes app since
-/// auto-save is 500ms-debounced and the write is fast; worst case, a single
-/// keystroke's worth of content is lost.
 #[tauri::command]
 fn exit_app() {
     std::process::exit(0)
 }
 
 /// Start a recursive file watcher on the workspace directory.
-///
-/// Emits `fs-watch:change` events to the frontend for `.md` file
-/// create/modify/delete events. The frontend should debounce and
-/// re-index only the changed file. The watcher runs until the app exits.
 #[tauri::command]
 fn start_watcher(app: tauri::AppHandle, workspace_path: String) -> Result<(), String> {
     let running = Arc::new(AtomicBool::new(true));
@@ -111,7 +101,6 @@ fn start_watcher(app: tauri::AppHandle, workspace_path: String) -> Result<(), St
         .watch(Path::new(&workspace_path), RecursiveMode::Recursive)
         .map_err(|e| e.to_string())?;
 
-    // Keep the watcher alive by parking the thread
     thread::spawn(move || {
         while running_clone.load(Ordering::Relaxed) {
             thread::sleep(std::time::Duration::from_secs(1));
@@ -136,6 +125,8 @@ pub fn run() {
             create_directory,
             start_watcher,
             exit_app,
+            search::compute_embedding,
+            search::rank_by_similarity,
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");

+ 200 - 0
apps/tauri/src-tauri/src/search.rs

@@ -0,0 +1,200 @@
+use serde::Serialize;
+use std::sync::{Arc, Mutex, OnceLock};
+use std::cmp::Ordering;
+use tauri::Emitter;
+
+static MODEL: OnceLock<Arc<Mutex<fastembed::TextEmbedding>>> = OnceLock::new();
+
+/// Compute a single embedding vector for the given text.
+#[tauri::command]
+pub async fn compute_embedding(
+    app: tauri::AppHandle,
+    text: String,
+) -> Result<Vec<f32>, String> {
+    // Lazy init via OnceLock — guarantees single initialization, lock-free reads
+    let model = match MODEL.get() {
+        Some(m) => m.clone(),
+        None => {
+            let _ = app.emit("model-loading", serde_json::json!({"status": "loading"}));
+            let m = initialize_model().await?;
+            let _ = app.emit("model-ready", serde_json::json!({"status": "ready"}));
+            let m = Arc::new(Mutex::new(m));
+            MODEL.set(m.clone()).map_err(|_| "model already initialized".to_string())?;
+            m
+        }
+    };
+
+    let result = tokio::task::spawn_blocking(move || -> Result<Vec<f32>, String> {
+        let mut model = model.lock().map_err(|e| e.to_string())?;
+        let embeddings = model.embed(vec![text], None).map_err(|e| e.to_string())?;
+        embeddings
+            .into_iter()
+            .next()
+            .ok_or_else(|| "no embedding returned".to_string())
+    })
+    .await
+    .map_err(|e| e.to_string())?
+    .map_err(|e| e.to_string())?;
+
+    Ok(result)
+}
+
+/// Rank candidates by cosine similarity against a query vector.
+#[tauri::command]
+pub fn rank_by_similarity(
+    query: Vec<f32>,
+    candidates: Vec<(String, Vec<f32>)>,
+) -> Vec<RankedCandidate> {
+    let mut scored: Vec<RankedCandidate> = candidates
+        .into_iter()
+        .map(|(id, emb)| {
+            let score = cosine_similarity(&query, &emb);
+            RankedCandidate { block_id: id, score }
+        })
+        .collect();
+    scored.sort_by(|a, b| {
+        b.score
+            .partial_cmp(&a.score)
+            .unwrap_or(Ordering::Equal)
+    });
+    scored
+}
+
+#[derive(Debug, Serialize)]
+pub struct RankedCandidate {
+    pub block_id: String,
+    pub score: f32,
+}
+
+pub(crate) fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
+    // Guard against dimension mismatch — silent truncation with zip() is dangerous
+    if a.len() != b.len() || a.is_empty() {
+        return 0.0;
+    }
+
+    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
+    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
+    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
+    if norm_a == 0.0 || norm_b == 0.0 {
+        0.0
+    } else {
+        dot / (norm_a * norm_b)
+    }
+}
+
+async fn initialize_model() -> Result<fastembed::TextEmbedding, String> {
+    tokio::task::spawn_blocking(|| {
+        fastembed::TextEmbedding::try_new(
+            fastembed::TextInitOptions::new(fastembed::EmbeddingModel::AllMiniLML6V2),
+        )
+        .map_err(|e| format!("failed to init embedding model: {}", e))
+    })
+    .await
+    .map_err(|e| e.to_string())?
+    .map_err(|e| e.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // ── cosine_similarity unit tests ───────────────────────────
+
+    #[test]
+    fn identical_vectors_score_one() {
+        let v = vec![1.0, 2.0, 3.0, 4.0];
+        let score = cosine_similarity(&v, &v);
+        assert!((score - 1.0).abs() < 1e-6, "expected 1.0, got {}", score);
+    }
+
+    #[test]
+    fn orthogonal_vectors_score_zero() {
+        let score = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]);
+        assert!((score - 0.0).abs() < 1e-6, "expected 0.0, got {}", score);
+    }
+
+    #[test]
+    fn opposite_vectors_score_negative_one() {
+        let score = cosine_similarity(&[1.0, 0.0], &[-1.0, 0.0]);
+        assert!((score - (-1.0)).abs() < 1e-6, "expected -1.0, got {}", score);
+    }
+
+    #[test]
+    fn zero_query_vector_returns_zero() {
+        let score = cosine_similarity(&[0.0, 0.0, 0.0], &[1.0, 2.0, 3.0]);
+        assert_eq!(score, 0.0);
+    }
+
+    #[test]
+    fn zero_candidate_vector_returns_zero() {
+        let score = cosine_similarity(&[1.0, 2.0, 3.0], &[0.0, 0.0, 0.0]);
+        assert_eq!(score, 0.0);
+    }
+
+    #[test]
+    fn parallel_vectors_score_one() {
+        let v1 = vec![2.0, 4.0, 6.0];
+        let v2 = vec![1.0, 2.0, 3.0];
+        let score = cosine_similarity(&v1, &v2);
+        assert!((score - 1.0).abs() < 1e-6, "expected 1.0, got {}", score);
+    }
+
+    #[test]
+    fn mismatched_dimensions_returns_zero() {
+        let score = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0, 5.0]);
+        assert_eq!(score, 0.0, "mismatched dims should return 0.0");
+    }
+
+    #[test]
+    fn empty_vectors_returns_zero() {
+        let score = cosine_similarity(&[], &[]);
+        assert_eq!(score, 0.0);
+    }
+
+    // ── rank_by_similarity integration tests ───────────────────
+
+    #[test]
+    fn ranks_highest_similarity_first() {
+        let query = vec![1.0, 0.0, 0.0];
+        let candidates = vec![
+            ("far".into(), vec![0.0, 1.0, 0.0]),
+            ("close".into(), vec![0.9, 0.0, 0.1]),
+            ("middle".into(), vec![0.5, 0.5, 0.0]),
+        ];
+
+        let result = rank_by_similarity(query, candidates);
+        assert_eq!(result.len(), 3);
+        assert_eq!(result[0].block_id, "close");
+        assert_eq!(result[1].block_id, "middle");
+        assert_eq!(result[2].block_id, "far");
+    }
+
+    #[test]
+    fn empty_candidates_returns_empty() {
+        let result = rank_by_similarity(vec![1.0, 0.0], vec![]);
+        assert!(result.is_empty());
+    }
+
+    #[test]
+    fn single_candidate_scores_one() {
+        let result = rank_by_similarity(
+            vec![1.0, 0.0],
+            vec![("only".into(), vec![1.0, 0.0])],
+        );
+        assert_eq!(result.len(), 1);
+        assert!((result[0].score - 1.0).abs() < 1e-6);
+    }
+
+    // ── RankedCandidate serialization ──────────────────────────
+
+    #[test]
+    fn ranked_candidate_serializes() {
+        let c = RankedCandidate {
+            block_id: "abc".into(),
+            score: 0.75,
+        };
+        let json = serde_json::to_string(&c).unwrap();
+        assert!(json.contains(r#""block_id":"abc""#));
+        assert!(json.contains(r#""score":0.75"#));
+    }
+}

+ 11 - 0
apps/tauri/src/assets/main.css

@@ -27,3 +27,14 @@
 }
 
 html { background-color: oklch(21.6% 0.006 56.043); }
+
+/* Block highlight animation — gentle pulse that fades away */
+.block-highlight {
+  animation: block-flash 1.2s ease-out forwards;
+}
+
+@keyframes block-flash {
+  0%   { background-color: transparent; }
+  20%  { background-color: color-mix(in srgb, var(--ui-primary) 12%, transparent); }
+  100% { background-color: transparent; }
+}

+ 146 - 20
apps/tauri/src/components/AppRightSidebar.vue

@@ -1,13 +1,52 @@
 <script setup lang="ts">
-import { ref } from "vue"
+import { useDebounceFn } from '@vueuse/core'
+import { useSearch, type SearchResult } from '~/composables/useSearch'
+import { usePageNavigation } from '~/composables/usePageNavigation'
+import { EditorBlock } from '@enesis/editor'
 
-const open = defineModel<boolean>("open", { required: true })
+const open = defineModel<boolean>('open', { required: true })
+const { navigateToBlock } = usePageNavigation()
 
-const tabs = ref([
-  { label: "References", icon: "i-lucide-link", value: "refs" },
-  { label: "Search", icon: "i-lucide-search", value: "search" },
-])
-const activeTab = ref("refs")
+const props = defineProps<{
+  searchTrigger?: number
+}>()
+
+const tabs = [
+  { label: 'References', icon: 'i-lucide-link', value: 'refs' },
+  { label: 'Search', icon: 'i-lucide-search', value: 'search' },
+]
+const activeTab = ref('refs')
+
+watch(() => props.searchTrigger, () => {
+  activeTab.value = 'search'
+})
+
+const { searchBlocks, isModelLoading, isSearching } = useSearch()
+
+const searchQuery = ref('')
+const results = ref<SearchResult[]>([])
+
+const debouncedSearch = useDebounceFn(async (query: string) => {
+  if (!query.trim()) {
+    results.value = []
+    return
+  }
+  results.value = await searchBlocks(query)
+}, 200)
+
+watch(searchQuery, q => debouncedSearch(q))
+
+// Map source type to design-system badge styles
+const sourceBadge: Record<string, { label: string; class: string }> = {
+  vector:  { label: 'semantic', class: 'bg-(--ui-primary)/10 text-(--ui-primary)' },
+  keyword: { label: 'keyword',  class: 'bg-(--ui-border-accented) text-muted' },
+  hybrid:  { label: 'hybrid',   class: 'bg-(--ui-secondary)/10 text-(--ui-secondary)' },
+}
+
+function handleResultClick(r: SearchResult) {
+  open.value = false
+  navigateToBlock(r.blockId)
+}
 </script>
 
 <template>
@@ -15,13 +54,14 @@ const activeTab = ref("refs")
     v-model:open="open"
     collapsible="offcanvas"
     side="right"
-    :ui="{ width: 'w-80' }"
+    :ui="{ body: 'p-0' }"
     rail
   >
     <template #header>
       <UTabs
         v-model="activeTab"
         :items="tabs"
+        :content="false"
         size="sm"
         color="neutral"
         variant="link"
@@ -29,20 +69,106 @@ const activeTab = ref("refs")
       />
     </template>
 
-    <div v-if="activeTab === 'refs'" class="p-3 text-sm text-muted">
-      <p>References will appear here once you start linking pages.</p>
+    <!-- References tab -->
+    <div v-if="activeTab === 'refs'" class="flex flex-col items-center justify-center h-full px-6 py-16 text-center gap-3">
+      <div class="w-10 h-10 rounded-xl bg-(--ui-primary)/10 flex items-center justify-center">
+        <UIcon name="i-lucide-link" class="w-5 h-5 text-(--ui-primary)" />
+      </div>
+      <p class="text-sm font-medium text-default">No references yet</p>
+      <p class="text-xs text-muted leading-relaxed">
+        Link to this page from your notes using <span class="font-mono text-xs bg-elevated px-1 py-0.5 rounded">[[page name]]</span> and references will appear here.
+      </p>
     </div>
 
-    <div v-if="activeTab === 'search'" class="p-3">
-      <UInput
-        placeholder="Search pages..."
-        size="sm"
-        color="neutral"
-        variant="outline"
-      />
-      <p class="mt-3 text-sm text-muted">
-        Search results appear here.
-      </p>
+    <!-- Search tab -->
+    <div v-else-if="activeTab === 'search'" class="flex flex-col h-full">
+
+      <!-- Input -->
+      <div class="shrink-0 p-3 pb-2">
+        <UInput
+          v-model="searchQuery"
+          icon="i-lucide-search"
+          placeholder="Search notes…"
+          size="sm"
+          color="neutral"
+          :loading="isSearching"
+          autofocus
+          class="w-full"
+        />
+      </div>
+
+      <!-- Model loading notice -->
+      <div v-if="isModelLoading" class="shrink-0 mx-3 mb-2 px-3 py-2.5 rounded-lg bg-(--ui-primary)/5 border border-(--ui-primary)/15">
+        <div class="flex items-center gap-2 mb-1.5">
+          <UIcon name="i-lucide-loader" class="w-3.5 h-3.5 text-(--ui-primary) animate-spin shrink-0" />
+          <span class="text-xs font-medium text-(--ui-primary)">Warming up search…</span>
+        </div>
+        <UProgress size="xs" color="primary" />
+      </div>
+
+      <!-- Results area -->
+      <div class="flex-1 overflow-y-auto px-3 pb-3 space-y-1.5">
+
+        <!-- Idle state -->
+        <div v-if="!searchQuery.trim()" class="flex flex-col items-center justify-center py-16 gap-2 text-center">
+          <UIcon name="i-lucide-search" class="w-6 h-6 text-muted/40" />
+          <p class="text-sm text-muted">Type to search your notes</p>
+        </div>
+
+        <!-- No results -->
+        <div
+          v-else-if="results.length === 0 && !isSearching"
+          class="flex flex-col items-center justify-center py-16 gap-2 text-center"
+        >
+          <UIcon name="i-lucide-file-search" class="w-6 h-6 text-muted/40" />
+          <p class="text-sm text-muted">
+            Nothing found for <span class="text-default italic">"{{ searchQuery }}"</span>
+          </p>
+        </div>
+
+        <!-- Results -->
+        <template v-else>
+          <button
+            v-for="r in results"
+            :key="r.blockId"
+            @click="handleResultClick(r)"
+            class="group w-full text-left rounded-lg border border-default
+                   hover:border-(--ui-primary)/30 hover:bg-elevated
+                   transition-all duration-150 p-3
+                   focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--ui-primary)"
+          >
+            <!-- Page title + source badge -->
+            <div class="flex items-center justify-between gap-2 mb-1.5">
+              <span class="text-xs font-medium text-muted truncate flex items-center gap-1.5">
+                <UIcon name="i-lucide-file-text" class="w-3 h-3 shrink-0" />
+                {{ r.pageTitle }}
+              </span>
+              <span
+                class="shrink-0 text-[10px] font-medium uppercase tracking-wider rounded px-1.5 py-px"
+                :class="sourceBadge[r.source]?.class ?? 'bg-elevated text-muted'"
+              >
+                {{ sourceBadge[r.source]?.label ?? r.source }}
+              </span>
+            </div>
+
+            <!-- Block rendering (read-only ProseMirror with full markdown decorators) -->
+            <div class="text-sm leading-relaxed text-default max-h-24 overflow-hidden">
+              <EditorBlock
+                :content="r.content"
+                :readonly="true"
+                marker-mode="always-visible"
+              />
+            </div>
+
+            <!-- Path -->
+            <p class="mt-2 text-[11px] text-muted/50 font-mono truncate
+                       opacity-0 group-hover:opacity-100 transition-opacity duration-150">
+              {{ r.pagePath }}
+            </p>
+          </button>
+        </template>
+
+      </div>
     </div>
   </USidebar>
 </template>

+ 2 - 1
apps/tauri/src/components/PageView.vue

@@ -44,6 +44,7 @@ const props = defineProps<{
   filename: string
   content: string
   backlinks: Backlink[]
+  targetBlockId?: string | null
 }>()
 
 const emit = defineEmits<{
@@ -125,7 +126,7 @@ function formatExcerpt(excerpt: string): string {
         v-model:content="localContent"
         :mention-data="mentionData"
         marker-mode="live-preview"
-        focused="first-zone"
+        :focused="targetBlockId ? { kind: 'block', id: targetBlockId } : 'first-zone'"
         @page-ref-clicked="({ pageName, alias }) => navigateToPage(pageName)"
         @block-ref-clicked="({ blockId }) => navigateToBlock(blockId)"
         @tag-clicked="({ tag }) => navigateToTag(tag)"

+ 50 - 0
apps/tauri/src/composables/useBlockHighlight.ts

@@ -0,0 +1,50 @@
+/**
+ * Highlight a block by ID — scrolls it into view and plays a
+ * gentle background-colour pulse that fades away.
+ *
+ * If the block element isn't in the DOM yet (e.g. the editor is still
+ * rendering), polls at 50ms intervals until it appears.
+ */
+function waitForBlock(blockId: string, timeout = 5000): Promise<HTMLElement | null> {
+  return new Promise((resolve) => {
+    const selector = `[data-block-id="${blockId}"]`
+
+    const el = document.querySelector<HTMLElement>(selector)
+    if (el) return resolve(el)
+
+    const start = Date.now()
+    const interval = setInterval(() => {
+      const found = document.querySelector<HTMLElement>(selector)
+      if (found) {
+        clearInterval(interval)
+        resolve(found)
+      } else if (Date.now() - start > timeout) {
+        clearInterval(interval)
+        resolve(null)
+      }
+    }, 50)
+  })
+}
+
+export function useBlockHighlight() {
+  async function highlight(blockId: string) {
+    const el = await waitForBlock(blockId)
+    if (!el) {
+      console.warn("[highlight] block not found in DOM:", blockId)
+      return
+    }
+    doHighlight(el)
+  }
+
+  return { highlight }
+}
+
+function doHighlight(el: HTMLElement) {
+  el.scrollIntoView({ behavior: "smooth", block: "center" })
+
+  // Let the smooth scroll settle before showing the flash
+  setTimeout(() => {
+    el.classList.add("block-highlight")
+    setTimeout(() => el.classList.remove("block-highlight"), 1200)
+  }, 400)
+}

+ 218 - 0
apps/tauri/src/composables/useSearch.ts

@@ -0,0 +1,218 @@
+/**
+ * Hybrid search composable (FTS5 + vector).
+ *
+ * Data flow:
+ * 1. Compute query embedding via Rust (fastembed)
+ * 2. Fetch all block embeddings from SQLite
+ * 3. Rank via Rust cosine similarity
+ * 4. Run FTS5 keyword query via plugin-sql
+ * 5. Merge both result sets via RRF (k=60)
+ * 6. Enrich with page titles
+ */
+import { invoke } from "@tauri-apps/api/core"
+import { listen } from "@tauri-apps/api/event"
+import { useWorkspaceStore } from "~/stores/workspace"
+
+const RRF_K = 60
+
+export interface SearchResult {
+  blockId: string
+  pagePath: string
+  pageTitle: string
+  content: string
+  excerpt: string
+  score: number
+  source: "vector" | "keyword" | "hybrid"
+}
+
+export interface EmbeddingProgress {
+  indexed: number
+  total: number
+  blockId: string
+  pagePath: string
+  done: boolean
+}
+
+function decodeEmbedding(blob: ArrayBuffer): number[] {
+  return Array.from(new Float32Array(blob))
+}
+
+// TODO: scalability — this loads ALL embeddings into memory on every search.
+// At 100k+ blocks, move the full-table scan into Rust memory and search
+// there directly instead of shipping every vector through IPC.
+
+export function useSearch() {
+  const ws = useWorkspaceStore()
+  const isModelLoading = ref(false)
+  const isSearching = ref(false)
+
+  const unlisteners: (() => void)[] = []
+
+  listen<{ status: string }>("model-loading", () => {
+    isModelLoading.value = true
+  }).then((fn) => unlisteners.push(fn))
+
+  listen<{ status: string }>("model-ready", () => {
+    isModelLoading.value = false
+  }).then((fn) => unlisteners.push(fn))
+
+  onUnmounted(() => {
+    for (const fn of unlisteners) fn()
+  })
+
+  async function searchBlocks(query: string): Promise<SearchResult[]> {
+    if (!query.trim() || !ws.workspacePath || !ws.db) return []
+
+    isSearching.value = true
+    try {
+      // 1. Always run FTS5 keyword search
+      const ftsQuery = query
+        .trim()
+        .split(/\s+/)
+        .map((w) => `"${w.replace(/"/g, "")}"*`)
+        .join(" ")
+
+      const ftsRows = await ws.db.select<{ block_id: string }[]>(
+        `SELECT b.id as block_id
+         FROM blocks_fts f
+         JOIN blocks b ON b.rowid = f.rowid
+         WHERE blocks_fts MATCH ?
+         ORDER BY rank
+         LIMIT 20`,
+        [ftsQuery],
+      )
+
+      // 2. Optionally run vector search (gracefully degrade if model unavailable)
+      let ranked: { block_id: string; score: number }[] = []
+      try {
+        const queryVec = await invoke<number[]>("compute_embedding", {
+          text: query.trim(),
+        })
+        if (queryVec && queryVec.length > 0) {
+          const embRows = await ws.db.select<{ id: string; embedding: ArrayBuffer }[]>(
+            "SELECT id, embedding FROM blocks WHERE embedding IS NOT NULL",
+          )
+          const candidates: [string, number[]][] = embRows
+            .filter((r) => r.embedding)
+            .map((r) => [r.id, decodeEmbedding(r.embedding)])
+          if (candidates.length > 0) {
+            ranked = await invoke("rank_by_similarity", {
+              query: queryVec,
+              candidates,
+            })
+          }
+        }
+      } catch (_) {
+        // Vector search unavailable — falling back to keyword only
+      }
+
+      // 3. Merge via RRF
+      const rankMap = new Map<string, { annRank?: number; ftsRank?: number }>()
+
+      ranked.forEach((r, i) => {
+        const entry = rankMap.get(r.block_id) ?? {}
+        entry.annRank = i + 1
+        rankMap.set(r.block_id, entry)
+      })
+
+      ftsRows.forEach((r, i) => {
+        const entry = rankMap.get(r.block_id) ?? {}
+        entry.ftsRank = i + 1
+        rankMap.set(r.block_id, entry)
+      })
+
+      const merged = [...rankMap.entries()]
+        .map(([blockId, ranks]) => {
+          let score = 0
+          let sources: string[] = []
+          if (ranks.annRank) {
+            score += 1 / (RRF_K + ranks.annRank)
+            sources.push("vector")
+          }
+          if (ranks.ftsRank) {
+            score += 1 / (RRF_K + ranks.ftsRank)
+            sources.push("keyword")
+          }
+          const source = (sources.length === 2 ? "hybrid" : sources[0] ?? "keyword") as SearchResult["source"]
+          return { blockId, score, source }
+        })
+        .sort((a, b) => b.score - a.score)
+        .slice(0, 20)
+
+      if (merged.length === 0) return []
+
+      // 4. Enrich — fetch block content for all merged IDs
+      const allIds = merged.map((r) => r.blockId)
+      const placeholders = allIds.map(() => "?").join(",")
+      const contentRows = await ws.db.select<
+        { id: string; content: string; page_path: string }[]
+      >(
+        `SELECT id, content, page_path FROM blocks WHERE id IN (${placeholders})`,
+        allIds,
+      )
+      const contentMap = new Map(contentRows.map((r) => [r.id, r.content]))
+      const pagePathMap = new Map(contentRows.map((r) => [r.id, r.page_path]))
+      const uniquePaths = [...new Set(contentRows.map((r) => r.page_path))]
+
+      // Fetch page titles
+      let titleMap = new Map<string, string>()
+      if (uniquePaths.length > 0) {
+        const pathPlaceholders = uniquePaths.map(() => "?").join(",")
+        const titleRows = await ws.db.select<{ path: string; title: string }[]>(
+          `SELECT path, title FROM pages WHERE path IN (${pathPlaceholders})`,
+          uniquePaths,
+        )
+        for (const t of titleRows) {
+          titleMap.set(t.path, t.title || t.path.split("/").pop()?.replace(/\.md$/, "") || t.path)
+        }
+      }
+
+      return merged.map(({ blockId, score, source }) => {
+        const content = contentMap.get(blockId) ?? ""
+        const pagePath = pagePathMap.get(blockId) ?? ""
+        const pageTitle = titleMap.get(pagePath) ?? pagePath.split("/").pop()?.replace(/\.md$/, "") ?? pagePath
+        return {
+          blockId,
+          pagePath,
+          pageTitle,
+          content,
+          excerpt: buildExcerpt(content, query),
+          score,
+          source,
+        }
+      })
+    } catch (e) {
+      console.warn("[useSearch] search failed:", e)
+      return []
+    } finally {
+      isSearching.value = false
+    }
+  }
+
+  return {
+    searchBlocks,
+    isModelLoading,
+    isSearching,
+  }
+}
+
+function buildExcerpt(content: string, query: string): string {
+  const lowerContent = content.toLowerCase()
+  const terms = query.split(/\s+/)
+  const pos = terms
+    .map((t) => lowerContent.indexOf(t.toLowerCase()))
+    .filter((p) => p >= 0)
+    .sort((a, b) => a - b)[0]
+
+  if (pos === undefined) return content.slice(0, 120) + (content.length > 120 ? "…" : "")
+
+  const start = Math.max(0, pos - 60)
+  const end = Math.min(content.length, pos + 60)
+  const excerpt = content.slice(start, end)
+
+  let result = ""
+  if (start > 0) result += "…"
+  result += excerpt
+  if (end < content.length) result += "…"
+  return result
+}

+ 9 - 2
apps/tauri/src/layouts/default.vue

@@ -15,11 +15,18 @@ const { lastDays, getToday } = useDates()
 const { densityMap } = useTimelineDensity()
 const { pages, tags } = useSidebarIndex()
 
+const searchTrigger = ref(0)
+
 function handleSelectDate(date: string) {
   ui.setActiveDate(date)
   router.push(`/?date=${date}`)
 }
 
+function handleSearch() {
+  searchTrigger.value++
+  ui.rightSidebarOpen = true
+}
+
 const dateKeys = lastDays(14)
 const today = getToday()
 </script>
@@ -35,7 +42,7 @@ const today = getToday()
       :pages="pages"
       :tags="tags"
       @select-date="handleSelectDate"
-      @search="console.log('search')"
+      @search="handleSearch"
       @page-clicked="navigateToPage($event)"
       @tag-clicked="navigateToTag($event)"
     />
@@ -44,6 +51,6 @@ const today = getToday()
       <slot />
     </UMain>
 
-    <AppRightSidebar v-model:open="ui.rightSidebarOpen" />
+    <AppRightSidebar v-model:open="ui.rightSidebarOpen" :search-trigger="searchTrigger" />
   </div>
 </template>

+ 3 - 2
apps/tauri/src/lib/indexer.test.ts

@@ -11,13 +11,14 @@ import {
 
 // Mock @enesis/editor — its dependency chain pulls in Vue files that vitest
 // can't parse without a Vue plugin.
+let blockIdSeed = 0
 vi.mock("@enesis/editor", () => ({
   splitMarkdownIntoBlocks: (content: string) =>
     content
       .split("\n")
       .filter(Boolean)
-      .map((line, i) => ({
-        id: `test-block-${i}`,
+      .map((line) => ({
+        id: `test-block-${++blockIdSeed}`,
         content: line,
         depth: 0,
       })),

+ 17 - 0
apps/tauri/src/lib/indexer.ts

@@ -41,6 +41,7 @@ export async function initIndex(dbPath: string): Promise<Database> {
   const db = await Database.load(`sqlite:${dbPath}`)
   await db.execute(SCHEMA_SQL)
   await migrateContentHash(db)
+  await migrateEmbeddingHash(db)
   return db
 }
 
@@ -60,6 +61,22 @@ async function migrateContentHash(db: Database): Promise<void> {
   }
 }
 
+async function migrateEmbeddingHash(db: Database): Promise<void> {
+  const cols = await db.select<{ name: string }[]>(
+    "SELECT name FROM pragma_table_info('blocks')",
+  )
+  if (!cols.some((c) => c.name === "embedding_hash")) {
+    await db.execute(
+      "ALTER TABLE blocks ADD COLUMN embedding_hash TEXT NOT NULL DEFAULT ''",
+    )
+  }
+  if (!cols.some((c) => c.name === "embedding")) {
+    await db.execute(
+      "ALTER TABLE blocks ADD COLUMN embedding BLOB",
+    )
+  }
+}
+
 /**
  * Index a single markdown file into the database.
  *

+ 2 - 0
apps/tauri/src/lib/schema.ts

@@ -22,6 +22,8 @@ CREATE TABLE IF NOT EXISTS blocks (
     )),
     has_id_stamp INTEGER NOT NULL DEFAULT 0,
     content_hash TEXT NOT NULL DEFAULT '',
+    embedding BLOB,
+    embedding_hash TEXT NOT NULL DEFAULT '',
     modified_at INTEGER NOT NULL
 );
 

+ 327 - 0
apps/tauri/src/lib/search.test.ts

@@ -0,0 +1,327 @@
+import Database from "better-sqlite3"
+import {
+  afterAll,
+  beforeAll,
+  beforeEach,
+  describe,
+  expect,
+  it,
+  vi,
+} from "vitest"
+
+// Mock @enesis/editor — same pattern as indexer.test.ts
+let blockIdSeed = 0
+vi.mock("@enesis/editor", () => ({
+  splitMarkdownIntoBlocks: (content: string) =>
+    content
+      .split("\n")
+      .filter(Boolean)
+      .map((line) => ({
+        id: `test-block-${++blockIdSeed}`,
+        content: line,
+        depth: 0,
+      })),
+}))
+
+vi.mock("@tauri-apps/plugin-sql", () => {
+  function translate(sql: string): string {
+    return sql.replace(/\$\d+/g, "?")
+  }
+
+  class MockDatabase {
+    private db: Database.Database
+
+    private constructor(db: Database.Database) {
+      this.db = db
+    }
+
+    static async load(_connectionString: string): Promise<MockDatabase> {
+      const { default: BS3 } = await import("better-sqlite3")
+      const db = new BS3(":memory:")
+      db.pragma("foreign_keys = ON")
+      return new MockDatabase(db)
+    }
+
+    async execute(
+      sql: string,
+      params?: unknown[],
+    ): Promise<{ rowsAffected: number }> {
+      if (params) {
+        const stmt = this.db.prepare(translate(sql))
+        const result = stmt.run(...params)
+        return { rowsAffected: result.changes }
+      }
+      const result = this.db.exec(sql)
+      return { rowsAffected: result.changes ?? 0 }
+    }
+
+    async select<T>(sql: string, params?: unknown[]): Promise<T[]> {
+      const stmt = this.db.prepare(translate(sql))
+      return (params ? stmt.all(...params) : stmt.all()) as T[]
+    }
+  }
+
+  return { default: MockDatabase }
+})
+
+import { fullReindex, initIndex, syncIndex } from "./indexer"
+import { SCHEMA_SQL } from "./schema"
+
+let db: Awaited<ReturnType<typeof initIndex>>
+let raw: Database.Database
+
+async function dumpDB(db: typeof db) {
+  console.log("=== pages ===")
+  console.log(await db.select("SELECT rowid, * FROM pages"))
+  console.log("=== blocks ===")
+  console.log(await db.select("SELECT rowid, * FROM blocks"))
+  console.log("=== blocks_fts ===")
+  console.log(await db.select("SELECT rowid, * FROM blocks_fts"))
+}
+
+beforeAll(() => {
+  raw = new Database(":memory:")
+  raw.exec(SCHEMA_SQL)
+})
+
+afterAll(() => {
+  vi.restoreAllMocks()
+  raw.close()
+})
+
+// Each test gets a fresh database to avoid FTS rowid mismatch across tests
+beforeEach(async () => {
+  db = await initIndex(":memory:")
+})
+
+function opts(files: Record<string, string>, workspacePath = "/ws") {
+  return {
+    listDirectory: vi.fn(async () =>
+      Object.keys(files).map((f) => `${workspacePath}/${f}`),
+    ),
+    readFile: vi.fn(async (path: string) => {
+      const relative = path.replace(`${workspacePath}/`, "")
+      if (!(relative in files)) throw new Error(`ENOENT: ${path}`)
+      return files[relative]
+    }),
+    workspacePath,
+  }
+}
+
+describe("schema — embedding columns", () => {
+  it("blocks table has embedding BLOB column", () => {
+    const cols = raw
+      .prepare("SELECT name, type FROM pragma_table_info('blocks') WHERE name = 'embedding'")
+      .get() as { name: string; type: string } | undefined
+    expect(cols?.name).toBe("embedding")
+    expect(cols?.type).toMatch(/BLOB/i)
+  })
+
+  it("blocks table has embedding_hash column with default ''", () => {
+    const cols = raw
+      .prepare("SELECT name, dflt_value FROM pragma_table_info('blocks') WHERE name = 'embedding_hash'")
+      .get() as { name: string; dflt_value: string } | undefined
+    expect(cols?.name).toBe("embedding_hash")
+    expect(cols?.dflt_value).toBe("''")
+  })
+})
+
+describe("FTS5 searchability", () => {
+  it("indexes content and returns results via blocks_fts MATCH", async () => {
+    const o = opts({
+      "pages/Rust.md": [
+        "# Rust Programming",
+        "Rust is a systems programming language.",
+        "It focuses on safety and performance.",
+        "The borrow checker ensures memory safety.",
+      ].join("\n"),
+    })
+    await syncIndex(o, db)
+
+    // Query FTS5 directly — same query pattern the search composable uses
+    const results = await db.select<{ block_id: string; snippet: string }[]>(
+      `SELECT b.id as block_id, b.content as snippet
+       FROM blocks_fts f
+       JOIN blocks b ON b.rowid = f.rowid
+       WHERE blocks_fts MATCH ?
+       ORDER BY rank
+       LIMIT 20`,
+      ['"rust"*'],
+    )
+
+    expect(results.length).toBeGreaterThan(0)
+
+    // Each result should have a block_id and content
+    const content = results.map((r) => r.snippet.toLowerCase())
+    const hasRustMatch = content.some((c) => c.includes("rust"))
+    expect(hasRustMatch).toBe(true)
+  })
+
+  it("returns no results for unmatched queries", async () => {
+    const o = opts({
+      "pages/Cooking.md": "Boil water and add pasta.",
+    })
+    await syncIndex(o, db)
+
+    const results = await db.select<{ block_id: string }[]>(
+      "SELECT b.id as block_id FROM blocks_fts f JOIN blocks b ON b.rowid = f.rowid WHERE blocks_fts MATCH ? LIMIT 20",
+      ['"quantum"*'],
+    )
+
+    expect(results).toHaveLength(0)
+  })
+
+  it("indexes multiple files without ID collision", async () => {
+    const o = opts({
+      "pages/Fish.md": "Salmon is a popular fish.",
+      "pages/Birds.md": "Eagles are birds of prey.",
+    })
+    await syncIndex(o, db)
+
+    const pages = await db.select<{ path: string }[]>(
+      "SELECT path FROM pages ORDER BY path",
+    )
+    expect(pages).toHaveLength(2)
+    expect(pages[0].path).toBe("pages/Birds.md")
+    expect(pages[1].path).toBe("pages/Fish.md")
+
+    const blocks = await db.select<{ page_path: string; content: string }[]>(
+      "SELECT page_path, content FROM blocks ORDER BY page_path",
+    )
+    expect(blocks).toHaveLength(2)
+    expect(blocks[0].content).toBe("Eagles are birds of prey.")
+    expect(blocks[1].content).toBe("Salmon is a popular fish.")
+  })
+
+  it("survives re-index (unchanged blocks keep same searchability)", async () => {
+    const o = opts({
+      "pages/Test.md": "Content that should be searchable.",
+    })
+    await syncIndex(o, db)
+    await syncIndex(o, db) // re-index unchanged
+
+    const results = await db.select<{ block_id: string }[]>(
+      "SELECT b.id as block_id FROM blocks_fts f JOIN blocks b ON b.rowid = f.rowid WHERE blocks_fts MATCH ? LIMIT 20",
+      ['"searchable"*'],
+    )
+
+    expect(results.length).toBeGreaterThan(0)
+  })
+})
+
+describe("FTS5 prefix matching", () => {
+  it("finds partial word matches via prefix syntax", async () => {
+    const o = opts({
+      "pages/Dev.md": "Development environment setup guide.",
+    })
+    await syncIndex(o, db)
+
+    // Prefix match "devel"* should find "Development"
+    const results = await db.select<{ block_id: string }[]>(
+      "SELECT b.id as block_id FROM blocks_fts f JOIN blocks b ON b.rowid = f.rowid WHERE blocks_fts MATCH ? LIMIT 20",
+      ['"devel"*'],
+    )
+
+    expect(results.length).toBeGreaterThan(0)
+  })
+
+  it("matches multiple terms combined", async () => {
+    const o = opts({
+      "pages/Full.md": "The quick brown fox jumps over the lazy dog.",
+    })
+    await syncIndex(o, db)
+
+    const results = await db.select<{ block_id: string }[]>(
+      "SELECT b.id as block_id FROM blocks_fts f JOIN blocks b ON b.rowid = f.rowid WHERE blocks_fts MATCH ? LIMIT 20",
+      ['"quick"* "fox"*'],
+    )
+
+    expect(results.length).toBeGreaterThan(0)
+  })
+})
+
+describe("buildExcerpt", () => {
+  function buildExcerpt(content: string, query: string): string {
+    const lowerContent = content.toLowerCase()
+    const terms = query.split(/\s+/)
+    const pos = terms
+      .map((t) => lowerContent.indexOf(t.toLowerCase()))
+      .filter((p) => p >= 0)
+      .sort((a, b) => a - b)[0]
+
+    if (pos === undefined) return content.slice(0, 120) + (content.length > 120 ? "…" : "")
+
+    const start = Math.max(0, pos - 60)
+    const end = Math.min(content.length, pos + 60)
+    const excerpt = content.slice(start, end)
+
+    let result = ""
+    if (start > 0) result += "…"
+    result += excerpt
+    if (end < content.length) result += "…"
+    return result
+  }
+
+  it("returns full content when shorter than 120 chars and no match", () => {
+    expect(buildExcerpt("Short text", "nonexistent")).toBe("Short text")
+  })
+
+  it("returns truncated content when longer than 120 chars and no match", () => {
+    const long = "a".repeat(200)
+    const result = buildExcerpt(long, "nonexistent")
+    expect(result).toHaveLength(121)
+    expect(result.endsWith("…")).toBe(true)
+  })
+
+  it("centers excerpt around matched term", () => {
+    const content = "This is a very long text ".repeat(20) + "Rust is great. " + "More trailing text ".repeat(20)
+    const result = buildExcerpt(content, "Rust")
+    expect(result).toContain("Rust")
+    expect(result.length).toBeLessThan(content.length)
+    expect(result.startsWith("…")).toBe(true)
+    expect(result.endsWith("…")).toBe(true)
+  })
+
+  it("handles content shorter than window", () => {
+    const content = "Rust programming language"
+    const result = buildExcerpt(content, "Rust")
+    expect(result).toBe("Rust programming language")
+  })
+
+  it("matches case-insensitively", () => {
+    const content = "The RUST language is fast."
+    const result = buildExcerpt(content, "rust")
+    expect(result).toContain("RUST")
+  })
+
+  it("handles multi-word queries", () => {
+    const content = "Learning Rust programming for systems development."
+    const result = buildExcerpt(content, "Rust programming")
+    expect(result).toContain("Rust")
+  })
+
+  it("handles empty query", () => {
+    const result = buildExcerpt("Some content", "")
+    expect(result).toBe("Some content")
+  })
+})
+
+describe("embedding encode/decode round-trip", () => {
+  it("Float32Array round-trips through Uint8Array BLOB", () => {
+    const original = new Float32Array([0.1, 0.2, 0.3, 0.0, -0.5, 1.0])
+    const blob = new Uint8Array(original.buffer)
+    const decoded = new Float32Array(blob.buffer)
+
+    expect(decoded.length).toBe(original.length)
+    for (let i = 0; i < original.length; i++) {
+      expect(decoded[i]).toBeCloseTo(original[i], 5)
+    }
+  })
+
+  it("produces correct BLOB size for 384-dim vector", () => {
+    const vec = new Float32Array(384)
+    const blob = new Uint8Array(vec.buffer)
+    // 384 floats × 4 bytes each = 1536 bytes
+    expect(blob.byteLength).toBe(1536)
+  })
+})

+ 25 - 1
apps/tauri/src/pages/[...slug].vue

@@ -44,11 +44,12 @@
  *   Blocks from the same file will have identical `modified_at` values.
  */
 import { rename } from "@tauri-apps/plugin-fs"
-import { computed, onMounted, onUnmounted, ref, watch } from "vue"
+import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"
 import { onBeforeRouteUpdate, useRouter } from "vue-router"
 import type { Backlink } from "~/components/PageView.vue"
 import PageView from "~/components/PageView.vue"
 import TagView from "~/components/TagView.vue"
+import { useBlockHighlight } from "~/composables/useBlockHighlight"
 import { useFileSystem } from "~/composables/useFileSystem"
 import { removeFile as removeFileFromIndex } from "~/lib/indexer"
 import { markInFlight, useWorkspaceStore } from "~/stores/workspace"
@@ -80,6 +81,18 @@ const content = ref("")
 const backlinks = ref<Backlink[]>([])
 const existsOnDisk = ref(false)
 
+// Extract target block ID from URL hash (#block-{id})
+const targetBlockId = computed(() => {
+  const match = route.hash.match(/^#block-(.+)$/)
+  return match?.[1] ?? null
+})
+
+const { highlight } = useBlockHighlight()
+
+function scrollToTargetBlock() {
+  if (targetBlockId.value) highlight(targetBlockId.value)
+}
+
 let saveTimer: ReturnType<typeof setTimeout> | null = null
 
 watch(content, (val) => {
@@ -213,6 +226,16 @@ onBeforeRouteUpdate(() => {
   loadContent()
 })
 
+let lastHighlightedBlock: string | null = null
+
+// After content is set and editor has rendered, scroll to target block
+watch(content, async () => {
+  if (!targetBlockId.value || targetBlockId.value === lastHighlightedBlock) return
+  lastHighlightedBlock = targetBlockId.value
+  await nextTick()
+  scrollToTargetBlock()
+})
+
 async function handleRename(newName: string) {
   if (!ws.workspacePath) return
 
@@ -263,6 +286,7 @@ function handleBacklinkClick(sourcePath: string) {
     :filename="filename"
     :content="content"
     :backlinks="backlinks"
+    :target-block-id="targetBlockId"
     @update:content="content = $event"
     @rename="handleRename"
     @backlink-clicked="handleBacklinkClick"

+ 27 - 0
apps/tauri/src/stores/workspace.ts

@@ -304,6 +304,33 @@ export const useWorkspaceStore = defineStore("workspace", () => {
     const fileContent = content ?? (await readFile(fullPath))
     await indexFileInDb(db.value, relativePath, fileContent)
     await scanFiles()
+
+    // Compute and store embeddings for changed blocks
+    try {
+      const changed = await db.value.select<
+        { id: string; content: string; content_hash: string }[]
+      >(
+        `SELECT id, content, content_hash FROM blocks
+         WHERE page_path = ? AND embedding_hash != content_hash
+         LIMIT 50`,
+        [relativePath],
+      )
+      for (const block of changed) {
+        const embedding = await invoke<number[]>("compute_embedding", {
+          text: block.content.slice(0, 1000),
+        })
+        if (embedding && embedding.length > 0) {
+          const buf = new Float32Array(embedding)
+          const blob = new Uint8Array(buf.buffer)
+          await db.value.execute(
+            "UPDATE blocks SET embedding = ?, embedding_hash = ? WHERE id = ?",
+            [blob, block.content_hash, block.id],
+          )
+        }
+      }
+    } catch (e) {
+      console.warn("[workspace] embedding failed:", e)
+    }
   }
 
   function openFile(relativePath: string) {

+ 1 - 1
package.json

@@ -4,7 +4,7 @@
   "scripts": {
     "dev": "pnpm run --filter @enesis/dev dev",
     "build": "pnpm -r --filter=!@enesis/tauri build",
-    "test": "pnpm run --filter @enesis/editor test",
+    "test": "pnpm -r test",
     "test:e2e": "pnpm run --filter @enesis/editor test:e2e",
     "tauri": "pnpm run --filter @enesis/tauri tauri",
     "check": "biome check",

+ 1 - 0
packages/editor/package.json

@@ -76,6 +76,7 @@
     "@nuxt/kit": "^4.4.8",
     "@nuxt/schema": "^4.4.8",
     "katex": "^0.17.0",
+    "murmurhash3js-revisited": "^3.0.0",
     "nanoid": "^5.1.15",
     "prosemirror-commands": "^1.7.1",
     "prosemirror-gapcursor": "^1.4.1",

+ 56 - 50
packages/editor/src/components/EditorBlock.vue

@@ -106,6 +106,7 @@ const props = defineProps<{
   markerMode?: MarkerVisibilityMode
   debug?: string
   registry?: FocusRegistry
+  readonly?: boolean
   // Prop instead of emit because CodeBlockView (a plain TS class, not a Vue
   // component) triggers it via CodeBlockNavigationDelegate. The delegate is
   // passed into the node view at construction time and routes through this
@@ -470,55 +471,59 @@ onMounted(() => {
     plugins: [
       gapCursor(),
       markdownDecorationsPlugin,
-      patternPlugin,
-      autoClosePlugin([
-        tripleBacktickRule,
-        dollarSignRule,
-        doubleAsteriskRule,
-        doubleTildeRule,
-        doubleCaretRule,
-        singleUnderscoreRule,
-        createPairRule("{", "}"),
-        createPairRule('"', '"', { context: /^$|[\s([{:>]/ }),
-        createPairRule("'", "'", { context: /^$|[\s([{:"]/ }),
-      ]),
-      keymap({
-        "Mod-b": (_state, _dispatch, view) => {
-          if (!view) return false
-          toggleBold(view)
-          return true
-        },
-        "Mod-i": (_state, _dispatch, view) => {
-          if (!view) return false
-          toggleItalic(view)
-          return true
-        },
-        "Mod-`": (_state, _dispatch, view) => {
-          if (!view) return false
-          toggleCode(view)
-          return true
-        },
-        "Mod-Shift-s": (_state, _dispatch, view) => {
-          if (!view) return false
-          toggleStrikethrough(view)
-          return true
-        },
-        "Mod-Shift-h": (_state, _dispatch, view) => {
-          if (!view) return false
-          toggleHighlight(view)
-          return true
-        },
-        "Mod-k": (_state, _dispatch, view) => {
-          if (!view) return false
-          insertLink(view)
-          return true
-        },
-        "Mod-m": (_state, _dispatch, view) => {
-          if (!view) return false
-          insertInlineMath(view)
-          return true
-        },
-      }),
+      ...(props.readonly
+        ? []
+        : [
+            patternPlugin,
+            autoClosePlugin([
+              tripleBacktickRule,
+              dollarSignRule,
+              doubleAsteriskRule,
+              doubleTildeRule,
+              doubleCaretRule,
+              singleUnderscoreRule,
+              createPairRule("{", "}"),
+              createPairRule('"', '"', { context: /^$|[\s([{:>]/ }),
+              createPairRule("'", "'", { context: /^$|[\s([{:"]/ }),
+            ]),
+            keymap({
+              "Mod-b": (_state, _dispatch, view) => {
+                if (!view) return false
+                toggleBold(view)
+                return true
+              },
+              "Mod-i": (_state, _dispatch, view) => {
+                if (!view) return false
+                toggleItalic(view)
+                return true
+              },
+              "Mod-`": (_state, _dispatch, view) => {
+                if (!view) return false
+                toggleCode(view)
+                return true
+              },
+              "Mod-Shift-s": (_state, _dispatch, view) => {
+                if (!view) return false
+                toggleStrikethrough(view)
+                return true
+              },
+              "Mod-Shift-h": (_state, _dispatch, view) => {
+                if (!view) return false
+                toggleHighlight(view)
+                return true
+              },
+              "Mod-k": (_state, _dispatch, view) => {
+                if (!view) return false
+                insertLink(view)
+                return true
+              },
+              "Mod-m": (_state, _dispatch, view) => {
+                if (!view) return false
+                insertInlineMath(view)
+                return true
+              },
+            }),
+          ]),
     ],
   })
 
@@ -647,6 +652,7 @@ onMounted(() => {
   try {
     view = new EditorView(mountEl, {
       state: editorState,
+      editable: () => !props.readonly,
       attributes: {
         role: "textbox",
         "aria-multiline": "true",
@@ -798,7 +804,7 @@ onMounted(() => {
       )
     }
 
-    if (props.id && props.registry) {
+    if (!props.readonly && props.id && props.registry) {
       props.registry.register(props.id, {
         focus: focusAt,
         element: mountEl ?? undefined,

+ 1 - 0
packages/editor/src/index.ts

@@ -45,6 +45,7 @@ export {
   splitMarkdownIntoBlocks,
   stampBlockId,
 } from "./lib/block-parser"
+export { contentBlockId } from "./lib/block-id"
 export { moveBlock } from "./lib/editor-operations"
 export type { FormattingHandlers } from "./lib/formatting"
 export type { MarkdownPattern } from "./lib/markdown-extensions"

+ 6 - 0
packages/editor/src/lib/block-id.d.ts

@@ -0,0 +1,6 @@
+declare module "murmurhash3js-revisited" {
+  const x64: {
+    hash128(data: Uint8Array | string): string
+  }
+  export default { x64 }
+}

+ 23 - 0
packages/editor/src/lib/block-id.ts

@@ -0,0 +1,23 @@
+// @ts-expect-error — no published types, local .d.ts provides them
+import MurmurHash3 from "murmurhash3js-revisited"
+
+/**
+ * Deterministic 6-char Base64URL hash from content + position.
+ * Same inputs always produce the same ID, regardless of caller.
+ * Used by both the indexer and the editor so block IDs remain
+ * consistent across processes.
+ */
+export function contentBlockId(content: string, index: number): string {
+  const encoder = new TextEncoder()
+  const data = encoder.encode(`${index}:${content}`)
+  const hexHash = MurmurHash3.x64.hash128(data)
+  const hexBytes = hexHash.substring(0, 12)
+  const byteBuffer = new Uint8Array(6)
+  for (let i = 0; i < 6; i++) {
+    byteBuffer[i] = parseInt(hexBytes.substr(i * 2, 2), 16)
+  }
+  return btoa(String.fromCharCode(...byteBuffer))
+    .replace(/\+/g, "-")
+    .replace(/\//g, "_")
+    .replace(/=+$/, "")
+}

+ 4 - 3
packages/editor/src/lib/block-parser.ts

@@ -1,6 +1,7 @@
 import type { SyntaxNode } from "@lezer/common"
 import { GFM, parser } from "@lezer/markdown"
 import { nanoid } from "nanoid"
+import { contentBlockId } from "@/lib/block-id"
 import { createMarkdownExtensions } from "@/lib/markdown-extensions"
 
 export interface EditorBlockData {
@@ -108,7 +109,7 @@ function ownContentEnd(node: SyntaxNode): number {
  */
 export function splitMarkdownIntoBlocks(
   md: string,
-  idFn: () => string = () => nanoid(6),
+  idFn: (content: string, index: number) => string = contentBlockId,
   existing?: Pick<EditorBlockData, "id">[],
 ): EditorBlockData[] {
   if (!md.trim()) return []
@@ -160,8 +161,8 @@ export function splitMarkdownIntoBlocks(
       return { id: existing[i].id as string, depth, content: cleaned }
     }
 
-    // Priority 3: fresh ID
-    return { id: idFn(), depth, content: cleaned }
+    // Priority 3: fresh ID (deterministic by default via contentBlockId)
+    return { id: idFn(cleaned, i), depth, content: cleaned }
   })
 }
 

+ 15 - 6
pnpm-lock.yaml

@@ -205,6 +205,9 @@ importers:
       katex:
         specifier: ^0.17.0
         version: 0.17.0
+      murmurhash3js-revisited:
+        specifier: ^3.0.0
+        version: 3.0.0
       nanoid:
         specifier: ^5.1.15
         version: 5.1.15
@@ -4382,6 +4385,10 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
 
+  [email protected]:
+    resolution: {integrity: sha512-/sF3ee6zvScXMb1XFJ8gDsSnY+X8PbOyjIuBhtgis10W2Jx4ZjIhikUCIF9c4gpJxVnQIsPAFrSwTCuAjicP6g==}
+    engines: {node: '>=8.0.0'}
+
   [email protected]:
     resolution: {integrity: sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==}
     engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -7406,8 +7413,8 @@ snapshots:
       typescript: 6.0.3
       ufo: 1.6.4
       unplugin: 3.0.0
-      unplugin-auto-import: 21.0.0(@nuxt/[email protected]([email protected]))(@vueuse/[email protected]([email protected]([email protected])))
-      unplugin-vue-components: 32.1.0(@nuxt/[email protected]([email protected]))([email protected]([email protected]))
+      unplugin-auto-import: 21.0.0(@nuxt/[email protected])(@vueuse/[email protected]([email protected]([email protected])))
+      unplugin-vue-components: 32.1.0(@nuxt/[email protected])([email protected]([email protected]))
       vaul-vue: 0.4.1([email protected]([email protected]([email protected])))([email protected]([email protected]))
       vue-component-type-helpers: 3.3.5
     optionalDependencies:
@@ -7517,8 +7524,8 @@ snapshots:
       typescript: 6.0.3
       ufo: 1.6.4
       unplugin: 3.0.0
-      unplugin-auto-import: 21.0.0(@nuxt/[email protected]([email protected]))(@vueuse/[email protected]([email protected]([email protected])))
-      unplugin-vue-components: 32.1.0(@nuxt/[email protected]([email protected]))([email protected]([email protected]))
+      unplugin-auto-import: 21.0.0(@nuxt/[email protected])(@vueuse/[email protected]([email protected]([email protected])))
+      unplugin-vue-components: 32.1.0(@nuxt/[email protected])([email protected]([email protected]))
       vaul-vue: 0.4.1([email protected]([email protected]([email protected])))([email protected]([email protected]))
       vue-component-type-helpers: 3.3.5
     optionalDependencies:
@@ -10343,6 +10350,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
@@ -11785,7 +11794,7 @@ snapshots:
 
   [email protected]: {}
 
-  [email protected](@nuxt/[email protected]([email protected]))(@vueuse/[email protected]([email protected]([email protected]))):
+  [email protected](@nuxt/[email protected])(@vueuse/[email protected]([email protected]([email protected]))):
     dependencies:
       local-pkg: 1.2.0
       magic-string: 0.30.21
@@ -11802,7 +11811,7 @@ snapshots:
       pathe: 2.0.3
       picomatch: 4.0.4
 
-  [email protected](@nuxt/[email protected]([email protected]))([email protected]([email protected])):
+  [email protected](@nuxt/[email protected])([email protected]([email protected])):
     dependencies:
       chokidar: 5.0.0
       local-pkg: 1.2.0

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác