Init
This commit is contained in:
commit
0ad405c26e
78 changed files with 9127 additions and 0 deletions
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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue