Files
InkNote/frontend/src/features/pdf/PdfViewer.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

833 lines
29 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import * as sync from "../../syncEngine";
import * as searchIndex from "../../search";
import { openPdf, renderPage, renderTextLayer } from "./pdfjs";
import { extractAndCache, indexPdfText } from "./pdfText";
import "./textLayer.css";
import { PdfDrawLayer } from "./PdfDrawLayer.jsx";
import { PdfPins } from "./PdfPins.jsx";
import { MARGIN_W, PdfMargin } from "./PdfMargin.jsx";
import { PdfNoteSheet } from "./PdfNoteSheet.jsx";
import {
allPinnedNotes,
asPdfStrokeDoc,
hasOverlay,
notesForPage,
strokesForPage,
totalPinnedNotes,
totalStrokes,
withPage,
withPageNotes,
} from "./pageStrokes";
import { COLORS } from "../../ui/constants.jsx";
import { I } from "../../ui/icons.jsx";
import { PEN_SIZES, DEFAULT_PEN_SIZE, newSaveToken } from "../drawing/strokes.js";
import { FONT_UI } from "../../ui/theme";
/** Pages this far outside the viewport are drawn ahead of being scrolled to. */
const RENDER_MARGIN_PX = 1200;
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
/**
* A page. Rendered only once it is near the viewport, and re-rendered when the
* width changes, because a canvas scaled by CSS goes blurry.
*
* Each page keeps its own canvas rather than one being reused across a virtual
* list: a PDF page is expensive to draw and cheap to keep, and scrolling back up
* through a document should not repaint everything.
*/
function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectable }) {
const canvasRef = useRef(null);
const textRef = useRef(null);
const renderedAt = useRef(0);
useEffect(() => {
if (!visible || !canvasRef.current) return;
if (renderedAt.current === width) return; // already sharp at this size
let cancelled = false;
let cancelRender = null;
(async () => {
const page = await doc.getPage(index + 1);
if (cancelled || !canvasRef.current) return;
const { done, cancel } = renderPage(page, canvasRef.current, width);
cancelRender = cancel;
await done;
if (cancelled) return;
renderedAt.current = width;
// Built after the picture, not before: it is only useful once there is
// something under it, and it must not delay the page appearing.
if (textRef.current) await renderTextLayer(page, textRef.current, width);
})().catch(() => {
/* a page that will not draw leaves its placeholder rather than the whole
document failing */
});
return () => {
cancelled = true;
cancelRender?.();
};
}, [doc, index, width, visible]);
return (
/* The page and its margin are one row, so a note card can be placed at the
height of its anchor without knowing where the page sits in the scroll. */
<div
data-pdf-row={index + 1}
style={{ display: "flex", justifyContent: "center", gap: 12, marginBottom: 16 }}
>
<div
data-pdf-page={index + 1}
style={{
position: "relative",
width,
height,
flexShrink: 0,
background: "#fff",
boxShadow: "0 2px 10px rgba(0,0,0,0.25)",
borderRadius: 2,
overflow: "hidden",
}}
>
<canvas ref={canvasRef} style={{ display: "block", width: "100%" }} />
<div ref={textRef} className="pdf-text-layer" data-selectable={selectable ? "true" : "false"} />
<PdfDrawLayer
pageNo={index + 1}
pageSize={size}
cssWidth={width}
cssHeight={height}
strokes={ink.strokes}
drawing={ink.drawing}
tool={ink.tool}
color={ink.color}
sizeIdx={ink.sizeIdx}
hidden={ink.hidden}
onCommit={ink.onCommit}
/>
{!pins.hidden && (
<PdfPins
T={pins.T}
pageNo={index + 1}
pageSize={size}
cssWidth={width}
cssHeight={height}
notes={pins.notes}
placing={pins.placing}
openId={pins.openId}
withMargin={pins.withMargin}
onOpen={pins.onOpen}
onPlace={(at) => pins.onPlace(index + 1, size, at)}
onMove={(id, x, y) => pins.onMove(index + 1, size, id, x, y)}
numberOf={pins.numberOf}
/>
)}
</div>
{pins.withMargin && !pins.hidden && (
<PdfMargin
T={pins.T}
notes={pins.notes}
pageHeight={size.height}
cssHeight={height}
openId={pins.openId}
onOpen={pins.onOpen}
onChange={(id, text) => pins.onChange(index + 1, size, id, text)}
onRemove={(id) => pins.onRemove(index + 1, size, id)}
numberOf={pins.numberOf}
/>
)}
</div>
);
}
/**
* Reads a PDF stored as an attachment.
*
* The bytes come from the offline cache when they are there, so a document read
* once stays readable without a connection. Pages are laid out at their true
* 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, 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]);
const [sizeIdx, setSizeIdx] = useState(DEFAULT_PEN_SIZE);
const [showPalette, setShowPalette] = useState(false);
/* The drawing as it stands, and where it came from.
*
* Held in state so the pages repaint, and mirrored in a ref because the undo
* stack and the save both need the current value synchronously. */
const [ink, setInk] = useState(() => asPdfStrokeDoc(note?.drawingStrokes));
const inkRef = useRef(ink);
inkRef.current = ink;
const undoRef = useRef([]);
const saveTimer = useRef(0);
const pendingToken = useRef(null);
/* A drawing arriving from the server is adopted unless it is the echo of our
* own save — the same stale-read race that once ate strokes in notes. */
useEffect(() => {
const incoming = asPdfStrokeDoc(note?.drawingStrokes);
if (note?.drawingStrokes?.t && note.drawingStrokes.t === pendingToken.current) return;
if (JSON.stringify(incoming) === JSON.stringify(inkRef.current)) return;
if (saveTimer.current) return; // a save of ours is still on its way out
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);
inkRef.current = next;
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saveTimer.current = 0;
const token = newSaveToken();
pendingToken.current = token;
// `hasOverlay`, not `totalStrokes`: a document with pinned notes but no
// ink would otherwise be saved as null and lose every note on it.
onUpdate?.({ drawingStrokes: hasOverlay(next) ? { ...next, t: token } : null });
}, 700);
},
[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];
if (!size) return;
undoRef.current = [...undoRef.current.slice(-40), inkRef.current];
saveInk(withPage(inkRef.current, pageNo, size, strokes));
},
[state.sizes, saveInk]
);
const undo = useCallback(() => {
const previous = undoRef.current.pop();
if (previous) saveInk(previous);
}, [saveInk]);
const clearPage = useCallback(
(pageNo) => {
const size = state.sizes?.[pageNo - 1];
if (!size) return;
undoRef.current = [...undoRef.current.slice(-40), inkRef.current];
saveInk(withPage(inkRef.current, pageNo, size, []));
},
[state.sizes, saveInk]
);
/* ── pinned notes ── */
/* They live in the page overlay, not in the note body, so nothing else would
* ever put them in the search index. Page numbers travel with them: finding
* the words is only half of finding the note. */
useEffect(() => {
const pinned = allPinnedNotes(ink)
.filter((n) => n.text.trim())
.map((n) => `S. ${n.page}: ${n.text}`)
.join(" · ");
searchIndex.setPinnedText(note.id, note.title || "", pinned);
}, [ink, note.id, note.title]);
/** Numbered in reading order, so the pin and the list agree at a glance. */
const pinNumbers = useMemo(() => {
const map = new Map();
allPinnedNotes(ink).forEach((n, i) => map.set(n.id, i + 1));
return map;
}, [ink]);
const writeNotes = useCallback(
(pageNumber, size, change) => {
const current = notesForPage(inkRef.current, pageNumber);
undoRef.current = [...undoRef.current.slice(-40), inkRef.current];
saveInk(withPageNotes(inkRef.current, pageNumber, size, change(current)));
},
[saveInk]
);
const placeNote = useCallback(
(pageNumber, size, at) => {
const id = `pin-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
writeNotes(pageNumber, size, (notes) => [...notes, { id, x: at.x, y: at.y, text: "" }]);
setPlacing(false);
setOpenPin(id); // straight into writing — placing a pin is wanting to write
},
[writeNotes]
);
const changeNote = useCallback(
(pageNumber, size, id, text) =>
writeNotes(pageNumber, size, (notes) => notes.map((n) => (n.id === id ? { ...n, text } : n))),
[writeNotes]
);
const moveNote = useCallback(
(pageNumber, size, id, x, y) =>
writeNotes(pageNumber, size, (notes) =>
notes.map((n) =>
n.id === id
? // Clamped to the page: a pin dragged past the edge would be
// unreachable, and its text with it.
{ ...n, x: Math.max(0, Math.min(x, size.width)), y: Math.max(0, Math.min(y, size.height)) }
: n
)
),
[writeNotes]
);
const removeNote = useCallback(
(pageNumber, size, id) => {
writeNotes(pageNumber, size, (notes) => notes.filter((n) => n.id !== id));
setOpenPin(null);
},
[writeNotes]
);
/**
* Someone followed a link to a pinned note from the notes beside the document.
*
* Scrolls its page into view and opens it. The pins are unhidden first: a link
* that led to something invisible would read as broken.
*/
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]));
const [containerWidth, setContainerWidth] = useState(0);
const scrollRef = useRef(null);
const innerRef = useRef(null);
const extractionRef = useRef(null);
const T = {
bg: dark ? "#0d1117" : "#f0f2f5",
bar: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
danger: dark ? "#f85149" : "#cf222e",
};
/* ── load ── */
useEffect(() => {
if (!note?.pdfAttachmentId) {
setState({ status: "error", message: "Zu dieser Notiz gehört keine PDF-Datei." });
return;
}
let cancelled = false;
setState({ status: "loading" });
(async () => {
const bytes = await sync.loadPdfBytes(note.pdfAttachmentId);
const loaded = await openPdf(bytes);
if (cancelled) return;
setState({ status: "ready", ...loaded });
onPagesReady?.(loaded);
// Text comes out after the document is on screen, so extraction never
// stands between the user and the first page.
const signal = { aborted: false };
extractionRef.current = signal;
const text = await extractAndCache(loaded.doc, note.pdfAttachmentId, signal);
if (text && !cancelled) indexPdfText(note.id, note.title || "", text);
})().catch((err) => {
// The message shown is deliberately plain; the reason belongs in the
// console, where it can be read when someone reports "it does not open".
console.error(`[pdf] could not open document: ${err?.name || "Error"}: ${err?.message || err}`);
if (!cancelled) {
setState({
status: "error",
message:
err?.message === "AUTH"
? "Sitzung abgelaufen — bitte neu anmelden."
: "Die Datei konnte nicht geöffnet werden. Offline und noch nie geladen?",
});
}
});
return () => {
cancelled = true;
if (extractionRef.current) extractionRef.current.aborted = true;
};
}, [note?.pdfAttachmentId, note?.id, note?.title, onPagesReady]);
/* ── available width ── */
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
const measure = () => setContainerWidth(el.clientWidth);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [state.status]);
const sizes = state.status === "ready" ? state.sizes : [];
// The widest page decides the scale, so a document mixing portrait and
// landscape does not make the narrow pages jump about as you scroll.
const widestPt = sizes.reduce((w, s) => Math.max(w, s.width), 0) || 1;
/* Whether there is room to write beside the page.
*
* On a phone there is not: a page narrow enough to read leaves no white space
* next to it, and taking 200px for a column would make the document itself
* unreadable. There the notes come up from the bottom instead.
*
* The column is kept as soon as the document has any pinned note, or while one
* 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 = 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. */
const openSheet = (() => {
if (!openPin) return null;
const found = allPinnedNotes(ink).find((n) => n.id === openPin);
const size = found ? sizes[found.page - 1] : null;
return found && size ? { note: found, page: found.page, size } : null;
})();
const reserved = withMargin ? MARGIN_W + 12 : 0;
const fitWidth = Math.max(240, Math.min(containerWidth - 32 - reserved, 1100));
const pageWidth = Math.round(fitWidth * zoom);
const scale = pageWidth / widestPt;
/* ── which pages are worth drawing ── */
const updateVisible = useCallback(() => {
const el = scrollRef.current;
const inner = innerRef.current;
if (!el || !inner) return;
const top = el.scrollTop - RENDER_MARGIN_PX;
const bottom = el.scrollTop + el.clientHeight + RENDER_MARGIN_PX;
const next = new Set();
let current = 1;
for (const child of inner.children) {
// The row, not the page: the page sits inside it next to its margin, and
// reading `dataset.pdfPage` off the row found nothing — so every page was
// "out of view", and the text layers were cancelled before they rendered.
const n = Number(child.dataset.pdfRow);
if (!n) continue;
const start = child.offsetTop;
const end = start + child.offsetHeight;
if (end >= top && start <= bottom) next.add(n - 1);
// The page covering the middle of the viewport is the one you are reading.
if (start <= el.scrollTop + el.clientHeight / 2) current = n;
}
setVisible(next);
setPageNo(current);
}, []);
useEffect(() => {
if (state.status !== "ready") return;
updateVisible();
const el = scrollRef.current;
el?.addEventListener("scroll", updateVisible, { passive: true });
return () => el?.removeEventListener("scroll", updateVisible);
}, [state.status, pageWidth, updateVisible]);
const goToPage = (n) => {
const target = innerRef.current?.querySelector(`[data-pdf-page="${n}"]`);
if (target && scrollRef.current) {
scrollRef.current.scrollTo({ top: target.offsetTop - 8, behavior: "smooth" });
}
};
const stepZoom = (dir) => {
const i = ZOOM_STEPS.indexOf(zoom);
const next = ZOOM_STEPS[Math.min(ZOOM_STEPS.length - 1, Math.max(0, (i < 0 ? 2 : i) + dir))];
if (next) setZoom(next);
};
const barButton = {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
fontSize: 15,
};
if (state.status === "error") {
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 30,
textAlign: "center",
color: T.danger,
fontSize: 13,
fontFamily: FONT_UI,
background: T.bg,
}}
>
{state.message}
</div>
);
}
return (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
/* Without this a flex item is at least as wide as its content, so the
margin column made the viewer wider, which made there be room for the
margin column — the page shrank to 254px inside a 420px phone while
the layout insisted it was 611px wide. */
minWidth: 0,
background: T.bg,
position: "relative",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
borderBottom: `1px solid ${T.border}`,
background: T.bar,
fontFamily: FONT_UI,
fontSize: 12,
color: T.muted,
flexShrink: 0,
/* On a phone the controls are wider than the screen. Scrolling the bar
keeps every one of them reachable; without it the ones at the end
were simply cut off. */
overflowX: "auto",
}}
>
<button onClick={() => goToPage(Math.max(1, pageNo - 1))} title="Vorherige Seite" style={barButton}>
↑
</button>
<button
onClick={() => goToPage(Math.min(state.pageCount || 1, pageNo + 1))}
title="Nächste Seite"
style={barButton}
>
↓
</button>
<span data-pdf-position style={{ minWidth: 74 }}>
{state.status === "ready" ? `Seite ${pageNo} / ${state.pageCount}` : "Lädt…"}
</span>
<div style={{ width: 1, height: 18, background: T.border, margin: "0 2px" }} />
{/* Reading, drawing and pinning are separate modes: each takes the
pointer differently, and scrolling with a finger still has to work. */}
<button
data-pdf-mode={drawing ? "draw" : "read"}
onClick={() => {
setDrawing((v) => !v);
setPlacing(false);
}}
title={drawing ? "Zeichnen beenden" : "Zeichnen"}
style={{
...barButton,
width: "auto",
padding: "0 10px",
gap: 6,
background: drawing ? T.accent + "22" : "transparent",
color: drawing ? T.accent : T.muted,
fontWeight: 600,
}}
>
{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"}
onClick={() => {
setPlacing((v) => !v);
setDrawing(false);
setOpenPin(null);
}}
title={placing ? "Stelle anklicken — oder abbrechen" : "Notiz an eine Stelle heften"}
style={{
...barButton,
width: "auto",
padding: "0 10px",
gap: 6,
background: placing ? T.accent + "22" : "transparent",
color: placing ? T.accent : T.muted,
fontWeight: 600,
}}
>
{I.pin} {placing ? "Stelle wählen" : "Randnotiz"}
</button>
{drawing && (
<>
{["pen", "highlighter", "eraser"].map((t) => (
<button
key={t}
data-pdf-tool={t}
onClick={() => setTool(t)}
title={t === "pen" ? "Stift" : t === "highlighter" ? "Marker" : "Radierer"}
style={{ ...barButton, background: tool === t ? T.accent + "22" : "transparent" }}
>
{t === "pen" ? I.pen : t === "highlighter" ? I.highlight : I.eraser}
</button>
))}
<div style={{ position: "relative" }}>
<button
onClick={() => setShowPalette((v) => !v)}
title="Farbe"
style={{ ...barButton, width: 22, height: 22, background: color, borderRadius: "50%" }}
/>
{showPalette && (
<div
style={{
position: "absolute",
top: 32,
left: 0,
zIndex: 40,
display: "grid",
gridTemplateColumns: "repeat(5, 20px)",
gap: 6,
padding: 8,
borderRadius: 10,
background: T.bar,
border: `1px solid ${T.border}`,
boxShadow: "0 8px 20px rgba(0,0,0,.25)",
}}
>
{COLORS.map((c) => (
<button
key={c}
onClick={() => {
setColor(c);
setShowPalette(false);
}}
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: c,
border: c === color ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
cursor: "pointer",
}}
/>
))}
</div>
)}
</div>
<select
value={sizeIdx}
onChange={(e) => setSizeIdx(Number(e.target.value))}
title="Strichstärke"
style={{
background: "transparent",
color: T.muted,
border: `1px solid ${T.border}`,
borderRadius: 8,
fontSize: 11,
padding: "2px 4px",
}}
>
{PEN_SIZES.map((s, i) => (
<option key={s.label} value={i}>
{s.label}
</option>
))}
</select>
<button onClick={undo} title="Rückgängig" style={barButton}>
{I.undo}
</button>
<button onClick={() => clearPage(pageNo)} title="Seite leeren" style={barButton}>
{I.trash}
</button>
</>
)}
{/* A labelled control, not a dot. Hiding the ink changes what the page
shows, so it has to be findable without being clicked to find out —
and its state has to be readable at a glance rather than from the
difference between a filled and a hollow circle.
Only shown once there is a drawing: until then it would offer to hide
nothing. */}
{totalStrokes(ink) > 0 && (
<button
data-pdf-hide-ink={hideInk ? "on" : "off"}
onClick={() => setHideInk((v) => !v)}
title={hideInk ? "Zeichnung wieder einblenden" : "Zeichnung ausblenden"}
style={{
...barButton,
width: "auto",
padding: "0 10px",
gap: 6,
border: `1px solid ${hideInk ? T.accent + "66" : T.border}`,
background: hideInk ? T.accent + "1a" : "transparent",
color: hideInk ? T.accent : T.muted,
fontWeight: 600,
fontSize: 12,
}}
>
{hideInk ? I.eyeOff : I.eye} {hideInk ? "Zeichnung aus" : "Zeichnung an"}
</button>
)}
{notesToggle}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
<button onClick={() => stepZoom(-1)} title="Kleiner" style={barButton}>
−
</button>
<span style={{ minWidth: 42, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
<button onClick={() => stepZoom(1)} title="Größer" style={barButton}>
+
</button>
</div>
</div>
<div ref={scrollRef} style={{ flex: 1, overflow: "auto", padding: 16, minHeight: 0, minWidth: 0 }}>
{state.status === "loading" && (
<div
style={{ color: T.muted, fontSize: 13, fontFamily: FONT_UI, textAlign: "center", padding: 40 }}
>
PDF wird geladen…
</div>
)}
{state.status === "ready" && (
<div ref={innerRef} style={{ position: "relative" }}>
{state.sizes.map((size, i) => (
<PdfPage
key={i}
doc={state.doc}
index={i}
// Sized from the page's own dimensions, not the widest one: a
// document mixing portrait and landscape must not stretch the
// narrow pages to match.
width={Math.round(size.width * scale)}
height={Math.round(size.height * scale)}
size={size}
visible={visible.has(i)}
selectable={!drawing && !placing}
ink={{
strokes: strokesForPage(ink, i + 1),
drawing,
tool,
color,
sizeIdx,
hidden: hideInk,
onCommit: commitPage,
}}
pins={{
T,
notes: notesForPage(ink, i + 1),
placing,
openId: openPin,
hidden: hideInk || !showPins,
withMargin,
onOpen: setOpenPin,
onPlace: placeNote,
onChange: changeNote,
onMove: moveNote,
onRemove: removeNote,
numberOf: (id) => pinNumbers.get(id) ?? "",
}}
/>
))}
</div>
)}
</div>
{/* No margin to write in — the note comes up from the bottom instead, over
the page but within reach of a thumb, and only while it is open. */}
{!withMargin && openSheet && (
<PdfNoteSheet
T={T}
note={openSheet.note}
number={pinNumbers.get(openSheet.note.id) ?? ""}
onChange={(text) => changeNote(openSheet.page, openSheet.size, openSheet.note.id, text)}
onRemove={() => removeNote(openSheet.page, openSheet.size, openSheet.note.id)}
onClose={() => setOpenPin(null)}
/>
)}
</div>
);
}