@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/src/scroll.ts CHANGED
@@ -1,15 +1,20 @@
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.
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
- // An upward move only disengages follow when a real gap is left below it.
10
- // Above 0 to absorb fractional-layout rounding on a shrink clamp; far below
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 the scroll listener and drops the element and callbacks; every method is a no-op after. */
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 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;
59
+ let expectedTop = opts.scrollEl.scrollTop;
58
60
  // Armed by noteContentShrink when the caller's own row removal moved the
59
- // position: the next event is that clamp, not a gesture.
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 prev = lastScrollTop;
105
- lastScrollTop = top;
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.
108
- const wasShrink = shrinkArmed;
109
- shrinkArmed = false;
110
- if (preserveFollowOnce) {
111
- preserveFollowOnce = false;
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
- // 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).
120
- if (onPosition) {
121
- onPosition();
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
- if (top < prev) {
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).
131
- if (wasShrink) {
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 (distanceFromBottom() > CLAMP_EPSILON_PX) {
135
- setFollowing(false);
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 (top > prev) {
140
- // Downward: only a move that lands at the bottom re-engages.
141
- setFollowing(atBottom());
347
+ if (e.type !== "touchmove") {
348
+ if (e.currentTarget === e.target) {
349
+ onTouchEnd(e as TouchEvent);
350
+ }
351
+ return;
142
352
  }
143
- // Unmoved: no intent, and the position may be one a shrink clamp set.
144
- };
145
- let scrollHandler: (() => void) | null = onScroll;
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
- // 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) {
364
+ function onTouchEnd(e: TouchEvent): void {
365
+ if (e.touches.length > 0) {
152
366
  return;
153
367
  }
154
- const before = scrollEl.scrollTop;
155
- scrollEl.scrollTop = next;
156
- const after = scrollEl.scrollTop;
157
- lastScrollTop = after;
158
- if (after !== before) {
159
- preserveFollowOnce = true;
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
- * (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.
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
- writePreservingFollow(bottomOffset());
465
+ settle();
466
+ write(bottomOffset(), true);
199
467
  }
200
468
 
201
469
  /**
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.
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 || !following) {
478
+ if (!scrollEl) {
209
479
  return;
210
480
  }
211
- if (distanceFromBottom() > 0) {
212
- scrollEl.scrollTop = bottomOffset();
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
- scrollEl.scrollTop = bottomOffset();
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: WebKit has never shipped `overflow-anchor`, so on
234
- * Safari every row evicted above a scrolled-up reader slid their position one
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
- writePreservingFollow(scrollEl.scrollTop + deltaPx);
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
- writePreservingFollow(view.top);
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 && scrollHandler) {
263
- scrollEl.removeEventListener("scroll", scrollHandler);
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;