Pen session lockout
Ignore touches while the pen is writing, and finger ink for 5 seconds after it was used.
diff --git a/src/lib/ink/fling.test.ts b/src/lib/ink/fling.test.ts
new file mode 100644
index 0000000..ec9ef97
--- /dev/null
+++ b/src/lib/ink/fling.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import { flingDir, pushSample, type Sample } from './fling';
+
+/** A straight drag from (0,0) by (dx,dy) over `ms`, sampled every 16 ms. */
+function drag(dx: number, dy: number, ms: number): Sample[] {
+ const out: Sample[] = [];
+ for (let t = 0; t <= ms; t += 16) pushSample(out, { x: (dx * t) / ms, y: (dy * t) / ms, t });
+ return out;
+}
+
+describe('flingDir', () => {
+ it('maps a flick to the side it reveals', () => {
+ expect(flingDir(drag(-200, 0, 96))).toBe('right');
+ expect(flingDir(drag(200, 0, 96))).toBe('left');
+ expect(flingDir(drag(0, -200, 96))).toBe('down');
+ expect(flingDir(drag(0, 200, 96))).toBe('up');
+ });
+
+ it('ignores slow drags', () => {
+ expect(flingDir(drag(-200, 0, 800))).toBeNull();
+ });
+
+ it('ignores diagonal flicks', () => {
+ expect(flingDir(drag(-200, -150, 96))).toBeNull();
+ });
+
+ it('ignores a drag that stops before lifting', () => {
+ const s = drag(-300, 0, 96);
+ const end = s.at(-1)!;
+ for (let t = 16; t <= 160; t += 16) pushSample(s, { ...end, t: end.t + t });
+ expect(flingDir(s)).toBeNull();
+ });
+
+ it('needs at least two samples', () => {
+ expect(flingDir([])).toBeNull();
+ expect(flingDir([{ x: 0, y: 0, t: 0 }])).toBeNull();
+ });
+
+ it('keeps the trail short', () => {
+ const s = drag(-2000, 0, 2000);
+ expect(s.at(-1)!.t - s[0].t).toBeLessThanOrEqual(216);
+ });
+});
diff --git a/src/lib/ink/fling.ts b/src/lib/ink/fling.ts
new file mode 100644
index 0000000..2b2a689
--- /dev/null
+++ b/src/lib/ink/fling.ts
@@ -0,0 +1,41 @@
+// Fling detection for one-finger pans: a quick flick that ends the drag.
+
+import type { Dir } from '$lib/model/tree';
+
+export interface Sample {
+ x: number;
+ y: number;
+ t: number;
+}
+
+/** Only the last stretch of the drag counts. */
+const WINDOW_MS = 100;
+/** px/ms; a slow drag that stops is not a fling. */
+const MIN_SPEED = 0.6;
+/** The main axis must dominate by this much. */
+const MIN_RATIO = 2;
+
+/**
+ * Side of the canvas a fling heads for, or null. Moving the finger left pulls in
+ * what's on the right, so a leftward flick returns 'right'.
+ */
+export function flingDir(samples: readonly Sample[]): Dir | null {
+ const last = samples.at(-1);
+ if (!last) return null;
+ const first = samples.find((s) => last.t - s.t <= WINDOW_MS);
+ if (!first || first === last) return null;
+ const dt = last.t - first.t;
+ if (dt <= 0) return null;
+ const dx = last.x - first.x;
+ const dy = last.y - first.y;
+ if (Math.hypot(dx, dy) / dt < MIN_SPEED) return null;
+ if (Math.abs(dx) >= Math.abs(dy) * MIN_RATIO) return dx < 0 ? 'right' : 'left';
+ if (Math.abs(dy) >= Math.abs(dx) * MIN_RATIO) return dy < 0 ? 'down' : 'up';
+ return null;
+}
+
+/** Keeps a short trail of pointer samples for flingDir. */
+export function pushSample(samples: Sample[], s: Sample) {
+ samples.push(s);
+ while (samples.length > 2 && s.t - samples[0].t > WINDOW_MS * 2) samples.shift();
+}
diff --git a/src/lib/ink/palm.test.ts b/src/lib/ink/palm.test.ts
index 473358c..add4644 100644
--- a/src/lib/ink/palm.test.ts
+++ b/src/lib/ink/palm.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { PalmRejector } from './palm';
+import { PalmRejector, touchIntent } from './palm';
const ev = (type: string, pointerType: string, extra: Partial
= {}) =>
({ type, pointerType, pointerId: pointerType === 'pen' ? 1 : 2, width: 10, height: 10, ...extra }) as PointerEvent;
@@ -53,4 +53,37 @@ describe('palm rejection', () => {
it('allows finger ink again once the pen session ends', () => {
expect(afterPen(5100).evaluate(touch(), 'draw').accept).toBe(true);
});
+
+ describe('add-page button taps', () => {
+ // With a pen in use fingers don't ink, but finger ink may still be on (before any pen).
+ const tap = (palm: PalmRejector, extra: Partial = {}, fingerInks = false) =>
+ palm.evaluate(touch(extra), touchIntent(fingerInks, true));
+
+ it('are handled as navigation even when fingers ink', () => {
+ expect(touchIntent(true, true)).toBe('navigate');
+ expect(touchIntent(false, true)).toBe('navigate');
+ expect(touchIntent(true, false)).toBe('draw');
+ expect(touchIntent(false, false)).toBe('navigate');
+ });
+
+ it('are accepted a couple of seconds after writing', () => {
+ expect(tap(afterPen(2000)).accept).toBe(true);
+ expect(tap(afterPen(2000), {}, true).accept).toBe(true);
+ });
+
+ it('are accepted while the pen hovers', () => {
+ expect(tap(afterPen(2000, { leave: false })).accept).toBe(true);
+ });
+
+ it('are rejected while the pen is down', () => {
+ const palm = new PalmRejector();
+ palm.track(ev('pointerdown', 'pen'));
+ expect(tap(palm).accept).toBe(false);
+ });
+
+ it('are rejected for a palm or right after the pen', () => {
+ expect(tap(afterPen(2000), { width: 60, height: 40 }).accept).toBe(false);
+ expect(tap(afterPen(50)).accept).toBe(false);
+ });
+ });
});
diff --git a/src/lib/ink/palm.ts b/src/lib/ink/palm.ts
index 7030b2b..2c9a233 100644
--- a/src/lib/ink/palm.ts
+++ b/src/lib/ink/palm.ts
@@ -27,6 +27,15 @@ const PALM_CONTACT_PX = 35;
/** What an accepted touch would do: ink (draw/erase) or pan/zoom. */
export type TouchIntent = 'draw' | 'navigate';
+/**
+ * Which policy a touch falls under. It only draws when finger ink is on and it
+ * didn't land on a button; taps on buttons (e.g. add page) navigate, so they keep
+ * working between pen strokes.
+ */
+export function touchIntent(fingerInks: boolean, onButton: boolean): TouchIntent {
+ return fingerInks && !onButton ? 'draw' : 'navigate';
+}
+
export interface Decision {
accept: boolean;
reason: string;
diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts
index 4fa5724..ceb10c2 100644
--- a/src/lib/state/settings.svelte.ts
+++ b/src/lib/state/settings.svelte.ts
@@ -6,6 +6,12 @@ import { DEFAULT_PALM, type PalmOptions } from '$lib/ink/palm';
export type ThemePref = 'system' | 'light' | 'dark';
export type PageStylePref = 'paper' | 'match';
+export interface GridSettings {
+ show: boolean;
+ /** Minor lines per page cell side (1 = page-sized cells only). */
+ divisions: number;
+}
+
export interface GitHubSettings {
token: string;
owner: string;
@@ -18,7 +24,10 @@ export interface GitHubSettings {
interface SettingsData {
theme: ThemePref;
pageStyle: PageStylePref;
+ grid: GridSettings;
fingerDraw: boolean;
+ /** A finger fling toward a free side of the active page offers a new page there. */
+ flingToAdd: boolean;
palm: PalmOptions;
github: GitHubSettings;
/** Minutes between automatic pushes (0 = manual only). */
@@ -30,7 +39,9 @@ interface SettingsData {
const DEFAULTS: SettingsData = {
theme: 'system',
pageStyle: 'match',
+ grid: { show: true, divisions: 1 },
fingerDraw: true,
+ flingToAdd: true,
palm: DEFAULT_PALM,
github: { token: '', owner: '', repo: '', branch: 'main', dir: '' },
pushInterval: 5,
@@ -48,6 +59,7 @@ function load(): SettingsData {
return {
...DEFAULTS,
...saved,
+ grid: { ...DEFAULTS.grid, ...saved.grid },
palm: { ...DEFAULTS.palm, ...saved.palm },
github: { ...DEFAULTS.github, ...saved.github }
};