Compare commits

..

36 Commits

Author SHA1 Message Date
Ilia Mashkov
778839d35e feat(Page): switch some sections
All checks were successful
Workflow / build (pull_request) Successful in 1m9s
2026-02-02 12:21:23 +03:00
Ilia Mashkov
92fb314615 feat(TypographyMenu): add comments and delete outdated code 2026-02-02 12:20:57 +03:00
Ilia Mashkov
6f0b69ff45 chore: incorporate renewed appliderFontStore and comparisonStore logic 2026-02-02 12:20:19 +03:00
Ilia Mashkov
2cd38797b9 feat(FontSearch): add IconButton instead of regular Button and delete unused code 2026-02-02 12:20:01 +03:00
Ilia Mashkov
6f231999e0 chore: add export/import and remove unused ones 2026-02-02 12:19:05 +03:00
Ilia Mashkov
31a72d90ea chore: incorporate renewed appliderFontStore and comparisonStore logic 2026-02-02 12:18:20 +03:00
Ilia Mashkov
072690270f chore(SearchBar): delete unused code and slightly tweak appearance 2026-02-02 12:16:51 +03:00
Ilia Mashkov
eaf9d069c5 feat(VirtualList): incoroprate new logic related to window scroll and separation of isVisible flag 2026-02-02 12:16:04 +03:00
Ilia Mashkov
4a94f7bd09 feat(FontListItem): separate isVisible flags into two (partial and fully) 2026-02-02 12:13:58 +03:00
Ilia Mashkov
918e792e41 fix(Layout): temporaly remove ScrollArea to fix virtual list 2026-02-02 12:13:07 +03:00
Ilia Mashkov
c9c8b9abfc feat(Section): add logic that triggers a callback when sections title moves out of the viewport 2026-02-02 12:11:48 +03:00
Ilia Mashkov
a392b575cc chore: migrate from direct <link> with css towards font-face approach 2026-02-02 12:10:38 +03:00
Ilia Mashkov
961475dea0 refactor(appliedFontsStore): migrate from direct <link> with css towards font-face approach 2026-02-02 12:10:12 +03:00
Ilia Mashkov
5496fd2680 chore: delete unused code 2026-02-02 12:09:16 +03:00
Ilia Mashkov
f90f1e39e0 feat(createVirtualizer): refine virtualizer logic, add useWindowScroll flag to use window scroll 2026-02-02 12:04:19 +03:00
Ilia Mashkov
ca161dfbd4 feat(ComparisonSlider): migrate from displayStore to comparisonStore 2026-02-02 12:02:33 +03:00
Ilia Mashkov
ac2d0c32a4 chore: add import/export 2026-02-02 12:00:58 +03:00
Ilia Mashkov
54d22d650d chore: add import/export 2026-02-02 12:00:19 +03:00
Ilia Mashkov
a9c63f2544 feat(Breadcrumb): create new entity that contains logic related to breadcrumb-like navigation 2026-02-02 11:59:57 +03:00
Ilia Mashkov
70f57283a8 feat(comparisonStore): replace displayStore with comparisonStore that has only the logic related to ComparisonSlider 2026-02-02 11:58:50 +03:00
Ilia Mashkov
d43c873dc9 feat(createPersistentStore): add a solution to keep user info between sections using browser storage 2026-02-02 11:57:00 +03:00
Ilia Mashkov
9501dbf281 chore: add import/export 2026-02-01 16:13:13 +03:00
Ilia Mashkov
0ac6acd174 feat(proxyFonts): add fetchFontsById function that fetches batch of fonts 2026-02-01 16:12:37 +03:00
Ilia Mashkov
5bb41c7e4c chore: comment typo 2026-02-01 11:58:22 +03:00
Ilia Mashkov
eed3339b0d feat(FontSearch): refactor component styles 2026-02-01 11:57:56 +03:00
Ilia Mashkov
d94e3cefb2 feat(SearchBar): move away from popover due to unnecessary complication and ux problems 2026-02-01 11:56:39 +03:00
Ilia Mashkov
cfb586f539 feat(SampleList): move font list display into widget layer 2026-02-01 11:55:46 +03:00
Ilia Mashkov
6e975e5f8e feat(VirtualList): add animate logic 2026-02-01 11:54:40 +03:00
Ilia Mashkov
142e4f0a19 feat(Page): display all components without conditions 2026-02-01 11:53:57 +03:00
Ilia Mashkov
59b85eead0 chore: remove unnecessary comments 2026-02-01 11:52:58 +03:00
Ilia Mashkov
010643e398 chore: add import/export 2026-02-01 11:52:32 +03:00
Ilia Mashkov
27f637531b feat(FontListItem): use children instead of the direct representation of the font 2026-02-01 11:52:09 +03:00
Ilia Mashkov
91fa08074b feat(VirtualList): incorporate shadcn scroll area to replace default scoll bar 2026-01-31 11:53:18 +03:00
Ilia Mashkov
c246f70fe9 feat(Labels): change the styles of the component 2026-01-31 11:48:58 +03:00
Ilia Mashkov
b1ce734f19 feat(VirtualList): VirtualList now supports pagination, it loads batches when user scrolls near the end of current batch 2026-01-31 11:48:14 +03:00
Ilia Mashkov
3add50a190 feat(VirtualList): add auto-pagination and correct scrollbar height
- Add 'total' prop to VirtualList for accurate scrollbar height in pagination scenarios
- Add 'onNearBottom' callback to trigger auto-loading when user scrolls near end
- Update FontVirtualList to forward the new props
- Implement auto-pagination in SuggestedFonts component (remove manual Load More button)
- Display loading indicator when fetching next batch
- Show accurate font count (e.g., "Showing 150 of 1920 fonts")

Key changes:
- VirtualList now uses total count for height calculation instead of items.length
- Auto-fetches next page when user scrolls within 5 items of the end
- Only fetches if hasMore is true and not already fetching
- Backward compatible: total defaults to items.length when not provided
2026-01-30 19:22:21 +03:00
44 changed files with 1226 additions and 667 deletions

View File

