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