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 {
allPinnedNotes,
asPdfStrokeDoc,
hasOverlay,
notesForPage,
strokesForPage,
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 (
);
}
/**
* 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 }) {
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 [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]);
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]
);
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]
);
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;
const fitWidth = Math.max(240, Math.min(containerWidth - 32, 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) {
const n = Number(child.dataset.pdfPage);
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 (
{state.message}
);
}
return (
{state.status === "ready" ? `Seite ${pageNo} / ${state.pageCount}` : "Lädt…"}
{/* Reading, drawing and pinning are separate modes: each takes the
pointer differently, and scrolling with a finger still has to work. */}
{/* A note about a passage belongs on the passage. */}
{drawing && (
<>
{["pen", "highlighter", "eraser"].map((t) => (
))}
{I.undo}
clearPage(pageNo)} title="Seite leeren" style={barButton}>
{I.trash}
>
)}
{/* 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 && (
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"}
)}
{notesToggle}