import { useEffect, useState } from "react"; // The shared scroll-affordance core behind `useScrollFade` (vertical edge fade) and the Carousel's // `useHScroll` (horizontal scroll buttons). Both need the exact same machinery — track whether a // scroller is clipped at the START and END of ONE axis, watching the scroller AND its children so the // edges re-evaluate even when no `scroll` event fires (a rail whose cards load in later grows // scrollWidth/scrollHeight silently; a Suspense fallback gets SWAPPED for the real content). This is // that machinery, parameterised by axis; the two hooks just map `start`/`end` to their own shape. // // Uses a node-state callback ref (not useRef) so the effect (re)runs whenever the element attaches — // e.g. a SidePanel that mounts collapsed and only renders its scroll body once expanded still wires up. // `start`/`end` = there is more content before / after the visible window ("y": top/bottom, "x": // left/right). The boolean-diff bail-out matters: `scroll` fires per frame and the page scroller's hook // sits above the whole tree, so returning a new object each frame would re-render everything. export function useScrollEdges(axis: "x" | "y"): { ref: (node: HTMLElement | null) => void; el: HTMLElement | null; start: boolean; end: boolean; } { const [el, setEl] = useState(null); const [edge, setEdge] = useState({ start: false, end: false }); useEffect(() => { if (!el) return; const update = () => { const pos = axis === "x" ? el.scrollLeft : el.scrollTop; const client = axis === "x" ? el.clientWidth : el.clientHeight; const scroll = axis === "x" ? el.scrollWidth : el.scrollHeight; const start = pos > 1; const end = pos + client < scroll - 1; setEdge((prev) => (prev.start === start && prev.end === end ? prev : { start, end })); }; update(); el.addEventListener("scroll", update, { passive: true }); // Watch the scroller AND its content: content that grows/shrinks/decrypts/filters changes neither // the scroller's own box nor its scroll offset, so without the children nothing re-evaluates. const ro = new ResizeObserver(update); ro.observe(el); for (const child of Array.from(el.children)) ro.observe(child); // Children get SWAPPED (a lazy page replacing its Suspense fallback), and a ResizeObserver holding a // detached node simply never fires again — so follow the child list, observing/unobserving exactly // the nodes named in each record (O(1) per change, not a re-observe of a long list on every append). const mo = new MutationObserver((records) => { for (const rec of records) { for (const node of Array.from(rec.addedNodes)) if (node instanceof Element) ro.observe(node); for (const node of Array.from(rec.removedNodes)) if (node instanceof Element) ro.unobserve(node); } update(); }); mo.observe(el, { childList: true }); return () => { el.removeEventListener("scroll", update); ro.disconnect(); mo.disconnect(); }; }, [el, axis]); return { ref: setEl, el, start: edge.start, end: edge.end }; }