@cplieger/web-terminal-engine 6.0.2 → 6.0.4
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 +11 -11
- package/jsr.json +1 -1
- package/package.json +6 -6
- package/src/connection.ts +13 -19
- package/src/intervals.ts +5 -23
- package/src/scroll.ts +380 -81
- package/src/store.ts +75 -215
- package/src/types.ts +3 -5
- package/src/wire-binary.ts +2 -2
package/src/scroll.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
// Scroll controller: the single owner of one
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// Scroll controller: the single owner of one container's scrollTop and of the
|
|
2
|
+
// follow state. Every movement is classified by its delta from `expectedTop`,
|
|
3
|
+
// the offset the library last wrote or observed, so a user move that shares one
|
|
4
|
+
// coalesced scroll event with the library's own write is still seen. Input
|
|
5
|
+
// intent on the container's window disengages before any event arrives, and is
|
|
6
|
+
// undone when no movement follows; while a touch or scrollbar press is down the
|
|
7
|
+
// bottom pin holds off.
|
|
8
8
|
const BOTTOM_TOLERANCE_PX = 24;
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// any real one-frame user scroll increment.
|
|
9
|
+
// Absorbs fractional-layout rounding on a clamp or an echo; far below any real
|
|
10
|
+
// one-frame user scroll increment.
|
|
12
11
|
const CLAMP_EPSILON_PX = 1;
|
|
12
|
+
// A finger travelling this far down is pulling the content: the reader is
|
|
13
|
+
// scrolling up. Android's TOUCH_SLOP (8dp), https://cs.android.com/android/platform/superproject/+/main:frameworks/base/core/java/android/view/ViewConfiguration.java
|
|
14
|
+
const TOUCH_PULL_PX = 8;
|
|
15
|
+
// Releases a pin hold where `scrollend` never fires: Safari shipped it in 26.2,
|
|
16
|
+
// https://developer.mozilla.org/docs/Web/API/Element/scrollend_event#browser_compatibility
|
|
17
|
+
const RELEASE_QUIET_MS = 200;
|
|
13
18
|
|
|
14
19
|
/** What `createScrollController` binds to and notifies. */
|
|
15
20
|
export interface ScrollControllerOptions {
|
|
@@ -39,7 +44,7 @@ export interface ScrollController {
|
|
|
39
44
|
adjustForContentShift(deltaPx: number): void;
|
|
40
45
|
/** Restore a saved view: the offset and the follow state, together and explicitly. */
|
|
41
46
|
restoreView(view: { top: number; following: boolean }): void;
|
|
42
|
-
/** Removes
|
|
47
|
+
/** Removes every listener and drops the element and callbacks; every method is a no-op after. */
|
|
43
48
|
dispose(): void;
|
|
44
49
|
}
|
|
45
50
|
|
|
@@ -49,19 +54,37 @@ export interface ScrollController {
|
|
|
49
54
|
*/
|
|
50
55
|
export function createScrollController(opts: ScrollControllerOptions): ScrollController {
|
|
51
56
|
let scrollEl: HTMLElement | null = opts.scrollEl;
|
|
57
|
+
const win = opts.scrollEl.ownerDocument.defaultView;
|
|
52
58
|
let following = true;
|
|
53
|
-
let
|
|
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;
|
|
59
|
+
let expectedTop = opts.scrollEl.scrollTop;
|
|
58
60
|
// Armed by noteContentShrink when the caller's own row removal moved the
|
|
59
|
-
// position
|
|
61
|
+
// position; consumed by the next classification, event or settle alike.
|
|
60
62
|
let shrinkArmed = false;
|
|
61
63
|
// Armed by noteContentShrink when the removal left the offset PAST the end
|
|
62
64
|
// of the content; consumed by reconcileScrollRange in the same pass.
|
|
63
65
|
// Independent of shrinkArmed: a partial reconciliation sets both.
|
|
64
66
|
let rangeCorrectionOwed = false;
|
|
67
|
+
// The echo of an anchor correction, a range correction or a restore notifies
|
|
68
|
+
// nothing: a paged-in prepend corrects through adjustForContentShift, and
|
|
69
|
+
// notifying for it would make every prepend a fetch trigger.
|
|
70
|
+
let echoSilent = false;
|
|
71
|
+
// A settle found user movement inside a write; the next event reports it.
|
|
72
|
+
let positionOwed = false;
|
|
73
|
+
// The pin hold: a press is down, or its momentum has not settled yet.
|
|
74
|
+
let pressHeld = false;
|
|
75
|
+
let momentumPending = false;
|
|
76
|
+
let scrolledSincePress = false;
|
|
77
|
+
let pinOwed = false;
|
|
78
|
+
// Intent turned follow off; no movement has confirmed it yet.
|
|
79
|
+
let intentUnconfirmed = false;
|
|
80
|
+
let touchStartY = 0;
|
|
81
|
+
let quietTimer: number | undefined;
|
|
82
|
+
// A touch's events target its start node
|
|
83
|
+
// (https://w3c.github.io/touch-events/#the-touchend-event) and the renderer
|
|
84
|
+
// rebuilds rows in place; a detached node's events never reach the window
|
|
85
|
+
// (https://dom.spec.whatwg.org/#concept-event-dispatch), so the container's
|
|
86
|
+
// descendants on the touch's path listen too. The container itself never does.
|
|
87
|
+
const touchTargets = new Set<EventTarget>();
|
|
65
88
|
let onFollowChange: ((scrolledUp: boolean) => void) | null = opts.onUserScrollChange ?? null;
|
|
66
89
|
let onPosition: (() => void) | null = opts.onScrollPosition ?? null;
|
|
67
90
|
|
|
@@ -96,77 +119,321 @@ export function createScrollController(opts: ScrollControllerOptions): ScrollCon
|
|
|
96
119
|
}
|
|
97
120
|
}
|
|
98
121
|
|
|
122
|
+
// Returns whether `top` is a real movement. An echo leaves the baseline where
|
|
123
|
+
// it is, so sub-epsilon steps accumulate into a movement rather than vanish.
|
|
124
|
+
function classify(top: number): boolean {
|
|
125
|
+
const delta = top - expectedTop;
|
|
126
|
+
const wasShrink = shrinkArmed;
|
|
127
|
+
shrinkArmed = false;
|
|
128
|
+
if (Math.abs(delta) <= CLAMP_EPSILON_PX) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
expectedTop = top;
|
|
132
|
+
// Only a movement that leaves a gap below confirms intent: a clamp landing
|
|
133
|
+
// at the tail (a viewport grow) is the browser's, not the reader's.
|
|
134
|
+
if (!wasShrink && distanceFromBottom() > CLAMP_EPSILON_PX) {
|
|
135
|
+
intentUnconfirmed = false;
|
|
136
|
+
}
|
|
137
|
+
if (delta < 0) {
|
|
138
|
+
// Upward: neither a shrink clamp nor a user may ENGAGE follow this way, so
|
|
139
|
+
// a clamp landing at the bottom under a holding reader keeps them holding.
|
|
140
|
+
if (!wasShrink && distanceFromBottom() > CLAMP_EPSILON_PX) {
|
|
141
|
+
setFollowing(false);
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
// Downward only engages: a pin hold lets output grow below a following
|
|
146
|
+
// reader, and a move toward the tail that falls short is not leaving it.
|
|
147
|
+
if (atBottom()) {
|
|
148
|
+
setFollowing(true);
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Classifies a movement the page can see before its event: the order "user
|
|
154
|
+
// moves, the library writes, the event dispatches" must not erase the move.
|
|
155
|
+
function settle(): void {
|
|
156
|
+
if (scrollEl && classify(scrollEl.scrollTop)) {
|
|
157
|
+
positionOwed = true;
|
|
158
|
+
if (pressHeld) {
|
|
159
|
+
scrolledSincePress = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Every library write ends here, so the baseline is the post-clamp offset.
|
|
165
|
+
function write(next: number, silent: boolean): void {
|
|
166
|
+
if (!scrollEl) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const before = scrollEl.scrollTop;
|
|
170
|
+
scrollEl.scrollTop = next;
|
|
171
|
+
expectedTop = scrollEl.scrollTop;
|
|
172
|
+
if (expectedTop !== before) {
|
|
173
|
+
echoSilent = silent;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
99
177
|
const onScroll = (): void => {
|
|
100
178
|
if (!scrollEl) {
|
|
101
179
|
return;
|
|
102
180
|
}
|
|
103
181
|
const top = scrollEl.scrollTop;
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
182
|
+
const echo = Math.abs(top - expectedTop) <= CLAMP_EPSILON_PX;
|
|
183
|
+
const silent = echoSilent && echo && !positionOwed;
|
|
184
|
+
echoSilent = false;
|
|
185
|
+
positionOwed = false;
|
|
186
|
+
// Reported before the follow decision and apart from it: a reader browsing
|
|
187
|
+
// history never toggles follow, and idle browsing is when paging must work.
|
|
188
|
+
if (!silent && onPosition) {
|
|
189
|
+
onPosition();
|
|
190
|
+
}
|
|
191
|
+
if (classify(top) && pressHeld) {
|
|
192
|
+
scrolledSincePress = true;
|
|
193
|
+
}
|
|
194
|
+
if (momentumPending) {
|
|
195
|
+
armQuietTimer();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function inside(target: EventTarget | null): boolean {
|
|
200
|
+
return (
|
|
201
|
+
scrollEl !== null &&
|
|
202
|
+
target !== null &&
|
|
203
|
+
typeof (target as Partial<Node>).nodeType === "number" &&
|
|
204
|
+
scrollEl.contains(target as Node)
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Intent is provisional: it opens the quiet window a momentum scroll gets, and
|
|
209
|
+
// the release restores follow unless a movement confirmed it meanwhile, so a
|
|
210
|
+
// touch slop or a key that scrolled some other element cannot strand the reader.
|
|
211
|
+
function disengageForIntent(): void {
|
|
212
|
+
if (!following || !scrollEl || scrollEl.scrollTop <= CLAMP_EPSILON_PX) {
|
|
112
213
|
return;
|
|
113
214
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
215
|
+
setFollowing(false);
|
|
216
|
+
intentUnconfirmed = true;
|
|
217
|
+
if (!pressHeld) {
|
|
218
|
+
momentumPending = true;
|
|
219
|
+
armQuietTimer();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function clearQuietTimer(): void {
|
|
224
|
+
if (quietTimer !== undefined && win) {
|
|
225
|
+
win.clearTimeout(quietTimer);
|
|
226
|
+
}
|
|
227
|
+
quietTimer = undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function armQuietTimer(): void {
|
|
231
|
+
clearQuietTimer();
|
|
232
|
+
if (win) {
|
|
233
|
+
quietTimer = win.setTimeout(releaseHold, RELEASE_QUIET_MS);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function releaseHold(): void {
|
|
238
|
+
clearQuietTimer();
|
|
239
|
+
momentumPending = false;
|
|
240
|
+
if (intentUnconfirmed) {
|
|
241
|
+
intentUnconfirmed = false;
|
|
242
|
+
setFollowing(true);
|
|
243
|
+
pinOwed = true;
|
|
244
|
+
}
|
|
245
|
+
if (pinOwed) {
|
|
246
|
+
pinOwed = false;
|
|
247
|
+
stickToBottom();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// A press that scrolled nothing (a tap) releases at once; one that scrolled,
|
|
252
|
+
// or whose intent has not shown its scroll yet, waits for the quiet window.
|
|
253
|
+
function endPress(): void {
|
|
254
|
+
if (!pressHeld) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
pressHeld = false;
|
|
258
|
+
if (scrolledSincePress || intentUnconfirmed) {
|
|
259
|
+
momentumPending = true;
|
|
260
|
+
armQuietTimer();
|
|
261
|
+
} else {
|
|
262
|
+
releaseHold();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function beginPress(): void {
|
|
267
|
+
clearQuietTimer();
|
|
268
|
+
pressHeld = true;
|
|
269
|
+
momentumPending = false;
|
|
270
|
+
scrolledSincePress = false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function releaseTouchTargets(): void {
|
|
274
|
+
for (const t of touchTargets) {
|
|
275
|
+
t.removeEventListener("touchmove", onDetachedTouch);
|
|
276
|
+
t.removeEventListener("touchend", onDetachedTouch);
|
|
277
|
+
t.removeEventListener("touchcancel", onDetachedTouch);
|
|
278
|
+
}
|
|
279
|
+
touchTargets.clear();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Window listeners run in the BUBBLE phase, after every element and document
|
|
283
|
+
// listener, so a gesture the page consumed (mouse tracking cancels the wheel,
|
|
284
|
+
// a mapped key is cancelled and sent) is visible as `defaultPrevented`.
|
|
285
|
+
function onWheel(e: WheelEvent): void {
|
|
286
|
+
if (e.deltaY < 0 && !e.ctrlKey && !e.defaultPrevented && inside(e.target)) {
|
|
287
|
+
disengageForIntent();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
292
|
+
if (e.key !== "PageUp" && e.key !== "Home") {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (e.defaultPrevented) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const doc = scrollEl?.ownerDocument;
|
|
299
|
+
if (e.target === doc?.body || inside(e.target)) {
|
|
300
|
+
disengageForIntent();
|
|
122
301
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function onTouchStart(e: TouchEvent): void {
|
|
305
|
+
if (e.defaultPrevented || !inside(e.target)) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
for (const node of e.composedPath()) {
|
|
309
|
+
if (node === scrollEl) {
|
|
310
|
+
break;
|
|
133
311
|
}
|
|
134
|
-
if (
|
|
135
|
-
|
|
312
|
+
if (!touchTargets.has(node)) {
|
|
313
|
+
node.addEventListener("touchmove", onDetachedTouch, { passive: true });
|
|
314
|
+
node.addEventListener("touchend", onDetachedTouch, { passive: true });
|
|
315
|
+
node.addEventListener("touchcancel", onDetachedTouch, { passive: true });
|
|
316
|
+
touchTargets.add(node);
|
|
136
317
|
}
|
|
318
|
+
}
|
|
319
|
+
if (e.touches.length === 1) {
|
|
320
|
+
touchStartY = e.touches[0]?.clientY ?? 0;
|
|
321
|
+
beginPress();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function notePull(e: TouchEvent): void {
|
|
326
|
+
const y = e.touches[0]?.clientY;
|
|
327
|
+
if (pressHeld && y !== undefined && y - touchStartY >= TOUCH_PULL_PX) {
|
|
328
|
+
disengageForIntent();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function onTouchMove(e: TouchEvent): void {
|
|
333
|
+
if (!e.defaultPrevented && inside(e.target)) {
|
|
334
|
+
notePull(e);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Acts only once the touched node has left the container; an attached touch
|
|
339
|
+
// stays on the window path. A move is judged at the last node of the detached
|
|
340
|
+
// path, after every listener the page had on that path when the touch began,
|
|
341
|
+
// so a cancel is visible; an end at the target, so no ancestor's listener can
|
|
342
|
+
// stop the release.
|
|
343
|
+
function onDetachedTouch(e: Event): void {
|
|
344
|
+
if (inside(e.target)) {
|
|
137
345
|
return;
|
|
138
346
|
}
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
347
|
+
if (e.type !== "touchmove") {
|
|
348
|
+
if (e.currentTarget === e.target) {
|
|
349
|
+
onTouchEnd(e as TouchEvent);
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
142
352
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
353
|
+
let last: EventTarget | undefined;
|
|
354
|
+
for (const node of e.composedPath()) {
|
|
355
|
+
if (touchTargets.has(node)) {
|
|
356
|
+
last = node;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (e.currentTarget === last && !e.defaultPrevented) {
|
|
360
|
+
notePull(e as TouchEvent);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
146
363
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// the container landed; the pass-through is armed only when it really moved.
|
|
150
|
-
function writePreservingFollow(next: number): void {
|
|
151
|
-
if (!scrollEl) {
|
|
364
|
+
function onTouchEnd(e: TouchEvent): void {
|
|
365
|
+
if (e.touches.length > 0) {
|
|
152
366
|
return;
|
|
153
367
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
368
|
+
releaseTouchTargets();
|
|
369
|
+
endPress();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function onPointerDown(e: PointerEvent): void {
|
|
373
|
+
if (
|
|
374
|
+
scrollEl !== null &&
|
|
375
|
+
e.pointerType === "mouse" &&
|
|
376
|
+
e.target === scrollEl &&
|
|
377
|
+
!e.defaultPrevented &&
|
|
378
|
+
e.offsetX >= scrollEl.clientWidth
|
|
379
|
+
) {
|
|
380
|
+
beginPress();
|
|
160
381
|
}
|
|
161
382
|
}
|
|
162
383
|
|
|
384
|
+
function onPointerUp(e: PointerEvent): void {
|
|
385
|
+
if (e.pointerType === "mouse") {
|
|
386
|
+
endPress();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// An element's `scrollend` does not bubble, so it is caught on the way down:
|
|
391
|
+
// https://drafts.csswg.org/cssom-view/#scrolling-events
|
|
392
|
+
function onScrollEnd(e: Event): void {
|
|
393
|
+
if (e.target === scrollEl && momentumPending) {
|
|
394
|
+
releaseHold();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// A hidden page receives no further input, so a press whose end never
|
|
399
|
+
// arrives must not keep holding the pin until the next touch. Captured, so a
|
|
400
|
+
// page listener that stops the document's event cannot hide it.
|
|
401
|
+
function onVisibilityChange(): void {
|
|
402
|
+
if (scrollEl?.ownerDocument.visibilityState === "hidden" && (pressHeld || momentumPending)) {
|
|
403
|
+
releaseTouchTargets();
|
|
404
|
+
pressHeld = false;
|
|
405
|
+
releaseHold();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const windowListeners: readonly (readonly [
|
|
410
|
+
string,
|
|
411
|
+
(e: never) => void,
|
|
412
|
+
AddEventListenerOptions,
|
|
413
|
+
])[] = [
|
|
414
|
+
["wheel", onWheel, { passive: true }],
|
|
415
|
+
["keydown", onKeydown, { passive: true }],
|
|
416
|
+
["touchstart", onTouchStart, { passive: true }],
|
|
417
|
+
["touchmove", onTouchMove, { passive: true }],
|
|
418
|
+
// Captured, from wherever the last finger lifts: a release must not depend
|
|
419
|
+
// on the page letting the event bubble.
|
|
420
|
+
["touchend", onTouchEnd, { passive: true, capture: true }],
|
|
421
|
+
["touchcancel", onTouchEnd, { passive: true, capture: true }],
|
|
422
|
+
["pointerdown", onPointerDown, { passive: true }],
|
|
423
|
+
["pointerup", onPointerUp, { passive: true }],
|
|
424
|
+
["pointercancel", onPointerUp, { passive: true }],
|
|
425
|
+
["scrollend", onScrollEnd, { passive: true, capture: true }],
|
|
426
|
+
["visibilitychange", onVisibilityChange, { passive: true, capture: true }],
|
|
427
|
+
];
|
|
428
|
+
let windowAttached = false;
|
|
429
|
+
|
|
163
430
|
/**
|
|
164
431
|
* Announce that the caller's own mutation just REMOVED content, so the clamp
|
|
165
432
|
* it produced is not classified as a gesture. Announced rather than inferred
|
|
166
433
|
* 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
|
-
*
|
|
169
|
-
*
|
|
434
|
+
* is fractional, so under zoom a clamp can read as a user scrolling up.
|
|
435
|
+
* Call it AFTER the mutation with the offset read BEFORE it, having collapsed
|
|
436
|
+
* the overlays anchored in that space.
|
|
170
437
|
*/
|
|
171
438
|
function noteContentShrink(scrollTopBefore: number): void {
|
|
172
439
|
if (!scrollEl) {
|
|
@@ -195,29 +462,39 @@ export function createScrollController(opts: ScrollControllerOptions): ScrollCon
|
|
|
195
462
|
return;
|
|
196
463
|
}
|
|
197
464
|
rangeCorrectionOwed = false;
|
|
198
|
-
|
|
465
|
+
settle();
|
|
466
|
+
write(bottomOffset(), true);
|
|
199
467
|
}
|
|
200
468
|
|
|
201
469
|
/**
|
|
202
|
-
* Pin the viewport to the bottom iff following
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
470
|
+
* Pin the viewport to the bottom iff following, deferred while a press or its
|
|
471
|
+
* momentum owns the offset: WebKit applies a programmatic scroll over an
|
|
472
|
+
* in-flight pan with no user-scroll guard (ScrollingTreeScrollingNode::
|
|
473
|
+
* handleScrollPositionRequest, https://github.com/WebKit/WebKit/blob/main/Source/WebCore/page/scrolling/ScrollingTreeScrollingNode.cpp).
|
|
474
|
+
* Only a positive distance is corrected: an offset PAST the end is
|
|
475
|
+
* reconcileScrollRange's, and here it would fight an overscroll bounce.
|
|
206
476
|
*/
|
|
207
477
|
function stickToBottom(): void {
|
|
208
|
-
if (!scrollEl
|
|
478
|
+
if (!scrollEl) {
|
|
209
479
|
return;
|
|
210
480
|
}
|
|
211
|
-
|
|
212
|
-
|
|
481
|
+
settle();
|
|
482
|
+
if (!following || distanceFromBottom() <= 0) {
|
|
483
|
+
return;
|
|
213
484
|
}
|
|
485
|
+
if (pressHeld || momentumPending) {
|
|
486
|
+
pinOwed = true;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
write(bottomOffset(), false);
|
|
214
490
|
}
|
|
215
491
|
|
|
216
492
|
function scrollToBottom(): void {
|
|
217
493
|
if (!scrollEl) {
|
|
218
494
|
return;
|
|
219
495
|
}
|
|
220
|
-
|
|
496
|
+
settle();
|
|
497
|
+
write(bottomOffset(), false);
|
|
221
498
|
setFollowing(true);
|
|
222
499
|
}
|
|
223
500
|
|
|
@@ -230,8 +507,9 @@ export function createScrollController(opts: ScrollControllerOptions): ScrollCon
|
|
|
230
507
|
}
|
|
231
508
|
|
|
232
509
|
/**
|
|
233
|
-
* Scroll anchoring by hand:
|
|
234
|
-
*
|
|
510
|
+
* Scroll anchoring by hand: Safari before 27 has no `overflow-anchor`
|
|
511
|
+
* (https://developer.mozilla.org/docs/Web/CSS/Reference/Properties/overflow-anchor#browser_compatibility),
|
|
512
|
+
* so every row evicted above a scrolled-up reader slid their position one
|
|
235
513
|
* line up. `following` is deliberately NOT changed; deriving it would let a
|
|
236
514
|
* correction that lands at the bottom silently re-engage auto-follow.
|
|
237
515
|
*/
|
|
@@ -239,7 +517,8 @@ export function createScrollController(opts: ScrollControllerOptions): ScrollCon
|
|
|
239
517
|
if (!scrollEl || following || deltaPx === 0) {
|
|
240
518
|
return;
|
|
241
519
|
}
|
|
242
|
-
|
|
520
|
+
settle();
|
|
521
|
+
write(scrollEl.scrollTop + deltaPx, true);
|
|
243
522
|
}
|
|
244
523
|
|
|
245
524
|
/**
|
|
@@ -252,25 +531,45 @@ export function createScrollController(opts: ScrollControllerOptions): ScrollCon
|
|
|
252
531
|
if (!scrollEl) {
|
|
253
532
|
return;
|
|
254
533
|
}
|
|
534
|
+
settle();
|
|
255
535
|
if (Number.isFinite(view.top)) {
|
|
256
|
-
|
|
536
|
+
write(view.top, true);
|
|
257
537
|
}
|
|
538
|
+
intentUnconfirmed = false;
|
|
258
539
|
setFollowing(view.following);
|
|
259
540
|
}
|
|
260
541
|
|
|
261
542
|
function dispose(): void {
|
|
262
|
-
if (scrollEl
|
|
263
|
-
scrollEl.removeEventListener("scroll",
|
|
543
|
+
if (scrollEl) {
|
|
544
|
+
scrollEl.removeEventListener("scroll", onScroll);
|
|
545
|
+
}
|
|
546
|
+
if (windowAttached && win) {
|
|
547
|
+
for (const [type, fn, options] of windowListeners) {
|
|
548
|
+
win.removeEventListener(type, fn as EventListener, { capture: options.capture ?? false });
|
|
549
|
+
}
|
|
264
550
|
}
|
|
551
|
+
windowAttached = false;
|
|
552
|
+
releaseTouchTargets();
|
|
553
|
+
clearQuietTimer();
|
|
554
|
+
pressHeld = false;
|
|
555
|
+
momentumPending = false;
|
|
556
|
+
pinOwed = false;
|
|
557
|
+
intentUnconfirmed = false;
|
|
265
558
|
scrollEl = null;
|
|
266
|
-
scrollHandler = null;
|
|
267
559
|
onFollowChange = null;
|
|
268
560
|
onPosition = null;
|
|
269
561
|
following = true;
|
|
270
562
|
}
|
|
271
563
|
|
|
272
564
|
try {
|
|
565
|
+
// Exactly one listener on the container, registered first.
|
|
273
566
|
opts.scrollEl.addEventListener("scroll", onScroll, { passive: true });
|
|
567
|
+
if (win) {
|
|
568
|
+
windowAttached = true;
|
|
569
|
+
for (const [type, fn, options] of windowListeners) {
|
|
570
|
+
win.addEventListener(type, fn as EventListener, options);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
274
573
|
} catch (err) {
|
|
275
574
|
dispose();
|
|
276
575
|
throw err;
|