Files
aragrp/components/roadmap/use-active-timeline-item.ts
T
ar.badieiandCursor c2376b3b9e
Build & Push Docker Image / build (push) Has been cancelled
feat: redesign landing page with brand system and section blocks
Refresh the homepage around new hero/services/roadmap sections, shared design tokens, and updated brand assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 17:00:55 +03:30

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 };
}