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

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

@ -0,0 +1,213 @@
// The open canvas: reactive page tree + pages, with undo/redo.
import { layout, type Rect } from '$lib/model/layout';
import { insert, remove, canInsert, flatten, withTrunk, type Dir, type Tree } from '$lib/model/tree';
import { A4, newId, type CanvasData, type Origin, type PageData, type Stroke } from '$lib/model/types';
export class PageModel {
readonly id: string;
width = $state(A4.width);
height = $state(A4.height);
markdown = $state('');
strokes = $state.raw<Stroke[]>([]);
readonly origin?: Origin;
constructor(d: PageData) {
this.id = d.id;
this.width = d.width;
this.height = d.height;
this.markdown = d.markdown;
this.strokes = d.strokes;
this.origin = d.origin;
}
toData(): PageData {
return {
id: this.id,
width: this.width,
height: this.height,
markdown: this.markdown,
strokes: this.strokes,
...(this.origin ? { origin: this.origin } : {})
};
}
}
interface Command {
undo(): void;
redo(): void;
}
const MAX_UNDO = 200;
export class CanvasDoc {
path = $state('');
tree = $state.raw<Tree>(withTrunk([]));
pages = $state.raw<Record<string, PageModel>>({});
activeId = $state<string | null>(null);
/** Bumped on every change; the workspace autosaves on it. */
version = $state(0);
updatedAt: number;
canUndo = $state(false);
canRedo = $state(false);
private undoStack: Command[] = [];
private redoStack: Command[] = [];
rects = $derived(layout(this.tree, (id) => this.pages[id] ?? A4));
constructor(path: string, data: CanvasData) {
this.path = path;
this.updatedAt = data.updatedAt;
this.tree = data.tree;
this.pages = Object.fromEntries(Object.values(data.pages).map((p) => [p.id, new PageModel(p)]));
this.activeId = data.tree.trunk[0] ?? null;
}
get order() {
return flatten(this.tree);
}
rectOf(id: string): Rect | undefined {
return this.rects.get(id);
}
touch() {
this.updatedAt = Date.now();
this.version++;
}
toData(): CanvasData {
const pages: Record<string, PageData> = {};
for (const id of flatten(this.tree)) if (this.pages[id]) pages[id] = this.pages[id].toData();
return { version: 1, updatedAt: this.updatedAt, tree: this.tree, pages };
}
// ---- undo/redo -------------------------------------------------------
private exec(cmd: Command) {
cmd.redo();
this.undoStack.push(cmd);
if (this.undoStack.length > MAX_UNDO) this.undoStack.shift();
this.redoStack = [];
this.syncFlags();
this.touch();
}
undo() {
const cmd = this.undoStack.pop();
if (!cmd) return;
cmd.undo();
this.redoStack.push(cmd);
this.syncFlags();
this.touch();
}
redo() {
const cmd = this.redoStack.pop();
if (!cmd) return;
cmd.redo();
this.undoStack.push(cmd);
this.syncFlags();
this.touch();
}
private syncFlags() {
this.canUndo = this.undoStack.length > 0;
this.canRedo = this.redoStack.length > 0;
}
// ---- ink -------------------------------------------------------------
addStroke(pageId: string, stroke: Stroke) {
this.setStrokes(pageId, (s) => [...s, stroke]);
}
/** Replace a page's strokes as one undoable step. */
setStrokes(pageId: string, fn: (s: Stroke[]) => Stroke[]) {
const page = this.pages[pageId];
if (!page) return;
const before = page.strokes;
const after = fn(before);
if (after === before) return;
this.exec({
redo: () => (this.pages[pageId].strokes = after),
undo: () => (this.pages[pageId].strokes = before)
});
}
// ---- tree ------------------------------------------------------------
private treeCommand(nextTree: Tree, nextPages: Record<string, PageModel>, nextActive: string | null) {
const before = { tree: this.tree, pages: this.pages, active: this.activeId };
const after = { tree: nextTree, pages: nextPages, active: nextActive };
const apply = (s: typeof before) => {
this.tree = s.tree;
this.pages = s.pages;
this.activeId = s.active && s.pages[s.active] ? s.active : (s.tree.trunk[0] ?? null);
};
this.exec({ redo: () => apply(after), undo: () => apply(before) });
}
/** Insert pages next to `anchor`; returns false if not allowed there. */
insertPages(anchor: string, dir: Dir, pages: PageData[]): boolean {
if (!canInsert(this.tree, anchor, dir) || pages.length === 0) return false;
const next = insert(this.tree, anchor, dir, pages.map((p) => p.id));
const models = { ...this.pages };
for (const p of pages) models[p.id] = new PageModel(p);
this.treeCommand(next, models, pages[0].id);
return true;
}
addBlankPage(anchor: string, dir: Dir): string | null {
const a = this.pages[anchor];
// New pages default to A4, oriented like their neighbour when it's a plain page.
const size = a && !a.origin && a.width > a.height ? { width: A4.height, height: A4.width } : A4;
const id = newId();
return this.insertPages(anchor, dir, [{ id, ...size, markdown: '', strokes: [] }]) ? id : null;
}
/** A fresh canvas: one plain page with nothing on it. */
get isBlank() {
const ids = this.order;
const p = ids.length === 1 ? this.pages[ids[0]] : null;
return !!p && !p.origin && !p.markdown.trim() && p.strokes.length === 0;
}
/** Replace every page with a new trunk (importing into a blank canvas). */
replaceWith(pages: PageData[]) {
if (pages.length === 0) return;
const models = Object.fromEntries(pages.map((p) => [p.id, new PageModel(p)]));
this.treeCommand(withTrunk(pages.map((p) => p.id)), models, pages[0].id);
}
deletePage(id: string) {
const { tree, removed } = remove(this.tree, id);
if (removed.length === 0) return;
const models = { ...this.pages };
for (const r of removed) delete models[r];
let nextTree = tree;
let active = this.activeId && models[this.activeId] ? this.activeId : (tree.trunk[0] ?? null);
// A canvas always keeps at least one page.
if (tree.trunk.length === 0) {
const fresh = newId();
models[fresh] = new PageModel({ id: fresh, ...A4, markdown: '', strokes: [] });
nextTree = withTrunk([fresh]);
active = fresh;
}
this.treeCommand(nextTree, models, active);
}
resizePage(id: string, width: number, height: number) {
const page = this.pages[id];
if (!page || page.origin) return;
const before = { width: page.width, height: page.height };
const after = { width, height };
const set = (s: typeof before) => {
const p = this.pages[id];
p.width = s.width;
p.height = s.height;
};
this.exec({ redo: () => set(after), undo: () => set(before) });
}
}

