diff --git a/.dockerignore b/.dockerignore index 12e029c..48e2821 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,8 @@ -.git +# Keep just enough of .git for the build to read the current commit. +.git/* +!.git/HEAD +!.git/refs +!.git/packed-refs .claude node_modules .svelte-kit diff --git a/README.md b/README.md index 824eec3..0cbf95a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ The image builds the PWA and serves it with nginx. Put an HTTPS reverse proxy in ## 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. +- **Pages**: the "+" circles beside the active page add a page there (a quick finger flick toward a free side offers one there; turn it off under Settings → Pen & touch); `Alt+Arrow` moves to (or creates) the neighbour. Right-click a page or use its `⋯` button to insert, resize or delete it. +- **Grid**: the faint background grid has one cell per active page; Settings → Appearance can hide it or subdivide 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. diff --git a/spec.md b/spec.md index 5125de1..c2f5235 100644 --- a/spec.md +++ b/spec.md @@ -9,10 +9,11 @@ One canvas = one tree = one `.pdf` file. - 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. +- Each free side of the active page shows a small "+" circle just off its edge; tapping it (or `Alt+Arrow`) adds a blank page there. A quick finger fling toward a free side offers a page there for a few seconds: that side's "+" pulses, or, if it is off screen, an "Add page" button appears at that edge of the screen. The fling never adds a page by itself, and can be turned off in Settings. +- The canvas background is a faint grid with one cell per active page, aligned to its edges; Settings can hide it or subdivide it. - 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. +- Pages default to A4, or auto-match the aspect ratio of an imported PDF page; resizable manually otherwise (page menu: presets, or a custom size in mm). ## 2. Flatten Algorithm (tree → linear page order, e.g. for PDF page order / print) @@ -45,7 +46,7 @@ Canvases (PDF files) live in a **file tree** you define, e.g. `school/english/le ## 5. Sync & Storage -- Repo-backed (GitHub/GitLab), one file per canvas. +- Repo-backed (GitHub for now; GitLab not implemented), 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). @@ -54,7 +55,7 @@ Canvases (PDF files) live in a **file tree** you define, e.g. `school/english/le - **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). +- **Storage**: IndexedDB locally; Git repo via the GitHub REST API (token in the browser, no proxy). ## 7. Confirmed Product Features @@ -79,12 +80,23 @@ Source: user-provided `palm-rejection-test.html` test harness. The acceptance po - 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. +**How the app applies it** (`src/lib/ink/palm.ts`): +- Each touch is evaluated with an intent. `draw` (finger ink) gets the full policy above. `navigate` (pan, pinch, and taps on canvas buttons such as the add-page "+") skips the 5 s pen session and the hover lockout, so fingers can move around and tap between pen strokes; pen-down, wide contacts and the 150 ms window still reject it. +- Once a pen has been used, fingers only navigate. "Draw with finger" in Settings turns finger ink off entirely. +- Each rule (pen session, wide contact, timing, hover) can be switched off in Settings → Pen & touch. +- An interrupted pointer (`pointercancel`) drops the stroke or erase in progress instead of committing it. + +**Ink engine (implemented)**: +- `StrokeRecorder.recordPoint()` stores `x, y, pressure, tiltX, tiltY, t` per sample in a flat `Stroke.points` array (stride 6); rendering replays it. Tilt is recorded but not yet used for rendering. +- Outlines are smoothed with `perfect-freehand`; mouse and finger strokes simulate pressure. +- Pen, highlighter (translucent, no thinning) and stroke eraser, with configurable colour palettes; the pen's eraser end erases too. +- Undo/redo covers ink, erasing and page-tree changes. +- Strokes are written to the PDF both as Ink annotations and as JSON attachments. ## 10. Open Items -- [ ] Nothing blocking — ready to move into implementation planning (component breakdown, page-tree data structures in Svelte, PDF encode/decode module) +- [ ] Conflict copies: sync currently lets the newer version win silently; keep the losing version as a separate file. +- [ ] Tell the user when a new version of the app has been deployed (the service worker updates, but the open tab keeps old code until reload). +- [ ] CI that runs `check`, `test` and `build` on every pull request. +- [ ] Use recorded tilt for rendering. +- [ ] GitLab sync. diff --git a/src/app.css b/src/app.css index 8b7ba45..912d602 100644 --- a/src/app.css +++ b/src/app.css @@ -59,7 +59,9 @@ --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); + --page-line: rgba(0, 0, 0, 0.18); + --grid-line: rgba(0, 0, 0, 0.05); + --grid-line-minor: rgba(0, 0, 0, 0.028); --ghost: rgba(0, 0, 0, 0.045); --ghost-border: rgba(0, 0, 0, 0.12); --radius: 6px; @@ -85,7 +87,9 @@ --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); + --page-line: rgba(255, 255, 255, 0.16); + --grid-line: rgba(255, 255, 255, 0.04); + --grid-line-minor: rgba(255, 255, 255, 0.022); --ghost: rgba(255, 255, 255, 0.04); --ghost-border: rgba(255, 255, 255, 0.14); color-scheme: dark; diff --git a/src/app.d.ts b/src/app.d.ts index be79b78..4b5ae00 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,9 @@ // See https://svelte.dev/docs/kit/types#app.d.ts declare global { namespace App {} + + /** Stamped in by vite.config.ts at build time. */ + const __BUILD__: { version: string; commit: string; dirty: boolean; time: string }; } export {}; diff --git a/src/lib/components/CanvasView.svelte b/src/lib/components/CanvasView.svelte index 4f05d39..6031c8a 100644 --- a/src/lib/components/CanvasView.svelte +++ b/src/lib/components/CanvasView.svelte @@ -16,10 +16,12 @@ import { bounds, intersects, rectBeside, type Rect } from '$lib/model/layout'; import { canInsert, freeSlots, freeSlotsOf, neighbor, type Dir, type Slot } from '$lib/model/tree'; import { A4, PAGE_PRESETS, type Stroke } from '$lib/model/types'; - import { PalmRejector } from '$lib/ink/palm'; + import { PalmRejector, touchIntent } from '$lib/ink/palm'; + import { flingDir, pushSample, type Sample } from '$lib/ink/fling'; import { StrokeRecorder, hitStroke } from '$lib/ink/stroke'; import { pageCssVars } from '$lib/editor/pageStyle'; import { downloadCanvas } from './download'; + import PageSizeDialog from './PageSizeDialog.svelte'; import PageView from './PageView.svelte'; import Toolbar from './Toolbar.svelte'; @@ -28,6 +30,30 @@ let stage: HTMLDivElement; const cssVars = pageCssVars(); + // Background grid: one cell per active page, aligned to its edges (A4 at the origin if none), + // optionally split into fainter subdivisions. + const gridStyle = $derived.by(() => { + const g = settings.data.grid; + if (!g.show) return ''; + const r = (doc.activeId && doc.rects.get(doc.activeId)) || { x: 0, y: 0, ...A4 }; + const s = viewport.scale; + const w = r.width * s; + const h = r.height * s; + const lines = (color: string) => [ + `linear-gradient(to right, ${color} 1px, transparent 1px)`, + `linear-gradient(to bottom, ${color} 1px, transparent 1px)` + ]; + const images = lines('var(--grid-line)'); + const sizes = [`${w}px ${h}px`, `${w}px ${h}px`]; + // Leave out subdivisions once they'd be too dense to read. + const n = g.divisions; + if (n > 1 && Math.min(w, h) / n >= 8) { + images.push(...lines('var(--grid-line-minor)')); + sizes.push(`${w / n}px ${h / n}px`, `${w / n}px ${h / n}px`); + } + return `background-image: ${images.join(', ')}; background-size: ${sizes.join(', ')}; background-position: ${viewport.x + r.x * s}px ${viewport.y + r.y * s}px`; + }); + // ---- autosave -------------------------------------------------------- $effect(() => { if (doc.version > 0) untrack(() => workspace.scheduleSave()); @@ -48,6 +74,7 @@ return () => { ro.disconnect(); stage.removeEventListener('wheel', onWheel); + clearTimeout(offerTimer); }; }); @@ -92,6 +119,34 @@ const placeSlots = $derived(tools.placing ? freeSlots(doc.tree) : []); + // A finger fling toward a free side of the active page offers a new page there: the plus + // pulses if it's on screen, otherwise a button appears at that edge of the screen. + let offer = $state(null); + let offerTimer: ReturnType | undefined; + + function setOffer(slot: Slot | null) { + clearTimeout(offerTimer); + offer = slot; + if (slot) offerTimer = setTimeout(() => (offer = null), 3000); + } + + function offerAfterFling(samples: Sample[]) { + if (!settings.data.flingToAdd) return; + const dir = flingDir(samples); + const id = doc.activeId; + const slot = dir && ghostSlots.find((s) => s.anchor === id && s.dir === dir); + if (slot) setOffer(slot); + } + + /** The offered plus is off screen, so the offer shows as an edge-of-screen button instead. */ + const offerOffscreen = $derived.by(() => { + const r = offer && plusRect(offer); + if (!r) return false; + const a = viewport.toScreen(r.x, r.y); + const b = viewport.toScreen(r.x + r.width, r.y + r.height); + return b.x < 0 || b.y < 0 || a.x > viewport.width || a.y > viewport.height; + }); + // Show every possible spot when an imported PDF is waiting to be placed. $effect(() => { if (!tools.placing) return; @@ -111,6 +166,22 @@ return rectBeside(a, slot.dir, s); } + /** + * Hit area of the add-page button: 48 px square touching the middle of the anchor's + * free edge. The visible 32 px circle sits centred in it, 8 px off the page. + */ + function plusRect(slot: Slot): Rect | null { + const a = doc.rectOf(slot.anchor); + if (!a) return null; + const d = 48 / viewport.scale; + const cx = a.x + (a.width - d) / 2; + const cy = a.y + (a.height - d) / 2; + if (slot.dir === 'right') return { x: a.x + a.width, y: cy, width: d, height: d }; + if (slot.dir === 'left') return { x: a.x - d, y: cy, width: d, height: d }; + if (slot.dir === 'down') return { x: cx, y: a.y + a.height, width: d, height: d }; + return { x: cx, y: a.y - d, width: d, height: d }; + } + /** Run a tree change without the anchor page jumping on screen. */ function keepAnchor(anchor: string, fn: () => void) { const before = doc.rectOf(anchor); @@ -192,15 +263,9 @@ 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]))); - } + /** Page whose custom-size dialog is open. */ + let sizing = $state(null); + const customSize = (id: string) => (sizing = id); const activeMenuPos = $derived.by(() => { const id = doc.activeId; @@ -219,7 +284,8 @@ type Gesture = | { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder } | { kind: 'erase'; pointerId: number; pointerType: string; hits: Map> } - | { kind: 'pan'; pointerId: number; lastX: number; lastY: number; startX: number; startY: number; moved: boolean; tap: boolean } + | { kind: 'pan'; pointerId: number; pointerType: string; lastX: number; lastY: number; startX: number; startY: number; moved: boolean; tap: boolean; trail: Sample[] } + | { kind: 'add'; pointerId: number; pointerType: string; startX: number; startY: number; slot: Slot } | { kind: 'pinch' }; let gesture: Gesture | null = null; @@ -267,14 +333,18 @@ function onPointerDown(e: PointerEvent) { palm.track(e); if (menu.open) menu.close(); + if (offer) setOffer(null); const target = e.target as Element; if (target.closest('.no-stage')) return; const p = local(e); // Fingers ink only when enabled and no pen has been used; otherwise they pan/zoom. const fingerInks = tools.inking && settings.data.fingerDraw && !palm.penSeen; + // Taps on an add-page button are palm-checked like navigation: still rejected for + // pen-down, palm-sized contacts and right after pen use, but not for the 5 s pen session. + const plus = target.closest('.ghost, .offer-pill'); if (e.pointerType === 'touch') { - const decision = palm.evaluate(e, fingerInks ? 'draw' : 'navigate'); + const decision = palm.evaluate(e, touchIntent(fingerInks, !!plus)); if (!decision.accept) return; // palm: ignore entirely touches.set(e.pointerId, p); if (touches.size >= 2) { @@ -285,6 +355,14 @@ } } + if (plus && e.button === 0) { + e.preventDefault(); + const slot = { anchor: plus.dataset.anchor!, dir: plus.dataset.dir as Dir }; + gesture = { kind: 'add', pointerId: e.pointerId, pointerType: e.pointerType, startX: p.x, startY: p.y, slot }; + capture(e.pointerId); + return; + } + const onPage = pageAt(p.x, p.y); const inEditor = !!target.closest('.cm-editor'); // Pressing anywhere outside the text leaves the editor (preventDefault @@ -296,10 +374,21 @@ e.button === 1 || spaceDown || (e.pointerType === 'touch' && (!fingerInks || !onPage)) || - (e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.ghost, .slot')); + (e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.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') }; + gesture = { + kind: 'pan', + pointerId: e.pointerId, + pointerType: e.pointerType, + lastX: p.x, + lastY: p.y, + startX: p.x, + startY: p.y, + moved: false, + tap: inEditor || !!target.closest('.slot'), + trail: [{ ...p, t: e.timeStamp }] + }; if (e.pointerType !== 'touch' || !gesture.tap) { if (e.pointerType !== 'touch') e.preventDefault(); capture(e.pointerId); @@ -358,16 +447,23 @@ viewport.panBy(dx, dy); gesture.lastX = p.x; gesture.lastY = p.y; + pushSample(gesture.trail, { ...p, t: e.timeStamp }); 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) { + if (gesture.pointerType === 'touch' && !palm.evaluate(e, gesture.kind === 'add' ? 'navigate' : 'draw').accept) { cancelGesture(); return; } + if (gesture.kind === 'add') { + // Dragging off the button is not a tap. + if (Math.hypot(p.x - gesture.startX, p.y - gesture.startY) > 12) gesture = null; + return; + } + if (gesture.kind === 'erase') { eraseAt(e); return; @@ -395,11 +491,19 @@ if (!gesture || gesture.pointerId !== e.pointerId) return; const g = gesture; gesture = null; - if (g.kind === 'draw') { + if (g.kind === 'pan') { + if (e.type === 'pointerup' && g.moved && g.pointerType === 'touch') { + pushSample(g.trail, { ...local(e), t: e.timeStamp }); + offerAfterFling(g.trail); + } + } else if (g.kind === 'add') { + if (e.type === 'pointerup' && (g.pointerType !== 'touch' || palm.evaluate(e, 'navigate').accept)) addPage(g.slot.anchor, g.slot.dir); + } else 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))); + // Like ink, an interrupted (pointercancel) erase is dropped. + if (e.type === 'pointerup') for (const [pageId, ids] of g.hits) doc.setStrokes(pageId, (s) => s.filter((x) => !ids.has(x.id))); erasing = new Set(); } } @@ -463,7 +567,7 @@ } function onKeyDown(e: KeyboardEvent) { - if (workspace.settingsOpen) return; + if (workspace.settingsOpen || sizing) return; const mod = e.ctrlKey || e.metaKey; const k = e.key.toLowerCase(); @@ -522,6 +626,7 @@ class:panning class:erasing={tools.tool === 'eraser'} bind:this={stage} + style={gridStyle} onpointerdown={onPointerDown} onpointermove={onPointerMove} onpointerup={onPointerUp} @@ -556,17 +661,19 @@ {/each} {#each ghostSlots as slot (slot.anchor + slot.dir)} - {@const r = slotRect(slot)} + {@const r = plusRect(slot)} {#if r} + {/if} {/each} @@ -604,6 +711,20 @@ {/if} + {#if offer && offerOffscreen} + + + {/if} + {#if eraserAt && tools.tool === 'eraser'}
{/if} @@ -616,6 +737,19 @@ {/if} + {#if sizing && doc.pages[sizing]} + {@const id = sizing} + (sizing = null)} + onapply={(w, h) => { + sizing = null; + keepAnchor(id, () => doc.resizePage(id, w, h)); + }} + /> + {/if} + fitPage(doc.activeId)} onfitall={fitAll} /> @@ -660,7 +794,7 @@ touch-action: none; user-select: none; -webkit-user-select: none; - background: var(--bg-canvas); + background-color: var(--bg-canvas); } .stage :global(.cm-content) { user-select: text; @@ -685,7 +819,79 @@ top: 0; transform-origin: 0 0; } - .ghost, + .ghost { + position: absolute; + display: grid; + place-items: center; + background: none; + border: 0; + padding: 0; + color: var(--faint); + cursor: pointer; + transition: color 0.12s; + } + .ghost:hover, + .ghost:focus-visible { + color: var(--accent); + } + .ghost .plus { + display: grid; + width: calc(32px / var(--s)); + height: calc(32px / var(--s)); + border-radius: 50%; + background: var(--bg-canvas); + } + .ghost.offer { + color: var(--accent); + } + .ghost.offer .plus { + animation: offer 1s ease-in-out infinite; + } + @keyframes offer { + 50% { + transform: scale(1.3); + } + } + .offer-pill { + position: absolute; + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px 10px 12px; + border: 1px solid var(--accent); + border-radius: 999px; + background: var(--bg); + color: var(--accent); + box-shadow: var(--shadow); + cursor: pointer; + white-space: nowrap; + } + .offer-pill[data-side='right'] { + right: 12px; + top: 50%; + transform: translateY(-50%); + } + .offer-pill[data-side='left'] { + left: 12px; + top: 50%; + transform: translateY(-50%); + } + .offer-pill[data-side='up'] { + top: 12px; + left: 50%; + transform: translateX(-50%); + } + .offer-pill[data-side='down'] { + /* Clear of the toolbar. */ + bottom: 72px; + left: 50%; + transform: translateX(-50%); + } + @media (prefers-reduced-motion: reduce) { + .ghost.offer .plus { + animation: none; + } + } .slot { position: absolute; display: grid; @@ -698,7 +904,6 @@ padding: 0; transition: background 0.12s, color 0.12s; } - .ghost:hover, .slot:hover { background: var(--accent-soft); color: var(--accent); @@ -709,7 +914,6 @@ color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); } - .plus, .arrow { display: grid; width: var(--icon); diff --git a/src/lib/components/PageSizeDialog.svelte b/src/lib/components/PageSizeDialog.svelte new file mode 100644 index 0000000..de320df --- /dev/null +++ b/src/lib/components/PageSizeDialog.svelte @@ -0,0 +1,173 @@ + + + e.key === 'Escape' && onclose()} /> + + +
e.target === e.currentTarget && onclose()}> +
{ + e.preventDefault(); + apply(); + }} + > +

