import type { Scope } from "./ui.ts";
const BOTTOM_MARGIN = 64;
/** What a scrolling pane offers the content that draws itself on demand as the reader reaches it. */
export type Viewport = {
readonly clientHeight: number;
scrollTop: number;
};
/** Scrolls to follow the newest output, unless the reader has scrolled up. */
export function scroller(
scope: Scope,
content: Element,
last: () => Element | null = () => content.lastElementChild,
/** Draws more of `content` as the reader reaches what is drawn, and asks for nothing once it is all there. */
grow: (pane: Viewport) => void = () => {},
): HTMLDivElement {
const jump = (
) as HTMLDivElement;
const element =
{content}{jump}
as HTMLDivElement;
// whether the reader follows the bottom; layout changes never decide this, only the reader's own scrolling does
let pinned = true;
// a gesture whose scroll event has not arrived yet
let scrolling = false;
let gestureFrame = 0;
// the layout the scroll position was last settled against; a different one means a scroll event is layout's, not the reader's
let width = element.clientWidth;
let viewport = element.clientHeight;
let height = element.scrollHeight;
let top = element.scrollTop;
const distance = () => element.scrollHeight - element.clientHeight - element.scrollTop;
const snap = () => {
// a hidden pane has no place to keep
if (element.clientHeight === 0) return;
element.scrollTop = element.scrollHeight;
top = element.scrollTop;
settle();
};
const settle = () => {
width = element.clientWidth;
viewport = element.clientHeight;
height = element.scrollHeight;
};
const onScroll = () => {
const laidOut = element.clientWidth !== width || element.clientHeight !== viewport ||
element.scrollHeight !== height;
if (scrolling || !laidOut) {
pinned = distance() <= BOTTOM_MARGIN;
settle();
}
scrolling = false;
top = element.scrollTop;
jump.hidden = pinned;
grow(element);
};
const onGesture = () => {
scrolling = true;
cancelAnimationFrame(gestureFrame);
gestureFrame = requestAnimationFrame(() => scrolling = false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (
["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)
) onGesture();
};
element.addEventListener("scroll", onScroll);
element.addEventListener("wheel", onGesture, { passive: true });
element.addEventListener("pointerdown", onGesture);
element.addEventListener("keydown", onKeyDown);
let bottom = last();
let loaded = false;
const initial = () => {
if (
loaded || element.clientHeight === 0 ||
element.scrollHeight <= element.clientHeight || last() === null
) return false;
loaded = true;
return true;
};
const mutations = new MutationObserver((records) => {
const next = last();
const changed = next !== null &&
(next !== bottom || records.some(({ target }) => next.contains(target)));
bottom = next;
// the reader may have moved before their scroll event arrives
const moved = scrolling && element.scrollTop !== top;
if (initial()) snap();
else if (changed && pinned && !moved) snap();
else if (moved || !pinned) settle();
});
mutations.observe(content, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
});
const resizes = new ResizeObserver(() => {
settle();
if (element.clientHeight === 0) return;
if (initial() || pinned) snap();
jump.hidden = pinned;
// after the initial scroll, which is to the newest entries, so that a transcript that has just opened is not taken
// for one the reader has gone above
grow(element);
});
resizes.observe(element);
resizes.observe(content);
scope.add(() => {
mutations.disconnect();
resizes.disconnect();
cancelAnimationFrame(gestureFrame);
element.removeEventListener("scroll", onScroll);
element.removeEventListener("wheel", onGesture);
element.removeEventListener("pointerdown", onGesture);
element.removeEventListener("keydown", onKeyDown);
});
return element;
}