@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/README.md +121 -61
- package/jsr.json +1 -1
- package/package.json +7 -7
- package/src/connection.ts +1205 -1587
- package/src/index.ts +24 -6
- package/src/keyboard.ts +56 -133
- package/src/modes.ts +92 -166
- package/src/mouse.ts +380 -413
- package/src/render.ts +1894 -2342
- package/src/scroll.ts +225 -476
- package/src/status-stream.ts +5 -0
- package/src/store.ts +31 -1
- package/src/terminal.ts +135 -0
- package/src/toolbar.ts +29 -43
package/src/scroll.ts
CHANGED
|
@@ -1,282 +1,133 @@
|
|
|
1
|
-
// Scroll controller: the single owner of
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
*
|
|
166
|
-
*
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
68
|
+
function distanceFromBottom(): number {
|
|
69
|
+
if (!scrollEl) {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
return scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
|
|
73
|
+
}
|
|
188
74
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
84
|
+
|
|
85
|
+
function atBottom(): boolean {
|
|
86
|
+
return distanceFromBottom() <= BOTTOM_TOLERANCE_PX;
|
|
196
87
|
}
|
|
197
|
-
}
|
|
198
88
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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:
|
|
234
|
-
//
|
|
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
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
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:
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
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:
|
|
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:
|
|
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
|
-
|
|
297
|
-
}
|
|
145
|
+
let scrollHandler: (() => void) | null = onScroll;
|
|
298
146
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
514
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
noteContentShrink,
|
|
281
|
+
reconcileScrollRange,
|
|
282
|
+
stickToBottom,
|
|
283
|
+
scrollToBottom,
|
|
284
|
+
isUserScrolledUp,
|
|
285
|
+
currentScrollTop,
|
|
286
|
+
adjustForContentShift,
|
|
287
|
+
restoreView,
|
|
288
|
+
dispose,
|
|
289
|
+
};
|
|
541
290
|
}
|