From 3ac278835b7a46236bcfc7c25188535350ea66c4 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 20:08:19 +0000 Subject: [PATCH 01/10] Show the build's version and commit in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vite.config.ts stamps the package version, git commit and build time into the bundle as __BUILD__, and the Settings sidebar shows "v0.1.0 · " (full sha and build time on hover). The Docker build has no git binary, so the commit is read from .git/HEAD and the refs directly; .dockerignore now lets just those files through. GIT_COMMIT in the environment overrides detection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia --- .dockerignore | 6 ++++- src/app.d.ts | 3 +++ src/lib/components/SettingsModal.svelte | 12 ++++++++++ vite.config.ts | 32 +++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) 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/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/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index c296b04..6b92676 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' : ''} +
@@ -187,6 +191,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/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' From 4cc2f1ae74d17f1a0929523f6811d4384d36b3c4 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 20:50:52 +0000 Subject: [PATCH 02/10] Use a plus-circle button for adding pages Replace the add-page bar with a 24 px plus-circle button centred on each free edge of the active page, 6 px off the edge (constant in screen pixels). Taps still go through the palm-checked gesture. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia --- src/lib/components/CanvasView.svelte | 80 ++++++++++------------------ 1 file changed, 29 insertions(+), 51 deletions(-) diff --git a/src/lib/components/CanvasView.svelte b/src/lib/components/CanvasView.svelte index f0ad3cb..add6cb6 100644 --- a/src/lib/components/CanvasView.svelte +++ b/src/lib/components/CanvasView.svelte @@ -1,5 +1,6 @@ + + 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} +
+ + +
+
+
+ + From 3d29255d0c6f16e2ffd219dca9d2061fbd167758 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 21:31:12 +0000 Subject: [PATCH 08/10] Add background grid settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appearance now has a toggle for the background grid and a choice of subdivisions (none, 2×2 up to 16×16) that split each page-sized cell into fainter lines. Subdivisions are left out when zoomed so far out that they would be under 8 px apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia --- src/app.css | 2 ++ src/lib/components/CanvasView.svelte | 28 ++++++++++++++++++------- src/lib/components/SettingsModal.svelte | 14 +++++++++++++ src/lib/state/settings.svelte.ts | 9 ++++++++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/app.css b/src/app.css index 76440c5..912d602 100644 --- a/src/app.css +++ b/src/app.css @@ -61,6 +61,7 @@ --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; @@ -88,6 +89,7 @@ --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 5b2b27f..4746268 100644 --- a/src/lib/components/CanvasView.svelte +++ b/src/lib/components/CanvasView.svelte @@ -30,11 +30,28 @@ let stage: HTMLDivElement; const cssVars = pageCssVars(); - // Background grid: one cell per active page, aligned to its edges (A4 at the origin if none). - const grid = $derived.by(() => { + // 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; - return { w: r.width * s, h: r.height * s, x: viewport.x + r.x * s, y: viewport.y + r.y * s }; + 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 -------------------------------------------------------- @@ -605,7 +622,7 @@ class:panning class:erasing={tools.tool === 'eraser'} bind:this={stage} - style="background-size: {grid.w}px {grid.h}px; background-position: {grid.x}px {grid.y}px" + style={gridStyle} onpointerdown={onPointerDown} onpointermove={onPointerMove} onpointerup={onPointerUp} @@ -760,9 +777,6 @@ user-select: none; -webkit-user-select: none; background-color: var(--bg-canvas); - background-image: - linear-gradient(to right, var(--grid-line) 1px, transparent 1px), - linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px); } .stage :global(.cm-content) { user-select: text; diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index 6b92676..9c8f886 100644 --- a/src/lib/components/SettingsModal.svelte +++ b/src/lib/components/SettingsModal.svelte @@ -83,6 +83,20 @@ +
+
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

diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts index 4fa5724..92cee0b 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,6 +24,7 @@ export interface GitHubSettings { interface SettingsData { theme: ThemePref; pageStyle: PageStylePref; + grid: GridSettings; fingerDraw: boolean; palm: PalmOptions; github: GitHubSettings; @@ -30,6 +37,7 @@ interface SettingsData { const DEFAULTS: SettingsData = { theme: 'system', pageStyle: 'match', + grid: { show: true, divisions: 1 }, fingerDraw: true, palm: DEFAULT_PALM, github: { token: '', owner: '', repo: '', branch: 'main', dir: '' }, @@ -48,6 +56,7 @@ function load(): SettingsData { return { ...DEFAULTS, ...saved, + grid: { ...DEFAULTS.grid, ...saved.grid }, palm: { ...DEFAULTS.palm, ...saved.palm }, github: { ...DEFAULTS.github, ...saved.github } }; From 2df91b561de29a46d588d4e020144b4f5f09cd35 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 21:31:54 +0000 Subject: [PATCH 09/10] Bring spec and README up to date The spec still listed undo, the eraser, colours and smoothing as missing. Describe the ink engine and palm rejection as built (draw vs navigate intents, settings toggles, cancelled pointers), the plus buttons, fling offer, grid and page size dialog, note that sync is GitHub-only, and replace the stale open item with the real ones. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia --- README.md | 3 ++- spec.md | 32 ++++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 824eec3..a3149e3 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 fling past a free edge highlights that side's "+"); `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..aaedd63 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 past a free edge highlights that side's "+" for a few seconds, but never adds a page by itself. +- 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. From 5fb11e49286c29149cf9b1bba7dcabce675ef47c Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 21:43:09 +0000 Subject: [PATCH 10/10] Let the page fling work anywhere, with a setting to turn it off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fling toward a free side of the active page now offers a page at any zoom or pan position, not only once that edge is on screen. If the side's plus is visible it pulses as before; otherwise an "Add page" button appears at that edge of the screen, tapped through the same palm-checked gesture as the plus. Settings → Pen & touch gets "Swipe to add pages" (on by default). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia --- README.md | 2 +- spec.md | 2 +- src/lib/components/CanvasView.svelte | 71 +++++++++++++++++++++---- src/lib/components/SettingsModal.svelte | 4 ++ src/lib/state/settings.svelte.ts | 3 ++ 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a3149e3..0cbf95a 100644 --- a/README.md +++ b/README.md @@ -23,7 +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 fling past a free edge highlights that side's "+"); `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. diff --git a/spec.md b/spec.md index aaedd63..c2f5235 100644 --- a/spec.md +++ b/spec.md @@ -9,7 +9,7 @@ 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 past a free edge highlights that side's "+" for a few seconds, but never adds a page by itself. +- 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. diff --git a/src/lib/components/CanvasView.svelte b/src/lib/components/CanvasView.svelte index 4746268..6031c8a 100644 --- a/src/lib/components/CanvasView.svelte +++ b/src/lib/components/CanvasView.svelte @@ -119,7 +119,8 @@ const placeSlots = $derived(tools.placing ? freeSlots(doc.tree) : []); - // A finger fling past the active page's free edge highlights the plus on that side. + // 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; @@ -130,18 +131,21 @@ } function offerAfterFling(samples: Sample[]) { + if (!settings.data.flingToAdd) return; const dir = flingDir(samples); const id = doc.activeId; - const r = id && doc.rects.get(id); const slot = dir && ghostSlots.find((s) => s.anchor === id && s.dir === dir); - if (!r || !slot) return; - // Only once that edge has come into view, i.e. the fling went past the end. + 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); - const edge = { left: a.x, right: b.x, up: a.y, down: b.y }[slot.dir]; - const size = slot.dir === 'left' || slot.dir === 'right' ? viewport.width : viewport.height; - if (edge >= 0 && edge <= size) setOffer(slot); - } + 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(() => { @@ -338,7 +342,7 @@ 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'); + const plus = target.closest('.ghost, .offer-pill'); if (e.pointerType === 'touch') { const decision = palm.evaluate(e, touchIntent(fingerInks, !!plus)); if (!decision.accept) return; // palm: ignore entirely @@ -707,6 +711,20 @@ {/if} + {#if offer && offerOffscreen} + + + {/if} + {#if eraserAt && tools.tool === 'eraser'}
{/if} @@ -834,6 +852,41 @@ 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; diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index 9c8f886..a6be752 100644 --- a/src/lib/components/SettingsModal.svelte +++ b/src/lib/components/SettingsModal.svelte @@ -103,6 +103,10 @@
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/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts index 92cee0b..ceb10c2 100644 --- a/src/lib/state/settings.svelte.ts +++ b/src/lib/state/settings.svelte.ts @@ -26,6 +26,8 @@ interface SettingsData { 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,6 +41,7 @@ const DEFAULTS: SettingsData = { pageStyle: 'match', grid: { show: true, divisions: 1 }, fingerDraw: true, + flingToAdd: true, palm: DEFAULT_PALM, github: { token: '', owner: '', repo: '', branch: 'main', dir: '' }, pushInterval: 5,