Pin notes to passages in a PDF, and put the notes control with the rest

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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-10 13:08:20 +02:00
1 parent 2cee00e38e
commit 6438648313
16 files changed
+1152 -94

No files matched your search

+30 -1
View File
@@ -1254,6 +1254,30 @@ function countStrokes(doc) {
return null; 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) => { app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
const { drawingStrokes, drawingData } = req.body; const { drawingStrokes, drawingData } = req.body;
const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); 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) { if (drawingStrokes !== undefined) {
// Vector strokes in the note's canonical coordinate space — the current format. // Vector strokes in the note's canonical coordinate space — the current format.
let count = 0; let count = 0;
let pinned = 0;
if (drawingStrokes !== null) { if (drawingStrokes !== null) {
count = countStrokes(drawingStrokes); count = countStrokes(drawingStrokes);
if (count === null) return res.status(400).json({ error: "Invalid strokes format" }); 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" }); 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); 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. // Clearing a drawing is always worth being able to undo.
snapshotNote(req.userId, req.params.id, req.deviceId, { snapshotNote(req.userId, req.params.id, req.deviceId, {
+212 -1
View File
@@ -5,6 +5,7 @@
* the right proportions, and that a document read once survives without the * the right proportions, and that a document read once survives without the
* network — the offline promise is the whole reason the bytes are cached. */ * network — the offline promise is the whole reason the bytes are cached. */
import { readFileSync, writeFileSync } from "node:fs"; import { readFileSync, writeFileSync } from "node:fs";
import { inflateSync } from "node:zlib";
import { import {
captureDownloads, captureDownloads,
check, check,
@@ -19,6 +20,50 @@ import {
const PDF_PATH = "/tmp/e2e-test.pdf"; 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. */ /** A two-page PDF built here, so the suite depends on no fixture file. */
function writeTestPdf() { function writeTestPdf() {
const objs = [ const objs = [
@@ -322,7 +367,7 @@ try {
/* The same document with the strokes written in. */ /* The same document with the strokes written in. */
await openExport(); await openExport();
await clearDownloads(p); 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.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(1500); await p.waitForTimeout(1500);
files = await downloads(p); files = await downloads(p);
@@ -411,6 +456,172 @@ try {
); );
await p.screenshot({ path: "/out/pdf-offline.png" }); await p.screenshot({ path: "/out/pdf-offline.png" });
await p.context().setOffline(false); 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) { } catch (e) {
check(false, "suite ran to the end", e.message); check(false, "suite ran to the end", e.message);
} finally { } finally {
+6 -4
View File
@@ -13,7 +13,7 @@ import { parsingExtensions } from "../editor/extensions.js";
import { CANON_W, CANON_H, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js"; import { CANON_W, CANON_H, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js";
import type { NoteContent, NoteMeta, Notebook } from "../types"; import type { NoteContent, NoteMeta, Notebook } from "../types";
import { noteToMarkdown, safeFilename } from "./markdown"; 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 { stampStrokesIntoPdf } from "../features/pdf/stampPdf";
import { printNotes, type PrintableNote } from "./print"; import { printNotes, type PrintableNote } from "./print";
import { bytesToBlob, createZip, download, type ZipEntry } from "./zip"; import { bytesToBlob, createZip, download, type ZipEntry } from "./zip";
@@ -103,7 +103,9 @@ export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise<v
const { content } = await loadNote(meta); const { content } = await loadNote(meta);
const ink = asPdfStrokeDoc(content.drawingStrokes); const ink = asPdfStrokeDoc(content.drawingStrokes);
if (totalStrokes(ink) === 0) { // Pinned notes count as much as strokes here: both are the reader's own work
// on the document, and an export that dropped either would lose it silently.
if (!hasOverlay(ink)) {
download(bytesToBlob(bytes, "application/pdf"), `${name}.pdf`); download(bytesToBlob(bytes, "application/pdf"), `${name}.pdf`);
return; return;
} }
@@ -112,11 +114,11 @@ export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise<v
download(bytesToBlob(stamped, "application/pdf"), `${name} (mit Notizen).pdf`); download(bytesToBlob(stamped, "application/pdf"), `${name} (mit Notizen).pdf`);
} }
/** True when the note's PDF carries any drawing, so the wizard can say so. */ /** True when the PDF carries drawings or pinned notes, so the wizard can say so. */
export async function pdfNoteHasInk(meta: NoteMeta): Promise<boolean> { export async function pdfNoteHasInk(meta: NoteMeta): Promise<boolean> {
try { try {
const { content } = await loadNote(meta); const { content } = await loadNote(meta);
return totalStrokes(asPdfStrokeDoc(content.drawingStrokes)) > 0; return hasOverlay(asPdfStrokeDoc(content.drawingStrokes));
} catch { } catch {
return false; return false;
} }
+21 -8
View File
@@ -35,6 +35,9 @@ const DropLine = ({ T }) => (
<div data-drop-line style={{ height: 2, borderRadius: 2, background: T.accent, margin: "2px 4px" }} /> <div data-drop-line style={{ height: 2, borderRadius: 2, background: T.accent, margin: "2px 4px" }} />
); );
/** 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. * A line drawn between notes, with an optional name.
* *
@@ -61,9 +64,12 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
cursor: "grab", cursor: "grab",
}} }}
> >
<div {/* The two rules always share what is left over equally, and the middle
style={{ height: 1, background: T.border, flex: separator.label || editing ? 0 : 1, minWidth: 12 }} 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. */}
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
<div style={{ flex: "0 0 auto", width: MIDDLE_W, display: "flex" }}>
{editing ? ( {editing ? (
<input <input
autoFocus autoFocus
@@ -80,7 +86,7 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
aria-label="Separator label" aria-label="Separator label"
placeholder="Abschnitt" placeholder="Abschnitt"
style={{ style={{
flex: 1, width: "100%",
minWidth: 0, minWidth: 0,
border: `1px solid ${T.border}`, border: `1px solid ${T.border}`,
borderRadius: 6, borderRadius: 6,
@@ -89,7 +95,10 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
fontSize: 10.5, fontSize: 10.5,
fontWeight: 700, fontWeight: 700,
letterSpacing: 0.6, letterSpacing: 0.6,
padding: "2px 6px", textAlign: "center",
// Matches the button's box exactly, so nothing shifts vertically
// either when the input replaces it.
padding: "1px 4px",
outline: "none", outline: "none",
}} }}
/> />
@@ -101,15 +110,17 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
}} }}
title="Umbenennen" title="Umbenennen"
style={{ style={{
border: "none", width: "100%",
border: "1px solid transparent",
background: "transparent", background: "transparent",
color: separator.label ? T.textSec : T.faint, color: separator.label ? T.textSec : T.faint,
fontSize: 10.5, fontSize: 10.5,
fontWeight: 700, fontWeight: 700,
letterSpacing: 0.6, letterSpacing: 0.6,
textTransform: "uppercase", textTransform: "uppercase",
textAlign: "center",
cursor: "text", cursor: "text",
padding: 0, padding: "1px 4px",
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
@@ -118,7 +129,8 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
{separator.label || "Benennen"} {separator.label || "Benennen"}
</button> </button>
)} )}
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 12 }} /> </div>
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
<button <button
onClick={() => onRemove(separator.id)} onClick={() => onRemove(separator.id)}
title="Trenner entfernen" title="Trenner entfernen"
@@ -131,6 +143,7 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
fontSize: 13, fontSize: 13,
lineHeight: 1, lineHeight: 1,
padding: "0 2px", padding: "0 2px",
flexShrink: 0,
}} }}
> >
× ×
+32 -1
View File
@@ -17,7 +17,15 @@ import { FONT_UI } from "../../ui/theme";
* and searchable exactly like any other note — a PDF note is a note that happens * and searchable exactly like any other note — a PDF note is a note that happens
* to have a document attached, not a separate kind of thing. * to have a document attached, not a separate kind of thing.
*/ */
export function PdfNotesPanel({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) { export function PdfNotesPanel({
note,
dark,
onUpdate,
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
onBackToDocument,
}) {
const T = { const T = {
bg: dark ? "#0d1117" : "#fff", bg: dark ? "#0d1117" : "#fff",
bar: dark ? "#161b22" : "#f6f8fa", bar: dark ? "#161b22" : "#f6f8fa",
@@ -72,9 +80,32 @@ export function PdfNotesPanel({ note, dark, onUpdate, onOpenNote, searchLinkTarg
overflowX: "auto", overflowX: "auto",
}} }}
> >
{onBackToDocument ? (
<button
data-pdf-back-to-document
onClick={onBackToDocument}
title="Zurück zum Dokument"
style={{
display: "flex",
alignItems: "center",
gap: 5,
flexShrink: 0,
border: `1px solid ${T.border}`,
borderRadius: 8,
background: "transparent",
color: T.muted,
cursor: "pointer",
fontSize: 11,
padding: "3px 8px",
}}
>
{I.back} Dokument
</button>
) : (
<span style={{ display: "flex", alignItems: "center", gap: 5, paddingRight: 4, flexShrink: 0 }}> <span style={{ display: "flex", alignItems: "center", gap: 5, paddingRight: 4, flexShrink: 0 }}>
{I.text} Notizen {I.text} Notizen
</span> </span>
)}
{editor && <EditorToolbar editor={editor} T={T} icons={I} compact dark={dark} />} {editor && <EditorToolbar editor={editor} T={T} icons={I} compact dark={dark} />}
</div> </div>
+261
View File
@@ -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 (
<div
ref={ref}
data-pin-bubble={pin.id}
onPointerDown={(e) => 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,
}}
>
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // the viewer's shortcuts are not wanted in here
if (e.key === "Escape") {
commit();
onClose();
}
}}
aria-label="Pinned note"
placeholder="Notiz zu dieser Stelle…"
rows={4}
style={{
width: "100%",
resize: "vertical",
border: `1px solid ${T.border}`,
borderRadius: 8,
background: "transparent",
color: T.text,
fontSize: 12,
lineHeight: 1.5,
padding: "6px 8px",
outline: "none",
fontFamily: "inherit",
}}
/>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6 }}>
<button
onClick={onRemove}
data-pin-remove
style={{
border: "none",
background: "transparent",
color: T.danger,
cursor: "pointer",
fontSize: 11,
padding: 0,
}}
>
Entfernen
</button>
<span style={{ flex: 1 }} />
<button
onClick={() => {
commit();
onClose();
}}
style={{
border: "none",
background: T.accent,
color: "#fff",
borderRadius: 8,
cursor: "pointer",
fontSize: 11,
fontWeight: 600,
padding: "4px 12px",
}}
>
Fertig
</button>
</div>
</div>
);
}
/**
* The pins on one page.
*
* Sits above the ink layer but below nothing else, and only takes the pointer
* where a pin actually is — the rest of the page must stay selectable and
* scrollable.
*/
export function PdfPins({
T,
pageNo,
pageSize,
cssWidth,
cssHeight,
notes,
placing,
openId,
onOpen,
onPlace,
onChange,
onMove,
onRemove,
numberOf,
}) {
const layerRef = useRef(null);
const dragging = useRef(null);
const toPoints = (e) => {
const box = layerRef.current.getBoundingClientRect();
return {
x: ((e.clientX - box.left) / box.width) * pageSize.width,
y: ((e.clientY - box.top) / box.height) * pageSize.height,
};
};
/* Dragging a pin is tracked here rather than with the HTML drag API: that one
* cannot report a position continuously, and a pin has to follow the finger to
* be placed on a line of text. */
const startDrag = (e, pin) => {
if (placing) return;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture?.(e.pointerId);
dragging.current = { id: pin.id, moved: false };
};
const onPointerMove = (e) => {
const drag = dragging.current;
if (!drag) return;
drag.moved = true;
const at = toPoints(e);
onMove(drag.id, at.x, at.y);
};
const endDrag = (e, pin) => {
const drag = dragging.current;
dragging.current = null;
if (!drag) return;
e.stopPropagation();
// A press that never moved is a click, and a click opens the note.
if (!drag.moved) onOpen(pin.id === openId ? null : pin.id);
};
return (
<div
ref={layerRef}
data-pdf-pins={pageNo}
onClick={placing ? (e) => onPlace(toPoints(e)) : undefined}
style={{
position: "absolute",
inset: 0,
width: cssWidth,
height: cssHeight,
zIndex: 12,
// Only the pins are targets unless a note is being placed, or the page
// could neither be scrolled nor its text selected.
pointerEvents: placing ? "auto" : "none",
cursor: placing ? "crosshair" : "default",
}}
>
{notes.map((pin) => {
const xf = pin.x / pageSize.width;
const yf = pin.y / pageSize.height;
return (
<div
key={pin.id}
style={{
position: "absolute",
left: `${xf * 100}%`,
top: `${yf * 100}%`,
pointerEvents: "auto",
}}
>
<button
data-pin={pin.id}
title={pin.text || "Notiz zu dieser Stelle"}
onPointerDown={(e) => startDrag(e, pin)}
onPointerMove={onPointerMove}
onPointerUp={(e) => endDrag(e, pin)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: "50%",
border: `2px solid ${T.bar}`,
background: T.accent,
color: "#fff",
fontSize: 11,
fontWeight: 700,
cursor: "grab",
boxShadow: "0 2px 6px rgba(0,0,0,.35)",
transform: "translate(-50%, -50%)",
touchAction: "none",
}}
>
{numberOf(pin.id)}
</button>
{openId === pin.id && (
<PinBubble
T={T}
pin={pin}
side={bubbleSide(xf)}
onChange={(text) => onChange(pin.id, text)}
onClose={() => onOpen(null)}
onRemove={() => onRemove(pin.id)}
/>
)}
</div>
);
})}
</div>
);
}
+149 -9
View File
@@ -1,11 +1,22 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import * as sync from "../../syncEngine"; import * as sync from "../../syncEngine";
import * as searchIndex from "../../search";
import { openPdf, renderPage, renderTextLayer } from "./pdfjs"; import { openPdf, renderPage, renderTextLayer } from "./pdfjs";
import { extractAndCache, indexPdfText } from "./pdfText"; import { extractAndCache, indexPdfText } from "./pdfText";
import "./textLayer.css"; import "./textLayer.css";
import { PdfDrawLayer } from "./PdfDrawLayer.jsx"; import { PdfDrawLayer } from "./PdfDrawLayer.jsx";
import { asPdfStrokeDoc, strokesForPage, totalStrokes, withPage } from "./pageStrokes"; import { PdfPins } from "./PdfPins.jsx";
import {
allPinnedNotes,
asPdfStrokeDoc,
hasOverlay,
notesForPage,
strokesForPage,
totalStrokes,
withPage,
withPageNotes,
} from "./pageStrokes";
import { COLORS } from "../../ui/constants.jsx"; import { COLORS } from "../../ui/constants.jsx";
import { I } from "../../ui/icons.jsx"; import { I } from "../../ui/icons.jsx";
import { PEN_SIZES, DEFAULT_PEN_SIZE, newSaveToken } from "../drawing/strokes.js"; import { PEN_SIZES, DEFAULT_PEN_SIZE, newSaveToken } from "../drawing/strokes.js";
@@ -23,7 +34,7 @@ const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
* list: a PDF page is expensive to draw and cheap to keep, and scrolling back up * list: a PDF page is expensive to draw and cheap to keep, and scrolling back up
* through a document should not repaint everything. * through a document should not repaint everything.
*/ */
function PdfPage({ doc, index, width, height, size, visible, ink, selectable }) { function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectable }) {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const textRef = useRef(null); const textRef = useRef(null);
const renderedAt = useRef(0); const renderedAt = useRef(0);
@@ -85,6 +96,24 @@ function PdfPage({ doc, index, width, height, size, visible, ink, selectable })
hidden={ink.hidden} hidden={ink.hidden}
onCommit={ink.onCommit} onCommit={ink.onCommit}
/> />
{!pins.hidden && (
<PdfPins
T={pins.T}
pageNo={index + 1}
pageSize={size}
cssWidth={width}
cssHeight={height}
notes={pins.notes}
placing={pins.placing}
openId={pins.openId}
onOpen={pins.onOpen}
onPlace={(at) => pins.onPlace(index + 1, size, at)}
onChange={(id, text) => pins.onChange(index + 1, size, id, text)}
onMove={(id, x, y) => pins.onMove(index + 1, size, id, x, y)}
onRemove={(id) => pins.onRemove(index + 1, size, id)}
numberOf={pins.numberOf}
/>
)}
</div> </div>
); );
} }
@@ -97,10 +126,12 @@ function PdfPage({ doc, index, width, height, size, visible, ink, selectable })
* proportions before anything is drawn, so the scrollbar is honest from the * proportions before anything is drawn, so the scrollbar is honest from the
* first frame instead of jumping as pages arrive. * first frame instead of jumping as pages arrive.
*/ */
export function PdfViewer({ note, dark, onUpdate, onPagesReady }) { export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
const [state, setState] = useState({ status: "loading" }); const [state, setState] = useState({ status: "loading" });
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
const [drawing, setDrawing] = useState(false); const [drawing, setDrawing] = useState(false);
const [placing, setPlacing] = useState(false);
const [openPin, setOpenPin] = useState(null);
const [hideInk, setHideInk] = useState(false); const [hideInk, setHideInk] = useState(false);
const [tool, setTool] = useState("pen"); const [tool, setTool] = useState("pen");
const [color, setColor] = useState(COLORS[1]); const [color, setColor] = useState(COLORS[1]);
@@ -137,7 +168,9 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
saveTimer.current = 0; saveTimer.current = 0;
const token = newSaveToken(); const token = newSaveToken();
pendingToken.current = token; pendingToken.current = token;
onUpdate?.({ drawingStrokes: totalStrokes(next) ? { ...next, t: token } : null }); // `hasOverlay`, not `totalStrokes`: a document with pinned notes but no
// ink would otherwise be saved as null and lose every note on it.
onUpdate?.({ drawingStrokes: hasOverlay(next) ? { ...next, t: token } : null });
}, 700); }, 700);
}, },
[onUpdate] [onUpdate]
@@ -167,6 +200,73 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
}, },
[state.sizes, saveInk] [state.sizes, saveInk]
); );
/* ── pinned notes ── */
/* They live in the page overlay, not in the note body, so nothing else would
* ever put them in the search index. Page numbers travel with them: finding
* the words is only half of finding the note. */
useEffect(() => {
const pinned = allPinnedNotes(ink)
.filter((n) => n.text.trim())
.map((n) => `S. ${n.page}: ${n.text}`)
.join(" · ");
searchIndex.setPinnedText(note.id, note.title || "", pinned);
}, [ink, note.id, note.title]);
/** Numbered in reading order, so the pin and the list agree at a glance. */
const pinNumbers = useMemo(() => {
const map = new Map();
allPinnedNotes(ink).forEach((n, i) => map.set(n.id, i + 1));
return map;
}, [ink]);
const writeNotes = useCallback(
(pageNumber, size, change) => {
const current = notesForPage(inkRef.current, pageNumber);
undoRef.current = [...undoRef.current.slice(-40), inkRef.current];
saveInk(withPageNotes(inkRef.current, pageNumber, size, change(current)));
},
[saveInk]
);
const placeNote = useCallback(
(pageNumber, size, at) => {
const id = `pin-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
writeNotes(pageNumber, size, (notes) => [...notes, { id, x: at.x, y: at.y, text: "" }]);
setPlacing(false);
setOpenPin(id); // straight into writing — placing a pin is wanting to write
},
[writeNotes]
);
const changeNote = useCallback(
(pageNumber, size, id, text) =>
writeNotes(pageNumber, size, (notes) => notes.map((n) => (n.id === id ? { ...n, text } : n))),
[writeNotes]
);
const moveNote = useCallback(
(pageNumber, size, id, x, y) =>
writeNotes(pageNumber, size, (notes) =>
notes.map((n) =>
n.id === id
? // Clamped to the page: a pin dragged past the edge would be
// unreachable, and its text with it.
{ ...n, x: Math.max(0, Math.min(x, size.width)), y: Math.max(0, Math.min(y, size.height)) }
: n
)
),
[writeNotes]
);
const removeNote = useCallback(
(pageNumber, size, id) => {
writeNotes(pageNumber, size, (notes) => notes.filter((n) => n.id !== id));
setOpenPin(null);
},
[writeNotes]
);
const [pageNo, setPageNo] = useState(1); const [pageNo, setPageNo] = useState(1);
const [visible, setVisible] = useState(() => new Set([0, 1])); const [visible, setVisible] = useState(() => new Set([0, 1]));
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
@@ -358,11 +458,14 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
</span> </span>
<div style={{ width: 1, height: 18, background: T.border, margin: "0 2px" }} /> <div style={{ width: 1, height: 18, background: T.border, margin: "0 2px" }} />
{/* Reading and drawing are separate modes: while drawing, the ink layer {/* Reading, drawing and pinning are separate modes: each takes the
takes the pointer, and scrolling with a finger still has to work. */} pointer differently, and scrolling with a finger still has to work. */}
<button <button
data-pdf-mode={drawing ? "draw" : "read"} data-pdf-mode={drawing ? "draw" : "read"}
onClick={() => setDrawing((v) => !v)} onClick={() => {
setDrawing((v) => !v);
setPlacing(false);
}}
title={drawing ? "Zeichnen beenden" : "Zeichnen"} title={drawing ? "Zeichnen beenden" : "Zeichnen"}
style={{ style={{
...barButton, ...barButton,
@@ -377,6 +480,28 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
{I.pen} {drawing ? "Zeichnen" : "Lesen"} {I.pen} {drawing ? "Zeichnen" : "Lesen"}
</button> </button>
{/* A note about a passage belongs on the passage. */}
<button
data-pdf-pin-mode={placing ? "on" : "off"}
onClick={() => {
setPlacing((v) => !v);
setDrawing(false);
setOpenPin(null);
}}
title={placing ? "Stelle anklicken — oder abbrechen" : "Notiz an eine Stelle heften"}
style={{
...barButton,
width: "auto",
padding: "0 10px",
gap: 6,
background: placing ? T.accent + "22" : "transparent",
color: placing ? T.accent : T.muted,
fontWeight: 600,
}}
>
{I.pin} {placing ? "Stelle wählen" : "Randnotiz"}
</button>
{drawing && ( {drawing && (
<> <>
{["pen", "highlighter", "eraser"].map((t) => ( {["pen", "highlighter", "eraser"].map((t) => (
@@ -491,6 +616,8 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
</button> </button>
)} )}
{notesToggle}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}> <div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
<button onClick={() => stepZoom(-1)} title="Kleiner" style={barButton}> <button onClick={() => stepZoom(-1)} title="Kleiner" style={barButton}>
− −
@@ -524,7 +651,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
height={Math.round(size.height * scale)} height={Math.round(size.height * scale)}
size={size} size={size}
visible={visible.has(i)} visible={visible.has(i)}
selectable={!drawing} selectable={!drawing && !placing}
ink={{ ink={{
strokes: strokesForPage(ink, i + 1), strokes: strokesForPage(ink, i + 1),
drawing, drawing,
@@ -534,6 +661,19 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
hidden: hideInk, hidden: hideInk,
onCommit: commitPage, onCommit: commitPage,
}} }}
pins={{
T,
notes: notesForPage(ink, i + 1),
placing,
openId: openPin,
hidden: hideInk,
onOpen: setOpenPin,
onPlace: placeNote,
onChange: changeNote,
onMove: moveNote,
onRemove: removeNote,
numberOf: (id) => pinNumbers.get(id) ?? "",
}}
/> />
))} ))}
</div> </div>
+19 -40
View File
@@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
import { PdfViewer } from "./PdfViewer.jsx"; import { PdfViewer } from "./PdfViewer.jsx";
import { PdfNotesPanel } from "./PdfNotesPanel.jsx"; import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
import { I } from "../../ui/icons.jsx"; import { I } from "../../ui/icons.jsx";
import { FONT_UI } from "../../ui/theme";
const MOBILE_BREAKPOINT = 900; const MOBILE_BREAKPOINT = 900;
@@ -14,9 +13,10 @@ const MOBILE_BREAKPOINT = 900;
* squeezed into half of a phone is not readable, and neither is what you write * squeezed into half of a phone is not readable, and neither is what you write
* about it. * about it.
* *
* Both halves can be hidden. That is not decoration: reading the original * The control for the notes sits *in* the viewer's toolbar rather than in a bar
* without one's own marks in the way is a normal thing to want, and so is * of its own above it. A second row for a single button cost a strip of reading
* writing without the document taking two thirds of the screen. * height on every screen and put one of the document's controls somewhere none
* of the others were.
*/ */
export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) { export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) {
const [showNotes, setShowNotes] = useState(false); const [showNotes, setShowNotes] = useState(false);
@@ -29,7 +29,6 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
}, []); }, []);
const T = { const T = {
bar: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de", border: dark ? "#30363d" : "#d0d7de",
muted: dark ? "#8b949e" : "#656d76", muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da", accent: dark ? "#388bfd" : "#0969da",
@@ -38,59 +37,36 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
// On a narrow screen the two are alternatives, not neighbours. // On a narrow screen the two are alternatives, not neighbours.
const showPdf = !narrow || !showNotes; const showPdf = !narrow || !showNotes;
const tab = (active, onClick, label, icon, attr) => ( const notesToggle = (
<button <button
{...attr} data-pdf-toggle-notes={showNotes ? "on" : "off"}
onClick={onClick} onClick={() => setShowNotes((v) => !v)}
title={showNotes ? "Notizen ausblenden" : "Notizen einblenden"}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center",
gap: 6, gap: 6,
padding: "4px 10px", height: 28,
padding: "0 10px",
borderRadius: 8, borderRadius: 8,
border: "none", border: "none",
background: active ? T.accent + "22" : "transparent", background: showNotes ? T.accent + "22" : "transparent",
color: active ? T.accent : T.muted, color: showNotes ? T.accent : T.muted,
cursor: "pointer", cursor: "pointer",
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
}} }}
> >
{icon} {label} {I.text} Notizen
</button> </button>
); );
return ( return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 10px",
borderBottom: `1px solid ${T.border}`,
background: T.bar,
fontFamily: FONT_UI,
flexShrink: 0,
}}
>
{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",
})}
</div>
<div style={{ flex: 1, display: "flex", minHeight: 0, minWidth: 0 }}> <div style={{ flex: 1, display: "flex", minHeight: 0, minWidth: 0 }}>
{showPdf && ( {showPdf && (
<div style={{ flex: 1, display: "flex", minWidth: 0, minHeight: 0 }}> <div style={{ flex: 1, display: "flex", minWidth: 0, minHeight: 0 }}>
<PdfViewer note={note} dark={dark} onUpdate={onUpdate} /> <PdfViewer note={note} dark={dark} onUpdate={onUpdate} notesToggle={notesToggle} />
</div> </div>
)} )}
{showNotes && ( {showNotes && (
@@ -100,6 +76,7 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
borderLeft: narrow ? "none" : `1px solid ${T.border}`, borderLeft: narrow ? "none" : `1px solid ${T.border}`,
display: "flex", display: "flex",
minHeight: 0, minHeight: 0,
minWidth: 0,
}} }}
> >
<PdfNotesPanel <PdfNotesPanel
@@ -109,10 +86,12 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
onOpenNote={onOpenNote} onOpenNote={onOpenNote}
searchLinkTargets={searchLinkTargets} searchLinkTargets={searchLinkTargets}
resolveLinkLabel={resolveLinkLabel} resolveLinkLabel={resolveLinkLabel}
/* On a phone the viewer is not on screen at all, so the way back to
the document has to be here. */
onBackToDocument={narrow ? () => setShowNotes(false) : null}
/> />
</div> </div>
)} )}
</div> </div>
</div>
); );
} }
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
allPinnedNotes,
asPdfStrokeDoc,
emptyPdfDoc,
hasOverlay,
notesForPage,
strokesForPage,
totalPinnedNotes,
totalStrokes,
withPage,
withPageNotes,
} from "./pageStrokes";
import type { Stroke } from "../../types";
const A4 = { width: 595, height: 842 };
const stroke = (): Stroke => ({ t: "pen", c: "#000000", s: 2, p: [10, 10, 20, 20] });
const pin = (id: string, text = "") => ({ id, x: 100, y: 200, text });
describe("pinned notes on a page", () => {
it("are stored beside the strokes, in the same page space", () => {
const doc = withPageNotes(emptyPdfDoc(), 3, A4, [pin("p1", "hier weiterlesen")]);
expect(notesForPage(doc, 3)).toEqual([pin("p1", "hier weiterlesen")]);
expect(doc.pages["3"]!.w).toBe(595);
});
/* The one that would have hurt: erasing the ink from a page must not take the
* notes pinned to it. They are on the same page but they are not the drawing. */
it("survive the page's ink being cleared", () => {
let doc = withPageNotes(emptyPdfDoc(), 1, A4, [pin("p1", "wichtig")]);
doc = withPage(doc, 1, A4, [stroke()]);
expect(totalStrokes(doc)).toBe(1);
doc = withPage(doc, 1, A4, []); // "Seite leeren"
expect(totalStrokes(doc)).toBe(0);
expect(notesForPage(doc, 1)).toEqual([pin("p1", "wichtig")]);
});
it("and conversely, removing every note leaves the ink alone", () => {
let doc = withPage(emptyPdfDoc(), 1, A4, [stroke()]);
doc = withPageNotes(doc, 1, A4, [pin("p1")]);
doc = withPageNotes(doc, 1, A4, []);
expect(strokesForPage(doc, 1)).toHaveLength(1);
});
it("drop the page only once nothing is left on it", () => {
let doc = withPageNotes(emptyPdfDoc(), 1, A4, [pin("p1")]);
doc = withPageNotes(doc, 1, A4, []);
expect(doc.pages["1"]).toBeUndefined();
});
/* The save path asks this before writing. Asking `totalStrokes` instead would
* have written null for a document with notes but no ink — losing all of them. */
it("count as content worth saving, even with no ink anywhere", () => {
const doc = withPageNotes(emptyPdfDoc(), 2, A4, [pin("p1", "x")]);
expect(totalStrokes(doc)).toBe(0);
expect(totalPinnedNotes(doc)).toBe(1);
expect(hasOverlay(doc)).toBe(true);
expect(hasOverlay(emptyPdfDoc())).toBe(false);
});
it("are read back from storage rather than discarded as a strokeless page", () => {
const stored = { v: 2, pages: { "5": { w: 595, h: 842, strokes: [], notes: [pin("p1", "x")] } } };
expect(notesForPage(asPdfStrokeDoc(stored), 5)).toHaveLength(1);
});
it("ignore entries that are not notes rather than failing the whole document", () => {
const stored = {
v: 2,
pages: { "1": { w: 595, h: 842, strokes: [stroke()], notes: [pin("ok"), null, { x: 1 }] } },
};
const doc = asPdfStrokeDoc(stored);
expect(notesForPage(doc, 1).map((n) => n.id)).toEqual(["ok"]);
expect(strokesForPage(doc, 1)).toHaveLength(1);
});
});
describe("allPinnedNotes", () => {
it("lists them in reading order across pages", () => {
let doc = withPageNotes(emptyPdfDoc(), 2, A4, [
{ id: "b", x: 10, y: 400, text: "" },
{ id: "a", x: 10, y: 100, text: "" },
]);
doc = withPageNotes(doc, 1, A4, [{ id: "first", x: 10, y: 50, text: "" }]);
expect(allPinnedNotes(doc).map((n) => n.id)).toEqual(["first", "a", "b"]);
});
it("carries the page each one sits on, which is half of finding it again", () => {
const doc = withPageNotes(emptyPdfDoc(), 7, A4, [pin("p1", "x")]);
expect(allPinnedNotes(doc)[0]!.page).toBe(7);
});
it("is empty for a document nobody has annotated", () => {
expect(allPinnedNotes(emptyPdfDoc())).toEqual([]);
});
});
+73 -4
View File
@@ -16,11 +16,26 @@ import type { Stroke } from "../../types";
export const PDF_STROKES_VERSION = 2; export const PDF_STROKES_VERSION = 2;
/**
* A note pinned to a spot on a page.
*
* `x`/`y` are in the same PDF points as the strokes, which is what keeps the pin
* on its passage at every zoom, on every screen, and in the exported file.
*/
export interface PinnedNote {
id: string;
x: number;
y: number;
text: string;
}
export interface PageStrokes { export interface PageStrokes {
/** Page width in PDF points — the space `strokes` is expressed in. */ /** Page width in PDF points — the space `strokes` is expressed in. */
w: number; w: number;
h: number; h: number;
strokes: Stroke[]; strokes: Stroke[];
/** Notes pinned to this page. Absent on pages that have none. */
notes?: PinnedNote[];
} }
export interface PdfStrokeDoc { export interface PdfStrokeDoc {
@@ -48,30 +63,84 @@ export function asPdfStrokeDoc(stored: unknown): PdfStrokeDoc {
if (!isPdfStrokeDoc(stored)) return emptyPdfDoc(); if (!isPdfStrokeDoc(stored)) return emptyPdfDoc();
const pages: Record<string, PageStrokes> = {}; const pages: Record<string, PageStrokes> = {};
for (const [key, page] of Object.entries(stored.pages)) { for (const [key, page] of Object.entries(stored.pages)) {
if (page && Array.isArray(page.strokes) && page.w > 0 && page.h > 0) pages[key] = page; if (!page || !(page.w > 0) || !(page.h > 0)) continue;
const strokes = Array.isArray(page.strokes) ? page.strokes : [];
const notes = Array.isArray(page.notes) ? page.notes.filter(isPinnedNote) : [];
// A page with only pinned notes is still a page worth keeping. Requiring
// strokes here would have thrown away every note the moment the ink was
// erased from that page.
if (strokes.length === 0 && notes.length === 0) continue;
pages[key] = notes.length ? { ...page, strokes, notes } : { ...page, strokes, notes: undefined };
} }
return { v: PDF_STROKES_VERSION, pages }; return { v: PDF_STROKES_VERSION, pages };
} }
const isPinnedNote = (n: unknown): n is PinnedNote =>
!!n &&
typeof n === "object" &&
typeof (n as PinnedNote).id === "string" &&
Number.isFinite((n as PinnedNote).x) &&
Number.isFinite((n as PinnedNote).y);
export const strokesForPage = (doc: PdfStrokeDoc, pageNo: number): Stroke[] => export const strokesForPage = (doc: PdfStrokeDoc, pageNo: number): Stroke[] =>
doc.pages[String(pageNo)]?.strokes ?? []; doc.pages[String(pageNo)]?.strokes ?? [];
/** A copy of the document with one page replaced. Empty pages are dropped. */ export const notesForPage = (doc: PdfStrokeDoc, pageNo: number): PinnedNote[] =>
doc.pages[String(pageNo)]?.notes ?? [];
/** A copy of the document with one page's strokes replaced. */
export function withPage( export function withPage(
doc: PdfStrokeDoc, doc: PdfStrokeDoc,
pageNo: number, pageNo: number,
size: { width: number; height: number }, size: { width: number; height: number },
strokes: Stroke[] strokes: Stroke[]
): PdfStrokeDoc { ): PdfStrokeDoc {
const key = String(pageNo);
const notes = doc.pages[key]?.notes ?? [];
const pages = { ...doc.pages }; const pages = { ...doc.pages };
if (strokes.length === 0) delete pages[String(pageNo)]; // Clearing the ink must not take the pinned notes with it — they are on the
else pages[String(pageNo)] = { w: size.width, h: size.height, strokes }; // same page but they are not the drawing.
if (strokes.length === 0 && notes.length === 0) delete pages[key];
else pages[key] = { w: size.width, h: size.height, strokes, ...(notes.length ? { notes } : {}) };
return { v: PDF_STROKES_VERSION, pages };
}
/** A copy of the document with one page's pinned notes replaced. */
export function withPageNotes(
doc: PdfStrokeDoc,
pageNo: number,
size: { width: number; height: number },
notes: PinnedNote[]
): PdfStrokeDoc {
const key = String(pageNo);
const strokes = doc.pages[key]?.strokes ?? [];
const pages = { ...doc.pages };
if (strokes.length === 0 && notes.length === 0) delete pages[key];
else pages[key] = { w: size.width, h: size.height, strokes, ...(notes.length ? { notes } : {}) };
return { v: PDF_STROKES_VERSION, pages }; return { v: PDF_STROKES_VERSION, pages };
} }
export const totalStrokes = (doc: PdfStrokeDoc): number => export const totalStrokes = (doc: PdfStrokeDoc): number =>
Object.values(doc.pages).reduce((n, p) => n + p.strokes.length, 0); Object.values(doc.pages).reduce((n, p) => n + p.strokes.length, 0);
export const totalPinnedNotes = (doc: PdfStrokeDoc): number =>
Object.values(doc.pages).reduce((n, p) => n + (p.notes?.length ?? 0), 0);
/**
* Whether the overlay holds anything at all.
*
* The save path used to ask `totalStrokes`, which would have written null the
* moment a document had pinned notes but no ink — losing every note on it.
*/
export const hasOverlay = (doc: PdfStrokeDoc): boolean => totalStrokes(doc) > 0 || totalPinnedNotes(doc) > 0;
/** Every pinned note in the document, in reading order. */
export const allPinnedNotes = (doc: PdfStrokeDoc): Array<PinnedNote & { page: number }> =>
Object.entries(doc.pages)
.flatMap(([key, page]) => (page.notes ?? []).map((n) => ({ ...n, page: Number(key) })))
.filter((n) => Number.isFinite(n.page))
.sort((a, b) => a.page - b.page || a.y - b.y || a.x - b.x);
/** Page numbers that carry ink, in order — used by the export. */ /** Page numbers that carry ink, in order — used by the export. */
export const drawnPages = (doc: PdfStrokeDoc): number[] => export const drawnPages = (doc: PdfStrokeDoc): number[] =>
Object.keys(doc.pages) Object.keys(doc.pages)
+112 -3
View File
@@ -12,8 +12,8 @@
import type { Stroke } from "../../types"; import type { Stroke } from "../../types";
import { HIGHLIGHTER_ALPHA } from "../drawing/strokes.js"; import { HIGHLIGHTER_ALPHA } from "../drawing/strokes.js";
import type { PdfStrokeDoc } from "./pageStrokes"; import type { PdfStrokeDoc, PinnedNote } from "./pageStrokes";
import { drawnPages } from "./pageStrokes"; import { allPinnedNotes, drawnPages } from "./pageStrokes";
/** `#rrggbb` → the 0–1 components pdf-lib wants. */ /** `#rrggbb` → the 0–1 components pdf-lib wants. */
function toRgb(hex: string): { r: number; g: number; b: number } { function toRgb(hex: string): { r: number; g: number; b: number } {
@@ -39,7 +39,7 @@ const opacityFor = (tool: string) => (tool === "highlighter" ? parseInt(HIGHLIGH
* would put grey scribbles where the user had rubbed something out. * would put grey scribbles where the user had rubbed something out.
*/ */
export async function stampStrokesIntoPdf(originalBytes: Uint8Array, ink: PdfStrokeDoc): Promise<Uint8Array> { export async function stampStrokesIntoPdf(originalBytes: Uint8Array, ink: PdfStrokeDoc): Promise<Uint8Array> {
const { PDFDocument, rgb } = await import("pdf-lib"); const { PDFDocument, StandardFonts, rgb } = await import("pdf-lib");
const doc = await PDFDocument.load(originalBytes); const doc = await PDFDocument.load(originalBytes);
const pages = doc.getPages(); const pages = doc.getPages();
@@ -77,5 +77,114 @@ export async function stampStrokesIntoPdf(originalBytes: Uint8Array, ink: PdfStr
} }
} }
await stampPinnedNotes(doc, ink, { StandardFonts, rgb });
return doc.save(); return doc.save();
} }
/* ── pinned notes ── */
const NOTE_BOX_W = 150;
const NOTE_FONT_SIZE = 8;
const NOTE_LINE_H = 10;
const MARKER_R = 8;
/** Greedy wrap at a measured width — the only way to know is to ask the font. */
function wrap(text: string, font: any, size: number, maxWidth: number): string[] {
const lines: string[] = [];
for (const paragraph of text.split("\n")) {
let line = "";
for (const word of paragraph.split(/\s+/).filter(Boolean)) {
const candidate = line ? `${line} ${word}` : word;
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
line = candidate;
} else {
if (line) lines.push(line);
line = word;
}
}
lines.push(line);
}
return lines;
}
/**
* Writes the pinned notes into the exported document.
*
* They are the reader's own words about a passage, so an export that dropped
* them would quietly lose work — the same standard the drawings are held to.
*
* Each becomes a numbered marker on its spot plus a small box holding the text,
* placed on whichever side of the pin has room. The box is opaque, because a
* note printed over the sentence it is about leaves neither readable.
*
* Only Latin-1 survives: the standard PDF fonts cannot encode more, and pdf-lib
* throws rather than guess. One em dash must not cost the whole export, so
* anything outside that range is replaced.
*/
async function stampPinnedNotes(
doc: any,
ink: PdfStrokeDoc,
lib: { StandardFonts: any; rgb: any }
): Promise<void> {
const notes = allPinnedNotes(ink).filter((n) => n.text.trim());
if (notes.length === 0) return;
const { rgb } = lib;
const font = await doc.embedFont(lib.StandardFonts.Helvetica);
const bold = await doc.embedFont(lib.StandardFonts.HelveticaBold);
const pages = doc.getPages();
// Numbered in reading order, exactly as they are on screen.
notes.forEach((note: PinnedNote & { page: number }, index: number) => {
const page = pages[note.page - 1];
const stored = ink.pages[String(note.page)];
if (!page || !stored) return;
const { width, height } = page.getSize();
const sx = width / stored.w;
const sy = height / stored.h;
const x = note.x * sx;
const y = height - note.y * sy; // PDF counts from the bottom
const text = note.text.replace(/[^\u0000-\u00FF]/g, "?");
const lines = wrap(text, font, NOTE_FONT_SIZE, NOTE_BOX_W - 10);
const boxH = lines.length * NOTE_LINE_H + 8;
// Whichever side has room, so a note near the right margin is not cut off.
const boxX = x + MARKER_R + 4 + NOTE_BOX_W > width ? x - MARKER_R - 4 - NOTE_BOX_W : x + MARKER_R + 4;
const boxY = Math.max(4, Math.min(y - boxH / 2, height - boxH - 4));
page.drawRectangle({
x: Math.max(4, boxX),
y: boxY,
width: NOTE_BOX_W,
height: boxH,
color: rgb(1, 0.98, 0.85),
borderColor: rgb(0.85, 0.72, 0.3),
borderWidth: 0.7,
opacity: 0.97,
});
lines.forEach((line: string, i: number) => {
page.drawText(line, {
x: Math.max(4, boxX) + 5,
y: boxY + boxH - 5 - NOTE_LINE_H * (i + 1) + 2.5,
size: NOTE_FONT_SIZE,
font,
color: rgb(0.15, 0.15, 0.15),
});
});
// The marker last, so it stays on top if the box reaches under it.
page.drawCircle({ x, y, size: MARKER_R, color: rgb(0.05, 0.45, 0.9), opacity: 0.95 });
const label = String(index + 1);
page.drawText(label, {
x: x - bold.widthOfTextAtSize(label, 9) / 2,
y: y - 3.2,
size: 9,
font: bold,
color: rgb(1, 1, 1),
});
});
}
+49
View File
@@ -158,3 +158,52 @@ describe("contentToText", () => {
expect(search.contentToText("")).toBe(""); expect(search.contentToText("")).toBe("");
}); });
}); });
describe("notes pinned into a PDF", () => {
beforeEach(() => {
seed();
search.removeEntry("pdf");
});
it("are searchable, even though they live outside the note body", () => {
// They are stored in the page overlay, so nothing else puts them in the
// index — and a note written onto page 14 that cannot be found again is
// not much of a note.
search.setEntry("pdf", "Handbuch", body("Meine Anmerkungen."));
search.setPinnedText("pdf", "Handbuch", "S. 14: Achtung, Drehmoment beachten");
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
});
it("show the pinned text as the excerpt, with its page", () => {
search.setEntry("pdf", "Handbuch", body("Meine Anmerkungen."));
search.setPinnedText("pdf", "Handbuch", "S. 14: Achtung, Drehmoment beachten");
expect(search.search("drehmoment")?.[0]?.snippet).toContain("S. 14");
});
it("do not displace what the reader typed beside the document", () => {
search.setEntry("pdf", "Handbuch", body("Meine Anmerkungen."));
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
expect(search.search("anmerkungen")?.map((h) => h.id)).toEqual(["pdf"]);
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
});
it("survive the document's own text being indexed afterwards", () => {
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
search.setAttachedText("pdf", "Handbuch", "Der Hersteller empfiehlt regelmäßige Wartung.");
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
expect(search.search("wartung")?.map((h) => h.id)).toEqual(["pdf"]);
});
it("survive the note body being saved afterwards", () => {
// Typing in the panel beside the PDF must not wipe what is pinned into it.
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
search.setEntry("pdf", "Handbuch", body("Später weiterlesen."));
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
});
it("are replaced wholesale, so a deleted pin stops being findable", () => {
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
search.setPinnedText("pdf", "Handbuch", "");
expect(search.search("drehmoment")).toEqual([]);
});
});
+38 -15
View File
@@ -23,6 +23,8 @@ interface IndexEntry {
raw: string; raw: string;
/** Text from an attached document, kept apart from what the user typed. */ /** Text from an attached document, kept apart from what the user typed. */
attachedRaw: string; attachedRaw: string;
/** Notes pinned to spots in an attached document. */
pinnedRaw: string;
} }
const _index = new Map<string, IndexEntry>(); const _index = new Map<string, IndexEntry>();
@@ -68,30 +70,45 @@ export function contentToText(raw: string | null | undefined): string {
* half. * half.
*/ */
export function setEntry(id: string, title: string, content?: string | null, attached?: string | null): void { export function setEntry(id: string, title: string, content?: string | null, attached?: string | null): void {
const existing = _index.get(id);
const raw = contentToText(content); const raw = contentToText(content);
const attachedRaw = attached === undefined ? (_index.get(id)?.attachedRaw ?? "") : (attached ?? ""); const attachedRaw = attached === undefined ? (existing?.attachedRaw ?? "") : (attached ?? "");
const pinnedRaw = existing?.pinnedRaw ?? "";
_index.set(id, { _index.set(id, {
title: normalize(title), title: normalize(title),
text: normalize(`${raw} ${attachedRaw}`), text: normalize(`${raw} ${attachedRaw} ${pinnedRaw}`),
raw, raw,
attachedRaw, attachedRaw,
pinnedRaw,
});
}
/** Replaces one of the three halves, leaving the other two alone. */
function setPart(id: string, title: string, part: "attachedRaw" | "pinnedRaw", value: string): void {
const existing = _index.get(id) ?? { raw: "", attachedRaw: "", pinnedRaw: "", title: "", text: "" };
const next = { ...existing, [part]: value };
_index.set(id, {
title: normalize(title),
text: normalize(`${next.raw} ${next.attachedRaw} ${next.pinnedRaw}`),
raw: next.raw,
attachedRaw: next.attachedRaw,
pinnedRaw: next.pinnedRaw,
}); });
} }
/** Replaces only the attached document's text, leaving what the user wrote. */ /** Replaces only the attached document's text, leaving what the user wrote. */
export function setAttachedText(id: string, title: string, attached: string): void { export const setAttachedText = (id: string, title: string, attached: string): void =>
const existing = _index.get(id); setPart(id, title, "attachedRaw", attached);
setEntry(id, title, null, attached);
// setEntry with null content would blank the written half; restore it. /**
if (existing) { * Replaces only the notes pinned into an attached document.
_index.set(id, { *
title: normalize(title), * These live in the page overlay rather than in the note body, so nothing else
text: normalize(`${existing.raw} ${attached}`), * puts them in the index — and a note you wrote onto page 14 that cannot be
raw: existing.raw, * searched for is a note you will not find again.
attachedRaw: attached, */
}); export const setPinnedText = (id: string, title: string, pinned: string): void =>
} setPart(id, title, "pinnedRaw", pinned);
}
export function removeEntry(id: string): void { export function removeEntry(id: string): void {
_index.delete(id); _index.delete(id);
@@ -169,6 +186,12 @@ function snippetFor(entry: IndexEntry, terms: string[], radius = 60): string {
let text = entry.raw; let text = entry.raw;
let at = firstHit(text); let at = firstHit(text);
// A pinned note is short and was written deliberately, so it outranks the
// document's own text as the excerpt to show.
if (at === -1 && entry.pinnedRaw) {
text = entry.pinnedRaw;
at = firstHit(text);
}
if (at === -1 && entry.attachedRaw) { if (at === -1 && entry.attachedRaw) {
text = entry.attachedRaw; text = entry.attachedRaw;
at = firstHit(text); at = firstHit(text);
+5 -5
View File
@@ -90,7 +90,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
await exportPdfNote(notes[0], kind === "pdf-stamped"); await exportPdfNote(notes[0], kind === "pdf-stamped");
setDone( setDone(
kind === "pdf-stamped" kind === "pdf-stamped"
? "PDF mit den Zeichnungen wurde heruntergeladen." ? "PDF mit deinen Anmerkungen wurde heruntergeladen."
: "Das unveränderte Original wurde heruntergeladen." : "Das unveränderte Original wurde heruntergeladen."
); );
} else if (kind === "png") { } else if (kind === "png") {
@@ -149,15 +149,15 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
{card( {card(
() => run("pdf-stamped"), () => run("pdf-stamped"),
busy === "pdf-stamped", busy === "pdf-stamped",
"PDF mit den Zeichnungen", "PDF mit deinen Anmerkungen",
[ [
"Deine Striche werden fest ins Dokument gerechnet.", "Striche und Randnotizen werden fest ins Dokument gerechnet.",
hasInk === false ? "Auf diesem Dokument ist noch nichts gezeichnet." : null, hasInk === false ? "Auf diesem Dokument ist noch nichts angemerkt." : null,
], ],
hasInk === false hasInk === false
)} )}
{card(() => run("pdf-original"), busy === "pdf-original", "PDF im Original", [ {card(() => run("pdf-original"), busy === "pdf-original", "PDF im Original", [
"Genau die Datei, die du importiert hast — ohne deine Zeichnungen.", "Genau die Datei, die du importiert hast — ohne deine Anmerkungen.",
])} ])}
{card(() => run("markdown"), busy === "markdown", "Nur die Notizen daneben (Markdown)", [ {card(() => run("markdown"), busy === "markdown", "Nur die Notizen daneben (Markdown)", [
"Was du neben dem Dokument geschrieben hast, ohne das PDF selbst.", "Was du neben dem Dokument geschrieben hast, ohne das PDF selbst.",
+14
View File
@@ -506,6 +506,20 @@ export const I = {
<line x1="12" y1="3" x2="12" y2="15" /> <line x1="12" y1="3" x2="12" y2="15" />
</svg> </svg>
), ),
pin: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8z" />
</svg>
),
eye: ( eye: (
<svg <svg
width="15" width="15"
+34 -3
View File
@@ -17,7 +17,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
- **Undo/Redo** — Full history stack for drawing operations - **Undo/Redo** — Full history stack for drawing operations
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder - **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
- **Star/Favorite** — Quick access to important notes - **Star/Favorite** — Quick access to important notes
- **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, write notes beside it, and export it with or without your marks - **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, pin notes to individual passages, write notes beside it, and export it with or without your marks
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser - **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
- **Import** — Markdown files, or a ZIP this app exported, come back as notes with their images - **Import** — Markdown files, or a ZIP this app exported, come back as notes with their images
- **Sorting** — Each notebook remembers how its notes are ordered, including an arrangement made by hand - **Sorting** — Each notebook remembers how its notes are ordered, including an arrangement made by hand
@@ -304,7 +304,7 @@ failure distinguished by comparing an error's `.message` to a magic string.
Failures now carry a typed `SyncError.code`; `.message` still holds the same Failures now carry a typed `SyncError.code`; `.message` still holds the same
string, so the remaining JavaScript callers are unaffected. string, so the remaining JavaScript callers are unaffected.
The 199 unit tests concentrate on the same places, and several exist because the The 215 unit tests concentrate on the same places, and several exist because the
bug they cover actually happened: bug they cover actually happened:
- a queue entry that fails must stay queued, with an attempt counter - a queue entry that fails must stay queued, with an attempt counter
@@ -332,7 +332,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 | | `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 | | `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 | | `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
| `pdf` | 41 | import, page proportions, lazy loading, and reading with the network off | | `pdf` | 57 | import, page proportions, lazy loading, and reading with the network off |
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does | | `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version | | `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 | | `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
@@ -753,6 +753,37 @@ its history on a change that is not to the note at all.
--- ---
## Notes pinned to a passage
The panel beside a PDF is for notes about the document. A note that belongs to
*one paragraph* is a different thing — and a note that has to begin "on page 14,
third paragraph" has already lost its place. So a note can be pinned to a spot:
click **Randnotiz**, click the passage, write.
Pins are stored in the same per-page overlay as the drawings, in **PDF points**.
That is what keeps one on its sentence at every zoom, on every screen, and in the
exported file — the same property the strokes have, for the same reason.
Being in the overlay rather than in the note body has three consequences, each of
which needed handling rather than assuming:
- **Search.** Nothing else would index them, so the viewer feeds them to the
local index itself, with their page numbers — finding the words is only half of
finding the note.
- **Clearing the ink.** "Seite leeren" empties the drawing. It must not take the
notes pinned to that page: same page, different thing.
- **"Is this overlay empty?"** The client and the server each held their own
answer, and both counted strokes only. An overlay carrying nothing but pinned
notes was therefore stored as `NULL` — every note on it lost on the next save.
Both now ask about strokes *and* pins.
They come along when the document is exported with annotations: each becomes a
numbered marker plus a small box with its text, placed on whichever side of the
pin has room. Only Latin-1 survives — the standard PDF fonts cannot encode more,
and one em dash must not cost the whole export, so anything else is replaced.
---
## Import ## Import
The other half of the promise that notes are not hostage to this program. A The other half of the promise that notes are not hostage to this program. A