From 5a501a5c871be424e51a4d00d1acd1a647e4a4d0 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 27 Sep 2026 21:29:16 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia --- src/lib/components/CanvasView.svelte | 67 ++++++++++++++++++++++++++-- src/lib/ink/fling.test.ts | 43 ++++++++++++++++++ src/lib/ink/fling.ts | 41 +++++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 src/lib/ink/fling.test.ts create mode 100644 src/lib/ink/fling.ts diff --git a/src/lib/components/CanvasView.svelte b/src/lib/components/CanvasView.svelte index 4db89b1..08e97d4 100644 --- a/src/lib/components/CanvasView.svelte +++ b/src/lib/components/CanvasView.svelte @@ -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(null); + let offerTimer: ReturnType | 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> } - | { 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 @@