@@ -40,13 +40,13 @@ let { children }: Props = $props();
<div id="app-root" class="min-h-screen flex flex-col bg-background"> <div id="app-root" class="min-h-screen flex flex-col bg-background">
<header></header> <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"> <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> <TooltipProvider>
<TypographyMenu /> <TypographyMenu />
{@render children?.()} {@render children?.()}
</TooltipProvider> </TooltipProvider>
</main> </main>
</ScrollArea> <!-- </ScrollArea> -->
<footer></footer> <footer></footer>
</div> </div>

View File

@@ -0,0 +1,2 @@
export { scrollBreadcrumbsStore } from './model';
export { BreadcrumbHeader } from './ui';

View File

@@ -0,0 +1 @@
export * from './store/scrollBreadcrumbsStore.svelte';

View File

@@ -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();

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte';
export { BreadcrumbHeader };

View File

@@ -6,6 +6,7 @@
// Proxy API (PRIMARY - NEW) // Proxy API (PRIMARY - NEW)
export { export {
fetchFontsByIds,
fetchProxyFontById, fetchProxyFontById,
fetchProxyFonts, fetchProxyFonts,
} from './proxy/proxyFonts'; } from './proxy/proxyFonts';

View File

@@ -246,3 +246,34 @@ export async function fetchProxyFontById(
return response.fonts.find(font => font.id === id); 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);
}

View File

@@ -1,5 +1,6 @@
// Proxy API (PRIMARY) // Proxy API (PRIMARY)
export { export {
fetchFontsByIds,
fetchProxyFontById, fetchProxyFontById,
fetchProxyFonts, fetchProxyFonts,
} from './api/proxy/proxyFonts'; } from './api/proxy/proxyFonts';

View File