Page size

+
+ {#each PAGE_PRESETS as p (p.label)} + + {/each} +
+
+ + + + mm +
+ {#if !valid} +

Sizes must be between {toMm(MIN_PT)} and {toMm(MAX_PT)} mm.

+ {/if} +
+ + +
+
+
+ + diff --git a/src/lib/components/PageView.svelte b/src/lib/components/PageView.svelte index 51843b8..ad52f25 100644 --- a/src/lib/components/PageView.svelte +++ b/src/lib/components/PageView.svelte @@ -49,7 +49,8 @@ inset: 0; background: #ffffff; overflow: hidden; - box-shadow: var(--page-shadow); + /* Crisp 1 screen-px outline, no blur. */ + box-shadow: 0 0 0 calc(1px / var(--s)) var(--page-line); } .paper.invert { filter: invert(0.88) hue-rotate(180deg); diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index c296b04..a6be752 100644 --- a/src/lib/components/SettingsModal.svelte +++ b/src/lib/components/SettingsModal.svelte @@ -8,6 +8,7 @@ type Tab = 'appearance' | 'input' | 'sync'; let tab = $state('appearance'); const s = settings.data; + const build = __BUILD__; let testing = $state(false); let testResult = $state<{ ok: boolean; text: string } | null>(null); @@ -55,6 +56,9 @@ +
+ v{build.version} · {build.commit.slice(0, 7)}{build.dirty ? '-dirty' : ''} +
@@ -79,12 +83,30 @@ +
+
Background grid
Faint lines behind the pages, one cell per active page.
+ +
+
+
Grid subdivisions
Split each page-sized cell into a finer grid for sketching and lining things up.
+ +
{:else if tab === 'input'}

Pen & touch

Draw with finger
When off, a single finger always pans; only pen and mouse draw. Once you use a pen, fingers only pan and zoom.
+
+
Swipe to add pages
A quick one-finger flick toward a free side of the active page offers to add a page there.
+ +

Palm rejection

Pen session lockout
Ignore touches while the pen is writing, and finger ink for 5 seconds after it was used.
@@ -187,6 +209,14 @@ color: var(--faint); padding: 0 10px 8px; } + .build { + margin-top: auto; + padding: 0 10px; + font-family: var(--mono-font); + font-size: 11px; + color: var(--faint); + user-select: text; + } nav button { text-align: left; border: 0; diff --git a/src/lib/ink/fling.test.ts b/src/lib/ink/fling.test.ts new file mode 100644 index 0000000..ec9ef97 --- /dev/null +++ b/src/lib/ink/fling.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { flingDir, pushSample, type Sample } from './fling'; + +/** A straight drag from (0,0) by (dx,dy) over `ms`, sampled every 16 ms. */ +function drag(dx: number, dy: number, ms: number): Sample[] { + const out: Sample[] = []; + for (let t = 0; t <= ms; t += 16) pushSample(out, { x: (dx * t) / ms, y: (dy * t) / ms, t }); + return out; +} + +describe('flingDir', () => { + it('maps a flick to the side it reveals', () => { + expect(flingDir(drag(-200, 0, 96))).toBe('right'); + expect(flingDir(drag(200, 0, 96))).toBe('left'); + expect(flingDir(drag(0, -200, 96))).toBe('down'); + expect(flingDir(drag(0, 200, 96))).toBe('up'); + }); + + it('ignores slow drags', () => { + expect(flingDir(drag(-200, 0, 800))).toBeNull(); + }); + + it('ignores diagonal flicks', () => { + expect(flingDir(drag(-200, -150, 96))).toBeNull(); + }); + + it('ignores a drag that stops before lifting', () => { + const s = drag(-300, 0, 96); + const end = s.at(-1)!; + for (let t = 16; t <= 160; t += 16) pushSample(s, { ...end, t: end.t + t }); + expect(flingDir(s)).toBeNull(); + }); + + it('needs at least two samples', () => { + expect(flingDir([])).toBeNull(); + expect(flingDir([{ x: 0, y: 0, t: 0 }])).toBeNull(); + }); + + it('keeps the trail short', () => { + const s = drag(-2000, 0, 2000); + expect(s.at(-1)!.t - s[0].t).toBeLessThanOrEqual(216); + }); +}); diff --git a/src/lib/ink/fling.ts b/src/lib/ink/fling.ts new file mode 100644 index 0000000..2b2a689 --- /dev/null +++ b/src/lib/ink/fling.ts @@ -0,0 +1,41 @@ +// Fling detection for one-finger pans: a quick flick that ends the drag. + +import type { Dir } from '$lib/model/tree'; + +export interface Sample { + x: number; + y: number; + t: number; +} + +/** Only the last stretch of the drag counts. */ +const WINDOW_MS = 100; +/** px/ms; a slow drag that stops is not a fling. */ +const MIN_SPEED = 0.6; +/** The main axis must dominate by this much. */ +const MIN_RATIO = 2; + +/** + * Side of the canvas a fling heads for, or null. Moving the finger left pulls in + * what's on the right, so a leftward flick returns 'right'. + */ +export function flingDir(samples: readonly Sample[]): Dir | null { + const last = samples.at(-1); + if (!last) return null; + const first = samples.find((s) => last.t - s.t <= WINDOW_MS); + if (!first || first === last) return null; + const dt = last.t - first.t; + if (dt <= 0) return null; + const dx = last.x - first.x; + const dy = last.y - first.y; + if (Math.hypot(dx, dy) / dt < MIN_SPEED) return null; + if (Math.abs(dx) >= Math.abs(dy) * MIN_RATIO) return dx < 0 ? 'right' : 'left'; + if (Math.abs(dy) >= Math.abs(dx) * MIN_RATIO) return dy < 0 ? 'down' : 'up'; + return null; +} + +/** Keeps a short trail of pointer samples for flingDir. */ +export function pushSample(samples: Sample[], s: Sample) { + samples.push(s); + while (samples.length > 2 && s.t - samples[0].t > WINDOW_MS * 2) samples.shift(); +} diff --git a/src/lib/ink/palm.test.ts b/src/lib/ink/palm.test.ts index 473358c..add4644 100644 --- a/src/lib/ink/palm.test.ts +++ b/src/lib/ink/palm.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { PalmRejector } from './palm'; +import { PalmRejector, touchIntent } from './palm'; const ev = (type: string, pointerType: string, extra: Partial = {}) => ({ type, pointerType, pointerId: pointerType === 'pen' ? 1 : 2, width: 10, height: 10, ...extra }) as PointerEvent; @@ -53,4 +53,37 @@ describe('palm rejection', () => { it('allows finger ink again once the pen session ends', () => { expect(afterPen(5100).evaluate(touch(), 'draw').accept).toBe(true); }); + + describe('add-page button taps', () => { + // With a pen in use fingers don't ink, but finger ink may still be on (before any pen). + const tap = (palm: PalmRejector, extra: Partial = {}, fingerInks = false) => + palm.evaluate(touch(extra), touchIntent(fingerInks, true)); + + it('are handled as navigation even when fingers ink', () => { + expect(touchIntent(true, true)).toBe('navigate'); + expect(touchIntent(false, true)).toBe('navigate'); + expect(touchIntent(true, false)).toBe('draw'); + expect(touchIntent(false, false)).toBe('navigate'); + }); + + it('are accepted a couple of seconds after writing', () => { + expect(tap(afterPen(2000)).accept).toBe(true); + expect(tap(afterPen(2000), {}, true).accept).toBe(true); + }); + + it('are accepted while the pen hovers', () => { + expect(tap(afterPen(2000, { leave: false })).accept).toBe(true); + }); + + it('are rejected while the pen is down', () => { + const palm = new PalmRejector(); + palm.track(ev('pointerdown', 'pen')); + expect(tap(palm).accept).toBe(false); + }); + + it('are rejected for a palm or right after the pen', () => { + expect(tap(afterPen(2000), { width: 60, height: 40 }).accept).toBe(false); + expect(tap(afterPen(50)).accept).toBe(false); + }); + }); }); diff --git a/src/lib/ink/palm.ts b/src/lib/ink/palm.ts index 7030b2b..2c9a233 100644 --- a/src/lib/ink/palm.ts +++ b/src/lib/ink/palm.ts @@ -27,6 +27,15 @@ const PALM_CONTACT_PX = 35; /** What an accepted touch would do: ink (draw/erase) or pan/zoom. */ export type TouchIntent = 'draw' | 'navigate'; +/** + * Which policy a touch falls under. It only draws when finger ink is on and it + * didn't land on a button; taps on buttons (e.g. add page) navigate, so they keep + * working between pen strokes. + */ +export function touchIntent(fingerInks: boolean, onButton: boolean): TouchIntent { + return fingerInks && !onButton ? 'draw' : 'navigate'; +} + export interface Decision { accept: boolean; reason: string; diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts index 4fa5724..ceb10c2 100644 --- a/src/lib/state/settings.svelte.ts +++ b/src/lib/state/settings.svelte.ts @@ -6,6 +6,12 @@ import { DEFAULT_PALM, type PalmOptions } from '$lib/ink/palm'; export type ThemePref = 'system' | 'light' | 'dark'; export type PageStylePref = 'paper' | 'match'; +export interface GridSettings { + show: boolean; + /** Minor lines per page cell side (1 = page-sized cells only). */ + divisions: number; +} + export interface GitHubSettings { token: string; owner: string; @@ -18,7 +24,10 @@ export interface GitHubSettings { interface SettingsData { theme: ThemePref; pageStyle: PageStylePref; + grid: GridSettings; fingerDraw: boolean; + /** A finger fling toward a free side of the active page offers a new page there. */ + flingToAdd: boolean; palm: PalmOptions; github: GitHubSettings; /** Minutes between automatic pushes (0 = manual only). */ @@ -30,7 +39,9 @@ interface SettingsData { const DEFAULTS: SettingsData = { theme: 'system', pageStyle: 'match', + grid: { show: true, divisions: 1 }, fingerDraw: true, + flingToAdd: true, palm: DEFAULT_PALM, github: { token: '', owner: '', repo: '', branch: 'main', dir: '' }, pushInterval: 5, @@ -48,6 +59,7 @@ function load(): SettingsData { return { ...DEFAULTS, ...saved, + grid: { ...DEFAULTS.grid, ...saved.grid }, palm: { ...DEFAULTS.palm, ...saved.palm }, github: { ...DEFAULTS.github, ...saved.github } }; diff --git a/vite.config.ts b/vite.config.ts index 3f382a5..c94dbc9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,40 @@ +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vitest/config'; +/** Current commit. Uses git when available, else reads .git directly (the Docker build has no git binary). */ +function gitCommit(): { commit: string; dirty: boolean } { + if (process.env.GIT_COMMIT) return { commit: process.env.GIT_COMMIT, dirty: false }; + try { + const commit = execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); + const dirty = execSync('git status --porcelain --untracked-files=no', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() !== ''; + return { commit, dirty }; + } catch { + /* fall through */ + } + try { + const head = readFileSync('.git/HEAD', 'utf8').trim(); + if (!head.startsWith('ref: ')) return { commit: head, dirty: false }; + const ref = head.slice(5); + if (existsSync(`.git/${ref}`)) return { commit: readFileSync(`.git/${ref}`, 'utf8').trim(), dirty: false }; + const packed = readFileSync('.git/packed-refs', 'utf8').split('\n').find((l) => l.endsWith(` ${ref}`)); + if (packed) return { commit: packed.split(' ')[0], dirty: false }; + } catch { + /* no repository */ + } + return { commit: 'unknown', dirty: false }; +} + +const build = { + version: JSON.parse(readFileSync('package.json', 'utf8')).version as string, + ...gitCommit(), + time: new Date().toISOString() +}; + export default defineConfig({ plugins: [sveltekit()], + define: { __BUILD__: JSON.stringify(build) }, test: { include: ['src/**/*.test.ts'], environment: 'node'