@cplieger/web-terminal-engine 5.2.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/scroll.ts CHANGED
@@ -1,282 +1,133 @@
1
- // Scroll controller: the single owner of the scroll container's scrollTop.
2
- //
3
- // One piece of state: `following`. The user is "following" when the viewport is
4
- // at (or within a small tolerance of) the bottom; otherwise they have scrolled
5
- // up to read and are "holding". The state is derived purely from the scroll
6
- // events — position plus movement direction — with no debounce window, no
7
- // suppress timer, and no programmatic-vs-user flag. That heuristic soup (a
8
- // 100px tolerance, a 150ms debounce, a 60-second touch window) was the source
9
- // of the view-jumping and scroll-interruption bugs (the legacy heuristic-soup
10
- // failure mode).
11
- //
12
- // The renderer calls stickToBottom() once after each flush: if following, pin
13
- // to the new bottom; if holding, do nothing. Holding the reading position when
14
- // content ABOVE it changes height (top-of-history eviction at the retention cap,
15
- // the trim marker appearing) is adjustForContentShift's job, called by the
16
- // renderer around its DOM mutations. That used to be left to native scroll
17
- // anchoring (overflow-anchor), which WebKit has never shipped — so on Safari and
18
- // iPadOS the read position slid up one line per evicted row while the user was
19
- // scrolled up reading.
20
- // Appending content at the bottom does not fire a scroll event, so following
21
- // stays true across new output and the post-flush pin lands correctly. Pinning
22
- // to the bottom produces a scroll event whose recomputation yields
23
- // following=true again (no churn; the pin only ever moves DOWN). Scrolling
24
- // back to within the tolerance of the bottom re-engages following.
25
- //
26
- // Disengaging is direction-based, not tolerance-based: ANY upward movement
27
- // that leaves a real gap below flips to holding at once. A tolerance-only
28
- // rule lost a race under heavy streaming — the renderer flushes (and pins)
29
- // every frame, so each frame's upward scroll increment restarted from the
30
- // bottom the previous pin reset it to, and unless a single frame's delta
31
- // exceeded the tolerance the user was yanked back down every few milliseconds
32
- // (the "scroll up fights me during output" bug). Per the HTML event loop the
33
- // scroll steps run before that frame's rAF flush, so the first upward tick
34
- // flips holding before the next pin can fire.
35
- //
36
- // ENGAGING is direction-based too, and asymmetrically so: only a move DOWNWARD
37
- // that lands at the bottom re-engages. An upward move never engages, and a
38
- // scroll event that did not move the position infers nothing. This asymmetry is
39
- // what makes a content SHRINK safe. A shrink (top-row eviction at the retention
40
- // cap, ED3 erasing scrollback, a wipe-and-rebuild) lowers scrollHeight, so the
41
- // browser clamps scrollTop DOWN to the new maximum and delivers that clamp as an
42
- // UPWARD move that lands at the bottom. The clamp is indistinguishable by
43
- // position from a user arriving at the tail, but not by direction: a user
44
- // returning to the tail always moves down, and a clamp always moves up. Before
45
- // the asymmetry, deriving the state from position alone meant any shrink
46
- // silently re-engaged auto-follow under a user who had deliberately scrolled up
47
- // to read, and every subsequent line then pinned them to the bottom. Measured on
48
- // an inline TUI that erases scrollback on each redraw: scrolled up 20000px, an
49
- // ED3 collapsed the content, and the viewport was pinned to the tail from there
50
- // on. The rule now: follow may be engaged by init, by an explicit
51
- // scrollToBottom/restoreView, or by a downward user scroll reaching the bottom.
52
- // Nothing else.
53
- //
54
- // The two library writes that happen WHILE HOLDING and must not change the
55
- // state (adjustForContentShift's anchor correction, restoreView's explicit
56
- // restore) say so directly, by arming a one-event pass-through for the single
57
- // scroll event their own write produces. That is deliberately not a general
58
- // "was this event ours?" mechanism: this module does not own every write to the
59
- // container (a consumer may page the viewport or animate a smooth jump through
60
- // the DOM), and per CSSOM View an element enqueues at most one scroll event per
61
- // frame, so a same-frame library write and user gesture are indistinguishable
62
- // anyway. The arm is set only when the write actually moved the position, so it
63
- // can never linger and swallow a later real gesture; every write from outside
64
- // this module is classified by direction like any user scroll, which is correct
65
- // for all of them (paging up holds, paging down or a smooth jump to the bottom
66
- // re-engages on landing).
67
-
68
- // A content SHRINK is ANNOUNCED, not inferred. The classification above works
69
- // off the clamp's arithmetic signature — an upward move that lands within
70
- // CLAMP_EPSILON_PX of the bottom — and that signature is lossy: scrollHeight
71
- // and clientHeight are integer-rounded while scrollTop is fractional, so under
72
- // browser zoom or a fractional device pixel ratio the residual can exceed the
73
- // epsilon and a clamp reads as a user scrolling up. The reader is then left
74
- // "holding at the bottom" — visually at the tail with auto-follow silently off,
75
- // a state only a keystroke or the jump button clears, and one that survives a
76
- // tab switch (restoreView carries it deliberately). See
77
- // docs/scroll-position-fidelity.md §1.3.
78
- //
79
- // The layer that REMOVES the rows knows a shrink happened, so it says so:
80
- // noteContentShrink() arms a one-event pass-through for the clamp its own
81
- // mutation caused. The epsilon keeps its original and only job, absorbing
82
- // subpixel residual, and stops being the thing that IDENTIFIES a clamp.
83
- //
84
- // The arm carries the same discipline as preserveFollowOnce, for the same
85
- // reason: it is set only when the position ACTUALLY moved (observed after the
86
- // mutation, never predicted from a row count), so an event is guaranteed to
87
- // follow and a lingering arm cannot swallow a later real gesture. It is also
88
- // cleared unconditionally by the first event that arrives, so it cannot outlive
89
- // one frame even if that guarantee were ever broken.
90
-
91
- // A shrink asks TWO independent questions, and for years this module asked only
92
- // the first.
93
- //
94
- // 1. Did the offset MOVE? That identifies the clamp's scroll event, so the
95
- // follow state survives it (shrinkArmed, above).
96
- // 2. Is the offset still OUT OF RANGE? That says the container has not
97
- // reconciled it at all, and the viewport is parked past the end of the
98
- // content (rangeCorrectionOwed, below).
99
- //
100
- // distanceFromBottom() has three arithmetic states and only two were consumed:
101
- // positive means content below (pin down), zero means the tail (nothing to do),
102
- // and NEGATIVE means the offset is beyond the content's end. The third was a
103
- // gap: `stickToBottom`'s `> 0` test is false for it, so the one invariant that
104
- // could rescue the view declined to act, and nothing else writes the offset.
105
- // The reader then sees the container's background with the content above the
106
- // visible region, until either the growing content reaches the parked offset or
107
- // they scroll and the container reconciles.
108
- //
109
- // The negative state is reachable because "the offset is clamped when the
110
- // content shrinks" is an implementation behaviour, not a specified one. CSSOM
111
- // View clamps a programmatic scroll at write time; neither it nor CSS Overflow 3
112
- // says WHEN a UA must reconcile an offset already established when the
113
- // scrollable overflow rectangle shrinks under it. Blink and Gecko reconcile
114
- // during layout, so the read that follows a row removal already reports the new
115
- // maximum. WebKit does not hold the offset inside the range at all: this repo
116
- // separately records that Safari "updates scrollTop PAST the maximum during an
117
- // overscroll bounce" (render.ts, the removedRowsThisPass comment), and iOS
118
- // Safari has been reported leaving an offset outside the valid range until a
119
- // manual scroll nudges the browser into fixing it
120
- // (stackoverflow.com/q/79752870, September 2025 — that report is about a
121
- // programmatic scroll during momentum rather than a shrink, so it corroborates
122
- // the state rather than proving this path into it).
123
- //
124
- // So the arithmetic is treated as authoritative and the browser is not:
125
- // reconcileScrollRange writes the offset the geometry says is the maximum. The
126
- // correction is gated on a caller's announced row removal, which is what keeps
127
- // an overscroll bounce (out of range with no content change) out of it.
1
+ // Scroll controller: the single owner of one scroll container's scrollTop and
2
+ // of one piece of state, `following`, derived from scroll events by position
3
+ // AND direction with no debounce, suppress timer or programmatic-vs-user flag.
4
+ // A library write that must not change the state arms a one-event pass-through
5
+ // for the scroll event its own write produces, only when the write moved the
6
+ // position, so the arm can never linger and swallow a later real gesture; every
7
+ // write from outside this module is classified by direction like a user scroll.
128
8
  const BOTTOM_TOLERANCE_PX = 24;
