Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
| import type { MaybeGetter } from "$lib/types.js"; | |
| import { ElementSize } from "runed"; | |
| import { createAttachmentKey } from "svelte/attachments"; | |
| import type { HTMLAttributes } from "svelte/elements"; | |
| import { extract } from "./extract.svelte"; | |
| interface VirtualScrollOptions { | |
| totalItems?: MaybeGetter<number>; | |
| itemHeight: MaybeGetter<number>; | |
| overscan?: MaybeGetter<number | undefined>; | |
| } | |
| export class VirtualScroll { | |
| #options: VirtualScrollOptions; | |
| itemHeight = $derived.by(() => extract(this.#options.itemHeight)); | |
| overscan = $derived.by(() => extract(this.#options.overscan, 10)); | |
| totalItems = $derived.by(() => extract(this.#options.totalItems, 0)); | |
| #scrollTop = $state(0); | |
| #containerEl = $state<HTMLElement>(); | |
| #containerSize = new ElementSize(() => this.#containerEl); | |
| constructor(options: VirtualScrollOptions) { | |
| this.#options = options; | |
| } | |
| get scrollTop() { | |
| return this.#scrollTop; | |
| } | |
| set scrollTop(value: number) { | |
| this.#scrollTop = value; | |
| } | |
| get visibleRange() { | |
| const startIndex = Math.floor(this.#scrollTop / this.itemHeight); | |
| const endIndex = Math.min( | |
| startIndex + Math.ceil(this.#containerSize.height / this.itemHeight), | |
| this.totalItems - 1, | |
| ); | |
| return { | |
| start: Math.max(0, startIndex - this.overscan), | |
| end: Math.min(this.totalItems - 1, endIndex + this.overscan), | |
| }; | |
| } | |
| get totalHeight() { | |
| return this.totalItems * this.itemHeight; | |
| } | |
| get offsetY() { | |
| return this.visibleRange.start * this.itemHeight; | |
| } | |
| getVisibleItems<T>(items: T[]): Array<{ item: T; index: number }> { | |
| const { start, end } = this.visibleRange; | |
| return items.slice(start, end + 1).map((item, i) => ({ | |
| item, | |
| index: start + i, | |
| })); | |
| } | |
| scrollToIndex(index: number) { | |
| const { start, end } = this.visibleRange; | |
| // Only scroll if the index is not currently visible | |
| if (index >= start && index <= end) { | |
| return; // Already visible, no need to scroll | |
| } | |
| let targetScrollTop: number; | |
| if (index < start) { | |
| // Scrolling up - position item at top with some buffer | |
| targetScrollTop = (index - this.overscan) * this.itemHeight; | |
| } else { | |
| // Scrolling down - position item at bottom with some buffer | |
| const visibleItems = Math.floor(this.#containerSize.height / this.itemHeight); | |
| targetScrollTop = (index - visibleItems + 1 + this.overscan) * this.itemHeight; | |
| } | |
| const maxScrollTop = this.totalHeight - this.#containerSize.height; | |
| this.#scrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop)); | |
| // Update the actual scroll container | |
| if (this.#containerEl) { | |
| this.#containerEl.scrollTop = this.#scrollTop; | |
| } | |
| } | |
| #attachmentKey = createAttachmentKey(); | |
| get container() { | |
| return { | |
| onscroll: e => { | |
| this.scrollTop = e.currentTarget.scrollTop; | |
| }, | |
| [this.#attachmentKey]: node => { | |
| this.#containerEl = node; | |
| return () => { | |
| this.#containerEl = undefined; | |
| }; | |
| }, | |
| } as const satisfies HTMLAttributes<HTMLElement>; | |
| } | |
| } | |