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;