View file

@ -0,0 +1,38 @@
// A single app-wide context menu.
import type { Component } from 'svelte';
export type MenuItem =
| { label: string; icon?: Component; action: () => void; danger?: boolean; disabled?: boolean; hint?: string }
| 'separator';
class Menu {
open = $state(false);
x = $state(0);
y = $state(0);
items = $state.raw<MenuItem[]>([]);
/** Grow upward from (x, y) — for menus opened from the bottom toolbar. */
above = $state(false);
show(x: number, y: number, items: MenuItem[]) {
this.above = false;
this.x = x;
this.y = y;
this.items = items;
this.open = true;
}
/** Open below/beside an anchor element. */
showAt(el: Element, items: MenuItem[], placement: 'below' | 'above' = 'below') {
const r = el.getBoundingClientRect();
this.show(r.left, placement === 'below' ? r.bottom + 4 : r.top - 4, items);
this.above = placement === 'above';
}
close() {
this.open = false;
this.above = false;
}
}
export const menu = new Menu();

View file

@ -0,0 +1,93 @@
// User settings, persisted in localStorage (including the GitHub token —
// by design there is no backend holding it).
import { DEFAULT_PALM, type PalmOptions } from '$lib/ink/palm';
export type ThemePref = 'system' | 'light' | 'dark';
export type PageStylePref = 'paper' | 'match';
export interface GitHubSettings {
token: string;
owner: string;
repo: string;
branch: string;
/** Folder inside the repo that holds the vault ('' = repo root). */
dir: string;
}
interface SettingsData {
theme: ThemePref;
pageStyle: PageStylePref;
fingerDraw: boolean;
palm: PalmOptions;
github: GitHubSettings;
/** Minutes between automatic pushes (0 = manual only). */
pushInterval: number;
penColors: string[];
highlighterColors: string[];
}
const DEFAULTS: SettingsData = {
theme: 'system',
pageStyle: 'match',
fingerDraw: true,
palm: DEFAULT_PALM,
github: { token: '', owner: '', repo: '', branch: 'main', dir: '' },
pushInterval: 5,
penColors: ['#1f1f1f', '#2563eb', '#dc2626', '#16a34a', '#9333ea'],
highlighterColors: ['#ffd400', '#7ee081', '#6ec5ff', '#ff8ad8']
};
const KEY = 'papure:settings';
function load(): SettingsData {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return structuredClone(DEFAULTS);
const saved = JSON.parse(raw);
return {
...DEFAULTS,
...saved,
palm: { ...DEFAULTS.palm, ...saved.palm },
github: { ...DEFAULTS.github, ...saved.github }
};
} catch {
return structuredClone(DEFAULTS);
}
}
class Settings {
data = $state<SettingsData>(load());
private systemDark = $state(false);
constructor() {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
this.systemDark = mq.matches;
mq.addEventListener('change', (e) => (this.systemDark = e.matches));
}
get dark() {
return this.data.theme === 'dark' || (this.data.theme === 'system' && this.systemDark);
}
get githubReady() {
const g = this.data.github;
return !!(g.token && g.owner && g.repo && g.branch);
}
save() {
try {
localStorage.setItem(KEY, JSON.stringify($state.snapshot(this.data)));
} catch {
// Storage unavailable (private mode) — settings last for this session only.
}
}
toggleTheme() {
this.data.theme = this.dark ? 'light' : 'dark';
this.save();
}
}
export const settings = new Settings();

