Spaces:
Runtime error
Runtime error
Thomas G. Lopes
commited on
Commit
·
7bfbc58
1
Parent(s):
73e0644
virtualization wip
Browse files
package.json
CHANGED
|
@@ -52,7 +52,7 @@
|
|
| 52 |
"highlight.js": "^11.10.0",
|
| 53 |
"jiti": "^2.4.2",
|
| 54 |
"jsdom": "^26.0.0",
|
| 55 |
-
"melt": "^0.
|
| 56 |
"openai": "^4.90.0",
|
| 57 |
"playwright": "^1.52.0",
|
| 58 |
"postcss": "^8.4.38",
|
|
|
|
| 52 |
"highlight.js": "^11.10.0",
|
| 53 |
"jiti": "^2.4.2",
|
| 54 |
"jsdom": "^26.0.0",
|
| 55 |
+
"melt": "^0.40.0",
|
| 56 |
"openai": "^4.90.0",
|
| 57 |
"playwright": "^1.52.0",
|
| 58 |
"postcss": "^8.4.38",
|
pnpm-lock.yaml
CHANGED
|
@@ -136,8 +136,8 @@ importers:
|
|
| 136 |
specifier: ^26.0.0
|
| 137 |
version: 26.1.0
|
| 138 |
melt:
|
| 139 |
-
specifier: ^0.
|
| 140 |
-
version: 0.
|
| 141 |
openai:
|
| 142 |
specifier: ^4.90.0
|
| 143 |
version: 4.90.0([email protected])([email protected])
|
|
@@ -1833,6 +1833,9 @@ packages:
|
|
| 1833 | |
| 1834 |
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
| 1835 |
|
|
|
|
|
|
|
|
|
|
| 1836 | |
| 1837 |
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
|
| 1838 |
|
|
@@ -2278,8 +2281,8 @@ packages:
|
|
| 2278 |
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
| 2279 |
engines: {node: '>= 0.8'}
|
| 2280 |
|
| 2281 |
-
melt@0.
|
| 2282 |
-
resolution: {integrity: sha512-
|
| 2283 |
peerDependencies:
|
| 2284 |
'@floating-ui/dom': ^1.6.0
|
| 2285 |
svelte: ^5.30.1
|
|
@@ -2380,11 +2383,6 @@ packages:
|
|
| 2380 |
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
| 2381 |
hasBin: true
|
| 2382 |
|
| 2383 | |
| 2384 |
-
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
|
| 2385 |
-
engines: {node: ^18 || >=20}
|
| 2386 |
-
hasBin: true
|
| 2387 |
-
|
| 2388 | |
| 2389 |
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
| 2390 |
|
|
@@ -2990,6 +2988,9 @@ packages:
|
|
| 2990 |
resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
|
| 2991 |
engines: {node: ^14.18.0 || >=16.0.0}
|
| 2992 |
|
|
|
|
|
|
|
|
|
|
| 2993 | |
| 2994 |
resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
|
| 2995 |
|
|
@@ -4954,6 +4955,10 @@ snapshots:
|
|
| 4954 |
|
| 4955 | |
| 4956 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4957 | |
| 4958 |
|
| 4959 | |
|
@@ -5394,12 +5399,12 @@ snapshots:
|
|
| 5394 |
|
| 5395 | |
| 5396 |
|
| 5397 |
-
melt@0.
|
| 5398 |
dependencies:
|
| 5399 |
'@floating-ui/dom': 1.6.13
|
| 5400 |
dequal: 2.0.3
|
|
|
|
| 5401 |
jest-axe: 9.0.0
|
| 5402 |
-
nanoid: 5.1.5
|
| 5403 |
runed: 0.23.4([email protected])
|
| 5404 |
svelte: 5.38.7
|
| 5405 |
|
|
@@ -5480,8 +5485,6 @@ snapshots:
|
|
| 5480 |
|
| 5481 | |
| 5482 |
|
| 5483 |
-
[email protected]: {}
|
| 5484 |
-
|
| 5485 | |
| 5486 |
|
| 5487 | |
|
@@ -6120,6 +6123,8 @@ snapshots:
|
|
| 6120 |
'@pkgr/core': 0.1.1
|
| 6121 |
tslib: 2.8.1
|
| 6122 |
|
|
|
|
|
|
|
| 6123 | |
| 6124 |
|
| 6125 |
|
|
|
| 136 |
specifier: ^26.0.0
|
| 137 |
version: 26.1.0
|
| 138 |
melt:
|
| 139 |
+
specifier: ^0.40.0
|
| 140 |
+
version: 0.40.0(@floating-ui/[email protected])([email protected])
|
| 141 |
openai:
|
| 142 |
specifier: ^4.90.0
|
| 143 |
version: 4.90.0([email protected])([email protected])
|
|
|
|
| 1833 | |
| 1834 |
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
| 1835 |
|
| 1836 | |
| 1837 |
+
resolution: {integrity: sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==}
|
| 1838 |
+
|
| 1839 | |
| 1840 |
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
|
| 1841 |
|
|
|
|
| 2281 |
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
| 2282 |
engines: {node: '>= 0.8'}
|
| 2283 |
|
| 2284 |
+
melt@0.40.0:
|
| 2285 |
+
resolution: {integrity: sha512-G+urf5f2Cy62cXiPQ+nf3muscjIE9e38kjDWZckU4x08bDaP+hryJ9rrsu81V1x0ZAgEwnMAMKVAFrEdomOYWw==}
|
| 2286 |
peerDependencies:
|
| 2287 |
'@floating-ui/dom': ^1.6.0
|
| 2288 |
svelte: ^5.30.1
|
|
|
|
| 2383 |
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
| 2384 |
hasBin: true
|
| 2385 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2386 | |
| 2387 |
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
| 2388 |
|
|
|
|
| 2988 |
resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
|
| 2989 |
engines: {node: ^14.18.0 || >=16.0.0}
|
| 2990 |
|
| 2991 | |
| 2992 |
+
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
| 2993 |
+
|
| 2994 | |
| 2995 |
resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
|
| 2996 |
|
|
|
|
| 4955 |
|
| 4956 | |
| 4957 |
|
| 4958 | |
| 4959 |
+
dependencies:
|
| 4960 |
+
tabbable: 6.2.0
|
| 4961 |
+
|
| 4962 | |
| 4963 |
|
| 4964 | |
|
|
|
| 5399 |
|
| 5400 | |
| 5401 |
|
| 5402 |
+
melt@0.40.0(@floating-ui/[email protected])([email protected]):
|
| 5403 |
dependencies:
|
| 5404 |
'@floating-ui/dom': 1.6.13
|
| 5405 |
dequal: 2.0.3
|
| 5406 |
+
focus-trap: 7.6.5
|
| 5407 |
jest-axe: 9.0.0
|
|
|
|
| 5408 |
runed: 0.23.4([email protected])
|
| 5409 |
svelte: 5.38.7
|
| 5410 |
|
|
|
|
| 5485 |
|
| 5486 | |
| 5487 |
|
|
|
|
|
|
|
| 5488 | |
| 5489 |
|
| 5490 | |
|
|
|
| 6123 |
'@pkgr/core': 0.1.1
|
| 6124 |
tslib: 2.8.1
|
| 6125 |
|
| 6126 |
+
[email protected]: {}
|
| 6127 |
+
|
| 6128 | |
| 6129 |
|
| 6130 |
src/lib/components/inference-playground/model-selector-modal.svelte
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
import { autofocus } from "$lib/attachments/autofocus.js";
|
| 3 |
import type { ConversationClass } from "$lib/state/conversations.svelte";
|
| 4 |
import { models } from "$lib/state/models.svelte.js";
|
|
|
|
| 5 |
import type { CustomModel, Model } from "$lib/types.js";
|
| 6 |
import { noop } from "$lib/utils/noop.js";
|
| 7 |
import fuzzysearch from "$lib/utils/search.js";
|
|
@@ -26,6 +27,56 @@
|
|
| 26 |
|
| 27 |
let { onModelSelect, onClose, conversation }: Props = $props();
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
const combobox = new Combobox<string | undefined>({
|
| 30 |
onOpenChange(o) {
|
| 31 |
if (!o) onClose?.();
|
|
@@ -40,6 +91,16 @@
|
|
| 40 |
onModelSelect?.(modelId);
|
| 41 |
onClose?.();
|
| 42 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
});
|
| 44 |
$effect(() => {
|
| 45 |
untrack(() => combobox.highlight(conversation.model.id));
|
|
@@ -48,25 +109,6 @@
|
|
| 48 |
combobox.open = true;
|
| 49 |
});
|
| 50 |
});
|
| 51 |
-
|
| 52 |
-
let backdropEl = $state<HTMLDivElement>();
|
| 53 |
-
let query = $state("");
|
| 54 |
-
|
| 55 |
-
const trending = $derived(fuzzysearch({ needle: query, haystack: models.trending, property: "id" }));
|
| 56 |
-
const other = $derived(fuzzysearch({ needle: query, haystack: models.nonTrending, property: "id" }));
|
| 57 |
-
const custom = $derived(fuzzysearch({ needle: query, haystack: models.custom, property: "id" }));
|
| 58 |
-
|
| 59 |
-
function handleBackdropClick(event: MouseEvent) {
|
| 60 |
-
event.stopPropagation();
|
| 61 |
-
if (window?.getSelection()?.toString()) {
|
| 62 |
-
return;
|
| 63 |
-
}
|
| 64 |
-
if (event.target === backdropEl) {
|
| 65 |
-
onClose?.();
|
| 66 |
-
}
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
const isCustom = typia.createIs<CustomModel>();
|
| 70 |
</script>
|
| 71 |
|
| 72 |
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
@@ -91,6 +133,7 @@
|
|
| 91 |
class="max-h-[220px] overflow-x-hidden overflow-y-auto md:max-h-[300px]"
|
| 92 |
{...combobox.content}
|
| 93 |
popover={undefined}
|
|
|
|
| 94 |
>
|
| 95 |
{#snippet modelEntry(model: Model | CustomModel, trending?: boolean)}
|
| 96 |
{@const [nameSpace, modelName] = model.id.split("/")}
|
|
@@ -168,38 +211,34 @@
|
|
| 168 |
{/if}
|
| 169 |
</div>
|
| 170 |
{/snippet}
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
| 196 |
</div>
|
| 197 |
-
{#if other.length > 0}
|
| 198 |
-
<div class="px-2 py-1.5 text-xs font-medium text-gray-500">Other models</div>
|
| 199 |
-
{#each other as model}
|
| 200 |
-
{@render modelEntry(model, false)}
|
| 201 |
-
{/each}
|
| 202 |
-
{/if}
|
| 203 |
</div>
|
| 204 |
</div>
|
| 205 |
</div>
|
|
|
|
| 2 |
import { autofocus } from "$lib/attachments/autofocus.js";
|
| 3 |
import type { ConversationClass } from "$lib/state/conversations.svelte";
|
| 4 |
import { models } from "$lib/state/models.svelte.js";
|
| 5 |
+
import { VirtualScroll } from "$lib/spells/virtual-scroll.svelte.js";
|
| 6 |
import type { CustomModel, Model } from "$lib/types.js";
|
| 7 |
import { noop } from "$lib/utils/noop.js";
|
| 8 |
import fuzzysearch from "$lib/utils/search.js";
|
|
|
|
| 27 |
|
| 28 |
let { onModelSelect, onClose, conversation }: Props = $props();
|
| 29 |
|
| 30 |
+
let backdropEl = $state<HTMLDivElement>();
|
| 31 |
+
let query = $state("");
|
| 32 |
+
|
| 33 |
+
const trending = $derived(fuzzysearch({ needle: query, haystack: models.trending, property: "id" }));
|
| 34 |
+
const other = $derived(fuzzysearch({ needle: query, haystack: models.nonTrending, property: "id" }));
|
| 35 |
+
const custom = $derived(fuzzysearch({ needle: query, haystack: models.custom, property: "id" }));
|
| 36 |
+
|
| 37 |
+
// Combine all filtered models into sections for virtualization
|
| 38 |
+
type SectionItem =
|
| 39 |
+
| { type: "header"; content: string }
|
| 40 |
+
| { type: "model"; content: Model | CustomModel | "__custom__"; trending?: boolean };
|
| 41 |
+
|
| 42 |
+
const allFilteredModels = $derived.by((): SectionItem[] => {
|
| 43 |
+
const sections: SectionItem[] = [];
|
| 44 |
+
|
| 45 |
+
if (trending.length > 0) {
|
| 46 |
+
sections.push({ type: "header", content: "Trending" });
|
| 47 |
+
trending.forEach(model => sections.push({ type: "model", content: model, trending: true }));
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
sections.push({ type: "header", content: "Custom endpoints" });
|
| 51 |
+
custom.forEach(model => sections.push({ type: "model", content: model }));
|
| 52 |
+
sections.push({ type: "model", content: "__custom__" }); // Add custom button
|
| 53 |
+
|
| 54 |
+
if (other.length > 0) {
|
| 55 |
+
sections.push({ type: "header", content: "Other models" });
|
| 56 |
+
other.forEach(model => sections.push({ type: "model", content: model }));
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
return sections;
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
const virtualScroll = new VirtualScroll({
|
| 63 |
+
itemHeight: 30, // Approximate height of each item
|
| 64 |
+
overscan: 5,
|
| 65 |
+
totalItems: () => allFilteredModels.length,
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
function handleBackdropClick(event: MouseEvent) {
|
| 69 |
+
event.stopPropagation();
|
| 70 |
+
if (window?.getSelection()?.toString()) {
|
| 71 |
+
return;
|
| 72 |
+
}
|
| 73 |
+
if (event.target === backdropEl) {
|
| 74 |
+
onClose?.();
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const isCustom = typia.createIs<CustomModel>();
|
| 79 |
+
|
| 80 |
const combobox = new Combobox<string | undefined>({
|
| 81 |
onOpenChange(o) {
|
| 82 |
if (!o) onClose?.();
|
|
|
|
| 91 |
onModelSelect?.(modelId);
|
| 92 |
onClose?.();
|
| 93 |
},
|
| 94 |
+
onNavigate(current, direction) {
|
| 95 |
+
const currIdx = allFilteredModels.findIndex(item => item.type === "model" && item.content === current);
|
| 96 |
+
// TODO: get next/prev item, scroll to it, and return its content. Make sure
|
| 97 |
+
// to wrap around.
|
| 98 |
+
if (direction === "next") {
|
| 99 |
+
}
|
| 100 |
+
if (direction === "prev") {
|
| 101 |
+
}
|
| 102 |
+
return null;
|
| 103 |
+
},
|
| 104 |
});
|
| 105 |
$effect(() => {
|
| 106 |
untrack(() => combobox.highlight(conversation.model.id));
|
|
|
|
| 109 |
combobox.open = true;
|
| 110 |
});
|
| 111 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
</script>
|
| 113 |
|
| 114 |
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
|
|
| 133 |
class="max-h-[220px] overflow-x-hidden overflow-y-auto md:max-h-[300px]"
|
| 134 |
{...combobox.content}
|
| 135 |
popover={undefined}
|
| 136 |
+
{...virtualScroll.container}
|
| 137 |
>
|
| 138 |
{#snippet modelEntry(model: Model | CustomModel, trending?: boolean)}
|
| 139 |
{@const [nameSpace, modelName] = model.id.split("/")}
|
|
|
|
| 211 |
{/if}
|
| 212 |
</div>
|
| 213 |
{/snippet}
|
| 214 |
+
|
| 215 |
+
<!-- Virtual scroll container -->
|
| 216 |
+
<div style="height: {virtualScroll.totalHeight}px; position: relative;">
|
| 217 |
+
<div style="transform: translateY({virtualScroll.offsetY}px);">
|
| 218 |
+
{#each virtualScroll.getVisibleItems(allFilteredModels) as { item }}
|
| 219 |
+
{#if item.type === "header"}
|
| 220 |
+
<div class="px-2 py-1.5 text-xs font-medium text-gray-500">{item.content}</div>
|
| 221 |
+
{:else if item.content === "__custom__"}
|
| 222 |
+
<div
|
| 223 |
+
class="flex w-full cursor-pointer items-center gap-2 px-2 py-1.5 text-sm text-gray-500 data-[highlighted]:bg-blue-500/15 data-[highlighted]:text-blue-600 dark:text-gray-400 dark:data-[highlighted]:text-blue-300"
|
| 224 |
+
{...combobox.getOption("__custom__", "custom", () => {
|
| 225 |
+
onClose?.();
|
| 226 |
+
openCustomModelConfig({
|
| 227 |
+
onSubmit: model => {
|
| 228 |
+
onModelSelect?.(model.id);
|
| 229 |
+
},
|
| 230 |
+
});
|
| 231 |
+
})}
|
| 232 |
+
>
|
| 233 |
+
<IconAdd class="rounded bg-blue-500/10 text-blue-600" />
|
| 234 |
+
Add a custom endpoint
|
| 235 |
+
</div>
|
| 236 |
+
{:else}
|
| 237 |
+
{@render modelEntry(item.content, item.trending)}
|
| 238 |
+
{/if}
|
| 239 |
+
{/each}
|
| 240 |
+
</div>
|
| 241 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
</div>
|
| 243 |
</div>
|
| 244 |
</div>
|
src/lib/spells/virtual-scroll.svelte.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { MaybeGetter } from "$lib/types.js";
|
| 2 |
+
import { ElementSize } from "runed";
|
| 3 |
+
import { createAttachmentKey } from "svelte/attachments";
|
| 4 |
+
import type { HTMLAttributes } from "svelte/elements";
|
| 5 |
+
import { extract } from "./extract.svelte";
|
| 6 |
+
|
| 7 |
+
interface VirtualScrollOptions {
|
| 8 |
+
totalItems?: MaybeGetter<number>;
|
| 9 |
+
itemHeight: MaybeGetter<number>;
|
| 10 |
+
overscan?: MaybeGetter<number | undefined>;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export class VirtualScroll {
|
| 14 |
+
#options: VirtualScrollOptions;
|
| 15 |
+
itemHeight = $derived.by(() => extract(this.#options.itemHeight));
|
| 16 |
+
overscan = $derived.by(() => extract(this.#options.overscan, 10));
|
| 17 |
+
totalItems = $derived.by(() => extract(this.#options.totalItems, 0));
|
| 18 |
+
|
| 19 |
+
#scrollTop = $state(0);
|
| 20 |
+
|
| 21 |
+
#containerEl = $state<HTMLElement>();
|
| 22 |
+
#containerSize = new ElementSize(() => this.#containerEl);
|
| 23 |
+
|
| 24 |
+
constructor(options: VirtualScrollOptions) {
|
| 25 |
+
this.#options = options;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
get scrollTop() {
|
| 29 |
+
return this.#scrollTop;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
set scrollTop(value: number) {
|
| 33 |
+
this.#scrollTop = value;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
get visibleRange() {
|
| 37 |
+
const startIndex = Math.floor(this.#scrollTop / this.itemHeight);
|
| 38 |
+
const endIndex = Math.min(
|
| 39 |
+
startIndex + Math.ceil(this.#containerSize.height / this.itemHeight),
|
| 40 |
+
this.totalItems - 1,
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
+
return {
|
| 44 |
+
start: Math.max(0, startIndex - this.overscan),
|
| 45 |
+
end: Math.min(this.totalItems - 1, endIndex + this.overscan),
|
| 46 |
+
};
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
get totalHeight() {
|
| 50 |
+
return this.totalItems * this.itemHeight;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
get offsetY() {
|
| 54 |
+
return this.visibleRange.start * this.itemHeight;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
getVisibleItems<T>(items: T[]): Array<{ item: T; index: number }> {
|
| 58 |
+
const { start, end } = this.visibleRange;
|
| 59 |
+
return items.slice(start, end + 1).map((item, i) => ({
|
| 60 |
+
item,
|
| 61 |
+
index: start + i,
|
| 62 |
+
}));
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
#attachmentKey = createAttachmentKey();
|
| 66 |
+
get container() {
|
| 67 |
+
return {
|
| 68 |
+
onscroll: e => {
|
| 69 |
+
this.scrollTop = e.currentTarget.scrollTop;
|
| 70 |
+
},
|
| 71 |
+
[this.#attachmentKey]: node => {
|
| 72 |
+
this.#containerEl = node;
|
| 73 |
+
return () => {
|
| 74 |
+
this.#containerEl = undefined;
|
| 75 |
+
};
|
| 76 |
+
},
|
| 77 |
+
} as const satisfies HTMLAttributes<HTMLElement>;
|
| 78 |
+
}
|
| 79 |
+
}
|