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>
527 lines
18 KiB
React
527 lines
18 KiB
React
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>
|
||
);
|
||
}
|