@@ -3,33 +3,47 @@ import { SvelteMap } from 'svelte/reactivity';
export type FontStatus = 'loading' | 'loaded' | 'error'; export type FontStatus = 'loading' | 'loaded' | 'error';
export interface FontConfigRequest { 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; weight: number;
/**
* Flag of the variable weight
*/
isVariable?: boolean; isVariable?: boolean;
} }
/** /**
* Manager that handles loading of fonts from Fontshare. * Manager that handles loading of fonts.
* Logic: * Logic:
* - Variable fonts: Loaded once per slug (covers all weights). * - Variable fonts: Loaded once per id (covers all weights).
* - Static fonts: Loaded per slug + weight combination. * - Static fonts: Loaded per id + weight combination.
*/ */
class AppliedFontsManager { class AppliedFontsManager {
// Tracking usage: Map<key, timestamp> where key is "slug" or "slug@weight"
#usageTracker = new Map<string, number>(); #usageTracker = new Map<string, number>();
// Map: key -> batchId #idToBatch = new Map<string, string>();
#slugToBatch = new Map<string, string>(); // Changed to HTMLStyleElement
// Map: batchId -> HTMLLinkElement #batchElements = new Map<string, HTMLStyleElement>();
#batchElements = new Map<string, HTMLLinkElement>();
#queue = new Set<string>(); #queue = new Map<string, FontConfigRequest>(); // Track config in queue
#timeoutId: ReturnType<typeof setTimeout> | null = null; #timeoutId: ReturnType<typeof setTimeout> | null = null;
#PURGE_INTERVAL = 60000; #PURGE_INTERVAL = 60000;
#TTL = 5 * 60 * 1000; #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>(); statuses = new SvelteMap<string, FontStatus>();
constructor() { constructor() {
@@ -38,139 +52,119 @@ class AppliedFontsManager {
} }
} }
/** #getFontKey(id: string, weight: number): string {
* Resolves a unique key for the font asset. return `${id.toLowerCase()}@${weight}`;
*/
#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}`;
} }
/**
* Call this when a font is rendered on screen.
*/
touch(configs: FontConfigRequest[]) { touch(configs: FontConfigRequest[]) {
const now = Date.now(); const now = Date.now();
const toRegister: string[] = []; configs.forEach(config => {
const key = this.#getFontKey(config.id, config.weight);
configs.forEach(({ slug, weight, isVariable = false }) => {
const key = this.#getFontKey(slug, weight, isVariable);
this.#usageTracker.set(key, now); this.#usageTracker.set(key, now);
if (!this.#slugToBatch.has(key)) { if (!this.#idToBatch.has(key) && !this.#queue.has(key)) {
toRegister.push(key); this.#queue.set(key, config);
}
});
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.#timeoutId) clearTimeout(this.#timeoutId); if (this.#timeoutId) clearTimeout(this.#timeoutId);
this.#timeoutId = setTimeout(() => this.#processQueue(), 50); this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
} }
});
}
getFontStatus(slug: string, weight: number, isVariable: boolean) { getFontStatus(id: string, weight: number) {
return this.statuses.get(this.#getFontKey(slug, weight, isVariable)); return this.statuses.get(this.#getFontKey(id, weight));
} }
#processQueue() { #processQueue() {
const fullQueue = Array.from(this.#queue); const entries = Array.from(this.#queue.entries());
if (fullQueue.length === 0) return; if (entries.length === 0) return;
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) { for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE)); this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE));
} }
this.#queue.clear(); this.#queue.clear();
this.#timeoutId = null; this.#timeoutId = null;
} }
#createBatch(keys: string[]) { #createBatch(batchEntries: [string, FontConfigRequest][]) {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const batchId = crypto.randomUUID(); const batchId = crypto.randomUUID();
let cssRules = '';
/** batchEntries.forEach(([key, config]) => {
* Fontshare API Logic: this.statuses.set(key, 'loading');
* - If key contains '@', it's static (e.g., satoshi@700) this.#idToBatch.set(key, batchId);
* - 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('&');
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`; // Construct the @font-face rule
// Using format('truetype') for .ttf
keys.forEach(key => this.statuses.set(key, 'loading')); cssRules += `
@font-face {
const link = document.createElement('link'); font-family: '${config.name}';
link.rel = 'stylesheet'; src: url('${config.url}') format('truetype');
link.href = url; font-weight: ${config.weight};
link.dataset.batchId = batchId; font-style: normal;
document.head.appendChild(link); font-display: swap;
}
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');
}); });
// 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() { #purgeUnused() {
const now = Date.now(); const now = Date.now();
const batchesToPotentialDelete = new Set<string>(); const batchesToRemove = new Set<string>();
const keysToDelete: string[] = []; const keysToRemove: string[] = [];
for (const [key, lastUsed] of this.#usageTracker.entries()) { for (const [key, lastUsed] of this.#usageTracker.entries()) {
if (now - lastUsed > this.#TTL) { if (now - lastUsed > this.#TTL) {
const batchId = this.#slugToBatch.get(key); const batchId = this.#idToBatch.get(key);
if (batchId) batchesToPotentialDelete.add(batchId); if (batchId) {
keysToDelete.push(key); // Check if EVERY font in this batch is expired
} const batchKeys = Array.from(this.#idToBatch.entries())
}
batchesToPotentialDelete.forEach(batchId => {
const batchKeys = Array.from(this.#slugToBatch.entries())
.filter(([_, bId]) => bId === batchId) .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) { if (canDeleteBatch) {
this.#batchElements.get(batchId)?.remove(); batchesToRemove.add(batchId);
this.#batchElements.delete(batchId); keysToRemove.push(...batchKeys);
batchKeys.forEach(k => { }
this.#slugToBatch.delete(k); }
}
}
batchesToRemove.forEach(id => {
this.#batchElements.get(id)?.remove();
this.#batchElements.delete(id);
});
keysToRemove.forEach(k => {
this.#idToBatch.delete(k);
this.#usageTracker.delete(k); this.#usageTracker.delete(k);
this.statuses.delete(k); this.statuses.delete(k);
}); });
} }
});
}
} }
export const appliedFontsManager = new AppliedFontsManager(); export const appliedFontsManager = new AppliedFontsManager();

View File

@@ -59,6 +59,11 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
} | null } | null
>(null); >(null);
/**
* Accumulated fonts from all pages (for infinite scroll)
*/
#accumulatedFonts = $state<UnifiedFont[]>([]);
/** /**
* Pagination metadata (derived from proxy API response) * 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 = {}) { constructor(initialParams: ProxyFontsParams = {}) {
super(initialParams); 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, 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; return response.fonts;
} }
// --- Getters (proxied from BaseFontStore) --- // --- Getters (proxied from BaseFontStore) ---
/** /**
* Get all fonts from current query result * Get all accumulated fonts (for infinite scroll)
*/ */
get fonts(): UnifiedFont[] { get fonts(): UnifiedFont[] {
// The result.data is UnifiedFont[] (from TanStack Query) return this.#accumulatedFonts;
return (this.result.data as UnifiedFont[] | undefined) ?? [];
} }
/** /**
@@ -288,5 +346,9 @@ export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
/** /**
* Singleton instance for global use * 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,
});

View File

@@ -20,6 +20,8 @@ interface Props {
* Font id to load * Font id to load
*/ */
id: string; id: string;
url: string;
/** /**
* Font weight * Font weight
*/ */
@@ -34,7 +36,7 @@ interface Props {
children?: Snippet; children?: Snippet;
} }
let { name, id, weight = 400, className, children }: Props = $props(); let { name, id, url, weight = 400, className, children }: Props = $props();
let element: Element; let element: Element;
// Track if the user has actually scrolled this into view // Track if the user has actually scrolled this into view
@@ -44,7 +46,7 @@ $effect(() => {
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) { if (entries[0].isIntersecting) {
hasEnteredViewport = true; hasEnteredViewport = true;
appliedFontsManager.touch([{ slug: id, weight }]); appliedFontsManager.touch([{ id, weight, name, url }]);
// Once it has entered, we can stop observing to save CPU // Once it has entered, we can stop observing to save CPU
observer.unobserve(element); observer.unobserve(element);
@@ -54,7 +56,7 @@ $effect(() => {
return () => observer.disconnect(); 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) // The "Show" condition: Element is in view AND (Font is ready OR it errored out)
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error')); const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));

View File

@@ -1,16 +1,15 @@
<!-- <!--
Component: FontListItem 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"> <script lang="ts">
import { Badge } from '$shared/shadcn/ui/badge';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
import { import {
type UnifiedFont, type UnifiedFont,
selectedFontsStore, selectedFontsStore,
} from '../../model'; } from '../../model';
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
interface Props { interface Props {
/** /**
@@ -20,16 +19,24 @@ interface Props {
/** /**
* Is element fully visible * Is element fully visible
*/ */
isVisible: boolean; isFullyVisible: boolean;
/**
* Is element partially visible
*/
isPartiallyVisible: boolean;
/** /**
* From 0 to 1 * From 0 to 1
*/ */
proximity: number; 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); let timeoutId = $state<NodeJS.Timeout | null>(null);
// Create a spring for smooth scale animation // Create a spring for smooth scale animation
@@ -46,11 +53,7 @@ const bloom = new Spring(0, {
// Sync spring to proximity for a "Lens" effect // Sync spring to proximity for a "Lens" effect
$effect(() => { $effect(() => {
bloom.target = isVisible ? 1 : 0; bloom.target = isPartiallyVisible ? 1 : 0;
});
$effect(() => {
selected = selectedFontsStore.has(font.id);
}); });
$effect(() => { $effect(() => {
@@ -61,11 +64,6 @@ $effect(() => {
}; };
}); });
function handleClick() {
animateSelection();
selected ? selectedFontsStore.removeOne(font.id) : selectedFontsStore.addOne(font);
}
function animateSelection() { function animateSelection() {
scale.target = 0.98; scale.target = 0.98;
@@ -83,58 +81,5 @@ function animateSelection() {
translateY({(1 - bloom.current) * 10}px) translateY({(1 - bloom.current) * 10}px)
" "
> >
<div style:transform={`scale(${scale.current})`}> {@render children?.(font)}
<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>
</div> </div>

View File

@@ -3,24 +3,40 @@
- Renders a virtualized list of fonts - Renders a virtualized list of fonts
- Handles font registration with the manager - 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 { VirtualList } from '$shared/ui';
import type { ComponentProps } from 'svelte'; import type { ComponentProps } from 'svelte';
import { appliedFontsManager } from '../../model'; import {
type UnifiedFont,
appliedFontsManager,
} from '../../model';
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> { interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
onVisibleItemsChange?: (items: T[]) => void; 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[]) { function handleInternalVisibleChange(visibleItems: T[]) {
// Auto-register fonts with the manager // Auto-register fonts with the manager
const slugs = visibleItems.map(item => item.id); const configs = visibleItems.map<FontConfigRequest>(item => ({
appliedFontsManager.registerFonts(slugs); 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 // Forward the call to any external listener
onVisibleItemsChange?.(visibleItems); onNearBottom?.(lastVisibleIndex);
} }
</script> </script>
@@ -28,6 +44,7 @@ function handleInternalVisibleChange(visibleItems: T[]) {
{items} {items}
{...rest} {...rest}
onVisibleItemsChange={handleInternalVisibleChange} onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
> >
{#snippet children(scope)} {#snippet children(scope)}
{@render children(scope)} {@render children(scope)}

View File

@@ -1,2 +1 @@
export { displayedFontsStore } from './model'; export { FontSampler } from './ui';
export { FontDisplay } from './ui';

View File

@@ -1 +0,0 @@
export { displayedFontsStore } from './store';

View File

@@ -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();

View File

@@ -1 +0,0 @@
export { displayedFontsStore } from './displayedFontsStore.svelte';

View File

@@ -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>

View File

@@ -86,7 +86,8 @@ function removeSample() {
</div> </div>
<div class="p-8 relative z-10"> <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 <ContentEditable
bind:text={text} bind:text={text}
{...restProps} {...restProps}

View File

@@ -1,3 +1,3 @@
import FontDisplay from './FontDisplay/FontDisplay.svelte'; import FontSampler from './FontSampler/FontSampler.svelte';
export { FontDisplay }; export { FontSampler };

View File

@@ -15,5 +15,4 @@ export { filterManager } from './model/state/manager.svelte';
export { export {
FilterControls, FilterControls,
Filters, Filters,
SuggestedFonts,
} from './ui'; } from './ui';

View File

@@ -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>

View File

@@ -1,9 +1,7 @@
import Filters from './Filters/Filters.svelte'; import Filters from './Filters/Filters.svelte';
import FilterControls from './FiltersControl/FilterControls.svelte'; import FilterControls from './FiltersControl/FilterControls.svelte';
import SuggestedFonts from './SuggestedFonts/SuggestedFonts.svelte';
export { export {
FilterControls, FilterControls,
Filters, Filters,
SuggestedFonts,
}; };

View File

@@ -3,109 +3,85 @@
Description: The main page component of the application. Description: The main page component of the application.
--> -->
<script lang="ts"> <script lang="ts">
import { appliedFontsManager } from '$entities/Font'; import { BreadcrumbHeader } from '$entities/Breadcrumb';
import { displayedFontsStore } from '$features/DisplayFont'; import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
import { controlManager } from '$features/SetupFont';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Section } from '$shared/ui'; import { Section } from '$shared/ui';
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte'; import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
import { FontSearch } from '$widgets/FontSearch'; import { FontSearch } from '$widgets/FontSearch';
import { SampleList } from '$widgets/SampleList';
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle'; import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
import ScanEyeIcon from '@lucide/svelte/icons/scan-eye'; import ScanEyeIcon from '@lucide/svelte/icons/scan-eye';
import ScanSearchIcon from '@lucide/svelte/icons/search';
import type { Snippet } from 'svelte';
let searchContainer: HTMLElement; let searchContainer: HTMLElement;
let isExpanded = $state(false); 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(() => { return () => {
appliedFontsManager.touch( scrollBreadcrumbsStore.remove(index);
displayedFontsStore.fonts.map(font => ({ slug: font.id, weight: controlManager.weight })), };
); }
});
// $effect(() => {
// appliedFontsManager.touch(
// selectedFontsStore.all.map(font => ({
// slug: font.id,
// weight: controlManager.weight,
// })),
// );
// });
</script> </script>
<BreadcrumbHeader />
<!-- Font List --> <!-- Font List -->
<div class="p-2 h-full flex flex-col gap-3"> <div class="p-2 h-full flex flex-col gap-3">
{#key isEmptyScreen} <Section class="my-12 gap-8" index={0} onTitleStatusChange={handleTitleStatusChanged}>
<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}>
{#snippet icon({ className })} {#snippet icon({ className })}
<ScanEyeIcon class={className} /> <ScanEyeIcon class={className} />
{/snippet} {/snippet}
{#snippet title({ className })} {#snippet title({ className })}
<h1 class={className}> <h1 class={className}>
Optical<br>Comparator Optical<br />Comparator
</h1> </h1>
{/snippet} {/snippet}
<ComparisonSlider /> <ComparisonSlider />
</Section> </Section>
{/if}
{#if displayedFontsStore.hasAnyFonts} <Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}>
<Section class="my-12 gap-8" index={2}> {#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 })} {#snippet icon({ className })}
<LineSquiggleIcon class={className} /> <LineSquiggleIcon class={className} />
{/snippet} {/snippet}
{#snippet title({ className })} {#snippet title({ className })}
<h2 class={className}> <h2 class={className}>
Sample<br>Set Sample<br />Set
</h2> </h2>
{/snippet} {/snippet}
<FontDisplay /> <SampleList />
</Section> </Section>
{/if}
</div> </div>
<style> <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 { .content {
/* Tells the browser to skip rendering off-screen content */ /* Tells the browser to skip rendering off-screen content */
content-visibility: auto; content-visibility: auto;
@@ -115,10 +91,4 @@ $effect(() => {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.will-change-[height] {
will-change: flex-grow, padding;
/* Forces GPU acceleration for the layout shift */
transform: translateZ(0);
}
</style> </style>

View File

@@ -46,9 +46,6 @@ export class EntityStore<T extends Entity> {
updateOne(id: string, changes: Partial<T>) { updateOne(id: string, changes: Partial<T>) {
const entity = this.#entities.get(id); const entity = this.#entities.get(id);
if (entity) { 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 }); this.#entities.set(id, { ...entity, ...changes });
} }
} }

View File

@@ -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;
},
};
}

View File

@@ -4,19 +4,37 @@
* Used to render visible items with absolute positioning based on computed offsets. * Used to render visible items with absolute positioning based on computed offsets.
*/ */
export interface VirtualItem { export interface VirtualItem {
/** Index of the item in the data array */ /**
* Index of the item in the data array
*/
index: number; index: number;
/** Offset from the top of the list in pixels */ /**
* Offset from the top of the list in pixels
*/
start: number; start: number;
/** Height/size of the item in pixels */ /**
* Height/size of the item in pixels
*/
size: number; size: number;
/** End position in pixels (start + size) */ /**
* End position in pixels (start + size)
*/
end: number; 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; key: string | number;
/** Whether the item is currently visible in the viewport */ /**
isVisible: boolean; * Whether the item is currently fully visible in the viewport
/** Proximity of the item to the center of 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; proximity: number;
} }
@@ -45,6 +63,11 @@ export interface VirtualizerOptions {
* Can be useful for handling sticky headers or other UI elements. * Can be useful for handling sticky headers or other UI elements.
*/ */
scrollMargin?: number; 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 containerHeight = $state(0);
let measuredSizes = $state<Record<number, number>>({}); let measuredSizes = $state<Record<number, number>>({});
let elementRef: HTMLElement | null = null; let elementRef: HTMLElement | null = null;
let elementOffsetTop = 0;
// By wrapping the getter in $derived, we track everything inside it // By wrapping the getter in $derived, we track everything inside it
const options = $derived(optionsGetter()); const options = $derived(optionsGetter());
@@ -157,9 +181,8 @@ export function createVirtualizer<T>(
const itemEnd = itemStart + itemSize; const itemEnd = itemStart + itemSize;
// Visibility check: Does the item overlap the viewport? // Visibility check: Does the item overlap the viewport?
// const isVisible = itemStart < viewportEnd && itemEnd > scrollOffset; const isPartiallyVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
// Fully visible const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
const isVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
// Proximity calculation: 1.0 at center, 0.0 at edges // Proximity calculation: 1.0 at center, 0.0 at edges
const itemCenter = itemStart + (itemSize / 2); const itemCenter = itemStart + (itemSize / 2);
@@ -173,7 +196,8 @@ export function createVirtualizer<T>(
size: itemSize, size: itemSize,
end: itemEnd, end: itemEnd,
key: options.getItemKey?.(i) ?? i, key: options.getItemKey?.(i) ?? i,
isVisible, isPartiallyVisible,
isFullyVisible,
proximity, proximity,
}); });
} }
@@ -192,6 +216,53 @@ export function createVirtualizer<T>(
*/ */
function container(node: HTMLElement) { function container(node: HTMLElement) {
elementRef = node; 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; containerHeight = node.offsetHeight;
const handleScroll = () => { const handleScroll = () => {
@@ -213,6 +284,7 @@ export function createVirtualizer<T>(
}, },
}; };
} }
}
let measurementBuffer: Record<number, number> = {}; let measurementBuffer: Record<number, number> = {};
let frameId: number | null = null; let frameId: number | null = null;
@@ -275,12 +347,23 @@ export function createVirtualizer<T>(
const itemStart = offsets[index]; const itemStart = offsets[index];
const itemSize = measuredSizes[index] ?? options.estimateSize(index); const itemSize = measuredSizes[index] ?? options.estimateSize(index);
let target = itemStart; 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 === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
if (align === 'end') target = itemStart - containerHeight + itemSize; if (align === 'end') target = itemStart - containerHeight + itemSize;
elementRef.scrollTo({ top: target, behavior: 'smooth' }); elementRef.scrollTo({ top: target, behavior: 'smooth' });
} }
}
return { return {
/** Computed array of visible items to render (reactive) */ /** Computed array of visible items to render (reactive) */

View File

@@ -31,3 +31,5 @@ export {
createCharacterComparison, createCharacterComparison,
type LineData, type LineData,
} from './createCharacterComparison/createCharacterComparison.svelte'; } from './createCharacterComparison/createCharacterComparison.svelte';
export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte';

View File

@@ -5,6 +5,7 @@ export {
createDebouncedState, createDebouncedState,
createEntityStore, createEntityStore,
createFilter, createFilter,
createPersistentStore,
createTypographyControl, createTypographyControl,
createVirtualizer, createVirtualizer,
type Entity, type Entity,

View File

@@ -45,11 +45,7 @@ let noChildrenValue = $state('');
placeholder: 'Type here...', placeholder: 'Type here...',
}} }}
> >
<SearchBar bind:value={defaultSearchValue} placeholder="Type here..."> <SearchBar bind:value={defaultSearchValue} placeholder="Type here..."> </SearchBar>
Here will be the search result
<br />
Popover closes only when the user clicks outside the search bar or presses the Escape key.
</SearchBar>
</Story> </Story>
<Story <Story
@@ -60,11 +56,7 @@ let noChildrenValue = $state('');
label: 'Search', label: 'Search',
}} }}
> >
<SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search"> <SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search"> </SearchBar>
<div class="p-4">
<p class="text-sm text-muted-foreground">No results found</p>
</div>
</SearchBar>
</Story> </Story>
<Story <Story
@@ -74,9 +66,5 @@ let noChildrenValue = $state('');
placeholder: 'Quick search...', placeholder: 'Quick search...',
}} }}
> >
<SearchBar bind:value={noChildrenValue} placeholder="Quick search..."> <SearchBar bind:value={noChildrenValue} placeholder="Quick search..."> </SearchBar>
<div class="p-4 text-center text-sm text-muted-foreground">
Start typing to see results
</div>
</SearchBar>
</Story> </Story>

View File

@@ -1,20 +1,7 @@
<!-- <!-- Component: SearchBar -->
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.
-->
<script lang="ts"> <script lang="ts">
import { Input } from '$shared/shadcn/ui/input'; import { Input } from '$shared/shadcn/ui/input';
import { Label } from '$shared/shadcn/ui/label'; import AsteriskIcon from '@lucide/svelte/icons/asterisk';
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';
interface Props { interface Props {
/** /**
@@ -25,10 +12,6 @@ interface Props {
* Current search value (bindable) * Current search value (bindable)
*/ */
value: string; value: string;
/**
* Whether popover is open (bindable)
*/
isOpen?: boolean;
/** /**
* Additional CSS classes for the container * Additional CSS classes for the container
*/ */
@@ -41,74 +24,52 @@ interface Props {
* Optional label displayed above the input * Optional label displayed above the input
*/ */
label?: string; label?: string;
/**
* Content to render inside the popover (receives unique content ID)
*/
children: Snippet<[{ id: string }]> | undefined;
} }
let { let {
id = 'search-bar', id = 'search-bar',
value = $bindable(''), value = $bindable(''),
isOpen = $bindable(false),
class: className, class: className,
placeholder, placeholder,
label,
children,
}: Props = $props(); }: Props = $props();
let triggerRef = $state<HTMLInputElement>(null!);
// svelte-ignore state_referenced_locally
const contentId = useId(id);
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') { if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
} }
} }
function handleInputClick() {
isOpen = true;
}
</script> </script>
<PopoverRoot bind:open={isOpen}> <div class="relative w-full">
<PopoverTrigger bind:ref={triggerRef}> <div class="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
{#snippet child({ props })} <AsteriskIcon class="size-4 stroke-gray-400 stroke-[1.5]" />
{@const { onclick, ...rest } = props} </div>
<div {...rest} class="flex flex-row flex-1 w-full">
{#if label}
<Label for={id}>{label}</Label>
{/if}
<Input <Input
id={id} id={id}
placeholder={placeholder} placeholder={placeholder}
bind:value={value} bind:value={value}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
onclick={handleInputClick}
class=" class="
h-20 w-full md:text-2xl backdrop-blur-sm bg-white/60 dark:bg-slate-900/40 h-16 w-full text-base
ring-2 ring-slate-200/50 backdrop-blur-md bg-white/80
active:ring-indigo-500/50 border border-gray-300/50
focus-visible:border-indigo-500/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/50 shadow-[0_1px_3px_rgba(0,0,0,0.04)]
hover:bg-white/70 dark:hover:bg-slate-900/50 text-slate-900 dark:text-slate-100 focus-visible:border-gray-400/60
placeholder:text-slate-400 px-6 py-4 rounded-2xl transition-all duration-300 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 font-medium
" "
/> />
</div> </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>

View File

@@ -29,15 +29,53 @@ interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
* Index of the section * Index of the section
*/ */
index?: number; 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 * Snippet for the section content
*/ */
children?: Snippet; 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 }; 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> </script>
<section <section
@@ -48,7 +86,7 @@ const flyParams: FlyParams = { y: 0, x: -50, duration: 300, easing: cubicOut, op
in:fly={flyParams} in:fly={flyParams}
out: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"> <div class="flex items-center gap-3 opacity-60">
{#if icon} {#if icon}
{@render icon({ className: 'size-4 stroke-gray-900 stroke-1' })} {@render icon({ className: 'size-4 stroke-gray-900 stroke-1' })}

View File

@@ -6,11 +6,15 @@
- Keyboard navigation (ArrowUp/Down, Home, End) - Keyboard navigation (ArrowUp/Down, Home, End)
- Fixed or dynamic item heights - Fixed or dynamic item heights
- ARIA listbox/option pattern with single tab stop - ARIA listbox/option pattern with single tab stop
- Custom shadcn ScrollArea scrollbar
--> -->
<script lang="ts" generics="T"> <script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib'; import { createVirtualizer } from '$shared/lib';
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing';
interface Props { interface Props {
/** /**
@@ -19,6 +23,20 @@ interface Props {
* @template T - The type of items in the list * @template T - The type of items in the list
*/ */
items: T[]; 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 * Height for each item, either as a fixed number
* or a function that returns height per index. * or a function that returns height per index.
@@ -40,6 +58,24 @@ interface Props {
* @param items - Loaded items * @param items - Loaded items
*/ */
onVisibleItemsChange?: (items: T[]) => void; 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. * Snippet for rendering individual list items.
* *
@@ -52,43 +88,79 @@ interface Props {
* *
* @template T - The type of items in the list * @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(() => ({ const virtualizer = createVirtualizer(() => ({
count: items.length, count: items.length,
data: items, data: items,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight, estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
overscan, overscan,
useWindowScroll,
})); }));
// Attach virtualizer.container action to the viewport when it becomes available
$effect(() => {
if (viewportRef) {
const { destroy } = virtualizer.container(viewportRef);
return destroy;
}
});
$effect(() => { $effect(() => {
const visibleItems = virtualizer.items.map(item => items[item.index]); const visibleItems = virtualizer.items.map(item => items[item.index]);
onVisibleItemsChange?.(visibleItems); 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> </script>
<div {#if useWindowScroll}
use:virtualizer.container <div class={cn('relative w-full', className)} bind:this={viewportRef}>
class={cn( <div style:height="{virtualizer.totalSize}px" class="relative w-full">
'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>
{#each virtualizer.items as item (item.key)} {#each virtualizer.items as item (item.key)}
<div <div
use:virtualizer.measureElement use:virtualizer.measureElement
@@ -96,12 +168,51 @@ $effect(() => {
class="absolute top-0 left-0 w-full" class="absolute top-0 left-0 w-full"
style:transform="translateY({item.start}px)" style:transform="translateY({item.start}px)"
> >
{#if item.index < items.length}
{@render children({ {@render children({
// TODO: Fix indenation rule for this case
item: items[item.index], item: items[item.index],
index: item.index, index: item.index,
isVisible: item.isVisible, isFullyVisible: item.isFullyVisible,
isPartiallyVisible: item.isPartiallyVisible,
proximity: item.proximity, proximity: item.proximity,
})} })}
{/if}
</div> </div>
{/each} {/each}
</div> </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}

View File

@@ -1 +1,2 @@
export * from './model';
export { ComparisonSlider } from './ui'; export { ComparisonSlider } from './ui';

View File

@@ -0,0 +1 @@
export { comparisonStore } from './stores/comparisonStore.svelte';

View File

@@ -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();

View File

@@ -10,23 +10,21 @@
- Performance optimized using offscreen canvas for measurements and transform-based animations. - Performance optimized using offscreen canvas for measurements and transform-based animations.
--> -->
<script lang="ts"> <script lang="ts">
import { displayedFontsStore } from '$features/DisplayFont';
import { import {
createCharacterComparison, createCharacterComparison,
createTypographyControl, createTypographyControl,
} from '$shared/lib'; } from '$shared/lib';
import type { LineData } from '$shared/lib'; import type { LineData } from '$shared/lib';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
import CharacterSlot from './components/CharacterSlot.svelte'; import CharacterSlot from './components/CharacterSlot.svelte';
import ControlsWrapper from './components/ControlsWrapper.svelte'; import ControlsWrapper from './components/ControlsWrapper.svelte';
import Labels from './components/Labels.svelte'; import Labels from './components/Labels.svelte';
import SliderLine from './components/SliderLine.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 // Pair of fonts to compare
const fontA = $derived(displayedFontsStore.fontA); const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(displayedFontsStore.fontB); const fontB = $derived(comparisonStore.fontB);
let container: HTMLElement | undefined = $state(); let container: HTMLElement | undefined = $state();
let controlsWrapperElement = $state<HTMLDivElement | null>(null); 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. * Manages line breaking and character state based on fonts and container dimensions.
*/ */
const charComparison = createCharacterComparison( const charComparison = createCharacterComparison(
() => text, () => comparisonStore.text,
() => fontA, () => fontA,
() => fontB, () => fontB,
() => weightControl.value, () => weightControl.value,
@@ -85,7 +83,10 @@ function handleMove(e: PointerEvent) {
} }
function startDragging(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(); e.stopPropagation();
return; return;
} }
@@ -109,7 +110,7 @@ $effect(() => {
// Re-run line breaking when container resizes or dependencies change // Re-run line breaking when container resizes or dependencies change
$effect(() => { $effect(() => {
// React on text and typography settings changes // React on text and typography settings changes
const _text = text; const _text = comparisonStore.text;
const _weight = weightControl.value; const _weight = weightControl.value;
const _size = sizeControl.value; const _size = sizeControl.value;
const _height = heightControl.value; const _height = heightControl.value;
@@ -125,9 +126,7 @@ $effect(() => {
$effect(() => { $effect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const handleResize = () => { const handleResize = () => {
if ( if (container && measureCanvas) {
container && measureCanvas
) {
charComparison.breakIntoLines(container, measureCanvas); charComparison.breakIntoLines(container, measureCanvas);
} }
}; };
@@ -215,17 +214,17 @@ $effect(() => {
<SliderLine {sliderPos} {isDragging} /> <SliderLine {sliderPos} {isDragging} />
</div> </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 --> <!-- Since there're slider controls inside we put them outside the main one -->
<ControlsWrapper <ControlsWrapper
bind:wrapper={controlsWrapperElement} bind:wrapper={controlsWrapperElement}
{sliderPos} {sliderPos}
{isDragging} {isDragging}
bind:text={text} bind:text={comparisonStore.text}
containerWidth={container?.clientWidth} containerWidth={container?.clientWidth}
weightControl={weightControl} {weightControl}
sizeControl={sizeControl} {sizeControl}
heightControl={heightControl} {heightControl}
/> />
</div> </div>
{/if} {/if}

View File

@@ -2,14 +2,13 @@
Component: Labels Component: Labels
Displays labels for font selection in the comparison slider. 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 { import {
FontVirtualList, FontVirtualList,
type UnifiedFont, type UnifiedFont,
unifiedFontStore,
} from '$entities/Font'; } from '$entities/Font';
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte'; import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
import { displayedFontsStore } from '$features/DisplayFont';
import { buttonVariants } from '$shared/shadcn/ui/button';
import { import {
Content as SelectContent, Content as SelectContent,
Item as SelectItem, Item as SelectItem,
@@ -17,6 +16,7 @@ import {
Trigger as SelectTrigger, Trigger as SelectTrigger,
} from '$shared/shadcn/ui/select'; } from '$shared/shadcn/ui/select';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
interface Props<T> { interface Props<T> {
/** /**
@@ -31,82 +31,124 @@ interface Props<T> {
* Position of the slider * Position of the slider
*/ */
sliderPos: number; sliderPos: number;
weight: number;
} }
let { fontA, fontB, sliderPos }: Props<T> = $props(); let { fontA, fontB, sliderPos, weight }: Props<T> = $props();
const fontList = $derived( const fontList = $derived(unifiedFontStore.fonts);
displayedFontsStore.fonts.filter(font => font.name !== fontA.name && font.name !== fontB.name),
);
function selectFontA(fontId: string) { function selectFontA(font: UnifiedFont) {
const newFontA = displayedFontsStore.getById(fontId); if (!font) return;
if (!newFontA) return; comparisonStore.fontA = font;
displayedFontsStore.fontA = newFontA;
} }
function selectFontB(fontId: string) { function selectFontB(font: UnifiedFont) {
const newFontB = displayedFontsStore.getById(fontId); if (!font) return;
if (!newFontB) return; comparisonStore.fontB = font;
displayedFontsStore.fontB = newFontB;
} }
</script> </script>
{#snippet fontSelector( {#snippet fontSelector(
name: string, name: string,
id: string, id: string,
url: string,
fonts: UnifiedFont[], fonts: UnifiedFont[],
handleChange: (value: string) => void, selectFont: (font: UnifiedFont) => void,
align: 'start' | 'end',
)} )}
<div <div
class="z-50 pointer-events-auto **:bg-transparent" class="z-50 pointer-events-auto"
onpointerdown={(e => e.stopPropagation())} onpointerdown={(e => e.stopPropagation())}
> >
<SelectRoot type="single" onValueChange={handleChange}> <SelectRoot type="single" disabled={!fontList.length}>
<SelectTrigger <SelectTrigger
class={cn(buttonVariants({ variant: 'ghost' }), 'border-none, hover:bg-indigo-100')} class={cn(
disabled={!fontList.length} '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} {name}
</FontApplicator> </FontApplicator>
</div>
</SelectTrigger> </SelectTrigger>
<SelectContent <SelectContent
class="h-60 bg-transparent **:bg-transparent backdrop-blur-0 data-[state=open]:backdrop-blur-lg transition-[backdrop-filter] duration-200" class={cn(
scrollYThreshold={100} 'bg-white/95 backdrop-blur-xl border border-gray-300/50 shadow-xl',
'w-52 max-h-[280px] overflow-hidden rounded-lg',
)}
side="top" side="top"
{align}
sideOffset={8}
size="small"
> >
<FontVirtualList items={fonts}> <div class="p-1.5">
<FontVirtualList items={fonts} {weight}>
{#snippet children({ item: font })} {#snippet children({ item: font })}
<SelectItem value={font.id} class="data-[highlighted]:bg-indigo-100"> {@const handleClick = () => selectFont(font)}
<FontApplicator name={font.name} id={font.id}> <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} {font.name}
</FontApplicator> </FontApplicator>
</SelectItem> </SelectItem>
{/snippet} {/snippet}
</FontVirtualList> </FontVirtualList>
</div>
</SelectContent> </SelectContent>
</SelectRoot> </SelectRoot>
</div> </div>
{/snippet} {/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 <div
class="flex flex-col gap-0.5 transition-opacity duration-300 items-start" class="flex flex-col gap-2 transition-all duration-500 items-start"
style:opacity={sliderPos < 15 ? 0 : 1} 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"> <div class="flex items-center gap-2.5 px-1">
Baseline <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> </span>
{@render fontSelector(fontB.name, fontB.id, fontList, selectFontB)} </div>
{@render fontSelector(
fontB.name,
fontB.id,
fontB.styles.regular!,
fontList,
selectFontB,
'start',
)}
</div> </div>
<div <div
class="flex flex-col items-end text-right gap-1 transition-opacity duration-300" class="flex flex-col items-end text-right gap-2 transition-all duration-500"
style:opacity={sliderPos > 85 ? 0 : 1} 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"> <div class="flex items-center gap-2.5 px-1">
Comparison <span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
ch_02
</span> </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>
</div> </div>

View File

@@ -1,22 +1,22 @@
<!-- <!--
Component: FontSearch Component: FontSearch
Provides a search input and filtration for fonts
Combines search input with font list display
--> -->
<script lang="ts"> <script lang="ts">
import { unifiedFontStore } from '$entities/Font'; import { unifiedFontStore } from '$entities/Font';
import { import {
FilterControls, FilterControls,
Filters, Filters,
SuggestedFonts,
filterManager, filterManager,
mapManagerToParams, mapManagerToParams,
} from '$features/GetFonts'; } from '$features/GetFonts';
import { springySlideFade } from '$shared/lib'; import { springySlideFade } from '$shared/lib';
import { Button } from '$shared/shadcn/ui/button';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { SearchBar } from '$shared/ui'; import {
import FunnelIcon from '@lucide/svelte/icons/funnel'; IconButton,
SearchBar,
} from '$shared/ui';
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { import {
@@ -26,11 +26,13 @@ import {
import { type SlideParams } from 'svelte/transition'; import { type SlideParams } from 'svelte/transition';
interface Props { interface Props {
/**
* Controllable flag to show/hide filters (bindable)
*/
showFilters?: boolean; showFilters?: boolean;
isOpen?: boolean;
} }
let { showFilters = $bindable(false), isOpen = $bindable(false) }: Props = $props(); let { showFilters = $bindable(false) }: Props = $props();
onMount(() => { onMount(() => {
/** /**
@@ -63,35 +65,32 @@ function toggleFilters() {
} }
</script> </script>
<div class="flex flex-col gap-2 relative"> <div class="flex flex-col gap-3 relative">
<div class="relative">
<SearchBar <SearchBar
id="font-search" id="font-search"
class="w-full" class="w-full"
placeholder="Search fonts by name..." placeholder="search_typefaces..."
label="query_input"
bind:value={filterManager.queryValue} bind:value={filterManager.queryValue}
bind:isOpen />
>
<SuggestedFonts />
</SearchBar>
<div class="absolute right-5 top-10 translate-y-[-50%] pl-5 border-l-2"> <div class="absolute right-4 top-1/2 translate-y-[-50%] z-10">
<div style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"> <div class="flex items-center gap-2">
<Button <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( class={cn(
'cursor-pointer will-change-transform hover:bg-inherit hover:*:stroke-indigo-500', className,
showFilters ? 'hover:*:stroke-indigo-500/80' : 'hover:*:stroke-indigo-500', showFilters ? 'stroke-gray-900 stroke-3' : 'stroke-gray-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',
)} )}
/> />
</Button> {/snippet}
</IconButton>
</div>
</div>
</div> </div>
</div> </div>
@@ -100,9 +99,29 @@ function toggleFilters() {
transition:springySlideFade|local={slideConfig} transition:springySlideFade|local={slideConfig}
class="will-change-[height,opacity] contain-layout overflow-hidden" 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 /> <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>
</div> </div>
{/if} {/if}

View File

@@ -0,0 +1 @@
export { SampleList } from './ui';

View 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>

View File

@@ -0,0 +1,3 @@
import SampleList from './SampleList/SampleList.svelte';
export { SampleList };

View File

@@ -1,3 +1,7 @@
<!--
Component: TypographyMenu
Provides a menu for selecting and configuring typography settings
-->
<script lang="ts"> <script lang="ts">
import { SetupFontMenu } from '$features/SetupFont'; import { SetupFontMenu } from '$features/SetupFont';
import { import {
@@ -5,7 +9,6 @@ import {
Root as ItemRoot, Root as ItemRoot,
} from '$shared/shadcn/ui/item'; } from '$shared/shadcn/ui/item';
import { displayedFontsStore } from '$features/DisplayFont';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition'; import { crossfade } from 'svelte/transition';
@@ -22,16 +25,17 @@ const [send, receive] = crossfade({
}); });
</script> </script>
{#if displayedFontsStore.hasAnyFonts} <div
<div
class="w-auto fixed bottom-5 inset-x-0 max-screen z-10 flex justify-center" class="w-auto fixed bottom-5 inset-x-0 max-screen z-10 flex justify-center"
in:receive={{ key: 'panel' }} in:receive={{ key: 'panel' }}
out:send={{ 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"> <ItemContent class="flex flex-row justify-center items-center max-w-max">
<SetupFontMenu /> <SetupFontMenu />
</ItemContent> </ItemContent>
</ItemRoot> </ItemRoot>
</div> </div>
{/if}