Build & Push Docker Image / build (push) Has been cancelled
Refresh the homepage around new hero/services/roadmap sections, shared design tokens, and updated brand assets. Co-authored-by: Cursor <cursoragent@cursor.com>
273 lines
8.4 KiB
TypeScript
273 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
|
|
|
/** Min time between step changes from wheel / proximity. */
|
|
const STEP_COOLDOWN_MS = 550;
|
|
/** Accumulate trackpad deltas before treating as one step intent. */
|
|
const WHEEL_INTENT_PX = 36;
|
|
/** Section must cover this band of the viewport to capture wheel. */
|
|
const LOCK_INSET = 0.18;
|
|
/** Proximity fallback hysteresis (touch / reduced-motion). */
|
|
const HYSTERESIS_PX = 48;
|
|
/** After click, ignore proximity / wheel stepping briefly. */
|
|
const MANUAL_LOCK_MS = 700;
|
|
|
|
function prefersReducedMotion(): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
}
|
|
|
|
/**
|
|
* Activates timeline steps one-at-a-time.
|
|
* While the section is in view, wheel/trackpad scroll is captured so each
|
|
* intentional scroll advances or retreats a single step (no skipping).
|
|
* Page scroll is released at the first/last step so the user can leave.
|
|
*/
|
|
export function useActiveTimelineItem(
|
|
itemCount: number,
|
|
sectionRef: RefObject<HTMLElement | null>
|
|
) {
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
const itemRefs = useRef<(HTMLElement | null)[]>([]);
|
|
const activeIndexRef = useRef(0);
|
|
const rafRef = useRef<number | null>(null);
|
|
const cooldownUntilRef = useRef(0);
|
|
const wheelAccumRef = useRef(0);
|
|
const manualLockRef = useRef(false);
|
|
const manualLockTimeoutRef = useRef<number | null>(null);
|
|
const engagedRef = useRef(false);
|
|
|
|
const setItemRef = useCallback(
|
|
(index: number) => (element: HTMLElement | null) => {
|
|
itemRefs.current[index] = element;
|
|
},
|
|
[]
|
|
);
|
|
|
|
const scrollStepIntoView = useCallback((index: number) => {
|
|
const el = itemRefs.current[index];
|
|
if (!el) return;
|
|
el.scrollIntoView({
|
|
behavior: prefersReducedMotion() ? "auto" : "smooth",
|
|
block: "center",
|
|
});
|
|
}, []);
|
|
|
|
const goToIndex = useCallback(
|
|
(index: number, opts?: { scroll?: boolean; cooldown?: boolean }) => {
|
|
const nextIndex = Math.min(Math.max(index, 0), itemCount - 1);
|
|
activeIndexRef.current = nextIndex;
|
|
setActiveIndex(nextIndex);
|
|
|
|
if (opts?.scroll !== false) {
|
|
scrollStepIntoView(nextIndex);
|
|
}
|
|
|
|
if (opts?.cooldown !== false) {
|
|
cooldownUntilRef.current = performance.now() + STEP_COOLDOWN_MS;
|
|
}
|
|
},
|
|
[itemCount, scrollStepIntoView]
|
|
);
|
|
|
|
// Wheel lock — one scroll gesture → exactly one step (no skip)
|
|
useEffect(() => {
|
|
const section = sectionRef.current;
|
|
if (!section || itemCount < 1) return;
|
|
if (prefersReducedMotion()) return;
|
|
|
|
const isSectionEngaged = () => {
|
|
const rect = section.getBoundingClientRect();
|
|
const vh = window.innerHeight;
|
|
return (
|
|
rect.top < vh * (1 - LOCK_INSET) && rect.bottom > vh * LOCK_INSET
|
|
);
|
|
};
|
|
|
|
let lastScrollDir: 1 | -1 = 1;
|
|
|
|
const onScroll = () => {
|
|
const engaged = isSectionEngaged();
|
|
if (engaged && !engagedRef.current) {
|
|
// Enter at the edge step for this direction — no snap scroll, no cooldown
|
|
// so the next wheel gesture immediately advances one step (not two-phase).
|
|
const edge = lastScrollDir >= 0 ? 0 : itemCount - 1;
|
|
activeIndexRef.current = edge;
|
|
setActiveIndex(edge);
|
|
wheelAccumRef.current = 0;
|
|
cooldownUntilRef.current = 0;
|
|
}
|
|
if (!engaged) {
|
|
wheelAccumRef.current = 0;
|
|
}
|
|
engagedRef.current = engaged;
|
|
};
|
|
|
|
const onWheel = (event: WheelEvent) => {
|
|
if (manualLockRef.current) return;
|
|
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|
|
|
|
if (event.deltaY !== 0) {
|
|
lastScrollDir = event.deltaY > 0 ? 1 : -1;
|
|
}
|
|
|
|
if (!isSectionEngaged()) {
|
|
wheelAccumRef.current = 0;
|
|
engagedRef.current = false;
|
|
return;
|
|
}
|
|
|
|
engagedRef.current = true;
|
|
|
|
const now = performance.now();
|
|
const current = activeIndexRef.current;
|
|
const goingDown = event.deltaY > 0;
|
|
|
|
// At ends, release so the page can leave the section.
|
|
if (goingDown && current >= itemCount - 1) {
|
|
wheelAccumRef.current = 0;
|
|
return;
|
|
}
|
|
if (!goingDown && current <= 0) {
|
|
wheelAccumRef.current = 0;
|
|
return;
|
|
}
|
|
|
|
// Capture scroll while mid-timeline — prevents skipping steps.
|
|
event.preventDefault();
|
|
|
|
if (now < cooldownUntilRef.current) {
|
|
wheelAccumRef.current = 0;
|
|
return;
|
|
}
|
|
|
|
wheelAccumRef.current += event.deltaY;
|
|
if (Math.abs(wheelAccumRef.current) < WHEEL_INTENT_PX) return;
|
|
|
|
const direction = wheelAccumRef.current > 0 ? 1 : -1;
|
|
wheelAccumRef.current = 0;
|
|
goToIndex(current + direction);
|
|
};
|
|
|
|
window.addEventListener("wheel", onWheel, { passive: false });
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
return () => {
|
|
window.removeEventListener("wheel", onWheel);
|
|
window.removeEventListener("scroll", onScroll);
|
|
};
|
|
}, [goToIndex, itemCount, sectionRef]);
|
|
|
|
// Proximity fallback for touch, keyboard, and reduced-motion (max ±1 step)
|
|
useEffect(() => {
|
|
const section = sectionRef.current;
|
|
const elements = itemRefs.current.filter(Boolean) as HTMLElement[];
|
|
if (!section || !elements.length) return;
|
|
|
|
const pickActiveIndex = () => {
|
|
if (manualLockRef.current) return;
|
|
if (performance.now() < cooldownUntilRef.current) return;
|
|
|
|
// Wheel lock owns step changes while the section is engaged.
|
|
if (!prefersReducedMotion()) {
|
|
const rect = section.getBoundingClientRect();
|
|
const vh = window.innerHeight;
|
|
const engaged =
|
|
rect.top < vh * (1 - LOCK_INSET) && rect.bottom > vh * LOCK_INSET;
|
|
if (engaged) return;
|
|
}
|
|
|
|
const viewportCenter = window.innerHeight / 2;
|
|
const sectionRect = section.getBoundingClientRect();
|
|
const sectionVisible =
|
|
sectionRect.top < window.innerHeight * 0.92 &&
|
|
sectionRect.bottom > window.innerHeight * 0.08;
|
|
|
|
if (!sectionVisible) return;
|
|
|
|
let closestIndex = 0;
|
|
let closestDistance = Infinity;
|
|
|
|
elements.forEach((element, index) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const elementCenter = rect.top + rect.height / 2;
|
|
const distance = Math.abs(elementCenter - viewportCenter);
|
|
|
|
if (distance < closestDistance) {
|
|
closestDistance = distance;
|
|
closestIndex = index;
|
|
}
|
|
});
|
|
|
|
setActiveIndex((prev) => {
|
|
if (closestIndex === prev) return prev;
|
|
|
|
const prevElement = elements[prev];
|
|
if (!prevElement) {
|
|
activeIndexRef.current = closestIndex;
|
|
return closestIndex;
|
|
}
|
|
|
|
const prevRect = prevElement.getBoundingClientRect();
|
|
const prevCenter = prevRect.top + prevRect.height / 2;
|
|
const prevDistance = Math.abs(prevCenter - viewportCenter);
|
|
|
|
if (closestDistance > prevDistance - HYSTERESIS_PX) return prev;
|
|
|
|
let next = prev;
|
|
if (closestIndex > prev) next = prev + 1;
|
|
else if (closestIndex < prev) next = prev - 1;
|
|
|
|
activeIndexRef.current = next;
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const scheduleUpdate = () => {
|
|
if (rafRef.current !== null) return;
|
|
rafRef.current = window.requestAnimationFrame(() => {
|
|
rafRef.current = null;
|
|
pickActiveIndex();
|
|
});
|
|
};
|
|
|
|
pickActiveIndex();
|
|
window.addEventListener("scroll", scheduleUpdate, { passive: true });
|
|
window.addEventListener("resize", scheduleUpdate, { passive: true });
|
|
|
|
return () => {
|
|
window.removeEventListener("scroll", scheduleUpdate);
|
|
window.removeEventListener("resize", scheduleUpdate);
|
|
if (rafRef.current !== null) {
|
|
window.cancelAnimationFrame(rafRef.current);
|
|
}
|
|
};
|
|
}, [itemCount, sectionRef]);
|
|
|
|
const selectActiveIndex = useCallback(
|
|
(index: number) => {
|
|
goToIndex(index, { scroll: true, cooldown: true });
|
|
|
|
manualLockRef.current = true;
|
|
if (manualLockTimeoutRef.current !== null) {
|
|
window.clearTimeout(manualLockTimeoutRef.current);
|
|
}
|
|
manualLockTimeoutRef.current = window.setTimeout(() => {
|
|
manualLockRef.current = false;
|
|
manualLockTimeoutRef.current = null;
|
|
}, MANUAL_LOCK_MS);
|
|
},
|
|
[goToIndex]
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (manualLockTimeoutRef.current !== null) {
|
|
window.clearTimeout(manualLockTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return { activeIndex, setItemRef, selectActiveIndex };
|
|
}
|