cli-jaw 1.6.27 → 1.7.0

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.
@@ -1,31 +1,22 @@
1
- // ── Virtual Scroll ──
1
+ // ── Virtual Scroll (TanStack Virtual Core) ──
2
+ import {
3
+ Virtualizer,
4
+ elementScroll,
5
+ observeElementRect,
6
+ observeElementOffset,
7
+ } from '@tanstack/virtual-core';
2
8
  import { generateId } from './uuid.js';
9
+
3
10
  // Activates at THRESHOLD messages to prevent DOM bloat
4
11
  // Below threshold: standard DOM append (zero overhead)
5
-
6
12
  const THRESHOLD = 80;
7
- const BUFFER = 5;
8
13
  const EST_HEIGHT = 80;
9
-
10
- interface ScrollAnchor {
11
- index: number;
12
- top: number;
13
- }
14
-
15
- export function computeAnchoredScrollTop(
16
- anchorTop: number,
17
- offsetWithinItem: number,
18
- containerPadTop: number,
19
- maxScrollTop: number,
20
- ): number {
21
- const nextScrollTop = containerPadTop + anchorTop + offsetWithinItem;
22
- return Math.max(0, Math.min(nextScrollTop, maxScrollTop));
23
- }
14
+ const OVERSCAN = 5;
24
15
 
25
16
  export interface VirtualItem {
26
17
  id: string;
27
18
  html: string;
28
- height: number;
19
+ height: number; // used as estimateSize hint; tanstack measures real heights
29
20
  }
30
21
 
31
22
  export type LazyRenderCallback = (targets: HTMLElement[]) => void;
