chat / src /lib /actions /snapScrollToBottom.ts
Andrew
feat(tree): Add ELK port-based layout and persona-specific branching
cb5990d
import { navigating } from "$app/state";
import { tick } from "svelte";
export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
let isDetached = false;
const threshold = 50; // Distance from bottom to consider "attached"
const isNearBottom = () => {
const { scrollTop, scrollHeight, clientHeight } = node;
// Use Math.abs for float precision safety, though distances are usually positive
return Math.abs(scrollHeight - scrollTop - clientHeight) <= threshold;
};
const updateScrollPosition = () => {
if (!isDetached) {
node.scrollTo({ top: node.scrollHeight, behavior: "instant" });
}
};
const onScroll = () => {
// If the user is near the bottom, they are attached.
// If they scroll up (away from bottom), they detach.
if (isNearBottom()) {
isDetached = false;
} else {
isDetached = true;
}
};
const update = async (_options: { force?: boolean } = {}) => {
const { force = false } = _options;
if (!force && isDetached && !navigating.to) return;
// Wait for DOM updates (e.g. new message rendered)
await tick();
node.scrollTo({ top: node.scrollHeight, behavior: "instant" });
};
// Observe content size changes (e.g. streaming responses, images loading)
// This ensures we stay at the bottom even if the container size doesn't change
// but the content grows.
const observer = new ResizeObserver(() => {
updateScrollPosition();
});
if (node.firstElementChild) {
observer.observe(node.firstElementChild);
} else {
observer.observe(node);
}
node.addEventListener("scroll", onScroll);
// Check initial state
if (dependency) {
update({ force: true });
}
return {
update,
destroy: () => {
node.removeEventListener("scroll", onScroll);
observer.disconnect();
},
};
};