Files
kandev/apps/web/components/task/chat/chat-scroll-motion.ts
T

124 lines
4.2 KiB
TypeScript

export type ScrollMotion = {
request: () => void;
cancel: () => void;
dispose: () => void;
isRunning: () => boolean;
};
const activeMotions = new WeakMap<HTMLElement, ScrollMotion>();
const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]);
export function cancelChatScrollMotion(element: HTMLElement): void {
activeMotions.get(element)?.cancel();
}
function listenForScrollIntent(element: HTMLElement, interrupt: () => void): () => void {
const onKey = (event: KeyboardEvent) => {
const target = event.target;
if (
event.defaultPrevented ||
(target instanceof Element &&
target !== element &&
target.closest(
'button, input, select, textarea, a[href], [role="button"], [contenteditable]:not([contenteditable="false"])',
))
)
return;
if (SCROLL_KEYS.has(event.key)) interrupt();
};
const onPointer = (event: PointerEvent) => {
if (event.target !== element || element.offsetWidth <= element.clientWidth) return;
const bounds = element.getBoundingClientRect();
const left = bounds.left + element.clientLeft;
if (event.clientX < left || event.clientX >= left + element.clientWidth) interrupt();
};
let touchY: number | undefined;
const onTouchStart = (event: TouchEvent) => {
touchY = event.touches[0]?.clientY;
};
const onTouchEnd = () => {
touchY = undefined;
};
const onTouchMove = (event: TouchEvent) => {
const y = event.touches[0]?.clientY;
if (touchY === undefined || y === undefined || Math.abs(y - touchY) < 6) return;
touchY = undefined;
interrupt();
};
element.addEventListener("wheel", interrupt, { passive: true });
element.addEventListener("pointerdown", onPointer, { passive: true });
element.addEventListener("touchstart", onTouchStart, { passive: true });
element.addEventListener("touchmove", onTouchMove, { passive: true });
element.addEventListener("touchend", onTouchEnd);
element.addEventListener("touchcancel", onTouchEnd);
element.addEventListener("keydown", onKey);
return () => {
element.removeEventListener("wheel", interrupt);
element.removeEventListener("pointerdown", onPointer);
element.removeEventListener("touchstart", onTouchStart);
element.removeEventListener("touchmove", onTouchMove);
element.removeEventListener("touchend", onTouchEnd);
element.removeEventListener("touchcancel", onTouchEnd);
element.removeEventListener("keydown", onKey);
};
}
/** Geometry is read in frames, never in the message commit that requests follow. */
export function createChatScrollMotion(
element: HTMLElement,
canFollow: () => boolean,
onInterrupt: () => void,
): ScrollMotion {
let frame: number | null = null;
let target = -1;
let start = 0;
let startedAt = 0;
let previousFrameAt = 0;
let disposed = false;
const cancel = () => {
if (frame !== null) cancelAnimationFrame(frame);
frame = null;
target = -1;
};
const tick = (time: number) => {
frame = null;
if (!canFollow() || disposed) {
target = -1;
return;
}
const nextTarget = Math.max(0, element.scrollHeight - element.clientHeight);
if (nextTarget !== target) {
// Retarget from the previous frame so uninterrupted growth still advances.
startedAt = target === -1 ? time : previousFrameAt;
target = nextTarget;
start = element.scrollTop;
}
previousFrameAt = time;
const progress = Math.min(1, (time - startedAt) / 180);
const next = start + (target - start) * (1 - (1 - progress) ** 3);
element.scrollTop = Math.abs(target - next) < 0.5 ? target : next;
if (element.scrollTop !== target) frame = requestAnimationFrame(tick);
else target = -1;
};
const interrupt = () => {
cancel();
onInterrupt();
};
const removeListeners = listenForScrollIntent(element, interrupt);
const motion: ScrollMotion = {
request: () => {
if (!disposed && frame === null && canFollow()) frame = requestAnimationFrame(tick);
},
cancel,
isRunning: () => frame !== null,
dispose: () => {
disposed = true;
cancel();
removeListeners();
if (activeMotions.get(element) === motion) activeMotions.delete(element);
},
};
activeMotions.set(element, motion);
return motion;
}