import { watch } from "runed"; import { setContext, getContext } from "svelte"; const STICK_TO_BOTTOM_CONTEXT_KEY = Symbol("stick-to-bottom-context"); class StickToBottomContext { #element: HTMLElement | null = $state(null); #isAtBottom = $state(true); #resizeObserver: ResizeObserver | null = null; #mutationObserver: MutationObserver | null = null; #intersectionObserver: IntersectionObserver | null = null; #sentinel: HTMLElement | null = null; #userHasScrolled = $state(false); isAtBottom = $derived(this.#isAtBottom); // Debug method to help troubleshoot get debugInfo() { if (!this.#element) return null; const { scrollTop, scrollHeight, clientHeight } = this.#element; return { scrollTop, scrollHeight, clientHeight, isAtBottom: this.#isAtBottom, userHasScrolled: this.#userHasScrolled, hasElement: !!this.#element, hasSentinel: !!this.#sentinel, }; } constructor() { watch( () => this.#element, () => { if (this.#element) { this.#setupObservers(); return () => this.#cleanup(); } } ); } setElement(element: HTMLElement) { this.#element = element; } scrollToBottom = (behavior: ScrollBehavior = "smooth") => { if (!this.#element) return; this.#userHasScrolled = false; // Reset user scroll flag when programmatically scrolling this.#element.scrollTo({ top: this.#element.scrollHeight, behavior, }); }; #handleScroll = () => { if (!this.#element) return; // Detect if user has scrolled up from bottom const { scrollTop, scrollHeight, clientHeight } = this.#element; const threshold = 200; // Increased threshold for better UX const isAtBottom = scrollTop + clientHeight >= scrollHeight - threshold; // Update the isAtBottom state based on scroll position this.#isAtBottom = isAtBottom; if (!isAtBottom) { this.#userHasScrolled = true; } else if (isAtBottom && this.#userHasScrolled) { // User scrolled back to bottom, reset flag this.#userHasScrolled = false; } }; #setupObservers() { if (!this.#element) return; // Create and position sentinel element this.#createSentinel(); // Setup intersection observer to detect if we're at bottom this.#intersectionObserver = new IntersectionObserver( (entries) => { const entry = entries[0]; // Use intersection observer as a backup, but prioritize scroll-based detection if (entry.isIntersecting) { this.#isAtBottom = true; this.#userHasScrolled = false; } }, { threshold: 0, root: this.#element, } ); if (this.#sentinel) { this.#intersectionObserver.observe(this.#sentinel); } // Add scroll event listener to detect user scrolling this.#element.addEventListener("scroll", this.#handleScroll, { passive: true, }); // Setup resize observer for smooth scrolling on resize this.#resizeObserver = new ResizeObserver(() => { // Check position after resize this.#checkScrollPosition(); if (this.#isAtBottom && !this.#userHasScrolled) { this.scrollToBottom("auto"); } }); this.#resizeObserver.observe(this.#element); // Setup mutation observer for smooth scrolling on content changes this.#mutationObserver = new MutationObserver(() => { // Small delay to ensure DOM has updated requestAnimationFrame(() => { // Check if we should auto-scroll BEFORE updating the position // Only auto-scroll if user was at bottom and hasn't manually scrolled const shouldAutoScroll = this.#isAtBottom && !this.#userHasScrolled; // Now update the scroll position after content changes this.#checkScrollPosition(); // Auto-scroll if conditions were met if (shouldAutoScroll) { this.scrollToBottom("smooth"); } }); }); this.#mutationObserver.observe(this.#element, { childList: true, subtree: true, characterData: true, }); // Check initial state this.#checkScrollPosition(); } #createSentinel() { if (!this.#element) return; this.#sentinel = document.createElement("div"); this.#sentinel.style.height = "1px"; this.#sentinel.style.width = "100%"; this.#sentinel.style.pointerEvents = "none"; this.#sentinel.style.opacity = "0"; this.#sentinel.setAttribute("data-stick-to-bottom-sentinel", ""); // Append to the end of the scrollable content, not positioned absolutely this.#element.appendChild(this.#sentinel); } #checkScrollPosition() { if (!this.#element) return; const { scrollTop, scrollHeight, clientHeight } = this.#element; const threshold = 200; // Increased threshold for better UX const isAtBottom = scrollTop + clientHeight >= scrollHeight - threshold; this.#isAtBottom = isAtBottom; } #cleanup() { this.#resizeObserver?.disconnect(); this.#mutationObserver?.disconnect(); this.#intersectionObserver?.disconnect(); // Remove scroll event listener if (this.#element) { this.#element.removeEventListener("scroll", this.#handleScroll); } if (this.#sentinel && this.#element?.contains(this.#sentinel)) { this.#element.removeChild(this.#sentinel); } this.#resizeObserver = null; this.#mutationObserver = null; this.#intersectionObserver = null; this.#sentinel = null; } } export function setStickToBottomContext(): StickToBottomContext { const context = new StickToBottomContext(); setContext(STICK_TO_BOTTOM_CONTEXT_KEY, context); return context; } export function getStickToBottomContext(): StickToBottomContext { const context = getContext(STICK_TO_BOTTOM_CONTEXT_KEY); if (!context) { throw new Error("StickToBottomContext must be used within a Conversation component"); } return context; } export { StickToBottomContext };