@humanspeak/svelte-virtual-chat 0.0.1
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/LICENSE +20 -0
- package/README.md +299 -0
- package/dist/SvelteVirtualChat.svelte +324 -0
- package/dist/SvelteVirtualChat.svelte.d.ts +43 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.js +1 -0
- package/dist/virtual-chat/chatAnchoring.d.ts +17 -0
- package/dist/virtual-chat/chatAnchoring.js +44 -0
- package/dist/virtual-chat/chatMeasurement.svelte.d.ts +32 -0
- package/dist/virtual-chat/chatMeasurement.svelte.js +71 -0
- package/dist/virtual-chat/chatTypes.d.ts +31 -0
- package/dist/virtual-chat/chatTypes.js +1 -0
- package/package.json +119 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2026 Humanspeak, Inc.
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# @humanspeak/svelte-virtual-chat
|
|
2
|
+
|
|
3
|
+
A high-performance virtual chat viewport for Svelte 5. Purpose-built for LLM conversations, support chat, and any message-based UI. Renders only visible messages to the DOM while maintaining smooth follow-bottom behavior, streaming stability, and history prepend with anchor preservation.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@humanspeak/svelte-virtual-chat)
|
|
6
|
+
[](https://github.com/humanspeak/svelte-virtual-chat/actions/workflows/npm-publish.yml)
|
|
7
|
+
[](https://github.com/humanspeak/svelte-virtual-chat/blob/main/LICENSE)
|
|
8
|
+
[](https://www.npmjs.com/package/@humanspeak/svelte-virtual-chat)
|
|
9
|
+
[](https://packagephobia.com/result?p=@humanspeak/svelte-virtual-chat)
|
|
10
|
+
[](https://trunk.io)
|
|
11
|
+
[](http://www.typescriptlang.org/)
|
|
12
|
+
[](https://www.npmjs.com/package/@humanspeak/svelte-virtual-chat)
|
|
13
|
+
[](https://github.com/humanspeak/svelte-virtual-chat/graphs/commit-activity)
|
|
14
|
+
|
|
15
|
+
## Why This Exists
|
|
16
|
+
|
|
17
|
+
Chat UIs are not generic lists. They have specific behaviors that general-purpose virtual list components handle poorly:
|
|
18
|
+
|
|
19
|
+
- Messages anchor to the **bottom**, not the top
|
|
20
|
+
- New messages should **auto-scroll** when you're at the bottom
|
|
21
|
+
- Scrolling away should **not snap you back** when new messages arrive
|
|
22
|
+
- LLM token streaming causes messages to **grow in height** mid-render
|
|
23
|
+
- Loading older history should **preserve your scroll position**
|
|
24
|
+
|
|
25
|
+
`@humanspeak/svelte-virtual-chat` is opinionated about these behaviors so you don't have to fight a generic abstraction.
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Bottom gravity** — messages sit at the bottom of the viewport, like every chat app
|
|
30
|
+
- **Follow-bottom** — viewport stays pinned to the newest message while at bottom
|
|
31
|
+
- **Scroll-away stability** — new messages don't yank you back when you've scrolled up
|
|
32
|
+
- **Virtualized rendering** — only visible messages exist in the DOM (handles 10,000+ messages)
|
|
33
|
+
- **Streaming-native** — height changes from LLM token streaming are batched per frame
|
|
34
|
+
- **History prepend** — load older messages at the top without viewport jumping
|
|
35
|
+
- **Message-aware** — uses message IDs for identity, not array indices
|
|
36
|
+
- **Full TypeScript** — strict types, generics, and exported type definitions
|
|
37
|
+
- **Svelte 5 runes** — built with `$state`, `$derived`, `$effect`, and snippets
|
|
38
|
+
- **Debug info** — real-time stats via `onDebugInfo` callback (total, DOM count, measured, range, following state)
|
|
39
|
+
- **E2E tested** — 57 Playwright tests across 6 test suites
|
|
40
|
+
- **Zero dependencies** — only `esm-env` for SSR detection
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- Svelte 5
|
|
45
|
+
- Node.js 18+
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Using pnpm (recommended)
|
|
51
|
+
pnpm add @humanspeak/svelte-virtual-chat
|
|
52
|
+
|
|
53
|
+
# Using npm
|
|
54
|
+
npm install @humanspeak/svelte-virtual-chat
|
|
55
|
+
|
|
56
|
+
# Using yarn
|
|
57
|
+
yarn add @humanspeak/svelte-virtual-chat
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Basic Usage
|
|
61
|
+
|
|
62
|
+
```svelte
|
|
63
|
+
<script lang="ts">
|
|
64
|
+
import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'
|
|
65
|
+
|
|
66
|
+
type Message = { id: string; role: string; content: string }
|
|
67
|
+
|
|
68
|
+
let messages: Message[] = $state([
|
|
69
|
+
{ id: '1', role: 'assistant', content: 'Hello! How can I help?' },
|
|
70
|
+
{ id: '2', role: 'user', content: 'Tell me about Svelte.' }
|
|
71
|
+
])
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<div class="h-[600px]">
|
|
75
|
+
<SvelteVirtualChat
|
|
76
|
+
{messages}
|
|
77
|
+
getMessageId={(msg) => msg.id}
|
|
78
|
+
estimatedMessageHeight={72}
|
|
79
|
+
containerClass="h-full"
|
|
80
|
+
viewportClass="h-full"
|
|
81
|
+
>
|
|
82
|
+
{#snippet renderMessage(message, index)}
|
|
83
|
+
<div class="p-4 border-b">
|
|
84
|
+
<strong>{message.role}</strong>
|
|
85
|
+
<p>{message.content}</p>
|
|
86
|
+
</div>
|
|
87
|
+
{/snippet}
|
|
88
|
+
</SvelteVirtualChat>
|
|
89
|
+
</div>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Props
|
|
93
|
+
|
|
94
|
+
| Prop | Type | Default | Description |
|
|
95
|
+
| ------------------------- | -------------------------------------------- | -------- | ------------------------------------------------------- |
|
|
96
|
+
| `messages` | `TMessage[]` | Required | Array of messages in chronological order (oldest first) |
|
|
97
|
+
| `getMessageId` | `(msg: TMessage) => string` | Required | Extract a unique, stable ID from a message |
|
|
98
|
+
| `renderMessage` | `Snippet<[TMessage, number]>` | Required | Snippet that renders a single message |
|
|
99
|
+
| `estimatedMessageHeight` | `number` | `72` | Height estimate in pixels for unmeasured messages |
|
|
100
|
+
| `followBottomThresholdPx` | `number` | `48` | Distance from bottom to consider "at bottom" |
|
|
101
|
+
| `overscan` | `number` | `6` | Extra messages rendered above/below the viewport |
|
|
102
|
+
| `onNeedHistory` | `() => void \| Promise<void>` | - | Called when user scrolls near top (load older messages) |
|
|
103
|
+
| `onFollowBottomChange` | `(isFollowing: boolean) => void` | - | Called when follow-bottom state changes |
|
|
104
|
+
| `onDebugInfo` | `(info: SvelteVirtualChatDebugInfo) => void` | - | Called with live stats on every scroll/render update |
|
|
105
|
+
| `containerClass` | `string` | `''` | CSS class for the outermost container |
|
|
106
|
+
| `viewportClass` | `string` | `''` | CSS class for the scrollable viewport |
|
|
107
|
+
| `debug` | `boolean` | `false` | Enable console debug logging |
|
|
108
|
+
| `testId` | `string` | - | Base test ID for `data-testid` attributes |
|
|
109
|
+
|
|
110
|
+
## Imperative API
|
|
111
|
+
|
|
112
|
+
Bind the component to access these methods:
|
|
113
|
+
|
|
114
|
+
```svelte
|
|
115
|
+
<script lang="ts">
|
|
116
|
+
let chat: ReturnType<typeof SvelteVirtualChat>
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<SvelteVirtualChat bind:this={chat} ... />
|
|
120
|
+
|
|
121
|
+
<button onclick={() => chat.scrollToBottom({ smooth: true })}> Scroll to bottom </button>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Method | Signature | Description |
|
|
125
|
+
| ----------------- | ------------------------------------------------------ | --------------------------------------------------- |
|
|
126
|
+
| `scrollToBottom` | `(options?: { smooth?: boolean }) => void` | Scroll the viewport to the bottom |
|
|
127
|
+
| `scrollToMessage` | `(id: string, options?: { smooth?: boolean }) => void` | Scroll to a specific message by its ID |
|
|
128
|
+
| `isAtBottom` | `() => boolean` | Check if the viewport is currently following bottom |
|
|
129
|
+
| `getDebugInfo` | `() => SvelteVirtualChatDebugInfo` | Get a snapshot of current debug stats |
|
|
130
|
+
|
|
131
|
+
## LLM Streaming
|
|
132
|
+
|
|
133
|
+
The component handles streaming natively. As a message grows token by token, ResizeObserver detects the height change and the viewport stays pinned to bottom without jitter.
|
|
134
|
+
|
|
135
|
+
Pair with [@humanspeak/svelte-markdown](https://www.npmjs.com/package/@humanspeak/svelte-markdown) for rich markdown rendering with streaming support:
|
|
136
|
+
|
|
137
|
+
```svelte
|
|
138
|
+
<script lang="ts">
|
|
139
|
+
import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'
|
|
140
|
+
import SvelteMarkdown from '@humanspeak/svelte-markdown'
|
|
141
|
+
|
|
142
|
+
type Message = {
|
|
143
|
+
id: string
|
|
144
|
+
role: 'user' | 'assistant'
|
|
145
|
+
content: string
|
|
146
|
+
isStreaming?: boolean
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let messages: Message[] = $state([...])
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<SvelteVirtualChat
|
|
153
|
+
{messages}
|
|
154
|
+
getMessageId={(msg) => msg.id}
|
|
155
|
+
containerClass="h-[600px]"
|
|
156
|
+
viewportClass="h-full"
|
|
157
|
+
>
|
|
158
|
+
{#snippet renderMessage(message, index)}
|
|
159
|
+
<div class="p-4 border-b">
|
|
160
|
+
{#if message.role === 'assistant'}
|
|
161
|
+
<SvelteMarkdown source={message.content} streaming={message.isStreaming ?? false} />
|
|
162
|
+
{:else}
|
|
163
|
+
<p>{message.content}</p>
|
|
164
|
+
{/if}
|
|
165
|
+
</div>
|
|
166
|
+
{/snippet}
|
|
167
|
+
</SvelteVirtualChat>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## History Loading
|
|
171
|
+
|
|
172
|
+
Load older messages when the user scrolls near the top. The component preserves the user's scroll position during prepend operations.
|
|
173
|
+
|
|
174
|
+
```svelte
|
|
175
|
+
<script lang="ts">
|
|
176
|
+
import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'
|
|
177
|
+
|
|
178
|
+
let messages = $state([...recentMessages])
|
|
179
|
+
let isLoading = false
|
|
180
|
+
|
|
181
|
+
async function loadHistory() {
|
|
182
|
+
if (isLoading) return
|
|
183
|
+
isLoading = true
|
|
184
|
+
const older = await fetchOlderMessages()
|
|
185
|
+
messages = [...older, ...messages]
|
|
186
|
+
isLoading = false
|
|
187
|
+
}
|
|
188
|
+
</script>
|
|
189
|
+
|
|
190
|
+
<SvelteVirtualChat
|
|
191
|
+
{messages}
|
|
192
|
+
getMessageId={(msg) => msg.id}
|
|
193
|
+
onNeedHistory={loadHistory}
|
|
194
|
+
containerClass="h-[600px]"
|
|
195
|
+
viewportClass="h-full"
|
|
196
|
+
>
|
|
197
|
+
{#snippet renderMessage(message, index)}
|
|
198
|
+
<div class="p-4">{message.content}</div>
|
|
199
|
+
{/snippet}
|
|
200
|
+
</SvelteVirtualChat>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Debug Info
|
|
204
|
+
|
|
205
|
+
The `onDebugInfo` callback provides real-time visibility into the component's internal state:
|
|
206
|
+
|
|
207
|
+
```svelte
|
|
208
|
+
<script lang="ts">
|
|
209
|
+
import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'
|
|
210
|
+
import type { SvelteVirtualChatDebugInfo } from '@humanspeak/svelte-virtual-chat'
|
|
211
|
+
|
|
212
|
+
let stats: SvelteVirtualChatDebugInfo | null = $state(null)
|
|
213
|
+
</script>
|
|
214
|
+
|
|
215
|
+
<SvelteVirtualChat
|
|
216
|
+
{messages}
|
|
217
|
+
getMessageId={(msg) => msg.id}
|
|
218
|
+
onDebugInfo={(info) => (stats = info)}
|
|
219
|
+
...
|
|
220
|
+
/>
|
|
221
|
+
|
|
222
|
+
{#if stats}
|
|
223
|
+
<div>
|
|
224
|
+
Total: {stats.totalMessages} | In DOM: {stats.renderedCount} | Following: {stats.isFollowingBottom}
|
|
225
|
+
</div>
|
|
226
|
+
{/if}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
| Field | Type | Description |
|
|
230
|
+
| ------------------- | --------- | ---------------------------------------- |
|
|
231
|
+
| `totalMessages` | `number` | Total messages in the array |
|
|
232
|
+
| `renderedCount` | `number` | Messages currently in the DOM |
|
|
233
|
+
| `measuredCount` | `number` | Messages with measured heights |
|
|
234
|
+
| `startIndex` | `number` | First rendered index |
|
|
235
|
+
| `endIndex` | `number` | Last rendered index |
|
|
236
|
+
| `totalHeight` | `number` | Calculated total content height (px) |
|
|
237
|
+
| `scrollTop` | `number` | Current scroll position (px) |
|
|
238
|
+
| `viewportHeight` | `number` | Viewport height (px) |
|
|
239
|
+
| `isFollowingBottom` | `boolean` | Whether the viewport is pinned to bottom |
|
|
240
|
+
| `averageHeight` | `number` | Average measured message height (px) |
|
|
241
|
+
|
|
242
|
+
## TypeScript
|
|
243
|
+
|
|
244
|
+
Full type exports for building typed wrappers and extensions:
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
import type {
|
|
248
|
+
SvelteVirtualChatProps,
|
|
249
|
+
SvelteVirtualChatDebugInfo,
|
|
250
|
+
ScrollToBottomOptions,
|
|
251
|
+
ScrollToMessageOptions,
|
|
252
|
+
VisibleRange,
|
|
253
|
+
ScrollAnchor
|
|
254
|
+
} from '@humanspeak/svelte-virtual-chat'
|
|
255
|
+
|
|
256
|
+
// Utility exports
|
|
257
|
+
import {
|
|
258
|
+
ChatHeightCache,
|
|
259
|
+
captureScrollAnchor,
|
|
260
|
+
restoreScrollAnchor
|
|
261
|
+
} from '@humanspeak/svelte-virtual-chat'
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## How Virtualization Works
|
|
265
|
+
|
|
266
|
+
The component uses standard top-to-bottom geometry (no inverted lists):
|
|
267
|
+
|
|
268
|
+
1. **Height caching** — Each message's height is measured via ResizeObserver and cached by ID
|
|
269
|
+
2. **Visible range** — On every scroll, the component calculates which messages fall within `scrollTop` to `scrollTop + viewportHeight`, plus an overscan buffer
|
|
270
|
+
3. **Absolute positioning** — Only visible messages are rendered, positioned via `transform: translateY()` inside a content div sized to the total calculated height
|
|
271
|
+
4. **Follow-bottom** — When at bottom, new messages and height changes trigger an automatic snap to `scrollHeight`
|
|
272
|
+
5. **Bottom gravity** — When messages don't fill the viewport, `flex-direction: column; justify-content: flex-end` pushes them to the bottom
|
|
273
|
+
|
|
274
|
+
With 10,000 messages, the DOM contains ~15-25 elements instead of 10,000.
|
|
275
|
+
|
|
276
|
+
## Performance
|
|
277
|
+
|
|
278
|
+
| Metric | Value |
|
|
279
|
+
| ------------------------------ | ---------------------------------------- |
|
|
280
|
+
| DOM nodes with 1,000 messages | ~15-25 (viewport + overscan) |
|
|
281
|
+
| DOM nodes with 10,000 messages | ~15-25 (same) |
|
|
282
|
+
| Follow-bottom snap | Single `requestAnimationFrame` per batch |
|
|
283
|
+
| Height measurement | ResizeObserver (no polling) |
|
|
284
|
+
| Streaming height updates | Batched per animation frame |
|
|
285
|
+
|
|
286
|
+
## Companion Libraries
|
|
287
|
+
|
|
288
|
+
| Package | Description |
|
|
289
|
+
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- |
|
|
290
|
+
| [@humanspeak/svelte-markdown](https://www.npmjs.com/package/@humanspeak/svelte-markdown) | Markdown renderer with LLM streaming mode (~1.6ms per update) |
|
|
291
|
+
| [@humanspeak/svelte-virtual-list](https://www.npmjs.com/package/@humanspeak/svelte-virtual-list) | General-purpose virtual list for non-chat use cases |
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
MIT © [Humanspeak, Inc.](LICENSE)
|
|
296
|
+
|
|
297
|
+
## Credits
|
|
298
|
+
|
|
299
|
+
Made with ❤️ by [Humanspeak](https://humanspeak.com)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
<script lang="ts" generics="TMessage">
|
|
2
|
+
import type { SvelteVirtualChatProps, SvelteVirtualChatDebugInfo } from './types.js'
|
|
3
|
+
import type { VisibleRange } from './virtual-chat/chatTypes.js'
|
|
4
|
+
import {
|
|
5
|
+
ChatHeightCache,
|
|
6
|
+
calculateTotalHeight,
|
|
7
|
+
calculateOffsetForIndex
|
|
8
|
+
} from './virtual-chat/chatMeasurement.svelte.js'
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
messages,
|
|
12
|
+
getMessageId,
|
|
13
|
+
estimatedMessageHeight = 72,
|
|
14
|
+
followBottomThresholdPx = 48,
|
|
15
|
+
overscan = 6,
|
|
16
|
+
renderMessage,
|
|
17
|
+
onNeedHistory,
|
|
18
|
+
onFollowBottomChange,
|
|
19
|
+
onDebugInfo,
|
|
20
|
+
containerClass = '',
|
|
21
|
+
viewportClass = '',
|
|
22
|
+
debug = false,
|
|
23
|
+
testId
|
|
24
|
+
}: SvelteVirtualChatProps<TMessage> = $props()
|
|
25
|
+
|
|
26
|
+
// ── DOM refs ────────────────────────────────────────────────────
|
|
27
|
+
let viewportEl: HTMLDivElement | undefined = $state()
|
|
28
|
+
|
|
29
|
+
// ── Core state ──────────────────────────────────────────────────
|
|
30
|
+
const heightCache = new ChatHeightCache()
|
|
31
|
+
let scrollTop = $state(0)
|
|
32
|
+
let viewportHeight = $state(0)
|
|
33
|
+
let isFollowingBottom = $state(true)
|
|
34
|
+
let pendingSnapToBottom = $state(false)
|
|
35
|
+
|
|
36
|
+
// ── Derived: total content height ───────────────────────────────
|
|
37
|
+
const totalHeight = $derived.by(() => {
|
|
38
|
+
void heightCache.version
|
|
39
|
+
return calculateTotalHeight(messages, getMessageId, heightCache, estimatedMessageHeight)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// ── Derived: top gap for bottom-gravity ─────────────────────────
|
|
43
|
+
// When content is shorter than viewport, push messages to the bottom
|
|
44
|
+
const topGap = $derived(Math.max(0, viewportHeight - totalHeight))
|
|
45
|
+
|
|
46
|
+
// ── Derived: visible range ──────────────────────────────────────
|
|
47
|
+
const visibleRange: VisibleRange = $derived.by(() => {
|
|
48
|
+
void heightCache.version
|
|
49
|
+
|
|
50
|
+
if (messages.length === 0 || viewportHeight === 0) {
|
|
51
|
+
return { start: 0, end: 0, visibleStart: 0, visibleEnd: 0 }
|
|
52
|
+
}
|
|
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
|
+
const messageScrollTop = Math.max(0, scrollTop - topGap)
|
|
57
|
+
const viewTop = messageScrollTop
|
|
58
|
+
const viewBottom = messageScrollTop + viewportHeight
|
|
59
|
+
|
|
60
|
+
let offsetY = 0
|
|
61
|
+
let visibleStart = -1
|
|
62
|
+
let visibleEnd = -1
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < messages.length; i++) {
|
|
65
|
+
const id = getMessageId(messages[i])
|
|
66
|
+
const h = heightCache.get(id) ?? estimatedMessageHeight
|
|
67
|
+
const itemTop = offsetY
|
|
68
|
+
const itemBottom = offsetY + h
|
|
69
|
+
|
|
70
|
+
if (itemBottom > viewTop && visibleStart === -1) {
|
|
71
|
+
visibleStart = i
|
|
72
|
+
}
|
|
73
|
+
if (itemTop < viewBottom) {
|
|
74
|
+
visibleEnd = i
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
offsetY += h
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (visibleStart === -1) visibleStart = 0
|
|
81
|
+
if (visibleEnd === -1) visibleEnd = 0
|
|
82
|
+
|
|
83
|
+
const start = Math.max(0, visibleStart - overscan)
|
|
84
|
+
const end = Math.min(messages.length - 1, visibleEnd + overscan)
|
|
85
|
+
|
|
86
|
+
return { start, end, visibleStart, visibleEnd }
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// ── Derived: offset for the first rendered item ─────────────────
|
|
90
|
+
const startOffset = $derived.by(() => {
|
|
91
|
+
void heightCache.version
|
|
92
|
+
return calculateOffsetForIndex(
|
|
93
|
+
messages,
|
|
94
|
+
visibleRange.start,
|
|
95
|
+
getMessageId,
|
|
96
|
+
heightCache,
|
|
97
|
+
estimatedMessageHeight
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ── Derived: slice of messages to render ────────────────────────
|
|
102
|
+
const renderedMessages = $derived(
|
|
103
|
+
messages.length === 0 ? [] : messages.slice(visibleRange.start, visibleRange.end + 1)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// ── Scroll event handler ────────────────────────────────────────
|
|
107
|
+
function handleScroll() {
|
|
108
|
+
if (!viewportEl) return
|
|
109
|
+
scrollTop = viewportEl.scrollTop
|
|
110
|
+
|
|
111
|
+
const maxScroll = viewportEl.scrollHeight - viewportEl.clientHeight
|
|
112
|
+
const wasFollowing = isFollowingBottom
|
|
113
|
+
isFollowingBottom = maxScroll <= 0 || maxScroll - scrollTop <= followBottomThresholdPx
|
|
114
|
+
|
|
115
|
+
if (wasFollowing !== isFollowingBottom) {
|
|
116
|
+
onFollowBottomChange?.(isFollowingBottom)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Trigger history loading when near top
|
|
120
|
+
if (onNeedHistory && scrollTop - topGap < viewportHeight * 0.5) {
|
|
121
|
+
onNeedHistory()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Measurement action ──────────────────────────────────────────
|
|
126
|
+
let snapNeeded = false
|
|
127
|
+
|
|
128
|
+
function scheduleSnapToBottom() {
|
|
129
|
+
if (!isFollowingBottom || !viewportEl) return
|
|
130
|
+
snapNeeded = true
|
|
131
|
+
if (pendingSnapToBottom) return // rAF already scheduled, it will re-check
|
|
132
|
+
pendingSnapToBottom = true
|
|
133
|
+
requestAnimationFrame(() => {
|
|
134
|
+
pendingSnapToBottom = false
|
|
135
|
+
if (snapNeeded && isFollowingBottom) {
|
|
136
|
+
snapNeeded = false
|
|
137
|
+
snapToBottom()
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function measureMessage(node: HTMLElement, messageId: string) {
|
|
143
|
+
const observer = new ResizeObserver((entries) => {
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height
|
|
146
|
+
if (height > 0) {
|
|
147
|
+
const changed = heightCache.set(messageId, height)
|
|
148
|
+
if (changed) {
|
|
149
|
+
scheduleSnapToBottom()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
observer.observe(node)
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
update(newMessageId: string) {
|
|
158
|
+
if (newMessageId !== messageId) {
|
|
159
|
+
messageId = newMessageId
|
|
160
|
+
const height = node.getBoundingClientRect().height
|
|
161
|
+
if (height > 0) {
|
|
162
|
+
heightCache.set(messageId, height)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
destroy() {
|
|
167
|
+
observer.disconnect()
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Snap to bottom helper ───────────────────────────────────────
|
|
173
|
+
function snapToBottom() {
|
|
174
|
+
if (!viewportEl) return
|
|
175
|
+
const maxScroll = viewportEl.scrollHeight - viewportEl.clientHeight
|
|
176
|
+
if (maxScroll > 0) {
|
|
177
|
+
viewportEl.scrollTop = viewportEl.scrollHeight
|
|
178
|
+
}
|
|
179
|
+
// Sync the following state directly (don't wait for scroll event)
|
|
180
|
+
scrollTop = viewportEl.scrollTop
|
|
181
|
+
isFollowingBottom = true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Follow-bottom on new messages ───────────────────────────────
|
|
185
|
+
$effect(() => {
|
|
186
|
+
void messages.length
|
|
187
|
+
if (isFollowingBottom && viewportEl) {
|
|
188
|
+
requestAnimationFrame(() => snapToBottom())
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// ── Follow-bottom on height changes ─────────────────────────────
|
|
193
|
+
// When measurements arrive, totalHeight changes. If following, re-snap.
|
|
194
|
+
$effect(() => {
|
|
195
|
+
void totalHeight
|
|
196
|
+
if (isFollowingBottom && viewportEl) {
|
|
197
|
+
scheduleSnapToBottom()
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// ── Viewport resize tracking ────────────────────────────────────
|
|
202
|
+
$effect(() => {
|
|
203
|
+
if (!viewportEl) return
|
|
204
|
+
viewportHeight = viewportEl.clientHeight
|
|
205
|
+
|
|
206
|
+
const observer = new ResizeObserver(() => {
|
|
207
|
+
if (viewportEl) {
|
|
208
|
+
viewportHeight = viewportEl.clientHeight
|
|
209
|
+
// On initial layout (or resize), snap to bottom if following
|
|
210
|
+
if (isFollowingBottom) {
|
|
211
|
+
requestAnimationFrame(() => snapToBottom())
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
observer.observe(viewportEl)
|
|
216
|
+
return () => observer.disconnect()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// ── Debug info builder ─────────────────────────────────────────
|
|
220
|
+
function buildDebugInfo(): SvelteVirtualChatDebugInfo {
|
|
221
|
+
const measuredCount = heightCache.size
|
|
222
|
+
return {
|
|
223
|
+
totalMessages: messages.length,
|
|
224
|
+
renderedCount: renderedMessages.length,
|
|
225
|
+
measuredCount,
|
|
226
|
+
startIndex: visibleRange.start,
|
|
227
|
+
endIndex: visibleRange.end,
|
|
228
|
+
totalHeight,
|
|
229
|
+
scrollTop,
|
|
230
|
+
viewportHeight,
|
|
231
|
+
isFollowingBottom,
|
|
232
|
+
averageHeight:
|
|
233
|
+
measuredCount > 0
|
|
234
|
+
? Math.round(totalHeight / messages.length)
|
|
235
|
+
: estimatedMessageHeight
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Debug effect: log + callback ────────────────────────────────
|
|
240
|
+
$effect(() => {
|
|
241
|
+
void renderedMessages.length
|
|
242
|
+
void heightCache.version
|
|
243
|
+
void scrollTop
|
|
244
|
+
void isFollowingBottom
|
|
245
|
+
|
|
246
|
+
const info = buildDebugInfo()
|
|
247
|
+
if (debug) console.log('[SvelteVirtualChat]', info)
|
|
248
|
+
onDebugInfo?.(info)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// ── Public API ──────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
export function scrollToBottom(options?: { smooth?: boolean }) {
|
|
254
|
+
if (!viewportEl) return
|
|
255
|
+
viewportEl.scrollTo({
|
|
256
|
+
top: viewportEl.scrollHeight,
|
|
257
|
+
behavior: options?.smooth ? 'smooth' : 'instant'
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function scrollToMessage(id: string, options?: { smooth?: boolean }) {
|
|
262
|
+
const index = messages.findIndex((m) => getMessageId(m) === id)
|
|
263
|
+
if (index === -1) return
|
|
264
|
+
|
|
265
|
+
const offset =
|
|
266
|
+
topGap +
|
|
267
|
+
calculateOffsetForIndex(
|
|
268
|
+
messages,
|
|
269
|
+
index,
|
|
270
|
+
getMessageId,
|
|
271
|
+
heightCache,
|
|
272
|
+
estimatedMessageHeight
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
viewportEl?.scrollTo({
|
|
276
|
+
top: offset,
|
|
277
|
+
behavior: options?.smooth ? 'smooth' : 'instant'
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function isAtBottom(): boolean {
|
|
282
|
+
return isFollowingBottom
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function getDebugInfo(): SvelteVirtualChatDebugInfo {
|
|
286
|
+
return buildDebugInfo()
|
|
287
|
+
}
|
|
288
|
+
</script>
|
|
289
|
+
|
|
290
|
+
<div
|
|
291
|
+
class={containerClass}
|
|
292
|
+
data-testid={testId ? `${testId}-container` : undefined}
|
|
293
|
+
style="display: flex; flex-direction: column; overflow: hidden;"
|
|
294
|
+
>
|
|
295
|
+
<div
|
|
296
|
+
bind:this={viewportEl}
|
|
297
|
+
class={viewportClass}
|
|
298
|
+
onscroll={handleScroll}
|
|
299
|
+
style="overflow-y: auto; flex: 1 1 0%; min-height: 0;"
|
|
300
|
+
data-testid={testId ? `${testId}-viewport` : undefined}
|
|
301
|
+
>
|
|
302
|
+
<div
|
|
303
|
+
style="min-height: 100%; position: relative; width: 100%; display: flex; flex-direction: column; justify-content: flex-end;"
|
|
304
|
+
data-testid={testId ? `${testId}-content` : undefined}
|
|
305
|
+
>
|
|
306
|
+
<div style="height: {totalHeight}px; position: relative; flex-shrink: 0;">
|
|
307
|
+
<div
|
|
308
|
+
style="position: absolute; top: 0; left: 0; right: 0; transform: translateY({startOffset}px);"
|
|
309
|
+
>
|
|
310
|
+
{#each renderedMessages as message, i (getMessageId(message))}
|
|
311
|
+
{@const globalIndex = visibleRange.start + i}
|
|
312
|
+
<div
|
|
313
|
+
use:measureMessage={getMessageId(message)}
|
|
314
|
+
data-testid={testId ? `${testId}-item-${globalIndex}` : undefined}
|
|
315
|
+
data-message-id={getMessageId(message)}
|
|
316
|
+
>
|
|
317
|
+
{@render renderMessage(message, globalIndex)}
|
|
318
|
+
</div>
|
|
319
|
+
{/each}
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SvelteVirtualChatProps, SvelteVirtualChatDebugInfo } from './types.js';
|
|
2
|
+
declare function $$render<TMessage>(): {
|
|
3
|
+
props: SvelteVirtualChatProps<TMessage>;
|
|
4
|
+
exports: {
|
|
5
|
+
scrollToBottom: (options?: {
|
|
6
|
+
smooth?: boolean;
|
|
7
|
+
}) => void;
|
|
8
|
+
scrollToMessage: (id: string, options?: {
|
|
9
|
+
smooth?: boolean;
|
|
10
|
+
}) => void;
|
|
11
|
+
isAtBottom: () => boolean;
|
|
12
|
+
getDebugInfo: () => SvelteVirtualChatDebugInfo;
|
|
13
|
+
};
|
|
14
|
+
bindings: "";
|
|
15
|
+
slots: {};
|
|
16
|
+
events: {};
|
|
17
|
+
};
|
|
18
|
+
declare class __sveltets_Render<TMessage> {
|
|
19
|
+
props(): ReturnType<typeof $$render<TMessage>>['props'];
|
|
20
|
+
events(): ReturnType<typeof $$render<TMessage>>['events'];
|
|
21
|
+
slots(): ReturnType<typeof $$render<TMessage>>['slots'];
|
|
22
|
+
bindings(): "";
|
|
23
|
+
exports(): {
|
|
24
|
+
scrollToBottom: (options?: {
|
|
25
|
+
smooth?: boolean;
|
|
26
|
+
} | undefined) => void;
|
|
27
|
+
scrollToMessage: (id: string, options?: {
|
|
28
|
+
smooth?: boolean;
|
|
29
|
+
} | undefined) => void;
|
|
30
|
+
isAtBottom: () => boolean;
|
|
31
|
+
getDebugInfo: () => SvelteVirtualChatDebugInfo;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
interface $$IsomorphicComponent {
|
|
35
|
+
new <TMessage>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<TMessage>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<TMessage>['props']>, ReturnType<__sveltets_Render<TMessage>['events']>, ReturnType<__sveltets_Render<TMessage>['slots']>> & {
|
|
36
|
+
$$bindings?: ReturnType<__sveltets_Render<TMessage>['bindings']>;
|
|
37
|
+
} & ReturnType<__sveltets_Render<TMessage>['exports']>;
|
|
38
|
+
<TMessage>(internal: unknown, props: ReturnType<__sveltets_Render<TMessage>['props']> & {}): ReturnType<__sveltets_Render<TMessage>['exports']>;
|
|
39
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
40
|
+
}
|
|
41
|
+
declare const SvelteVirtualChat: $$IsomorphicComponent;
|
|
42
|
+
type SvelteVirtualChat<TMessage> = InstanceType<typeof SvelteVirtualChat<TMessage>>;
|
|
43
|
+
export default SvelteVirtualChat;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import SvelteVirtualChat from './SvelteVirtualChat.svelte';
|
|
2
|
+
export type { ScrollToBottomOptions, ScrollToMessageOptions, SvelteVirtualChatDebugInfo, SvelteVirtualChatProps } from './types.js';
|
|
3
|
+
export type { ScrollAnchor, VisibleRange } from './virtual-chat/chatTypes.js';
|
|
4
|
+
export { ChatHeightCache } from './virtual-chat/chatMeasurement.svelte.js';
|
|
5
|
+
export { captureScrollAnchor, restoreScrollAnchor } from './virtual-chat/chatAnchoring.js';
|
|
6
|
+
export default SvelteVirtualChat;
|
package/dist/index.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Configuration properties for the SvelteVirtualChat component.
|
|
4
|
+
*/
|
|
5
|
+
export type SvelteVirtualChatProps<TMessage = any> = {
|
|
6
|
+
/**
|
|
7
|
+
* Array of messages in chronological order (oldest first).
|
|
8
|
+
*/
|
|
9
|
+
messages: TMessage[];
|
|
10
|
+
/**
|
|
11
|
+
* Extract a unique, stable identifier from a message.
|
|
12
|
+
* Used for height caching, keyed rendering, and scroll-to-message.
|
|
13
|
+
*/
|
|
14
|
+
getMessageId: (_message: TMessage) => string;
|
|
15
|
+
/**
|
|
16
|
+
* Estimated height in pixels for messages not yet measured.
|
|
17
|
+
* @default 72
|
|
18
|
+
*/
|
|
19
|
+
estimatedMessageHeight?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Distance in pixels from the bottom at which the viewport
|
|
22
|
+
* is considered "at bottom" for follow-bottom behavior.
|
|
23
|
+
* @default 48
|
|
24
|
+
*/
|
|
25
|
+
followBottomThresholdPx?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Number of extra messages to render above and below the visible area.
|
|
28
|
+
* @default 6
|
|
29
|
+
*/
|
|
30
|
+
overscan?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Snippet that renders a single message.
|
|
33
|
+
* Receives the message object and its index in the messages array.
|
|
34
|
+
*/
|
|
35
|
+
renderMessage: Snippet<[message: TMessage, index: number]>;
|
|
36
|
+
/**
|
|
37
|
+
* Called when the user scrolls near the top, signaling that older
|
|
38
|
+
* history should be loaded.
|
|
39
|
+
*/
|
|
40
|
+
onNeedHistory?: () => void | Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Called when the follow-bottom state changes.
|
|
43
|
+
*/
|
|
44
|
+
onFollowBottomChange?: (_isFollowing: boolean) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Called whenever debug info updates (on scroll, height changes, message changes).
|
|
47
|
+
* Use this to display live stats in your UI.
|
|
48
|
+
*/
|
|
49
|
+
onDebugInfo?: (_info: SvelteVirtualChatDebugInfo) => void;
|
|
50
|
+
/**
|
|
51
|
+
* CSS class for the outermost container element.
|
|
52
|
+
*/
|
|
53
|
+
containerClass?: string;
|
|
54
|
+
/**
|
|
55
|
+
* CSS class for the scrollable viewport element.
|
|
56
|
+
*/
|
|
57
|
+
viewportClass?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Enable debug logging and stats.
|
|
60
|
+
* @default false
|
|
61
|
+
*/
|
|
62
|
+
debug?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Base test ID for E2E testing attributes.
|
|
65
|
+
*/
|
|
66
|
+
testId?: string;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Debug information emitted by the chat viewport.
|
|
70
|
+
*/
|
|
71
|
+
export type SvelteVirtualChatDebugInfo = {
|
|
72
|
+
totalMessages: number;
|
|
73
|
+
renderedCount: number;
|
|
74
|
+
measuredCount: number;
|
|
75
|
+
startIndex: number;
|
|
76
|
+
endIndex: number;
|
|
77
|
+
totalHeight: number;
|
|
78
|
+
scrollTop: number;
|
|
79
|
+
viewportHeight: number;
|
|
80
|
+
isFollowingBottom: boolean;
|
|
81
|
+
averageHeight: number;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Options for the scrollToBottom imperative method.
|
|
85
|
+
*/
|
|
86
|
+
export type ScrollToBottomOptions = {
|
|
87
|
+
/** Use smooth scrolling animation. @default false */
|
|
88
|
+
smooth?: boolean;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Options for the scrollToMessage imperative method.
|
|
92
|
+
*/
|
|
93
|
+
export type ScrollToMessageOptions = {
|
|
94
|
+
/** Use smooth scrolling animation. @default false */
|
|
95
|
+
smooth?: boolean;
|
|
96
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ChatHeightCache } from './chatMeasurement.svelte.js';
|
|
2
|
+
import type { ScrollAnchor } from './chatTypes.js';
|
|
3
|
+
/**
|
|
4
|
+
* Capture a scroll anchor before a history prepend operation.
|
|
5
|
+
*
|
|
6
|
+
* Records the first visible message and its pixel offset from the
|
|
7
|
+
* viewport top, so we can restore the same visual position after
|
|
8
|
+
* new messages are inserted above.
|
|
9
|
+
*/
|
|
10
|
+
export declare function captureScrollAnchor<T>(messages: T[], getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number, scrollTop: number): ScrollAnchor | null;
|
|
11
|
+
/**
|
|
12
|
+
* Restore scroll position after a history prepend, using a previously captured anchor.
|
|
13
|
+
*
|
|
14
|
+
* Finds the anchor message in the (now larger) message array and sets scrollTop
|
|
15
|
+
* so the anchor appears at the same visual offset from the viewport top.
|
|
16
|
+
*/
|
|
17
|
+
export declare function restoreScrollAnchor<T>(anchor: ScrollAnchor, messages: T[], getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number): number;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { calculateOffsetForIndex } from './chatMeasurement.svelte.js';
|
|
2
|
+
/**
|
|
3
|
+
* Capture a scroll anchor before a history prepend operation.
|
|
4
|
+
*
|
|
5
|
+
* Records the first visible message and its pixel offset from the
|
|
6
|
+
* viewport top, so we can restore the same visual position after
|
|
7
|
+
* new messages are inserted above.
|
|
8
|
+
*/
|
|
9
|
+
export function captureScrollAnchor(messages, getMessageId, heightCache, estimatedHeight, scrollTop) {
|
|
10
|
+
if (messages.length === 0)
|
|
11
|
+
return null;
|
|
12
|
+
let offsetY = 0;
|
|
13
|
+
for (let i = 0; i < messages.length; i++) {
|
|
14
|
+
const id = getMessageId(messages[i]);
|
|
15
|
+
const h = heightCache.get(id) ?? estimatedHeight;
|
|
16
|
+
const itemBottom = offsetY + h;
|
|
17
|
+
if (itemBottom > scrollTop) {
|
|
18
|
+
return {
|
|
19
|
+
messageId: id,
|
|
20
|
+
offsetFromViewportTop: offsetY - scrollTop
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
offsetY += h;
|
|
24
|
+
}
|
|
25
|
+
// Fallback: anchor to last message
|
|
26
|
+
const lastId = getMessageId(messages[messages.length - 1]);
|
|
27
|
+
return {
|
|
28
|
+
messageId: lastId,
|
|
29
|
+
offsetFromViewportTop: offsetY - scrollTop - (heightCache.get(lastId) ?? estimatedHeight)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Restore scroll position after a history prepend, using a previously captured anchor.
|
|
34
|
+
*
|
|
35
|
+
* Finds the anchor message in the (now larger) message array and sets scrollTop
|
|
36
|
+
* so the anchor appears at the same visual offset from the viewport top.
|
|
37
|
+
*/
|
|
38
|
+
export function restoreScrollAnchor(anchor, messages, getMessageId, heightCache, estimatedHeight) {
|
|
39
|
+
const anchorIndex = messages.findIndex((m) => getMessageId(m) === anchor.messageId);
|
|
40
|
+
if (anchorIndex === -1)
|
|
41
|
+
return 0;
|
|
42
|
+
const anchorOffset = calculateOffsetForIndex(messages, anchorIndex, getMessageId, heightCache, estimatedHeight);
|
|
43
|
+
return anchorOffset - anchor.offsetFromViewportTop;
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive height cache for chat messages.
|
|
3
|
+
*
|
|
4
|
+
* Uses Svelte 5 runes for fine-grained reactivity. Heights are stored
|
|
5
|
+
* in a plain object keyed by message ID, and a version counter triggers
|
|
6
|
+
* derived recalculations when any height changes.
|
|
7
|
+
*/
|
|
8
|
+
export declare class ChatHeightCache {
|
|
9
|
+
#private;
|
|
10
|
+
/** Get the measured height for a message, or undefined if not yet measured. */
|
|
11
|
+
get(id: string): number | undefined;
|
|
12
|
+
/** Set the measured height for a message. Returns true if the value changed. */
|
|
13
|
+
set(id: string, height: number): boolean;
|
|
14
|
+
/** Remove a message from the cache (e.g. when messages are pruned). */
|
|
15
|
+
delete(id: string): void;
|
|
16
|
+
/** Check if a message has been measured. */
|
|
17
|
+
has(id: string): boolean;
|
|
18
|
+
/** Number of measured entries. */
|
|
19
|
+
get size(): number;
|
|
20
|
+
/** Current version — use this to establish reactive dependencies on any height change. */
|
|
21
|
+
get version(): number;
|
|
22
|
+
/** Clear all cached heights. */
|
|
23
|
+
clear(): void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Calculate the total content height given messages and a height cache.
|
|
27
|
+
*/
|
|
28
|
+
export declare function calculateTotalHeight<T>(messages: T[], getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number): number;
|
|
29
|
+
/**
|
|
30
|
+
* Calculate the Y offset for a message at a given index.
|
|
31
|
+
*/
|
|
32
|
+
export declare function calculateOffsetForIndex<T>(messages: T[], index: number, getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number): number;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive height cache for chat messages.
|
|
3
|
+
*
|
|
4
|
+
* Uses Svelte 5 runes for fine-grained reactivity. Heights are stored
|
|
5
|
+
* in a plain object keyed by message ID, and a version counter triggers
|
|
6
|
+
* derived recalculations when any height changes.
|
|
7
|
+
*/
|
|
8
|
+
export class ChatHeightCache {
|
|
9
|
+
#heights = $state({});
|
|
10
|
+
#version = $state(0);
|
|
11
|
+
/** Get the measured height for a message, or undefined if not yet measured. */
|
|
12
|
+
get(id) {
|
|
13
|
+
return this.#heights[id];
|
|
14
|
+
}
|
|
15
|
+
/** Set the measured height for a message. Returns true if the value changed. */
|
|
16
|
+
set(id, height) {
|
|
17
|
+
if (this.#heights[id] === height)
|
|
18
|
+
return false;
|
|
19
|
+
this.#heights[id] = height;
|
|
20
|
+
this.#version++;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
/** Remove a message from the cache (e.g. when messages are pruned). */
|
|
24
|
+
delete(id) {
|
|
25
|
+
if (id in this.#heights) {
|
|
26
|
+
delete this.#heights[id];
|
|
27
|
+
this.#version++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Check if a message has been measured. */
|
|
31
|
+
has(id) {
|
|
32
|
+
return id in this.#heights;
|
|
33
|
+
}
|
|
34
|
+
/** Number of measured entries. */
|
|
35
|
+
get size() {
|
|
36
|
+
// Access version to establish reactive dependency
|
|
37
|
+
void this.#version;
|
|
38
|
+
return Object.keys(this.#heights).length;
|
|
39
|
+
}
|
|
40
|
+
/** Current version — use this to establish reactive dependencies on any height change. */
|
|
41
|
+
get version() {
|
|
42
|
+
return this.#version;
|
|
43
|
+
}
|
|
44
|
+
/** Clear all cached heights. */
|
|
45
|
+
clear() {
|
|
46
|
+
this.#heights = {};
|
|
47
|
+
this.#version++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Calculate the total content height given messages and a height cache.
|
|
52
|
+
*/
|
|
53
|
+
export function calculateTotalHeight(messages, getMessageId, heightCache, estimatedHeight) {
|
|
54
|
+
let total = 0;
|
|
55
|
+
for (const msg of messages) {
|
|
56
|
+
const id = getMessageId(msg);
|
|
57
|
+
total += heightCache.get(id) ?? estimatedHeight;
|
|
58
|
+
}
|
|
59
|
+
return total;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Calculate the Y offset for a message at a given index.
|
|
63
|
+
*/
|
|
64
|
+
export function calculateOffsetForIndex(messages, index, getMessageId, heightCache, estimatedHeight) {
|
|
65
|
+
let offset = 0;
|
|
66
|
+
for (let i = 0; i < index && i < messages.length; i++) {
|
|
67
|
+
const id = getMessageId(messages[i]);
|
|
68
|
+
offset += heightCache.get(id) ?? estimatedHeight;
|
|
69
|
+
}
|
|
70
|
+
return offset;
|
|
71
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal height cache entry for a single message.
|
|
3
|
+
*/
|
|
4
|
+
export type HeightCacheEntry = {
|
|
5
|
+
/** Measured pixel height, or undefined if not yet measured */
|
|
6
|
+
measured: number | undefined;
|
|
7
|
+
/** Whether the measurement is stale and needs re-measure */
|
|
8
|
+
dirty: boolean;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Visible range result from the range calculation.
|
|
12
|
+
*/
|
|
13
|
+
export type VisibleRange = {
|
|
14
|
+
/** First rendered index (including overscan) */
|
|
15
|
+
start: number;
|
|
16
|
+
/** Last rendered index (including overscan) */
|
|
17
|
+
end: number;
|
|
18
|
+
/** First truly visible index (no overscan) */
|
|
19
|
+
visibleStart: number;
|
|
20
|
+
/** Last truly visible index (no overscan) */
|
|
21
|
+
visibleEnd: number;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Scroll anchor for preserving position during history prepend.
|
|
25
|
+
*/
|
|
26
|
+
export type ScrollAnchor = {
|
|
27
|
+
/** Message ID of the anchor */
|
|
28
|
+
messageId: string;
|
|
29
|
+
/** Pixel offset from viewport top to the anchor message top */
|
|
30
|
+
offsetFromViewportTop: number;
|
|
31
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@humanspeak/svelte-virtual-chat",
|
|
3
|
+
"version": "0.0.1",
|
|
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"
|
|
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"
|
|
118
|
+
}
|
|
119
|
+
}
|