commit 0ad405c26ee57f355ba4683667ebfb6d79834a42 Author: Reudy Date: Sat Sep 26 19:13:02 2026 +0200 Init diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0a37a01 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebSearch" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9205d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +/.svelte-kit +/build +.DS_Store +.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ce1d3a --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Papure + +Lecture notes as a tree of pages ("comb") — live-preview markdown underneath, vector ink on top — stored as one dual-layer PDF per canvas and synced through a GitHub repo. See `spec.md` for the design. + +## Run + +```sh +npm install +npm run dev # http://localhost:5173 +npm test # unit tests (tree, markdown, PDF round trip, sync against a fake GitHub) +npm run check # type-check +npm run build # static PWA in build/ — serve it from any static host (HTTPS for PWA install) +``` + +## Using it + +- **Tools** (bottom bar): Text `T`, Pen `P`, Highlighter `H`, Eraser `E`, Import PDF, Undo/Redo, zoom. The pen's eraser end erases too. +- **Pages**: the grey "+" tiles next to the active page add a page there; `Alt+Arrow` moves to (or creates) the neighbour. Right-click a page or use its `⋯` button to insert, resize or delete it. +- **Importing a PDF** into a non-empty canvas shows every free spot; click one to place the chain. Into a blank canvas it simply replaces the empty page. The Files panel can also import a PDF as a new canvas. +- **View**: wheel/trackpad pans, `Ctrl`+wheel or pinch zooms, `Ctrl+0` fits the page, `Ctrl+9` shows the whole tree, space-drag or middle-drag pans. +- **Sync**: Settings → Sync. Use a fine-grained token with *Contents: read & write* on the repo. Edits autosave locally; pushes happen every N minutes and on `Ctrl+S` / the sync button. Conflicts: newer wins. + +## Layout + +- `src/lib/model` — page tree (pure functions: insert/remove/flatten/layout). +- `src/lib/editor` — markdown analysis shared by the CodeMirror live preview and the PDF text layout. +- `src/lib/pdf` — PDF writer (vector text, Ink annotations, embedded source-of-truth files) and reader. +- `src/lib/ink` — stroke recording/outlines and palm rejection (ported from `palm-rejection-test.html`). +- `src/lib/storage`, `src/lib/sync` — IndexedDB vault and GitHub sync. + +Fonts in `static/fonts` are pre-subset (`scripts/subset-fonts.sh`) because pdf-lib's own subsetter drops glyphs. diff --git a/memos/memos/.thumbnail_cache/1.png b/memos/memos/.thumbnail_cache/1.png new file mode 100644 index 0000000..95a7f82 Binary files /dev/null and b/memos/memos/.thumbnail_cache/1.png differ diff --git a/memos/memos/.thumbnail_cache/2.jpg b/memos/memos/.thumbnail_cache/2.jpg new file mode 100644 index 0000000..7ec3b20 Binary files /dev/null and b/memos/memos/.thumbnail_cache/2.jpg differ diff --git a/memos/memos/memos_prod.db b/memos/memos/memos_prod.db new file mode 100644 index 0000000..d1bd158 Binary files /dev/null and b/memos/memos/memos_prod.db differ diff --git a/memos/memos/memos_prod.db-shm b/memos/memos/memos_prod.db-shm new file mode 100644 index 0000000..3c0933e Binary files /dev/null and b/memos/memos/memos_prod.db-shm differ diff --git a/memos/memos/memos_prod.db-wal b/memos/memos/memos_prod.db-wal new file mode 100644 index 0000000..ad379ab Binary files /dev/null and b/memos/memos/memos_prod.db-wal differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8d015a4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2158 @@ +{ + "name": "papure", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "papure", + "version": "0.1.0", + "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-markdown": "^6.5.2", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", + "@lezer/highlight": "^1.2.4", + "@lezer/markdown": "^1.7.2", + "@lucide/svelte": "^1.48.0", + "@pdf-lib/fontkit": "^1.1.1", + "idb": "^8.0.3", + "marked": "^18.0.14", + "pdf-lib": "^1.17.1", + "pdfjs-dist": "^6.3.289", + "perfect-freehand": "^1.2.3" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.70.3", + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@types/node": "^22.20.4", + "fake-indexeddb": "^6.2.5", + "svelte": "^5.57.1", + "svelte-check": "^4.7.6", + "typescript": "^6.0.3", + "vite": "^8.3.1", + "vitest": "^5.0.2" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.12", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz", + "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz", + "integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.8.tgz", + "integrity": "sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.4.tgz", + "integrity": "sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.5.tgz", + "integrity": "sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz", + "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lucide/svelte": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.48.0.tgz", + "integrity": "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz", + "integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==", + "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.9", + "@napi-rs/canvas-darwin-arm64": "1.0.9", + "@napi-rs/canvas-darwin-x64": "1.0.9", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.9", + "@napi-rs/canvas-linux-arm64-musl": "1.0.9", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.9", + "@napi-rs/canvas-linux-x64-gnu": "1.0.9", + "@napi-rs/canvas-linux-x64-musl": "1.0.9", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.9", + "@napi-rs/canvas-win32-x64-msvc": "1.0.9" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz", + "integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==", + "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.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz", + "integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==", + "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.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz", + "integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==", + "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.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz", + "integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==", + "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.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz", + "integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==", + "cpu": [ + "arm64" + ], + "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.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz", + "integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==", + "cpu": [ + "arm64" + ], + "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.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz", + "integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==", + "cpu": [ + "riscv64" + ], + "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.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz", + "integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==", + "cpu": [ + "x64" + ], + "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.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz", + "integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==", + "cpu": [ + "x64" + ], + "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.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz", + "integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==", + "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.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz", + "integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@pdf-lib/fontkit": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz", + "integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.2", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.12.tgz", + "integrity": "sha512-X+t0J3EQKrrw5lgpgjn4hKjD17cHCZBZIcp2OUOyH7teMjH67gNRR2p+b1rMYavBkRN6VfHPWJOPY7w+VwQf/g==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "18.0.14", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.14.tgz", + "integrity": "sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdfjs-dist": { + "version": "6.3.289", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", + "integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, + "node_modules/perfect-freehand": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.3.tgz", + "integrity": "sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.57.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", + "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.2", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.2", + "@vitest/browser-preview": "5.0.2", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.2", + "@vitest/coverage-v8": "5.0.2", + "@vitest/ui": "5.0.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3884168 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "papure", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.70.3", + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@types/node": "^22.20.4", + "fake-indexeddb": "^6.2.5", + "svelte": "^5.57.1", + "svelte-check": "^4.7.6", + "typescript": "^6.0.3", + "vite": "^8.3.1", + "vitest": "^5.0.2" + }, + "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-markdown": "^6.5.2", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", + "@lezer/highlight": "^1.2.4", + "@lezer/markdown": "^1.7.2", + "@lucide/svelte": "^1.48.0", + "@pdf-lib/fontkit": "^1.1.1", + "idb": "^8.0.3", + "marked": "^18.0.14", + "pdf-lib": "^1.17.1", + "pdfjs-dist": "^6.3.289", + "perfect-freehand": "^1.2.3" + } +} diff --git a/palm-rejection-test.html b/palm-rejection-test.html new file mode 100644 index 0000000..5bf5763 --- /dev/null +++ b/palm-rejection-test.html @@ -0,0 +1,339 @@ + + + + + +Palm Rejection Test + + + +
+

Palm Rejection Test

+

Rest your palm on the screen and write with the Surface Pen. Toggle strategies below to feel the difference. Accepted strokes draw in black; if a rejected touch briefly draws before being caught, it flashes red then clears.

