bunnyquery 1.8.16 → 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 -168
- package/dist/engine.cjs +49 -132
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +42 -60
- package/dist/engine.d.ts +42 -60
- package/dist/engine.mjs +49 -132
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/scroll_anchor.ts +171 -356
|
@@ -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,33 +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
|
-
/**
|
|
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
153
|
/** Pin to the bottom, instantly, recording the write. The ONLY way to pin. */
|
|
168
154
|
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;
|
|
173
155
|
/** Absorb one element's own resize. See below. */
|
|
174
156
|
absorb: (el: AnchorGrowableEl | null | undefined) => void;
|
|
175
157
|
/** Drop the remembered place (chat switch, unmount). */
|
|
@@ -198,9 +180,6 @@ var ROW_POS_ATTR = 'data-row-pos';
|
|
|
198
180
|
var MAX_ALTS = 2;
|
|
199
181
|
/** How far past the anchor collectAlts will look for them. */
|
|
200
182
|
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;
|
|
204
183
|
/** data-row-pos is present but empty: the row cannot say where it is anchored. */
|
|
205
184
|
var UNKNOWN_ROW_POS = '\u0000?';
|
|
206
185
|
|
|
@@ -219,6 +198,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
219
198
|
var kids = box.children;
|
|
220
199
|
var fallback: RowAnchor | null = null;
|
|
221
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 }> = [];
|
|
222
203
|
for (var i = 0; i < kids.length; i++) {
|
|
223
204
|
var el = kids[i];
|
|
224
205
|
if (!el || typeof el.getAttribute !== 'function') continue;
|
|
@@ -227,7 +208,18 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
227
208
|
var top = el.getBoundingClientRect().top - boxTop;
|
|
228
209
|
// Rows still (partly) on screen. `top` is negative when a row starts
|
|
229
210
|
// above the fold, which is exactly the offset to preserve.
|
|
230
|
-
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
|
+
}
|
|
231
223
|
// And STOP at the bottom of the viewport. Without this the preference
|
|
232
224
|
// for an ordinary row walks straight past a screenful of collapsed
|
|
233
225
|
// indexing rows and anchors on a message two screens down — which is
|
|
@@ -248,20 +240,29 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
248
240
|
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
|
|
249
241
|
};
|
|
250
242
|
if (rawPos === null) {
|
|
251
|
-
// An ordinary row: use it, and take
|
|
252
|
-
//
|
|
253
|
-
|
|
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);
|
|
254
249
|
return cand;
|
|
255
250
|
}
|
|
256
251
|
if (!fallback) { fallback = cand; fallbackAt = i; } // group row: last resort
|
|
257
252
|
}
|
|
258
253
|
// The weak anchor needs standbys MORE than the strong one does, not less: a
|
|
259
254
|
// screenful of collapsed indexing rows is exactly what a background sweep
|
|
260
|
-
// re-keys and re-anchors, and without them
|
|
261
|
-
//
|
|
262
|
-
//
|
|
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.
|
|
263
261
|
if (fallback) {
|
|
264
|
-
fallback.alts =
|
|
262
|
+
fallback.alts = withBehind(
|
|
263
|
+
collectAlts(box, boxTop, fallbackAt + 1, true) || collectAlts(box, boxTop, fallbackAt + 1),
|
|
264
|
+
behind,
|
|
265
|
+
);
|
|
265
266
|
return fallback;
|
|
266
267
|
}
|
|
267
268
|
return {
|
|
@@ -294,6 +295,15 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
294
295
|
return out.length ? out : undefined;
|
|
295
296
|
}
|
|
296
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
|
+
|
|
297
307
|
function findRow(box: AnchorBoxEl, anchor: RowAnchor): AnchorRowEl | null {
|
|
298
308
|
// The same node, still in the box: one rect read instead of a scan. Vue
|
|
299
309
|
// patches keyed rows in place, so this is the common path there, and it is
|
|
@@ -310,109 +320,69 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
310
320
|
return null;
|
|
311
321
|
}
|
|
312
322
|
|
|
313
|
-
|
|
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;
|
|
323
|
+
function restore(anchor: RowAnchor | null): void {
|
|
356
324
|
var box = options.getBox();
|
|
357
325
|
if (!box || !anchor || options.isStuck()) return;
|
|
358
326
|
var el = findRow(box, anchor);
|
|
359
327
|
if (el) {
|
|
360
|
-
// A row that MOVED (an older page re-anchored a collapsed run to its
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
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
|
+
//
|
|
367
334
|
// A relocation disqualifies the ROW, not the whole capture: fall through
|
|
368
|
-
// to the standbys, which
|
|
335
|
+
// to the standbys, which are a real measurement, rather than to lost()'s
|
|
369
336
|
// whole-list guess.
|
|
337
|
+
var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
|
|
370
338
|
if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS &&
|
|
371
339
|
livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) el = null;
|
|
372
340
|
}
|
|
373
341
|
if (el) {
|
|
374
342
|
var boxTop = box.getBoundingClientRect().top;
|
|
375
343
|
var delta = (el.getBoundingClientRect().top - boxTop) - anchor.top;
|
|
376
|
-
// A row can also be MOVED rather than resized: a background refetch
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
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.
|
|
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.
|
|
386
350
|
var slack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
|
|
387
|
-
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
+
}
|
|
409
375
|
}
|
|
410
|
-
// 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.
|
|
411
378
|
// A standby row that IS still there beats lost()'s guess outright.
|
|
412
379
|
var alts = anchor.alts;
|
|
413
380
|
for (var ai = 0; alts && ai < alts.length; ai++) {
|
|
414
381
|
var alt = alts[ai];
|
|
415
|
-
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
|
+
});
|
|
416
386
|
if (!ael) continue;
|
|
417
387
|
if (alt.pos !== null && alt.pos !== UNKNOWN_ROW_POS) {
|
|
418
388
|
var altLive = ael.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
|
|
@@ -421,12 +391,9 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
421
391
|
var aboxTop = box.getBoundingClientRect().top;
|
|
422
392
|
var adelta = (ael.getBoundingClientRect().top - aboxTop) - alt.top;
|
|
423
393
|
var aslack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
|
|
424
|
-
|
|
425
|
-
|
|
394
|
+
var amoved = adelta - (anchor.scrollTop - box.scrollTop);
|
|
395
|
+
if (amoved > aslack || amoved < -aslack) continue;
|
|
426
396
|
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
397
|
held = {
|
|
431
398
|
key: alt.key, top: alt.top, pos: alt.pos,
|
|
432
399
|
scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: ael,
|
|
@@ -438,13 +405,12 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
438
405
|
}
|
|
439
406
|
|
|
440
407
|
/**
|
|
441
|
-
*
|
|
408
|
+
* Neither the anchored row nor any standby survived: it is gone, or it moved.
|
|
442
409
|
*
|
|
443
410
|
* What is still known is how much the list GREW, and in the case this branch
|
|
444
411
|
* exists for — the pager, whose page can carry the very pass that re-anchors a
|
|
445
412
|
* collapsed row — all of that growth is above the reader. So pay it. Missing it
|
|
446
|
-
* costs
|
|
447
|
-
* most visible version of this bug.
|
|
413
|
+
* costs a whole page of history in one jump.
|
|
448
414
|
*
|
|
449
415
|
* With nothing gained there is nothing to pay, and then the two views differ:
|
|
450
416
|
* one REBUILDS the list (its teardown clamped scrollTop to 0, so the raw offset
|
|
@@ -454,8 +420,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
454
420
|
function lost(box: AnchorBoxEl, anchor: RowAnchor): void {
|
|
455
421
|
held = null;
|
|
456
422
|
var grew = box.scrollHeight - anchor.scrollHeight;
|
|
457
|
-
if (grew > 0) { box.scrollTop = anchor.scrollTop + grew;
|
|
458
|
-
if (options.rawFallback)
|
|
423
|
+
if (grew > 0) { box.scrollTop = anchor.scrollTop + grew; return; }
|
|
424
|
+
if (options.rawFallback) box.scrollTop = anchor.scrollTop;
|
|
459
425
|
}
|
|
460
426
|
|
|
461
427
|
function preserve<T>(mutate: () => T): T {
|
|
@@ -465,33 +431,25 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
465
431
|
return result;
|
|
466
432
|
}
|
|
467
433
|
|
|
434
|
+
/** Record the reader's place. Call from the box's scroll handler. */
|
|
468
435
|
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;
|
|
477
436
|
held = capture();
|
|
478
437
|
}
|
|
479
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
|
+
*/
|
|
480
449
|
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; }
|
|
488
450
|
var box = options.getBox();
|
|
489
451
|
if (!box || options.isStuck()) { held = null; return; }
|
|
490
452
|
if (!held) { held = capture(); return; }
|
|
491
|
-
// Something moved the box on purpose since the anchor was taken — the user
|
|
492
|
-
// scrolled, a shrink clamped it, or the browser's own scroll anchoring
|
|
493
|
-
// already compensated. Restoring here would undo a move the reader made or
|
|
494
|
-
// double-count one already made for us, so re-measure instead.
|
|
495
453
|
if (box.scrollTop !== held.scrollTop) { held = capture(); return; }
|
|
496
454
|
// restore() re-stamps `held` with the position it just pinned, so repeated
|
|
497
455
|
// holds (one image after another finishing) each start from a valid anchor.
|
|
@@ -502,23 +460,23 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
502
460
|
* Absorb a resize made by ONE element, wherever it sits.
|
|
503
461
|
*
|
|
504
462
|
* The row anchor cannot see this case. A reader partway through an assistant
|
|
505
|
-
* reply
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
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.
|
|
510
468
|
*
|
|
511
|
-
* So images go through here
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
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.
|
|
517
475
|
*
|
|
518
|
-
* The height it last saw is remembered per element, so the caller does not
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
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.
|
|
522
480
|
*/
|
|
523
481
|
function absorb(el: AnchorGrowableEl | null | undefined): void {
|
|
524
482
|
if (!el) return;
|
|
@@ -526,181 +484,44 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
526
484
|
if (!box) return;
|
|
527
485
|
var h = el.offsetHeight;
|
|
528
486
|
var prev = seen ? seen.get(el) : undefined;
|
|
529
|
-
// The baseline is recorded even while
|
|
530
|
-
//
|
|
531
|
-
// hidden tab has still resized, and forgetting that would make the next
|
|
532
|
-
// 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.
|
|
533
489
|
if (seen) seen.set(el, h);
|
|
534
490
|
if (options.isStuck()) return;
|
|
535
491
|
if (prev === undefined) prev = 0;
|
|
536
492
|
var delta = h - prev;
|
|
537
493
|
if (delta === 0) return;
|
|
538
|
-
if (frozen()) { foldFrozenGrowth(box, el, delta); return; }
|
|
539
494
|
// The element's own TOP, which a resize never moves: everything BELOW it
|
|
540
|
-
// slides by delta, everything above stays. So the reader's first visible
|
|
541
|
-
//
|
|
542
|
-
// grew, collapsed, or straddles the fold now that it has grown. When the
|
|
543
|
-
// top is at or below the fold the growth happens on screen, at or under a
|
|
544
|
-
// 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.
|
|
545
497
|
if (el.getBoundingClientRect().top >= box.getBoundingClientRect().top) return;
|
|
546
498
|
box.scrollTop += delta;
|
|
547
|
-
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
// own top never moved, so scrolling by delta changed the row's offset by
|
|
553
|
-
// -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.
|
|
554
504
|
if (held) held = capture();
|
|
555
505
|
}
|
|
556
506
|
|
|
557
507
|
/**
|
|
558
|
-
*
|
|
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.
|
|
508
|
+
* Pin to the bottom, instantly.
|
|
577
509
|
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
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
|
|
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
|
|
609
514
|
* animation strands the reader off the bottom permanently, aiming at a target
|
|
610
|
-
* that was already stale when the glide started.
|
|
611
|
-
* write looks like the reader and would retire an armed return early.
|
|
515
|
+
* that was already stale when the glide started.
|
|
612
516
|
*/
|
|
613
517
|
function pinBottom(): void {
|
|
614
518
|
var box = options.getBox();
|
|
615
519
|
if (!box) return;
|
|
616
520
|
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
521
|
}
|
|
700
522
|
|
|
701
523
|
function forget(): void {
|
|
702
524
|
held = null;
|
|
703
|
-
release();
|
|
704
525
|
}
|
|
705
526
|
|
|
706
527
|
return {
|
|
@@ -709,13 +530,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
|
|
|
709
530
|
preserve: preserve,
|
|
710
531
|
remember: remember,
|
|
711
532
|
hold: hold,
|
|
712
|
-
park: park,
|
|
713
|
-
settleReturn: settleReturn,
|
|
714
|
-
isReturning: isReturning,
|
|
715
|
-
release: release,
|
|
716
533
|
pinBottom: pinBottom,
|
|
717
|
-
isFrozen: frozen,
|
|
718
|
-
thaw: thaw,
|
|
719
534
|
absorb: absorb,
|
|
720
535
|
forget: forget,
|
|
721
536
|
};
|