Compare commits

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

14 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
a813c1c086 Enlarge the add-page button and let fingers tap it
The button is now a 32 px circle in a 48 px hit area, 8 px off the
page edge. Touch taps on it were palm-checked with the 'draw' policy,
which rejects every finger for 5 s after pen activity and while the pen
hovers, so fingers rarely got through. Use the 'navigate' policy
instead: pen-down, palm-sized contacts and touches right after pen use
are still rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia
2026-09-27 20:56:04 +00: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
4cc2f1ae74 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia
2026-09-27 20:50:52 +00:00
2d415d88a3 Merge pull request 'Show the build's version and commit in Settings' (#9) from build-version into main
Reviewed-on: #9
2026-09-27 22:08:52 +02:00
11 changed files with 549 additions and 80 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
- **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.

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 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.

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

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import DownloadIcon from '@lucide/svelte/icons/download';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
@ -15,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';
@ -27,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 --------------------------------------------------------
@ -54,6 +74,7 @@
return () => {
ro.disconnect();
stage.removeEventListener('wheel', onWheel);
clearTimeout(offerTimer);
};
});
@ -98,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<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.
$effect(() => {
if (!tools.placing) return;
@ -117,15 +166,20 @@
return rectBeside(a, slot.dir, s);
}
/** Hit area of the add-page bar: a strip hugging the anchor's free edge. */
function barRect(slot: Slot): Rect | null {
/**
* 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 t = 20 / viewport.scale;
if (slot.dir === 'right') return { x: a.x + a.width, y: a.y, width: t, height: a.height };
if (slot.dir === 'left') return { x: a.x - t, y: a.y, width: t, height: a.height };
if (slot.dir === 'down') return { x: a.x, y: a.y + a.height, width: a.width, height: t };
return { x: a.x, y: a.y - t, width: a.width, height: t };
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. */
@ -209,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<string | null>(null);
const customSize = (id: string) => (sizing = id);
const activeMenuPos = $derived.by(() => {
const id = doc.activeId;
@ -236,7 +284,7 @@
type Gesture =
| { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder }
| { 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: 'pinch' };
@ -285,16 +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 bar get the same palm policy as writing.
const bar = target.closest<HTMLElement>('.ghost');
// 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<HTMLElement>('.ghost, .offer-pill');
if (e.pointerType === 'touch') {
const decision = palm.evaluate(e, fingerInks || bar ? '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) {
@ -305,9 +355,9 @@
}
}
if (bar && e.button === 0) {
if (plus && e.button === 0) {
e.preventDefault();
const slot = { anchor: bar.dataset.anchor!, dir: bar.dataset.dir as Dir };
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;
@ -327,7 +377,18 @@
(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('.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);
@ -386,18 +447,19 @@
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 bar is not a tap.
// Dragging off the button is not a tap.
if (Math.hypot(p.x - gesture.startX, p.y - gesture.startY) > 12) gesture = null;
return;
}
@ -429,13 +491,19 @@
if (!gesture || gesture.pointerId !== e.pointerId) return;
const g = gesture;
gesture = null;
if (g.kind === 'add') {
if (e.type === 'pointerup' && (g.pointerType !== 'touch' || palm.evaluate(e).accept)) addPage(g.slot.anchor, g.slot.dir);
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();
}
}
@ -499,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();
@ -558,7 +626,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}
@ -593,17 +661,20 @@
{/each}
{#each ghostSlots as slot (slot.anchor + slot.dir)}
{@const r = barRect(slot)}
{@const r = plusRect(slot)}
{#if r}
<!-- Pointer taps go through the palm-checked gesture; onclick only serves the keyboard. -->
<button
class="ghost"
class:offer={offer?.anchor === slot.anchor && offer.dir === slot.dir}
data-anchor={slot.anchor}
data-dir={slot.dir}
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
title="Add page ({slot.dir})"
onclick={(e) => e.detail === 0 && addPage(slot.anchor, slot.dir)}
></button>
>
<span class="plus"><CirclePlusIcon size="100%" strokeWidth={1.5} /></span>
</button>
{/if}
{/each}
@ -640,6 +711,20 @@
</button>
{/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'}
<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}
@ -652,6 +737,19 @@
{/if}
</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} />
</div>
@ -697,9 +795,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;
@ -726,46 +821,76 @@
}
.ghost {
position: absolute;
display: grid;
place-items: center;
background: none;
border: 0;
padding: 0;
color: var(--faint);
cursor: pointer;
transition: color 0.12s;
}
/* The visible bar: 4 screen px thick, 6 px off the page edge. */
.ghost::before {
content: '';
.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;
background: var(--ghost-border);
border-radius: calc(2px / var(--s));
transition: background 0.12s;
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;
}
.ghost[data-dir='left']::before,
.ghost[data-dir='right']::before {
top: 0;
bottom: 0;
width: calc(4px / var(--s));
.offer-pill[data-side='right'] {
right: 12px;
top: 50%;
transform: translateY(-50%);
}
.ghost[data-dir='up']::before,
.ghost[data-dir='down']::before {
left: 0;
right: 0;
height: calc(4px / var(--s));
.offer-pill[data-side='left'] {
left: 12px;
top: 50%;
transform: translateY(-50%);
}
.ghost[data-dir='right']::before {
left: calc(6px / var(--s));
.offer-pill[data-side='up'] {
top: 12px;
left: 50%;
transform: translateX(-50%);
}
.ghost[data-dir='left']::before {
right: calc(6px / var(--s));
.offer-pill[data-side='down'] {
/* Clear of the toolbar. */
bottom: 72px;
left: 50%;
transform: translateX(-50%);
}
.ghost[data-dir='down']::before {
top: calc(6px / var(--s));
}
.ghost[data-dir='up']::before {
bottom: calc(6px / var(--s));
}
.ghost:hover::before,
.ghost:focus-visible::before {
background: var(--accent);
@media (prefers-reduced-motion: reduce) {
.ghost.offer .plus {
animation: none;
}
}
.slot {
position: absolute;

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>
</select>
</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'}
<h2>Pen &amp; touch</h2>
<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>
<input type="checkbox" class="toggle" bind:checked={s.fingerDraw} onchange={save} />
</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>
<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>

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 { PalmRejector } from './palm';
import { PalmRejector, touchIntent } from './palm';
const ev = (type: string, pointerType: string, extra: Partial<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', () => {
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. */
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;

View file

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