Init
This commit is contained in:
commit
0ad405c26e
78 changed files with 9127 additions and 0 deletions
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch"
|
||||
]
|
||||
}
|
||||
}
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
/.svelte-kit
|
||||
/build
|
||||
.DS_Store
|
||||
.tmp
|
||||
31
README.md
Normal file
31
README.md
Normal 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.
|
||||
BIN
memos/memos/.thumbnail_cache/1.png
Normal file
BIN
memos/memos/.thumbnail_cache/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
BIN
memos/memos/.thumbnail_cache/2.jpg
Normal file
BIN
memos/memos/.thumbnail_cache/2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
memos/memos/memos_prod.db
Normal file
BIN
memos/memos/memos_prod.db
Normal file
Binary file not shown.
BIN
memos/memos/memos_prod.db-shm
Normal file
BIN
memos/memos/memos_prod.db-shm
Normal file
Binary file not shown.
BIN
memos/memos/memos_prod.db-wal
Normal file
BIN
memos/memos/memos_prod.db-wal
Normal file
Binary file not shown.
2158
package-lock.json
generated
Normal file
2158
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
41
package.json
Normal file
41
package.json
Normal 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
339
palm-rejection-test.html
Normal 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> <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
13
scripts/subset-fonts.sh
Executable 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
90
spec.md
Normal 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
203
src/app.css
Normal 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
6
src/app.d.ts
vendored
Normal 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
17
src/app.html
Normal 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>
|
||||
132
src/lib/components/App.svelte
Normal file
132
src/lib/components/App.svelte
Normal 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>
|
||||
754
src/lib/components/CanvasView.svelte
Normal file
754
src/lib/components/CanvasView.svelte
Normal 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>
|
||||
110
src/lib/components/ContextMenu.svelte
Normal file
110
src/lib/components/ContextMenu.svelte
Normal 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>
|
||||
45
src/lib/components/EmptyState.svelte
Normal file
45
src/lib/components/EmptyState.svelte
Normal 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>
|
||||
231
src/lib/components/FileExplorer.svelte
Normal file
231
src/lib/components/FileExplorer.svelte
Normal 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>
|
||||
168
src/lib/components/FileTreeNode.svelte
Normal file
168
src/lib/components/FileTreeNode.svelte
Normal 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>
|
||||
57
src/lib/components/InkLayer.svelte
Normal file
57
src/lib/components/InkLayer.svelte
Normal 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>
|
||||
181
src/lib/components/MarkdownEditor.svelte
Normal file
181
src/lib/components/MarkdownEditor.svelte
Normal 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>
|
||||
74
src/lib/components/PageView.svelte
Normal file
74
src/lib/components/PageView.svelte
Normal 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>
|
||||
71
src/lib/components/PdfBackground.svelte
Normal file
71
src/lib/components/PdfBackground.svelte
Normal 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>
|
||||
115
src/lib/components/Ribbon.svelte
Normal file
115
src/lib/components/Ribbon.svelte
Normal 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>
|
||||
122
src/lib/components/SearchPanel.svelte
Normal file
122
src/lib/components/SearchPanel.svelte
Normal 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>
|
||||
345
src/lib/components/SettingsModal.svelte
Normal file
345
src/lib/components/SettingsModal.svelte
Normal 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 & 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 & 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 & 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>
|
||||
34
src/lib/components/Toasts.svelte
Normal file
34
src/lib/components/Toasts.svelte
Normal 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>
|
||||
208
src/lib/components/Toolbar.svelte
Normal file
208
src/lib/components/Toolbar.svelte
Normal 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>
|
||||
19
src/lib/components/download.ts
Normal file
19
src/lib/components/download.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
184
src/lib/editor/livepreview.ts
Normal file
184
src/lib/editor/livepreview.ts
Normal 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
|
||||
];
|
||||
}
|
||||
39
src/lib/editor/mdmarks.test.ts
Normal file
39
src/lib/editor/mdmarks.test.ts
Normal 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
225
src/lib/editor/mdmarks.ts
Normal 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;
|
||||
}
|
||||
55
src/lib/editor/pageStyle.ts
Normal file
55
src/lib/editor/pageStyle.ts
Normal 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
98
src/lib/ink/palm.ts
Normal 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
114
src/lib/ink/stroke.ts
Normal 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
82
src/lib/model/layout.ts
Normal 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;
|
||||
}
|
||||
68
src/lib/model/tree.test.ts
Normal file
68
src/lib/model/tree.test.ts
Normal 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
164
src/lib/model/tree.ts
Normal 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
65
src/lib/model/types.ts
Normal 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
40
src/lib/pdf/fonts.ts
Normal 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
17
src/lib/pdf/format.ts
Normal 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
162
src/lib/pdf/read.ts
Normal 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
62
src/lib/pdf/render.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
105
src/lib/pdf/roundtrip.test.ts
Normal file
105
src/lib/pdf/roundtrip.test.ts
Normal 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
237
src/lib/pdf/text.ts
Normal 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
271
src/lib/pdf/write.ts
Normal 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
213
src/lib/state/doc.svelte.ts
Normal 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) });
|
||||
}
|
||||
}
|
||||
38
src/lib/state/menu.svelte.ts
Normal file
38
src/lib/state/menu.svelte.ts
Normal 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();
|
||||
93
src/lib/state/settings.svelte.ts
Normal file
93
src/lib/state/settings.svelte.ts
Normal 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();
|
||||
43
src/lib/state/tools.svelte.ts
Normal file
43
src/lib/state/tools.svelte.ts
Normal 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();
|
||||
59
src/lib/state/viewport.svelte.ts
Normal file
59
src/lib/state/viewport.svelte.ts
Normal 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();
|
||||
95
src/lib/state/workspace.svelte.ts
Normal file
95
src/lib/state/workspace.svelte.ts
Normal 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
53
src/lib/storage/db.ts
Normal 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);
|
||||
}
|
||||
256
src/lib/storage/vault.svelte.ts
Normal file
256
src/lib/storage/vault.svelte.ts
Normal 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
133
src/lib/sync/github.ts
Normal 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
211
src/lib/sync/sync.svelte.ts
Normal 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
227
src/lib/sync/sync.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
7
src/routes/+layout.svelte
Normal file
7
src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
3
src/routes/+layout.ts
Normal file
3
src/routes/+layout.ts
Normal 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
5
src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import App from '$lib/components/App.svelte';
|
||||
</script>
|
||||
|
||||
<App />
|
||||
61
src/service-worker.ts
Normal file
61
src/service-worker.ts
Normal 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;
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
BIN
static/fonts/Inter_400Regular.ttf
Normal file
BIN
static/fonts/Inter_400Regular.ttf
Normal file
Binary file not shown.
BIN
static/fonts/Inter_400Regular_Italic.ttf
Normal file
BIN
static/fonts/Inter_400Regular_Italic.ttf
Normal file
Binary file not shown.
BIN
static/fonts/Inter_600SemiBold.ttf
Normal file
BIN
static/fonts/Inter_600SemiBold.ttf
Normal file
Binary file not shown.
BIN
static/fonts/Inter_700Bold.ttf
Normal file
BIN
static/fonts/Inter_700Bold.ttf
Normal file
Binary file not shown.
BIN
static/fonts/Inter_700Bold_Italic.ttf
Normal file
BIN
static/fonts/Inter_700Bold_Italic.ttf
Normal file
Binary file not shown.
BIN
static/fonts/JetBrainsMono_400Regular.ttf
Normal file
BIN
static/fonts/JetBrainsMono_400Regular.ttf
Normal file
Binary file not shown.
BIN
static/icon-192.png
Normal file
BIN
static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
static/icon-512.png
Normal file
BIN
static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
static/icon-maskable.png
Normal file
BIN
static/icon-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
9
static/icon.svg
Normal file
9
static/icon.svg
Normal 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 |
19
static/manifest.webmanifest
Normal file
19
static/manifest.webmanifest
Normal 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
14
svelte.config.js
Normal 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
14
tsconfig.json
Normal 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
10
vite.config.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue