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) => ( ))}
)}
)}
{Math.round(zoom * 100)}%
{state.status === "loading" && (
PDF wird geladen…
)} {state.status === "ready" && (
{state.sizes.map((size, i) => ( ))}
)}
); }