UI: interactive back-swipe (left-band, snapshot backdrop)
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
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.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user