파일 페이지와 카테고리 페이지에서 파일 목록을 표시할 때도 가상 리스트를 사용하여 효율적으로 랜더링하도록 개선

This commit is contained in:
static
2025-12-27 23:27:57 +09:00
parent 0d13d3baef
commit 174305ca1b
5 changed files with 121 additions and 71 deletions

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import { untrack, type Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
class?: ClassValue;
count: number;
item: Snippet<[index: number]>;
itemHeight: (index: number) => number;
placeholder?: Snippet;
}
let { class: className, count, item, itemHeight, placeholder }: Props = $props();
const virtualizer = $derived(
createWindowVirtualizer({
count: untrack(() => count),
estimateSize: itemHeight,
}),
);
const measureItem = (node: HTMLElement) => {
$effect(() => $virtualizer.measureElement(node));
};
$effect(() => $virtualizer.setOptions({ count }));
</script>
<div class={["relative", className]}>
<div style:height="{$virtualizer.getTotalSize()}px">
{#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
<div
class="absolute left-0 top-0 w-full"
style:transform="translateY({virtualItem.start}px)"
data-index={virtualItem.index}
use:measureItem
>
{@render item(virtualItem.index)}
</div>
{/each}
</div>
{#if placeholder && $virtualizer.getVirtualItems().length === 0}
{@render placeholder()}
{/if}
</div>

View File

@@ -3,3 +3,4 @@ export * from "./buttons";
export * from "./divs"; export * from "./divs";
export * from "./inputs"; export * from "./inputs";
export { default as Modal } from "./Modal.svelte"; export { default as Modal } from "./Modal.svelte";
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { get, type Writable } from "svelte/store"; import { get, type Writable } from "svelte/store";
import { CheckBox } from "$lib/components/atoms"; import { CheckBox, RowVirtualizer } from "$lib/components/atoms";
import { SubCategories, type SelectedCategory } from "$lib/components/molecules"; import { SubCategories, type SelectedCategory } from "$lib/components/molecules";
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem"; import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
@@ -89,19 +89,26 @@
<p class="font-medium">하위 카테고리의 파일</p> <p class="font-medium">하위 카테고리의 파일</p>
</CheckBox> </CheckBox>
</div> </div>
<div class="space-y-1">
{#key info} {#key info}
{#each files as { info, isRecursive }} <RowVirtualizer
count={files.length}
itemHeight={(index) => 56 + (index + 1 < files.length ? 4 : 0)}
>
{#snippet item(index)}
{@const { info, isRecursive } = files[index]!}
<div class={[index + 1 < files.length && "pb-1"]}>
<File <File
{info} {info}
onclick={onFileClick} onclick={onFileClick}
onRemoveClick={!isRecursive ? onFileRemoveClick : undefined} onRemoveClick={!isRecursive ? onFileRemoveClick : undefined}
/> />
{:else}
<p class="text-gray-500 text-center">이 카테고리에 추가된 파일이 없어요.</p>
{/each}
{/key}
</div> </div>
{/snippet}
{#snippet placeholder()}
<p class="text-center text-gray-500">이 카테고리에 추가된 파일이 없어요.</p>
{/snippet}
</RowVirtualizer>
{/key}
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { get, type Writable } from "svelte/store"; import { get, type Writable } from "svelte/store";
import { FileThumbnailButton } from "$lib/components/atoms"; import { FileThumbnailButton, RowVirtualizer } from "$lib/components/atoms";
import type { FileInfo } from "$lib/modules/filesystem"; import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils"; import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
@@ -17,25 +16,11 @@
| { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> } | { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> }
| { date: Date; contentType: string; info: Writable<FileInfo | null> }; | { date: Date; contentType: string; info: Writable<FileInfo | null> };
type Row = type Row =
| { type: "header"; key: string; label: string } | { type: "header"; label: string }
| { type: "items"; key: string; items: FileEntry[] }; | { type: "items"; items: FileEntry[]; isLast: boolean };
let filesWithDate: FileEntry[] = $state([]); let filesWithDate: FileEntry[] = $state([]);
let rows: Row[] = $state([]); let rows: Row[] = $state([]);
let listElement: HTMLDivElement | undefined = $state();
const virtualizer = createWindowVirtualizer({
count: 0,
getItemKey: (index) => rows[index]!.key,
estimateSize: () => 1000, // TODO
});
const measureRow = (node: HTMLElement) => {
$virtualizer.measureElement(node);
return {
update: () => $virtualizer.measureElement(node),
};
};
$effect(() => { $effect(() => {
filesWithDate = files.map((file) => { filesWithDate = files.map((file) => {
@@ -76,18 +61,19 @@
newRows.push({ newRows.push({
type: "header", type: "header",
key: `header-${date}`,
label: formatDate(entries[0]!.date!), label: formatDate(entries[0]!.date!),
}); });
for (let i = 0; i < entries.length; i += 4) {
newRows.push({ newRows.push({
type: "items", type: "items",
key: `items-${date}`, items: entries.slice(i, i + 4),
items: entries, isLast: i + 4 >= entries.length,
}); });
} }
}
rows = newRows; rows = newRows;
$virtualizer.setOptions({ count: rows.length });
}; };
return untrack(() => { return untrack(() => {
buildRows(); buildRows();
@@ -110,29 +96,29 @@
}); });
</script> </script>
<div bind:this={listElement} class="relative flex flex-grow flex-col"> <RowVirtualizer
<div style="height: {$virtualizer.getTotalSize()}px;"> count={rows.length}
{#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)} itemHeight={(index) =>
{@const row = rows[virtualRow.index]!} rows[index]!.type === "header"
<div ? 32
use:measureRow : Math.ceil(rows[index]!.items.length / 4) * 181 +
data-index={virtualRow.index} (Math.ceil(rows[index]!.items.length / 4) - 1) * 4 +
class="absolute left-0 top-0 w-full" 16}
style="transform: translateY({virtualRow.start}px);" class="flex flex-grow flex-col"
> >
{#snippet item(index)}
{@const row = rows[index]!}
{#if row.type === "header"} {#if row.type === "header"}
<p class="pb-2 font-medium">{row.label}</p> <p class="pb-2 font-medium">{row.label}</p>
{:else} {:else}
<div class="grid grid-cols-4 gap-1 pb-4"> <div class={["grid grid-cols-4 gap-x-1", row.isLast ? "pb-4" : "pb-1"]}>
{#each row.items as { info }} {#each row.items as { info }}
<FileThumbnailButton {info} onclick={onFileClick} /> <FileThumbnailButton {info} onclick={onFileClick} />
{/each} {/each}
</div> </div>
{/if} {/if}
</div> {/snippet}
{/each} {#snippet placeholder()}
</div>
{#if $virtualizer.getVirtualItems().length === 0}
<div class="flex h-full flex-grow items-center justify-center"> <div class="flex h-full flex-grow items-center justify-center">
<p class="text-gray-500"> <p class="text-gray-500">
{#if files.length === 0} {#if files.length === 0}
@@ -144,5 +130,5 @@
{/if} {/if}
</p> </p>
</div> </div>
{/if} {/snippet}
</div> </RowVirtualizer>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { get, type Writable } from "svelte/store"; import { get, type Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms"; import { ActionEntryButton, RowVirtualizer } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import { import {
getDirectoryInfo, getDirectoryInfo,
@@ -127,13 +127,23 @@
{#each subDirectories as { info }} {#each subDirectories as { info }}
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} /> <SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{/each} {/each}
{#each files as file} {#if files.length > 0}
<RowVirtualizer
count={files.length}
itemHeight={(index) => 48 + (index + 1 < files.length ? 4 : 0)}
>
{#snippet item(index)}
{@const file = files[index]!}
<div class={index + 1 < files.length ? "pb-1" : ""}>
{#if file.type === "file"} {#if file.type === "file"}
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} /> <File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else} {:else}
<UploadingFile status={file.info} /> <UploadingFile status={file.info} />
{/if} {/if}
{/each} </div>
{/snippet}
</RowVirtualizer>
{/if}
</div> </div>
{:else} {:else}
<div class="flex flex-grow items-center justify-center"> <div class="flex flex-grow items-center justify-center">