@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.
@@ -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
- function handleScroll() {
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
- // ── 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
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 (snapNeeded && isFollowingBottom) {
136
- snapNeeded = false
137
+ if (isFollowingBottom && !userScrolling) {
137
138
  snapToBottom()
138
139
  }
139
140
  })
140
141
  }
141
142
 
142
- function measureMessage(node: HTMLElement, messageId: string) {
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
- function snapToBottom() {
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(() => snapToBottom())
191
+ requestAnimationFrame(() => {
192
+ if (isFollowingBottom) snapToBottom()
193
+ })
189
194
  }
190
195
  })
191
196
 
192
197
  // ── Follow-bottom on height changes ─────────────────────────────
193
- // When measurements arrive, totalHeight changes. If following, re-snap.
198
+ // Height changes during scroll (e.g. newly measured items) respect user-scroll suppression
194
199
  $effect(() => {
195
200
  void totalHeight
196
- if (isFollowingBottom && viewportEl) {
197
- scheduleSnapToBottom()
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 builder ─────────────────────────────────────────
220
- function buildDebugInfo(): SvelteVirtualChatDebugInfo {
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
- export function scrollToBottom(options?: { smooth?: boolean }) {
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
- export function scrollToMessage(id: string, options?: { smooth?: boolean }) {
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
- export function isAtBottom(): boolean {
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
- export function getDebugInfo(): SvelteVirtualChatDebugInfo {
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
- scrollToBottom: (options?: {
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
- scrollToMessage: (id: string, options?: {
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
- isAtBottom: () => boolean;
12
- getDebugInfo: () => SvelteVirtualChatDebugInfo;
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
- scrollToBottom: (options?: {
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
- scrollToMessage: (id: string, options?: {
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
- isAtBottom: () => boolean;
31
- getDebugInfo: () => SvelteVirtualChatDebugInfo;
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 we can restore the same visual position after
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 function captureScrollAnchor<T>(messages: T[], getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number, scrollTop: number): ScrollAnchor | null;
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, using a previously captured anchor.
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
- * 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.
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 function restoreScrollAnchor<T>(anchor: ScrollAnchor, messages: T[], getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number): number;
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 we can restore the same visual position after
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 function captureScrollAnchor(messages, getMessageId, heightCache, estimatedHeight, scrollTop) {
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, using a previously captured anchor.
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
- * 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.
59
+ * @example
60
+ * ```ts
61
+ * const scrollTop = restoreScrollAnchor(anchor, messages, (m) => m.id, cache, 72)
62
+ * viewport.scrollTop = scrollTop
63
+ * ```
37
64
  */
38
- export function restoreScrollAnchor(anchor, messages, getMessageId, heightCache, estimatedHeight) {
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 function calculateTotalHeight<T>(messages: T[], getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number): number;
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 function calculateOffsetForIndex<T>(messages: T[], index: number, getMessageId: (_message: T) => string, heightCache: ChatHeightCache, estimatedHeight: number): number;
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 function calculateTotalHeight(messages, getMessageId, heightCache, estimatedHeight) {
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 function calculateOffsetForIndex(messages, index, getMessageId, heightCache, estimatedHeight) {
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
- "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"
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
+ }