Files
InkNote/frontend/src/features/pdf/PdfMargin.jsx
T
marcandClaude Opus 5 4c1b632029 Link the margin notes from the notes, and let them be hidden
The linking went the wrong way round. A remark in the margin is short by design;
when there is more to say about that passage it belongs in the notes beside the
document — and it has to be possible to say *which* passage. So the pins are
offered to the editor's `@` menu exactly like notes and notebooks are, labelled
with their page, and following one scrolls the document to the spot and opens the
remark. Pins are unhidden first: a link leading to something invisible would read
as broken.

A pin link is the same node with kind "pin", and is deliberately kept out of the
links reported to the server. Its target is a marker inside this note's own
overlay, so no note has that id, and reporting it would fill the backlink table
with rows matching nothing.

They can also be hidden altogether. Reading a document as it was printed is a
normal thing to want; the toolbar takes the markers and the column away together
and the page takes the width back.

The other direction — making a note out of a remark — is removed, along with its
picker and the pin's noteId: 357 lines fewer than before. The unmount flush it
turned up stays, since a drawing save was genuinely being dropped by navigating
away inside its debounce.

218 unit tests, 231 browser checks across eleven suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 20:00:46 +02:00

215 lines
6.3 KiB
React

import { useEffect, useRef, useState } from "react";
/**
* The notes for a page, written in the margin beside it.
*
* This is where a reader has always put remarks: level with the passage, in the
* white space, not over the words. Writing them onto the page would cover the
* sentence the note is about, which is the one thing a note about a sentence
* must not do.
*
* Each card sits at the height of its anchor. Cards keep one height so that two
* notes close together can be laid out without measuring: they are simply pushed
* down until they no longer overlap, and the marker's hairline still says which
* spot each belongs to. The card being written in grows and rises above the
* others, because that one is being read closely and the rest are not.
*/
export const MARGIN_W = 208;
const CARD_H = 74;
const CARD_GAP = 6;
/** Cards at their anchors, pushed down where they would sit on top of each other. */
function layout(notes, pageHeight, cssHeight) {
let previousBottom = -Infinity;
return notes
.slice()
.sort((a, b) => a.y - b.y)
.map((note) => {
const anchor = (note.y / pageHeight) * cssHeight - 12;
const top = Math.max(anchor, previousBottom + CARD_GAP);
previousBottom = top + CARD_H;
return { note, top };
});
}
function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
const [draft, setDraft] = useState(note.text);
const areaRef = useRef(null);
// Adopt text that arrived from elsewhere — another device, or an undo — but
// never while this card is the one being typed into.
useEffect(() => {
if (!open) setDraft(note.text);
}, [note.text, open]);
useEffect(() => {
if (open) areaRef.current?.focus();
}, [open]);
// Committed on the way out, not per keystroke: every save rewrites the whole
// overlay, and one per character would be absurd.
const commit = () => {
if (draft !== note.text) onChange(draft);
};
return (
<div
data-pin-card={note.id}
onClick={() => !open && onOpen(note.id)}
style={{
position: "absolute",
left: 0,
right: 0,
top,
zIndex: open ? 5 : 1,
display: "flex",
gap: 6,
padding: 8,
borderRadius: 10,
border: `1px solid ${open ? T.accent : T.border}`,
background: T.bar,
boxShadow: open ? "0 8px 22px rgba(0,0,0,.28)" : "none",
cursor: open ? "default" : "pointer",
}}
>
<span
style={{
flexShrink: 0,
width: 18,
height: 18,
borderRadius: "50%",
background: T.accent,
color: "#fff",
fontSize: 10.5,
fontWeight: 700,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{number}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
{open ? (
<>
<textarea
ref={areaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
commit();
onOpen(null);
}
}}
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: 11.5,
lineHeight: 1.5,
padding: "5px 7px",
outline: "none",
fontFamily: "inherit",
}}
/>
<div style={{ display: "flex", alignItems: "center", marginTop: 5 }}>
<button
onClick={onRemove}
data-pin-remove
style={{
border: "none",
background: "transparent",
color: T.danger,
cursor: "pointer",
fontSize: 10.5,
padding: 0,
}}
>
Entfernen
</button>
<span style={{ flex: 1 }} />
<button
onClick={() => {
commit();
onOpen(null);
}}
style={{
border: "none",
background: T.accent,
color: "#fff",
borderRadius: 7,
cursor: "pointer",
fontSize: 10.5,
fontWeight: 600,
padding: "3px 10px",
}}
>
Fertig
</button>
</div>
</>
) : (
<div
style={{
fontSize: 11.5,
lineHeight: 1.45,
color: note.text ? T.text : T.muted,
maxHeight: CARD_H - 22,
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
wordBreak: "break-word",
}}
>
{note.text || "Leer — zum Schreiben antippen"}
</div>
)}
</div>
</div>
);
}
export function PdfMargin({ T, notes, pageHeight, cssHeight, openId, onOpen, onChange, onRemove, numberOf }) {
const placed = layout(notes, pageHeight, cssHeight);
// Cards near the foot of a page may need more room than the page is tall.
const needed = placed.length ? placed[placed.length - 1].top + CARD_H : 0;
return (
<div
data-pdf-margin
style={{
position: "relative",
width: MARGIN_W,
flexShrink: 0,
minHeight: Math.max(cssHeight, needed),
}}
>
{placed.map(({ note, top }) => (
<Card
key={note.id}
T={T}
note={note}
top={top}
number={numberOf(note.id)}
open={openId === note.id}
onOpen={onOpen}
onChange={(text) => onChange(note.id, text)}
onRemove={() => onRemove(note.id)}
/>
))}
</div>
);
}