Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/composables/useBottomFollowScroll.ts
T

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 };
}