129
9
  // An upward move only disengages follow when a real gap is left below it.
130
- // Bigger than 0 to absorb fractional-layout rounding in the shrink-clamp case
131
- // (a clamp lands at the bottom, but scrollTop can be subpixel-off); far below
10
+ // Above 0 to absorb fractional-layout rounding on a shrink clamp; far below
132
11
  // any real one-frame user scroll increment.
133
12
  const CLAMP_EPSILON_PX = 1;
134
13
 
135
- let scrollEl: HTMLElement | null = null;
136
- let following = true;
137
- let lastScrollTop = 0;
138
- // Armed by a library write that must PRESERVE the current follow state across
139
- // the single scroll event it produces (see the header). Consumed by that one
140
- // event; never armed when the write did not move the position, because then no
141
- // event fires and a lingering arm would swallow the next real gesture.
142
- let preserveFollowOnce = false;
143
- // Armed by noteContentShrink when a caller's own row removal moved the
144
- // position: the next event is that clamp, not a gesture. Consumed (and in all
145
- // cases cleared) by the first event to arrive.
146
- let shrinkArmed = false;
147
- // Armed by noteContentShrink when a caller's own row removal left the offset
148
- // PAST the end of the content, i.e. the container did not reconcile it.
149
- // Consumed by reconcileScrollRange in the same pass. Independent of shrinkArmed:
150
- // one says the offset moved, this one says it did not move far enough, and a
151
- // partial reconciliation sets both.
152
- let rangeCorrectionOwed = false;
153
- let onFollowChange: ((scrolledUp: boolean) => void) | null = null;
154
- let onPosition: (() => void) | null = null;
155
- let scrollHandler: (() => void) | null = null;
14
+ /** What `createScrollController` binds to and notifies. */
15
+ export interface ScrollControllerOptions {
16
+ /** Element whose scroll position is observed and owned. */
17
+ scrollEl: HTMLElement;
18
+ /** Fired on a follow/hold toggle; the argument is true when the user has scrolled up. */
19
+ onUserScrollChange?: (scrolledUp: boolean) => void;
20
+ /** Fired on every scroll event that reflects a real position change. */
21
+ onScrollPosition?: () => void;
22
+ }
156
23
 
