diff --git a/README.md b/README.md index 0cbf95a..824eec3 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,7 @@ 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 "+" 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. +- **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. diff --git a/spec.md b/spec.md index c2f5235..5125de1 100644 --- a/spec.md +++ b/spec.md @@ -9,11 +9,10 @@ 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. -- 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. +- 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 (page menu: presets, or a custom size in mm). +- 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) @@ -46,7 +45,7 @@ Canvases (PDF files) live in a **file tree** you define, e.g. `school/english/le ## 5. Sync & Storage -- Repo-backed (GitHub for now; GitLab not implemented), one file per canvas. +- 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). @@ -55,7 +54,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**: IndexedDB locally; Git repo via the GitHub REST API (token in the browser, no proxy). +- **Storage**: Git repo via GitHub/GitLab API (or local backend proxy holding the token). ## 7. Confirmed Product Features @@ -80,23 +79,12 @@ 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. -**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. +**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 -- [ ] 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. +- [ ] Nothing blocking — ready to move into implementation planning (component breakdown, page-tree data structures in Svelte, PDF encode/decode module) diff --git a/src/app.css b/src/app.css index 912d602..76440c5 100644 --- a/src/app.css +++ b/src/app.css @@ -61,7 +61,6 @@ --shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08); --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; @@ -89,7 +88,6 @@ --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.35); --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/lib/components/CanvasView.svelte b/src/lib/components/CanvasView.svelte index 6031c8a..f0ad3cb 100644 --- a/src/lib/components/CanvasView.svelte +++ b/src/lib/components/CanvasView.svelte @@ -1,6 +1,5 @@ - - 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/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index a6be752..6b92676 100644 --- a/src/lib/components/SettingsModal.svelte +++ b/src/lib/components/SettingsModal.svelte @@ -83,30 +83,12 @@ -
-
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.
diff --git a/src/lib/ink/fling.test.ts b/src/lib/ink/fling.test.ts deleted file mode 100644 index ec9ef97..0000000 --- a/src/lib/ink/fling.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 2b2a689..0000000 --- a/src/lib/ink/fling.ts +++ /dev/null @@ -1,41 +0,0 @@ -// 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 add4644..473358c 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, touchIntent } from './palm'; +import { PalmRejector } 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,37 +53,4 @@ 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 2c9a233..7030b2b 100644 --- a/src/lib/ink/palm.ts +++ b/src/lib/ink/palm.ts @@ -27,15 +27,6 @@ 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 ceb10c2..4fa5724 100644 --- a/src/lib/state/settings.svelte.ts +++ b/src/lib/state/settings.svelte.ts @@ -6,12 +6,6 @@ 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; @@ -24,10 +18,7 @@ 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). */ @@ -39,9 +30,7 @@ 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, @@ -59,7 +48,6 @@ function load(): SettingsData { return { ...DEFAULTS, ...saved, - grid: { ...DEFAULTS.grid, ...saved.grid }, palm: { ...DEFAULTS.palm, ...saved.palm }, github: { ...DEFAULTS.github, ...saved.github } };