6d263ff7c6
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 41s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Replace the 24px edge-swipe with a router-level interactive back gesture: a rightward drag in the left ~38% band tracks the finger while a cheap themed backdrop parallaxes in, and on release commits the back-navigation (past ~40% of the width or a rightward flick) or snaps back. The live parent mounts only on commit, which suppresses that one route slide for a seamless hand-off. Pure decision logic (shouldArm/lockAxis/decideCommit + the per-gesture controller) lives in lib/backswipe.ts with unit tests; the DOM wiring, hit-test and settle animation in lib/backswipe.svelte.ts. The gesture yields to native vertical scroll, skips the rack / draggable pending tiles / text inputs / the board while zoomed-in, and is disabled inside Telegram (native BackButton owns it). parentPath in App.svelte is now the single source for both the Telegram button and the swipe. Tests: backswipe unit (13) + e2e (3; Chromium+WebKit: commit, zoomed-in skip, vertical scroll). Docs: UI_DESIGN.md Back bullet.
151 lines
5.4 KiB
TypeScript
151 lines
5.4 KiB
TypeScript
/**
|
|
* backswipe.svelte wires the framework-agnostic gesture in backswipe.ts to the DOM and to
|
|
* a reactive rune the router binds. It owns the window pointer listeners, the hit-testing
|
|
* that keeps the swipe clear of the rack, draggable tiles and a zoomed-in board, the
|
|
* release settle animation, and the one-shot transition suppression that hands a committed
|
|
* drag over to the freshly mounted parent without replaying the route slide.
|
|
*/
|
|
|
|
import { app } from './app.svelte';
|
|
import { createBackSwipe, shouldArm } from './backswipe';
|
|
import { navigate } from './router.svelte';
|
|
import { insideTelegram } from './telegram';
|
|
|
|
/**
|
|
* backSwipe is the reactive view of the in-flight gesture. App binds `progress` to the
|
|
* pane/backdrop transforms and reads `suppressTransition` to skip the route slide for the
|
|
* one navigation a committed drag performs.
|
|
*/
|
|
export const backSwipe = $state<{ active: boolean; progress: number; suppressTransition: boolean }>({
|
|
active: false,
|
|
progress: 0,
|
|
suppressTransition: false,
|
|
});
|
|
|
|
// Elements whose own pointer behaviour must win over the back-swipe: the rack (tile lift),
|
|
// a draggable pending tile, the board only while zoomed-in (it pans natively then), and any
|
|
// text-editing control. Mark any other conflicting element with data-noswipe.
|
|
const SKIP_SELECTOR = '[data-rack], .cell.pending, .viewport.zoomed, input, textarea, select, [data-noswipe]';
|
|
|
|
const SETTLE_MS = 180;
|
|
|
|
/**
|
|
* initBackSwipe attaches the global pointer listeners powering the interactive back
|
|
* gesture and returns a teardown. getParent yields the current screen's parent route path
|
|
* (e.g. "/" or "/game/:id"), or null at a navigation root where the gesture must not arm.
|
|
*/
|
|
export function initBackSwipe(getParent: () => string | null): () => void {
|
|
if (typeof window === 'undefined') return () => {};
|
|
|
|
const ctrl = createBackSwipe({
|
|
onChange: (active, progress) => {
|
|
backSwipe.active = active;
|
|
backSwipe.progress = progress;
|
|
},
|
|
});
|
|
|
|
let parent: string | null = null;
|
|
let tracking = false;
|
|
let settling = false;
|
|
|
|
function clearActive(): void {
|
|
backSwipe.active = false;
|
|
backSwipe.progress = 0;
|
|
}
|
|
|
|
// settleTo eases progress to target, paints the final value for one frame, then runs done.
|
|
// The extra frame lets a committed pane finish sliding off before the parent swaps in.
|
|
function settleTo(target: number, done: () => void): void {
|
|
settling = true;
|
|
const from = backSwipe.progress;
|
|
const dur = app.reduceMotion ? 0 : SETTLE_MS;
|
|
const t0 = performance.now();
|
|
function frame(now: number): void {
|
|
const k = dur === 0 ? 1 : Math.min(1, (now - t0) / dur);
|
|
const eased = 1 - Math.pow(1 - k, 3);
|
|
backSwipe.progress = from + (target - from) * eased;
|
|
if (k < 1) requestAnimationFrame(frame);
|
|
else requestAnimationFrame(() => {
|
|
settling = false;
|
|
done();
|
|
});
|
|
}
|
|
requestAnimationFrame(frame);
|
|
}
|
|
|
|
function onMove(e: PointerEvent): void {
|
|
const axis = ctrl.move({ x: e.clientX, y: e.clientY, t: e.timeStamp });
|
|
if (axis === 'horizontal') e.preventDefault();
|
|
else if (axis === 'vertical') detach(); // lost to native scroll
|
|
}
|
|
|
|
function onUp(): void {
|
|
detach();
|
|
const decision = ctrl.end();
|
|
if (decision === 'commit' && parent) {
|
|
settleTo(1, () => {
|
|
// Suppress the one route slide, then navigate. active/progress/suppressTransition are
|
|
// cleared by App's routeKey effect in the same flush as the keyed swap, so the
|
|
// off-screen outgoing pane never paints back at rest before the parent mounts.
|
|
backSwipe.suppressTransition = true;
|
|
navigate(parent!);
|
|
});
|
|
} else if (decision === 'cancel') {
|
|
settleTo(0, clearActive);
|
|
} else {
|
|
clearActive();
|
|
}
|
|
}
|
|
|
|
function onCancel(): void {
|
|
detach();
|
|
ctrl.cancel();
|
|
settleTo(0, clearActive);
|
|
}
|
|
|
|
function detach(): void {
|
|
tracking = false;
|
|
window.removeEventListener('pointermove', onMove, true);
|
|
window.removeEventListener('pointerup', onUp, true);
|
|
window.removeEventListener('pointercancel', onCancel, true);
|
|
}
|
|
|
|
function onDown(e: PointerEvent): void {
|
|
if (settling) return;
|
|
if (tracking) {
|
|
// A second pointer (e.g. a pinch) aborts the in-flight back-swipe.
|
|
onCancel();
|
|
return;
|
|
}
|
|
// Still active but not tracking means a committed drag is awaiting its (async) route
|
|
// swap; ignore presses until the parent has mounted and the effect resets the rune.
|
|
if (backSwipe.active) return;
|
|
parent = getParent();
|
|
if (
|
|
!shouldArm({
|
|
clientX: e.clientX,
|
|
width: window.innerWidth,
|
|
pointerType: e.pointerType,
|
|
hasParent: !!parent,
|
|
insideTelegram: insideTelegram(),
|
|
})
|
|
)
|
|
return;
|
|
// Hit-test the element actually under the press (robust for capture-phase and synthetic
|
|
// events alike) and skip the interactive/draggable zones.
|
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
|
if (el?.closest(SKIP_SELECTOR)) return;
|
|
ctrl.start({ x: e.clientX, y: e.clientY, t: e.timeStamp }, window.innerWidth);
|
|
tracking = true;
|
|
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
|
|
window.addEventListener('pointerup', onUp, true);
|
|
window.addEventListener('pointercancel', onCancel, true);
|
|
}
|
|
|
|
window.addEventListener('pointerdown', onDown, true);
|
|
return () => {
|
|
window.removeEventListener('pointerdown', onDown, true);
|
|
detach();
|
|
};
|
|
}
|