The panel beside a PDF is for notes about the document; a note that belongs to one paragraph is a different thing, and one that has to begin "on page 14, third paragraph" has already lost its place. Click Randnotiz, click the passage, write. Pins ride in the same per-page overlay as the drawings, in PDF points, so one stays on its sentence at every zoom and in the exported file. Living outside the note body has consequences that each needed handling: nothing else would index them for search, so the viewer feeds them in with their page numbers; clearing a page's ink must not take them; and "is this overlay empty?" was answered by counting strokes — in the client *and* independently in the server, so an overlay holding only pinned notes was stored as NULL and lost every note on it. They are stamped into the annotated export as a numbered marker plus a box of text, on whichever side of the pin has room. Standard PDF fonts are Latin-1, so characters outside it are replaced rather than failing the whole export. The Notizen control moves into the viewer's toolbar. A second row for one button cost reading height on every screen and put one of the document's controls somewhere none of the others were. On a phone, where the viewer is not on screen while the notes are, the way back sits in the panel. Renaming a separator no longer shifts the row: the two rules always share what is left over equally and the middle keeps one width in both states. 215 unit tests, 206 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
685 lines
23 KiB
React
685 lines
23 KiB
React
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 {
|
||
allPinnedNotes,
|
||
asPdfStrokeDoc,
|
||
hasOverlay,
|
||
notesForPage,
|
||
strokesForPage,
|
||
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 (
|
||
<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}
|
||
/>
|
||
{!pins.hidden && (
|
||
<PdfPins
|
||
T={pins.T}
|
||
pageNo={index + 1}
|
||
pageSize={size}
|
||
cssWidth={width}
|
||
cssHeight={height}
|
||
notes={pins.notes}
|
||
placing={pins.placing}
|
||
openId={pins.openId}
|
||
onOpen={pins.onOpen}
|
||
onPlace={(at) => pins.onPlace(index + 1, size, at)}
|
||
onChange={(id, text) => pins.onChange(index + 1, size, id, text)}
|
||
onMove={(id, x, y) => pins.onMove(index + 1, size, id, x, y)}
|
||
onRemove={(id) => pins.onRemove(index + 1, size, id)}
|
||
numberOf={pins.numberOf}
|
||
/>
|
||
)}
|
||
</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, notesToggle }) {
|
||
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 [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;
|
||
// `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]
|
||
);
|
||
|
||
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]
|
||
);
|
||
|
||
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, drawing and pinning are separate modes: each takes the
|
||
pointer differently, and scrolling with a finger still has to work. */}
|
||
<button
|
||
data-pdf-mode={drawing ? "draw" : "read"}
|
||
onClick={() => {
|
||
setDrawing((v) => !v);
|
||
setPlacing(false);
|
||
}}
|
||
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>
|
||
|
||
{/* A note about a passage belongs on the passage. */}
|
||
<button
|
||
data-pdf-pin-mode={placing ? "on" : "off"}
|
||
onClick={() => {
|
||
setPlacing((v) => !v);
|
||
setDrawing(false);
|
||
setOpenPin(null);
|
||
}}
|
||
title={placing ? "Stelle anklicken — oder abbrechen" : "Notiz an eine Stelle heften"}
|
||
style={{
|
||
...barButton,
|
||
width: "auto",
|
||
padding: "0 10px",
|
||
gap: 6,
|
||
background: placing ? T.accent + "22" : "transparent",
|
||
color: placing ? T.accent : T.muted,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{I.pin} {placing ? "Stelle wählen" : "Randnotiz"}
|
||
</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>
|
||
</>
|
||
)}
|
||
|
||
{/* 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 && (
|
||
<button
|
||
data-pdf-hide-ink={hideInk ? "on" : "off"}
|
||
onClick={() => setHideInk((v) => !v)}
|
||
title={hideInk ? "Zeichnung wieder einblenden" : "Zeichnung ausblenden"}
|
||
style={{
|
||
...barButton,
|
||
width: "auto",
|
||
padding: "0 10px",
|
||
gap: 6,
|
||
border: `1px solid ${hideInk ? T.accent + "66" : T.border}`,
|
||
background: hideInk ? T.accent + "1a" : "transparent",
|
||
color: hideInk ? T.accent : T.muted,
|
||
fontWeight: 600,
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{hideInk ? I.eyeOff : I.eye} {hideInk ? "Zeichnung aus" : "Zeichnung an"}
|
||
</button>
|
||
)}
|
||
|
||
{notesToggle}
|
||
|
||
<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 && !placing}
|
||
ink={{
|
||
strokes: strokesForPage(ink, i + 1),
|
||
drawing,
|
||
tool,
|
||
color,
|
||
sizeIdx,
|
||
hidden: hideInk,
|
||
onCommit: commitPage,
|
||
}}
|
||
pins={{
|
||
T,
|
||
notes: notesForPage(ink, i + 1),
|
||
placing,
|
||
openId: openPin,
|
||
hidden: hideInk,
|
||
onOpen: setOpenPin,
|
||
onPlace: placeNote,
|
||
onChange: changeNote,
|
||
onMove: moveNote,
|
||
onRemove: removeNote,
|
||
numberOf: (id) => pinNumbers.get(id) ?? "",
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|