+
+ +
+ + + + +
+ +
+ +
waiting for input…
+
+
+ + +
+
+ + + + diff --git a/scripts/subset-fonts.sh b/scripts/subset-fonts.sh new file mode 100755 index 0000000..ee7eb7d --- /dev/null +++ b/scripts/subset-fonts.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Subsets the bundled fonts to Latin (+Extended), Greek, Cyrillic, punctuation, +# arrows and common math. pdf-lib's own subsetter drops glyphs, so the PDF +# writer embeds these pre-subset files whole. +set -euo pipefail +cd "$(dirname "$0")/.." +UNICODES="U+0000-024F,U+0300-036F,U+0370-03FF,U+0400-04FF,U+1E00-1EFF,U+2000-206F,U+20A0-20CF,U+2100-214F,U+2190-21FF,U+2200-22FF,U+2460-24FF,U+25A0-25FF,U+2713,U+2717" +for f in static/fonts/*.ttf; do + pyftsubset "$f" --unicodes="$UNICODES" --layout-features='kern,liga,calt,ccmp,locl,mark,mkmk' \ + --no-hinting --desubroutinize --output-file="$f.sub" + mv "$f.sub" "$f" +done +ls -la static/fonts diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..5125de1 --- /dev/null +++ b/spec.md @@ -0,0 +1,90 @@ +# Lecture Notes App — Architecture Doc v1 + +## 1. Page Tree Model ("Comb") + +One canvas = one tree = one `.pdf` file. + +- **Trunk**: a single vertical chain of pages. Grows **up or down** (both allowed). +- **Branch**: a horizontal chain of pages attached to exactly one trunk page, going **left or right**. + - A branch can only ever continue in the direction it started (strictly linear — no sub-branches off a branch). + - A trunk page can have at most one left branch and one right branch. +- **PDF import**: creates a chain of pages in whatever direction you pick when placing it (up/down = extends the trunk, left/right = becomes a branch off the trunk page you clicked). Only one trunk exists per canvas — additional imported PDFs must attach as a branch off some trunk page, or extend an existing branch further outward in its established direction. +- Hidden/unopened neighbor pages render as a gray tile with a "+" in a circle; clicking (or a shortcut) instantiates them as real pages. +- Deleting a page removes it (undo via Ctrl+Z); no permanent trash. +- Default zoom: one page fills the screen. Zoom out reveals the tree. +- Pages default to A4, or auto-match the aspect ratio of an imported PDF page; resizable manually otherwise. + +## 2. Flatten Algorithm (tree → linear page order, e.g. for PDF page order / print) + +Starting at the **topmost** trunk node, walk down: + +``` +for each trunk_node, top → bottom: + emit trunk_node + emit left_branch pages, closest-to-trunk → outward + emit right_branch pages, closest-to-trunk → outward +``` + +This is recomputed at export/save time (not creation order), since the trunk can grow upward later and shift what "topmost" means. + +## 3. File Format + +One canvas = **one `.pdf` file**, dual-layer: + +- **Visual layer** (any standard PDF viewer sees this): rendered markdown as real vector text, ink drawn as PDF Ink annotations, original imported PDF content where applicable. Fully readable/printable by anyone, no app required. +- **Source-of-truth layer** (embedded file attachments, invisible in normal viewers): raw markdown source per page, full vector stroke data (points + pressure + tilt — richer than what Ink annotations can hold), and grid metadata (each page's tree position, size, and which PDF import it originated from, for provenance search). +- App reads the attachment layer first to reconstruct the live editable canvas; falls back to parsing visual content only if attachments are missing. + +**Libraries**: `pdf-lib` (write/edit/attach), `pdf.js` (render/import). + +**Trade-off accepted**: binary PDFs mean no line-level git diffs. Flag for later if this becomes annoying — we could add a parallel auto-generated `.md` export per canvas purely for git-diff readability, PDF stays canonical. + +## 4. Organization + +Canvases (PDF files) live in a **file tree** you define, e.g. `school/english/lecture1.pdf`, `school/math/lecture2.pdf`, `work/...`. Folders are purely organizational, no canvas of their own. + +## 5. Sync & Storage + +- Repo-backed (GitHub/GitLab), one file per canvas. +- PWA: offline-first, caches locally, explicit save/sync pushes to repo. +- Multi-device: git pull/push is the sync mechanism (not real-time collab). + +## 6. Tech Stack (tentative) + +- **Frontend**: SvelteKit, deployed on your VPS, installable as PWA. +- **Ink input**: Pointer Events + pressure (your existing artifact — pending). +- **PDF read/write**: pdf.js + pdf-lib. +- **Storage**: Git repo via GitHub/GitLab API (or local backend proxy holding the token). + +## 7. Confirmed Product Features + +- Live-rendered markdown (background layer) + vector ink (foreground layer), never converted to text. +- Full ink toolset: colors, highlighter, eraser. +- Search across typed markdown text. +- Pages remember their PDF-import origin, searchable. +- No export needed beyond the PDF itself (for now). + +## 8. Sync, Auth & Conflicts (resolved) + +- **Git auth**: personal access token stored in the browser/PWA (no backend proxy, for now). +- **Save model**: autocommit + periodic push, plus a manual "sync now" button that does pull + push on demand. +- **Conflicts**: newer version wins (timestamp-based), no merge UI for now. +- **Repo size**: not a concern for now — revisit if it becomes a problem. + +## 9. Pen Input & Palm Rejection + +Source: user-provided `palm-rejection-test.html` test harness. The acceptance policy carries over almost verbatim: + +- `pointerType === 'pen'` is always accepted; its down/move/up updates `lastPenActivityTime`. +- A `touch` pointer is rejected if any of: a pen stroke is active or was active within the last ~5s, its contact ellipse (`width`/`height`) exceeds ~35px (palm heel vs. fingertip), it's within 150ms of the last pen activity, or a pen is currently hovering (armed lockout before it even touches down). +- A touch stroke already in progress gets cut off mid-stroke if pen activity appears. + +**Gaps before this is the real ink engine** (currently a raster test, not vector): +- `draw()` writes straight to canvas via `ctx.lineTo`/`stroke` with pressure-scaled line width, and throws points away. Needs to become `recordPoint()`: push `{x, y, pressure, tiltX, tiltY, t}` into a `Stroke.points[]` array per stroke; rendering = replaying that array. This is what makes strokes resizable/erasable/undoable and exportable to both the PDF Ink-annotation layer and the JSON attachment layer. +- `tiltX`/`tiltY` exist on the pen's `PointerEvent` but aren't read anywhere yet — needed for tilt support. +- No smoothing yet (raw straight segments) — fine as a v1, can layer in curve smoothing later without changing point storage. +- No undo/redo, multi-color, eraser yet — needs a `Stroke[]` array per page's ink layer with add/remove, which undo/redo just pops/pushes. + +## 10. Open Items + +- [ ] Nothing blocking — ready to move into implementation planning (component breakdown, page-tree data structures in Svelte, PDF encode/decode module) diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..8b7ba45 --- /dev/null +++ b/src/app.css @@ -0,0 +1,203 @@ +@font-face { + font-family: 'Inter'; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url('/fonts/Inter_400Regular.ttf') format('truetype'); +} +@font-face { + font-family: 'Inter'; + font-weight: 400; + font-style: italic; + font-display: swap; + src: url('/fonts/Inter_400Regular_Italic.ttf') format('truetype'); +} +@font-face { + font-family: 'Inter'; + font-weight: 600; + font-style: normal; + font-display: swap; + src: url('/fonts/Inter_600SemiBold.ttf') format('truetype'); +} +@font-face { + font-family: 'Inter'; + font-weight: 700; + font-style: normal; + font-display: swap; + src: url('/fonts/Inter_700Bold.ttf') format('truetype'); +} +@font-face { + font-family: 'Inter'; + font-weight: 700; + font-style: italic; + font-display: swap; + src: url('/fonts/Inter_700Bold_Italic.ttf') format('truetype'); +} +@font-face { + font-family: 'JetBrains Mono'; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url('/fonts/JetBrainsMono_400Regular.ttf') format('truetype'); +} + +/* Obsidian-like palette. */ +:root { + --bg: #ffffff; + --bg-2: #f6f6f6; + --bg-3: #ececec; + --bg-canvas: #efefef; + --border: #e3e3e3; + --hover: #e9e9e9; + --text: #222222; + --muted: #6b6b6b; + --faint: #a6a6a6; + --accent: #705dcf; + --accent-soft: rgba(112, 93, 207, 0.14); + --accent-text: #ffffff; + --danger: #e03e3e; + --success: #2f9e5c; + --warn: #d9901b; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08); + --page-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04); + --ghost: rgba(0, 0, 0, 0.045); + --ghost-border: rgba(0, 0, 0, 0.12); + --radius: 6px; + --ui-font: 'Inter', -apple-system, 'Segoe UI', system-ui, sans-serif; + --mono-font: 'JetBrains Mono', ui-monospace, monospace; + color-scheme: light; +} + +:root[data-theme='dark'] { + --bg: #1e1e1e; + --bg-2: #262626; + --bg-3: #303030; + --bg-canvas: #161616; + --border: #363636; + --hover: #333333; + --text: #dadada; + --muted: #a3a3a3; + --faint: #666666; + --accent: #8a7cf0; + --accent-soft: rgba(138, 124, 240, 0.18); + --accent-text: #ffffff; + --danger: #fb464c; + --success: #44cf6e; + --warn: #e9973f; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.35); + --page-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 2px 8px rgba(0, 0, 0, 0.5); + --ghost: rgba(255, 255, 255, 0.04); + --ghost-border: rgba(255, 255, 255, 0.14); + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; + overflow: hidden; + overscroll-behavior: none; +} + +body { + background: var(--bg); + color: var(--text); + font: 13px/1.4 var(--ui-font); + -webkit-font-smoothing: antialiased; +} + +button { + font: inherit; + color: inherit; +} + +input, +select { + font: inherit; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 5px 8px; + outline: none; +} + +input:focus, +select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); +} + +/* Small square icon button, the Obsidian ribbon/toolbar look. */ +.icon-btn { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--muted); + cursor: pointer; + flex: none; +} + +.icon-btn:hover { + background: var(--hover); + color: var(--text); +} + +.icon-btn.active { + color: var(--accent); + background: var(--accent-soft); +} + +.icon-btn:disabled { + opacity: 0.35; + cursor: default; + background: transparent; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + background: var(--bg-2); + border-radius: 4px; + padding: 6px 12px; + cursor: pointer; +} + +.btn:hover { + background: var(--hover); +} + +.btn.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-text); +} + +.btn.primary:hover { + filter: brightness(1.08); +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-thumb { + background: var(--bg-3); + border-radius: 10px; + border: 3px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-track { + background: transparent; +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..be79b78 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,6 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +declare global { + namespace App {} +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..c812386 --- /dev/null +++ b/src/app.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Papure + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/components/App.svelte b/src/lib/components/App.svelte new file mode 100644 index 0000000..009dfb1 --- /dev/null +++ b/src/lib/components/App.svelte @@ -0,0 +1,132 @@ + + + + +
+ + {#if workspace.panel} + + {/if} +
+ {#if ready && workspace.doc} + {#key workspace.doc} + + {/key} + {:else if ready} + + {/if} +
+
+ +{#if workspace.settingsOpen} + +{/if} + + + + diff --git a/src/lib/components/CanvasView.svelte b/src/lib/components/CanvasView.svelte new file mode 100644 index 0000000..35ad5a6 --- /dev/null +++ b/src/lib/components/CanvasView.svelte @@ -0,0 +1,754 @@ + + + (spaceDown = false)} /> + +
+
+
+ {#if dirName(doc.path)}{dirName(doc.path).replaceAll('/', ' / ')} /{/if} + {displayName(doc.path)} +
+ +
+ + +
{ + palm.track(e); + eraserAt = null; + }} + oncontextmenu={onContextMenu} + > +
+ {#each visible as id (id)} + {@const page = doc.pages[id]} + {@const rect = doc.rects.get(id)} + {#if page && rect} + doc.touch()} + onactivate={() => (doc.activeId = id)} + /> + {/if} + {/each} + + {#each ghostSlots as slot (slot.anchor + slot.dir)} + {@const r = slotRect(slot)} + {#if r} + + {/if} + {/each} + + {#if tools.placing} + {#each placeSlots as slot (slot.anchor + slot.dir)} + {@const first = tools.placing.pages[0]} + {@const r = slotRect(slot, first)} + {#if r} + + {/if} + {/each} + {/if} +
+ + {#if activeMenuPos && doc.activeId} + + {/if} + + {#if eraserAt && tools.tool === 'eraser'} +
+ {/if} + + {#if tools.placing} + + {/if} +
+ + fitPage(doc.activeId)} onfitall={fitAll} /> +
+ + diff --git a/src/lib/components/ContextMenu.svelte b/src/lib/components/ContextMenu.svelte new file mode 100644 index 0000000..9afdf7b --- /dev/null +++ b/src/lib/components/ContextMenu.svelte @@ -0,0 +1,110 @@ + + + e.key === 'Escape' && menu.close()} + onblur={() => menu.close()} + onresize={() => menu.close()} +/> + +{#if menu.open} + +{/if} + + diff --git a/src/lib/components/EmptyState.svelte b/src/lib/components/EmptyState.svelte new file mode 100644 index 0000000..4a91daf --- /dev/null +++ b/src/lib/components/EmptyState.svelte @@ -0,0 +1,45 @@ + + +
+

No canvas open

+ + {#if vault.canvases.length} + + {/if} +
+ + diff --git a/src/lib/components/FileExplorer.svelte b/src/lib/components/FileExplorer.svelte new file mode 100644 index 0000000..baafa38 --- /dev/null +++ b/src/lib/components/FileExplorer.svelte @@ -0,0 +1,231 @@ + + +
+ Files +
+ + + +
+
+ + +
contextMenu(e, null)} + ondragover={(e) => e.preventDefault()} + ondrop={(e) => { + e.preventDefault(); + onDrop('', e.dataTransfer?.getData('text/x-papure-path') ?? ''); + }} +> + {#each root.children as node (node.path)} + workspace.open(p)} + onContext={contextMenu} + onRename={commitRename} + onCancelRename={() => (renaming = null)} + {onDrop} + /> + {:else} +

No canvases yet.
Right-click or use the buttons above.

+ {/each} +
+ + + + diff --git a/src/lib/components/FileTreeNode.svelte b/src/lib/components/FileTreeNode.svelte new file mode 100644 index 0000000..23d5889 --- /dev/null +++ b/src/lib/components/FileTreeNode.svelte @@ -0,0 +1,168 @@ + + + + +
e.key === 'Enter' && props.renaming !== node.path && click()} + oncontextmenu={(e) => { + e.stopPropagation(); + props.onContext(e, node); + }} + ondragstart={(e) => e.dataTransfer?.setData('text/x-papure-path', node.path)} + ondragover={(e) => { + if (node.kind !== 'folder') return; + e.preventDefault(); + e.stopPropagation(); + dragOver = true; + }} + ondragleave={() => (dragOver = false)} + ondrop={(e) => { + if (node.kind !== 'folder') return; + e.preventDefault(); + e.stopPropagation(); + dragOver = false; + props.onDrop(node.path, e.dataTransfer?.getData('text/x-papure-path') ?? ''); + }} +> + {#if node.kind === 'folder'} + + {/if} + {#if props.renaming === node.path} + e.stopPropagation()} + onblur={(e) => props.onRename(node.path, node.kind, e.currentTarget.value)} + /> + {:else} + {node.name} + {/if} +
+ +{#if node.kind === 'folder' && isOpen} +
+ {#each node.children as child (child.path)} + + {/each} +
+{/if} + + diff --git a/src/lib/components/InkLayer.svelte b/src/lib/components/InkLayer.svelte new file mode 100644 index 0000000..a16e94c --- /dev/null +++ b/src/lib/components/InkLayer.svelte @@ -0,0 +1,57 @@ + + + + + diff --git a/src/lib/components/MarkdownEditor.svelte b/src/lib/components/MarkdownEditor.svelte new file mode 100644 index 0000000..592bc67 --- /dev/null +++ b/src/lib/components/MarkdownEditor.svelte @@ -0,0 +1,181 @@ + + +
+ + diff --git a/src/lib/components/PageView.svelte b/src/lib/components/PageView.svelte new file mode 100644 index 0000000..51843b8 --- /dev/null +++ b/src/lib/components/PageView.svelte @@ -0,0 +1,74 @@ + + +
+
+ {#if page.origin} + + {/if} + + +
+ {#if page.origin && active} +
{page.origin.name} · p. {page.origin.pageIndex + 1}
+ {/if} +
+ + diff --git a/src/lib/components/PdfBackground.svelte b/src/lib/components/PdfBackground.svelte new file mode 100644 index 0000000..4c070ba --- /dev/null +++ b/src/lib/components/PdfBackground.svelte @@ -0,0 +1,71 @@ + + + +{#if failed} +
Source PDF “{origin.name}” is not available
+{/if} + + diff --git a/src/lib/components/Ribbon.svelte b/src/lib/components/Ribbon.svelte new file mode 100644 index 0000000..35fc760 --- /dev/null +++ b/src/lib/components/Ribbon.svelte @@ -0,0 +1,115 @@ + + + + + diff --git a/src/lib/components/SearchPanel.svelte b/src/lib/components/SearchPanel.svelte new file mode 100644 index 0000000..17e0828 --- /dev/null +++ b/src/lib/components/SearchPanel.svelte @@ -0,0 +1,122 @@ + + +
+ +
+ +
+ {#each grouped as [path, hits] (path)} +
+ + {#each hits.filter((h) => h.pageId) as hit (hit.pageId)} + + {/each} +
+ {:else} + {#if query.trim()}

No matches.

{/if} + {/each} +
+ + diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte new file mode 100644 index 0000000..42a4e25 --- /dev/null +++ b/src/lib/components/SettingsModal.svelte @@ -0,0 +1,345 @@ + + + e.key === 'Escape' && close()} /> + + +
e.target === e.currentTarget && close()}> + +
+ + diff --git a/src/lib/components/Toasts.svelte b/src/lib/components/Toasts.svelte new file mode 100644 index 0000000..7f3a62f --- /dev/null +++ b/src/lib/components/Toasts.svelte @@ -0,0 +1,34 @@ + + +
+ {#each workspace.toasts as t (t.id)} +
{t.text}
+ {/each} +
+ + diff --git a/src/lib/components/Toolbar.svelte b/src/lib/components/Toolbar.svelte new file mode 100644 index 0000000..7f5915a --- /dev/null +++ b/src/lib/components/Toolbar.svelte @@ -0,0 +1,208 @@ + + +
+ +
+ + + + diff --git a/src/lib/components/download.ts b/src/lib/components/download.ts new file mode 100644 index 0000000..664cb36 --- /dev/null +++ b/src/lib/components/download.ts @@ -0,0 +1,19 @@ +import { vault, baseName } from '$lib/storage/vault.svelte'; +import { workspace } from '$lib/state/workspace.svelte'; + +/** Build a canvas's PDF and hand it to the browser as a download. */ +export async function downloadCanvas(path: string) { + try { + await workspace.flush(); + const bytes = await vault.buildPdf(path); + const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'application/pdf' })); + const a = document.createElement('a'); + a.href = url; + a.download = baseName(path); + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); + } catch (e) { + console.error(e); + workspace.toast('Could not build the PDF', 'error'); + } +} diff --git a/src/lib/editor/livepreview.ts b/src/lib/editor/livepreview.ts new file mode 100644 index 0000000..520fb6f --- /dev/null +++ b/src/lib/editor/livepreview.ts @@ -0,0 +1,184 @@ +// Obsidian-style live preview for CodeMirror 6: markup is hidden and styled, +// except on lines the cursor is on (while the editor has focus). + +import { syntaxTree } from '@codemirror/language'; +import { commonmarkLanguage, markdown, markdownKeymap } from '@codemirror/lang-markdown'; +import { GFM } from '@lezer/markdown'; +import { EditorSelection, type Extension, type Range } from '@codemirror/state'; +import { + Decoration, + EditorView, + ViewPlugin, + WidgetType, + keymap, + type DecorationSet, + type ViewUpdate +} from '@codemirror/view'; +import { analyze } from './mdmarks'; + +class BulletWidget extends WidgetType { + eq() { + return true; + } + toDOM() { + const s = document.createElement('span'); + s.className = 'md-bullet'; + s.textContent = '•'; + return s; + } +} + +class TaskWidget extends WidgetType { + constructor( + readonly checked: boolean, + readonly pos: number + ) { + super(); + } + eq(o: TaskWidget) { + return o.checked === this.checked && o.pos === this.pos; + } + toDOM(view: EditorView) { + const box = document.createElement('span'); + box.className = 'md-task' + (this.checked ? ' done' : ''); + box.setAttribute('role', 'checkbox'); + box.setAttribute('aria-checked', String(this.checked)); + box.addEventListener('mousedown', (e) => { + e.preventDefault(); + view.dispatch({ changes: { from: this.pos + 1, to: this.pos + 2, insert: this.checked ? ' ' : 'x' } }); + }); + return box; + } + ignoreEvent() { + return true; + } +} + +const bullet = Decoration.replace({ widget: new BulletWidget() }); +const hidden = Decoration.replace({}); +const dimMark = Decoration.mark({ class: 'md-mark' }); +const styleMarks = { + strong: Decoration.mark({ class: 'md-strong' }), + em: Decoration.mark({ class: 'md-em' }), + code: Decoration.mark({ class: 'md-code' }), + strike: Decoration.mark({ class: 'md-strike' }), + link: Decoration.mark({ class: 'md-link' }) +}; + +function build(view: EditorView): DecorationSet { + const { state } = view; + const text = state.doc.toString(); + const a = analyze(text, syntaxTree(state)); + + // Lines touched by the selection show their raw markup. + const active = new Set(); + if (view.hasFocus) { + for (const r of state.selection.ranges) { + const l1 = state.doc.lineAt(r.from).number; + const l2 = state.doc.lineAt(r.to).number; + for (let n = l1; n <= l2; n++) active.add(n); + } + } + const revealed = (pos: number) => active.has(state.doc.lineAt(pos).number); + + const out: Range[] = []; + for (const l of a.lines) { + const cls: string[] = []; + if (l.heading) cls.push(`md-h md-h${l.heading}`); + if (l.code) cls.push('md-codeblock'); + if (l.fence) cls.push('md-fence'); + if (l.hr) cls.push(revealed(l.from) ? 'md-hr-raw' : 'md-hr'); + if (l.quote) cls.push('md-quote'); + if (cls.length) { + out.push( + Decoration.line({ + class: cls.join(' '), + attributes: l.quote ? { style: `--q:${l.quote}` } : undefined + }).range(l.from) + ); + } + } + for (const s of a.styles) if (s.to > s.from) out.push(styleMarks[s.style].range(s.from, s.to)); + + for (const h of mergeRanges(a.hides)) { + // Plugins can't replace across line breaks; leave such (rare) markup visible. + if (h.to <= h.from || text.slice(h.from, h.to).includes('\n')) continue; + out.push((revealed(h.from) ? dimMark : hidden).range(h.from, h.to)); + } + for (const b of a.bullets) if (!revealed(b.from)) out.push(bullet.range(b.from, b.to)); + for (const t of a.tasks) { + const inside = state.selection.ranges.some((r) => view.hasFocus && r.to >= t.from && r.from <= t.to); + if (!inside) out.push(Decoration.replace({ widget: new TaskWidget(t.checked, t.from) }).range(t.from, t.to)); + } + return Decoration.set(out, true); +} + +function mergeRanges(rs: { from: number; to: number }[]) { + const out: { from: number; to: number }[] = []; + for (const r of [...rs].sort((x, y) => x.from - y.from)) { + const last = out[out.length - 1]; + if (last && r.from <= last.to) last.to = Math.max(last.to, r.to); + else out.push({ ...r }); + } + return out; +} + +const preview = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = build(view); + } + update(u: ViewUpdate) { + if (u.docChanged || u.selectionSet || u.focusChanged || u.viewportChanged || syntaxTree(u.startState) !== syntaxTree(u.state)) { + this.decorations = build(u.view); + } + } + }, + { decorations: (v) => v.decorations } +); + +/** Wrap the selection in `mark` (or unwrap it). */ +function toggleWrap(mark: string) { + return (view: EditorView) => { + const { state } = view; + view.dispatch( + state.changeByRange((r) => { + const before = state.sliceDoc(r.from - mark.length, r.from); + const after = state.sliceDoc(r.to, r.to + mark.length); + if (before === mark && after === mark) { + return { + changes: [ + { from: r.from - mark.length, to: r.from }, + { from: r.to, to: r.to + mark.length } + ], + range: EditorSelection.range(r.from - mark.length, r.to - mark.length) + }; + } + return { + changes: [ + { from: r.from, insert: mark }, + { from: r.to, insert: mark } + ], + range: EditorSelection.range(r.from + mark.length, r.to + mark.length) + }; + }) + ); + return true; + }; +} + +export function livePreview(): Extension { + return [ + // Same parser configuration as the PDF writer (CommonMark + GFM). + markdown({ base: commonmarkLanguage, extensions: [GFM], addKeymap: false }), + keymap.of([ + ...markdownKeymap, + { key: 'Mod-b', run: toggleWrap('**') }, + { key: 'Mod-i', run: toggleWrap('*') }, + { key: 'Mod-e', run: toggleWrap('`') } + ]), + preview, + EditorView.lineWrapping + ]; +} diff --git a/src/lib/editor/mdmarks.test.ts b/src/lib/editor/mdmarks.test.ts new file mode 100644 index 0000000..9cbb163 --- /dev/null +++ b/src/lib/editor/mdmarks.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { analyze, renderedRuns } from './mdmarks'; + +const render = (text: string) => { + const a = analyze(text); + return a.lines.map((l) => + renderedRuns(text, a, l) + .map((r) => (r.widget ? `<${r.widget}>` : r.styles.size ? `[${[...r.styles].join('+')}:${r.text}]` : r.text)) + .join('') + ); +}; + +describe('markdown analysis', () => { + it('hides markup and keeps styles', () => { + expect(render('# Title #')).toEqual(['Title']); + expect(render('a **b** *c* `d` ~~e~~')).toEqual(['a [strong:b] [em:c] [code:d] [strike:e]']); + expect(render('see [docs](http://x "t") now')).toEqual(['see [link:docs] now']); + expect(render('\\*literal')).toEqual(['*literal']); + }); + + it('renders list markers, tasks and quotes', () => { + expect(render('- one\n- [x] done\n1. first')).toEqual([' one', ' done', '1. first']); + const a = analyze('> q\n> > deep'); + expect(a.lines.map((l) => l.quote)).toEqual([1, 2]); + expect(render('> q')).toEqual(['q']); + }); + + it('marks line kinds', () => { + const a = analyze('## H\n```js\ncode\n```\n---'); + expect(a.lines.map((l) => [l.heading, l.code, l.fence, l.hr])).toEqual([ + [2, false, false, false], + [0, true, true, false], + [0, true, false, false], + [0, true, true, false], + [0, false, false, true] + ]); + expect(render('```js\ncode\n```')).toEqual(['', 'code', '']); + }); +}); diff --git a/src/lib/editor/mdmarks.ts b/src/lib/editor/mdmarks.ts new file mode 100644 index 0000000..b99891d --- /dev/null +++ b/src/lib/editor/mdmarks.ts @@ -0,0 +1,225 @@ +// Shared markdown analysis. The same Lezer tree drives both the CodeMirror +// live-preview decorations (on screen) and the PDF text layout (on export), +// so the two render line for line the same. + +import type { Tree } from '@lezer/common'; +import { parser as baseParser, GFM } from '@lezer/markdown'; + +export const mdParser = baseParser.configure(GFM); + +export type InlineStyle = 'strong' | 'em' | 'code' | 'strike' | 'link'; + +export interface LineInfo { + from: number; + to: number; + /** 0 = not a heading. */ + heading: number; + code: boolean; + /** Opening/closing ``` line of a fenced block. */ + fence: boolean; + hr: boolean; + quote: number; +} + +export interface MdAnalysis { + lines: LineInfo[]; + styles: { from: number; to: number; style: InlineStyle }[]; + /** Markup hidden unless the cursor is on its line. */ + hides: { from: number; to: number }[]; + /** Unordered list markers rendered as a bullet. */ + bullets: { from: number; to: number }[]; + tasks: { from: number; to: number; checked: boolean }[]; +} + +const INLINE: Record = { + StrongEmphasis: 'strong', + Emphasis: 'em', + InlineCode: 'code', + Strikethrough: 'strike', + Link: 'link', + Autolink: 'link' +}; + +const HIDE_MARKS = new Set(['EmphasisMark', 'CodeMark', 'StrikethroughMark']); + +export function analyze(text: string, tree: Tree = mdParser.parse(text)): MdAnalysis { + const lines: LineInfo[] = []; + for (let from = 0; ; ) { + const nl = text.indexOf('\n', from); + const to = nl < 0 ? text.length : nl; + lines.push({ from, to, heading: 0, code: false, fence: false, hr: false, quote: 0 }); + if (nl < 0) break; + from = nl + 1; + } + const lineAt = (pos: number) => { + let lo = 0, hi = lines.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lines[mid].from <= pos) lo = mid; + else hi = mid - 1; + } + return lo; + }; + const eachLine = (from: number, to: number, fn: (l: LineInfo) => void) => { + for (let i = lineAt(from); i < lines.length && lines[i].from <= to; i++) fn(lines[i]); + }; + + const a: MdAnalysis = { lines, styles: [], hides: [], bullets: [], tasks: [] }; + const hideWithSpace = (from: number, to: number) => { + if (text[to] === ' ') to++; + a.hides.push({ from, to }); + }; + + tree.iterate({ + enter(node) { + const { name, from, to } = node; + const heading = /^(?:ATX|Setext)Heading(\d)$/.exec(name); + if (heading) { + const level = +heading[1]; + if (name.startsWith('Setext')) { + // Only the text line is the heading; the underline is markup. + const first = lines[lineAt(from)]; + first.heading = level; + } else eachLine(from, to, (l) => (l.heading = level)); + return; + } + if (name === 'HeaderMark') { + const parent = node.node.parent?.name ?? ''; + const l = lines[lineAt(from)]; + if (parent.startsWith('Setext')) a.hides.push({ from, to }); + else if (/^\s*$/.test(text.slice(l.from, from))) hideWithSpace(from, to); + else a.hides.push({ from: text[from - 1] === ' ' ? from - 1 : from, to }); // closing #s + return; + } + if (name === 'FencedCode' || name === 'CodeBlock') { + eachLine(from, to, (l) => (l.code = true)); + if (name === 'FencedCode') { + const first = lines[lineAt(from)]; + first.fence = true; + const last = lines[lineAt(to)]; + if (last !== first && /^\s*(```|~~~)/.test(text.slice(last.from, last.to))) last.fence = true; + } + return; + } + if (name === 'CodeInfo' || (name === 'CodeMark' && node.node.parent?.name === 'FencedCode')) { + a.hides.push({ from, to }); + return; + } + if (name === 'HorizontalRule') { + eachLine(from, to, (l) => (l.hr = true)); + a.hides.push({ from, to }); + return; + } + if (name === 'Blockquote') { + eachLine(from, to, (l) => l.quote++); + return; + } + if (name === 'QuoteMark') { + hideWithSpace(from, to); + return; + } + if (name === 'ListMark') { + if (/^[-*+]$/.test(text.slice(from, to)) && !isTaskItem(node.node)) a.bullets.push({ from, to }); + else if (/^[-*+]$/.test(text.slice(from, to))) hideWithSpace(from, to); + return; + } + if (name === 'TaskMarker') { + a.tasks.push({ from, to, checked: /x/i.test(text.slice(from, to)) }); + return; + } + if (name === 'Escape') { + a.hides.push({ from, to: from + 1 }); + return; + } + if (name === 'Image' || name === 'HTMLTag' || name === 'Comment') return false; + const style = INLINE[name]; + if (style) a.styles.push({ from, to, style }); + if (name === 'Link' || name === 'Autolink') { + // Show only the link text: hide "[" and everything from "]" on. + const marks = node.node.getChildren('LinkMark'); + if (name === 'Autolink' || marks.length < 2) { + for (const m of marks) a.hides.push({ from: m.from, to: m.to }); + } else { + a.hides.push({ from: marks[0].from, to: marks[0].to }); + a.hides.push({ from: marks[1].from, to }); + } + return false; + } + if (HIDE_MARKS.has(name)) a.hides.push({ from, to }); + } + }); + a.hides.sort((x, y) => x.from - y.from); + return a; +} + +function isTaskItem(listMark: { parent: { getChild(name: string): unknown } | null }): boolean { + return !!listMark.parent?.getChild('Task'); +} + +/** Line index containing `pos`. */ +export function lineIndexAt(a: MdAnalysis, pos: number): number { + let lo = 0, hi = a.lines.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (a.lines[mid].from <= pos) lo = mid; + else hi = mid - 1; + } + return lo; +} + +/** A run of text on one rendered line with uniform style. */ +export interface Run { + text: string; + styles: Set; + /** Special glyphs drawn instead of text. */ + widget?: 'bullet' | 'task' | 'task-done'; +} + +/** + * Rendered runs of one line with all markup hidden — what a reader sees when + * the cursor is elsewhere (and what the PDF prints). + */ +export function renderedRuns(text: string, a: MdAnalysis, line: LineInfo): Run[] { + const runs: Run[] = []; + const { from, to } = line; + if (line.hr) return runs; + const widgets = [ + ...a.bullets.map((b) => ({ ...b, widget: 'bullet' as const })), + ...a.tasks.map((t) => ({ from: t.from, to: t.to, widget: t.checked ? ('task-done' as const) : ('task' as const) })) + ].filter((w) => w.from >= from && w.to <= to); + const hidden = a.hides.filter((h) => h.to > from && h.from < to); + const styles = a.styles.filter((s) => s.to > from && s.from < to); + + let pos = from; + while (pos < to) { + const w = widgets.find((w) => w.from === pos); + if (w) { + runs.push({ text: '', styles: new Set(), widget: w.widget }); + pos = w.to; + continue; + } + const h = hidden.find((h) => h.from <= pos && h.to > pos); + if (h) { + pos = h.to; + continue; + } + // Advance to the next boundary. + let end = to; + for (const b of [...widgets.map((w) => w.from), ...hidden.map((h) => h.from), ...styles.flatMap((s) => [s.from, s.to])]) { + if (b > pos && b < end) end = b; + } + const set = new Set(styles.filter((s) => s.from <= pos && s.to >= end).map((s) => s.style)); + const chunk = text.slice(pos, end); + const prev = runs[runs.length - 1]; + if (prev && !prev.widget && sameSet(prev.styles, set)) prev.text += chunk; + else runs.push({ text: chunk, styles: set }); + pos = end; + } + return runs; +} + +function sameSet(a: Set, b: Set) { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} diff --git a/src/lib/editor/pageStyle.ts b/src/lib/editor/pageStyle.ts new file mode 100644 index 0000000..b8b2fc2 --- /dev/null +++ b/src/lib/editor/pageStyle.ts @@ -0,0 +1,55 @@ +// Typography of a page, shared by the on-screen editor (as CSS variables) and +// the PDF text layout. All sizes are PDF points (= CSS px in world space). + +export const PAGE_STYLE = { + margin: 48, + fontSize: 11, + lineHeight: 1.6, + headingLineHeight: 1.35, + /** Heading font sizes relative to the body size, h1…h6. */ + headingScale: [1.8, 1.45, 1.2, 1.05, 1, 1], + codeScale: 0.92, + quoteIndent: 12, + colors: { + text: '#1f1f1f', + muted: '#8a8a8a', + link: '#6a5acd', + codeBg: '#f1f1ef', + quoteBar: '#c9c9c9', + rule: '#d4d4d4' + } +} as const; + +export function lineMetrics(heading: number, code: boolean) { + const s = PAGE_STYLE; + if (heading) { + const size = s.fontSize * s.headingScale[heading - 1]; + return { size, height: size * s.headingLineHeight }; + } + if (code) return { size: s.fontSize * s.codeScale, height: s.fontSize * s.lineHeight }; + return { size: s.fontSize, height: s.fontSize * s.lineHeight }; +} + +/** CSS custom properties that mirror PAGE_STYLE for the editor. */ +export function pageCssVars(): string { + const s = PAGE_STYLE; + const vars: Record = { + '--pg-margin': `${s.margin}px`, + '--pg-font': `${s.fontSize}px`, + '--pg-lh': s.lineHeight, + '--pg-hlh': s.headingLineHeight, + '--pg-code': `${s.fontSize * s.codeScale}px`, + '--pg-code-lh': `${s.fontSize * s.lineHeight}px`, + '--pg-quote': `${s.quoteIndent}px`, + '--pg-text': s.colors.text, + '--pg-muted': s.colors.muted, + '--pg-link': s.colors.link, + '--pg-code-bg': s.colors.codeBg, + '--pg-quote-bar': s.colors.quoteBar, + '--pg-rule': s.colors.rule + }; + s.headingScale.forEach((k, i) => (vars[`--pg-h${i + 1}`] = `${s.fontSize * k}px`)); + return Object.entries(vars) + .map(([k, v]) => `${k}:${v}`) + .join(';'); +} diff --git a/src/lib/ink/palm.ts b/src/lib/ink/palm.ts new file mode 100644 index 0000000..341247d --- /dev/null +++ b/src/lib/ink/palm.ts @@ -0,0 +1,98 @@ +// Palm rejection — ported from palm-rejection-test.html. +// +// Pens are always accepted. A touch is rejected when a pen session is active +// (stroke in progress or pen activity within the last 5 s), when its contact +// ellipse is palm-sized, when it lands within 150 ms of pen activity, or while +// a pen hovers over the screen. A touch already drawing is cut off as soon as +// pen activity appears (callers re-run `evaluate` on every move). + +export interface PalmOptions { + /** Block all touches while a pen session is active (5 s window). */ + penSession: boolean; + /** Reject touches with a wide contact ellipse. */ + geometry: boolean; + /** Reject touches within 150 ms of pen activity. */ + timing: boolean; + /** Arm the lockout as soon as the pen hovers. */ + hover: boolean; +} + +export const DEFAULT_PALM: PalmOptions = { penSession: true, geometry: true, timing: true, hover: true }; + +const PEN_SESSION_MS = 5000; +const PEN_TIMING_MS = 150; +const PALM_CONTACT_PX = 35; + +export interface Decision { + accept: boolean; + reason: string; +} + +export class PalmRejector { + options: PalmOptions; + lastPenActivityTime = 0; + private activePens = new Set(); + private penHovering = false; + + constructor(options: PalmOptions = DEFAULT_PALM) { + this.options = options; + } + + get penIsDown() { + return this.activePens.size > 0; + } + + /** Feed every pointerdown/move/up/leave so pen state stays current. */ + track(e: PointerEvent) { + if (e.pointerType !== 'pen') return; + const now = Date.now(); + switch (e.type) { + case 'pointerdown': + this.activePens.add(e.pointerId); + this.lastPenActivityTime = now; + break; + case 'pointermove': + case 'pointerover': + case 'pointerenter': + // Hover (buttons == 0) counts as "pen nearby" too. + this.lastPenActivityTime = now; + this.penHovering = !this.activePens.has(e.pointerId); + break; + case 'pointerup': + case 'pointercancel': + this.activePens.delete(e.pointerId); + this.lastPenActivityTime = now; + break; + case 'pointerleave': + case 'pointerout': + this.penHovering = false; + this.lastPenActivityTime = now; + break; + } + } + + evaluate(e: PointerEvent): Decision { + if (e.pointerType === 'pen') return { accept: true, reason: 'pen input' }; + if (e.pointerType !== 'touch') return { accept: true, reason: 'non-touch pointer' }; + + const o = this.options; + const since = Date.now() - this.lastPenActivityTime; + + if (o.penSession && (since < PEN_SESSION_MS || this.penIsDown)) { + return { accept: false, reason: 'pen session active' }; + } + if (o.geometry) { + const contact = Math.max(e.width || 0, e.height || 0); + if (contact > PALM_CONTACT_PX) { + return { accept: false, reason: `wide contact (${contact.toFixed(0)}px)` }; + } + } + if (o.timing && since < PEN_TIMING_MS) { + return { accept: false, reason: 'within 150ms of pen activity' }; + } + if (o.hover && this.penHovering) { + return { accept: false, reason: 'pen hovering' }; + } + return { accept: true, reason: 'touch accepted' }; + } +} diff --git a/src/lib/ink/stroke.ts b/src/lib/ink/stroke.ts new file mode 100644 index 0000000..1820d3e --- /dev/null +++ b/src/lib/ink/stroke.ts @@ -0,0 +1,114 @@ +// Vector stroke recording, outline generation and hit testing. + +import { getStroke } from 'perfect-freehand'; +import { STRIDE, newId, type InkTool, type Stroke } from '$lib/model/types'; + +export const HIGHLIGHTER_OPACITY = 0.35; + +/** Incrementally records points for one stroke. */ +export class StrokeRecorder { + stroke: Stroke; + private t0 = performance.now(); + + constructor(tool: InkTool, color: string, width: number, pressure: boolean) { + this.stroke = { id: newId(), tool, color, width, pressure, points: [] }; + } + + /** Push one sample; x/y are page-local points. */ + recordPoint(x: number, y: number, e: PointerEvent) { + const p = this.stroke.pressure ? e.pressure || 0.5 : 0.5; + const t = Math.round(e.timeStamp ? e.timeStamp - this.t0 : performance.now() - this.t0); + this.stroke.points.push(round(x), round(y), round(p, 3), e.tiltX || 0, e.tiltY || 0, Math.max(0, t)); + } + + get length() { + return this.stroke.points.length / STRIDE; + } +} + +const round = (v: number, digits = 2) => { + const f = 10 ** digits; + return Math.round(v * f) / f; +}; + +export function* samples(s: Stroke) { + for (let i = 0; i < s.points.length; i += STRIDE) { + yield { x: s.points[i], y: s.points[i + 1], p: s.points[i + 2] }; + } +} + +/** Closed outline polygon of a stroke (page-local points). */ +export function outline(s: Stroke, last = true): [number, number][] { + const input: number[][] = []; + for (const { x, y, p } of samples(s)) input.push([x, y, p]); + const highlighter = s.tool === 'highlighter'; + return getStroke(input, { + size: s.width, + thinning: highlighter ? 0 : 0.6, + smoothing: 0.5, + streamline: 0.4, + simulatePressure: !s.pressure && !highlighter, + last, + start: { cap: true }, + end: { cap: true } + }) as [number, number][]; +} + +const pathCache = new WeakMap(); + +export function svgPath(s: Stroke, live = false): string { + if (!live) { + const cached = pathCache.get(s); + if (cached !== undefined) return cached; + } + const pts = outline(s, !live); + const d = pathFromOutline(pts); + if (!live) pathCache.set(s, d); + return d; +} + +function pathFromOutline(pts: [number, number][]): string { + if (pts.length < 2) return ''; + // Quadratic smoothing through midpoints. + const avg = (a: number, b: number) => ((a + b) / 2).toFixed(2); + let d = `M${pts[0][0].toFixed(2)},${pts[0][1].toFixed(2)} Q`; + for (let i = 0; i < pts.length; i++) { + const [x0, y0] = pts[i]; + const [x1, y1] = pts[(i + 1) % pts.length]; + d += `${x0.toFixed(2)},${y0.toFixed(2)} ${avg(x0, x1)},${avg(y0, y1)} `; + } + return d + 'Z'; +} + +/** Axis-aligned bounds of the stroke's centreline, padded by its width. */ +export function strokeBounds(s: Stroke) { + let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; + for (const { x, y } of samples(s)) { + x1 = Math.min(x1, x); + y1 = Math.min(y1, y); + x2 = Math.max(x2, x); + y2 = Math.max(y2, y); + } + const pad = s.width; + return { x1: x1 - pad, y1: y1 - pad, x2: x2 + pad, y2: y2 + pad }; +} + +/** Does a circle at (x, y) with radius r touch the stroke? */ +export function hitStroke(s: Stroke, x: number, y: number, r: number): boolean { + const b = strokeBounds(s); + if (x < b.x1 - r || x > b.x2 + r || y < b.y1 - r || y > b.y2 + r) return false; + const reach = r + s.width / 2; + const pts = s.points; + if (pts.length === STRIDE) return Math.hypot(pts[0] - x, pts[1] - y) <= reach; + for (let i = STRIDE; i < pts.length; i += STRIDE) { + if (segDist(x, y, pts[i - STRIDE], pts[i - STRIDE + 1], pts[i], pts[i + 1]) <= reach) return true; + } + return false; +} + +function segDist(px: number, py: number, ax: number, ay: number, bx: number, by: number) { + const dx = bx - ax, dy = by - ay; + const len2 = dx * dx + dy * dy; + const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)); + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); +} diff --git a/src/lib/model/layout.ts b/src/lib/model/layout.ts new file mode 100644 index 0000000..8e8c8df --- /dev/null +++ b/src/lib/model/layout.ts @@ -0,0 +1,82 @@ +// Tree → world-space rectangles (units are PDF points; 1 CSS px = 1 pt in the +// untransformed world layer). + +import { branchesOf, type Dir, type Slot, type Tree } from './tree'; + +export interface Size { + width: number; + height: number; +} + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export const GAP = 40; + +export function layout(tree: Tree, sizeOf: (id: string) => Size): Map { + const rects = new Map(); + let y = 0; + for (const trunkId of tree.trunk) { + const b = branchesOf(tree, trunkId); + const rowIds = [trunkId, ...b.left, ...b.right]; + const rowHeight = Math.max(...rowIds.map((id) => sizeOf(id).height)); + const place = (id: string, x: number) => { + const s = sizeOf(id); + const r = { x, y: y + (rowHeight - s.height) / 2, width: s.width, height: s.height }; + rects.set(id, r); + return r; + }; + + const trunk = place(trunkId, -sizeOf(trunkId).width / 2); + let left = trunk.x; + for (const id of b.left) left = place(id, left - GAP - sizeOf(id).width).x; + let right = trunk.x + trunk.width; + for (const id of b.right) { + const r = place(id, right + GAP); + right = r.x + r.width; + } + y += rowHeight + GAP; + } + return rects; +} + +/** Where a ghost tile for `slot` sits, sized like `size`. */ +export function slotRect(rects: Map, slot: Slot, size: Size): Rect | null { + const a = rects.get(slot.anchor); + if (!a) return null; + return rectBeside(a, slot.dir, size); +} + +export function rectBeside(a: Rect, dir: Dir, size: Size): Rect { + const cx = a.x + a.width / 2; + const cy = a.y + a.height / 2; + switch (dir) { + case 'up': + return { x: cx - size.width / 2, y: a.y - GAP - size.height, ...size }; + case 'down': + return { x: cx - size.width / 2, y: a.y + a.height + GAP, ...size }; + case 'left': + return { x: a.x - GAP - size.width, y: cy - size.height / 2, ...size }; + case 'right': + return { x: a.x + a.width + GAP, y: cy - size.height / 2, ...size }; + } +} + +export function bounds(rects: Iterable): Rect | null { + let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; + for (const r of rects) { + x1 = Math.min(x1, r.x); + y1 = Math.min(y1, r.y); + x2 = Math.max(x2, r.x + r.width); + y2 = Math.max(y2, r.y + r.height); + } + return x1 === Infinity ? null : { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; +} + +export function intersects(a: Rect, b: Rect): boolean { + return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; +} diff --git a/src/lib/model/tree.test.ts b/src/lib/model/tree.test.ts new file mode 100644 index 0000000..ebc6578 --- /dev/null +++ b/src/lib/model/tree.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { canInsert, flatten, freeSlotsOf, insert, locate, neighbor, remove, withTrunk } from './tree'; + +describe('page tree', () => { + it('flattens trunk top→bottom with left then right branches, closest→outward', () => { + let t = withTrunk(['a', 'b']); + t = insert(t, 'a', 'right', ['r1']); + t = insert(t, 'r1', 'right', ['r2']); + t = insert(t, 'a', 'left', ['l1']); + t = insert(t, 'l1', 'left', ['l2']); + t = insert(t, 'b', 'right', ['br']); + expect(flatten(t)).toEqual(['a', 'l1', 'l2', 'r1', 'r2', 'b', 'br']); + }); + + it('growing the trunk upward changes what comes first', () => { + let t = withTrunk(['a']); + t = insert(t, 'a', 'up', ['p1', 'p2']); + expect(flatten(t)).toEqual(['p1', 'p2', 'a']); + }); + + it('inserts an imported chain in reading order on every side', () => { + let t = withTrunk(['a']); + t = insert(t, 'a', 'left', ['x1', 'x2', 'x3']); + expect(t.branches.a.left).toEqual(['x1', 'x2', 'x3']); + t = insert(t, 'a', 'down', ['d1', 'd2']); + expect(t.trunk).toEqual(['a', 'd1', 'd2']); + }); + + it('forbids sub-branches off a branch', () => { + let t = withTrunk(['a']); + t = insert(t, 'a', 'right', ['r']); + expect(canInsert(t, 'r', 'up')).toBe(false); + expect(insert(t, 'r', 'down', ['z'])).toBe(t); + expect(freeSlotsOf(t, 'r').map((s) => s.dir)).toEqual(['right']); + }); + + it('inserting toward the trunk from a branch page goes between', () => { + let t = withTrunk(['a']); + t = insert(t, 'a', 'right', ['r1', 'r2']); + t = insert(t, 'r2', 'left', ['mid']); + expect(t.branches.a.right).toEqual(['r1', 'mid', 'r2']); + }); + + it('navigates neighbours', () => { + let t = withTrunk(['a', 'b']); + t = insert(t, 'a', 'left', ['l1', 'l2']); + expect(neighbor(t, 'a', 'down')).toBe('b'); + expect(neighbor(t, 'a', 'left')).toBe('l1'); + expect(neighbor(t, 'l1', 'left')).toBe('l2'); + expect(neighbor(t, 'l1', 'right')).toBe('a'); + expect(neighbor(t, 'l2', 'up')).toBe(null); + }); + + it('removing a trunk page takes its branches with it', () => { + let t = withTrunk(['a', 'b']); + t = insert(t, 'a', 'right', ['r']); + const { tree, removed } = remove(t, 'a'); + expect(removed).toEqual(['a', 'r']); + expect(flatten(tree)).toEqual(['b']); + expect(locate(tree, 'r')).toBe(null); + }); + + it('removing a branch page closes the gap', () => { + let t = withTrunk(['a']); + t = insert(t, 'a', 'right', ['r1', 'r2', 'r3']); + expect(remove(t, 'r2').tree.branches.a.right).toEqual(['r1', 'r3']); + }); +}); diff --git a/src/lib/model/tree.ts b/src/lib/model/tree.ts new file mode 100644 index 0000000..e035e37 --- /dev/null +++ b/src/lib/model/tree.ts @@ -0,0 +1,164 @@ +// Page tree ("comb") — pure, immutable operations. +// +// One vertical trunk of pages; each trunk page may carry one left and one +// right branch. Branches are strictly linear and ordered closest-to-trunk → +// outward. Nothing here knows about page contents, only ids. + +export type Side = 'left' | 'right'; +export type Dir = 'up' | 'down' | Side; + +export interface Branches { + left: string[]; + right: string[]; +} + +export interface Tree { + /** Trunk page ids, top → bottom. */ + trunk: string[]; + /** Branches keyed by the trunk page they hang off. */ + branches: Record; +} + +export type Pos = + | { kind: 'trunk'; row: number; id: string } + | { kind: 'branch'; row: number; side: Side; index: number; id: string; trunkId: string }; + +export const emptyTree = (): Tree => ({ trunk: [], branches: {} }); + +export function branchesOf(tree: Tree, trunkId: string): Branches { + return tree.branches[trunkId] ?? { left: [], right: [] }; +} + +export function locate(tree: Tree, id: string): Pos | null { + for (let row = 0; row < tree.trunk.length; row++) { + const trunkId = tree.trunk[row]; + if (trunkId === id) return { kind: 'trunk', row, id }; + const b = branchesOf(tree, trunkId); + for (const side of ['left', 'right'] as const) { + const index = b[side].indexOf(id); + if (index >= 0) return { kind: 'branch', row, side, index, id, trunkId }; + } + } + return null; +} + +export function allIds(tree: Tree): string[] { + return flatten(tree); +} + +/** + * Linear page order for export/print: each trunk page top → bottom, followed + * by its left branch then its right branch, both closest → outward. + */ +export function flatten(tree: Tree): string[] { + const out: string[] = []; + for (const id of tree.trunk) { + out.push(id); + const b = branchesOf(tree, id); + out.push(...b.left, ...b.right); + } + return out; +} + +/** The page adjacent to `id` in direction `dir`, if any. */ +export function neighbor(tree: Tree, id: string, dir: Dir): string | null { + const pos = locate(tree, id); + if (!pos) return null; + if (pos.kind === 'trunk') { + if (dir === 'up') return tree.trunk[pos.row - 1] ?? null; + if (dir === 'down') return tree.trunk[pos.row + 1] ?? null; + return branchesOf(tree, id)[dir][0] ?? null; + } + if (dir === 'up' || dir === 'down') return null; + const chain = branchesOf(tree, pos.trunkId)[pos.side]; + if (dir === pos.side) return chain[pos.index + 1] ?? null; + return pos.index === 0 ? pos.trunkId : chain[pos.index - 1]; +} + +/** Whether new pages may be inserted next to `id` in direction `dir`. */ +export function canInsert(tree: Tree, id: string, dir: Dir): boolean { + const pos = locate(tree, id); + if (!pos) return false; + // Branch pages only grow along their own axis — no sub-branches. + if (pos.kind === 'branch') return dir === 'left' || dir === 'right'; + return true; +} + +/** + * Insert a chain of pages adjacent to `anchor` in direction `dir`, pushing any + * existing pages further out. The chain is given in reading order (the order + * `flatten` should later produce), e.g. PDF page 1 first. + */ +export function insert(tree: Tree, anchor: string, dir: Dir, ids: string[]): Tree { + const pos = locate(tree, anchor); + if (!pos || !canInsert(tree, anchor, dir) || ids.length === 0) return tree; + const trunk = [...tree.trunk]; + const branches = { ...tree.branches }; + + if (pos.kind === 'trunk') { + if (dir === 'up') trunk.splice(pos.row, 0, ...ids); + else if (dir === 'down') trunk.splice(pos.row + 1, 0, ...ids); + else { + const b = branchesOf(tree, anchor); + branches[anchor] = { ...b, [dir]: [...ids, ...b[dir]] }; + } + return { trunk, branches }; + } + + const b = branchesOf(tree, pos.trunkId); + const chain = [...b[pos.side]]; + // Moving outward inserts after the anchor; moving back toward the trunk + // inserts before it. Either way the chain keeps closest → outward order. + const at = dir === pos.side ? pos.index + 1 : pos.index; + chain.splice(at, 0, ...ids); + branches[pos.trunkId] = { ...b, [pos.side]: chain }; + return { trunk, branches }; +} + +/** Start a tree from a chain of pages (they all become trunk pages). */ +export function withTrunk(ids: string[]): Tree { + return { trunk: [...ids], branches: {} }; +} + +/** + * Remove a page. Removing a trunk page also removes its branches (a branch + * cannot exist without its trunk page). Returns every id that was removed. + */ +export function remove(tree: Tree, id: string): { tree: Tree; removed: string[] } { + const pos = locate(tree, id); + if (!pos) return { tree, removed: [] }; + const branches = { ...tree.branches }; + if (pos.kind === 'trunk') { + const b = branchesOf(tree, id); + delete branches[id]; + return { + tree: { trunk: tree.trunk.filter((t) => t !== id), branches }, + removed: [id, ...b.left, ...b.right] + }; + } + const b = branchesOf(tree, pos.trunkId); + const chain = b[pos.side].filter((p) => p !== id); + const next = { ...b, [pos.side]: chain }; + if (next.left.length === 0 && next.right.length === 0) delete branches[pos.trunkId]; + else branches[pos.trunkId] = next; + return { tree: { trunk: [...tree.trunk], branches }, removed: [id] }; +} + +/** An empty spot next to an existing page where a new page could go. */ +export interface Slot { + anchor: string; + dir: Dir; +} + +/** Free neighbouring spots of one page. */ +export function freeSlotsOf(tree: Tree, id: string): Slot[] { + const dirs: Dir[] = ['up', 'down', 'left', 'right']; + return dirs + .filter((dir) => canInsert(tree, id, dir) && neighbor(tree, id, dir) === null) + .map((dir) => ({ anchor: id, dir })); +} + +/** Every free spot in the whole tree (used when placing an imported PDF). */ +export function freeSlots(tree: Tree): Slot[] { + return flatten(tree).flatMap((id) => freeSlotsOf(tree, id)); +} diff --git a/src/lib/model/types.ts b/src/lib/model/types.ts new file mode 100644 index 0000000..08e3b68 --- /dev/null +++ b/src/lib/model/types.ts @@ -0,0 +1,65 @@ +import type { Tree } from './tree'; + +/** A4 in PDF points. */ +export const A4 = { width: 595.28, height: 841.89 }; + +export const PAGE_PRESETS: { label: string; width: number; height: number }[] = [ + { label: 'A4', width: 595.28, height: 841.89 }, + { label: 'A4 landscape', width: 841.89, height: 595.28 }, + { label: 'A5', width: 419.53, height: 595.28 }, + { label: 'Letter', width: 612, height: 792 }, + { label: 'Square', width: 595.28, height: 595.28 }, + { label: '16:9', width: 841.89, height: 473.56 } +]; + +export type InkTool = 'pen' | 'highlighter'; + +/** + * A vector ink stroke in page-local points (origin top-left). + * `points` is flat with stride {@link STRIDE}: x, y, pressure, tiltX, tiltY, t + * (t = ms since the stroke started). + */ +export interface Stroke { + id: string; + tool: InkTool; + color: string; + /** Nominal diameter in points. */ + width: number; + /** False when the input device had no real pressure (mouse). */ + pressure: boolean; + points: number[]; +} + +export const STRIDE = 6; + +/** Where an imported page came from. */ +export interface Origin { + /** Content hash of the source PDF (key in the sources store). */ + sourceId: string; + /** 0-based page index inside the source PDF. */ + pageIndex: number; + /** Original file name of the imported PDF, kept for provenance search. */ + name: string; +} + +export interface PageData { + id: string; + width: number; + height: number; + markdown: string; + strokes: Stroke[]; + origin?: Origin; +} + +/** Plain, serialisable form of a canvas (what autosave writes to IndexedDB). */ +export interface CanvasData { + version: 1; + updatedAt: number; + tree: Tree; + pages: Record; +} + +export function newId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)); + return Array.from(bytes, (b) => b.toString(36).padStart(2, '0')).join('').slice(0, 12); +} diff --git a/src/lib/pdf/fonts.ts b/src/lib/pdf/fonts.ts new file mode 100644 index 0000000..dba603b --- /dev/null +++ b/src/lib/pdf/fonts.ts @@ -0,0 +1,40 @@ +// Font files shared by the UI (@font-face in app.css) and the PDF writer, so +// text wraps identically on screen and on paper. + +export const FONT_FILES = { + regular: 'Inter_400Regular.ttf', + bold: 'Inter_700Bold.ttf', + italic: 'Inter_400Regular_Italic.ttf', + boldItalic: 'Inter_700Bold_Italic.ttf', + mono: 'JetBrainsMono_400Regular.ttf' +} as const; + +export type FontKey = keyof typeof FONT_FILES; +export type FontBytes = Record; + +type Loader = (file: string) => Promise; + +let loader: Loader = async (file) => { + const res = await fetch(new URL(`fonts/${file}`, document.baseURI)); + if (!res.ok) throw new Error(`Could not load font ${file}`); + return new Uint8Array(await res.arrayBuffer()); +}; + +/** Tests (Node) swap in a filesystem loader. */ +export function setFontLoader(fn: Loader) { + loader = fn; + cache = null; +} + +let cache: Promise | null = null; + +export function loadFonts(): Promise { + cache ??= (async () => { + const entries = await Promise.all( + Object.entries(FONT_FILES).map(async ([k, f]) => [k, await loader(f)] as const) + ); + return Object.fromEntries(entries) as FontBytes; + })(); + cache.catch(() => (cache = null)); + return cache; +} diff --git a/src/lib/pdf/format.ts b/src/lib/pdf/format.ts new file mode 100644 index 0000000..b1cfb08 --- /dev/null +++ b/src/lib/pdf/format.ts @@ -0,0 +1,17 @@ +// Names of the embedded files that make up the source-of-truth layer. + +export const ATTACH = { + manifest: 'papure/manifest.json', + markdown: (id: string) => `papure/pages/${id}.md`, + ink: (id: string) => `papure/pages/${id}.ink.json`, + source: (id: string) => `papure/sources/${id}.pdf` +}; + +export const FORMAT = 'papure'; + +export async function sha256Hex(bytes: Uint8Array, length = 24): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes as BufferSource); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')) + .join('') + .slice(0, length); +} diff --git a/src/lib/pdf/read.ts b/src/lib/pdf/read.ts new file mode 100644 index 0000000..3dec509 --- /dev/null +++ b/src/lib/pdf/read.ts @@ -0,0 +1,162 @@ +// PDF → CanvasData. Reads the embedded source-of-truth layer first; a PDF +// without it (any ordinary PDF) is taken as an import: one trunk page per +// PDF page, with the file itself as the source. + +import { + PDFArray, + PDFDict, + PDFDocument, + PDFHexString, + PDFName, + PDFRawStream, + PDFStream, + PDFString, + decodePDFRawStream +} from 'pdf-lib'; +import { withTrunk, flatten, type Tree } from '$lib/model/tree'; +import { newId, type CanvasData, type Origin, type PageData, type Stroke } from '$lib/model/types'; +import { ATTACH, FORMAT, sha256Hex } from './format'; +import { normRotation } from './write'; + +export interface ReadResult { + data: CanvasData; + /** Source PDFs referenced by pages, keyed by source id. */ + sources: Map; + /** True when the file had no Papure layer and was imported as-is. */ + imported: boolean; +} + +export async function readCanvasPdf(bytes: Uint8Array, name: string): Promise { + const doc = await PDFDocument.load(bytes, { ignoreEncryption: true, updateMetadata: false }); + const files = readAttachments(doc); + const manifestBytes = files.get(ATTACH.manifest); + if (manifestBytes) { + try { + return { ...fromAttachments(files, JSON.parse(text(manifestBytes))), imported: false }; + } catch (err) { + console.warn('Papure layer unreadable, importing visual content instead', err); + } + } + return importPdf(bytes, name, doc); +} + +interface Manifest { + format: string; + version: 1; + updatedAt: number; + tree: Tree; + pages: Record; +} + +function fromAttachments(files: Map, m: Manifest) { + if (m.format !== FORMAT) throw new Error('not a papure manifest'); + const pages: Record = {}; + for (const id of flatten(m.tree)) { + const meta = m.pages[id]; + if (!meta) continue; + const md = files.get(ATTACH.markdown(id)); + const ink = files.get(ATTACH.ink(id)); + pages[id] = { + id, + width: meta.width, + height: meta.height, + origin: meta.origin, + markdown: md ? text(md) : '', + strokes: ink ? (JSON.parse(text(ink)).strokes as Stroke[]) : [] + }; + } + const sources = new Map(); + for (const p of Object.values(pages)) { + if (!p.origin || sources.has(p.origin.sourceId)) continue; + const b = files.get(ATTACH.source(p.origin.sourceId)); + if (b) sources.set(p.origin.sourceId, b); + } + const data: CanvasData = { version: 1, updatedAt: m.updatedAt, tree: m.tree, pages }; + return { data, sources }; +} + +/** Pages of a PDF as they appear on screen (CropBox, /Rotate applied). */ +export async function pdfPageSizes(bytes: Uint8Array, loaded?: PDFDocument) { + const doc = loaded ?? (await PDFDocument.load(bytes, { ignoreEncryption: true, updateMetadata: false })); + return doc.getPages().map((p) => { + const { width, height } = p.getCropBox(); + const r = normRotation(p.getRotation().angle); + return r === 90 || r === 270 ? { width: height, height: width } : { width, height }; + }); +} + +/** Build page records for every page of a source PDF, in order. */ +export async function pagesFromPdf(bytes: Uint8Array, name: string, loaded?: PDFDocument) { + const sourceId = await sha256Hex(bytes); + const sizes = await pdfPageSizes(bytes, loaded); + const pages: PageData[] = sizes.map((s, pageIndex) => ({ + id: newId(), + width: round(s.width), + height: round(s.height), + markdown: '', + strokes: [], + origin: { sourceId, pageIndex, name } + })); + return { sourceId, pages }; +} + +async function importPdf(bytes: Uint8Array, name: string, doc: PDFDocument): Promise { + const { sourceId, pages } = await pagesFromPdf(bytes, name, doc); + const data: CanvasData = { + version: 1, + updatedAt: Date.now(), + tree: withTrunk(pages.map((p) => p.id)), + pages: Object.fromEntries(pages.map((p) => [p.id, p])) + }; + return { data, sources: new Map([[sourceId, bytes]]), imported: true }; +} + +const round = (v: number) => Math.round(v * 100) / 100; +const text = (b: Uint8Array) => new TextDecoder().decode(b); + +/** All embedded files (EmbeddedFiles name tree), by name. */ +export function readAttachments(doc: PDFDocument): Map { + const out = new Map(); + const names = doc.catalog.lookupMaybe(PDFName.of('Names'), PDFDict); + const root = names?.lookupMaybe(PDFName.of('EmbeddedFiles'), PDFDict); + if (!root) return out; + + const visit = (node: PDFDict, depth: number) => { + if (depth > 32) return; + const arr = node.lookupMaybe(PDFName.of('Names'), PDFArray); + if (arr) { + for (let i = 0; i + 1 < arr.size(); i += 2) { + const key = arr.lookup(i); + const spec = arr.lookup(i + 1); + if (!(spec instanceof PDFDict)) continue; + const fileName = + decodeName(spec.lookup(PDFName.of('UF'))) ?? + decodeName(spec.lookup(PDFName.of('F'))) ?? + decodeName(key); + const ef = spec.lookupMaybe(PDFName.of('EF'), PDFDict); + const stream = ef?.lookup(PDFName.of('F')); + if (!fileName || !(stream instanceof PDFStream)) continue; + out.set(fileName, streamBytes(stream)); + } + } + const kids = node.lookupMaybe(PDFName.of('Kids'), PDFArray); + if (kids) { + for (let i = 0; i < kids.size(); i++) { + const kid = kids.lookup(i); + if (kid instanceof PDFDict) visit(kid, depth + 1); + } + } + }; + visit(root, 0); + return out; +} + +function decodeName(v: unknown): string | undefined { + if (v instanceof PDFString || v instanceof PDFHexString) return v.decodeText(); + return undefined; +} + +function streamBytes(stream: PDFStream): Uint8Array { + if (stream instanceof PDFRawStream) return decodePDFRawStream(stream).decode(); + return stream.getContents(); +} diff --git a/src/lib/pdf/render.ts b/src/lib/pdf/render.ts new file mode 100644 index 0000000..44530fa --- /dev/null +++ b/src/lib/pdf/render.ts @@ -0,0 +1,62 @@ +// On-screen rendering of imported PDF pages with pdf.js. + +import * as pdfjs from 'pdfjs-dist'; +import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; +import type { PDFDocumentProxy } from 'pdfjs-dist'; + +pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; + +const docs = new Map>(); + +export function loadSource(sourceId: string, getBytes: () => Promise) { + let p = docs.get(sourceId); + if (!p) { + p = (async () => { + const bytes = await getBytes(); + if (!bytes) throw new Error(`Missing source PDF ${sourceId}`); + // pdf.js transfers the buffer to its worker, so hand it a copy. + return pdfjs.getDocument({ data: bytes.slice() }).promise; + })(); + p.catch(() => docs.delete(sourceId)); + docs.set(sourceId, p); + } + return p; +} + +/** + * Render one page into `canvas` at `pxPerPt` device pixels per PDF point. + * Returns a cancel function. + */ +export function renderPage( + doc: PDFDocumentProxy, + pageIndex: number, + canvas: HTMLCanvasElement, + pxPerPt: number +): { promise: Promise; cancel: () => void } { + let task: ReturnType>['render']> | null = null; + let cancelled = false; + const promise = (async () => { + const page = await doc.getPage(pageIndex + 1); + if (cancelled) return; + const viewport = page.getViewport({ scale: pxPerPt }); + const off = document.createElement('canvas'); + off.width = Math.max(1, Math.floor(viewport.width)); + off.height = Math.max(1, Math.floor(viewport.height)); + task = page.render({ canvas: off, viewport }); + await task.promise; + if (cancelled) return; + // Swap in one go so there's no blank flash while re-rendering. + canvas.width = off.width; + canvas.height = off.height; + canvas.getContext('2d')!.drawImage(off, 0, 0); + })().catch((e) => { + if (!cancelled && e?.name !== 'RenderingCancelledException') console.warn(e); + }); + return { + promise, + cancel: () => { + cancelled = true; + task?.cancel(); + } + }; +} diff --git a/src/lib/pdf/roundtrip.test.ts b/src/lib/pdf/roundtrip.test.ts new file mode 100644 index 0000000..fc006a2 --- /dev/null +++ b/src/lib/pdf/roundtrip.test.ts @@ -0,0 +1,105 @@ +import { readFile } from 'node:fs/promises'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { PDFDocument, PDFName, degrees } from 'pdf-lib'; +import { insert, withTrunk } from '$lib/model/tree'; +import type { CanvasData, Stroke } from '$lib/model/types'; +import { setFontLoader } from './fonts'; +import { canvasToPdf } from './write'; +import { readCanvasPdf } from './read'; + +beforeAll(() => { + setFontLoader(async (f) => new Uint8Array(await readFile(`static/fonts/${f}`))); +}); + +const stroke: Stroke = { + id: 's1', + tool: 'pen', + color: '#1f1f1f', + width: 2, + pressure: true, + points: [10, 10, 0.5, 0, 0, 0, 40, 30, 0.7, 5, -5, 16, 80, 35, 0.4, 0, 0, 32] +}; + +function sample(): CanvasData { + let tree = withTrunk(['a', 'b']); + tree = insert(tree, 'a', 'right', ['r']); + return { + version: 1, + updatedAt: 1_700_000_000_000, + tree, + pages: { + a: { id: 'a', width: 595.28, height: 841.89, markdown: '# Lecture 1\n\nŘeřicha **tučně** a `kód`\n- item\n- [x] done', strokes: [stroke] }, + b: { id: 'b', width: 595.28, height: 841.89, markdown: '', strokes: [] }, + r: { id: 'r', width: 841.89, height: 595.28, markdown: '> side note', strokes: [{ ...stroke, id: 's2', tool: 'highlighter', color: '#ffd400', width: 14 }] } + } + }; +} + +async function makeSourcePdf() { + const src = await PDFDocument.create(); + src.addPage([300, 400]); // blank: no content stream at all + const p = src.addPage([500, 200]); + p.drawRectangle({ x: 10, y: 10, width: 50, height: 50 }); + p.setRotation(degrees(90)); + return src.save(); +} + +describe('pdf round trip', () => { + it('writes the flatten order and restores the model from attachments', async () => { + const data = sample(); + const bytes = await canvasToPdf(data, { title: 'test', getSource: async () => undefined }); + const doc = await PDFDocument.load(bytes); + expect(doc.getPageCount()).toBe(3); + expect(doc.getPage(1).getSize()).toEqual({ width: 841.89, height: 595.28 }); // order: a, r, b + const annots = doc.getPage(0).node.lookup(PDFName.of('Annots')); + expect(annots).toBeTruthy(); + + const back = await readCanvasPdf(bytes, 'test.pdf'); + expect(back.imported).toBe(false); + expect(back.data.tree).toEqual(data.tree); + expect(back.data.pages.a.markdown).toBe(data.pages.a.markdown); + expect(back.data.pages.a.strokes).toEqual([stroke]); + expect(back.data.pages.r.strokes[0].tool).toBe('highlighter'); + }); + + it('embeds imported pages and their source, and imports plain PDFs', async () => { + const srcBytes = await makeSourcePdf(); + const plain = await readCanvasPdf(srcBytes, 'slides.pdf'); + expect(plain.imported).toBe(true); + const pages = Object.values(plain.data.pages); + expect(pages.map((p) => [p.width, p.height])).toEqual([ + [300, 400], + [200, 500] + ]); + expect(pages[0].origin?.name).toBe('slides.pdf'); + + const sources = plain.sources; + const bytes = await canvasToPdf(plain.data, { title: 't', getSource: async (id) => sources.get(id) }); + const back = await readCanvasPdf(bytes, 'x.pdf'); + expect(back.imported).toBe(false); + expect([...back.sources.keys()]).toEqual([...sources.keys()]); + const doc = await PDFDocument.load(bytes); + expect(doc.getPage(1).getSize()).toEqual({ width: 200, height: 500 }); + }); +}); + +describe('imported pages', () => { + it('keep the annotations of the source page', async () => { + const withInk = await canvasToPdf(sample(), { title: 't', getSource: async () => undefined }); + const imported = await readCanvasPdf(withInk, 'notes.pdf'); + // A papure PDF read back is not "imported"; force a plain import of its bytes. + const plain = await readCanvasPdf(await stripAttachments(withInk), 'notes.pdf'); + expect(plain.imported).toBe(true); + expect(imported.imported).toBe(false); + const again = await canvasToPdf(plain.data, { title: 't', getSource: async (id) => plain.sources.get(id) }); + const doc = await PDFDocument.load(again); + const annots = doc.getPage(0).node.Annots(); + expect(annots?.size()).toBe(1); + }); +}); + +async function stripAttachments(bytes: Uint8Array) { + const doc = await PDFDocument.load(bytes); + doc.catalog.delete(PDFName.of('Names')); + return doc.save(); +} diff --git a/src/lib/pdf/text.ts b/src/lib/pdf/text.ts new file mode 100644 index 0000000..7d7c9c5 --- /dev/null +++ b/src/lib/pdf/text.ts @@ -0,0 +1,237 @@ +// Markdown → vector text on a PDF page, laid out line for line like the +// live-preview editor (same parser, same fonts, same metrics). + +import { rgb, type PDFFont, type PDFPage } from 'pdf-lib'; +import { analyze, renderedRuns, type LineInfo, type Run } from '$lib/editor/mdmarks'; +import { PAGE_STYLE, lineMetrics } from '$lib/editor/pageStyle'; + +export interface Fonts { + regular: PDFFont; + bold?: PDFFont; + italic?: PDFFont; + boldItalic?: PDFFont; + mono?: PDFFont; +} + +/** ascent/descent as a fraction of the em, per font. */ +export type Metrics = Map; + +export function hex(color: string) { + const m = /^#?([0-9a-f]{6})$/i.exec(color.trim()); + const n = m ? parseInt(m[1], 16) : 0; + return rgb(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255); +} + +interface Piece { + text: string; + font: PDFFont; + size: number; + width: number; + space: boolean; + run: Run; +} + +const TASK_BOX = 0.85; // em, matches .md-task in the editor CSS + +function fontKey(run: Run, heading: boolean, code: boolean): keyof Fonts { + if (code || run.styles.has('code')) return 'mono'; + const bold = heading || run.styles.has('strong'); + const italic = run.styles.has('em'); + return bold && italic ? 'boldItalic' : bold ? 'bold' : italic ? 'italic' : 'regular'; +} + +/** Which font files a page's markdown needs (so unused ones aren't embedded). */ +export function fontsNeeded(markdown: string, into = new Set()) { + if (!markdown.trim()) return into; + const a = analyze(markdown); + for (const line of a.lines) { + for (const run of renderedRuns(markdown, a, line)) { + into.add(run.widget === 'bullet' ? 'regular' : fontKey(run, line.heading > 0, line.code)); + } + } + return into; +} + +export function drawMarkdown(page: PDFPage, markdown: string, fonts: Fonts, metrics: Metrics) { + if (!markdown.trim()) return; + const text = markdown.replace(/\t/g, ' '); + const s = PAGE_STYLE; + const { width: pw, height: ph } = page.getSize(); + const a = analyze(text); + const contentW = pw - 2 * s.margin; + let y = s.margin; // top-down, converted when drawing + + const toPdfY = (top: number) => ph - top; + + for (const line of a.lines) { + const m = lineMetrics(line.heading, line.code); + const indent = line.quote * s.quoteIndent; + const x0 = s.margin + indent; + const maxW = contentW - indent; + + const visual = line.hr ? [[]] : wrap(renderedRuns(text, a, line), line, fonts, m.size, maxW); + for (const pieces of visual) { + if (y + m.height > ph) return; // clipped at the page edge, like the editor + decorateLine(page, line, x0, y, m.height, contentW - indent, toPdfY); + let x = x0; + for (const p of pieces) { + drawPiece(page, p, x, y, m.height, metrics, toPdfY); + x += p.width; + } + y += m.height; + } + } +} + +function wrap(runs: Run[], line: LineInfo, fonts: Fonts, size: number, maxW: number): Piece[][] { + const pieces: Piece[] = []; + for (const run of runs) { + const font = fonts[fontKey(run, line.heading > 0, line.code)] ?? fonts.regular; + const pieceSize = run.styles.has('code') && !line.code ? size * PAGE_STYLE.codeScale : size; + if (run.widget) { + const width = run.widget === 'bullet' ? font.widthOfTextAtSize('•', size) : TASK_BOX * size; + pieces.push({ text: run.widget === 'bullet' ? '•' : '', font, size, width, space: false, run }); + continue; + } + for (const part of run.text.split(/(\s+)/)) { + if (!part) continue; + pieces.push({ + text: part, + font, + size: pieceSize, + width: safeWidth(font, part, pieceSize), + space: /^\s+$/.test(part), + run + }); + } + } + + const lines: Piece[][] = [[]]; + let x = 0; + for (const p of pieces) { + let cur = lines[lines.length - 1]; + if (!p.space && x + p.width > maxW && cur.some((q) => !q.space)) { + cur = []; + lines.push(cur); + x = 0; + } + if (!p.space && p.width > maxW) { + // A single word wider than the line: break it by characters. + let chunk = ''; + for (const ch of p.text) { + const w = safeWidth(p.font, chunk + ch, p.size); + if (x + w > maxW && chunk) { + cur.push({ ...p, text: chunk, width: safeWidth(p.font, chunk, p.size) }); + cur = []; + lines.push(cur); + x = 0; + chunk = ch; + } else chunk += ch; + } + const w = safeWidth(p.font, chunk, p.size); + cur.push({ ...p, text: chunk, width: w }); + x += w; + continue; + } + cur.push(p); + x += p.width; + } + return lines; +} + +function safeWidth(font: PDFFont, text: string, size: number) { + try { + return font.widthOfTextAtSize(text, size); + } catch { + return text.length * size * 0.5; + } +} + +function decorateLine( + page: PDFPage, + line: LineInfo, + x0: number, + top: number, + height: number, + width: number, + toPdfY: (v: number) => number +) { + const s = PAGE_STYLE; + for (let d = 0; d < line.quote; d++) { + page.drawRectangle({ + x: s.margin + d * s.quoteIndent, + y: toPdfY(top + height), + width: 2, + height, + color: hex(s.colors.quoteBar) + }); + } + if (line.code) { + page.drawRectangle({ x: x0 - 6, y: toPdfY(top + height), width: width + 12, height, color: hex(s.colors.codeBg) }); + } + if (line.hr) { + page.drawLine({ + start: { x: x0, y: toPdfY(top + height / 2) }, + end: { x: x0 + width, y: toPdfY(top + height / 2) }, + thickness: 1, + color: hex(s.colors.rule) + }); + } +} + +function drawPiece( + page: PDFPage, + p: Piece, + x: number, + top: number, + lineHeight: number, + metrics: Metrics, + toPdfY: (v: number) => number +) { + const s = PAGE_STYLE; + const fm = metrics.get(p.font) ?? { ascent: 0.9, descent: 0.25 }; + // CSS centres the font's content box (ascent + descent) in the line box. + const baseline = top + (lineHeight - (fm.ascent + fm.descent) * p.size) / 2 + fm.ascent * p.size; + const color = hex(p.run.styles.has('link') ? s.colors.link : s.colors.text); + + if (p.run.widget === 'task' || p.run.widget === 'task-done') { + const box = TASK_BOX * p.size; + const boxTop = baseline - box * 0.9; + page.drawRectangle({ + x: x + 0.5, + y: toPdfY(boxTop + box) + 0.5, + width: box - 1, + height: box - 1, + borderColor: hex(s.colors.muted), + borderWidth: 0.8 + }); + if (p.run.widget === 'task-done') { + page.drawLine({ start: { x: x + box * 0.22, y: toPdfY(boxTop + box * 0.52) }, end: { x: x + box * 0.42, y: toPdfY(boxTop + box * 0.74) }, thickness: 1.1, color: hex(s.colors.text) }); + page.drawLine({ start: { x: x + box * 0.42, y: toPdfY(boxTop + box * 0.74) }, end: { x: x + box * 0.8, y: toPdfY(boxTop + box * 0.26) }, thickness: 1.1, color: hex(s.colors.text) }); + } + return; + } + if (p.run.styles.has('code')) { + page.drawRectangle({ + x: x - 1, + y: toPdfY(baseline + fm.descent * p.size + 1), + width: p.width + 2, + height: (fm.ascent + fm.descent) * p.size + 2, + color: hex(s.colors.codeBg) + }); + } + if (p.space) return; + try { + page.drawText(p.text, { x, y: toPdfY(baseline), size: p.size, font: p.font, color }); + } catch { + // Glyph the font can't encode — skip rather than failing the save. + } + if (p.run.styles.has('strike')) { + const yMid = toPdfY(baseline - p.size * 0.3); + page.drawLine({ start: { x, y: yMid }, end: { x: x + p.width, y: yMid }, thickness: 0.7, color }); + } + if (p.run.styles.has('link')) { + const yU = toPdfY(baseline + p.size * 0.12); + page.drawLine({ start: { x, y: yU }, end: { x: x + p.width, y: yU }, thickness: 0.5, color }); + } +} diff --git a/src/lib/pdf/write.ts b/src/lib/pdf/write.ts new file mode 100644 index 0000000..427dc78 --- /dev/null +++ b/src/lib/pdf/write.ts @@ -0,0 +1,271 @@ +// CanvasData → dual-layer PDF. +// +// Visual layer: pages in flatten order, imported PDF content, markdown as +// vector text, ink as Ink annotations (with appearance streams). +// Source-of-truth layer: embedded files (see format.ts). + +import fontkit from '@pdf-lib/fontkit'; +import { + PDFArray, + PDFDict, + PDFDocument, + PDFName, + PDFNumber, + PDFObjectCopier, + PDFString, + degrees, + type PDFEmbeddedPage, + type PDFPage +} from 'pdf-lib'; + +interface EmbeddedSource { + page: PDFEmbeddedPage; + rotation: number; + src: PDFPage; + crop: { x: number; y: number }; +} +import { flatten } from '$lib/model/tree'; +import type { CanvasData, PageData, Stroke } from '$lib/model/types'; +import { HIGHLIGHTER_OPACITY, outline, samples } from '$lib/ink/stroke'; +import { ATTACH, FORMAT } from './format'; +import { loadFonts } from './fonts'; +import { drawMarkdown, fontsNeeded, hex, type Fonts, type Metrics } from './text'; + +export interface WriteOptions { + title: string; + getSource: (id: string) => Promise; +} + +export async function canvasToPdf(data: CanvasData, opts: WriteOptions): Promise { + const doc = await PDFDocument.create(); + doc.registerFontkit(fontkit); + doc.setTitle(opts.title); + doc.setProducer('Papure'); + doc.setCreator('Papure'); + doc.setModificationDate(new Date(data.updatedAt)); + + const order = flatten(data.tree); + const pages = order.map((id) => data.pages[id]).filter(Boolean); + + const embedded = await embedSources(doc, pages, opts.getSource); + const hasText = pages.some((p) => p.markdown.trim()); + const text = hasText ? await embedFonts(doc, pages) : null; + + for (const p of pages) { + const page = doc.addPage([p.width, p.height]); + const src = p.origin && embedded.get(`${p.origin.sourceId}:${p.origin.pageIndex}`); + if (src) { + drawSourcePage(page, src.page, src.rotation); + copyMarkupAnnotations(doc, page, src); + } + if (text && p.markdown.trim()) drawMarkdown(page, p.markdown, text.fonts, text.metrics); + for (const s of p.strokes) addInkAnnotation(doc, page, s); + } + if (pages.length === 0) doc.addPage([595.28, 841.89]); + + // Source-of-truth layer. + const when = new Date(data.updatedAt); + const attach = (bytes: Uint8Array | string, name: string, mimeType: string) => + doc.attach(typeof bytes === 'string' ? new TextEncoder().encode(bytes) : bytes, name, { + mimeType, + creationDate: when, + modificationDate: when + }); + + const manifest = { + format: FORMAT, + version: data.version, + updatedAt: data.updatedAt, + tree: data.tree, + order, + pages: Object.fromEntries(pages.map((p) => [p.id, { width: p.width, height: p.height, origin: p.origin }])) + }; + await attach(JSON.stringify(manifest), ATTACH.manifest, 'application/json'); + for (const p of pages) { + if (p.markdown) await attach(p.markdown, ATTACH.markdown(p.id), 'text/markdown'); + if (p.strokes.length) await attach(JSON.stringify({ strokes: p.strokes }), ATTACH.ink(p.id), 'application/json'); + } + const sourceIds = new Set(pages.flatMap((p) => (p.origin ? [p.origin.sourceId] : []))); + for (const id of sourceIds) { + const bytes = await opts.getSource(id); + if (bytes) await attach(bytes, ATTACH.source(id), 'application/pdf'); + } + + return doc.save({ useObjectStreams: true }); +} + +async function embedFonts(doc: PDFDocument, pages: PageData[]) { + const needed = new Set(['regular']); + for (const p of pages) fontsNeeded(p.markdown, needed); + const bytes = await loadFonts(); + const fonts = {} as Fonts; + const metrics: Metrics = new Map(); + for (const key of needed) { + // Files are pre-subset (scripts/subset-fonts.sh); pdf-lib's subsetter drops glyphs. + const font = await doc.embedFont(bytes[key], { subset: false }); + fonts[key] = font; + metrics.set(font, fontMetrics(bytes[key])); + } + return { fonts, metrics }; +} + +function fontMetrics(bytes: Uint8Array) { + const f = fontkit.create(bytes); + return { ascent: f.ascent / f.unitsPerEm, descent: Math.abs(f.descent) / f.unitsPerEm }; +} + +async function embedSources( + doc: PDFDocument, + pages: PageData[], + getSource: WriteOptions['getSource'] +): Promise> { + const bySource = new Map>(); + for (const p of pages) { + if (!p.origin) continue; + let set = bySource.get(p.origin.sourceId); + if (!set) bySource.set(p.origin.sourceId, (set = new Set())); + set.add(p.origin.pageIndex); + } + const out = new Map(); + for (const [sourceId, indices] of bySource) { + const bytes = await getSource(sourceId); + if (!bytes) continue; + try { + const src = await PDFDocument.load(bytes, { ignoreEncryption: true, updateMetadata: false }); + const idx = [...indices].filter((i) => i < src.getPageCount()); + const srcPages = idx.map((i) => src.getPage(i)); + const boxes = srcPages.map((sp) => { + const c = sp.getCropBox(); + return { left: c.x, bottom: c.y, right: c.x + c.width, top: c.y + c.height }; + }); + for (let k = 0; k < idx.length; k++) { + // A blank source page has no content stream; there is nothing to draw + // (and pdf-lib would fail later, at save time). + if (!srcPages[k].node.Contents()) continue; + const [emb] = await doc.embedPages([srcPages[k]], [boxes[k]]); + out.set(`${sourceId}:${idx[k]}`, { + page: emb, + rotation: normRotation(srcPages[k].getRotation().angle), + src: srcPages[k], + crop: { x: boxes[k].left, y: boxes[k].bottom } + }); + } + } catch (err) { + console.warn('Could not embed source PDF', sourceId, err); + } + } + return out; +} + +export const normRotation = (a: number) => (((Math.round(a / 90) * 90) % 360) + 360) % 360; + +/** Draw an embedded source page honouring its /Rotate. */ +function drawSourcePage(page: PDFPage, emb: PDFEmbeddedPage, rotation: number) { + const { width: W, height: H } = page.getSize(); + const size = { width: emb.width, height: emb.height }; + switch (rotation) { + case 90: + return page.drawPage(emb, { x: 0, y: H, rotate: degrees(-90), ...size }); + case 180: + return page.drawPage(emb, { x: W, y: H, rotate: degrees(180), ...size }); + case 270: + return page.drawPage(emb, { x: W, y: 0, rotate: degrees(90), ...size }); + default: + return page.drawPage(emb, { x: 0, y: 0, ...size }); + } +} + +// Annotations that are part of what a reader sees (ink, highlights, notes…). +// Links, form widgets and popups are left behind: they point into the source. +const MARKUP = new Set([ + 'Ink', 'Highlight', 'Underline', 'StrikeOut', 'Squiggly', 'Square', 'Circle', 'Line', + 'Polygon', 'PolyLine', 'FreeText', 'Text', 'Stamp', 'Caret' +]); + +/** + * embedPage only carries the content stream, so annotations on an imported + * page (e.g. ink from another app, or an earlier export) are copied across. + */ +function copyMarkupAnnotations(doc: PDFDocument, page: PDFPage, s: EmbeddedSource) { + // Rotated sources would need every Rect transformed; not supported yet. + if (s.rotation !== 0) return; + const annots = s.src.node.Annots(); + if (!annots) return; + const srcContext = s.src.doc.context; + const copier = PDFObjectCopier.for(srcContext, doc.context); + for (let i = 0; i < annots.size(); i++) { + const a = annots.lookup(i); + if (!(a instanceof PDFDict)) continue; + const subtype = a.lookup(PDFName.of('Subtype')); + if (!(subtype instanceof PDFName) || !MARKUP.has(subtype.decodeText())) continue; + const clone = a.clone(srcContext); + // Drop back-references into the source document. + for (const k of ['P', 'Popup', 'IRT', 'Parent', 'StructParent']) clone.delete(PDFName.of(k)); + const copied = copier.copy(clone); + const rect = copied.lookup(PDFName.of('Rect')); + if (rect instanceof PDFArray && (s.crop.x || s.crop.y)) { + const v = rect.asArray().map((n) => (n instanceof PDFNumber ? n.asNumber() : 0)); + copied.set(PDFName.of('Rect'), doc.context.obj([v[0] - s.crop.x, v[1] - s.crop.y, v[2] - s.crop.x, v[3] - s.crop.y])); + } + page.node.addAnnot(doc.context.register(copied)); + } +} + +function addInkAnnotation(doc: PDFDocument, page: PDFPage, s: Stroke) { + const H = page.getHeight(); + const poly = outline(s); + if (poly.length < 3) return; + const flip = (x: number, y: number) => [x, H - y] as const; + + let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; + for (const [px, py] of poly) { + const [x, y] = flip(px, py); + x1 = Math.min(x1, x); + y1 = Math.min(y1, y); + x2 = Math.max(x2, x); + y2 = Math.max(y2, y); + } + const rect = [x1 - 1, y1 - 1, x2 + 1, y2 + 1].map((v) => +v.toFixed(2)); + + const c = hex(s.color); + const color = [c.red, c.green, c.blue].map((v) => +v.toFixed(3)); + const opacity = s.tool === 'highlighter' ? HIGHLIGHTER_OPACITY : 1; + + // Appearance: the same filled outline the app renders (variable width). + const ops: string[] = ['q']; + if (opacity < 1) ops.push('/GS0 gs'); + ops.push(`${color.join(' ')} rg`); + poly.forEach(([px, py], i) => { + const [x, y] = flip(px, py); + ops.push(`${x.toFixed(2)} ${y.toFixed(2)} ${i === 0 ? 'm' : 'l'}`); + }); + ops.push('h f Q'); + + const ctx = doc.context; + const resources = + opacity < 1 + ? { ExtGState: { GS0: { Type: 'ExtGState', CA: opacity, ca: opacity, BM: 'Multiply' } } } + : {}; + const ap = ctx.register( + ctx.stream(ops.join('\n'), { Type: 'XObject', Subtype: 'Form', BBox: rect, Resources: resources }) + ); + + // Centreline for viewers that rebuild the appearance from InkList. + const ink: number[] = []; + for (const { x, y } of samples(s)) ink.push(...flip(x, y).map((v) => +v.toFixed(2))); + + const annot = ctx.obj({ + Type: 'Annot', + Subtype: 'Ink', + Rect: rect, + InkList: [ink], + C: color, + CA: opacity, + F: 4, + BS: { W: s.width * (s.tool === 'highlighter' ? 1 : 0.6) }, + AP: { N: ap }, + NM: PDFString.of(s.id) + }); + annot.set(PDFName.of('M'), PDFString.fromDate(new Date())); + page.node.addAnnot(ctx.register(annot)); +} diff --git a/src/lib/state/doc.svelte.ts b/src/lib/state/doc.svelte.ts new file mode 100644 index 0000000..53900df --- /dev/null +++ b/src/lib/state/doc.svelte.ts @@ -0,0 +1,213 @@ +// The open canvas: reactive page tree + pages, with undo/redo. + +import { layout, type Rect } from '$lib/model/layout'; +import { insert, remove, canInsert, flatten, withTrunk, type Dir, type Tree } from '$lib/model/tree'; +import { A4, newId, type CanvasData, type Origin, type PageData, type Stroke } from '$lib/model/types'; + +export class PageModel { + readonly id: string; + width = $state(A4.width); + height = $state(A4.height); + markdown = $state(''); + strokes = $state.raw([]); + readonly origin?: Origin; + + constructor(d: PageData) { + this.id = d.id; + this.width = d.width; + this.height = d.height; + this.markdown = d.markdown; + this.strokes = d.strokes; + this.origin = d.origin; + } + + toData(): PageData { + return { + id: this.id, + width: this.width, + height: this.height, + markdown: this.markdown, + strokes: this.strokes, + ...(this.origin ? { origin: this.origin } : {}) + }; + } +} + +interface Command { + undo(): void; + redo(): void; +} + +const MAX_UNDO = 200; + +export class CanvasDoc { + path = $state(''); + tree = $state.raw(withTrunk([])); + pages = $state.raw>({}); + activeId = $state(null); + /** Bumped on every change; the workspace autosaves on it. */ + version = $state(0); + updatedAt: number; + + canUndo = $state(false); + canRedo = $state(false); + private undoStack: Command[] = []; + private redoStack: Command[] = []; + + rects = $derived(layout(this.tree, (id) => this.pages[id] ?? A4)); + + constructor(path: string, data: CanvasData) { + this.path = path; + this.updatedAt = data.updatedAt; + this.tree = data.tree; + this.pages = Object.fromEntries(Object.values(data.pages).map((p) => [p.id, new PageModel(p)])); + this.activeId = data.tree.trunk[0] ?? null; + } + + get order() { + return flatten(this.tree); + } + + rectOf(id: string): Rect | undefined { + return this.rects.get(id); + } + + touch() { + this.updatedAt = Date.now(); + this.version++; + } + + toData(): CanvasData { + const pages: Record = {}; + for (const id of flatten(this.tree)) if (this.pages[id]) pages[id] = this.pages[id].toData(); + return { version: 1, updatedAt: this.updatedAt, tree: this.tree, pages }; + } + + // ---- undo/redo ------------------------------------------------------- + + private exec(cmd: Command) { + cmd.redo(); + this.undoStack.push(cmd); + if (this.undoStack.length > MAX_UNDO) this.undoStack.shift(); + this.redoStack = []; + this.syncFlags(); + this.touch(); + } + + undo() { + const cmd = this.undoStack.pop(); + if (!cmd) return; + cmd.undo(); + this.redoStack.push(cmd); + this.syncFlags(); + this.touch(); + } + + redo() { + const cmd = this.redoStack.pop(); + if (!cmd) return; + cmd.redo(); + this.undoStack.push(cmd); + this.syncFlags(); + this.touch(); + } + + private syncFlags() { + this.canUndo = this.undoStack.length > 0; + this.canRedo = this.redoStack.length > 0; + } + + // ---- ink ------------------------------------------------------------- + + addStroke(pageId: string, stroke: Stroke) { + this.setStrokes(pageId, (s) => [...s, stroke]); + } + + /** Replace a page's strokes as one undoable step. */ + setStrokes(pageId: string, fn: (s: Stroke[]) => Stroke[]) { + const page = this.pages[pageId]; + if (!page) return; + const before = page.strokes; + const after = fn(before); + if (after === before) return; + this.exec({ + redo: () => (this.pages[pageId].strokes = after), + undo: () => (this.pages[pageId].strokes = before) + }); + } + + // ---- tree ------------------------------------------------------------ + + private treeCommand(nextTree: Tree, nextPages: Record, nextActive: string | null) { + const before = { tree: this.tree, pages: this.pages, active: this.activeId }; + const after = { tree: nextTree, pages: nextPages, active: nextActive }; + const apply = (s: typeof before) => { + this.tree = s.tree; + this.pages = s.pages; + this.activeId = s.active && s.pages[s.active] ? s.active : (s.tree.trunk[0] ?? null); + }; + this.exec({ redo: () => apply(after), undo: () => apply(before) }); + } + + /** Insert pages next to `anchor`; returns false if not allowed there. */ + insertPages(anchor: string, dir: Dir, pages: PageData[]): boolean { + if (!canInsert(this.tree, anchor, dir) || pages.length === 0) return false; + const next = insert(this.tree, anchor, dir, pages.map((p) => p.id)); + const models = { ...this.pages }; + for (const p of pages) models[p.id] = new PageModel(p); + this.treeCommand(next, models, pages[0].id); + return true; + } + + addBlankPage(anchor: string, dir: Dir): string | null { + const a = this.pages[anchor]; + // New pages default to A4, oriented like their neighbour when it's a plain page. + const size = a && !a.origin && a.width > a.height ? { width: A4.height, height: A4.width } : A4; + const id = newId(); + return this.insertPages(anchor, dir, [{ id, ...size, markdown: '', strokes: [] }]) ? id : null; + } + + /** A fresh canvas: one plain page with nothing on it. */ + get isBlank() { + const ids = this.order; + const p = ids.length === 1 ? this.pages[ids[0]] : null; + return !!p && !p.origin && !p.markdown.trim() && p.strokes.length === 0; + } + + /** Replace every page with a new trunk (importing into a blank canvas). */ + replaceWith(pages: PageData[]) { + if (pages.length === 0) return; + const models = Object.fromEntries(pages.map((p) => [p.id, new PageModel(p)])); + this.treeCommand(withTrunk(pages.map((p) => p.id)), models, pages[0].id); + } + + deletePage(id: string) { + const { tree, removed } = remove(this.tree, id); + if (removed.length === 0) return; + const models = { ...this.pages }; + for (const r of removed) delete models[r]; + let nextTree = tree; + let active = this.activeId && models[this.activeId] ? this.activeId : (tree.trunk[0] ?? null); + // A canvas always keeps at least one page. + if (tree.trunk.length === 0) { + const fresh = newId(); + models[fresh] = new PageModel({ id: fresh, ...A4, markdown: '', strokes: [] }); + nextTree = withTrunk([fresh]); + active = fresh; + } + this.treeCommand(nextTree, models, active); + } + + resizePage(id: string, width: number, height: number) { + const page = this.pages[id]; + if (!page || page.origin) return; + const before = { width: page.width, height: page.height }; + const after = { width, height }; + const set = (s: typeof before) => { + const p = this.pages[id]; + p.width = s.width; + p.height = s.height; + }; + this.exec({ redo: () => set(after), undo: () => set(before) }); + } +} diff --git a/src/lib/state/menu.svelte.ts b/src/lib/state/menu.svelte.ts new file mode 100644 index 0000000..007843b --- /dev/null +++ b/src/lib/state/menu.svelte.ts @@ -0,0 +1,38 @@ +// A single app-wide context menu. + +import type { Component } from 'svelte'; + +export type MenuItem = + | { label: string; icon?: Component; action: () => void; danger?: boolean; disabled?: boolean; hint?: string } + | 'separator'; + +class Menu { + open = $state(false); + x = $state(0); + y = $state(0); + items = $state.raw([]); + /** Grow upward from (x, y) — for menus opened from the bottom toolbar. */ + above = $state(false); + + show(x: number, y: number, items: MenuItem[]) { + this.above = false; + this.x = x; + this.y = y; + this.items = items; + this.open = true; + } + + /** Open below/beside an anchor element. */ + showAt(el: Element, items: MenuItem[], placement: 'below' | 'above' = 'below') { + const r = el.getBoundingClientRect(); + this.show(r.left, placement === 'below' ? r.bottom + 4 : r.top - 4, items); + this.above = placement === 'above'; + } + + close() { + this.open = false; + this.above = false; + } +} + +export const menu = new Menu(); diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts new file mode 100644 index 0000000..4fa5724 --- /dev/null +++ b/src/lib/state/settings.svelte.ts @@ -0,0 +1,93 @@ +// User settings, persisted in localStorage (including the GitHub token — +// by design there is no backend holding it). + +import { DEFAULT_PALM, type PalmOptions } from '$lib/ink/palm'; + +export type ThemePref = 'system' | 'light' | 'dark'; +export type PageStylePref = 'paper' | 'match'; + +export interface GitHubSettings { + token: string; + owner: string; + repo: string; + branch: string; + /** Folder inside the repo that holds the vault ('' = repo root). */ + dir: string; +} + +interface SettingsData { + theme: ThemePref; + pageStyle: PageStylePref; + fingerDraw: boolean; + palm: PalmOptions; + github: GitHubSettings; + /** Minutes between automatic pushes (0 = manual only). */ + pushInterval: number; + penColors: string[]; + highlighterColors: string[]; +} + +const DEFAULTS: SettingsData = { + theme: 'system', + pageStyle: 'match', + fingerDraw: true, + palm: DEFAULT_PALM, + github: { token: '', owner: '', repo: '', branch: 'main', dir: '' }, + pushInterval: 5, + penColors: ['#1f1f1f', '#2563eb', '#dc2626', '#16a34a', '#9333ea'], + highlighterColors: ['#ffd400', '#7ee081', '#6ec5ff', '#ff8ad8'] +}; + +const KEY = 'papure:settings'; + +function load(): SettingsData { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return structuredClone(DEFAULTS); + const saved = JSON.parse(raw); + return { + ...DEFAULTS, + ...saved, + palm: { ...DEFAULTS.palm, ...saved.palm }, + github: { ...DEFAULTS.github, ...saved.github } + }; + } catch { + return structuredClone(DEFAULTS); + } +} + +class Settings { + data = $state(load()); + private systemDark = $state(false); + + constructor() { + if (typeof window === 'undefined') return; + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + this.systemDark = mq.matches; + mq.addEventListener('change', (e) => (this.systemDark = e.matches)); + } + + get dark() { + return this.data.theme === 'dark' || (this.data.theme === 'system' && this.systemDark); + } + + get githubReady() { + const g = this.data.github; + return !!(g.token && g.owner && g.repo && g.branch); + } + + save() { + try { + localStorage.setItem(KEY, JSON.stringify($state.snapshot(this.data))); + } catch { + // Storage unavailable (private mode) — settings last for this session only. + } + } + + toggleTheme() { + this.data.theme = this.dark ? 'light' : 'dark'; + this.save(); + } +} + +export const settings = new Settings(); diff --git a/src/lib/state/tools.svelte.ts b/src/lib/state/tools.svelte.ts new file mode 100644 index 0000000..c7102de --- /dev/null +++ b/src/lib/state/tools.svelte.ts @@ -0,0 +1,43 @@ +import type { PageData } from '$lib/model/types'; +import { settings } from './settings.svelte'; + +export type Tool = 'text' | 'pen' | 'highlighter' | 'eraser'; + +export const PEN_WIDTHS = [1.2, 2, 3.5]; +export const HIGHLIGHTER_WIDTHS = [10, 16, 24]; + +class Tools { + tool = $state('text'); + penColor = $state(settings.data.penColors[0]); + penWidth = $state(PEN_WIDTHS[1]); + highlighterColor = $state(settings.data.highlighterColors[0]); + highlighterWidth = $state(HIGHLIGHTER_WIDTHS[1]); + eraserRadius = $state(8); + + /** Pages of an imported PDF waiting to be placed on the canvas. */ + placing = $state.raw<{ name: string; pages: PageData[] } | null>(null); + + get inking() { + return this.tool !== 'text'; + } + + get color() { + return this.tool === 'highlighter' ? this.highlighterColor : this.penColor; + } + + set color(c: string) { + if (this.tool === 'highlighter') this.highlighterColor = c; + else this.penColor = c; + } + + get width() { + return this.tool === 'highlighter' ? this.highlighterWidth : this.penWidth; + } + + set width(w: number) { + if (this.tool === 'highlighter') this.highlighterWidth = w; + else this.penWidth = w; + } +} + +export const tools = new Tools(); diff --git a/src/lib/state/viewport.svelte.ts b/src/lib/state/viewport.svelte.ts new file mode 100644 index 0000000..f45696f --- /dev/null +++ b/src/lib/state/viewport.svelte.ts @@ -0,0 +1,59 @@ +// Pan/zoom of the canvas view. World units are PDF points. + +import type { Rect } from '$lib/model/layout'; + +export const MIN_SCALE = 0.04; +export const MAX_SCALE = 8; + +class Viewport { + /** Screen position of the world origin, in CSS px. */ + x = $state(0); + y = $state(0); + scale = $state(1); + /** Size of the stage element. */ + width = $state(0); + height = $state(0); + + toWorld(sx: number, sy: number) { + return { x: (sx - this.x) / this.scale, y: (sy - this.y) / this.scale }; + } + + toScreen(wx: number, wy: number) { + return { x: wx * this.scale + this.x, y: wy * this.scale + this.y }; + } + + /** The visible world rectangle. */ + get world(): Rect { + return { x: -this.x / this.scale, y: -this.y / this.scale, width: this.width / this.scale, height: this.height / this.scale }; + } + + zoomAt(factor: number, sx = this.width / 2, sy = this.height / 2) { + const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, this.scale * factor)); + const w = this.toWorld(sx, sy); + this.scale = next; + this.x = sx - w.x * next; + this.y = sy - w.y * next; + } + + panBy(dx: number, dy: number) { + this.x += dx; + this.y += dy; + } + + /** Fit a world rectangle into the stage with some padding. */ + fit(r: Rect, pad = 24) { + if (!this.width || !this.height) return; + const s = Math.min((this.width - 2 * pad) / r.width, (this.height - 2 * pad) / r.height); + this.scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, s)); + this.x = this.width / 2 - (r.x + r.width / 2) * this.scale; + this.y = this.height / 2 - (r.y + r.height / 2) * this.scale; + } + + /** Pan so the rectangle is centred, keeping the zoom. */ + center(r: Rect) { + this.x = this.width / 2 - (r.x + r.width / 2) * this.scale; + this.y = this.height / 2 - (r.y + r.height / 2) * this.scale; + } +} + +export const viewport = new Viewport(); diff --git a/src/lib/state/workspace.svelte.ts b/src/lib/state/workspace.svelte.ts new file mode 100644 index 0000000..4d10f4c --- /dev/null +++ b/src/lib/state/workspace.svelte.ts @@ -0,0 +1,95 @@ +// App-level state: which canvas is open, autosave, sidebar, notifications. + +import { CanvasDoc } from './doc.svelte'; +import { vault } from '$lib/storage/vault.svelte'; +import { kvGet, kvSet } from '$lib/storage/db'; +import { sync } from '$lib/sync/sync.svelte'; + +export type Panel = 'files' | 'search'; + +const AUTOSAVE_MS = 700; + +class Workspace { + doc = $state.raw(null); + panel = $state('files'); + settingsOpen = $state(false); + toasts = $state<{ id: number; text: string; kind: 'info' | 'error' }[]>([]); + /** Page to reveal once the open canvas is laid out (search hits). */ + focusPage = $state(null); + + private saveTimer: ReturnType | null = null; + private savedVersion = 0; + private toastId = 0; + + async init() { + await vault.init(); + sync.hooks = { + beforeSync: () => this.flush(), + afterPull: async (changed) => { + const path = this.doc?.path; + if (!path || !changed.includes(path)) return; + if (vault.files.some((f) => f.path === path)) await this.open(path, { keepView: true }); + else this.close(); + } + }; + const last = await kvGet('lastOpen'); + if (last && vault.files.some((f) => f.path === last)) await this.open(last); + } + + async open(path: string, opts: { keepView?: boolean; page?: string } = {}) { + if (this.doc?.path === path && !opts.keepView) { + if (opts.page) this.focusPage = opts.page; + return; + } + await this.flush(); + const data = await vault.loadDoc(path); + if (!data) return this.toast(`Could not open ${path}`, 'error'); + const doc = new CanvasDoc(path, data); + this.savedVersion = 0; + this.doc = doc; + this.focusPage = opts.page ?? null; + await kvSet('lastOpen', path); + } + + /** Close without saving (used after the open canvas was deleted). */ + close() { + if (this.saveTimer) clearTimeout(this.saveTimer); + this.saveTimer = null; + this.doc = null; + void kvSet('lastOpen', null); + } + + /** Called by the canvas view whenever doc.version changes. */ + scheduleSave() { + if (this.saveTimer) clearTimeout(this.saveTimer); + this.saveTimer = setTimeout(() => void this.flush(), AUTOSAVE_MS); + } + + async flush() { + if (this.saveTimer) clearTimeout(this.saveTimer); + this.saveTimer = null; + const doc = this.doc; + if (!doc || doc.version === this.savedVersion) return; + const version = doc.version; + await vault.saveDoc(doc.path, doc.toData()); + if (this.doc === doc) this.savedVersion = version; + } + + /** Keep the open document pointed at its new path after a rename/move. */ + async renamed(from: string, to: string) { + const doc = this.doc; + if (!doc) return; + if (doc.path === from || doc.path.startsWith(from + '/')) { + doc.path = to + doc.path.slice(from.length); + await kvSet('lastOpen', doc.path); + } + } + + toast(text: string, kind: 'info' | 'error' = 'info') { + const id = ++this.toastId; + this.toasts.push({ id, text, kind }); + setTimeout(() => (this.toasts = this.toasts.filter((t) => t.id !== id)), kind === 'error' ? 6000 : 3000); + } +} + +export const workspace = new Workspace(); diff --git a/src/lib/storage/db.ts b/src/lib/storage/db.ts new file mode 100644 index 0000000..27a5f2e --- /dev/null +++ b/src/lib/storage/db.ts @@ -0,0 +1,53 @@ +// IndexedDB persistence (offline-first local working copy). + +import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; +import type { CanvasData } from '$lib/model/types'; + +export interface FileRecord { + /** Repo-relative path, e.g. "school/math/lecture2.pdf" or "school/math" for a folder. */ + path: string; + kind: 'canvas' | 'folder'; + updatedAt: number; + /** Git blob sha of the version last pulled/pushed. */ + remoteSha?: string; + /** Local changes not yet pushed. */ + dirty: boolean; + /** Tombstone: deleted locally, deletion not yet pushed. */ + deleted?: boolean; +} + +export interface SearchRecord { + path: string; + pages: { id: string; text: string; origin?: string }[]; +} + +interface Schema extends DBSchema { + files: { key: string; value: FileRecord }; + docs: { key: string; value: CanvasData }; + sources: { key: string; value: Uint8Array }; + search: { key: string; value: SearchRecord }; + kv: { key: string; value: unknown }; +} + +let dbp: Promise> | null = null; + +export function db() { + dbp ??= openDB('papure', 1, { + upgrade(d) { + d.createObjectStore('files', { keyPath: 'path' }); + d.createObjectStore('docs'); + d.createObjectStore('sources'); + d.createObjectStore('search', { keyPath: 'path' }); + d.createObjectStore('kv'); + } + }); + return dbp; +} + +export async function kvGet(key: string): Promise { + return (await (await db()).get('kv', key)) as T | undefined; +} + +export async function kvSet(key: string, value: unknown) { + await (await db()).put('kv', value, key); +} diff --git a/src/lib/storage/vault.svelte.ts b/src/lib/storage/vault.svelte.ts new file mode 100644 index 0000000..175e49d --- /dev/null +++ b/src/lib/storage/vault.svelte.ts @@ -0,0 +1,256 @@ +// The local vault: file tree of canvases and folders, their documents, +// imported source PDFs and the search index. + +import { db, type FileRecord, type SearchRecord } from './db'; +import { A4, newId, type CanvasData } from '$lib/model/types'; +import { withTrunk, flatten } from '$lib/model/tree'; +import { canvasToPdf } from '$lib/pdf/write'; +import { readCanvasPdf } from '$lib/pdf/read'; + +export const EXT = '.pdf'; + +export const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1); +export const dirName = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''); +export const displayName = (path: string) => baseName(path).replace(/\.pdf$/i, ''); +export const joinPath = (dir: string, name: string) => (dir ? `${dir}/${name}` : name); + +export function blankCanvas(): CanvasData { + const id = newId(); + return { + version: 1, + updatedAt: Date.now(), + tree: withTrunk([id]), + pages: { [id]: { id, ...A4, markdown: '', strokes: [] } } + }; +} + +export function sanitizeName(name: string) { + return name.replace(/[\\/:*?"<>|]/g, '-').trim(); +} + +class Vault { + /** Live (non-deleted) records. */ + files = $state([]); + ready = $state(false); + + async init() { + const all = await (await db()).getAll('files'); + this.files = all.filter((f) => !f.deleted); + this.ready = true; + } + + get canvases() { + return this.files.filter((f) => f.kind === 'canvas'); + } + + /** Every folder, explicit or implied by a canvas path. */ + get folders(): string[] { + const set = new Set(); + for (const f of this.files) { + const parts = f.path.split('/'); + const upto = f.kind === 'folder' ? parts.length : parts.length - 1; + for (let i = 1; i <= upto; i++) set.add(parts.slice(0, i).join('/')); + } + return [...set].sort(); + } + + exists(path: string) { + return this.files.some((f) => f.path === path) || this.folders.includes(path); + } + + uniquePath(dir: string, name: string, ext = '') { + let candidate = joinPath(dir, name + ext); + for (let i = 2; this.exists(candidate); i++) candidate = joinPath(dir, `${name} ${i}${ext}`); + return candidate; + } + + private async putRecord(rec: FileRecord) { + const d = await db(); + const prev = await d.get('files', rec.path); + // Re-creating a path whose deletion was never pushed keeps its remote sha. + if (prev?.remoteSha && !rec.remoteSha) rec.remoteSha = prev.remoteSha; + await d.put('files', rec); + const i = this.files.findIndex((f) => f.path === rec.path); + if (rec.deleted) { + if (i >= 0) this.files.splice(i, 1); + } else if (i >= 0) this.files[i] = rec; + else this.files.push(rec); + } + + async createFolder(dir: string, name = 'New folder') { + const path = this.uniquePath(dir, sanitizeName(name) || 'New folder'); + await this.putRecord({ path, kind: 'folder', updatedAt: Date.now(), dirty: true }); + return path; + } + + async createCanvas(dir: string, name = 'Untitled', data = blankCanvas()) { + const path = this.uniquePath(dir, sanitizeName(name) || 'Untitled', EXT); + await this.saveDoc(path, data); + return path; + } + + async importPdfAsCanvas(dir: string, file: File) { + const bytes = new Uint8Array(await file.arrayBuffer()); + const { data, sources } = await readCanvasPdf(bytes, file.name); + for (const [id, b] of sources) await this.putSource(id, b); + return this.createCanvas(dir, file.name.replace(/\.pdf$/i, ''), data); + } + + async loadDoc(path: string): Promise { + return (await db()).get('docs', path); + } + + /** Autosave: store the working copy and mark it for the next push. */ + async saveDoc(path: string, data: CanvasData, opts: { remoteSha?: string; clean?: boolean } = {}) { + const d = await db(); + await d.put('docs', $state.snapshot(data) as CanvasData, path); + await d.put('search', searchRecord(path, data)); + const prev = this.files.find((f) => f.path === path); + await this.putRecord({ + path, + kind: 'canvas', + updatedAt: data.updatedAt, + remoteSha: opts.remoteSha ?? prev?.remoteSha, + dirty: !opts.clean + }); + } + + async rename(from: string, to: string) { + if (from === to) return; + const d = await db(); + const moving = this.files.filter((f) => f.path === from || f.path.startsWith(from + '/')); + for (const rec of moving) { + const next = to + rec.path.slice(from.length); + if (rec.kind === 'canvas') { + const doc = await d.get('docs', rec.path); + if (doc) { + doc.updatedAt = Date.now(); + await this.saveDoc(next, doc); + } + } else await this.putRecord({ path: next, kind: 'folder', updatedAt: Date.now(), dirty: true }); + await this.forget(rec); + } + // An implied folder (no record) becomes explicit so it survives when empty. + if (moving.length === 0) await this.putRecord({ path: to, kind: 'folder', updatedAt: Date.now(), dirty: true }); + } + + async remove(path: string) { + const doomed = this.files.filter((f) => f.path === path || f.path.startsWith(path + '/')); + for (const rec of doomed) await this.forget(rec); + } + + /** Delete locally; leave a tombstone if the remote has it. */ + private async forget(rec: FileRecord) { + const d = await db(); + await d.delete('docs', rec.path); + await d.delete('search', rec.path); + if (rec.remoteSha) await this.putRecord({ ...rec, deleted: true, dirty: true, updatedAt: Date.now() }); + else { + await d.delete('files', rec.path); + const i = this.files.findIndex((f) => f.path === rec.path); + if (i >= 0) this.files.splice(i, 1); + } + } + + /** Drop a record entirely (after a pushed deletion or a remote deletion). */ + async purge(path: string) { + const d = await db(); + await d.delete('files', path); + await d.delete('docs', path); + await d.delete('search', path); + const i = this.files.findIndex((f) => f.path === path); + if (i >= 0) this.files.splice(i, 1); + } + + /** A folder that exists remotely. */ + async setFolder(path: string, remoteSha: string) { + await this.putRecord({ path, kind: 'folder', updatedAt: Date.now(), remoteSha, dirty: false }); + } + + /** Record which remote version local changes are based on (keeps `dirty`). */ + async setRemoteSha(path: string, remoteSha: string | undefined) { + const d = await db(); + const rec = await d.get('files', path); + if (!rec) return; + const next = { ...rec, remoteSha }; + if (!remoteSha) delete next.remoteSha; + await d.put('files', next); + const i = this.files.findIndex((f) => f.path === path); + if (i >= 0) this.files[i] = next; + } + + async allRecords() { + return (await db()).getAll('files'); + } + + async markClean(path: string, remoteSha: string, ifUpdatedAt?: number) { + const d = await db(); + const rec = await d.get('files', path); + if (!rec) return; + const unchanged = ifUpdatedAt === undefined || rec.updatedAt === ifUpdatedAt; + await this.putRecord({ ...rec, remoteSha, dirty: rec.dirty && !unchanged }); + } + + async getSource(id: string) { + return (await db()).get('sources', id); + } + + async putSource(id: string, bytes: Uint8Array) { + await (await db()).put('sources', bytes, id); + } + + async buildPdf(path: string): Promise { + const data = await this.loadDoc(path); + if (!data) throw new Error(`No such canvas: ${path}`); + return canvasToPdf(data, { title: displayName(path), getSource: (id) => this.getSource(id) }); + } + + /** Store a PDF pulled from the repo as the local working copy. */ + async ingestPdf(path: string, bytes: Uint8Array, remoteSha: string) { + const { data, sources } = await readCanvasPdf(bytes, baseName(path)); + for (const [id, b] of sources) await this.putSource(id, b); + await this.saveDoc(path, data, { remoteSha, clean: true }); + return data; + } + + async search(query: string, limit = 100) { + const q = query.trim().toLowerCase(); + if (!q) return []; + const all = await (await db()).getAll('search'); + const results: { path: string; pageId: string; snippet: string; origin?: string; inName: boolean }[] = []; + for (const rec of all) { + if (this.files.every((f) => f.path !== rec.path)) continue; + const inName = rec.path.toLowerCase().includes(q); + let hit = false; + for (const p of rec.pages) { + const i = p.text.toLowerCase().indexOf(q); + const inOrigin = p.origin?.toLowerCase().includes(q); + if (i < 0 && !inOrigin) continue; + hit = true; + results.push({ path: rec.path, pageId: p.id, snippet: snippet(p.text, i, q.length), origin: p.origin, inName }); + if (results.length >= limit) return results; + } + if (!hit && inName) results.push({ path: rec.path, pageId: '', snippet: '', inName }); + } + return results; + } +} + +function searchRecord(path: string, data: CanvasData): SearchRecord { + return { + path, + pages: flatten(data.tree) + .map((id) => data.pages[id]) + .filter(Boolean) + .map((p) => ({ id: p.id, text: p.markdown, origin: p.origin?.name })) + }; +} + +function snippet(text: string, at: number, len: number) { + if (at < 0) return text.slice(0, 80).replace(/\s+/g, ' '); + const start = Math.max(0, at - 40); + const end = Math.min(text.length, at + len + 60); + return (start > 0 ? '…' : '') + text.slice(start, end).replace(/\s+/g, ' ') + (end < text.length ? '…' : ''); +} + +export const vault = new Vault(); diff --git a/src/lib/sync/github.ts b/src/lib/sync/github.ts new file mode 100644 index 0000000..b1a7cd2 --- /dev/null +++ b/src/lib/sync/github.ts @@ -0,0 +1,133 @@ +// Minimal GitHub REST client (Git Data API) used for sync. + +import type { GitHubSettings } from '$lib/state/settings.svelte'; + +export class GitHubError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + } +} + +export interface TreeEntry { + path: string; + type: 'blob' | 'tree' | 'commit'; + sha: string; + mode: string; +} + +/** Git's blob sha for an empty file (used for folder `.gitkeep`s). */ +export const EMPTY_BLOB_SHA = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'; + +export class GitHub { + constructor(private s: GitHubSettings) {} + + private async req(method: string, path: string, body?: unknown): Promise { + const url = path.startsWith('https://') ? path : `https://api.github.com/repos/${this.s.owner}/${this.s.repo}${path}`; + const res = await fetch(url, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${this.s.token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(body ? { 'Content-Type': 'application/json' } : {}) + }, + body: body ? JSON.stringify(body) : undefined, + cache: 'no-store' + }); + if (!res.ok) { + let msg = res.statusText; + try { + msg = (await res.json()).message ?? msg; + } catch { + /* not json */ + } + throw new GitHubError(`GitHub ${method} ${path}: ${res.status} ${msg}`, res.status); + } + return res.status === 204 ? (undefined as T) : res.json(); + } + + async checkAccess() { + const repo = await this.req<{ permissions?: { push?: boolean }; default_branch: string }>('GET', ''); + if (repo.permissions && !repo.permissions.push) throw new Error('The token has no push access to this repository.'); + return repo; + } + + /** Head commit sha of the branch, or null if the branch (or repo) is empty. */ + async head(branch = this.s.branch): Promise { + try { + const ref = await this.req<{ object: { sha: string } }>('GET', `/git/ref/heads/${enc(branch)}`); + return ref.object.sha; + } catch (e) { + if (e instanceof GitHubError && (e.status === 404 || e.status === 409)) return null; + throw e; + } + } + + async commit(sha: string) { + return this.req<{ sha: string; tree: { sha: string } }>('GET', `/git/commits/${sha}`); + } + + async tree(sha: string): Promise { + const t = await this.req<{ tree: TreeEntry[]; truncated: boolean }>('GET', `/git/trees/${sha}?recursive=1`); + if (t.truncated) console.warn('GitHub tree listing truncated; some files may be missing from sync'); + return t.tree; + } + + async blob(sha: string): Promise { + const b = await this.req<{ content: string; encoding: string }>('GET', `/git/blobs/${sha}`); + return fromBase64(b.content.replace(/\n/g, '')); + } + + async createBlob(bytes: Uint8Array): Promise { + const r = await this.req<{ sha: string }>('POST', '/git/blobs', { content: toBase64(bytes), encoding: 'base64' }); + return r.sha; + } + + async createTree(base: string | null, entries: { path: string; sha: string | null }[]): Promise { + const r = await this.req<{ sha: string }>('POST', '/git/trees', { + ...(base ? { base_tree: base } : {}), + tree: entries.map((e) => ({ path: e.path, mode: '100644', type: 'blob', sha: e.sha })) + }); + return r.sha; + } + + async createCommit(message: string, tree: string, parent: string | null): Promise { + const r = await this.req<{ sha: string }>('POST', '/git/commits', { message, tree, parents: parent ? [parent] : [] }); + return r.sha; + } + + /** Move the branch to `sha`. Throws a 422 GitHubError if not a fast-forward. */ + async updateRef(sha: string, create: boolean) { + if (create) await this.req('POST', '/git/refs', { ref: `refs/heads/${this.s.branch}`, sha }); + else await this.req('PATCH', `/git/refs/heads/${enc(this.s.branch)}`, { sha, force: false }); + } + + /** The Git Data API refuses to work on a repo with no commits at all. */ + async initEmptyRepo() { + await this.req('PUT', `/contents/.papure`, { + message: 'Initialise Papure vault', + content: toBase64(new TextEncoder().encode('Papure vault\n')) + }); + } +} + +const enc = (s: string) => s.split('/').map(encodeURIComponent).join('/'); + +export function toBase64(bytes: Uint8Array): string { + let bin = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(bin); +} + +export function fromBase64(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} diff --git a/src/lib/sync/sync.svelte.ts b/src/lib/sync/sync.svelte.ts new file mode 100644 index 0000000..e58e169 --- /dev/null +++ b/src/lib/sync/sync.svelte.ts @@ -0,0 +1,211 @@ +// Repo sync: pull then push. Local edits autosave to IndexedDB continuously +// ("autocommit"); this pushes them on a timer or on demand. Conflicts are +// resolved by timestamp — the newer version wins, no merging. + +import { settings } from '$lib/state/settings.svelte'; +import { vault } from '$lib/storage/vault.svelte'; +import { readCanvasPdf } from '$lib/pdf/read'; +import { GitHub, GitHubError } from './github'; + +export type SyncStatus = 'disabled' | 'idle' | 'syncing' | 'error' | 'offline'; + +interface Hooks { + /** Flush pending autosaves before reading local state. */ + beforeSync?: () => Promise; + /** Called with paths whose local copy was replaced or removed by a pull. */ + afterPull?: (changed: string[]) => Promise | void; +} + +const KEEP = '.gitkeep'; + +class Sync { + status = $state('disabled'); + error = $state(''); + lastSync = $state(null); + private running: Promise | null = null; + private timer: ReturnType | null = null; + hooks: Hooks = {}; + + get pending() { + return vault.files.filter((f) => f.dirty).length; + } + + start() { + this.refreshStatus(); + this.schedule(); + window.addEventListener('online', () => { + this.refreshStatus(); + void this.syncNow(); + }); + window.addEventListener('offline', () => this.refreshStatus()); + if (settings.githubReady) void this.syncNow(); + } + + /** Re-arm the periodic push after settings change. */ + schedule() { + if (this.timer) clearInterval(this.timer); + this.timer = null; + const minutes = settings.data.pushInterval; + if (minutes > 0) { + this.timer = setInterval(() => { + if (this.pending > 0) void this.syncNow(); + }, minutes * 60_000); + } + this.refreshStatus(); + } + + private refreshStatus() { + if (this.status === 'syncing') return; + if (!settings.githubReady) this.status = 'disabled'; + else if (!navigator.onLine) this.status = 'offline'; + else if (this.status !== 'error') this.status = 'idle'; + } + + /** Pull + push. Concurrent calls share one run. */ + syncNow(): Promise { + if (!settings.githubReady || !navigator.onLine) { + this.refreshStatus(); + return Promise.resolve(); + } + this.running ??= this.run().finally(() => (this.running = null)); + return this.running; + } + + private async run() { + this.status = 'syncing'; + this.error = ''; + try { + await this.hooks.beforeSync?.(); + const gh = new GitHub(settings.data.github); + for (let attempt = 0; ; attempt++) { + const head = await this.pull(gh); + try { + await this.push(gh, head); + break; + } catch (e) { + // Someone pushed between our pull and push: pull again, retry once. + if (attempt === 0 && e instanceof GitHubError && e.status === 422) continue; + throw e; + } + } + this.lastSync = Date.now(); + this.status = 'idle'; + } catch (e) { + console.error(e); + this.error = e instanceof Error ? e.message : String(e); + this.status = 'error'; + } + } + + private get prefix() { + const dir = settings.data.github.dir.replace(/^\/+|\/+$/g, ''); + return dir ? dir + '/' : ''; + } + + private async pull(gh: GitHub): Promise { + const head = await gh.head(); + const remote = new Map(); + if (head) { + const commit = await gh.commit(head); + for (const e of await gh.tree(commit.tree.sha)) { + if (e.type !== 'blob' || !e.path.startsWith(this.prefix)) continue; + const rel = e.path.slice(this.prefix.length); + if (/\.pdf$/i.test(rel)) remote.set(rel, { sha: e.sha, kind: 'canvas' }); + else if (rel.endsWith('/' + KEEP)) remote.set(rel.slice(0, -KEEP.length - 1), { sha: e.sha, kind: 'folder' }); + } + } + + const local = new Map((await vault.allRecords()).map((r) => [r.path, r])); + const changed: string[] = []; + + for (const [path, r] of remote) { + const rec = local.get(path); + if (rec?.remoteSha === r.sha) continue; + if (r.kind === 'folder') { + if (!rec || !rec.dirty) await vault.setFolder(path, r.sha); + else await vault.setRemoteSha(path, r.sha); + continue; + } + if (!rec || !rec.dirty) { + await vault.ingestPdf(path, await gh.blob(r.sha), r.sha); + changed.push(path); + continue; + } + // Conflict: changed on both sides. Newer wins. + const bytes = await gh.blob(r.sha); + const remoteUpdated = (await readCanvasPdf(bytes, path)).data.updatedAt; + if (remoteUpdated > rec.updatedAt) { + await vault.ingestPdf(path, bytes, r.sha); + changed.push(path); + } else { + await vault.setRemoteSha(path, r.sha); // keep ours; push overwrites + } + } + + for (const [path, rec] of local) { + if (remote.has(path) || !rec.remoteSha) continue; + // Was synced before, now gone remotely. + if (!rec.dirty || rec.deleted) { + await vault.purge(path); + changed.push(path); + } else { + // Edited here, deleted there: keep the edit, push it as a new file. + await vault.setRemoteSha(path, undefined); + } + } + + if (changed.length) await this.hooks.afterPull?.(changed); + return head; + } + + private async push(gh: GitHub, head: string | null) { + const dirty = (await vault.allRecords()).filter((r) => r.dirty); + if (dirty.length === 0) return; + + if (!head) { + const repo = await gh.checkAccess(); + // The Git Data API can't write to a repo with no commits: seed one. + if (!(await gh.head(repo.default_branch))) await gh.initEmptyRepo(); + // If the configured branch still doesn't exist, it's created below + // from an orphan commit. + head = await gh.head(); + } + const baseTree = head ? (await gh.commit(head)).tree.sha : null; + + const entries: { path: string; sha: string | null }[] = []; + const done: { path: string; sha: string | null; updatedAt: number }[] = []; + let emptyBlob: string | null = null; + + for (const rec of dirty) { + const repoPath = this.prefix + (rec.kind === 'folder' ? `${rec.path}/${KEEP}` : rec.path); + if (rec.deleted) { + if (rec.remoteSha) entries.push({ path: repoPath, sha: null }); + done.push({ path: rec.path, sha: null, updatedAt: rec.updatedAt }); + continue; + } + let sha: string; + if (rec.kind === 'folder') sha = emptyBlob ??= await gh.createBlob(new Uint8Array()); + else sha = await gh.createBlob(await vault.buildPdf(rec.path)); + entries.push({ path: repoPath, sha }); + done.push({ path: rec.path, sha, updatedAt: rec.updatedAt }); + } + + if (entries.length) { + const tree = await gh.createTree(baseTree, entries); + const names = done.map((d) => d.path); + const message = + names.length === 1 + ? `Update ${names[0]}` + : `Update ${names.length} files\n\n${names.map((n) => `- ${n}`).join('\n')}`; + const commit = await gh.createCommit(message, tree, head); + await gh.updateRef(commit, head === null); + } + + for (const d of done) { + if (d.sha === null) await vault.purge(d.path); + else await vault.markClean(d.path, d.sha, d.updatedAt); + } + } +} + +export const sync = new Sync(); diff --git a/src/lib/sync/sync.test.ts b/src/lib/sync/sync.test.ts new file mode 100644 index 0000000..08095d4 --- /dev/null +++ b/src/lib/sync/sync.test.ts @@ -0,0 +1,227 @@ +// Sync against an in-memory fake of the GitHub Git Data API, with each +// "device" getting its own IndexedDB and fresh module instances. + +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ---- fake GitHub ------------------------------------------------------------ + +class FakeRepo { + blobs = new Map(); + trees = new Map>(); + commits = new Map(); + head: string | null = null; + private n = 0; + + id(prefix: string) { + return `${prefix}${++this.n}`.padEnd(40, '0'); + } + + files() { + if (!this.head) return new Map(); + return this.trees.get(this.commits.get(this.head)!.tree)!; + } + + text(path: string) { + const sha = this.files().get(path); + return sha ? this.blobs.get(sha) : undefined; + } + + commitFiles(files: Map) { + const tree = this.id('t'); + this.trees.set(tree, files); + const sha = this.id('c'); + this.commits.set(sha, { tree, parents: this.head ? [this.head] : [] }); + this.head = sha; + } + + fetch = async (input: string | URL, init?: RequestInit): Promise => { + const url = new URL(String(input)); + const path = url.pathname.replace('/repos/me/notes', ''); + const method = init?.method ?? 'GET'; + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + const json = (v: unknown, status = 200) => new Response(JSON.stringify(v), { status }); + const notFound = () => json({ message: 'Not Found' }, 404); + + if (path === '' && method === 'GET') return json({ permissions: { push: true }, default_branch: 'main' }); + if (path === '/git/ref/heads/main') return this.head ? json({ object: { sha: this.head } }) : notFound(); + let m; + if ((m = /^\/git\/commits\/(\w+)$/.exec(path))) { + const c = this.commits.get(m[1]); + return c ? json({ sha: m[1], tree: { sha: c.tree } }) : notFound(); + } + if ((m = /^\/git\/trees\/(\w+)$/.exec(path))) { + const t = this.trees.get(m[1])!; + return json({ tree: [...t].map(([p, sha]) => ({ path: p, sha, type: 'blob', mode: '100644' })), truncated: false }); + } + if ((m = /^\/git\/blobs\/(\w+)$/.exec(path))) { + return json({ content: Buffer.from(this.blobs.get(m[1])!).toString('base64'), encoding: 'base64' }); + } + if (path === '/git/blobs' && method === 'POST') { + const bytes = new Uint8Array(Buffer.from(body.content, 'base64')); + const sha = createHash('sha1').update(bytes).digest('hex'); + this.blobs.set(sha, bytes); + return json({ sha }, 201); + } + if (path === '/git/trees' && method === 'POST') { + const files = new Map(body.base_tree ? this.trees.get(body.base_tree) : []); + for (const e of body.tree) { + if (e.sha === null) { + if (!files.has(e.path)) return json({ message: 'GitRPC::BadObjectState' }, 422); + files.delete(e.path); + } else { + if (!this.blobs.has(e.sha)) return json({ message: 'bad sha' }, 422); + files.set(e.path, e.sha); + } + } + const sha = this.id('t'); + this.trees.set(sha, files); + return json({ sha }, 201); + } + if (path === '/git/commits' && method === 'POST') { + const sha = this.id('c'); + this.commits.set(sha, { tree: body.tree, parents: body.parents }); + return json({ sha }, 201); + } + if (path === '/git/refs/heads/main' && method === 'PATCH') { + const parent = this.commits.get(body.sha)!.parents[0]; + if (parent !== this.head) return json({ message: 'Update is not a fast forward' }, 422); + this.head = body.sha; + return json({}); + } + if (path === '/git/refs' && method === 'POST') { + this.head = body.sha; + return json({}, 201); + } + if (path === '/contents/.papure' && method === 'PUT') { + const bytes = new Uint8Array(Buffer.from(body.content, 'base64')); + const sha = createHash('sha1').update(bytes).digest('hex'); + this.blobs.set(sha, bytes); + this.commitFiles(new Map([['.papure', sha]])); + return json({}, 201); + } + return json({ message: `unhandled ${method} ${path}` }, 500); + }; +} + +// ---- devices ---------------------------------------------------------------- + +let repo: FakeRepo; + +async function device() { + vi.resetModules(); + globalThis.indexedDB = new IDBFactory(); + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k) + }); + vi.stubGlobal('navigator', { onLine: true }); + vi.stubGlobal('fetch', repo.fetch); + + const { setFontLoader } = await import('$lib/pdf/fonts'); + setFontLoader(async (f) => new Uint8Array(await readFile(`static/fonts/${f}`))); + const { settings } = await import('$lib/state/settings.svelte'); + settings.data.github = { token: 't', owner: 'me', repo: 'notes', branch: 'main', dir: 'vault' }; + const { vault, blankCanvas } = await import('$lib/storage/vault.svelte'); + const { sync } = await import('./sync.svelte'); + await vault.init(); + + const write = async (path: string, markdown: string, updatedAt = Date.now()) => { + const data = (await vault.loadDoc(path)) ?? blankCanvas(); + const first = data.tree.trunk[0]; + data.pages[first].markdown = markdown; + data.updatedAt = updatedAt; + await vault.saveDoc(path, data); + }; + const read = async (path: string) => { + const d = await vault.loadDoc(path); + return d && d.pages[d.tree.trunk[0]].markdown; + }; + const run = async () => { + await sync.syncNow(); + if (sync.status === 'error') throw new Error(sync.error); + }; + return { vault, sync, write, read, run }; +} + +beforeEach(() => { + repo = new FakeRepo(); +}); + +describe('sync', () => { + it('pushes into an empty repo and pulls on another device', async () => { + const a = await device(); + await a.vault.createFolder('', 'school'); + await a.write('school/lecture1.pdf', '# Hello'); + await a.run(); + expect([...repo.files().keys()].sort()).toEqual(['.papure', 'vault/school/.gitkeep', 'vault/school/lecture1.pdf']); + expect(a.sync.pending).toBe(0); + + const b = await device(); + await b.run(); + expect(await b.read('school/lecture1.pdf')).toBe('# Hello'); + expect(b.vault.folders).toContain('school'); + }); + + it('newer version wins a conflict', async () => { + const a = await device(); + await a.write('n.pdf', 'base', 1000); + await a.run(); + const b = await device(); + await b.run(); + + await a.write('n.pdf', 'from A (older)', 2000); + await b.write('n.pdf', 'from B (newer)', 3000); + await b.run(); + await a.run(); // A pulls B's newer version, its own edit loses + expect(await a.read('n.pdf')).toBe('from B (newer)'); + expect(a.sync.pending).toBe(0); + + // And the other way round: local newer than remote → local is pushed. + await b.write('n.pdf', 'B again', 4000); + await b.run(); + await a.write('n.pdf', 'A newest', 5000); + await a.run(); + await b.run(); + expect(await b.read('n.pdf')).toBe('A newest'); + }); + + it('propagates deletions and renames', async () => { + const a = await device(); + await a.write('x.pdf', 'x'); + await a.write('y.pdf', 'y'); + await a.run(); + const b = await device(); + await b.run(); + + await a.vault.remove('x.pdf'); + await a.vault.rename('y.pdf', 'z.pdf'); + await a.run(); + expect([...repo.files().keys()].filter((p) => p.endsWith('.pdf')).sort()).toEqual(['vault/z.pdf']); + + await b.run(); + expect(b.vault.canvases.map((f) => f.path)).toEqual(['z.pdf']); + expect(await b.read('z.pdf')).toBe('y'); + }); + + it('retries when someone else pushed in between', async () => { + const a = await device(); + await a.write('a.pdf', 'a'); + await a.run(); + const b = await device(); + await b.run(); + await b.write('b.pdf', 'b'); + await a.write('a.pdf', 'a2'); + await b.run(); + await a.run(); + const b2 = await device(); + await b2.run(); + expect(await b2.read('a.pdf')).toBe('a2'); + expect(await b2.read('b.pdf')).toBe('b'); + }); +}); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..b93e9ba --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..bd6b80e --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1,3 @@ +// The app is fully client-side (IndexedDB, pointer input, PDF rendering). +export const ssr = false; +export const prerender = false; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..424a635 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/service-worker.ts b/src/service-worker.ts new file mode 100644 index 0000000..0927424 --- /dev/null +++ b/src/service-worker.ts @@ -0,0 +1,61 @@ +/// +/// +/// +/// + +// Offline-first: the app shell and static assets are cached on install. +// GitHub API calls are never cached (sync needs live data). + +import { build, files, version } from '$service-worker'; + +const sw = self as unknown as ServiceWorkerGlobalScope; +const CACHE = `papure-${version}`; +const ASSETS = [...build, ...files, '/']; + +sw.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CACHE) + .then((c) => c.addAll(ASSETS)) + .then(() => sw.skipWaiting()) + ); +}); + +sw.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => sw.clients.claim()) + ); +}); + +sw.addEventListener('fetch', (event) => { + const req = event.request; + if (req.method !== 'GET') return; + const url = new URL(req.url); + if (url.origin !== sw.location.origin) return; + + // Navigations: network first, fall back to the cached shell (SPA). + if (req.mode === 'navigate') { + event.respondWith( + fetch(req).catch(async () => (await caches.match('/')) ?? Response.error()) + ); + return; + } + + // Assets: cache first (they're versioned or fixed). + event.respondWith( + caches.match(req).then( + (hit) => + hit ?? + fetch(req).then((res) => { + if (res.ok && ASSETS.includes(url.pathname)) { + const copy = res.clone(); + void caches.open(CACHE).then((c) => c.put(req, copy)); + } + return res; + }) + ) + ); +}); diff --git a/static/fonts/Inter_400Regular.ttf b/static/fonts/Inter_400Regular.ttf new file mode 100644 index 0000000..ec0cd8e Binary files /dev/null and b/static/fonts/Inter_400Regular.ttf differ diff --git a/static/fonts/Inter_400Regular_Italic.ttf b/static/fonts/Inter_400Regular_Italic.ttf new file mode 100644 index 0000000..adf13ce Binary files /dev/null and b/static/fonts/Inter_400Regular_Italic.ttf differ diff --git a/static/fonts/Inter_600SemiBold.ttf b/static/fonts/Inter_600SemiBold.ttf new file mode 100644 index 0000000..25ebbc6 Binary files /dev/null and b/static/fonts/Inter_600SemiBold.ttf differ diff --git a/static/fonts/Inter_700Bold.ttf b/static/fonts/Inter_700Bold.ttf new file mode 100644 index 0000000..ac569cc Binary files /dev/null and b/static/fonts/Inter_700Bold.ttf differ diff --git a/static/fonts/Inter_700Bold_Italic.ttf b/static/fonts/Inter_700Bold_Italic.ttf new file mode 100644 index 0000000..9985282 Binary files /dev/null and b/static/fonts/Inter_700Bold_Italic.ttf differ diff --git a/static/fonts/JetBrainsMono_400Regular.ttf b/static/fonts/JetBrainsMono_400Regular.ttf new file mode 100644 index 0000000..ad3baec Binary files /dev/null and b/static/fonts/JetBrainsMono_400Regular.ttf differ diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000..eca770a Binary files /dev/null and b/static/icon-192.png differ diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000..268769f Binary files /dev/null and b/static/icon-512.png differ diff --git a/static/icon-maskable.png b/static/icon-maskable.png new file mode 100644 index 0000000..ea4e006 Binary files /dev/null and b/static/icon-maskable.png differ diff --git a/static/icon.svg b/static/icon.svg new file mode 100644 index 0000000..9f7ebef --- /dev/null +++ b/static/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..facfd6b --- /dev/null +++ b/static/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Papure", + "short_name": "Papure", + "description": "Lecture notes: markdown and ink on a tree of PDF pages.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#1e1e1e", + "theme_color": "#1e1e1e", + "icons": [ + { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }, + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ], + "file_handlers": [ + { "action": "/", "accept": { "application/pdf": [".pdf"] } } + ] +} diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..57e6b52 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + // Pure client-side PWA: everything is rendered in the browser. + adapter: adapter({ fallback: 'index.html' }), + serviceWorker: { register: true } + } +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a8f10c8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..3f382a5 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + include: ['src/**/*.test.ts'], + environment: 'node' + } +});