Files
InkNote/frontend/src/hooks/useNoteData.ts
T
marcandClaude Opus 5 a9391cdcf2 One direction to drop in, and a new note that stays on top
Every new note pulled the arrangement apart. A note was made with no
position and left to the tie-break, which put it near the top by age --
which is not the same thing. A separator's position counts the rows above
it, so a row appearing without a renumber left every line one row too
high. placeOnTop builds the order from the list *without* the new note and
puts it in front: measuring the room after the furniture has moved would
write the misreading back as the truth.

A drop now lands the row under the one it was released on, and only there.
Dropping above a row and dropping below the row before it are the same move
described twice, so every gap could be aimed at from two sides. The strip
above the first row is the one place a drop under something cannot reach.

A chapter could only be dropped into, never dragged past: taking the whole
row as a place quietly took reordering away. The bottom edge of its header
is the gap now, the rest of the row is still the chapter.

A link showed the sheet of paper whatever it pointed at. The @ menu dropped
the mark on the way into the link, and the refresh that keeps a link current
ran once, while the editor was still empty -- content is poured in afterwards
without an update event, so nothing ever looked again.

The mark in the title bar and the box in a task list were each centred on a
line box rather than on the letters in it, which is not the same as being on
one line. The mark shares the title's baseline; the box is centred on the
capitals and sized in em.

Move to... is divided under headings that are deliberately not shaped like
entries, and each row carries the mark its chapter or notebook wears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:45:16 +02:00

