298 lines
8.8 KiB
TypeScript
298 lines
8.8 KiB
TypeScript
import { ref, watch, onUnmounted, type Ref } from 'vue';
|
|
|
|
export function useAutoScroll(elementRef: Ref<HTMLElement | null>, 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 anchorDebounceTimeout: ReturnType<typeof setTimeout> | 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;
|
|
if (!value) anchor = null;
|
|
};
|
|
|
|
const scrollToBottom = (behavior: ScrollBehavior = 'instant') => {
|
|
const el = elementRef.value;
|
|
if (!el) {
|
|
return;
|
|
}
|
|
|
|
const bottom = Math.max(0, el.scrollHeight - el.clientHeight);
|
|
if (Math.abs(el.scrollTop - bottom) < 0.5) {
|
|
setUnhooked(false);
|
|
lastScrollTop = el.scrollTop;
|
|
return;
|
|
}
|
|
|
|
isProgrammatic = true;
|
|
el.scrollTo({
|
|
top: bottom,
|
|
behavior,
|
|
});
|
|
|
|
requestAnimationFrame(() => {
|
|
isProgrammatic = false;
|
|
if (elementRef.value && isAtBottom(elementRef.value)) {
|
|
setUnhooked(false);
|
|
lastScrollTop = elementRef.value.scrollTop;
|
|
}
|
|
});
|
|
};
|
|
|
|
const captureAnchor = (el: HTMLElement) => {
|
|
if (!unhooked.value) {
|
|
anchor = null;
|
|
return;
|
|
}
|
|
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 requestAnchorCapture = () => {
|
|
if (!unhooked.value || anchorRaf !== null) return;
|
|
anchorRaf = requestAnimationFrame(() => {
|
|
anchorRaf = null;
|
|
const el = elementRef.value;
|
|
if (el) captureAnchor(el);
|
|
});
|
|
};
|
|
|
|
const scheduleAnchorCapture = () => {
|
|
const el = elementRef.value;
|
|
// Native scrollend is the primary path; debounce is only a fallback.
|
|
if (!el || 'onscrollend' in el || !unhooked.value) return;
|
|
if (anchorDebounceTimeout !== null) {
|
|
clearTimeout(anchorDebounceTimeout);
|
|
}
|
|
anchorDebounceTimeout = setTimeout(() => {
|
|
anchorDebounceTimeout = null;
|
|
requestAnchorCapture();
|
|
}, 150);
|
|
};
|
|
|
|
const handleScrollEnd = () => {
|
|
if (anchorDebounceTimeout !== null) {
|
|
clearTimeout(anchorDebounceTimeout);
|
|
anchorDebounceTimeout = null;
|
|
}
|
|
requestAnchorCapture();
|
|
};
|
|
|
|
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;
|
|
|
|
if (isProgrammatic) {
|
|
if (isAtBottom(el)) {
|
|
setUnhooked(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If the user is scrolling up, they are deliberately navigating away from the bottom.
|
|
// Unhook immediately so we never fight the scroll or snap back down.
|
|
if (scrollingUp) {
|
|
setUnhooked(true);
|
|
scheduleAnchorCapture();
|
|
return;
|
|
}
|
|
|
|
// Only re-hook when scrolling down into the bottom threshold.
|
|
if (isAtBottom(el)) {
|
|
setUnhooked(false);
|
|
return;
|
|
}
|
|
scheduleAnchorCapture();
|
|
};
|
|
|
|
const followIfHooked = () => {
|
|
if (unhooked.value) {
|
|
return;
|
|
}
|
|
scrollToBottom('instant');
|
|
};
|
|
|
|
const handleMutation = (records: MutationRecord[]) => {
|
|
if (unhooked.value || mutationRaf !== null) {
|
|
return;
|
|
}
|
|
// The isolated sizing mirror changes on every keystroke, but cannot
|
|
// change scroll height. Keep observing real composer content (files,
|
|
// tool controls, etc.) as well as message content.
|
|
if (records.every(record => {
|
|
const target = record.target instanceof Element ? record.target : record.target.parentElement;
|
|
return target?.closest('[data-chat-measurement]');
|
|
})) 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 });
|
|
el.addEventListener('scrollend', handleScrollEnd, { 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;
|
|
}
|
|
|
|
if (!anchor) {
|
|
captureAnchor(current);
|
|
}
|
|
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);
|
|
el.removeEventListener('scrollend', handleScrollEnd);
|
|
}
|
|
observer?.disconnect();
|
|
observer = null;
|
|
resizeObserver?.disconnect();
|
|
resizeObserver = null;
|
|
if (mutationRaf !== null) {
|
|
cancelAnimationFrame(mutationRaf);
|
|
mutationRaf = null;
|
|
}
|
|
if (anchorRaf !== null) {
|
|
cancelAnimationFrame(anchorRaf);
|
|
anchorRaf = null;
|
|
}
|
|
if (anchorDebounceTimeout !== null) {
|
|
clearTimeout(anchorDebounceTimeout);
|
|
anchorDebounceTimeout = 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;
|
|
},
|
|
};
|
|
}
|