@cplieger/web-terminal-engine 2.3.2 → 2.3.3

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/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "2.3.2",
3
+ "version": "2.3.3",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "2.3.2",
3
+ "version": "2.3.3",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
@@ -15,11 +15,11 @@
15
15
  "@typescript/native": "npm:typescript@7.0.2",
16
16
  "@vitest/coverage-v8": "4.1.10",
17
17
  "esbuild": "0.28.1",
18
- "eslint": "10.6.0",
19
- "fast-check": "4.8.0",
18
+ "eslint": "10.7.0",
19
+ "fast-check": "4.9.0",
20
20
  "happy-dom": "20.10.6",
21
21
  "pngjs": "7.0.0",
22
- "prettier": "3.9.4",
22
+ "prettier": "3.9.5",
23
23
  "typescript": "npm:@typescript/typescript6@6.0.2",
24
24
  "typescript-eslint": "8.63.0",
25
25
  "vitest": "4.1.10"
package/src/scroll.ts CHANGED
@@ -2,11 +2,11 @@
2
2
  //
3
3
  // One piece of state: `following`. The user is "following" when the viewport is
4
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 scroll
6
- // position on every scroll event — there is no debounce window, no suppress
7
- // timer, and no programmatic-vs-user flag. That heuristic soup (a 100px
8
- // tolerance, a 150ms debounce, a 60-second touch window) was the source of the
9
- // view-jumping and scroll-interruption bugs (the legacy heuristic-soup
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
10
  // failure mode; see the #web-terminal-engine steering doc, "Design rationale").
11
11
  //
12
12
  // The renderer calls stickToBottom() once after each flush: if following, pin
@@ -15,13 +15,32 @@
15
15
  // Appending content at the bottom does not fire a scroll event, so following
16
16
  // stays true across new output and the post-flush pin lands correctly. Pinning
17
17
  // to the bottom produces a scroll event whose recomputation yields
18
- // following=true again (no churn). Scrolling up past the tolerance flips to
19
- // holding; scrolling back flips to following.
18
+ // following=true again (no churn; the pin only ever moves DOWN). Scrolling
19
+ // back to within the tolerance of the bottom re-engages following.
20
+ //
21
+ // Disengaging is direction-based, not tolerance-based: ANY upward movement
22
+ // that leaves a real gap below flips to holding at once. A tolerance-only
23
+ // rule lost a race under heavy streaming — the renderer flushes (and pins)
24
+ // every frame, so each frame's upward scroll increment restarted from the
25
+ // bottom the previous pin reset it to, and unless a single frame's delta
26
+ // exceeded the tolerance the user was yanked back down every few milliseconds
27
+ // (the "scroll up fights me during output" bug). Per the HTML event loop the
28
+ // scroll steps run before that frame's rAF flush, so the first upward tick
29
+ // flips holding before the next pin can fire. The nothing-below guard keeps
30
+ // one non-user case following: when content SHRINKS (top-row eviction, a
31
+ // clear), the browser clamps scrollTop DOWN to the new maximum — an upward
32
+ // move that lands exactly at the bottom, which must not break auto-follow.
20
33
 
21
34
  const BOTTOM_TOLERANCE_PX = 24;
35
+ // An upward move only disengages follow when a real gap is left below it.
36
+ // Bigger than 0 to absorb fractional-layout rounding in the shrink-clamp case
37
+ // (a clamp lands at the bottom, but scrollTop can be subpixel-off); far below
38
+ // any real one-frame user scroll increment.
39
+ const CLAMP_EPSILON_PX = 1;
22
40
 
23
41
  let scrollEl: HTMLElement | null = null;
24
42
  let following = true;
43
+ let lastScrollTop = 0;
25
44
  let onFollowChange: ((scrolledUp: boolean) => void) | null = null;
26
45
  let scrollHandler: (() => void) | null = null;
27
46
 
@@ -65,8 +84,24 @@ export function init(opts: {
65
84
  scrollEl = opts.scrollEl;
66
85
  onFollowChange = opts.onUserScrollChange ?? null;
67
86
  following = true;
87
+ lastScrollTop = scrollEl.scrollTop;
68
88
  scrollHandler = () => {
69
- setFollowing(atBottom());
89
+ if (!scrollEl) {
90
+ return;
91
+ }
92
+ const top = scrollEl.scrollTop;
93
+ const movedUp = top < lastScrollTop;
94
+ lastScrollTop = top;
95
+ // Upward movement with content still below = the user pulling away from
96
+ // the live tail: hold immediately, however small the move (see the header
97
+ // comment for why the tolerance alone lost the streaming race). An upward
98
+ // move that lands AT the bottom is the browser clamping after a content
99
+ // shrink, not the user; everything else derives from position as before.
100
+ if (movedUp && distanceFromBottom() > CLAMP_EPSILON_PX) {
101
+ setFollowing(false);
102
+ } else {
103
+ setFollowing(atBottom());
104
+ }
70
105
  };
71
106
  scrollEl.addEventListener("scroll", scrollHandler, { passive: true });
72
107
  }