Appearance
Core UI Features: Notes System & Annotations
Part 2 of 5 in the Core UI Features series.
Notes & Annotation System already documents why the annotation architecture looks the way it does — the DOM-mutation rewrite, the KaTeX offset problem, the portal fix for clipped popovers. This page covers the two-part notes system end to end and, new here, how the same model is implemented on desktop with SQLite instead of Firestore.
Two kinds of notes
Lecture notes are Ediky-authored markdown, fetched at runtime from the private Docy repo and rendered read-only except for the annotation layer on top. Short notes are student-authored summaries typed directly in the app, persisted per subject via useShortNotes with a small rich-text toolbar (bold/italic/code/lists/headings) that edits markdown source, not HTML — keeping the storage format identical to lecture notes so the same renderer handles both.
Why annotations are data, not DOM edits
The first annotation implementation stored DOM ranges and mutated rendered HTML directly, which broke in every way that approach always does: React reconciliation wiped or duplicated highlights on re-render, KaTeX-rendered formulas produced markup whose text content didn't match the source so character offsets drifted, and selections spanning paragraph or list boundaries corrupted ranges outright.
The fix inverts the model: an annotation is a plain object anchored to the source markdown, computed once at capture time and replayed at render time — never touching the DOM after mount.
rendering diagram…
drag to pan · ctrl / ⌘ + scroll to zoom
Offset mapping, conceptually
Each annotation stores which markdown block it belongs to plus a start/end character offset within that block's source text, alongside the originally-selected text as a fuzzy-match fallback for when the source shifts slightly (an instructor edit, a typo fix).
javascript
// Conceptual offset capture — runs against source text, never the DOM
function captureAnnotation(blockId, sourceText, selectionRange) {
const { start, end } = mapSelectionToSourceOffsets(selectionRange, sourceText);
return {
blockId,
start,
end,
selectedText: sourceText.slice(start, end), // fuzzy fallback
color: currentHighlightColor(),
createdAt: Date.now(),
};
}
// Render-time: turn stored offsets back into <mark> spans
function applyAnnotations(blockText, annotations) {
return annotations
.filter((a) => a.blockId === currentBlockId)
.sort((a, b) => a.start - b.start)
.reduce(insertMarkSpan, blockText); // pure — returns new nodes, no mutation
}KaTeX-rendered regions are treated as atomic: a selection snaps around a formula rather than measuring character offsets through its generated markup, which is what the naive DOM-range approach got wrong in the first place.
Storage: Firestore vs. SQLite
Web stores one document per annotation under users/{uid}/annotations, synced in real time to every signed-in tab. Desktop stores the same logical object in a local SQLite table:
sql
CREATE TABLE annotations (
id TEXT PRIMARY KEY,
doc_key TEXT, -- which lecture note this belongs to
payload TEXT, -- JSON: blockId, start, end, selectedText, color
updated_at INTEGER,
deleted INTEGER DEFAULT 0
);
CREATE INDEX idx_annotations_doc_key ON annotations(doc_key);That index was added specifically to stop an O(total-annotations) scan every time a note opened — a real regression, not a hypothetical one, fixed in a later migration once the table grew. Rust exposes db_annotations, db_put_annotation, and db_delete_annotation; the frontend's useAnnotations hook layers a localStorage cache over SQLite as the source of truth, with tombstoned deletes so a delete on one sync doesn't resurrect on another.
One honest gap: desktop annotations do not currently sync back to Firestore. A outbox table exists in the schema for exactly that purpose but nothing in the Rust backend writes to it yet — it's reserved, not wired. Until that lands, annotations made offline on desktop stay local to that machine; only content (the notes themselves) syncs both ways, per Desktop Features & Data Flows.
Annotation UI
Selecting text raises a small toolbar for choosing a highlight color and, optionally, attaching a note. Existing highlights respond to hover with a tooltip showing the attached note, and to click with an edit/delete popover. That popover renders through createPortal to document.body — the original version rendered inside the notes scroll container, whose overflow: hidden clipped it near the edges of the screen, and portaling out of that container is the general pattern reused wherever else the app needs floating UI that must not be clipped by an ancestor.
Revision integration
Marking a lecture section or annotation for revision creates an entry consumed by the spaced-repetition scheduler covered in Dashboard & Progress Tracking — the learning workspace feeds retention, it isn't a dead-end reader.
Self-assessment
Word count for this page: 809 words.
← Previous: Dashboard & Progress Tracking→ Next: Dark Mode, Split-Pane & Search