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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue