import { ref, watch, onUnmounted, type Ref } from 'vue'; export function useAutoScroll(elementRef: Ref, options: { threshold?: number; } = {}) { const { threshold = 30 } = options; /** User intentionally left the bottom — stop following new content */ const unhooked = ref(false); /** Back-compat alias for UI that keys off "user scrolled away" */ const isUserScrollingUp = unhooked; const shouldAutoScroll = ref(true); let isProgrammatic = false; let lastScrollTop = 0; let mutationRaf: number | null = null; let anchorRaf: number | null = null; let observer: MutationObserver | null = null; let resizeObserver: ResizeObserver | null = null; let lastWidth = 0; let anchor: { element: Element; offsetFromTop: number } | null = null; const isAtBottom = (el: HTMLElement) => el.scrollHeight - el.scrollTop - el.clientHeight <= threshold; const setUnhooked = (value: boolean) => { unhooked.value = value; shouldAutoScroll.value = !value; }; const scrollToBottom = (behavior: ScrollBehavior = 'instant') => { const el = elementRef.value; if (!el) return; isProgrammatic = true; el.scrollTo({ top: el.scrollHeight, behavior, }); requestAnimationFrame(() => { isProgrammatic = false; if (elementRef.value && isAtBottom(elementRef.value)) { setUnhooked(false); lastScrollTop = elementRef.value.scrollTop; } }); }; const captureAnchor = (el: HTMLElement) => { const rect = el.getBoundingClientRect(); const x = rect.left + rect.width / 2; const offsets = [4, 24, 48, 80, 120]; for (const y of offsets) { const target = document.elementFromPoint(x, rect.top + y); if (!target || target === el || !el.contains(target)) continue; anchor = { element: target, offsetFromTop: target.getBoundingClientRect().top - rect.top, }; return; } anchor = null; }; const scheduleAnchorCapture = () => { if (anchorRaf !== null) return; anchorRaf = requestAnimationFrame(() => { anchorRaf = null; const el = elementRef.value; if (el) captureAnchor(el); }); }; const restoreAnchor = (el: HTMLElement) => { if (!anchor || !el.contains(anchor.element)) return; const top = el.getBoundingClientRect().top; const delta = anchor.element.getBoundingClientRect().top - top - anchor.offsetFromTop; if (Math.abs(delta) < 0.5) return; isProgrammatic = true; el.scrollTop += delta; requestAnimationFrame(() => { isProgrammatic = false; }); }; const handleWheel = (event: WheelEvent) => { isProgrammatic = false; // Intent to leave bottom — unhook before content mutations can re-stick if (event.deltaY < 0) { setUnhooked(true); } }; const handleScroll = () => { const el = elementRef.value; if (!el) return; const scrollingUp = el.scrollTop < lastScrollTop; lastScrollTop = el.scrollTop; scheduleAnchorCapture(); if (isProgrammatic) { if (isAtBottom(el)) setUnhooked(false); return; } // Only re-hook when actually inside the bottom threshold. // Mid-page scroll (up or down) must never re-enable stick. if (isAtBottom(el)) { setUnhooked(false); return; } if (scrollingUp) { setUnhooked(true); } }; const followIfHooked = () => { if (unhooked.value) return; scrollToBottom('instant'); }; const handleMutation = () => { if (mutationRaf !== null) return; mutationRaf = requestAnimationFrame(() => { mutationRaf = null; followIfHooked(); }); }; const attach = (el: HTMLElement) => { el.style.overflowAnchor = 'none'; lastScrollTop = el.scrollTop; lastWidth = el.clientWidth; setUnhooked(false); el.addEventListener('wheel', handleWheel, { passive: true }); el.addEventListener('scroll', handleScroll, { passive: true }); observer = new MutationObserver(handleMutation); observer.observe(el, { childList: true, subtree: true, characterData: true, }); resizeObserver = new ResizeObserver(() => { const current = elementRef.value; if (!current) return; const width = current.clientWidth; if (width === lastWidth) { // Height-only growth (streaming layout): stay stuck if hooked if (!unhooked.value) { scrollToBottom('instant'); } return; } lastWidth = width; if (!unhooked.value) { scrollToBottom('instant'); return; } restoreAnchor(current); }); resizeObserver.observe(el); // Content already taller than the viewport on mount — catch up once requestAnimationFrame(() => { if (!unhooked.value) scrollToBottom('instant'); }); }; const detach = (el: HTMLElement | null) => { if (el) { el.removeEventListener('wheel', handleWheel); el.removeEventListener('scroll', handleScroll); } observer?.disconnect(); observer = null; resizeObserver?.disconnect(); resizeObserver = null; if (mutationRaf !== null) { cancelAnimationFrame(mutationRaf); mutationRaf = null; } if (anchorRaf !== null) { cancelAnimationFrame(anchorRaf); anchorRaf = null; } anchor = null; }; watch(elementRef, (newEl, oldEl) => { detach(oldEl ?? null); if (newEl) attach(newEl); }, { immediate: true }); onUnmounted(() => { detach(elementRef.value); }); return { scrollToBottom, isUserScrollingUp, shouldAutoScroll, isAtBottom: () => { const el = elementRef.value; return el ? isAtBottom(el) : true; }, }; }