179 lines
5.4 KiB
TypeScript
179 lines
5.4 KiB
TypeScript
import { clamp } from '@robonen/stdlib';
|
|
import type { DrawerDirection } from './types';
|
|
import {
|
|
MAX_VELOCITY_AGE,
|
|
MIN_SETTLE_DURATION,
|
|
MIN_VELOCITY_DT,
|
|
REVERSE_CANCEL_ARM_DISTANCE,
|
|
REVERSE_CANCEL_THRESHOLD,
|
|
SETTLE_VELOCITY_THRESHOLD,
|
|
TRANSITIONS,
|
|
} from './constants';
|
|
|
|
/** The client-coordinate axis a drawer drags along. */
|
|
export type GestureAxis = 'x' | 'y';
|
|
|
|
export interface VelocityTracker {
|
|
/** Record a pointer sample (client coordinate along the axis + event timeStamp). */
|
|
add: (position: number, time: number) => void;
|
|
/**
|
|
* Instantaneous velocity (px/ms) over the two newest samples. Returns 0 when
|
|
* the last sample is older than {@link MAX_VELOCITY_AGE} — the pointer paused
|
|
* before release, so no fling momentum should apply.
|
|
*/
|
|
read: (now: number) => number;
|
|
reset: () => void;
|
|
}
|
|
|
|
/**
|
|
* Instantaneous release velocity from the trailing pair of pointer samples,
|
|
* instead of averaging the whole gesture: "slow pull, then flick" reads as a
|
|
* flick, and "fast start, stop, release" reads as a stop.
|
|
*/
|
|
export function createVelocityTracker(): VelocityTracker {
|
|
let lastPosition = 0;
|
|
let lastTime = Number.NaN;
|
|
let velocity = 0;
|
|
|
|
return {
|
|
add(position, time) {
|
|
if (!Number.isNaN(lastTime) && time > lastTime) {
|
|
// Clamp dt so same-frame event bursts don't produce huge spikes.
|
|
const dt = Math.max(time - lastTime, MIN_VELOCITY_DT);
|
|
velocity = (position - lastPosition) / dt;
|
|
}
|
|
|
|
lastPosition = position;
|
|
lastTime = time;
|
|
},
|
|
read(now) {
|
|
if (Number.isNaN(lastTime) || now - lastTime > MAX_VELOCITY_AGE)
|
|
return 0;
|
|
|
|
return velocity;
|
|
},
|
|
reset() {
|
|
lastPosition = 0;
|
|
lastTime = Number.NaN;
|
|
velocity = 0;
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface ReverseCancelTracker {
|
|
/** Feed the current dismiss-positive displacement (px). */
|
|
update: (displacement: number) => void;
|
|
/** Whether the gesture pulled back far enough to cancel the dismiss. */
|
|
readonly cancelled: boolean;
|
|
reset: () => void;
|
|
}
|
|
|
|
/**
|
|
* Detects the "changed my mind" gesture: once the drawer has been dragged at
|
|
* least {@link REVERSE_CANCEL_ARM_DISTANCE} toward dismiss, pulling back by
|
|
* {@link REVERSE_CANCEL_THRESHOLD} from the furthest point cancels the dismiss
|
|
* even if the release still sits past the close threshold. Dragging past the
|
|
* previous furthest point re-arms the dismiss (renewed intent).
|
|
*/
|
|
export function createReverseCancelTracker(): ReverseCancelTracker {
|
|
let max = 0;
|
|
let cancelled = false;
|
|
|
|
return {
|
|
update(displacement) {
|
|
if (displacement >= max) {
|
|
max = displacement;
|
|
cancelled = false;
|
|
return;
|
|
}
|
|
|
|
if (max > REVERSE_CANCEL_ARM_DISTANCE && max - displacement > REVERSE_CANCEL_THRESHOLD)
|
|
cancelled = true;
|
|
},
|
|
get cancelled() {
|
|
return cancelled;
|
|
},
|
|
reset() {
|
|
max = 0;
|
|
cancelled = false;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Settle duration (in seconds) scaled by the release velocity: a hard flick
|
|
* over a short remaining distance settles in as little as
|
|
* {@link MIN_SETTLE_DURATION}ms, while a gentle release keeps the default
|
|
* {@link TRANSITIONS} duration. Never returns a duration longer than the default.
|
|
*/
|
|
export function computeSettleDuration(remainingDistance: number, velocity: number): number {
|
|
const fallback = TRANSITIONS.DURATION;
|
|
|
|
if (!Number.isFinite(remainingDistance) || remainingDistance <= 0)
|
|
return fallback;
|
|
|
|
const speed = Math.abs(velocity);
|
|
|
|
if (!Number.isFinite(speed) || speed < SETTLE_VELOCITY_THRESHOLD)
|
|
return fallback;
|
|
|
|
return clamp(remainingDistance / speed, MIN_SETTLE_DURATION, fallback * 1000) / 1000;
|
|
}
|
|
|
|
/**
|
|
* The nearest ancestor (from `start` up to and including `boundary`) that can
|
|
* scroll along `axis`. The `getComputedStyle` read runs at most once per
|
|
* candidate and only at gesture start — never per pointer move.
|
|
*/
|
|
export function findScrollableAncestor(
|
|
start: Element | null,
|
|
boundary: HTMLElement,
|
|
axis: GestureAxis,
|
|
): HTMLElement | null {
|
|
let element: Element | null = start;
|
|
|
|
while (element) {
|
|
if (element instanceof HTMLElement) {
|
|
const canScroll = axis === 'y'
|
|
? element.scrollHeight > element.clientHeight
|
|
: element.scrollWidth > element.clientWidth;
|
|
|
|
if (canScroll) {
|
|
const overflow = getComputedStyle(element)[axis === 'y' ? 'overflowY' : 'overflowX'];
|
|
|
|
if (overflow === 'auto' || overflow === 'scroll')
|
|
return element;
|
|
}
|
|
}
|
|
|
|
if (element === boundary)
|
|
break;
|
|
|
|
element = element.parentElement;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Whether a scroll container sits at the edge the dismiss gesture pulls away
|
|
* from — only then may a drag that starts inside it become a drawer gesture;
|
|
* otherwise the user is scrolling, not dismissing:
|
|
* - `bottom` drawer dismisses downward → the scroller must be at its top;
|
|
* - `top` drawer dismisses upward → at its bottom;
|
|
* - `right` drawer dismisses rightward → at its left edge;
|
|
* - `left` drawer dismisses leftward → at its right edge.
|
|
*/
|
|
export function isAtScrollEdge(scroller: HTMLElement, direction: DrawerDirection): boolean {
|
|
switch (direction) {
|
|
case 'bottom':
|
|
return scroller.scrollTop <= 0;
|
|
case 'top':
|
|
return scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
|
|
case 'right':
|
|
return scroller.scrollLeft <= 0;
|
|
case 'left':
|
|
return scroller.scrollLeft >= scroller.scrollWidth - scroller.clientWidth;
|
|
}
|
|
}
|