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:
parent
abb11ad407
commit
5a501a5c87
3 changed files with 148 additions and 3 deletions
|
|
@ -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
43
src/lib/ink/fling.test.ts
Normal 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
41
src/lib/ink/fling.ts
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue