diff --git a/backend/server.js b/backend/server.js index abca15e..6b26da7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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, { diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs index 6da9f06..54a74c1 100644 --- a/e2e/pdf.mjs +++ b/e2e/pdf.mjs @@ -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. diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx index 7ef1521..95a5708 100644 --- a/frontend/src/features/notes/EditorPane.jsx +++ b/frontend/src/features/notes/EditorPane.jsx @@ -212,7 +212,7 @@ export function EditorPane({
{note.type === "pdf" ? ( - + ) : ( 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 ( +