@cplieger/web-terminal-engine 5.0.3 → 5.0.5
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 +1 -1
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/keyboard.ts +19 -20
- package/src/mouse.ts +14 -4
- package/src/render.ts +78 -17
- package/src/scroll.ts +132 -2
- package/src/store.ts +14 -9
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ document.addEventListener("keydown", (ev) => {
|
|
|
81
81
|
- **`keyboard`** — Translates `KeyboardEvent` to terminal byte sequences. `mapKeyboardEvent`, `bracketTextForPaste`, `prepareTextForTerminal`, `ctrlByteFor`, plus the shared logical-key encodings (`plainCursorKeySeq`, `plainEscapeSeq`) the toolbar module reuses. Honors `applicationCursor`, `applicationKeypad`, `bracketedPaste`.
|
|
82
82
|
- **`toolbar`** — On-screen mobile toolbar widget (moved out of `keyboard` in v3). `bindMobileToolbar({toolbar, send, ids?})` wires `pointerdown` handlers for an on-screen Ctrl/arrows/Tab/Enter/Esc toolbar (with sticky-Ctrl semantics and kitty/DECCKM-aware arrows byte-identical to the physical-key path), returning a `MobileToolbarController` exposing `applyStickyCtrl`, `setCtrlArmed`, `isCtrlArmed`, and `dispose`.
|
|
83
83
|
- **`mouse`** — SGR 1006 mouse + focus reporting encoder. `init`, `encodeSGR`, `MouseInputHandler`. Auto-gates on `mouseMode > 0`.
|
|
84
|
-
- **`scroll`** — Auto-follow tracker for the scroll container. `init`, `stickToBottom`, `scrollToBottom`, `isUserScrolledUp
|
|
84
|
+
- **`scroll`** — Auto-follow tracker for the scroll container. `init`, `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`, `currentScrollTop`, `restoreView`, `adjustForContentShift`, plus two renderer seams: `noteContentShrink(scrollTopBefore)` says a row removal rather than a gesture caused the scroll event about to arrive, and `reconcileScrollRange()` moves the offset back inside the container's range when the container did not reconcile the shrink itself. The second exists because clamping an offset the content shrank out from under is an implementation behaviour and not a specified one: Blink and Gecko reconcile during layout, WebKit does not, and without the correction an iOS viewport is left parked past the end of the content after an application clears the screen, showing background with the content above it until the reader scrolls.
|
|
85
85
|
- **`modes`** — DEC private mode state (synced from server's `ModesMessage`). `setModes`, `isBracketedPaste`, `isApplicationCursor`, `getMouseMode`, `isMouseSGR`, `isFocusReporting`, `isApplicationKeypad`, `isReverseVideo`.
|
|
86
86
|
- **`decodeWireBinary(buf)`**: Top-level decoder for the binary WebSocket frames. Returns a `ServerMessage` or `null` for invalid/truncated frames.
|
|
87
87
|
- **Wire compatibility metadata**: `WIRE_PROTOCOL_VERSION`, `MIN_SUPPORTED_SERVER_WIRE_VERSION`, `WIRE_INCOMPATIBLE_CLOSE_CODE`, and `WIRE_COMPATIBILITY` publish this client release's directional contract. The same values ship as a language-neutral JSON artifact for non-TypeScript consumers — see [Wire compatibility manifest](#wire-compatibility-manifest).
|
package/jsr.json
CHANGED
package/package.json
CHANGED
package/src/keyboard.ts
CHANGED
|
@@ -57,17 +57,6 @@ function modifiersDigit(ev: KeyboardEvent): number {
|
|
|
57
57
|
/** The cursor-key trailing letters that honor DECCKM (arrows + Home/End). */
|
|
58
58
|
type CursorKeyLetter = "A" | "B" | "C" | "D" | "H" | "F";
|
|
59
59
|
|
|
60
|
-
function isCursorKeyLetter(letter: string): letter is CursorKeyLetter {
|
|
61
|
-
return (
|
|
62
|
-
letter === "A" ||
|
|
63
|
-
letter === "B" ||
|
|
64
|
-
letter === "C" ||
|
|
65
|
-
letter === "D" ||
|
|
66
|
-
letter === "H" ||
|
|
67
|
-
letter === "F"
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
60
|
/**
|
|
72
61
|
* plainCursorKeySeq is THE encoding of an unmodified logical cursor-key press
|
|
73
62
|
* (arrows, Home, End) — the one home for the mode decision, shared by the
|
|
@@ -114,21 +103,31 @@ function kittyActiveIn(modes: KeyboardModes): boolean {
|
|
|
114
103
|
return (modes.getKeyboardFlags() & KITTY_DISAMBIGUATE) !== 0;
|
|
115
104
|
}
|
|
116
105
|
|
|
117
|
-
// Letter is the xterm trailing letter
|
|
118
|
-
//
|
|
119
|
-
// modifiers we send CSI 1;{mod}{letter}. xterm.js Keyboard.ts pattern.
|
|
106
|
+
// Letter is the xterm trailing letter for the keys that honor DECCKM: the four
|
|
107
|
+
// arrows plus Home (H) and End (F). Without modifiers we send the bare CSI form;
|
|
108
|
+
// with modifiers we send CSI 1;{mod}{letter}. xterm.js Keyboard.ts pattern.
|
|
109
|
+
// (F1-F4's SS3/CSI pair is encoded inline at its own call site, not here.)
|
|
110
|
+
//
|
|
111
|
+
// The parameter type is `CursorKeyLetter` rather than `string`, so "every
|
|
112
|
+
// letter reaching here honors DECCKM" is a compiler guarantee instead of a
|
|
113
|
+
// runtime net: all three call sites pass A/B/C/D from ARROW_LETTER, or the H/F
|
|
114
|
+
// literals.
|
|
120
115
|
//
|
|
121
116
|
// Application cursor mode (DECCKM, CSI ?1) is plumbed via modes.ts.
|
|
122
117
|
// When the application has set DECCKM, the modifier-less form switches
|
|
123
118
|
// from CSI to SS3 (ESC O letter) via plainCursorKeySeq; modifier-bearing
|
|
124
119
|
// forms stay on CSI because they have no SS3 equivalent.
|
|
125
|
-
function csiLetter(letter:
|
|
120
|
+
function csiLetter(letter: CursorKeyLetter, ev: KeyboardEvent, modes: KeyboardModes): string {
|
|
126
121
|
const m = modifiersDigit(ev);
|
|
127
122
|
if (m === 1) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
// `kittyActiveIn(modes)` is always FALSE as reached from today's only
|
|
124
|
+
// caller: mapKeyboardEvent returns through encodeKittyDisambiguate before
|
|
125
|
+
// any cursor key gets here. It is passed anyway rather than hard-coded,
|
|
126
|
+
// because plainCursorKeySeq is the one home for the mode decision and a
|
|
127
|
+
// literal `false` would make this function structurally unable to express
|
|
128
|
+
// the kitty form — a later change to that early return would then
|
|
129
|
+
// mis-encode silently instead of failing to compile or reading wrong.
|
|
130
|
+
return plainCursorKeySeq(letter, kittyActiveIn(modes), modes.isApplicationCursor());
|
|
132
131
|
}
|
|
133
132
|
return `${ESC}[1;${m}${letter}`;
|
|
134
133
|
}
|
|
@@ -169,7 +168,7 @@ const FN_TILDE: Record<string, number | undefined> = {
|
|
|
169
168
|
F20: 34,
|
|
170
169
|
};
|
|
171
170
|
|
|
172
|
-
const ARROW_LETTER: Record<string,
|
|
171
|
+
const ARROW_LETTER: Record<string, CursorKeyLetter | undefined> = {
|
|
173
172
|
ArrowUp: "A",
|
|
174
173
|
ArrowDown: "B",
|
|
175
174
|
ArrowRight: "C",
|
package/src/mouse.ts
CHANGED
|
@@ -264,10 +264,20 @@ function onWheel(e: WheelEvent): void {
|
|
|
264
264
|
if (!pos) {
|
|
265
265
|
return;
|
|
266
266
|
}
|
|
267
|
-
// Wheel up = button 64, wheel down = button 65
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
// Wheel up = button 64, wheel down = button 65. A gesture with NO vertical
|
|
268
|
+
// component — a horizontal trackpad swipe, or shift-wheel where the browser
|
|
269
|
+
// routes the delta to deltaX — has no wheel button to report: xterm.js's
|
|
270
|
+
// reference encoder returns before encoding when the vertical delta is zero,
|
|
271
|
+
// while its wheel handler still calls preventDefault unconditionally. So
|
|
272
|
+
// parity is "report nothing, still swallow the gesture": a terminal owns the
|
|
273
|
+
// wheel, and letting a sideways swipe through would scroll the page instead.
|
|
274
|
+
// Reporting 65 here (the pre-fix behavior, `deltaY < 0 ? 64 : 65` with no
|
|
275
|
+
// zero case) scrolled the TUI DOWN on a sideways gesture.
|
|
276
|
+
if (e.deltaY !== 0) {
|
|
277
|
+
const btn = e.deltaY < 0 ? 64 : 65;
|
|
278
|
+
const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
279
|
+
handler.send(encodeSGR(code, pos.col, pos.row, false));
|
|
280
|
+
}
|
|
271
281
|
e.preventDefault();
|
|
272
282
|
}
|
|
273
283
|
|
package/src/render.ts
CHANGED
|
@@ -1393,6 +1393,12 @@ function flushRender(): void {
|
|
|
1393
1393
|
// silently switching auto-follow off (scroll.ts's header; §1.3).
|
|
1394
1394
|
if (removedRowsThisPass) {
|
|
1395
1395
|
scroll.noteContentShrink(scrollTopBefore);
|
|
1396
|
+
// And correct the offset when the container left it past the end of the
|
|
1397
|
+
// shrunken content instead of reconciling it (scroll.ts's header: the third
|
|
1398
|
+
// arithmetic state). Before the three invariants below, so they measure a
|
|
1399
|
+
// geometry the container agrees with rather than one it is still holding an
|
|
1400
|
+
// impossible offset into.
|
|
1401
|
+
scroll.reconcileScrollRange();
|
|
1396
1402
|
}
|
|
1397
1403
|
// Three position invariants, applied after every DOM mutation and in this
|
|
1398
1404
|
// order. An armed view restore goes first because it OWNS the position while
|
|
@@ -1497,6 +1503,16 @@ function flushRenderInner(): void {
|
|
|
1497
1503
|
}
|
|
1498
1504
|
gapMarkerEls.clear();
|
|
1499
1505
|
cursorAbs = -1;
|
|
1506
|
+
// The rows are not the only thing in content space, and this wipe is as
|
|
1507
|
+
// total as rebuild()'s. Collapse the four overlays with them HERE rather
|
|
1508
|
+
// than relying on the tail's positionCursorOverlay/onCursorMove, so the
|
|
1509
|
+
// shrink this pass announces is the real one: while an overlay still holds
|
|
1510
|
+
// the container's scrollable overflow at its old offset, both the
|
|
1511
|
+
// announcement and the range correction measure a phantom height and read
|
|
1512
|
+
// "already at the bottom" over zero rows of content. See
|
|
1513
|
+
// collapseContentSpaceOverlays for the measurement, and note that the tail
|
|
1514
|
+
// is exactly what does not run on the three paths listed there.
|
|
1515
|
+
collapseContentSpaceOverlays();
|
|
1500
1516
|
} else {
|
|
1501
1517
|
for (const abs of ch.evictedLines) {
|
|
1502
1518
|
const el = rowEls.get(abs);
|
|
@@ -2056,6 +2072,19 @@ function renderAlt(rows: readonly (readonly WireRun[])[]): void {
|
|
|
2056
2072
|
div.replaceChildren(...buildRowSpans(runs));
|
|
2057
2073
|
els.push(div);
|
|
2058
2074
|
}
|
|
2075
|
+
// This replaceChildren REMOVES rows, and on the first alt frame it removes
|
|
2076
|
+
// the entire main-buffer scrollback in favour of one screen of grid: the
|
|
2077
|
+
// largest shrink in this module after rebuild()'s wipe. The alt branch in
|
|
2078
|
+
// flushRenderInner returns before the shared bookkeeping, so the flag is set
|
|
2079
|
+
// here or not at all, and without it the pass reached neither
|
|
2080
|
+
// noteContentShrink (leaving the clamp to be inferred from its lossy
|
|
2081
|
+
// arithmetic signature, which can silently switch auto-follow off) nor
|
|
2082
|
+
// reconcileScrollRange (leaving the viewport parked past the grid).
|
|
2083
|
+
//
|
|
2084
|
+
// Over-announcing is harmless by mechanism: the resize and desynced-DOM
|
|
2085
|
+
// cases reach this branch too, and both seams are position-gated, so a
|
|
2086
|
+
// rebuild that moves nothing and strands nothing announces nothing.
|
|
2087
|
+
removedRowsThisPass = true;
|
|
2059
2088
|
output.replaceChildren(...els);
|
|
2060
2089
|
altPrevRows = rows.slice();
|
|
2061
2090
|
return;
|
|
@@ -2280,19 +2309,48 @@ function rowTopInTermWrap(el: HTMLElement): number {
|
|
|
2280
2309
|
return top;
|
|
2281
2310
|
}
|
|
2282
2311
|
|
|
2312
|
+
// rowTopFor resolves the top of the row holding absolute line `abs`, in
|
|
2313
|
+
// termWrap's coordinate space. Three positioners need that answer — the caret
|
|
2314
|
+
// overlay, the predicted-cursor overlay, and `getCursorPx` for the consumer's
|
|
2315
|
+
// IME view and hidden textarea — and they must not disagree, because they
|
|
2316
|
+
// describe the same row. They did: `getCursorPx` fell back to a bare `padT`
|
|
2317
|
+
// while the two that PAINT fell back to grid arithmetic, and `rowEls` is
|
|
2318
|
+
// populated only by the main-screen row builder (`renderAlt` clears it), so for
|
|
2319
|
+
// an ENTIRE alt-screen session the caret painted on the right row while the
|
|
2320
|
+
// consumer was told the top of the terminal every frame.
|
|
2321
|
+
//
|
|
2322
|
+
// Three cases, in order:
|
|
2323
|
+
// - the map holds a built row: its own DOM offset, walked into termWrap space.
|
|
2324
|
+
// - no row for a real index (an alt-screen grid row, or a main row this flush
|
|
2325
|
+
// has not built yet): uniform-grid arithmetic from the window base.
|
|
2326
|
+
// - `abs < 0`: not a row at all. That is the state
|
|
2327
|
+
// `collapseContentSpaceOverlays` leaves behind, where the cursor genuinely
|
|
2328
|
+
// has no row and the content origin IS the accurate answer; the arithmetic
|
|
2329
|
+
// would put the overlay a cell above it.
|
|
2330
|
+
function rowTopFor(abs: number): number {
|
|
2331
|
+
const el = rowEls.get(abs);
|
|
2332
|
+
if (el) {
|
|
2333
|
+
return rowTopInTermWrap(el);
|
|
2334
|
+
}
|
|
2335
|
+
const { padT } = termPadding();
|
|
2336
|
+
if (abs < 0) {
|
|
2337
|
+
return padT;
|
|
2338
|
+
}
|
|
2339
|
+
return padT + (abs - store.getWindow().base) * cellHeight;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2283
2342
|
/**
|
|
2284
2343
|
* Returns the cursor's pixel position relative to the scroll container
|
|
2285
2344
|
* (termWrap) — the coordinate space of the absolutely-positioned overlays
|
|
2286
2345
|
* (predicted-cursor, IME composition view) — plus the current cell height.
|
|
2287
|
-
* Uses the cursor row's actual DOM offset
|
|
2346
|
+
* Uses the cursor row's actual DOM offset, or the grid position of a row that
|
|
2347
|
+
* has none (see rowTopFor).
|
|
2288
2348
|
*/
|
|
2289
2349
|
export function getCursorPx(): { left: number; top: number; cellH: number } {
|
|
2290
|
-
const { padL
|
|
2291
|
-
const el = rowEls.get(cursorAbs);
|
|
2292
|
-
const top = el ? rowTopInTermWrap(el) : padT;
|
|
2350
|
+
const { padL } = termPadding();
|
|
2293
2351
|
return {
|
|
2294
2352
|
left: Math.round(padL + cursorCol * cellWidth),
|
|
2295
|
-
top: Math.round(
|
|
2353
|
+
top: Math.round(rowTopFor(cursorAbs)),
|
|
2296
2354
|
cellH: cellHeight,
|
|
2297
2355
|
};
|
|
2298
2356
|
}
|
|
@@ -2352,15 +2410,13 @@ function positionCursorOverlay(runs: readonly WireRun[] | undefined): void {
|
|
|
2352
2410
|
el.className = "term-cursor-overlay";
|
|
2353
2411
|
return;
|
|
2354
2412
|
}
|
|
2355
|
-
const
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
//
|
|
2359
|
-
//
|
|
2360
|
-
//
|
|
2361
|
-
|
|
2362
|
-
// regardless of which ancestor is the rows' offsetParent.
|
|
2363
|
-
const top = rowEl ? rowTopInTermWrap(rowEl) : padT + (cursorAbs - win.base) * cellHeight;
|
|
2413
|
+
const { padL } = termPadding();
|
|
2414
|
+
// Alt-screen rows are not registered in rowEls; their grid is uniform, so the
|
|
2415
|
+
// row offset derives from the window-relative row index. Main-screen rows
|
|
2416
|
+
// exist by the time this runs (the flush force-builds the cursor row).
|
|
2417
|
+
// rowTopFor is shared with getCursorPx so the consumer's IME view cannot be
|
|
2418
|
+
// told a different row than the one the caret paints on.
|
|
2419
|
+
const top = rowTopFor(cursorAbs);
|
|
2364
2420
|
const ch = glyphAt(runs, cursorCol);
|
|
2365
2421
|
// A wide glyph (CJK, emoji) owns two cells; the overlay covers both, like
|
|
2366
2422
|
// the old inline cursor span did via its continuation-spacer adjustment.
|
|
@@ -2404,9 +2460,14 @@ export function setPredictedCursor(row: number, col: number, active: boolean): v
|
|
|
2404
2460
|
el.classList.remove("visible");
|
|
2405
2461
|
return;
|
|
2406
2462
|
}
|
|
2407
|
-
const { padL
|
|
2408
|
-
|
|
2409
|
-
|
|
2463
|
+
const { padL } = termPadding();
|
|
2464
|
+
// Same resolver as the caret and getCursorPx. A row the flush has not built
|
|
2465
|
+
// yet answers from grid arithmetic, which for `predAbs >= 0` is exactly the
|
|
2466
|
+
// `padT + row * cellHeight` this used to compute inline. A prediction BELOW
|
|
2467
|
+
// the first line of the session (`predAbs < 0`) now lands on the content
|
|
2468
|
+
// origin instead of a negative top: that row does not exist, and an overlay
|
|
2469
|
+
// at a negative offset paints over the terminal's top padding.
|
|
2470
|
+
const top = rowTopFor(predAbs);
|
|
2410
2471
|
el.style.left = `${Math.round(padL + col * cellWidth)}px`;
|
|
2411
2472
|
el.style.top = `${Math.round(top)}px`;
|
|
2412
2473
|
el.style.width = `${cellWidth}px`;
|
package/src/scroll.ts
CHANGED
|
@@ -87,6 +87,44 @@
|
|
|
87
87
|
// follow and a lingering arm cannot swallow a later real gesture. It is also
|
|
88
88
|
// cleared unconditionally by the first event that arrives, so it cannot outlive
|
|
89
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.
|
|
90
128
|
const BOTTOM_TOLERANCE_PX = 24;
|
|
91
129
|
// An upward move only disengages follow when a real gap is left below it.
|
|
92
130
|
// Bigger than 0 to absorb fractional-layout rounding in the shrink-clamp case
|
|
@@ -106,6 +144,12 @@ let preserveFollowOnce = false;
|
|
|
106
144
|
// position: the next event is that clamp, not a gesture. Consumed (and in all
|
|
107
145
|
// cases cleared) by the first event to arrive.
|
|
108
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;
|
|
109
153
|
let onFollowChange: ((scrolledUp: boolean) => void) | null = null;
|
|
110
154
|
let onPosition: (() => void) | null = null;
|
|
111
155
|
let scrollHandler: (() => void) | null = null;
|
|
@@ -117,6 +161,27 @@ function distanceFromBottom(): number {
|
|
|
117
161
|
return scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
|
|
118
162
|
}
|
|
119
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The largest offset the container can hold: the zero point of
|
|
166
|
+
* distanceFromBottom, written out. Every write that means "the bottom" targets
|
|
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.
|
|
177
|
+
*/
|
|
178
|
+
function bottomOffset(): number {
|
|
179
|
+
if (!scrollEl) {
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
return Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
|
|
183
|
+
}
|
|
184
|
+
|
|
120
185
|
function atBottom(): boolean {
|
|
121
186
|
return distanceFromBottom() <= BOTTOM_TOLERANCE_PX;
|
|
122
187
|
}
|
|
@@ -157,6 +222,7 @@ export function init(opts: {
|
|
|
157
222
|
lastScrollTop = scrollEl.scrollTop;
|
|
158
223
|
preserveFollowOnce = false;
|
|
159
224
|
shrinkArmed = false;
|
|
225
|
+
rangeCorrectionOwed = false;
|
|
160
226
|
scrollHandler = () => {
|
|
161
227
|
if (!scrollEl) {
|
|
162
228
|
return;
|
|
@@ -281,6 +347,12 @@ function writePreservingFollow(next: number): void {
|
|
|
281
347
|
* this; that is correct rather than incidental, since it is likewise the
|
|
282
348
|
* library's own mutation moving the viewport and not the user.
|
|
283
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
|
+
*
|
|
284
356
|
* @param scrollTopBefore The container's offset read before the mutation.
|
|
285
357
|
*/
|
|
286
358
|
export function noteContentShrink(scrollTopBefore: number): void {
|
|
@@ -290,19 +362,77 @@ export function noteContentShrink(scrollTopBefore: number): void {
|
|
|
290
362
|
if (scrollEl.scrollTop < scrollTopBefore) {
|
|
291
363
|
shrinkArmed = true;
|
|
292
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;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Move the offset back inside the container's range when a caller's own row
|
|
376
|
+
* removal left it past the end of the content. A no-op unless noteContentShrink
|
|
377
|
+
* armed it in this pass, so it is safe to call after every removal.
|
|
378
|
+
*
|
|
379
|
+
* A RENDERER SEAM. It is public because the whole scroll namespace is
|
|
380
|
+
* re-exported, not because a consumer has a reason to call it; an out-of-band
|
|
381
|
+
* call finds nothing armed and does nothing.
|
|
382
|
+
*
|
|
383
|
+
* Call it in the same pass as noteContentShrink and BEFORE the position
|
|
384
|
+
* invariants (the view restore, the read anchor, the bottom pin), so those
|
|
385
|
+
* measure a geometry the container agrees with. Deliberately NOT folded into
|
|
386
|
+
* either neighbour: `stickToBottom` is follow-gated by contract and a HOLDING
|
|
387
|
+
* reader whose history was discarded is stranded over empty space just the same
|
|
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;
|
|
414
|
+
}
|
|
415
|
+
rangeCorrectionOwed = false;
|
|
416
|
+
writePreservingFollow(bottomOffset());
|
|
293
417
|
}
|
|
294
418
|
|
|
295
419
|
/**
|
|
296
420
|
* Pin the viewport to the bottom iff the user is following. Called by the
|
|
297
421
|
* renderer after each flush. A no-op when holding (scrolled up) or already at
|
|
298
422
|
* the bottom, so it never fights the user and never scrolls redundantly.
|
|
423
|
+
*
|
|
424
|
+
* Only the positive branch of distanceFromBottom is this function's business: an
|
|
425
|
+
* offset PAST the end of the content is a geometry error rather than a following
|
|
426
|
+
* reader who fell behind, and reconcileScrollRange owns it (see the header).
|
|
427
|
+
* Correcting it here instead would fight an overscroll bounce, which presents
|
|
428
|
+
* the same arithmetic with nothing wrong.
|
|
299
429
|
*/
|
|
300
430
|
export function stickToBottom(): void {
|
|
301
431
|
if (!scrollEl || !following) {
|
|
302
432
|
return;
|
|
303
433
|
}
|
|
304
434
|
if (distanceFromBottom() > 0) {
|
|
305
|
-
scrollEl.scrollTop =
|
|
435
|
+
scrollEl.scrollTop = bottomOffset();
|
|
306
436
|
}
|
|
307
437
|
}
|
|
308
438
|
|
|
@@ -314,7 +444,7 @@ export function scrollToBottom(): void {
|
|
|
314
444
|
if (!scrollEl) {
|
|
315
445
|
return;
|
|
316
446
|
}
|
|
317
|
-
scrollEl.scrollTop =
|
|
447
|
+
scrollEl.scrollTop = bottomOffset();
|
|
318
448
|
setFollowing(true);
|
|
319
449
|
}
|
|
320
450
|
|
package/src/store.ts
CHANGED
|
@@ -1262,10 +1262,12 @@ export class LineStore {
|
|
|
1262
1262
|
const tailKeys = [...this.lines.keys()]
|
|
1263
1263
|
.filter((k) => !this.browse.has(k))
|
|
1264
1264
|
.sort((a, b) => a - b);
|
|
1265
|
+
// `excess >= 1` here, so no guard: `tailCount` is `lines.size -
|
|
1266
|
+
// browse.size` while `tailKeys.length` is `lines.size` minus the browse
|
|
1267
|
+
// keys that are ACTUALLY in `lines`, so `tailKeys.length >= tailCount`
|
|
1268
|
+
// unconditionally — and reaching this line means the gate above found
|
|
1269
|
+
// `tailCount > keep`.
|
|
1265
1270
|
const excess = tailKeys.length - keep;
|
|
1266
|
-
if (excess <= 0) {
|
|
1267
|
-
return false;
|
|
1268
|
-
}
|
|
1269
1271
|
const winFloor = this.win.height > 0 ? this.win.base : Number.POSITIVE_INFINITY;
|
|
1270
1272
|
return this.classifyAsBrowse(tailKeys.slice(0, excess).filter((k) => k < winFloor)) > 0;
|
|
1271
1273
|
}
|
|
@@ -1445,7 +1447,6 @@ export class LineStore {
|
|
|
1445
1447
|
this.everEvictedThrough = -1;
|
|
1446
1448
|
this.erasedThrough = -1;
|
|
1447
1449
|
this.unconfirmedFrom = Number.POSITIVE_INFINITY;
|
|
1448
|
-
this.paging = false;
|
|
1449
1450
|
this.serverOldest = -1;
|
|
1450
1451
|
this.win = emptyWindow();
|
|
1451
1452
|
this.alt = false;
|
|
@@ -1933,11 +1934,15 @@ export class LineStore {
|
|
|
1933
1934
|
// keeps the classification consistent if one ever does.)
|
|
1934
1935
|
this.everEvictedThrough = Math.max(this.everEvictedThrough, victim);
|
|
1935
1936
|
}
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1937
|
+
// No empty-store arm: the walk cannot evict the last line. The loop
|
|
1938
|
+
// guard's `cursor < this.highest` conjunct stops it once the cursor
|
|
1939
|
+
// reaches the newest key, and with a protected window the sibling
|
|
1940
|
+
// `cursor > winBottom` cannot hold there either. That is the ONLY thing
|
|
1941
|
+
// holding when the target is 0, which `new LineStore(0)` produces
|
|
1942
|
+
// (`evictionBatch(0)` is 1, so `target = 0 - 1 + 1`; the bare
|
|
1943
|
+
// constructor accepts 0, unlike `render.init` and `fromSnapshot`, which
|
|
1944
|
+
// both require a positive integer) — a reason to leave that conjunct
|
|
1945
|
+
// alone.
|
|
1941
1946
|
// Advance to the lowest remaining key above the victim. Contiguous
|
|
1942
1947
|
// history (the common case: live scroll-off, resume replay, a
|
|
1943
1948
|
// cat-bigfile burst) leaves victim + 1 present -- an O(1) probe. Only a
|