Read imported PDFs in the app
Stage three: the viewer. Drawing on pages comes next. pdf.js stays out of the bundle everyone downloads — it is larger than the rest of the application put together, so it is behind a dynamic import and a session that never opens a PDF never fetches it. Main bundle grew by 8 kB for the viewer shell; the 162 kB library arrives only when needed. The worker is bundled rather than pulled from a CDN, because a viewer that stops working during someone else's outage is not one you can rely on offline. Two things that only showed up once it ran in a browser: - pdf.js 6 calls Uint8Array.prototype.toHex, which only very recent browsers have. Anything older failed with "n.toHex is not a function", which says nothing about the cause. Now on the legacy build, which compiles those away — a little larger, and the difference between working everywhere and working on this year's browsers. - nginx has no MIME type for .mjs and served the worker as octet-stream, which a browser refuses to run as a module. Pages are laid out at their true proportions before anything is drawn, so the scrollbar is honest immediately and a document mixing portrait and landscape keeps both — the first version scaled every page to the widest one, which the suite caught by checking the two aspect ratios differ. Bytes come from the authenticated attachment route and are cached in IndexedDB. Content-addressed storage means a cached copy can never be stale, so a document opened once needs no connection again. Verified with the network switched off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
53965281b4
commit
2986bcb620
16 files changed
+1079
-12
No files matched your search
+135
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Generated
+278
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
<NoteEditor
|
||||
key={`${note.id}:${restoreEpoch}`}
|
||||
note={note}
|
||||
onUpdate={onUpdate}
|
||||
dark={dark}
|
||||
onOpenNote={linkProps.openLinkTarget}
|
||||
searchLinkTargets={linkProps.searchLinkTargets}
|
||||
resolveLinkLabel={linkProps.resolveLinkLabel}
|
||||
backlinks={backlinks}
|
||||
/>
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", minHeight: 0 }}>
|
||||
{note.type === "pdf" ? (
|
||||
<PdfViewer note={note} dark={dark} />
|
||||
) : (
|
||||
<NoteEditor
|
||||
key={`${note.id}:${restoreEpoch}`}
|
||||
note={note}
|
||||
onUpdate={onUpdate}
|
||||
dark={dark}
|
||||
onOpenNote={linkProps.openLinkTarget}
|
||||
searchLinkTargets={linkProps.searchLinkTargets}
|
||||
resolveLinkLabel={linkProps.resolveLinkLabel}
|
||||
backlinks={backlinks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ export function NoteList({
|
||||
onSelectNote,
|
||||
onNoteContextMenu,
|
||||
onCreateNote,
|
||||
onImportPdf,
|
||||
onEmptyTrash,
|
||||
}) {
|
||||
return (
|
||||
@@ -63,6 +64,26 @@ export function NoteList({
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{!isTrashView && (
|
||||
<button
|
||||
onClick={onImportPdf}
|
||||
title="Import PDF"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: T.muted,
|
||||
}}
|
||||
>
|
||||
{I.pdf}
|
||||
</button>
|
||||
)}
|
||||
{!isTrashView && (
|
||||
<button
|
||||
onClick={onCreateNote}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import * as sync from "../../syncEngine";
|
||||
import { openPdf, renderPage } from "./pdfjs";
|
||||
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, visible, onRendered }) {
|
||||
const canvasRef = 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) {
|
||||
renderedAt.current = width;
|
||||
onRendered?.(index, page);
|
||||
}
|
||||
})().catch(() => {
|
||||
/* a page that will not draw leaves its placeholder rather than the whole
|
||||
document failing */
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelRender?.();
|
||||
};
|
||||
}, [doc, index, width, visible, onRendered]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, onPagesReady }) {
|
||||
const [state, setState] = useState({ status: "loading" });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
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 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);
|
||||
})().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;
|
||||
};
|
||||
}, [note?.pdfAttachmentId, 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={{ 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)}
|
||||
visible={visible.has(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<PdfjsModule> | null = null;
|
||||
|
||||
/** Resolves to the pdf.js module, fetching it on the first call. */
|
||||
export function loadPdfjs(): Promise<PdfjsModule> {
|
||||
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<LoadedPdf> {
|
||||
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<void>; 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(),
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export interface NoteData {
|
||||
reset: () => void;
|
||||
|
||||
createNote: (folderId: string) => Promise<NoteMeta>;
|
||||
importPdf: (folderId: string, file: File) => Promise<NoteMeta>;
|
||||
updateNote: (id: string, edit: NoteEdit) => void;
|
||||
setNoteContent: (id: string, content: NoteContent) => void;
|
||||
toggleStar: (id: string) => Promise<void>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<T>("DELETE", p);
|
||||
},
|
||||
|
||||
/** Sends raw bytes; req() would JSON-encode them and inflate the payload. */
|
||||
async postBinary<T>(path: string, body: Blob, contentType: string): Promise<T> {
|
||||
const headers: Record<string, string> = { "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<Blob> {
|
||||
const headers: Record<string, string> = { "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<Blob> {
|
||||
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<AttachmentInfo> {
|
||||
const query = filename ? `?filename=${encodeURIComponent(filename)}` : "";
|
||||
return api.postBinary<AttachmentInfo>(`/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<Uint8Array> {
|
||||
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<NoteMeta> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -453,6 +453,23 @@ export const I = {
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
),
|
||||
pdf: (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<path d="M9 15h1.5a1.5 1.5 0 0 0 0-3H9v6" />
|
||||
<path d="M14 18v-6h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
export: (
|
||||
<svg
|
||||
width="16"
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user