import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import * as sync from "../../syncEngine";
import { openPdf, renderPage, renderTextLayer } from "./pdfjs";
import { extractAndCache, indexPdfText } from "./pdfText";
import "./textLayer.css";
import { PdfDrawLayer } from "./PdfDrawLayer.jsx";
import { asPdfStrokeDoc, strokesForPage, totalStrokes, withPage } 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, 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 }) {
const [state, setState] = useState({ status: "loading" });
const [zoom, setZoom] = useState(1);
const [drawing, setDrawing] = useState(false);
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;
onUpdate?.({ drawingStrokes: totalStrokes(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]
);
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 and drawing are separate modes: while drawing, the ink layer
takes the pointer, and scrolling with a finger still has to work. */}
{drawing && (
<>
{["pen", "highlighter", "eraser"].map((t) => (
))}