TipTap editor, version history, search, attachments — and a cleanup
The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.
Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
missing; it refuses to start. The old fallback silently made every stored
note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
already eaten drawing strokes and note links; sync.hasPendingWrites() now
guards every path that takes a body from the server.
Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.
Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
unreadable.
Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
search, layout, history and drag each live behind a hook, and the four
panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
restoring a history entry pushed a duplicate one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d97591b242
commit
92d525b3ab
71 files changed
+14354
-1826
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { MobileView } from "./useResponsive";
|
||||
|
||||
export interface NavState {
|
||||
folder: string;
|
||||
note: string | null;
|
||||
view: MobileView;
|
||||
}
|
||||
|
||||
const same = (a: NavState, b: NavState) => a.folder === b.folder && a.note === b.note && a.view === b.view;
|
||||
|
||||
/**
|
||||
* Maps the app's current location onto browser history, so the phone's back
|
||||
* gesture steps back through notes and notebooks instead of leaving the app.
|
||||
*
|
||||
* `apply` is called when the user navigates back and should adopt the state it
|
||||
* is handed.
|
||||
*/
|
||||
export function useNavigationHistory(
|
||||
current: NavState,
|
||||
isMobile: boolean,
|
||||
apply: (state: NavState) => void
|
||||
): void {
|
||||
const applyRef = useRef(apply);
|
||||
applyRef.current = apply;
|
||||
const currentRef = useRef(current);
|
||||
currentRef.current = current;
|
||||
const mobileRef = useRef(isMobile);
|
||||
mobileRef.current = isMobile;
|
||||
|
||||
/** What the topmost history entry represents. */
|
||||
const pushed = useRef<NavState>({ folder: "dashboard", note: null, view: "list" });
|
||||
|
||||
useEffect(() => {
|
||||
if (same(pushed.current, current)) return;
|
||||
pushed.current = { ...current };
|
||||
try {
|
||||
window.history.pushState(pushed.current, "");
|
||||
} catch {
|
||||
/* pushState is rate-limited in some browsers; losing an entry is not fatal */
|
||||
}
|
||||
}, [current.folder, current.note, current.view]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPop = (e: PopStateEvent) => {
|
||||
const state = e.state as Partial<NavState> | null;
|
||||
const cur = currentRef.current;
|
||||
const view = mobileRef.current ? (state?.view ?? "list") : cur.view;
|
||||
|
||||
let next: NavState;
|
||||
if (state) {
|
||||
next = { folder: state.folder || "dashboard", note: state.note ?? null, view };
|
||||
} else if (cur.note) {
|
||||
// Stepping back past our own entries: close the note, then the notebook.
|
||||
next = { folder: cur.folder, note: null, view: mobileRef.current ? "list" : cur.view };
|
||||
} else if (cur.folder !== "dashboard") {
|
||||
next = { folder: "dashboard", note: null, view: cur.view };
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Record where history now stands *before* applying it. Without this the
|
||||
// push effect sees a change it did not make and pushes a duplicate entry,
|
||||
// which makes the back button bounce between two views forever.
|
||||
pushed.current = next;
|
||||
applyRef.current(next);
|
||||
};
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import * as sync from "../syncEngine";
|
||||
import * as searchIndex from "../search";
|
||||
import { sameStrokeDoc } from "../features/drawing/strokes.js";
|
||||
import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
|
||||
|
||||
/** How often the whole account is reconciled with the server. */
|
||||
const FULL_SYNC_MS = 30_000;
|
||||
/** How often the open note is re-read, so a second device's edits show up. */
|
||||
const ACTIVE_NOTE_POLL_MS = 6_000;
|
||||
/** After this long without a body arriving, show an empty editor rather than a spinner. */
|
||||
const CONTENT_TIMEOUT_MS = 5_000;
|
||||
|
||||
const EMPTY_CONTENT: NoteContent = { content: "", drawingData: null, drawingStrokes: null };
|
||||
|
||||
export interface NoteEdit {
|
||||
title?: string;
|
||||
content?: string;
|
||||
/** Ids of notes and notebooks the body links to; stored server-side for backlinks. */
|
||||
links?: string[];
|
||||
drawingStrokes?: StrokeDoc | null;
|
||||
}
|
||||
|
||||
export interface NoteData {
|
||||
notes: NoteMeta[];
|
||||
notebooks: Notebook[];
|
||||
noteContents: Record<string, NoteContent>;
|
||||
/** Notes linking to the currently open one. */
|
||||
backlinks: Backlink[];
|
||||
|
||||
reload: () => Promise<void>;
|
||||
reset: () => void;
|
||||
|
||||
createNote: (folderId: string) => Promise<NoteMeta>;
|
||||
updateNote: (id: string, edit: NoteEdit) => void;
|
||||
setNoteContent: (id: string, content: NoteContent) => void;
|
||||
toggleStar: (id: string) => Promise<void>;
|
||||
trashNote: (id: string) => Promise<void>;
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
permanentDeleteNote: (id: string) => Promise<void>;
|
||||
emptyTrash: () => Promise<void>;
|
||||
moveNote: (id: string, folderId: string) => Promise<void>;
|
||||
|
||||
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
|
||||
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<void>;
|
||||
reorderNotebooks: (dragId: string, targetId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns everything the app knows about notes and notebooks: the local copy, the
|
||||
* mutations that change it, and the background work that keeps it current.
|
||||
*
|
||||
* Every mutation writes locally first and hands the change to the sync engine,
|
||||
* so the UI stays responsive and offline edits are never lost. Confirmation
|
||||
* prompts are deliberately *not* here — destructive intent is a question for
|
||||
* the interface, not the store.
|
||||
*/
|
||||
export function useNoteData(user: User | null, activeNote: string | null): NoteData {
|
||||
const [notes, setNotes] = useState<NoteMeta[]>([]);
|
||||
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
|
||||
const [noteContents, setNoteContents] = useState<Record<string, NoteContent>>({});
|
||||
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
||||
|
||||
const notesRef = useRef(notes);
|
||||
notesRef.current = notes;
|
||||
const notebooksRef = useRef(notebooks);
|
||||
notebooksRef.current = notebooks;
|
||||
const contentsRef = useRef(noteContents);
|
||||
contentsRef.current = noteContents;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setNotebooks(await sync.getNotebooks());
|
||||
setNotes(await sync.getNotes());
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setNotes([]);
|
||||
setNotebooks([]);
|
||||
setNoteContents({});
|
||||
setBacklinks([]);
|
||||
}, []);
|
||||
|
||||
/* ── Background upkeep ── */
|
||||
|
||||
// The sync engine tells us when it has written to IndexedDB behind our back.
|
||||
// Loaded bodies are kept as they are: replacing them here would make the
|
||||
// editor flicker on every poll.
|
||||
useEffect(() => sync.onDataChange(reload), [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => {
|
||||
if (sync.isOnline()) void sync.fullSync().then(reload).catch(noop);
|
||||
}, FULL_SYNC_MS);
|
||||
return () => clearInterval(i);
|
||||
}, [reload]);
|
||||
|
||||
/* ── The open note's body ── */
|
||||
|
||||
const hasContent = activeNote != null && noteContents[activeNote] !== undefined;
|
||||
|
||||
// Fetched on demand: on a cold start every body is missing, and loading them
|
||||
// all up front would be slow and mostly wasted.
|
||||
useEffect(() => {
|
||||
if (!activeNote || hasContent) return;
|
||||
let cancelled = false;
|
||||
sync
|
||||
.loadNoteContent(activeNote)
|
||||
.then((c) => {
|
||||
if (cancelled) return;
|
||||
setNoteContents((p) => ({ ...p, [activeNote]: c }));
|
||||
const title = notesRef.current.find((n) => n.id === activeNote)?.title || "";
|
||||
searchIndex.setEntry(activeNote, title, c.content);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setNoteContents((p) => ({ ...p, [activeNote]: EMPTY_CONTENT }));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeNote, hasContent]);
|
||||
|
||||
// Hard fallback: an unreachable server must not leave the editor stuck.
|
||||
useEffect(() => {
|
||||
if (!activeNote || hasContent) return;
|
||||
const t = setTimeout(() => {
|
||||
setNoteContents((p) => (p[activeNote] ? p : { ...p, [activeNote]: EMPTY_CONTENT }));
|
||||
}, CONTENT_TIMEOUT_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [activeNote, hasContent]);
|
||||
|
||||
// Keep the open note fresh across devices.
|
||||
useEffect(() => {
|
||||
if (!user || !activeNote) return;
|
||||
const i = setInterval(() => {
|
||||
if (!sync.isOnline()) return;
|
||||
// Never adopt a response while this note has writes on the way out: it
|
||||
// was requested before them and would undo what was just typed.
|
||||
if (sync.hasPendingWrites(activeNote)) return;
|
||||
sync
|
||||
.loadNoteContent(activeNote, { forceRemote: true })
|
||||
.then((c) => {
|
||||
setNoteContents((p) => {
|
||||
const prev = p[activeNote];
|
||||
if (
|
||||
prev &&
|
||||
prev.content === c.content &&
|
||||
prev.drawingData === c.drawingData &&
|
||||
sameStrokeDoc(prev.drawingStrokes, c.drawingStrokes)
|
||||
)
|
||||
return p;
|
||||
return { ...p, [activeNote]: c };
|
||||
});
|
||||
})
|
||||
.catch(noop);
|
||||
}, ACTIVE_NOTE_POLL_MS);
|
||||
return () => clearInterval(i);
|
||||
}, [user, activeNote]);
|
||||
|
||||
const activeBody = activeNote ? noteContents[activeNote]?.content : undefined;
|
||||
useEffect(() => {
|
||||
if (!activeNote || !user) {
|
||||
setBacklinks([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
sync
|
||||
.getBacklinks(activeNote)
|
||||
.then((r) => {
|
||||
if (!cancelled) setBacklinks(r);
|
||||
})
|
||||
.catch(noop);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeNote, user, activeBody]);
|
||||
|
||||
/* ── Notes ── */
|
||||
|
||||
const createNote = useCallback(async (folderId: string) => {
|
||||
const note = await sync.createNote(folderId);
|
||||
setNotes((p) => [note, ...p]);
|
||||
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
|
||||
return note;
|
||||
}, []);
|
||||
|
||||
const updateNote = useCallback((id: string, edit: NoteEdit) => {
|
||||
if (edit.title !== undefined) {
|
||||
const title = edit.title;
|
||||
const updatedAt = new Date().toISOString();
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, title, updatedAt } : n)));
|
||||
void sync.updateNoteTitle(id, title);
|
||||
searchIndex.setEntry(id, title, contentsRef.current[id]?.content);
|
||||
}
|
||||
if (edit.content !== undefined) {
|
||||
const content = edit.content;
|
||||
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), content } }));
|
||||
void sync.updateNoteContent(id, content, edit.links);
|
||||
searchIndex.setEntry(id, notesRef.current.find((n) => n.id === id)?.title || "", content);
|
||||
}
|
||||
if (edit.drawingStrokes !== undefined) {
|
||||
const drawingStrokes = edit.drawingStrokes;
|
||||
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), drawingStrokes } }));
|
||||
void sync.updateNoteDrawing(id, drawingStrokes);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Replaces a body wholesale — used when restoring an older version. */
|
||||
const setNoteContent = useCallback((id: string, content: NoteContent) => {
|
||||
setNoteContents((p) => ({ ...p, [id]: content }));
|
||||
}, []);
|
||||
|
||||
const toggleStar = useCallback(async (id: string) => {
|
||||
const starred = await sync.toggleNoteStar(id);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, starred } : n)));
|
||||
}, []);
|
||||
|
||||
const trashNote = useCallback(async (id: string) => {
|
||||
await sync.trashNote(id);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: true } : n)));
|
||||
}, []);
|
||||
|
||||
const restoreNote = useCallback(async (id: string) => {
|
||||
await sync.restoreNoteLocal(id);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: false } : n)));
|
||||
}, []);
|
||||
|
||||
const permanentDeleteNote = useCallback(async (id: string) => {
|
||||
await sync.permanentDeleteNoteLocal(id);
|
||||
searchIndex.removeEntry(id);
|
||||
setNotes((p) => p.filter((n) => n.id !== id));
|
||||
}, []);
|
||||
|
||||
const emptyTrash = useCallback(async () => {
|
||||
await sync.emptyTrashLocal();
|
||||
for (const n of notesRef.current) if (n.trashed) searchIndex.removeEntry(n.id);
|
||||
setNotes((p) => p.filter((n) => !n.trashed));
|
||||
}, []);
|
||||
|
||||
const moveNote = useCallback(async (id: string, folderId: string) => {
|
||||
await sync.moveNote(id, folderId);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
|
||||
}, []);
|
||||
|
||||
/* ── Notebooks ── */
|
||||
|
||||
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
|
||||
const nb = await sync.createNotebook(fields);
|
||||
setNotebooks((p) => [...p, nb]);
|
||||
return nb;
|
||||
}, []);
|
||||
|
||||
const updateNotebook = useCallback(async (id: string, updates: Partial<Notebook>) => {
|
||||
await sync.updateNotebook(id, updates);
|
||||
setNotebooks((p) => p.map((nb) => (nb.id === id ? { ...nb, ...updates } : nb)));
|
||||
}, []);
|
||||
|
||||
/** Deleting a notebook trashes its notes rather than destroying them. */
|
||||
const deleteNotebook = useCallback(async (id: string) => {
|
||||
await sync.deleteNotebookLocal(id);
|
||||
setNotes((p) => p.map((n) => (n.folderId === id ? { ...n, trashed: true } : n)));
|
||||
setNotebooks((p) => p.filter((nb) => nb.id !== id));
|
||||
}, []);
|
||||
|
||||
const reorderNotebooks = useCallback((dragId: string, targetId: string) => {
|
||||
const list = [...notebooksRef.current];
|
||||
const from = list.findIndex((n) => n.id === dragId);
|
||||
const to = list.findIndex((n) => n.id === targetId);
|
||||
if (from < 0 || to < 0 || from === to) return;
|
||||
const [moved] = list.splice(from, 1);
|
||||
if (!moved) return;
|
||||
list.splice(to, 0, moved);
|
||||
const reordered = list.map((n, i) => ({ ...n, sortOrder: i }));
|
||||
setNotebooks(reordered);
|
||||
void sync.reorderNotebooks(reordered.map((n) => n.id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
notes,
|
||||
notebooks,
|
||||
noteContents,
|
||||
backlinks,
|
||||
reload,
|
||||
reset,
|
||||
createNote,
|
||||
updateNote,
|
||||
setNoteContent,
|
||||
toggleStar,
|
||||
trashNote,
|
||||
restoreNote,
|
||||
permanentDeleteNote,
|
||||
emptyTrash,
|
||||
moveNote,
|
||||
createNotebook,
|
||||
updateNotebook,
|
||||
deleteNotebook,
|
||||
reorderNotebooks,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { DragEvent } from "react";
|
||||
|
||||
export interface NotebookDnd {
|
||||
/** Notebook currently under the pointer, for the drop indicator. */
|
||||
overId: string | null;
|
||||
onDragStart: (e: DragEvent, id: string) => void;
|
||||
onDragOver: (e: DragEvent, id: string) => void;
|
||||
onDrop: (e: DragEvent, id: string) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag-to-reorder for the notebook list, shared by the sidebar and the
|
||||
* dashboard so a notebook can be dragged in either place.
|
||||
*/
|
||||
export function useNotebookDnd(reorder: (dragId: string, targetId: string) => void): NotebookDnd {
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
// The dragged id is not rendered, so a ref avoids a re-render per dragover.
|
||||
const dragId = useRef<string | null>(null);
|
||||
|
||||
const onDragStart = useCallback((e: DragEvent, id: string) => {
|
||||
dragId.current = id;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}, []);
|
||||
|
||||
const onDragOver = useCallback((e: DragEvent, id: string) => {
|
||||
e.preventDefault();
|
||||
if (dragId.current && dragId.current !== id) setOverId(id);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
dragId.current = null;
|
||||
setOverId(null);
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: DragEvent, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const from = dragId.current;
|
||||
if (from && from !== targetId) reorder(from, targetId);
|
||||
clear();
|
||||
},
|
||||
[reorder, clear]
|
||||
);
|
||||
|
||||
return { overId, onDragStart, onDragOver, onDrop, onDragEnd: clear };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Chrome's non-standard install event. Not in lib.dom, so it is declared here. */
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
export interface PwaInstall {
|
||||
/** Non-null only while the browser is willing to show an install prompt. */
|
||||
canInstall: boolean;
|
||||
promptInstall: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the browser's install offer so the app can present it at a sensible
|
||||
* moment instead of letting the browser choose one.
|
||||
*/
|
||||
export function usePwaInstall(): PwaInstall {
|
||||
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onPrompt = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setEvent(e as BeforeInstallPromptEvent);
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", onPrompt);
|
||||
return () => window.removeEventListener("beforeinstallprompt", onPrompt);
|
||||
}, []);
|
||||
|
||||
const promptInstall = async () => {
|
||||
if (!event) return;
|
||||
await event.prompt();
|
||||
const { outcome } = await event.userChoice;
|
||||
// The captured event is single-use; drop it either way so the button does
|
||||
// not linger after a decision.
|
||||
if (outcome === "accepted") setEvent(null);
|
||||
};
|
||||
|
||||
return { canInstall: event !== null, promptInstall };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export type MobileView = "list" | "editor";
|
||||
|
||||
export interface Responsive {
|
||||
isMobile: boolean;
|
||||
sidebarOpen: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
/** On a phone, list and editor share the screen; this says which one has it. */
|
||||
mobileView: MobileView;
|
||||
setMobileView: (view: MobileView) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether we are on a phone-sized screen and keeps the sidebar's
|
||||
* open/closed state consistent with it: on a desktop the sidebar is always
|
||||
* available, on a phone it is an overlay that starts closed.
|
||||
*/
|
||||
export function useResponsive(): Responsive {
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > MOBILE_BREAKPOINT);
|
||||
const [mobileView, setMobileView] = useState<MobileView>("list");
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
const mobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||||
setIsMobile(mobile);
|
||||
// Growing past the breakpoint must not leave the sidebar hidden with no
|
||||
// way to bring it back.
|
||||
if (!mobile) setSidebarOpen(true);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
return { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import * as sync from "../syncEngine";
|
||||
import * as searchIndex from "../search";
|
||||
import type { NoteMeta, SearchHit, User } from "../types";
|
||||
|
||||
const DEBOUNCE_MS = 120;
|
||||
/** Refresh visible results every N notes while the backfill is still running. */
|
||||
const PROGRESS_EVERY = 10;
|
||||
|
||||
export interface SearchState {
|
||||
/** null means "not searching"; an empty array means "searched, found nothing". */
|
||||
results: SearchHit[] | null;
|
||||
/** Excerpt around the match, empty when the note is not a hit. */
|
||||
snippetFor: (id: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search over the local copy of every note.
|
||||
*
|
||||
* The server stores bodies encrypted and cannot index them, so the index is
|
||||
* built here. Bodies normally reach the client only when a note is opened, so
|
||||
* the missing ones are backfilled in the background after login.
|
||||
*/
|
||||
export function useSearchIndex(user: User | null, notes: NoteMeta[], query: string): SearchState {
|
||||
const [results, setResults] = useState<SearchHit[] | null>(null);
|
||||
const [version, setVersion] = useState(0);
|
||||
const running = useRef(false);
|
||||
|
||||
// Read by the backfill so it sees current titles without being torn down and
|
||||
// restarted every time a note changes.
|
||||
const notesRef = useRef(notes);
|
||||
notesRef.current = notes;
|
||||
|
||||
useEffect(() => {
|
||||
// Waits for the note list: starting before the first sync would find
|
||||
// nothing to fetch and never run again.
|
||||
if (!user || notes.length === 0 || running.current) return;
|
||||
running.current = true;
|
||||
let cancelled = false;
|
||||
const signal = { aborted: false };
|
||||
|
||||
void (async () => {
|
||||
await searchIndex.rebuild();
|
||||
if (!cancelled) setVersion((v) => v + 1);
|
||||
await sync.prefetchAllContent({
|
||||
signal,
|
||||
onProgress: ({ id, content, fetched }) => {
|
||||
const note = notesRef.current.find((n) => n.id === id);
|
||||
searchIndex.setEntry(id, note?.title || "", content);
|
||||
if (fetched % PROGRESS_EVERY === 0 && !cancelled) setVersion((v) => v + 1);
|
||||
},
|
||||
});
|
||||
if (!cancelled) setVersion((v) => v + 1);
|
||||
running.current = false;
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
signal.aborted = true;
|
||||
};
|
||||
}, [user, notes.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (!q) {
|
||||
setResults(null);
|
||||
return;
|
||||
}
|
||||
// Debounced so typing does not re-scan the index on every keystroke.
|
||||
const t = setTimeout(() => setResults(searchIndex.search(q)), DEBOUNCE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [query, version]);
|
||||
|
||||
const snippets = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const r of results || []) if (r.snippet) m.set(r.id, r.snippet);
|
||||
return m;
|
||||
}, [results]);
|
||||
|
||||
const snippetFor = useMemo(() => (id: string) => snippets.get(id) || "", [snippets]);
|
||||
|
||||
return { results, snippetFor };
|
||||
}
|
||||
Reference in new issue
Block a user