fix: Dramatically better auto scroll
This commit is contained in:
@@ -3,80 +3,221 @@ import { ref, watch, onUnmounted, type Ref } from 'vue';
|
||||
export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
threshold?: number;
|
||||
} = {}) {
|
||||
const { threshold = 80 } = options;
|
||||
const { threshold = 30 } = options;
|
||||
|
||||
const isUserScrollingUp = ref(false);
|
||||
/** 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);
|
||||
|
||||
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
|
||||
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,
|
||||
});
|
||||
shouldAutoScroll.value = true;
|
||||
|
||||
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 { scrollTop, scrollHeight, clientHeight } = el;
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
const scrollingUp = el.scrollTop < lastScrollTop;
|
||||
lastScrollTop = el.scrollTop;
|
||||
|
||||
if (distanceFromBottom <= threshold) {
|
||||
if (isUserScrollingUp.value) {
|
||||
isUserScrollingUp.value = false;
|
||||
shouldAutoScroll.value = true;
|
||||
}
|
||||
} else {
|
||||
isUserScrollingUp.value = true;
|
||||
shouldAutoScroll.value = false;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const followIfHooked = () => {
|
||||
if (unhooked.value) return;
|
||||
scrollToBottom('instant');
|
||||
};
|
||||
|
||||
watch(elementRef, (newEl, oldEl) => {
|
||||
if (oldEl) {
|
||||
oldEl.removeEventListener('scroll', handleScroll);
|
||||
observer?.disconnect();
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
}
|
||||
const handleMutation = () => {
|
||||
if (mutationRaf !== null) return;
|
||||
mutationRaf = requestAnimationFrame(() => {
|
||||
mutationRaf = null;
|
||||
followIfHooked();
|
||||
});
|
||||
};
|
||||
|
||||
if (newEl) {
|
||||
newEl.addEventListener('scroll', handleScroll, { passive: true });
|
||||
const attach = (el: HTMLElement) => {
|
||||
el.style.overflowAnchor = 'none';
|
||||
lastScrollTop = el.scrollTop;
|
||||
lastWidth = el.clientWidth;
|
||||
setUnhooked(false);
|
||||
|
||||
observer = new MutationObserver(() => {
|
||||
// Only auto-scroll if user hasn't scrolled up and is near bottom
|
||||
if (!isUserScrollingUp.value && shouldAutoScroll.value) {
|
||||
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;
|
||||
}
|
||||
|
||||
observer.observe(newEl, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
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(() => {
|
||||
elementRef.value?.removeEventListener('scroll', handleScroll);
|
||||
observer?.disconnect();
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
detach(elementRef.value);
|
||||
});
|
||||
|
||||
return {
|
||||
scrollToBottom,
|
||||
isUserScrollingUp, // expose for UI feedback (optional)
|
||||
isUserScrollingUp,
|
||||
shouldAutoScroll,
|
||||
isAtBottom: () => {
|
||||
const el = elementRef.value;
|
||||
return el ? isAtBottom(el) : true;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user