This commit is contained in:
Reudy 2026-09-26 19:13:02 +02:00
commit 0ad405c26e
78 changed files with 9127 additions and 0 deletions

View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebSearch"
]
}
}

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
/.svelte-kit
/build
.DS_Store
.tmp

31
README.md Normal file
View file

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
memos/memos/memos_prod.db Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

2158
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

41
package.json Normal file
View file

@ -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"
}
}

339
palm-rejection-test.html Normal file
View file

@ -0,0 +1,339 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Palm Rejection Test</title>
<style>
:root {
--bg: #f7f7f5;
--panel: #ffffff;
--ink: #1a1a1a;
--accent: #2563eb;
--reject: #dc2626;
--muted: #6b7280;
--border: #e5e7eb;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #17181a;
--panel: #1f2023;
--ink: #f0f0f0;
--border: #33353a;
--muted: #9aa0aa;
}
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--ink);
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
display: flex;
flex-direction: column;
min-height: 100%;
}
header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
header h1 { font-size: 16px; margin: 0 0 4px; }
header p { font-size: 13px; color: var(--muted); margin: 0; }
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
padding: 10px 18px;
background: var(--panel);
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.stage {
position: relative;
flex: 1;
overflow: hidden;
touch-action: none;
}
canvas {
display: block;
width: 100%;
height: 100%;
background: var(--panel);
}
.hud {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0,0,0,0.75);
color: #fff;
font-family: ui-monospace, Consolas, monospace;
font-size: 12px;
padding: 10px 12px;
border-radius: 8px;
line-height: 1.5;
min-width: 220px;
pointer-events: none;
}
.hud .accepted { color: #4ade80; }
.hud .rejected { color: #f87171; }
.toolbar {
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
background: var(--panel);
border: 1px solid var(--border);
padding: 6px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
button {
border: none;
background: transparent;
color: var(--ink);
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
button:hover { background: var(--border); }
button.active { background: var(--accent); color: white; }
.log {
position: absolute;
bottom: 14px;
left: 14px;
max-width: 260px;
max-height: 160px;
overflow-y: auto;
background: rgba(0,0,0,0.75);
color: #ddd;
font-family: ui-monospace, Consolas, monospace;
font-size: 11px;
padding: 8px 10px;
border-radius: 8px;
pointer-events: none;
}
.log div { white-space: nowrap; }
</style>
</head>
<body>
<header>
<h1>Palm Rejection Test</h1>
<p>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.</p>
</header>
<div class="controls">
<label><input type="checkbox" id="optType" checked> Filter by pointerType (pen only)</label>
<label><input type="checkbox" id="optGeometry" checked> Reject wide-contact touches</label>
<label><input type="checkbox" id="optTiming" checked> Reject touch near pen activity (150ms)</label>
<label><input type="checkbox" id="optHover" checked> Arm lockout on pen hover</label>
</div>
<div class="stage">
<canvas id="c"></canvas>
<div class="hud" id="hud">waiting for input…</div>
<div class="log" id="log"></div>
<div class="toolbar">
<button id="clearBtn">Clear</button>
<button id="rawBtn">Show raw (no rejection)</button>
</div>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const hud = document.getElementById('hud');
const logEl = document.getElementById('log');
const optType = document.getElementById('optType');
const optGeometry = document.getElementById('optGeometry');
const optTiming = document.getElementById('optTiming');
const optHover = document.getElementById('optHover');
const rawBtn = document.getElementById('rawBtn');
let rawMode = false;
rawBtn.addEventListener('click', () => {
rawMode = !rawMode;
rawBtn.classList.toggle('active', rawMode);
optType.disabled = optGeometry.disabled = optTiming.disabled = optHover.disabled = rawMode;
});
function resize() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
resize();
window.addEventListener('resize', () => {
// preserve drawing by not clearing on resize where possible is nontrivial; keep simple
resize();
});
document.getElementById('clearBtn').addEventListener('click', () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
});
// --- Palm rejection state ---
let lastPenActivityTime = 0; // timestamp of most recent pen contact/hover
let penIsDown = false; // is a pen stroke currently in progress
const activePenStrokes = new Map(); // pointerId -> true, for pen pointers currently drawing
const activeTouchStrokes = new Map(); // pointerId -> {x,y,accepted}
let accepted = 0, rejected = 0;
function log(msg) {
const line = document.createElement('div');
line.textContent = msg;
logEl.prepend(line);
while (logEl.children.length > 30) logEl.removeChild(logEl.lastChild);
}
function updateHud(extra) {
hud.innerHTML =
`pointerType: <b>${extra?.pointerType ?? '-'}</b><br>` +
`pressure: ${extra?.pressure?.toFixed(2) ?? '-'}<br>` +
`contact w/h: ${extra?.width?.toFixed(0) ?? '-'} x ${extra?.height?.toFixed(0) ?? '-'}<br>` +
`<span class="accepted">accepted: ${accepted}</span> &nbsp; <span class="rejected">rejected: ${rejected}</span>`;
}
updateHud();
// Decide whether a given pointer event should be treated as drawing input
function evaluatePointer(e) {
if (rawMode) return { accept: true, reason: 'raw mode' };
// Strategy 1: type filtering — only 'pen' draws; everything else is a touch candidate
if (e.pointerType === 'pen') {
return { accept: true, reason: 'pen input' };
}
if (e.pointerType !== 'touch') {
// mouse, etc — accept for testing convenience but note it
return { accept: true, reason: 'non-touch pointer' };
}
// From here on we're evaluating a TOUCH pointer as a palm-rejection candidate
if (optType.checked) {
// If strict type filtering is on, touches never draw at all once a pen has been seen recently
if (Date.now() - lastPenActivityTime < 5000 || activePenStrokes.size > 0) {
return { accept: false, reason: 'touch blocked: pen session active' };
}
}
if (optGeometry.checked) {
const w = e.width || 0, h = e.height || 0;
const contactSize = Math.max(w, h);
if (contactSize > 35) { // heuristic threshold in CSS px; palms report large contact ellipses
return { accept: false, reason: `touch blocked: wide contact (${contactSize.toFixed(0)}px)` };
}
}
if (optTiming.checked) {
if (Date.now() - lastPenActivityTime < 150) {
return { accept: false, reason: 'touch blocked: within 150ms of pen activity' };
}
}
return { accept: true, reason: 'touch accepted (no pen nearby)' };
}
function draw(x, y, pressure, startNew, colorOverride) {
ctx.strokeStyle = colorOverride || getComputedStyle(document.documentElement).getPropertyValue('--ink');
ctx.lineWidth = Math.max(1, (pressure || 0.5) * 6);
if (startNew) {
ctx.beginPath();
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y);
}
}
canvas.addEventListener('pointerdown', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left, y = e.clientY - rect.top;
if (e.pointerType === 'pen') {
penIsDown = true;
lastPenActivityTime = Date.now();
activePenStrokes.set(e.pointerId, true);
}
const decision = evaluatePointer(e);
updateHud(e);
if (decision.accept) {
accepted++;
draw(x, y, e.pressure, true);
activeTouchStrokes.set(e.pointerId, { x, y, accepted: true, pointerType: e.pointerType });
if (e.pointerType !== 'pen') log(`✓ ${e.pointerType} down — ${decision.reason}`);
} else {
rejected++;
activeTouchStrokes.set(e.pointerId, { x, y, accepted: false });
log(`✗ ${e.pointerType} down — ${decision.reason}`);
}
});
canvas.addEventListener('pointermove', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left, y = e.clientY - rect.top;
// Hover lockout: pen hovering (buttons==0, not down) still counts as "pen nearby"
if (e.pointerType === 'pen') {
lastPenActivityTime = Date.now();
if (optHover.checked && !penIsDown) {
// just arm the lockout, don't draw
}
}
updateHud(e);
const stroke = activeTouchStrokes.get(e.pointerId);
if (!stroke) return;
if (e.pointerType === 'pen') {
draw(x, y, e.pressure, false);
} else {
// re-evaluate mid-stroke in case pen just started nearby
const decision = evaluatePointer(e);
if (decision.accept && stroke.accepted) {
draw(x, y, e.pressure, false);
} else if (stroke.accepted && !decision.accept) {
// pen showed up mid-touch-stroke: cut it off
log(`✗ touch cut off mid-stroke — ${decision.reason}`);
stroke.accepted = false;
}
}
});
function endStroke(e) {
if (e.pointerType === 'pen') {
activePenStrokes.delete(e.pointerId);
if (activePenStrokes.size === 0) penIsDown = false;
lastPenActivityTime = Date.now();
}
activeTouchStrokes.delete(e.pointerId);
}
canvas.addEventListener('pointerup', endStroke);
canvas.addEventListener('pointercancel', endStroke);
canvas.addEventListener('pointerleave', (e) => {
if (e.pointerType === 'pen') lastPenActivityTime = Date.now();
});
</script>
</body>
</html>

13
scripts/subset-fonts.sh Executable file
View file

@ -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

90
spec.md Normal file
View file

@ -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)

203
src/app.css Normal file
View file

@ -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;
}

6
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {}
}
export {};

