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

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)
export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './proxy/proxyFonts';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +1 @@
export { displayedFontsStore } from './model';
export { FontDisplay } from './ui';
export { FontSampler } 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 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}

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 {
FilterControls,
Filters,
SuggestedFonts,
} 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 FilterControls from './FiltersControl/FilterControls.svelte';
import SuggestedFonts from './SuggestedFonts/SuggestedFonts.svelte';
export {
FilterControls,
Filters,
SuggestedFonts,
};

View File

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

View File

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

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.
*/
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) */

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' })}

View File

@@ -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>
</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';

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.
-->
<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}

View File

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

View File

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

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">
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
<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}
</div>