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. */
{pins.withMargin && !pins.hidden && ( pins.onChange(index + 1, size, id, text)} onRemove={(id) => pins.onRemove(index + 1, size, id)} numberOf={pins.numberOf} /> )}
); } /** * 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 (
{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. */} {/* 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 && ( )} {/* A note about a passage belongs on the passage. */} {drawing && ( <> {["pen", "highlighter", "eraser"].map((t) => ( ))}
)}
)} {/* 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 && ( )} {notesToggle}
{Math.round(zoom * 100)}%
{state.status === "loading" && (
PDF wird geladen…
)} {state.status === "ready" && (
{state.sizes.map((size, i) => ( pinNumbers.get(id) ?? "", }} /> ))}
)}
{/* 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 && ( changeNote(openSheet.page, openSheet.size, openSheet.note.id, text)} onRemove={() => removeNote(openSheet.page, openSheet.size, openSheet.note.id)} onClose={() => setOpenPin(null)} /> )}
); }