char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somvirtualize transcript contentsba4e2f4

main
4.5 KiB133 linesraw
1import type { Scope } from "./ui.ts";
2
3const BOTTOM_MARGIN = 64;
4
5/** What a scrolling pane offers the content that draws itself on demand as the reader reaches it. */
6export type Viewport = {
7  readonly clientHeight: number;
8  scrollTop: number;
9};
10
11/** Scrolls to follow the newest output, unless the reader has scrolled up. */
12export function scroller(
13  scope: Scope,
14  content: Element,
15  last: () => Element | null = () => content.lastElementChild,
16  /** Draws more of `content` as the reader reaches what is drawn, and asks for nothing once it is all there. */
17  grow: (pane: Viewport) => void = () => {},
18): HTMLDivElement {
19  const jump = (
20    <div class="jump-to-bottom" hidden>
21      <button
22        type="button"
23        aria-label="Jump to the newest output"
24        title="Jump to the newest output"
25        // keep the keyboard up
26        _onmousedown={(event) => event.preventDefault()}
27        _onclick={() => {
28          pinned = true;
29          snap();
30          jump.hidden = true;
31        }}
32      />
33    </div>
34  ) as HTMLDivElement;
35  const element = <div class="scroller">{content}{jump}</div> as HTMLDivElement;
36  // whether the reader follows the bottom; layout changes never decide this, only the reader's own scrolling does
37  let pinned = true;
38  // a gesture whose scroll event has not arrived yet
39  let scrolling = false;
40  let gestureFrame = 0;
41  // the layout the scroll position was last settled against; a different one means a scroll event is layout's, not the reader's
42  let width = element.clientWidth;
43  let viewport = element.clientHeight;
44  let height = element.scrollHeight;
45  let top = element.scrollTop;
46  const distance = () => element.scrollHeight - element.clientHeight - element.scrollTop;
47  const snap = () => {
48    // a hidden pane has no place to keep
49    if (element.clientHeight === 0) return;
50    element.scrollTop = element.scrollHeight;
51    top = element.scrollTop;
52    settle();
53  };
54  const settle = () => {
55    width = element.clientWidth;
56    viewport = element.clientHeight;
57    height = element.scrollHeight;
58  };
59  const onScroll = () => {
60    const laidOut = element.clientWidth !== width || element.clientHeight !== viewport ||
61      element.scrollHeight !== height;
62    if (scrolling || !laidOut) {
63      pinned = distance() <= BOTTOM_MARGIN;
64      settle();
65    }
66    scrolling = false;
67    top = element.scrollTop;
68    jump.hidden = pinned;
69    grow(element);
70  };
71  const onGesture = () => {
72    scrolling = true;
73    cancelAnimationFrame(gestureFrame);
74    gestureFrame = requestAnimationFrame(() => scrolling = false);
75  };
76  const onKeyDown = (event: KeyboardEvent) => {
77    if (
78      ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)
79    ) onGesture();
80  };
81  element.addEventListener("scroll", onScroll);
82  element.addEventListener("wheel", onGesture, { passive: true });
83  element.addEventListener("pointerdown", onGesture);
84  element.addEventListener("keydown", onKeyDown);
85  let bottom = last();
86  let loaded = false;
87  const initial = () => {
88    if (
89      loaded || element.clientHeight === 0 ||
90      element.scrollHeight <= element.clientHeight || last() === null
91    ) return false;
92    loaded = true;
93    return true;
94  };
95  const mutations = new MutationObserver((records) => {
96    const next = last();
97    const changed = next !== null &&
98      (next !== bottom || records.some(({ target }) => next.contains(target)));
99    bottom = next;
100    // the reader may have moved before their scroll event arrives
101    const moved = scrolling && element.scrollTop !== top;
102    if (initial()) snap();
103    else if (changed && pinned && !moved) snap();
104    else if (moved || !pinned) settle();
105  });
106  mutations.observe(content, {
107    subtree: true,
108    childList: true,
109    characterData: true,
110    attributes: true,
111  });
112  const resizes = new ResizeObserver(() => {
113    settle();
114    if (element.clientHeight === 0) return;
115    if (initial() || pinned) snap();
116    jump.hidden = pinned;
117    // after the initial scroll, which is to the newest entries, so that a transcript that has just opened is not taken
118    // for one the reader has gone above
119    grow(element);
120  });
121  resizes.observe(element);
122  resizes.observe(content);
123  scope.add(() => {
124    mutations.disconnect();
125    resizes.disconnect();
126    cancelAnimationFrame(gestureFrame);
127    element.removeEventListener("scroll", onScroll);
128    element.removeEventListener("wheel", onGesture);
129    element.removeEventListener("pointerdown", onGesture);
130    element.removeEventListener("keydown", onKeyDown);
131  });
132  return element;
133}