- Add matrix-js-sdk, marked, highlight.js, twemoji, @tanstack/svelte-virtual deps - Copy Matrix core layer: /matrix/, /stores/matrix.ts, /cache/, /services/ - Copy Matrix components: matrix/, message/, chat-layout/, chat-settings/ - Copy UI components: EmojiPicker, Twemoji, ImagePreviewModal, VirtualList - Copy utils: emojiData, twemoji, twemojiGlobal - Replace lucide-svelte with Material Symbols in SyncRecoveryBanner - Extend Avatar with xs size and status indicator prop - Fix ui.ts store conflict: re-export toasts from toast.svelte.ts - Add migration 020_matrix_credentials for storing Matrix tokens per user/org - Add /api/matrix-credentials endpoint (GET/POST/DELETE) - Create [orgSlug]/chat page with Matrix login form + full chat UI - Add Chat to sidebar navigation
115 lines
3.0 KiB
Svelte
115 lines
3.0 KiB
Svelte
<script lang="ts" generics="T">
|
|
import { onMount, tick } from "svelte";
|
|
|
|
interface Props {
|
|
items: T[];
|
|
itemHeight: number;
|
|
overscan?: number;
|
|
containerClass?: string;
|
|
getKey: (item: T, index: number) => string;
|
|
children: import("svelte").Snippet<[T, number]>;
|
|
onScrollTop?: () => void;
|
|
onScrollBottom?: () => void;
|
|
}
|
|
|
|
let {
|
|
items,
|
|
itemHeight,
|
|
overscan = 5,
|
|
containerClass = "",
|
|
getKey,
|
|
children,
|
|
onScrollTop,
|
|
onScrollBottom,
|
|
}: Props = $props();
|
|
|
|
let containerRef: HTMLDivElement | null = $state(null);
|
|
let scrollTop = $state(0);
|
|
let containerHeight = $state(0);
|
|
|
|
// Calculate visible range
|
|
const visibleRange = $derived(() => {
|
|
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
|
const visibleCount = Math.ceil(containerHeight / itemHeight) + overscan * 2;
|
|
const endIndex = Math.min(items.length, startIndex + visibleCount);
|
|
return { startIndex, endIndex };
|
|
});
|
|
|
|
// Get visible items with their indices
|
|
const visibleItems = $derived(() => {
|
|
const { startIndex, endIndex } = visibleRange();
|
|
return items.slice(startIndex, endIndex).map((item, i) => ({
|
|
item,
|
|
index: startIndex + i,
|
|
}));
|
|
});
|
|
|
|
// Total height of the list
|
|
const totalHeight = $derived(items.length * itemHeight);
|
|
|
|
// Offset for visible items
|
|
const offsetY = $derived(visibleRange().startIndex * itemHeight);
|
|
|
|
function handleScroll(e: Event) {
|
|
const target = e.target as HTMLDivElement;
|
|
scrollTop = target.scrollTop;
|
|
|
|
// Check for scroll to top (load more)
|
|
if (target.scrollTop < 100 && onScrollTop) {
|
|
onScrollTop();
|
|
}
|
|
|
|
// Check for scroll to bottom
|
|
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
|
if (distanceToBottom < 100 && onScrollBottom) {
|
|
onScrollBottom();
|
|
}
|
|
}
|
|
|
|
function updateContainerHeight() {
|
|
if (containerRef) {
|
|
containerHeight = containerRef.clientHeight;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
updateContainerHeight();
|
|
const resizeObserver = new ResizeObserver(updateContainerHeight);
|
|
if (containerRef) {
|
|
resizeObserver.observe(containerRef);
|
|
}
|
|
return () => resizeObserver.disconnect();
|
|
});
|
|
|
|
// Scroll to bottom
|
|
export async function scrollToBottom() {
|
|
await tick();
|
|
if (containerRef) {
|
|
containerRef.scrollTop = containerRef.scrollHeight;
|
|
}
|
|
}
|
|
|
|
// Scroll to specific index
|
|
export function scrollToIndex(index: number) {
|
|
if (containerRef) {
|
|
containerRef.scrollTop = index * itemHeight;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
bind:this={containerRef}
|
|
class="overflow-y-auto {containerClass}"
|
|
onscroll={handleScroll}
|
|
>
|
|
<div style="height: {totalHeight}px; position: relative;">
|
|
<div style="transform: translateY({offsetY}px);">
|
|
{#each visibleItems() as { item, index } (getKey(item, index))}
|
|
<div style="height: {itemHeight}px;">
|
|
{@render children(item, index)}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|