145 lines
4.3 KiB
TypeScript
145 lines
4.3 KiB
TypeScript
import { nextTick, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue";
|
|
|
|
export interface BottomFollowScrollOptions {
|
|
threshold?: number;
|
|
initialFollowing?: boolean;
|
|
}
|
|
|
|
export function useBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T | null>, options: BottomFollowScrollOptions = {}) {
|
|
const threshold = options.threshold ?? 24;
|
|
const following = ref(options.initialFollowing ?? true);
|
|
let animationFrame: number | null = null;
|
|
let releaseProgrammaticFrame: number | null = null;
|
|
let programmaticScroll = false;
|
|
let observedElement: T | null = null;
|
|
let mutationObserver: MutationObserver | null = null;
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
|
|
watch(containerRef, (element) => {
|
|
installObservers(element);
|
|
if (element && following.value) scheduleBottomAlignment();
|
|
}, { flush: "post" });
|
|
|
|
onMounted(() => {
|
|
installObservers(containerRef.value);
|
|
if (following.value) scheduleBottomAlignment();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
disconnectObservers();
|
|
cancelAnimationFrameIfNeeded();
|
|
if (releaseProgrammaticFrame !== null) window.cancelAnimationFrame(releaseProgrammaticFrame);
|
|
});
|
|
|
|
function onScroll(): void {
|
|
if (programmaticScroll) return;
|
|
syncFollowingFromPosition();
|
|
}
|
|
|
|
async function keepBottomAfterUpdate(): Promise<void> {
|
|
if (!following.value) return;
|
|
await nextTick();
|
|
scheduleBottomAlignment();
|
|
}
|
|
|
|
function scrollToBottom(): void {
|
|
following.value = true;
|
|
scheduleBottomAlignment();
|
|
}
|
|
|
|
function resumeFollowing(): void {
|
|
scrollToBottom();
|
|
}
|
|
|
|
function pauseFollowing(): void {
|
|
following.value = false;
|
|
}
|
|
|
|
function toggleFollowing(): void {
|
|
if (following.value) pauseFollowing();
|
|
else resumeFollowing();
|
|
}
|
|
|
|
function syncFollowingFromPosition(): void {
|
|
const element = containerRef.value;
|
|
if (!element) return;
|
|
following.value = distanceToBottom(element) <= threshold;
|
|
}
|
|
|
|
function scheduleBottomAlignment(): void {
|
|
if (animationFrame !== null) return;
|
|
animationFrame = window.requestAnimationFrame(() => {
|
|
animationFrame = null;
|
|
if (!following.value) return;
|
|
const element = containerRef.value;
|
|
if (!element) return;
|
|
alignBottom(element);
|
|
});
|
|
}
|
|
|
|
function alignBottom(element: HTMLElement): void {
|
|
markProgrammaticScroll();
|
|
element.scrollTo({ top: maxScrollTop(element), behavior: "auto" });
|
|
}
|
|
|
|
function markProgrammaticScroll(): void {
|
|
programmaticScroll = true;
|
|
if (releaseProgrammaticFrame !== null) window.cancelAnimationFrame(releaseProgrammaticFrame);
|
|
releaseProgrammaticFrame = window.requestAnimationFrame(() => {
|
|
releaseProgrammaticFrame = null;
|
|
programmaticScroll = false;
|
|
syncFollowingFromPosition();
|
|
});
|
|
}
|
|
|
|
function installObservers(element: T | null): void {
|
|
if (observedElement === element) return;
|
|
disconnectObservers();
|
|
observedElement = element;
|
|
if (!element) return;
|
|
|
|
const onContentChanged = () => {
|
|
refreshResizeTargets(element);
|
|
if (following.value) scheduleBottomAlignment();
|
|
};
|
|
mutationObserver = new MutationObserver(onContentChanged);
|
|
mutationObserver.observe(element, { childList: true, subtree: true, characterData: true });
|
|
|
|
resizeObserver = new ResizeObserver(() => {
|
|
if (following.value) scheduleBottomAlignment();
|
|
});
|
|
refreshResizeTargets(element);
|
|
}
|
|
|
|
function refreshResizeTargets(element: HTMLElement): void {
|
|
if (!resizeObserver) return;
|
|
resizeObserver.disconnect();
|
|
resizeObserver.observe(element);
|
|
for (const child of Array.from(element.children)) resizeObserver.observe(child);
|
|
}
|
|
|
|
function disconnectObservers(): void {
|
|
mutationObserver?.disconnect();
|
|
resizeObserver?.disconnect();
|
|
mutationObserver = null;
|
|
resizeObserver = null;
|
|
observedElement = null;
|
|
}
|
|
|
|
function cancelAnimationFrameIfNeeded(): void {
|
|
if (animationFrame === null) return;
|
|
window.cancelAnimationFrame(animationFrame);
|
|
animationFrame = null;
|
|
}
|
|
|
|
function distanceToBottom(element: HTMLElement): number {
|
|
return maxScrollTop(element) - element.scrollTop;
|
|
}
|
|
|
|
function maxScrollTop(element: HTMLElement): number {
|
|
return Math.max(0, element.scrollHeight - element.clientHeight);
|
|
}
|
|
|
|
return { following, keepBottomAfterUpdate, onScroll, pauseFollowing, resumeFollowing, scrollToBottom, toggleFollowing };
|
|
}
|