367 lines
9.6 KiB
TypeScript
367 lines
9.6 KiB
TypeScript
import { animate } from "motion";
|
|
|
|
/**
|
|
* Svelte action: fade in + slide up on mount
|
|
*/
|
|
export function fadeIn(
|
|
node: HTMLElement,
|
|
options?: { duration?: number; delay?: number; y?: number },
|
|
) {
|
|
const { duration = 0.5, delay = 0, y = 15 } = options ?? {};
|
|
node.style.opacity = "0";
|
|
|
|
const controls = animate(
|
|
node,
|
|
{ opacity: [0, 1], transform: [`translateY(${y}px)`, "translateY(0px)"] },
|
|
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
|
|
);
|
|
|
|
return {
|
|
destroy() {
|
|
controls.cancel();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Svelte action: scale in + fade on mount
|
|
*/
|
|
export function scaleIn(
|
|
node: HTMLElement,
|
|
options?: { duration?: number; delay?: number },
|
|
) {
|
|
const { duration = 0.6, delay = 0 } = options ?? {};
|
|
node.style.opacity = "0";
|
|
|
|
const controls = animate(
|
|
node,
|
|
{ opacity: [0, 1], transform: ["scale(0.92)", "scale(1)"] },
|
|
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
|
|
);
|
|
|
|
return {
|
|
destroy() {
|
|
controls.cancel();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Svelte action: animate when scrolled into view (IntersectionObserver)
|
|
*/
|
|
export function inView(
|
|
node: HTMLElement,
|
|
options?: { delay?: number; y?: number; threshold?: number },
|
|
) {
|
|
const { delay = 0, y = 20, threshold = 0.05 } = options ?? {};
|
|
node.style.opacity = "0";
|
|
node.style.transform = `translateY(${y}px)`;
|
|
|
|
let controls: ReturnType<typeof animate> | null = null;
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) {
|
|
controls = animate(
|
|
node,
|
|
{
|
|
opacity: [0, 1],
|
|
transform: [`translateY(${y}px)`, "translateY(0px)"],
|
|
},
|
|
{ duration: 0.45, delay, easing: [0.22, 0.03, 0.26, 1] },
|
|
);
|
|
observer.disconnect();
|
|
}
|
|
},
|
|
{ threshold },
|
|
);
|
|
|
|
observer.observe(node);
|
|
|
|
return {
|
|
destroy() {
|
|
observer.disconnect();
|
|
controls?.cancel();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Svelte action: spring-scale press feedback on buttons
|
|
*/
|
|
export function pressable(node: HTMLElement) {
|
|
let active: ReturnType<typeof animate> | null = null;
|
|
|
|
function onDown() {
|
|
active?.cancel();
|
|
// Explicit [from, to] avoids transform conflicts with fadeIn's translateY
|
|
active = animate(
|
|
node,
|
|
{ transform: ["scale(1)", "scale(0.95)"] },
|
|
{ duration: 0.1, easing: [0.22, 0.03, 0.26, 1] },
|
|
);
|
|
}
|
|
|
|
function onUp() {
|
|
active?.cancel();
|
|
active = animate(
|
|
node,
|
|
{ transform: ["scale(0.95)", "scale(1)"] },
|
|
{ duration: 0.3, easing: [0.22, 1.2, 0.36, 1] },
|
|
);
|
|
}
|
|
|
|
node.addEventListener("mousedown", onDown);
|
|
node.addEventListener("mouseup", onUp);
|
|
node.addEventListener("mouseleave", onUp);
|
|
|
|
return {
|
|
destroy() {
|
|
node.removeEventListener("mousedown", onDown);
|
|
node.removeEventListener("mouseup", onUp);
|
|
node.removeEventListener("mouseleave", onUp);
|
|
active?.cancel();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Svelte action: animated glow band on hover.
|
|
* Creates a crisp ring "band" plus a soft atmospheric glow.
|
|
* Pass a hex color (e.g. "#ff4d00").
|
|
*
|
|
* Uses same-hue zero-alpha as the "off" state so the Web Animations API
|
|
* interpolates through the correct color channel instead of through black.
|
|
*/
|
|
export function glowHover(
|
|
node: HTMLElement,
|
|
options?: { color?: string },
|
|
) {
|
|
let color = options?.color ?? "#ff4d00";
|
|
let enterAnim: ReturnType<typeof animate> | null = null;
|
|
let leaveAnim: ReturnType<typeof animate> | null = null;
|
|
|
|
// "off" state: same hue at zero alpha (NOT transparent, which is rgba(0,0,0,0))
|
|
function off() { return `0 0 0 0px ${color}00, 0 0 0px 0px ${color}00`; }
|
|
function on() { return `0 0 0 1.5px ${color}90, 0 0 22px 6px ${color}40`; }
|
|
|
|
node.style.boxShadow = off();
|
|
|
|
function onEnter() {
|
|
leaveAnim?.cancel();
|
|
enterAnim = animate(
|
|
node,
|
|
{ boxShadow: [off(), on()] },
|
|
{ duration: 0.4, easing: [0.22, 0.03, 0.26, 1] },
|
|
);
|
|
}
|
|
|
|
function onLeave() {
|
|
enterAnim?.cancel();
|
|
leaveAnim = animate(
|
|
node,
|
|
{ boxShadow: [on(), off()] },
|
|
{ duration: 0.5, easing: [0.22, 0.03, 0.26, 1] },
|
|
);
|
|
}
|
|
|
|
node.addEventListener("mouseenter", onEnter);
|
|
node.addEventListener("mouseleave", onLeave);
|
|
|
|
return {
|
|
update(newOptions?: { color?: string }) {
|
|
color = newOptions?.color ?? "#ff4d00";
|
|
node.style.boxShadow = off();
|
|
},
|
|
destroy() {
|
|
node.removeEventListener("mouseenter", onEnter);
|
|
node.removeEventListener("mouseleave", onLeave);
|
|
enterAnim?.cancel();
|
|
leaveAnim?.cancel();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Svelte action: momentum-based grab-and-drag scrolling
|
|
* with elastic overscroll and spring-back at boundaries.
|
|
*
|
|
* IMPORTANT: The node must have exactly one child element (a wrapper div).
|
|
* Overscroll transforms are applied to that child, NOT to the scroll
|
|
* container itself (which would break overflow clipping).
|
|
*/
|
|
export function dragScroll(node: HTMLElement) {
|
|
const content = node.children[0] as HTMLElement | null;
|
|
|
|
let isDown = false;
|
|
let startY = 0;
|
|
let scrollStart = 0;
|
|
let lastY = 0;
|
|
let lastTime = 0;
|
|
let animFrame = 0;
|
|
let velocitySamples: number[] = [];
|
|
let overscrollAmount = 0;
|
|
let springAnim: ReturnType<typeof animate> | null = null;
|
|
|
|
function getMaxScroll() {
|
|
return node.scrollHeight - node.clientHeight;
|
|
}
|
|
|
|
function setOverscroll(amount: number) {
|
|
if (!content) return;
|
|
overscrollAmount = amount;
|
|
if (Math.abs(amount) < 0.5) {
|
|
content.style.transform = "";
|
|
overscrollAmount = 0;
|
|
} else {
|
|
content.style.transform = `translateY(${-amount}px)`;
|
|
}
|
|
}
|
|
|
|
function springBack() {
|
|
if (!content) return;
|
|
const from = overscrollAmount;
|
|
if (Math.abs(from) < 0.5) {
|
|
setOverscroll(0);
|
|
return;
|
|
}
|
|
springAnim = animate(
|
|
content,
|
|
{ transform: [`translateY(${-from}px)`, "translateY(0px)"] },
|
|
{ duration: 0.6, easing: [0.16, 1, 0.3, 1] },
|
|
);
|
|
overscrollAmount = 0;
|
|
// Ensure DOM is clean after animation completes
|
|
springAnim.finished.then(() => {
|
|
if (content) content.style.transform = "";
|
|
}).catch(() => { /* cancelled — onDown handles cleanup */ });
|
|
}
|
|
|
|
function forceReset() {
|
|
springAnim?.cancel();
|
|
cancelAnimationFrame(animFrame);
|
|
overscrollAmount = 0;
|
|
if (content) content.style.transform = "";
|
|
}
|
|
|
|
function onDown(e: MouseEvent) {
|
|
if (e.button !== 0) return;
|
|
const tag = (e.target as HTMLElement).tagName;
|
|
if (["BUTTON", "INPUT", "LABEL", "SELECT", "TEXTAREA"].includes(tag)) return;
|
|
|
|
// Force-reset any lingering animation/transform state
|
|
forceReset();
|
|
|
|
isDown = true;
|
|
startY = e.pageY;
|
|
scrollStart = node.scrollTop;
|
|
lastY = e.pageY;
|
|
lastTime = Date.now();
|
|
velocitySamples = [];
|
|
node.style.cursor = "grabbing";
|
|
}
|
|
|
|
function onMove(e: MouseEvent) {
|
|
if (!isDown) return;
|
|
e.preventDefault();
|
|
const y = e.pageY;
|
|
const now = Date.now();
|
|
const dt = now - lastTime;
|
|
if (dt > 0) {
|
|
velocitySamples.push((lastY - y) / dt);
|
|
if (velocitySamples.length > 5) velocitySamples.shift();
|
|
}
|
|
lastY = y;
|
|
lastTime = now;
|
|
|
|
const desiredScroll = scrollStart - (y - startY);
|
|
const maxScroll = getMaxScroll();
|
|
|
|
if (desiredScroll < 0) {
|
|
node.scrollTop = 0;
|
|
setOverscroll(desiredScroll * 0.3);
|
|
} else if (desiredScroll > maxScroll) {
|
|
node.scrollTop = maxScroll;
|
|
setOverscroll((desiredScroll - maxScroll) * 0.3);
|
|
} else {
|
|
node.scrollTop = desiredScroll;
|
|
if (overscrollAmount !== 0) setOverscroll(0);
|
|
}
|
|
}
|
|
|
|
function onUp() {
|
|
if (!isDown) return;
|
|
isDown = false;
|
|
node.style.cursor = "";
|
|
|
|
if (overscrollAmount !== 0) {
|
|
springBack();
|
|
return;
|
|
}
|
|
|
|
const avgVelocity = velocitySamples.length > 0
|
|
? velocitySamples.reduce((a, b) => a + b, 0) / velocitySamples.length
|
|
: 0;
|
|
|
|
// Clamp velocity (px/ms) and bail if negligible
|
|
const maxV = 4;
|
|
const v0 = Math.max(-maxV, Math.min(maxV, avgVelocity));
|
|
if (Math.abs(v0) < 0.005) return;
|
|
|
|
// Time-based exponential decay (iOS-style scroll physics).
|
|
// position(t) = start + v0 * tau * (1 - e^(-t/tau))
|
|
// velocity(t) = v0 * e^(-t/tau) → naturally asymptotes to zero
|
|
const tau = 325; // time constant in ms — iOS UIScrollView feel
|
|
const coastStart = performance.now();
|
|
const scrollStart2 = node.scrollTop;
|
|
const totalDist = v0 * tau;
|
|
|
|
function coast() {
|
|
const t = performance.now() - coastStart;
|
|
const decay = Math.exp(-t / tau);
|
|
const offset = totalDist * (1 - decay);
|
|
const targetScroll = scrollStart2 + offset;
|
|
|
|
const maxScroll = getMaxScroll();
|
|
|
|
if (targetScroll < 0) {
|
|
node.scrollTop = 0;
|
|
const currentV = Math.abs(v0 * decay);
|
|
const bounce = Math.min(40, currentV * 50);
|
|
setOverscroll(-bounce);
|
|
springBack();
|
|
return;
|
|
}
|
|
if (targetScroll > maxScroll) {
|
|
node.scrollTop = maxScroll;
|
|
const currentV = Math.abs(v0 * decay);
|
|
const bounce = Math.min(40, currentV * 50);
|
|
setOverscroll(bounce);
|
|
springBack();
|
|
return;
|
|
}
|
|
|
|
node.scrollTop = targetScroll;
|
|
|
|
// Stop when velocity < 0.5 px/sec (completely imperceptible)
|
|
if (Math.abs(v0 * decay) > 0.0005) {
|
|
animFrame = requestAnimationFrame(coast);
|
|
}
|
|
}
|
|
coast();
|
|
}
|
|
|
|
node.addEventListener("mousedown", onDown);
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
|
|
return {
|
|
destroy() {
|
|
node.removeEventListener("mousedown", onDown);
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
forceReset();
|
|
},
|
|
};
|
|
}
|