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, + }} + > +