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