Compare commits
36 Commits
ef48d9815c
...
778839d35e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
778839d35e | ||
|
|
92fb314615 | ||
|
|
6f0b69ff45 | ||
|
|
2cd38797b9 | ||
|
|
6f231999e0 | ||
|
|
31a72d90ea | ||
|
|
072690270f | ||
|
|
eaf9d069c5 | ||
|
|
4a94f7bd09 | ||
|
|
918e792e41 | ||
|
|
c9c8b9abfc | ||
|
|
a392b575cc | ||
|
|
961475dea0 | ||
|
|
5496fd2680 | ||
|
|
f90f1e39e0 | ||
|
|
ca161dfbd4 | ||
|
|
ac2d0c32a4 | ||
|
|
54d22d650d | ||
|
|
a9c63f2544 | ||
|
|
70f57283a8 | ||
|
|
d43c873dc9 | ||
|
|
9501dbf281 | ||
|
|
0ac6acd174 | ||
|
|
5bb41c7e4c | ||
|
|
eed3339b0d | ||
|
|
d94e3cefb2 | ||
|
|
cfb586f539 | ||
|
|
6e975e5f8e | ||
|
|
142e4f0a19 | ||
|
|
59b85eead0 | ||
|
|
010643e398 | ||
|
|
27f637531b | ||
|
|
91fa08074b | ||
|
|
c246f70fe9 | ||
|
|
b1ce734f19 | ||
|
|
3add50a190 |
@@ -40,13 +40,13 @@ let { children }: Props = $props();
|
|||||||
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
<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>
|
||||||
|
|||||||
2
src/entities/Breadcrumb/index.ts
Normal file
2
src/entities/Breadcrumb/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { scrollBreadcrumbsStore } from './model';
|
||||||
|
export { BreadcrumbHeader } from './ui';
|
||||||
1
src/entities/Breadcrumb/model/index.ts
Normal file
1
src/entities/Breadcrumb/model/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './store/scrollBreadcrumbsStore.svelte';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
index: number;
|
||||||
|
title: Snippet<[{ className?: string }]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollBreadcrumbsStore {
|
||||||
|
#items = $state<BreadcrumbItem[]>([]);
|
||||||
|
|
||||||
|
get items() {
|
||||||
|
// Keep them sorted by index for Swiss orderliness
|
||||||
|
return this.#items.sort((a, b) => a.index - b.index);
|
||||||
|
}
|
||||||
|
add(item: BreadcrumbItem) {
|
||||||
|
if (!this.#items.find(i => i.index === item.index)) {
|
||||||
|
this.#items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remove(index: number) {
|
||||||
|
this.#items = this.#items.filter(i => i.index !== index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScrollBreadcrumbsStore() {
|
||||||
|
return new ScrollBreadcrumbsStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!--
|
||||||
|
Component: BreadcrumbHeader
|
||||||
|
Fixed header for breadcrumbs navigation for sections in the page
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@lucide/svelte/icons/align-vertical-justify-center';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { scrollBreadcrumbsStore } from '../../model';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if scrollBreadcrumbsStore.items.length > 0}
|
||||||
|
<div
|
||||||
|
transition:slide={{ duration: 200 }}
|
||||||
|
class="
|
||||||
|
fixed top-0 left-0 right-0 z-100
|
||||||
|
backdrop-blur-lg bg-white/20
|
||||||
|
border-b border-gray-300/50
|
||||||
|
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||||
|
h-12
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="max-w-8xl mx-auto px-6 h-full flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2.5 opacity-70">
|
||||||
|
<Icon class="size-4 stroke-gray-900 stroke-1" />
|
||||||
|
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
||||||
|
<span class="font-mono text-[9px] uppercase tracking-[0.25em] text-gray-500 font-medium">
|
||||||
|
nav_trace
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-4 w-px bg-gray-300/60"></div>
|
||||||
|
|
||||||
|
<nav class="flex items-center gap-3 overflow-x-auto scrollbar-hide flex-1">
|
||||||
|
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
|
||||||
|
<div
|
||||||
|
animate:flip={{ duration: 200 }}
|
||||||
|
class="flex items-center gap-3 whitespace-nowrap shrink-0"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-[9px] text-gray-400 tracking-wider">
|
||||||
|
{String(item.index).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{@render item.title({
|
||||||
|
className: 'font-mono text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-gray-900',
|
||||||
|
})}
|
||||||
|
|
||||||
|
{#if idx < scrollBreadcrumbsStore.items.length - 1}
|
||||||
|
<div class="flex items-center gap-0.5 opacity-40">
|
||||||
|
<div class="w-1 h-px bg-gray-400"></div>
|
||||||
|
<div class="w-1 h-px bg-gray-400"></div>
|
||||||
|
<div class="w-1 h-px bg-gray-400"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 opacity-50 ml-auto">
|
||||||
|
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
||||||
|
<span class="font-mono text-[8px] text-gray-400 tracking-wider">
|
||||||
|
[{scrollBreadcrumbsStore.items.length}]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
src/entities/Breadcrumb/ui/index.ts
Normal file
3
src/entities/Breadcrumb/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte';
|
||||||
|
|
||||||
|
export { BreadcrumbHeader };
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
// Proxy API (PRIMARY - NEW)
|
// Proxy API (PRIMARY - NEW)
|
||||||
export {
|
export {
|
||||||
|
fetchFontsByIds,
|
||||||
fetchProxyFontById,
|
fetchProxyFontById,
|
||||||
fetchProxyFonts,
|
fetchProxyFonts,
|
||||||
} from './proxy/proxyFonts';
|
} from './proxy/proxyFonts';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export { displayedFontsStore } from './model';
|
export { FontSampler } from './ui';
|
||||||
export { FontDisplay } from './ui';
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { displayedFontsStore } from './store';
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import {
|
|
||||||
type UnifiedFont,
|
|
||||||
selectedFontsStore,
|
|
||||||
} from '$entities/Font';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store for displayed font samples
|
|
||||||
* - Handles shown text
|
|
||||||
* - Stores selected fonts for display
|
|
||||||
*/
|
|
||||||
export class DisplayedFontsStore {
|
|
||||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
|
||||||
|
|
||||||
#displayedFonts = $derived.by(() => {
|
|
||||||
return selectedFontsStore.all;
|
|
||||||
});
|
|
||||||
|
|
||||||
#fontA = $state<UnifiedFont | undefined>(undefined);
|
|
||||||
|
|
||||||
#fontB = $state<UnifiedFont | undefined>(undefined);
|
|
||||||
|
|
||||||
#hasAnySelectedFonts = $derived(this.#displayedFonts.length > 0);
|
|
||||||
|
|
||||||
get fonts() {
|
|
||||||
return this.#displayedFonts;
|
|
||||||
}
|
|
||||||
|
|
||||||
get fontA() {
|
|
||||||
return this.#fontA ?? this.#displayedFonts[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
set fontA(font: UnifiedFont | undefined) {
|
|
||||||
this.#fontA = font;
|
|
||||||
}
|
|
||||||
|
|
||||||
get fontB() {
|
|
||||||
return this.#fontB ?? this.#displayedFonts[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
set fontB(font: UnifiedFont | undefined) {
|
|
||||||
this.#fontB = font;
|
|
||||||
}
|
|
||||||
|
|
||||||
get text() {
|
|
||||||
return this.#sampleText;
|
|
||||||
}
|
|
||||||
|
|
||||||
set text(text: string) {
|
|
||||||
this.#sampleText = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasAnyFonts() {
|
|
||||||
return this.#hasAnySelectedFonts;
|
|
||||||
}
|
|
||||||
|
|
||||||
getById(id: string): UnifiedFont | undefined {
|
|
||||||
return selectedFontsStore.getById(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const displayedFontsStore = new DisplayedFontsStore();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { displayedFontsStore } from './displayedFontsStore.svelte';
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: FontDisplay
|
|
||||||
Displays a grid of FontSampler components for each displayed font.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { flip } from 'svelte/animate';
|
|
||||||
import { quintOut } from 'svelte/easing';
|
|
||||||
import { displayedFontsStore } from '../../model';
|
|
||||||
import FontSampler from '../FontSampler/FontSampler.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))] will-change-tranform transition-transform content">
|
|
||||||
{#each displayedFontsStore.fonts as font, index (font.id)}
|
|
||||||
<div animate:flip={{ delay: 0, duration: 400, easing: quintOut }}>
|
|
||||||
<FontSampler font={font} bind:text={displayedFontsStore.text} index={index} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -86,7 +86,8 @@ function removeSample() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import FontDisplay from './FontDisplay/FontDisplay.svelte';
|
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||||
|
|
||||||
export { FontDisplay };
|
export { FontSampler };
|
||||||
|
|||||||
@@ -15,5 +15,4 @@ export { filterManager } from './model/state/manager.svelte';
|
|||||||
export {
|
export {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
SuggestedFonts,
|
|
||||||
} from './ui';
|
} from './ui';
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: SuggestedFonts
|
|
||||||
Renders a list of suggested fonts in a virtualized list to improve performance.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
FontListItem,
|
|
||||||
FontVirtualList,
|
|
||||||
unifiedFontStore,
|
|
||||||
} from '$entities/Font';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FontVirtualList items={unifiedFontStore.fonts}>
|
|
||||||
{#snippet children({ item: font, isVisible, proximity })}
|
|
||||||
<FontListItem {font} {isVisible} {proximity} />
|
|
||||||
{/snippet}
|
|
||||||
</FontVirtualList>
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import Filters from './Filters/Filters.svelte';
|
import 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,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Reusable persistent storage utility using Svelte 5 runes
|
||||||
|
*
|
||||||
|
* Automatically syncs state with localStorage.
|
||||||
|
*/
|
||||||
|
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||||
|
// Initialize from storage or default
|
||||||
|
const loadFromStorage = (): T => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : defaultValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[createPersistentStore] Error loading ${key}:`, error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = $state<T>(loadFromStorage());
|
||||||
|
|
||||||
|
// Sync to storage whenever value changes
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[createPersistentStore] Error saving ${key}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(v: T) {
|
||||||
|
value = v;
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
value = defaultValue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,19 +4,37 @@
|
|||||||
* Used to render visible items with absolute positioning based on computed offsets.
|
* 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) */
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
createDebouncedState,
|
createDebouncedState,
|
||||||
createEntityStore,
|
createEntityStore,
|
||||||
createFilter,
|
createFilter,
|
||||||
|
createPersistentStore,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
createVirtualizer,
|
createVirtualizer,
|
||||||
type Entity,
|
type Entity,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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' })}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './model';
|
||||||
export { ComparisonSlider } from './ui';
|
export { ComparisonSlider } from './ui';
|
||||||
|
|||||||
1
src/widgets/ComparisonSlider/model/index.ts
Normal file
1
src/widgets/ComparisonSlider/model/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { comparisonStore } from './stores/comparisonStore.svelte';
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import {
|
||||||
|
type UnifiedFont,
|
||||||
|
fetchFontsByIds,
|
||||||
|
unifiedFontStore,
|
||||||
|
} from '$entities/Font';
|
||||||
|
import { createPersistentStore } from '$shared/lib';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage schema for comparison state
|
||||||
|
*/
|
||||||
|
interface ComparisonState {
|
||||||
|
fontAId: string | null;
|
||||||
|
fontBId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistent storage for selected comparison fonts
|
||||||
|
const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
|
||||||
|
fontAId: null,
|
||||||
|
fontBId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store for managing font comparison state
|
||||||
|
* - Persists selection to localStorage
|
||||||
|
* - Handles font fetching on initialization
|
||||||
|
* - Manages sample text
|
||||||
|
*/
|
||||||
|
class ComparisonStore {
|
||||||
|
#fontA = $state<UnifiedFont | undefined>();
|
||||||
|
#fontB = $state<UnifiedFont | undefined>();
|
||||||
|
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
||||||
|
#isRestoring = $state(true);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.restoreFromStorage();
|
||||||
|
|
||||||
|
// Reactively set defaults if we aren't restoring and have no selection
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
// Wait until we are done checking storage
|
||||||
|
if (this.#isRestoring) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we already have a selection, do nothing
|
||||||
|
if (this.#fontA && this.#fontB) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if fonts are available to set as defaults
|
||||||
|
const fonts = unifiedFontStore.fonts;
|
||||||
|
if (fonts.length >= 2) {
|
||||||
|
// Only set if we really have nothing (fallback)
|
||||||
|
if (!this.#fontA) this.#fontA = fonts[0];
|
||||||
|
if (!this.#fontB) this.#fontB = fonts[fonts.length - 1];
|
||||||
|
|
||||||
|
// Sync defaults to storage so they persist if the user leaves
|
||||||
|
this.updateStorage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore state from persistent storage
|
||||||
|
*/
|
||||||
|
async restoreFromStorage() {
|
||||||
|
this.#isRestoring = true;
|
||||||
|
const { fontAId, fontBId } = storage.value;
|
||||||
|
|
||||||
|
if (fontAId && fontBId) {
|
||||||
|
try {
|
||||||
|
// Batch fetch the saved fonts
|
||||||
|
const fonts = await fetchFontsByIds([fontAId, fontBId]);
|
||||||
|
const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId);
|
||||||
|
const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId);
|
||||||
|
|
||||||
|
if (loadedFontA && loadedFontB) {
|
||||||
|
this.#fontA = loadedFontA;
|
||||||
|
this.#fontB = loadedFontB;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ComparisonStore] Failed to restore fonts:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark restoration as complete (whether success or fail)
|
||||||
|
this.#isRestoring = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update storage with current state
|
||||||
|
*/
|
||||||
|
private updateStorage() {
|
||||||
|
// Don't save if we are currently restoring (avoid race)
|
||||||
|
if (this.#isRestoring) return;
|
||||||
|
|
||||||
|
storage.value = {
|
||||||
|
fontAId: this.#fontA?.id ?? null,
|
||||||
|
fontBId: this.#fontB?.id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Getters & Setters ---
|
||||||
|
|
||||||
|
get fontA() {
|
||||||
|
return this.#fontA;
|
||||||
|
}
|
||||||
|
|
||||||
|
set fontA(font: UnifiedFont | undefined) {
|
||||||
|
this.#fontA = font;
|
||||||
|
this.updateStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
get fontB() {
|
||||||
|
return this.#fontB;
|
||||||
|
}
|
||||||
|
|
||||||
|
set fontB(font: UnifiedFont | undefined) {
|
||||||
|
this.#fontB = font;
|
||||||
|
this.updateStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
get text() {
|
||||||
|
return this.#sampleText;
|
||||||
|
}
|
||||||
|
|
||||||
|
set text(value: string) {
|
||||||
|
this.#sampleText = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if both fonts are selected
|
||||||
|
*/
|
||||||
|
get isReady() {
|
||||||
|
return !!this.#fontA && !!this.#fontB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public initializer (optional, as constructor starts it)
|
||||||
|
* Kept for compatibility if manual re-init is needed
|
||||||
|
*/
|
||||||
|
initialize() {
|
||||||
|
if (!this.#isRestoring && !this.#fontA && !this.#fontB) {
|
||||||
|
this.restoreFromStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const comparisonStore = new ComparisonStore();
|
||||||
@@ -10,23 +10,21 @@
|
|||||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
- 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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
1
src/widgets/SampleList/index.ts
Normal file
1
src/widgets/SampleList/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { SampleList } from './ui';
|
||||||
72
src/widgets/SampleList/ui/SampleList/SampleList.svelte
Normal file
72
src/widgets/SampleList/ui/SampleList/SampleList.svelte
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<!--
|
||||||
|
Component: SampleList
|
||||||
|
Renders a list of fonts in a virtualized list to improve performance.
|
||||||
|
Includes pagination with auto-loading when scrolling near the bottom.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
FontListItem,
|
||||||
|
FontVirtualList,
|
||||||
|
unifiedFontStore,
|
||||||
|
} from '$entities/Font';
|
||||||
|
import { FontSampler } from '$features/DisplayFont';
|
||||||
|
import { controlManager } from '$features/SetupFont';
|
||||||
|
|
||||||
|
let text = $state('The quick brown fox jumps over the lazy dog...');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load more fonts by moving to the next page
|
||||||
|
*/
|
||||||
|
function loadMore() {
|
||||||
|
if (
|
||||||
|
!unifiedFontStore.pagination.hasMore
|
||||||
|
|| unifiedFontStore.isFetching
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unifiedFontStore.nextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle scroll near bottom - auto-load next page
|
||||||
|
*
|
||||||
|
* Triggered by VirtualList when the user scrolls within 5 items of the end
|
||||||
|
* of the loaded items. Only fetches if there are more pages available.
|
||||||
|
*/
|
||||||
|
function handleNearBottom(_lastVisibleIndex: number) {
|
||||||
|
const { hasMore } = unifiedFontStore.pagination;
|
||||||
|
|
||||||
|
// VirtualList already checks if we're near the bottom of loaded items
|
||||||
|
if (hasMore && !unifiedFontStore.isFetching) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate display range for pagination info
|
||||||
|
*/
|
||||||
|
const displayRange = $derived.by(() => {
|
||||||
|
const { offset, limit, total } = unifiedFontStore.pagination;
|
||||||
|
const loadedCount = Math.min(offset + limit, total);
|
||||||
|
return `Showing ${loadedCount} of ${total} fonts`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if unifiedFontStore.isFetching || unifiedFontStore.isLoading}
|
||||||
|
<span class="ml-2 text-xs text-muted-foreground/70">(Loading...)</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<FontVirtualList
|
||||||
|
items={unifiedFontStore.fonts}
|
||||||
|
total={unifiedFontStore.pagination.total}
|
||||||
|
onNearBottom={handleNearBottom}
|
||||||
|
itemHeight={280}
|
||||||
|
useWindowScroll={true}
|
||||||
|
weight={controlManager.weight}
|
||||||
|
>
|
||||||
|
{#snippet children({ item: font, isFullyVisible, isPartiallyVisible, proximity, index })}
|
||||||
|
<FontListItem {font} {isFullyVisible} {isPartiallyVisible} {proximity}>
|
||||||
|
<FontSampler {font} bind:text {index} />
|
||||||
|
</FontListItem>
|
||||||
|
{/snippet}
|
||||||
|
</FontVirtualList>
|
||||||
3
src/widgets/SampleList/ui/index.ts
Normal file
3
src/widgets/SampleList/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import SampleList from './SampleList/SampleList.svelte';
|
||||||
|
|
||||||
|
export { SampleList };
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
<!--
|
||||||
|
Component: TypographyMenu
|
||||||
|
Provides a menu for selecting and configuring typography settings
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<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}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user