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
This commit is contained in:
agent 2026-09-27 21:29:16 +00:00
parent abb11ad407
commit 5a501a5c87
3 changed files with 148 additions and 3 deletions

View file

@ -17,6 +17,7 @@
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, 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';
@ -55,6 +56,7 @@
return () => {
ro.disconnect();
stage.removeEventListener('wheel', onWheel);
clearTimeout(offerTimer);
};
});
@ -99,6 +101,30 @@
const placeSlots = $derived(tools.placing ? freeSlots(doc.tree) : []);
// A finger fling past the active page's free edge highlights the plus on that side.
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[]) {
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.
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);
}
// Show every possible spot when an imported PDF is waiting to be placed.
$effect(() => {
if (!tools.placing) return;
@ -242,7 +268,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' };
@ -291,6 +317,7 @@
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);
@ -334,7 +361,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);
@ -393,6 +431,7 @@
viewport.panBy(dx, dy);
gesture.lastX = p.x;
gesture.lastY = p.y;
pushSample(gesture.trail, { ...p, t: e.timeStamp });
return;
}
@ -436,7 +475,12 @@
if (!gesture || gesture.pointerId !== e.pointerId) return;
const g = gesture;
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);
} else if (g.kind === 'draw') {
live = null;
@ -606,6 +650,7 @@
<!-- 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"
@ -756,6 +801,22 @@
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);
}
}
@media (prefers-reduced-motion: reduce) {
.ghost.offer .plus {
animation: none;
}
}
.slot {
position: absolute;
display: grid;

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