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

+67 -1
View File
@@ -135,6 +135,7 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
onChange={(id, text) => pins.onChange(index + 1, size, id, text)}
onRemove={(id) => pins.onRemove(index + 1, size, id)}
numberOf={pins.numberOf}
link={pins.link}
/>
)}
</div>
@@ -149,7 +150,7 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
* proportions before anything is drawn, so the scrollbar is honest from the
* first frame instead of jumping as pages arrive.
*/
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pinLinks }) {
const [state, setState] = useState({ status: "loading" });
const [zoom, setZoom] = useState(1);
const [drawing, setDrawing] = useState(false);
@@ -182,6 +183,12 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
setInk(incoming);
}, [note?.drawingStrokes]);
/* Held in a ref so the unmount flush below can reach the current one: the
* effect that runs it has no dependencies, and a captured `onUpdate` would be
* the one from the first render. */
const onUpdateRef = useRef(onUpdate);
onUpdateRef.current = onUpdate;
const saveInk = useCallback(
(next) => {
setInk(next);
@@ -199,6 +206,22 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
[onUpdate]
);
/* A save waits 700ms for the next stroke. Leaving the document inside that
* window — which is exactly what happens when a remark is turned into a note
* and the app goes there — would drop it. Flushed on the way out. */
useEffect(
() => () => {
if (!saveTimer.current) return;
clearTimeout(saveTimer.current);
saveTimer.current = 0;
const doc = inkRef.current;
onUpdateRef.current?.({
drawingStrokes: hasOverlay(doc) ? { ...doc, t: newSaveToken() } : null,
});
},
[]
);
const commitPage = useCallback(
(pageNo, strokes) => {
const size = state.sizes?.[pageNo - 1];
@@ -290,6 +313,35 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
[writeNotes]
);
/** Points a pin at a note, or lets go of one — the note itself is untouched. */
const linkNote = useCallback(
(pageNumber, size, id, noteId) =>
writeNotes(pageNumber, size, (notes) => notes.map((n) => (n.id === id ? { ...n, noteId } : n))),
[writeNotes]
);
/**
* Makes a note out of a remark and jumps to it.
*
* The pin keeps the new note's id, so the passage leads there ever after; the
* note is created with a link back, so the document lists it in its backlinks.
*/
const makeNoteFor = useCallback(
async (pageNumber, size, id, text) => {
if (!pinLinks?.onCreate) return;
const title = (text || "").trim().split("\n")[0]?.slice(0, 60) || `Notiz zu Seite ${pageNumber}`;
const created = await pinLinks.onCreate(title);
if (!created) return;
// Linked before leaving, and the pending write is flushed on unmount.
linkNote(pageNumber, size, id, created.id);
setOpenPin(null);
// Not `onOpenNote`: that one looks the note up in a list this note is not
// in yet, and would quietly go nowhere.
pinLinks.onOpenCreated?.(created.id, created.folderId);
},
[pinLinks, linkNote]
);
const [pageNo, setPageNo] = useState(1);
const [visible, setVisible] = useState(() => new Set([0, 1]));
const [containerWidth, setContainerWidth] = useState(0);
@@ -741,6 +793,13 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
onMove: moveNote,
onRemove: removeNote,
numberOf: (id) => pinNumbers.get(id) ?? "",
link: {
search: pinLinks?.search,
labelOf: pinLinks?.labelOf,
onOpenNote: pinLinks?.onOpenNote,
onCreate: (pinId, text) => makeNoteFor(i + 1, state.sizes[i], pinId, text),
onLink: (pinId, noteId) => linkNote(i + 1, state.sizes[i], pinId, noteId),
},
}}
/>
))}
@@ -758,6 +817,13 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle }) {
onChange={(text) => changeNote(openSheet.page, openSheet.size, openSheet.note.id, text)}
onRemove={() => removeNote(openSheet.page, openSheet.size, openSheet.note.id)}
onClose={() => setOpenPin(null)}
link={{
search: pinLinks?.search,
labelOf: pinLinks?.labelOf,
onOpenNote: pinLinks?.onOpenNote,
onCreate: (text) => makeNoteFor(openSheet.page, openSheet.size, openSheet.note.id, text),
onLink: (noteId) => linkNote(openSheet.page, openSheet.size, openSheet.note.id, noteId),
}}
/>
)}
</div>