530 lines
20 KiB
TypeScript

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 { placeOnTop, reindexEntries } from "../features/notes/noteSort";
import type { Arrangement } from "../features/notes/noteSort";
import { extractOnImport } from "../features/pdf/pdfText";
import type {
Backlink,
Chapter,
NoteContent,
NoteMeta,
Notebook,
Separator,
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[];
/** Ids of attachments the body embeds, so unused ones can eventually be freed. */
attachments?: 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, chapterId?: string | null) => Promise<NoteMeta>;
importPdf: (folderId: string, file: File) => Promise<NoteMeta>;
updateNote: (id: string, edit: NoteEdit) => void;
setNoteContent: (id: string, content: NoteContent) => void;
toggleStar: (id: string) => Promise<void>;
/** The note's own mark beside its title, and the colour it is drawn in. */
setNoteIcon: (id: string, icon: string | null, iconColor: string | null) => 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, chapterId?: string | null) => Promise<void>;
/** Drag-to-reorder within a notebook; moves notes and separators alike. */
rearrange: (notebookId: string, dragId: string, targetId: string, chapterId?: string | null) => void;
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
chapters: Chapter[];
createChapter: (
notebookId: string,
name: string,
color?: string | null,
icon?: string | null
) => Promise<Chapter>;
/** Name, colour, or both — one write for one decision. */
editChapter: (id: string, updates: { name?: string; color?: string | null }) => Promise<void>;
deleteChapter: (id: string) => 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 [chapters, setChapters] = useState<Chapter[]>([]);
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 chaptersRef = useRef(chapters);
chaptersRef.current = chapters;
const contentsRef = useRef(noteContents);
contentsRef.current = noteContents;
const reload = useCallback(async () => {
setNotebooks(await sync.getNotebooks());
setChapters(await sync.getChapters());
setNotes(await sync.getNotes());
}, []);
const reset = useCallback(() => {
setNotes([]);
setNotebooks([]);
setChapters([]);
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 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, edit.attachments);
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 setNoteIcon = useCallback(async (id: string, icon: string | null, iconColor: string | null) => {
setNotes((p) => p.map((n) => (n.id === id ? { ...n, icon, iconColor } : n)));
await sync.updateNoteIcon(id, icon, iconColor);
}, []);
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, chapterId: string | null = null) => {
await sync.moveNote(id, folderId, chapterId);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId, chapterId } : n)));
}, []);
/**
* Everything one list is made of: its notes, its lines, its chapters.
*
* `chapterId` scopes it to one list — the notebook itself, or one of its
* chapters. A row's place is only meaningful beside the rows it is shown with,
* and the separators of the other chapters have to come through a write
* untouched. The notes are taken from the store rather than from whatever the
* list is showing: a search must never be able to renumber what it is hiding.
*
* `extra` is a note that exists but has not reached the store yet — the one
* just created. Building the list without it would renumber the notebook
* around a row that is already on screen.
*/
const listOf = useCallback((notebookId: string, chapterId: string | null, extra?: NoteMeta) => {
const notebook = notebooksRef.current.find((nb) => nb.id === notebookId);
if (!notebook) return null;
const known = extra ? [extra, ...notesRef.current.filter((n) => n.id !== extra.id)] : notesRef.current;
const notes = known.filter(
(n) => !n.trashed && n.folderId === notebookId && (n.chapterId ?? null) === chapterId
);
const all = notebook.separators || [];
return {
notes,
all,
mine: all.filter((sep) => (sep.chapterId ?? null) === chapterId),
others: all.filter((sep) => (sep.chapterId ?? null) !== chapterId),
/* Chapters are rows in this list too, and only in the notebook itself — a
chapter has no chapters of its own. */
chapters: chapterId ? [] : chaptersRef.current.filter((c) => c.notebookId === notebookId),
};
}, []);
/** Writes an arrangement back — to the screen at once, to the server behind it. */
const applyArrangement = useCallback(
(
notebookId: string,
chapterId: string | null,
all: Separator[],
others: Separator[],
next: Arrangement
) => {
const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))];
const position = new Map(next.notes.map((n) => [n.id, n.order]));
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
void sync.reorderNotes(next.notes);
if (next.chapters.length) {
const chapterAt = new Map(next.chapters.map((c) => [c.id, c.order]));
setChapters((p) => p.map((c) => (chapterAt.has(c.id) ? { ...c, sortOrder: chapterAt.get(c.id)! } : c)));
void sync.reorderChapters(next.chapters);
}
/* Only written when it actually changed.
*
* A plain note drag leaves the separators alone, and a write for nothing
* costs more than a request: it reloads the notes, which reshuffles any
* that share an `updatedAt` second — so "last changed" quietly came out in
* a different order after every drag.
*
* Compared as a set rather than as an array, because the merge above puts
* the other chapters' separators first and that says nothing about whether
* anything changed. */
const fingerprint = (seps: Separator[]) =>
seps
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`)
.sort()
.join("\n");
if (fingerprint(merged) !== fingerprint(all)) {
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
void sync.updateNotebook(notebookId, { separators: merged });
}
},
[]
);
/**
* Puts one row under the one it was dropped on, in the user's own order.
*
* Notes, lines and chapters move the same way, so this takes all three and
* writes all three back.
*/
const rearrange = useCallback(
(notebookId: string, dragId: string, targetId: string, chapterId: string | null = null) => {
const list = listOf(notebookId, chapterId);
if (!list) return;
const next = reindexEntries(list.notes, list.mine, dragId, targetId, list.chapters);
if (!next) return;
applyArrangement(notebookId, chapterId, list.all, list.others, next);
},
[applyArrangement, listOf]
);
/**
* Puts a freshly made note at the top of the list it was made in.
*
* A new note used to be given no position at all, and the sort put it near the
* top by age. That is not the same thing: a separator's position counts the
* rows above it, so a row that appears without anybody being renumbered leaves
* every line one row too high — and each new note pulled the notebook's
* arrangement further apart. Renumbering the list once, here, keeps every
* other row exactly where it was and puts the new one above all of them,
* lines and chapters included.
*/
const putOnTop = useCallback(
(note: NoteMeta) => {
if (!note.folderId) return;
const chapterId = note.chapterId ?? null;
const list = listOf(note.folderId, chapterId, note);
if (!list) return;
const next = placeOnTop(list.notes, list.mine, list.chapters, note.id);
if (!next) return;
applyArrangement(note.folderId, chapterId, list.all, list.others, next);
},
[applyArrangement, listOf]
);
const createNote = useCallback(
async (folderId: string, chapterId: string | null = null) => {
const note = await sync.createNote(folderId, chapterId);
setNotes((p) => [note, ...p]);
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
putOnTop(note);
return note;
},
[putOnTop]
);
/**
* Uploads a PDF and creates the note that holds it.
*
* Not queued for offline: the file has to reach the server before a note can
* point at it, and a note referring to bytes nobody has would be worse than
* being told to try again with a connection.
*/
const importPdf = useCallback(
async (folderId: string, file: File) => {
const attachment = await sync.uploadPdf(file, file.name);
const title = file.name.replace(/\.pdf$/i, "") || "Dokument";
const note = await sync.createPdfNote(folderId, title, attachment.id);
setNotes((p) => [note, ...p]);
putOnTop(note);
/* Read the document here, now, on the machine that already has it open.
*
* Waiting until someone opens it means a filed invoice may never be read
* at all, and until it is, no device can find it. Not awaited: the note
* exists and should appear at once — the text catches up. */
void extractOnImport(file, attachment.id);
return note;
},
[putOnTop]
);
/* ── Notebooks ── */
/* ── Chapters ──
*
* Folders inside a notebook. Deleting one never deletes its notes: they come
* back to the notebook, which is why the local copy is refreshed rather than
* having the removal applied by hand here.
*/
const createChapter = useCallback(
async (notebookId: string, name: string, color: string | null = null, icon: string | null = null) => {
const chapter = await sync.createChapter(notebookId, name, color, icon);
setChapters((p) => [...p, chapter]);
return chapter;
},
[]
);
/* Name and colour go the same way, in one write.
*
* They used to be two calls because they came from two places — a dialog and
* a submenu — and renaming a chapter while recolouring it then meant two
* requests for one decision. */
const editChapter = useCallback(async (id: string, updates: { name?: string; color?: string | null }) => {
setChapters((p) => p.map((c) => (c.id === id ? { ...c, ...updates } : c)));
await sync.updateChapter(id, updates);
}, []);
const deleteChapter = useCallback(
async (id: string) => {
await sync.deleteChapter(id);
setChapters((p) => p.filter((c) => c.id !== id));
setNotes((p) => p.map((n) => (n.chapterId === id ? { ...n, chapterId: null } : n)));
},
[]
);
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,
importPdf,
updateNote,
setNoteContent,
toggleStar,
setNoteIcon,
trashNote,
restoreNote,
permanentDeleteNote,
emptyTrash,
moveNote,
rearrange,
createNotebook,
updateNotebook,
chapters,
createChapter,
editChapter,
deleteChapter,
deleteNotebook,
reorderNotebooks,
};
}
const noop = () => {};