bunnyquery 1.8.14 → 1.8.16
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.js +251 -26
- package/dist/engine.cjs +189 -12
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +73 -0
- package/dist/engine.d.ts +73 -0
- package/dist/engine.mjs +189 -12
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/host.ts +14 -0
- package/src/engine/scroll_anchor.ts +366 -11
- package/src/engine/session.ts +11 -1
package/package.json
CHANGED
package/src/engine/host.ts
CHANGED
|
@@ -194,6 +194,20 @@ export interface ChatHost {
|
|
|
194
194
|
* that cannot scroll has no way to reach page 2 (see viewport_fill). Only
|
|
195
195
|
* the view can measure that, which is why the engine merely announces it. */
|
|
196
196
|
onHistoryLoaded?(fetchMore: boolean, token: number): void;
|
|
197
|
+
/**
|
|
198
|
+
* A list refresh just changed heights: put the reader back where they were.
|
|
199
|
+
*
|
|
200
|
+
* Called at BOTH moments a first-page refresh moves things — the surface page
|
|
201
|
+
* landing, and the deferred background-indexing batch merging on top of it a
|
|
202
|
+
* round trip later — because leaving a wrong position on screen between the two
|
|
203
|
+
* is what reads as "the scroll jumped, then travelled somewhere else".
|
|
204
|
+
*
|
|
205
|
+
* The view owns the decision (it is the only side that can measure): pinned to
|
|
206
|
+
* the bottom means the bottom AFTER the batch merged, anywhere else means the
|
|
207
|
+
* exact line the reader was on. Falls back to scrollToBottomIfSticky when a host
|
|
208
|
+
* does not implement it, which is the old behaviour.
|
|
209
|
+
*/
|
|
210
|
+
settleScroll?(): void;
|
|
197
211
|
|
|
198
212
|
// --- skapi surface beyond configureChatEngine() ---
|
|
199
213
|
cancelRequest(opts: {
|
|
@@ -83,6 +83,18 @@ export interface RowAnchor {
|
|
|
83
83
|
* key. Never trusted without re-checking that it is still in the box.
|
|
84
84
|
*/
|
|
85
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 }>;
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
export interface ScrollAnchorOptions {
|
|
@@ -93,6 +105,21 @@ export interface ScrollAnchorOptions {
|
|
|
93
105
|
* scrollToBottom* paths own the position, so every method here no-ops.
|
|
94
106
|
*/
|
|
95
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;
|
|
96
123
|
/**
|
|
97
124
|
* Fall back to the raw scrollTop when the anchored row cannot be found again.
|
|
98
125
|
*
|
|
@@ -116,6 +143,33 @@ export interface ScrollAnchor {
|
|
|
116
143
|
remember: () => void;
|
|
117
144
|
/** Put the remembered place back, if it is still the reader's own. */
|
|
118
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
|
+
/**
|
|
157
|
+
* The reader is driving. Retire any armed return, at once.
|
|
158
|
+
*
|
|
159
|
+
* Call from the raw gesture handlers (wheel, touchstart, touchmove, keydown),
|
|
160
|
+
* which fire synchronously on a real user action and never for a programmatic
|
|
161
|
+
* scroll. Waiting for the scroll EVENT is not good enough: a background refresh
|
|
162
|
+
* can settle in between, and a settle with a return still armed puts the reader
|
|
163
|
+
* back where they were before they left, which lands as "I touched the scroll
|
|
164
|
+
* and it threw me somewhere else".
|
|
165
|
+
*/
|
|
166
|
+
release: () => void;
|
|
167
|
+
/** Pin to the bottom, instantly, recording the write. The ONLY way to pin. */
|
|
168
|
+
pinBottom: () => void;
|
|
169
|
+
/** The box is not being painted (hidden tab). Shared so hosts agree. */
|
|
170
|
+
isFrozen: () => boolean;
|
|
171
|
+
/** Deprecated alias of settleReturn, kept so a stale dist does not break. */
|
|
172
|
+
thaw: () => void;
|
|
119
173
|
/** Absorb one element's own resize. See below. */
|
|
120
174
|
absorb: (el: AnchorGrowableEl | null | undefined) => void;
|
|
121
175
|
/** Drop the remembered place (chat switch, unmount). */
|
|
@@ -139,6 +193,14 @@ var ROW_KEY_ATTR = 'data-row-key';
|
|
|
139
193
|
* it did not move.
|
|
140
194
|
*/
|
|
141
195
|
var ROW_POS_ATTR = 'data-row-pos';
|
|
196
|
+
/** How many standby rows capture() records. Two is enough to survive a refresh
|
|
197
|
+
* that drops the reader's own row without paying for a full-list guess. */
|
|
198
|
+
var MAX_ALTS = 2;
|
|
199
|
+
/** How far past the anchor collectAlts will look for them. */
|
|
200
|
+
var ALT_SCAN_LIMIT = 64;
|
|
201
|
+
/** Settles an armed return may act on: the two halves of one head refresh, plus
|
|
202
|
+
* one spare for a refresh that was already in flight when the reader left. */
|
|
203
|
+
var MAX_RETURN_SETTLES = 3;
|
|
142
204
|
/** data-row-pos is present but empty: the row cannot say where it is anchored. */
|
|
143
205
|
var UNKNOWN_ROW_POS = '\u0000?';
|
|
144
206
|
|
|
@@ -156,6 +218,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
156
218
|
var boxTop = box.getBoundingClientRect().top;
|
|
157
219
|
var kids = box.children;
|
|
158
220
|
var fallback: RowAnchor | null = null;
|
|
221
|
+
var fallbackAt = -1;
|
|
159
222
|
for (var i = 0; i < kids.length; i++) {
|
|
160
223
|
var el = kids[i];
|
|
161
224
|
if (!el || typeof el.getAttribute !== 'function') continue;
|
|
@@ -184,15 +247,53 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
184
247
|
key: key, top: top, pos: pos,
|
|
185
248
|
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
|
|
186
249
|
};
|
|
187
|
-
if (rawPos === null)
|
|
188
|
-
|
|
250
|
+
if (rawPos === null) {
|
|
251
|
+
// An ordinary row: use it, and take a couple of standbys from the rows
|
|
252
|
+
// after it in the same walk.
|
|
253
|
+
cand.alts = collectAlts(box, boxTop, i + 1);
|
|
254
|
+
return cand;
|
|
255
|
+
}
|
|
256
|
+
if (!fallback) { fallback = cand; fallbackAt = i; } // group row: last resort
|
|
257
|
+
}
|
|
258
|
+
// The weak anchor needs standbys MORE than the strong one does, not less: a
|
|
259
|
+
// screenful of collapsed indexing rows is exactly what a background sweep
|
|
260
|
+
// re-keys and re-anchors, and without them state (c) fell straight into
|
|
261
|
+
// lost()'s whole-list guess. Ordinary rows only — more group rows from the
|
|
262
|
+
// same block are the very rows the same batch rewrites.
|
|
263
|
+
if (fallback) {
|
|
264
|
+
fallback.alts = collectAlts(box, boxTop, fallbackAt + 1, true);
|
|
265
|
+
return fallback;
|
|
189
266
|
}
|
|
190
|
-
return
|
|
267
|
+
return {
|
|
191
268
|
key: null, top: 0, pos: null,
|
|
192
269
|
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: null,
|
|
193
270
|
};
|
|
194
271
|
}
|
|
195
272
|
|
|
273
|
+
/** Up to MAX_ALTS anchorable rows starting at `from`, for restore's fallback. */
|
|
274
|
+
function collectAlts(box: AnchorBoxEl, boxTop: number, from: number, ordinaryOnly?: boolean) {
|
|
275
|
+
var out: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }> = [];
|
|
276
|
+
var kids = box.children;
|
|
277
|
+
// Bounded: a chat can carry hundreds of collapsed rows below the fold, and
|
|
278
|
+
// this must not become a full-list walk for two standbys.
|
|
279
|
+
var stop = Math.min(kids.length, from + ALT_SCAN_LIMIT);
|
|
280
|
+
for (var i = from; i < stop && out.length < MAX_ALTS; i++) {
|
|
281
|
+
var el = kids[i];
|
|
282
|
+
if (!el || typeof el.getAttribute !== 'function') continue;
|
|
283
|
+
var key = el.getAttribute(ROW_KEY_ATTR);
|
|
284
|
+
if (!key) continue;
|
|
285
|
+
var rawPos = el.getAttribute(ROW_POS_ATTR);
|
|
286
|
+
if (ordinaryOnly && rawPos !== null) continue;
|
|
287
|
+
out.push({
|
|
288
|
+
key: key,
|
|
289
|
+
top: el.getBoundingClientRect().top - boxTop,
|
|
290
|
+
pos: rawPos === null ? null : (rawPos || UNKNOWN_ROW_POS),
|
|
291
|
+
el: el,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return out.length ? out : undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
196
297
|
function findRow(box: AnchorBoxEl, anchor: RowAnchor): AnchorRowEl | null {
|
|
197
298
|
// The same node, still in the box: one rect read instead of a scan. Vue
|
|
198
299
|
// patches keyed rows in place, so this is the common path there, and it is
|
|
@@ -209,7 +310,49 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
209
310
|
return null;
|
|
210
311
|
}
|
|
211
312
|
|
|
212
|
-
|
|
313
|
+
// A frozen stretch happened and has not been settled yet. hold() has to know,
|
|
314
|
+
// because its own safety rule cannot survive one: on the first call after the
|
|
315
|
+
// tab comes forward it must put the reader back rather than conclude they moved
|
|
316
|
+
// (and, worse, re-measure — which is what DESTROYS the only record of where they
|
|
317
|
+
// were, and why this cannot be left to whoever calls thaw() first).
|
|
318
|
+
var sawFrozen = false;
|
|
319
|
+
// Where the reader was when they went away. Held apart from `held` because the
|
|
320
|
+
// ordinary compensation keeps re-stamping that one while they are gone.
|
|
321
|
+
var parked: RowAnchor | null = null;
|
|
322
|
+
// They left pinned to the bottom. capture() records nothing for such a reader
|
|
323
|
+
// (there is no row to hold, the bottom IS the place), so this is the only note
|
|
324
|
+
// of it — and without it a stickiness lost during the absence had no fallback.
|
|
325
|
+
var parkedStuck = false;
|
|
326
|
+
// A return is armed: it has not landed yet. It survives across BOTH halves of a
|
|
327
|
+
// head refresh, which is what puts a pinned reader on the bottom that exists
|
|
328
|
+
// after the deferred batch merges rather than the surface page's bottom.
|
|
329
|
+
var returning = false;
|
|
330
|
+
// How many more settles an armed return may act on. A return exists to survive
|
|
331
|
+
// the two halves of one head refresh; beyond that it is a stale instruction
|
|
332
|
+
// lying in wait, and the reader has moved on. Bounded so a blur that never got
|
|
333
|
+
// a matching focus (clicking into devtools, another window, an iframe — all of
|
|
334
|
+
// which fire window blur) cannot arm something that fires minutes later.
|
|
335
|
+
var returnBudget = 0;
|
|
336
|
+
// The last scrollTop THIS module wrote. A scroll event reporting anything else
|
|
337
|
+
// is the reader, and the reader always wins — that is the whole retirement rule
|
|
338
|
+
// for an armed return, and it is why every write below records it.
|
|
339
|
+
var wroteTop = -1;
|
|
340
|
+
// Did the last restore's write actually land, and was it a real row pin rather
|
|
341
|
+
// than lost()'s guess. A shrink below the reader silently truncates a
|
|
342
|
+
// correction, and a return that believes a clamped write succeeded is a reader
|
|
343
|
+
// left a few hundred pixels off their line, permanently.
|
|
344
|
+
var restoreExact = true;
|
|
345
|
+
var restorePinned = false;
|
|
346
|
+
function frozen(): boolean {
|
|
347
|
+
var f = !!options.isFrozen && options.isFrozen();
|
|
348
|
+
if (f) sawFrozen = true;
|
|
349
|
+
return f;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function restore(anchor: RowAnchor | null, unbounded?: boolean): void {
|
|
353
|
+
// Frozen: leave `held` exactly as it is. It is the reader's last real
|
|
354
|
+
// position, and it is what hold() puts back when the tab comes forward.
|
|
355
|
+
if (frozen()) return;
|
|
213
356
|
var box = options.getBox();
|
|
214
357
|
if (!box || !anchor || options.isStuck()) return;
|
|
215
358
|
var el = findRow(box, anchor);
|
|
@@ -221,11 +364,13 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
221
364
|
// learned its anchorId (or lost it) has not moved; it has just started (or
|
|
222
365
|
// stopped) being able to answer.
|
|
223
366
|
var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
|
|
367
|
+
// A relocation disqualifies the ROW, not the whole capture: fall through
|
|
368
|
+
// to the standbys, which is a real measurement, rather than to lost()'s
|
|
369
|
+
// whole-list guess.
|
|
224
370
|
if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS &&
|
|
225
|
-
livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
371
|
+
livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) el = null;
|
|
372
|
+
}
|
|
373
|
+
if (el) {
|
|
229
374
|
var boxTop = box.getBoundingClientRect().top;
|
|
230
375
|
var delta = (el.getBoundingClientRect().top - boxTop) - anchor.top;
|
|
231
376
|
// A row can also be MOVED rather than resized: a background refetch
|
|
@@ -234,11 +379,19 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
234
379
|
// reader across the conversation. A real prepend or in-place growth
|
|
235
380
|
// can only ever need a correction on the order of what the list gained,
|
|
236
381
|
// so a delta a whole screen beyond that is a relocation, not a resize.
|
|
382
|
+
// `unbounded` is the thaw: across a hidden stretch the box may have been
|
|
383
|
+
// scrolled anywhere at all (a settling request, a clamp), so a correction
|
|
384
|
+
// the size of the whole list is not evidence that the ROW moved — it is
|
|
385
|
+
// just how far the reader has to be carried back.
|
|
237
386
|
var slack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
|
|
238
|
-
if (delta > slack || delta < -slack) { lost(box, anchor); return; }
|
|
387
|
+
if (!unbounded && (delta > slack || delta < -slack)) { lost(box, anchor); return; }
|
|
239
388
|
// Sub-pixel noise is not a jump, and writing scrollTop for it costs a
|
|
240
389
|
// scroll event (and a re-layout) on every settle.
|
|
390
|
+
var want = box.scrollTop + delta;
|
|
241
391
|
if (delta >= 1 || delta <= -1) box.scrollTop += delta;
|
|
392
|
+
wroteTop = box.scrollTop;
|
|
393
|
+
restorePinned = true;
|
|
394
|
+
restoreExact = box.scrollTop >= want - 1 && box.scrollTop <= want + 1;
|
|
242
395
|
// This position is now the reader's place, and hold() has to know it:
|
|
243
396
|
// a bracketed restore MOVES scrollTop, which is exactly what hold()
|
|
244
397
|
// reads as "someone scrolled, my anchor is stale". Without this, every
|
|
@@ -248,10 +401,39 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
248
401
|
held = {
|
|
249
402
|
key: anchor.key, top: anchor.top, pos: anchor.pos,
|
|
250
403
|
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
|
|
404
|
+
// Carried, not dropped: one successful restore used to disarm the
|
|
405
|
+
// standbys for every later hold.
|
|
406
|
+
alts: anchor.alts,
|
|
251
407
|
};
|
|
252
408
|
return;
|
|
253
409
|
}
|
|
254
410
|
// The anchor row is gone: its group collapsed, or the history was replaced.
|
|
411
|
+
// A standby row that IS still there beats lost()'s guess outright.
|
|
412
|
+
var alts = anchor.alts;
|
|
413
|
+
for (var ai = 0; alts && ai < alts.length; ai++) {
|
|
414
|
+
var alt = alts[ai];
|
|
415
|
+
var ael = findRow(box, { key: alt.key, top: alt.top, pos: alt.pos, scrollTop: anchor.scrollTop, scrollHeight: anchor.scrollHeight, el: alt.el });
|
|
416
|
+
if (!ael) continue;
|
|
417
|
+
if (alt.pos !== null && alt.pos !== UNKNOWN_ROW_POS) {
|
|
418
|
+
var altLive = ael.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
|
|
419
|
+
if (altLive !== UNKNOWN_ROW_POS && altLive !== alt.pos) continue;
|
|
420
|
+
}
|
|
421
|
+
var aboxTop = box.getBoundingClientRect().top;
|
|
422
|
+
var adelta = (ael.getBoundingClientRect().top - aboxTop) - alt.top;
|
|
423
|
+
var aslack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
|
|
424
|
+
if (!unbounded && (adelta > aslack || adelta < -aslack)) continue;
|
|
425
|
+
var awant = box.scrollTop + adelta;
|
|
426
|
+
if (adelta >= 1 || adelta <= -1) box.scrollTop += adelta;
|
|
427
|
+
wroteTop = box.scrollTop;
|
|
428
|
+
restorePinned = true;
|
|
429
|
+
restoreExact = box.scrollTop >= awant - 1 && box.scrollTop <= awant + 1;
|
|
430
|
+
held = {
|
|
431
|
+
key: alt.key, top: alt.top, pos: alt.pos,
|
|
432
|
+
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: ael,
|
|
433
|
+
alts: alts.slice(ai + 1),
|
|
434
|
+
};
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
255
437
|
lost(box, anchor);
|
|
256
438
|
}
|
|
257
439
|
|
|
@@ -272,8 +454,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
272
454
|
function lost(box: AnchorBoxEl, anchor: RowAnchor): void {
|
|
273
455
|
held = null;
|
|
274
456
|
var grew = box.scrollHeight - anchor.scrollHeight;
|
|
275
|
-
if (grew > 0) { box.scrollTop = anchor.scrollTop + grew; return; }
|
|
276
|
-
if (options.rawFallback) box.scrollTop = anchor.scrollTop;
|
|
457
|
+
if (grew > 0) { box.scrollTop = anchor.scrollTop + grew; wroteTop = box.scrollTop; return; }
|
|
458
|
+
if (options.rawFallback) { box.scrollTop = anchor.scrollTop; wroteTop = box.scrollTop; }
|
|
277
459
|
}
|
|
278
460
|
|
|
279
461
|
function preserve<T>(mutate: () => T): T {
|
|
@@ -284,10 +466,25 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
284
466
|
}
|
|
285
467
|
|
|
286
468
|
function remember(): void {
|
|
469
|
+
// THE retirement rule for an armed return, and the only one. This runs from
|
|
470
|
+
// the host's scroll handler, which sees every scroll event; a position this
|
|
471
|
+
// module did not write is the reader, and the reader always wins. Checked
|
|
472
|
+
// before the frozen bail because it is the one thing allowed to end a return.
|
|
473
|
+
var b0 = options.getBox();
|
|
474
|
+
if (returning && b0 && b0.scrollTop !== wroteTop) release();
|
|
475
|
+
// A scroll event while the tab is hidden is not the reader moving.
|
|
476
|
+
if (frozen()) return;
|
|
287
477
|
held = capture();
|
|
288
478
|
}
|
|
289
479
|
|
|
290
480
|
function hold(): void {
|
|
481
|
+
if (frozen()) return;
|
|
482
|
+
// Coming out of a FROZEN stretch, and only that. An armed return is settled
|
|
483
|
+
// by the host, from its focus/visible handler and the refresh settles that
|
|
484
|
+
// follow — never from here. hold() runs on every render, including the many
|
|
485
|
+
// that happen WHILE the reader is still away, and letting those settle the
|
|
486
|
+
// return spent it before they were back to see it.
|
|
487
|
+
if (sawFrozen) { settleReturn(); return; }
|
|
291
488
|
var box = options.getBox();
|
|
292
489
|
if (!box || options.isStuck()) { held = null; return; }
|
|
293
490
|
if (!held) { held = capture(); return; }
|
|
@@ -329,11 +526,16 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
329
526
|
if (!box) return;
|
|
330
527
|
var h = el.offsetHeight;
|
|
331
528
|
var prev = seen ? seen.get(el) : undefined;
|
|
529
|
+
// The baseline is recorded even while frozen (and even while pinned to the
|
|
530
|
+
// bottom): what must not happen is the WRITE. An image that decodes in a
|
|
531
|
+
// hidden tab has still resized, and forgetting that would make the next
|
|
532
|
+
// visible change pay for its whole height.
|
|
332
533
|
if (seen) seen.set(el, h);
|
|
333
534
|
if (options.isStuck()) return;
|
|
334
535
|
if (prev === undefined) prev = 0;
|
|
335
536
|
var delta = h - prev;
|
|
336
537
|
if (delta === 0) return;
|
|
538
|
+
if (frozen()) { foldFrozenGrowth(box, el, delta); return; }
|
|
337
539
|
// The element's own TOP, which a resize never moves: everything BELOW it
|
|
338
540
|
// slides by delta, everything above stays. So the reader's first visible
|
|
339
541
|
// line moved exactly when that top is above the fold — whether the element
|
|
@@ -342,6 +544,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
342
544
|
// line the reader is looking at, and moving them would be the jump.
|
|
343
545
|
if (el.getBoundingClientRect().top >= box.getBoundingClientRect().top) return;
|
|
344
546
|
box.scrollTop += delta;
|
|
547
|
+
wroteTop = box.scrollTop;
|
|
345
548
|
// Re-measure the remembered anchor, do not patch it. Its scrollTop is
|
|
346
549
|
// stale after that write (hold() would read the difference as "the reader
|
|
347
550
|
// scrolled" and throw the anchor away), and so is its offset whenever the
|
|
@@ -351,8 +554,153 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
351
554
|
if (held) held = capture();
|
|
352
555
|
}
|
|
353
556
|
|
|
557
|
+
/**
|
|
558
|
+
* The tab has come forward. Put the reader back on the line they left, whatever
|
|
559
|
+
* scrollTop says now.
|
|
560
|
+
*
|
|
561
|
+
* hold() cannot do this job. Its safety rule is "the anchor is valid only while
|
|
562
|
+
* box.scrollTop still equals the value it was captured at", which is what stops
|
|
563
|
+
* it dragging a reader back after they scroll themselves — and across a hidden
|
|
564
|
+
* stretch that rule points the wrong way. Plenty of things write scrollTop while
|
|
565
|
+
* the tab is hidden without going through this module at all (a settling request
|
|
566
|
+
* scrolling to the bottom, the browser clamping after a refresh shortened the
|
|
567
|
+
* list), so on return the value has moved and hold() concludes the reader moved
|
|
568
|
+
* it and gives up — leaving them wherever the last invisible write landed. That
|
|
569
|
+
* IS the "somehow placed in the middle".
|
|
570
|
+
*
|
|
571
|
+
* Nothing that happened while nobody was looking was the reader, so here the
|
|
572
|
+
* remembered place simply wins.
|
|
573
|
+
*/
|
|
574
|
+
/**
|
|
575
|
+
* The reader is going away. Park the place they are leaving, in a slot that the
|
|
576
|
+
* ordinary compensation cannot overwrite.
|
|
577
|
+
*
|
|
578
|
+
* Not the same thing as freezing. A tab that goes HIDDEN stops being painted, so
|
|
579
|
+
* there is nothing to compensate and writing scrollTop is pointless. But a window
|
|
580
|
+
* that merely loses focus — the user switched to another application — is usually
|
|
581
|
+
* still `document.visibilityState === 'visible'`: nothing fires visibilitychange,
|
|
582
|
+
* the page keeps rendering, and the chat keeps mutating underneath a reader who
|
|
583
|
+
* is not there. Compensation must keep running for that case (they may still be
|
|
584
|
+
* able to SEE the window, on a second monitor or beside the other app), which is
|
|
585
|
+
* exactly why `held` cannot be trusted on return: every background correction
|
|
586
|
+
* re-stamps it. So the leaving place is parked separately.
|
|
587
|
+
*/
|
|
588
|
+
function park(): void {
|
|
589
|
+
parked = capture();
|
|
590
|
+
parkedStuck = !!options.isStuck();
|
|
591
|
+
returning = true;
|
|
592
|
+
returnBudget = MAX_RETURN_SETTLES;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** The reader is driving: an armed return is no longer anyone's business. */
|
|
596
|
+
function release(): void {
|
|
597
|
+
parked = null;
|
|
598
|
+
parkedStuck = false;
|
|
599
|
+
returning = false;
|
|
600
|
+
returnBudget = 0;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Pin to the bottom, instantly, recording the write.
|
|
605
|
+
*
|
|
606
|
+
* The ONE way anything is allowed to pin. Instant because a smooth glide fires a
|
|
607
|
+
* scroll event per frame at positions that are not the bottom, and the hosts
|
|
608
|
+
* clear stickToBottom on each of them — so any merge landing inside the ~130ms
|
|
609
|
+
* animation strands the reader off the bottom permanently, aiming at a target
|
|
610
|
+
* that was already stale when the glide started. Recorded because an unrecorded
|
|
611
|
+
* write looks like the reader and would retire an armed return early.
|
|
612
|
+
*/
|
|
613
|
+
function pinBottom(): void {
|
|
614
|
+
var box = options.getBox();
|
|
615
|
+
if (!box) return;
|
|
616
|
+
box.scrollTop = box.scrollHeight;
|
|
617
|
+
wroteTop = box.scrollTop;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The reader is back. Put them on the parked place, whatever scrollTop says now.
|
|
622
|
+
*
|
|
623
|
+
* hold() cannot do this job. Its safety rule is "the anchor is valid only while
|
|
624
|
+
* box.scrollTop still equals the value it was captured at", which is what stops
|
|
625
|
+
* it dragging a reader back after they scroll themselves — and across an absence
|
|
626
|
+
* that rule points the wrong way. Plenty of things write scrollTop while nobody
|
|
627
|
+
* is looking (a settling request scrolling to the bottom, the browser clamping
|
|
628
|
+
* after a refresh shortened the list), so on return the value has moved and
|
|
629
|
+
* hold() concludes the reader moved it and gives up — leaving them wherever the
|
|
630
|
+
* last unwatched write landed. That IS the "somehow placed in the middle".
|
|
631
|
+
*
|
|
632
|
+
* Nothing that happened while they were away was them, so the parked place wins.
|
|
633
|
+
*/
|
|
634
|
+
function settleReturn(): boolean {
|
|
635
|
+
if (frozen()) return false;
|
|
636
|
+
var wasFrozen = sawFrozen;
|
|
637
|
+
sawFrozen = false;
|
|
638
|
+
// Nothing is armed, so there is nothing to settle. Without this the call was
|
|
639
|
+
// a licence to re-impose `held` UNBOUNDED at any time — which is exactly how
|
|
640
|
+
// a reader who had already taken control got thrown back: they released the
|
|
641
|
+
// return, and the next background settle simply used the live anchor instead.
|
|
642
|
+
if (!returning && !wasFrozen) return false;
|
|
643
|
+
if (returning) {
|
|
644
|
+
if (returnBudget <= 0) { release(); return false; }
|
|
645
|
+
returnBudget--;
|
|
646
|
+
}
|
|
647
|
+
// A reader who left pinned has no row to hold; the bottom is the place. Stay
|
|
648
|
+
// armed so the NEXT settle re-pins after the deferred batch merges, which is
|
|
649
|
+
// the bottom they actually mean.
|
|
650
|
+
if (parkedStuck) { pinBottom(); return true; }
|
|
651
|
+
var target = parked || held;
|
|
652
|
+
restoreExact = true; restorePinned = false;
|
|
653
|
+
restore(target, true);
|
|
654
|
+
// Keep the parked place when the correction could not land. A phase-1 shrink
|
|
655
|
+
// leaves the list at its shortest, so the browser truncates the write and the
|
|
656
|
+
// reader ends up short — and phase 2 re-grows the list, making that same
|
|
657
|
+
// place reachable again. Believing the clamped write was a success is what
|
|
658
|
+
// made the error permanent.
|
|
659
|
+
parked = (!restorePinned || !restoreExact) ? target : null;
|
|
660
|
+
returning = !!parked && returnBudget > 0;
|
|
661
|
+
if (!returning) { parked = null; parkedStuck = false; returnBudget = 0; }
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function isReturning(): boolean { return returning; }
|
|
666
|
+
|
|
667
|
+
/** Deprecated alias. */
|
|
668
|
+
function thaw(): void { settleReturn(); }
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* An element resized while nobody was looking. Fold it into the remembered
|
|
672
|
+
* places instead of dropping it.
|
|
673
|
+
*
|
|
674
|
+
* The live box cannot be measured against here: while frozen every compensator
|
|
675
|
+
* no-ops, so a prepend or a clamp may have moved the box out from under the
|
|
676
|
+
* offsets these anchors were taken at. Judge it in the ANCHOR's own frame
|
|
677
|
+
* instead — is this element inside the anchored row, and above the reader's
|
|
678
|
+
* line — and adjust the anchor rather than the scroll.
|
|
679
|
+
*
|
|
680
|
+
* `held` and `parked` can be different rows, so each is folded separately. The
|
|
681
|
+
* standbys are deliberately not touched: a row below the growth is re-measured
|
|
682
|
+
* by restore() anyway.
|
|
683
|
+
*/
|
|
684
|
+
function foldFrozenGrowth(box: AnchorBoxEl, el: AnchorGrowableEl, delta: number): void {
|
|
685
|
+
var elTop = el.getBoundingClientRect().top;
|
|
686
|
+
foldInto(box, held, elTop, delta);
|
|
687
|
+
foldInto(box, parked, elTop, delta);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function foldInto(box: AnchorBoxEl, a: RowAnchor | null, elTop: number, delta: number): void {
|
|
691
|
+
if (!a || a.top >= 0) return; // the row starts at or below the fold
|
|
692
|
+
var rowEl = findRow(box, a);
|
|
693
|
+
if (!rowEl) return;
|
|
694
|
+
var within = elTop - rowEl.getBoundingClientRect().top;
|
|
695
|
+
if (within < 0 || within >= rowEl.offsetHeight) return; // outside, or stale
|
|
696
|
+
if (within >= -a.top) return; // at or below the reader's own line
|
|
697
|
+
a.top -= delta;
|
|
698
|
+
a.scrollHeight += delta;
|
|
699
|
+
}
|
|
700
|
+
|
|
354
701
|
function forget(): void {
|
|
355
702
|
held = null;
|
|
703
|
+
release();
|
|
356
704
|
}
|
|
357
705
|
|
|
358
706
|
return {
|
|
@@ -361,6 +709,13 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
361
709
|
preserve: preserve,
|
|
362
710
|
remember: remember,
|
|
363
711
|
hold: hold,
|
|
712
|
+
park: park,
|
|
713
|
+
settleReturn: settleReturn,
|
|
714
|
+
isReturning: isReturning,
|
|
715
|
+
release: release,
|
|
716
|
+
pinBottom: pinBottom,
|
|
717
|
+
isFrozen: frozen,
|
|
718
|
+
thaw: thaw,
|
|
364
719
|
absorb: absorb,
|
|
365
720
|
forget: forget,
|
|
366
721
|
};
|
package/src/engine/session.ts
CHANGED
|
@@ -3803,6 +3803,12 @@ export class ChatSession {
|
|
|
3803
3803
|
releaseBgFlag();
|
|
3804
3804
|
self.updateHistoryCache();
|
|
3805
3805
|
self.host.notify();
|
|
3806
|
+
// The other half of the refresh. Everything this batch brought in
|
|
3807
|
+
// sits ABOVE or AMONG the newest turn, so a reader pinned to the
|
|
3808
|
+
// bottom was left stranded above it by exactly the batch's height,
|
|
3809
|
+
// with nothing to put them back (every scroll-anchor method no-ops
|
|
3810
|
+
// while pinned, and this handler ended here).
|
|
3811
|
+
if (self.host.settleScroll) self.host.settleScroll();
|
|
3806
3812
|
}, function () {
|
|
3807
3813
|
// Stubs missing until the next refresh re-asks; never fatal.
|
|
3808
3814
|
releaseBgFlag();
|
|
@@ -3932,7 +3938,11 @@ export class ChatSession {
|
|
|
3932
3938
|
// the one resumePolling fires on visibilitychange. Forcing yanked a
|
|
3933
3939
|
// reader who had scrolled up back to the bottom. On a genuine mount the
|
|
3934
3940
|
// user is still pinned, so this behaves identically there.
|
|
3935
|
-
|
|
3941
|
+
//
|
|
3942
|
+
// settleScroll when the host has one: it makes the same decision, and it
|
|
3943
|
+
// is also called again once the deferred bg batch merges, so a pinned
|
|
3944
|
+
// reader lands on the REAL bottom instead of the surface page's.
|
|
3945
|
+
if (!fetchMore) return self.host.settleScroll ? self.host.settleScroll() : self.host.scrollToBottomIfSticky();
|
|
3936
3946
|
}).catch(function (err: any) {
|
|
3937
3947
|
console.warn('[chat-engine] getChatHistory failed', err);
|
|
3938
3948
|
}).then(function () {
|