Read imported PDFs in the app
Stage three: the viewer. Drawing on pages comes next. pdf.js stays out of the bundle everyone downloads — it is larger than the rest of the application put together, so it is behind a dynamic import and a session that never opens a PDF never fetches it. Main bundle grew by 8 kB for the viewer shell; the 162 kB library arrives only when needed. The worker is bundled rather than pulled from a CDN, because a viewer that stops working during someone else's outage is not one you can rely on offline. Two things that only showed up once it ran in a browser: - pdf.js 6 calls Uint8Array.prototype.toHex, which only very recent browsers have. Anything older failed with "n.toHex is not a function", which says nothing about the cause. Now on the legacy build, which compiles those away — a little larger, and the difference between working everywhere and working on this year's browsers. - nginx has no MIME type for .mjs and served the worker as octet-stream, which a browser refuses to run as a module. Pages are laid out at their true proportions before anything is drawn, so the scrollbar is honest immediately and a document mixing portrait and landscape keeps both — the first version scaled every page to the widest one, which the suite caught by checking the two aspect ratios differ. Bytes come from the authenticated attachment route and are cached in IndexedDB. Content-addressed storage means a cached copy can never be stale, so a document opened once needs no connection again. Verified with the network switched off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
53965281b4
commit
2986bcb620
16 files changed
+1079
-12
No files matched your search
@@ -0,0 +1,299 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import * as sync from "../../syncEngine";
|
||||
import { openPdf, renderPage } from "./pdfjs";
|
||||
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, visible, onRendered }) {
|
||||
const canvasRef = 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) {
|
||||
renderedAt.current = width;
|
||||
onRendered?.(index, page);
|
||||
}
|
||||
})().catch(() => {
|
||||
/* a page that will not draw leaves its placeholder rather than the whole
|
||||
document failing */
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelRender?.();
|
||||
};
|
||||
}, [doc, index, width, visible, onRendered]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-pdf-page={index + 1}
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
height,
|
||||
margin: "0 auto 16px",
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, onPagesReady }) {
|
||||
const [state, setState] = useState({ status: "loading" });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
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 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);
|
||||
})().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;
|
||||
};
|
||||
}, [note?.pdfAttachmentId, 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 (
|
||||
<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, background: T.bg }}>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<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={{ 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 }}>
|
||||
{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)}
|
||||
visible={visible.has(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user