Files
InkNote/frontend/src/features/pdf/PdfViewer.jsx
T
marcandClaude Opus 5 b12b0d6142 Select text in a PDF, and find PDFs by their contents
Stage five. pdf.js draws pages as pictures, so selecting means laying
transparent spans over them exactly where each word was painted. The
layer carries the same scale as the canvas — they drift apart otherwise
and a selection lands on the wrong words. While drawing it stops taking
pointer events entirely, or a stroke would come out as a selection.

The text is also extracted and handed to the index that already holds the
notes, so one search box finds a sentence whether it was typed or
imported. Extraction runs after the document is on screen and yields
between pages: it must never stand between the reader and the first page.

The extracted text is deliberately not sent to the server. It is
derivable from a file the server already holds, so storing it again costs
space and a second thing to encrypt for nothing gained. The price, stated
plainly in the readme, is that a PDF becomes searchable on a device once
that device has opened it — the same shape as the limitation notes have.

Text runs are joined on pdf.js's hasEOL rather than concatenated. Without
it the last word of a line glues to the first of the next and matches
nothing when searched for.

The minimal text-layer CSS is written out rather than importing
pdfjs-dist/web/pdf_viewer.css, which carries the whole reference viewer's
toolbars and dialogs.

Suite now checks the layer sits over the page rather than stacked in a
corner, that the text is transparent, that dragging selects real words,
that a word from inside the file finds the note and one that is not in it
finds nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:12:06 +02:00

527 lines
18 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<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 ref={textRef} className="pdf-text-layer" data-selectable={selectable ? "true" : "false"} />
<PdfDrawLayer
pageNo={index + 1}
pageSize={size}
cssWidth={width}
cssHeight={height}
strokes={ink.strokes}
drawing={ink.drawing}
tool={ink.tool}
color={ink.color}
sizeIdx={ink.sizeIdx}
hidden={ink.hidden}
onCommit={ink.onCommit}
/>
</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, 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 (
<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={{ width: 1, height: 18, background: T.border, margin: "0 2px" }} />
{/* Reading and drawing are separate modes: while drawing, the ink layer
takes the pointer, and scrolling with a finger still has to work. */}
<button
data-pdf-mode={drawing ? "draw" : "read"}
onClick={() => setDrawing((v) => !v)}
title={drawing ? "Zeichnen beenden" : "Zeichnen"}
style={{
...barButton,
width: "auto",
padding: "0 10px",
gap: 6,
background: drawing ? T.accent + "22" : "transparent",
color: drawing ? T.accent : T.muted,
fontWeight: 600,
}}
>
{I.pen} {drawing ? "Zeichnen" : "Lesen"}
</button>
{drawing && (
<>
{["pen", "highlighter", "eraser"].map((t) => (
<button
key={t}
data-pdf-tool={t}
onClick={() => setTool(t)}
title={t === "pen" ? "Stift" : t === "highlighter" ? "Marker" : "Radierer"}
style={{ ...barButton, background: tool === t ? T.accent + "22" : "transparent" }}
>
{t === "pen" ? I.pen : t === "highlighter" ? I.highlight : I.eraser}
</button>
))}
<div style={{ position: "relative" }}>
<button
onClick={() => setShowPalette((v) => !v)}
title="Farbe"
style={{ ...barButton, width: 22, height: 22, background: color, borderRadius: "50%" }}
/>
{showPalette && (
<div
style={{
position: "absolute",
top: 32,
left: 0,
zIndex: 40,
display: "grid",
gridTemplateColumns: "repeat(5, 20px)",
gap: 6,
padding: 8,
borderRadius: 10,
background: T.bar,
border: `1px solid ${T.border}`,
boxShadow: "0 8px 20px rgba(0,0,0,.25)",
}}
>
{COLORS.map((c) => (
<button
key={c}
onClick={() => {
setColor(c);
setShowPalette(false);
}}
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: c,
border: c === color ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
cursor: "pointer",
}}
/>
))}
</div>
)}
</div>
<select
value={sizeIdx}
onChange={(e) => setSizeIdx(Number(e.target.value))}
title="Strichstärke"
style={{
background: "transparent",
color: T.muted,
border: `1px solid ${T.border}`,
borderRadius: 8,
fontSize: 11,
padding: "2px 4px",
}}
>
{PEN_SIZES.map((s, i) => (
<option key={s.label} value={i}>
{s.label}
</option>
))}
</select>
<button onClick={undo} title="Rückgängig" style={barButton}>
{I.undo}
</button>
<button onClick={() => clearPage(pageNo)} title="Seite leeren" style={barButton}>
{I.trash}
</button>
</>
)}
<button
data-pdf-hide-ink={hideInk ? "on" : "off"}
onClick={() => setHideInk((v) => !v)}
title={hideInk ? "Zeichnung einblenden" : "Zeichnung ausblenden"}
style={{ ...barButton, color: hideInk ? T.accent : T.muted }}
>
{hideInk ? "◌" : "●"}
</button>
<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)}
size={size}
visible={visible.has(i)}
selectable={!drawing}
ink={{
strokes: strokesForPage(ink, i + 1),
drawing,
tool,
color,
sizeIdx,
hidden: hideInk,
onCommit: commitPage,
}}
/>
))}
</div>
)}
</div>
</div>
);
}