|
57 | 57 |
|
58 | 58 | export let onSelect = (e) => {}; |
59 | 59 |
|
60 | | - export let messagesCount: number | null = 20; |
| 60 | + export let messagesCount: number | null = 10; |
61 | 61 | let messagesLoading = false; |
62 | 62 |
|
| 63 | + // Off-screen message unloading. Heights are measured on scroll so spacers |
| 64 | + // always match real sizes — no scroll jumps, no feedback loops needed. |
| 65 | + const OVERSCAN = 3; |
| 66 | + const DEFAULT_HEIGHT = 150; |
| 67 | + let visibleStart = 0; |
| 68 | + let visibleEnd = 0; |
| 69 | + let messageHeights = new Map(); |
| 70 | + let topSpacerHeight = 0; |
| 71 | + let bottomSpacerHeight = 0; |
| 72 | + let pendingCull = null; |
| 73 | +
|
| 74 | + // Helper: get height for a message (cached or default) |
| 75 | + const heightOf = (id) => messageHeights.get(id) ?? DEFAULT_HEIGHT; |
| 76 | +
|
| 77 | + /** Measure all currently rendered message elements and cache their heights */ |
| 78 | + const measureMessageHeights = () => { |
| 79 | + const elements = document.getElementById('messages-container')?.querySelectorAll('[role="listitem"]'); |
| 80 | + if (!elements) return; |
| 81 | +
|
| 82 | + messageHeights = new Map([ |
| 83 | + ...messageHeights, |
| 84 | + ...Array.from(elements) |
| 85 | + .map((el, i) => [messages[visibleStart + i]?.id, el.getBoundingClientRect().height]) |
| 86 | + .filter(([id]) => id != null) |
| 87 | + ]); |
| 88 | + }; |
| 89 | +
|
| 90 | + /** Compute visible range from current scroll position and apply */ |
| 91 | + const updateVisibleRange = () => { |
| 92 | + const container = document.getElementById('messages-container'); |
| 93 | + if (!container || messages.length === 0) return; |
| 94 | +
|
| 95 | + const st = container.scrollTop; |
| 96 | + const ch = container.clientHeight; |
| 97 | +
|
| 98 | + // Build prefix sums from measured heights |
| 99 | + const prefixSums = messages.reduce( |
| 100 | + (acc, m) => [...acc, acc[acc.length - 1] + heightOf(m.id)], |
| 101 | + [0] |
| 102 | + ); |
| 103 | +
|
| 104 | + const firstVisible = Math.max(0, prefixSums.findIndex((h) => h > st) - 1); |
| 105 | + const lastVisible = prefixSums.findIndex((h) => h > st + ch); |
| 106 | +
|
| 107 | + // Only cull messages that have been measured (so spacer height is accurate) |
| 108 | + // findIndex returns -1 when all are measured → no limit on culling |
| 109 | + const firstUnmeasured = messages.findIndex((m) => !messageHeights.has(m.id)); |
| 110 | + const cullLimit = firstUnmeasured === -1 ? messages.length : firstUnmeasured; |
| 111 | +
|
| 112 | + visibleStart = Math.max(0, Math.min(firstVisible - OVERSCAN, cullLimit)); |
| 113 | + visibleEnd = Math.min(messages.length, (lastVisible === -1 ? messages.length : lastVisible) + OVERSCAN); |
| 114 | + topSpacerHeight = prefixSums[visibleStart] ?? 0; |
| 115 | + bottomSpacerHeight = (prefixSums[messages.length] ?? 0) - (prefixSums[visibleEnd] ?? 0); |
| 116 | + }; |
| 117 | +
|
| 118 | + /** Scroll handler: measure every frame, cull via rAF (same throttle as pendingRebuild) */ |
| 119 | + const handleContainerScroll = () => { |
| 120 | + measureMessageHeights(); |
| 121 | +
|
| 122 | + // Don't cull during progressive loading |
| 123 | + if (messagesLoading) return; |
| 124 | +
|
| 125 | + if (!pendingCull) { |
| 126 | + pendingCull = requestAnimationFrame(() => { |
| 127 | + pendingCull = null; |
| 128 | + updateVisibleRange(); |
| 129 | + }); |
| 130 | + } |
| 131 | + }; |
| 132 | +
|
| 133 | + let scrollListenerAttached = false; |
| 134 | +
|
| 135 | + const attachScrollListener = () => { |
| 136 | + if (scrollListenerAttached) return; |
| 137 | + const container = document.getElementById('messages-container'); |
| 138 | + if (!container) return; |
| 139 | +
|
| 140 | + container.addEventListener('scroll', handleContainerScroll, { passive: true }); |
| 141 | + scrollListenerAttached = true; |
| 142 | + }; |
| 143 | +
|
| 144 | + onMount(() => { |
| 145 | + attachScrollListener(); |
| 146 | + }); |
| 147 | +
|
| 148 | + onDestroy(() => { |
| 149 | + const container = document.getElementById('messages-container'); |
| 150 | + if (container && scrollListenerAttached) { |
| 151 | + container.removeEventListener('scroll', handleContainerScroll); |
| 152 | + } |
| 153 | + cancelAnimationFrame(pendingCull); |
| 154 | + cancelAnimationFrame(pendingRebuild); |
| 155 | + }); |
| 156 | +
|
63 | 157 | const loadMoreMessages = async () => { |
64 | 158 | // scroll slightly down to disable continuous loading |
65 | 159 | const element = document.getElementById('messages-container'); |
66 | 160 | element.scrollTop = element.scrollTop + 100; |
67 | 161 |
|
68 | 162 | messagesLoading = true; |
69 | | - messagesCount += 20; |
| 163 | + messagesCount += 10; |
| 164 | +
|
70 | 165 | buildMessages(); |
71 | 166 |
|
| 167 | + // Show all messages during progressive loading (no culling) |
| 168 | + visibleStart = 0; |
| 169 | + visibleEnd = messages.length; |
| 170 | + topSpacerHeight = 0; |
| 171 | + bottomSpacerHeight = 0; |
| 172 | +
|
72 | 173 | await tick(); |
73 | 174 |
|
74 | 175 | messagesLoading = false; |
|
95 | 196 | } |
96 | 197 |
|
97 | 198 | messages = _messages.reverse(); |
| 199 | + visibleEnd = messages.length; |
98 | 200 | }; |
99 | 201 |
|
100 | 202 | // Throttle message list rebuilds to once per animation frame during streaming. |
|
113 | 215 | cancelAnimationFrame(pendingRebuild); |
114 | 216 | pendingRebuild = null; |
115 | 217 | buildMessages(); |
| 218 | + // No explicit culling needed — scrollToBottom will fire a scroll event, |
| 219 | + // which triggers handleContainerScroll → rAF → updateVisibleRange |
116 | 220 | } else if (_messages) { |
117 | 221 | // Content update (streaming) — throttle to once per frame |
118 | 222 | if (!pendingRebuild) { |
|
426 | 530 | showMessage({ id: parentMessageId }, false); |
427 | 531 | }; |
428 | 532 |
|
429 | | - onDestroy(() => { |
430 | | - cancelAnimationFrame(pendingRebuild); |
431 | | - }); |
| 533 | +
|
432 | 534 |
|
433 | 535 | const triggerScroll = () => { |
434 | 536 | if (autoScroll) { |
|
465 | 567 | </Loader> |
466 | 568 | {/if} |
467 | 569 | <ul role="log" aria-live="polite" aria-relevant="additions" aria-atomic="false"> |
468 | | - {#each messages as message, messageIdx (message.id)} |
| 570 | + <!-- Top spacer: sum of cached heights for messages above visible range --> |
| 571 | + {#if topSpacerHeight > 0} |
| 572 | + <div style="height: {topSpacerHeight}px" aria-hidden="true" /> |
| 573 | + {/if} |
| 574 | + |
| 575 | + {#each messages.slice(visibleStart, visibleEnd) as message, i (message.id)} |
| 576 | + {@const messageIdx = visibleStart + i} |
469 | 577 | <Message |
470 | 578 | {chatId} |
471 | 579 | bind:history |
|
494 | 602 | {topPadding} |
495 | 603 | /> |
496 | 604 | {/each} |
| 605 | + |
| 606 | + <!-- Bottom spacer: sum of cached heights for messages below visible range --> |
| 607 | + {#if bottomSpacerHeight > 0} |
| 608 | + <div style="height: {bottomSpacerHeight}px" aria-hidden="true" /> |
| 609 | + {/if} |
497 | 610 | </ul> |
498 | 611 | </section> |
499 | 612 | <div class="pb-18" /> |
|
0 commit comments