diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs
index 4488ff3..e0024ed 100644
--- a/e2e/pdf.mjs
+++ b/e2e/pdf.mjs
@@ -236,6 +236,46 @@ try {
await p.waitForTimeout(9000);
check((await inkOnPage(1)) > 100, "and is still there after the sync poller has run");
+ /* ── notes beside the document ── */
+ check((await p.locator("[data-pdf-notes]").count()) === 0, "the notes panel starts folded away");
+ await p.locator("[data-pdf-toggle-notes]").click();
+ await p.waitForTimeout(1200);
+ check((await p.locator("[data-pdf-notes]").count()) === 1, "it opens beside the document");
+ check((await p.locator("[data-pdf-page]").count()) > 0, "and the document is still there next to it");
+
+ const noteText = "Wichtig auf Seite eins: Randbemerkung.";
+ await p.locator("[data-pdf-notes] .ProseMirror").click();
+ await p.keyboard.type(noteText);
+ await p.waitForTimeout(2200);
+
+ await p.locator("[data-pdf-toggle-notes]").click();
+ await p.waitForTimeout(600);
+ check((await p.locator("[data-pdf-notes]").count()) === 0, "and folds away again without losing anything");
+
+ /* Written notes belong to the note, so they survive like any other text. */
+ await p.reload({ waitUntil: "networkidle" });
+ await p.waitForTimeout(2500);
+ await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
+ await p.waitForTimeout(1200);
+ await p.locator("[data-note-row]").first().click();
+ await p.waitForTimeout(4000);
+ await p.locator("[data-pdf-toggle-notes]").click();
+ await p.waitForTimeout(1500);
+ check(
+ (await p.locator("[data-pdf-notes] .ProseMirror").innerText()).includes("Randbemerkung"),
+ "the notes survived a reload"
+ );
+ await p.screenshot({ path: "/out/pdf-with-notes.png" });
+
+ /* And they are searchable, alongside the document's own text. */
+ await p.getByPlaceholder("Search notes...").fill("Randbemerkung");
+ await p.waitForTimeout(1500);
+ check((await p.locator("[data-note-row]").count()) === 1, "notes written beside a PDF are searchable");
+ await p.getByPlaceholder("Search notes...").fill("");
+ await p.waitForTimeout(1000);
+ await p.locator("[data-note-row]").first().click();
+ await p.waitForTimeout(3000);
+
/* ── it stays readable offline ── */
// A reload lands on the dashboard, which has no note list; the notebook has
// to be opened first before a row exists to click.
diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx
index 95a5708..cc03867 100644
--- a/frontend/src/features/notes/EditorPane.jsx
+++ b/frontend/src/features/notes/EditorPane.jsx
@@ -2,7 +2,7 @@ import { I } from "../../ui/icons.jsx";
import { BackButton } from "../../ui/BackButton.jsx";
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
import { NoteEditor } from "./NoteEditor.jsx";
-import { PdfViewer } from "../pdf/PdfViewer.jsx";
+import { PdfWorkspace } from "../pdf/PdfWorkspace.jsx";
const toolbarButton = (T, color) => ({
display: "flex",
@@ -212,7 +212,14 @@ export function EditorPane({
{note.type === "pdf" ? (
-
+
) : (
onUpdateRef.current?.({ content, links }),
+ searchLinkTargets,
+ });
+
+ // A panel that opens focused would steal the first keystroke from the reader,
+ // who is usually here to read rather than to write.
+ useEffect(() => {
+ if (editor && !editor.isDestroyed) editor.commands.blur();
+ }, [editor]);
+
+ return (
+
+
+
+ {I.text} Notizen
+
+ {editor && }
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/pdf/PdfWorkspace.jsx b/frontend/src/features/pdf/PdfWorkspace.jsx
new file mode 100644
index 0000000..d8e802d
--- /dev/null
+++ b/frontend/src/features/pdf/PdfWorkspace.jsx
@@ -0,0 +1,118 @@
+import { useEffect, useState } from "react";
+
+import { PdfViewer } from "./PdfViewer.jsx";
+import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
+import { I } from "../../ui/icons.jsx";
+import { FONT_UI } from "../../ui/theme";
+
+const MOBILE_BREAKPOINT = 900;
+
+/**
+ * A PDF and the notes taken beside it.
+ *
+ * Side by side on a wide screen, one at a time on a narrow one — a document
+ * squeezed into half of a phone is not readable, and neither is what you write
+ * about it.
+ *
+ * Both halves can be hidden. That is not decoration: reading the original
+ * without one's own marks in the way is a normal thing to want, and so is
+ * writing without the document taking two thirds of the screen.
+ */
+export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) {
+ const [showNotes, setShowNotes] = useState(false);
+ const [narrow, setNarrow] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
+
+ useEffect(() => {
+ const onResize = () => setNarrow(window.innerWidth < MOBILE_BREAKPOINT);
+ window.addEventListener("resize", onResize);
+ return () => window.removeEventListener("resize", onResize);
+ }, []);
+
+ const T = {
+ bar: dark ? "#161b22" : "#fff",
+ border: dark ? "#30363d" : "#d0d7de",
+ muted: dark ? "#8b949e" : "#656d76",
+ accent: dark ? "#388bfd" : "#0969da",
+ };
+
+ // On a narrow screen the two are alternatives, not neighbours.
+ const showPdf = !narrow || !showNotes;
+
+ const tab = (active, onClick, label, icon, attr) => (
+
+ );
+
+ return (
+
+
+ {narrow
+ ? // Two tabs: whichever is showing is the one you are in.
+ [
+ tab(showPdf, () => setShowNotes(false), "Dokument", I.pdf, { "data-pdf-tab": "pdf" }),
+ tab(showNotes, () => setShowNotes(true), "Notizen", I.text, { "data-pdf-tab": "notes" }),
+ ]
+ : // Wide: the document is always there, the notes fold in and out.
+ tab(showNotes, () => setShowNotes((v) => !v), "Notizen", I.text, {
+ "data-pdf-toggle-notes": showNotes ? "on" : "off",
+ title: showNotes ? "Notizen ausblenden" : "Notizen einblenden",
+ })}
+
+
+
+ {showPdf && (
+
+
+
+ )}
+ {showNotes && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/features/pdf/pdfText.ts b/frontend/src/features/pdf/pdfText.ts
index 79da138..25ac905 100644
--- a/frontend/src/features/pdf/pdfText.ts
+++ b/frontend/src/features/pdf/pdfText.ts
@@ -75,9 +75,14 @@ export async function extractAndCache(
return result;
}
-/** Puts a PDF's text into the search index under its note. */
+/**
+ * Puts a PDF's text into the search index under its note.
+ *
+ * Kept apart from what the reader typed beside it, so re-reading the document
+ * does not drop their notes and typing does not drop the document.
+ */
export function indexPdfText(noteId: string, title: string, text: PdfText): void {
- searchIndex.setEntry(noteId, title, text.full);
+ searchIndex.setAttachedText(noteId, title, text.full);
}
/** Page numbers containing a term, so a hit can be jumped to. */
diff --git a/frontend/src/search.test.ts b/frontend/src/search.test.ts
index 45cc046..7227995 100644
--- a/frontend/src/search.test.ts
+++ b/frontend/src/search.test.ts
@@ -74,6 +74,58 @@ describe("search", () => {
});
});
+describe("attached document text", () => {
+ /* A PDF note has two sources of text, written at different moments by
+ * different code. Before these, whichever ran last was the only searchable
+ * half — typing a note dropped the document, re-reading it dropped the note. */
+ it("finds a note by a word from its attached document", () => {
+ search.setEntry("p1", "Handbuch", body("Meine eigene Notiz."));
+ search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
+ expect(search.search("Zündkerze")?.map((h) => h.id)).toEqual(["p1"]);
+ search.removeEntry("p1");
+ });
+
+ it("and by a word the user typed beside it", () => {
+ search.setEntry("p1", "Handbuch", body("Meine eigene Notiz."));
+ search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
+ expect(search.search("eigene")?.map((h) => h.id)).toEqual(["p1"]);
+ search.removeEntry("p1");
+ });
+
+ it("keeps the document text when the note is edited afterwards", () => {
+ search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
+ search.setEntry("p1", "Handbuch", body("Ganz neuer Notiztext."));
+ expect(search.search("Zündkerze")?.map((h) => h.id)).toEqual(["p1"]);
+ expect(search.search("neuer")?.map((h) => h.id)).toEqual(["p1"]);
+ search.removeEntry("p1");
+ });
+
+ it("keeps the note when the document is re-read afterwards", () => {
+ search.setEntry("p1", "Handbuch", body("Meine eigene Notiz."));
+ search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
+ search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze und einen Riemen.");
+ expect(search.search("eigene")?.map((h) => h.id)).toEqual(["p1"]);
+ expect(search.search("Riemen")?.map((h) => h.id)).toEqual(["p1"]);
+ search.removeEntry("p1");
+ });
+
+ it("shows the sentence a document hit was found in, not an unrelated excerpt", () => {
+ search.setEntry("p1", "Handbuch", body("Kurze Notiz."));
+ search.setAttachedText("p1", "Handbuch", "Vorher. Der Motor hat eine Zündkerze. Nachher.");
+ const hit = search.search("Zündkerze")?.[0];
+ expect(hit?.snippet).toMatch(/Zündkerze/);
+ search.removeEntry("p1");
+ });
+
+ it("clears the attached text when it is set to empty", () => {
+ search.setEntry("p1", "Handbuch", body("Notiz."));
+ search.setAttachedText("p1", "Handbuch", "Zündkerze");
+ search.setEntry("p1", "Handbuch", body("Notiz."), "");
+ expect(search.search("Zündkerze")).toEqual([]);
+ search.removeEntry("p1");
+ });
+});
+
describe("contentToText", () => {
it("reads a v2 document", () => {
expect(search.contentToText(body("hello world"))).toBe("hello world");
diff --git a/frontend/src/search.ts b/frontend/src/search.ts
index e61fb02..09bf556 100644
--- a/frontend/src/search.ts
+++ b/frontend/src/search.ts
@@ -21,6 +21,8 @@ interface IndexEntry {
text: string;
/** Original body text, for building the snippet shown to the user. */
raw: string;
+ /** Text from an attached document, kept apart from what the user typed. */
+ attachedRaw: string;
}
const _index = new Map();
@@ -55,9 +57,45 @@ export function contentToText(raw: string | null | undefined): string {
return `${el.textContent || ""} ${tasks}`.replace(/\s+/g, " ").trim();
}
-export function setEntry(id: string, title: string, content?: string | null): void {
+/**
+ * Adds or replaces a note in the index.
+ *
+ * A PDF note has two sources of text — what the reader wrote and what the
+ * document says — updated at different moments by different code. `attached`
+ * is only replaced when given, so typing a note does not drop the document's
+ * text and re-reading the document does not drop the note. Passing one and
+ * forgetting the other used to leave whichever ran last as the only searchable
+ * half.
+ */
+export function setEntry(
+ id: string,
+ title: string,
+ content?: string | null,
+ attached?: string | null
+): void {
const raw = contentToText(content);
- _index.set(id, { title: normalize(title), text: normalize(raw), raw });
+ const attachedRaw = attached === undefined ? (_index.get(id)?.attachedRaw ?? "") : (attached ?? "");
+ _index.set(id, {
+ title: normalize(title),
+ text: normalize(`${raw} ${attachedRaw}`),
+ raw,
+ attachedRaw,
+ });
+}
+
+/** Replaces only the attached document's text, leaving what the user wrote. */
+export function setAttachedText(id: string, title: string, attached: string): void {
+ const existing = _index.get(id);
+ setEntry(id, title, null, attached);
+ // setEntry with null content would blank the written half; restore it.
+ if (existing) {
+ _index.set(id, {
+ title: normalize(title),
+ text: normalize(`${existing.raw} ${attached}`),
+ raw: existing.raw,
+ attachedRaw: attached,
+ });
+ }
}
export function removeEntry(id: string): void {
@@ -115,16 +153,32 @@ export function search(query: string, { limit = 200 }: { limit?: number } = {}):
}));
}
-/** A short excerpt around the first matching term, for the results list. */
+/**
+ * A short excerpt around the first matching term, for the results list.
+ *
+ * Looks in what the user wrote first, then in an attached document: a hit inside
+ * a PDF should still show the sentence it was found in, rather than an excerpt
+ * of unrelated notes or nothing at all.
+ */
function snippetFor(entry: IndexEntry, terms: string[], radius = 60): string {
- const text = entry.raw;
- if (!text) return "";
- const haystack = normalize(text);
- let at = -1;
- for (const term of terms) {
- const i = haystack.indexOf(term);
- if (i !== -1 && (at === -1 || i < at)) at = i;
+ const firstHit = (source: string) => {
+ if (!source) return -1;
+ const haystack = normalize(source);
+ let at = -1;
+ for (const term of terms) {
+ const i = haystack.indexOf(term);
+ if (i !== -1 && (at === -1 || i < at)) at = i;
+ }
+ return at;
+ };
+
+ let text = entry.raw;
+ let at = firstHit(text);
+ if (at === -1 && entry.attachedRaw) {
+ text = entry.attachedRaw;
+ at = firstHit(text);
}
+ if (!text) return "";
if (at === -1) return text.slice(0, radius * 2).trim();
const start = Math.max(0, at - radius);
diff --git a/readme.md b/readme.md
index e7add3c..1558534 100644
--- a/readme.md
+++ b/readme.md
@@ -17,7 +17,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
- **Undo/Redo** — Full history stack for drawing operations
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
- **Star/Favorite** — Quick access to important notes
-- **PDF Reading** — Import a PDF, read it, select its text, draw on it; the file is cached so it stays readable offline
+- **PDF Reading** — Import a PDF, read it, select its text, draw on it and write notes beside it; the file is cached so it stays readable offline
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
- **Dark & Light Mode** — Full theme support with refined color schemes
@@ -330,7 +330,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
-| `pdf` | 26 | import, page proportions, lazy loading, and reading with the network off |
+| `pdf` | 32 | import, page proportions, lazy loading, and reading with the network off |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
@@ -442,6 +442,31 @@ storage is content-addressed, a cached copy can never go stale.
---
+## Notes beside a PDF
+
+A PDF note is a note that happens to have a document attached, not a separate
+kind of thing: what you write beside it goes into the note's ordinary `content`,
+so it is encrypted, versioned, linkable and searchable like any other note.
+
+The panel is composed from the editor pieces directly rather than reusing
+`NoteEditor`, which carries a whole drawing mode with its own canvas and undo
+stack — the PDF has its own drawing layer, and a second one hidden behind the
+panel would be two places to draw with one of them invisible.
+
+Side by side on a wide screen, two tabs on a narrow one. Both halves can be
+hidden: reading the original without one's own marks in the way is a normal
+thing to want, and so is writing without the document taking two thirds of the
+screen.
+
+**One trap this uncovered.** A PDF note has two sources of searchable text —
+what the reader typed and what the document says — written at different moments
+by different code. Both called `setEntry`, so whichever ran last was the only
+half you could search: typing a note dropped the document's text, and re-reading
+the document dropped the note. The index now keeps them apart and only replaces
+the half it was given.
+
+---
+
## Selecting and searching PDF text
pdf.js draws pages as pictures, so selecting means laying transparent spans over