/** * 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(); }; }