From 6438648313a81e6d3105ae31a1b3de4948a4adae Mon Sep 17 00:00:00 2001 From: marc Date: Mon, 10 Aug 2026 13:08:20 +0200 Subject: [PATCH] Pin notes to passages in a PDF, and put the notes control with the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel beside a PDF is for notes about the document; a note that belongs to one paragraph is a different thing, and one that has to begin "on page 14, third paragraph" has already lost its place. Click Randnotiz, click the passage, write. Pins ride in the same per-page overlay as the drawings, in PDF points, so one stays on its sentence at every zoom and in the exported file. Living outside the note body has consequences that each needed handling: nothing else would index them for search, so the viewer feeds them in with their page numbers; clearing a page's ink must not take them; and "is this overlay empty?" was answered by counting strokes — in the client *and* independently in the server, so an overlay holding only pinned notes was stored as NULL and lost every note on it. They are stamped into the annotated export as a numbered marker plus a box of text, on whichever side of the pin has room. Standard PDF fonts are Latin-1, so characters outside it are replaced rather than failing the whole export. The Notizen control moves into the viewer's toolbar. A second row for one button cost reading height on every screen and put one of the document's controls somewhere none of the others were. On a phone, where the viewer is not on screen while the notes are, the way back sits in the panel. Renaming a separator no longer shifts the row: the two rules always share what is left over equally and the middle keeps one width in both states. 215 unit tests, 206 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 --- backend/server.js | 31 ++- e2e/pdf.mjs | 213 +++++++++++++- frontend/src/export/exportNotes.ts | 10 +- frontend/src/features/notes/NoteList.jsx | 127 +++++---- frontend/src/features/pdf/PdfNotesPanel.jsx | 39 ++- frontend/src/features/pdf/PdfPins.jsx | 261 ++++++++++++++++++ frontend/src/features/pdf/PdfViewer.jsx | 158 ++++++++++- frontend/src/features/pdf/PdfWorkspace.jsx | 107 +++---- frontend/src/features/pdf/pageStrokes.test.ts | 97 +++++++ frontend/src/features/pdf/pageStrokes.ts | 77 +++++- frontend/src/features/pdf/stampPdf.ts | 115 +++++++- frontend/src/search.test.ts | 49 ++++ frontend/src/search.ts | 53 +++- frontend/src/ui/ExportDialog.jsx | 10 +- frontend/src/ui/icons.jsx | 14 + readme.md | 37 ++- 16 files changed, 1228 insertions(+), 170 deletions(-) create mode 100644 frontend/src/features/pdf/PdfPins.jsx create mode 100644 frontend/src/features/pdf/pageStrokes.test.ts diff --git a/backend/server.js b/backend/server.js index 01828b7..56ff0bd 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1254,6 +1254,30 @@ function countStrokes(doc) { return null; } +/* Notes pinned to spots in a PDF. They ride in the same overlay as the strokes + * and are just as much the reader's work, so "is there anything here" has to ask + * about both — storing an overlay that holds only pinned notes as null threw + * every one of them away. */ +const MAX_PINNED_NOTES = 500; +const MAX_PINNED_LENGTH = 4000; + +function countPinnedNotes(doc) { + if (!doc || typeof doc !== "object" || !doc.pages || typeof doc.pages !== "object") return 0; + let n = 0; + for (const page of Object.values(doc.pages)) { + if (!page || page.notes === undefined) continue; + if (!Array.isArray(page.notes)) return null; + for (const pin of page.notes) { + if (!pin || typeof pin.id !== "string" || !Number.isFinite(pin.x) || !Number.isFinite(pin.y)) { + return null; + } + if (typeof pin.text === "string" && pin.text.length > MAX_PINNED_LENGTH) return null; + } + n += page.notes.length; + } + return n; +} + app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => { const { drawingStrokes, drawingData } = req.body; const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); @@ -1262,12 +1286,17 @@ app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => { if (drawingStrokes !== undefined) { // Vector strokes in the note's canonical coordinate space — the current format. let count = 0; + let pinned = 0; if (drawingStrokes !== null) { count = countStrokes(drawingStrokes); if (count === null) return res.status(400).json({ error: "Invalid strokes format" }); if (count > MAX_STROKES) return res.status(413).json({ error: "Too many strokes" }); + pinned = countPinnedNotes(drawingStrokes); + if (pinned === null) return res.status(400).json({ error: "Invalid pinned notes format" }); + if (pinned > MAX_PINNED_NOTES) return res.status(413).json({ error: "Too many pinned notes" }); } - const empty = !drawingStrokes || count === 0; + // A page carrying only pinned notes is not an empty overlay. + const empty = !drawingStrokes || (count === 0 && pinned === 0); const previous = db.prepare("SELECT drawing_strokes FROM notes WHERE id = ?").get(req.params.id); // Clearing a drawing is always worth being able to undo. snapshotNote(req.userId, req.params.id, req.deviceId, { diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs index 6ab1543..bd7010b 100644 --- a/e2e/pdf.mjs +++ b/e2e/pdf.mjs @@ -5,6 +5,7 @@ * the right proportions, and that a document read once survives without the * network — the offline promise is the whole reason the bytes are cached. */ import { readFileSync, writeFileSync } from "node:fs"; +import { inflateSync } from "node:zlib"; import { captureDownloads, check, @@ -19,6 +20,50 @@ import { const PDF_PATH = "/tmp/e2e-test.pdf"; +/** + * The text inside a PDF's content streams. + * + * pdf-lib flate-compresses what it writes, so searching the raw bytes for a word + * finds nothing whether it is in there or not. Every stream is inflated and the + * results joined; ones that are not compressed (or not inflatable) are taken as + * they are. + */ +function inflatedText(bytes) { + const out = []; + const open = Buffer.from("stream"); + const close = Buffer.from("endstream"); + let at = 0; + while ((at = bytes.indexOf(open, at)) !== -1) { + // "endstream" contains "stream"; matching it would start the next block + // three bytes into a keyword and inflate garbage. + if (at >= 3 && bytes.subarray(at - 3, at).toString("latin1") === "end") { + at += open.length; + continue; + } + let start = at + open.length; + if (bytes[start] === 0x0d) start++; + if (bytes[start] === 0x0a) start++; + const end = bytes.indexOf(close, start); + if (end === -1) break; + const chunk = bytes.subarray(start, end); + try { + out.push(inflateSync(chunk).toString("latin1")); + } catch { + out.push(chunk.toString("latin1")); // stored, not compressed + } + at = end + close.length; + } + /* Text is written as `<48656C6C6F> Tj` — hex, because the font's encoding is + not necessarily Latin-1. Decoded here so a check can look for a word. */ + return out + .join("\n") + .replace(/<([0-9A-Fa-f]{4,})>/g, (whole, hex) => + hex.length % 2 + ? whole + : Buffer.from(hex, "hex").toString("latin1") + ); +} + /** A two-page PDF built here, so the suite depends on no fixture file. */ function writeTestPdf() { const objs = [ @@ -322,7 +367,7 @@ try { /* The same document with the strokes written in. */ await openExport(); await clearDownloads(p); - await p.getByRole("button", { name: /PDF mit den Zeichnungen/ }).click(); + await p.getByRole("button", { name: /PDF mit deinen Anmerkungen/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(1500); files = await downloads(p); @@ -411,6 +456,172 @@ try { ); await p.screenshot({ path: "/out/pdf-offline.png" }); await p.context().setOffline(false); + /* ── notes pinned to a passage ── */ + // The panel beside the document is for notes about the document; this is for + // a note about the paragraph you were reading. + await p.locator("[data-pdf-hide-ink]").click().catch(() => {}); // ink out of the way + await p.waitForTimeout(400); + await p.locator("[data-pdf-hide-ink]").click().catch(() => {}); + await p.waitForTimeout(400); + + check( + (await p.locator("[data-pdf-pin-mode]").count()) === 1, + "the viewer offers pinning a note to a spot" + ); + + await p.locator("[data-pdf-pin-mode]").click(); + await p.waitForTimeout(400); + check( + (await p.locator("[data-pdf-pin-mode]").getAttribute("data-pdf-pin-mode")) === "on", + "and says it is waiting for a spot" + ); + + const page1 = await p.locator('[data-pdf-page="1"]').boundingBox(); + await p.mouse.click(page1.x + page1.width * 0.4, page1.y + page1.height * 0.3); + await p.waitForTimeout(800); + + check((await p.locator("[data-pin]").count()) === 1, "clicking the page leaves a pin"); + const bubble = p.getByLabel("Pinned note"); + check(await bubble.isVisible(), "and opens straight into writing"); + + await bubble.fill("Drehmoment beachten"); + await p.getByRole("button", { name: "Fertig" }).click(); + await p.waitForTimeout(2500); + + /* It has to be on the passage, not near it — so the position is checked + against the page rather than merely being non-zero. */ + const placed = await p.evaluate(() => { + const pin = document.querySelector("[data-pin]").getBoundingClientRect(); + const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect(); + return { x: (pin.x + pin.width / 2 - page.x) / page.width, y: (pin.y + pin.height / 2 - page.y) / page.height }; + }); + check( + Math.abs(placed.x - 0.4) < 0.03 && Math.abs(placed.y - 0.3) < 0.03, + "the pin sits where the page was clicked", + `${placed.x.toFixed(2)} / ${placed.y.toFixed(2)}` + ); + + /* Stored in PDF points, so zooming must not move it off its passage — the same + property the strokes have. */ + await p.getByTitle("Größer").click(); + await p.waitForTimeout(2000); + const zoomed = await p.evaluate(() => { + const pin = document.querySelector("[data-pin]").getBoundingClientRect(); + const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect(); + return { x: (pin.x + pin.width / 2 - page.x) / page.width, y: (pin.y + pin.height / 2 - page.y) / page.height }; + }); + check( + Math.abs(zoomed.x - placed.x) < 0.02 && Math.abs(zoomed.y - placed.y) < 0.02, + "and stays there when the document is zoomed", + `${zoomed.x.toFixed(2)} / ${zoomed.y.toFixed(2)}` + ); + await p.getByTitle("Kleiner").click(); + await p.waitForTimeout(1200); + + /* Searchable: it lives in the page overlay, not in the note body, so nothing + else would ever index it. */ + await p.getByPlaceholder("Search notes...").fill("Drehmoment"); + await p.waitForTimeout(1500); + check( + (await p.locator("[data-note-row]").count()) === 1, + "a pinned note can be searched for", + String(await p.locator("[data-note-row]").count()) + ); + await p.getByPlaceholder("Search notes...").fill(""); + await p.waitForTimeout(1000); + + /* It belongs to the note, so it has to come back from the server. */ + 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); + check((await p.locator("[data-pin]").count()) === 1, "the pin survives a reload"); + + await p.locator("[data-pin]").click(); + await p.waitForTimeout(600); + check( + (await p.getByLabel("Pinned note").inputValue()) === "Drehmoment beachten", + "with the text that was written into it" + ); + + /* Clearing a page's ink must not take its pinned notes: same page, different + thing. */ + await p.keyboard.press("Escape"); + await p.waitForTimeout(400); + await p.locator("[data-pdf-mode]").click(); + await p.waitForTimeout(500); + await p.getByTitle("Seite leeren").click(); + await p.waitForTimeout(2000); + check((await p.locator("[data-pin]").count()) === 1, "emptying the page's ink leaves the pinned note"); + await p.locator("[data-pdf-mode]").click(); + await p.waitForTimeout(500); + + await p.locator("[data-pin]").click(); + await p.waitForTimeout(600); + await p.locator("[data-pin-remove]").click(); + await p.waitForTimeout(2000); + check((await p.locator("[data-pin]").count()) === 0, "a pinned note can be removed again"); + + /* An export that dropped the reader's own words would lose work as surely as + one that dropped the drawings. Written back in before the pin is removed. */ + await p.locator("[data-pdf-pin-mode]").click(); + await p.waitForTimeout(400); + const page1b = await p.locator('[data-pdf-page="1"]').boundingBox(); + await p.mouse.click(page1b.x + page1b.width * 0.75, page1b.y + page1b.height * 0.7); + await p.waitForTimeout(700); + await p.getByLabel("Pinned note").fill("Randbemerkung Zebrastreifen"); + await p.getByRole("button", { name: "Fertig" }).click(); + await p.waitForTimeout(2500); + + /* The server keeps its own judgement of "is this overlay empty". It counted + strokes only, so an overlay holding just pinned notes was stored as null and + every note in it was lost. */ + const overlay = await p.evaluate(async () => { + const t = localStorage.getItem("inknote_token"); + const notes = await (await fetch("/api/notes", { headers: { Authorization: "Bearer " + t } })).json(); + const d = await (await fetch("/api/notes/" + notes[0].id, { headers: { Authorization: "Bearer " + t } })).json(); + return JSON.stringify(d.drawingStrokes ?? null); + }); + check( + overlay.includes("Randbemerkung"), + "the server stores an overlay that holds only pinned notes", + overlay.slice(0, 120) + ); + + await captureDownloads(p); + await clearDownloads(p); + await p.getByTitle("Export").click(); + await p.waitForSelector("[data-export-dialog]", { timeout: 10000 }); + await p.waitForTimeout(800); + await p.getByRole("button", { name: /PDF mit deinen Anmerkungen/ }).click(); + await p.waitForSelector("[data-export-done]", { timeout: 30000 }); + await p.waitForTimeout(1500); + const withPins = Buffer.from((await downloads(p))[0].base64, "base64"); + check(withPins.subarray(0, 5).toString() === "%PDF-", "the export still produces a PDF"); + check( + inflatedText(withPins).includes("Randbemerkung"), + "and the pinned note's own words are written into it", + `${withPins.length} bytes` + ); + await p.keyboard.press("Escape"); + await p.waitForTimeout(800); + + + + /* ── the notes panel lives in the same toolbar now ── */ + check( + (await p.locator("[data-pdf-toggle-notes]").count()) === 1, + "the notes control sits with the document's other controls" + ); + const sameBar = await p.evaluate(() => { + const notes = document.querySelector("[data-pdf-toggle-notes]"); + const mode = document.querySelector("[data-pdf-mode]"); + return notes && mode ? notes.parentElement === mode.parentElement : false; + }); + check(sameBar, "in the one toolbar, not in a row of its own above it"); + } catch (e) { check(false, "suite ran to the end", e.message); } finally { diff --git a/frontend/src/export/exportNotes.ts b/frontend/src/export/exportNotes.ts index 17aff8d..0dc97b6 100644 --- a/frontend/src/export/exportNotes.ts +++ b/frontend/src/export/exportNotes.ts @@ -13,7 +13,7 @@ import { parsingExtensions } from "../editor/extensions.js"; import { CANON_W, CANON_H, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js"; import type { NoteContent, NoteMeta, Notebook } from "../types"; import { noteToMarkdown, safeFilename } from "./markdown"; -import { asPdfStrokeDoc, totalStrokes } from "../features/pdf/pageStrokes"; +import { asPdfStrokeDoc, hasOverlay } from "../features/pdf/pageStrokes"; import { stampStrokesIntoPdf } from "../features/pdf/stampPdf"; import { printNotes, type PrintableNote } from "./print"; import { bytesToBlob, createZip, download, type ZipEntry } from "./zip"; @@ -103,7 +103,9 @@ export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise { try { const { content } = await loadNote(meta); - return totalStrokes(asPdfStrokeDoc(content.drawingStrokes)) > 0; + return hasOverlay(asPdfStrokeDoc(content.drawingStrokes)); } catch { return false; } diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index 7e3349a..0e7ba38 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -35,6 +35,9 @@ const DropLine = ({ T }) => (
); +/** Width the separator's label keeps in both states, so renaming moves nothing. */ +const MIDDLE_W = 132; + /** * A line drawn between notes, with an optional name. * @@ -61,64 +64,73 @@ function SeparatorRow({ T, separator, onRename, onRemove }) { cursor: "grab", }} > -
- {editing ? ( - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === "Enter") commit(); - if (e.key === "Escape") { + {/* The two rules always share what is left over equally, and the middle + keeps one width whether it is showing a label or an input. Sizing it to + its contents made the whole row jump sideways the moment renaming + started, which is exactly when the eye is on it. */} +
+
+ {editing ? ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { + setDraft(separator.label); + setEditing(false); + } + }} + aria-label="Separator label" + placeholder="Abschnitt" + style={{ + width: "100%", + minWidth: 0, + border: `1px solid ${T.border}`, + borderRadius: 6, + background: "transparent", + color: T.textSec, + fontSize: 10.5, + fontWeight: 700, + letterSpacing: 0.6, + textAlign: "center", + // Matches the button's box exactly, so nothing shifts vertically + // either when the input replaces it. + padding: "1px 4px", + outline: "none", + }} + /> + ) : ( + - )} -
+ setEditing(true); + }} + title="Umbenennen" + style={{ + width: "100%", + border: "1px solid transparent", + background: "transparent", + color: separator.label ? T.textSec : T.faint, + fontSize: 10.5, + fontWeight: 700, + letterSpacing: 0.6, + textTransform: "uppercase", + textAlign: "center", + cursor: "text", + padding: "1px 4px", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }} + > + {separator.label || "Benennen"} + + )} +
+
+ ) : ( + + {I.text} Notizen + + )} {editor && }
diff --git a/frontend/src/features/pdf/PdfPins.jsx b/frontend/src/features/pdf/PdfPins.jsx new file mode 100644 index 0000000..3eea4d4 --- /dev/null +++ b/frontend/src/features/pdf/PdfPins.jsx @@ -0,0 +1,261 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Notes pinned to a spot on a PDF page. + * + * The panel beside the document is for notes about the document; this is for + * notes about a *passage* — the paragraph you were reading when you thought of + * it. A note that has to say "on page 14, third paragraph" is a note that has + * lost its place. + * + * Positions are stored in PDF points, the same space as the drawing, so a pin + * stays on its sentence at every zoom and on every screen. + */ + +/** Where a pin's bubble opens, kept inside the page rather than off its edge. */ +function bubbleSide(xFraction) { + return xFraction > 0.6 ? "right" : "left"; +} + +function PinBubble({ T, pin, onChange, onClose, onRemove, side }) { + const ref = useRef(null); + const [draft, setDraft] = useState(pin.text); + + // Committed on the way out rather than on every keystroke: each save is a + // write of the whole overlay, and one per character would be absurd. + const commit = () => { + if (draft !== pin.text) onChange(draft); + }; + + useEffect(() => { + const away = (e) => { + if (ref.current && !ref.current.contains(e.target)) { + commit(); + onClose(); + } + }; + // Deferred, or the click that opened the bubble closes it again. + const t = setTimeout(() => document.addEventListener("pointerdown", away), 0); + return () => { + clearTimeout(t); + document.removeEventListener("pointerdown", away); + }; + }); + + return ( +
e.stopPropagation()} + style={{ + position: "absolute", + top: 26, + [side]: 0, + width: 230, + zIndex: 30, + background: T.bar, + border: `1px solid ${T.border}`, + borderRadius: 10, + boxShadow: "0 10px 26px rgba(0,0,0,.3)", + padding: 8, + }} + > +