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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-10 20:00:46 +02:00
1 parent 502aecfc92
commit 4c1b632029
13 files changed
+239 -484

No files matched your search

@@ -230,8 +230,6 @@ export function EditorPane({
onOpenNote={linkProps.openLinkTarget}
searchLinkTargets={linkProps.searchLinkTargets}
resolveLinkLabel={linkProps.resolveLinkLabel}
onCreateNoteForPin={linkProps.createNoteForPin}
onOpenCreatedNote={linkProps.openCreatedNote}
/>
) : (
<NoteEditor
+4 -111
View File
@@ -1,7 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { PinLinkPicker } from "./PinLinkPicker.jsx";
/**
* The notes for a page, written in the margin beside it.
*
@@ -21,9 +19,6 @@ 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;
@@ -33,14 +28,13 @@ function layout(notes, pageHeight, cssHeight) {
.map((note) => {
const anchor = (note.y / pageHeight) * cssHeight - 12;
const top = Math.max(anchor, previousBottom + CARD_GAP);
previousBottom = top + heightOf(note);
previousBottom = top + CARD_H;
return { note, top };
});
}
function Card({ T, note, top, number, open, onOpen, onChange, onRemove, link }) {
function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
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
@@ -144,23 +138,6 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove, link })
>
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={() => {
@@ -181,18 +158,6 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove, link })
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
@@ -211,86 +176,15 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove, link })
{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>
);
}
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,
}) {
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 last = placed[placed.length - 1];
const needed = last ? last.top + heightOf(last.note) : 0;
const needed = placed.length ? placed[placed.length - 1].top + CARD_H : 0;
return (
<div
@@ -313,7 +207,6 @@ export function PdfMargin({
onOpen={onOpen}
onChange={(text) => onChange(note.id, text)}
onRemove={() => onRemove(note.id)}
link={link}
/>
))}
</div>
+1 -50
View File
@@ -12,7 +12,7 @@ import { useEffect, useRef, useState } from "react";
* The page stays visible above it, so the passage the note is about is still
* there while writing about it.
*/
export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove, link }) {
export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
const [draft, setDraft] = useState(note.text);
const areaRef = useRef(null);
@@ -109,55 +109,6 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove, lin
}}
/>
{/* Same two ways out as the margin card: some remarks turn into a page of
their own, and the pin should lead there afterwards. */}
<div style={{ display: "flex", alignItems: "center", gap: 14, marginTop: 8 }}>
{note.noteId ? (
<button
data-pin-goto={note.noteId}
onClick={() => link.onOpenNote(note.noteId)}
style={{
display: "flex",
alignItems: "center",
gap: 5,
flex: 1,
minWidth: 0,
border: "none",
background: "transparent",
color: T.accent,
cursor: "pointer",
fontSize: 12,
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>
) : (
<button
data-pin-make-note
onClick={() => {
commit();
link.onCreate(draft);
}}
style={{
border: "none",
background: "transparent",
color: T.accent,
cursor: "pointer",
fontSize: 12,
padding: 0,
}}
>
Notiz anlegen
</button>
)}
</div>
<button
onClick={close}
style={{
+44 -43
View File
@@ -135,7 +135,6 @@ 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>
@@ -150,12 +149,13 @@ 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, pinLinks }) {
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, focusPin, onFocusHandled }) {
const [state, setState] = useState({ status: "loading" });
const [zoom, setZoom] = useState(1);
const [drawing, setDrawing] = useState(false);
const [placing, setPlacing] = useState(false);
const [openPin, setOpenPin] = useState(null);
const [showPins, setShowPins] = useState(true);
const [hideInk, setHideInk] = useState(false);
const [tool, setTool] = useState("pen");
const [color, setColor] = useState(COLORS[1]);
@@ -313,34 +313,24 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
[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.
* Someone followed a link to a pinned note from the notes beside the document.
*
* 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.
* Scrolls its page into view and opens it. The pins are unhidden first: a link
* that led to something invisible would read as broken.
*/
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]
);
useEffect(() => {
if (!focusPin || state.status !== "ready") return;
const target = allPinnedNotes(ink).find((n) => n.id === focusPin);
onFocusHandled?.();
if (!target) return;
setShowPins(true);
setOpenPin(target.id);
const row = innerRef.current?.querySelector(`[data-pdf-row="${target.page}"]`);
if (row && scrollRef.current) {
scrollRef.current.scrollTo({ top: row.offsetTop - 8, behavior: "smooth" });
}
}, [focusPin, state.status, ink, onFocusHandled]);
const [pageNo, setPageNo] = useState(1);
const [visible, setVisible] = useState(() => new Set([0, 1]));
@@ -429,7 +419,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
* is being placed — so it is already there before the first note exists and
* the page does not jump sideways at the moment of writing it. */
const roomForMargin = containerWidth >= MARGIN_W + 380;
const withMargin = roomForMargin && (placing || totalPinnedNotes(ink) > 0);
const withMargin = showPins && roomForMargin && (placing || totalPinnedNotes(ink) > 0);
/* The open note, with the page it belongs to — the sheet needs the page size
to write a change back into the right page's overlay. */
@@ -599,6 +589,31 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
{I.pen} {drawing ? "Zeichnen" : "Lesen"}
</button>
{/* Reading the document as it was printed is a normal thing to want, and
so is having every remark in view. Both are one click away. */}
{totalPinnedNotes(ink) > 0 && (
<button
data-pdf-show-pins={showPins ? "on" : "off"}
onClick={() => {
setShowPins((v) => !v);
setOpenPin(null);
}}
title={showPins ? "Randnotizen ausblenden" : "Randnotizen einblenden"}
style={{
...barButton,
width: "auto",
padding: "0 10px",
gap: 6,
border: `1px solid ${showPins ? T.border : T.accent + "66"}`,
background: showPins ? "transparent" : T.accent + "1a",
color: showPins ? T.muted : T.accent,
fontWeight: 600,
}}
>
{showPins ? I.eye : I.eyeOff} Randnotizen
</button>
)}
{/* A note about a passage belongs on the passage. */}
<button
data-pdf-pin-mode={placing ? "on" : "off"}
@@ -785,7 +800,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
notes: notesForPage(ink, i + 1),
placing,
openId: openPin,
hidden: hideInk,
hidden: hideInk || !showPins,
withMargin,
onOpen: setOpenPin,
onPlace: placeNote,
@@ -793,13 +808,6 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
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),
},
}}
/>
))}
@@ -817,13 +825,6 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, pin
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>
+60 -21
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { PdfViewer } from "./PdfViewer.jsx";
import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
import { I } from "../../ui/icons.jsx";
import { allPinnedNotes, asPdfStrokeDoc } from "./pageStrokes";
const MOBILE_BREAKPOINT = 900;
@@ -18,17 +19,11 @@ const MOBILE_BREAKPOINT = 900;
* 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,
onCreateNoteForPin,
onOpenCreatedNote,
}) {
export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) {
const [showNotes, setShowNotes] = useState(false);
// Set when a link in the notes points at a marker; the viewer clears it once
// it has scrolled there.
const [focusPin, setFocusPin] = useState(null);
const [narrow, setNarrow] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
useEffect(() => {
@@ -46,6 +41,55 @@ export function PdfWorkspace({
// On a narrow screen the two are alternatives, not neighbours.
const showPdf = !narrow || !showNotes;
/* ── linking the margin notes from the notes ──
*
* 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, and following one scrolls
* the document to the spot and opens the remark. */
const pins = useMemo(() => allPinnedNotes(asPdfStrokeDoc(note?.drawingStrokes)), [note?.drawingStrokes]);
const pinLabel = useCallback((pin) => {
const text = (pin.text || "").trim().replace(/\s+/g, " ");
return text ? `S. ${pin.page}: ${text.slice(0, 40)}` : `S. ${pin.page}: Randnotiz`;
}, []);
const searchTargets = useCallback(
(query) => {
const q = (query || "").toLowerCase().trim();
const fromPins = pins
.filter((pin) => !q || pinLabel(pin).toLowerCase().includes(q))
.slice(0, 6)
.map((pin) => ({ id: pin.id, label: pinLabel(pin), kind: "pin" }));
// Pins first: inside a document, the passage is usually what is meant.
return [...fromPins, ...(searchLinkTargets ? searchLinkTargets(query) : [])];
},
[pins, pinLabel, searchLinkTargets]
);
const resolveLabel = useCallback(
(id) => {
const pin = pins.find((p) => p.id === id);
if (pin) return pinLabel(pin);
return resolveLinkLabel ? resolveLinkLabel(id) : null;
},
[pins, pinLabel, resolveLinkLabel]
);
const openTarget = useCallback(
(id, kind) => {
if (kind === "pin") {
setFocusPin(id);
// On a phone the document is not on screen while the notes are.
if (narrow) setShowNotes(false);
return;
}
onOpenNote?.(id, kind);
},
[narrow, onOpenNote]
);
const notesToggle = (
<button
data-pdf-toggle-notes={showNotes ? "on" : "off"}
@@ -80,13 +124,8 @@ export function PdfWorkspace({
dark={dark}
onUpdate={onUpdate}
notesToggle={notesToggle}
pinLinks={{
search: searchLinkTargets,
labelOf: resolveLinkLabel,
onOpenNote,
onCreate: (title) => onCreateNoteForPin?.(note, title),
onOpenCreated: onOpenCreatedNote,
}}
focusPin={focusPin}
onFocusHandled={() => setFocusPin(null)}
/>
</div>
)}
@@ -104,9 +143,9 @@ export function PdfWorkspace({
note={note}
dark={dark}
onUpdate={onUpdate}
onOpenNote={onOpenNote}
searchLinkTargets={searchLinkTargets}
resolveLinkLabel={resolveLinkLabel}
onOpenNote={openTarget}
searchLinkTargets={searchTargets}
resolveLinkLabel={resolveLabel}
/* 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}
-120
View File
@@ -1,120 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
/**
* Choosing which note a pinned remark points at.
*
* Deliberately the same shape as the editor's `@` menu — type, see matches,
* pick one — because it is the same question asked in a different place, and
* two different ways to answer it would be one too many to remember.
*/
export function PinLinkPicker({ T, search, onPick, onClose }) {
const [query, setQuery] = useState("");
const [highlighted, setHighlighted] = useState(0);
const box = useRef(null);
const results = useMemo(
() => (search ? search(query).filter((r) => r.kind === "note") : []),
[search, query]
);
useEffect(() => setHighlighted(0), [query]);
useEffect(() => {
const away = (e) => {
if (box.current && !box.current.contains(e.target)) onClose();
};
const t = setTimeout(() => document.addEventListener("pointerdown", away), 0);
return () => {
clearTimeout(t);
document.removeEventListener("pointerdown", away);
};
}, [onClose]);
const onKeyDown = (e) => {
e.stopPropagation();
if (e.key === "Escape") return onClose();
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlighted((i) => Math.min(i + 1, results.length - 1));
}
if (e.key === "ArrowUp") {
e.preventDefault();
setHighlighted((i) => Math.max(i - 1, 0));
}
if (e.key === "Enter" && results[highlighted]) {
e.preventDefault();
onPick(results[highlighted]);
}
};
return (
<div
ref={box}
data-pin-link-picker
onPointerDown={(e) => e.stopPropagation()}
style={{
position: "absolute",
left: 8,
right: 8,
top: "100%",
marginTop: 4,
zIndex: 40,
background: T.bar,
border: `1px solid ${T.border}`,
borderRadius: 10,
boxShadow: "0 10px 26px rgba(0,0,0,.3)",
padding: 6,
}}
>
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
aria-label="Link to note"
placeholder="Notiz suchen…"
style={{
width: "100%",
border: `1px solid ${T.border}`,
borderRadius: 7,
background: "transparent",
color: T.text,
fontSize: 11.5,
padding: "4px 7px",
outline: "none",
}}
/>
<div style={{ maxHeight: 168, overflowY: "auto", marginTop: 4 }}>
{results.length === 0 ? (
<div style={{ fontSize: 11, color: T.muted, padding: "6px 7px" }}>Keine Notiz gefunden.</div>
) : (
results.map((r, i) => (
<button
key={r.id}
data-pin-link-option={r.id}
onClick={() => onPick(r)}
onMouseEnter={() => setHighlighted(i)}
style={{
display: "block",
width: "100%",
textAlign: "left",
border: "none",
borderRadius: 7,
background: i === highlighted ? T.accent + "22" : "transparent",
color: i === highlighted ? T.accent : T.text,
cursor: "pointer",
fontSize: 11.5,
padding: "5px 7px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{r.label}
</button>
))
)}
</div>
</div>
);
}
-9
View File
@@ -27,15 +27,6 @@ export interface PinnedNote {
x: number;
y: number;
text: string;
/**
* A note this pin points at.
*
* A margin card holds a remark; some remarks turn into a page of their own.
* Keeping the id here means the passage and the note that came out of it stay
* connected in both directions — the pin leads to the note, and the note is
* written with a link back, so it shows up in the document's backlinks.
*/
noteId?: string | null;
}
export interface PageStrokes {