@humanspeak/svelte-virtual-chat 0.0.1 → 0.0.2
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/dist/SvelteVirtualChat.svelte +94 -31
- package/dist/SvelteVirtualChat.svelte.d.ts +112 -8
- package/dist/virtual-chat/chatAnchoring.d.ts +34 -6
- package/dist/virtual-chat/chatAnchoring.js +36 -9
- package/dist/virtual-chat/chatMeasurement.svelte.d.ts +31 -2
- package/dist/virtual-chat/chatMeasurement.svelte.js +33 -5
- package/package.json +115 -117
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
onDebugInfo,
|
|
20
20
|
containerClass = '',
|
|
21
21
|
viewportClass = '',
|
|
22
|
-
debug = false,
|
|
23
22
|
testId
|
|
24
23
|
}: SvelteVirtualChatProps<TMessage> = $props()
|
|
25
24
|
|
|
@@ -32,6 +31,8 @@
|
|
|
32
31
|
let viewportHeight = $state(0)
|
|
33
32
|
let isFollowingBottom = $state(true)
|
|
34
33
|
let pendingSnapToBottom = $state(false)
|
|
34
|
+
let userScrolling = false
|
|
35
|
+
let userScrollTimer: ReturnType<typeof setTimeout> | null = null
|
|
35
36
|
|
|
36
37
|
// ── Derived: total content height ───────────────────────────────
|
|
37
38
|
const totalHeight = $derived.by(() => {
|
|
@@ -40,7 +41,6 @@
|
|
|
40
41
|
})
|
|
41
42
|
|
|
42
43
|
// ── Derived: top gap for bottom-gravity ─────────────────────────
|
|
43
|
-
// When content is shorter than viewport, push messages to the bottom
|
|
44
44
|
const topGap = $derived(Math.max(0, viewportHeight - totalHeight))
|
|
45
45
|
|
|
46
46
|
// ── Derived: visible range ──────────────────────────────────────
|
|
@@ -51,8 +51,6 @@
|
|
|
51
51
|
return { start: 0, end: 0, visibleStart: 0, visibleEnd: 0 }
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
// scrollTop is relative to the full content area (which includes topGap)
|
|
55
|
-
// Adjust to get the scroll position relative to the message content
|
|
56
54
|
const messageScrollTop = Math.max(0, scrollTop - topGap)
|
|
57
55
|
const viewTop = messageScrollTop
|
|
58
56
|
const viewBottom = messageScrollTop + viewportHeight
|
|
@@ -104,10 +102,17 @@
|
|
|
104
102
|
)
|
|
105
103
|
|
|
106
104
|
// ── Scroll event handler ────────────────────────────────────────
|
|
107
|
-
|
|
105
|
+
const handleScroll = () => {
|
|
108
106
|
if (!viewportEl) return
|
|
109
107
|
scrollTop = viewportEl.scrollTop
|
|
110
108
|
|
|
109
|
+
// Suppress programmatic snaps while the user is actively scrolling
|
|
110
|
+
userScrolling = true
|
|
111
|
+
if (userScrollTimer) clearTimeout(userScrollTimer)
|
|
112
|
+
userScrollTimer = setTimeout(() => {
|
|
113
|
+
userScrolling = false
|
|
114
|
+
}, 150)
|
|
115
|
+
|
|
111
116
|
const maxScroll = viewportEl.scrollHeight - viewportEl.clientHeight
|
|
112
117
|
const wasFollowing = isFollowingBottom
|
|
113
118
|
isFollowingBottom = maxScroll <= 0 || maxScroll - scrollTop <= followBottomThresholdPx
|
|
@@ -116,30 +121,28 @@
|
|
|
116
121
|
onFollowBottomChange?.(isFollowingBottom)
|
|
117
122
|
}
|
|
118
123
|
|
|
119
|
-
// Trigger history loading when near top
|
|
120
124
|
if (onNeedHistory && scrollTop - topGap < viewportHeight * 0.5) {
|
|
121
125
|
onNeedHistory()
|
|
122
126
|
}
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
// ──
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
snapNeeded = true
|
|
131
|
-
if (pendingSnapToBottom) return // rAF already scheduled, it will re-check
|
|
129
|
+
// ── Snap scheduling ─────────────────────────────────────────────
|
|
130
|
+
/** Batches snap-to-bottom into a single rAF, respecting user-scroll suppression. */
|
|
131
|
+
const scheduleSnapToBottom = () => {
|
|
132
|
+
if (!isFollowingBottom || !viewportEl || userScrolling) return
|
|
133
|
+
if (pendingSnapToBottom) return
|
|
132
134
|
pendingSnapToBottom = true
|
|
133
135
|
requestAnimationFrame(() => {
|
|
134
136
|
pendingSnapToBottom = false
|
|
135
|
-
if (
|
|
136
|
-
snapNeeded = false
|
|
137
|
+
if (isFollowingBottom && !userScrolling) {
|
|
137
138
|
snapToBottom()
|
|
138
139
|
}
|
|
139
140
|
})
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
|
|
143
|
+
// ── Measurement action ──────────────────────────────────────────
|
|
144
|
+
/** Svelte action: attaches a ResizeObserver to track message height changes. */
|
|
145
|
+
const measureMessage = (node: HTMLElement, messageId: string) => {
|
|
143
146
|
const observer = new ResizeObserver((entries) => {
|
|
144
147
|
for (const entry of entries) {
|
|
145
148
|
const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height
|
|
@@ -170,13 +173,13 @@
|
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
// ── Snap to bottom helper ───────────────────────────────────────
|
|
173
|
-
|
|
176
|
+
/** Instantly scrolls viewport to bottom and syncs follow state. */
|
|
177
|
+
const snapToBottom = () => {
|
|
174
178
|
if (!viewportEl) return
|
|
175
179
|
const maxScroll = viewportEl.scrollHeight - viewportEl.clientHeight
|
|
176
180
|
if (maxScroll > 0) {
|
|
177
181
|
viewportEl.scrollTop = viewportEl.scrollHeight
|
|
178
182
|
}
|
|
179
|
-
// Sync the following state directly (don't wait for scroll event)
|
|
180
183
|
scrollTop = viewportEl.scrollTop
|
|
181
184
|
isFollowingBottom = true
|
|
182
185
|
}
|
|
@@ -185,16 +188,23 @@
|
|
|
185
188
|
$effect(() => {
|
|
186
189
|
void messages.length
|
|
187
190
|
if (isFollowingBottom && viewportEl) {
|
|
188
|
-
requestAnimationFrame(() =>
|
|
191
|
+
requestAnimationFrame(() => {
|
|
192
|
+
if (isFollowingBottom) snapToBottom()
|
|
193
|
+
})
|
|
189
194
|
}
|
|
190
195
|
})
|
|
191
196
|
|
|
192
197
|
// ── Follow-bottom on height changes ─────────────────────────────
|
|
193
|
-
//
|
|
198
|
+
// Height changes during scroll (e.g. newly measured items) respect user-scroll suppression
|
|
194
199
|
$effect(() => {
|
|
195
200
|
void totalHeight
|
|
196
|
-
|
|
197
|
-
|
|
201
|
+
scheduleSnapToBottom()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// ── Cleanup user-scroll timer on destroy ────────────────────────
|
|
205
|
+
$effect(() => {
|
|
206
|
+
return () => {
|
|
207
|
+
if (userScrollTimer) clearTimeout(userScrollTimer)
|
|
198
208
|
}
|
|
199
209
|
})
|
|
200
210
|
|
|
@@ -206,7 +216,6 @@
|
|
|
206
216
|
const observer = new ResizeObserver(() => {
|
|
207
217
|
if (viewportEl) {
|
|
208
218
|
viewportHeight = viewportEl.clientHeight
|
|
209
|
-
// On initial layout (or resize), snap to bottom if following
|
|
210
219
|
if (isFollowingBottom) {
|
|
211
220
|
requestAnimationFrame(() => snapToBottom())
|
|
212
221
|
}
|
|
@@ -216,8 +225,8 @@
|
|
|
216
225
|
return () => observer.disconnect()
|
|
217
226
|
})
|
|
218
227
|
|
|
219
|
-
// ── Debug info
|
|
220
|
-
|
|
228
|
+
// ── Debug info ──────────────────────────────────────────────────
|
|
229
|
+
const buildDebugInfo = (): SvelteVirtualChatDebugInfo => {
|
|
221
230
|
const measuredCount = heightCache.size
|
|
222
231
|
return {
|
|
223
232
|
totalMessages: messages.length,
|
|
@@ -236,7 +245,6 @@
|
|
|
236
245
|
}
|
|
237
246
|
}
|
|
238
247
|
|
|
239
|
-
// ── Debug effect: log + callback ────────────────────────────────
|
|
240
248
|
$effect(() => {
|
|
241
249
|
void renderedMessages.length
|
|
242
250
|
void heightCache.version
|
|
@@ -244,13 +252,24 @@
|
|
|
244
252
|
void isFollowingBottom
|
|
245
253
|
|
|
246
254
|
const info = buildDebugInfo()
|
|
247
|
-
if (debug) console.log('[SvelteVirtualChat]', info)
|
|
248
255
|
onDebugInfo?.(info)
|
|
249
256
|
})
|
|
250
257
|
|
|
251
258
|
// ── Public API ──────────────────────────────────────────────────
|
|
252
259
|
|
|
253
|
-
|
|
260
|
+
/**
|
|
261
|
+
* Scroll the viewport to the bottom.
|
|
262
|
+
*
|
|
263
|
+
* @param options - Optional configuration
|
|
264
|
+
* @param options.smooth - Use smooth scrolling animation (default: false)
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```ts
|
|
268
|
+
* chat.scrollToBottom()
|
|
269
|
+
* chat.scrollToBottom({ smooth: true })
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
export const scrollToBottom = (options?: { smooth?: boolean }) => {
|
|
254
273
|
if (!viewportEl) return
|
|
255
274
|
viewportEl.scrollTo({
|
|
256
275
|
top: viewportEl.scrollHeight,
|
|
@@ -258,7 +277,22 @@
|
|
|
258
277
|
})
|
|
259
278
|
}
|
|
260
279
|
|
|
261
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Scroll to a specific message by its ID.
|
|
282
|
+
*
|
|
283
|
+
* If the message ID is not found in the messages array, this is a no-op.
|
|
284
|
+
*
|
|
285
|
+
* @param id - The message ID as returned by `getMessageId`
|
|
286
|
+
* @param options - Optional configuration
|
|
287
|
+
* @param options.smooth - Use smooth scrolling animation (default: false)
|
|
288
|
+
*
|
|
289
|
+
* @example
|
|
290
|
+
* ```ts
|
|
291
|
+
* chat.scrollToMessage('msg-42')
|
|
292
|
+
* chat.scrollToMessage('msg-42', { smooth: true })
|
|
293
|
+
* ```
|
|
294
|
+
*/
|
|
295
|
+
export const scrollToMessage = (id: string, options?: { smooth?: boolean }) => {
|
|
262
296
|
const index = messages.findIndex((m) => getMessageId(m) === id)
|
|
263
297
|
if (index === -1) return
|
|
264
298
|
|
|
@@ -278,11 +312,40 @@
|
|
|
278
312
|
})
|
|
279
313
|
}
|
|
280
314
|
|
|
281
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Check if the viewport is currently following bottom.
|
|
317
|
+
*
|
|
318
|
+
* Returns `true` when the viewport is within `followBottomThresholdPx`
|
|
319
|
+
* of the bottom, meaning new messages will auto-scroll into view.
|
|
320
|
+
*
|
|
321
|
+
* @returns Whether the viewport is pinned to the bottom
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* ```ts
|
|
325
|
+
* if (chat.isAtBottom()) {
|
|
326
|
+
* // User sees the latest message
|
|
327
|
+
* }
|
|
328
|
+
* ```
|
|
329
|
+
*/
|
|
330
|
+
export const isAtBottom = (): boolean => {
|
|
282
331
|
return isFollowingBottom
|
|
283
332
|
}
|
|
284
333
|
|
|
285
|
-
|
|
334
|
+
/**
|
|
335
|
+
* Get a snapshot of the current internal state.
|
|
336
|
+
*
|
|
337
|
+
* Returns debug information including total messages, rendered DOM count,
|
|
338
|
+
* measured heights, visible range, scroll position, and follow state.
|
|
339
|
+
*
|
|
340
|
+
* @returns Current debug info snapshot
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* ```ts
|
|
344
|
+
* const info = chat.getDebugInfo()
|
|
345
|
+
* console.log(`${info.renderedCount}/${info.totalMessages} in DOM`)
|
|
346
|
+
* ```
|
|
347
|
+
*/
|
|
348
|
+
export const getDebugInfo = (): SvelteVirtualChatDebugInfo => {
|
|
286
349
|
return buildDebugInfo()
|
|
287
350
|
}
|
|
288
351
|
</script>
|
|
@@ -2,14 +2,66 @@ import type { SvelteVirtualChatProps, SvelteVirtualChatDebugInfo } from './types
|
|
|
2
2
|
declare function $$render<TMessage>(): {
|
|
3
3
|
props: SvelteVirtualChatProps<TMessage>;
|
|
4
4
|
exports: {
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Scroll the viewport to the bottom.
|
|
7
|
+
*
|
|
8
|
+
* @param options - Optional configuration
|
|
9
|
+
* @param options.smooth - Use smooth scrolling animation (default: false)
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* chat.scrollToBottom()
|
|
14
|
+
* chat.scrollToBottom({ smooth: true })
|
|
15
|
+
* ```
|
|
16
|
+
*/ scrollToBottom: (options?: {
|
|
6
17
|
smooth?: boolean;
|
|
7
18
|
}) => void;
|
|
8
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Scroll to a specific message by its ID.
|
|
21
|
+
*
|
|
22
|
+
* If the message ID is not found in the messages array, this is a no-op.
|
|
23
|
+
*
|
|
24
|
+
* @param id - The message ID as returned by `getMessageId`
|
|
25
|
+
* @param options - Optional configuration
|
|
26
|
+
* @param options.smooth - Use smooth scrolling animation (default: false)
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* chat.scrollToMessage('msg-42')
|
|
31
|
+
* chat.scrollToMessage('msg-42', { smooth: true })
|
|
32
|
+
* ```
|
|
33
|
+
*/ scrollToMessage: (id: string, options?: {
|
|
9
34
|
smooth?: boolean;
|
|
10
35
|
}) => void;
|
|
11
|
-
|
|
12
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Check if the viewport is currently following bottom.
|
|
38
|
+
*
|
|
39
|
+
* Returns `true` when the viewport is within `followBottomThresholdPx`
|
|
40
|
+
* of the bottom, meaning new messages will auto-scroll into view.
|
|
41
|
+
*
|
|
42
|
+
* @returns Whether the viewport is pinned to the bottom
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* if (chat.isAtBottom()) {
|
|
47
|
+
* // User sees the latest message
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
*/ isAtBottom: () => boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Get a snapshot of the current internal state.
|
|
53
|
+
*
|
|
54
|
+
* Returns debug information including total messages, rendered DOM count,
|
|
55
|
+
* measured heights, visible range, scroll position, and follow state.
|
|
56
|
+
*
|
|
57
|
+
* @returns Current debug info snapshot
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* const info = chat.getDebugInfo()
|
|
62
|
+
* console.log(`${info.renderedCount}/${info.totalMessages} in DOM`)
|
|
63
|
+
* ```
|
|
64
|
+
*/ getDebugInfo: () => SvelteVirtualChatDebugInfo;
|
|
13
65
|
};
|
|
14
66
|
bindings: "";
|
|
15
67
|
slots: {};
|
|
@@ -21,14 +73,66 @@ declare class __sveltets_Render<TMessage> {
|
|
|
21
73
|
slots(): ReturnType<typeof $$render<TMessage>>['slots'];
|
|
22
74
|
bindings(): "";
|
|
23
75
|
exports(): {
|
|
24
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Scroll the viewport to the bottom.
|
|
78
|
+
*
|
|
79
|
+
* @param options - Optional configuration
|
|
80
|
+
* @param options.smooth - Use smooth scrolling animation (default: false)
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* chat.scrollToBottom()
|
|
85
|
+
* chat.scrollToBottom({ smooth: true })
|
|
86
|
+
* ```
|
|
87
|
+
*/ scrollToBottom: (options?: {
|
|
25
88
|
smooth?: boolean;
|
|
26
89
|
} | undefined) => void;
|
|
27
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Scroll to a specific message by its ID.
|
|
92
|
+
*
|
|
93
|
+
* If the message ID is not found in the messages array, this is a no-op.
|
|
94
|
+
*
|
|
95
|
+
* @param id - The message ID as returned by `getMessageId`
|
|
96
|
+
* @param options - Optional configuration
|
|
97
|
+
* @param options.smooth - Use smooth scrolling animation (default: false)
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* chat.scrollToMessage('msg-42')
|
|
102
|
+
* chat.scrollToMessage('msg-42', { smooth: true })
|
|
103
|
+
* ```
|
|
104
|
+
*/ scrollToMessage: (id: string, options?: {
|
|
28
105
|
smooth?: boolean;
|
|
29
106
|
} | undefined) => void;
|
|
30
|
-
|
|
31
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Check if the viewport is currently following bottom.
|
|
109
|
+
*
|
|
110
|
+
* Returns `true` when the viewport is within `followBottomThresholdPx`
|
|
111
|
+
* of the bottom, meaning new messages will auto-scroll into view.
|
|
112
|
+
*
|
|
113
|
+
* @returns Whether the viewport is pinned to the bottom
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```ts
|
|
117
|
+
* if (chat.isAtBottom()) {
|
|
118
|
+
* // User sees the latest message
|
|
119
|
+
* }
|
|
120
|
+
* ```
|
|
121
|
+
*/ isAtBottom: () => boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Get a snapshot of the current internal state.
|
|
124
|
+
*
|
|
125
|
+
* Returns debug information including total messages, rendered DOM count,
|
|
126
|
+
* measured heights, visible range, scroll position, and follow state.
|
|
127
|
+
*
|
|
128
|
+
* @returns Current debug info snapshot
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```ts
|
|
132
|
+
* const info = chat.getDebugInfo()
|
|
133
|
+
* console.log(`${info.renderedCount}/${info.totalMessages} in DOM`)
|
|
134
|
+
* ```
|
|
135
|
+
*/ getDebugInfo: () => SvelteVirtualChatDebugInfo;
|
|
32
136
|
};
|
|
33
137
|
}
|
|
34
138
|
interface $$IsomorphicComponent {
|
|
@@ -4,14 +4,42 @@ import type { ScrollAnchor } from './chatTypes.js';
|
|
|
4
4
|
* Capture a scroll anchor before a history prepend operation.
|
|
5
5
|
*
|
|
6
6
|
* Records the first visible message and its pixel offset from the
|
|
7
|
-
* viewport top, so
|
|
7
|
+
* viewport top, so the same visual position can be restored after
|
|
8
8
|
* new messages are inserted above.
|
|
9
|
+
*
|
|
10
|
+
* @param messages - Current array of message objects
|
|
11
|
+
* @param getMessageId - Function to extract a unique ID from a message
|
|
12
|
+
* @param heightCache - The reactive height cache instance
|
|
13
|
+
* @param estimatedHeight - Fallback height in pixels for unmeasured messages
|
|
14
|
+
* @param scrollTop - Current scrollTop of the viewport
|
|
15
|
+
* @returns A scroll anchor object, or null if messages is empty
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const anchor = captureScrollAnchor(messages, (m) => m.id, cache, 72, viewport.scrollTop)
|
|
20
|
+
* // ... prepend older messages ...
|
|
21
|
+
* const newScrollTop = restoreScrollAnchor(anchor, messages, (m) => m.id, cache, 72)
|
|
22
|
+
* ```
|
|
9
23
|
*/
|
|
10
|
-
export declare
|
|
24
|
+
export declare const captureScrollAnchor: <T>(messages: T[], getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number, scrollTop: number) => ScrollAnchor | null;
|
|
11
25
|
/**
|
|
12
|
-
* Restore scroll position after a history prepend
|
|
26
|
+
* Restore scroll position after a history prepend using a previously captured anchor.
|
|
27
|
+
*
|
|
28
|
+
* Finds the anchor message in the (now larger) message array and calculates
|
|
29
|
+
* the scrollTop value that places it at the same visual offset from the
|
|
30
|
+
* viewport top as when the anchor was captured.
|
|
31
|
+
*
|
|
32
|
+
* @param anchor - The scroll anchor captured before prepend
|
|
33
|
+
* @param messages - Updated message array (with prepended messages)
|
|
34
|
+
* @param getMessageId - Function to extract a unique ID from a message
|
|
35
|
+
* @param heightCache - The reactive height cache instance
|
|
36
|
+
* @param estimatedHeight - Fallback height in pixels for unmeasured messages
|
|
37
|
+
* @returns The scrollTop value to restore the visual position, or 0 if anchor not found
|
|
13
38
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* const scrollTop = restoreScrollAnchor(anchor, messages, (m) => m.id, cache, 72)
|
|
42
|
+
* viewport.scrollTop = scrollTop
|
|
43
|
+
* ```
|
|
16
44
|
*/
|
|
17
|
-
export declare
|
|
45
|
+
export declare const restoreScrollAnchor: <T>(anchor: ScrollAnchor, messages: T[], getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number) => number;
|
|
@@ -3,10 +3,24 @@ import { calculateOffsetForIndex } from './chatMeasurement.svelte.js';
|
|
|
3
3
|
* Capture a scroll anchor before a history prepend operation.
|
|
4
4
|
*
|
|
5
5
|
* Records the first visible message and its pixel offset from the
|
|
6
|
-
* viewport top, so
|
|
6
|
+
* viewport top, so the same visual position can be restored after
|
|
7
7
|
* new messages are inserted above.
|
|
8
|
+
*
|
|
9
|
+
* @param messages - Current array of message objects
|
|
10
|
+
* @param getMessageId - Function to extract a unique ID from a message
|
|
11
|
+
* @param heightCache - The reactive height cache instance
|
|
12
|
+
* @param estimatedHeight - Fallback height in pixels for unmeasured messages
|
|
13
|
+
* @param scrollTop - Current scrollTop of the viewport
|
|
14
|
+
* @returns A scroll anchor object, or null if messages is empty
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const anchor = captureScrollAnchor(messages, (m) => m.id, cache, 72, viewport.scrollTop)
|
|
19
|
+
* // ... prepend older messages ...
|
|
20
|
+
* const newScrollTop = restoreScrollAnchor(anchor, messages, (m) => m.id, cache, 72)
|
|
21
|
+
* ```
|
|
8
22
|
*/
|
|
9
|
-
export
|
|
23
|
+
export const captureScrollAnchor = (messages, getMessageId, heightCache, estimatedHeight, scrollTop) => {
|
|
10
24
|
if (messages.length === 0)
|
|
11
25
|
return null;
|
|
12
26
|
let offsetY = 0;
|
|
@@ -22,23 +36,36 @@ export function captureScrollAnchor(messages, getMessageId, heightCache, estimat
|
|
|
22
36
|
}
|
|
23
37
|
offsetY += h;
|
|
24
38
|
}
|
|
25
|
-
// Fallback: anchor to last message
|
|
26
39
|
const lastId = getMessageId(messages[messages.length - 1]);
|
|
27
40
|
return {
|
|
28
41
|
messageId: lastId,
|
|
29
42
|
offsetFromViewportTop: offsetY - scrollTop - (heightCache.get(lastId) ?? estimatedHeight)
|
|
30
43
|
};
|
|
31
|
-
}
|
|
44
|
+
};
|
|
32
45
|
/**
|
|
33
|
-
* Restore scroll position after a history prepend
|
|
46
|
+
* Restore scroll position after a history prepend using a previously captured anchor.
|
|
47
|
+
*
|
|
48
|
+
* Finds the anchor message in the (now larger) message array and calculates
|
|
49
|
+
* the scrollTop value that places it at the same visual offset from the
|
|
50
|
+
* viewport top as when the anchor was captured.
|
|
51
|
+
*
|
|
52
|
+
* @param anchor - The scroll anchor captured before prepend
|
|
53
|
+
* @param messages - Updated message array (with prepended messages)
|
|
54
|
+
* @param getMessageId - Function to extract a unique ID from a message
|
|
55
|
+
* @param heightCache - The reactive height cache instance
|
|
56
|
+
* @param estimatedHeight - Fallback height in pixels for unmeasured messages
|
|
57
|
+
* @returns The scrollTop value to restore the visual position, or 0 if anchor not found
|
|
34
58
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* const scrollTop = restoreScrollAnchor(anchor, messages, (m) => m.id, cache, 72)
|
|
62
|
+
* viewport.scrollTop = scrollTop
|
|
63
|
+
* ```
|
|
37
64
|
*/
|
|
38
|
-
export
|
|
65
|
+
export const restoreScrollAnchor = (anchor, messages, getMessageId, heightCache, estimatedHeight) => {
|
|
39
66
|
const anchorIndex = messages.findIndex((m) => getMessageId(m) === anchor.messageId);
|
|
40
67
|
if (anchorIndex === -1)
|
|
41
68
|
return 0;
|
|
42
69
|
const anchorOffset = calculateOffsetForIndex(messages, anchorIndex, getMessageId, heightCache, estimatedHeight);
|
|
43
70
|
return anchorOffset - anchor.offsetFromViewportTop;
|
|
44
|
-
}
|
|
71
|
+
};
|
|
@@ -24,9 +24,38 @@ export declare class ChatHeightCache {
|
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* Calculate the total content height given messages and a height cache.
|
|
27
|
+
*
|
|
28
|
+
* Sums measured heights for known messages and uses the estimated height
|
|
29
|
+
* for any messages not yet measured by ResizeObserver.
|
|
30
|
+
*
|
|
31
|
+
* @param messages - Array of message objects
|
|
32
|
+
* @param getMessageId - Function to extract a unique ID from a message
|
|
33
|
+
* @param heightCache - The reactive height cache instance
|
|
34
|
+
* @param estimatedHeight - Fallback height in pixels for unmeasured messages
|
|
35
|
+
* @returns Total content height in pixels
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const total = calculateTotalHeight(messages, (m) => m.id, cache, 72)
|
|
40
|
+
* ```
|
|
27
41
|
*/
|
|
28
|
-
export declare
|
|
42
|
+
export declare const calculateTotalHeight: <T>(messages: T[], getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number) => number;
|
|
29
43
|
/**
|
|
30
44
|
* Calculate the Y offset for a message at a given index.
|
|
45
|
+
*
|
|
46
|
+
* Sums the heights of all messages before the target index to determine
|
|
47
|
+
* the pixel offset from the top of the content area.
|
|
48
|
+
*
|
|
49
|
+
* @param messages - Array of message objects
|
|
50
|
+
* @param index - Target index to calculate offset for
|
|
51
|
+
* @param getMessageId - Function to extract a unique ID from a message
|
|
52
|
+
* @param heightCache - The reactive height cache instance
|
|
53
|
+
* @param estimatedHeight - Fallback height in pixels for unmeasured messages
|
|
54
|
+
* @returns Pixel offset from the top of the content area
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* const offset = calculateOffsetForIndex(messages, 5, (m) => m.id, cache, 72)
|
|
59
|
+
* ```
|
|
31
60
|
*/
|
|
32
|
-
export declare
|
|
61
|
+
export declare const calculateOffsetForIndex: <T>(messages: T[], index: number, getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number) => number;
|
|
@@ -33,7 +33,6 @@ export class ChatHeightCache {
|
|
|
33
33
|
}
|
|
34
34
|
/** Number of measured entries. */
|
|
35
35
|
get size() {
|
|
36
|
-
// Access version to establish reactive dependency
|
|
37
36
|
void this.#version;
|
|
38
37
|
return Object.keys(this.#heights).length;
|
|
39
38
|
}
|
|
@@ -49,23 +48,52 @@ export class ChatHeightCache {
|
|
|
49
48
|
}
|
|
50
49
|
/**
|
|
51
50
|
* Calculate the total content height given messages and a height cache.
|
|
51
|
+
*
|
|
52
|
+
* Sums measured heights for known messages and uses the estimated height
|
|
53
|
+
* for any messages not yet measured by ResizeObserver.
|
|
54
|
+
*
|
|
55
|
+
* @param messages - Array of message objects
|
|
56
|
+
* @param getMessageId - Function to extract a unique ID from a message
|
|
57
|
+
* @param heightCache - The reactive height cache instance
|
|
58
|
+
* @param estimatedHeight - Fallback height in pixels for unmeasured messages
|
|
59
|
+
* @returns Total content height in pixels
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* const total = calculateTotalHeight(messages, (m) => m.id, cache, 72)
|
|
64
|
+
* ```
|
|
52
65
|
*/
|
|
53
|
-
export
|
|
66
|
+
export const calculateTotalHeight = (messages, getMessageId, heightCache, estimatedHeight) => {
|
|
54
67
|
let total = 0;
|
|
55
68
|
for (const msg of messages) {
|
|
56
69
|
const id = getMessageId(msg);
|
|
57
70
|
total += heightCache.get(id) ?? estimatedHeight;
|
|
58
71
|
}
|
|
59
72
|
return total;
|
|
60
|
-
}
|
|
73
|
+
};
|
|
61
74
|
/**
|
|
62
75
|
* Calculate the Y offset for a message at a given index.
|
|
76
|
+
*
|
|
77
|
+
* Sums the heights of all messages before the target index to determine
|
|
78
|
+
* the pixel offset from the top of the content area.
|
|
79
|
+
*
|
|
80
|
+
* @param messages - Array of message objects
|
|
81
|
+
* @param index - Target index to calculate offset for
|
|
82
|
+
* @param getMessageId - Function to extract a unique ID from a message
|
|
83
|
+
* @param heightCache - The reactive height cache instance
|
|
84
|
+
* @param estimatedHeight - Fallback height in pixels for unmeasured messages
|
|
85
|
+
* @returns Pixel offset from the top of the content area
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* const offset = calculateOffsetForIndex(messages, 5, (m) => m.id, cache, 72)
|
|
90
|
+
* ```
|
|
63
91
|
*/
|
|
64
|
-
export
|
|
92
|
+
export const calculateOffsetForIndex = (messages, index, getMessageId, heightCache, estimatedHeight) => {
|
|
65
93
|
let offset = 0;
|
|
66
94
|
for (let i = 0; i < index && i < messages.length; i++) {
|
|
67
95
|
const id = getMessageId(messages[i]);
|
|
68
96
|
offset += heightCache.get(id) ?? estimatedHeight;
|
|
69
97
|
}
|
|
70
98
|
return offset;
|
|
71
|
-
}
|
|
99
|
+
};
|
package/package.json
CHANGED
|
@@ -1,119 +1,117 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
"svelte": "./dist/index.js",
|
|
42
|
-
"types": "./dist/index.d.ts",
|
|
43
|
-
"files": [
|
|
44
|
-
"dist",
|
|
45
|
-
"!dist/**/*.test.*",
|
|
46
|
-
"!dist/**/*.spec.*",
|
|
47
|
-
"!dist/test/**/*"
|
|
48
|
-
],
|
|
49
|
-
"scripts": {
|
|
50
|
-
"build": "vite build && pnpm run package",
|
|
51
|
-
"cf-typegen": "pnpm --filter docs cf-typegen",
|
|
52
|
-
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
53
|
-
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
54
|
-
"dev": "vite dev",
|
|
55
|
-
"dev:all": "mprocs",
|
|
56
|
-
"dev:pkg": "svelte-kit sync && svelte-package --watch",
|
|
57
|
-
"format": "prettier --write .",
|
|
58
|
-
"lint": "prettier --check . && eslint .",
|
|
59
|
-
"lint:fix": "pnpm run format && eslint . --fix",
|
|
60
|
-
"package": "svelte-kit sync && svelte-package && publint",
|
|
61
|
-
"prepare": "npx husky",
|
|
62
|
-
"prepublishOnly": "npm run package",
|
|
63
|
-
"preview": "vite preview",
|
|
64
|
-
"test": "vitest run --coverage --",
|
|
65
|
-
"test:all": "pnpm run test && pnpm run test:e2e",
|
|
66
|
-
"test:e2e": "playwright test",
|
|
67
|
-
"test:only": "vitest run --",
|
|
68
|
-
"test:unit": "vitest run --coverage",
|
|
69
|
-
"test:watch": "vitest --"
|
|
70
|
-
},
|
|
71
|
-
"dependencies": {
|
|
72
|
-
"esm-env": "^1.2.2"
|
|
73
|
-
},
|
|
74
|
-
"devDependencies": {
|
|
75
|
-
"@eslint/compat": "^2.0.5",
|
|
76
|
-
"@eslint/js": "^10.0.1",
|
|
77
|
-
"@playwright/test": "^1.59.1",
|
|
78
|
-
"@sveltejs/adapter-auto": "^7.0.1",
|
|
79
|
-
"@sveltejs/kit": "^2.57.1",
|
|
80
|
-
"@sveltejs/package": "^2.5.7",
|
|
81
|
-
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
82
|
-
"@tailwindcss/vite": "^4.2.2",
|
|
83
|
-
"@testing-library/jest-dom": "^6.9.1",
|
|
84
|
-
"@testing-library/svelte": "^5.3.1",
|
|
85
|
-
"@testing-library/user-event": "^14.6.1",
|
|
86
|
-
"@types/node": "^25.6.0",
|
|
87
|
-
"@typescript-eslint/eslint-plugin": "^8.58.2",
|
|
88
|
-
"@typescript-eslint/parser": "^8.58.2",
|
|
89
|
-
"@vitest/coverage-v8": "^4.1.4",
|
|
90
|
-
"eslint": "^10.2.0",
|
|
91
|
-
"eslint-config-prettier": "^10.1.8",
|
|
92
|
-
"eslint-plugin-svelte": "^3.17.0",
|
|
93
|
-
"eslint-plugin-unused-imports": "^4.4.1",
|
|
94
|
-
"globals": "^17.5.0",
|
|
95
|
-
"jsdom": "^29.0.2",
|
|
96
|
-
"mprocs": "^0.9.2",
|
|
97
|
-
"prettier": "^3.8.2",
|
|
98
|
-
"prettier-plugin-organize-imports": "^4.3.0",
|
|
99
|
-
"prettier-plugin-svelte": "^3.5.1",
|
|
100
|
-
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
101
|
-
"publint": "^0.3.18",
|
|
102
|
-
"svelte": "^5.55.4",
|
|
103
|
-
"svelte-check": "^4.4.6",
|
|
104
|
-
"tailwindcss": "^4.2.2",
|
|
105
|
-
"typescript": "^6.0.2",
|
|
106
|
-
"typescript-eslint": "^8.58.2",
|
|
107
|
-
"vite": "^8.0.8",
|
|
108
|
-
"vitest": "^4.1.4"
|
|
109
|
-
},
|
|
110
|
-
"peerDependencies": {
|
|
111
|
-
"svelte": "^5.0.0"
|
|
112
|
-
},
|
|
113
|
-
"volta": {
|
|
114
|
-
"node": "24.13.0"
|
|
115
|
-
},
|
|
116
|
-
"publishConfig": {
|
|
117
|
-
"access": "public"
|
|
2
|
+
"name": "@humanspeak/svelte-virtual-chat",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "A high-performance virtual chat viewport for Svelte 5. Purpose-built for LLM conversations, support chat, and any message-based UI. Follow-bottom, streaming-stable, history-prepend with anchor preservation.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"svelte",
|
|
7
|
+
"virtual-chat",
|
|
8
|
+
"chat-ui",
|
|
9
|
+
"llm",
|
|
10
|
+
"virtual-scroll",
|
|
11
|
+
"streaming",
|
|
12
|
+
"ai-chat",
|
|
13
|
+
"svelte5",
|
|
14
|
+
"conversation",
|
|
15
|
+
"chat-viewport"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://virtualchat.svelte.page",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/humanspeak/svelte-virtual-chat/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/humanspeak/svelte-virtual-chat.git"
|
|
24
|
+
},
|
|
25
|
+
"funding": {
|
|
26
|
+
"type": "github",
|
|
27
|
+
"url": "https://github.com/sponsors/humanspeak"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": "Humanspeak, Inc.",
|
|
31
|
+
"sideEffects": [
|
|
32
|
+
"**/*.css"
|
|
33
|
+
],
|
|
34
|
+
"type": "module",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"svelte": "./dist/index.js"
|
|
118
39
|
}
|
|
119
|
-
}
|
|
40
|
+
},
|
|
41
|
+
"svelte": "./dist/index.js",
|
|
42
|
+
"types": "./dist/index.d.ts",
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"!dist/**/*.test.*",
|
|
46
|
+
"!dist/**/*.spec.*",
|
|
47
|
+
"!dist/test/**/*"
|
|
48
|
+
],
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"esm-env": "^1.2.2"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@eslint/compat": "^2.0.5",
|
|
54
|
+
"@eslint/js": "^10.0.1",
|
|
55
|
+
"@playwright/test": "^1.59.1",
|
|
56
|
+
"@sveltejs/adapter-auto": "^7.0.1",
|
|
57
|
+
"@sveltejs/kit": "^2.57.1",
|
|
58
|
+
"@sveltejs/package": "^2.5.7",
|
|
59
|
+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
60
|
+
"@tailwindcss/vite": "^4.2.2",
|
|
61
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
62
|
+
"@testing-library/svelte": "^5.3.1",
|
|
63
|
+
"@testing-library/user-event": "^14.6.1",
|
|
64
|
+
"@types/node": "^25.6.0",
|
|
65
|
+
"@typescript-eslint/eslint-plugin": "^8.58.2",
|
|
66
|
+
"@typescript-eslint/parser": "^8.58.2",
|
|
67
|
+
"@vitest/coverage-v8": "^4.1.4",
|
|
68
|
+
"eslint": "^10.2.0",
|
|
69
|
+
"eslint-config-prettier": "^10.1.8",
|
|
70
|
+
"eslint-plugin-svelte": "^3.17.0",
|
|
71
|
+
"eslint-plugin-unused-imports": "^4.4.1",
|
|
72
|
+
"globals": "^17.5.0",
|
|
73
|
+
"jsdom": "^29.0.2",
|
|
74
|
+
"mprocs": "^0.9.2",
|
|
75
|
+
"prettier": "^3.8.2",
|
|
76
|
+
"prettier-plugin-organize-imports": "^4.3.0",
|
|
77
|
+
"prettier-plugin-svelte": "^3.5.1",
|
|
78
|
+
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
79
|
+
"publint": "^0.3.18",
|
|
80
|
+
"svelte": "^5.55.4",
|
|
81
|
+
"svelte-check": "^4.4.6",
|
|
82
|
+
"tailwindcss": "^4.2.2",
|
|
83
|
+
"typescript": "^6.0.2",
|
|
84
|
+
"typescript-eslint": "^8.58.2",
|
|
85
|
+
"vite": "^8.0.8",
|
|
86
|
+
"vitest": "^4.1.4"
|
|
87
|
+
},
|
|
88
|
+
"peerDependencies": {
|
|
89
|
+
"svelte": "^5.0.0"
|
|
90
|
+
},
|
|
91
|
+
"volta": {
|
|
92
|
+
"node": "24.13.0"
|
|
93
|
+
},
|
|
94
|
+
"publishConfig": {
|
|
95
|
+
"access": "public"
|
|
96
|
+
},
|
|
97
|
+
"scripts": {
|
|
98
|
+
"build": "vite build && pnpm run package",
|
|
99
|
+
"cf-typegen": "pnpm --filter docs cf-typegen",
|
|
100
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
101
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
102
|
+
"dev": "vite dev",
|
|
103
|
+
"dev:all": "mprocs",
|
|
104
|
+
"dev:pkg": "svelte-kit sync && svelte-package --watch",
|
|
105
|
+
"format": "prettier --write .",
|
|
106
|
+
"lint": "prettier --check . && eslint .",
|
|
107
|
+
"lint:fix": "pnpm run format && eslint . --fix",
|
|
108
|
+
"package": "svelte-kit sync && svelte-package && publint",
|
|
109
|
+
"preview": "vite preview",
|
|
110
|
+
"test": "vitest run --coverage --",
|
|
111
|
+
"test:all": "pnpm run test && pnpm run test:e2e",
|
|
112
|
+
"test:e2e": "playwright test",
|
|
113
|
+
"test:only": "vitest run --",
|
|
114
|
+
"test:unit": "vitest run --coverage",
|
|
115
|
+
"test:watch": "vitest --"
|
|
116
|
+
}
|
|
117
|
+
}
|