diff --git a/.gitignore b/.gitignore index 688bf80..59fc97a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ vite.config.ts.timestamp-* /docs AGENTS.md +*.md +!README.md *storybook.log storybook-static diff --git a/dprint.json b/dprint.json index bc8bd21..776bdf3 100644 --- a/dprint.json +++ b/dprint.json @@ -16,7 +16,7 @@ "https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm" ], "typescript": { - "lineWidth": 100, + "lineWidth": 120, "indentWidth": 4, "useTabs": false, "semiColons": "prefer", @@ -41,7 +41,7 @@ "lineWidth": 100 }, "markup": { - "printWidth": 100, + "printWidth": 120, "indentWidth": 4, "useTabs": false, "quotes": "double", diff --git a/src/app/styles/app.css b/src/app/styles/app.css index f574f19..96ec340 100644 --- a/src/app/styles/app.css +++ b/src/app/styles/app.css @@ -117,6 +117,8 @@ } body { @apply bg-background text-foreground; + font-family: 'Karla', system-ui, sans-serif; + font-optical-sizing: auto; } } @@ -138,3 +140,25 @@ .peer:focus-visible ~ * { transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); } + +@keyframes nudge { + 0%, 100% { + transform: translateY(0) scale(1) rotate(0deg); + } + 2% { + transform: translateY(-2px) scale(1.1) rotate(-1deg); + } + 4% { + transform: translateY(0) scale(1) rotate(1deg); + } + 6% { + transform: translateY(-2px) scale(1.1) rotate(0deg); + } + 8% { + transform: translateY(0) scale(1) rotate(0deg); + } +} + +.animate-nudge { + animation: nudge 10s ease-in-out infinite; +} diff --git a/src/app/ui/Layout.svelte b/src/app/ui/Layout.svelte index 1764df0..0019373 100644 --- a/src/app/ui/Layout.svelte +++ b/src/app/ui/Layout.svelte @@ -3,47 +3,50 @@ * Layout Component * * Root layout wrapper that provides the application shell structure. Handles favicon, - * sidebar provider initialization, and renders child routes with consistent structure. + * toolbar provider initialization, and renders child routes with consistent structure. * * Layout structure: * - Header area (currently empty, reserved for future use) - * - Collapsible sidebar with main content area - * - Footer area (currently empty, reserved for future use) * - * Uses Sidebar.Provider to enable mobile-responsive collapsible sidebar behavior - * throughout the application. + * - Footer area (currently empty, reserved for future use) */ import favicon from '$shared/assets/favicon.svg'; -import * as Sidebar from '$shared/shadcn/ui/sidebar/index'; -import { FiltersSidebar } from '$widgets/FiltersSidebar'; -import TypographyMenu from '$widgets/TypographySettings/ui/TypographyMenu.svelte'; +import { ScrollArea } from '$shared/shadcn/ui/scroll-area'; +import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip'; +import { TypographyMenu } from '$widgets/TypographySettings'; +import type { Snippet } from 'svelte'; + +interface Props { + children: Snippet; +} /** Slot content for route pages to render */ -let { children } = $props(); +let { children }: Props = $props(); + + + + -
+
- - -
+ +
+ {@render children?.()} -
- + +
+
- - diff --git a/src/entities/Breadcrumb/index.ts b/src/entities/Breadcrumb/index.ts new file mode 100644 index 0000000..18a6254 --- /dev/null +++ b/src/entities/Breadcrumb/index.ts @@ -0,0 +1,2 @@ +export { scrollBreadcrumbsStore } from './model'; +export { BreadcrumbHeader } from './ui'; diff --git a/src/entities/Breadcrumb/model/index.ts b/src/entities/Breadcrumb/model/index.ts new file mode 100644 index 0000000..f177f5c --- /dev/null +++ b/src/entities/Breadcrumb/model/index.ts @@ -0,0 +1 @@ +export * from './store/scrollBreadcrumbsStore.svelte'; diff --git a/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts new file mode 100644 index 0000000..f914c5e --- /dev/null +++ b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts @@ -0,0 +1,29 @@ +import type { Snippet } from 'svelte'; + +export interface BreadcrumbItem { + index: number; + title: Snippet<[{ className?: string }]>; +} + +class ScrollBreadcrumbsStore { + #items = $state([]); + + 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(); diff --git a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte new file mode 100644 index 0000000..e044e85 --- /dev/null +++ b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte @@ -0,0 +1,78 @@ + + + +{#if scrollBreadcrumbsStore.items.length > 0} +
+
+
+ +
+ + nav_trace + +
+ +
+ + + +
+
+ + [{scrollBreadcrumbsStore.items.length}] + +
+
+
+{/if} + + diff --git a/src/entities/Breadcrumb/ui/index.ts b/src/entities/Breadcrumb/ui/index.ts new file mode 100644 index 0000000..76d13e1 --- /dev/null +++ b/src/entities/Breadcrumb/ui/index.ts @@ -0,0 +1,3 @@ +import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte'; + +export { BreadcrumbHeader }; diff --git a/src/entities/Font/api/index.ts b/src/entities/Font/api/index.ts index 50c12ef..9b3fb8d 100644 --- a/src/entities/Font/api/index.ts +++ b/src/entities/Font/api/index.ts @@ -4,6 +4,18 @@ * Exports API clients and normalization utilities */ +// Proxy API (PRIMARY - NEW) +export { + fetchFontsByIds, + fetchProxyFontById, + fetchProxyFonts, +} from './proxy/proxyFonts'; +export type { + ProxyFontsParams, + ProxyFontsResponse, +} from './proxy/proxyFonts'; + +// Google Fonts API (DEPRECATED - kept for backward compatibility) export { fetchGoogleFontFamily, fetchGoogleFonts, @@ -14,6 +26,7 @@ export type { GoogleFontsResponse, } from './google/googleFonts'; +// Fontshare API (DEPRECATED - kept for backward compatibility) export { fetchAllFontshareFonts, fetchFontshareFontBySlug, diff --git a/src/entities/Font/api/proxy/proxyFonts.ts b/src/entities/Font/api/proxy/proxyFonts.ts new file mode 100644 index 0000000..7852008 --- /dev/null +++ b/src/entities/Font/api/proxy/proxyFonts.ts @@ -0,0 +1,279 @@ +/** + * Proxy API client + * + * Handles API requests to GlyphDiff proxy API for fetching font metadata. + * Provides error handling, pagination support, and type-safe responses. + * + * Proxy API normalizes font data from Google Fonts and Fontshare into a single + * unified format, eliminating the need for client-side normalization. + * + * Fallback: If proxy API fails, falls back to Fontshare API for development. + * + * @see https://api.glyphdiff.com/api/v1/fonts + */ + +import { api } from '$shared/api/api'; +import { buildQueryString } from '$shared/lib/utils'; +import type { QueryParams } from '$shared/lib/utils'; +import type { UnifiedFont } from '../../model/types'; +import type { + FontCategory, + FontSubset, +} from '../../model/types'; + +/** + * Proxy API base URL + */ +const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const; + +/** + * Whether to use proxy API (true) or fallback (false) + * + * Set to true when your proxy API is ready: + * const USE_PROXY_API = true; + * + * Set to false to use Fontshare API as fallback during development: + * const USE_PROXY_API = false; + * + * The app will automatically fall back to Fontshare API if the proxy fails. + */ +const USE_PROXY_API = true; + +/** + * Proxy API parameters + * + * Maps directly to the proxy API query parameters + */ +export interface ProxyFontsParams extends QueryParams { + /** + * Font provider filter ("google" or "fontshare") + * Omit to fetch from both providers + */ + provider?: 'google' | 'fontshare'; + + /** + * Font category filter + */ + category?: FontCategory; + + /** + * Character subset filter + */ + subset?: FontSubset; + + /** + * Search query (e.g., "roboto", "satoshi") + */ + q?: string; + + /** + * Sort order for results + * "name" - Alphabetical by font name + * "popularity" - Most popular first + * "lastModified" - Recently updated first + */ + sort?: 'name' | 'popularity' | 'lastModified'; + + /** + * Number of items to return (pagination) + */ + limit?: number; + + /** + * Number of items to skip (pagination) + * Use for pagination: offset = (page - 1) * limit + */ + offset?: number; +} + +/** + * Proxy API response + * + * Includes pagination metadata alongside font data + */ +export interface ProxyFontsResponse { + /** Array of unified font objects */ + fonts: UnifiedFont[]; + + /** Total number of fonts matching the query */ + total: number; + + /** Limit used for this request */ + limit: number; + + /** Offset used for this request */ + offset: number; +} + +/** + * Fetch fonts from proxy API + * + * If proxy API fails or is unavailable, falls back to Fontshare API for development. + * + * @param params - Query parameters for filtering and pagination + * @returns Promise resolving to proxy API response + * @throws ApiError when request fails + * + * @example + * ```ts + * // Fetch all sans-serif fonts from Google + * const response = await fetchProxyFonts({ + * provider: 'google', + * category: 'sans-serif', + * limit: 50, + * offset: 0 + * }); + * + * // Search fonts across all providers + * const searchResponse = await fetchProxyFonts({ + * q: 'roboto', + * limit: 20 + * }); + * + * // Fetch fonts with pagination + * const page1 = await fetchProxyFonts({ limit: 50, offset: 0 }); + * const page2 = await fetchProxyFonts({ limit: 50, offset: 50 }); + * ``` + */ +export async function fetchProxyFonts( + params: ProxyFontsParams = {}, +): Promise { + // Try proxy API first if enabled + if (USE_PROXY_API) { + try { + const queryString = buildQueryString(params); + const url = `${PROXY_API_URL}${queryString}`; + + console.log('[fetchProxyFonts] Fetching from proxy API', { params, url }); + + const response = await api.get(url); + + // Validate response has fonts array + if (!response.data || !Array.isArray(response.data.fonts)) { + console.error('[fetchProxyFonts] Invalid response from proxy API', response.data); + throw new Error('Proxy API returned invalid response'); + } + + console.log('[fetchProxyFonts] Proxy API success', { + count: response.data.fonts.length, + }); + return response.data; + } catch (error) { + console.warn('[fetchProxyFonts] Proxy API failed, using fallback', error); + + // Check if it's a network error or proxy not available + const isNetworkError = error instanceof Error + && (error.message.includes('Failed to fetch') + || error.message.includes('Network') + || error.message.includes('404') + || error.message.includes('500')); + + if (isNetworkError) { + // Fall back to Fontshare API + console.log('[fetchProxyFonts] Using Fontshare API as fallback'); + return await fetchFontshareFallback(params); + } + + // Re-throw other errors + if (error instanceof Error) { + throw error; + } + throw new Error(`Failed to fetch fonts from proxy API: ${String(error)}`); + } + } + + // Use Fontshare API directly + console.log('[fetchProxyFonts] Using Fontshare API (proxy disabled)'); + return await fetchFontshareFallback(params); +} + +/** + * Fallback to Fontshare API when proxy is unavailable + * + * Maps proxy API params to Fontshare API params and normalizes response + */ +async function fetchFontshareFallback( + params: ProxyFontsParams, +): Promise { + // Import dynamically to avoid circular dependency + const { fetchFontshareFonts } = await import('$entities/Font/api/fontshare/fontshare'); + const { normalizeFontshareFonts } = await import('$entities/Font/lib/normalize/normalize'); + + // Map proxy params to Fontshare params + const fontshareParams = { + q: params.q, + categories: params.category ? [params.category] : undefined, + page: params.offset ? Math.floor(params.offset / (params.limit || 50)) + 1 : undefined, + limit: params.limit, + }; + + const response = await fetchFontshareFonts(fontshareParams); + const normalizedFonts = normalizeFontshareFonts(response.fonts); + + return { + fonts: normalizedFonts, + total: response.count_total, + limit: params.limit || response.count, + offset: params.offset || 0, + }; +} + +/** + * Fetch font by ID + * + * Convenience function for fetching a single font by ID + * Note: This fetches a page and filters client-side, which is not ideal + * For production, consider adding a dedicated endpoint to the proxy API + * + * @param id - Font ID (family name for Google, slug for Fontshare) + * @returns Promise resolving to font or undefined + * + * @example + * ```ts + * const roboto = await fetchProxyFontById('Roboto'); + * const satoshi = await fetchProxyFontById('satoshi'); + * ``` + */ +export async function fetchProxyFontById( + id: string, +): Promise { + const response = await fetchProxyFonts({ limit: 1000, q: id }); + + if (!response || !response.fonts) { + console.error('[fetchProxyFontById] No fonts in response', { response }); + return undefined; + } + + 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 { + 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(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); +} diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index 2c92962..f9ed924 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -1,3 +1,15 @@ +// Proxy API (PRIMARY) +export { + fetchFontsByIds, + fetchProxyFontById, + fetchProxyFonts, +} from './api/proxy/proxyFonts'; +export type { + ProxyFontsParams, + ProxyFontsResponse, +} from './api/proxy/proxyFonts'; + +// Fontshare API (DEPRECATED) export { fetchAllFontshareFonts, fetchFontshareFontBySlug, @@ -7,6 +19,8 @@ export type { FontshareParams, FontshareResponse, } from './api/fontshare/fontshare'; + +// Google Fonts API (DEPRECATED) export { fetchGoogleFontFamily, fetchGoogleFonts, @@ -42,7 +56,6 @@ export type { FontshareFont, FontshareLink, FontsharePublisher, - FontshareStore, FontshareStyle, FontshareStyleProperties, FontshareTag, @@ -61,18 +74,11 @@ export type { export { appliedFontsManager, - createFontshareStore, - fetchFontshareFontsQuery, - fontshareStore, + createUnifiedFontStore, selectedFontsStore, + unifiedFontStore, } from './model'; -// Stores -export { - createGoogleFontsStore, - GoogleFontsStore, -} from './model/services/fetchGoogleFonts.svelte'; - // UI elements export { FontApplicator, diff --git a/src/entities/Font/lib/normalize/normalize.test.ts b/src/entities/Font/lib/normalize/normalize.test.ts index e45eefa..c3166e7 100644 --- a/src/entities/Font/lib/normalize/normalize.test.ts +++ b/src/entities/Font/lib/normalize/normalize.test.ts @@ -24,11 +24,9 @@ describe('Font Normalization', () => { subsets: ['latin', 'latin-ext'], files: { regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', - '700': - 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2', + '700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2', italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2', - '700italic': - 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2', + '700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2', }, version: 'v30', lastModified: '2022-01-01', diff --git a/src/entities/Font/model/index.ts b/src/entities/Font/model/index.ts index b1ce8a1..7fa38b2 100644 --- a/src/entities/Font/model/index.ts +++ b/src/entities/Font/model/index.ts @@ -34,12 +34,10 @@ export type { UnifiedFontVariant, } from './types'; -export { fetchFontshareFontsQuery } from './services'; - export { appliedFontsManager, - createFontshareStore, - type FontshareStore, - fontshareStore, + createUnifiedFontStore, selectedFontsStore, + type UnifiedFontStore, + unifiedFontStore, } from './store'; diff --git a/src/entities/Font/model/services/fetchFontshareFonts.svelte.ts b/src/entities/Font/model/services/fetchFontshareFonts.svelte.ts deleted file mode 100644 index 77cd544..0000000 --- a/src/entities/Font/model/services/fetchFontshareFonts.svelte.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - type FontshareParams, - fetchFontshareFonts, -} from '../../api'; -import { normalizeFontshareFonts } from '../../lib'; -import type { UnifiedFont } from '../types'; - -/** - * Query function for fetching fonts from Fontshare. - * - * @param params - The parameters for fetching fonts from Fontshare (E.g. search query, page number, etc.). - * @returns A promise that resolves with an array of UnifiedFont objects representing the fonts found in Fontshare. - */ -export async function fetchFontshareFontsQuery(params: FontshareParams): Promise { - try { - const response = await fetchFontshareFonts(params); - return normalizeFontshareFonts(response.fonts); - } catch (error) { - if (error instanceof Error) { - if (error.message.includes('Failed to fetch')) { - throw new Error( - 'Unable to connect to Fontshare. Please check your internet connection.', - ); - } - if (error.message.includes('404')) { - throw new Error('Font not found in Fontshare catalog.'); - } - } - throw new Error('Failed to load fonts from Fontshare.'); - } -} diff --git a/src/entities/Font/model/services/fetchGoogleFonts.svelte.ts b/src/entities/Font/model/services/fetchGoogleFonts.svelte.ts deleted file mode 100644 index db9818a..0000000 --- a/src/entities/Font/model/services/fetchGoogleFonts.svelte.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Service for fetching Google Fonts with Svelte 5 runes + TanStack Query - */ -import { - type CreateQueryResult, - createQuery, - useQueryClient, -} from '@tanstack/svelte-query'; -import { - type GoogleFontsParams, - fetchGoogleFonts, -} from '../../api'; -import { normalizeGoogleFonts } from '../../lib'; -import type { - FontCategory, - FontSubset, -} from '../types'; -import type { UnifiedFont } from '../types/normalize'; - -/** - * Query key factory - */ -function getGoogleFontsQueryKey(params: GoogleFontsParams) { - return ['googleFonts', params] as const; -} - -/** - * Query function - */ -export async function fetchGoogleFontsQuery(params: GoogleFontsParams): Promise { - try { - const response = await fetchGoogleFonts({ - category: params.category, - subset: params.subset, - sort: params.sort, - }); - return normalizeGoogleFonts(response.items); - } catch (error) { - if (error instanceof Error) { - if (error.message.includes('Failed to fetch')) { - throw new Error( - 'Unable to connect to Google Fonts. Please check your internet connection.', - ); - } - if (error.message.includes('404')) { - throw new Error('Font not found in Google Fonts catalog.'); - } - } - throw new Error('Failed to load fonts from Google Fonts.'); - } -} - -/** - * Google Fonts store wrapping TanStack Query with runes - */ -export class GoogleFontsStore { - params = $state({}); - private query: CreateQueryResult; - private queryClient = useQueryClient(); - - constructor(initialParams: GoogleFontsParams = {}) { - this.params = initialParams; - - // Create the query - automatically reactive - this.query = createQuery(() => ({ - queryKey: getGoogleFontsQueryKey(this.params), - queryFn: () => fetchGoogleFontsQuery(this.params), - staleTime: 5 * 60 * 1000, // 5 minutes - gcTime: 10 * 60 * 1000, // 10 minutes - })); - } - - // Proxy TanStack Query's reactive state - get fonts() { - return this.query.data ?? []; - } - - get isLoading() { - return this.query.isLoading; - } - - get isFetching() { - return this.query.isFetching; - } - - get isRefetching() { - return this.query.isRefetching; - } - - get error() { - return this.query.error; - } - - get isError() { - return this.query.isError; - } - - get isSuccess() { - return this.query.isSuccess; - } - - get status() { - return this.query.status; - } - - // Derived helpers - get hasData() { - return this.fonts.length > 0; - } - - get isEmpty() { - return !this.isLoading && this.fonts.length === 0; - } - - get fontCount() { - return this.fonts.length; - } - - // Filtered fonts by category (if you need additional client-side filtering) - get sansSerifFonts() { - return this.fonts.filter(f => f.category === 'sans-serif'); - } - - get serifFonts() { - return this.fonts.filter(f => f.category === 'serif'); - } - - get displayFonts() { - return this.fonts.filter(f => f.category === 'display'); - } - - get handwritingFonts() { - return this.fonts.filter(f => f.category === 'handwriting'); - } - - get monospaceFonts() { - return this.fonts.filter(f => f.category === 'monospace'); - } - - /** - * Update parameters - TanStack Query will automatically refetch - */ - setParams(newParams: Partial) { - this.params = { ...this.params, ...newParams }; - } - - setCategory(category: FontCategory | undefined) { - this.setParams({ category }); - } - - setSubset(subset: FontSubset | undefined) { - this.setParams({ subset }); - } - - setSort(sort: 'popularity' | 'alpha' | 'date' | undefined) { - this.setParams({ sort }); - } - - setSearch(search: string) { - this.setParams({ search }); - } - - clearSearch() { - this.setParams({ search: undefined }); - } - - clearFilters() { - this.params = {}; - } - - /** - * Manually refetch - */ - async refetch() { - await this.query.refetch(); - } - - /** - * Invalidate cache and refetch - */ - invalidate() { - this.queryClient.invalidateQueries({ - queryKey: getGoogleFontsQueryKey(this.params), - }); - } - - /** - * Invalidate all Google Fonts queries - */ - invalidateAll() { - this.queryClient.invalidateQueries({ - queryKey: ['googleFonts'], - }); - } - - /** - * Prefetch with different params (for hover states, pagination, etc.) - */ - async prefetch(params: GoogleFontsParams) { - await this.queryClient.prefetchQuery({ - queryKey: getGoogleFontsQueryKey(params), - queryFn: () => fetchGoogleFontsQuery(params), - staleTime: 5 * 60 * 1000, - }); - } - - /** - * Prefetch next category (useful for tab switching) - */ - async prefetchCategory(category: FontCategory) { - await this.prefetch({ ...this.params, category }); - } - - /** - * Cancel ongoing queries - */ - cancel() { - this.queryClient.cancelQueries({ - queryKey: getGoogleFontsQueryKey(this.params), - }); - } - - /** - * Clear cache for current params - */ - clearCache() { - this.queryClient.removeQueries({ - queryKey: getGoogleFontsQueryKey(this.params), - }); - } - - /** - * Get cached data without triggering fetch - */ - getCachedData() { - return this.queryClient.getQueryData( - getGoogleFontsQueryKey(this.params), - ); - } - - /** - * Check if data exists in cache - */ - hasCache(params?: GoogleFontsParams) { - const key = params ? getGoogleFontsQueryKey(params) : getGoogleFontsQueryKey(this.params); - return this.queryClient.getQueryData(key) !== undefined; - } - - /** - * Set data manually (optimistic updates) - */ - setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) { - this.queryClient.setQueryData( - getGoogleFontsQueryKey(this.params), - updater, - ); - } - - /** - * Get query state for debugging - */ - getQueryState() { - return this.queryClient.getQueryState( - getGoogleFontsQueryKey(this.params), - ); - } -} - -/** - * Factory function to create Google Fonts store - */ -export function createGoogleFontsStore(params: GoogleFontsParams = {}) { - return new GoogleFontsStore(params); -} diff --git a/src/entities/Font/model/services/index.ts b/src/entities/Font/model/services/index.ts deleted file mode 100644 index 78e45ce..0000000 --- a/src/entities/Font/model/services/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { fetchFontshareFontsQuery } from './fetchFontshareFonts.svelte'; -export { fetchGoogleFontsQuery } from './fetchGoogleFonts.svelte'; diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 3394b6f..4517c91 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -2,147 +2,167 @@ import { SvelteMap } from 'svelte/reactivity'; export type FontStatus = 'loading' | 'loaded' | 'error'; +export interface FontConfigRequest { + /** + * Font id + */ + id: string; + /** + * Real font name (e.g. "Lato") + */ + name: string; + /** + * The .ttf URL + */ + url: string; + /** + * Font weight + */ + weight: number; + /** + * Flag of the variable weight + */ + isVariable?: boolean; +} + /** - * Manager that handles loading of the fonts - * Adds tags to - * - Uses batch loading to reduce the number of requests - * - Uses a queue to prevent too many requests at once - * - Purges unused fonts after a certain time + * Manager that handles loading of fonts. + * Logic: + * - Variable fonts: Loaded once per id (covers all weights). + * - Static fonts: Loaded per id + weight combination. */ class AppliedFontsManager { - // Stores: slug -> timestamp of last visibility #usageTracker = new Map(); - // Stores: slug -> batchId - #slugToBatch = new Map(); - // Stores: batchId -> HTMLLinkElement (for physical cleanup) - #batchElements = new Map(); + #idToBatch = new Map(); + // Changed to HTMLStyleElement + #batchElements = new Map(); - #queue = new Set(); + #queue = new Map(); // Track config in queue #timeoutId: ReturnType | null = null; - #PURGE_INTERVAL = 60000; // Check every minute - #TTL = 5 * 60 * 1000; // 5 minutes - #CHUNK_SIZE = 3; + + #PURGE_INTERVAL = 60000; + #TTL = 5 * 60 * 1000; + #CHUNK_SIZE = 5; // Can be larger since we're just injecting strings statuses = new SvelteMap(); constructor() { if (typeof window !== 'undefined') { - // Start the "Janitor" loop setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL); } } - /** - * Updates the 'last seen' timestamp for fonts. - * Prevents them from being purged while they are on screen. - */ - touch(slugs: string[]) { - const now = Date.now(); - const toRegister: string[] = []; + #getFontKey(id: string, weight: number): string { + return `${id.toLowerCase()}@${weight}`; + } - slugs.forEach(slug => { - this.#usageTracker.set(slug, now); - if (!this.#slugToBatch.has(slug)) { - toRegister.push(slug); + touch(configs: FontConfigRequest[]) { + const now = Date.now(); + configs.forEach(config => { + const key = this.#getFontKey(config.id, config.weight); + this.#usageTracker.set(key, now); + + if (!this.#idToBatch.has(key) && !this.#queue.has(key)) { + this.#queue.set(key, config); + + if (this.#timeoutId) clearTimeout(this.#timeoutId); + this.#timeoutId = setTimeout(() => this.#processQueue(), 50); } }); - - if (toRegister.length > 0) this.registerFonts(toRegister); } - registerFonts(slugs: string[]) { - const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s)); - if (newSlugs.length === 0) return; - - newSlugs.forEach(s => this.#queue.add(s)); - - if (this.#timeoutId) clearTimeout(this.#timeoutId); - this.#timeoutId = setTimeout(() => this.#processQueue(), 50); - } - - getFontStatus(slug: string) { - return this.statuses.get(slug); + getFontStatus(id: string, weight: number) { + return this.statuses.get(this.#getFontKey(id, weight)); } #processQueue() { - const fullQueue = Array.from(this.#queue); - if (fullQueue.length === 0) return; + const entries = Array.from(this.#queue.entries()); + if (entries.length === 0) return; - for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) { - this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE)); + for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) { + this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE)); } this.#queue.clear(); this.#timeoutId = null; } - #createBatch(slugs: string[]) { + #createBatch(batchEntries: [string, FontConfigRequest][]) { if (typeof document === 'undefined') return; const batchId = crypto.randomUUID(); - // font-display=swap included for better UX - const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&'); - const url = `https://api.fontshare.com/v2/css?${query}&display=swap`; + let cssRules = ''; - // Mark all as loading immediately - slugs.forEach(slug => this.statuses.set(slug, 'loading')); + batchEntries.forEach(([key, config]) => { + this.statuses.set(key, 'loading'); + this.#idToBatch.set(key, batchId); - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = url; - link.dataset.batchId = batchId; - document.head.appendChild(link); + // Construct the @font-face rule + // Using format('truetype') for .ttf + cssRules += ` + @font-face { + font-family: '${config.name}'; + src: url('${config.url}') format('truetype'); + font-weight: ${config.weight}; + font-style: normal; + font-display: swap; + } + `; + }); - this.#batchElements.set(batchId, link); - slugs.forEach(slug => { - this.#slugToBatch.set(slug, batchId); + // 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); - // Use the Native Font Loading API - // format: "font-size font-family" - document.fonts.load(`1em "${slug}"`) - .then(loadedFonts => { - if (loadedFonts.length > 0) { - this.statuses.set(slug, 'loaded'); - } else { - this.statuses.set(slug, 'error'); - } + // 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(slug, 'error'); - }); + .catch(() => this.statuses.set(key, 'error')); }); } #purgeUnused() { const now = Date.now(); - const batchesToPotentialDelete = new Set(); - const slugsToDelete: string[] = []; + const batchesToRemove = new Set(); + const keysToRemove: string[] = []; - // Identify expired slugs - for (const [slug, lastUsed] of this.#usageTracker.entries()) { + for (const [key, lastUsed] of this.#usageTracker.entries()) { if (now - lastUsed > this.#TTL) { - const batchId = this.#slugToBatch.get(slug); - if (batchId) batchesToPotentialDelete.add(batchId); - slugsToDelete.push(slug); + const batchId = this.#idToBatch.get(key); + if (batchId) { + // Check if EVERY font in this batch is expired + const batchKeys = Array.from(this.#idToBatch.entries()) + .filter(([_, bId]) => bId === batchId) + .map(([k]) => k); + + const canDeleteBatch = batchKeys.every(k => { + const lastK = this.#usageTracker.get(k); + return lastK && (now - lastK > this.#TTL); + }); + + if (canDeleteBatch) { + batchesToRemove.add(batchId); + keysToRemove.push(...batchKeys); + } + } } } - // Only remove a batch if ALL fonts in that batch are expired - batchesToPotentialDelete.forEach(batchId => { - const batchSlugs = Array.from(this.#slugToBatch.entries()) - .filter(([_, bId]) => bId === batchId) - .map(([slug]) => slug); + batchesToRemove.forEach(id => { + this.#batchElements.get(id)?.remove(); + this.#batchElements.delete(id); + }); - const allExpired = batchSlugs.every(s => slugsToDelete.includes(s)); - - if (allExpired) { - this.#batchElements.get(batchId)?.remove(); - this.#batchElements.delete(batchId); - batchSlugs.forEach(s => { - this.#slugToBatch.delete(s); - this.#usageTracker.delete(s); - }); - } + keysToRemove.forEach(k => { + this.#idToBatch.delete(k); + this.#usageTracker.delete(k); + this.statuses.delete(k); }); } } diff --git a/src/entities/Font/model/store/baseFontStore.svelte.ts b/src/entities/Font/model/store/baseFontStore.svelte.ts index d9ffd12..21d62cb 100644 --- a/src/entities/Font/model/store/baseFontStore.svelte.ts +++ b/src/entities/Font/model/store/baseFontStore.svelte.ts @@ -59,6 +59,7 @@ export abstract class BaseFontStore> { queryKey: this.getQueryKey(params), queryFn: () => this.fetchFn(params), staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, }; } diff --git a/src/entities/Font/model/store/fontshareStore.svelte.ts b/src/entities/Font/model/store/fontshareStore.svelte.ts deleted file mode 100644 index cec798d..0000000 --- a/src/entities/Font/model/store/fontshareStore.svelte.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { FontshareParams } from '../../api'; -import { fetchFontshareFontsQuery } from '../services'; -import type { UnifiedFont } from '../types'; -import { BaseFontStore } from './baseFontStore.svelte'; - -/** - * Fontshare store wrapping TanStack Query with runes - */ -export class FontshareStore extends BaseFontStore { - constructor(initialParams: FontshareParams = {}) { - super(initialParams); - } - - protected getQueryKey(params: FontshareParams) { - return ['fontshare', params] as const; - } - - protected async fetchFn(params: FontshareParams): Promise { - return fetchFontshareFontsQuery(params); - } - - // Provider-specific methods (shortcuts) - setSearch(search: string) { - this.setParams({ q: search } as any); - } -} - -export function createFontshareStore(params: FontshareParams = {}) { - return new FontshareStore(params); -} - -export const fontshareStore = new FontshareStore(); diff --git a/src/entities/Font/model/store/googleFontsStore.svelte.ts b/src/entities/Font/model/store/googleFontsStore.svelte.ts deleted file mode 100644 index 428c476..0000000 --- a/src/entities/Font/model/store/googleFontsStore.svelte.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { GoogleFontsParams } from '../../api'; -import { fetchGoogleFontsQuery } from '../services'; -import type { UnifiedFont } from '../types'; -import { BaseFontStore } from './baseFontStore.svelte'; - -/** - * Google Fonts store wrapping TanStack Query with runes - */ -export class GoogleFontsStore extends BaseFontStore { - constructor(initialParams: GoogleFontsParams = {}) { - super(initialParams); - } - - protected getQueryKey(params: GoogleFontsParams) { - return ['googleFonts', params] as const; - } - - protected async fetchFn(params: GoogleFontsParams): Promise { - return fetchGoogleFontsQuery(params); - } -} - -export function createFontshareStore(params: GoogleFontsParams = {}) { - return new GoogleFontsStore(params); -} - -export const googleFontsStore = new GoogleFontsStore(); diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index f019a3c..cd890ba 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -6,18 +6,15 @@ * Single export point for the unified font store infrastructure. */ -// export { -// createUnifiedFontStore, -// UNIFIED_FONT_STORE_KEY, -// type UnifiedFontStore, -// } from './unifiedFontStore.svelte'; - +// Primary store (unified) export { - createFontshareStore, - type FontshareStore, - fontshareStore, -} from './fontshareStore.svelte'; + createUnifiedFontStore, + type UnifiedFontStore, + unifiedFontStore, +} from './unifiedFontStore.svelte'; +// Applied fonts manager (CSS loading - unchanged) export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; +// Selected fonts store (user selection - unchanged) export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte'; diff --git a/src/entities/Font/model/store/unifiedFontStore.svelte.ts b/src/entities/Font/model/store/unifiedFontStore.svelte.ts index 460e521..b229709 100644 --- a/src/entities/Font/model/store/unifiedFontStore.svelte.ts +++ b/src/entities/Font/model/store/unifiedFontStore.svelte.ts @@ -1,25 +1,354 @@ -import { type Filter } from '$shared/lib'; -import { SvelteMap } from 'svelte/reactivity'; -import type { FontProvider } from '../types'; -import type { CheckboxFilter } from '../types/common'; -import type { BaseFontStore } from './baseFontStore.svelte'; -import { createFontshareStore } from './fontshareStore.svelte'; -import type { ProviderParams } from './types'; +/** + * Unified font store + * + * Single source of truth for font data, powered by the proxy API. + * Extends BaseFontStore for TanStack Query integration and reactivity. + * + * Key features: + * - Provider-agnostic (proxy API handles provider logic) + * - Reactive to filter changes + * - Optimistic updates via TanStack Query + * - Pagination support + * - Provider-specific shortcuts for common operations + */ -export class UnitedFontStore { - private sources: Partial>>; +import type { ProxyFontsParams } from '../../api'; +import { fetchProxyFonts } from '../../api'; +import type { UnifiedFont } from '../types'; +import { BaseFontStore } from './baseFontStore.svelte'; - filters: SvelteMap; - queryValue = $state(''); +/** + * Unified font store wrapping TanStack Query with Svelte 5 runes + * + * Extends BaseFontStore to provide: + * - Reactive state management + * - TanStack Query integration for caching + * - Dynamic parameter binding for filters + * - Pagination support + * + * @example + * ```ts + * const store = new UnifiedFontStore({ + * provider: 'google', + * category: 'sans-serif', + * limit: 50 + * }); + * + * // Access reactive state + * $effect(() => { + * console.log(store.fonts); + * console.log(store.isLoading); + * console.log(store.pagination); + * }); + * + * // Update parameters + * store.setCategory('serif'); + * store.nextPage(); + * ``` + */ +export class UnifiedFontStore extends BaseFontStore { + /** + * Store pagination metadata separately from fonts + * This is a workaround for TanStack Query's type system + */ + #paginationMetadata = $state< + { + total: number; + limit: number; + offset: number; + } | null + >(null); - constructor(initialConfig: Partial> = {}) { - this.sources = { - fontshare: createFontshareStore(initialConfig?.fontshare), + /** + * Accumulated fonts from all pages (for infinite scroll) + */ + #accumulatedFonts = $state([]); + + /** + * Pagination metadata (derived from proxy API response) + */ + readonly pagination = $derived.by(() => { + if (this.#paginationMetadata) { + const { total, limit, offset } = this.#paginationMetadata; + return { + total, + limit, + offset, + hasMore: offset + limit < total, + page: Math.floor(offset / limit) + 1, + totalPages: Math.ceil(total / limit), + }; + } + return { + total: 0, + limit: this.params.limit || 50, + offset: this.params.offset || 0, + hasMore: false, + page: 1, + totalPages: 0, }; - this.filters = new SvelteMap(); + }); + + /** + * Track previous filter params to detect changes and reset pagination + */ + #previousFilterParams = $state(''); + + /** + * Cleanup function for the filter tracking effect + */ + #filterCleanup: (() => void) | null = null; + + constructor(initialParams: ProxyFontsParams = {}) { + super(initialParams); + + // Track filter params (excluding pagination params) + // Wrapped in $effect.root() to prevent effect_orphan error + this.#filterCleanup = $effect.root(() => { + $effect(() => { + const filterParams = JSON.stringify({ + provider: this.params.provider, + category: this.params.category, + subset: this.params.subset, + q: this.params.q, + }); + + // If filters changed, reset offset to 0 + if (filterParams !== this.#previousFilterParams) { + if (this.#previousFilterParams && this.params.offset !== 0) { + this.setParams({ offset: 0 }); + } + this.#previousFilterParams = filterParams; + } + }); + }); } - get fonts() { - return Object.values(this.sources).map(store => store.fonts).flat(); + /** + * 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; + } + } + + /** + * Query key for TanStack Query caching + * Normalizes params to treat empty arrays/strings as undefined + */ + protected getQueryKey(params: ProxyFontsParams) { + // Normalize params to treat empty arrays/strings as undefined + const normalized = Object.entries(params).reduce((acc, [key, value]) => { + if (value === '' || (Array.isArray(value) && value.length === 0)) { + return acc; + } + return { ...acc, [key]: value }; + }, {}); + + return ['unifiedFonts', normalized] as const; + } + + /** + * Fetch function that calls the proxy API + * Returns the full response including pagination metadata + */ + protected async fetchFn(params: ProxyFontsParams): Promise { + const response = await fetchProxyFonts(params); + + // Validate response structure + if (!response) { + console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params }); + throw new Error('Proxy API returned undefined response'); + } + + if (!response.fonts) { + console.error('[UnifiedFontStore] response.fonts is undefined', { response }); + throw new Error('Proxy API response missing fonts array'); + } + + if (!Array.isArray(response.fonts)) { + console.error('[UnifiedFontStore] response.fonts is not an array', { + fonts: response.fonts, + }); + throw new Error('Proxy API fonts is not an array'); + } + + // Store pagination metadata separately for derived values + this.#paginationMetadata = { + total: response.total ?? 0, + limit: response.limit ?? this.params.limit ?? 50, + offset: response.offset ?? this.params.offset ?? 0, + }; + + // Accumulate fonts for infinite scroll + if (params.offset === 0) { + // Reset when starting from beginning (new search/filter) + this.#accumulatedFonts = response.fonts; + } else { + // Append new fonts to existing ones + this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts]; + } + + return response.fonts; + } + + // --- Getters (proxied from BaseFontStore) --- + + /** + * Get all accumulated fonts (for infinite scroll) + */ + get fonts(): UnifiedFont[] { + return this.#accumulatedFonts; + } + + /** + * Check if loading initial data + */ + get isLoading(): boolean { + return this.result.isLoading; + } + + /** + * Check if fetching (including background refetches) + */ + get isFetching(): boolean { + return this.result.isFetching; + } + + /** + * Check if error occurred + */ + get isError(): boolean { + return this.result.isError; + } + + /** + * Check if result is empty (not loading and no fonts) + */ + get isEmpty(): boolean { + return !this.isLoading && this.fonts.length === 0; + } + + // --- Provider-specific shortcuts --- + + /** + * Set provider filter + */ + setProvider(provider: 'google' | 'fontshare' | undefined) { + this.setParams({ provider }); + } + + /** + * Set category filter + */ + setCategory(category: ProxyFontsParams['category']) { + this.setParams({ category }); + } + + /** + * Set subset filter + */ + setSubset(subset: ProxyFontsParams['subset']) { + this.setParams({ subset }); + } + + /** + * Set search query + */ + setSearch(search: string) { + this.setParams({ q: search || undefined }); + } + + /** + * Set sort order + */ + setSort(sort: ProxyFontsParams['sort']) { + this.setParams({ sort }); + } + + // --- Pagination methods --- + + /** + * Go to next page + */ + nextPage() { + if (this.pagination.hasMore) { + this.setParams({ + offset: this.pagination.offset + this.pagination.limit, + }); + } + } + + /** + * Go to previous page + */ + prevPage() { + if (this.pagination.page > 1) { + this.setParams({ + offset: this.pagination.offset - this.pagination.limit, + }); + } + } + + /** + * Go to specific page + */ + goToPage(page: number) { + if (page >= 1 && page <= this.pagination.totalPages) { + this.setParams({ + offset: (page - 1) * this.pagination.limit, + }); + } + } + + /** + * Set limit (items per page) + */ + setLimit(limit: number) { + this.setParams({ limit }); + } + + // --- Category shortcuts (for convenience) --- + + get sansSerifFonts() { + return this.fonts.filter(f => f.category === 'sans-serif'); + } + + get serifFonts() { + return this.fonts.filter(f => f.category === 'serif'); + } + + get displayFonts() { + return this.fonts.filter(f => f.category === 'display'); + } + + get handwritingFonts() { + return this.fonts.filter(f => f.category === 'handwriting'); + } + + get monospaceFonts() { + return this.fonts.filter(f => f.category === 'monospace'); } } + +/** + * Factory function to create unified font store + */ +export function createUnifiedFontStore(params: ProxyFontsParams = {}) { + return new UnifiedFontStore(params); +} + +/** + * Singleton instance for global use + * Initialized with a default limit to prevent fetching all fonts at once + */ +export const unifiedFontStore = new UnifiedFontStore({ + limit: 50, + offset: 0, +}); diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte index 15dd0d9..fbde458 100644 --- a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte +++ b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte @@ -6,9 +6,9 @@ - Adds smooth transition when font appears --> -
- +
+ {@render children?.(font)}
diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte index 65102db..b26a909 100644 --- a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte +++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte @@ -3,24 +3,40 @@ - Renders a virtualized list of fonts - Handles font registration with the manager --> - @@ -28,6 +44,7 @@ function handleInternalVisibleChange(visibleItems: T[]) { {items} {...rest} onVisibleItemsChange={handleInternalVisibleChange} + onNearBottom={handleNearBottom} > {#snippet children(scope)} {@render children(scope)} diff --git a/src/features/DisplayFont/index.ts b/src/features/DisplayFont/index.ts index 4fb9052..a15fd38 100644 --- a/src/features/DisplayFont/index.ts +++ b/src/features/DisplayFont/index.ts @@ -1 +1 @@ -export { FontDisplay } from './ui'; +export { FontSampler } from './ui'; diff --git a/src/features/DisplayFont/model/index.ts b/src/features/DisplayFont/model/index.ts deleted file mode 100644 index 5099f73..0000000 --- a/src/features/DisplayFont/model/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { displayedFontsStore } from './store'; diff --git a/src/features/DisplayFont/model/store/displayedFontsStore.svelte.ts b/src/features/DisplayFont/model/store/displayedFontsStore.svelte.ts deleted file mode 100644 index 723cec6..0000000 --- a/src/features/DisplayFont/model/store/displayedFontsStore.svelte.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { 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; - }); - - get fonts() { - return this.#displayedFonts; - } - - get text() { - return this.#sampleText; - } - - set text(text: string) { - this.#sampleText = text; - } -} - -export const displayedFontsStore = new DisplayedFontsStore(); diff --git a/src/features/DisplayFont/model/store/index.ts b/src/features/DisplayFont/model/store/index.ts deleted file mode 100644 index 43bb021..0000000 --- a/src/features/DisplayFont/model/store/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { displayedFontsStore } from './displayedFontsStore.svelte'; diff --git a/src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte b/src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte deleted file mode 100644 index 915078f..0000000 --- a/src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - - -
- {#each displayedFontsStore.fonts as font (font.id)} - - {/each} -
diff --git a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte index 872e11c..453c23f 100644 --- a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte +++ b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte @@ -6,8 +6,14 @@ import { FontApplicator, type UnifiedFont, + selectedFontsStore, } from '$entities/Font'; -import { ContentEditable } from '$shared/ui'; +import { controlManager } from '$features/SetupFont'; +import { + ContentEditable, + IconButton, +} from '$shared/ui'; +import XIcon from '@lucide/svelte/icons/x'; interface Props { /** @@ -18,6 +24,10 @@ interface Props { * Text to display */ text: string; + /** + * Index of the font sampler + */ + index?: number; /** * Font settings */ @@ -29,18 +39,80 @@ interface Props { let { font, text = $bindable(), + index = 0, ...restProps }: Props = $props(); + +const fontWeight = $derived(controlManager.weight); +const fontSize = $derived(controlManager.size); +const lineHeight = $derived(controlManager.height); +const letterSpacing = $derived(controlManager.spacing); + +function removeSample() { + selectedFontsStore.removeOne(font.id); +}
- - - +
+
+ + typeface_{String(index).padStart(3, '0')} + +
+ + {font.name} + +
+ + + {#snippet icon({ className })} + + {/snippet} + +
+ +
+ + + + +
+ +
+ + SZ:{fontSize}PX + +
+ + WGT:{fontWeight} + +
+ + LH:{lineHeight?.toFixed(2)} + +
+ + LTR:{letterSpacing} + +
diff --git a/src/features/DisplayFont/ui/index.ts b/src/features/DisplayFont/ui/index.ts index cf3cfc7..b055bdf 100644 --- a/src/features/DisplayFont/ui/index.ts +++ b/src/features/DisplayFont/ui/index.ts @@ -1,3 +1,3 @@ -import FontDisplay from './FontDisplay/FontDisplay.svelte'; +import FontSampler from './FontSampler/FontSampler.svelte'; -export { FontDisplay }; +export { FontSampler }; diff --git a/src/features/GetFonts/index.ts b/src/features/GetFonts/index.ts index 9f557d6..9f63121 100644 --- a/src/features/GetFonts/index.ts +++ b/src/features/GetFonts/index.ts @@ -15,5 +15,4 @@ export { filterManager } from './model/state/manager.svelte'; export { FilterControls, Filters, - FontSearch, } from './ui'; diff --git a/src/features/GetFonts/lib/mapper/mapManagerToParams.ts b/src/features/GetFonts/lib/mapper/mapManagerToParams.ts index 637a052..8375a10 100644 --- a/src/features/GetFonts/lib/mapper/mapManagerToParams.ts +++ b/src/features/GetFonts/lib/mapper/mapManagerToParams.ts @@ -1,18 +1,54 @@ -import type { FontshareParams } from '$entities/Font'; +import type { ProxyFontsParams } from '$entities/Font/api'; import type { FilterManager } from '../filterManager/filterManager.svelte'; /** - * Maps filter manager to fontshare params. + * Maps filter manager to proxy API parameters. * - * @param manager - Filter manager instance. - * @returns - Partial fontshare params. + * Transforms UI filter state into proxy API query parameters. + * Handles conversion from filter groups to API-specific parameters. + * + * @param manager - Filter manager instance with reactive state + * @returns - Partial proxy API parameters ready for API call + * + * @example + * ```ts + * // Example filter manager state: + * // { + * // queryValue: 'roboto', + * // providers: ['google'], + * // categories: ['sans-serif'], + * // subsets: ['latin'] + * // } + * + * const params = mapManagerToParams(manager); + * // Returns: { provider: 'google', category: 'sans-serif', subset: 'latin', q: 'roboto' } + * ``` */ -export function mapManagerToParams(manager: FilterManager): Partial { +export function mapManagerToParams(manager: FilterManager): Partial { + const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value); + const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value); + const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value); + return { - q: manager.debouncedQueryValue, - // Map groups to specific API keys - categories: manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value) - ?? [], - tags: manager.getGroup('tags')?.instance.selectedProperties.map(p => p.value) ?? [], + // Search query (debounced) + q: manager.debouncedQueryValue || undefined, + + // Provider filter (single value - proxy API doesn't support array) + // Use first provider if multiple selected, or undefined if none/all selected + provider: providers && providers.length === 1 + ? (providers[0] as 'google' | 'fontshare') + : undefined, + + // Category filter (single value - proxy API doesn't support array) + // Use first category if multiple selected, or undefined if none/all selected + category: categories && categories.length === 1 + ? (categories[0] as ProxyFontsParams['category']) + : undefined, + + // Subset filter (single value - proxy API doesn't support array) + // Use first subset if multiple selected, or undefined if none/all selected + subset: subsets && subsets.length === 1 + ? (subsets[0] as ProxyFontsParams['subset']) + : undefined, }; } diff --git a/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte b/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte index 2b74696..d50e84c 100644 --- a/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte +++ b/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte @@ -5,15 +5,42 @@ --> -
+
diff --git a/src/features/GetFonts/ui/FontSearch/FontSearch.svelte b/src/features/GetFonts/ui/FontSearch/FontSearch.svelte deleted file mode 100644 index c792eed..0000000 --- a/src/features/GetFonts/ui/FontSearch/FontSearch.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - diff --git a/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte b/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte deleted file mode 100644 index 3c24e8c..0000000 --- a/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - - - {#snippet children({ item: font })} - - {/snippet} - diff --git a/src/features/GetFonts/ui/index.ts b/src/features/GetFonts/ui/index.ts index c5bc080..b2d2dd9 100644 --- a/src/features/GetFonts/ui/index.ts +++ b/src/features/GetFonts/ui/index.ts @@ -1,9 +1,7 @@ import Filters from './Filters/Filters.svelte'; import FilterControls from './FiltersControl/FilterControls.svelte'; -import FontSearch from './FontSearch/FontSearch.svelte'; export { FilterControls, Filters, - FontSearch, }; diff --git a/src/features/SetupFont/index.ts b/src/features/SetupFont/index.ts index bc8a71a..7d53b11 100644 --- a/src/features/SetupFont/index.ts +++ b/src/features/SetupFont/index.ts @@ -4,6 +4,7 @@ export { controlManager, DEFAULT_FONT_SIZE, DEFAULT_FONT_WEIGHT, + DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, FONT_SIZE_STEP, FONT_WEIGHT_STEP, diff --git a/src/features/SetupFont/lib/controlManager/controlManager.svelte.ts b/src/features/SetupFont/lib/controlManager/controlManager.svelte.ts index 3307c4c..134e71e 100644 --- a/src/features/SetupFont/lib/controlManager/controlManager.svelte.ts +++ b/src/features/SetupFont/lib/controlManager/controlManager.svelte.ts @@ -1,7 +1,53 @@ import { type ControlModel, + type TypographyControl, createTypographyControl, } from '$shared/lib'; +import { SvelteMap } from 'svelte/reactivity'; + +export interface Control { + id: string; + increaseLabel?: string; + decreaseLabel?: string; + controlLabel?: string; + instance: TypographyControl; +} + +export class TypographyControlManager { + #controls = new SvelteMap(); + + constructor(configs: ControlModel[]) { + configs.forEach(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => { + this.#controls.set(id, { + id, + increaseLabel, + decreaseLabel, + controlLabel, + instance: createTypographyControl(config), + }); + }); + } + + get controls() { + return this.#controls.values(); + } + + get weight() { + return this.#controls.get('font_weight')?.instance.value ?? 400; + } + + get size() { + return this.#controls.get('font_size')?.instance.value; + } + + get height() { + return this.#controls.get('line_height')?.instance.value; + } + + get spacing() { + return this.#controls.get('letter_spacing')?.instance.value; + } +} /** * Creates a typography control manager that handles a collection of typography controls. @@ -10,19 +56,5 @@ import { * @returns - Typography control manager instance. */ export function createTypographyControlManager(configs: ControlModel[]) { - const controls = $state( - configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({ - id, - increaseLabel, - decreaseLabel, - controlLabel, - instance: createTypographyControl(config), - })), - ); - - return { - get controls() { - return controls; - }, - }; + return new TypographyControlManager(configs); } diff --git a/src/features/SetupFont/model/const/const.ts b/src/features/SetupFont/model/const/const.ts index 21bf5b7..97d60a0 100644 --- a/src/features/SetupFont/model/const/const.ts +++ b/src/features/SetupFont/model/const/const.ts @@ -1,7 +1,7 @@ /** * Font size constants */ -export const DEFAULT_FONT_SIZE = 16; +export const DEFAULT_FONT_SIZE = 48; export const MIN_FONT_SIZE = 8; export const MAX_FONT_SIZE = 100; export const FONT_SIZE_STEP = 1; @@ -21,3 +21,11 @@ export const DEFAULT_LINE_HEIGHT = 1.5; export const MIN_LINE_HEIGHT = 1; export const MAX_LINE_HEIGHT = 2; export const LINE_HEIGHT_STEP = 0.05; + +/** + * Letter spacing constants + */ +export const DEFAULT_LETTER_SPACING = 0; +export const MIN_LETTER_SPACING = -0.1; +export const MAX_LETTER_SPACING = 0.5; +export const LETTER_SPACING_STEP = 0.01; diff --git a/src/features/SetupFont/model/index.ts b/src/features/SetupFont/model/index.ts index 8f7451c..22411a9 100644 --- a/src/features/SetupFont/model/index.ts +++ b/src/features/SetupFont/model/index.ts @@ -1,6 +1,7 @@ export { DEFAULT_FONT_SIZE, DEFAULT_FONT_WEIGHT, + DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, FONT_SIZE_STEP, FONT_WEIGHT_STEP, diff --git a/src/features/SetupFont/model/state/manager.svelte.ts b/src/features/SetupFont/model/state/manager.svelte.ts index f39823e..7b05a49 100644 --- a/src/features/SetupFont/model/state/manager.svelte.ts +++ b/src/features/SetupFont/model/state/manager.svelte.ts @@ -3,15 +3,19 @@ import { createTypographyControlManager } from '../../lib'; import { DEFAULT_FONT_SIZE, DEFAULT_FONT_WEIGHT, + DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, FONT_SIZE_STEP, FONT_WEIGHT_STEP, + LETTER_SPACING_STEP, LINE_HEIGHT_STEP, MAX_FONT_SIZE, MAX_FONT_WEIGHT, + MAX_LETTER_SPACING, MAX_LINE_HEIGHT, MIN_FONT_SIZE, MIN_FONT_WEIGHT, + MIN_LETTER_SPACING, MIN_LINE_HEIGHT, } from '../const/const'; @@ -49,6 +53,17 @@ const controlData: ControlModel[] = [ decreaseLabel: 'Decrease Line Height', controlLabel: 'Line Height', }, + { + id: 'letter_spacing', + value: DEFAULT_LETTER_SPACING, + max: MAX_LETTER_SPACING, + min: MIN_LETTER_SPACING, + step: LETTER_SPACING_STEP, + + increaseLabel: 'Increase Letter Spacing', + decreaseLabel: 'Decrease Letter Spacing', + controlLabel: 'Letter Spacing', + }, ]; export const controlManager = createTypographyControlManager(controlData); diff --git a/src/features/SetupFont/ui/SetupFontMenu.svelte b/src/features/SetupFont/ui/SetupFontMenu.svelte index 5941585..5384878 100644 --- a/src/features/SetupFont/ui/SetupFontMenu.svelte +++ b/src/features/SetupFont/ui/SetupFontMenu.svelte @@ -3,18 +3,19 @@ Contains controls for setting up font properties. --> -
- - -
+
+
{#each controlManager.controls as control (control.id)} - + {/each}
diff --git a/src/routes/Page.svelte b/src/routes/Page.svelte index 8f077bd..c064d08 100644 --- a/src/routes/Page.svelte +++ b/src/routes/Page.svelte @@ -1,12 +1,94 @@ + + + -
- +
+
+ {#snippet icon({ className })} + + {/snippet} + {#snippet title({ className })} +

+ Optical
Comparator +

+ {/snippet} + +
+ +
+ {#snippet icon({ className })} + + {/snippet} + {#snippet title({ className })} +

+ Query
Module +

+ {/snippet} + +
+ +
+ {#snippet icon({ className })} + + {/snippet} + {#snippet title({ className })} +

+ Sample
Set +

+ {/snippet} + +
+ + diff --git a/src/shared/api/api.ts b/src/shared/api/api.ts index 1b440d6..a786e4c 100644 --- a/src/shared/api/api.ts +++ b/src/shared/api/api.ts @@ -56,6 +56,5 @@ export const api = { body: JSON.stringify(body), }), - delete: (url: string, options?: RequestInit) => - request(url, { ...options, method: 'DELETE' }), + delete: (url: string, options?: RequestInit) => request(url, { ...options, method: 'DELETE' }), }; diff --git a/src/shared/lib/accessibility/motion.svelte.ts b/src/shared/lib/accessibility/motion.svelte.ts deleted file mode 100644 index 4dceb77..0000000 --- a/src/shared/lib/accessibility/motion.svelte.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Check if we are in a browser environment -const isBrowser = typeof window !== 'undefined'; - -// A class to manage motion preference and provide a single instance for use everywhere -class MotionPreference { - // Reactive state - #reduced = $state(false); - - constructor() { - if (isBrowser) { - const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); - - // Set initial value immediately - this.#reduced = mediaQuery.matches; - - // Simple listener that updates the reactive state - const handleChange = (e: MediaQueryListEvent) => { - this.#reduced = e.matches; - }; - - mediaQuery.addEventListener('change', handleChange); - } - } - - // Getter allows us to use 'motion.reduced' reactively in components - get reduced() { - return this.#reduced; - } -} - -// Export a single instance to be used everywhere -export const motion = new MotionPreference(); diff --git a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts new file mode 100644 index 0000000..2867dda --- /dev/null +++ b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts @@ -0,0 +1,257 @@ +/** + * Interface representing a line of text with its measured width. + */ +export interface LineData { + text: string; + width: number; +} + +/** + * Creates a helper for splitting text into lines and calculating character proximity. + * This is used by the ComparisonSlider (TestTen) to render morphing text. + * + * @param text - The text to split and measure + * @param fontA - The first font definition + * @param fontB - The second font definition + * @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState) + */ +export function createCharacterComparison< + T extends { name: string; id: string } | undefined = undefined, +>( + text: () => string, + fontA: () => T, + fontB: () => T, + weight: () => number, + size: () => number, +) { + let lines = $state([]); + let containerWidth = $state(0); + + function fontDefined(font: T | undefined): font is T { + return font !== undefined; + } + + /** + * Measures text width using a canvas context. + * @param ctx - Canvas rendering context + * @param text - Text string to measure + * @param fontFamily - Font family name + * @param fontSize - Font size in pixels + * @param fontWeight - Font weight + */ + function measureText( + ctx: CanvasRenderingContext2D, + text: string, + fontSize: number, + fontWeight: number, + fontFamily?: string, + ): number { + if (!fontFamily) return 0; + ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`; + return ctx.measureText(text).width; + } + + /** + * Determines the appropriate font size based on window width. + * Matches the Tailwind breakpoints used in the component. + */ + function getFontSize() { + if (typeof window === 'undefined') { + return 64; + } + return window.innerWidth >= 1024 + ? 112 + : window.innerWidth >= 768 + ? 96 + : window.innerWidth >= 640 + ? 80 + : 64; + } + + /** + * Breaks the text into lines based on the container width and measure canvas. + * Populates the `lines` state. + * + * @param container - The container element to measure width from + * @param measureCanvas - The canvas element used for text measurement + */ + + function breakIntoLines( + container: HTMLElement | undefined, + measureCanvas: HTMLCanvasElement | undefined, + ) { + if (!container || !measureCanvas || !fontA() || !fontB()) return; + + const rect = container.getBoundingClientRect(); + containerWidth = rect.width; + + // Padding considerations - matches the container padding + const padding = window.innerWidth < 640 ? 48 : 96; + const availableWidth = rect.width - padding; + const ctx = measureCanvas.getContext('2d'); + if (!ctx) return; + + const controlledFontSize = size(); + const fontSize = getFontSize(); + const currentWeight = weight(); // Get current weight + const words = text().split(' '); + const newLines: LineData[] = []; + let currentLineWords: string[] = []; + + function pushLine(words: string[]) { + if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) { + return; + } + const lineText = words.join(' '); + // Measure both fonts at the CURRENT weight + const widthA = measureText( + ctx!, + lineText, + Math.min(fontSize, controlledFontSize), + currentWeight, + fontA()?.name, + ); + const widthB = measureText( + ctx!, + lineText, + Math.min(fontSize, controlledFontSize), + currentWeight, + fontB()?.name, + ); + const maxWidth = Math.max(widthA, widthB); + newLines.push({ text: lineText, width: maxWidth }); + } + + for (const word of words) { + const testLine = currentLineWords.length > 0 + ? currentLineWords.join(' ') + ' ' + word + : word; + // Measure with both fonts and use the wider one to prevent layout shifts + const widthA = measureText( + ctx, + testLine, + Math.min(fontSize, controlledFontSize), + currentWeight, + fontA()?.name, + ); + const widthB = measureText( + ctx, + testLine, + Math.min(fontSize, controlledFontSize), + currentWeight, + fontB()?.name, + ); + const maxWidth = Math.max(widthA, widthB); + const isContainerOverflown = maxWidth > availableWidth; + + if (isContainerOverflown) { + if (currentLineWords.length > 0) { + pushLine(currentLineWords); + currentLineWords = []; + } + + let remainingWord = word; + while (remainingWord.length > 0) { + let low = 1; + let high = remainingWord.length; + let bestBreak = 1; + + // Binary Search to find the maximum characters that fit + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const testFragment = remainingWord.slice(0, mid); + + const wA = measureText( + ctx, + testFragment, + fontSize, + currentWeight, + fontA()?.name, + ); + const wB = measureText( + ctx, + testFragment, + fontSize, + currentWeight, + fontB()?.name, + ); + + if (Math.max(wA, wB) <= availableWidth) { + bestBreak = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + pushLine([remainingWord.slice(0, bestBreak)]); + remainingWord = remainingWord.slice(bestBreak); + } + } else if (maxWidth > availableWidth && currentLineWords.length > 0) { + pushLine(currentLineWords); + currentLineWords = [word]; + } else { + currentLineWords.push(word); + } + } + + if (currentLineWords.length > 0) { + pushLine(currentLineWords); + } + lines = newLines; + } + + /** + * precise calculation of character state based on global slider position. + * + * @param charIndex - Index of the character in the line + * @param sliderPos - Current slider position (0-100) + * @param lineElement - The line element + * @param container - The container element + * @returns Object containing proximity (0-1) and isPast (boolean) + */ + function getCharState( + charIndex: number, + sliderPos: number, + lineElement?: HTMLElement, + container?: HTMLElement, + ) { + if (!containerWidth || !container) { + return { + proximity: 0, + isPast: false, + }; + } + const charElement = lineElement?.children[charIndex] as HTMLElement; + + if (!charElement) { + return { proximity: 0, isPast: false }; + } + + // Get the actual bounding box of the character + const charRect = charElement.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + // Calculate character center relative to container + const charCenter = charRect.left + (charRect.width / 2) - containerRect.left; + const charGlobalPercent = (charCenter / containerWidth) * 100; + + const distance = Math.abs(sliderPos - charGlobalPercent); + const range = 5; + const proximity = Math.max(0, 1 - distance / range); + const isPast = sliderPos > charGlobalPercent; + + return { proximity, isPast }; + } + + return { + get lines() { + return lines; + }, + get containerWidth() { + return containerWidth; + }, + breakIntoLines, + getCharState, + }; +} diff --git a/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts b/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts index 14e2c30..46c6f00 100644 --- a/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts +++ b/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts @@ -46,9 +46,6 @@ export class EntityStore { updateOne(id: string, changes: Partial) { const entity = this.#entities.get(id); if (entity) { - // In Svelte 5, updating the object property directly is reactive - // if the object itself was made reactive, but here we replace - // the reference to ensure top-level map triggers. this.#entities.set(id, { ...entity, ...changes }); } } diff --git a/src/shared/lib/helpers/createPersistentStore/createPersistentStore.svelte.ts b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.svelte.ts new file mode 100644 index 0000000..cfb37ad --- /dev/null +++ b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.svelte.ts @@ -0,0 +1,51 @@ +/** + * Reusable persistent storage utility using Svelte 5 runes + * + * Automatically syncs state with localStorage. + */ +export function createPersistentStore(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(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; + }, + }; +} diff --git a/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts b/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts index 1ba9476..bec0f1d 100644 --- a/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts +++ b/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts @@ -30,15 +30,15 @@ export interface ControlModel extends ControlDataModel { /** * Area label for increase button */ - increaseLabel: string; + increaseLabel?: string; /** * Area label for decrease button */ - decreaseLabel: string; + decreaseLabel?: string; /** * Control area label */ - controlLabel: string; + controlLabel?: string; } export function createTypographyControl( diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index af9e7ba..aeb67a7 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -4,16 +4,38 @@ * Used to render visible items with absolute positioning based on computed offsets. */ export interface VirtualItem { - /** Index of the item in the data array */ + /** + * Index of the item in the data array + */ index: number; - /** Offset from the top of the list in pixels */ + /** + * Offset from the top of the list in pixels + */ start: number; - /** Height/size of the item in pixels */ + /** + * Height/size of the item in pixels + */ size: number; - /** End position in pixels (start + size) */ + /** + * End position in pixels (start + size) + */ end: number; - /** Unique key for the item (for Svelte's {#each} keying) */ + /** + * Unique key for the item (for Svelte's {#each} keying) + */ key: string | number; + /** + * Whether the item is currently fully visible in the viewport + */ + isFullyVisible: boolean; + /** + * Whether the item is currently partially visible in the viewport + */ + isPartiallyVisible: boolean; + /** + * Proximity of the item to the center of the viewport + */ + proximity: number; } /** @@ -41,6 +63,11 @@ export interface VirtualizerOptions { * Can be useful for handling sticky headers or other UI elements. */ scrollMargin?: number; + /** + * Whether to use the window as the scroll container. + * @default false + */ + useWindowScroll?: boolean; } /** @@ -88,6 +115,7 @@ export function createVirtualizer( let containerHeight = $state(0); let measuredSizes = $state>({}); let elementRef: HTMLElement | null = null; + let elementOffsetTop = 0; // By wrapping the getter in $derived, we track everything inside it const options = $derived(optionsGetter()); @@ -136,6 +164,8 @@ export function createVirtualizer( let endIdx = startIdx; const viewportEnd = scrollOffset + containerHeight; + const viewportCenter = scrollOffset + (containerHeight / 2); + while (endIdx < count && offsets[endIdx] < viewportEnd) { endIdx++; } @@ -144,13 +174,31 @@ export function createVirtualizer( const end = Math.min(count, endIdx + overscan); const result: VirtualItem[] = []; + for (let i = start; i < end; i++) { + const itemStart = offsets[i]; + const itemSize = measuredSizes[i] ?? options.estimateSize(i); + const itemEnd = itemStart + itemSize; + + // Visibility check: Does the item overlap the viewport? + const isPartiallyVisible = itemStart < viewportEnd && itemEnd > scrollOffset; + const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd; + + // Proximity calculation: 1.0 at center, 0.0 at edges + const itemCenter = itemStart + (itemSize / 2); + const distanceToCenter = Math.abs(viewportCenter - itemCenter); + const maxDistance = containerHeight / 2; + const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance)); + result.push({ index: i, - start: offsets[i], - size: measuredSizes[i] ?? options.estimateSize(i), - end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)), + start: itemStart, + size: itemSize, + end: itemEnd, key: options.getItemKey?.(i) ?? i, + isPartiallyVisible, + isFullyVisible, + proximity, }); } @@ -168,26 +216,74 @@ export function createVirtualizer( */ function container(node: HTMLElement) { elementRef = node; - containerHeight = node.offsetHeight; + const { useWindowScroll } = optionsGetter(); - const handleScroll = () => { - scrollOffset = node.scrollTop; - }; + if (useWindowScroll) { + // Calculate initial offset ONCE + const getElementOffset = () => { + const rect = node.getBoundingClientRect(); + return rect.top + window.scrollY; + }; - const resizeObserver = new ResizeObserver(([entry]) => { - if (entry) containerHeight = entry.contentRect.height; - }); + let cachedOffsetTop = getElementOffset(); + containerHeight = window.innerHeight; - node.addEventListener('scroll', handleScroll, { passive: true }); - resizeObserver.observe(node); + const handleScroll = () => { + // Use cached offset for scroll calculations + scrollOffset = Math.max(0, window.scrollY - cachedOffsetTop); + }; - return { - destroy() { - node.removeEventListener('scroll', handleScroll); - resizeObserver.disconnect(); - elementRef = null; - }, - }; + const handleResize = () => { + const oldHeight = containerHeight; + containerHeight = window.innerHeight; + + // Recalculate offset on resize (layout may have shifted) + const newOffsetTop = getElementOffset(); + if (Math.abs(newOffsetTop - cachedOffsetTop) > 0.5) { + cachedOffsetTop = newOffsetTop; + handleScroll(); // Recalculate scroll position + } + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', handleResize); + + // Initial calculation + handleScroll(); + + return { + destroy() { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleResize); + if (frameId !== null) { + cancelAnimationFrame(frameId); + frameId = null; + } + elementRef = null; + }, + }; + } else { + containerHeight = node.offsetHeight; + + const handleScroll = () => { + scrollOffset = node.scrollTop; + }; + + const resizeObserver = new ResizeObserver(([entry]) => { + if (entry) containerHeight = entry.contentRect.height; + }); + + node.addEventListener('scroll', handleScroll, { passive: true }); + resizeObserver.observe(node); + + return { + destroy() { + node.removeEventListener('scroll', handleScroll); + resizeObserver.disconnect(); + elementRef = null; + }, + }; + } } let measurementBuffer: Record = {}; @@ -207,21 +303,23 @@ export function createVirtualizer( const index = parseInt(node.dataset.index || '', 10); const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; - if (!isNaN(index) && measuredSizes[index] !== height) { - // 1. Stuff the measurement into a temporary buffer - measurementBuffer[index] = height; + if (!isNaN(index)) { + const oldHeight = measuredSizes[index]; + // Only update if the height difference is significant (> 0.5px) + // This prevents "jitter" from focus rings or sub-pixel border changes + if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) { + // Stuff the measurement into a temporary buffer + measurementBuffer[index] = height; - // 2. Schedule a single update for the next animation frame - if (frameId === null) { - frameId = requestAnimationFrame(() => { - // 3. Update the state once for all collected measurements - // We use spread to trigger a single fine-grained update - measuredSizes = { ...measuredSizes, ...measurementBuffer }; - - // 4. Reset the buffer - measurementBuffer = {}; - frameId = null; - }); + // Schedule a single update for the next animation frame + if (frameId === null) { + frameId = requestAnimationFrame(() => { + measuredSizes = { ...measuredSizes, ...measurementBuffer }; + // Reset the buffer + measurementBuffer = {}; + frameId = null; + }); + } } } }); @@ -249,11 +347,22 @@ export function createVirtualizer( const itemStart = offsets[index]; const itemSize = measuredSizes[index] ?? options.estimateSize(index); let target = itemStart; + const { useWindowScroll } = optionsGetter(); - if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2; - if (align === 'end') target = itemStart - containerHeight + itemSize; + if (useWindowScroll) { + if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2; + if (align === 'end') target = itemStart - window.innerHeight + itemSize; - elementRef.scrollTo({ top: target, behavior: 'smooth' }); + // Add container offset to target to get absolute document position + const absoluteTarget = target + elementOffsetTop; + + window.scrollTo({ top: absoluteTarget, behavior: 'smooth' }); + } else { + if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2; + if (align === 'end') target = itemStart - containerHeight + itemSize; + + elementRef.scrollTo({ top: target, behavior: 'smooth' }); + } } return { diff --git a/src/shared/lib/helpers/index.ts b/src/shared/lib/helpers/index.ts index 62db226..f325d4f 100644 --- a/src/shared/lib/helpers/index.ts +++ b/src/shared/lib/helpers/index.ts @@ -26,3 +26,10 @@ export { type Entity, type EntityStore, } from './createEntityStore/createEntityStore.svelte'; + +export { + createCharacterComparison, + type LineData, +} from './createCharacterComparison/createCharacterComparison.svelte'; + +export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte'; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index fea0978..7cde5c5 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,15 +1,18 @@ export { type ControlDataModel, type ControlModel, + createCharacterComparison, createDebouncedState, createEntityStore, createFilter, + createPersistentStore, createTypographyControl, createVirtualizer, type Entity, type EntityStore, type Filter, type FilterModel, + type LineData, type Property, type TypographyControl, type VirtualItem, @@ -17,5 +20,6 @@ export { type VirtualizerOptions, } from './helpers'; -export { motion } from './accessibility/motion.svelte'; export { splitArray } from './utils'; + +export { springySlideFade } from './transitions'; diff --git a/src/shared/lib/transitions/index.ts b/src/shared/lib/transitions/index.ts new file mode 100644 index 0000000..2264a9d --- /dev/null +++ b/src/shared/lib/transitions/index.ts @@ -0,0 +1 @@ +export { springySlideFade } from './springySlideFade/springySlideFade'; diff --git a/src/shared/lib/transitions/springySlideFade/springySlideFade.ts b/src/shared/lib/transitions/springySlideFade/springySlideFade.ts new file mode 100644 index 0000000..a32d8a0 --- /dev/null +++ b/src/shared/lib/transitions/springySlideFade/springySlideFade.ts @@ -0,0 +1,60 @@ +import type { + SlideParams, + TransitionConfig, +} from 'svelte/transition'; + +function elasticOut(t: number) { + return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1; +} + +function gentleSpring(t: number) { + return 1 - Math.pow(1 - t, 3) * Math.cos(t * Math.PI * 2); +} + +/** + * Svelte slide transition function for custom slide+fade + * @param node - The element to apply the transition to + * @param params - Transition parameters + * @returns Transition configuration + */ +export function springySlideFade( + node: HTMLElement, + params: SlideParams = {}, +): TransitionConfig { + const { duration = 400 } = params; + const height = node.scrollHeight; + + // Check if the browser is Firefox to work around specific rendering issues + const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); + + return { + duration, + // We use 'tick' for the most precise control over the + // coordination with the elements below. + css: t => { + // Use elastic easing + const eased = gentleSpring(t); + + return ` + height: ${eased * height}px; + opacity: ${t}; + transform: translateY(${(1 - t) * -10}px); + transform-origin: top; + overflow: hidden; + contain: size layout style; + will-change: max-height, opacity, transform; + backface-visibility: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + ${ + isFirefox + ? ` + perspective: 1000px; + isolation: isolate; + ` + : '' + } + `; + }, + }; +} diff --git a/src/shared/shadcn/ui/badge/badge.svelte b/src/shared/shadcn/ui/badge/badge.svelte index 523a922..2caaee6 100644 --- a/src/shared/shadcn/ui/badge/badge.svelte +++ b/src/shared/shadcn/ui/badge/badge.svelte @@ -9,10 +9,8 @@ export const badgeVariants = tv({ 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3', variants: { variant: { - default: - 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent', - secondary: - 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent', + default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent', + secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent', destructive: 'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white', outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', diff --git a/src/shared/shadcn/ui/scroll-area/index.ts b/src/shared/shadcn/ui/scroll-area/index.ts new file mode 100644 index 0000000..2d8d691 --- /dev/null +++ b/src/shared/shadcn/ui/scroll-area/index.ts @@ -0,0 +1,10 @@ +import Scrollbar from './scroll-area-scrollbar.svelte'; +import Root from './scroll-area.svelte'; + +export { + Root, + // , + Root as ScrollArea, + Scrollbar, + Scrollbar as ScrollAreaScrollbar, +}; diff --git a/src/shared/shadcn/ui/scroll-area/scroll-area-scrollbar.svelte b/src/shared/shadcn/ui/scroll-area/scroll-area-scrollbar.svelte new file mode 100644 index 0000000..6dc1737 --- /dev/null +++ b/src/shared/shadcn/ui/scroll-area/scroll-area-scrollbar.svelte @@ -0,0 +1,34 @@ + + + + {@render children?.()} + + diff --git a/src/shared/shadcn/ui/scroll-area/scroll-area.svelte b/src/shared/shadcn/ui/scroll-area/scroll-area.svelte new file mode 100644 index 0000000..45f86c4 --- /dev/null +++ b/src/shared/shadcn/ui/scroll-area/scroll-area.svelte @@ -0,0 +1,46 @@ + + + + + {@render children?.()} + + {#if orientation === 'vertical' || orientation === 'both'} + + {/if} + {#if orientation === 'horizontal' || orientation === 'both'} + + {/if} + + diff --git a/src/shared/shadcn/ui/select/index.ts b/src/shared/shadcn/ui/select/index.ts new file mode 100644 index 0000000..8c303c3 --- /dev/null +++ b/src/shared/shadcn/ui/select/index.ts @@ -0,0 +1,37 @@ +import Content from './select-content.svelte'; +import GroupHeading from './select-group-heading.svelte'; +import Group from './select-group.svelte'; +import Item from './select-item.svelte'; +import Label from './select-label.svelte'; +import Portal from './select-portal.svelte'; +import ScrollDownButton from './select-scroll-down-button.svelte'; +import ScrollUpButton from './select-scroll-up-button.svelte'; +import Separator from './select-separator.svelte'; +import Trigger from './select-trigger.svelte'; +import Root from './select.svelte'; + +export { + Content, + Content as SelectContent, + Group, + Group as SelectGroup, + GroupHeading, + GroupHeading as SelectGroupHeading, + Item, + Item as SelectItem, + Label, + Label as SelectLabel, + Portal, + Portal as SelectPortal, + Root, + // + Root as Select, + ScrollDownButton, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton, + ScrollUpButton as SelectScrollUpButton, + Separator, + Separator as SelectSeparator, + Trigger, + Trigger as SelectTrigger, +}; diff --git a/src/shared/shadcn/ui/select/select-content.svelte b/src/shared/shadcn/ui/select/select-content.svelte new file mode 100644 index 0000000..572f197 --- /dev/null +++ b/src/shared/shadcn/ui/select/select-content.svelte @@ -0,0 +1,48 @@ + + + + + + + {@render children?.()} + + + + diff --git a/src/shared/shadcn/ui/select/select-group-heading.svelte b/src/shared/shadcn/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..4e8f720 --- /dev/null +++ b/src/shared/shadcn/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/src/shared/shadcn/ui/select/select-group.svelte b/src/shared/shadcn/ui/select/select-group.svelte new file mode 100644 index 0000000..8e0e694 --- /dev/null +++ b/src/shared/shadcn/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/shared/shadcn/ui/select/select-item.svelte b/src/shared/shadcn/ui/select/select-item.svelte new file mode 100644 index 0000000..e375e45 --- /dev/null +++ b/src/shared/shadcn/ui/select/select-item.svelte @@ -0,0 +1,41 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/src/shared/shadcn/ui/select/select-label.svelte b/src/shared/shadcn/ui/select/select-label.svelte new file mode 100644 index 0000000..301930d --- /dev/null +++ b/src/shared/shadcn/ui/select/select-label.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/src/shared/shadcn/ui/select/select-portal.svelte b/src/shared/shadcn/ui/select/select-portal.svelte new file mode 100644 index 0000000..c4fc326 --- /dev/null +++ b/src/shared/shadcn/ui/select/select-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/shared/shadcn/ui/select/select-scroll-down-button.svelte b/src/shared/shadcn/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..bdb96f5 --- /dev/null +++ b/src/shared/shadcn/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/src/shared/shadcn/ui/select/select-scroll-up-button.svelte b/src/shared/shadcn/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..b28fbc9 --- /dev/null +++ b/src/shared/shadcn/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/src/shared/shadcn/ui/select/select-separator.svelte b/src/shared/shadcn/ui/select/select-separator.svelte new file mode 100644 index 0000000..a570547 --- /dev/null +++ b/src/shared/shadcn/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/shared/shadcn/ui/select/select-trigger.svelte b/src/shared/shadcn/ui/select/select-trigger.svelte new file mode 100644 index 0000000..b9b280f --- /dev/null +++ b/src/shared/shadcn/ui/select/select-trigger.svelte @@ -0,0 +1,32 @@ + + + + {@render children?.()} + + diff --git a/src/shared/shadcn/ui/select/select.svelte b/src/shared/shadcn/ui/select/select.svelte new file mode 100644 index 0000000..8eca78b --- /dev/null +++ b/src/shared/shadcn/ui/select/select.svelte @@ -0,0 +1,11 @@ + + + diff --git a/src/shared/shadcn/ui/sidebar/sidebar-provider.svelte b/src/shared/shadcn/ui/sidebar/sidebar-provider.svelte index 5d9e4e5..4bb4d4c 100644 --- a/src/shared/shadcn/ui/sidebar/sidebar-provider.svelte +++ b/src/shared/shadcn/ui/sidebar/sidebar-provider.svelte @@ -33,8 +33,7 @@ const sidebar = setSidebar({ onOpenChange(value); // This sets the cookie to keep the sidebar state. - document.cookie = - `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; }, }); diff --git a/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte b/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte index 996dc7b..5ab3e1a 100644 --- a/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte +++ b/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte @@ -8,8 +8,10 @@ - Local transition prevents animation when component first renders --> - - - - - {#snippet child({ props })} - - {/snippet} - - -
- - -
-
-
- -
+ + + + + {#snippet icon({ className })} + + {/snippet} + + + + {#snippet child({ props })} + + {/snippet} + + +
+ + +
+
+
+ + + {#snippet icon({ className })} + + {/snippet} + +
+
+ {#if controlLabel} + + {controlLabel} + + {/if} +
diff --git a/src/shared/ui/ComboControlV2/ComboControlV2.svelte b/src/shared/ui/ComboControlV2/ComboControlV2.svelte new file mode 100644 index 0000000..7a8f54d --- /dev/null +++ b/src/shared/ui/ComboControlV2/ComboControlV2.svelte @@ -0,0 +1,72 @@ + + + +
+ + +
diff --git a/src/shared/ui/ContentEditable/ContentEditable.svelte b/src/shared/ui/ContentEditable/ContentEditable.svelte index 0c9bc0b..8b45acb 100644 --- a/src/shared/ui/ContentEditable/ContentEditable.svelte +++ b/src/shared/ui/ContentEditable/ContentEditable.svelte @@ -5,14 +5,20 @@ + + +{/* @ts-ignore */ null} + + {#snippet children(args)} +
+ +
+ {/snippet} +
+ +{/* @ts-ignore */ null} + + {#snippet children(args)} +
+ +
+ {/snippet} +
+ +{/* @ts-ignore */ null} + + {#snippet children(args)} +
+ +
+ {/snippet} +
diff --git a/src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte b/src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte new file mode 100644 index 0000000..c405e82 --- /dev/null +++ b/src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte @@ -0,0 +1,195 @@ + + + +
+ {@render badge?.({ expanded, disabled })} + +
+ {@render visibleContent?.({ expanded, disabled })} + + {#if expanded} +
+ {@render hiddenContent?.({ expanded, disabled })} +
+ {/if} +
+
diff --git a/src/shared/ui/IconButton/IconButton.svelte b/src/shared/ui/IconButton/IconButton.svelte new file mode 100644 index 0000000..6f7a6e2 --- /dev/null +++ b/src/shared/ui/IconButton/IconButton.svelte @@ -0,0 +1,50 @@ + + + + diff --git a/src/shared/ui/SearchBar/SearchBar.stories.svelte b/src/shared/ui/SearchBar/SearchBar.stories.svelte index f86dad9..6316bde 100644 --- a/src/shared/ui/SearchBar/SearchBar.stories.svelte +++ b/src/shared/ui/SearchBar/SearchBar.stories.svelte @@ -45,11 +45,7 @@ let noChildrenValue = $state(''); placeholder: 'Type here...', }} > - - Here will be the search result -
- Popover closes only when the user clicks outside the search bar or presses the Escape key. -
+ - -
-

No results found

-
-
+
- -
- Start typing to see results -
-
+
diff --git a/src/shared/ui/SearchBar/SearchBar.svelte b/src/shared/ui/SearchBar/SearchBar.svelte index d1e339f..7615891 100644 --- a/src/shared/ui/SearchBar/SearchBar.svelte +++ b/src/shared/ui/SearchBar/SearchBar.svelte @@ -1,90 +1,75 @@ - + - - - {#snippet child({ props })} - {@const { onclick, ...rest } = props} -
- {#if label} - - {/if} - -
- {/snippet} -
- - e.preventDefault()} - onInteractOutside={(e => { - if (e.target === triggerRef) { - e.preventDefault(); - } - })} - class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width)" - > - {@render children?.({ id: contentId })} - -
+
+
+ +
+ +
diff --git a/src/shared/ui/Section/Section.svelte b/src/shared/ui/Section/Section.svelte new file mode 100644 index 0000000..e5b0c5f --- /dev/null +++ b/src/shared/ui/Section/Section.svelte @@ -0,0 +1,108 @@ + + + +
+
+
+ {#if icon} + {@render icon({ className: 'size-4 stroke-gray-900 stroke-1' })} +
+ {/if} + {#if typeof index === 'number'} + + Component_{String(index).padStart(3, '0')} + + {/if} +
+ + {#if title} + {@render title({ className: 'text-5xl md:text-6xl font-semibold tracking-tighter text-gray-900 leading-[0.9]' })} + {/if} +
+ + {@render children?.()} +
diff --git a/src/shared/ui/VirtualList/VirtualList.svelte b/src/shared/ui/VirtualList/VirtualList.svelte index cbc0ea1..d4e2f05 100644 --- a/src/shared/ui/VirtualList/VirtualList.svelte +++ b/src/shared/ui/VirtualList/VirtualList.svelte @@ -6,11 +6,15 @@ - Keyboard navigation (ArrowUp/Down, Home, End) - Fixed or dynamic item heights - ARIA listbox/option pattern with single tab stop + - Custom shadcn ScrollArea scrollbar --> -
-
-
- - {#each virtualizer.items as item (item.key)} -
- {@render children({ item: items[item.index], index: item.index })} +{#if useWindowScroll} +
+
+ {#each virtualizer.items as item (item.key)} +
+ {#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} +
+ {/each}
- {/each} -
+
+{:else} + +
+ {#each virtualizer.items as item (item.key)} +
+ {#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} +
+ {/each} +
+
+{/if} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 307dcc6..a8429cb 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -6,14 +6,22 @@ import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte'; import ComboControl from './ComboControl/ComboControl.svelte'; +import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte'; import ContentEditable from './ContentEditable/ContentEditable.svelte'; +import ExpandableWrapper from './ExpandableWrapper/ExpandableWrapper.svelte'; +import IconButton from './IconButton/IconButton.svelte'; import SearchBar from './SearchBar/SearchBar.svelte'; +import Section from './Section/Section.svelte'; import VirtualList from './VirtualList/VirtualList.svelte'; export { CheckboxFilter, ComboControl, + ComboControlV2, ContentEditable, + ExpandableWrapper, + IconButton, SearchBar, + Section, VirtualList, }; diff --git a/src/widgets/ComparisonSlider/index.ts b/src/widgets/ComparisonSlider/index.ts new file mode 100644 index 0000000..b34444e --- /dev/null +++ b/src/widgets/ComparisonSlider/index.ts @@ -0,0 +1,2 @@ +export * from './model'; +export { ComparisonSlider } from './ui'; diff --git a/src/widgets/ComparisonSlider/model/index.ts b/src/widgets/ComparisonSlider/model/index.ts new file mode 100644 index 0000000..993fd73 --- /dev/null +++ b/src/widgets/ComparisonSlider/model/index.ts @@ -0,0 +1 @@ +export { comparisonStore } from './stores/comparisonStore.svelte'; diff --git a/src/widgets/ComparisonSlider/model/stores/comparisonStore.svelte.ts b/src/widgets/ComparisonSlider/model/stores/comparisonStore.svelte.ts new file mode 100644 index 0000000..503900f --- /dev/null +++ b/src/widgets/ComparisonSlider/model/stores/comparisonStore.svelte.ts @@ -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('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(); + #fontB = $state(); + #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(); diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte new file mode 100644 index 0000000..2120233 --- /dev/null +++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte @@ -0,0 +1,230 @@ + + + +{#snippet renderLine(line: LineData, index: number)} +
+ {#each line.text.split('') as char, charIndex} + {@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)} + + {#if fontA && fontB} + + {/if} + {/each} +
+{/snippet} + +{#if fontA && fontB} + + + +
+
+ +
+ {#each charComparison.lines as line, lineIndex} +
+ {@render renderLine(line, lineIndex)} +
+ {/each} +
+ + +
+ + + + +
+{/if} diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte new file mode 100644 index 0000000..03135fd --- /dev/null +++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte @@ -0,0 +1,52 @@ + + +
+ {#each chars as char, i} + + {char} + + {/each} +
+ + diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/CharacterSlot.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/CharacterSlot.svelte new file mode 100644 index 0000000..a1e9b67 --- /dev/null +++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/CharacterSlot.svelte @@ -0,0 +1,77 @@ + + + + 0.5 ? '0 0 15px rgba(99,102,241,0.3)' : 'none'} + style:will-change={proximity > 0 ? 'transform, font-family, color' : 'auto'} +> + {char === ' ' ? '\u00A0' : char} + + + diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/ControlsWrapper.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/ControlsWrapper.svelte new file mode 100644 index 0000000..c23106b --- /dev/null +++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/ControlsWrapper.svelte @@ -0,0 +1,174 @@ + + + +
+ + {#snippet badge()} +
+ +
+ {/snippet} + + {#snippet visibleContent()} +
+ +
+ {/snippet} + + {#snippet hiddenContent()} +
+ + + +
+ {/snippet} +
+
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/Labels.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/Labels.svelte new file mode 100644 index 0000000..2475288 --- /dev/null +++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/Labels.svelte @@ -0,0 +1,154 @@ + + + +{#snippet fontSelector( + name: string, + id: string, + url: string, + fonts: UnifiedFont[], + selectFont: (font: UnifiedFont) => void, + align: 'start' | 'end', +)} +
e.stopPropagation())} + > + + +
+ + {name} + +
+
+ +
+ + {#snippet children({ item: font })} + {@const handleClick = () => selectFont(font)} + + + {font.name} + + + {/snippet} + +
+
+
+
+{/snippet} + +
+
+
+
+
+ + ch_01 + +
+ {@render fontSelector( + fontB.name, + fontB.id, + fontB.styles.regular!, + fontList, + selectFontB, + 'start', +)} +
+ +
80 ? 0 : 1} + style:transform="translateY({sliderPos > 80 ? '8px' : '0px'})" + > +
+ + ch_02 + +
+
+
+ {@render fontSelector( + fontA.name, + fontA.id, + fontA.styles.regular!, + fontList, + selectFontA, + 'end', +)} +
+
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/SliderLine.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/SliderLine.svelte new file mode 100644 index 0000000..fbb2e52 --- /dev/null +++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/SliderLine.svelte @@ -0,0 +1,70 @@ + + +
+ + + + + + +
+
+ + + + + + +
diff --git a/src/widgets/ComparisonSlider/ui/index.ts b/src/widgets/ComparisonSlider/ui/index.ts new file mode 100644 index 0000000..ccad21a --- /dev/null +++ b/src/widgets/ComparisonSlider/ui/index.ts @@ -0,0 +1,3 @@ +import ComparisonSlider from './ComparisonSlider/ComparisonSlider.svelte'; + +export { ComparisonSlider }; diff --git a/src/widgets/FiltersSidebar/index.ts b/src/widgets/FiltersSidebar/index.ts deleted file mode 100644 index 895591f..0000000 --- a/src/widgets/FiltersSidebar/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import FiltersSidebar from './ui/FiltersSidebar.svelte'; - -export { FiltersSidebar }; diff --git a/src/widgets/FiltersSidebar/ui/FiltersSidebar.svelte b/src/widgets/FiltersSidebar/ui/FiltersSidebar.svelte deleted file mode 100644 index 6ac8070..0000000 --- a/src/widgets/FiltersSidebar/ui/FiltersSidebar.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - diff --git a/src/widgets/FontSearch/index.ts b/src/widgets/FontSearch/index.ts new file mode 100644 index 0000000..e9369b4 --- /dev/null +++ b/src/widgets/FontSearch/index.ts @@ -0,0 +1 @@ +export { FontSearch } from './ui'; diff --git a/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte b/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte new file mode 100644 index 0000000..e8596c7 --- /dev/null +++ b/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte @@ -0,0 +1,128 @@ + + + +
+
+ + +
+
+
+
+ + {#snippet icon({ className })} + + {/snippet} + +
+
+
+
+ + {#if showFilters} +
+
+
+
+
+ + filter_params + +
+ +
+ +
+ +
+ +
+
+
+ {/if} +
diff --git a/src/widgets/FontSearch/ui/index.ts b/src/widgets/FontSearch/ui/index.ts new file mode 100644 index 0000000..e71451a --- /dev/null +++ b/src/widgets/FontSearch/ui/index.ts @@ -0,0 +1,3 @@ +import FontSearch from './FontSearch/FontSearch.svelte'; + +export { FontSearch }; diff --git a/src/widgets/SampleList/index.ts b/src/widgets/SampleList/index.ts new file mode 100644 index 0000000..fac592d --- /dev/null +++ b/src/widgets/SampleList/index.ts @@ -0,0 +1 @@ +export { SampleList } from './ui'; diff --git a/src/widgets/SampleList/ui/SampleList/SampleList.svelte b/src/widgets/SampleList/ui/SampleList/SampleList.svelte new file mode 100644 index 0000000..112f3fb --- /dev/null +++ b/src/widgets/SampleList/ui/SampleList/SampleList.svelte @@ -0,0 +1,72 @@ + + + +{#if unifiedFontStore.isFetching || unifiedFontStore.isLoading} + (Loading...) +{/if} + + + {#snippet children({ item: font, isFullyVisible, isPartiallyVisible, proximity, index })} + + + + {/snippet} + diff --git a/src/widgets/SampleList/ui/index.ts b/src/widgets/SampleList/ui/index.ts new file mode 100644 index 0000000..d73a19d --- /dev/null +++ b/src/widgets/SampleList/ui/index.ts @@ -0,0 +1,3 @@ +import SampleList from './SampleList/SampleList.svelte'; + +export { SampleList }; diff --git a/src/widgets/TypographySettings/ui/TypographyMenu.svelte b/src/widgets/TypographySettings/ui/TypographyMenu.svelte index 2b4f4b7..8c2733f 100644 --- a/src/widgets/TypographySettings/ui/TypographyMenu.svelte +++ b/src/widgets/TypographySettings/ui/TypographyMenu.svelte @@ -1,17 +1,41 @@ + -
- - +
+ + -
diff --git a/src/widgets/index.ts b/src/widgets/index.ts new file mode 100644 index 0000000..0abb6ac --- /dev/null +++ b/src/widgets/index.ts @@ -0,0 +1,3 @@ +export { ComparisonSlider } from './ComparisonSlider'; +export { FontSearch } from './FontSearch'; +export { TypographyMenu } from './TypographySettings';