bunnyquery 1.8.13 → 1.8.15
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/bunnyquery.css +8 -0
- package/bunnyquery.js +718 -104
- package/dist/engine.cjs +606 -52
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +544 -212
- package/dist/engine.d.ts +544 -212
- package/dist/engine.mjs +602 -53
- package/dist/engine.mjs.map +1 -1
- package/package.json +3 -2
- package/src/engine/history.ts +64 -0
- package/src/engine/host.ts +14 -0
- package/src/engine/image_preview.ts +0 -0
- package/src/engine/index.ts +17 -0
- package/src/engine/link_markup.ts +9 -1
- package/src/engine/links.ts +31 -3
- package/src/engine/scroll_anchor.ts +684 -0
- package/src/engine/session.ts +313 -61
- package/src/widget.css +8 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hold the reader's place in the message list.
|
|
3
|
+
*
|
|
4
|
+
* A chat box mutates constantly WITHOUT the reader asking for it: an older page
|
|
5
|
+
* prepends, a poll resolves, an indexing row splices in or changes label, a link
|
|
6
|
+
* chip goes grey, an image preview finishes decoding, the "Fetching history..."
|
|
7
|
+
* bar appears and disappears. Every one of those changes the height of something
|
|
8
|
+
* that may sit ABOVE the viewport, and the browser answers by keeping scrollTop —
|
|
9
|
+
* which slides the sentence the user was reading out from under them.
|
|
10
|
+
*
|
|
11
|
+
* Both clients had their own copy of a row anchor for the ONE case each could
|
|
12
|
+
* bracket (agent.vue watched its row-key list, the widget bracketed its full
|
|
13
|
+
* re-render). Everything else — anything that changed a height without changing
|
|
14
|
+
* the row SET, and everything asynchronous — was uncovered in both. This is the
|
|
15
|
+
* single implementation, and it covers both shapes:
|
|
16
|
+
*
|
|
17
|
+
* preserve(fn) / capture() + restore(a)
|
|
18
|
+
* A mutation you can bracket. Measures immediately before and immediately
|
|
19
|
+
* after, so it is exact even when the mutation tears the list down.
|
|
20
|
+
*
|
|
21
|
+
* remember() + hold()
|
|
22
|
+
* A layout change you CANNOT bracket — an image decoding, a font arriving,
|
|
23
|
+
* a re-parse triggered from a promise. `remember()` runs from the view's
|
|
24
|
+
* scroll handler, so the anchor is always the reader's own last position;
|
|
25
|
+
* `hold()` puts that position back whenever something settles.
|
|
26
|
+
*
|
|
27
|
+
* The staleness rule is what makes the unbracketed half safe. A layout change
|
|
28
|
+
* above the viewport does NOT change scrollTop — the browser preserves it, which
|
|
29
|
+
* is precisely why the content appears to jump. So a remembered anchor is still
|
|
30
|
+
* valid exactly while `box.scrollTop` equals the value it was captured at. If it
|
|
31
|
+
* differs, something moved the box on purpose (the user scrolled, a clamp fired,
|
|
32
|
+
* or the browser's own scroll anchoring already compensated), and `hold()`
|
|
33
|
+
* re-captures rather than dragging the reader back to a position they left.
|
|
34
|
+
*
|
|
35
|
+
* DOM-free like the rest of the engine: the element shapes below are structural,
|
|
36
|
+
* so real DOM nodes satisfy them while this file imports nothing from lib.dom.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export interface AnchorRect {
|
|
40
|
+
top: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AnchorRowEl {
|
|
44
|
+
getAttribute(name: string): string | null;
|
|
45
|
+
getBoundingClientRect(): AnchorRect;
|
|
46
|
+
offsetHeight: number;
|
|
47
|
+
parentNode: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Anything inside the list that resizes on its own schedule. See absorb(). */
|
|
51
|
+
export interface AnchorGrowableEl {
|
|
52
|
+
getBoundingClientRect(): AnchorRect;
|
|
53
|
+
offsetHeight: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AnchorBoxEl {
|
|
57
|
+
children: ArrayLike<AnchorRowEl>;
|
|
58
|
+
getBoundingClientRect(): AnchorRect;
|
|
59
|
+
scrollTop: number;
|
|
60
|
+
scrollHeight: number;
|
|
61
|
+
clientHeight: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RowAnchor {
|
|
65
|
+
/** data-row-key of the anchored row, or null when nothing was anchorable. */
|
|
66
|
+
key: string | null;
|
|
67
|
+
/** Offset of that row from the top of the viewport. Negative above the fold. */
|
|
68
|
+
top: number;
|
|
69
|
+
/** data-row-pos, present only on rows that can RELOCATE (see below). */
|
|
70
|
+
pos: string | null;
|
|
71
|
+
/** scrollTop at capture time. The staleness check, and the raw fallback. */
|
|
72
|
+
scrollTop: number;
|
|
73
|
+
/**
|
|
74
|
+
* scrollHeight at capture time. How much the list GREW is the best available
|
|
75
|
+
* answer when the anchored row itself cannot be found again, and the bound on
|
|
76
|
+
* how far a correction can legitimately be.
|
|
77
|
+
*/
|
|
78
|
+
scrollHeight: number;
|
|
79
|
+
/**
|
|
80
|
+
* The anchored element itself. A view that patches in place (Vue) keeps the
|
|
81
|
+
* same node across an update, so restore is one rect read instead of a scan;
|
|
82
|
+
* a view that rebuilds the list (the widget) drops it and falls back to the
|
|
83
|
+
* key. Never trusted without re-checking that it is still in the box.
|
|
84
|
+
*/
|
|
85
|
+
el: AnchorRowEl | null;
|
|
86
|
+
/**
|
|
87
|
+
* The next few anchorable rows below the primary, each with its own offset.
|
|
88
|
+
*
|
|
89
|
+
* The primary row does not always survive: a refresh can drop it, a collapsed
|
|
90
|
+
* indexing row can be re-identified, an expanded group can fold. Without a
|
|
91
|
+
* fallback the only thing left is lost(), which guesses from the list's total
|
|
92
|
+
* growth — and total growth includes everything added BELOW the reader, so a
|
|
93
|
+
* merge that lands rows on both sides of them over-pays. A second row that is
|
|
94
|
+
* still there beats any guess, and collecting them costs nothing: capture is
|
|
95
|
+
* already walking these rows.
|
|
96
|
+
*/
|
|
97
|
+
alts?: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ScrollAnchorOptions {
|
|
101
|
+
/** The scrolling message box, or null when it is not mounted. */
|
|
102
|
+
getBox: () => AnchorBoxEl | null;
|
|
103
|
+
/**
|
|
104
|
+
* The reader is pinned to the bottom. There the bottom IS the anchor and the
|
|
105
|
+
* scrollToBottom* paths own the position, so every method here no-ops.
|
|
106
|
+
*/
|
|
107
|
+
isStuck: () => boolean;
|
|
108
|
+
/**
|
|
109
|
+
* The reader cannot see this box right now (the tab is hidden), so FREEZE:
|
|
110
|
+
* remember where they were and refuse to move them.
|
|
111
|
+
*
|
|
112
|
+
* A hidden tab still runs everything that mutates the list — a resumed poll, a
|
|
113
|
+
* head refresh and its deferred background batch, a settling request — and each
|
|
114
|
+
* of those would otherwise write scrollTop against a layout nobody is looking at
|
|
115
|
+
* and re-stamp the remembered position on the way through. The reader then comes
|
|
116
|
+
* back to wherever the last of those writes happened to land, which is the
|
|
117
|
+
* "somehow placed in the middle" they see, and only the NEXT correction puts
|
|
118
|
+
* them right. So while frozen, reads still happen but nothing writes and nothing
|
|
119
|
+
* re-stamps: the anchor holds the last position the reader actually had, and one
|
|
120
|
+
* hold() on return puts them back on it.
|
|
121
|
+
*/
|
|
122
|
+
isFrozen?: () => boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Fall back to the raw scrollTop when the anchored row cannot be found again.
|
|
125
|
+
*
|
|
126
|
+
* For a view that REBUILDS the list (the widget's renderMessages), detaching
|
|
127
|
+
* every child collapses scrollHeight and the browser clamps scrollTop to 0,
|
|
128
|
+
* so the raw offset is strictly better than the clamp it would otherwise be
|
|
129
|
+
* left with. For a view that patches in place (Vue) the browser has already
|
|
130
|
+
* kept a sane position and re-imposing a stale offset is worse than nothing.
|
|
131
|
+
*/
|
|
132
|
+
rawFallback?: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ScrollAnchor {
|
|
136
|
+
/** Measure the reader's current place. Null while pinned to the bottom. */
|
|
137
|
+
capture: () => RowAnchor | null;
|
|
138
|
+
/** Put a captured place back. Safe to call with null. */
|
|
139
|
+
restore: (anchor: RowAnchor | null) => void;
|
|
140
|
+
/** capture -> mutate -> restore, for a mutation you can bracket. */
|
|
141
|
+
preserve: <T>(mutate: () => T) => T;
|
|
142
|
+
/** Record the reader's place. Call from the box's scroll handler. */
|
|
143
|
+
remember: () => void;
|
|
144
|
+
/** Put the remembered place back, if it is still the reader's own. */
|
|
145
|
+
hold: () => void;
|
|
146
|
+
/** The reader is going away: park the exact place they are leaving. */
|
|
147
|
+
park: () => void;
|
|
148
|
+
/**
|
|
149
|
+
* They are back. Puts them on the parked place, and STAYS ARMED until it has
|
|
150
|
+
* actually landed. Returns true when the host must pin to the bottom instead
|
|
151
|
+
* (the reader left pinned), which only the host can do meaningfully.
|
|
152
|
+
*/
|
|
153
|
+
settleReturn: () => boolean;
|
|
154
|
+
/** A return is armed: its position, not the host's, decides scrollTop. */
|
|
155
|
+
isReturning: () => boolean;
|
|
156
|
+
/** Pin to the bottom, instantly, recording the write. The ONLY way to pin. */
|
|
157
|
+
pinBottom: () => void;
|
|
158
|
+
/** The box is not being painted (hidden tab). Shared so hosts agree. */
|
|
159
|
+
isFrozen: () => boolean;
|
|
160
|
+
/** Deprecated alias of settleReturn, kept so a stale dist does not break. */
|
|
161
|
+
thaw: () => void;
|
|
162
|
+
/** Absorb one element's own resize. See below. */
|
|
163
|
+
absorb: (el: AnchorGrowableEl | null | undefined) => void;
|
|
164
|
+
/** Drop the remembered place (chat switch, unmount). */
|
|
165
|
+
forget: () => void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A row is anchorable when it carries data-row-key. The bars that are not rows —
|
|
170
|
+
* "Fetching history...", the greeting, the drafting bubble, an expanded group's
|
|
171
|
+
* trailing loader — deliberately carry none, so they are never anchored ON while
|
|
172
|
+
* still being fully covered BY the anchor: they change height above a row that
|
|
173
|
+
* is held in place, and holding it is what absorbs them.
|
|
174
|
+
*/
|
|
175
|
+
var ROW_KEY_ATTR = 'data-row-key';
|
|
176
|
+
/**
|
|
177
|
+
* A collapsed indexing row names the turn it currently renders at. It is a WEAK
|
|
178
|
+
* anchor because it can RELOCATE — an older page carrying earlier passes of the
|
|
179
|
+
* same run moves the row itself — and pinning a row while it moves is what would
|
|
180
|
+
* drag the reader along with it. So an ordinary message row is always preferred,
|
|
181
|
+
* and a group row is used only when nothing else is on screen, and then only if
|
|
182
|
+
* it did not move.
|
|
183
|
+
*/
|
|
184
|
+
var ROW_POS_ATTR = 'data-row-pos';
|
|
185
|
+
/** How many standby rows capture() records. Two is enough to survive a refresh
|
|
186
|
+
* that drops the reader's own row without paying for a full-list guess. */
|
|
187
|
+
var MAX_ALTS = 2;
|
|
188
|
+
/** How far past the anchor collectAlts will look for them. */
|
|
189
|
+
var ALT_SCAN_LIMIT = 64;
|
|
190
|
+
/** data-row-pos is present but empty: the row cannot say where it is anchored. */
|
|
191
|
+
var UNKNOWN_ROW_POS = '\u0000?';
|
|
192
|
+
|
|
193
|
+
export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
194
|
+
var held: RowAnchor | null = null;
|
|
195
|
+
// Per-element height, so absorb() needs no "before" call from the caller.
|
|
196
|
+
// Weak on purpose: a re-render throws every row away and a strong map would
|
|
197
|
+
// hold the whole conversation's DOM alive behind it.
|
|
198
|
+
var seen: WeakMap<object, number> | null =
|
|
199
|
+
typeof WeakMap === 'function' ? new WeakMap<object, number>() : null;
|
|
200
|
+
|
|
201
|
+
function capture(): RowAnchor | null {
|
|
202
|
+
var box = options.getBox();
|
|
203
|
+
if (!box || options.isStuck()) return null;
|
|
204
|
+
var boxTop = box.getBoundingClientRect().top;
|
|
205
|
+
var kids = box.children;
|
|
206
|
+
var fallback: RowAnchor | null = null;
|
|
207
|
+
var fallbackAt = -1;
|
|
208
|
+
for (var i = 0; i < kids.length; i++) {
|
|
209
|
+
var el = kids[i];
|
|
210
|
+
if (!el || typeof el.getAttribute !== 'function') continue;
|
|
211
|
+
var key = el.getAttribute(ROW_KEY_ATTR);
|
|
212
|
+
if (!key) continue;
|
|
213
|
+
var top = el.getBoundingClientRect().top - boxTop;
|
|
214
|
+
// Rows still (partly) on screen. `top` is negative when a row starts
|
|
215
|
+
// above the fold, which is exactly the offset to preserve.
|
|
216
|
+
if (top + el.offsetHeight <= 0) continue;
|
|
217
|
+
// And STOP at the bottom of the viewport. Without this the preference
|
|
218
|
+
// for an ordinary row walks straight past a screenful of collapsed
|
|
219
|
+
// indexing rows and anchors on a message two screens down — which is
|
|
220
|
+
// not the reader's place, and holds the wrong thing when a row between
|
|
221
|
+
// the two changes height.
|
|
222
|
+
if (top >= box.clientHeight) break;
|
|
223
|
+
// An EMPTY data-row-pos means "this row cannot say where it is anchored
|
|
224
|
+
// yet" — a run:: stub has no anchorId until its real group loads. Treating
|
|
225
|
+
// "" as a position made every stub -> real-group handoff read as a
|
|
226
|
+
// relocation and abort the anchor, which is a background resolution that
|
|
227
|
+
// happens on every fresh open. Empty is normalised to null, which also
|
|
228
|
+
// keeps such a row from being MISTAKEN for an ordinary one: the
|
|
229
|
+
// ordinary/group split is the attribute's PRESENCE, tested first.
|
|
230
|
+
var rawPos = el.getAttribute(ROW_POS_ATTR);
|
|
231
|
+
var pos = rawPos === null ? null : (rawPos || UNKNOWN_ROW_POS);
|
|
232
|
+
var cand: RowAnchor = {
|
|
233
|
+
key: key, top: top, pos: pos,
|
|
234
|
+
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
|
|
235
|
+
};
|
|
236
|
+
if (rawPos === null) {
|
|
237
|
+
// An ordinary row: use it, and take a couple of standbys from the rows
|
|
238
|
+
// after it in the same walk.
|
|
239
|
+
cand.alts = collectAlts(box, boxTop, i + 1);
|
|
240
|
+
return cand;
|
|
241
|
+
}
|
|
242
|
+
if (!fallback) { fallback = cand; fallbackAt = i; } // group row: last resort
|
|
243
|
+
}
|
|
244
|
+
// The weak anchor needs standbys MORE than the strong one does, not less: a
|
|
245
|
+
// screenful of collapsed indexing rows is exactly what a background sweep
|
|
246
|
+
// re-keys and re-anchors, and without them state (c) fell straight into
|
|
247
|
+
// lost()'s whole-list guess. Ordinary rows only — more group rows from the
|
|
248
|
+
// same block are the very rows the same batch rewrites.
|
|
249
|
+
if (fallback) {
|
|
250
|
+
fallback.alts = collectAlts(box, boxTop, fallbackAt + 1, true);
|
|
251
|
+
return fallback;
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
key: null, top: 0, pos: null,
|
|
255
|
+
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: null,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Up to MAX_ALTS anchorable rows starting at `from`, for restore's fallback. */
|
|
260
|
+
function collectAlts(box: AnchorBoxEl, boxTop: number, from: number, ordinaryOnly?: boolean) {
|
|
261
|
+
var out: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }> = [];
|
|
262
|
+
var kids = box.children;
|
|
263
|
+
// Bounded: a chat can carry hundreds of collapsed rows below the fold, and
|
|
264
|
+
// this must not become a full-list walk for two standbys.
|
|
265
|
+
var stop = Math.min(kids.length, from + ALT_SCAN_LIMIT);
|
|
266
|
+
for (var i = from; i < stop && out.length < MAX_ALTS; i++) {
|
|
267
|
+
var el = kids[i];
|
|
268
|
+
if (!el || typeof el.getAttribute !== 'function') continue;
|
|
269
|
+
var key = el.getAttribute(ROW_KEY_ATTR);
|
|
270
|
+
if (!key) continue;
|
|
271
|
+
var rawPos = el.getAttribute(ROW_POS_ATTR);
|
|
272
|
+
if (ordinaryOnly && rawPos !== null) continue;
|
|
273
|
+
out.push({
|
|
274
|
+
key: key,
|
|
275
|
+
top: el.getBoundingClientRect().top - boxTop,
|
|
276
|
+
pos: rawPos === null ? null : (rawPos || UNKNOWN_ROW_POS),
|
|
277
|
+
el: el,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return out.length ? out : undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function findRow(box: AnchorBoxEl, anchor: RowAnchor): AnchorRowEl | null {
|
|
284
|
+
// The same node, still in the box: one rect read instead of a scan. Vue
|
|
285
|
+
// patches keyed rows in place, so this is the common path there, and it is
|
|
286
|
+
// what keeps a per-update hold() cheap enough to run on every update.
|
|
287
|
+
var el = anchor.el;
|
|
288
|
+
if (el && el.parentNode === (box as unknown)) return el;
|
|
289
|
+
if (!anchor.key) return null;
|
|
290
|
+
var kids = box.children;
|
|
291
|
+
for (var i = 0; i < kids.length; i++) {
|
|
292
|
+
var kid = kids[i];
|
|
293
|
+
if (!kid || typeof kid.getAttribute !== 'function') continue;
|
|
294
|
+
if (kid.getAttribute(ROW_KEY_ATTR) === anchor.key) return kid;
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// A frozen stretch happened and has not been settled yet. hold() has to know,
|
|
300
|
+
// because its own safety rule cannot survive one: on the first call after the
|
|
301
|
+
// tab comes forward it must put the reader back rather than conclude they moved
|
|
302
|
+
// (and, worse, re-measure — which is what DESTROYS the only record of where they
|
|
303
|
+
// were, and why this cannot be left to whoever calls thaw() first).
|
|
304
|
+
var sawFrozen = false;
|
|
305
|
+
// Where the reader was when they went away. Held apart from `held` because the
|
|
306
|
+
// ordinary compensation keeps re-stamping that one while they are gone.
|
|
307
|
+
var parked: RowAnchor | null = null;
|
|
308
|
+
// They left pinned to the bottom. capture() records nothing for such a reader
|
|
309
|
+
// (there is no row to hold, the bottom IS the place), so this is the only note
|
|
310
|
+
// of it — and without it a stickiness lost during the absence had no fallback.
|
|
311
|
+
var parkedStuck = false;
|
|
312
|
+
// A return is armed: it has not landed yet. It survives across BOTH halves of a
|
|
313
|
+
// head refresh, which is what puts a pinned reader on the bottom that exists
|
|
314
|
+
// after the deferred batch merges rather than the surface page's bottom.
|
|
315
|
+
var returning = false;
|
|
316
|
+
// The last scrollTop THIS module wrote. A scroll event reporting anything else
|
|
317
|
+
// is the reader, and the reader always wins — that is the whole retirement rule
|
|
318
|
+
// for an armed return, and it is why every write below records it.
|
|
319
|
+
var wroteTop = -1;
|
|
320
|
+
// Did the last restore's write actually land, and was it a real row pin rather
|
|
321
|
+
// than lost()'s guess. A shrink below the reader silently truncates a
|
|
322
|
+
// correction, and a return that believes a clamped write succeeded is a reader
|
|
323
|
+
// left a few hundred pixels off their line, permanently.
|
|
324
|
+
var restoreExact = true;
|
|
325
|
+
var restorePinned = false;
|
|
326
|
+
function frozen(): boolean {
|
|
327
|
+
var f = !!options.isFrozen && options.isFrozen();
|
|
328
|
+
if (f) sawFrozen = true;
|
|
329
|
+
return f;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function restore(anchor: RowAnchor | null, unbounded?: boolean): void {
|
|
333
|
+
// Frozen: leave `held` exactly as it is. It is the reader's last real
|
|
334
|
+
// position, and it is what hold() puts back when the tab comes forward.
|
|
335
|
+
if (frozen()) return;
|
|
336
|
+
var box = options.getBox();
|
|
337
|
+
if (!box || !anchor || options.isStuck()) return;
|
|
338
|
+
var el = findRow(box, anchor);
|
|
339
|
+
if (el) {
|
|
340
|
+
// A row that MOVED (an older page re-anchored a collapsed run to its
|
|
341
|
+
// true first pass) must not be pinned: doing so would drag the reader
|
|
342
|
+
// along with it, to wherever the run now starts.
|
|
343
|
+
// Only compare when BOTH sides actually name a turn. A stub that has since
|
|
344
|
+
// learned its anchorId (or lost it) has not moved; it has just started (or
|
|
345
|
+
// stopped) being able to answer.
|
|
346
|
+
var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
|
|
347
|
+
// A relocation disqualifies the ROW, not the whole capture: fall through
|
|
348
|
+
// to the standbys, which is a real measurement, rather than to lost()'s
|
|
349
|
+
// whole-list guess.
|
|
350
|
+
if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS &&
|
|
351
|
+
livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) el = null;
|
|
352
|
+
}
|
|
353
|
+
if (el) {
|
|
354
|
+
var boxTop = box.getBoundingClientRect().top;
|
|
355
|
+
var delta = (el.getBoundingClientRect().top - boxTop) - anchor.top;
|
|
356
|
+
// A row can also be MOVED rather than resized: a background refetch
|
|
357
|
+
// that merges a run's passes into the middle of page 1 relocates the
|
|
358
|
+
// bubble this anchor is holding, and following it would carry the
|
|
359
|
+
// reader across the conversation. A real prepend or in-place growth
|
|
360
|
+
// can only ever need a correction on the order of what the list gained,
|
|
361
|
+
// so a delta a whole screen beyond that is a relocation, not a resize.
|
|
362
|
+
// `unbounded` is the thaw: across a hidden stretch the box may have been
|
|
363
|
+
// scrolled anywhere at all (a settling request, a clamp), so a correction
|
|
364
|
+
// the size of the whole list is not evidence that the ROW moved — it is
|
|
365
|
+
// just how far the reader has to be carried back.
|
|
366
|
+
var slack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
|
|
367
|
+
if (!unbounded && (delta > slack || delta < -slack)) { lost(box, anchor); return; }
|
|
368
|
+
// Sub-pixel noise is not a jump, and writing scrollTop for it costs a
|
|
369
|
+
// scroll event (and a re-layout) on every settle.
|
|
370
|
+
var want = box.scrollTop + delta;
|
|
371
|
+
if (delta >= 1 || delta <= -1) box.scrollTop += delta;
|
|
372
|
+
wroteTop = box.scrollTop;
|
|
373
|
+
restorePinned = true;
|
|
374
|
+
restoreExact = box.scrollTop >= want - 1 && box.scrollTop <= want + 1;
|
|
375
|
+
// This position is now the reader's place, and hold() has to know it:
|
|
376
|
+
// a bracketed restore MOVES scrollTop, which is exactly what hold()
|
|
377
|
+
// reads as "someone scrolled, my anchor is stale". Without this, every
|
|
378
|
+
// image that decodes after a re-render (which is all of them: the list
|
|
379
|
+
// is rebuilt with src-less, zero-height previews and hydrated
|
|
380
|
+
// afterwards) would find a stale anchor and go uncompensated.
|
|
381
|
+
held = {
|
|
382
|
+
key: anchor.key, top: anchor.top, pos: anchor.pos,
|
|
383
|
+
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
|
|
384
|
+
// Carried, not dropped: one successful restore used to disarm the
|
|
385
|
+
// standbys for every later hold.
|
|
386
|
+
alts: anchor.alts,
|
|
387
|
+
};
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// The anchor row is gone: its group collapsed, or the history was replaced.
|
|
391
|
+
// A standby row that IS still there beats lost()'s guess outright.
|
|
392
|
+
var alts = anchor.alts;
|
|
393
|
+
for (var ai = 0; alts && ai < alts.length; ai++) {
|
|
394
|
+
var alt = alts[ai];
|
|
395
|
+
var ael = findRow(box, { key: alt.key, top: alt.top, pos: alt.pos, scrollTop: anchor.scrollTop, scrollHeight: anchor.scrollHeight, el: alt.el });
|
|
396
|
+
if (!ael) continue;
|
|
397
|
+
if (alt.pos !== null && alt.pos !== UNKNOWN_ROW_POS) {
|
|
398
|
+
var altLive = ael.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
|
|
399
|
+
if (altLive !== UNKNOWN_ROW_POS && altLive !== alt.pos) continue;
|
|
400
|
+
}
|
|
401
|
+
var aboxTop = box.getBoundingClientRect().top;
|
|
402
|
+
var adelta = (ael.getBoundingClientRect().top - aboxTop) - alt.top;
|
|
403
|
+
var aslack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
|
|
404
|
+
if (!unbounded && (adelta > aslack || adelta < -aslack)) continue;
|
|
405
|
+
var awant = box.scrollTop + adelta;
|
|
406
|
+
if (adelta >= 1 || adelta <= -1) box.scrollTop += adelta;
|
|
407
|
+
wroteTop = box.scrollTop;
|
|
408
|
+
restorePinned = true;
|
|
409
|
+
restoreExact = box.scrollTop >= awant - 1 && box.scrollTop <= awant + 1;
|
|
410
|
+
held = {
|
|
411
|
+
key: alt.key, top: alt.top, pos: alt.pos,
|
|
412
|
+
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: ael,
|
|
413
|
+
alts: alts.slice(ai + 1),
|
|
414
|
+
};
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
lost(box, anchor);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The anchored row cannot be held: it is gone, or it relocated.
|
|
422
|
+
*
|
|
423
|
+
* What is still known is how much the list GREW, and in the case this branch
|
|
424
|
+
* exists for — the pager, whose page can carry the very pass that re-anchors a
|
|
425
|
+
* collapsed row — all of that growth is above the reader. So pay it. Missing it
|
|
426
|
+
* costs the reader a whole page of history in one jump, which is the single
|
|
427
|
+
* most visible version of this bug.
|
|
428
|
+
*
|
|
429
|
+
* With nothing gained there is nothing to pay, and then the two views differ:
|
|
430
|
+
* one REBUILDS the list (its teardown clamped scrollTop to 0, so the raw offset
|
|
431
|
+
* beats the clamp) and one patches in place (the browser already kept a sane
|
|
432
|
+
* position, so re-imposing a stale offset is worse than nothing).
|
|
433
|
+
*/
|
|
434
|
+
function lost(box: AnchorBoxEl, anchor: RowAnchor): void {
|
|
435
|
+
held = null;
|
|
436
|
+
var grew = box.scrollHeight - anchor.scrollHeight;
|
|
437
|
+
if (grew > 0) { box.scrollTop = anchor.scrollTop + grew; wroteTop = box.scrollTop; return; }
|
|
438
|
+
if (options.rawFallback) { box.scrollTop = anchor.scrollTop; wroteTop = box.scrollTop; }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function preserve<T>(mutate: () => T): T {
|
|
442
|
+
var anchor = capture();
|
|
443
|
+
var result = mutate();
|
|
444
|
+
restore(anchor);
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function remember(): void {
|
|
449
|
+
// THE retirement rule for an armed return, and the only one. This runs from
|
|
450
|
+
// the host's scroll handler, which sees every scroll event; a position this
|
|
451
|
+
// module did not write is the reader, and the reader always wins. Checked
|
|
452
|
+
// before the frozen bail because it is the one thing allowed to end a return.
|
|
453
|
+
var b0 = options.getBox();
|
|
454
|
+
if (returning && b0 && b0.scrollTop !== wroteTop) {
|
|
455
|
+
parked = null; parkedStuck = false; returning = false;
|
|
456
|
+
}
|
|
457
|
+
// A scroll event while the tab is hidden is not the reader moving.
|
|
458
|
+
if (frozen()) return;
|
|
459
|
+
held = capture();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function hold(): void {
|
|
463
|
+
if (frozen()) return;
|
|
464
|
+
// Coming out of an absence: nothing that moved this box while nobody was
|
|
465
|
+
// looking was the reader, so the parked place simply wins. `returning` keeps
|
|
466
|
+
// this true across BOTH halves of a head refresh, so a correction the first
|
|
467
|
+
// half's shrink clamped away is retried once the second half re-grows the list.
|
|
468
|
+
if (sawFrozen || returning) { settleReturn(); return; }
|
|
469
|
+
var box = options.getBox();
|
|
470
|
+
if (!box || options.isStuck()) { held = null; return; }
|
|
471
|
+
if (!held) { held = capture(); return; }
|
|
472
|
+
// Something moved the box on purpose since the anchor was taken — the user
|
|
473
|
+
// scrolled, a shrink clamped it, or the browser's own scroll anchoring
|
|
474
|
+
// already compensated. Restoring here would undo a move the reader made or
|
|
475
|
+
// double-count one already made for us, so re-measure instead.
|
|
476
|
+
if (box.scrollTop !== held.scrollTop) { held = capture(); return; }
|
|
477
|
+
// restore() re-stamps `held` with the position it just pinned, so repeated
|
|
478
|
+
// holds (one image after another finishing) each start from a valid anchor.
|
|
479
|
+
restore(held);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Absorb a resize made by ONE element, wherever it sits.
|
|
484
|
+
*
|
|
485
|
+
* The row anchor cannot see this case. A reader partway through an assistant
|
|
486
|
+
* reply that is taller than the viewport is anchored ON that row, and a
|
|
487
|
+
* picture decoding higher up INSIDE it moves every line they are reading
|
|
488
|
+
* without moving the row's own top by a pixel. Rows above the fold have the
|
|
489
|
+
* same problem in reverse: hold() would fix them, but it cannot be allowed to
|
|
490
|
+
* run for an image as well or the two would each pay the same debt.
|
|
491
|
+
*
|
|
492
|
+
* So images go through here instead, and it is the more precise of the two:
|
|
493
|
+
* it compensates by the element's own height delta, and only while the
|
|
494
|
+
* element's TOP is above the fold — which is exactly the condition for
|
|
495
|
+
* "everything the reader can see just moved by this much". An element that
|
|
496
|
+
* starts at or below the fold is left alone: it grew on screen, under a line
|
|
497
|
+
* the reader is looking at, and moving them is what would be the jump.
|
|
498
|
+
*
|
|
499
|
+
* The height it last saw is remembered per element, so the caller does not
|
|
500
|
+
* have to bracket anything. An element it has never seen counts as zero,
|
|
501
|
+
* which is what an <img> measures before it has anything to paint — including
|
|
502
|
+
* the markdown `` images that have no hydration hook at all.
|
|
503
|
+
*/
|
|
504
|
+
function absorb(el: AnchorGrowableEl | null | undefined): void {
|
|
505
|
+
if (!el) return;
|
|
506
|
+
var box = options.getBox();
|
|
507
|
+
if (!box) return;
|
|
508
|
+
var h = el.offsetHeight;
|
|
509
|
+
var prev = seen ? seen.get(el) : undefined;
|
|
510
|
+
// The baseline is recorded even while frozen (and even while pinned to the
|
|
511
|
+
// bottom): what must not happen is the WRITE. An image that decodes in a
|
|
512
|
+
// hidden tab has still resized, and forgetting that would make the next
|
|
513
|
+
// visible change pay for its whole height.
|
|
514
|
+
if (seen) seen.set(el, h);
|
|
515
|
+
if (options.isStuck()) return;
|
|
516
|
+
if (prev === undefined) prev = 0;
|
|
517
|
+
var delta = h - prev;
|
|
518
|
+
if (delta === 0) return;
|
|
519
|
+
if (frozen()) { foldFrozenGrowth(box, el, delta); return; }
|
|
520
|
+
// The element's own TOP, which a resize never moves: everything BELOW it
|
|
521
|
+
// slides by delta, everything above stays. So the reader's first visible
|
|
522
|
+
// line moved exactly when that top is above the fold — whether the element
|
|
523
|
+
// grew, collapsed, or straddles the fold now that it has grown. When the
|
|
524
|
+
// top is at or below the fold the growth happens on screen, at or under a
|
|
525
|
+
// line the reader is looking at, and moving them would be the jump.
|
|
526
|
+
if (el.getBoundingClientRect().top >= box.getBoundingClientRect().top) return;
|
|
527
|
+
box.scrollTop += delta;
|
|
528
|
+
wroteTop = box.scrollTop;
|
|
529
|
+
// Re-measure the remembered anchor, do not patch it. Its scrollTop is
|
|
530
|
+
// stale after that write (hold() would read the difference as "the reader
|
|
531
|
+
// scrolled" and throw the anchor away), and so is its offset whenever the
|
|
532
|
+
// element that just resized lives INSIDE the anchored row: there the row's
|
|
533
|
+
// own top never moved, so scrolling by delta changed the row's offset by
|
|
534
|
+
// -delta and the next hold() would faithfully undo this correction.
|
|
535
|
+
if (held) held = capture();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The tab has come forward. Put the reader back on the line they left, whatever
|
|
540
|
+
* scrollTop says now.
|
|
541
|
+
*
|
|
542
|
+
* hold() cannot do this job. Its safety rule is "the anchor is valid only while
|
|
543
|
+
* box.scrollTop still equals the value it was captured at", which is what stops
|
|
544
|
+
* it dragging a reader back after they scroll themselves — and across a hidden
|
|
545
|
+
* stretch that rule points the wrong way. Plenty of things write scrollTop while
|
|
546
|
+
* the tab is hidden without going through this module at all (a settling request
|
|
547
|
+
* scrolling to the bottom, the browser clamping after a refresh shortened the
|
|
548
|
+
* list), so on return the value has moved and hold() concludes the reader moved
|
|
549
|
+
* it and gives up — leaving them wherever the last invisible write landed. That
|
|
550
|
+
* IS the "somehow placed in the middle".
|
|
551
|
+
*
|
|
552
|
+
* Nothing that happened while nobody was looking was the reader, so here the
|
|
553
|
+
* remembered place simply wins.
|
|
554
|
+
*/
|
|
555
|
+
/**
|
|
556
|
+
* The reader is going away. Park the place they are leaving, in a slot that the
|
|
557
|
+
* ordinary compensation cannot overwrite.
|
|
558
|
+
*
|
|
559
|
+
* Not the same thing as freezing. A tab that goes HIDDEN stops being painted, so
|
|
560
|
+
* there is nothing to compensate and writing scrollTop is pointless. But a window
|
|
561
|
+
* that merely loses focus — the user switched to another application — is usually
|
|
562
|
+
* still `document.visibilityState === 'visible'`: nothing fires visibilitychange,
|
|
563
|
+
* the page keeps rendering, and the chat keeps mutating underneath a reader who
|
|
564
|
+
* is not there. Compensation must keep running for that case (they may still be
|
|
565
|
+
* able to SEE the window, on a second monitor or beside the other app), which is
|
|
566
|
+
* exactly why `held` cannot be trusted on return: every background correction
|
|
567
|
+
* re-stamps it. So the leaving place is parked separately.
|
|
568
|
+
*/
|
|
569
|
+
function park(): void {
|
|
570
|
+
parked = capture();
|
|
571
|
+
parkedStuck = !!options.isStuck();
|
|
572
|
+
returning = true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Pin to the bottom, instantly, recording the write.
|
|
577
|
+
*
|
|
578
|
+
* The ONE way anything is allowed to pin. Instant because a smooth glide fires a
|
|
579
|
+
* scroll event per frame at positions that are not the bottom, and the hosts
|
|
580
|
+
* clear stickToBottom on each of them — so any merge landing inside the ~130ms
|
|
581
|
+
* animation strands the reader off the bottom permanently, aiming at a target
|
|
582
|
+
* that was already stale when the glide started. Recorded because an unrecorded
|
|
583
|
+
* write looks like the reader and would retire an armed return early.
|
|
584
|
+
*/
|
|
585
|
+
function pinBottom(): void {
|
|
586
|
+
var box = options.getBox();
|
|
587
|
+
if (!box) return;
|
|
588
|
+
box.scrollTop = box.scrollHeight;
|
|
589
|
+
wroteTop = box.scrollTop;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The reader is back. Put them on the parked place, whatever scrollTop says now.
|
|
594
|
+
*
|
|
595
|
+
* hold() cannot do this job. Its safety rule is "the anchor is valid only while
|
|
596
|
+
* box.scrollTop still equals the value it was captured at", which is what stops
|
|
597
|
+
* it dragging a reader back after they scroll themselves — and across an absence
|
|
598
|
+
* that rule points the wrong way. Plenty of things write scrollTop while nobody
|
|
599
|
+
* is looking (a settling request scrolling to the bottom, the browser clamping
|
|
600
|
+
* after a refresh shortened the list), so on return the value has moved and
|
|
601
|
+
* hold() concludes the reader moved it and gives up — leaving them wherever the
|
|
602
|
+
* last unwatched write landed. That IS the "somehow placed in the middle".
|
|
603
|
+
*
|
|
604
|
+
* Nothing that happened while they were away was them, so the parked place wins.
|
|
605
|
+
*/
|
|
606
|
+
function settleReturn(): boolean {
|
|
607
|
+
if (frozen()) return false;
|
|
608
|
+
sawFrozen = false;
|
|
609
|
+
// A reader who left pinned has no row to hold; the bottom is the place. Stay
|
|
610
|
+
// armed so the NEXT settle re-pins after the deferred batch merges, which is
|
|
611
|
+
// the bottom they actually mean.
|
|
612
|
+
if (parkedStuck) { pinBottom(); return true; }
|
|
613
|
+
var target = parked || held;
|
|
614
|
+
restoreExact = true; restorePinned = false;
|
|
615
|
+
restore(target, true);
|
|
616
|
+
// Keep the parked place when the correction could not land. A phase-1 shrink
|
|
617
|
+
// leaves the list at its shortest, so the browser truncates the write and the
|
|
618
|
+
// reader ends up short — and phase 2 re-grows the list, making that same
|
|
619
|
+
// place reachable again. Believing the clamped write was a success is what
|
|
620
|
+
// made the error permanent.
|
|
621
|
+
parked = (!restorePinned || !restoreExact) ? target : null;
|
|
622
|
+
returning = !!parked;
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function isReturning(): boolean { return returning; }
|
|
627
|
+
|
|
628
|
+
/** Deprecated alias. */
|
|
629
|
+
function thaw(): void { settleReturn(); }
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* An element resized while nobody was looking. Fold it into the remembered
|
|
633
|
+
* places instead of dropping it.
|
|
634
|
+
*
|
|
635
|
+
* The live box cannot be measured against here: while frozen every compensator
|
|
636
|
+
* no-ops, so a prepend or a clamp may have moved the box out from under the
|
|
637
|
+
* offsets these anchors were taken at. Judge it in the ANCHOR's own frame
|
|
638
|
+
* instead — is this element inside the anchored row, and above the reader's
|
|
639
|
+
* line — and adjust the anchor rather than the scroll.
|
|
640
|
+
*
|
|
641
|
+
* `held` and `parked` can be different rows, so each is folded separately. The
|
|
642
|
+
* standbys are deliberately not touched: a row below the growth is re-measured
|
|
643
|
+
* by restore() anyway.
|
|
644
|
+
*/
|
|
645
|
+
function foldFrozenGrowth(box: AnchorBoxEl, el: AnchorGrowableEl, delta: number): void {
|
|
646
|
+
var elTop = el.getBoundingClientRect().top;
|
|
647
|
+
foldInto(box, held, elTop, delta);
|
|
648
|
+
foldInto(box, parked, elTop, delta);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function foldInto(box: AnchorBoxEl, a: RowAnchor | null, elTop: number, delta: number): void {
|
|
652
|
+
if (!a || a.top >= 0) return; // the row starts at or below the fold
|
|
653
|
+
var rowEl = findRow(box, a);
|
|
654
|
+
if (!rowEl) return;
|
|
655
|
+
var within = elTop - rowEl.getBoundingClientRect().top;
|
|
656
|
+
if (within < 0 || within >= rowEl.offsetHeight) return; // outside, or stale
|
|
657
|
+
if (within >= -a.top) return; // at or below the reader's own line
|
|
658
|
+
a.top -= delta;
|
|
659
|
+
a.scrollHeight += delta;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function forget(): void {
|
|
663
|
+
held = null;
|
|
664
|
+
parked = null;
|
|
665
|
+
parkedStuck = false;
|
|
666
|
+
returning = false;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
capture: capture,
|
|
671
|
+
restore: restore,
|
|
672
|
+
preserve: preserve,
|
|
673
|
+
remember: remember,
|
|
674
|
+
hold: hold,
|
|
675
|
+
park: park,
|
|
676
|
+
settleReturn: settleReturn,
|
|
677
|
+
isReturning: isReturning,
|
|
678
|
+
pinBottom: pinBottom,
|
|
679
|
+
isFrozen: frozen,
|
|
680
|
+
thaw: thaw,
|
|
681
|
+
absorb: absorb,
|
|
682
|
+
forget: forget,
|
|
683
|
+
};
|
|
684
|
+
}
|