157
- function distanceFromBottom(): number {
158
- if (!scrollEl) {
159
- return 0;
160
- }
161
- return scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
24
+ /** The single owner of one scroll container's `scrollTop` and follow state. */
25
+ export interface ScrollController {
26
+ /** Announce that the caller's own mutation just REMOVED content; pass the offset read before it. */
27
+ noteContentShrink(scrollTopBefore: number): void;
28
+ /** Move the offset back inside the range after an announced shrink left it past the end. */
29
+ reconcileScrollRange(): void;
30
+ /** Pin the viewport to the bottom iff the user is following. */
31
+ stickToBottom(): void;
32
+ /** Force scroll to the bottom and re-engage following. */
33
+ scrollToBottom(): void;
34
+ /** Whether the user has scrolled away from the bottom (auto-follow disengaged). */
35
+ isUserScrolledUp(): boolean;
36
+ /** The viewport's current scroll offset; 0 once disposed. */
37
+ currentScrollTop(): number;
38
+ /** Shift the viewport by a content-height change ABOVE the reading position; a no-op while following. */
39
+ adjustForContentShift(deltaPx: number): void;
40
+ /** Restore a saved view: the offset and the follow state, together and explicitly. */
41
+ restoreView(view: { top: number; following: boolean }): void;
42
+ /** Removes the scroll listener and drops the element and callbacks; every method is a no-op after. */
43
+ dispose(): void;
162
44
  }
163
45
 
164
46
  /**
165
- * The largest offset the container can hold: the zero point of
166
- * distanceFromBottom, written out. Every write that means "the bottom" targets
167
- * this rather than `scrollHeight`.
168
- *
169
- * The over-scroll write it replaces (`scrollTop = scrollHeight`, an extra
170
- * clientHeight past the end) delegated the arithmetic to the container's own
171
- * clamp. That is the one thing this module can no longer assume: the container
172
- * this UI runs on is reported to leave an offset outside the valid range, and a
173
- * write is not obviously exempt from that (the report behind
174
- * rangeCorrectionOwed is about a programmatic scroll). Writing the maximum
175
- * needs no clamp to be correct, is identical on a conforming container, and
176
- * keeps one definition of "the bottom" for the reads and the writes both.
47
+ * Creates the scroll controller for `opts.scrollEl`, following from
48
+ * construction. Throws, holding nothing, when the listener cannot be attached.
177
49
  */
178
- function bottomOffset(): number {
179
- if (!scrollEl) {
180
- return 0;
181
- }
182
- return Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
183
- }
50
+ export function createScrollController(opts: ScrollControllerOptions): ScrollController {
51
+ let scrollEl: HTMLElement | null = opts.scrollEl;
52
+ let following = true;
53
+ let lastScrollTop = opts.scrollEl.scrollTop;
54
+ // Armed by a library write that must PRESERVE the follow state across the
55
+ // single scroll event it produces; never armed when the write did not move
56
+ // the position, because then no event fires to consume it.
57
+ let preserveFollowOnce = false;
58
+ // Armed by noteContentShrink when the caller's own row removal moved the
59
+ // position: the next event is that clamp, not a gesture.
60
+ let shrinkArmed = false;
61
+ // Armed by noteContentShrink when the removal left the offset PAST the end
62
+ // of the content; consumed by reconcileScrollRange in the same pass.
63
+ // Independent of shrinkArmed: a partial reconciliation sets both.
64
+ let rangeCorrectionOwed = false;
65
+ let onFollowChange: ((scrolledUp: boolean) => void) | null = opts.onUserScrollChange ?? null;
66
+ let onPosition: (() => void) | null = opts.onScrollPosition ?? null;
184
67
 
185
- function atBottom(): boolean {
186
- return distanceFromBottom() <= BOTTOM_TOLERANCE_PX;
187
- }
68
+ function distanceFromBottom(): number {
69
+ if (!scrollEl) {
70
+ return 0;
71
+ }
72
+ return scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
73
+ }
188
74
 
189
- function setFollowing(next: boolean): void {
190
- if (next === following) {
191
- return;
75
+ // The largest offset the container can hold. Every write that means "the
76
+ // bottom" targets this rather than `scrollHeight`: writing the maximum needs
77
+ // no clamp to be correct on a container that leaves offsets out of range.
78
+ function bottomOffset(): number {
79
+ if (!scrollEl) {
80
+ return 0;
81
+ }
82
+ return Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
192
83
  }
193
- following = next;
194
- if (onFollowChange) {
195
- onFollowChange(!following);
84
+
85
+ function atBottom(): boolean {
86
+ return distanceFromBottom() <= BOTTOM_TOLERANCE_PX;
196
87
  }
197
- }
198
88
 
199
- /**
200
- * Initialize the scroll controller on the scroll container. The optional
201
- * callbacks fire whenever the follow state toggles (its argument is true when
202
- * the user has scrolled up / disengaged auto-follow), and — separately — on
203
- * every scroll event that reflects a real position change.
204
- *
205
- * @param opts.scrollEl Element whose scroll position is observed and owned.
206
- * @param opts.onUserScrollChange Optional callback fired on follow/hold toggle.
207
- * @param opts.onScrollPosition Optional callback fired on every real scroll move.
208
- */
209
- export function init(opts: {
210
- scrollEl: HTMLElement;
211
- onUserScrollChange?: (scrolledUp: boolean) => void;
212
- onScrollPosition?: () => void;
213
- }): void {
214
- // Detach any prior listener (re-init in tests / re-mount).
215
- if (scrollEl && scrollHandler) {
216
- scrollEl.removeEventListener("scroll", scrollHandler);
89
+ function setFollowing(next: boolean): void {
90
+ if (next === following) {
91
+ return;
92
+ }
93
+ following = next;
94
+ if (onFollowChange) {
95
+ onFollowChange(!following);
96
+ }
217
97
  }
218
- scrollEl = opts.scrollEl;
219
- onFollowChange = opts.onUserScrollChange ?? null;
220
- onPosition = opts.onScrollPosition ?? null;
221
- following = true;
222
- lastScrollTop = scrollEl.scrollTop;
223
- preserveFollowOnce = false;
224
- shrinkArmed = false;
225
- rangeCorrectionOwed = false;
226
- scrollHandler = () => {
98
+
99
+ const onScroll = (): void => {
227
100
  if (!scrollEl) {
228
101
  return;
229
102
  }
230
103
  const top = scrollEl.scrollTop;
231
104
  const prev = lastScrollTop;
232
105
  lastScrollTop = top;
233
- // Read-and-clear: whatever this event turns out to be, the announcement
234
- // does not survive it. An arm that could outlive one event is the bug
235
- // preserveFollowOnce's "only when it moved" rule exists to prevent, and a
236
- // coalesced event that nets out downward must not leave one behind.
106
+ // Read-and-clear: the announcement does not survive this event, whatever
107
+ // it turns out to be, so a coalesced event netting downward leaves no arm.
237
108
  const wasShrink = shrinkArmed;
238
109
  shrinkArmed = false;
239
110
  if (preserveFollowOnce) {
240
- // This event is the echo of a library write that carries its own follow
241
- // intent; the state is already correct.
242
111
  preserveFollowOnce = false;
243
112
  return;
244
113
  }
245
- // The POSITION seam, fired after the early-return above and before the
246
- // follow decision below. Both halves of that placement are load-bearing
247
- // (docs/paged-scrollback.md §5.4): after, because the swallowed event is
248
- // the echo of the library's OWN write — a paged-in prepend goes through
249
- // adjustForContentShift, and firing the seam for it would turn every
250
- // prepend into a fresh fetch trigger, a self-feeding loop; and separate
251
- // from onFollowChange, because that one fires only on a follow/hold TOGGLE,
252
- // so an idle session where the reader scrolls within history would never
253
- // notify at all — and idle browsing is exactly when paging must work.
254
- //
255
- // Deliberately a BARE notification: no index, no transport. scroll.ts stays
256
- // index-free and transport-free; the renderer owns the mapping from scroll
257
- // position to absolute index and decides whether to fetch.
258
- //
259
- // NOT "the user scrolled". This sits before the direction branch below, so
260
- // it also fires for the browser's own clamps — an eviction, an ED3 wipe, a
261
- // consumer restyling the surface — i.e. every position change that was not
262
- // the library's own arming write. That is harmless because the fetch
263
- // trigger re-evaluates its full guard set on each call and asks for nothing
264
- // when the geometry says nothing is missing, but a reader who mistakes this
265
- // for a gesture signal will wire the wrong thing to it.
114
+ // Fired AFTER the pass-through above (a paged-in prepend goes through
115
+ // adjustForContentShift, and notifying for it would make every prepend a
116
+ // fetch trigger) and separately from onFollowChange (a reader browsing
117
+ // history never toggles follow, and idle browsing is when paging must
118
+ // work). It also fires for the browser's own clamps, so it is a position
119
+ // change, not a gesture (docs/paged-scrollback.md §5.4).
266
120
  if (onPosition) {
267
121
  onPosition();
268
122
  }
269
123
  if (top < prev) {
270
- // Upward: either the user pulling away from the live tail, or the browser
271
- // clamping after a content shrink. Neither may ENGAGE follow (see the
272
- // header). Only the former leaves a real gap below, and only that
273
- // disengages.
274
- //
275
- // An ANNOUNCED shrink is the clamp, told to us by the layer that removed
276
- // the rows, so the state is preserved without consulting the epsilon at
277
- // all. The epsilon still backs the un-announced case (a shrink from
278
- // outside this library's mutation paths, e.g. a consumer restyling the
279
- // surface).
124
+ // Upward: a user pulling away or a shrink clamp. ANY upward move leaving
125
+ // a real gap disengages at once: the renderer pins every frame, and a
126
+ // tolerance-only rule let each pin reset the baseline under a user
127
+ // scrolling up. Neither may ENGAGE follow: a shrink clamp lands at the
128
+ // bottom by an upward move, indistinguishable from a user arriving by
129
+ // position but not by direction. An announced shrink skips the epsilon,
130
+ // which still backs an un-announced one (a consumer restyling the surface).
280
131
  if (wasShrink) {
281
132
  return;
282
133
  }
@@ -286,256 +137,154 @@ export function init(opts: {
286
137
  return;
287
138
  }
288
139
  if (top > prev) {
289
- // Downward: position decides. Reaching the bottom re-engages follow;
290
- // stopping short of it holds.
140
+ // Downward: only a move that lands at the bottom re-engages.
291
141
  setFollowing(atBottom());
292
142
  }
293
- // Unmoved: a scroll event that changed nothing implies no intent, and the
294
- // position it reports may be one a shrink clamp already put us at.
143
+ // Unmoved: no intent, and the position may be one a shrink clamp set.
295
144
  };
296
- scrollEl.addEventListener("scroll", scrollHandler, { passive: true });
297
- }
145
+ let scrollHandler: (() => void) | null = onScroll;
298
146
 
299
- /**
300
- * Write the container's scroll offset on the library's own behalf, preserving
301
- * the current follow state across the resulting scroll event. `lastScrollTop` is
302
- * synced to the POST-clamp value so the next event's direction is computed from
303
- * where the container actually landed, and the one-event pass-through is armed
304
- * only when the position really moved (no move means no event to consume).
305
- */
306
- function writePreservingFollow(next: number): void {
307
- if (!scrollEl) {
308
- return;
309
- }
310
- const before = scrollEl.scrollTop;
311
- scrollEl.scrollTop = next;
312
- const after = scrollEl.scrollTop;
313
- lastScrollTop = after;
314
- if (after !== before) {
315
- preserveFollowOnce = true;
147
+ // Writes the offset on the library's own behalf. `lastScrollTop` is synced to
148
+ // the POST-clamp value so the next event's direction is computed from where
149
+ // the container landed; the pass-through is armed only when it really moved.
150
+ function writePreservingFollow(next: number): void {
151
+ if (!scrollEl) {
152
+ return;
153
+ }
154
+ const before = scrollEl.scrollTop;
155
+ scrollEl.scrollTop = next;
156
+ const after = scrollEl.scrollTop;
157
+ lastScrollTop = after;
158
+ if (after !== before) {
159
+ preserveFollowOnce = true;
160
+ }
316
161
  }
317
- }
318
162
 
319
- /**
320
- * Announce that the caller's own mutation just REMOVED content, so the scroll
321
- * event it produced is the browser's clamp rather than a user gesture, and the
322
- * follow state must survive it.
323
- *
324
- * Call it AFTER the mutation, passing the offset read BEFORE it. The arm is set
325
- * only when the position actually moved, which is what makes it safe: a clamp
326
- * that moved the position guarantees an event to consume the arm, and a
327
- * mutation that did not move the position arms nothing. Predicting the movement
328
- * from a row count instead would arm on every removal — including the many that
329
- * cannot clamp (rows removed below the viewport, a removal smaller than the
330
- * remaining bottom gap, a wipe whose scrollHeight is held up by an
331
- * absolutely-positioned overlay) — and a lingering arm swallows the next real
332
- * gesture.
333
- *
334
- * That last case used to be live here rather than illustrative, and it is why
335
- * the caller's ORDER matters: the caret, the predicted cursor, the IME view and
336
- * the consumer's hidden textarea all sit inside the scroll container carrying a
337
- * `top` in CONTENT coordinates, so any one of them left at the old offset holds
338
- * scrollHeight above the built content and the clamp never happens. `rebuild`
339
- * now collapses all four as part of its wipe, before it calls this
340
- * (`render.ts` collapseContentSpaceOverlays), so the clamp it announces is real.
341
- * A caller that removes content without collapsing whatever else is anchored in
342
- * that space arms nothing and gets no announcement. See
343
- * `docs/tab-switch-repaint.md` §6.2.
344
- *
345
- * A native-scroll-anchoring adjustment (Chrome/Firefox lowering scrollTop as
346
- * rows are removed above the viewport) also moves the position and also arms
347
- * this; that is correct rather than incidental, since it is likewise the
348
- * library's own mutation moving the viewport and not the user.
349
- *
350
- * It answers a SECOND question at the same time, and the two are independent
351
- * (see the header): whether the offset is still out of range after the removal.
352
- * If it is, the container has not reconciled it and reconcileScrollRange owes a
353
- * write. A partial reconciliation arms both, which is why neither test returns
354
- * early on the other.
355
- *
356
- * @param scrollTopBefore The container's offset read before the mutation.
357
- */
358
- export function noteContentShrink(scrollTopBefore: number): void {
359
- if (!scrollEl) {
360
- return;
361
- }
362
- if (scrollEl.scrollTop < scrollTopBefore) {
363
- shrinkArmed = true;
364
- }
365
- // The epsilon keeps its original job here too: scrollHeight and clientHeight
366
- // are integer-rounded while scrollTop is fractional, so a correctly
367
- // reconciled offset can read a fraction of a pixel past the end. Correcting
368
- // that would write on every shrink pass for no visible gain.
369
- if (distanceFromBottom() < -CLAMP_EPSILON_PX) {
370
- rangeCorrectionOwed = true;
163
+ /**
164
+ * Announce that the caller's own mutation just REMOVED content, so the clamp
165
+ * it produced is not classified as a gesture. Announced rather than inferred
166
+ * because scrollHeight and clientHeight are integer-rounded while scrollTop
167
+ * is fractional, so under zoom a clamp can read as a user scrolling up
168
+ * (docs/scroll-position-fidelity.md §1.3). Call it AFTER the mutation with the
169
+ * offset read BEFORE it, having collapsed the overlays anchored in that space.
170
+ */
171
+ function noteContentShrink(scrollTopBefore: number): void {
172
+ if (!scrollEl) {
173
+ return;
174
+ }
175
+ if (scrollEl.scrollTop < scrollTopBefore) {
176
+ shrinkArmed = true;
177
+ }
178
+ // A correctly reconciled offset can read a fraction past the end because
179
+ // scrollHeight and clientHeight are integer-rounded; do not correct that.
180
+ if (distanceFromBottom() < -CLAMP_EPSILON_PX) {
181
+ rangeCorrectionOwed = true;
182
+ }
371
183
  }
372
- }
373
184
 
374
- /**
375
- * Move the offset back inside the container's range when a caller's own row
376
- * removal left it past the end of the content. A no-op unless noteContentShrink
377
- * armed it in this pass, so it is safe to call after every removal.
378
- *
379
- * A RENDERER SEAM. It is public because the whole scroll namespace is
380
- * re-exported, not because a consumer has a reason to call it; an out-of-band
381
- * call finds nothing armed and does nothing.
382
- *
383
- * Call it in the same pass as noteContentShrink and BEFORE the position
384
- * invariants (the view restore, the read anchor, the bottom pin), so those
385
- * measure a geometry the container agrees with. Deliberately NOT folded into
386
- * either neighbour: `stickToBottom` is follow-gated by contract and a HOLDING
387
- * reader whose history was discarded is stranded over empty space just the same
388
- * (the read anchor stands down for a discard, so the pin cannot own this), and
389
- * `noteContentShrink` is named for a read and the flush tail's own ordering
390
- * comment depends on it not writing.
391
- *
392
- * The destination is the maximum offset, which for a reader who was HOLDING is
393
- * the tail with follow still off. That is this design's ratified degradation for
394
- * a reading position whose lines no longer exist (docs/scroll-position-fidelity.md
395
- * §3.4, §5), not a new decision.
396
- *
397
- * The write goes through writePreservingFollow, so the scroll event it produces
398
- * cannot re-derive the state: a following reader stays following, and a holding
399
- * reader stays holding at the bottom.
400
- *
401
- * One accepted overlap, recorded so it is not read as an oversight. On a
402
- * container that reports an offset past the maximum during an overscroll bounce,
403
- * a bounce that coincides with a row-removing pass (cap eviction under heavy
404
- * streaming) satisfies the gate, and this write then cuts the bounce short at
405
- * the offset it was settling towards anyway. Refusing the correction whenever
406
- * the offset was ALREADY out of range before the mutation would avoid that, at
407
- * the price of skipping the repair for the whole rest of the session whenever
408
- * the two coincide. A cut animation lasts one frame; a stranded viewport lasts
409
- * until the user scrolls. The bounce loses.
410
- */
411
- export function reconcileScrollRange(): void {
412
- if (!scrollEl || !rangeCorrectionOwed) {
413
- return;
185
+ /**
186
+ * Move the offset back inside the range when an announced row removal left
187
+ * it past the end: CSSOM View does not say when a UA must reconcile an
188
+ * established offset after the overflow shrinks, and WebKit can leave it there
189
+ * until a manual scroll (stackoverflow.com/q/79752870). Call it in the same
190
+ * pass as noteContentShrink and BEFORE the position invariants. Not folded
191
+ * into stickToBottom, which is follow-gated while a HOLDING reader is stranded.
192
+ */
193
+ function reconcileScrollRange(): void {
194
+ if (!scrollEl || !rangeCorrectionOwed) {
195
+ return;
196
+ }
197
+ rangeCorrectionOwed = false;
198
+ writePreservingFollow(bottomOffset());
414
199
  }
415
- rangeCorrectionOwed = false;
416
- writePreservingFollow(bottomOffset());
417
- }
418
200
 
419
- /**
420
- * Pin the viewport to the bottom iff the user is following. Called by the
421
- * renderer after each flush. A no-op when holding (scrolled up) or already at
422
- * the bottom, so it never fights the user and never scrolls redundantly.
423
- *
424
- * Only the positive branch of distanceFromBottom is this function's business: an
425
- * offset PAST the end of the content is a geometry error rather than a following
426
- * reader who fell behind, and reconcileScrollRange owns it (see the header).
427
- * Correcting it here instead would fight an overscroll bounce, which presents
428
- * the same arithmetic with nothing wrong.
429
- */
430
- export function stickToBottom(): void {
431
- if (!scrollEl || !following) {
432
- return;
201
+ /**
202
+ * Pin the viewport to the bottom iff following. Only the positive branch of
203
+ * distanceFromBottom is this function's business: an offset PAST the end is
204
+ * a geometry error owned by reconcileScrollRange, and correcting it here
205
+ * would fight an overscroll bounce, which presents the same arithmetic.
206
+ */
207
+ function stickToBottom(): void {
208
+ if (!scrollEl || !following) {
209
+ return;
210
+ }
211
+ if (distanceFromBottom() > 0) {
212
+ scrollEl.scrollTop = bottomOffset();
213
+ }
433
214
  }
434
- if (distanceFromBottom() > 0) {
215
+
216
+ function scrollToBottom(): void {
217
+ if (!scrollEl) {
218
+ return;
219
+ }
435
220
  scrollEl.scrollTop = bottomOffset();
221
+ setFollowing(true);
436
222
  }
437
- }
438
223
 
439
- /**
440
- * Force scroll to the bottom and re-engage following. Used by the explicit
441
- * jump-to-bottom control.
442
- */
443
- export function scrollToBottom(): void {
444
- if (!scrollEl) {
445
- return;
224
+ function isUserScrolledUp(): boolean {
225
+ return !following;
446
226
  }
447
- scrollEl.scrollTop = bottomOffset();
448
- setFollowing(true);
449
- }
450
227
 
451
- /** Whether the user has scrolled away from the bottom (auto-follow disengaged). */
452
- export function isUserScrolledUp(): boolean {
453
- return !following;
454
- }
455
-
456
- /**
457
- * The viewport's current scroll offset, for a consumer keeping per-view
458
- * scroll memory (a tabbed shell saving the position of the tab it is
459
- * leaving). Pairs with restoreScrollTop. This module owns the container's
460
- * scrollTop; consumers read it through this seam rather than the DOM element
461
- * so an engine-side change to the scroll geometry cannot silently break them.
462
- */
463
- export function currentScrollTop(): number {
464
- return scrollEl ? scrollEl.scrollTop : 0;
465
- }
228
+ function currentScrollTop(): number {
229
+ return scrollEl ? scrollEl.scrollTop : 0;
230
+ }
466
231
 
467
- /**
468
- * Shift the viewport by a content-height change that happened ABOVE the reading
469
- * position, so the user keeps looking at the same line. A no-op while following
470
- * (the bottom pin owns the position then) and when the delta is zero.
471
- *
472
- * This is scroll anchoring, done by hand. Chrome and Firefox do it natively
473
- * (`overflow-anchor`), and this module's design leaned on that: "if holding, do
474
- * nothing and let native scroll anchoring hold the reading position when history
475
- * is inserted above". WebKit has never shipped scroll anchoring, so on Safari
476
- * — including iPadOS, where this UI mostly lives — nothing compensated, and
477
- * every row evicted from the top of history while the user was scrolled up
478
- * reading slid their reading position one line further up. Over a streaming
479
- * agent session at the retention cap that reads as "the view scrolls itself
480
- * while I am trying to read".
481
- *
482
- * `following` is deliberately NOT changed: this is a library correction, not a
483
- * user gesture, so it must leave the reader holding exactly as they were. The
484
- * write goes through writePreservingFollow, which syncs the direction baseline
485
- * and lets the resulting scroll event pass through without re-deriving the
486
- * state. Deriving it instead would let a correction that happens to land the
487
- * reader at the bottom silently re-engage auto-follow.
488
- */
489
- export function adjustForContentShift(deltaPx: number): void {
490
- if (!scrollEl || following || deltaPx === 0) {
491
- return;
232
+ /**
233
+ * Scroll anchoring by hand: WebKit has never shipped `overflow-anchor`, so on
234
+ * Safari every row evicted above a scrolled-up reader slid their position one
235
+ * line up. `following` is deliberately NOT changed; deriving it would let a
236
+ * correction that lands at the bottom silently re-engage auto-follow.
237
+ */
238
+ function adjustForContentShift(deltaPx: number): void {
239
+ if (!scrollEl || following || deltaPx === 0) {
240
+ return;
241
+ }
242
+ writePreservingFollow(scrollEl.scrollTop + deltaPx);
492
243
  }
493
- writePreservingFollow(scrollEl.scrollTop + deltaPx);
494
- }
495
244
 
496
- /**
497
- * Restore a saved view: both the scroll offset and the follow state, together
498
- * and explicitly. For a consumer keeping per-view scroll memory (a tabbed shell
499
- * re-entering a tab), this is the correct call. `restoreScrollTop` writes only
500
- * the position and lets the state re-derive, which cannot express a view that
501
- * was holding AT the bottom — a state that is reachable, because a content
502
- * shrink under a scrolled-up reader clamps them to the bottom without engaging
503
- * follow.
504
- *
505
- * A non-finite offset is ignored rather than assigned (the DOM would coerce NaN
506
- * to 0 and silently jump to the top of history); the follow state is still
507
- * applied, since the caller's intent for it is unambiguous.
508
- */
509
- export function restoreView(view: { top: number; following: boolean }): void {
510
- if (!scrollEl) {
511
- return;
245
+ /**
246
+ * Restore both the offset and the follow state explicitly: writing only the
247
+ * position cannot express a view holding AT the bottom, which a shrink under
248
+ * a scrolled-up reader produces. A non-finite offset is ignored (the DOM
249
+ * would coerce NaN to 0 and jump to the top); the follow state still applies.
250
+ */
251
+ function restoreView(view: { top: number; following: boolean }): void {
252
+ if (!scrollEl) {
253
+ return;
254
+ }
255
+ if (Number.isFinite(view.top)) {
256
+ writePreservingFollow(view.top);
257
+ }
258
+ setFollowing(view.following);
512
259
  }
513
- if (Number.isFinite(view.top)) {
514
- writePreservingFollow(view.top);
260
+
261
+ function dispose(): void {
262
+ if (scrollEl && scrollHandler) {
263
+ scrollEl.removeEventListener("scroll", scrollHandler);
264
+ }
265
+ scrollEl = null;
266
+ scrollHandler = null;
267
+ onFollowChange = null;
268
+ onPosition = null;
269
+ following = true;
515
270
  }
516
- setFollowing(view.following);
517
- }
518
271
 
519
- /**
520
- * Restore a previously saved scroll offset (per-view scroll memory: a tabbed
521
- * shell re-entering a tab whose user had scrolled up to read). Position only:
522
- * the follow/hold state then re-derives from the resulting scroll event exactly
523
- * as it does for a user scroll, so a restored read position holds and a
524
- * restored at-bottom position re-engages following ONLY if the restore moved
525
- * downward to reach it. Prefer restoreView when the saved view carries a follow
526
- * state, which is the only way to express "holding at the bottom".
527
- *
528
- * @deprecated A pixel offset cannot identify a reading position: replayed into
529
- * a surface whose rows are still being built it is silently clamped, and
530
- * replayed into one whose content grew it points at a different line. Use
531
- * `render.captureViewMemory()` + `render.bind(store, { view })`, which restore
532
- * a LINE (see docs/scroll-position-fidelity.md §3). Kept for one release for
533
- * any external consumer holding pixel-space view memory; removed in the next
534
- * major.
535
- */
536
- export function restoreScrollTop(top: number): void {
537
- if (!scrollEl) {
538
- return;
272
+ try {
273
+ opts.scrollEl.addEventListener("scroll", onScroll, { passive: true });
274
+ } catch (err) {
275
+ dispose();
276
+ throw err;
539
277
  }
540
- scrollEl.scrollTop = top;
278
+
279
+ return {
280
+ noteContentShrink,
281
+ reconcileScrollRange,
282
+ stickToBottom,
283
+ scrollToBottom,
284
+ isUserScrolledUp,
285
+ currentScrollTop,
286
+ adjustForContentShift,
287
+ restoreView,
288
+ dispose,
289
+ };
541
290
  }