View file

@ -0,0 +1,43 @@
import type { PageData } from '$lib/model/types';
import { settings } from './settings.svelte';
export type Tool = 'text' | 'pen' | 'highlighter' | 'eraser';
export const PEN_WIDTHS = [1.2, 2, 3.5];
export const HIGHLIGHTER_WIDTHS = [10, 16, 24];
class Tools {
tool = $state<Tool>('text');
penColor = $state(settings.data.penColors[0]);
penWidth = $state(PEN_WIDTHS[1]);
highlighterColor = $state(settings.data.highlighterColors[0]);
highlighterWidth = $state(HIGHLIGHTER_WIDTHS[1]);
eraserRadius = $state(8);
/** Pages of an imported PDF waiting to be placed on the canvas. */
placing = $state.raw<{ name: string; pages: PageData[] } | null>(null);
get inking() {
return this.tool !== 'text';
}
get color() {
return this.tool === 'highlighter' ? this.highlighterColor : this.penColor;
}
set color(c: string) {
if (this.tool === 'highlighter') this.highlighterColor = c;
else this.penColor = c;
}
get width() {
return this.tool === 'highlighter' ? this.highlighterWidth : this.penWidth;
}
set width(w: number) {
if (this.tool === 'highlighter') this.highlighterWidth = w;
else this.penWidth = w;
}
}
export const tools = new Tools();

View file

