Let a margin remark become a note, and lead to it ever after

Some remarks turn into a page of their own. "Notiz anlegen" on a margin card
makes one, titles it from what was written, and goes there; the pin keeps its id
so the passage leads to that note afterwards. The note is created with a link
back to the document, so the connection reads both ways and the document lists it
among its backlinks — a note born from a passage should not be a note with
nothing on it saying which passage. An existing note can be linked instead, and a
link can be let go of without touching the note. Both are offered on the phone
sheet too.

Two older bugs stood in the way and are fixed:

`openLinkTarget` finds a note's notebook by looking it up in the notes list, and
a note created in the same tick is not in the copy that callback closed over — so
jumping to a fresh note went nowhere at all. Creating one now navigates by the
notebook it already knows.

A drawing save waits 700ms for the next stroke, and leaving the document inside
that window dropped it. That is precisely what turning a remark into a note does,
so the link would have been lost on the way out. The viewer flushes a pending
write when it unmounts, which also covers drawing and navigating away quickly.

215 unit tests, 229 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 16:40:38 +02:00
1 parent 011ba82a94
commit 502aecfc92
10 files changed
+541 -12

No files matched your search

+111 -4
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { PinLinkPicker } from "./PinLinkPicker.jsx";
/**
* The notes for a page, written in the margin beside it.
*
@@ -19,6 +21,9 @@ export const MARGIN_W = 208;
const CARD_H = 74;
const CARD_GAP = 6;
/** A card carrying a link to a note is one row taller. */
const heightOf = (note) => CARD_H + (note.noteId ? 20 : 0);
/** Cards at their anchors, pushed down where they would sit on top of each other. */
function layout(notes, pageHeight, cssHeight) {
let previousBottom = -Infinity;
@@ -28,13 +33,14 @@ function layout(notes, pageHeight, cssHeight) {
.map((note) => {
const anchor = (note.y / pageHeight) * cssHeight - 12;
const top = Math.max(anchor, previousBottom + CARD_GAP);
previousBottom = top + CARD_H;
previousBottom = top + heightOf(note);
return { note, top };
});
}
function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
function Card({ T, note, top, number, open, onOpen, onChange, onRemove, link }) {
const [draft, setDraft] = useState(note.text);
const [picking, setPicking] = useState(false);
const areaRef = useRef(null);
// Adopt text that arrived from elsewhere — another device, or an undo — but
@@ -138,6 +144,23 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
>
Entfernen
</button>
{!note.noteId && (
<>
<button
data-pin-make-note
onClick={() => {
commit();
link.onCreate(note.id, draft);
}}
style={linkAction(T)}
>
Notiz anlegen
</button>
<button data-pin-link onClick={() => setPicking((v) => !v)} style={linkAction(T)}>
Verknüpfen
</button>
</>
)}
<span style={{ flex: 1 }} />
<button
onClick={() => {
@@ -158,6 +181,18 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
Fertig
</button>
</div>
{picking && (
<PinLinkPicker
T={T}
search={link.search}
onClose={() => setPicking(false)}
onPick={(target) => {
setPicking(false);
commit();
link.onLink(note.id, target.id);
}}
/>
)}
</>
) : (
<div
@@ -176,15 +211,86 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
{note.text || "Leer — zum Schreiben antippen"}
</div>
)}
{/* The note this remark grew into. Shown in both states: when the card
is closed it is the whole point of the card, and when it is open it
is what stops a second note being made for the same passage. */}
{note.noteId && (
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6 }}>
<button
data-pin-goto={note.noteId}
onClick={(e) => {
e.stopPropagation();
link.onOpenNote(note.noteId);
}}
title="Zur Notiz springen"
style={{
display: "flex",
alignItems: "center",
gap: 4,
flex: 1,
minWidth: 0,
border: "none",
background: "transparent",
color: T.accent,
cursor: "pointer",
fontSize: 10.5,
fontWeight: 600,
padding: 0,
textAlign: "left",
}}
>
<span style={{ flexShrink: 0 }}>→</span>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{link.labelOf(note.noteId) ?? "Notiz gelöscht"}
</span>
</button>
{open && (
<button
data-pin-unlink
onClick={(e) => {
e.stopPropagation();
link.onLink(note.id, null);
}}
title="Verweis lösen (die Notiz bleibt)"
style={{ ...linkAction(T), color: T.muted }}
>
Lösen
</button>
)}
</div>
)}
</div>
</div>
);
}
export function PdfMargin({ T, notes, pageHeight, cssHeight, openId, onOpen, onChange, onRemove, numberOf }) {
const linkAction = (T) => ({
border: "none",
background: "transparent",
color: T.accent,
cursor: "pointer",
fontSize: 10.5,
padding: 0,
marginRight: 10,
});
export function PdfMargin({
T,
notes,
pageHeight,
cssHeight,
openId,
onOpen,
onChange,
onRemove,
numberOf,
link,
}) {
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;
const last = placed[placed.length - 1];
const needed = last ? last.top + heightOf(last.note) : 0;
return (
<div
@@ -207,6 +313,7 @@ export function PdfMargin({ T, notes, pageHeight, cssHeight, openId, onOpen, onC
onOpen={onOpen}
onChange={(text) => onChange(note.id, text)}
onRemove={() => onRemove(note.id)}
link={link}
/>
))}
</div>