@cplieger/web-terminal-engine 2.3.2 → 2.3.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/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.4",
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.4",
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/mouse.ts CHANGED
@@ -85,6 +85,13 @@ export interface MouseInputHandler {
85
85
 
86
86
  let handler: MouseInputHandler | null = null;
87
87
  let lastButton = -1;
88
+ // True while a Shift-initiated press is in flight. Shift+click/drag is the
89
+ // xterm convention for "bypass application mouse tracking": the gesture is
90
+ // reserved for the browser's native text selection instead of being reported
91
+ // to (and preventDefault-ed away from) the TUI. Tracked from press to release
92
+ // so the move/up of a bypassed drag stay bypassed even if Shift is lifted
93
+ // mid-drag.
94
+ let shiftBypass = false;
88
95
 
89
96
  /**
90
97
  * Initialize the mouse module by attaching pointer/wheel/focus listeners to
@@ -138,6 +145,12 @@ function onMouseDown(e: MouseEvent): void {
138
145
  if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
139
146
  return;
140
147
  }
148
+ if (e.shiftKey) {
149
+ // Shift+press: leave the whole gesture to the browser (native selection).
150
+ shiftBypass = true;
151
+ return;
152
+ }
153
+ shiftBypass = false;
141
154
  const pos = pixelToCell(e);
142
155
  if (!pos) {
143
156
  return;
@@ -153,6 +166,11 @@ function onMouseUp(e: MouseEvent): void {
153
166
  if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
154
167
  return;
155
168
  }
169
+ if (shiftBypass) {
170
+ // Release of a Shift-initiated (native-selection) gesture: report nothing.
171
+ shiftBypass = false;
172
+ return;
173
+ }
156
174
  const pos = pixelToCell(e);
157
175
  if (!pos) {
158
176
  return;
@@ -169,6 +187,9 @@ function onMouseMove(e: MouseEvent): void {
169
187
  if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
170
188
  return;
171
189
  }
190
+ if (shiftBypass && e.buttons) {
191
+ return; // a Shift-initiated drag is a native selection; don't report it
192
+ }
172
193
  // mode 1000: no motion events
173
194
  // mode 1002: motion only while button held (drag)
174
195
  // mode 1003: all motion events
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
  }