@ -0,0 +1,59 @@
// Pan/zoom of the canvas view. World units are PDF points.
import type { Rect } from '$lib/model/layout';
export const MIN_SCALE = 0.04;
export const MAX_SCALE = 8;
class Viewport {
/** Screen position of the world origin, in CSS px. */
x = $state(0);
y = $state(0);
scale = $state(1);
/** Size of the stage element. */
width = $state(0);
height = $state(0);
toWorld(sx: number, sy: number) {
return { x: (sx - this.x) / this.scale, y: (sy - this.y) / this.scale };
}
toScreen(wx: number, wy: number) {
return { x: wx * this.scale + this.x, y: wy * this.scale + this.y };
}
/** The visible world rectangle. */
get world(): Rect {
return { x: -this.x / this.scale, y: -this.y / this.scale, width: this.width / this.scale, height: this.height / this.scale };
}
zoomAt(factor: number, sx = this.width / 2, sy = this.height / 2) {
const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, this.scale * factor));
const w = this.toWorld(sx, sy);
this.scale = next;
this.x = sx - w.x * next;
this.y = sy - w.y * next;
}
panBy(dx: number, dy: number) {
this.x += dx;
this.y += dy;
}
/** Fit a world rectangle into the stage with some padding. */
fit(r: Rect, pad = 24) {
if (!this.width || !this.height) return;
const s = Math.min((this.width - 2 * pad) / r.width, (this.height - 2 * pad) / r.height);
this.scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
this.x = this.width / 2 - (r.x + r.width / 2) * this.scale;
this.y = this.height / 2 - (r.y + r.height / 2) * this.scale;
}
/** Pan so the rectangle is centred, keeping the zoom. */
center(r: Rect) {
this.x = this.width / 2 - (r.x + r.width / 2) * this.scale;
this.y = this.height / 2 - (r.y + r.height / 2) * this.scale;
}
}
export const viewport = new Viewport();

View file

@ -0,0 +1,95 @@
// App-level state: which canvas is open, autosave, sidebar, notifications.
import { CanvasDoc } from './doc.svelte';
import { vault } from '$lib/storage/vault.svelte';
import { kvGet, kvSet } from '$lib/storage/db';
import { sync } from '$lib/sync/sync.svelte';
export type Panel = 'files' | 'search';
const AUTOSAVE_MS = 700;
class Workspace {
doc = $state.raw<CanvasDoc | null>(null);
panel = $state<Panel | null>('files');
settingsOpen = $state(false);
toasts = $state<{ id: number; text: string; kind: 'info' | 'error' }[]>([]);
/** Page to reveal once the open canvas is laid out (search hits). */
focusPage = $state<string | null>(null);
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private savedVersion = 0;
private toastId = 0;
async init() {
await vault.init();
sync.hooks = {
beforeSync: () => this.flush(),
afterPull: async (changed) => {
const path = this.doc?.path;
if (!path || !changed.includes(path)) return;
if (vault.files.some((f) => f.path === path)) await this.open(path, { keepView: true });
else this.close();
}
};
const last = await kvGet<string>('lastOpen');
if (last && vault.files.some((f) => f.path === last)) await this.open(last);
}
async open(path: string, opts: { keepView?: boolean; page?: string } = {}) {
if (this.doc?.path === path && !opts.keepView) {
if (opts.page) this.focusPage = opts.page;
return;
}
await this.flush();
const data = await vault.loadDoc(path);
if (!data) return this.toast(`Could not open ${path}`, 'error');
const doc = new CanvasDoc(path, data);
this.savedVersion = 0;
this.doc = doc;
this.focusPage = opts.page ?? null;
await kvSet('lastOpen', path);
}
/** Close without saving (used after the open canvas was deleted). */
close() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = null;
this.doc = null;
void kvSet('lastOpen', null);
}
/** Called by the canvas view whenever doc.version changes. */
scheduleSave() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => void this.flush(), AUTOSAVE_MS);
}
async flush() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = null;
const doc = this.doc;
if (!doc || doc.version === this.savedVersion) return;
const version = doc.version;
await vault.saveDoc(doc.path, doc.toData());
if (this.doc === doc) this.savedVersion = version;
}
/** Keep the open document pointed at its new path after a rename/move. */
async renamed(from: string, to: string) {
const doc = this.doc;
if (!doc) return;
if (doc.path === from || doc.path.startsWith(from + '/')) {
doc.path = to + doc.path.slice(from.length);
await kvSet('lastOpen', doc.path);
}
}
toast(text: string, kind: 'info' | 'error' = 'info') {
const id = ++this.toastId;
this.toasts.push({ id, text, kind });
setTimeout(() => (this.toasts = this.toasts.filter((t) => t.id !== id)), kind === 'error' ? 6000 : 3000);
}
}
export const workspace = new Workspace();