/** * useScroll — scroll utilities for message list. * Aligned with Vue3 useScroll: only provides scrollToBottom and scrollToMessage. */ interface ScrollToMessageOptions { behavior?: 'auto' | 'smooth' | 'instant'; block?: 'start' | 'center' | 'end' | 'nearest'; skipIfVisible?: boolean; } const isElementVisible = (element: Element, container: Element): boolean => { const elementRect = element.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const topVisible = elementRect.top >= containerRect.top && elementRect.top <= containerRect.bottom; const bottomVisible = elementRect.bottom >= containerRect.top && elementRect.bottom <= containerRect.bottom; const elementContainsContainer = elementRect.top <= containerRect.top && elementRect.bottom >= containerRect.bottom; return topVisible || bottomVisible || elementContainsContainer; }; function scrollToBottom(container: HTMLElement | null, behavior: ScrollBehavior = 'auto'): Promise { return new Promise((resolve) => { if (!container) { resolve(); return; } if (behavior !== 'smooth') { container.scrollTo({ top: container.scrollHeight, behavior: 'auto' }); resolve(); return; } requestAnimationFrame(() => { // When already at the bottom, a smooth scrollTo produces zero movement and // the 'scrollend' event never fires. That would leak the listener below, // which then fires on an unrelated later scroll (e.g. a quote-click locate) // and yanks the list back to the bottom. Resolve immediately in that case. const targetTop = container.scrollHeight - container.clientHeight; if (Math.abs(container.scrollTop - targetTop) < 1) { resolve(); return; } container.scrollTo({ top: container.scrollHeight, behavior, }); let isSettled = false; let fallbackTimer: ReturnType | null = null; const settle = () => { if (isSettled) { return; } isSettled = true; container.removeEventListener('scrollend', onScrollEnd); if (fallbackTimer !== null) { clearTimeout(fallbackTimer); } setTimeout(() => { container.scrollTo({ top: container.scrollHeight, behavior: 'auto' }); resolve(); }, 100); }; const onScrollEnd = () => settle(); if ('onscrollend' in window) { container.addEventListener('scrollend', onScrollEnd); // Safety net: if 'scrollend' never fires for any reason, clean up so the // listener can't leak and later trigger an unexpected jump to bottom. fallbackTimer = setTimeout(settle, 1000); } else { setTimeout(() => { container.scrollTo({ top: container.scrollHeight, behavior: 'auto' }); resolve(); }, 500); } }); }); } function scrollToMessage(container: HTMLElement | null, messageID: string, options: ScrollToMessageOptions = {}): Promise { return new Promise((resolve, reject) => { const targetMessageDom = container?.querySelector(`[data-message-id="${messageID}"]`); if (!targetMessageDom || !container) { reject(new Error('Target message or scrollable container not found')); return; } if (options.skipIfVisible && isElementVisible(targetMessageDom, container)) { resolve(); return; } const defaultOptions: ScrollToMessageOptions = { behavior: 'smooth', block: 'center' }; const mergedOptions = { ...defaultOptions, ...options }; const behavior = mergedOptions.behavior === 'instant' ? 'auto' : mergedOptions.behavior || 'auto'; const targetTop = (targetMessageDom as HTMLElement).offsetTop; let scrollTop = targetTop; if (mergedOptions.block === 'center') { scrollTop = targetTop - (container.clientHeight - (targetMessageDom as HTMLElement).clientHeight) / 2; } else if (mergedOptions.block === 'end') { scrollTop = targetTop + (targetMessageDom as HTMLElement).clientHeight - container.clientHeight; } scrollTop = Math.max(0, Math.min(scrollTop, container.scrollHeight - container.clientHeight)); // If the target scroll position is already reached, no scroll will happen // and 'scrollend' will never fire — resolve immediately. if (Math.abs(container.scrollTop - scrollTop) < 1) { resolve(); return; } requestAnimationFrame(() => { container.scrollTo({ top: scrollTop, behavior }); if (behavior === 'smooth') { const onScrollEnd = () => { container.removeEventListener('scrollend', onScrollEnd); resolve(); }; if ('onscrollend' in window) { container.addEventListener('scrollend', onScrollEnd); } else { setTimeout(resolve, 500); } } else { resolve(); } }); }); } /** * Synchronously position a message by directly setting scrollTop. * Meant to be called inside a layout effect (before the browser paints) so the * target is already in place on the very first frame — no hide/flash/animation. * Returns true if the target element was found and positioned. */ function scrollToMessageImmediate( container: HTMLElement | null, messageID: string, options: { block?: 'start' | 'center' | 'end' } = {}, ): boolean { const targetMessageDom = container?.querySelector(`[data-message-id="${messageID}"]`) as HTMLElement | null; if (!targetMessageDom || !container) { return false; } const block = options.block ?? 'center'; const targetTop = targetMessageDom.offsetTop; let scrollTop = targetTop; if (block === 'center') { scrollTop = targetTop - (container.clientHeight - targetMessageDom.clientHeight) / 2; } else if (block === 'end') { scrollTop = targetTop + targetMessageDom.clientHeight - container.clientHeight; } scrollTop = Math.max(0, Math.min(scrollTop, container.scrollHeight - container.clientHeight)); container.scrollTop = scrollTop; return true; } export { scrollToBottom, scrollToMessage, scrollToMessageImmediate }; export type { ScrollToMessageOptions };