UNPKG

@blossom-carousel/svelte

Version:

A native-scroll-first carousel component for Svelte.

105 lines (104 loc) 4.41 kB
import { getActiveMarkerIndex, getMarkerPositions, getMarkerTargets, } from "./markers.js"; import { canScroll } from "./scroll.js"; import { getMarkerSnaps, getSnapPositions, isRtl } from "./snap.js"; import { clearSnapCache, getSnapCache, setSnapCache } from "./cache.js"; /** * Computes the current navigation state of a scroller via native reads. Uses * the cached, scroll-invariant marker list and geometry when available so the * per-frame work on the scroll hot loop is free of DOM queries, style reads, * sorts, and allocations — only the scroll metrics are read live. */ export function getNavigationState(scroller) { const cache = getSnapCache(scroller); const targets = cache?.targets ?? getMarkerTargets(scroller); return { count: targets.length, activeIndex: getActiveMarkerIndex(scroller, targets, cache), canPrev: canScroll(scroller, "prev"), canNext: canScroll(scroller, "next"), }; } /** Recomputes and caches the scroll-invariant snap geometry for a scroller. */ function refreshSnapCache(scroller) { const targets = getMarkerTargets(scroller); const activePositions = getMarkerPositions(scroller, targets); setSnapCache(scroller, { targets, markers: getMarkerSnaps(targets), pages: getSnapPositions(scroller), activePositions, sortedActivePositions: [...activePositions].sort((a, b) => a - b), rtl: isRtl(scroller), }); } const OBSERVERS = Symbol.for("blossom-carousel.navigation.observers"); /** Sets up the single shared observer set + frame loop for a scroller. */ function createRegistration(scroller) { const subscribers = new Set(); let frame = 0; let snapsDirty = true; const flush = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { if (snapsDirty) { snapsDirty = false; refreshSnapCache(scroller); } const state = getNavigationState(scroller); for (const subscriber of subscribers) subscriber(state); }); }; // Scroll/snap settling: nav state changes, snap geometry does not. const emit = () => flush(); // Layout shifts: snap geometry must be recomputed before the next flush. const onLayoutChange = () => { snapsDirty = true; flush(); }; scroller.addEventListener("scroll", emit, { passive: true }); scroller.addEventListener("scrollend", emit, { passive: true }); const resizeObserver = new ResizeObserver(onLayoutChange); resizeObserver.observe(scroller); const mutationObserver = new MutationObserver(onLayoutChange); mutationObserver.observe(scroller, { childList: true, subtree: true }); const teardown = () => { cancelAnimationFrame(frame); scroller.removeEventListener("scroll", emit); scroller.removeEventListener("scrollend", emit); resizeObserver.disconnect(); mutationObserver.disconnect(); clearSnapCache(scroller); }; return { subscribers, flush, teardown }; } /** * Subscribes to everything that can change the navigation state (scrolling, * snap settling, resizes, and added/removed slides) and pushes a fresh * `NavigationState` to the callback, coalesced to one update per frame. Returns * a cleanup function that removes the subscriber. * * Ref-counted per scroller: multiple controls pointing at the same scroller * share one observer set and one frame loop, computing the state once and * fanning it out to every subscriber. The observers (and the per-scroller snap * cache they maintain — off the scroll path, since snap geometry only changes * on resize/mutation) are torn down once the last subscriber unsubscribes. */ export function observeNavigationState(scroller, callback) { const el = scroller; const registration = el[OBSERVERS] ?? createRegistration(scroller); el[OBSERVERS] = registration; registration.subscribers.add(callback); // Deliver an initial state to the new subscriber (coalesced with any others). registration.flush(); return () => { const current = el[OBSERVERS]; if (!current) return; current.subscribers.delete(callback); if (current.subscribers.size === 0) { current.teardown(); delete el[OBSERVERS]; } }; }