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.
- package/package.json +2 -1
- package/public/css/chat.css +34 -19
- package/public/dist/assets/{employees-lptr4zja.js → employees-24wBPdC2.js} +1 -1
- package/public/dist/assets/{index-8LWemR8D.js → index-D9XG97uZ.js} +4 -4
- package/public/dist/assets/index-DzQ_i0rm.css +1 -0
- package/public/dist/assets/{render-ROX2ge5j.js → render-C7DsYgET.js} +1 -1
- package/public/dist/assets/settings-BGkIsmES.js +1 -0
- package/public/dist/assets/{settings-C30yFb3l.js → settings-HAf-ZrYQ.js} +1 -1
- package/public/dist/assets/skills-CExPA9C6.js +1 -0
- package/public/dist/assets/{skills-Cl-BTg62.js → skills-_TZjX3Up.js} +1 -1
- package/public/dist/assets/slash-commands-BDirbbJU.js +1 -0
- package/public/dist/assets/{slash-commands-CwH3zfdM.js → slash-commands-D3eSek1_.js} +1 -1
- package/public/dist/assets/ui-6qaNORIN.js +1 -0
- package/public/dist/assets/ui-DdKdsH-Y.js +134 -0
- package/public/dist/assets/{ws-fRU9Z9F7.js → ws-CjGC2cKl.js} +1 -1
- package/public/dist/index.html +2 -2
- package/public/js/ui.ts +10 -41
- package/public/js/virtual-scroll-bootstrap.ts +6 -19
- package/public/js/virtual-scroll.ts +191 -391
- package/public/dist/assets/index-B4ZSYCSC.css +0 -1
- package/public/dist/assets/settings-DBu6J3nu.js +0 -1
- package/public/dist/assets/skills-CQtJ9e6L.js +0 -1
- package/public/dist/assets/slash-commands-BWCMcqTz.js +0 -1
- package/public/dist/assets/ui-D39zHnDA.js +0 -131
- package/public/dist/assets/ui-c57_qjIR.js +0 -1
|
@@ -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
|
|
37
|
-
private spacerBottom: HTMLDivElement;
|
|
38
|
-
private viewport: HTMLDivElement;
|
|
27
|
+
private innerEl: HTMLDivElement;
|
|
39
28
|
private _active = false;
|
|
40
|
-
private
|
|
41
|
-
private
|
|
42
|
-
private
|
|
43
|
-
private
|
|
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.
|
|
61
|
-
this.
|
|
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
|
-
// ──
|
|
72
|
-
|
|
73
|
-
private
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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.
|
|
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
|
-
|
|
229
|
-
this.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
243
|
-
if (this.
|
|
244
|
-
|
|
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.
|
|
260
|
-
|
|
261
|
-
// Measure
|
|
262
|
-
this.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
this.
|
|
267
|
-
this.
|
|
268
|
-
|
|
269
|
-
this.
|
|
270
|
-
|
|
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
|
|
276
|
-
if (this.
|
|
277
|
-
|
|
278
|
-
this.
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
228
|
+
private renderItems(): void {
|
|
229
|
+
if (!this.virtualizer) return;
|
|
230
|
+
this.virtualizer._willUpdate();
|
|
292
231
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 };
|