Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
894291df7f Merge pull request 'Let the page flick work anywhere, with a setting to turn it off' (#13) from canvas-polish into main
Reviewed-on: #13
2026-09-27 23:46:59 +02:00
5fb11e4928 Let the page fling work anywhere, with a setting to turn it off
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia
2026-09-27 21:43:09 +00:00
361e3e5e89 Merge pull request 'Palm tests, erase fix, swipe-to-add, size dialog, grid settings, docs' (#12) from canvas-polish into main
Reviewed-on: #12
2026-09-27 23:34:25 +02:00
2df91b561d 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia
2026-09-27 21:31:54 +00:00
3d29255d0c Add background grid settings
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia
2026-09-27 21:31:12 +00:00
f018012cdd Replace the custom page size prompt with a dialog
window.prompt() looks out of place and behaves badly in an installed
PWA, especially on iPad. The new dialog has width/height fields in mm,
preset chips and a swap button, validates the 50–5000 pt range, and
keeps preset and unchanged sizes exact instead of rounding them to
whole millimetres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia
2026-09-27 21:30:31 +00:00
5a501a5c87 Offer a new page after a finger fling past the page edge
A quick one-finger flick toward a free side of the active page, once
that edge is on screen, highlights and pulses the plus button on that
side for three seconds. It never adds a page by itself, so ordinary
panning can't create pages; a tap on the highlighted plus does.

Fling detection lives in ink/fling.ts with tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia
2026-09-27 21:29:16 +00:00
abb11ad407 Drop an eraser drag when the pointer is cancelled
A pointercancel (system gesture, notification, palm) used to apply the
strokes erased so far. Only a real pointerup commits the erase now, the
same as ink strokes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia
2026-09-27 21:28:10 +00:00
d2c6e617bb Test palm rejection for add-page button taps
Move the draw-or-navigate choice into touchIntent() so the rule the
canvas uses can be tested, and cover finger taps on the plus button:
accepted after writing and while the pen hovers, rejected while the
pen is down, for palm-sized contacts and right after pen activity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia
2026-09-27 21:28:01 +00:00
2760819466 Merge pull request 'Enlarge the add-page button and let fingers tap it' (#11) from add-page-plus into main
Reviewed-on: #11
2026-09-27 22:58:09 +02:00
4ab0d95cad Merge pull request 'Use a plus-circle button for adding pages' (#10) from add-page-plus into main
Reviewed-on: #10
2026-09-27 22:52:56 +02:00
11 changed files with 517 additions and 36 deletions

View file

@ -23,7 +23,8 @@ The image builds the PWA and serves it with nginx. Put an HTTPS reverse proxy in
## Using it ## 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. - **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. - **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. - **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. - **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.

32
spec.md
View file

@ -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 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. - 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. - **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. - Deleting a page removes it (undo via Ctrl+Z); no permanent trash.
- Default zoom: one page fills the screen. Zoom out reveals the tree. - 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) ## 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 ## 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. - PWA: offline-first, caches locally, explicit save/sync pushes to repo.
- Multi-device: git pull/push is the sync mechanism (not real-time collab). - 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. - **Frontend**: SvelteKit, deployed on your VPS, installable as PWA.
- **Ink input**: Pointer Events + pressure (your existing artifact — pending). - **Ink input**: Pointer Events + pressure (your existing artifact — pending).
- **PDF read/write**: pdf.js + pdf-lib. - **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 ## 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` 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. - 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): **How the app applies it** (`src/lib/ink/palm.ts`):
- `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. - 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.
- `tiltX`/`tiltY` exist on the pen's `PointerEvent` but aren't read anywhere yet — needed for tilt support. - Once a pen has been used, fingers only navigate. "Draw with finger" in Settings turns finger ink off entirely.
- No smoothing yet (raw straight segments) — fine as a v1, can layer in curve smoothing later without changing point storage. - Each rule (pen session, wide contact, timing, hover) can be switched off in Settings → Pen & touch.
- 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. - 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 ## 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.

View file

@ -61,6 +61,7 @@
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08); --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); --page-line: rgba(0, 0, 0, 0.18);
--grid-line: rgba(0, 0, 0, 0.05); --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: rgba(0, 0, 0, 0.045);
--ghost-border: rgba(0, 0, 0, 0.12); --ghost-border: rgba(0, 0, 0, 0.12);
--radius: 6px; --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); --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); --page-line: rgba(255, 255, 255, 0.16);
--grid-line: rgba(255, 255, 255, 0.04); --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: rgba(255, 255, 255, 0.04);
--ghost-border: rgba(255, 255, 255, 0.14); --ghost-border: rgba(255, 255, 255, 0.14);
color-scheme: dark; color-scheme: dark;

View file

@ -16,10 +16,12 @@
import { bounds, intersects, rectBeside, type Rect } from '$lib/model/layout'; import { bounds, intersects, rectBeside, type Rect } from '$lib/model/layout';
import { canInsert, freeSlots, freeSlotsOf, neighbor, type Dir, type Slot } from '$lib/model/tree'; import { canInsert, freeSlots, freeSlotsOf, neighbor, type Dir, type Slot } from '$lib/model/tree';
import { A4, PAGE_PRESETS, type Stroke } from '$lib/model/types'; 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 { StrokeRecorder, hitStroke } from '$lib/ink/stroke';
import { pageCssVars } from '$lib/editor/pageStyle'; import { pageCssVars } from '$lib/editor/pageStyle';
import { downloadCanvas } from './download'; import { downloadCanvas } from './download';
import PageSizeDialog from './PageSizeDialog.svelte';
import PageView from './PageView.svelte'; import PageView from './PageView.svelte';
import Toolbar from './Toolbar.svelte'; import Toolbar from './Toolbar.svelte';
@ -28,11 +30,28 @@
let stage: HTMLDivElement; let stage: HTMLDivElement;
const cssVars = pageCssVars(); const cssVars = pageCssVars();
// Background grid: one cell per active page, aligned to its edges (A4 at the origin if none). // Background grid: one cell per active page, aligned to its edges (A4 at the origin if none),
const grid = $derived.by(() => { // 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 r = (doc.activeId && doc.rects.get(doc.activeId)) || { x: 0, y: 0, ...A4 };
const s = viewport.scale; 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 -------------------------------------------------------- // ---- autosave --------------------------------------------------------
@ -55,6 +74,7 @@
return () => { return () => {
ro.disconnect(); ro.disconnect();
stage.removeEventListener('wheel', onWheel); stage.removeEventListener('wheel', onWheel);
clearTimeout(offerTimer);
}; };
}); });
@ -99,6 +119,34 @@
const placeSlots = $derived(tools.placing ? freeSlots(doc.tree) : []); 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<Slot | null>(null);
let offerTimer: ReturnType<typeof setTimeout> | 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. // Show every possible spot when an imported PDF is waiting to be placed.
$effect(() => { $effect(() => {
if (!tools.placing) return; if (!tools.placing) return;
@ -215,15 +263,9 @@
menu.show(x, y, items); menu.show(x, y, items);
} }
function customSize(id: string) { /** Page whose custom-size dialog is open. */
const page = doc.pages[id]; let sizing = $state<string | null>(null);
const mm = (pt: number) => Math.round((pt / 72) * 25.4); const customSize = (id: string) => (sizing = id);
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])));
}
const activeMenuPos = $derived.by(() => { const activeMenuPos = $derived.by(() => {
const id = doc.activeId; const id = doc.activeId;
@ -242,7 +284,7 @@
type Gesture = type Gesture =
| { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder } | { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder }
| { kind: 'erase'; pointerId: number; pointerType: string; hits: Map<string, Set<string>> } | { kind: 'erase'; pointerId: number; pointerType: string; hits: Map<string, Set<string>> }
| { 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: 'add'; pointerId: number; pointerType: string; startX: number; startY: number; slot: Slot }
| { kind: 'pinch' }; | { kind: 'pinch' };
@ -291,6 +333,7 @@
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
palm.track(e); palm.track(e);
if (menu.open) menu.close(); if (menu.open) menu.close();
if (offer) setOffer(null);
const target = e.target as Element; const target = e.target as Element;
if (target.closest('.no-stage')) return; if (target.closest('.no-stage')) return;
const p = local(e); const p = local(e);
@ -299,9 +342,9 @@
const fingerInks = tools.inking && settings.data.fingerDraw && !palm.penSeen; const fingerInks = tools.inking && settings.data.fingerDraw && !palm.penSeen;
// Taps on an add-page button are palm-checked like navigation: still rejected for // 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. // pen-down, palm-sized contacts and right after pen use, but not for the 5 s pen session.
const plus = target.closest<HTMLElement>('.ghost'); const plus = target.closest<HTMLElement>('.ghost, .offer-pill');
if (e.pointerType === 'touch') { if (e.pointerType === 'touch') {
const decision = palm.evaluate(e, fingerInks && !plus ? 'draw' : 'navigate'); const decision = palm.evaluate(e, touchIntent(fingerInks, !!plus));
if (!decision.accept) return; // palm: ignore entirely if (!decision.accept) return; // palm: ignore entirely
touches.set(e.pointerId, p); touches.set(e.pointerId, p);
if (touches.size >= 2) { if (touches.size >= 2) {
@ -334,7 +377,18 @@
(e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.slot')); (e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.slot'));
if (wantsPan) { if (wantsPan) {
if (onPage && !inEditor) doc.activeId = onPage; 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('.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' || !gesture.tap) {
if (e.pointerType !== 'touch') e.preventDefault(); if (e.pointerType !== 'touch') e.preventDefault();
capture(e.pointerId); capture(e.pointerId);
@ -393,6 +447,7 @@
viewport.panBy(dx, dy); viewport.panBy(dx, dy);
gesture.lastX = p.x; gesture.lastX = p.x;
gesture.lastY = p.y; gesture.lastY = p.y;
pushSample(gesture.trail, { ...p, t: e.timeStamp });
return; return;
} }
@ -436,13 +491,19 @@
if (!gesture || gesture.pointerId !== e.pointerId) return; if (!gesture || gesture.pointerId !== e.pointerId) return;
const g = gesture; const g = gesture;
gesture = null; gesture = null;
if (g.kind === 'add') { 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); if (e.type === 'pointerup' && (g.pointerType !== 'touch' || palm.evaluate(e, 'navigate').accept)) addPage(g.slot.anchor, g.slot.dir);
} else if (g.kind === 'draw') { } else if (g.kind === 'draw') {
live = null; live = null;
if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke); if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke);
} else if (g.kind === 'erase') { } 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(); erasing = new Set();
} }
} }
@ -506,7 +567,7 @@
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (workspace.settingsOpen) return; if (workspace.settingsOpen || sizing) return;
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase(); const k = e.key.toLowerCase();
@ -565,7 +626,7 @@
class:panning class:panning
class:erasing={tools.tool === 'eraser'} class:erasing={tools.tool === 'eraser'}
bind:this={stage} bind:this={stage}
style="background-size: {grid.w}px {grid.h}px; background-position: {grid.x}px {grid.y}px" style={gridStyle}
onpointerdown={onPointerDown} onpointerdown={onPointerDown}
onpointermove={onPointerMove} onpointermove={onPointerMove}
onpointerup={onPointerUp} onpointerup={onPointerUp}
@ -605,6 +666,7 @@
<!-- Pointer taps go through the palm-checked gesture; onclick only serves the keyboard. --> <!-- Pointer taps go through the palm-checked gesture; onclick only serves the keyboard. -->
<button <button
class="ghost" class="ghost"
class:offer={offer?.anchor === slot.anchor && offer.dir === slot.dir}
data-anchor={slot.anchor} data-anchor={slot.anchor}
data-dir={slot.dir} data-dir={slot.dir}
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px" style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
@ -649,6 +711,20 @@
</button> </button>
{/if} {/if}
{#if offer && offerOffscreen}
<!-- Like the plus buttons, taps go through the palm-checked gesture. -->
<button
class="offer-pill"
data-anchor={offer.anchor}
data-dir={offer.dir}
data-side={offer.dir}
onclick={(e) => e.detail === 0 && offer && addPage(offer.anchor, offer.dir)}
>
<CirclePlusIcon size={18} strokeWidth={1.75} />
Add page
</button>
{/if}
{#if eraserAt && tools.tool === 'eraser'} {#if eraserAt && tools.tool === 'eraser'}
<div class="eraser-ring" style="left:{eraserAt.x}px; top:{eraserAt.y}px; width:{tools.eraserRadius * 2}px; height:{tools.eraserRadius * 2}px"></div> <div class="eraser-ring" style="left:{eraserAt.x}px; top:{eraserAt.y}px; width:{tools.eraserRadius * 2}px; height:{tools.eraserRadius * 2}px"></div>
{/if} {/if}
@ -661,6 +737,19 @@
{/if} {/if}
</div> </div>
{#if sizing && doc.pages[sizing]}
{@const id = sizing}
<PageSizeDialog
width={doc.pages[id].width}
height={doc.pages[id].height}
onclose={() => (sizing = null)}
onapply={(w, h) => {
sizing = null;
keepAnchor(id, () => doc.resizePage(id, w, h));
}}
/>
{/if}
<Toolbar {doc} onfit={() => fitPage(doc.activeId)} onfitall={fitAll} /> <Toolbar {doc} onfit={() => fitPage(doc.activeId)} onfitall={fitAll} />
</div> </div>
@ -706,9 +795,6 @@
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
background-color: var(--bg-canvas); 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) { .stage :global(.cm-content) {
user-select: text; user-select: text;
@ -755,6 +841,57 @@
border-radius: 50%; border-radius: 50%;
background: var(--bg-canvas); 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 { .slot {
position: absolute; position: absolute;
display: grid; display: grid;

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { untrack } from 'svelte';
import ArrowLeftRightIcon from '@lucide/svelte/icons/arrow-left-right';
import { PAGE_PRESETS } from '$lib/model/types';
let {
width,
height,
onapply,
onclose
}: { width: number; height: number; onapply: (width: number, height: number) => void; onclose: () => void } = $props();
// Sizes are stored in points; people think in millimetres.
const MIN_PT = 50;
const MAX_PT = 5000;
const toMm = (pt: number) => Math.round((pt / 72) * 25.4);
const toPt = (mm: number) => (mm / 25.4) * 72;
// Start from the page's current size; edits are local until Apply.
let w = $state(untrack(() => toMm(width)));
let h = $state(untrack(() => toMm(height)));
let first: HTMLInputElement;
const valid = $derived([w, h].every((v) => Number.isFinite(v) && toPt(v) >= MIN_PT && toPt(v) <= MAX_PT));
$effect(() => {
first.focus();
first.select();
});
function apply() {
if (!valid) return;
// Whole millimetres would nudge exact sizes (A4 is 595.28 pt), so keep those as they are.
const exact = [{ width, height }, ...PAGE_PRESETS].find((p) => toMm(p.width) === w && toMm(p.height) === h);
if (exact) onapply(exact.width, exact.height);
else onapply(toPt(w), toPt(h));
}
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && onclose()} />
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="backdrop" onclick={(e) => e.target === e.currentTarget && onclose()}>
<form
class="dialog"
aria-label="Page size"
onsubmit={(e) => {
e.preventDefault();
apply();
}}
>
<h2>Page size</h2>
<div class="presets">
{#each PAGE_PRESETS as p (p.label)}
<button
type="button"
class="chip"
class:active={toMm(p.width) === w && toMm(p.height) === h}
onclick={() => ((w = toMm(p.width)), (h = toMm(p.height)))}>{p.label}</button
>
{/each}
</div>
<div class="fields">
<label>
<span>Width</span>
<input type="number" inputmode="decimal" min={toMm(MIN_PT)} max={toMm(MAX_PT)} step="1" bind:value={w} bind:this={first} />
</label>
<button type="button" class="icon-btn" title="Swap width and height" onclick={() => ([w, h] = [h, w])}>
<ArrowLeftRightIcon size={16} />
</button>
<label>
<span>Height</span>
<input type="number" inputmode="decimal" min={toMm(MIN_PT)} max={toMm(MAX_PT)} step="1" bind:value={h} />
</label>
<span class="unit">mm</span>
</div>
{#if !valid}
<p class="hint">Sizes must be between {toMm(MIN_PT)} and {toMm(MAX_PT)} mm.</p>
{/if}
<div class="actions">
<button type="button" class="btn" onclick={onclose}>Cancel</button>
<button type="submit" class="btn primary" disabled={!valid}>Apply</button>
</div>
</form>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.45);
padding: 16px;
}
.dialog {
width: min(400px, 100%);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
padding: 18px 20px 20px;
}
h2 {
font-size: 16px;
margin: 0 0 14px;
}
.presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.chip {
border: 1px solid var(--border);
background: none;
color: var(--muted);
border-radius: 14px;
padding: 4px 10px;
cursor: pointer;
}
.chip:hover {
color: var(--text);
background: var(--hover);
}
.chip.active {
color: var(--text);
border-color: var(--accent);
background: var(--accent-soft);
}
.fields {
display: flex;
align-items: flex-end;
gap: 8px;
}
label {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
label span {
font-size: 12px;
color: var(--muted);
}
input {
width: 100%;
}
.fields .icon-btn {
margin-bottom: 2px;
}
.unit {
color: var(--faint);
padding-bottom: 7px;
}
.hint {
color: var(--danger);
font-size: 12px;
margin: 8px 0 0;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.btn:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -83,12 +83,30 @@
<option value="paper">Paper</option> <option value="paper">Paper</option>
</select> </select>
</div> </div>
<div class="row">
<div><div class="name">Background grid</div><div class="desc">Faint lines behind the pages, one cell per active page.</div></div>
<input type="checkbox" class="toggle" bind:checked={s.grid.show} onchange={save} />
</div>
<div class="row">
<div><div class="name">Grid subdivisions</div><div class="desc">Split each page-sized cell into a finer grid for sketching and lining things up.</div></div>
<select bind:value={s.grid.divisions} onchange={save} disabled={!s.grid.show}>
<option value={1}>None</option>
<option value={2}>2 × 2</option>
<option value={4}>4 × 4</option>
<option value={8}>8 × 8</option>
<option value={16}>16 × 16</option>
</select>
</div>
{:else if tab === 'input'} {:else if tab === 'input'}
<h2>Pen &amp; touch</h2> <h2>Pen &amp; touch</h2>
<div class="row"> <div class="row">
<div><div class="name">Draw with finger</div><div class="desc">When off, a single finger always pans; only pen and mouse draw. Once you use a pen, fingers only pan and zoom.</div></div> <div><div class="name">Draw with finger</div><div class="desc">When off, a single finger always pans; only pen and mouse draw. Once you use a pen, fingers only pan and zoom.</div></div>
<input type="checkbox" class="toggle" bind:checked={s.fingerDraw} onchange={save} /> <input type="checkbox" class="toggle" bind:checked={s.fingerDraw} onchange={save} />
</div> </div>
<div class="row">
<div><div class="name">Swipe to add pages</div><div class="desc">A quick one-finger flick toward a free side of the active page offers to add a page there.</div></div>
<input type="checkbox" class="toggle" bind:checked={s.flingToAdd} onchange={save} />
</div>
<h3>Palm rejection</h3> <h3>Palm rejection</h3>
<div class="row"> <div class="row">
<div><div class="name">Pen session lockout</div><div class="desc">Ignore touches while the pen is writing, and finger ink for 5 seconds after it was used.</div></div> <div><div class="name">Pen session lockout</div><div class="desc">Ignore touches while the pen is writing, and finger ink for 5 seconds after it was used.</div></div>

43
src/lib/ink/fling.test.ts Normal file
View file

@ -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);
});
});

41
src/lib/ink/fling.ts Normal file
View file

@ -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();
}

View file

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 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<PointerEvent> = {}) => const ev = (type: string, pointerType: string, extra: Partial<PointerEvent> = {}) =>
({ type, pointerType, pointerId: pointerType === 'pen' ? 1 : 2, width: 10, height: 10, ...extra }) as PointerEvent; ({ 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', () => { it('allows finger ink again once the pen session ends', () => {
expect(afterPen(5100).evaluate(touch(), 'draw').accept).toBe(true); 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<PointerEvent> = {}, 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);
});
});
}); });

View file

@ -27,6 +27,15 @@ const PALM_CONTACT_PX = 35;
/** What an accepted touch would do: ink (draw/erase) or pan/zoom. */ /** What an accepted touch would do: ink (draw/erase) or pan/zoom. */
export type TouchIntent = 'draw' | 'navigate'; 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 { export interface Decision {
accept: boolean; accept: boolean;
reason: string; reason: string;

View file

@ -6,6 +6,12 @@ import { DEFAULT_PALM, type PalmOptions } from '$lib/ink/palm';
export type ThemePref = 'system' | 'light' | 'dark'; export type ThemePref = 'system' | 'light' | 'dark';
export type PageStylePref = 'paper' | 'match'; 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 { export interface GitHubSettings {
token: string; token: string;
owner: string; owner: string;
@ -18,7 +24,10 @@ export interface GitHubSettings {
interface SettingsData { interface SettingsData {
theme: ThemePref; theme: ThemePref;
pageStyle: PageStylePref; pageStyle: PageStylePref;
grid: GridSettings;
fingerDraw: boolean; fingerDraw: boolean;
/** A finger fling toward a free side of the active page offers a new page there. */
flingToAdd: boolean;
palm: PalmOptions; palm: PalmOptions;
github: GitHubSettings; github: GitHubSettings;
/** Minutes between automatic pushes (0 = manual only). */ /** Minutes between automatic pushes (0 = manual only). */
@ -30,7 +39,9 @@ interface SettingsData {
const DEFAULTS: SettingsData = { const DEFAULTS: SettingsData = {
theme: 'system', theme: 'system',
pageStyle: 'match', pageStyle: 'match',
grid: { show: true, divisions: 1 },
fingerDraw: true, fingerDraw: true,
flingToAdd: true,
palm: DEFAULT_PALM, palm: DEFAULT_PALM,
github: { token: '', owner: '', repo: '', branch: 'main', dir: '' }, github: { token: '', owner: '', repo: '', branch: 'main', dir: '' },
pushInterval: 5, pushInterval: 5,
@ -48,6 +59,7 @@ function load(): SettingsData {
return { return {
...DEFAULTS, ...DEFAULTS,
...saved, ...saved,
grid: { ...DEFAULTS.grid, ...saved.grid },
palm: { ...DEFAULTS.palm, ...saved.palm }, palm: { ...DEFAULTS.palm, ...saved.palm },
github: { ...DEFAULTS.github, ...saved.github } github: { ...DEFAULTS.github, ...saved.github }
}; };