Compare commits
36 Commits
ef48d9815c
...
778839d35e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
778839d35e | ||
|
|
92fb314615 | ||
|
|
6f0b69ff45 | ||
|
|
2cd38797b9 | ||
|
|
6f231999e0 | ||
|
|
31a72d90ea | ||
|
|
072690270f | ||
|
|
eaf9d069c5 | ||
|
|
4a94f7bd09 | ||
|
|
918e792e41 | ||
|
|
c9c8b9abfc | ||
|
|
a392b575cc | ||
|
|
961475dea0 | ||
|
|
5496fd2680 | ||
|
|
f90f1e39e0 | ||
|
|
ca161dfbd4 | ||
|
|
ac2d0c32a4 | ||
|
|
54d22d650d | ||
|
|
a9c63f2544 | ||
|
|
70f57283a8 | ||
|
|
d43c873dc9 | ||
|
|
9501dbf281 | ||
|
|
0ac6acd174 | ||
|
|
5bb41c7e4c | ||
|
|
eed3339b0d | ||
|
|
d94e3cefb2 | ||
|
|
cfb586f539 | ||
|
|
6e975e5f8e | ||
|
|
142e4f0a19 | ||
|
|
59b85eead0 | ||
|
|
010643e398 | ||
|
|
27f637531b | ||
|
|
91fa08074b | ||
|
|
c246f70fe9 | ||
|
|
b1ce734f19 | ||
|
|
3add50a190 |
@@ -40,13 +40,13 @@ let { children }: Props = $props();
|
||||
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
||||
<header></header>
|
||||
|
||||
<ScrollArea class="h-screen w-screen">
|
||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||
<main class="flex-1 h-full w-full max-w-6xl mx-auto px-4 pt-6 pb-10 md:px-8 lg:pt-10 lg:pb-20 relative">
|
||||
<TooltipProvider>
|
||||
<TypographyMenu />
|
||||
{@render children?.()}
|
||||
</TooltipProvider>
|
||||
</main>
|
||||
</ScrollArea>
|
||||
<!-- </ScrollArea> -->
|
||||
<footer></footer>
|
||||
</div>
|
||||
|
||||
2
src/entities/Breadcrumb/index.ts
Normal file
2
src/entities/Breadcrumb/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { scrollBreadcrumbsStore } from './model';
|
||||
export { BreadcrumbHeader } from './ui';
|
||||
1
src/entities/Breadcrumb/model/index.ts
Normal file
1
src/entities/Breadcrumb/model/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
index: number;
|
||||
title: Snippet<[{ className?: string }]>;
|
||||
}
|
||||
|
||||
class ScrollBreadcrumbsStore {
|
||||
#items = $state<BreadcrumbItem[]>([]);
|
||||
|
||||
get items() {
|
||||
// Keep them sorted by index for Swiss orderliness
|
||||
return this.#items.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
add(item: BreadcrumbItem) {
|
||||
if (!this.#items.find(i => i.index === item.index)) {
|
||||
this.#items.push(item);
|
||||
}
|
||||
}
|
||||
remove(index: number) {
|
||||
this.#items = this.#items.filter(i => i.index !== index);
|
||||
}
|
||||
}
|
||||
|
||||
export function createScrollBreadcrumbsStore() {
|
||||
return new ScrollBreadcrumbsStore();
|
||||
}
|
||||
|
||||
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
||||
@@ -0,0 +1,78 @@
|
||||
<!--
|
||||
Component: BreadcrumbHeader
|
||||
Fixed header for breadcrumbs navigation for sections in the page
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Icon from '@lucide/svelte/icons/align-vertical-justify-center';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { scrollBreadcrumbsStore } from '../../model';
|
||||
</script>
|
||||
|
||||
{#if scrollBreadcrumbsStore.items.length > 0}
|
||||
<div
|
||||
transition:slide={{ duration: 200 }}
|
||||
class="
|
||||
fixed top-0 left-0 right-0 z-100
|
||||
backdrop-blur-lg bg-white/20
|
||||
border-b border-gray-300/50
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
h-12
|
||||
"
|
||||
>
|
||||
<div class="max-w-8xl mx-auto px-6 h-full flex items-center gap-4">
|
||||
<div class="flex items-center gap-2.5 opacity-70">
|
||||
<Icon class="size-4 stroke-gray-900 stroke-1" />
|
||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
||||
<span class="font-mono text-[9px] uppercase tracking-[0.25em] text-gray-500 font-medium">
|
||||
nav_trace
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-4 w-px bg-gray-300/60"></div>
|
||||
|
||||
<nav class="flex items-center gap-3 overflow-x-auto scrollbar-hide flex-1">
|
||||
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
|
||||
<div
|
||||
animate:flip={{ duration: 200 }}
|
||||
class="flex items-center gap-3 whitespace-nowrap shrink-0"
|
||||
>
|
||||
<span class="font-mono text-[9px] text-gray-400 tracking-wider">
|
||||
{String(item.index).padStart(2, '0')}
|
||||
</span>
|
||||
|
||||
{@render item.title({
|
||||
className: 'font-mono text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-gray-900',
|
||||
})}
|
||||
|
||||
{#if idx < scrollBreadcrumbsStore.items.length - 1}
|
||||
<div class="flex items-center gap-0.5 opacity-40">
|
||||
<div class="w-1 h-px bg-gray-400"></div>
|
||||
<div class="w-1 h-px bg-gray-400"></div>
|
||||
<div class="w-1 h-px bg-gray-400"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-2 opacity-50 ml-auto">
|
||||
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
||||
<span class="font-mono text-[8px] text-gray-400 tracking-wider">
|
||||
[{scrollBreadcrumbsStore.items.length}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
3
src/entities/Breadcrumb/ui/index.ts
Normal file
3
src/entities/Breadcrumb/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte';
|
||||
|
||||
export { BreadcrumbHeader };
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
// Proxy API (PRIMARY - NEW)
|
||||
export {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
fetchProxyFonts,
|
||||
} from './proxy/proxyFonts';
|
||||
|
||||
@@ -246,3 +246,34 @@ export async function fetchProxyFontById(
|
||||
|
||||
return response.fonts.find(font => font.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch multiple fonts by their IDs
|
||||
*
|
||||
* @param ids - Array of font IDs to fetch
|
||||
* @returns Promise resolving to an array of fonts
|
||||
*/
|
||||
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
// Use proxy API if enabled
|
||||
if (USE_PROXY_API) {
|
||||
const queryString = ids.join(',');
|
||||
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<UnifiedFont[]>(url);
|
||||
return response.data ?? [];
|
||||
} catch (error) {
|
||||
console.warn('[fetchFontsByIds] Proxy API batch fetch failed, falling back', error);
|
||||
// Fallthrough to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Fetch individually (not efficient but functional for fallback)
|
||||
const results = await Promise.all(
|
||||
ids.map(id => fetchProxyFontById(id)),
|
||||
);
|
||||
|
||||
return results.filter((f): f is UnifiedFont => !!f);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Proxy API (PRIMARY)
|
||||
export {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
fetchProxyFonts,
|
||||
} from './api/proxy/proxyFonts';
|
||||
|
||||
@@ -3,33 +3,47 @@ import { SvelteMap } from 'svelte/reactivity';
|
||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||
|
||||
export interface FontConfigRequest {
|
||||
slug: string;
|
||||
/**
|
||||
* Font id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Real font name (e.g. "Lato")
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The .ttf URL
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Font weight
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Flag of the variable weight
|
||||
*/
|
||||
isVariable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager that handles loading of fonts from Fontshare.
|
||||
* Manager that handles loading of fonts.
|
||||
* Logic:
|
||||
* - Variable fonts: Loaded once per slug (covers all weights).
|
||||
* - Static fonts: Loaded per slug + weight combination.
|
||||
* - Variable fonts: Loaded once per id (covers all weights).
|
||||
* - Static fonts: Loaded per id + weight combination.
|
||||
*/
|
||||
class AppliedFontsManager {
|
||||
// Tracking usage: Map<key, timestamp> where key is "slug" or "slug@weight"
|
||||
#usageTracker = new Map<string, number>();
|
||||
// Map: key -> batchId
|
||||
#slugToBatch = new Map<string, string>();
|
||||
// Map: batchId -> HTMLLinkElement
|
||||
#batchElements = new Map<string, HTMLLinkElement>();
|
||||
#idToBatch = new Map<string, string>();
|
||||
// Changed to HTMLStyleElement
|
||||
#batchElements = new Map<string, HTMLStyleElement>();
|
||||
|
||||
#queue = new Set<string>();
|
||||
#queue = new Map<string, FontConfigRequest>(); // Track config in queue
|
||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
#PURGE_INTERVAL = 60000;
|
||||
#TTL = 5 * 60 * 1000;
|
||||
#CHUNK_SIZE = 3;
|
||||
#CHUNK_SIZE = 5; // Can be larger since we're just injecting strings
|
||||
|
||||
// Reactive status map for UI feedback
|
||||
statuses = new SvelteMap<string, FontStatus>();
|
||||
|
||||
constructor() {
|
||||
@@ -38,139 +52,119 @@ class AppliedFontsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a unique key for the font asset.
|
||||
*/
|
||||
#getFontKey(slug: string, weight: number, isVariable: boolean): string {
|
||||
const s = slug.toLowerCase();
|
||||
// Variable fonts only need one entry regardless of weight
|
||||
return isVariable ? s : `${s}@${weight}`;
|
||||
#getFontKey(id: string, weight: number): string {
|
||||
return `${id.toLowerCase()}@${weight}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this when a font is rendered on screen.
|
||||
*/
|
||||
touch(configs: FontConfigRequest[]) {
|
||||
const now = Date.now();
|
||||
const toRegister: string[] = [];
|
||||
|
||||
configs.forEach(({ slug, weight, isVariable = false }) => {
|
||||
const key = this.#getFontKey(slug, weight, isVariable);
|
||||
|
||||
configs.forEach(config => {
|
||||
const key = this.#getFontKey(config.id, config.weight);
|
||||
this.#usageTracker.set(key, now);
|
||||
|
||||
if (!this.#slugToBatch.has(key)) {
|
||||
toRegister.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (toRegister.length > 0) this.registerFonts(toRegister);
|
||||
}
|
||||
|
||||
registerFonts(keys: string[]) {
|
||||
const newKeys = keys.filter(k => !this.#slugToBatch.has(k) && !this.#queue.has(k));
|
||||
if (newKeys.length === 0) return;
|
||||
|
||||
newKeys.forEach(k => this.#queue.add(k));
|
||||
if (!this.#idToBatch.has(key) && !this.#queue.has(key)) {
|
||||
this.#queue.set(key, config);
|
||||
|
||||
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getFontStatus(slug: string, weight: number, isVariable: boolean) {
|
||||
return this.statuses.get(this.#getFontKey(slug, weight, isVariable));
|
||||
getFontStatus(id: string, weight: number) {
|
||||
return this.statuses.get(this.#getFontKey(id, weight));
|
||||
}
|
||||
|
||||
#processQueue() {
|
||||
const fullQueue = Array.from(this.#queue);
|
||||
if (fullQueue.length === 0) return;
|
||||
const entries = Array.from(this.#queue.entries());
|
||||
if (entries.length === 0) return;
|
||||
|
||||
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) {
|
||||
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE));
|
||||
for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
|
||||
this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE));
|
||||
}
|
||||
|
||||
this.#queue.clear();
|
||||
this.#timeoutId = null;
|
||||
}
|
||||
|
||||
#createBatch(keys: string[]) {
|
||||
#createBatch(batchEntries: [string, FontConfigRequest][]) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const batchId = crypto.randomUUID();
|
||||
let cssRules = '';
|
||||
|
||||
/**
|
||||
* Fontshare API Logic:
|
||||
* - If key contains '@', it's static (e.g., satoshi@700)
|
||||
* - If it's a plain slug, it's variable. We append '@1,2' for variable assets.
|
||||
*/
|
||||
const query = keys.map(k => {
|
||||
return k.includes('@') ? `f[]=${k}` : `f[]=${k}@1,2`;
|
||||
}).join('&');
|
||||
batchEntries.forEach(([key, config]) => {
|
||||
this.statuses.set(key, 'loading');
|
||||
this.#idToBatch.set(key, batchId);
|
||||
|
||||
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
|
||||
|
||||
keys.forEach(key => this.statuses.set(key, 'loading'));
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = url;
|
||||
link.dataset.batchId = batchId;
|
||||
document.head.appendChild(link);
|
||||
|
||||
this.#batchElements.set(batchId, link);
|
||||
|
||||
keys.forEach(key => {
|
||||
this.#slugToBatch.set(key, batchId);
|
||||
|
||||
// Determine what to check in the Font Loading API
|
||||
const isVariable = !key.includes('@');
|
||||
const [family, staticWeight] = key.split('@');
|
||||
|
||||
// For variable fonts, we check a standard weight;
|
||||
// for static, we check the specific numeric weight requested.
|
||||
const weightToCheck = isVariable ? '400' : staticWeight;
|
||||
|
||||
document.fonts.load(`${weightToCheck} 1em "${family}"`)
|
||||
.then(loadedFonts => {
|
||||
this.statuses.set(key, loadedFonts.length > 0 ? 'loaded' : 'error');
|
||||
})
|
||||
.catch(() => {
|
||||
this.statuses.set(key, 'error');
|
||||
// Construct the @font-face rule
|
||||
// Using format('truetype') for .ttf
|
||||
cssRules += `
|
||||
@font-face {
|
||||
font-family: '${config.name}';
|
||||
src: url('${config.url}') format('truetype');
|
||||
font-weight: ${config.weight};
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
`;
|
||||
});
|
||||
|
||||
// Create and inject the style tag
|
||||
const style = document.createElement('style');
|
||||
style.dataset.batchId = batchId;
|
||||
style.innerHTML = cssRules;
|
||||
document.head.appendChild(style);
|
||||
this.#batchElements.set(batchId, style);
|
||||
|
||||
// Verify loading via Font Loading API
|
||||
batchEntries.forEach(([key, config]) => {
|
||||
document.fonts.load(`${config.weight} 1em "${config.name}"`)
|
||||
.then(loaded => {
|
||||
this.statuses.set(key, loaded.length > 0 ? 'loaded' : 'error');
|
||||
})
|
||||
.catch(() => this.statuses.set(key, 'error'));
|
||||
});
|
||||
}
|
||||
|
||||
#purgeUnused() {
|
||||
const now = Date.now();
|
||||
const batchesToPotentialDelete = new Set<string>();
|
||||
const keysToDelete: string[] = [];
|
||||
const batchesToRemove = new Set<string>();
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (const [key, lastUsed] of this.#usageTracker.entries()) {
|
||||
if (now - lastUsed > this.#TTL) {
|
||||
const batchId = this.#slugToBatch.get(key);
|
||||
if (batchId) batchesToPotentialDelete.add(batchId);
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
batchesToPotentialDelete.forEach(batchId => {
|
||||
const batchKeys = Array.from(this.#slugToBatch.entries())
|
||||
const batchId = this.#idToBatch.get(key);
|
||||
if (batchId) {
|
||||
// Check if EVERY font in this batch is expired
|
||||
const batchKeys = Array.from(this.#idToBatch.entries())
|
||||
.filter(([_, bId]) => bId === batchId)
|
||||
.map(([key]) => key);
|
||||
.map(([k]) => k);
|
||||
|
||||
const allExpired = batchKeys.every(k => keysToDelete.includes(k));
|
||||
const canDeleteBatch = batchKeys.every(k => {
|
||||
const lastK = this.#usageTracker.get(k);
|
||||
return lastK && (now - lastK > this.#TTL);
|
||||
});
|
||||
|
||||
if (allExpired) {
|
||||
this.#batchElements.get(batchId)?.remove();
|
||||
this.#batchElements.delete(batchId);
|
||||
batchKeys.forEach(k => {
|
||||
this.#slugToBatch.delete(k);
|
||||
if (canDeleteBatch) {
|
||||
batchesToRemove.add(batchId);
|
||||
keysToRemove.push(...batchKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
batchesToRemove.forEach(id => {
|
||||
this.#batchElements.get(id)?.remove();
|
||||
this.#batchElements.delete(id);
|
||||
});
|
||||
|
||||
keysToRemove.forEach(k => {
|
||||
this.#idToBatch.delete(k);
|
||||
this.#usageTracker.delete(k);
|
||||
this.statuses.delete(k);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const appliedFontsManager = new AppliedFontsManager();
|
||||
|
||||
@@ -59,6 +59,11 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||
} | null
|
||||
>(null);
|
||||
|
||||
/**
|
||||
* Accumulated fonts from all pages (for infinite scroll)
|
||||
*/
|
||||
#accumulatedFonts = $state<UnifiedFont[]>([]);
|
||||
|
||||
/**
|
||||
* Pagination metadata (derived from proxy API response)
|
||||
*/
|
||||
@@ -84,8 +89,53 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Track previous filter params to detect changes and reset pagination
|
||||
*/
|
||||
#previousFilterParams = $state<string>('');
|
||||
|
||||
/**
|
||||
* Cleanup function for the filter tracking effect
|
||||
*/
|
||||
#filterCleanup: (() => void) | null = null;
|
||||
|
||||
constructor(initialParams: ProxyFontsParams = {}) {
|
||||
super(initialParams);
|
||||
|
||||
// Track filter params (excluding pagination params)
|
||||
// Wrapped in $effect.root() to prevent effect_orphan error
|
||||
this.#filterCleanup = $effect.root(() => {
|
||||
$effect(() => {
|
||||
const filterParams = JSON.stringify({
|
||||
provider: this.params.provider,
|
||||
category: this.params.category,
|
||||
subset: this.params.subset,
|
||||
q: this.params.q,
|
||||
});
|
||||
|
||||
// If filters changed, reset offset to 0
|
||||
if (filterParams !== this.#previousFilterParams) {
|
||||
if (this.#previousFilterParams && this.params.offset !== 0) {
|
||||
this.setParams({ offset: 0 });
|
||||
}
|
||||
this.#previousFilterParams = filterParams;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up both parent and child effects
|
||||
*/
|
||||
destroy() {
|
||||
// Call parent cleanup (TanStack observer effect)
|
||||
super.destroy();
|
||||
|
||||
// Call filter tracking effect cleanup
|
||||
if (this.#filterCleanup) {
|
||||
this.#filterCleanup();
|
||||
this.#filterCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,17 +186,25 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||
offset: response.offset ?? this.params.offset ?? 0,
|
||||
};
|
||||
|
||||
// Accumulate fonts for infinite scroll
|
||||
if (params.offset === 0) {
|
||||
// Reset when starting from beginning (new search/filter)
|
||||
this.#accumulatedFonts = response.fonts;
|
||||
} else {
|
||||
// Append new fonts to existing ones
|
||||
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||
}
|
||||
|
||||
return response.fonts;
|
||||
}
|
||||
|
||||
// --- Getters (proxied from BaseFontStore) ---
|
||||
|
||||
/**
|
||||
* Get all fonts from current query result
|
||||
* Get all accumulated fonts (for infinite scroll)
|
||||
*/
|
||||
get fonts(): UnifiedFont[] {
|
||||
// The result.data is UnifiedFont[] (from TanStack Query)
|
||||
return (this.result.data as UnifiedFont[] | undefined) ?? [];
|
||||
return this.#accumulatedFonts;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,5 +346,9 @@ export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
|
||||
|
||||
/**
|
||||
* Singleton instance for global use
|
||||
* Initialized with a default limit to prevent fetching all fonts at once
|
||||
*/
|
||||
export const unifiedFontStore = new UnifiedFontStore();
|
||||
export const unifiedFontStore = new UnifiedFontStore({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ interface Props {
|
||||
* Font id to load
|
||||
*/
|
||||
id: string;
|
||||
|
||||
url: string;
|
||||
/**
|
||||
* Font weight
|
||||
*/
|
||||
@@ -34,7 +36,7 @@ interface Props {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { name, id, weight = 400, className, children }: Props = $props();
|
||||
let { name, id, url, weight = 400, className, children }: Props = $props();
|
||||
let element: Element;
|
||||
|
||||
// Track if the user has actually scrolled this into view
|
||||
@@ -44,7 +46,7 @@ $effect(() => {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
hasEnteredViewport = true;
|
||||
appliedFontsManager.touch([{ slug: id, weight }]);
|
||||
appliedFontsManager.touch([{ id, weight, name, url }]);
|
||||
|
||||
// Once it has entered, we can stop observing to save CPU
|
||||
observer.unobserve(element);
|
||||
@@ -54,7 +56,7 @@ $effect(() => {
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
const status = $derived(appliedFontsManager.getFontStatus(id, weight, false));
|
||||
const status = $derived(appliedFontsManager.getFontStatus(id, weight));
|
||||
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
|
||||
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<!--
|
||||
Component: FontListItem
|
||||
Displays a font item with a checkbox and its characteristics in badges.
|
||||
Displays a font item and manages its animations
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Badge } from '$shared/shadcn/ui/badge';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import {
|
||||
type UnifiedFont,
|
||||
selectedFontsStore,
|
||||
} from '../../model';
|
||||
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -20,16 +19,24 @@ interface Props {
|
||||
/**
|
||||
* Is element fully visible
|
||||
*/
|
||||
isVisible: boolean;
|
||||
isFullyVisible: boolean;
|
||||
/**
|
||||
* Is element partially visible
|
||||
*/
|
||||
isPartiallyVisible: boolean;
|
||||
/**
|
||||
* From 0 to 1
|
||||
*/
|
||||
proximity: number;
|
||||
/**
|
||||
* Children snippet
|
||||
*/
|
||||
children: Snippet<[font: UnifiedFont]>;
|
||||
}
|
||||
|
||||
const { font, isVisible, proximity }: Props = $props();
|
||||
const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props();
|
||||
|
||||
let selected = $state(selectedFontsStore.has(font.id));
|
||||
const selected = $derived(selectedFontsStore.has(font.id));
|
||||
let timeoutId = $state<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Create a spring for smooth scale animation
|
||||
@@ -46,11 +53,7 @@ const bloom = new Spring(0, {
|
||||
|
||||
// Sync spring to proximity for a "Lens" effect
|
||||
$effect(() => {
|
||||
bloom.target = isVisible ? 1 : 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
selected = selectedFontsStore.has(font.id);
|
||||
bloom.target = isPartiallyVisible ? 1 : 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -61,11 +64,6 @@ $effect(() => {
|
||||
};
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
animateSelection();
|
||||
selected ? selectedFontsStore.removeOne(font.id) : selectedFontsStore.addOne(font);
|
||||
}
|
||||
|
||||
function animateSelection() {
|
||||
scale.target = 0.98;
|
||||
|
||||
@@ -83,58 +81,5 @@ function animateSelection() {
|
||||
translateY({(1 - bloom.current) * 10}px)
|
||||
"
|
||||
>
|
||||
<div style:transform={`scale(${scale.current})`}>
|
||||
<div
|
||||
class={cn(
|
||||
'w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3',
|
||||
'active:transition-transform active:duration-150',
|
||||
'border dark:border-slate-800',
|
||||
'bg-white/10 border-white/20',
|
||||
isVisible && 'bg-white/40 border-white/40',
|
||||
selected && 'ring-2 ring-indigo-600 ring-inset bg-indigo-50/50 hover:bg-indigo-50',
|
||||
)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onmousedown={(e => {
|
||||
// Prevent browser focus-jump
|
||||
if (e.currentTarget === document.activeElement) return;
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
})}
|
||||
onkeydown={(e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="flex flex-row gap-1 w-full items-center justify-between">
|
||||
<div class="flex flex-col gap-1 transition-all duration-150 ease-out">
|
||||
<div class="flex flex-row gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[0.5rem] font-mono uppercase tracking-widest text-slate-900"
|
||||
>
|
||||
{font.provider}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[0.5rem] font-mono uppercase tracking-widest text-slate-900"
|
||||
>
|
||||
{font.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<FontApplicator
|
||||
id={font.id}
|
||||
className="text-2xl"
|
||||
name={font.name}
|
||||
>
|
||||
{font.name}
|
||||
</FontApplicator>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{@render children?.(font)}
|
||||
</div>
|
||||
|
||||
@@ -3,24 +3,40 @@
|
||||
- Renders a virtualized list of fonts
|
||||
- Handles font registration with the manager
|
||||
-->
|
||||
<script lang="ts" generics="T extends { id: string }">
|
||||
<script lang="ts" generics="T extends UnifiedFont">
|
||||
import type { FontConfigRequest } from '$entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte';
|
||||
import { VirtualList } from '$shared/ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { appliedFontsManager } from '../../model';
|
||||
import {
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
} from '../../model';
|
||||
|
||||
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
|
||||
onVisibleItemsChange?: (items: T[]) => void;
|
||||
onNearBottom?: (lastVisibleIndex: number) => void;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
let { items, children, onVisibleItemsChange, ...rest }: Props = $props();
|
||||
let { items, children, onVisibleItemsChange, onNearBottom, weight, ...rest }: Props = $props();
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: T[]) {
|
||||
// Auto-register fonts with the manager
|
||||
const slugs = visibleItems.map(item => item.id);
|
||||
appliedFontsManager.registerFonts(slugs);
|
||||
const configs = visibleItems.map<FontConfigRequest>(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
weight,
|
||||
url: item.styles.regular!,
|
||||
}));
|
||||
appliedFontsManager.touch(configs);
|
||||
|
||||
// // Forward the call to any external listener
|
||||
// onVisibleItemsChange?.(visibleItems);
|
||||
}
|
||||
|
||||
function handleNearBottom(lastVisibleIndex: number) {
|
||||
// Forward the call to any external listener
|
||||
onVisibleItemsChange?.(visibleItems);
|
||||
onNearBottom?.(lastVisibleIndex);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -28,6 +44,7 @@ function handleInternalVisibleChange(visibleItems: T[]) {
|
||||
{items}
|
||||
{...rest}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
onNearBottom={handleNearBottom}
|
||||
>
|
||||
{#snippet children(scope)}
|
||||
{@render children(scope)}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { displayedFontsStore } from './model';
|
||||
export { FontDisplay } from './ui';
|
||||
export { FontSampler } from './ui';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { displayedFontsStore } from './store';
|
||||
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
type UnifiedFont,
|
||||
selectedFontsStore,
|
||||
} from '$entities/Font';
|
||||
|
||||
/**
|
||||
* Store for displayed font samples
|
||||
* - Handles shown text
|
||||
* - Stores selected fonts for display
|
||||
*/
|
||||
export class DisplayedFontsStore {
|
||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
||||
|
||||
#displayedFonts = $derived.by(() => {
|
||||
return selectedFontsStore.all;
|
||||
});
|
||||
|
||||
#fontA = $state<UnifiedFont | undefined>(undefined);
|
||||
|
||||
#fontB = $state<UnifiedFont | undefined>(undefined);
|
||||
|
||||
#hasAnySelectedFonts = $derived(this.#displayedFonts.length > 0);
|
||||
|
||||
get fonts() {
|
||||
return this.#displayedFonts;
|
||||
}
|
||||
|
||||
get fontA() {
|
||||
return this.#fontA ?? this.#displayedFonts[0];
|
||||
}
|
||||
|
||||
set fontA(font: UnifiedFont | undefined) {
|
||||
this.#fontA = font;
|
||||
}
|
||||
|
||||
get fontB() {
|
||||
return this.#fontB ?? this.#displayedFonts[1];
|
||||
}
|
||||
|
||||
set fontB(font: UnifiedFont | undefined) {
|
||||
this.#fontB = font;
|
||||
}
|
||||
|
||||
get text() {
|
||||
return this.#sampleText;
|
||||
}
|
||||
|
||||
set text(text: string) {
|
||||
this.#sampleText = text;
|
||||
}
|
||||
|
||||
get hasAnyFonts() {
|
||||
return this.#hasAnySelectedFonts;
|
||||
}
|
||||
|
||||
getById(id: string): UnifiedFont | undefined {
|
||||
return selectedFontsStore.getById(id);
|
||||
}
|
||||
}
|
||||
|
||||
export const displayedFontsStore = new DisplayedFontsStore();
|
||||
@@ -1 +0,0 @@
|
||||
export { displayedFontsStore } from './displayedFontsStore.svelte';
|
||||
@@ -1,18 +0,0 @@
|
||||
<!--
|
||||
Component: FontDisplay
|
||||
Displays a grid of FontSampler components for each displayed font.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { flip } from 'svelte/animate';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { displayedFontsStore } from '../../model';
|
||||
import FontSampler from '../FontSampler/FontSampler.svelte';
|
||||
</script>
|
||||
|
||||
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))] will-change-tranform transition-transform content">
|
||||
{#each displayedFontsStore.fonts as font, index (font.id)}
|
||||
<div animate:flip={{ delay: 0, duration: 400, easing: quintOut }}>
|
||||
<FontSampler font={font} bind:text={displayedFontsStore.text} index={index} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -86,7 +86,8 @@ function removeSample() {
|
||||
</div>
|
||||
|
||||
<div class="p-8 relative z-10">
|
||||
<FontApplicator id={font.id} name={font.name}>
|
||||
<!-- TODO: Fix this ! -->
|
||||
<FontApplicator id={font.id} name={font.name} url={font.styles.regular!}>
|
||||
<ContentEditable
|
||||
bind:text={text}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import FontDisplay from './FontDisplay/FontDisplay.svelte';
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
|
||||
export { FontDisplay };
|
||||
export { FontSampler };
|
||||
|
||||
@@ -15,5 +15,4 @@ export { filterManager } from './model/state/manager.svelte';
|
||||
export {
|
||||
FilterControls,
|
||||
Filters,
|
||||
SuggestedFonts,
|
||||
} from './ui';
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<!--
|
||||
Component: SuggestedFonts
|
||||
Renders a list of suggested fonts in a virtualized list to improve performance.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontListItem,
|
||||
FontVirtualList,
|
||||
unifiedFontStore,
|
||||
} from '$entities/Font';
|
||||
</script>
|
||||
|
||||
<FontVirtualList items={unifiedFontStore.fonts}>
|
||||
{#snippet children({ item: font, isVisible, proximity })}
|
||||
<FontListItem {font} {isVisible} {proximity} />
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
@@ -1,9 +1,7 @@
|
||||
import Filters from './Filters/Filters.svelte';
|
||||
import FilterControls from './FiltersControl/FilterControls.svelte';
|
||||
import SuggestedFonts from './SuggestedFonts/SuggestedFonts.svelte';
|
||||
|
||||
export {
|
||||
FilterControls,
|
||||
Filters,
|
||||
SuggestedFonts,
|
||||
};
|
||||
|
||||
@@ -3,109 +3,85 @@
|
||||
Description: The main page component of the application.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { appliedFontsManager } from '$entities/Font';
|
||||
import { displayedFontsStore } from '$features/DisplayFont';
|
||||
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
||||
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
import { Section } from '$shared/ui';
|
||||
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
||||
import { FontSearch } from '$widgets/FontSearch';
|
||||
import { SampleList } from '$widgets/SampleList';
|
||||
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
|
||||
import ScanEyeIcon from '@lucide/svelte/icons/scan-eye';
|
||||
import ScanSearchIcon from '@lucide/svelte/icons/search';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let searchContainer: HTMLElement;
|
||||
|
||||
let isExpanded = $state(false);
|
||||
let isOpen = $state(false);
|
||||
|
||||
let isEmptyScreen = $derived(!displayedFontsStore.hasAnyFonts && !isExpanded && !isOpen);
|
||||
function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) {
|
||||
if (isPast && title) {
|
||||
scrollBreadcrumbsStore.add({ index, title });
|
||||
} else {
|
||||
scrollBreadcrumbsStore.remove(index);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
appliedFontsManager.touch(
|
||||
displayedFontsStore.fonts.map(font => ({ slug: font.id, weight: controlManager.weight })),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
scrollBreadcrumbsStore.remove(index);
|
||||
};
|
||||
}
|
||||
|
||||
// $effect(() => {
|
||||
// appliedFontsManager.touch(
|
||||
// selectedFontsStore.all.map(font => ({
|
||||
// slug: font.id,
|
||||
// weight: controlManager.weight,
|
||||
// })),
|
||||
// );
|
||||
// });
|
||||
</script>
|
||||
|
||||
<BreadcrumbHeader />
|
||||
|
||||
<!-- Font List -->
|
||||
<div class="p-2 h-full flex flex-col gap-3">
|
||||
{#key isEmptyScreen}
|
||||
<div
|
||||
class={cn(
|
||||
'flex flex-col transition-all duration-700 ease-[cubic-bezier(0.23,1,0.32,1)] mx-40',
|
||||
'will-change-[flex-grow] transform-gpu',
|
||||
isEmptyScreen
|
||||
? 'grow justify-center'
|
||||
: 'animate-search',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
'transition-transform duration-700 ease-[cubic-bezier(0.23,1,0.32,1)]',
|
||||
)}
|
||||
>
|
||||
<FontSearch bind:showFilters={isExpanded} bind:isOpen />
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
{#if displayedFontsStore.fonts.length > 1}
|
||||
<Section class="my-12 gap-8" index={1}>
|
||||
<Section class="my-12 gap-8" index={0} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
{#snippet icon({ className })}
|
||||
<ScanEyeIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h1 class={className}>
|
||||
Optical<br>Comparator
|
||||
Optical<br />Comparator
|
||||
</h1>
|
||||
{/snippet}
|
||||
<ComparisonSlider />
|
||||
</Section>
|
||||
{/if}
|
||||
|
||||
{#if displayedFontsStore.hasAnyFonts}
|
||||
<Section class="my-12 gap-8" index={2}>
|
||||
<Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
{#snippet icon({ className })}
|
||||
<ScanSearchIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h2 class={className}>
|
||||
Query<br />Module
|
||||
</h2>
|
||||
{/snippet}
|
||||
<FontSearch bind:showFilters={isExpanded} />
|
||||
</Section>
|
||||
|
||||
<Section class="my-12 gap-8" index={2} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
{#snippet icon({ className })}
|
||||
<LineSquiggleIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h2 class={className}>
|
||||
Sample<br>Set
|
||||
Sample<br />Set
|
||||
</h2>
|
||||
{/snippet}
|
||||
<FontDisplay />
|
||||
<SampleList />
|
||||
</Section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes search {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
30% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
flex-grow: 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-search {
|
||||
animation: search 0.5s cubic-bezier(0.165, 0.84, 0.44, 1) forwards;
|
||||
}
|
||||
|
||||
.content {
|
||||
/* Tells the browser to skip rendering off-screen content */
|
||||
content-visibility: auto;
|
||||
@@ -115,10 +91,4 @@ $effect(() => {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.will-change-[height] {
|
||||
will-change: flex-grow, padding;
|
||||
/* Forces GPU acceleration for the layout shift */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,9 +46,6 @@ export class EntityStore<T extends Entity> {
|
||||
updateOne(id: string, changes: Partial<T>) {
|
||||
const entity = this.#entities.get(id);
|
||||
if (entity) {
|
||||
// In Svelte 5, updating the object property directly is reactive
|
||||
// if the object itself was made reactive, but here we replace
|
||||
// the reference to ensure top-level map triggers.
|
||||
this.#entities.set(id, { ...entity, ...changes });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Reusable persistent storage utility using Svelte 5 runes
|
||||
*
|
||||
* Automatically syncs state with localStorage.
|
||||
*/
|
||||
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||
// Initialize from storage or default
|
||||
const loadFromStorage = (): T => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch (error) {
|
||||
console.warn(`[createPersistentStore] Error loading ${key}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
let value = $state<T>(loadFromStorage());
|
||||
|
||||
// Sync to storage whenever value changes
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn(`[createPersistentStore] Error saving ${key}:`, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(v: T) {
|
||||
value = v;
|
||||
},
|
||||
clear() {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
value = defaultValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,19 +4,37 @@
|
||||
* Used to render visible items with absolute positioning based on computed offsets.
|
||||
*/
|
||||
export interface VirtualItem {
|
||||
/** Index of the item in the data array */
|
||||
/**
|
||||
* Index of the item in the data array
|
||||
*/
|
||||
index: number;
|
||||
/** Offset from the top of the list in pixels */
|
||||
/**
|
||||
* Offset from the top of the list in pixels
|
||||
*/
|
||||
start: number;
|
||||
/** Height/size of the item in pixels */
|
||||
/**
|
||||
* Height/size of the item in pixels
|
||||
*/
|
||||
size: number;
|
||||
/** End position in pixels (start + size) */
|
||||
/**
|
||||
* End position in pixels (start + size)
|
||||
*/
|
||||
end: number;
|
||||
/** Unique key for the item (for Svelte's {#each} keying) */
|
||||
/**
|
||||
* Unique key for the item (for Svelte's {#each} keying)
|
||||
*/
|
||||
key: string | number;
|
||||
/** Whether the item is currently visible in the viewport */
|
||||
isVisible: boolean;
|
||||
/** Proximity of the item to the center of the viewport */
|
||||
/**
|
||||
* Whether the item is currently fully visible in the viewport
|
||||
*/
|
||||
isFullyVisible: boolean;
|
||||
/**
|
||||
* Whether the item is currently partially visible in the viewport
|
||||
*/
|
||||
isPartiallyVisible: boolean;
|
||||
/**
|
||||
* Proximity of the item to the center of the viewport
|
||||
*/
|
||||
proximity: number;
|
||||
}
|
||||
|
||||
@@ -45,6 +63,11 @@ export interface VirtualizerOptions {
|
||||
* Can be useful for handling sticky headers or other UI elements.
|
||||
*/
|
||||
scrollMargin?: number;
|
||||
/**
|
||||
* Whether to use the window as the scroll container.
|
||||
* @default false
|
||||
*/
|
||||
useWindowScroll?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +115,7 @@ export function createVirtualizer<T>(
|
||||
let containerHeight = $state(0);
|
||||
let measuredSizes = $state<Record<number, number>>({});
|
||||
let elementRef: HTMLElement | null = null;
|
||||
let elementOffsetTop = 0;
|
||||
|
||||
// By wrapping the getter in $derived, we track everything inside it
|
||||
const options = $derived(optionsGetter());
|
||||
@@ -157,9 +181,8 @@ export function createVirtualizer<T>(
|
||||
const itemEnd = itemStart + itemSize;
|
||||
|
||||
// Visibility check: Does the item overlap the viewport?
|
||||
// const isVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
|
||||
// Fully visible
|
||||
const isVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
||||
const isPartiallyVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
|
||||
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
||||
|
||||
// Proximity calculation: 1.0 at center, 0.0 at edges
|
||||
const itemCenter = itemStart + (itemSize / 2);
|
||||
@@ -173,7 +196,8 @@ export function createVirtualizer<T>(
|
||||
size: itemSize,
|
||||
end: itemEnd,
|
||||
key: options.getItemKey?.(i) ?? i,
|
||||
isVisible,
|
||||
isPartiallyVisible,
|
||||
isFullyVisible,
|
||||
proximity,
|
||||
});
|
||||
}
|
||||
@@ -192,6 +216,53 @@ export function createVirtualizer<T>(
|
||||
*/
|
||||
function container(node: HTMLElement) {
|
||||
elementRef = node;
|
||||
const { useWindowScroll } = optionsGetter();
|
||||
|
||||
if (useWindowScroll) {
|
||||
// Calculate initial offset ONCE
|
||||
const getElementOffset = () => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.top + window.scrollY;
|
||||
};
|
||||
|
||||
let cachedOffsetTop = getElementOffset();
|
||||
containerHeight = window.innerHeight;
|
||||
|
||||
const handleScroll = () => {
|
||||
// Use cached offset for scroll calculations
|
||||
scrollOffset = Math.max(0, window.scrollY - cachedOffsetTop);
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
const oldHeight = containerHeight;
|
||||
containerHeight = window.innerHeight;
|
||||
|
||||
// Recalculate offset on resize (layout may have shifted)
|
||||
const newOffsetTop = getElementOffset();
|
||||
if (Math.abs(newOffsetTop - cachedOffsetTop) > 0.5) {
|
||||
cachedOffsetTop = newOffsetTop;
|
||||
handleScroll(); // Recalculate scroll position
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Initial calculation
|
||||
handleScroll();
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
}
|
||||
elementRef = null;
|
||||
},
|
||||
};
|
||||
} else {
|
||||
containerHeight = node.offsetHeight;
|
||||
|
||||
const handleScroll = () => {
|
||||
@@ -213,6 +284,7 @@ export function createVirtualizer<T>(
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let measurementBuffer: Record<number, number> = {};
|
||||
let frameId: number | null = null;
|
||||
@@ -275,12 +347,23 @@ export function createVirtualizer<T>(
|
||||
const itemStart = offsets[index];
|
||||
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
|
||||
let target = itemStart;
|
||||
const { useWindowScroll } = optionsGetter();
|
||||
|
||||
if (useWindowScroll) {
|
||||
if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2;
|
||||
if (align === 'end') target = itemStart - window.innerHeight + itemSize;
|
||||
|
||||
// Add container offset to target to get absolute document position
|
||||
const absoluteTarget = target + elementOffsetTop;
|
||||
|
||||
window.scrollTo({ top: absoluteTarget, behavior: 'smooth' });
|
||||
} else {
|
||||
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
|
||||
if (align === 'end') target = itemStart - containerHeight + itemSize;
|
||||
|
||||
elementRef.scrollTo({ top: target, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** Computed array of visible items to render (reactive) */
|
||||
|
||||
@@ -31,3 +31,5 @@ export {
|
||||
createCharacterComparison,
|
||||
type LineData,
|
||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||
|
||||
export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte';
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
createDebouncedState,
|
||||
createEntityStore,
|
||||
createFilter,
|
||||
createPersistentStore,
|
||||
createTypographyControl,
|
||||
createVirtualizer,
|
||||
type Entity,
|
||||
|
||||
@@ -45,11 +45,7 @@ let noChildrenValue = $state('');
|
||||
placeholder: 'Type here...',
|
||||
}}
|
||||
>
|
||||
<SearchBar bind:value={defaultSearchValue} placeholder="Type here...">
|
||||
Here will be the search result
|
||||
<br />
|
||||
Popover closes only when the user clicks outside the search bar or presses the Escape key.
|
||||
</SearchBar>
|
||||
<SearchBar bind:value={defaultSearchValue} placeholder="Type here..."> </SearchBar>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
@@ -60,11 +56,7 @@ let noChildrenValue = $state('');
|
||||
label: 'Search',
|
||||
}}
|
||||
>
|
||||
<SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search">
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-muted-foreground">No results found</p>
|
||||
</div>
|
||||
</SearchBar>
|
||||
<SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search"> </SearchBar>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
@@ -74,9 +66,5 @@ let noChildrenValue = $state('');
|
||||
placeholder: 'Quick search...',
|
||||
}}
|
||||
>
|
||||
<SearchBar bind:value={noChildrenValue} placeholder="Quick search...">
|
||||
<div class="p-4 text-center text-sm text-muted-foreground">
|
||||
Start typing to see results
|
||||
</div>
|
||||
</SearchBar>
|
||||
<SearchBar bind:value={noChildrenValue} placeholder="Quick search..."> </SearchBar>
|
||||
</Story>
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
<!--
|
||||
Component: SearchBar
|
||||
|
||||
Search input with popover dropdown for results/suggestions
|
||||
- Features keyboard navigation (ArrowDown/Up/Enter) and auto-focus prevention on popover open.
|
||||
- The input field serves as the popover trigger.
|
||||
-->
|
||||
<!-- Component: SearchBar -->
|
||||
<script lang="ts">
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { Label } from '$shared/shadcn/ui/label';
|
||||
import {
|
||||
Content as PopoverContent,
|
||||
Root as PopoverRoot,
|
||||
Trigger as PopoverTrigger,
|
||||
} from '$shared/shadcn/ui/popover';
|
||||
import { useId } from 'bits-ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import AsteriskIcon from '@lucide/svelte/icons/asterisk';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -25,10 +12,6 @@ interface Props {
|
||||
* Current search value (bindable)
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Whether popover is open (bindable)
|
||||
*/
|
||||
isOpen?: boolean;
|
||||
/**
|
||||
* Additional CSS classes for the container
|
||||
*/
|
||||
@@ -41,74 +24,52 @@ interface Props {
|
||||
* Optional label displayed above the input
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Content to render inside the popover (receives unique content ID)
|
||||
*/
|
||||
children: Snippet<[{ id: string }]> | undefined;
|
||||
}
|
||||
|
||||
let {
|
||||
id = 'search-bar',
|
||||
value = $bindable(''),
|
||||
isOpen = $bindable(false),
|
||||
class: className,
|
||||
placeholder,
|
||||
label,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
let triggerRef = $state<HTMLInputElement>(null!);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const contentId = useId(id);
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputClick() {
|
||||
isOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PopoverRoot bind:open={isOpen}>
|
||||
<PopoverTrigger bind:ref={triggerRef}>
|
||||
{#snippet child({ props })}
|
||||
{@const { onclick, ...rest } = props}
|
||||
<div {...rest} class="flex flex-row flex-1 w-full">
|
||||
{#if label}
|
||||
<Label for={id}>{label}</Label>
|
||||
{/if}
|
||||
<div class="relative w-full">
|
||||
<div class="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<AsteriskIcon class="size-4 stroke-gray-400 stroke-[1.5]" />
|
||||
</div>
|
||||
<Input
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
bind:value={value}
|
||||
onkeydown={handleKeyDown}
|
||||
onclick={handleInputClick}
|
||||
class="
|
||||
h-20 w-full md:text-2xl backdrop-blur-sm bg-white/60 dark:bg-slate-900/40
|
||||
ring-2 ring-slate-200/50
|
||||
active:ring-indigo-500/50
|
||||
focus-visible:border-indigo-500/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/50
|
||||
hover:bg-white/70 dark:hover:bg-slate-900/50 text-slate-900 dark:text-slate-100
|
||||
placeholder:text-slate-400 px-6 py-4 rounded-2xl transition-all duration-300
|
||||
h-16 w-full text-base
|
||||
backdrop-blur-md bg-white/80
|
||||
border border-gray-300/50
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
focus-visible:border-gray-400/60
|
||||
focus-visible:outline-none
|
||||
focus-visible:ring-1
|
||||
focus-visible:ring-gray-400/30
|
||||
focus-visible:bg-white/90
|
||||
hover:bg-white/90
|
||||
hover:border-gray-400/60
|
||||
text-gray-900
|
||||
placeholder:text-gray-400
|
||||
placeholder:font-mono
|
||||
placeholder:text-sm
|
||||
placeholder:tracking-wide
|
||||
pl-14 pr-6
|
||||
rounded-xl
|
||||
transition-all duration-200
|
||||
font-medium
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
onOpenAutoFocus={e => e.preventDefault()}
|
||||
onInteractOutside={(e => {
|
||||
if (e.target === triggerRef) {
|
||||
e.preventDefault();
|
||||
}
|
||||
})}
|
||||
class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width) md:rounded-2xl"
|
||||
>
|
||||
{@render children?.({ id: contentId })}
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
|
||||
@@ -29,15 +29,53 @@ interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
||||
* Index of the section
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* Callback function to notify when the title visibility status changes
|
||||
*
|
||||
* @param index - Index of the section
|
||||
* @param isPast - Whether the section is past the current scroll position
|
||||
* @param title - Snippet for a title itself
|
||||
* @returns Cleanup callback
|
||||
*/
|
||||
onTitleStatusChange?: (index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) => () => void;
|
||||
/**
|
||||
* Snippet for the section content
|
||||
*/
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
const { class: className, title, icon, index, children }: Props = $props();
|
||||
const { class: className, title, icon, index = 0, onTitleStatusChange, children }: Props = $props();
|
||||
|
||||
let titleContainer = $state<HTMLElement>();
|
||||
const flyParams: FlyParams = { y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 };
|
||||
|
||||
// Track if the user has actually scrolled away from view
|
||||
let isScrolledPast = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!titleContainer) {
|
||||
return;
|
||||
}
|
||||
let cleanup: ((index: number) => void) | undefined;
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
const entry = entries[0];
|
||||
const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0;
|
||||
|
||||
if (isPast !== isScrolledPast) {
|
||||
isScrolledPast = isPast;
|
||||
cleanup = onTitleStatusChange?.(index, isPast, title);
|
||||
}
|
||||
}, {
|
||||
// Set threshold to 0 to trigger exactly when the last pixel leaves
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
observer.observe(titleContainer);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cleanup?.(index);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section
|
||||
@@ -48,7 +86,7 @@ const flyParams: FlyParams = { y: 0, x: -50, duration: 300, easing: cubicOut, op
|
||||
in:fly={flyParams}
|
||||
out:fly={flyParams}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2" bind:this={titleContainer}>
|
||||
<div class="flex items-center gap-3 opacity-60">
|
||||
{#if icon}
|
||||
{@render icon({ className: 'size-4 stroke-gray-900 stroke-1' })}
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
- Keyboard navigation (ArrowUp/Down, Home, End)
|
||||
- Fixed or dynamic item heights
|
||||
- ARIA listbox/option pattern with single tab stop
|
||||
- Custom shadcn ScrollArea scrollbar
|
||||
-->
|
||||
<script lang="ts" generics="T">
|
||||
import { createVirtualizer } from '$shared/lib';
|
||||
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -19,6 +23,20 @@ interface Props {
|
||||
* @template T - The type of items in the list
|
||||
*/
|
||||
items: T[];
|
||||
/**
|
||||
* Total number of items (including not-yet-loaded items for pagination).
|
||||
* If not provided, defaults to items.length.
|
||||
*
|
||||
* Use this when implementing pagination to ensure the scrollbar
|
||||
* reflects the total count of items, not just the loaded ones.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Pagination scenario: 1920 total fonts, but only 50 loaded
|
||||
* <VirtualList items={loadedFonts} total={1920}>
|
||||
* ```
|
||||
*/
|
||||
total?: number;
|
||||
/**
|
||||
* Height for each item, either as a fixed number
|
||||
* or a function that returns height per index.
|
||||
@@ -40,6 +58,24 @@ interface Props {
|
||||
* @param items - Loaded items
|
||||
*/
|
||||
onVisibleItemsChange?: (items: T[]) => void;
|
||||
/**
|
||||
* An optional callback that will be called when user scrolls near the end of the list.
|
||||
* Useful for triggering auto-pagination.
|
||||
*
|
||||
* The callback receives the index of the last visible item. You can use this
|
||||
* to determine if you should load more data.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* onNearBottom={(lastVisibleIndex) => {
|
||||
* const itemsRemaining = total - lastVisibleIndex;
|
||||
* if (itemsRemaining < 5 && hasMore && !isFetching) {
|
||||
* loadMore();
|
||||
* }
|
||||
* }}
|
||||
* ```
|
||||
*/
|
||||
onNearBottom?: (lastVisibleIndex: number) => void;
|
||||
/**
|
||||
* Snippet for rendering individual list items.
|
||||
*
|
||||
@@ -52,43 +88,79 @@ interface Props {
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
*/
|
||||
children: Snippet<[{ item: T; index: number; isVisible: boolean; proximity: number }]>;
|
||||
/**
|
||||
* Snippet for rendering individual list items.
|
||||
*
|
||||
* The snippet receives an object containing:
|
||||
* - `item`: The item from the items array (type T)
|
||||
* - `index`: The current item's index in the array
|
||||
*
|
||||
* This pattern provides type safety and flexibility for
|
||||
* rendering different item types without prop drilling.
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
*/
|
||||
children: Snippet<
|
||||
[{ item: T; index: number; isFullyVisible: boolean; isPartiallyVisible: boolean; proximity: number }]
|
||||
>;
|
||||
/**
|
||||
* Whether to use the window as the scroll container.
|
||||
* @default false
|
||||
*/
|
||||
useWindowScroll?: boolean;
|
||||
}
|
||||
|
||||
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }: Props = $props();
|
||||
let {
|
||||
items,
|
||||
total = items.length,
|
||||
itemHeight = 80,
|
||||
overscan = 5,
|
||||
class: className,
|
||||
onVisibleItemsChange,
|
||||
onNearBottom,
|
||||
children,
|
||||
useWindowScroll = false,
|
||||
}: Props = $props();
|
||||
|
||||
// Reference to the ScrollArea viewport element for attaching the virtualizer
|
||||
let viewportRef = $state<HTMLElement | null>(null);
|
||||
|
||||
const virtualizer = createVirtualizer(() => ({
|
||||
count: items.length,
|
||||
data: items,
|
||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||
overscan,
|
||||
useWindowScroll,
|
||||
}));
|
||||
|
||||
// Attach virtualizer.container action to the viewport when it becomes available
|
||||
$effect(() => {
|
||||
if (viewportRef) {
|
||||
const { destroy } = virtualizer.container(viewportRef);
|
||||
return destroy;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const visibleItems = virtualizer.items.map(item => items[item.index]);
|
||||
onVisibleItemsChange?.(visibleItems);
|
||||
|
||||
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
|
||||
if (virtualizer.items.length > 0 && onNearBottom) {
|
||||
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
|
||||
// Compare against loaded items length, not total
|
||||
const itemsRemaining = items.length - lastVisibleItem.index;
|
||||
|
||||
if (itemsRemaining <= 5) {
|
||||
onNearBottom(lastVisibleItem.index);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
use:virtualizer.container
|
||||
class={cn(
|
||||
'relative overflow-auto rounded-md bg-background',
|
||||
'h-150 w-full',
|
||||
'scroll-smooth',
|
||||
className,
|
||||
)}
|
||||
onfocusin={(e => {
|
||||
// Prevent the browser from jumping the scroll when an inner element gets focus
|
||||
e.preventDefault();
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style:height="{virtualizer.totalSize}px"
|
||||
class="w-full pointer-events-none"
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if useWindowScroll}
|
||||
<div class={cn('relative w-full', className)} bind:this={viewportRef}>
|
||||
<div style:height="{virtualizer.totalSize}px" class="relative w-full">
|
||||
{#each virtualizer.items as item (item.key)}
|
||||
<div
|
||||
use:virtualizer.measureElement
|
||||
@@ -96,12 +168,51 @@ $effect(() => {
|
||||
class="absolute top-0 left-0 w-full"
|
||||
style:transform="translateY({item.start}px)"
|
||||
>
|
||||
{#if item.index < items.length}
|
||||
{@render children({
|
||||
// TODO: Fix indenation rule for this case
|
||||
item: items[item.index],
|
||||
index: item.index,
|
||||
isVisible: item.isVisible,
|
||||
isFullyVisible: item.isFullyVisible,
|
||||
isPartiallyVisible: item.isPartiallyVisible,
|
||||
proximity: item.proximity,
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ScrollArea
|
||||
bind:viewportRef
|
||||
class={cn(
|
||||
'relative rounded-md bg-background',
|
||||
'h-150 w-full',
|
||||
className,
|
||||
)}
|
||||
orientation="vertical"
|
||||
>
|
||||
<div style:height="{virtualizer.totalSize}px" class="relative w-full">
|
||||
{#each virtualizer.items as item (item.key)}
|
||||
<div
|
||||
use:virtualizer.measureElement
|
||||
data-index={item.index}
|
||||
class="absolute top-0 left-0 w-full"
|
||||
style:transform="translateY({item.start}px)"
|
||||
animate:flip={{ delay: 0, duration: 300, easing: quintOut }}
|
||||
>
|
||||
{#if item.index < items.length}
|
||||
{@render children({
|
||||
// TODO: Fix indenation rule for this case
|
||||
item: items[item.index],
|
||||
index: item.index,
|
||||
isFullyVisible: item.isFullyVisible,
|
||||
isPartiallyVisible: item.isPartiallyVisible,
|
||||
proximity: item.proximity,
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './model';
|
||||
export { ComparisonSlider } from './ui';
|
||||
|
||||
1
src/widgets/ComparisonSlider/model/index.ts
Normal file
1
src/widgets/ComparisonSlider/model/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { comparisonStore } from './stores/comparisonStore.svelte';
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
type UnifiedFont,
|
||||
fetchFontsByIds,
|
||||
unifiedFontStore,
|
||||
} from '$entities/Font';
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
|
||||
/**
|
||||
* Storage schema for comparison state
|
||||
*/
|
||||
interface ComparisonState {
|
||||
fontAId: string | null;
|
||||
fontBId: string | null;
|
||||
}
|
||||
|
||||
// Persistent storage for selected comparison fonts
|
||||
const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
|
||||
fontAId: null,
|
||||
fontBId: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Store for managing font comparison state
|
||||
* - Persists selection to localStorage
|
||||
* - Handles font fetching on initialization
|
||||
* - Manages sample text
|
||||
*/
|
||||
class ComparisonStore {
|
||||
#fontA = $state<UnifiedFont | undefined>();
|
||||
#fontB = $state<UnifiedFont | undefined>();
|
||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
||||
#isRestoring = $state(true);
|
||||
|
||||
constructor() {
|
||||
this.restoreFromStorage();
|
||||
|
||||
// Reactively set defaults if we aren't restoring and have no selection
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
// Wait until we are done checking storage
|
||||
if (this.#isRestoring) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already have a selection, do nothing
|
||||
if (this.#fontA && this.#fontB) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if fonts are available to set as defaults
|
||||
const fonts = unifiedFontStore.fonts;
|
||||
if (fonts.length >= 2) {
|
||||
// Only set if we really have nothing (fallback)
|
||||
if (!this.#fontA) this.#fontA = fonts[0];
|
||||
if (!this.#fontB) this.#fontB = fonts[fonts.length - 1];
|
||||
|
||||
// Sync defaults to storage so they persist if the user leaves
|
||||
this.updateStorage();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore state from persistent storage
|
||||
*/
|
||||
async restoreFromStorage() {
|
||||
this.#isRestoring = true;
|
||||
const { fontAId, fontBId } = storage.value;
|
||||
|
||||
if (fontAId && fontBId) {
|
||||
try {
|
||||
// Batch fetch the saved fonts
|
||||
const fonts = await fetchFontsByIds([fontAId, fontBId]);
|
||||
const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId);
|
||||
const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId);
|
||||
|
||||
if (loadedFontA && loadedFontB) {
|
||||
this.#fontA = loadedFontA;
|
||||
this.#fontB = loadedFontB;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ComparisonStore] Failed to restore fonts:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark restoration as complete (whether success or fail)
|
||||
this.#isRestoring = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update storage with current state
|
||||
*/
|
||||
private updateStorage() {
|
||||
// Don't save if we are currently restoring (avoid race)
|
||||
if (this.#isRestoring) return;
|
||||
|
||||
storage.value = {
|
||||
fontAId: this.#fontA?.id ?? null,
|
||||
fontBId: this.#fontB?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Getters & Setters ---
|
||||
|
||||
get fontA() {
|
||||
return this.#fontA;
|
||||
}
|
||||
|
||||
set fontA(font: UnifiedFont | undefined) {
|
||||
this.#fontA = font;
|
||||
this.updateStorage();
|
||||
}
|
||||
|
||||
get fontB() {
|
||||
return this.#fontB;
|
||||
}
|
||||
|
||||
set fontB(font: UnifiedFont | undefined) {
|
||||
this.#fontB = font;
|
||||
this.updateStorage();
|
||||
}
|
||||
|
||||
get text() {
|
||||
return this.#sampleText;
|
||||
}
|
||||
|
||||
set text(value: string) {
|
||||
this.#sampleText = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if both fonts are selected
|
||||
*/
|
||||
get isReady() {
|
||||
return !!this.#fontA && !!this.#fontB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public initializer (optional, as constructor starts it)
|
||||
* Kept for compatibility if manual re-init is needed
|
||||
*/
|
||||
initialize() {
|
||||
if (!this.#isRestoring && !this.#fontA && !this.#fontB) {
|
||||
this.restoreFromStorage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comparisonStore = new ComparisonStore();
|
||||
@@ -10,23 +10,21 @@
|
||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { displayedFontsStore } from '$features/DisplayFont';
|
||||
import {
|
||||
createCharacterComparison,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import type { LineData } from '$shared/lib';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import CharacterSlot from './components/CharacterSlot.svelte';
|
||||
import ControlsWrapper from './components/ControlsWrapper.svelte';
|
||||
import Labels from './components/Labels.svelte';
|
||||
import SliderLine from './components/SliderLine.svelte';
|
||||
|
||||
// Displayed text
|
||||
let text = $state('The quick brown fox jumps over the lazy dog...');
|
||||
// Pair of fonts to compare
|
||||
const fontA = $derived(displayedFontsStore.fontA);
|
||||
const fontB = $derived(displayedFontsStore.fontB);
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
|
||||
let container: HTMLElement | undefined = $state();
|
||||
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
|
||||
@@ -59,7 +57,7 @@ const sizeControl = createTypographyControl({
|
||||
* Manages line breaking and character state based on fonts and container dimensions.
|
||||
*/
|
||||
const charComparison = createCharacterComparison(
|
||||
() => text,
|
||||
() => comparisonStore.text,
|
||||
() => fontA,
|
||||
() => fontB,
|
||||
() => weightControl.value,
|
||||
@@ -85,7 +83,10 @@ function handleMove(e: PointerEvent) {
|
||||
}
|
||||
|
||||
function startDragging(e: PointerEvent) {
|
||||
if (e.target === controlsWrapperElement || controlsWrapperElement?.contains(e.target as Node)) {
|
||||
if (
|
||||
e.target === controlsWrapperElement
|
||||
|| controlsWrapperElement?.contains(e.target as Node)
|
||||
) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
@@ -109,7 +110,7 @@ $effect(() => {
|
||||
// Re-run line breaking when container resizes or dependencies change
|
||||
$effect(() => {
|
||||
// React on text and typography settings changes
|
||||
const _text = text;
|
||||
const _text = comparisonStore.text;
|
||||
const _weight = weightControl.value;
|
||||
const _size = sizeControl.value;
|
||||
const _height = heightControl.value;
|
||||
@@ -125,9 +126,7 @@ $effect(() => {
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handleResize = () => {
|
||||
if (
|
||||
container && measureCanvas
|
||||
) {
|
||||
if (container && measureCanvas) {
|
||||
charComparison.breakIntoLines(container, measureCanvas);
|
||||
}
|
||||
};
|
||||
@@ -215,17 +214,17 @@ $effect(() => {
|
||||
<SliderLine {sliderPos} {isDragging} />
|
||||
</div>
|
||||
|
||||
<Labels fontA={fontA} fontB={fontB} {sliderPos} />
|
||||
<Labels {fontA} {fontB} {sliderPos} weight={weightControl.value} />
|
||||
<!-- Since there're slider controls inside we put them outside the main one -->
|
||||
<ControlsWrapper
|
||||
bind:wrapper={controlsWrapperElement}
|
||||
{sliderPos}
|
||||
{isDragging}
|
||||
bind:text={text}
|
||||
bind:text={comparisonStore.text}
|
||||
containerWidth={container?.clientWidth}
|
||||
weightControl={weightControl}
|
||||
sizeControl={sizeControl}
|
||||
heightControl={heightControl}
|
||||
{weightControl}
|
||||
{sizeControl}
|
||||
{heightControl}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
Component: Labels
|
||||
Displays labels for font selection in the comparison slider.
|
||||
-->
|
||||
<script lang="ts" generics="T extends { name: string; id: string }">
|
||||
<script lang="ts" generics="T extends UnifiedFont">
|
||||
import {
|
||||
FontVirtualList,
|
||||
type UnifiedFont,
|
||||
unifiedFontStore,
|
||||
} from '$entities/Font';
|
||||
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
|
||||
import { displayedFontsStore } from '$features/DisplayFont';
|
||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||
import {
|
||||
Content as SelectContent,
|
||||
Item as SelectItem,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
Trigger as SelectTrigger,
|
||||
} from '$shared/shadcn/ui/select';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
|
||||
interface Props<T> {
|
||||
/**
|
||||
@@ -31,82 +31,124 @@ interface Props<T> {
|
||||
* Position of the slider
|
||||
*/
|
||||
sliderPos: number;
|
||||
|
||||
weight: number;
|
||||
}
|
||||
let { fontA, fontB, sliderPos }: Props<T> = $props();
|
||||
let { fontA, fontB, sliderPos, weight }: Props<T> = $props();
|
||||
|
||||
const fontList = $derived(
|
||||
displayedFontsStore.fonts.filter(font => font.name !== fontA.name && font.name !== fontB.name),
|
||||
);
|
||||
const fontList = $derived(unifiedFontStore.fonts);
|
||||
|
||||
function selectFontA(fontId: string) {
|
||||
const newFontA = displayedFontsStore.getById(fontId);
|
||||
if (!newFontA) return;
|
||||
displayedFontsStore.fontA = newFontA;
|
||||
function selectFontA(font: UnifiedFont) {
|
||||
if (!font) return;
|
||||
comparisonStore.fontA = font;
|
||||
}
|
||||
|
||||
function selectFontB(fontId: string) {
|
||||
const newFontB = displayedFontsStore.getById(fontId);
|
||||
if (!newFontB) return;
|
||||
displayedFontsStore.fontB = newFontB;
|
||||
function selectFontB(font: UnifiedFont) {
|
||||
if (!font) return;
|
||||
comparisonStore.fontB = font;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet fontSelector(
|
||||
name: string,
|
||||
id: string,
|
||||
url: string,
|
||||
fonts: UnifiedFont[],
|
||||
handleChange: (value: string) => void,
|
||||
selectFont: (font: UnifiedFont) => void,
|
||||
align: 'start' | 'end',
|
||||
)}
|
||||
<div
|
||||
class="z-50 pointer-events-auto **:bg-transparent"
|
||||
class="z-50 pointer-events-auto"
|
||||
onpointerdown={(e => e.stopPropagation())}
|
||||
>
|
||||
<SelectRoot type="single" onValueChange={handleChange}>
|
||||
<SelectRoot type="single" disabled={!fontList.length}>
|
||||
<SelectTrigger
|
||||
class={cn(buttonVariants({ variant: 'ghost' }), 'border-none, hover:bg-indigo-100')}
|
||||
disabled={!fontList.length}
|
||||
class={cn(
|
||||
'w-44 sm:w-52 h-9 border border-gray-300/40 bg-white/60 backdrop-blur-sm',
|
||||
'px-3 rounded-lg transition-all flex items-center justify-between gap-2',
|
||||
'font-mono text-[11px] tracking-tight font-medium text-gray-900',
|
||||
'hover:bg-white/80 hover:border-gray-400/60 hover:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<FontApplicator name={name} id={id}>
|
||||
<div class="text-left flex-1 min-w-0">
|
||||
<FontApplicator {name} {id} {url}>
|
||||
{name}
|
||||
</FontApplicator>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
class="h-60 bg-transparent **:bg-transparent backdrop-blur-0 data-[state=open]:backdrop-blur-lg transition-[backdrop-filter] duration-200"
|
||||
scrollYThreshold={100}
|
||||
class={cn(
|
||||
'bg-white/95 backdrop-blur-xl border border-gray-300/50 shadow-xl',
|
||||
'w-52 max-h-[280px] overflow-hidden rounded-lg',
|
||||
)}
|
||||
side="top"
|
||||
{align}
|
||||
sideOffset={8}
|
||||
size="small"
|
||||
>
|
||||
<FontVirtualList items={fonts}>
|
||||
<div class="p-1.5">
|
||||
<FontVirtualList items={fonts} {weight}>
|
||||
{#snippet children({ item: font })}
|
||||
<SelectItem value={font.id} class="data-[highlighted]:bg-indigo-100">
|
||||
<FontApplicator name={font.name} id={font.id}>
|
||||
{@const handleClick = () => selectFont(font)}
|
||||
<SelectItem
|
||||
value={font.id}
|
||||
class="data-[highlighted]:bg-gray-100 font-mono text-[11px] px-3 py-2.5 rounded-md cursor-pointer transition-colors"
|
||||
onclick={handleClick}
|
||||
>
|
||||
<FontApplicator name={font.name} id={font.id} url={font.styles.regular!}>
|
||||
{font.name}
|
||||
</FontApplicator>
|
||||
</SelectItem>
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="absolute bottom-6 inset-x-6 sm:inset-x-6 flex justify-between items-end pointer-events-none z-20">
|
||||
<div class="absolute bottom-8 inset-x-6 sm:inset-x-12 flex justify-between items-end pointer-events-none z-20">
|
||||
<div
|
||||
class="flex flex-col gap-0.5 transition-opacity duration-300 items-start"
|
||||
style:opacity={sliderPos < 15 ? 0 : 1}
|
||||
class="flex flex-col gap-2 transition-all duration-500 items-start"
|
||||
style:opacity={sliderPos < 20 ? 0 : 1}
|
||||
style:transform="translateY({sliderPos < 20 ? '8px' : '0px'})"
|
||||
>
|
||||
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-indigo-400">
|
||||
Baseline
|
||||
<div class="flex items-center gap-2.5 px-1">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.6)]"></div>
|
||||
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
||||
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
|
||||
ch_01
|
||||
</span>
|
||||
{@render fontSelector(fontB.name, fontB.id, fontList, selectFontB)}
|
||||
</div>
|
||||
{@render fontSelector(
|
||||
fontB.name,
|
||||
fontB.id,
|
||||
fontB.styles.regular!,
|
||||
fontList,
|
||||
selectFontB,
|
||||
'start',
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-end text-right gap-1 transition-opacity duration-300"
|
||||
style:opacity={sliderPos > 85 ? 0 : 1}
|
||||
class="flex flex-col items-end text-right gap-2 transition-all duration-500"
|
||||
style:opacity={sliderPos > 80 ? 0 : 1}
|
||||
style:transform="translateY({sliderPos > 80 ? '8px' : '0px'})"
|
||||
>
|
||||
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-slate-400">
|
||||
Comparison
|
||||
<div class="flex items-center gap-2.5 px-1">
|
||||
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
|
||||
ch_02
|
||||
</span>
|
||||
{@render fontSelector(fontA.name, fontA.id, fontList, selectFontA)}
|
||||
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-gray-900 shadow-[0_0_6px_rgba(0,0,0,0.4)]"></div>
|
||||
</div>
|
||||
{@render fontSelector(
|
||||
fontA.name,
|
||||
fontA.id,
|
||||
fontA.styles.regular!,
|
||||
fontList,
|
||||
selectFontA,
|
||||
'end',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<!--
|
||||
Component: FontSearch
|
||||
|
||||
Combines search input with font list display
|
||||
Provides a search input and filtration for fonts
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { unifiedFontStore } from '$entities/Font';
|
||||
import {
|
||||
FilterControls,
|
||||
Filters,
|
||||
SuggestedFonts,
|
||||
filterManager,
|
||||
mapManagerToParams,
|
||||
} from '$features/GetFonts';
|
||||
import { springySlideFade } from '$shared/lib';
|
||||
import { Button } from '$shared/shadcn/ui/button';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { SearchBar } from '$shared/ui';
|
||||
import FunnelIcon from '@lucide/svelte/icons/funnel';
|
||||
import {
|
||||
IconButton,
|
||||
SearchBar,
|
||||
} from '$shared/ui';
|
||||
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import {
|
||||
@@ -26,11 +26,13 @@ import {
|
||||
import { type SlideParams } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Controllable flag to show/hide filters (bindable)
|
||||
*/
|
||||
showFilters?: boolean;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
let { showFilters = $bindable(false), isOpen = $bindable(false) }: Props = $props();
|
||||
let { showFilters = $bindable(false) }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
@@ -63,35 +65,32 @@ function toggleFilters() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 relative">
|
||||
<div class="flex flex-col gap-3 relative">
|
||||
<div class="relative">
|
||||
<SearchBar
|
||||
id="font-search"
|
||||
class="w-full"
|
||||
placeholder="Search fonts by name..."
|
||||
placeholder="search_typefaces..."
|
||||
label="query_input"
|
||||
bind:value={filterManager.queryValue}
|
||||
bind:isOpen
|
||||
>
|
||||
<SuggestedFonts />
|
||||
</SearchBar>
|
||||
/>
|
||||
|
||||
<div class="absolute right-5 top-10 translate-y-[-50%] pl-5 border-l-2">
|
||||
<div style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)">
|
||||
<Button
|
||||
<div class="absolute right-4 top-1/2 translate-y-[-50%] z-10">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-px h-5 bg-gray-300/60"></div>
|
||||
<div style:transform="scale({transform.current.scale})">
|
||||
<IconButton onclick={toggleFilters}>
|
||||
{#snippet icon({ className })}
|
||||
<SlidersHorizontalIcon
|
||||
class={cn(
|
||||
'cursor-pointer will-change-transform hover:bg-inherit hover:*:stroke-indigo-500',
|
||||
showFilters ? 'hover:*:stroke-indigo-500/80' : 'hover:*:stroke-indigo-500',
|
||||
)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={toggleFilters}
|
||||
>
|
||||
<FunnelIcon
|
||||
class={cn(
|
||||
'size-8 stroke-indigo-600/50 transition-all duration-150',
|
||||
showFilters ? 'stroke-indigo-600' : 'stroke-indigo-600/50',
|
||||
className,
|
||||
showFilters ? 'stroke-gray-900 stroke-3' : 'stroke-gray-500',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,9 +99,29 @@ function toggleFilters() {
|
||||
transition:springySlideFade|local={slideConfig}
|
||||
class="will-change-[height,opacity] contain-layout overflow-hidden"
|
||||
>
|
||||
<div class="grid gap-1 grid-cols-[repeat(auto-fit,minmax(8em,14em))]">
|
||||
<div
|
||||
class="
|
||||
p-4 rounded-xl
|
||||
backdrop-blur-md bg-white/80
|
||||
border border-gray-300/50
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 mb-4 opacity-70">
|
||||
<div class="w-1 h-1 rounded-full bg-gray-900"></div>
|
||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
||||
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
|
||||
filter_params
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 grid-cols-[repeat(auto-fit,minmax(8em,14em))]">
|
||||
<Filters />
|
||||
<FilterControls class="ml-auto py-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-gray-300/40">
|
||||
<FilterControls class="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
1
src/widgets/SampleList/index.ts
Normal file
1
src/widgets/SampleList/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SampleList } from './ui';
|
||||
72
src/widgets/SampleList/ui/SampleList/SampleList.svelte
Normal file
72
src/widgets/SampleList/ui/SampleList/SampleList.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<!--
|
||||
Component: SampleList
|
||||
Renders a list of fonts in a virtualized list to improve performance.
|
||||
Includes pagination with auto-loading when scrolling near the bottom.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontListItem,
|
||||
FontVirtualList,
|
||||
unifiedFontStore,
|
||||
} from '$entities/Font';
|
||||
import { FontSampler } from '$features/DisplayFont';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
|
||||
let text = $state('The quick brown fox jumps over the lazy dog...');
|
||||
|
||||
/**
|
||||
* Load more fonts by moving to the next page
|
||||
*/
|
||||
function loadMore() {
|
||||
if (
|
||||
!unifiedFontStore.pagination.hasMore
|
||||
|| unifiedFontStore.isFetching
|
||||
) {
|
||||
return;
|
||||
}
|
||||
unifiedFontStore.nextPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll near bottom - auto-load next page
|
||||
*
|
||||
* Triggered by VirtualList when the user scrolls within 5 items of the end
|
||||
* of the loaded items. Only fetches if there are more pages available.
|
||||
*/
|
||||
function handleNearBottom(_lastVisibleIndex: number) {
|
||||
const { hasMore } = unifiedFontStore.pagination;
|
||||
|
||||
// VirtualList already checks if we're near the bottom of loaded items
|
||||
if (hasMore && !unifiedFontStore.isFetching) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate display range for pagination info
|
||||
*/
|
||||
const displayRange = $derived.by(() => {
|
||||
const { offset, limit, total } = unifiedFontStore.pagination;
|
||||
const loadedCount = Math.min(offset + limit, total);
|
||||
return `Showing ${loadedCount} of ${total} fonts`;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if unifiedFontStore.isFetching || unifiedFontStore.isLoading}
|
||||
<span class="ml-2 text-xs text-muted-foreground/70">(Loading...)</span>
|
||||
{/if}
|
||||
|
||||
<FontVirtualList
|
||||
items={unifiedFontStore.fonts}
|
||||
total={unifiedFontStore.pagination.total}
|
||||
onNearBottom={handleNearBottom}
|
||||
itemHeight={280}
|
||||
useWindowScroll={true}
|
||||
weight={controlManager.weight}
|
||||
>
|
||||
{#snippet children({ item: font, isFullyVisible, isPartiallyVisible, proximity, index })}
|
||||
<FontListItem {font} {isFullyVisible} {isPartiallyVisible} {proximity}>
|
||||
<FontSampler {font} bind:text {index} />
|
||||
</FontListItem>
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
3
src/widgets/SampleList/ui/index.ts
Normal file
3
src/widgets/SampleList/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import SampleList from './SampleList/SampleList.svelte';
|
||||
|
||||
export { SampleList };
|
||||
@@ -1,3 +1,7 @@
|
||||
<!--
|
||||
Component: TypographyMenu
|
||||
Provides a menu for selecting and configuring typography settings
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { SetupFontMenu } from '$features/SetupFont';
|
||||
import {
|
||||
@@ -5,7 +9,6 @@ import {
|
||||
Root as ItemRoot,
|
||||
} from '$shared/shadcn/ui/item';
|
||||
|
||||
import { displayedFontsStore } from '$features/DisplayFont';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { crossfade } from 'svelte/transition';
|
||||
|
||||
@@ -22,16 +25,17 @@ const [send, receive] = crossfade({
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if displayedFontsStore.hasAnyFonts}
|
||||
<div
|
||||
class="w-auto fixed bottom-5 inset-x-0 max-screen z-10 flex justify-center"
|
||||
in:receive={{ key: 'panel' }}
|
||||
out:send={{ key: 'panel' }}
|
||||
>
|
||||
<ItemRoot variant="outline" class="w-auto max-w-max p-2.5 rounded-2xl backdrop-blur-lg">
|
||||
<ItemRoot
|
||||
variant="outline"
|
||||
class="w-auto max-w-max p-2.5 rounded-2xl backdrop-blur-lg"
|
||||
>
|
||||
<ItemContent class="flex flex-row justify-center items-center max-w-max">
|
||||
<SetupFontMenu />
|
||||
</ItemContent>
|
||||
</ItemRoot>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user