diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs new file mode 100644 index 0000000..6da9f06 --- /dev/null +++ b/e2e/pdf.mjs @@ -0,0 +1,135 @@ +/* Importing and reading a PDF. + * + * The interesting parts are not that a canvas appears: it is that the library + * stays out of the main bundle until a PDF is opened, that the pages come out at + * the right proportions, and that a document read once survives without the + * network — the offline promise is the whole reason the bytes are cached. */ +import { writeFileSync } from "node:fs"; +import { check, createNotebook, launch, report, withFreshUser } from "./lib.mjs"; + +const PDF_PATH = "/tmp/e2e-test.pdf"; + +/** A two-page PDF built here, so the suite depends on no fixture file. */ +function writeTestPdf() { + const objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 7 0 R >> >> >>", + null, // stream, filled below + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>", + null, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]; + const s1 = "BT /F1 24 Tf 72 760 Td (Seite eins Zebrastreifen) Tj ET"; + const s2 = "BT /F1 24 Tf 72 520 Td (Seite zwei Dichtung) Tj ET"; + objs[3] = `<< /Length ${s1.length} >>\nstream\n${s1}\nendstream`; + objs[5] = `<< /Length ${s2.length} >>\nstream\n${s2}\nendstream`; + + let out = "%PDF-1.4\n"; + const offsets = []; + objs.forEach((o, i) => { + offsets.push(out.length); + out += `${i + 1} 0 obj\n${o}\nendobj\n`; + }); + const xref = out.length; + out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`; + for (const off of offsets) out += `${String(off).padStart(10, "0")} 00000 n \n`; + out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + writeFileSync(PDF_PATH, out, "latin1"); +} + +const browser = await launch(); +let session; +try { + writeTestPdf(); + session = await withFreshUser(browser, "pdf"); + const p = session.page; + + /* The library must not be in the bundle everyone downloads. */ + const scriptsBefore = await p.evaluate(() => + performance.getEntriesByType("resource").map((r) => r.name) + ); + check( + !scriptsBefore.some((u) => /pdf[.-]/i.test(u)), + "pdf.js is not loaded before a PDF is opened", + scriptsBefore.filter((u) => /pdf/i.test(u)).join(", ") || "none" + ); + + await createNotebook(p, "PDF Buch"); + + /* ── import ── */ + const chooser = p.waitForEvent("filechooser"); + await p.getByTitle("Import PDF").click(); + await (await chooser).setFiles(PDF_PATH); + await p.waitForTimeout(4000); + + check((await p.locator("[data-pdf-page]").count()) > 0, "the document is shown after importing"); + check( + (await p.locator("[data-pdf-position]").innerText()).includes("/ 2"), + "both pages are counted", + await p.locator("[data-pdf-position]").innerText() + ); + await p.screenshot({ path: "/out/pdf-imported.png" }); + + /* ── proportions ── */ + const boxes = await p.$$eval("[data-pdf-page]", (els) => + els.map((e) => ({ w: e.offsetWidth, h: e.offsetHeight })) + ); + check(boxes.length === 2, "both pages are laid out", JSON.stringify(boxes)); + // Page one is A4 portrait (595×842), page two the same turned on its side. + const ratio1 = boxes[0].h / boxes[0].w; + const ratio2 = boxes[1].h / boxes[1].w; + check(Math.abs(ratio1 - 842 / 595) < 0.05, "the portrait page keeps its proportions", ratio1.toFixed(2)); + check(Math.abs(ratio2 - 595 / 842) < 0.05, "the landscape page is not stretched to match", ratio2.toFixed(2)); + + /* ── something was actually painted ── */ + const inkedPixels = await p.evaluate(() => { + const c = document.querySelector("[data-pdf-page] canvas"); + if (!c) return -1; + const ctx = c.getContext("2d"); + const d = ctx.getImageData(0, 0, c.width, Math.min(c.height, 400)).data; + let dark = 0; + for (let i = 0; i < d.length; i += 4) if (d[i] < 200) dark++; + return dark; + }); + check(inkedPixels > 50, "the page has actual content drawn on it", `${inkedPixels} dark pixels`); + + const loadedAfter = await p.evaluate(() => performance.getEntriesByType("resource").map((r) => r.name)); + check( + loadedAfter.some((u) => /pdf.*\.js|pdf\.worker/i.test(u)), + "pdf.js was fetched only once it was needed" + ); + + /* ── 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. + const reopen = async () => { + 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(4000); + }; + + await p.reload({ waitUntil: "networkidle" }); + await p.waitForTimeout(2500); + await reopen(); + check((await p.locator("[data-pdf-page]").count()) > 0, "the document reopens after a reload"); + + await p.context().setOffline(true); + await p.reload({ waitUntil: "domcontentloaded" }); + await p.waitForTimeout(3000); + await reopen(); + check( + (await p.locator("[data-pdf-page]").count()) > 0, + "and still opens with the network switched off" + ); + await p.screenshot({ path: "/out/pdf-offline.png" }); + await p.context().setOffline(false); +} catch (e) { + check(false, "suite ran to the end", e.message); +} finally { + await session?.cleanup(); + const code = report(); + await browser.close(); + process.exit(code); +} diff --git a/e2e/run.sh b/e2e/run.sh index 7a7f48f..6db33ce 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -13,7 +13,7 @@ cd "$(dirname "$0")" IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble" URL="${INKNOTE_URL:-http://localhost:3000}" SUITES=("$@") -[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict) +[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf) mkdir -p shots if [ ! -d node_modules ]; then diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 6f12c7f..43e198a 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -26,6 +26,15 @@ server { # Vite hashed assets — immutable cache location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; + # nginx has no mapping for .mjs and falls back to octet-stream, which a + # browser refuses to run as a module. pdf.js ships its worker as .mjs. + types { + application/javascript js mjs; + text/css css; + application/json json map; + image/svg+xml svg; + font/woff2 woff2; + } try_files $uri =404; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b164984..cc89a62 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@tiptap/react": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", "@tiptap/suggestion": "^2.27.2", + "pdfjs-dist": "^6.2.108", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -897,6 +898,271 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.3.tgz", + "integrity": "sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.3", + "@napi-rs/canvas-darwin-arm64": "1.0.3", + "@napi-rs/canvas-darwin-x64": "1.0.3", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.3", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.3", + "@napi-rs/canvas-linux-arm64-musl": "1.0.3", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.3", + "@napi-rs/canvas-linux-x64-gnu": "1.0.3", + "@napi-rs/canvas-linux-x64-musl": "1.0.3", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.3", + "@napi-rs/canvas-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.3.tgz", + "integrity": "sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.3.tgz", + "integrity": "sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.3.tgz", + "integrity": "sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -3048,6 +3314,18 @@ "node": ">= 14.16" } }, + "node_modules/pdfjs-dist": { + "version": "6.2.108", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz", + "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 81d0f0a..7c63865 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "@tiptap/react": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", "@tiptap/suggestion": "^2.27.2", + "pdfjs-dist": "^6.2.108", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6b6bfa7..c44ed0c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -234,6 +234,39 @@ export default function App() { ); /* ── Actions ── */ + /** + * Picks a file and imports it as a PDF note. + * + * The input is created and thrown away per use rather than living in the + * markup: a hidden input that persists keeps its previous selection, and + * choosing the same file twice in a row would then fire no change event. + */ + const importPdf = () => { + const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder; + if (!folderId) return; + + const input = document.createElement("input"); + input.type = "file"; + input.accept = "application/pdf,.pdf"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + try { + const note = await data.importPdf(folderId, file); + if (activeFolder === "dashboard") setActiveFolder(folderId); + openNote(note.id); + } catch (e) { + setConfirmDialog({ + title: "Import fehlgeschlagen", + message: e?.message || "Die Datei konnte nicht importiert werden.", + confirmLabel: "OK", + onConfirm: () => setConfirmDialog(null), + }); + } + }; + input.click(); + }; + const createNote = async () => { const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder; if (!folderId) return; @@ -489,6 +522,7 @@ export default function App() { onSelectNote={openNote} onNoteContextMenu={noteContextMenu} onCreateNote={createNote} + onImportPdf={importPdf} onEmptyTrash={emptyTrash} /> )} diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx index 2c2ef4e..7ef1521 100644 --- a/frontend/src/features/notes/EditorPane.jsx +++ b/frontend/src/features/notes/EditorPane.jsx @@ -2,6 +2,7 @@ import { I } from "../../ui/icons.jsx"; import { BackButton } from "../../ui/BackButton.jsx"; import { FONT_SERIF, FONT_UI } from "../../ui/theme"; import { NoteEditor } from "./NoteEditor.jsx"; +import { PdfViewer } from "../pdf/PdfViewer.jsx"; const toolbarButton = (T, color) => ({ display: "flex", @@ -209,17 +210,21 @@ export function EditorPane({ {I.trash} -
- +
+ {note.type === "pdf" ? ( + + ) : ( + + )}
); diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index 49dffa9..4fbabf5 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -16,6 +16,7 @@ export function NoteList({ onSelectNote, onNoteContextMenu, onCreateNote, + onImportPdf, onEmptyTrash, }) { return ( @@ -63,6 +64,26 @@ export function NoteList({
+ {!isTrashView && ( + + )} {!isTrashView && ( + + + {state.status === "ready" ? `Seite ${pageNo} / ${state.pageCount}` : "Lädt…"} + +
+ + {Math.round(zoom * 100)}% + +
+
+ +
+ {state.status === "loading" && ( +
+ PDF wird geladen… +
+ )} + {state.status === "ready" && ( +
+ {state.sizes.map((size, i) => ( + + ))} +
+ )} +
+ + ); +} diff --git a/frontend/src/features/pdf/pdfjs.ts b/frontend/src/features/pdf/pdfjs.ts new file mode 100644 index 0000000..7dafde1 --- /dev/null +++ b/frontend/src/features/pdf/pdfjs.ts @@ -0,0 +1,117 @@ +/** + * Loading pdf.js, once and only when a PDF is actually opened. + * + * The library is larger than the rest of the application put together, so it is + * behind a dynamic import: a session that never opens a PDF never downloads it. + * Vite gives it its own chunk automatically. + * + * The worker is bundled with the app rather than fetched from a CDN. pdf.js + * documents the CDN route, but this instance is meant to run without reaching + * out to anyone — and a viewer that stops working when a third party has an + * outage is not one you can rely on offline. + */ + +import type { PDFDocumentProxy, PDFPageProxy } from "pdfjs-dist"; + +export type { PDFDocumentProxy, PDFPageProxy }; + +/* The legacy build, not the default one. + * + * pdf.js 6 calls Uint8Array.prototype.toHex, which only very recent browsers + * have; on anything older the document fails to parse with a message that says + * nothing about the cause. The legacy build compiles those away. It costs a + * little size, and it is the difference between working everywhere and working + * on this year's browsers. */ +type PdfjsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs"); + +let loading: Promise | null = null; + +/** Resolves to the pdf.js module, fetching it on the first call. */ +export function loadPdfjs(): Promise { + if (!loading) { + loading = (async () => { + const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); + // `?url` keeps the worker a separate file instead of inlining it, which is + // what a worker needs to be. + const workerUrl = (await import("pdfjs-dist/legacy/build/pdf.worker.min.mjs?url")).default; + pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; + return pdfjs; + })().catch((err) => { + // A failed load must not be remembered as a resolved promise, or the + // viewer would stay broken for the rest of the session. + loading = null; + throw err; + }); + } + return loading; +} + +export interface PdfPageSize { + /** Page width in PDF points at scale 1 — the space strokes are stored in. */ + width: number; + height: number; +} + +export interface LoadedPdf { + doc: PDFDocumentProxy; + pageCount: number; + /** Size of every page, so the layout can be built before anything is rendered. */ + sizes: PdfPageSize[]; +} + +/** + * Opens a PDF from bytes. + * + * Takes the bytes rather than a URL because they come from the encrypted + * attachment route or from the offline cache — pdf.js fetching the URL itself + * would send no Authorization header and get a 401. + */ +export async function openPdf(bytes: Uint8Array): Promise { + const pdfjs = await loadPdfjs(); + // pdf.js takes ownership of the buffer it is given and detaches it; a copy + // keeps the caller's cached bytes usable afterwards. + const doc = await pdfjs.getDocument({ data: new Uint8Array(bytes) }).promise; + + const sizes: PdfPageSize[] = []; + for (let n = 1; n <= doc.numPages; n++) { + const page = await doc.getPage(n); + const viewport = page.getViewport({ scale: 1 }); + sizes.push({ width: viewport.width, height: viewport.height }); + } + return { doc, pageCount: doc.numPages, sizes }; +} + +/** + * Renders one page onto a canvas at the given CSS width. + * + * The canvas is sized in device pixels and scaled down by CSS, so the page is + * sharp on a high-density screen instead of being upscaled from a low-res + * bitmap. Returns a cancel function: scrolling quickly can start more renders + * than finish, and pdf.js complains loudly if two run on one canvas. + */ +export function renderPage( + page: PDFPageProxy, + canvas: HTMLCanvasElement, + cssWidth: number +): { done: Promise; cancel: () => void } { + const base = page.getViewport({ scale: 1 }); + const dpr = Math.min(window.devicePixelRatio || 1, 2); // beyond 2 costs memory for no visible gain + const viewport = page.getViewport({ scale: (cssWidth / base.width) * dpr }); + + canvas.width = Math.round(viewport.width); + canvas.height = Math.round(viewport.height); + canvas.style.width = `${cssWidth}px`; + canvas.style.height = `${Math.round(viewport.height / dpr)}px`; + + const ctx = canvas.getContext("2d"); + if (!ctx) return { done: Promise.resolve(), cancel: () => {} }; + + const task = page.render({ canvasContext: ctx, viewport, canvas }); + return { + done: task.promise.catch((err: unknown) => { + // A cancelled render is the normal outcome of scrolling, not a fault. + if ((err as { name?: string })?.name !== "RenderingCancelledException") throw err; + }), + cancel: () => task.cancel(), + }; +} diff --git a/frontend/src/hooks/useNoteData.ts b/frontend/src/hooks/useNoteData.ts index fd384ab..8140488 100644 --- a/frontend/src/hooks/useNoteData.ts +++ b/frontend/src/hooks/useNoteData.ts @@ -33,6 +33,7 @@ export interface NoteData { reset: () => void; createNote: (folderId: string) => Promise; + importPdf: (folderId: string, file: File) => Promise; updateNote: (id: string, edit: NoteEdit) => void; setNoteContent: (id: string, content: NoteContent) => void; toggleStar: (id: string) => Promise; @@ -185,6 +186,21 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD return note; }, []); + /** + * Uploads a PDF and creates the note that holds it. + * + * Not queued for offline: the file has to reach the server before a note can + * point at it, and a note referring to bytes nobody has would be worse than + * being told to try again with a connection. + */ + const importPdf = useCallback(async (folderId: string, file: File) => { + const attachment = await sync.uploadPdf(file, file.name); + const title = file.name.replace(/\.pdf$/i, "") || "Dokument"; + const note = await sync.createPdfNote(folderId, title, attachment.id); + setNotes((p) => [note, ...p]); + return note; + }, []); + const updateNote = useCallback((id: string, edit: NoteEdit) => { if (edit.title !== undefined) { const title = edit.title; @@ -284,6 +300,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD reload, reset, createNote, + importPdf, updateNote, setNoteContent, toggleStar, diff --git a/frontend/src/syncEngine.ts b/frontend/src/syncEngine.ts index 75b7dea..171f4e3 100644 --- a/frontend/src/syncEngine.ts +++ b/frontend/src/syncEngine.ts @@ -81,6 +81,8 @@ interface ServerNote { created_at: string; updated_at?: string; deleted_at?: string; + type?: string; + pdf_attachment_id?: string | null; } interface ServerNoteBody { @@ -190,6 +192,26 @@ const api = { return this.req("DELETE", p); }, + /** Sends raw bytes; req() would JSON-encode them and inflate the payload. */ + async postBinary(path: string, body: Blob, contentType: string): Promise { + const headers: Record = { "Content-Type": contentType, "X-Device-Id": DEVICE_ID }; + if (this.token) headers["Authorization"] = "Bearer " + this.token; + // No timeout here: a large upload on a slow line is not a hung request. + const r = await fetch(this.base + path, { method: "POST", headers, body }); + if (r.status === 401) { + this.forgetToken(); + throw new SyncError("AUTH"); + } + let data: unknown = {}; + try { + data = await r.json(); + } catch { + /* an error page without JSON still needs a sensible failure */ + } + if (!r.ok) throw new SyncError("REQUEST_FAILED", (data as { error?: string })?.error || "Upload failed"); + return data as T; + }, + /** Binary responses cannot go through req(), which always parses JSON. */ async getBlob(path: string): Promise { const headers: Record = { "X-Device-Id": DEVICE_ID }; @@ -427,6 +449,8 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta { trashed, createdAt: n.created_at, updatedAt: n.updated_at || n.deleted_at || n.created_at, + type: n.type === "pdf" ? "pdf" : "text", + pdfAttachmentId: n.pdf_attachment_id ?? null, }; } @@ -991,6 +1015,66 @@ export async function fetchAttachmentBlob(id: string): Promise { return api.getBlob(`/attachments/${id}`); } +/* ─── PDFs ─── + * + * A PDF rides on the attachment machinery — encrypted at rest, named by content + * hash, shared between notes holding the same file — but takes its own route up, + * as raw bytes rather than base64 inside JSON. */ + +export async function uploadPdf(file: Blob, filename?: string): Promise { + const query = filename ? `?filename=${encodeURIComponent(filename)}` : ""; + return api.postBinary(`/attachments/pdf${query}`, file, "application/pdf"); +} + +/** + * The bytes of a PDF, from the local cache when possible. + * + * Cached on first read, so a document stays readable offline and a reopened note + * does not re-download tens of megabytes. Content-addressed storage means the + * bytes behind an id never change, so a cached copy can never be stale. + */ +export async function loadPdfBytes(attachmentId: string): Promise { + const cached = await db.getAttachmentBlob(attachmentId); + if (cached) return new Uint8Array(await cached.arrayBuffer()); + + const blob = await api.getBlob(`/attachments/${attachmentId}`); + try { + await db.putAttachmentBlob(attachmentId, blob); + } catch { + /* a full quota costs us the cache, not the document */ + } + return new Uint8Array(await blob.arrayBuffer()); +} + +/** Creates a note that holds a PDF. */ +export async function createPdfNote( + folderId: string | null, + title: string, + pdfAttachmentId: string +): Promise { + const r = await api.post<{ id: string }>("/notes", { + type: "pdf", + title, + notebookId: folderId, + pdfAttachmentId, + }); + const now = new Date().toISOString(); + const meta: NoteMeta = { + id: r.id, + title, + folderId, + starred: false, + trashed: false, + createdAt: now, + updatedAt: now, + type: "pdf", + pdfAttachmentId, + }; + await db.putNote(meta); + notifyData("notes", null); + return meta; +} + /* ─── Content prefetch ─── * * Note bodies are normally fetched only when a note is opened, which leaves diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bf839fa..f04460f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -34,10 +34,16 @@ export interface StrokeDoc { /* ── Notes ── */ +export type NoteKind = "text" | "pdf"; + /** Note metadata, as held in the notes list. */ export interface NoteMeta { id: string; title: string; + /** "pdf" notes render a document instead of the text editor. */ + type?: NoteKind; + /** Attachment holding the PDF, for notes of type "pdf". */ + pdfAttachmentId?: string | null; folderId: string | null; starred: boolean; trashed: boolean; diff --git a/frontend/src/ui/icons.jsx b/frontend/src/ui/icons.jsx index bcfac52..d5410ca 100644 --- a/frontend/src/ui/icons.jsx +++ b/frontend/src/ui/icons.jsx @@ -453,6 +453,23 @@ export const I = { ), + pdf: ( + + + + + + + ), export: ( + +/** + * Vite's `?url` suffix hands back the built asset's path instead of its + * contents. `vite/client` covers the common cases; the pdf.js worker is an + * `.mjs` file, which is not among them. + */ +declare module "*.mjs?url" { + const src: string; + export default src; +} diff --git a/readme.md b/readme.md index 71ac202..0e76ad5 100644 --- a/readme.md +++ b/readme.md @@ -17,6 +17,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing, - **Undo/Redo** — Full history stack for drawing operations - **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder - **Star/Favorite** — Quick access to important notes +- **PDF Reading** — Import a PDF and read it in the app; the file is cached so it stays readable offline - **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser - **Dark & Light Mode** — Full theme support with refined color schemes @@ -329,6 +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 | | `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 | @@ -409,6 +411,37 @@ a snippet around the first match. --- +## Reading PDFs + +An imported PDF becomes a note of type `pdf` and is shown by a viewer instead of +the text editor. Pages are laid out at their true proportions before anything is +drawn, so the scrollbar is honest from the first frame; a document mixing +portrait and landscape keeps both, rather than stretching the narrow pages. + +Only pages near the viewport are rendered, and each keeps its own canvas — a PDF +page is expensive to draw and cheap to hold, so scrolling back up repaints +nothing. Canvases are sized in device pixels and scaled down by CSS, which is +what keeps text sharp on a high-density screen. + +**pdf.js is not in the main bundle.** It is larger than the rest of the +application put together, so it sits behind a dynamic import: a session that +never opens a PDF never downloads it. The worker is bundled too, not fetched +from a CDN — a viewer that stops working during someone else's outage is not one +you can rely on offline. + +The **legacy** pdf.js build is used deliberately. Version 6 calls +`Uint8Array.prototype.toHex`, which only very recent browsers have; anything +older fails to parse the document with a message that says nothing about why. + +Bytes are read through the authenticated attachment route and cached in +IndexedDB, so a document opened once stays readable with no connection. Because +storage is content-addressed, a cached copy can never go stale. + +> nginx needs a MIME type for `.mjs` — without one it serves the worker as +> `application/octet-stream` and the browser refuses to run it as a module. + +--- + ## PDF storage An imported PDF is stored as an attachment: encrypted, named by the hash of its