@@ -33,146 +24,41 @@ export type LazyRenderCallback = (targets: HTMLElement[]) => void;
33
24
  export class VirtualScroll {
34
25
  private items: VirtualItem[] = [];
35
26
  private container: HTMLElement;
36
- private spacerTop: HTMLDivElement;
37
- private spacerBottom: HTMLDivElement;
38
- private viewport: HTMLDivElement;
27
+ private innerEl: HTMLDivElement;
39
28
  private _active = false;
40
- private rafId: number | null = null;
41
- private suppressScrollDepth = 0;
42
- private _remeasuring = false;
43
- private firstVisible = -1;
44
- private lastVisible = -1;
45
-
46
- // Prefix sum for O(log n) offset lookup
47
- private prefixHeights: number[] = [0];
48
- private prefixDirtyFrom = 0;
49
-
50
- // Spacing model — measured from rendered .msg margin-bottom
51
- private itemSpacing = 0;
52
- private containerPadTop = 0;
53
- private containerPadBottom = 0;
29
+ private virtualizer: Virtualizer<HTMLElement, HTMLElement> | null = null;
30
+ private cleanupFn: (() => void) | null = null;
31
+ private mounted = new Map<number, HTMLElement>();
32
+ private itemGap = 0;
54
33
 
55
34
  onLazyRender: LazyRenderCallback | null = null;
56
35
  onPostRender: ((viewport: HTMLElement) => void) | null = null;
57
36
 
58
37
  constructor(containerId: string) {
59
38
  this.container = document.getElementById(containerId)!;
60
- this.spacerTop = document.createElement('div');
61
- this.spacerTop.className = 'vs-spacer-top';
62
- this.spacerBottom = document.createElement('div');
63
- this.spacerBottom.className = 'vs-spacer-bottom';
64
- this.viewport = document.createElement('div');
65
- this.viewport.className = 'vs-viewport';
39
+ this.innerEl = document.createElement('div');
40
+ this.innerEl.className = 'vs-inner';
66
41
  }
67
42
 
68
43
  get active(): boolean { return this._active; }
69
44
  get count(): number { return this.items.length; }
70
45
 
71
- // ── Prefix sum helpers ──
72
-
73
- private markPrefixDirty(from: number): void {
74
- this.prefixDirtyFrom = Math.min(this.prefixDirtyFrom, Math.max(0, from));
46
+ // ── Measure gap from CSS ──
47
+
48
+ private measureGap(): number {
49
+ if (this.itemGap > 0) return this.itemGap;
50
+ const probe = document.createElement('div');
51
+ probe.className = 'msg';
52
+ probe.style.position = 'absolute';
53
+ probe.style.visibility = 'hidden';
54
+ probe.textContent = ' ';
55
+ this.innerEl.appendChild(probe);
56
+ this.itemGap = parseFloat(getComputedStyle(probe).marginBottom) || 0;
57
+ probe.remove();
58
+ return this.itemGap;
75
59
  }
76
60
 
77
- private rebuildPrefixHeights(): void {
78
- const n = this.items.length;
79
- if (this.prefixHeights.length !== n + 1) {
80
- this.prefixHeights = new Array(n + 1).fill(0);
81
- this.prefixDirtyFrom = 0;
82
- }
83
- for (let i = this.prefixDirtyFrom; i < n; i++) {
84
- this.prefixHeights[i + 1] = this.prefixHeights[i] + this.items[i].height;
85
- }
86
- this.prefixDirtyFrom = n;
87
- }
88
-
89
- /** Raw cumulative height up to (but not including) index */
90
- private offsetForIndex(index: number): number {
91
- this.rebuildPrefixHeights();
92
- return this.prefixHeights[Math.max(0, Math.min(index, this.items.length))];
93
- }
94
-
95
- /** Effective offset including inter-item spacing (margin-bottom) */
96
- private effectiveOffset(index: number): number {
97
- return this.offsetForIndex(index) + index * this.itemSpacing;
98
- }
99
-
100
- /** Total effective height of all items with spacing.
101
- * Every item has margin-bottom in CSS (.vs-active .msg), so N items
102
- * produce N spacing gaps. This equals effectiveOffset(n). */
103
- private totalEffectiveHeight(): number {
104
- const n = this.items.length;
105
- if (n === 0) return 0;
106
- return this.effectiveOffset(n);
107
- }
108
-
109
- /** Spacer height below the last rendered item. */
110
- private bottomSpacerHeight(lastVisible: number): number {
111
- return Math.max(0, this.totalEffectiveHeight() - this.effectiveOffset(lastVisible + 1));
112
- }
113
-
114
- /** Binary search: find item index at given scroll offset */
115
- private indexForOffset(offset: number): number {
116
- this.rebuildPrefixHeights();
117
- const n = this.items.length;
118
- if (n === 0) return 0;
119
- let lo = 0;
120
- let hi = n - 1;
121
- while (lo < hi) {
122
- const mid = (lo + hi + 1) >> 1;
123
- const effOff = this.prefixHeights[mid] + mid * this.itemSpacing;
124
- if (effOff <= offset) lo = mid;
125
- else hi = mid - 1;
126
- }
127
- return lo;
128
- }
129
-
130
- // ── Spacing model ──
131
-
132
- private refreshLayoutMetrics(): void {
133
- const containerStyle = getComputedStyle(this.container);
134
- this.containerPadTop = parseFloat(containerStyle.paddingTop) || 0;
135
- this.containerPadBottom = parseFloat(containerStyle.paddingBottom) || 0;
136
- const msgs = Array.from(this.viewport.querySelectorAll<HTMLElement>('.msg'));
137
- const sample = msgs[0] ?? null;
138
- if (sample) {
139
- const spacing = parseFloat(getComputedStyle(sample).marginBottom) || 0;
140
- if (spacing > 0) this.itemSpacing = spacing;
141
- return;
142
- }
143
- // Fallback: when the viewport currently contains no messages, probe the
144
- // active CSS rule directly instead of collapsing spacing to 0.
145
- if (this._active && this.viewport.isConnected) {
146
- const probe = document.createElement('div');
147
- probe.className = 'msg';
148
- probe.style.position = 'absolute';
149
- probe.style.visibility = 'hidden';
150
- probe.style.pointerEvents = 'none';
151
- probe.textContent = ' ';
152
- this.viewport.appendChild(probe);
153
- const spacing = parseFloat(getComputedStyle(probe).marginBottom) || 0;
154
- probe.remove();
155
- if (spacing > 0) this.itemSpacing = spacing;
156
- }
157
- }
158
-
159
-
160
- // ── Public API ──
161
-
162
- flushToDOM(): void {
163
- if (!this._active) return;
164
- this.container.classList.remove('vs-active');
165
- this.container.removeEventListener('scroll', this.scrollHandler);
166
- if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; }
167
- this.container.innerHTML = this.items.map(it => it.html).join('');
168
- this._active = false;
169
- this.firstVisible = -1;
170
- this.lastVisible = -1;
171
- this.items = [];
172
- this.prefixHeights = [0];
173
- this.prefixDirtyFrom = 0;
174
- this.itemSpacing = 0;
175
- }
61
+ // ── Public API (preserved for callers) ──
176
62
 
177
63
  /** Bulk-load items. Call AFTER registering onLazyRender/onPostRender. */
178
64
  setItems(
@@ -180,25 +66,21 @@ export class VirtualScroll {
180
66
  options?: { autoActivate?: boolean; toBottom?: boolean },
181
67
  ): void {
182
68
  this.items = items;
183
- this.prefixHeights = new Array(items.length + 1).fill(0);
184
- this.prefixDirtyFrom = 0;
185
69
  if (options?.autoActivate === false) return;
186
70
  if (!this._active && this.items.length >= THRESHOLD) {
187
71
  this.activate(options?.toBottom ?? true);
188
72
  }
189
73
  }
190
74
 
191
- /** Seed measured heights before activation. */
75
+ /** Seed heights into items so estimateSize returns accurate values.
76
+ * tanstack will re-measure via ResizeObserver on mount, but seeding
77
+ * gives accurate initial getTotalSize() for scrollToIndex precision. */
192
78
  seedMeasuredHeights(startIndex: number, heights: number[]): void {
193
79
  for (let offset = 0; offset < heights.length; offset++) {
194
80
  const idx = startIndex + offset;
195
- const item = this.items[idx];
196
- if (!item) continue;
197
- const oldH = item.height;
198
- const nextH = heights[offset];
199
- if (oldH === nextH) continue;
200
- item.height = nextH;
201
- this.markPrefixDirty(idx);
81
+ if (this.items[idx]) {
82
+ this.items[idx].height = heights[offset];
83
+ }
202
84
  }
203
85
  }
204
86
 
@@ -212,12 +94,15 @@ export class VirtualScroll {
212
94
  addItem(id: string, html: string): void {
213
95
  const item: VirtualItem = { id, html, height: EST_HEIGHT };
214
96
  this.items.push(item);
215
- this.markPrefixDirty(this.items.length - 1);
216
97
  if (!this._active && this.items.length >= THRESHOLD) {
217
98
  this.activate(true);
99
+ return;
218
100
  }
219
- if (this._active) {
220
- this.scheduleRender();
101
+ if (this._active && this.virtualizer) {
102
+ this.virtualizer.setOptions({
103
+ ...this.virtualizer.options,
104
+ count: this.items.length,
105
+ });
221
106
  }
222
107
  }
223
108
 
@@ -225,12 +110,13 @@ export class VirtualScroll {
225
110
  if (!this._active) return;
226
111
  const html = div.outerHTML;
227
112
  const id = generateId();
228
- const item: VirtualItem = { id, html, height: EST_HEIGHT };
229
- this.items.push(item);
230
- this.markPrefixDirty(this.items.length - 1);
231
- // Don't force scroll here — let the ui.ts wrapper decide
232
- // based on userNearBottom state
233
- this.scheduleRender();
113
+ this.items.push({ id, html, height: EST_HEIGHT });
114
+ if (this.virtualizer) {
115
+ this.virtualizer.setOptions({
116
+ ...this.virtualizer.options,
117
+ count: this.items.length,
118
+ });
119
+ }
234
120
  }
235
121
 
236
122
  updateItemHtml(idx: number, html: string): void {
@@ -239,273 +125,174 @@ export class VirtualScroll {
239
125
  }
240
126
  }
241
127
 
242
- private scrollHandler = () => {
243
- if (this.suppressScrollDepth > 0) return;
244
- this.scheduleRender();
245
- };
128
+ scrollToBottom(): void {
129
+ if (this.virtualizer && this.items.length > 0) {
130
+ // TanStack API — syncs internal scrollState + DOM together
131
+ this.virtualizer.scrollToIndex(this.items.length - 1, { align: 'end' });
132
+ }
133
+ // Also set DOM scrollTop for non-VS content below innerEl
134
+ // (streaming placeholder lives outside VS as direct container child)
135
+ this.container.scrollTop = this.container.scrollHeight;
136
+ }
137
+
138
+ flushToDOM(): void {
139
+ if (!this._active) return;
140
+ this.deactivate();
141
+ this.container.innerHTML = this.items.map(it => it.html).join('');
142
+ this.items = [];
143
+ }
144
+
145
+ clear(): void {
146
+ this.deactivate();
147
+ this.items = [];
148
+ this.itemGap = 0;
149
+ this.onLazyRender = null;
150
+ this.onPostRender = null;
151
+ }
152
+
153
+ // ── Activation / Deactivation ──
246
154
 
247
155
  private activate(toBottom = false): void {
248
156
  this._active = true;
157
+
158
+ // Measure real heights from existing DOM before replacing
249
159
  const existing = this.container.querySelectorAll('.msg');
250
160
  existing.forEach((el, i) => {
251
161
  if (this.items[i]) {
252
162
  this.items[i].height = el.getBoundingClientRect().height;
253
163
  }
254
164
  });
255
- this.prefixHeights = new Array(this.items.length + 1).fill(0);
256
- this.prefixDirtyFrom = 0;
257
165
 
258
166
  this.container.classList.add('vs-active');
259
- this.container.replaceChildren(this.spacerTop, this.viewport, this.spacerBottom);
260
- this.container.addEventListener('scroll', this.scrollHandler, { passive: true });
261
- // Measure spacing AFTER .vs-active is applied so CSS margin-bottom is active
262
- this.refreshLayoutMetrics();
263
-
264
- if (toBottom) {
265
- const total = this.totalEffectiveHeight();
266
- this.spacerTop.style.height = `${total}px`;
267
- this.spacerBottom.style.height = '0px';
268
- this.container.scrollTop = this.container.scrollHeight;
269
- this.firstVisible = -1;
270
- this.lastVisible = -1;
167
+ this.container.replaceChildren(this.innerEl);
168
+
169
+ // Measure gap after .vs-active is applied
170
+ this.measureGap();
171
+
172
+ this.virtualizer = new Virtualizer<HTMLElement, HTMLElement>({
173
+ count: this.items.length,
174
+ getScrollElement: () => this.container,
175
+ estimateSize: (i: number) => this.items[i]?.height ?? EST_HEIGHT,
176
+ overscan: OVERSCAN,
177
+ gap: this.itemGap,
178
+ // Our post-render pipeline mutates the mounted message DOM
179
+ // (markdown lazy render, widgets, linkification). Defer RO-driven
180
+ // measurements by one frame to avoid "ResizeObserver loop completed
181
+ // with undelivered notifications" during those mutations.
182
+ useAnimationFrameWithResizeObserver: true,
183
+ onChange: () => this.renderItems(),
184
+ observeElementRect,
185
+ observeElementOffset,
186
+ scrollToFn: elementScroll,
187
+ getItemKey: (i: number) => this.items[i]?.id ?? i,
188
+ indexAttribute: 'data-vs-idx',
189
+ });
190
+
191
+ this.cleanupFn = this.virtualizer._didMount();
192
+ this.virtualizer._willUpdate();
193
+
194
+ if (toBottom && this.items.length > 0) {
195
+ // Hide during initial settle — scrollToIndex reconciliation
196
+ // takes 1-2 RAF frames to converge
197
+ this.container.style.opacity = '0';
198
+ this.renderItems();
199
+ // Use TanStack API — sets internal scrollState + DOM scrollTop
200
+ // together, then reconciliation loop auto-corrects as items
201
+ // get measured with real heights
202
+ this.virtualizer.scrollToIndex(this.items.length - 1, { align: 'end' });
203
+ // Show after reconciliation settles
204
+ requestAnimationFrame(() => {
205
+ requestAnimationFrame(() => {
206
+ this.container.style.opacity = '';
207
+ });
208
+ });
209
+ } else {
210
+ this.renderItems();
271
211
  }
272
- this.render();
273
212
  }
274
213
 
275
- private scheduleRender(): void {
276
- if (this.rafId) return;
277
- this.rafId = requestAnimationFrame(() => {
278
- this.rafId = null;
279
- this.render();
280
- });
214
+ private deactivate(): void {
215
+ if (this.cleanupFn) {
216
+ this.cleanupFn();
217
+ this.cleanupFn = null;
218
+ }
219
+ this.virtualizer = null;
220
+ this._active = false;
221
+ this.mounted.clear();
222
+ this.container.classList.remove('vs-active');
223
+ this.container.innerHTML = '';
281
224
  }
282
225
 
283
- private render(): void {
284
- this.refreshLayoutMetrics();
285
- const scrollTop = this.container.scrollTop;
286
- const viewHeight = this.container.clientHeight;
287
- const contentScrollTop = Math.max(0, scrollTop - this.containerPadTop);
288
- const contentViewHeight = Math.max(0, viewHeight - this.containerPadTop - this.containerPadBottom);
226
+ // ── Render loop (called by tanstack onChange) ──
289
227
 
290
- const startIdx = this.indexForOffset(contentScrollTop);
291
- const first = Math.max(0, startIdx - BUFFER);
228
+ private renderItems(): void {
229
+ if (!this.virtualizer) return;
230
+ this.virtualizer._willUpdate();
292
231
 
293
- let endIdx = startIdx;
294
- for (let i = startIdx; i < this.items.length; i++) {
295
- endIdx = i;
296
- if (this.effectiveOffset(i + 1) > contentScrollTop + contentViewHeight) break;
297
- }
298
- const last = Math.min(this.items.length - 1, endIdx + BUFFER);
299
-
300
- if (first === this.firstVisible && last === this.lastVisible) {
301
- // Still update spacers when heights changed (RC3)
302
- const topSpace = this.effectiveOffset(first);
303
- const botSpace = this.bottomSpacerHeight(last);
304
- this.spacerTop.style.height = `${topSpace}px`;
305
- this.spacerBottom.style.height = `${botSpace}px`;
306
- return;
307
- }
308
- this.firstVisible = first;
309
- this.lastVisible = last;
310
- const anchor = this.captureScrollAnchor();
311
-
312
- // Build map of currently mounted items by vsIdx
313
- const mounted = new Map<number, HTMLElement>();
314
- for (const child of Array.from(this.viewport.children) as HTMLElement[]) {
315
- const idx = Number(child.dataset.vsIdx);
316
- if (!isNaN(idx)) mounted.set(idx, child);
317
- }
232
+ const virtualItems = this.virtualizer.getVirtualItems();
233
+ const totalSize = this.virtualizer.getTotalSize();
234
+
235
+ // Update inner container height (provides scrollbar range)
236
+ this.innerEl.style.height = `${totalSize}px`;
318
237
 
319
- for (const [idx, el] of mounted) {
320
- if (idx < first || idx > last) {
238
+ // Determine which indices tanstack wants rendered
239
+ const wantedSet = new Set(virtualItems.map(vi => vi.index));
240
+
241
+ // Remove items no longer in range
242
+ for (const [idx, el] of this.mounted) {
243
+ if (!wantedSet.has(idx)) {
321
244
  el.remove();
322
- mounted.delete(idx);
245
+ this.mounted.delete(idx);
323
246
  }
324
247
  }
325
248
 
326
- const ordered: HTMLElement[] = [];
327
- for (let i = first; i <= last; i++) {
328
- const existing = mounted.get(i);
329
- if (existing) {
330
- ordered.push(existing);
331
- } else {
332
- const item = this.items[i];
333
- const div = document.createElement('div');
334
- div.innerHTML = item.html;
335
- const el = div.firstElementChild as HTMLElement;
336
- if (el) {
337
- el.dataset.vsIdx = String(i);
338
- ordered.push(el);
339
- }
249
+ // Mount / reposition items
250
+ const newlyMounted: HTMLElement[] = [];
251
+ for (const vItem of virtualItems) {
252
+ let el = this.mounted.get(vItem.index);
253
+
254
+ if (!el) {
255
+ // Create new element from stored HTML
256
+ const item = this.items[vItem.index];
257
+ if (!item) continue;
258
+ const wrapper = document.createElement('div');
259
+ wrapper.innerHTML = item.html;
260
+ el = wrapper.firstElementChild as HTMLElement;
261
+ if (!el) continue;
262
+ el.dataset.vsIdx = String(vItem.index);
263
+ this.innerEl.appendChild(el);
264
+ this.mounted.set(vItem.index, el);
265
+ newlyMounted.push(el);
340
266
  }
341
- }
342
267
 
343
- let nodeRef = this.viewport.firstChild as HTMLElement | null;
344
- for (const el of ordered) {
345
- if (el !== nodeRef) {
346
- this.viewport.insertBefore(el, nodeRef);
347
- } else {
348
- nodeRef = nodeRef.nextSibling as HTMLElement | null;
349
- }
268
+ // Position via transform only left/right/width handled by CSS
269
+ // so .msg-user align-self / left:auto works correctly
270
+ el.style.transform = `translateY(${vItem.start}px)`;
350
271
  }
351
272
 
352
- // Measure spacing from actual DOM AFTER mounting items
353
- this.refreshLayoutMetrics();
354
-
355
- // Compute spacers with correct spacing.
356
- // When the visible range shifts, spacerTop can jump by hundreds of px
357
- // (because previously-measured items have heights ≠ EST_HEIGHT).
358
- // Compensate scrollTop by the delta so the viewport content stays put.
359
- const oldTopSpace = parseFloat(this.spacerTop.style.height) || 0;
360
- const topSpace = this.effectiveOffset(first);
361
- const botSpace = this.bottomSpacerHeight(last);
362
- const spacerDelta = topSpace - oldTopSpace;
363
- this.spacerTop.style.height = `${topSpace}px`;
364
- this.spacerBottom.style.height = `${botSpace}px`;
365
- if (Math.abs(spacerDelta) > 1) {
366
- this.suppressScrollDepth++;
367
- this.container.scrollTop += spacerDelta;
368
- requestAnimationFrame(() => { requestAnimationFrame(() => { this.suppressScrollDepth--; }); });
273
+ // Let tanstack measure real heights via ResizeObserver
274
+ // only for newly mounted elements (already-observed ones are tracked)
275
+ for (const el of newlyMounted) {
276
+ this.virtualizer!.measureElement(el);
369
277
  }
370
278
 
279
+ // Lazy render: process any lazy-pending elements
371
280
  if (this.onLazyRender) {
372
- const lazyTargets = this.viewport.querySelectorAll<HTMLElement>('.lazy-pending');
281
+ const lazyTargets = this.innerEl.querySelectorAll<HTMLElement>('.lazy-pending');
373
282
  if (lazyTargets.length > 0) {
374
283
  this.onLazyRender(Array.from(lazyTargets));
375
284
  }
376
285
  }
377
286
 
287
+ // Post render: activate widgets, linkify paths
378
288
  if (this.onPostRender) {
379
- this.onPostRender(this.viewport);
380
- }
381
-
382
- this.remeasureVisible(anchor);
383
- }
384
-
385
- private captureScrollAnchor(): ScrollAnchor | null {
386
- const containerRect = this.container.getBoundingClientRect();
387
- const visibleTop = containerRect.top + this.containerPadTop;
388
- const candidates = Array.from(this.viewport.querySelectorAll<HTMLElement>('[data-vs-idx]'));
389
- const anchorEl = candidates.find((el) => el.getBoundingClientRect().bottom > visibleTop) ?? candidates[0] ?? null;
390
- if (!anchorEl) return null;
391
- const index = Number(anchorEl.dataset.vsIdx);
392
- if (!Number.isFinite(index)) return null;
393
- return {
394
- index,
395
- top: anchorEl.getBoundingClientRect().top - containerRect.top,
396
- };
397
- }
398
-
399
- private applyCurrentSpacers(): void {
400
- if (this.firstVisible < 0 || this.lastVisible < 0 || this.items.length === 0) {
401
- this.spacerTop.style.height = '0px';
402
- this.spacerBottom.style.height = '0px';
403
- return;
404
- }
405
- const topSpace = this.effectiveOffset(this.firstVisible);
406
- const botSpace = this.bottomSpacerHeight(this.lastVisible);
407
- this.spacerTop.style.height = `${topSpace}px`;
408
- this.spacerBottom.style.height = `${botSpace}px`;
409
- }
410
-
411
- private restoreScrollAnchor(anchor: ScrollAnchor | null): void {
412
- if (!anchor) return;
413
- const containerRect = this.container.getBoundingClientRect();
414
- const anchorEl = this.viewport.querySelector<HTMLElement>(`[data-vs-idx="${anchor.index}"]`);
415
- if (!anchorEl) return;
416
- const currentTop = anchorEl.getBoundingClientRect().top - containerRect.top;
417
- const delta = currentTop - anchor.top;
418
- if (Math.abs(delta) <= 1) return;
419
- const maxScrollTop = Math.max(0, this.container.scrollHeight - this.container.clientHeight);
420
- // Suppress the asynchronous scroll event that fires after programmatic
421
- // scrollTop changes. Cleared in a nested RAF so it outlives the browser's
422
- // "update the rendering" step where scroll events are dispatched.
423
- // Uses a depth counter so overlapping suppression windows are safe.
424
- this.suppressScrollDepth++;
425
- this.container.scrollTop = Math.max(0, Math.min(this.container.scrollTop + delta, maxScrollTop));
426
- requestAnimationFrame(() => { requestAnimationFrame(() => { this.suppressScrollDepth--; }); });
427
- }
428
-
429
- private remeasureVisible(anchor: ScrollAnchor | null): void {
430
- if (this._remeasuring) return;
431
-
432
- // Threshold must be small (< 1 wheel tick ≈ 30px on macOS) so that
433
- // a single trackpad/wheel scroll-up escapes the "stick to bottom" zone.
434
- const wasAtBottom = this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 5;
435
-
436
- const rects: { idx: number; newH: number }[] = [];
437
- this.viewport.querySelectorAll('[data-vs-idx]').forEach(el => {
438
- const idx = Number((el as HTMLElement).dataset.vsIdx);
439
- if (this.items[idx]) {
440
- rects.push({ idx, newH: el.getBoundingClientRect().height });
441
- }
442
- });
443
- let heightChanged = false;
444
- for (const { idx, newH } of rects) {
445
- const oldH = this.items[idx].height;
446
- if (oldH !== newH) {
447
- this.items[idx].height = newH;
448
- this.markPrefixDirty(idx);
449
- heightChanged = true;
450
- }
451
- }
452
- if (!heightChanged) return;
453
-
454
- this._remeasuring = true;
455
- try {
456
- this.applyCurrentSpacers();
457
- if (wasAtBottom) {
458
- this.scrollToBottom();
459
- return;
460
- }
461
- this.restoreScrollAnchor(anchor);
462
- } finally {
463
- this._remeasuring = false;
464
- }
465
- }
466
-
467
- /** Synchronous scroll — cancel pending RAF, update spacers, render directly */
468
- scrollToBottom(): void {
469
- if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; }
470
- const total = this.totalEffectiveHeight();
471
- this.spacerTop.style.height = `${total}px`;
472
- this.spacerBottom.style.height = '0px';
473
- this.suppressScrollDepth++;
474
- this.container.scrollTop = this.container.scrollHeight;
475
- requestAnimationFrame(() => { requestAnimationFrame(() => { this.suppressScrollDepth--; }); });
476
- this.firstVisible = -1;
477
- this.lastVisible = -1;
478
- this.render();
479
- }
480
-
481
- clear(): void {
482
- this.items = [];
483
- this.prefixHeights = [0];
484
- this.prefixDirtyFrom = 0;
485
- this.itemSpacing = 0;
486
- this.containerPadTop = 0;
487
- this.containerPadBottom = 0;
488
- if (this._active) {
489
- this.container.classList.remove('vs-active');
490
- this.container.removeEventListener('scroll', this.scrollHandler);
491
- this.viewport.innerHTML = '';
492
- this.spacerTop.style.height = '0';
493
- this.spacerBottom.style.height = '0';
494
- this.container.innerHTML = '';
495
- }
496
- this._active = false;
497
- this.firstVisible = -1;
498
- this.lastVisible = -1;
499
- this.onLazyRender = null;
500
- this.onPostRender = null;
501
- if (this.rafId) {
502
- cancelAnimationFrame(this.rafId);
503
- this.rafId = null;
289
+ this.onPostRender(this.innerEl);
504
290
  }
505
291
  }
506
292
  }
507
293
 
508
- // Singleton instance
294
+ // ── Singleton ──
295
+
509
296
  let instance: VirtualScroll | null = null;
510
297
 
511
298
  export function getVirtualScroll(): VirtualScroll {
@@ -515,4 +302,17 @@ export function getVirtualScroll(): VirtualScroll {
515
302
  return instance;
516
303
  }
517
304
 
305
+ // ── Compat exports ──
306
+
307
+ /** @deprecated Kept for test compat — tanstack handles anchoring internally */
308
+ export function computeAnchoredScrollTop(
309
+ anchorTop: number,
310
+ offsetWithinItem: number,
311
+ containerPadTop: number,
312
+ maxScrollTop: number,
313
+ ): number {
314
+ const nextScrollTop = containerPadTop + anchorTop + offsetWithinItem;
315
+ return Math.max(0, Math.min(nextScrollTop, maxScrollTop));
316
+ }
317
+
518
318
  export { THRESHOLD as VS_THRESHOLD };