feat(grid): virtualize grid rows

This commit implements a virtual list in the Grid component, hopefully increasing performance.
This commit is contained in:
ByteAtATime 2025-07-01 10:26:56 -07:00
parent 87a7f946fa
commit 58b593fb81
No known key found for this signature in database
2 changed files with 103 additions and 30 deletions

View file

@ -5,6 +5,7 @@
import GridItem from './GridItem.svelte';
import { useGridView } from '$lib/views';
import { useTypedNode } from '$lib/node.svelte';
import { VList, type VListHandle } from 'virtua/svelte';
type Props = {
nodeId: number;
@ -26,30 +27,41 @@
onSearchTextChange: !!gridProps?.onSearchTextChange,
inset: gridProps?.inset
}));
let vlist: VListHandle | null = null;
$effect(() => {
view.vlistInstance = vlist ?? undefined;
});
</script>
<svelte:window onkeydown={view.handleKeydown} />
<div class="flex h-full flex-col">
<div class="flex-grow overflow-y-auto px-4">
<div
class="grid h-full content-start gap-x-2.5 gap-y-2"
style:grid-template-columns={`repeat(${gridProps?.columns ?? 6}, 1fr)`}
>
{#each view.flatList as item, index (item.id)}
<VList bind:this={vlist} data={view.virtualListItems} getKey={(item) => item.id} class="h-full">
{#snippet children(item)}
<div class="h-2"></div>
{#if item.type === 'header'}
<GridSection props={item.props} />
{:else if item.type === 'item'}
<div id="item-{item.id}">
<GridItem
props={item.props as GridItemProps}
selected={view.selectedItemIndex === index}
onclick={() => view.setSelectedItemIndex(index)}
inset={item.inset}
/>
{:else if item.type === 'row'}
<div
class="grid content-start gap-x-2.5"
style:grid-template-columns={`repeat(${gridProps?.columns ?? 6}, 1fr)`}
>
{#each item.items as gridItem (gridItem.id)}
{@const flatIndex = view.flatList.findIndex((f) => f.id === gridItem.id)}
<div id="item-{gridItem.id}">
<GridItem
props={gridItem.props as GridItemProps}
selected={view.selectedItemIndex === flatIndex}
onclick={() => view.setSelectedItemIndex(flatIndex)}
inset={gridItem.inset}
/>
</div>
{/each}
</div>
{/if}
{/each}
</div>
{/snippet}
</VList>
</div>
</div>

View file

@ -1,6 +1,15 @@
import { _useBaseView, type BaseViewArgs } from './base.svelte';
import type { GridInset, ViewSectionProps } from '$lib/props';
import type { GridInset, ViewSectionProps, GridItemProps } from '$lib/props';
import { focusManager } from '$lib/focus.svelte';
import type { VListHandle } from 'virtua/svelte';
export type VirtualGridItem =
| { id: string | number; type: 'header'; props: ViewSectionProps }
| {
id: string;
type: 'row';
items: { id: number; type: 'item'; props: GridItemProps; inset?: GridInset }[];
};
export function useGridView(args: () => BaseViewArgs & { columns: number; inset?: GridInset }) {
const base = _useBaseView(args, 'Grid.Item');
@ -8,18 +17,15 @@ export function useGridView(args: () => BaseViewArgs & { columns: number; inset?
const processedFlatList = $derived.by(() => {
const list = base.flatList;
const newList: ((typeof list)[number] & { inset?: GridInset })[] = [];
const newList: (typeof list)[number][] = [];
let currentSectionInset: GridInset | undefined;
for (const item of list) {
if (item.type === 'header') {
const sectionProps = item.props as ViewSectionProps;
if (item.id === -1) {
// This is the synthetic section for top-level items, so it should inherit from the Grid.
currentSectionInset = gridInset;
} else {
// This is a user-defined <Grid.Section>. It does not inherit.
// If `sectionProps.inset` is undefined, it's treated as "none" by GridItem.
currentSectionInset = sectionProps.inset;
}
newList.push(item);
@ -30,14 +36,69 @@ export function useGridView(args: () => BaseViewArgs & { columns: number; inset?
return newList;
});
type GridMapItem = {
const virtualListItems = $derived.by((): VirtualGridItem[] => {
const list: VirtualGridItem[] = [];
let currentRow: (typeof processedFlatList)[number][] = [];
for (const item of processedFlatList) {
if (item.type === 'header') {
if (currentRow.length > 0) {
list.push({ id: `row-${list.length}`, type: 'row', items: currentRow });
currentRow = [];
}
list.push({ id: `header-${item.id}`, type: 'header', props: item.props });
} else if (item.type === 'item') {
currentRow.push(item);
if (currentRow.length === columns) {
list.push({ id: `row-${list.length}`, type: 'row', items: currentRow });
currentRow = [];
}
}
}
if (currentRow.length > 0) {
list.push({ id: `row-${list.length}`, type: 'row', items: currentRow });
}
return list;
});
const flatIndexToVirtualRowIndexMap = $derived.by(() => {
const map = new Map<number, number>();
virtualListItems.forEach((vItem, vIndex) => {
if (vItem.type === 'row') {
vItem.items.forEach((item) => {
const flatIndex = processedFlatList.findIndex((f) => f.id === item.id);
if (flatIndex !== -1) {
map.set(flatIndex, vIndex);
}
});
}
});
return map;
});
let vlistInstance = $state<VListHandle | undefined>();
$effect(() => {
if (base.selectedItemIndex >= 0 && vlistInstance) {
const virtualRowIndex = flatIndexToVirtualRowIndexMap.get(base.selectedItemIndex);
if (virtualRowIndex !== undefined) {
vlistInstance.scrollToIndex(virtualRowIndex, { align: 'nearest' });
}
}
});
const gridMap: {
flatListIndex: number;
sectionIndex: number;
rowIndex: number;
colIndex: number;
};
const gridMap: GridMapItem[] = $derived.by(() => {
const newGridMap: GridMapItem[] = [];
}[] = $derived.by(() => {
const newGridMap: {
flatListIndex: number;
sectionIndex: number;
rowIndex: number;
colIndex: number;
}[] = [];
let sectionIndex = -1,
rowIndex = 0,
colIndex = 0;
@ -55,12 +116,6 @@ export function useGridView(args: () => BaseViewArgs & { columns: number; inset?
return newGridMap;
});
$effect(() => {
if (base.selectedItemIndex < 0) return;
const elementId = `item-${processedFlatList[base.selectedItemIndex]?.id}`;
document.getElementById(elementId)?.scrollIntoView({ block: 'nearest' });
});
const handleKeydown = (event: KeyboardEvent) => {
if (focusManager.activeScope !== 'main-input') {
return;
@ -124,12 +179,18 @@ export function useGridView(args: () => BaseViewArgs & { columns: number; inset?
get flatList() {
return processedFlatList;
},
get virtualListItems() {
return virtualListItems;
},
get selectedItemIndex() {
return base.selectedItemIndex;
},
setSelectedItemIndex: (index: number) => {
base.selectedItemIndex = index;
},
set vlistInstance(instance: VListHandle | undefined) {
vlistInstance = instance;
},
handleKeydown
};
}