bunnyquery 1.8.15 → 1.8.17
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 +66 -139
- package/dist/engine.cjs +49 -114
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +42 -49
- package/dist/engine.d.ts +42 -49
- package/dist/engine.mjs +49 -114
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/scroll_anchor.ts +171 -318
|
@@ -5,32 +5,54 @@
|
|
|
5
5
|
* prepends, a poll resolves, an indexing row splices in or changes label, a link
|
|
6
6
|
* chip goes grey, an image preview finishes decoding, the "Fetching history..."
|
|
7
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
|
|
8
|
+
* that may sit ABOVE the viewport, and the browser answers by keeping scrollTop,
|
|
9
|
+
* which slides the sentence the reader was on out from under them.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* THE ONE RULE: nothing here stores a position to be applied later. Every method
|
|
12
|
+
* acts at the moment it is called, against the box as it is at that moment, and
|
|
13
|
+
* is finished when it returns.
|
|
14
|
+
*
|
|
15
|
+
* That rule is the whole design, and it was learned the hard way. An earlier
|
|
16
|
+
* version of this module also had a "park the place they left, put them back when
|
|
17
|
+
* they return" half — parked/parkedStuck/returning/returnBudget/sawFrozen, a
|
|
18
|
+
* frozen mode, a retry budget. Every one of those was a stored instruction that
|
|
19
|
+
* some later event would carry out, and the reader's experience of a stored
|
|
20
|
+
* instruction is that the chat throws them somewhere for no reason they can see:
|
|
21
|
+
* they came back from another app, tapped the composer, the on-screen keyboard
|
|
22
|
+
* fired a resize, the resize triggered a fetch, the fetch settled, and the settle
|
|
23
|
+
* dutifully executed an instruction recorded before they ever left. Compensating
|
|
24
|
+
* at the moment of the change needs no such instruction, and a return then needs
|
|
25
|
+
* no handling at all, because nothing moved the reader in the first place.
|
|
26
|
+
*
|
|
27
|
+
* Two shapes, both immediate:
|
|
16
28
|
*
|
|
17
29
|
* preserve(fn) / capture() + restore(a)
|
|
18
30
|
* A mutation you can bracket. Measures immediately before and immediately
|
|
19
31
|
* after, so it is exact even when the mutation tears the list down.
|
|
20
32
|
*
|
|
21
33
|
* remember() + hold()
|
|
22
|
-
* A layout change you CANNOT bracket
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
34
|
+
* A layout change you CANNOT bracket: an image decoding, a font arriving, a
|
|
35
|
+
* re-parse from a promise. remember() runs from the view's scroll handler,
|
|
36
|
+
* so the anchor is always the reader's own last position.
|
|
37
|
+
*
|
|
38
|
+
* hold() is not an exception to the rule, and the staleness check is why. A height
|
|
39
|
+
* change above the viewport does NOT change scrollTop; the browser preserves it,
|
|
40
|
+
* which is precisely why the content appears to jump. So the remembered anchor is
|
|
41
|
+
* valid exactly while `box.scrollTop` still equals the value it was captured at,
|
|
42
|
+
* i.e. while nothing at all has moved the box. The instant that stops being true
|
|
43
|
+
* it is re-measured, never replayed. hold() can therefore only ever undo a height
|
|
44
|
+
* change that just happened, and can never carry out an old intention.
|
|
45
|
+
*
|
|
46
|
+
* Two more consequences worth stating, because both were once done the other way:
|
|
26
47
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* is
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
48
|
+
* NO FREEZE. Compensation runs whether or not the tab is visible. Layout and
|
|
49
|
+
* getBoundingClientRect are live in a hidden tab; only painting and rAF stop.
|
|
50
|
+
* Suspending compensation while hidden is what created the need to restore
|
|
51
|
+
* something afterwards.
|
|
52
|
+
*
|
|
53
|
+
* A CLAMP IS ACCEPTED. If the content below the reader shrank, their line is
|
|
54
|
+
* genuinely unreachable and the browser truncates the correction. Retrying that
|
|
55
|
+
* later is how a correction turns into an ambush.
|
|
34
56
|
*
|
|
35
57
|
* DOM-free like the rest of the engine: the element shapes below are structural,
|
|
36
58
|
* so real DOM nodes satisfy them while this file imports nothing from lib.dom.
|
|
@@ -105,21 +127,6 @@ export interface ScrollAnchorOptions {
|
|
|
105
127
|
* scrollToBottom* paths own the position, so every method here no-ops.
|
|
106
128
|
*/
|
|
107
129
|
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
130
|
/**
|
|
124
131
|
* Fall back to the raw scrollTop when the anchored row cannot be found again.
|
|
125
132
|
*
|
|
@@ -143,22 +150,8 @@ export interface ScrollAnchor {
|
|
|
143
150
|
remember: () => void;
|
|
144
151
|
/** Put the remembered place back, if it is still the reader's own. */
|
|
145
152
|
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
153
|
/** Pin to the bottom, instantly, recording the write. The ONLY way to pin. */
|
|
157
154
|
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
155
|
/** Absorb one element's own resize. See below. */
|
|
163
156
|
absorb: (el: AnchorGrowableEl | null | undefined) => void;
|
|
164
157
|
/** Drop the remembered place (chat switch, unmount). */
|
|
@@ -205,6 +198,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
205
198
|
var kids = box.children;
|
|
206
199
|
var fallback: RowAnchor | null = null;
|
|
207
200
|
var fallbackAt = -1;
|
|
201
|
+
// The last few rows passed on the way down, kept as standbys of last resort.
|
|
202
|
+
var behind: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }> = [];
|
|
208
203
|
for (var i = 0; i < kids.length; i++) {
|
|
209
204
|
var el = kids[i];
|
|
210
205
|
if (!el || typeof el.getAttribute !== 'function') continue;
|
|
@@ -213,7 +208,18 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
213
208
|
var top = el.getBoundingClientRect().top - boxTop;
|
|
214
209
|
// Rows still (partly) on screen. `top` is negative when a row starts
|
|
215
210
|
// above the fold, which is exactly the offset to preserve.
|
|
216
|
-
if (top + el.offsetHeight <= 0)
|
|
211
|
+
if (top + el.offsetHeight <= 0) {
|
|
212
|
+
// Entirely above: not the reader's place, but a free standby. The rect
|
|
213
|
+
// is already in hand, and a row ABOVE the anchor survives the batches
|
|
214
|
+
// that rewrite the ones below it. Anything beats lost()'s guess.
|
|
215
|
+
var bpos = el.getAttribute(ROW_POS_ATTR);
|
|
216
|
+
behind.push({
|
|
217
|
+
key: key, top: top,
|
|
218
|
+
pos: bpos === null ? null : (bpos || UNKNOWN_ROW_POS), el: el,
|
|
219
|
+
});
|
|
220
|
+
if (behind.length > MAX_ALTS) behind.shift();
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
217
223
|
// And STOP at the bottom of the viewport. Without this the preference
|
|
218
224
|
// for an ordinary row walks straight past a screenful of collapsed
|
|
219
225
|
// indexing rows and anchors on a message two screens down — which is
|
|
@@ -234,20 +240,29 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
234
240
|
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
|
|
235
241
|
};
|
|
236
242
|
if (rawPos === null) {
|
|
237
|
-
// An ordinary row: use it, and take
|
|
238
|
-
//
|
|
239
|
-
|
|
243
|
+
// An ordinary row: use it, and take standbys from the rows after it in
|
|
244
|
+
// the same walk, then the ones already passed. Forward first — they are
|
|
245
|
+
// nearer the reader — but a batch that rewrites everything below the
|
|
246
|
+
// fold leaves the rows above it alone, so backward is the better last
|
|
247
|
+
// measurement.
|
|
248
|
+
cand.alts = withBehind(collectAlts(box, boxTop, i + 1), behind);
|
|
240
249
|
return cand;
|
|
241
250
|
}
|
|
242
251
|
if (!fallback) { fallback = cand; fallbackAt = i; } // group row: last resort
|
|
243
252
|
}
|
|
244
253
|
// The weak anchor needs standbys MORE than the strong one does, not less: a
|
|
245
254
|
// screenful of collapsed indexing rows is exactly what a background sweep
|
|
246
|
-
// re-keys and re-anchors, and without them
|
|
247
|
-
//
|
|
248
|
-
//
|
|
255
|
+
// re-keys and re-anchors, and without them it fell straight into lost()'s
|
|
256
|
+
// whole-list guess. Ordinary rows are PREFERRED, because they are the ones a
|
|
257
|
+
// background batch does not rewrite — but no standby at all means the guess,
|
|
258
|
+
// and a neighbouring group row is a real measurement that restore() will
|
|
259
|
+
// reject anyway if it relocated. So group rows are the last thing before the
|
|
260
|
+
// guess, not something to hold out against.
|
|
249
261
|
if (fallback) {
|
|
250
|
-
fallback.alts =
|
|
262
|
+
fallback.alts = withBehind(
|
|
263
|
+
collectAlts(box, boxTop, fallbackAt + 1, true) || collectAlts(box, boxTop, fallbackAt + 1),
|
|
264
|
+
behind,
|
|
265
|
+
);
|
|
251
266
|
return fallback;
|
|
252
267
|
}
|
|
253
268
|
return {
|
|
@@ -280,6 +295,15 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
280
295
|
return out.length ? out : undefined;
|
|
281
296
|
}
|
|
282
297
|
|
|
298
|
+
/** Forward standbys first, then the rows already passed, newest-first. */
|
|
299
|
+
function withBehind(
|
|
300
|
+
forward: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }> | undefined,
|
|
301
|
+
behind: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }>,
|
|
302
|
+
) {
|
|
303
|
+
var out = (forward || []).concat(behind.slice().reverse());
|
|
304
|
+
return out.length ? out : undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
283
307
|
function findRow(box: AnchorBoxEl, anchor: RowAnchor): AnchorRowEl | null {
|
|
284
308
|
// The same node, still in the box: one rect read instead of a scan. Vue
|
|
285
309
|
// patches keyed rows in place, so this is the common path there, and it is
|
|
@@ -296,103 +320,69 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
296
320
|
return null;
|
|
297
321
|
}
|
|
298
322
|
|
|
299
|
-
|
|
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;
|
|
323
|
+
function restore(anchor: RowAnchor | null): void {
|
|
336
324
|
var box = options.getBox();
|
|
337
325
|
if (!box || !anchor || options.isStuck()) return;
|
|
338
326
|
var el = findRow(box, anchor);
|
|
339
327
|
if (el) {
|
|
340
|
-
// A row that MOVED (an older page re-anchored a collapsed run to its
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
|
|
328
|
+
// A row that MOVED (an older page re-anchored a collapsed run to its true
|
|
329
|
+
// first pass) must not be pinned: doing so would drag the reader along
|
|
330
|
+
// with it, to wherever the run now starts. Only compare when BOTH sides
|
|
331
|
+
// actually name a turn — a stub that has since learned its anchorId (or
|
|
332
|
+
// lost it) has not moved, it has just started being able to answer.
|
|
333
|
+
//
|
|
347
334
|
// A relocation disqualifies the ROW, not the whole capture: fall through
|
|
348
|
-
// to the standbys, which
|
|
335
|
+
// to the standbys, which are a real measurement, rather than to lost()'s
|
|
349
336
|
// whole-list guess.
|
|
337
|
+
var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
|
|
350
338
|
if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS &&
|
|
351
339
|
livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) el = null;
|
|
352
340
|
}
|
|
353
341
|
if (el) {
|
|
354
342
|
var boxTop = box.getBoundingClientRect().top;
|
|
355
343
|
var delta = (el.getBoundingClientRect().top - boxTop) - anchor.top;
|
|
356
|
-
// A row can also be MOVED rather than resized: a background refetch
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
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.
|
|
344
|
+
// A row can also be MOVED rather than resized: a background refetch that
|
|
345
|
+
// merges a run's passes into the middle of page 1 relocates the bubble
|
|
346
|
+
// this anchor is holding, and following it would carry the reader across
|
|
347
|
+
// the conversation. A real prepend or in-place growth can only ever need a
|
|
348
|
+
// correction on the order of what the list gained, so a delta a whole
|
|
349
|
+
// screen beyond that is a relocation, not a resize.
|
|
366
350
|
var slack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
|
|
367
|
-
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
351
|
+
// Test the relocation in CONTENT coordinates. `delta` is measured in the
|
|
352
|
+
// VIEWPORT, so it also contains however far the box itself moved since the
|
|
353
|
+
// capture — and the widget's teardown clamps scrollTop to 0, which makes
|
|
354
|
+
// delta enormous for a row that never went anywhere. Judging that as a
|
|
355
|
+
// relocation dropped a perfectly good anchor into lost()'s whole-list
|
|
356
|
+
// guess. Subtracting the box's own movement leaves what the ROW did.
|
|
357
|
+
var moved = delta - (anchor.scrollTop - box.scrollTop);
|
|
358
|
+
if (moved > slack || moved < -slack) {
|
|
359
|
+
// The row really did relocate. That disqualifies the ROW, not the
|
|
360
|
+
// capture: fall through to the standbys, which are a real measurement.
|
|
361
|
+
el = null;
|
|
362
|
+
} else {
|
|
363
|
+
// Sub-pixel noise is not a jump, and writing scrollTop for it costs a
|
|
364
|
+
// scroll event (and a re-layout) on every settle.
|
|
365
|
+
if (delta >= 1 || delta <= -1) box.scrollTop += delta;
|
|
366
|
+
held = {
|
|
367
|
+
key: anchor.key, top: anchor.top, pos: anchor.pos,
|
|
368
|
+
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
|
|
369
|
+
// Carried, not dropped: one successful restore used to disarm the
|
|
370
|
+
// standbys for every later hold.
|
|
371
|
+
alts: anchor.alts,
|
|
372
|
+
};
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
389
375
|
}
|
|
390
|
-
// The anchor row is gone: its group collapsed,
|
|
376
|
+
// The anchor row is gone or it relocated: its group collapsed, the history was
|
|
377
|
+
// replaced, a background sweep re-keyed it.
|
|
391
378
|
// A standby row that IS still there beats lost()'s guess outright.
|
|
392
379
|
var alts = anchor.alts;
|
|
393
380
|
for (var ai = 0; alts && ai < alts.length; ai++) {
|
|
394
381
|
var alt = alts[ai];
|
|
395
|
-
var ael = findRow(box, {
|
|
382
|
+
var ael = findRow(box, {
|
|
383
|
+
key: alt.key, top: alt.top, pos: alt.pos,
|
|
384
|
+
scrollTop: anchor.scrollTop, scrollHeight: anchor.scrollHeight, el: alt.el,
|
|
385
|
+
});
|
|
396
386
|
if (!ael) continue;
|
|
397
387
|
if (alt.pos !== null && alt.pos !== UNKNOWN_ROW_POS) {
|
|
398
388
|
var altLive = ael.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
|
|
@@ -401,12 +391,9 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
401
391
|
var aboxTop = box.getBoundingClientRect().top;
|
|
402
392
|
var adelta = (ael.getBoundingClientRect().top - aboxTop) - alt.top;
|
|
403
393
|
var aslack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
|
|
404
|
-
|
|
405
|
-
|
|
394
|
+
var amoved = adelta - (anchor.scrollTop - box.scrollTop);
|
|
395
|
+
if (amoved > aslack || amoved < -aslack) continue;
|
|
406
396
|
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
397
|
held = {
|
|
411
398
|
key: alt.key, top: alt.top, pos: alt.pos,
|
|
412
399
|
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: ael,
|
|
@@ -418,13 +405,12 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
418
405
|
}
|
|
419
406
|
|
|
420
407
|
/**
|
|
421
|
-
*
|
|
408
|
+
* Neither the anchored row nor any standby survived: it is gone, or it moved.
|
|
422
409
|
*
|
|
423
410
|
* What is still known is how much the list GREW, and in the case this branch
|
|
424
411
|
* exists for — the pager, whose page can carry the very pass that re-anchors a
|
|
425
412
|
* collapsed row — all of that growth is above the reader. So pay it. Missing it
|
|
426
|
-
* costs
|
|
427
|
-
* most visible version of this bug.
|
|
413
|
+
* costs a whole page of history in one jump.
|
|
428
414
|
*
|
|
429
415
|
* With nothing gained there is nothing to pay, and then the two views differ:
|
|
430
416
|
* one REBUILDS the list (its teardown clamped scrollTop to 0, so the raw offset
|
|
@@ -434,8 +420,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
434
420
|
function lost(box: AnchorBoxEl, anchor: RowAnchor): void {
|
|
435
421
|
held = null;
|
|
436
422
|
var grew = box.scrollHeight - anchor.scrollHeight;
|
|
437
|
-
if (grew > 0) { box.scrollTop = anchor.scrollTop + grew;
|
|
438
|
-
if (options.rawFallback)
|
|
423
|
+
if (grew > 0) { box.scrollTop = anchor.scrollTop + grew; return; }
|
|
424
|
+
if (options.rawFallback) box.scrollTop = anchor.scrollTop;
|
|
439
425
|
}
|
|
440
426
|
|
|
441
427
|
function preserve<T>(mutate: () => T): T {
|
|
@@ -445,34 +431,25 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
445
431
|
return result;
|
|
446
432
|
}
|
|
447
433
|
|
|
434
|
+
/** Record the reader's place. Call from the box's scroll handler. */
|
|
448
435
|
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
436
|
held = capture();
|
|
460
437
|
}
|
|
461
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Put the remembered place back, if it is still the reader's own.
|
|
441
|
+
*
|
|
442
|
+
* The staleness check is what keeps this immediate rather than a stored
|
|
443
|
+
* intention: a height change above the viewport does NOT move scrollTop, so the
|
|
444
|
+
* anchor is valid exactly while scrollTop is still the value it was captured
|
|
445
|
+
* at. Anything else — the reader scrolled, a clamp fired, a pin ran — means the
|
|
446
|
+
* box moved for a reason of its own, and the answer is to re-measure, never to
|
|
447
|
+
* replay.
|
|
448
|
+
*/
|
|
462
449
|
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
450
|
var box = options.getBox();
|
|
470
451
|
if (!box || options.isStuck()) { held = null; return; }
|
|
471
452
|
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
453
|
if (box.scrollTop !== held.scrollTop) { held = capture(); return; }
|
|
477
454
|
// restore() re-stamps `held` with the position it just pinned, so repeated
|
|
478
455
|
// holds (one image after another finishing) each start from a valid anchor.
|
|
@@ -483,23 +460,23 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
483
460
|
* Absorb a resize made by ONE element, wherever it sits.
|
|
484
461
|
*
|
|
485
462
|
* The row anchor cannot see this case. A reader partway through an assistant
|
|
486
|
-
* reply
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
463
|
+
* reply taller than the viewport is anchored ON that row, and a picture decoding
|
|
464
|
+
* higher up INSIDE it moves every line they are reading without moving the row's
|
|
465
|
+
* own top by a pixel. Rows above the fold have the same problem in reverse:
|
|
466
|
+
* hold() would fix them, but it cannot be allowed to run for an image as well or
|
|
467
|
+
* the two would each pay the same debt.
|
|
491
468
|
*
|
|
492
|
-
* So images go through here
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
469
|
+
* So images go through here, and it is the more precise of the two: it
|
|
470
|
+
* compensates by the element's own height delta, and only while the element's
|
|
471
|
+
* TOP is above the fold, which is exactly the condition for "everything the
|
|
472
|
+
* reader can see just moved by this much". An element starting at or below the
|
|
473
|
+
* fold is left alone: it grew on screen, under a line the reader is looking at,
|
|
474
|
+
* and moving them is what would be the jump.
|
|
498
475
|
*
|
|
499
|
-
* The height it last saw is remembered per element, so the caller does not
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
476
|
+
* The height it last saw is remembered per element, so the caller does not have
|
|
477
|
+
* to bracket anything. An element it has never seen counts as zero, which is
|
|
478
|
+
* what an <img> measures before it has anything to paint — including the
|
|
479
|
+
* markdown images that have no hydration hook at all.
|
|
503
480
|
*/
|
|
504
481
|
function absorb(el: AnchorGrowableEl | null | undefined): void {
|
|
505
482
|
if (!el) return;
|
|
@@ -507,163 +484,44 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
507
484
|
if (!box) return;
|
|
508
485
|
var h = el.offsetHeight;
|
|
509
486
|
var prev = seen ? seen.get(el) : undefined;
|
|
510
|
-
// The baseline is recorded even while
|
|
511
|
-
//
|
|
512
|
-
// hidden tab has still resized, and forgetting that would make the next
|
|
513
|
-
// visible change pay for its whole height.
|
|
487
|
+
// The baseline is recorded even while pinned to the bottom (where no write is
|
|
488
|
+
// wanted): forgetting it would make the next change pay for this height too.
|
|
514
489
|
if (seen) seen.set(el, h);
|
|
515
490
|
if (options.isStuck()) return;
|
|
516
491
|
if (prev === undefined) prev = 0;
|
|
517
492
|
var delta = h - prev;
|
|
518
493
|
if (delta === 0) return;
|
|
519
|
-
if (frozen()) { foldFrozenGrowth(box, el, delta); return; }
|
|
520
494
|
// 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
|
-
//
|
|
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.
|
|
495
|
+
// slides by delta, everything above stays. So the reader's first visible line
|
|
496
|
+
// moved exactly when that top is above the fold.
|
|
526
497
|
if (el.getBoundingClientRect().top >= box.getBoundingClientRect().top) return;
|
|
527
498
|
box.scrollTop += delta;
|
|
528
|
-
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
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.
|
|
499
|
+
// Re-measure the remembered anchor, do not patch it. Its scrollTop is stale
|
|
500
|
+
// after that write, and so is its offset whenever the element that resized
|
|
501
|
+
// lives INSIDE the anchored row: there the row's own top never moved, so
|
|
502
|
+
// scrolling by delta changed the row's offset by -delta and the next hold()
|
|
503
|
+
// would faithfully undo this correction.
|
|
535
504
|
if (held) held = capture();
|
|
536
505
|
}
|
|
537
506
|
|
|
538
507
|
/**
|
|
539
|
-
*
|
|
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.
|
|
508
|
+
* Pin to the bottom, instantly.
|
|
577
509
|
*
|
|
578
|
-
* The ONE way anything is allowed to pin
|
|
579
|
-
*
|
|
580
|
-
*
|
|
510
|
+
* The ONE way anything is allowed to pin, and it reads the bottom at the moment
|
|
511
|
+
* it is called rather than aiming at a remembered one. Instant because a smooth
|
|
512
|
+
* glide fires a scroll event per frame at positions that are not the bottom, and
|
|
513
|
+
* the hosts clear stickToBottom on each of them, so any merge landing inside the
|
|
581
514
|
* animation strands the reader off the bottom permanently, aiming at a target
|
|
582
|
-
* that was already stale when the glide started.
|
|
583
|
-
* write looks like the reader and would retire an armed return early.
|
|
515
|
+
* that was already stale when the glide started.
|
|
584
516
|
*/
|
|
585
517
|
function pinBottom(): void {
|
|
586
518
|
var box = options.getBox();
|
|
587
519
|
if (!box) return;
|
|
588
520
|
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
521
|
}
|
|
661
522
|
|
|
662
523
|
function forget(): void {
|
|
663
524
|
held = null;
|
|
664
|
-
parked = null;
|
|
665
|
-
parkedStuck = false;
|
|
666
|
-
returning = false;
|
|
667
525
|
}
|
|
668
526
|
|
|
669
527
|
return {
|
|
@@ -672,12 +530,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
672
530
|
preserve: preserve,
|
|
673
531
|
remember: remember,
|
|
674
532
|
hold: hold,
|
|
675
|
-
park: park,
|
|
676
|
-
settleReturn: settleReturn,
|
|
677
|
-
isReturning: isReturning,
|
|
678
533
|
pinBottom: pinBottom,
|
|
679
|
-
isFrozen: frozen,
|
|
680
|
-
thaw: thaw,
|
|
681
534
|
absorb: absorb,
|
|
682
535
|
forget: forget,
|
|
683
536
|
};
|