17
src/app.html Normal file
View file

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#1e1e1e" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<link rel="icon" href="%sveltekit.assets%/icon.svg" type="image/svg+xml" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon-192.png" />
<title>Papure</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,132 @@
<script lang="ts">
import { onMount } from 'svelte';
import { settings } from '$lib/state/settings.svelte';
import { workspace } from '$lib/state/workspace.svelte';
import { sync } from '$lib/sync/sync.svelte';
import { displayName, vault } from '$lib/storage/vault.svelte';
import Ribbon from './Ribbon.svelte';
import FileExplorer from './FileExplorer.svelte';
import SearchPanel from './SearchPanel.svelte';
import CanvasView from './CanvasView.svelte';
import SettingsModal from './SettingsModal.svelte';
import ContextMenu from './ContextMenu.svelte';
import Toasts from './Toasts.svelte';
import EmptyState from './EmptyState.svelte';
let ready = $state(false);
onMount(() => {
workspace.init().then(() => {
ready = true;
sync.start();
handleLaunchedFiles();
});
const onHide = () => {
if (document.visibilityState === 'hidden') void workspace.flush();
};
document.addEventListener('visibilitychange', onHide);
window.addEventListener('pagehide', onHide);
return () => {
document.removeEventListener('visibilitychange', onHide);
window.removeEventListener('pagehide', onHide);
};
});
/** PDFs opened with the installed app (manifest file_handlers). */
function handleLaunchedFiles() {
const lq = (window as unknown as { launchQueue?: { setConsumer(fn: (p: { files: FileSystemFileHandle[] }) => void): void } }).launchQueue;
lq?.setConsumer(async ({ files }) => {
for (const handle of files) {
const file = await handle.getFile();
const path = await vault.importPdfAsCanvas('', file);
await workspace.open(path);
}
});
}
$effect(() => {
document.documentElement.dataset.theme = settings.dark ? 'dark' : 'light';
});
$effect(() => {
const doc = workspace.doc;
document.title = doc ? `${displayName(doc.path)} — Papure` : 'Papure';
});
function onKey(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 's') {
e.preventDefault();
void workspace.flush().then(() => sync.syncNow());
} else if (mod && e.shiftKey && e.key.toLowerCase() === 'f') {
e.preventDefault();
workspace.panel = 'search';
} else if (mod && e.key === ',') {
e.preventDefault();
workspace.settingsOpen = true;
}
}
</script>
<svelte:window onkeydown={onKey} />
<div class="app">
<Ribbon />
{#if workspace.panel}
<aside class="sidebar">
{#if workspace.panel === 'files'}
<FileExplorer />
{:else}
<SearchPanel />
{/if}
</aside>
{/if}
<main>
{#if ready && workspace.doc}
{#key workspace.doc}
<CanvasView doc={workspace.doc} />
{/key}
{:else if ready}
<EmptyState />
{/if}
</main>
</div>
{#if workspace.settingsOpen}
<SettingsModal />
{/if}
<ContextMenu />
<Toasts />
<style>
.app {
display: flex;
height: 100dvh;
width: 100vw;
}
.sidebar {
width: 260px;
flex: none;
background: var(--bg-2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
main {
flex: 1;
min-width: 0;
position: relative;
background: var(--bg-canvas);
}
@media (max-width: 700px) {
.sidebar {
position: absolute;
left: 44px;
top: 0;
bottom: 0;
z-index: 20;
box-shadow: var(--shadow);
}
}
</style>

View file

@ -0,0 +1,754 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import DownloadIcon from '@lucide/svelte/icons/download';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import TrashIcon from '@lucide/svelte/icons/trash-2';
import CheckIcon from '@lucide/svelte/icons/check';
import type { CanvasDoc } from '$lib/state/doc.svelte';
import { viewport } from '$lib/state/viewport.svelte';
import { tools } from '$lib/state/tools.svelte';
import { settings } from '$lib/state/settings.svelte';
import { workspace } from '$lib/state/workspace.svelte';
import { menu, type MenuItem } from '$lib/state/menu.svelte';
import { dirName, displayName } from '$lib/storage/vault.svelte';
import { bounds, intersects, rectBeside, type Rect } from '$lib/model/layout';
import { canInsert, freeSlots, freeSlotsOf, neighbor, type Dir, type Slot } from '$lib/model/tree';
import { PAGE_PRESETS, type Stroke } from '$lib/model/types';
import { PalmRejector } from '$lib/ink/palm';
import { StrokeRecorder, hitStroke } from '$lib/ink/stroke';
import { pageCssVars } from '$lib/editor/pageStyle';
import { downloadCanvas } from './download';
import PageView from './PageView.svelte';
import Toolbar from './Toolbar.svelte';
let { doc }: { doc: CanvasDoc } = $props();
let stage: HTMLDivElement;
const cssVars = pageCssVars();
// ---- autosave --------------------------------------------------------
$effect(() => {
if (doc.version > 0) untrack(() => workspace.scheduleSave());
});
// ---- viewport --------------------------------------------------------
onMount(() => {
const ro = new ResizeObserver(() => {
const r = stage.getBoundingClientRect();
const first = viewport.width === 0;
viewport.width = r.width;
viewport.height = r.height;
if (first) fitPage(workspace.focusPage ?? doc.activeId);
});
viewport.width = 0;
ro.observe(stage);
stage.addEventListener('wheel', onWheel, { passive: false });
return () => {
ro.disconnect();
stage.removeEventListener('wheel', onWheel);
};
});
// Search hits etc. ask for a page to be revealed.
$effect(() => {
const id = workspace.focusPage;
if (!id || !viewport.width) return;
untrack(() => {
if (doc.pages[id]) {
doc.activeId = id;
fitPage(id);
}
workspace.focusPage = null;
});
});
function fitPage(id: string | null) {
const r = (id && doc.rectOf(id)) || doc.rectOf(doc.order[0]);
if (r) viewport.fit(r);
}
function fitAll() {
const b = bounds(doc.rects.values());
if (b) viewport.fit(b, 48);
}
const visible = $derived.by(() => {
const w = viewport.world;
// One extra screen of margin around the view so panning doesn't pop.
const area = { x: w.x - w.width, y: w.y - w.height, width: w.width * 3, height: w.height * 3 };
return doc.order.filter((id) => {
const r = doc.rects.get(id);
return r && intersects(r, area);
});
});
// ---- adding pages ----------------------------------------------------
const ghostSlots = $derived.by((): Slot[] => {
if (tools.placing || !doc.activeId) return [];
return freeSlotsOf(doc.tree, doc.activeId);
});
const placeSlots = $derived(tools.placing ? freeSlots(doc.tree) : []);
// Show every possible spot when an imported PDF is waiting to be placed.
$effect(() => {
if (!tools.placing) return;
untrack(() => {
const size = tools.placing!.pages[0];
const rects = [...doc.rects.values(), ...placeSlots.map((s) => slotRect(s, size)).filter((r): r is Rect => !!r)];
const b = bounds(rects);
if (b) viewport.fit(b, 48);
});
});
function slotRect(slot: Slot, size?: { width: number; height: number }): Rect | null {
const a = doc.rectOf(slot.anchor);
if (!a) return null;
const page = doc.pages[slot.anchor];
const s = size ?? (page && !page.origin ? { width: page.width, height: page.height } : { width: 595.28, height: 841.89 });
return rectBeside(a, slot.dir, s);
}
/** Run a tree change without the anchor page jumping on screen. */
function keepAnchor(anchor: string, fn: () => void) {
const before = doc.rectOf(anchor);
fn();
const after = doc.rectOf(anchor);
if (before && after) viewport.panBy(-(after.x - before.x) * viewport.scale, -(after.y - before.y) * viewport.scale);
}
function revealPage(id: string) {
const r = doc.rectOf(id);
if (!r) return;
const w = viewport.world;
const inView = r.x >= w.x && r.y >= w.y && r.x + r.width <= w.x + w.width && r.y + r.height <= w.y + w.height;
if (!inView) {
const fits = r.width * viewport.scale <= viewport.width && r.height * viewport.scale <= viewport.height;
if (fits) viewport.center(r);
else viewport.fit(r);
}
}
function addPage(anchor: string, dir: Dir) {
let id: string | null = null;
keepAnchor(anchor, () => (id = doc.addBlankPage(anchor, dir)));
if (id) revealPage(id);
}
function place(slot: Slot) {
const p = tools.placing;
if (!p) return;
let ok = false;
keepAnchor(slot.anchor, () => (ok = doc.insertPages(slot.anchor, slot.dir, p.pages)));
if (ok) {
tools.placing = null;
revealPage(p.pages[0].id);
workspace.toast(`Placed ${p.pages.length} page${p.pages.length === 1 ? '' : 's'} from ${p.name}`);
}
}
function go(dir: Dir) {
const id = doc.activeId;
if (!id) return;
const next = neighbor(doc.tree, id, dir);
if (next) {
doc.activeId = next;
revealPage(next);
} else if (canInsert(doc.tree, id, dir)) addPage(id, dir);
}
// ---- page menu -------------------------------------------------------
function pageMenu(x: number, y: number, id: string) {
const page = doc.pages[id];
if (!page) return;
doc.activeId = id;
const dirs: [Dir, string][] = [
['up', 'Insert page above'],
['down', 'Insert page below'],
['left', 'Insert page left'],
['right', 'Insert page right']
];
const items: MenuItem[] = dirs.map(([dir, label]) => ({
label,
disabled: !canInsert(doc.tree, id, dir),
action: () => addPage(id, dir)
}));
if (!page.origin) {
items.push('separator');
for (const p of PAGE_PRESETS) {
const current = Math.abs(p.width - page.width) < 0.5 && Math.abs(p.height - page.height) < 0.5;
items.push({
label: p.label,
icon: current ? CheckIcon : undefined,
hint: `${Math.round(p.width)}×${Math.round(p.height)}`,
action: () => keepAnchor(id, () => doc.resizePage(id, p.width, p.height))
});
}
items.push({ label: 'Custom size…', action: () => customSize(id) });
}
items.push('separator', { label: 'Delete page', icon: TrashIcon, danger: true, action: () => doc.deletePage(id) });
menu.show(x, y, items);
}
function customSize(id: string) {
const page = doc.pages[id];
const mm = (pt: number) => Math.round((pt / 72) * 25.4);
const answer = prompt('Page size in millimetres (width × height)', `${mm(page.width)} × ${mm(page.height)}`);
const m = answer && /^\s*(\d+(?:\.\d+)?)\s*[x×*,\s]\s*(\d+(?:\.\d+)?)\s*$/i.exec(answer);
if (!m) return;
const pt = (v: string) => Math.min(5000, Math.max(50, (parseFloat(v) / 25.4) * 72));
keepAnchor(id, () => doc.resizePage(id, pt(m[1]), pt(m[2])));
}
const activeMenuPos = $derived.by(() => {
const id = doc.activeId;
const r = id && doc.rects.get(id);
if (!r || tools.placing) return null;
const p = viewport.toScreen(r.x + r.width, r.y);
return { x: p.x + 6, y: p.y };
});
// ---- input -----------------------------------------------------------
const palm = new PalmRejector(settings.data.palm);
$effect(() => {
palm.options = settings.data.palm;
});
type Gesture =
| { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder }
| { kind: 'erase'; pointerId: number; pointerType: string; hits: Map<string, Set<string>> }
| { kind: 'pan'; pointerId: number; lastX: number; lastY: number; startX: number; startY: number; moved: boolean; tap: boolean }
| { kind: 'pinch' };
let gesture: Gesture | null = null;
const touches = new Map<number, { x: number; y: number }>();
let pinchLast: { dist: number; mx: number; my: number } | null = null;
let spaceDown = $state(false);
// Reactive bits for rendering in-progress ink.
let live = $state.raw<{ pageId: string; stroke: Stroke } | null>(null);
let tick = $state(0);
let erasing = $state.raw<Set<string>>(new Set());
let eraserAt = $state<{ x: number; y: number } | null>(null);
function local(e: PointerEvent | MouseEvent) {
const r = stage.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function pageAt(sx: number, sy: number): string | null {
const w = viewport.toWorld(sx, sy);
for (const id of visible) {
const r = doc.rects.get(id)!;
if (w.x >= r.x && w.x <= r.x + r.width && w.y >= r.y && w.y <= r.y + r.height) return id;
}
return null;
}
function pagePoint(pageId: string, sx: number, sy: number) {
const r = doc.rects.get(pageId)!;
const w = viewport.toWorld(sx, sy);
return { x: w.x - r.x, y: w.y - r.y };
}
/** Pointer capture can throw if the pointer is already gone; drawing still works without it. */
function capture(id: number) {
try {
stage.setPointerCapture(id);
} catch {
/* ignore */
}
}
const isEraserButton = (e: PointerEvent) => e.pointerType === 'pen' && (e.button === 5 || (e.buttons & 32) !== 0);
function onPointerDown(e: PointerEvent) {
palm.track(e);
if (menu.open) menu.close();
const target = e.target as Element;
if (target.closest('.no-stage')) return;
const p = local(e);
if (e.pointerType === 'touch') {
const decision = palm.evaluate(e);
if (!decision.accept) return; // palm: ignore entirely
touches.set(e.pointerId, p);
if (touches.size >= 2) {
cancelGesture();
gesture = { kind: 'pinch' };
pinchLast = pinchState();
return;
}
}
const onPage = pageAt(p.x, p.y);
const inEditor = !!target.closest('.cm-editor');
// Pressing anywhere outside the text leaves the editor (preventDefault
// below would otherwise keep focus in it).
if (!inEditor) (document.activeElement as HTMLElement | null)?.blur?.();
// Panning: middle mouse, space+drag, or background drags.
const wantsPan =
e.button === 1 ||
spaceDown ||
(e.pointerType === 'touch' && (!tools.inking || !settings.data.fingerDraw)) ||
(e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.ghost, .slot'));
if (wantsPan) {
if (onPage && !inEditor) doc.activeId = onPage;
gesture = { kind: 'pan', pointerId: e.pointerId, lastX: p.x, lastY: p.y, startX: p.x, startY: p.y, moved: false, tap: inEditor || !!target.closest('.ghost, .slot') };
if (e.pointerType !== 'touch' || !gesture.tap) {
if (e.pointerType !== 'touch') e.preventDefault();
capture(e.pointerId);
}
return;
}
if (onPage) doc.activeId = onPage;
if (e.button !== 0 && e.pointerType === 'mouse') return;
const eraser = tools.tool === 'eraser' || isEraserButton(e);
if ((tools.inking || isEraserButton(e)) && onPage) {
e.preventDefault();
capture(e.pointerId);
if (eraser) {
gesture = { kind: 'erase', pointerId: e.pointerId, pointerType: e.pointerType, hits: new Map() };
erasing = new Set();
eraseAt(e);
} else {
const tool = tools.tool === 'highlighter' ? 'highlighter' : 'pen';
const rec = new StrokeRecorder(tool, tools.color, tools.width, e.pointerType === 'pen');
const pt = pagePoint(onPage, p.x, p.y);
rec.recordPoint(pt.x, pt.y, e);
gesture = { kind: 'draw', pointerId: e.pointerId, pointerType: e.pointerType, pageId: onPage, rec };
live = { pageId: onPage, stroke: rec.stroke };
tick++;
}
}
}
function onPointerMove(e: PointerEvent) {
palm.track(e);
const p = local(e);
if (tools.tool === 'eraser' || isEraserButton(e)) eraserAt = p;
if (e.pointerType === 'touch' && touches.has(e.pointerId)) {
touches.set(e.pointerId, p);
if (gesture?.kind === 'pinch') {
const s = pinchState();
if (s && pinchLast) {
viewport.zoomAt(s.dist / pinchLast.dist, s.mx, s.my);
viewport.panBy(s.mx - pinchLast.mx, s.my - pinchLast.my);
}
pinchLast = s;
return;
}
}
if (!gesture || gesture.kind === 'pinch' || gesture.pointerId !== e.pointerId) return;
if (gesture.kind === 'pan') {
const dx = p.x - gesture.lastX;
const dy = p.y - gesture.lastY;
if (!gesture.moved && Math.hypot(p.x - gesture.startX, p.y - gesture.startY) < 8) return;
if (!gesture.moved && gesture.tap) capture(e.pointerId);
gesture.moved = true;
viewport.panBy(dx, dy);
gesture.lastX = p.x;
gesture.lastY = p.y;
return;
}
// A touch stroke is cut off as soon as the palm policy rejects it
// (e.g. the pen just came into range).
if (gesture.pointerType === 'touch' && !palm.evaluate(e).accept) {
cancelGesture();
return;
}
if (gesture.kind === 'erase') {
eraseAt(e);
return;
}
const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [];
for (const ev of events.length ? events : [e]) {
const q = local(ev);
const pt = pagePoint(gesture.pageId, q.x, q.y);
gesture.rec.recordPoint(pt.x, pt.y, ev);
}
tick++;
}
function onPointerUp(e: PointerEvent) {
palm.track(e);
touches.delete(e.pointerId);
if (gesture?.kind === 'pinch') {
if (touches.size < 2) {
gesture = null;
pinchLast = null;
}
return;
}
if (!gesture || gesture.pointerId !== e.pointerId) return;
const g = gesture;
gesture = null;
if (g.kind === 'draw') {
live = null;
if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke);
} else if (g.kind === 'erase') {
for (const [pageId, ids] of g.hits) doc.setStrokes(pageId, (s) => s.filter((x) => !ids.has(x.id)));
erasing = new Set();
}
}
function cancelGesture() {
if (gesture?.kind === 'draw') live = null;
if (gesture?.kind === 'erase') erasing = new Set();
gesture = null;
}
function eraseAt(e: PointerEvent) {
if (gesture?.kind !== 'erase') return;
const p = local(e);
const pageId = pageAt(p.x, p.y);
if (!pageId) return;
const pt = pagePoint(pageId, p.x, p.y);
const r = tools.eraserRadius / viewport.scale;
let set = gesture.hits.get(pageId);
let changed = false;
for (const s of doc.pages[pageId].strokes) {
if (set?.has(s.id) || !hitStroke(s, pt.x, pt.y, r)) continue;
if (!set) gesture.hits.set(pageId, (set = new Set()));
set.add(s.id);
changed = true;
}
if (changed) erasing = new Set([...gesture.hits.values()].flatMap((x) => [...x]));
}
function pinchState() {
const pts = [...touches.values()];
if (pts.length < 2) return null;
const [a, b] = pts;
return { dist: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
}
function onWheel(e: WheelEvent) {
e.preventDefault();
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? viewport.height : 1;
const p = local(e);
if (e.ctrlKey || e.metaKey) {
viewport.zoomAt(Math.exp(-e.deltaY * unit * 0.0025), p.x, p.y);
} else if (e.shiftKey && !e.deltaX) {
viewport.panBy(-e.deltaY * unit, 0);
} else {
viewport.panBy(-e.deltaX * unit, -e.deltaY * unit);
}
}
function onContextMenu(e: MouseEvent) {
if ((e.target as Element).closest('.cm-editor') && !tools.inking) return; // native menu for text
e.preventDefault();
const p = local(e);
const id = pageAt(p.x, p.y);
if (id) pageMenu(e.clientX, e.clientY, id);
}
// ---- keyboard --------------------------------------------------------
function editable(t: EventTarget | null) {
const el = t as HTMLElement | null;
return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
}
function onKeyDown(e: KeyboardEvent) {
if (workspace.settingsOpen) return;
const mod = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase();
if (mod && (k === '=' || k === '+')) return void (e.preventDefault(), viewport.zoomAt(1.25));
if (mod && k === '-') return void (e.preventDefault(), viewport.zoomAt(0.8));
if (mod && k === '0') return void (e.preventDefault(), fitPage(doc.activeId));
if (mod && k === '9') return void (e.preventDefault(), fitAll());
if (editable(e.target)) return;
if (mod && k === 'z' && !e.shiftKey) return void (e.preventDefault(), doc.undo());
if (mod && ((k === 'z' && e.shiftKey) || k === 'y')) return void (e.preventDefault(), doc.redo());
if (e.key === 'Escape') {
if (tools.placing) tools.placing = null;
return;
}
if (e.altKey && e.key.startsWith('Arrow')) {
e.preventDefault();
go(e.key.slice(5).toLowerCase() as Dir);
return;
}
if (e.key === ' ' && !e.repeat) {
spaceDown = true;
e.preventDefault();
return;
}
if (mod || e.altKey) return;
const shortcuts: Record<string, typeof tools.tool> = { t: 'text', p: 'pen', h: 'highlighter', e: 'eraser' };
if (shortcuts[k]) tools.tool = shortcuts[k];
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === ' ') spaceDown = false;
}
const panning = $derived(spaceDown);
</script>
<svelte:window onkeydown={onKeyDown} onkeyup={onKeyUp} onblur={() => (spaceDown = false)} />
<div class="view">
<header class="titlebar">
<div class="crumbs">
{#if dirName(doc.path)}<span class="dir">{dirName(doc.path).replaceAll('/', ' / ')} /</span>{/if}
<span class="name">{displayName(doc.path)}</span>
</div>
<button class="icon-btn" title="Download PDF" onclick={() => downloadCanvas(doc.path)}>
<DownloadIcon size={16} strokeWidth={1.75} />
</button>
</header>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="stage"
class:inking={tools.inking}
class:panning
class:erasing={tools.tool === 'eraser'}
bind:this={stage}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
onpointerleave={(e) => {
palm.track(e);
eraserAt = null;
}}
oncontextmenu={onContextMenu}
>
<div
class="world"
style="transform: translate({viewport.x}px, {viewport.y}px) scale({viewport.scale}); --s: {viewport.scale}; {cssVars}"
>
{#each visible as id (id)}
{@const page = doc.pages[id]}
{@const rect = doc.rects.get(id)}
{#if page && rect}
<PageView
{page}
{rect}
active={doc.activeId === id}
invert={settings.dark && settings.data.pageStyle === 'match'}
scale={viewport.scale}
live={live?.pageId === id ? live.stroke : null}
{tick}
{erasing}
onchange={() => doc.touch()}
onactivate={() => (doc.activeId = id)}
/>
{/if}
{/each}
{#each ghostSlots as slot (slot.anchor + slot.dir)}
{@const r = slotRect(slot)}
{#if r}
<button
class="ghost"
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
title="Add page ({slot.dir})"
onclick={() => addPage(slot.anchor, slot.dir)}
>
<span class="plus" style="--icon: {Math.min(r.width, r.height) * 0.14}px">
<CirclePlusIcon size="100%" strokeWidth={1.25} />
</span>
</button>
{/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}
<button
class="slot"
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
onclick={() => place(slot)}
>
<span class="arrow" data-dir={slot.dir} style="--icon: {Math.min(r.width, r.height) * 0.16}px">
<ArrowUpIcon size="100%" strokeWidth={1.25} />
</span>
</button>
{/if}
{/each}
{/if}
</div>
{#if activeMenuPos && doc.activeId}
<button
class="icon-btn page-menu no-stage"
style="left:{activeMenuPos.x}px; top:{activeMenuPos.y}px"
title="Page options"
onclick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
pageMenu(r.left, r.bottom + 4, doc.activeId!);
}}
>
<EllipsisIcon size={16} />
</button>
{/if}
{#if eraserAt && tools.tool === 'eraser'}
<div class="eraser-ring" style="left:{eraserAt.x}px; top:{eraserAt.y}px; width:{tools.eraserRadius * 2}px; height:{tools.eraserRadius * 2}px"></div>
{/if}
{#if tools.placing}
<div class="banner no-stage">
Choose where to place <b>{tools.placing.name}</b> ({tools.placing.pages.length} page{tools.placing.pages.length === 1 ? '' : 's'})
<button class="btn" onclick={() => (tools.placing = null)}>Cancel</button>
</div>
{/if}
</div>
<Toolbar {doc} onfit={() => fitPage(doc.activeId)} onfitall={fitAll} />
</div>
<style>
.view {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.titlebar {
height: 38px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: var(--bg);
border-bottom: 1px solid var(--border);
padding: 0 8px;
}
.crumbs {
display: flex;
gap: 6px;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.crumbs .dir {
color: var(--faint);
}
.titlebar .icon-btn {
position: absolute;
right: 8px;
}
.stage {
position: relative;
flex: 1;
overflow: hidden;
touch-action: none;
user-select: none;
-webkit-user-select: none;
background: var(--bg-canvas);
}
.stage :global(.cm-content) {
user-select: text;
-webkit-user-select: text;
}
.stage.inking {
cursor: crosshair;
}
.stage.erasing {
cursor: none;
}
.stage.inking :global(.page .md),
.stage.panning :global(.page .md) {
pointer-events: none;
}
.stage.panning {
cursor: grab;
}
.world {
position: absolute;
left: 0;
top: 0;
transform-origin: 0 0;
}
.ghost,
.slot {
position: absolute;
display: grid;
place-items: center;
background: var(--ghost);
border: calc(1px / var(--s)) dashed var(--ghost-border);
border-radius: calc(4px / var(--s));
color: var(--faint);
cursor: pointer;
padding: 0;
transition: background 0.12s, color 0.12s;
}
.ghost:hover,
.slot:hover {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.slot {
border-style: solid;
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
.plus,
.arrow {
display: grid;
width: var(--icon);
height: var(--icon);
}
.arrow[data-dir='down'] {
transform: rotate(180deg);
}
.arrow[data-dir='left'] {
transform: rotate(-90deg);
}
.arrow[data-dir='right'] {
transform: rotate(90deg);
}
.page-menu {
position: absolute;
width: 26px;
height: 26px;
background: var(--bg);
box-shadow: var(--shadow);
}
.eraser-ring {
position: absolute;
transform: translate(-50%, -50%);
border: 1.5px solid var(--muted);
border-radius: 50%;
pointer-events: none;
}
.banner {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
padding: 6px 6px 6px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,110 @@
<script lang="ts">
import { menu } from '$lib/state/menu.svelte';
let el = $state<HTMLDivElement>();
let pos = $state({ x: 0, y: 0 });
// Keep the menu on screen.
$effect(() => {
if (!menu.open || !el) return;
void menu.items;
const r = el.getBoundingClientRect();
let y = menu.above ? menu.y - r.height : menu.y;
const x = Math.min(menu.x, window.innerWidth - r.width - 8);
if (y + r.height > window.innerHeight - 8) y = window.innerHeight - r.height - 8;
pos = { x: Math.max(8, x), y: Math.max(8, y) };
});
function onDown(e: PointerEvent) {
if (menu.open && el && !el.contains(e.target as Node)) menu.close();
}
</script>
<svelte:window
onpointerdowncapture={onDown}
onkeydown={(e) => e.key === 'Escape' && menu.close()}
onblur={() => menu.close()}
onresize={() => menu.close()}
/>
{#if menu.open}
<div class="menu" role="menu" bind:this={el} style="left:{pos.x}px; top:{pos.y}px">
{#each menu.items as item, i (i)}
{#if item === 'separator'}
<div class="sep"></div>
{:else}
<button
role="menuitem"
class:danger={item.danger}
disabled={item.disabled}
onclick={() => {
menu.close();
item.action();
}}
>
<span class="icon">{#if item.icon}<item.icon size={15} strokeWidth={1.75} />{/if}</span>
<span class="label">{item.label}</span>
{#if item.hint}<span class="hint">{item.hint}</span>{/if}
</button>
{/if}
{/each}
</div>
{/if}
<style>
.menu {
position: fixed;
z-index: 100;
min-width: 190px;
padding: 4px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
}
button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px 6px 6px;
border: 0;
background: none;
border-radius: 4px;
text-align: left;
cursor: pointer;
color: var(--text);
}
button:hover:not(:disabled) {
background: var(--hover);
}
button:disabled {
color: var(--faint);
cursor: default;
}
.danger {
color: var(--danger);
}
.icon {
display: inline-grid;
place-items: center;
width: 18px;
color: var(--muted);
}
.danger .icon {
color: inherit;
}
.label {
flex: 1;
}
.hint {
color: var(--faint);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sep {
height: 1px;
margin: 4px 6px;
background: var(--border);
}
</style>

View file

@ -0,0 +1,45 @@
<script lang="ts">
import { vault } from '$lib/storage/vault.svelte';
import { workspace } from '$lib/state/workspace.svelte';
async function create() {
const path = await vault.createCanvas('');
await workspace.open(path);
}
</script>
<div class="empty">
<p class="lead">No canvas open</p>
<button class="link" onclick={create}>Create a new canvas</button>
{#if vault.canvases.length}
<button class="link" onclick={() => (workspace.panel = 'files')}>Open a file</button>
{/if}
</div>
<style>
.empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
background: var(--bg);
}
.lead {
color: var(--faint);
font-size: 15px;
margin: 0 0 8px;
}
.link {
border: 0;
background: none;
color: var(--accent);
cursor: pointer;
padding: 4px;
}
.link:hover {
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,231 @@
<script lang="ts">
import FilePlusIcon from '@lucide/svelte/icons/file-plus';
import FolderPlusIcon from '@lucide/svelte/icons/folder-plus';
import FileInputIcon from '@lucide/svelte/icons/file-input';
import PencilIcon from '@lucide/svelte/icons/pencil';
import TrashIcon from '@lucide/svelte/icons/trash-2';
import DownloadIcon from '@lucide/svelte/icons/download';
import { vault, baseName, dirName, displayName, joinPath, sanitizeName, EXT } from '$lib/storage/vault.svelte';
import { workspace } from '$lib/state/workspace.svelte';
import { menu, type MenuItem } from '$lib/state/menu.svelte';
import { downloadCanvas } from '$lib/components/download';
import FileTreeNode, { type Node } from './FileTreeNode.svelte';
const OPEN_KEY = 'papure:openFolders';
let open = $state<Set<string>>(loadOpen());
let renaming = $state<string | null>(null);
let importInput: HTMLInputElement;
let importDir = '';
function loadOpen() {
try {
return new Set<string>(JSON.parse(localStorage.getItem(OPEN_KEY) ?? '[]'));
} catch {
return new Set<string>();
}
}
function toggleFolder(path: string) {
const next = new Set(open);
if (next.has(path)) next.delete(path);
else next.add(path);
open = next;
try {
localStorage.setItem(OPEN_KEY, JSON.stringify([...next]));
} catch {
/* ignore */
}
}
function ensureOpen(dir: string) {
if (!dir) return;
const parts = dir.split('/');
const next = new Set(open);
for (let i = 1; i <= parts.length; i++) next.add(parts.slice(0, i).join('/'));
open = next;
}
const root = $derived.by(() => {
const nodes = new Map<string, Node>();
const top: Node = { path: '', name: '', kind: 'folder', children: [] };
const folderNode = (path: string): Node => {
if (!path) return top;
let n = nodes.get(path);
if (!n) {
n = { path, name: baseName(path), kind: 'folder', children: [] };
nodes.set(path, n);
folderNode(dirName(path)).children.push(n);
}
return n;
};
for (const f of vault.folders) folderNode(f);
for (const f of vault.canvases) {
folderNode(dirName(f.path)).children.push({ path: f.path, name: displayName(f.path), kind: 'canvas', children: [] });
}
const sort = (n: Node) => {
n.children.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name, undefined, { numeric: true }) : a.kind === 'folder' ? -1 : 1));
n.children.forEach(sort);
};
sort(top);
return top;
});
async function newCanvas(dir = '') {
const path = await vault.createCanvas(dir);
ensureOpen(dir);
await workspace.open(path);
renaming = path;
}
async function newFolder(dir = '') {
const path = await vault.createFolder(dir);
ensureOpen(dir);
renaming = path;
}
function importHere(dir = '') {
importDir = dir;
importInput.click();
}
async function onImport(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const files = [...(input.files ?? [])];
input.value = '';
for (const file of files) {
try {
const path = await vault.importPdfAsCanvas(importDir, file);
ensureOpen(importDir);
await workspace.open(path);
} catch (err) {
console.error(err);
workspace.toast(`Could not import ${file.name}`, 'error');
}
}
}
async function commitRename(path: string, kind: Node['kind'], name: string) {
if (renaming !== path) return; // Enter already committed; this is the blur
renaming = null;
const clean = sanitizeName(name);
if (!clean) return;
const target = joinPath(dirName(path), kind === 'canvas' ? clean + EXT : clean);
if (target === path) return;
if (vault.exists(target)) return workspace.toast(`"${clean}" already exists`, 'error');
await move(path, target);
}
async function move(from: string, to: string) {
await workspace.flush();
await vault.rename(from, to);
await workspace.renamed(from, to);
}
async function onDrop(target: string, source: string) {
if (!source || source === target || target.startsWith(source + '/') || dirName(source) === target) return;
const to = joinPath(target, baseName(source));
if (vault.exists(to)) return workspace.toast(`"${baseName(source)}" already exists there`, 'error');
ensureOpen(target);
await move(source, to);
}
async function remove(n: Node) {
const what = n.kind === 'folder' ? `folder "${n.name}" and everything in it` : `"${n.name}"`;
if (!confirm(`Delete ${what}?`)) return;
const openPath = workspace.doc?.path;
if (openPath && (openPath === n.path || openPath.startsWith(n.path + '/'))) workspace.close();
await vault.remove(n.path);
}
function contextMenu(e: MouseEvent, n: Node | null) {
e.preventDefault();
const dir = !n ? '' : n.kind === 'folder' ? n.path : dirName(n.path);
const items: MenuItem[] = [
{ label: 'New canvas', icon: FilePlusIcon, action: () => newCanvas(dir) },
{ label: 'New folder', icon: FolderPlusIcon, action: () => newFolder(dir) },
{ label: 'Import PDF…', icon: FileInputIcon, action: () => importHere(dir) }
];
if (n) {
items.push('separator', { label: 'Rename', icon: PencilIcon, action: () => (renaming = n.path) });
if (n.kind === 'canvas') items.push({ label: 'Download PDF', icon: DownloadIcon, action: () => downloadCanvas(n.path) });
items.push('separator', { label: 'Delete', icon: TrashIcon, danger: true, action: () => remove(n) });
}
menu.show(e.clientX, e.clientY, items);
}
</script>
<div class="head">
<span class="title">Files</span>
<div class="actions">
<button class="icon-btn" title="New canvas" onclick={() => newCanvas()}><FilePlusIcon size={16} strokeWidth={1.75} /></button>
<button class="icon-btn" title="New folder" onclick={() => newFolder()}><FolderPlusIcon size={16} strokeWidth={1.75} /></button>
<button class="icon-btn" title="Import PDF as canvas" onclick={() => importHere()}><FileInputIcon size={16} strokeWidth={1.75} /></button>
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="tree"
oncontextmenu={(e) => 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)}
<FileTreeNode
{node}
depth={0}
{open}
{renaming}
activePath={workspace.doc?.path ?? null}
onToggle={toggleFolder}
onOpen={(p) => workspace.open(p)}
onContext={contextMenu}
onRename={commitRename}
onCancelRename={() => (renaming = null)}
{onDrop}
/>
{:else}
<p class="empty">No canvases yet.<br />Right-click or use the buttons above.</p>
{/each}
</div>
<input bind:this={importInput} type="file" accept="application/pdf,.pdf" multiple hidden onchange={onImport} />
<style>
.head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px 6px 14px;
}
.title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--faint);
}
.actions {
display: flex;
gap: 2px;
}
.actions .icon-btn {
width: 26px;
height: 26px;
}
.tree {
flex: 1;
overflow: auto;
padding: 2px 6px 16px;
}
.empty {
color: var(--faint);
text-align: center;
margin-top: 32px;
font-size: 12px;
line-height: 1.6;
}
</style>

View file

@ -0,0 +1,168 @@
<script lang="ts" module>
export interface Node {
path: string;
name: string;
kind: 'folder' | 'canvas';
children: Node[];
}
</script>
<script lang="ts">
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import Self from './FileTreeNode.svelte';
interface Props {
node: Node;
depth: number;
open: Set<string>;
renaming: string | null;
activePath: string | null;
onToggle: (path: string) => void;
onOpen: (path: string) => void;
onContext: (e: MouseEvent, n: Node) => void;
onRename: (path: string, kind: Node['kind'], name: string) => void;
onCancelRename: () => void;
onDrop: (targetDir: string, source: string) => void;
}
let props: Props = $props();
const { node, depth } = $derived(props);
const isOpen = $derived(props.open.has(node.path));
let dragOver = $state(false);
function focusSelect(el: HTMLInputElement) {
el.focus();
el.select();
}
function onKey(e: KeyboardEvent) {
const input = e.currentTarget as HTMLInputElement;
if (e.key === 'Enter') props.onRename(node.path, node.kind, input.value);
else if (e.key === 'Escape') props.onCancelRename();
}
function click() {
if (node.kind === 'folder') props.onToggle(node.path);
else props.onOpen(node.path);
}
</script>
<div
class="row"
class:active={node.kind === 'canvas' && props.activePath === node.path}
class:drag-over={dragOver}
style="padding-left: {8 + depth * 14}px"
role="treeitem"
aria-selected={props.activePath === node.path}
aria-expanded={node.kind === 'folder' ? isOpen : undefined}
tabindex="0"
draggable={props.renaming !== node.path}
onclick={click}
onkeydown={(e) => 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'}
<span class="chev" class:open={isOpen}><ChevronRightIcon size={14} strokeWidth={2} /></span>
{/if}
{#if props.renaming === node.path}
<input
class="rename"
value={node.name}
use:focusSelect
onkeydown={onKey}
onclick={(e) => e.stopPropagation()}
onblur={(e) => props.onRename(node.path, node.kind, e.currentTarget.value)}
/>
{:else}
<span class="name" class:folder={node.kind === 'folder'}>{node.name}</span>
{/if}
</div>
{#if node.kind === 'folder' && isOpen}
<div class="children" style="--guide: {14 + depth * 14}px">
{#each node.children as child (child.path)}
<Self {...props} node={child} depth={depth + 1} />
{/each}
</div>
{/if}
<style>
.row {
display: flex;
align-items: center;
gap: 2px;
height: 28px;
padding-right: 8px;
border-radius: 4px;
cursor: pointer;
color: var(--muted);
user-select: none;
outline: none;
}
.row:hover,
.row:focus-visible {
background: var(--hover);
color: var(--text);
}
.row.active {
background: var(--accent-soft);
color: var(--text);
}
.row.drag-over {
box-shadow: inset 0 0 0 1px var(--accent);
}
.chev {
display: inline-grid;
place-items: center;
width: 16px;
color: var(--faint);
transition: transform 0.12s;
}
.chev.open {
transform: rotate(90deg);
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 4px;
}
.name.folder {
padding-left: 0;
}
.rename {
flex: 1;
min-width: 0;
height: 22px;
padding: 2px 6px;
}
.children {
position: relative;
}
.children::before {
content: '';
position: absolute;
left: var(--guide);
top: 0;
bottom: 0;
border-left: 1px solid var(--border);
}
</style>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { svgPath, HIGHLIGHTER_OPACITY } from '$lib/ink/stroke';
import type { Stroke } from '$lib/model/types';
interface Props {
width: number;
height: number;
strokes: Stroke[];
/** Stroke being drawn right now (points mutate; `tick` changes). */
live?: Stroke | null;
tick?: number;
/** Strokes hidden while an eraser gesture is in progress. */
erasing?: Set<string>;
}
let { width, height, strokes, live = null, tick = 0, erasing }: Props = $props();
const livePath = $derived.by(() => {
void tick;
return live ? svgPath(live, true) : '';
});
</script>
<svg class="ink" viewBox="0 0 {width} {height}" preserveAspectRatio="none" aria-hidden="true">
{#each strokes as s (s.id)}
{#if !erasing?.has(s.id)}
<path
d={svgPath(s)}
fill={s.color}
fill-opacity={s.tool === 'highlighter' ? HIGHLIGHTER_OPACITY : 1}
class:hl={s.tool === 'highlighter'}
/>
{/if}
{/each}
{#if live}
<path
d={livePath}
fill={live.color}
fill-opacity={live.tool === 'highlighter' ? HIGHLIGHTER_OPACITY : 1}
class:hl={live.tool === 'highlighter'}
/>
{/if}
</svg>
<style>
.ink {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
}
.hl {
mix-blend-mode: multiply;
}
</style>

View file

@ -0,0 +1,181 @@
<script lang="ts">
import { onMount } from 'svelte';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap, placeholder, drawSelection } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { livePreview } from '$lib/editor/livepreview';
import type { PageModel } from '$lib/state/doc.svelte';
interface Props {
page: PageModel;
onchange: () => void;
onfocus: () => void;
}
let { page, onchange, onfocus }: Props = $props();
let host: HTMLDivElement;
onMount(() => {
const view = new EditorView({
parent: host,
state: EditorState.create({
doc: page.markdown,
extensions: [
history(),
drawSelection(),
EditorState.tabSize.of(4),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
livePreview(),
page.origin ? [] : placeholder('Start typing…'),
EditorView.updateListener.of((u) => {
if (u.docChanged) {
page.markdown = u.state.doc.toString();
onchange();
}
if (u.focusChanged && u.view.hasFocus) onfocus();
}),
EditorView.domEventHandlers({
keydown: (e, v) => {
if (e.key === 'Escape') {
v.contentDOM.blur();
return true;
}
return false;
}
})
]
})
});
return () => view.destroy();
});
</script>
<div class="md" bind:this={host}></div>
<style>
.md {
position: absolute;
/* Bottom overflow is clipped by the page itself (as in the PDF). */
inset: var(--pg-margin) var(--pg-margin) 0 var(--pg-margin);
}
.md :global(.cm-editor) {
height: 100%;
background: transparent;
color: var(--pg-text);
font-family: var(--ui-font);
font-size: var(--pg-font);
/* The page is laid out at ~11px and then scaled; without this, glyph
advances are rounded at the small size and spacing looks uneven. */
text-rendering: geometricPrecision;
font-kerning: normal;
}
.md :global(.cm-editor.cm-focused) {
outline: none;
}
.md :global(.cm-scroller) {
font-family: inherit;
line-height: var(--pg-lh);
overflow: visible !important;
}
.md :global(.cm-content) {
padding: 0;
caret-color: var(--pg-text);
}
.md :global(.cm-line) {
padding: 0;
}
.md :global(.cm-cursor) {
border-left-color: var(--pg-text);
}
.md :global(.cm-selectionBackground) {
background: rgba(112, 93, 207, 0.18) !important;
}
.md :global(.cm-placeholder) {
color: #b5b5b5;
}
/* Live preview — keep in step with PAGE_STYLE / pdf/text.ts. */
.md :global(.md-h) {
font-weight: 700;
line-height: var(--pg-hlh);
}
.md :global(.md-h1) { font-size: var(--pg-h1); }
.md :global(.md-h2) { font-size: var(--pg-h2); }
.md :global(.md-h3) { font-size: var(--pg-h3); }
.md :global(.md-h4) { font-size: var(--pg-h4); }
.md :global(.md-h5) { font-size: var(--pg-h5); }
.md :global(.md-h6) { font-size: var(--pg-h6); }
.md :global(.md-strong) {
font-weight: 700;
}
.md :global(.md-em) {
font-style: italic;
}
.md :global(.md-strike) {
text-decoration: line-through;
}
.md :global(.md-link) {
color: var(--pg-link);
text-decoration: underline;
text-decoration-thickness: 0.5px;
text-underline-offset: 0.12em;
}
.md :global(.md-code) {
font-family: var(--mono-font);
font-size: 0.92em;
background: var(--pg-code-bg);
border-radius: 2px;
}
.md :global(.md-codeblock) {
font-family: var(--mono-font);
font-size: var(--pg-code);
line-height: var(--pg-code-lh);
background: var(--pg-code-bg);
margin: 0 -6px !important;
padding: 0 6px !important;
}
.md :global(.md-codeblock .md-code) {
background: none;
font-size: inherit;
}
.md :global(.md-mark) {
color: var(--pg-muted);
}
.md :global(.md-quote) {
padding-left: calc(var(--q) * var(--pg-quote)) !important;
background-image: repeating-linear-gradient(
to right,
var(--pg-quote-bar) 0 2px,
transparent 2px var(--pg-quote)
);
background-size: calc(var(--q) * var(--pg-quote)) 100%;
background-repeat: no-repeat;
}
.md :global(.md-hr) {
background: linear-gradient(var(--pg-rule), var(--pg-rule)) center / 100% 1px no-repeat;
}
.md :global(.md-bullet) {
color: var(--pg-text);
}
.md :global(.md-task) {
display: inline-block;
width: 0.85em;
height: 0.85em;
vertical-align: -0.05em;
border: 0.8px solid var(--pg-muted);
border-radius: 1px;
cursor: pointer;
position: relative;
}
.md :global(.md-task.done::after) {
content: '';
position: absolute;
left: 0.22em;
top: 0.04em;
width: 0.2em;
height: 0.45em;
border: solid var(--pg-text);
border-width: 0 1.1px 1.1px 0;
transform: rotate(45deg);
}
</style>

View file

@ -0,0 +1,74 @@
<script lang="ts">
import type { Rect } from '$lib/model/layout';
import type { Stroke } from '$lib/model/types';
import type { PageModel } from '$lib/state/doc.svelte';
import MarkdownEditor from './MarkdownEditor.svelte';
import PdfBackground from './PdfBackground.svelte';
import InkLayer from './InkLayer.svelte';
interface Props {
page: PageModel;
rect: Rect;
active: boolean;
invert: boolean;
scale: number;
live: Stroke | null;
tick: number;
erasing: Set<string>;
onchange: () => void;
onactivate: () => void;
}
let { page, rect, active, invert, scale, live, tick, erasing, onchange, onactivate }: Props = $props();
</script>
<div
class="page"
class:active
data-page={page.id}
style="left:{rect.x}px; top:{rect.y}px; width:{rect.width}px; height:{rect.height}px"
>
<div class="paper" class:invert>
{#if page.origin}
<PdfBackground origin={page.origin} width={page.width} height={page.height} {scale} />
{/if}
<MarkdownEditor {page} {onchange} onfocus={onactivate} />
<InkLayer width={page.width} height={page.height} strokes={page.strokes} {live} {tick} {erasing} />
</div>
{#if page.origin && active}
<div class="origin" style="font-size:{11 / scale}px; top:{-18 / scale}px">{page.origin.name} · p. {page.origin.pageIndex + 1}</div>
{/if}
</div>
<style>
.page {
position: absolute;
}
.paper {
position: absolute;
inset: 0;
background: #ffffff;
overflow: hidden;
box-shadow: var(--page-shadow);
}
.paper.invert {
filter: invert(0.88) hue-rotate(180deg);
}
.page.active::after {
content: '';
position: absolute;
/* --s is the zoom, set on the world layer: keep the ring 1.5 screen px. */
inset: calc(-3px / var(--s));
border: calc(1.5px / var(--s)) solid var(--accent);
border-radius: calc(3px / var(--s));
opacity: 0.55;
pointer-events: none;
}
.origin {
position: absolute;
left: 0;
white-space: nowrap;
color: var(--faint);
pointer-events: none;
}
</style>

View file

@ -0,0 +1,71 @@
<script lang="ts">
import { loadSource, renderPage } from '$lib/pdf/render';
import { vault } from '$lib/storage/vault.svelte';
import type { Origin } from '$lib/model/types';
interface Props {
origin: Origin;
width: number;
height: number;
/** Current zoom (screen px per PDF point). */
scale: number;
}
let { origin, width, height, scale }: Props = $props();
let canvas: HTMLCanvasElement;
let failed = $state(false);
// Render at a stepped resolution so zooming doesn't re-render constantly.
const STEPS = [0.25, 0.5, 1, 1.5, 2, 3, 4];
const MAX_PIXELS = 12_000_000;
const resolution = $derived.by(() => {
const want = scale * (window.devicePixelRatio || 1);
const step = STEPS.find((s) => s >= want) ?? STEPS[STEPS.length - 1];
const cap = Math.sqrt(MAX_PIXELS / (width * height));
return Math.min(step, cap);
});
$effect(() => {
const res = resolution;
const { sourceId, pageIndex } = origin;
let job: ReturnType<typeof renderPage> | null = null;
let cancelled = false;
const timer = setTimeout(async () => {
try {
const doc = await loadSource(sourceId, () => vault.getSource(sourceId));
if (cancelled) return;
job = renderPage(doc, pageIndex, canvas, res);
failed = false;
} catch {
failed = true;
}
}, 120);
return () => {
cancelled = true;
clearTimeout(timer);
job?.cancel();
};
});
</script>
<canvas bind:this={canvas} class="bg" class:failed></canvas>
{#if failed}
<div class="missing">Source PDF “{origin.name}” is not available</div>
{/if}
<style>
.bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.missing {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #999;
font-size: 12px;
}
</style>

View file

@ -0,0 +1,115 @@
<script lang="ts">
import FilesIcon from '@lucide/svelte/icons/files';
import SearchIcon from '@lucide/svelte/icons/search';
import SettingsIcon from '@lucide/svelte/icons/settings';
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
import RefreshIcon from '@lucide/svelte/icons/refresh-cw';
import CloudOffIcon from '@lucide/svelte/icons/cloud-off';
import { settings } from '$lib/state/settings.svelte';
import { workspace, type Panel } from '$lib/state/workspace.svelte';
import { sync } from '$lib/sync/sync.svelte';
function toggle(p: Panel) {
workspace.panel = workspace.panel === p ? null : p;
}
const syncTitle = $derived.by(() => {
switch (sync.status) {
case 'disabled':
return 'Sync not set up — open settings';
case 'offline':
return `Offline · ${sync.pending} change(s) waiting`;
case 'syncing':
return 'Syncing…';
case 'error':
return `Sync failed: ${sync.error}`;
default:
return sync.pending
? `${sync.pending} unpushed change(s) — sync now`
: `Synced${sync.lastSync ? ' ' + new Date(sync.lastSync).toLocaleTimeString() : ''}`;
}
});
function onSync() {
if (sync.status === 'disabled') workspace.settingsOpen = true;
else void sync.syncNow();
}
</script>
<nav class="ribbon" aria-label="Ribbon">
<div class="group">
<button class="icon-btn" class:active={workspace.panel === 'files'} title="Files" onclick={() => toggle('files')}>
<FilesIcon size={18} strokeWidth={1.75} />
</button>
<button class="icon-btn" class:active={workspace.panel === 'search'} title="Search (Ctrl+Shift+F)" onclick={() => toggle('search')}>
<SearchIcon size={18} strokeWidth={1.75} />
</button>
</div>
<div class="group">
<button class="icon-btn sync" title={syncTitle} onclick={onSync} data-status={sync.status}>
{#if sync.status === 'disabled' || sync.status === 'offline'}
<CloudOffIcon size={18} strokeWidth={1.75} />
{:else}
<span class:spin={sync.status === 'syncing'}><RefreshIcon size={18} strokeWidth={1.75} /></span>
{/if}
{#if sync.status === 'error'}
<i class="dot error"></i>
{:else if sync.pending > 0 && sync.status !== 'disabled'}
<i class="dot pending"></i>
{/if}
</button>
<button class="icon-btn" title={settings.dark ? 'Light theme' : 'Dark theme'} onclick={() => settings.toggleTheme()}>
{#if settings.dark}<SunIcon size={18} strokeWidth={1.75} />{:else}<MoonIcon size={18} strokeWidth={1.75} />{/if}
</button>
<button class="icon-btn" title="Settings (Ctrl+,)" onclick={() => (workspace.settingsOpen = true)}>
<SettingsIcon size={18} strokeWidth={1.75} />
</button>
</div>
</nav>
<style>
.ribbon {
width: 44px;
flex: none;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
padding: 8px 0 10px;
background: var(--bg-2);
border-right: 1px solid var(--border);
z-index: 21;
}
.group {
display: flex;
flex-direction: column;
gap: 4px;
}
.sync {
position: relative;
}
.dot {
position: absolute;
top: 5px;
right: 5px;
width: 6px;
height: 6px;
border-radius: 50%;
}
.dot.pending {
background: var(--warn);
}
.dot.error {
background: var(--danger);
}
.spin {
display: inline-grid;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,122 @@
<script lang="ts">
import FileTextIcon from '@lucide/svelte/icons/file-text';
import { vault, displayName, dirName } from '$lib/storage/vault.svelte';
import { workspace } from '$lib/state/workspace.svelte';
let query = $state('');
let results = $state<Awaited<ReturnType<typeof vault.search>>>([]);
let timer: ReturnType<typeof setTimeout>;
$effect(() => {
const q = query;
clearTimeout(timer);
timer = setTimeout(async () => (results = await vault.search(q)), 120);
});
const grouped = $derived.by(() => {
const map = new Map<string, typeof results>();
for (const r of results) {
const list = map.get(r.path) ?? [];
list.push(r);
map.set(r.path, list);
}
return [...map];
});
function highlight(text: string, q: string) {
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
const i = text.toLowerCase().indexOf(q.toLowerCase());
if (!q || i < 0) return esc(text);
return esc(text.slice(0, i)) + '<mark>' + esc(text.slice(i, i + q.length)) + '</mark>' + esc(text.slice(i + q.length));
}
function focus(el: HTMLInputElement) {
el.focus();
}
</script>
<div class="head">
<input type="search" placeholder="Search text and PDF sources…" bind:value={query} use:focus />
</div>
<div class="results">
{#each grouped as [path, hits] (path)}
<div class="file">
<button class="file-name" onclick={() => workspace.open(path)}>
<FileTextIcon size={14} strokeWidth={1.75} />
<span>{displayName(path)}</span>
{#if dirName(path)}<small>{dirName(path)}</small>{/if}
</button>
{#each hits.filter((h) => h.pageId) as hit (hit.pageId)}
<button class="hit" onclick={() => workspace.open(path, { page: hit.pageId })}>
{#if hit.snippet}<span class="snippet">{@html highlight(hit.snippet, query.trim())}</span>{/if}
{#if hit.origin}<span class="origin">from {@html highlight(hit.origin, query.trim())}</span>{/if}
</button>
{/each}
</div>
{:else}
{#if query.trim()}<p class="empty">No matches.</p>{/if}
{/each}
</div>
<style>
.head {
padding: 10px 10px 6px;
}
.head input {
width: 100%;
}
.results {
flex: 1;
overflow: auto;
padding: 4px 6px 16px;
}
.file {
margin-bottom: 8px;
}
button {
display: flex;
width: 100%;
text-align: left;
border: 0;
background: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: var(--hover);
}
.file-name {
align-items: center;
gap: 6px;
padding: 5px 8px;
color: var(--text);
font-weight: 600;
}
.file-name small {
margin-left: auto;
color: var(--faint);
font-weight: 400;
}
.hit {
flex-direction: column;
gap: 2px;
padding: 5px 8px 5px 28px;
color: var(--muted);
font-size: 12px;
}
.origin {
color: var(--faint);
font-size: 11px;
}
.hit :global(mark) {
background: var(--accent-soft);
color: var(--text);
border-radius: 2px;
}
.empty {
color: var(--faint);
text-align: center;
margin-top: 24px;
}
</style>

View file

@ -0,0 +1,345 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { settings } from '$lib/state/settings.svelte';
import { workspace } from '$lib/state/workspace.svelte';
import { sync } from '$lib/sync/sync.svelte';
import { GitHub } from '$lib/sync/github';
type Tab = 'appearance' | 'input' | 'sync';
let tab = $state<Tab>('appearance');
const s = settings.data;
let testing = $state(false);
let testResult = $state<{ ok: boolean; text: string } | null>(null);
function save() {
settings.save();
}
function close() {
settings.save();
sync.schedule();
workspace.settingsOpen = false;
}
async function testConnection() {
save();
testing = true;
testResult = null;
try {
const repo = await new GitHub(s.github).checkAccess();
testResult = { ok: true, text: `Connected. Default branch: ${repo.default_branch}` };
} catch (e) {
testResult = { ok: false, text: e instanceof Error ? e.message : String(e) };
} finally {
testing = false;
}
}
async function syncNow() {
save();
sync.schedule();
await sync.syncNow();
if (sync.status === 'error') testResult = { ok: false, text: sync.error };
else testResult = { ok: true, text: 'Synced.' };
}
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="backdrop" onclick={(e) => e.target === e.currentTarget && close()}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Settings">
<nav>
<div class="nav-title">Options</div>
<button class:active={tab === 'appearance'} onclick={() => (tab = 'appearance')}>Appearance</button>
<button class:active={tab === 'input'} onclick={() => (tab = 'input')}>Pen &amp; touch</button>
<button class:active={tab === 'sync'} onclick={() => (tab = 'sync')}>Sync</button>
</nav>
<section>
<button class="icon-btn close" title="Close" onclick={close}><XIcon size={18} /></button>
{#if tab === 'appearance'}
<h2>Appearance</h2>
<div class="row">
<div><div class="name">Theme</div><div class="desc">Follow the system, or pick one.</div></div>
<select bind:value={s.theme} onchange={save}>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="row">
<div>
<div class="name">Pages in dark theme</div>
<div class="desc">Dim pages to match the theme, or keep them white like paper. Only affects the screen; the PDF is always on white.</div>
</div>
<select bind:value={s.pageStyle} onchange={save}>
<option value="match">Match theme</option>
<option value="paper">Paper</option>
</select>
</div>
{:else if tab === 'input'}
<h2>Pen &amp; touch</h2>
<div class="row">
<div><div class="name">Draw with finger</div><div class="desc">When off, a single finger always pans; only pen and mouse draw.</div></div>
<input type="checkbox" class="toggle" bind:checked={s.fingerDraw} onchange={save} />
</div>
<h3>Palm rejection</h3>
<div class="row">
<div><div class="name">Pen session lockout</div><div class="desc">Ignore touches while the pen is writing or was used in the last 5 seconds.</div></div>
<input type="checkbox" class="toggle" bind:checked={s.palm.penSession} onchange={save} />
</div>
<div class="row">
<div><div class="name">Reject wide contacts</div><div class="desc">Ignore touches with a palm-sized contact area (over 35 px).</div></div>
<input type="checkbox" class="toggle" bind:checked={s.palm.geometry} onchange={save} />
</div>
<div class="row">
<div><div class="name">Reject touches right after pen activity</div><div class="desc">Ignore touches within 150 ms of pen activity.</div></div>
<input type="checkbox" class="toggle" bind:checked={s.palm.timing} onchange={save} />
</div>
<div class="row">
<div><div class="name">Lock out on pen hover</div><div class="desc">Ignore touches while the pen hovers above the screen.</div></div>
<input type="checkbox" class="toggle" bind:checked={s.palm.hover} onchange={save} />
</div>
{:else}
<h2>Sync</h2>
<p class="intro">
Canvases are stored as PDFs in a GitHub repository. Changes save on this device straight away and are pushed
periodically, or when you press sync. If a file changed in both places, the newer version wins.
</p>
<div class="row">
<div>
<div class="name">Personal access token</div>
<div class="desc">A fine-grained token with <b>Contents: read &amp; write</b> on the repository. It's stored only in this browser.</div>
</div>
<input type="password" autocomplete="off" bind:value={s.github.token} onchange={save} placeholder="github_pat_…" />
</div>
<div class="row">
<div><div class="name">Repository</div><div class="desc">Owner and name.</div></div>
<div class="pair">
<input bind:value={s.github.owner} onchange={save} placeholder="owner" />
<span>/</span>
<input bind:value={s.github.repo} onchange={save} placeholder="notes" />
</div>
</div>
<div class="row">
<div><div class="name">Branch</div></div>
<input bind:value={s.github.branch} onchange={save} placeholder="main" />
</div>
<div class="row">
<div><div class="name">Folder in repository</div><div class="desc">Leave empty to use the repository root.</div></div>
<input bind:value={s.github.dir} onchange={save} placeholder="(root)" />
</div>
<div class="row">
<div><div class="name">Push automatically every</div><div class="desc">Minutes. 0 means only when you press sync.</div></div>
<input type="number" min="0" max="120" bind:value={s.pushInterval} onchange={() => (save(), sync.schedule())} />
</div>
<div class="actions">
<button class="btn" disabled={testing || !settings.githubReady} onclick={testConnection}>Test connection</button>
<button class="btn primary" disabled={!settings.githubReady || sync.status === 'syncing'} onclick={syncNow}>
{sync.status === 'syncing' ? 'Syncing…' : 'Sync now'}
</button>
</div>
{#if testResult}
<p class="result" class:bad={!testResult.ok}>{testResult.text}</p>
{/if}
{/if}
</section>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.45);
padding: 16px;
}
.modal {
display: flex;
width: min(860px, 100%);
height: min(600px, 100%);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
}
nav {
width: 190px;
flex: none;
background: var(--bg-2);
border-right: 1px solid var(--border);
padding: 16px 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--faint);
padding: 0 10px 8px;
}
nav button {
text-align: left;
border: 0;
background: none;
padding: 6px 10px;
border-radius: 4px;
color: var(--muted);
cursor: pointer;
}
nav button:hover {
background: var(--hover);
color: var(--text);
}
nav button.active {
background: var(--accent-soft);
color: var(--text);
}
section {
position: relative;
flex: 1;
overflow: auto;
padding: 20px 28px 28px;
}
.close {
position: absolute;
right: 12px;
top: 12px;
}
h2 {
font-size: 17px;
margin: 4px 0 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
h3 {
font-size: 13px;
margin: 24px 0 4px;
color: var(--muted);
}
.intro {
color: var(--muted);
margin: 0 0 8px;
line-height: 1.5;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.row > :first-child {
flex: 1;
}
.name {
color: var(--text);
}
.desc {
color: var(--muted);
font-size: 12px;
margin-top: 3px;
line-height: 1.45;
}
.row input:not([type='checkbox']),
.row select {
width: 220px;
}
.row input[type='number'] {
width: 80px;
}
.pair {
display: flex;
align-items: center;
gap: 6px;
}
.pair input {
width: 104px !important;
}
.pair span {
color: var(--faint);
}
.toggle {
appearance: none;
width: 36px;
height: 20px;
border-radius: 10px;
background: var(--bg-3);
border: 0;
padding: 0;
position: relative;
cursor: pointer;
flex: none;
transition: background 0.15s;
}
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 0.15s;
}
.toggle:checked {
background: var(--accent);
}
.toggle:checked::after {
transform: translateX(16px);
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 18px;
}
.btn:disabled {
opacity: 0.5;
cursor: default;
}
.result {
text-align: right;
color: var(--success);
margin: 10px 0 0;
}
.result.bad {
color: var(--danger);
}
@media (max-width: 640px) {
.modal {
flex-direction: column;
}
nav {
width: auto;
flex-direction: row;
border-right: 0;
border-bottom: 1px solid var(--border);
padding: 8px;
}
.nav-title {
display: none;
}
.row {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.row input:not([type='checkbox']),
.row select {
width: 100%;
}
}
</style>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { workspace } from '$lib/state/workspace.svelte';
</script>
<div class="toasts" aria-live="polite">
{#each workspace.toasts as t (t.id)}
<div class="toast" class:error={t.kind === 'error'}>{t.text}</div>
{/each}
</div>
<style>
.toasts {
position: fixed;
top: 14px;
right: 14px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 200;
pointer-events: none;
}
.toast {
padding: 8px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: var(--shadow);
max-width: 340px;
}
.toast.error {
border-color: var(--danger);
color: var(--danger);
}
</style>

View file

@ -0,0 +1,208 @@
<script lang="ts">
import TypeIcon from '@lucide/svelte/icons/type';
import PenIcon from '@lucide/svelte/icons/pen-line';
import HighlighterIcon from '@lucide/svelte/icons/highlighter';
import EraserIcon from '@lucide/svelte/icons/eraser';
import FileUpIcon from '@lucide/svelte/icons/file-up';
import UndoIcon from '@lucide/svelte/icons/undo-2';
import RedoIcon from '@lucide/svelte/icons/redo-2';
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
import ScanIcon from '@lucide/svelte/icons/scan';
import type { Component } from 'svelte';
import type { CanvasDoc } from '$lib/state/doc.svelte';
import { tools, PEN_WIDTHS, HIGHLIGHTER_WIDTHS, type Tool } from '$lib/state/tools.svelte';
import { settings } from '$lib/state/settings.svelte';
import { viewport } from '$lib/state/viewport.svelte';
import { workspace } from '$lib/state/workspace.svelte';
import { vault } from '$lib/storage/vault.svelte';
import { pagesFromPdf } from '$lib/pdf/read';
let { doc, onfit, onfitall }: { doc: CanvasDoc; onfit: () => void; onfitall: () => void } = $props();
let fileInput: HTMLInputElement;
let colorInput = $state<HTMLInputElement>();
const TOOLS: { id: Tool; label: string; key: string; icon: Component }[] = [
{ id: 'text', label: 'Text', key: 'T', icon: TypeIcon },
{ id: 'pen', label: 'Pen', key: 'P', icon: PenIcon },
{ id: 'highlighter', label: 'Highlighter', key: 'H', icon: HighlighterIcon },
{ id: 'eraser', label: 'Eraser', key: 'E', icon: EraserIcon }
];
const colors = $derived(tools.tool === 'highlighter' ? settings.data.highlighterColors : settings.data.penColors);
const widths = $derived(tools.tool === 'highlighter' ? HIGHLIGHTER_WIDTHS : PEN_WIDTHS);
const showInk = $derived(tools.tool === 'pen' || tools.tool === 'highlighter');
async function onFile(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const { sourceId, pages } = await pagesFromPdf(bytes, file.name);
if (pages.length === 0) return workspace.toast('That PDF has no pages', 'error');
await vault.putSource(sourceId, bytes);
if (doc.isBlank) {
doc.replaceWith(pages);
onfit();
} else {
tools.placing = { name: file.name, pages };
}
} catch (err) {
console.error(err);
workspace.toast(`Could not read ${file.name}`, 'error');
}
}
function customColor(e: Event) {
const c = (e.currentTarget as HTMLInputElement).value;
tools.color = c;
const list = tools.tool === 'highlighter' ? settings.data.highlighterColors : settings.data.penColors;
if (!list.includes(c)) {
list.push(c);
if (list.length > 8) list.splice(1, 1); // keep the first (default) colour
settings.save();
}
}
</script>
<div class="dock no-stage">
<div class="bar" role="toolbar" aria-label="Tools">
{#each TOOLS as t (t.id)}
<button
class="icon-btn"
class:active={tools.tool === t.id}
title="{t.label} ({t.key})"
aria-pressed={tools.tool === t.id}
onclick={() => (tools.tool = t.id)}
>
<t.icon size={18} strokeWidth={1.75} />
</button>
{/each}
{#if showInk}
<span class="sep"></span>
{#each colors as c (c)}
<button
class="swatch"
class:selected={tools.color === c}
style="--c: {c}"
title={c}
aria-label="Colour {c}"
onclick={() => (tools.color = c)}
></button>
{/each}
<button class="swatch custom" title="Custom colour" aria-label="Custom colour" onclick={() => colorInput?.click()}></button>
<input bind:this={colorInput} type="color" class="hidden-color" value={tools.color} onchange={customColor} />
<span class="sep"></span>
{#each widths as w, i (w)}
<button class="icon-btn width" class:active={tools.width === w} title="Width {i + 1}" onclick={() => (tools.width = w)}>
<span class="dot" style="width:{4 + i * 4}px; height:{4 + i * 4}px"></span>
</button>
{/each}
{/if}
<span class="sep"></span>
<button class="icon-btn" title="Import PDF" onclick={() => fileInput.click()}><FileUpIcon size={18} strokeWidth={1.75} /></button>
<button class="icon-btn" title="Undo (Ctrl+Z)" disabled={!doc.canUndo} onclick={() => doc.undo()}><UndoIcon size={18} strokeWidth={1.75} /></button>
<button class="icon-btn" title="Redo (Ctrl+Shift+Z)" disabled={!doc.canRedo} onclick={() => doc.redo()}><RedoIcon size={18} strokeWidth={1.75} /></button>
<span class="sep"></span>
<button class="icon-btn" title="Zoom out (Ctrl+−)" onclick={() => viewport.zoomAt(0.8)}><ZoomOutIcon size={18} strokeWidth={1.75} /></button>
<button class="zoom" title="Fit page (Ctrl+0)" onclick={onfit}>{Math.round(viewport.scale * 100)}%</button>
<button class="icon-btn" title="Zoom in (Ctrl+=)" onclick={() => viewport.zoomAt(1.25)}><ZoomInIcon size={18} strokeWidth={1.75} /></button>
<button class="icon-btn" title="Show whole tree (Ctrl+9)" onclick={onfitall}><ScanIcon size={18} strokeWidth={1.75} /></button>
</div>
</div>
<input bind:this={fileInput} type="file" accept="application/pdf,.pdf" hidden onchange={onFile} />
<style>
.dock {
position: absolute;
left: 0;
right: 0;
bottom: max(14px, env(safe-area-inset-bottom));
display: flex;
justify-content: center;
pointer-events: none;
padding: 0 12px;
}
.bar {
pointer-events: auto;
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
}
.icon-btn {
width: 32px;
height: 32px;
}
.sep {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 4px;
flex: none;
}
.swatch {
width: 22px;
height: 22px;
margin: 0 1px;
padding: 0;
flex: none;
border-radius: 50%;
border: 2px solid transparent;
background: var(--c);
background-clip: content-box;
box-shadow: inset 0 0 0 1px rgba(128, 128, 128, 0.35);
cursor: pointer;
}
.swatch.selected {
border-color: var(--accent);
padding: 2px;
}
.swatch.custom {
background: conic-gradient(#f43, #fd2, #3c6, #2be, #63f, #f3c, #f43);
background-clip: content-box;
}
.hidden-color {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
border: 0;
padding: 0;
}
.width .dot {
display: block;
border-radius: 50%;
background: currentColor;
}
.zoom {
min-width: 48px;
height: 32px;
border: 0;
background: none;
border-radius: 4px;
color: var(--muted);
font-variant-numeric: tabular-nums;
cursor: pointer;
flex: none;
}
.zoom:hover {
background: var(--hover);
color: var(--text);
}
</style>

View file

@ -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');
}
}

View file

@ -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<number>();
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<Decoration>[] = [];
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
];
}

View file

@ -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(['<bullet> one', '<task-done> 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', '']);
});
});

225
src/lib/editor/mdmarks.ts Normal file
View file

@ -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<string, InlineStyle> = {
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<InlineStyle>;
/** 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<string>, b: Set<string>) {
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}

View file

@ -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<string, string | number> = {
'--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(';');
}

98
src/lib/ink/palm.ts Normal file
View file

@ -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<number>();
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' };
}
}

114
src/lib/ink/stroke.ts Normal file
View file

@ -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<Stroke, string>();
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));
}

82
src/lib/model/layout.ts Normal file
View file

@ -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<string, Rect> {
const rects = new Map<string, Rect>();
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<string, Rect>, 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>): 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;
}

View file

@ -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']);
});
});

164
src/lib/model/tree.ts Normal file
View file

@ -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<string, Branches>;
}
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));
}

65
src/lib/model/types.ts Normal file
View file

@ -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<string, PageData>;
}
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);
}

40
src/lib/pdf/fonts.ts Normal file
View file

@ -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<FontKey, Uint8Array>;
type Loader = (file: string) => Promise<Uint8Array>;
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<FontBytes> | null = null;
export function loadFonts(): Promise<FontBytes> {
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;
}

17
src/lib/pdf/format.ts Normal file
View file

@ -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<string> {
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);
}

162
src/lib/pdf/read.ts Normal file
View file

@ -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<string, Uint8Array>;
/** True when the file had no Papure layer and was imported as-is. */
imported: boolean;
}
export async function readCanvasPdf(bytes: Uint8Array, name: string): Promise<ReadResult> {
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<string, { width: number; height: number; origin?: Origin }>;
}
function fromAttachments(files: Map<string, Uint8Array>, m: Manifest) {
if (m.format !== FORMAT) throw new Error('not a papure manifest');
const pages: Record<string, PageData> = {};
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<string, Uint8Array>();
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<ReadResult> {
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<string, Uint8Array> {
const out = new Map<string, Uint8Array>();
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();
}

62
src/lib/pdf/render.ts Normal file
View file

@ -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<string, Promise<PDFDocumentProxy>>();
export function loadSource(sourceId: string, getBytes: () => Promise<Uint8Array | undefined>) {
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<void>; cancel: () => void } {
let task: ReturnType<Awaited<ReturnType<PDFDocumentProxy['getPage']>>['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();
}
};
}

View file

@ -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();
}

237
src/lib/pdf/text.ts Normal file
View file

@ -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<PDFFont, { ascent: number; descent: number }>;
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<keyof Fonts>()) {
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 });
}
}

271
src/lib/pdf/write.ts Normal file
View file

@ -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<Uint8Array | undefined>;
}
export async function canvasToPdf(data: CanvasData, opts: WriteOptions): Promise<Uint8Array> {
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<keyof Fonts>(['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<Map<string, EmbeddedSource>> {
const bySource = new Map<string, Set<number>>();
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<string, EmbeddedSource>();
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));
}

213
src/lib/state/doc.svelte.ts Normal file
View file

@ -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<Stroke[]>([]);
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<Tree>(withTrunk([]));
pages = $state.raw<Record<string, PageModel>>({});
activeId = $state<string | null>(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<string, PageData> = {};
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<string, PageModel>, 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) });
}
}

View file

@ -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<MenuItem[]>([]);
/** 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();

View file

@ -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<SettingsData>(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();

View file

@ -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<Tool>('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();

View file

@ -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();

View file

@ -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<CanvasDoc | null>(null);
panel = $state<Panel | null>('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<string | null>(null);
private saveTimer: ReturnType<typeof setTimeout> | 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<string>('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();

53
src/lib/storage/db.ts Normal file
View file

@ -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<IDBPDatabase<Schema>> | null = null;
export function db() {
dbp ??= openDB<Schema>('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<T>(key: string): Promise<T | undefined> {
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);
}

View file

@ -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<FileRecord[]>([]);
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<string>();
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<CanvasData | undefined> {
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<Uint8Array> {
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();

133
src/lib/sync/github.ts Normal file
View file

@ -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<T>(method: string, path: string, body?: unknown): Promise<T> {
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<string | null> {
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<TreeEntry[]> {
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<Uint8Array> {
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<string> {
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<string> {
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<string> {
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;
}

211
src/lib/sync/sync.svelte.ts Normal file
View file

@ -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<void>;
/** Called with paths whose local copy was replaced or removed by a pull. */
afterPull?: (changed: string[]) => Promise<void> | void;
}
const KEEP = '.gitkeep';
class Sync {
status = $state<SyncStatus>('disabled');
error = $state('');
lastSync = $state<number | null>(null);
private running: Promise<void> | null = null;
private timer: ReturnType<typeof setInterval> | 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<void> {
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<string | null> {
const head = await gh.head();
const remote = new Map<string, { sha: string; kind: 'canvas' | 'folder' }>();
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();

227
src/lib/sync/sync.test.ts Normal file
View file

@ -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<string, Uint8Array>();
trees = new Map<string, Map<string, string>>();
commits = new Map<string, { tree: string; parents: string[] }>();
head: string | null = null;
private n = 0;
id(prefix: string) {
return `${prefix}${++this.n}`.padEnd(40, '0');
}
files() {
if (!this.head) return new Map<string, string>();
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<string, string>) {
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<Response> => {
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<string, string>();
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');
});
});

View file

@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

3
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,3 @@
// The app is fully client-side (IndexedDB, pointer input, PDF rendering).
export const ssr = false;
export const prerender = false;

5
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,5 @@
<script lang="ts">
import App from '$lib/components/App.svelte';
</script>
<App />

61
src/service-worker.ts Normal file
View file

@ -0,0 +1,61 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
// 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;
})
)
);
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
static/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
static/icon-maskable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

9
static/icon.svg Normal file
View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#7f6df2"/>
<g fill="#fff">
<rect x="196" y="72" width="120" height="160" rx="14"/>
<rect x="196" y="280" width="120" height="160" rx="14"/>
<rect x="352" y="280" width="92" height="160" rx="14" opacity=".55"/>
<rect x="68" y="72" width="92" height="160" rx="14" opacity=".55"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 421 B

View file

@ -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"] } }
]
}

14
svelte.config.js Normal file
View file

@ -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;

14
tsconfig.json Normal file
View file

@ -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"
}
}

10
vite.config.ts Normal file
View file

@ -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'
}
});