"use client"; import { computeTopAnchorReserve, computeTopAnchorTargetScrollTop, } from "./computeTopAnchorSlack"; import { createReserveObservers } from "./createReserveObservers"; import { createReserveElement, getAnchorId, setReserveHeight, snapScrollTop, } from "./topAnchorUtils"; /** * Minimal slice of `ThreadViewportStore` that the top-anchor reserve needs. * Decoupling from the full store keeps `mountTopAnchorReserve` testable in * isolation and re-usable from any consumer that can adapt to this shape. */ export type TopAnchorStore = { getState(): { turnAnchor: "top" | "bottom"; element: { viewport: HTMLElement | null; anchor: HTMLElement | null; target: HTMLElement | null; }; targetConfig: { tallerThan: number; visibleHeight: number; } | null; }; subscribe(fn: () => void): () => void; }; const createFrameScheduler = (fn: () => void) => { let frame: number | null = null; return { schedule: () => { if (frame !== null) return; frame = requestAnimationFrame(() => { frame = null; fn(); }); }, cancel: () => { if (frame !== null) { cancelAnimationFrame(frame); frame = null; } }, }; }; export const mountTopAnchorReserve = (store: TopAnchorStore) => { let reserve: HTMLElement | null = null; let lastScrolledAnchorId: string | undefined; function apply() { const state = store.getState(); const { viewport, anchor, target } = state.element; const clamp = state.targetConfig; if ( state.turnAnchor !== "top" || !viewport || !anchor || !target || !clamp ) { observers.disconnect(); if (reserve) { setReserveHeight(reserve, 0); reserve.remove(); } return; } reserve ??= createReserveElement(); if ( reserve.parentElement !== target.parentElement || reserve.previousElementSibling !== target ) { target.after(reserve); } observers.target(viewport, anchor, target); const reserveChanged = setReserveHeight( reserve, computeTopAnchorReserve({ viewport, anchor, reserve, ...clamp }), ); if (reserveChanged) { scheduler.schedule(); return; } const anchorId = getAnchorId(anchor); if (anchorId !== undefined && lastScrolledAnchorId === anchorId) return; const targetScrollTop = snapScrollTop( computeTopAnchorTargetScrollTop({ viewport, anchor, ...clamp }), ); if (Math.abs(viewport.scrollTop - targetScrollTop) > 1) { viewport.scrollTo({ top: targetScrollTop, behavior: "smooth" }); } if (anchorId !== undefined) lastScrolledAnchorId = anchorId; } const scheduler = createFrameScheduler(apply); const observers = createReserveObservers(scheduler.schedule); scheduler.schedule(); const unsubscribe = store.subscribe(scheduler.schedule); return () => { scheduler.cancel(); unsubscribe(); observers.disconnect(); reserve?.remove(); }; };