mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
- A failed likes request shows the error in the panel instead of an empty list. - A failed sign-out says why instead of doing nothing. - Long toasts, like a copied link, wrap instead of running off narrow screens. - Keys no longer act on the feed behind the help; ? still toggles it, and focus goes into the help and back to where it was. - Phones reach the help from the account panel; the bar has no room for ?. - Cards next to the current one are inert, so Tab no longer walks into buttons that are off screen. - Keyboard focus is visible everywhere, and panels use the shared colour and radius tokens.
241 lines
8.4 KiB
TypeScript
241 lines
8.4 KiB
TypeScript
// Full-screen vertical pager. One card fills the screen; drag, fling, wheel and keys
|
|
// move between cards. Only the current card and its two neighbours are in the DOM,
|
|
// placed with transforms and settled by a spring, so long sessions stay light and
|
|
// the motion follows the finger exactly.
|
|
|
|
export interface PagerSource {
|
|
/// How many cards exist right now.
|
|
count(): number;
|
|
/// The element for card `i`; the owner builds and caches it.
|
|
element(i: number): HTMLElement;
|
|
/// Card `i` became the current one. Called as soon as a move is decided, before
|
|
/// the animation ends, so audio can start right away.
|
|
onChange(i: number): void;
|
|
/// The user is at (or pulling past) the last card.
|
|
onNeedMore(): void;
|
|
}
|
|
|
|
const STIFFNESS = 380; // 1/s², spring towards the resting position
|
|
const DAMPING = 34; // 1/s, a little under critical (39): settles with a hint of bounce
|
|
const FLING = 0.45; // px/ms; a flick faster than this turns the page
|
|
const DISTANCE = 0.22; // or dragging further than this share of the height
|
|
const SLOP = 8; // px before a touch counts as a drag
|
|
const WHEEL_STEP = 40; // px of wheel delta per page
|
|
const WHEEL_IDLE = 180; // ms of quiet that ends one wheel gesture (trackpad inertia)
|
|
|
|
interface Drag {
|
|
id: number;
|
|
x0: number;
|
|
y0: number;
|
|
base: number;
|
|
active: boolean;
|
|
samples: [number, number][];
|
|
}
|
|
|
|
export class Pager {
|
|
index = 0;
|
|
/// True right after a drag, so the click that follows it is not a tap.
|
|
suppressClick = false;
|
|
private offset = 0; // px; positive while pulled down (towards the previous card)
|
|
private velocity = 0; // px/s
|
|
private height = 1;
|
|
private mounted = new Map<number, HTMLElement>();
|
|
private raf = 0;
|
|
private last = 0;
|
|
private drag: Drag | null = null;
|
|
private wheelAcc = 0;
|
|
private wheelLocked = false;
|
|
private wheelTimer = 0;
|
|
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
|
|
|
|
constructor(
|
|
private root: HTMLElement,
|
|
private src: PagerSource,
|
|
) {
|
|
this.height = root.clientHeight || innerHeight;
|
|
root.addEventListener("pointerdown", (e) => this.down(e));
|
|
root.addEventListener("pointermove", (e) => this.move(e));
|
|
root.addEventListener("pointerup", (e) => this.up(e));
|
|
root.addEventListener("pointercancel", (e) => this.up(e, true));
|
|
root.addEventListener("wheel", (e) => this.wheel(e), { passive: false });
|
|
new ResizeObserver(() => {
|
|
this.height = root.clientHeight || innerHeight;
|
|
this.render();
|
|
}).observe(root);
|
|
}
|
|
|
|
/// Move to card `i`. Returns false when there is no such card (yet).
|
|
go(i: number, { instant = false } = {}): boolean {
|
|
if (i < 0) return false;
|
|
if (i >= this.src.count()) {
|
|
this.src.onNeedMore();
|
|
return false;
|
|
}
|
|
if (i === this.index) return true;
|
|
this.offset += (i - this.index) * this.height;
|
|
this.index = i;
|
|
this.refresh();
|
|
this.src.onChange(i);
|
|
if (instant || this.reduced.matches || document.hidden) this.settleNow();
|
|
else this.animate();
|
|
if (i >= this.src.count() - 1) this.src.onNeedMore();
|
|
return true;
|
|
}
|
|
|
|
/// Re-read the cards around the current one (after inserting or removing cards).
|
|
refresh(): void {
|
|
const want = new Map<number, HTMLElement>();
|
|
for (let i = this.index - 1; i <= this.index + 1; i++) {
|
|
if (i >= 0 && i < this.src.count()) want.set(i, this.src.element(i));
|
|
}
|
|
for (const [i, el] of this.mounted) {
|
|
if (want.get(i) !== el && ![...want.values()].includes(el)) el.remove();
|
|
}
|
|
for (const el of want.values()) {
|
|
if (el.parentElement !== this.root) this.root.append(el);
|
|
}
|
|
this.mounted = want;
|
|
this.render();
|
|
}
|
|
|
|
private render(): void {
|
|
const h = this.height;
|
|
for (const [i, el] of this.mounted) {
|
|
const p = i - this.index + this.offset / h;
|
|
el.style.transform = `translate3d(0, ${(p * h).toFixed(2)}px, 0)`;
|
|
// 0 when resting in place, 1 one full page away; cards use it for depth effects.
|
|
el.style.setProperty("--away", Math.min(1, Math.abs(p)).toFixed(3));
|
|
// Only the current card's buttons are reachable with the keyboard.
|
|
el.inert = i !== this.index;
|
|
}
|
|
}
|
|
|
|
private settleNow(): void {
|
|
cancelAnimationFrame(this.raf);
|
|
this.raf = 0;
|
|
this.offset = 0;
|
|
this.velocity = 0;
|
|
this.render();
|
|
}
|
|
|
|
private animate(): void {
|
|
if (this.raf) return;
|
|
this.last = performance.now();
|
|
const step = (now: number) => {
|
|
// Small fixed steps keep the spring stable on slow frames.
|
|
let dt = Math.min(0.05, (now - this.last) / 1000);
|
|
this.last = now;
|
|
while (dt > 0) {
|
|
const h = Math.min(dt, 1 / 240);
|
|
this.velocity += (-STIFFNESS * this.offset - DAMPING * this.velocity) * h;
|
|
this.offset += this.velocity * h;
|
|
dt -= h;
|
|
}
|
|
if (Math.abs(this.offset) < 0.5 && Math.abs(this.velocity) < 20) {
|
|
this.raf = 0;
|
|
this.settleNow();
|
|
return;
|
|
}
|
|
this.render();
|
|
this.raf = requestAnimationFrame(step);
|
|
};
|
|
this.raf = requestAnimationFrame(step);
|
|
}
|
|
|
|
// Pointer ---------------------------------------------------------------
|
|
|
|
private down(e: PointerEvent): void {
|
|
if (this.drag || (e.pointerType === "mouse" && e.button !== 0)) return;
|
|
if ((e.target as Element).closest("button, a, input, textarea, select, .wave-wrap")) return;
|
|
this.drag = { id: e.pointerId, x0: e.clientX, y0: e.clientY, base: this.offset, active: false, samples: [] };
|
|
}
|
|
|
|
private move(e: PointerEvent): void {
|
|
const d = this.drag;
|
|
if (!d || e.pointerId !== d.id) return;
|
|
// The button came up where we did not see it (outside the window, or while
|
|
// the drop was being turned).
|
|
if (e.pointerType === "mouse" && !(e.buttons & 1)) {
|
|
this.up(e, true);
|
|
return;
|
|
}
|
|
const dx = e.clientX - d.x0;
|
|
const dy = e.clientY - d.y0;
|
|
if (!d.active) {
|
|
if (Math.abs(dy) < SLOP || Math.abs(dy) < Math.abs(dx)) return;
|
|
d.active = true;
|
|
this.root.setPointerCapture(e.pointerId);
|
|
// Catch a card that is still settling where it is.
|
|
cancelAnimationFrame(this.raf);
|
|
this.raf = 0;
|
|
d.base = this.offset;
|
|
d.y0 = e.clientY;
|
|
}
|
|
let off = d.base + (e.clientY - d.y0);
|
|
const atStart = this.index === 0 && off > 0;
|
|
const atEnd = this.index >= this.src.count() - 1 && off < 0;
|
|
if (atStart || atEnd) off = Math.sign(off) * this.rubber(Math.abs(off));
|
|
if (atEnd) this.src.onNeedMore();
|
|
this.offset = off;
|
|
const now = performance.now();
|
|
d.samples.push([now, e.clientY]);
|
|
while (d.samples.length > 2 && now - d.samples[0][0] > 100) d.samples.shift();
|
|
this.render();
|
|
}
|
|
|
|
/// Resistance past the first and last card.
|
|
private rubber(x: number): number {
|
|
const h = this.height;
|
|
return (1 - 1 / ((x * 0.55) / h + 1)) * h;
|
|
}
|
|
|
|
private up(e: PointerEvent, cancelled = false): void {
|
|
const d = this.drag;
|
|
if (!d || e.pointerId !== d.id) return;
|
|
this.drag = null;
|
|
if (!d.active) return;
|
|
this.suppressClick = true;
|
|
setTimeout(() => (this.suppressClick = false), 350);
|
|
let v = 0; // px/ms
|
|
if (!cancelled && d.samples.length > 1) {
|
|
const [t0, y0] = d.samples[0];
|
|
const [t1, y1] = d.samples[d.samples.length - 1];
|
|
if (t1 > t0) v = (y1 - y0) / (t1 - t0);
|
|
}
|
|
const h = this.height;
|
|
let target = this.index;
|
|
if ((this.offset < -h * DISTANCE || v < -FLING) && this.index + 1 < this.src.count()) target++;
|
|
else if ((this.offset > h * DISTANCE || v > FLING) && this.index > 0) target--;
|
|
this.velocity = v * 1000;
|
|
if (target !== this.index) {
|
|
this.offset += (target - this.index) * h;
|
|
this.index = target;
|
|
this.refresh();
|
|
this.src.onChange(target);
|
|
if (target >= this.src.count() - 1) this.src.onNeedMore();
|
|
}
|
|
if (this.reduced.matches) this.settleNow();
|
|
else this.animate();
|
|
}
|
|
|
|
// Wheel and trackpad -------------------------------------------------------
|
|
|
|
private wheel(e: WheelEvent): void {
|
|
if (e.ctrlKey) return; // pinch zoom
|
|
e.preventDefault();
|
|
clearTimeout(this.wheelTimer);
|
|
this.wheelTimer = window.setTimeout(() => {
|
|
this.wheelLocked = false;
|
|
this.wheelAcc = 0;
|
|
}, WHEEL_IDLE);
|
|
if (this.wheelLocked) return;
|
|
this.wheelAcc += e.deltaY * (e.deltaMode === 1 ? 30 : e.deltaMode === 2 ? this.height : 1);
|
|
if (Math.abs(this.wheelAcc) >= WHEEL_STEP) {
|
|
// One page per gesture: the rest of a trackpad swipe's inertia is ignored.
|
|
this.wheelLocked = true;
|
|
this.go(this.index + Math.sign(this.wheelAcc));
|
|
this.wheelAcc = 0;
|
|
}
|
|
}
|
|
}
|