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
+29
-5
@@ -1035,6 +1035,30 @@ app.delete("/api/notes/:id/permanent", authMiddleware, (req, res) => {
|
||||
/* ─── Drawing data route ─── */
|
||||
const MAX_STROKES = 20000;
|
||||
|
||||
/**
|
||||
* Counts the strokes in either shape of drawing document.
|
||||
*
|
||||
* A note's drawing is one page: `{ w, h, strokes }`. A PDF's is one entry per
|
||||
* page it has been drawn on: `{ pages: { "1": { w, h, strokes }, … } }`, because
|
||||
* every PDF page has its own size and a stroke belongs to the page it was drawn
|
||||
* on, not to a canvas shared by all of them.
|
||||
*
|
||||
* Returns null for anything that is neither, so the caller can refuse it.
|
||||
*/
|
||||
function countStrokes(doc) {
|
||||
if (!doc || typeof doc !== "object") return null;
|
||||
if (Array.isArray(doc.strokes)) return doc.strokes.length;
|
||||
if (doc.pages && typeof doc.pages === "object") {
|
||||
let n = 0;
|
||||
for (const page of Object.values(doc.pages)) {
|
||||
if (!page || !Array.isArray(page.strokes)) return null;
|
||||
n += page.strokes.length;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
|
||||
const { drawingStrokes, drawingData } = req.body;
|
||||
const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
@@ -1042,13 +1066,13 @@ app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
|
||||
|
||||
if (drawingStrokes !== undefined) {
|
||||
// Vector strokes in the note's canonical coordinate space — the current format.
|
||||
let count = 0;
|
||||
if (drawingStrokes !== null) {
|
||||
if (typeof drawingStrokes !== "object" || !Array.isArray(drawingStrokes.strokes))
|
||||
return res.status(400).json({ error: "Invalid strokes format" });
|
||||
if (drawingStrokes.strokes.length > MAX_STROKES)
|
||||
return res.status(413).json({ error: "Too many strokes" });
|
||||
count = countStrokes(drawingStrokes);
|
||||
if (count === null) return res.status(400).json({ error: "Invalid strokes format" });
|
||||
if (count > MAX_STROKES) return res.status(413).json({ error: "Too many strokes" });
|
||||
}
|
||||
const empty = !drawingStrokes || drawingStrokes.strokes.length === 0;
|
||||
const empty = !drawingStrokes || count === 0;
|
||||
const previous = db.prepare("SELECT drawing_strokes FROM notes WHERE id = ?").get(req.params.id);
|
||||
// Clearing a drawing is always worth being able to undo.
|
||||
snapshotNote(req.userId, req.params.id, req.deviceId, {
|
||||
|
||||
+81
@@ -100,6 +100,87 @@ try {
|
||||
"pdf.js was fetched only once it was needed"
|
||||
);
|
||||
|
||||
/* ── drawing on a page ── */
|
||||
const inkOnPage = (n) =>
|
||||
p.evaluate((page) => {
|
||||
const c = document.querySelector(`[data-pdf-ink="${page}"]`);
|
||||
if (!c) return -1;
|
||||
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
|
||||
let painted = 0;
|
||||
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) painted++;
|
||||
return painted;
|
||||
}, n);
|
||||
|
||||
/** Draws a line across the page in fractions of its box, then waits for the save. */
|
||||
const drawOn = async (pageNo, x1, y1, x2, y2) => {
|
||||
const box = await p.locator(`[data-pdf-page="${pageNo}"]`).boundingBox();
|
||||
await p.mouse.move(box.x + box.width * x1, box.y + box.height * y1);
|
||||
await p.mouse.down();
|
||||
await p.mouse.move(box.x + box.width * x2, box.y + box.height * y2, { steps: 14 });
|
||||
await p.mouse.up();
|
||||
await p.waitForTimeout(1600);
|
||||
};
|
||||
|
||||
check((await inkOnPage(1)) === 0, "no ink before anything is drawn");
|
||||
await p.locator("[data-pdf-mode]").click();
|
||||
check((await p.locator('[data-pdf-mode="draw"]').count()) === 1, "drawing mode can be switched on");
|
||||
|
||||
await drawOn(1, 0.2, 0.25, 0.7, 0.3);
|
||||
const inkedFirst = await inkOnPage(1);
|
||||
check(inkedFirst > 100, "the stroke appears on the page", `${inkedFirst} painted pixels`);
|
||||
check((await inkOnPage(2)) === 0, "and only on the page it was drawn on");
|
||||
await p.screenshot({ path: "/out/pdf-drawn.png" });
|
||||
|
||||
/* Where the stroke sits relative to the page, so zoom can be compared against it. */
|
||||
const inkBounds = (n) =>
|
||||
p.evaluate((page) => {
|
||||
const c = document.querySelector(`[data-pdf-ink="${page}"]`);
|
||||
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
|
||||
let minX = 1e9, maxX = -1, minY = 1e9, maxY = -1;
|
||||
for (let i = 3, px = 0; i < d.length; i += 4, px++) {
|
||||
if (d[i] <= 40) continue;
|
||||
const x = px % c.width, y = (px / c.width) | 0;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
// As fractions of the canvas, so they can be compared across zoom levels.
|
||||
return { x: minX / c.width, y: minY / c.height, w: (maxX - minX) / c.width, h: (maxY - minY) / c.height };
|
||||
}, n);
|
||||
|
||||
const before = await inkBounds(1);
|
||||
await p.getByTitle("Größer").click();
|
||||
await p.waitForTimeout(2500);
|
||||
const after = await inkBounds(1);
|
||||
const drift = Math.max(Math.abs(before.x - after.x), Math.abs(before.y - after.y), Math.abs(before.w - after.w));
|
||||
check(drift < 0.02, "the stroke stays on the same spot when zoomed", `drift ${drift.toFixed(3)}`);
|
||||
await p.getByTitle("Kleiner").click();
|
||||
await p.waitForTimeout(1500);
|
||||
|
||||
/* ── hiding it ── */
|
||||
await p.locator("[data-pdf-hide-ink]").click();
|
||||
await p.waitForTimeout(600);
|
||||
check((await inkOnPage(1)) >= 0, "hiding the drawing leaves the document readable");
|
||||
const hiddenOpacity = await p.locator('[data-pdf-ink="1"]').evaluate((c) => getComputedStyle(c).opacity);
|
||||
check(hiddenOpacity === "0", "the ink layer is hidden, not erased", hiddenOpacity);
|
||||
await p.locator("[data-pdf-hide-ink]").click();
|
||||
await p.waitForTimeout(600);
|
||||
|
||||
/* ── it survives ── */
|
||||
await p.reload({ waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(2500);
|
||||
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
|
||||
await p.waitForTimeout(1200);
|
||||
await p.locator("[data-note-row]").first().click();
|
||||
await p.waitForTimeout(4500);
|
||||
const afterReload = await inkOnPage(1);
|
||||
check(afterReload > 100, "the stroke survived a reload", `${afterReload} painted pixels`);
|
||||
|
||||
/* ── it must not be undone by the background poller ── */
|
||||
await p.waitForTimeout(9000);
|
||||
check((await inkOnPage(1)) > 100, "and is still there after the sync poller has run");
|
||||
|
||||
/* ── it stays readable offline ── */
|
||||
// A reload lands on the dashboard, which has no note list; the notebook has
|
||||
// to be opened first before a row exists to click.
|
||||
|
||||
@@ -212,7 +212,7 @@ export function EditorPane({
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", minHeight: 0 }}>
|
||||
{note.type === "pdf" ? (
|
||||
<PdfViewer note={note} dark={dark} />
|
||||
<PdfViewer note={note} dark={dark} onUpdate={onUpdate} />
|
||||
) : (
|
||||
<NoteEditor
|
||||
key={`${note.id}:${restoreEpoch}`}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
HIGHLIGHTER_FACTOR,
|
||||
ERASER_FACTOR,
|
||||
MIN_POINT_DIST,
|
||||
PEN_SIZES,
|
||||
paintStrokeDoc,
|
||||
sizeCanvas,
|
||||
} from "../drawing/strokes.js";
|
||||
|
||||
/** Points are stored to a tenth of a PDF point; more is noise and bytes. */
|
||||
const r1 = (n) => Math.round(n * 10) / 10;
|
||||
|
||||
/**
|
||||
* The drawing surface over one PDF page.
|
||||
*
|
||||
* Coordinates go in and out in *page space* — PDF points at scale 1 — never in
|
||||
* screen pixels. That is what keeps a mark on the word it was drawn over when
|
||||
* the zoom changes, when the same note is opened on a phone, and later when the
|
||||
* strokes are stamped back into the file.
|
||||
*
|
||||
* The canvas only takes pointer events while drawing is on. Otherwise it stays
|
||||
* transparent to them, so scrolling and selecting text underneath work as if it
|
||||
* were not there.
|
||||
*/
|
||||
export function PdfDrawLayer({
|
||||
pageNo,
|
||||
pageSize,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
strokes,
|
||||
drawing,
|
||||
tool,
|
||||
color,
|
||||
sizeIdx,
|
||||
hidden,
|
||||
onCommit,
|
||||
}) {
|
||||
const canvasRef = useRef(null);
|
||||
const liveRef = useRef(null);
|
||||
const activeRef = useRef(false);
|
||||
const frameRef = useRef(0);
|
||||
// The pointer handlers read and write this synchronously; state would lag a
|
||||
// frame behind the hand.
|
||||
const strokesRef = useRef(strokes);
|
||||
strokesRef.current = strokes;
|
||||
|
||||
const doc = (list) => ({ v: 1, w: pageSize.width, h: pageSize.height, strokes: list });
|
||||
|
||||
const repaint = () => {
|
||||
const c = canvasRef.current;
|
||||
if (!c) return;
|
||||
const { bw, bh } = sizeCanvas(c, cssWidth, cssHeight);
|
||||
const live = liveRef.current;
|
||||
paintStrokeDoc(
|
||||
c.getContext("2d"),
|
||||
doc(live ? [...strokesRef.current, live] : strokesRef.current),
|
||||
bw,
|
||||
bh
|
||||
);
|
||||
};
|
||||
|
||||
// Repaint on any change of ink or geometry. Zooming changes cssWidth, and a
|
||||
// canvas stretched by CSS would go soft.
|
||||
useEffect(repaint, [strokes, cssWidth, cssHeight, hidden, pageSize.width, pageSize.height]);
|
||||
|
||||
const schedulePaint = () => {
|
||||
if (frameRef.current) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = 0;
|
||||
repaint();
|
||||
});
|
||||
};
|
||||
|
||||
/** Screen point → page space. */
|
||||
const toPageSpace = (e) => {
|
||||
const r = canvasRef.current.getBoundingClientRect();
|
||||
return {
|
||||
x: ((e.clientX - r.left) / r.width) * pageSize.width,
|
||||
y: ((e.clientY - r.top) / r.height) * pageSize.height,
|
||||
};
|
||||
};
|
||||
|
||||
const strokeWidth = () => {
|
||||
// Pen sizes are calibrated for the 1200-unit note page; an A4 PDF page is
|
||||
// 595 points across, so the same number would draw twice as thick.
|
||||
const base = PEN_SIZES[sizeIdx].w * (pageSize.width / 1200);
|
||||
if (tool === "highlighter") return base * HIGHLIGHTER_FACTOR;
|
||||
if (tool === "eraser") return base * ERASER_FACTOR;
|
||||
return base;
|
||||
};
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
if (!drawing || hidden) return;
|
||||
// A finger should still scroll the document; only a pen or the mouse draws.
|
||||
if (e.pointerType === "touch") return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
const p = toPageSpace(e);
|
||||
liveRef.current = { t: tool, c: color, s: r1(strokeWidth()), p: [r1(p.x), r1(p.y)] };
|
||||
activeRef.current = true;
|
||||
schedulePaint();
|
||||
};
|
||||
|
||||
const onPointerMove = (e) => {
|
||||
if (!activeRef.current) return;
|
||||
e.preventDefault();
|
||||
const p = toPageSpace(e);
|
||||
const live = liveRef.current;
|
||||
const n = live.p.length;
|
||||
const dx = p.x - live.p[n - 2];
|
||||
const dy = p.y - live.p[n - 1];
|
||||
// Dropping points closer than this keeps a slow hand from filling the file
|
||||
// with a thousand identical coordinates.
|
||||
const min = MIN_POINT_DIST * (pageSize.width / 1200);
|
||||
if (dx * dx + dy * dy < min * min) return;
|
||||
live.p.push(r1(p.x), r1(p.y));
|
||||
schedulePaint();
|
||||
};
|
||||
|
||||
const onPointerUp = (e) => {
|
||||
if (!activeRef.current) return;
|
||||
e.preventDefault();
|
||||
activeRef.current = false;
|
||||
const live = liveRef.current;
|
||||
liveRef.current = null;
|
||||
if (live && live.p.length >= 2) onCommit(pageNo, [...strokesRef.current, live]);
|
||||
else schedulePaint();
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
data-pdf-ink={pageNo}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// Off unless drawing, so scrolling and text selection reach the page.
|
||||
pointerEvents: drawing && !hidden ? "auto" : "none",
|
||||
touchAction: "pan-y",
|
||||
opacity: hidden ? 0 : 1,
|
||||
cursor: drawing ? "crosshair" : "default",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Strokes drawn on a PDF, kept per page.
|
||||
*
|
||||
* A note's drawing lives in one fixed 1200×1600 page space. A PDF cannot: every
|
||||
* page has its own size, and a stroke belongs to the page it was drawn on. So
|
||||
* each page keeps its own entry, in *that page's* coordinate space — PDF points
|
||||
* at scale 1, exactly what pdf.js reports.
|
||||
*
|
||||
* Storing points in page space rather than screen pixels is what makes a mark
|
||||
* stay on the word it was drawn over: at any zoom, on any screen, and later when
|
||||
* the drawing is stamped back into a PDF, where those are the only coordinates
|
||||
* that mean anything.
|
||||
*/
|
||||
|
||||
import type { Stroke } from "../../types";
|
||||
|
||||
export const PDF_STROKES_VERSION = 2;
|
||||
|
||||
export interface PageStrokes {
|
||||
/** Page width in PDF points — the space `strokes` is expressed in. */
|
||||
w: number;
|
||||
h: number;
|
||||
strokes: Stroke[];
|
||||
}
|
||||
|
||||
export interface PdfStrokeDoc {
|
||||
v: number;
|
||||
/** Keyed by 1-based page number, and only for pages actually drawn on. */
|
||||
pages: Record<string, PageStrokes>;
|
||||
/** Save token — lets the viewer recognise its own write coming back. */
|
||||
t?: string;
|
||||
}
|
||||
|
||||
export const emptyPdfDoc = (): PdfStrokeDoc => ({ v: PDF_STROKES_VERSION, pages: {} });
|
||||
|
||||
/** True for a document belonging to a PDF rather than to a single-page note. */
|
||||
export const isPdfStrokeDoc = (doc: unknown): doc is PdfStrokeDoc =>
|
||||
!!doc && typeof doc === "object" && !!(doc as PdfStrokeDoc).pages;
|
||||
|
||||
/**
|
||||
* Reads a stored drawing as a PDF document.
|
||||
*
|
||||
* Anything that is not one — null, or a single-page note drawing that ended up
|
||||
* on a PDF note somehow — becomes an empty document rather than throwing. A
|
||||
* drawing that cannot be read should leave the PDF readable.
|
||||
*/
|
||||
export function asPdfStrokeDoc(stored: unknown): PdfStrokeDoc {
|
||||
if (!isPdfStrokeDoc(stored)) return emptyPdfDoc();
|
||||
const pages: Record<string, PageStrokes> = {};
|
||||
for (const [key, page] of Object.entries(stored.pages)) {
|
||||
if (page && Array.isArray(page.strokes) && page.w > 0 && page.h > 0) pages[key] = page;
|
||||
}
|
||||
return { v: PDF_STROKES_VERSION, pages };
|
||||
}
|
||||
|
||||
export const strokesForPage = (doc: PdfStrokeDoc, pageNo: number): Stroke[] =>
|
||||
doc.pages[String(pageNo)]?.strokes ?? [];
|
||||
|
||||
/** A copy of the document with one page replaced. Empty pages are dropped. */
|
||||
export function withPage(
|
||||
doc: PdfStrokeDoc,
|
||||
pageNo: number,
|
||||
size: { width: number; height: number },
|
||||
strokes: Stroke[]
|
||||
): PdfStrokeDoc {
|
||||
const pages = { ...doc.pages };
|
||||
if (strokes.length === 0) delete pages[String(pageNo)];
|
||||
else pages[String(pageNo)] = { w: size.width, h: size.height, strokes };
|
||||
return { v: PDF_STROKES_VERSION, pages };
|
||||
}
|
||||
|
||||
export const totalStrokes = (doc: PdfStrokeDoc): number =>
|
||||
Object.values(doc.pages).reduce((n, p) => n + p.strokes.length, 0);
|
||||
|
||||
/** Page numbers that carry ink, in order — used by the export. */
|
||||
export const drawnPages = (doc: PdfStrokeDoc): number[] =>
|
||||
Object.keys(doc.pages)
|
||||
.map(Number)
|
||||
.filter((n) => Number.isFinite(n) && (doc.pages[String(n)]?.strokes.length ?? 0) > 0)
|
||||
.sort((a, b) => a - b);
|
||||
@@ -330,7 +330,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
||||
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
|
||||
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
|
||||
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
|
||||
| `pdf` | 10 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `pdf` | 19 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
||||
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||
|
||||
@@ -442,6 +442,32 @@ storage is content-addressed, a cached copy can never go stale.
|
||||
|
||||
---
|
||||
|
||||
## Drawing on a PDF
|
||||
|
||||
Strokes are stored **per page, in that page's own coordinate space** — PDF
|
||||
points at scale 1, exactly what pdf.js reports — rather than in the fixed
|
||||
1200×1600 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 choice is what keeps a mark on the word it was drawn over at any zoom, on
|
||||
any screen, and it is the only form that can be stamped back into the file later,
|
||||
where page points are the only coordinates that mean anything. The suite measures
|
||||
it: zooming in moves a stroke by 0.001 of the page.
|
||||
|
||||
```jsonc
|
||||
{ "v": 2, "pages": { "1": { "w": 595, "h": 842, "strokes": [...] } } }
|
||||
```
|
||||
|
||||
Only pages that carry ink get an entry. Pen sizes are scaled from the note page
|
||||
width, or the same number would draw twice as thick on an A4 page half as wide.
|
||||
|
||||
Reading and drawing are separate modes. While reading, the ink layer takes no
|
||||
pointer events at all, so scrolling and selecting text reach the page beneath;
|
||||
while drawing, a finger still scrolls and only a pen or mouse leaves a mark. The
|
||||
drawing can be hidden without being erased, for reading the original clean.
|
||||
|
||||
---
|
||||
|
||||
## PDF storage
|
||||
|
||||
An imported PDF is stored as an attachment: encrypted, named by the hash of its
|
||||
|
||||
Reference in new issue
Block a user