Draw on PDF pages
Stage four. Strokes are kept per page in that page's own coordinate space — PDF points at scale 1 — not in the fixed 1200x1600 space a note's drawing uses. A PDF cannot use one space: every page has its own size, and a stroke belongs to the page it was drawn on. That is what keeps a mark on the word it was drawn over at any zoom and on any screen, and it is the only form that can be stamped back into the file in stage seven, where page points are the only coordinates that mean anything. Measured rather than assumed: zooming moves a stroke by 0.001 of the page. Pen sizes are scaled by the page width. The sizes are calibrated for the 1200-unit note page, so on a 595-point A4 page the same number would have drawn twice as thick. Reading and drawing are separate modes. While reading the ink layer takes no pointer events, so scrolling and selecting text reach the page beneath it; while drawing, a finger still scrolls and only a pen or mouse marks, because a touch-drag on a phone means scroll far more often than it means draw. The drawing can be hidden without being erased, for reading the original clean. The server accepted only the single-page shape; it now takes either and counts strokes across pages for its limit. Carried over from notes: the save token guard, so the six-second poller adopting a response issued before the local save cannot undo what was just drawn. The suite waits nine seconds and checks the ink is still there — that bug has cost strokes twice before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2986bcb620
commit
1be3a9b9ba
7 files changed
+589
-14
No files matched your search
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
|
||||
|
||||
import * as sync from "../../syncEngine";
|
||||
import { openPdf, renderPage } from "./pdfjs";
|
||||
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. */
|
||||
@@ -16,7 +21,7 @@ const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||
* 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 }) {
|
||||
function PdfPage({ doc, index, width, height, size, visible, ink }) {
|
||||
const canvasRef = useRef(null);
|
||||
const renderedAt = useRef(0);
|
||||
|
||||
@@ -32,10 +37,7 @@ function PdfPage({ doc, index, width, height, visible, onRendered }) {
|
||||
const { done, cancel } = renderPage(page, canvasRef.current, width);
|
||||
cancelRender = cancel;
|
||||
await done;
|
||||
if (!cancelled) {
|
||||
renderedAt.current = width;
|
||||
onRendered?.(index, page);
|
||||
}
|
||||
if (!cancelled) renderedAt.current = width;
|
||||
})().catch(() => {
|
||||
/* a page that will not draw leaves its placeholder rather than the whole
|
||||
document failing */
|
||||
@@ -45,7 +47,7 @@ function PdfPage({ doc, index, width, height, visible, onRendered }) {
|
||||
cancelled = true;
|
||||
cancelRender?.();
|
||||
};
|
||||
}, [doc, index, width, visible, onRendered]);
|
||||
}, [doc, index, width, visible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -62,6 +64,19 @@ function PdfPage({ doc, index, width, height, visible, onRendered }) {
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} style={{ display: "block", width: "100%" }} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -74,9 +89,76 @@ function PdfPage({ doc, index, width, height, visible, onRendered }) {
|
||||
* 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 }) {
|
||||
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);
|
||||
@@ -257,6 +339,123 @@ export function PdfViewer({ note, dark, onPagesReady }) {
|
||||
<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}>
|
||||
−
|
||||
@@ -288,7 +487,17 @@ export function PdfViewer({ note, dark, onPagesReady }) {
|
||||
// narrow pages to match.
|
||||
width={Math.round(size.width * scale)}
|
||||
height={Math.round(size.height * scale)}
|
||||
size={size}
|
||||
visible={visible.has(i)}
|
||||
ink={{
|
||||
strokes: strokesForPage(ink, i + 1),
|
||||
drawing,
|
||||
tool,
|
||||
color,
|
||||
sizeIdx,
|
||||
hidden: hideInk,
|
||||
onCommit: commitPage,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user