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
+1228 -170

No files matched your search

+70 -57
View File
@@ -35,6 +35,9 @@ const DropLine = ({ T }) => (
<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.
*
@@ -61,64 +64,73 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
cursor: "grab",
}}
>
<div
style={{ height: 1, background: T.border, flex: separator.label || editing ? 0 : 1, minWidth: 12 }}
/>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => 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. */}
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
<div style={{ flex: "0 0 auto", width: MIDDLE_W, display: "flex" }}>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => 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",
}}
/>
) : (
<button
onClick={() => {
setDraft(separator.label);
setEditing(false);
}
}}
aria-label="Separator label"
placeholder="Abschnitt"
style={{
flex: 1,
minWidth: 0,
border: `1px solid ${T.border}`,
borderRadius: 6,
background: "transparent",
color: T.textSec,
fontSize: 10.5,
fontWeight: 700,
letterSpacing: 0.6,
padding: "2px 6px",
outline: "none",
}}
/>
) : (
<button
onClick={() => {
setDraft(separator.label);
setEditing(true);
}}
title="Umbenennen"
style={{
border: "none",
background: "transparent",
color: separator.label ? T.textSec : T.faint,
fontSize: 10.5,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
cursor: "text",
padding: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{separator.label || "Benennen"}
</button>
)}
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 12 }} />
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"}
</button>
)}
</div>
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
<button
onClick={() => onRemove(separator.id)}
title="Trenner entfernen"
@@ -131,6 +143,7 @@ function SeparatorRow({ T, separator, onRename, onRemove }) {
fontSize: 13,
lineHeight: 1,
padding: "0 2px",
flexShrink: 0,
}}
>
×