Compare commits
28 Commits
752e38adf9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b81be6614 | |||
|
|
a74abbb0b3 | ||
|
|
20accb9c93 | ||
|
|
46b9db1db3 | ||
|
|
4b017a83bb | ||
|
|
49822f8af7 | ||
|
|
338ca9b4fd | ||
|
|
99f662e2d5 | ||
|
|
5977e0a0dc | ||
|
|
2b0d8470e5 | ||
|
|
351ee9fd52 | ||
|
|
a526a51af8 | ||
|
|
fcde78abad | ||
| 26737f2f11 | |||
|
|
d9fa2bc501 | ||
|
|
5f38996665 | ||
| d70fc9f918 | |||
|
|
14dbd374ec | ||
|
|
dc6e15492a | ||
|
|
45eac0c396 | ||
|
|
ed7d31bf5c | ||
|
|
468d2e7f8c | ||
|
|
2a761b9d47 | ||
|
|
a9e4633b64 | ||
|
|
778988977f | ||
|
|
9a9ff95bf3 | ||
|
|
7517678e87 | ||
| 4281d94d66 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ node_modules
|
|||||||
/build
|
/build
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
|
# Git worktrees (isolated development branches)
|
||||||
|
.worktrees
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@chenglou/pretext": "^0.0.5",
|
||||||
"@tanstack/svelte-query": "^6.0.14"
|
"@tanstack/svelte-query": "^6.0.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +1,3 @@
|
|||||||
// Proxy API (primary)
|
export * from './api';
|
||||||
export {
|
export * from './model';
|
||||||
fetchFontsByIds,
|
export * from './ui';
|
||||||
fetchProxyFontById,
|
|
||||||
fetchProxyFonts,
|
|
||||||
} from './api/proxy/proxyFonts';
|
|
||||||
export type {
|
|
||||||
ProxyFontsParams,
|
|
||||||
ProxyFontsResponse,
|
|
||||||
} from './api/proxy/proxyFonts';
|
|
||||||
|
|
||||||
export {
|
|
||||||
normalizeFontshareFont,
|
|
||||||
normalizeFontshareFonts,
|
|
||||||
} from './lib/normalize/normalize';
|
|
||||||
export type {
|
|
||||||
// Domain types
|
|
||||||
FontCategory,
|
|
||||||
FontCollectionFilters,
|
|
||||||
FontCollectionSort,
|
|
||||||
// Store types
|
|
||||||
FontCollectionState,
|
|
||||||
FontFeatures,
|
|
||||||
FontFiles,
|
|
||||||
FontItem,
|
|
||||||
FontMetadata,
|
|
||||||
FontProvider,
|
|
||||||
// Fontshare API types
|
|
||||||
FontshareApiModel,
|
|
||||||
FontshareAxis,
|
|
||||||
FontshareDesigner,
|
|
||||||
FontshareFeature,
|
|
||||||
FontshareFont,
|
|
||||||
FontshareLink,
|
|
||||||
FontsharePublisher,
|
|
||||||
FontshareStyle,
|
|
||||||
FontshareStyleProperties,
|
|
||||||
FontshareTag,
|
|
||||||
FontshareWeight,
|
|
||||||
FontStyleUrls,
|
|
||||||
FontSubset,
|
|
||||||
FontVariant,
|
|
||||||
FontWeight,
|
|
||||||
FontWeightItalic,
|
|
||||||
// Normalization types
|
|
||||||
UnifiedFont,
|
|
||||||
UnifiedFontVariant,
|
|
||||||
} from './model';
|
|
||||||
|
|
||||||
export {
|
|
||||||
appliedFontsManager,
|
|
||||||
createUnifiedFontStore,
|
|
||||||
unifiedFontStore,
|
|
||||||
} from './model';
|
|
||||||
|
|
||||||
// Mock data helpers for Storybook and testing
|
|
||||||
export {
|
|
||||||
createCategoriesFilter,
|
|
||||||
createErrorState,
|
|
||||||
createGenericFilter,
|
|
||||||
createLoadingState,
|
|
||||||
createMockComparisonStore,
|
|
||||||
// Filter mocks
|
|
||||||
createMockFilter,
|
|
||||||
createMockFontApiResponse,
|
|
||||||
createMockFontStoreState,
|
|
||||||
// Store mocks
|
|
||||||
createMockQueryState,
|
|
||||||
createMockReactiveState,
|
|
||||||
createMockStore,
|
|
||||||
createProvidersFilter,
|
|
||||||
createSubsetsFilter,
|
|
||||||
createSuccessState,
|
|
||||||
FONTHARE_FONTS,
|
|
||||||
generateMixedCategoryFonts,
|
|
||||||
generateMockFonts,
|
|
||||||
generatePaginatedFonts,
|
|
||||||
generateSequentialFilter,
|
|
||||||
GENERIC_FILTERS,
|
|
||||||
getAllMockFonts,
|
|
||||||
getFontsByCategory,
|
|
||||||
getFontsByProvider,
|
|
||||||
GOOGLE_FONTS,
|
|
||||||
MOCK_FILTERS,
|
|
||||||
MOCK_FILTERS_ALL_SELECTED,
|
|
||||||
MOCK_FILTERS_EMPTY,
|
|
||||||
MOCK_FILTERS_SELECTED,
|
|
||||||
MOCK_FONT_STORE_STATES,
|
|
||||||
MOCK_STORES,
|
|
||||||
type MockFilterOptions,
|
|
||||||
type MockFilters,
|
|
||||||
mockFontshareFont,
|
|
||||||
type MockFontshareFontOptions,
|
|
||||||
type MockFontStoreState,
|
|
||||||
// Font mocks
|
|
||||||
mockGoogleFont,
|
|
||||||
// Types
|
|
||||||
type MockGoogleFontOptions,
|
|
||||||
type MockQueryObserverResult,
|
|
||||||
type MockQueryState,
|
|
||||||
mockUnifiedFont,
|
|
||||||
type MockUnifiedFontOptions,
|
|
||||||
UNIFIED_FONTS,
|
|
||||||
} from './lib/mocks';
|
|
||||||
|
|
||||||
export {
|
|
||||||
FontNetworkError,
|
|
||||||
FontResponseError,
|
|
||||||
} from './lib/errors/errors';
|
|
||||||
|
|
||||||
// UI elements
|
|
||||||
export {
|
|
||||||
FontApplicator,
|
|
||||||
FontVirtualList,
|
|
||||||
} from './ui';
|
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
export {
|
|
||||||
normalizeFontshareFont,
|
|
||||||
normalizeFontshareFonts,
|
|
||||||
normalizeGoogleFont,
|
|
||||||
normalizeGoogleFonts,
|
|
||||||
} from './normalize/normalize';
|
|
||||||
|
|
||||||
export { getFontUrl } from './getFontUrl/getFontUrl';
|
export { getFontUrl } from './getFontUrl/getFontUrl';
|
||||||
|
|
||||||
// Mock data helpers for Storybook and testing
|
// Mock data helpers for Storybook and testing
|
||||||
@@ -25,7 +18,6 @@ export {
|
|||||||
createProvidersFilter,
|
createProvidersFilter,
|
||||||
createSubsetsFilter,
|
createSubsetsFilter,
|
||||||
createSuccessState,
|
createSuccessState,
|
||||||
FONTHARE_FONTS,
|
|
||||||
generateMixedCategoryFonts,
|
generateMixedCategoryFonts,
|
||||||
generateMockFonts,
|
generateMockFonts,
|
||||||
generatePaginatedFonts,
|
generatePaginatedFonts,
|
||||||
@@ -34,7 +26,6 @@ export {
|
|||||||
getAllMockFonts,
|
getAllMockFonts,
|
||||||
getFontsByCategory,
|
getFontsByCategory,
|
||||||
getFontsByProvider,
|
getFontsByProvider,
|
||||||
GOOGLE_FONTS,
|
|
||||||
MOCK_FILTERS,
|
MOCK_FILTERS,
|
||||||
MOCK_FILTERS_ALL_SELECTED,
|
MOCK_FILTERS_ALL_SELECTED,
|
||||||
MOCK_FILTERS_EMPTY,
|
MOCK_FILTERS_EMPTY,
|
||||||
@@ -43,13 +34,9 @@ export {
|
|||||||
MOCK_STORES,
|
MOCK_STORES,
|
||||||
type MockFilterOptions,
|
type MockFilterOptions,
|
||||||
type MockFilters,
|
type MockFilters,
|
||||||
mockFontshareFont,
|
|
||||||
type MockFontshareFontOptions,
|
|
||||||
type MockFontStoreState,
|
type MockFontStoreState,
|
||||||
// Font mocks
|
// Font mocks
|
||||||
mockGoogleFont,
|
|
||||||
// Types
|
// Types
|
||||||
type MockGoogleFontOptions,
|
|
||||||
type MockQueryObserverResult,
|
type MockQueryObserverResult,
|
||||||
type MockQueryState,
|
type MockQueryState,
|
||||||
mockUnifiedFont,
|
mockUnifiedFont,
|
||||||
@@ -61,3 +48,6 @@ export {
|
|||||||
FontNetworkError,
|
FontNetworkError,
|
||||||
FontResponseError,
|
FontResponseError,
|
||||||
} from './errors/errors';
|
} from './errors/errors';
|
||||||
|
|
||||||
|
export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver';
|
||||||
|
export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver';
|
||||||
|
|||||||
@@ -58,29 +58,6 @@ export interface MockFilters {
|
|||||||
|
|
||||||
// FONT CATEGORIES
|
// FONT CATEGORIES
|
||||||
|
|
||||||
/**
|
|
||||||
* Google Fonts categories
|
|
||||||
*/
|
|
||||||
export const GOOGLE_CATEGORIES: Property<'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'>[] = [
|
|
||||||
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
|
|
||||||
{ id: 'serif', name: 'Serif', value: 'serif' },
|
|
||||||
{ id: 'display', name: 'Display', value: 'display' },
|
|
||||||
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
|
||||||
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fontshare categories (mapped to common naming)
|
|
||||||
*/
|
|
||||||
export const FONTHARE_CATEGORIES: Property<'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script'>[] = [
|
|
||||||
{ id: 'sans', name: 'Sans', value: 'sans' },
|
|
||||||
{ id: 'serif', name: 'Serif', value: 'serif' },
|
|
||||||
{ id: 'slab', name: 'Slab', value: 'slab' },
|
|
||||||
{ id: 'display', name: 'Display', value: 'display' },
|
|
||||||
{ id: 'handwritten', name: 'Handwritten', value: 'handwritten' },
|
|
||||||
{ id: 'script', name: 'Script', value: 'script' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified categories (combines both providers)
|
* Unified categories (combines both providers)
|
||||||
*/
|
*/
|
||||||
@@ -90,6 +67,8 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
|
|||||||
{ id: 'display', name: 'Display', value: 'display' },
|
{ id: 'display', name: 'Display', value: 'display' },
|
||||||
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
||||||
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
||||||
|
{ id: 'slab', name: 'Slab', value: 'slab' },
|
||||||
|
{ id: 'script', name: 'Script', value: 'script' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// FONT SUBSETS
|
// FONT SUBSETS
|
||||||
|
|||||||
@@ -38,11 +38,6 @@ import type {
|
|||||||
FontSubset,
|
FontSubset,
|
||||||
FontVariant,
|
FontVariant,
|
||||||
} from '$entities/Font/model/types';
|
} from '$entities/Font/model/types';
|
||||||
import type {
|
|
||||||
FontItem,
|
|
||||||
FontshareFont,
|
|
||||||
GoogleFontItem,
|
|
||||||
} from '$entities/Font/model/types';
|
|
||||||
import type {
|
import type {
|
||||||
FontFeatures,
|
FontFeatures,
|
||||||
FontMetadata,
|
FontMetadata,
|
||||||
@@ -50,351 +45,6 @@ import type {
|
|||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
} from '$entities/Font/model/types';
|
} from '$entities/Font/model/types';
|
||||||
|
|
||||||
// GOOGLE FONTS MOCKS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for creating a mock Google Font
|
|
||||||
*/
|
|
||||||
export interface MockGoogleFontOptions {
|
|
||||||
/** Font family name (default: 'Mock Font') */
|
|
||||||
family?: string;
|
|
||||||
/** Font category (default: 'sans-serif') */
|
|
||||||
category?: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
|
||||||
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
|
||||||
variants?: FontVariant[];
|
|
||||||
/** Font subsets (default: ['latin']) */
|
|
||||||
subsets?: string[];
|
|
||||||
/** Font version (default: 'v30') */
|
|
||||||
version?: string;
|
|
||||||
/** Last modified date (default: current ISO date) */
|
|
||||||
lastModified?: string;
|
|
||||||
/** Custom file URLs (if not provided, mock URLs are generated) */
|
|
||||||
files?: Partial<Record<FontVariant, string>>;
|
|
||||||
/** Popularity rank (1 = most popular) */
|
|
||||||
popularity?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default mock Google Font
|
|
||||||
*/
|
|
||||||
export function mockGoogleFont(options: MockGoogleFontOptions = {}): GoogleFontItem {
|
|
||||||
const {
|
|
||||||
family = 'Mock Font',
|
|
||||||
category = 'sans-serif',
|
|
||||||
variants = ['regular', '700', 'italic', '700italic'],
|
|
||||||
subsets = ['latin'],
|
|
||||||
version = 'v30',
|
|
||||||
lastModified = new Date().toISOString().split('T')[0],
|
|
||||||
files,
|
|
||||||
popularity = 1,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const baseUrl = `https://fonts.gstatic.com/s/${family.toLowerCase().replace(/\s+/g, '')}/${version}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
family,
|
|
||||||
category,
|
|
||||||
variants: variants as FontVariant[],
|
|
||||||
subsets,
|
|
||||||
version,
|
|
||||||
lastModified,
|
|
||||||
files: files ?? {
|
|
||||||
regular: `${baseUrl}/KFOmCnqEu92Fr1Me4W.woff2`,
|
|
||||||
'700': `${baseUrl}/KFOlCnqEu92Fr1MmWUlfBBc9.woff2`,
|
|
||||||
italic: `${baseUrl}/KFOkCnqEu92Fr1Mu51xIIzI.woff2`,
|
|
||||||
'700italic': `${baseUrl}/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2`,
|
|
||||||
},
|
|
||||||
menu: `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preset Google Font mocks
|
|
||||||
*/
|
|
||||||
export const GOOGLE_FONTS: Record<string, GoogleFontItem> = {
|
|
||||||
roboto: mockGoogleFont({
|
|
||||||
family: 'Roboto',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['100', '300', '400', '500', '700', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
|
||||||
popularity: 1,
|
|
||||||
}),
|
|
||||||
openSans: mockGoogleFont({
|
|
||||||
family: 'Open Sans',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['300', '400', '500', '600', '700', '800', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
|
||||||
popularity: 2,
|
|
||||||
}),
|
|
||||||
lato: mockGoogleFont({
|
|
||||||
family: 'Lato',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['100', '300', '400', '700', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext'],
|
|
||||||
popularity: 3,
|
|
||||||
}),
|
|
||||||
playfairDisplay: mockGoogleFont({
|
|
||||||
family: 'Playfair Display',
|
|
||||||
category: 'serif',
|
|
||||||
variants: ['400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic'],
|
|
||||||
popularity: 10,
|
|
||||||
}),
|
|
||||||
montserrat: mockGoogleFont({
|
|
||||||
family: 'Montserrat',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
|
||||||
popularity: 4,
|
|
||||||
}),
|
|
||||||
sourceSansPro: mockGoogleFont({
|
|
||||||
family: 'Source Sans Pro',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['200', '300', '400', '600', '700', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
|
||||||
popularity: 5,
|
|
||||||
}),
|
|
||||||
merriweather: mockGoogleFont({
|
|
||||||
family: 'Merriweather',
|
|
||||||
category: 'serif',
|
|
||||||
variants: ['300', '400', '700', '900', 'italic', '700italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
|
||||||
popularity: 15,
|
|
||||||
}),
|
|
||||||
robotoSlab: mockGoogleFont({
|
|
||||||
family: 'Roboto Slab',
|
|
||||||
category: 'serif',
|
|
||||||
variants: ['100', '300', '400', '500', '700', '900'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
|
||||||
popularity: 8,
|
|
||||||
}),
|
|
||||||
oswald: mockGoogleFont({
|
|
||||||
family: 'Oswald',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['200', '300', '400', '500', '600', '700'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'vietnamese'],
|
|
||||||
popularity: 6,
|
|
||||||
}),
|
|
||||||
raleway: mockGoogleFont({
|
|
||||||
family: 'Raleway',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic'],
|
|
||||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
|
||||||
popularity: 7,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// FONTHARE MOCKS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for creating a mock Fontshare font
|
|
||||||
*/
|
|
||||||
export interface MockFontshareFontOptions {
|
|
||||||
/** Font name (default: 'Mock Font') */
|
|
||||||
name?: string;
|
|
||||||
/** URL-friendly slug (default: derived from name) */
|
|
||||||
slug?: string;
|
|
||||||
/** Font category (default: 'sans') */
|
|
||||||
category?: 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script' | 'mono';
|
|
||||||
/** Script (default: 'latin') */
|
|
||||||
script?: string;
|
|
||||||
/** Whether this is a variable font (default: false) */
|
|
||||||
isVariable?: boolean;
|
|
||||||
/** Font version (default: '1.0') */
|
|
||||||
version?: string;
|
|
||||||
/** Popularity/views count (default: 1000) */
|
|
||||||
views?: number;
|
|
||||||
/** Usage tags */
|
|
||||||
tags?: string[];
|
|
||||||
/** Font weights available */
|
|
||||||
weights?: number[];
|
|
||||||
/** Publisher name */
|
|
||||||
publisher?: string;
|
|
||||||
/** Designer name */
|
|
||||||
designer?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock Fontshare style
|
|
||||||
*/
|
|
||||||
function mockFontshareStyle(
|
|
||||||
weight: number,
|
|
||||||
isItalic: boolean,
|
|
||||||
isVariable: boolean,
|
|
||||||
slug: string,
|
|
||||||
): FontshareFont['styles'][number] {
|
|
||||||
const weightLabel = weight === 400 ? 'Regular' : weight === 700 ? 'Bold' : weight.toString();
|
|
||||||
const suffix = isItalic ? 'italic' : '';
|
|
||||||
const variablePrefix = isVariable ? 'variable-' : '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `style-${weight}${isItalic ? '-italic' : ''}`,
|
|
||||||
default: weight === 400 && !isItalic,
|
|
||||||
file: `//cdn.fontshare.com/wf/${slug}-${variablePrefix}${weight}${suffix}.woff2`,
|
|
||||||
is_italic: isItalic,
|
|
||||||
is_variable: isVariable,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: isVariable ? 'Variable' + (isItalic ? ' Italic' : '') : weightLabel,
|
|
||||||
name: isVariable ? 'Variable' + (isItalic ? 'Italic' : '') : weightLabel,
|
|
||||||
native_name: null,
|
|
||||||
number: isVariable ? 0 : weight,
|
|
||||||
weight: isVariable ? 0 : weight,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default mock Fontshare font
|
|
||||||
*/
|
|
||||||
export function mockFontshareFont(options: MockFontshareFontOptions = {}): FontshareFont {
|
|
||||||
const {
|
|
||||||
name = 'Mock Font',
|
|
||||||
slug = name.toLowerCase().replace(/\s+/g, '-'),
|
|
||||||
category = 'sans',
|
|
||||||
script = 'latin',
|
|
||||||
isVariable = false,
|
|
||||||
version = '1.0',
|
|
||||||
views = 1000,
|
|
||||||
tags = [],
|
|
||||||
weights = [400, 700],
|
|
||||||
publisher = 'Mock Foundry',
|
|
||||||
designer = 'Mock Designer',
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// Generate styles based on weights and variable setting
|
|
||||||
const styles: FontshareFont['styles'] = isVariable
|
|
||||||
? [
|
|
||||||
mockFontshareStyle(0, false, true, slug),
|
|
||||||
mockFontshareStyle(0, true, true, slug),
|
|
||||||
]
|
|
||||||
: weights.flatMap(weight => [
|
|
||||||
mockFontshareStyle(weight, false, false, slug),
|
|
||||||
mockFontshareStyle(weight, true, false, slug),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `mock-${slug}`,
|
|
||||||
name,
|
|
||||||
native_name: null,
|
|
||||||
slug,
|
|
||||||
category,
|
|
||||||
script,
|
|
||||||
publisher: {
|
|
||||||
bio: `Mock publisher bio for ${publisher}`,
|
|
||||||
email: null,
|
|
||||||
id: `pub-${slug}`,
|
|
||||||
links: [],
|
|
||||||
name: publisher,
|
|
||||||
},
|
|
||||||
designers: [
|
|
||||||
{
|
|
||||||
bio: `Mock designer bio for ${designer}`,
|
|
||||||
links: [],
|
|
||||||
name: designer,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
related_families: null,
|
|
||||||
display_publisher_as_designer: false,
|
|
||||||
trials_enabled: true,
|
|
||||||
show_latin_metrics: false,
|
|
||||||
license_type: 'ofl',
|
|
||||||
languages: 'English, Spanish, French, German',
|
|
||||||
inserted_at: '2021-03-12T20:49:05Z',
|
|
||||||
story: `<p>A mock font story for ${name}.</p>`,
|
|
||||||
version,
|
|
||||||
views,
|
|
||||||
views_recent: Math.floor(views * 0.1),
|
|
||||||
is_hot: views > 5000,
|
|
||||||
is_new: views < 500,
|
|
||||||
is_shortlisted: null,
|
|
||||||
is_top: views > 10000,
|
|
||||||
axes: isVariable
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: 'Weight',
|
|
||||||
property: 'wght',
|
|
||||||
range_default: 400,
|
|
||||||
range_left: 300,
|
|
||||||
range_right: 700,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
font_tags: tags.map(name => ({ name })),
|
|
||||||
features: [],
|
|
||||||
styles,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preset Fontshare font mocks
|
|
||||||
*/
|
|
||||||
export const FONTHARE_FONTS: Record<string, FontshareFont> = {
|
|
||||||
satoshi: mockFontshareFont({
|
|
||||||
name: 'Satoshi',
|
|
||||||
slug: 'satoshi',
|
|
||||||
category: 'sans',
|
|
||||||
isVariable: true,
|
|
||||||
views: 15000,
|
|
||||||
tags: ['Branding', 'Logos', 'Editorial'],
|
|
||||||
publisher: 'Indian Type Foundry',
|
|
||||||
designer: 'Denis Shelabovets',
|
|
||||||
}),
|
|
||||||
generalSans: mockFontshareFont({
|
|
||||||
name: 'General Sans',
|
|
||||||
slug: 'general-sans',
|
|
||||||
category: 'sans',
|
|
||||||
isVariable: true,
|
|
||||||
views: 12000,
|
|
||||||
tags: ['UI', 'Branding', 'Display'],
|
|
||||||
publisher: 'Indestructible Type',
|
|
||||||
designer: 'Eugene Tantsur',
|
|
||||||
}),
|
|
||||||
clashDisplay: mockFontshareFont({
|
|
||||||
name: 'Clash Display',
|
|
||||||
slug: 'clash-display',
|
|
||||||
category: 'display',
|
|
||||||
isVariable: false,
|
|
||||||
views: 8000,
|
|
||||||
tags: ['Headlines', 'Posters', 'Branding'],
|
|
||||||
weights: [400, 500, 600, 700],
|
|
||||||
publisher: 'Letterogika',
|
|
||||||
designer: 'Matěj Trnka',
|
|
||||||
}),
|
|
||||||
fonta: mockFontshareFont({
|
|
||||||
name: 'Fonta',
|
|
||||||
slug: 'fonta',
|
|
||||||
category: 'serif',
|
|
||||||
isVariable: false,
|
|
||||||
views: 5000,
|
|
||||||
tags: ['Editorial', 'Books', 'Magazines'],
|
|
||||||
weights: [300, 400, 500, 600, 700],
|
|
||||||
publisher: 'Fonta',
|
|
||||||
designer: 'Alexei Vanyashin',
|
|
||||||
}),
|
|
||||||
aileron: mockFontshareFont({
|
|
||||||
name: 'Aileron',
|
|
||||||
slug: 'aileron',
|
|
||||||
category: 'sans',
|
|
||||||
isVariable: false,
|
|
||||||
views: 3000,
|
|
||||||
tags: ['Display', 'Headlines'],
|
|
||||||
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
|
|
||||||
publisher: 'Sorkin Type',
|
|
||||||
designer: 'Sorkin Type',
|
|
||||||
}),
|
|
||||||
beVietnamPro: mockFontshareFont({
|
|
||||||
name: 'Be Vietnam Pro',
|
|
||||||
slug: 'be-vietnam-pro',
|
|
||||||
category: 'sans',
|
|
||||||
isVariable: true,
|
|
||||||
views: 20000,
|
|
||||||
tags: ['UI', 'App', 'Web'],
|
|
||||||
publisher: 'ildefox',
|
|
||||||
designer: 'Manh Nguyen',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// UNIFIED FONT MOCKS
|
// UNIFIED FONT MOCKS
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,17 +26,11 @@
|
|||||||
|
|
||||||
// Font mocks
|
// Font mocks
|
||||||
export {
|
export {
|
||||||
FONTHARE_FONTS,
|
|
||||||
generateMixedCategoryFonts,
|
generateMixedCategoryFonts,
|
||||||
generateMockFonts,
|
generateMockFonts,
|
||||||
getAllMockFonts,
|
getAllMockFonts,
|
||||||
getFontsByCategory,
|
getFontsByCategory,
|
||||||
getFontsByProvider,
|
getFontsByProvider,
|
||||||
GOOGLE_FONTS,
|
|
||||||
mockFontshareFont,
|
|
||||||
type MockFontshareFontOptions,
|
|
||||||
mockGoogleFont,
|
|
||||||
type MockGoogleFontOptions,
|
|
||||||
mockUnifiedFont,
|
mockUnifiedFont,
|
||||||
type MockUnifiedFontOptions,
|
type MockUnifiedFontOptions,
|
||||||
UNIFIED_FONTS,
|
UNIFIED_FONTS,
|
||||||
@@ -51,10 +45,8 @@ export {
|
|||||||
createSubsetsFilter,
|
createSubsetsFilter,
|
||||||
FONT_PROVIDERS,
|
FONT_PROVIDERS,
|
||||||
FONT_SUBSETS,
|
FONT_SUBSETS,
|
||||||
FONTHARE_CATEGORIES,
|
|
||||||
generateSequentialFilter,
|
generateSequentialFilter,
|
||||||
GENERIC_FILTERS,
|
GENERIC_FILTERS,
|
||||||
GOOGLE_CATEGORIES,
|
|
||||||
MOCK_FILTERS,
|
MOCK_FILTERS,
|
||||||
MOCK_FILTERS_ALL_SELECTED,
|
MOCK_FILTERS_ALL_SELECTED,
|
||||||
MOCK_FILTERS_EMPTY,
|
MOCK_FILTERS_EMPTY,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
|
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
|
||||||
*
|
*
|
||||||
* // Use preset stores
|
* // Use preset stores
|
||||||
* const mockFontStore = MOCK_STORES.unifiedFontStore();
|
* const mockFontStore = createMockFontStore();
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -459,6 +459,117 @@ export const MOCK_STORES = {
|
|||||||
resetFilters: () => {},
|
resetFilters: () => {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Create a mock FontStore object
|
||||||
|
* Matches FontStore's public API for Storybook use
|
||||||
|
*/
|
||||||
|
fontStore: (config: {
|
||||||
|
fonts?: UnifiedFont[];
|
||||||
|
total?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
error?: Error | null;
|
||||||
|
hasMore?: boolean;
|
||||||
|
page?: number;
|
||||||
|
} = {}) => {
|
||||||
|
const {
|
||||||
|
fonts: mockFonts = Object.values(UNIFIED_FONTS).slice(0, 5),
|
||||||
|
total: mockTotal = mockFonts.length,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
isLoading = false,
|
||||||
|
isFetching = false,
|
||||||
|
isError = false,
|
||||||
|
error = null,
|
||||||
|
hasMore = false,
|
||||||
|
page = 1,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(mockTotal / limit);
|
||||||
|
const state = {
|
||||||
|
params: { limit },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State getters
|
||||||
|
get params() {
|
||||||
|
return state.params;
|
||||||
|
},
|
||||||
|
get fonts() {
|
||||||
|
return mockFonts;
|
||||||
|
},
|
||||||
|
get isLoading() {
|
||||||
|
return isLoading;
|
||||||
|
},
|
||||||
|
get isFetching() {
|
||||||
|
return isFetching;
|
||||||
|
},
|
||||||
|
get isError() {
|
||||||
|
return isError;
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
get isEmpty() {
|
||||||
|
return !isLoading && !isFetching && mockFonts.length === 0;
|
||||||
|
},
|
||||||
|
get pagination() {
|
||||||
|
return {
|
||||||
|
total: mockTotal,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// Category getters
|
||||||
|
get sansSerifFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'sans-serif');
|
||||||
|
},
|
||||||
|
get serifFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'serif');
|
||||||
|
},
|
||||||
|
get displayFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'display');
|
||||||
|
},
|
||||||
|
get handwritingFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'handwriting');
|
||||||
|
},
|
||||||
|
get monospaceFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'monospace');
|
||||||
|
},
|
||||||
|
// Lifecycle
|
||||||
|
destroy() {},
|
||||||
|
// Param management
|
||||||
|
setParams(_updates: Record<string, unknown>) {},
|
||||||
|
invalidate() {},
|
||||||
|
// Async operations (no-op for Storybook)
|
||||||
|
refetch() {},
|
||||||
|
prefetch() {},
|
||||||
|
cancel() {},
|
||||||
|
getCachedData() {
|
||||||
|
return mockFonts.length > 0 ? mockFonts : undefined;
|
||||||
|
},
|
||||||
|
setQueryData() {},
|
||||||
|
// Filter shortcuts
|
||||||
|
setProviders() {},
|
||||||
|
setCategories() {},
|
||||||
|
setSubsets() {},
|
||||||
|
setSearch() {},
|
||||||
|
setSort() {},
|
||||||
|
// Pagination navigation
|
||||||
|
nextPage() {},
|
||||||
|
prevPage() {},
|
||||||
|
goToPage() {},
|
||||||
|
setLimit(_limit: number) {
|
||||||
|
state.params.limit = _limit;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// REACTIVE STATE MOCKS
|
// REACTIVE STATE MOCKS
|
||||||
|
|||||||
@@ -1,582 +0,0 @@
|
|||||||
import {
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
} from 'vitest';
|
|
||||||
import type {
|
|
||||||
FontItem,
|
|
||||||
FontshareFont,
|
|
||||||
GoogleFontItem,
|
|
||||||
} from '../../model/types';
|
|
||||||
import {
|
|
||||||
normalizeFontshareFont,
|
|
||||||
normalizeFontshareFonts,
|
|
||||||
normalizeGoogleFont,
|
|
||||||
normalizeGoogleFonts,
|
|
||||||
} from './normalize';
|
|
||||||
|
|
||||||
describe('Font Normalization', () => {
|
|
||||||
describe('normalizeGoogleFont', () => {
|
|
||||||
const mockGoogleFont: GoogleFontItem = {
|
|
||||||
family: 'Roboto',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['regular', '700', 'italic', '700italic'],
|
|
||||||
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',
|
|
||||||
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
|
||||||
'700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
|
||||||
},
|
|
||||||
version: 'v30',
|
|
||||||
lastModified: '2022-01-01',
|
|
||||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('normalizes Google Font to unified model', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.id).toBe('Roboto');
|
|
||||||
expect(result.name).toBe('Roboto');
|
|
||||||
expect(result.provider).toBe('google');
|
|
||||||
expect(result.category).toBe('sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps font variants correctly', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.variants).toEqual(['regular', '700', 'italic', '700italic']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps subsets correctly', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.subsets).toContain('latin');
|
|
||||||
expect(result.subsets).toContain('latin-ext');
|
|
||||||
expect(result.subsets).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps style URLs correctly', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.styles.regular).toBeDefined();
|
|
||||||
expect(result.styles.bold).toBeDefined();
|
|
||||||
expect(result.styles.italic).toBeDefined();
|
|
||||||
expect(result.styles.boldItalic).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes metadata', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.metadata.cachedAt).toBeDefined();
|
|
||||||
expect(result.metadata.version).toBe('v30');
|
|
||||||
expect(result.metadata.lastModified).toBe('2022-01-01');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks Google Fonts as non-variable', () => {
|
|
||||||
const result = normalizeGoogleFont(mockGoogleFont);
|
|
||||||
|
|
||||||
expect(result.features.isVariable).toBe(false);
|
|
||||||
expect(result.features.tags).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles sans-serif category', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'sans-serif' };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles serif category', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'serif' };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles display category', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'display' };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('display');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles handwriting category', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'handwriting' };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('handwriting');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles cursive category (maps to handwriting)', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'cursive' as any };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('handwriting');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles monospace category', () => {
|
|
||||||
const font: FontItem = { ...mockGoogleFont, category: 'monospace' };
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('monospace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters invalid subsets', () => {
|
|
||||||
const font = {
|
|
||||||
...mockGoogleFont,
|
|
||||||
subsets: ['latin', 'latin-ext', 'invalid-subset'],
|
|
||||||
};
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.subsets).not.toContain('invalid-subset');
|
|
||||||
expect(result.subsets).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps variant weights correctly', () => {
|
|
||||||
const font: GoogleFontItem = {
|
|
||||||
...mockGoogleFont,
|
|
||||||
variants: ['regular', '100', '400', '700', '900'] as any,
|
|
||||||
};
|
|
||||||
const result = normalizeGoogleFont(font);
|
|
||||||
|
|
||||||
expect(result.variants).toContain('regular');
|
|
||||||
expect(result.variants).toContain('100');
|
|
||||||
expect(result.variants).toContain('400');
|
|
||||||
expect(result.variants).toContain('700');
|
|
||||||
expect(result.variants).toContain('900');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeFontshareFont', () => {
|
|
||||||
const mockFontshareFont: FontshareFont = {
|
|
||||||
id: '20e9fcdc-1e41-4559-a43d-1ede0adc8896',
|
|
||||||
name: 'Satoshi',
|
|
||||||
native_name: null,
|
|
||||||
slug: 'satoshi',
|
|
||||||
category: 'Sans',
|
|
||||||
script: 'latin',
|
|
||||||
publisher: {
|
|
||||||
bio: 'Indian Type Foundry',
|
|
||||||
email: null,
|
|
||||||
id: 'test-id',
|
|
||||||
links: [],
|
|
||||||
name: 'Indian Type Foundry',
|
|
||||||
},
|
|
||||||
designers: [
|
|
||||||
{
|
|
||||||
bio: 'Designer bio',
|
|
||||||
links: [],
|
|
||||||
name: 'Designer Name',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
related_families: null,
|
|
||||||
display_publisher_as_designer: false,
|
|
||||||
trials_enabled: true,
|
|
||||||
show_latin_metrics: false,
|
|
||||||
license_type: 'itf_ffl',
|
|
||||||
languages: 'Afar, Afrikaans',
|
|
||||||
inserted_at: '2021-03-12T20:49:05Z',
|
|
||||||
story: '<p>Font story</p>',
|
|
||||||
version: '1.0',
|
|
||||||
views: 10000,
|
|
||||||
views_recent: 500,
|
|
||||||
is_hot: true,
|
|
||||||
is_new: false,
|
|
||||||
is_shortlisted: false,
|
|
||||||
is_top: true,
|
|
||||||
axes: [],
|
|
||||||
font_tags: [
|
|
||||||
{ name: 'Branding' },
|
|
||||||
{ name: 'Logos' },
|
|
||||||
],
|
|
||||||
features: [
|
|
||||||
{
|
|
||||||
name: 'Alternate t',
|
|
||||||
on_by_default: false,
|
|
||||||
tag: 'ss01',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
styles: [
|
|
||||||
{
|
|
||||||
id: 'style-id-1',
|
|
||||||
default: true,
|
|
||||||
file: '//cdn.fontshare.com/wf/satoshi.woff2',
|
|
||||||
is_italic: false,
|
|
||||||
is_variable: false,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Regular',
|
|
||||||
name: 'Regular',
|
|
||||||
native_name: null,
|
|
||||||
number: 400,
|
|
||||||
weight: 400,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'style-id-2',
|
|
||||||
default: false,
|
|
||||||
file: '//cdn.fontshare.com/wf/satoshi-bold.woff2',
|
|
||||||
is_italic: false,
|
|
||||||
is_variable: false,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Bold',
|
|
||||||
name: 'Bold',
|
|
||||||
native_name: null,
|
|
||||||
number: 700,
|
|
||||||
weight: 700,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'style-id-3',
|
|
||||||
default: false,
|
|
||||||
file: '//cdn.fontshare.com/wf/satoshi-italic.woff2',
|
|
||||||
is_italic: true,
|
|
||||||
is_variable: false,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Regular',
|
|
||||||
name: 'Regular',
|
|
||||||
native_name: null,
|
|
||||||
number: 400,
|
|
||||||
weight: 400,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'style-id-4',
|
|
||||||
default: false,
|
|
||||||
file: '//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
|
||||||
is_italic: true,
|
|
||||||
is_variable: false,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Bold',
|
|
||||||
name: 'Bold',
|
|
||||||
native_name: null,
|
|
||||||
number: 700,
|
|
||||||
weight: 700,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
it('normalizes Fontshare font to unified model', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.id).toBe('satoshi');
|
|
||||||
expect(result.name).toBe('Satoshi');
|
|
||||||
expect(result.provider).toBe('fontshare');
|
|
||||||
expect(result.category).toBe('sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses slug as unique identifier', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.id).toBe('satoshi');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts variant names from styles', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.variants).toContain('Regular');
|
|
||||||
expect(result.variants).toContain('Bold');
|
|
||||||
expect(result.variants).toContain('Regularitalic');
|
|
||||||
expect(result.variants).toContain('Bolditalic');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps Fontshare Sans to sans-serif category', () => {
|
|
||||||
const font = { ...mockFontshareFont, category: 'Sans' };
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps Fontshare Serif to serif category', () => {
|
|
||||||
const font = { ...mockFontshareFont, category: 'Serif' };
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps Fontshare Display to display category', () => {
|
|
||||||
const font = { ...mockFontshareFont, category: 'Display' };
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('display');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps Fontshare Script to handwriting category', () => {
|
|
||||||
const font = { ...mockFontshareFont, category: 'Script' };
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('handwriting');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps Fontshare Mono to monospace category', () => {
|
|
||||||
const font = { ...mockFontshareFont, category: 'Mono' };
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('monospace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps style URLs correctly', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.styles.regular).toBe('//cdn.fontshare.com/wf/satoshi.woff2');
|
|
||||||
expect(result.styles.bold).toBe('//cdn.fontshare.com/wf/satoshi-bold.woff2');
|
|
||||||
expect(result.styles.italic).toBe('//cdn.fontshare.com/wf/satoshi-italic.woff2');
|
|
||||||
expect(result.styles.boldItalic).toBe(
|
|
||||||
'//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles variable fonts', () => {
|
|
||||||
const variableFont: FontshareFont = {
|
|
||||||
...mockFontshareFont,
|
|
||||||
axes: [
|
|
||||||
{
|
|
||||||
name: 'wght',
|
|
||||||
property: 'wght',
|
|
||||||
range_default: 400,
|
|
||||||
range_left: 300,
|
|
||||||
range_right: 900,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
styles: [
|
|
||||||
{
|
|
||||||
id: 'var-style',
|
|
||||||
default: true,
|
|
||||||
file: '//cdn.fontshare.com/wf/satoshi-variable.woff2',
|
|
||||||
is_italic: false,
|
|
||||||
is_variable: true,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Variable',
|
|
||||||
name: 'Variable',
|
|
||||||
native_name: null,
|
|
||||||
number: 0,
|
|
||||||
weight: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = normalizeFontshareFont(variableFont);
|
|
||||||
|
|
||||||
expect(result.features.isVariable).toBe(true);
|
|
||||||
expect(result.features.axes).toHaveLength(1);
|
|
||||||
expect(result.features.axes?.[0].name).toBe('wght');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts font tags', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.features.tags).toContain('Branding');
|
|
||||||
expect(result.features.tags).toContain('Logos');
|
|
||||||
expect(result.features.tags).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes popularity from views', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.metadata.popularity).toBe(10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes metadata', () => {
|
|
||||||
const result = normalizeFontshareFont(mockFontshareFont);
|
|
||||||
|
|
||||||
expect(result.metadata.cachedAt).toBeDefined();
|
|
||||||
expect(result.metadata.version).toBe('1.0');
|
|
||||||
expect(result.metadata.lastModified).toBe('2021-03-12T20:49:05Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles missing subsets gracefully', () => {
|
|
||||||
const font = {
|
|
||||||
...mockFontshareFont,
|
|
||||||
script: 'invalid-script',
|
|
||||||
};
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.subsets).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty tags', () => {
|
|
||||||
const font = {
|
|
||||||
...mockFontshareFont,
|
|
||||||
font_tags: [],
|
|
||||||
};
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.features.tags).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty axes', () => {
|
|
||||||
const font = {
|
|
||||||
...mockFontshareFont,
|
|
||||||
axes: [],
|
|
||||||
};
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.features.isVariable).toBe(false);
|
|
||||||
expect(result.features.axes).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeGoogleFonts', () => {
|
|
||||||
it('normalizes array of Google Fonts', () => {
|
|
||||||
const fonts: GoogleFontItem[] = [
|
|
||||||
{
|
|
||||||
family: 'Roboto',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['regular'],
|
|
||||||
subsets: ['latin'],
|
|
||||||
files: { regular: 'url' },
|
|
||||||
version: 'v1',
|
|
||||||
lastModified: '2022-01-01',
|
|
||||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
family: 'Open Sans',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['regular'],
|
|
||||||
subsets: ['latin'],
|
|
||||||
files: { regular: 'url' },
|
|
||||||
version: 'v1',
|
|
||||||
lastModified: '2022-01-01',
|
|
||||||
menu: 'https://fonts.googleapis.com/css2?family=Open+Sans',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = normalizeGoogleFonts(fonts);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0].name).toBe('Roboto');
|
|
||||||
expect(result[1].name).toBe('Open Sans');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty array for empty input', () => {
|
|
||||||
const result = normalizeGoogleFonts([]);
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeFontshareFonts', () => {
|
|
||||||
it('normalizes array of Fontshare fonts', () => {
|
|
||||||
const fonts: FontshareFont[] = [
|
|
||||||
{
|
|
||||||
...mockMinimalFontshareFont('font1', 'Font 1'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...mockMinimalFontshareFont('font2', 'Font 2'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = normalizeFontshareFonts(fonts);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0].name).toBe('Font 1');
|
|
||||||
expect(result[1].name).toBe('Font 2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty array for empty input', () => {
|
|
||||||
const result = normalizeFontshareFonts([]);
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('handles Google Font with missing optional fields', () => {
|
|
||||||
const font: Partial<GoogleFontItem> = {
|
|
||||||
family: 'Test Font',
|
|
||||||
category: 'sans-serif',
|
|
||||||
variants: ['regular'],
|
|
||||||
subsets: ['latin'],
|
|
||||||
files: { regular: 'url' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = normalizeGoogleFont(font as GoogleFontItem);
|
|
||||||
|
|
||||||
expect(result.id).toBe('Test Font');
|
|
||||||
expect(result.metadata.version).toBeUndefined();
|
|
||||||
expect(result.metadata.lastModified).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles Fontshare font with minimal data', () => {
|
|
||||||
const result = normalizeFontshareFont(mockMinimalFontshareFont('slug', 'Name'));
|
|
||||||
|
|
||||||
expect(result.id).toBe('slug');
|
|
||||||
expect(result.name).toBe('Name');
|
|
||||||
expect(result.provider).toBe('fontshare');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles unknown Fontshare category', () => {
|
|
||||||
const font = {
|
|
||||||
...mockMinimalFontshareFont('slug', 'Name'),
|
|
||||||
category: 'Unknown Category',
|
|
||||||
};
|
|
||||||
const result = normalizeFontshareFont(font);
|
|
||||||
|
|
||||||
expect(result.category).toBe('sans-serif'); // fallback
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to create minimal Fontshare font mock
|
|
||||||
*/
|
|
||||||
function mockMinimalFontshareFont(slug: string, name: string): FontshareFont {
|
|
||||||
return {
|
|
||||||
id: 'test-id',
|
|
||||||
name,
|
|
||||||
native_name: null,
|
|
||||||
slug,
|
|
||||||
category: 'Sans',
|
|
||||||
script: 'latin',
|
|
||||||
publisher: {
|
|
||||||
bio: '',
|
|
||||||
email: null,
|
|
||||||
id: '',
|
|
||||||
links: [],
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
designers: [],
|
|
||||||
related_families: null,
|
|
||||||
display_publisher_as_designer: false,
|
|
||||||
trials_enabled: false,
|
|
||||||
show_latin_metrics: false,
|
|
||||||
license_type: '',
|
|
||||||
languages: '',
|
|
||||||
inserted_at: '',
|
|
||||||
story: '',
|
|
||||||
version: '1.0',
|
|
||||||
views: 0,
|
|
||||||
views_recent: 0,
|
|
||||||
is_hot: false,
|
|
||||||
is_new: false,
|
|
||||||
is_shortlisted: null,
|
|
||||||
is_top: false,
|
|
||||||
axes: [],
|
|
||||||
font_tags: [],
|
|
||||||
features: [],
|
|
||||||
styles: [
|
|
||||||
{
|
|
||||||
id: 'style-id',
|
|
||||||
default: true,
|
|
||||||
file: '//cdn.fontshare.com/wf/test.woff2',
|
|
||||||
is_italic: false,
|
|
||||||
is_variable: false,
|
|
||||||
properties: {},
|
|
||||||
weight: {
|
|
||||||
label: 'Regular',
|
|
||||||
name: 'Regular',
|
|
||||||
native_name: null,
|
|
||||||
number: 400,
|
|
||||||
weight: 400,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
/**
|
|
||||||
* Normalize fonts from Google Fonts and Fontshare to unified model
|
|
||||||
*
|
|
||||||
* Transforms provider-specific font data into a common interface
|
|
||||||
* for consistent handling across the application.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
FontCategory,
|
|
||||||
FontStyleUrls,
|
|
||||||
FontSubset,
|
|
||||||
FontshareFont,
|
|
||||||
GoogleFontItem,
|
|
||||||
UnifiedFont,
|
|
||||||
UnifiedFontVariant,
|
|
||||||
} from '../../model/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map Google Fonts category to unified FontCategory
|
|
||||||
*/
|
|
||||||
function mapGoogleCategory(category: string): FontCategory {
|
|
||||||
const normalized = category.toLowerCase();
|
|
||||||
if (normalized.includes('sans-serif')) {
|
|
||||||
return 'sans-serif';
|
|
||||||
}
|
|
||||||
if (normalized.includes('serif')) {
|
|
||||||
return 'serif';
|
|
||||||
}
|
|
||||||
if (normalized.includes('display')) {
|
|
||||||
return 'display';
|
|
||||||
}
|
|
||||||
if (normalized.includes('handwriting') || normalized.includes('cursive')) {
|
|
||||||
return 'handwriting';
|
|
||||||
}
|
|
||||||
if (normalized.includes('monospace')) {
|
|
||||||
return 'monospace';
|
|
||||||
}
|
|
||||||
// Default fallback
|
|
||||||
return 'sans-serif';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map Fontshare category to unified FontCategory
|
|
||||||
*/
|
|
||||||
function mapFontshareCategory(category: string): FontCategory {
|
|
||||||
const normalized = category.toLowerCase();
|
|
||||||
if (normalized === 'sans' || normalized === 'sans-serif') {
|
|
||||||
return 'sans-serif';
|
|
||||||
}
|
|
||||||
if (normalized === 'serif') {
|
|
||||||
return 'serif';
|
|
||||||
}
|
|
||||||
if (normalized === 'display') {
|
|
||||||
return 'display';
|
|
||||||
}
|
|
||||||
if (normalized === 'script') {
|
|
||||||
return 'handwriting';
|
|
||||||
}
|
|
||||||
if (normalized === 'mono' || normalized === 'monospace') {
|
|
||||||
return 'monospace';
|
|
||||||
}
|
|
||||||
// Default fallback
|
|
||||||
return 'sans-serif';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map Google subset to unified FontSubset
|
|
||||||
*/
|
|
||||||
function mapGoogleSubset(subset: string): FontSubset | null {
|
|
||||||
const validSubsets: FontSubset[] = [
|
|
||||||
'latin',
|
|
||||||
'latin-ext',
|
|
||||||
'cyrillic',
|
|
||||||
'greek',
|
|
||||||
'arabic',
|
|
||||||
'devanagari',
|
|
||||||
];
|
|
||||||
return validSubsets.includes(subset as FontSubset)
|
|
||||||
? (subset as FontSubset)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map Fontshare script to unified FontSubset
|
|
||||||
*/
|
|
||||||
function mapFontshareScript(script: string): FontSubset | null {
|
|
||||||
const normalized = script.toLowerCase();
|
|
||||||
const mapping: Record<string, FontSubset | null> = {
|
|
||||||
latin: 'latin',
|
|
||||||
'latin-ext': 'latin-ext',
|
|
||||||
cyrillic: 'cyrillic',
|
|
||||||
greek: 'greek',
|
|
||||||
arabic: 'arabic',
|
|
||||||
devanagari: 'devanagari',
|
|
||||||
};
|
|
||||||
return mapping[normalized] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize Google Font to unified model
|
|
||||||
*
|
|
||||||
* @param apiFont - Font item from Google Fonts API
|
|
||||||
* @returns Unified font model
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const roboto = normalizeGoogleFont({
|
|
||||||
* family: 'Roboto',
|
|
||||||
* category: 'sans-serif',
|
|
||||||
* variants: ['regular', '700'],
|
|
||||||
* subsets: ['latin', 'latin-ext'],
|
|
||||||
* files: { regular: '...', '700': '...' }
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* console.log(roboto.id); // 'Roboto'
|
|
||||||
* console.log(roboto.provider); // 'google'
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function normalizeGoogleFont(apiFont: GoogleFontItem): UnifiedFont {
|
|
||||||
const category = mapGoogleCategory(apiFont.category);
|
|
||||||
const subsets = apiFont.subsets
|
|
||||||
.map(mapGoogleSubset)
|
|
||||||
.filter((subset): subset is FontSubset => subset !== null);
|
|
||||||
|
|
||||||
// Map variant files to style URLs
|
|
||||||
const styles: FontStyleUrls = {};
|
|
||||||
for (const [variant, url] of Object.entries(apiFont.files)) {
|
|
||||||
const urlString = url as string; // Type assertion for Record<string, string>
|
|
||||||
if (variant === 'regular' || variant === '400') {
|
|
||||||
styles.regular = urlString;
|
|
||||||
} else if (variant === 'italic' || variant === '400italic') {
|
|
||||||
styles.italic = urlString;
|
|
||||||
} else if (variant === 'bold' || variant === '700') {
|
|
||||||
styles.bold = urlString;
|
|
||||||
} else if (variant === 'bolditalic' || variant === '700italic') {
|
|
||||||
styles.boldItalic = urlString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: apiFont.family,
|
|
||||||
name: apiFont.family,
|
|
||||||
provider: 'google',
|
|
||||||
category,
|
|
||||||
subsets,
|
|
||||||
variants: apiFont.variants,
|
|
||||||
styles,
|
|
||||||
metadata: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
version: apiFont.version,
|
|
||||||
lastModified: apiFont.lastModified,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
isVariable: false, // Google Fonts doesn't expose variable font info
|
|
||||||
tags: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize Fontshare font to unified model
|
|
||||||
*
|
|
||||||
* @param apiFont - Font item from Fontshare API
|
|
||||||
* @returns Unified font model
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const satoshi = normalizeFontshareFont({
|
|
||||||
* id: 'uuid',
|
|
||||||
* name: 'Satoshi',
|
|
||||||
* slug: 'satoshi',
|
|
||||||
* category: 'Sans',
|
|
||||||
* script: 'latin',
|
|
||||||
* styles: [ ... ]
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* console.log(satoshi.id); // 'satoshi'
|
|
||||||
* console.log(satoshi.provider); // 'fontshare'
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
|
|
||||||
const category = mapFontshareCategory(apiFont.category);
|
|
||||||
const subset = mapFontshareScript(apiFont.script);
|
|
||||||
const subsets = subset ? [subset] : [];
|
|
||||||
|
|
||||||
// Extract variant names from styles
|
|
||||||
const variants = apiFont.styles.map(style => {
|
|
||||||
const weightLabel = style.weight.label;
|
|
||||||
const isItalic = style.is_italic;
|
|
||||||
return (isItalic ? `${weightLabel}italic` : weightLabel) as UnifiedFontVariant;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map styles to URLs
|
|
||||||
const styles: FontStyleUrls = {};
|
|
||||||
for (const style of apiFont.styles) {
|
|
||||||
if (style.is_variable) {
|
|
||||||
// Variable font - store as primary variant
|
|
||||||
styles.regular = style.file;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const weight = style.weight.number;
|
|
||||||
const isItalic = style.is_italic;
|
|
||||||
|
|
||||||
if (weight === 400 && !isItalic) {
|
|
||||||
styles.regular = style.file;
|
|
||||||
} else if (weight === 400 && isItalic) {
|
|
||||||
styles.italic = style.file;
|
|
||||||
} else if (weight >= 700 && !isItalic) {
|
|
||||||
styles.bold = style.file;
|
|
||||||
} else if (weight >= 700 && isItalic) {
|
|
||||||
styles.boldItalic = style.file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract variable font axes
|
|
||||||
const axes = apiFont.axes.map(axis => ({
|
|
||||||
name: axis.name,
|
|
||||||
property: axis.property,
|
|
||||||
default: axis.range_default,
|
|
||||||
min: axis.range_left,
|
|
||||||
max: axis.range_right,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Extract tags
|
|
||||||
const tags = apiFont.font_tags.map(tag => tag.name);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: apiFont.slug,
|
|
||||||
name: apiFont.name,
|
|
||||||
provider: 'fontshare',
|
|
||||||
category,
|
|
||||||
subsets,
|
|
||||||
variants,
|
|
||||||
styles,
|
|
||||||
metadata: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
version: apiFont.version,
|
|
||||||
lastModified: apiFont.inserted_at,
|
|
||||||
popularity: apiFont.views,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
isVariable: apiFont.axes.length > 0,
|
|
||||||
axes: axes.length > 0 ? axes : undefined,
|
|
||||||
tags: tags.length > 0 ? tags : undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize multiple Google Fonts to unified model
|
|
||||||
*
|
|
||||||
* @param apiFonts - Array of Google Font items
|
|
||||||
* @returns Array of unified fonts
|
|
||||||
*/
|
|
||||||
export function normalizeGoogleFonts(
|
|
||||||
apiFonts: GoogleFontItem[],
|
|
||||||
): UnifiedFont[] {
|
|
||||||
return apiFonts.map(normalizeGoogleFont);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize multiple Fontshare fonts to unified model
|
|
||||||
*
|
|
||||||
* @param apiFonts - Array of Fontshare font items
|
|
||||||
* @returns Array of unified fonts
|
|
||||||
*/
|
|
||||||
export function normalizeFontshareFonts(
|
|
||||||
apiFonts: FontshareFont[],
|
|
||||||
): UnifiedFont[] {
|
|
||||||
return apiFonts.map(normalizeFontshareFont);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export UnifiedFont for backward compatibility
|
|
||||||
export type { UnifiedFont } from '../../model/types/normalize';
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { TextLayoutEngine } from '$shared/lib';
|
||||||
|
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
||||||
|
import { clearCache } from '@chenglou/pretext';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import type { FontLoadStatus } from '../../model/types';
|
||||||
|
import { mockUnifiedFont } from '../mocks';
|
||||||
|
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
|
||||||
|
|
||||||
|
// Fixed-width canvas mock: every character is 10px wide regardless of font.
|
||||||
|
// This makes wrapping math predictable: N chars × 10px = N×10 total width.
|
||||||
|
const CHAR_WIDTH = 10;
|
||||||
|
const LINE_HEIGHT = 20;
|
||||||
|
const CONTAINER_WIDTH = 200;
|
||||||
|
const CONTENT_PADDING_X = 32; // p-4 × 2 sides = 32px
|
||||||
|
const CHROME_HEIGHT = 56;
|
||||||
|
const FALLBACK_HEIGHT = 220;
|
||||||
|
const FONT_SIZE_PX = 16;
|
||||||
|
|
||||||
|
describe('createFontRowSizeResolver', () => {
|
||||||
|
let statusMap: Map<string, FontLoadStatus>;
|
||||||
|
let getStatus: (key: string) => FontLoadStatus | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
installCanvasMock((_font, text) => text.length * CHAR_WIDTH);
|
||||||
|
clearCache();
|
||||||
|
statusMap = new Map();
|
||||||
|
getStatus = key => statusMap.get(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeResolver(overrides?: Partial<Parameters<typeof createFontRowSizeResolver>[0]>) {
|
||||||
|
const font = mockUnifiedFont({ id: 'inter', name: 'Inter' });
|
||||||
|
return {
|
||||||
|
font,
|
||||||
|
resolver: createFontRowSizeResolver({
|
||||||
|
getFonts: () => [font],
|
||||||
|
getWeight: () => 400,
|
||||||
|
getPreviewText: () => 'Hello',
|
||||||
|
getContainerWidth: () => CONTAINER_WIDTH,
|
||||||
|
getFontSizePx: () => FONT_SIZE_PX,
|
||||||
|
getLineHeightPx: () => LINE_HEIGHT,
|
||||||
|
getStatus,
|
||||||
|
contentHorizontalPadding: CONTENT_PADDING_X,
|
||||||
|
chromeHeight: CHROME_HEIGHT,
|
||||||
|
fallbackHeight: FALLBACK_HEIGHT,
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns fallbackHeight when font status is undefined', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight when font status is "loading"', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
statusMap.set('inter@400', 'loading');
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight when font status is "error"', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
statusMap.set('inter@400', 'error');
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight when containerWidth is 0', () => {
|
||||||
|
const { resolver } = makeResolver({ getContainerWidth: () => 0 });
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight when previewText is empty', () => {
|
||||||
|
const { resolver } = makeResolver({ getPreviewText: () => '' });
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight for out-of-bounds rowIndex', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
expect(resolver(99)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns computed height (totalHeight + chromeHeight) when font is loaded', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
|
// 'Hello' = 5 chars × 10px = 50px. contentWidth = 200 - 32 = 168px. Fits on one line.
|
||||||
|
// totalHeight = 1 × LINE_HEIGHT = 20. result = 20 + CHROME_HEIGHT = 76.
|
||||||
|
const result = resolver(0);
|
||||||
|
expect(result).toBe(LINE_HEIGHT + CHROME_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns increased height when text wraps due to narrow container', () => {
|
||||||
|
// contentWidth = 40 - 32 = 8px — 'Hello' (50px) forces wrapping onto many lines
|
||||||
|
const { resolver } = makeResolver({ getContainerWidth: () => 40 });
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
|
const result = resolver(0);
|
||||||
|
expect(result).toBeGreaterThan(LINE_HEIGHT + CHROME_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call layout() again on second call with same arguments', () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
|
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
||||||
|
|
||||||
|
resolver(0);
|
||||||
|
resolver(0);
|
||||||
|
|
||||||
|
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
layoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls layout() again when containerWidth changes (cache miss)', () => {
|
||||||
|
let width = CONTAINER_WIDTH;
|
||||||
|
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
|
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
||||||
|
|
||||||
|
resolver(0);
|
||||||
|
width = 100;
|
||||||
|
resolver(0);
|
||||||
|
|
||||||
|
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
||||||
|
layoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns greater height when container narrows (more wrapping)', () => {
|
||||||
|
let width = CONTAINER_WIDTH;
|
||||||
|
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
||||||
|
statusMap.set('inter@400', 'loaded');
|
||||||
|
|
||||||
|
const h1 = resolver(0);
|
||||||
|
width = 100; // narrower → more wrapping
|
||||||
|
const h2 = resolver(0);
|
||||||
|
|
||||||
|
expect(h2).toBeGreaterThanOrEqual(h1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses variable font key for variable fonts', () => {
|
||||||
|
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
|
||||||
|
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
|
||||||
|
// Variable fonts use '{id}@vf' key, not '{id}@{weight}'
|
||||||
|
statusMap.set('roboto@vf', 'loaded');
|
||||||
|
const result = resolver(0);
|
||||||
|
expect(result).not.toBe(FALLBACK_HEIGHT);
|
||||||
|
expect(result).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallbackHeight for variable font when static key is set instead', () => {
|
||||||
|
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
|
||||||
|
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
|
||||||
|
// Setting the static key should NOT unlock computed height for variable fonts
|
||||||
|
statusMap.set('roboto@400', 'loaded');
|
||||||
|
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts
Normal file
112
src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { TextLayoutEngine } from '$shared/lib';
|
||||||
|
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey';
|
||||||
|
import type {
|
||||||
|
FontLoadStatus,
|
||||||
|
UnifiedFont,
|
||||||
|
} from '../../model/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for {@link createFontRowSizeResolver}.
|
||||||
|
*
|
||||||
|
* All getter functions are called on every resolver invocation. When called
|
||||||
|
* inside a Svelte `$derived.by` block, any reactive state read within them
|
||||||
|
* (e.g. `SvelteMap.get()`) is automatically tracked as a dependency.
|
||||||
|
*/
|
||||||
|
export interface FontRowSizeResolverOptions {
|
||||||
|
/** Returns the current fonts array. Index `i` corresponds to row `i`. */
|
||||||
|
getFonts: () => UnifiedFont[];
|
||||||
|
/** Returns the active font weight (e.g. 400). */
|
||||||
|
getWeight: () => number;
|
||||||
|
/** Returns the preview text string. */
|
||||||
|
getPreviewText: () => string;
|
||||||
|
/** Returns the scroll container's inner width in pixels. Returns 0 before mount. */
|
||||||
|
getContainerWidth: () => number;
|
||||||
|
/** Returns the font size in pixels (e.g. `controlManager.renderedSize`). */
|
||||||
|
getFontSizePx: () => number;
|
||||||
|
/**
|
||||||
|
* Returns the computed line height in pixels.
|
||||||
|
* Typically `controlManager.height * controlManager.renderedSize`.
|
||||||
|
*/
|
||||||
|
getLineHeightPx: () => number;
|
||||||
|
/**
|
||||||
|
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
|
||||||
|
*
|
||||||
|
* In production: `(key) => appliedFontsManager.statuses.get(key)`.
|
||||||
|
* Injected for testability — avoids a module-level singleton dependency in tests.
|
||||||
|
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
|
||||||
|
* for reactivity to work. This is satisfied when `itemHeight` is called by
|
||||||
|
* `createVirtualizer`'s `estimateSize`.
|
||||||
|
*/
|
||||||
|
getStatus: (fontKey: string) => FontLoadStatus | undefined;
|
||||||
|
/**
|
||||||
|
* Total horizontal padding of the text content area in pixels.
|
||||||
|
* Use the smallest breakpoint value (mobile `p-4` = 32px) to guarantee
|
||||||
|
* the content width is never over-estimated, keeping the height estimate safe.
|
||||||
|
*/
|
||||||
|
contentHorizontalPadding: number;
|
||||||
|
/** Fixed height in pixels of chrome that is not text content (header bar, etc.). */
|
||||||
|
chromeHeight: number;
|
||||||
|
/** Height in pixels to return when the font is not loaded or container width is 0. */
|
||||||
|
fallbackHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a row-height resolver for `FontSampler` rows in `VirtualList`.
|
||||||
|
*
|
||||||
|
* The returned function is suitable as the `itemHeight` prop of `VirtualList`.
|
||||||
|
* Pass it from the widget layer (`SampleList`) so that typography values from
|
||||||
|
* `controlManager` are injected as getter functions rather than imported directly,
|
||||||
|
* keeping `$entities/Font` free of `$features` dependencies.
|
||||||
|
*
|
||||||
|
* **Reactivity:** When the returned function reads `getStatus()` inside a
|
||||||
|
* `$derived.by` block (as `estimateSize` does in `createVirtualizer`), any
|
||||||
|
* `SvelteMap.get()` call within `getStatus` registers a Svelte 5 dependency.
|
||||||
|
* When a font's status changes to `'loaded'`, `offsets` recomputes automatically —
|
||||||
|
* no DOM snap occurs.
|
||||||
|
*
|
||||||
|
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
|
||||||
|
* prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated
|
||||||
|
* naturally because a change in any input produces a different cache key.
|
||||||
|
*
|
||||||
|
* @param options - Configuration and getter functions (all injected for testability).
|
||||||
|
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
|
||||||
|
*/
|
||||||
|
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
|
||||||
|
const engine = new TextLayoutEngine();
|
||||||
|
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
|
||||||
|
const cache = new Map<string, number>();
|
||||||
|
|
||||||
|
return function resolveRowHeight(rowIndex: number): number {
|
||||||
|
const fonts = options.getFonts();
|
||||||
|
const font = fonts[rowIndex];
|
||||||
|
if (!font) return options.fallbackHeight;
|
||||||
|
|
||||||
|
const containerWidth = options.getContainerWidth();
|
||||||
|
const previewText = options.getPreviewText();
|
||||||
|
|
||||||
|
if (containerWidth <= 0 || !previewText) return options.fallbackHeight;
|
||||||
|
|
||||||
|
const weight = options.getWeight();
|
||||||
|
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
|
||||||
|
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
|
||||||
|
|
||||||
|
// Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(),
|
||||||
|
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
|
||||||
|
const status = options.getStatus(fontKey);
|
||||||
|
if (status !== 'loaded') return options.fallbackHeight;
|
||||||
|
|
||||||
|
const fontSizePx = options.getFontSizePx();
|
||||||
|
const lineHeightPx = options.getLineHeightPx();
|
||||||
|
const contentWidth = containerWidth - options.contentHorizontalPadding;
|
||||||
|
const fontCssString = `${weight} ${fontSizePx}px "${font.name}"`;
|
||||||
|
|
||||||
|
const cacheKey = `${fontCssString}|${previewText}|${contentWidth}|${lineHeightPx}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
|
||||||
|
const result = totalHeight + options.chromeHeight;
|
||||||
|
cache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,44 +1,7 @@
|
|||||||
export type {
|
|
||||||
// Domain types
|
|
||||||
FontCategory,
|
|
||||||
FontCollectionFilters,
|
|
||||||
FontCollectionSort,
|
|
||||||
// Store types
|
|
||||||
FontCollectionState,
|
|
||||||
FontFeatures,
|
|
||||||
FontFiles,
|
|
||||||
FontItem,
|
|
||||||
FontLoadRequestConfig,
|
|
||||||
FontLoadStatus,
|
|
||||||
FontMetadata,
|
|
||||||
FontProvider,
|
|
||||||
// Fontshare API types
|
|
||||||
FontshareApiModel,
|
|
||||||
FontshareAxis,
|
|
||||||
FontshareDesigner,
|
|
||||||
FontshareFeature,
|
|
||||||
FontshareFont,
|
|
||||||
FontshareLink,
|
|
||||||
FontsharePublisher,
|
|
||||||
FontshareStyle,
|
|
||||||
FontshareStyleProperties,
|
|
||||||
FontshareTag,
|
|
||||||
FontshareWeight,
|
|
||||||
FontStyleUrls,
|
|
||||||
FontSubset,
|
|
||||||
FontVariant,
|
|
||||||
FontWeight,
|
|
||||||
FontWeightItalic,
|
|
||||||
// Google Fonts API types
|
|
||||||
GoogleFontsApiModel,
|
|
||||||
// Normalization types
|
|
||||||
UnifiedFont,
|
|
||||||
UnifiedFontVariant,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
createUnifiedFontStore,
|
createFontStore,
|
||||||
type UnifiedFontStore,
|
FontStore,
|
||||||
unifiedFontStore,
|
fontStore,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
export * from './types';
|
||||||
|
|||||||
@@ -1,644 +0,0 @@
|
|||||||
import { QueryClient } from '@tanstack/query-core';
|
|
||||||
import { flushSync } from 'svelte';
|
|
||||||
import {
|
|
||||||
afterEach,
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import { generateMockFonts } from '../../../lib/mocks/fonts.mock';
|
|
||||||
import type { UnifiedFont } from '../../types';
|
|
||||||
import { BaseFontStore } from './baseFontStore.svelte';
|
|
||||||
|
|
||||||
vi.mock('$shared/api/queryClient', () => ({
|
|
||||||
queryClient: new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: 0,
|
|
||||||
gcTime: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
|
||||||
|
|
||||||
interface TestParams {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
q?: string;
|
|
||||||
providers?: string[];
|
|
||||||
categories?: string[];
|
|
||||||
subsets?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
class TestFontStore extends BaseFontStore<TestParams> {
|
|
||||||
protected getQueryKey(params: TestParams) {
|
|
||||||
return ['testFonts', params] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async fetchFn(params: TestParams): Promise<UnifiedFont[]> {
|
|
||||||
return generateMockFonts(params.limit || 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('baseFontStore', () => {
|
|
||||||
describe('constructor', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates a new store with initial params', () => {
|
|
||||||
const store = new TestFontStore({ limit: 20, offset: 10 });
|
|
||||||
|
|
||||||
expect(store.params.limit).toBe(20);
|
|
||||||
expect(store.params.offset).toBe(10);
|
|
||||||
store.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults offset to 0 if not provided', () => {
|
|
||||||
const store = new TestFontStore({ limit: 10 });
|
|
||||||
|
|
||||||
expect(store.params.offset).toBe(0);
|
|
||||||
store.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('initializes observer with query options', () => {
|
|
||||||
const store = new TestFontStore({ limit: 10 });
|
|
||||||
|
|
||||||
expect((store as any).observer).toBeDefined();
|
|
||||||
store.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('params getter', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10, offset: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns merged internal params', () => {
|
|
||||||
store.setParams({ limit: 20 });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.limit).toBe(20);
|
|
||||||
expect(store.params.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults offset to 0 when undefined', () => {
|
|
||||||
const store2 = new TestFontStore({});
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store2.params.offset).toBe(0);
|
|
||||||
store2.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('state getters', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fonts', () => {
|
|
||||||
it('returns fonts after auto-fetch on mount', async () => {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
expect(store.fonts).toHaveLength(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fonts when data is loaded', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.fonts).toHaveLength(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fonts when data is loaded', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.fonts).toHaveLength(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isLoading', () => {
|
|
||||||
it('is false after initial fetch completes', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false when error occurs', async () => {
|
|
||||||
vi.spyOn(store, 'fetchFn' as any).mockRejectedValue(new Error('fail'));
|
|
||||||
await store.refetch().catch(() => {});
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isFetching', () => {
|
|
||||||
it('is false after fetch completes', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.isFetching).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is true during refetch', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
const refetchPromise = store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.isFetching).toBe(true);
|
|
||||||
await refetchPromise;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isError', () => {
|
|
||||||
it('is false initially', () => {
|
|
||||||
expect(store.isError).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is true after fetch error', async () => {
|
|
||||||
vi.spyOn(store, 'fetchFn' as any).mockRejectedValue(new Error('fail'));
|
|
||||||
await store.refetch().catch(() => {});
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.isError).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false after successful fetch', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.isError).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('error', () => {
|
|
||||||
it('is null initially', () => {
|
|
||||||
expect(store.error).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error object after fetch error', async () => {
|
|
||||||
const testError = new Error('test error');
|
|
||||||
vi.spyOn(store, 'fetchFn' as any).mockRejectedValue(testError);
|
|
||||||
await store.refetch().catch(() => {});
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.error).toBe(testError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is null after successful fetch', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.error).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isEmpty', () => {
|
|
||||||
it('is true when no fonts loaded and not loading', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
store.setQueryData(() => []);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.isEmpty).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false when fonts are present', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.isEmpty).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false when loading', () => {
|
|
||||||
expect(store.isEmpty).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setParams', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10, offset: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('merges new params with existing', () => {
|
|
||||||
store.setParams({ limit: 20 });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.limit).toBe(20);
|
|
||||||
expect(store.params.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaces existing param values', () => {
|
|
||||||
store.setParams({ limit: 30 });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
store.setParams({ limit: 40 });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.limit).toBe(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('triggers observer options update', async () => {
|
|
||||||
const setOptionsSpy = vi.spyOn((store as any).observer, 'setOptions');
|
|
||||||
|
|
||||||
store.setParams({ limit: 20 });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(setOptionsSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateInternalParams', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10, offset: 20 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates internal params without triggering setParams hooks', () => {
|
|
||||||
(store as any).updateInternalParams({ offset: 0 });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.offset).toBe(0);
|
|
||||||
expect(store.params.limit).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('merges with existing internal params', () => {
|
|
||||||
(store as any).updateInternalParams({ offset: 0, limit: 30 });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.offset).toBe(0);
|
|
||||||
expect(store.params.limit).toBe(30);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates observer options', () => {
|
|
||||||
const setOptionsSpy = vi.spyOn((store as any).observer, 'setOptions');
|
|
||||||
|
|
||||||
(store as any).updateInternalParams({ offset: 0 });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(setOptionsSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('invalidate', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('invalidates query for current params', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
||||||
store.invalidate();
|
|
||||||
|
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
||||||
queryKey: ['testFonts', store.params],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('triggers refetch of invalidated query', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
const fetchSpy = vi.spyOn(store, 'fetchFn' as any);
|
|
||||||
store.invalidate();
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('destroy', () => {
|
|
||||||
it('calls cleanup function', () => {
|
|
||||||
const store = new TestFontStore({ limit: 10 });
|
|
||||||
const cleanupSpy = vi.spyOn(store, 'cleanup' as any);
|
|
||||||
|
|
||||||
store.destroy();
|
|
||||||
|
|
||||||
expect(cleanupSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can be called multiple times without error', () => {
|
|
||||||
const store = new TestFontStore({ limit: 10 });
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
store.destroy();
|
|
||||||
store.destroy();
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('refetch', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('triggers a refetch', async () => {
|
|
||||||
const fetchSpy = vi.spyOn(store, 'fetchFn' as any);
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates observer options before refetching', async () => {
|
|
||||||
const setOptionsSpy = vi.spyOn((store as any).observer, 'setOptions');
|
|
||||||
const refetchSpy = vi.spyOn((store as any).observer, 'refetch');
|
|
||||||
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(setOptionsSpy).toHaveBeenCalledBefore(refetchSpy);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses current params for refetch', async () => {
|
|
||||||
store.setParams({ limit: 20 });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.limit).toBe(20);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('prefetch', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefetches data with provided params', async () => {
|
|
||||||
const prefetchSpy = vi.spyOn(queryClient, 'prefetchQuery');
|
|
||||||
|
|
||||||
await store.prefetch({ limit: 20, offset: 0 });
|
|
||||||
|
|
||||||
expect(prefetchSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores prefetched data in cache', async () => {
|
|
||||||
queryClient.clear();
|
|
||||||
|
|
||||||
const store2 = new TestFontStore({ limit: 10 });
|
|
||||||
await store2.prefetch({ limit: 5, offset: 0 });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
const cached = store2.getCachedData();
|
|
||||||
expect(cached).toBeDefined();
|
|
||||||
expect(cached?.length).toBeGreaterThanOrEqual(0);
|
|
||||||
store2.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cancel', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cancels ongoing queries', () => {
|
|
||||||
const cancelSpy = vi.spyOn(queryClient, 'cancelQueries');
|
|
||||||
|
|
||||||
store.cancel();
|
|
||||||
|
|
||||||
expect(cancelSpy).toHaveBeenCalledWith({
|
|
||||||
queryKey: ['testFonts', store.params],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getCachedData', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined when no data cached', () => {
|
|
||||||
queryClient.clear();
|
|
||||||
|
|
||||||
const store2 = new TestFontStore({ limit: 10 });
|
|
||||||
expect(store2.getCachedData()).toBeUndefined();
|
|
||||||
store2.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns cached data after fetch', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
const cached = store.getCachedData();
|
|
||||||
expect(cached).toHaveLength(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns data from manual cache update', () => {
|
|
||||||
store.setQueryData(() => [generateMockFonts(1)[0]]);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
const cached = store.getCachedData();
|
|
||||||
expect(cached).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setQueryData', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets data in cache', () => {
|
|
||||||
store.setQueryData(() => [generateMockFonts(1)[0]]);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
const cached = store.getCachedData();
|
|
||||||
expect(cached).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates existing cached data', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
store.setQueryData(old => [...(old || []), generateMockFonts(1)[0]]);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
const cached = store.getCachedData();
|
|
||||||
expect(cached).toHaveLength(11);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('receives previous data in updater function', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
const updater = vi.fn((old: UnifiedFont[] | undefined) => old || []);
|
|
||||||
store.setQueryData(updater);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(updater).toHaveBeenCalledWith(expect.any(Array));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getOptions', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns query options with query key', () => {
|
|
||||||
const options = (store as any).getOptions();
|
|
||||||
|
|
||||||
expect(options.queryKey).toEqual(['testFonts', store.params]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns query options with query fn', () => {
|
|
||||||
const options = (store as any).getOptions();
|
|
||||||
|
|
||||||
expect(options.queryFn).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses provided params when passed', () => {
|
|
||||||
const customParams = { limit: 20, offset: 0 };
|
|
||||||
const options = (store as any).getOptions(customParams);
|
|
||||||
|
|
||||||
expect(options.queryKey).toEqual(['testFonts', customParams]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has default staleTime and gcTime', () => {
|
|
||||||
const options = (store as any).getOptions();
|
|
||||||
|
|
||||||
expect(options.staleTime).toBe(5 * 60 * 1000);
|
|
||||||
expect(options.gcTime).toBe(10 * 60 * 1000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('observer integration', () => {
|
|
||||||
let store: TestFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new TestFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('syncs observer state to Svelte state', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.fonts).toHaveLength(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('observer syncs on state changes', async () => {
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect((store as any).result.data).toHaveLength(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('effect cleanup', () => {
|
|
||||||
it('cleanup function is set on constructor', () => {
|
|
||||||
const store = new TestFontStore({ limit: 10 });
|
|
||||||
|
|
||||||
expect(store.cleanup).toBeDefined();
|
|
||||||
expect(typeof store.cleanup).toBe('function');
|
|
||||||
|
|
||||||
store.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
import { queryClient } from '$shared/api/queryClient';
|
|
||||||
import {
|
|
||||||
type QueryKey,
|
|
||||||
QueryObserver,
|
|
||||||
type QueryObserverOptions,
|
|
||||||
type QueryObserverResult,
|
|
||||||
} from '@tanstack/query-core';
|
|
||||||
import type { UnifiedFont } from '../../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class for font stores using TanStack Query
|
|
||||||
*
|
|
||||||
* Provides reactive font data fetching with caching, automatic refetching,
|
|
||||||
* and parameter binding. Extended by UnifiedFontStore for provider-agnostic
|
|
||||||
* font fetching.
|
|
||||||
*
|
|
||||||
* @template TParams - Type of query parameters
|
|
||||||
*/
|
|
||||||
export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|
||||||
/**
|
|
||||||
* Cleanup function for effects
|
|
||||||
* Call destroy() to remove effects and prevent memory leaks
|
|
||||||
*/
|
|
||||||
cleanup: () => void;
|
|
||||||
|
|
||||||
/** Internal parameter state */
|
|
||||||
#internalParams = $state<TParams>({} as TParams);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merged params from internal state
|
|
||||||
* Computed synchronously on access
|
|
||||||
*/
|
|
||||||
get params(): TParams {
|
|
||||||
// Default offset to 0 if undefined (for pagination methods)
|
|
||||||
let result = this.#internalParams as TParams;
|
|
||||||
if (result.offset === undefined) {
|
|
||||||
result = { ...result, offset: 0 } as TParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** TanStack Query result state */
|
|
||||||
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
|
|
||||||
/** TanStack Query observer instance */
|
|
||||||
protected observer: QueryObserver<UnifiedFont[], Error>;
|
|
||||||
/** Shared query client */
|
|
||||||
protected qc = queryClient;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new base font store
|
|
||||||
* @param initialParams - Initial query parameters
|
|
||||||
*/
|
|
||||||
constructor(initialParams: TParams) {
|
|
||||||
this.#internalParams = initialParams;
|
|
||||||
|
|
||||||
this.observer = new QueryObserver(this.qc, this.getOptions());
|
|
||||||
|
|
||||||
// Sync TanStack Query state -> Svelte state
|
|
||||||
this.observer.subscribe(r => {
|
|
||||||
this.result = r;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync Svelte state changes -> TanStack Query options
|
|
||||||
this.cleanup = $effect.root(() => {
|
|
||||||
$effect(() => {
|
|
||||||
this.observer.setOptions(this.getOptions());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Must be implemented by child class
|
|
||||||
* Returns the query key for TanStack Query caching
|
|
||||||
*/
|
|
||||||
protected abstract getQueryKey(params: TParams): QueryKey;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Must be implemented by child class
|
|
||||||
* Fetches font data from API
|
|
||||||
*/
|
|
||||||
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets TanStack Query options
|
|
||||||
* @param params - Query parameters (defaults to current params)
|
|
||||||
*/
|
|
||||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
|
||||||
// Always use current params, not the captured closure params
|
|
||||||
return {
|
|
||||||
queryKey: this.getQueryKey(params),
|
|
||||||
queryFn: () => this.fetchFn(this.params),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Array of fonts (empty array if loading/error) */
|
|
||||||
get fonts() {
|
|
||||||
return this.result.data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether currently fetching initial data */
|
|
||||||
get isLoading() {
|
|
||||||
return this.result.isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether any fetch is in progress (including refetches) */
|
|
||||||
get isFetching() {
|
|
||||||
return this.result.isFetching;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether last fetch resulted in an error */
|
|
||||||
get isError() {
|
|
||||||
return this.result.isError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The error from the last failed fetch, or null if no error. */
|
|
||||||
get error(): Error | null {
|
|
||||||
return this.result.error ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether no fonts are loaded (not loading and empty array) */
|
|
||||||
get isEmpty() {
|
|
||||||
return !this.isLoading && this.fonts.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update query parameters
|
|
||||||
* @param newParams - Partial params to merge with existing
|
|
||||||
*/
|
|
||||||
setParams(newParams: Partial<TParams>) {
|
|
||||||
this.#internalParams = { ...this.#internalParams, ...newParams };
|
|
||||||
// Manually update observer options since effects may not run in test contexts
|
|
||||||
this.observer.setOptions(this.getOptions());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update internal params without triggering setParams hooks
|
|
||||||
* Used for resetting offset when filters change
|
|
||||||
* @param newParams - Partial params to merge with existing
|
|
||||||
*/
|
|
||||||
protected updateInternalParams(newParams: Partial<TParams>) {
|
|
||||||
this.#internalParams = { ...this.#internalParams, ...newParams };
|
|
||||||
// Update observer options
|
|
||||||
this.observer.setOptions(this.getOptions());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate cache and refetch
|
|
||||||
*/
|
|
||||||
invalidate() {
|
|
||||||
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up effects and observers
|
|
||||||
*/
|
|
||||||
destroy() {
|
|
||||||
this.cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually trigger a refetch
|
|
||||||
*/
|
|
||||||
async refetch() {
|
|
||||||
// Update options before refetching to ensure current params are used
|
|
||||||
this.observer.setOptions(this.getOptions());
|
|
||||||
await this.observer.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefetch data with different parameters
|
|
||||||
*/
|
|
||||||
async prefetch(params: TParams) {
|
|
||||||
await this.qc.prefetchQuery(this.getOptions(params));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel ongoing queries
|
|
||||||
*/
|
|
||||||
cancel() {
|
|
||||||
this.qc.cancelQueries({
|
|
||||||
queryKey: this.getQueryKey(this.params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached data without triggering fetch
|
|
||||||
*/
|
|
||||||
getCachedData() {
|
|
||||||
return this.qc.getQueryData<UnifiedFont[]>(
|
|
||||||
this.getQueryKey(this.params),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set data manually (optimistic updates)
|
|
||||||
*/
|
|
||||||
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
|
||||||
this.qc.setQueryData(
|
|
||||||
this.getQueryKey(this.params),
|
|
||||||
updater,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
583
src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
Normal file
583
src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
import { QueryClient } from '@tanstack/query-core';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '../../../lib/errors/errors';
|
||||||
|
import {
|
||||||
|
generateMixedCategoryFonts,
|
||||||
|
generateMockFonts,
|
||||||
|
} from '../../../lib/mocks/fonts.mock';
|
||||||
|
import type { UnifiedFont } from '../../types';
|
||||||
|
import { FontStore } from './fontStore.svelte';
|
||||||
|
|
||||||
|
vi.mock('$shared/api/queryClient', () => ({
|
||||||
|
queryClient: new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||||
|
|
||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import { fetchProxyFonts } from '../../../api';
|
||||||
|
|
||||||
|
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||||
|
|
||||||
|
const makeResponse = (
|
||||||
|
fonts: UnifiedFont[],
|
||||||
|
meta: { total?: number; limit?: number; offset?: number } = {},
|
||||||
|
): FontPage => ({
|
||||||
|
fonts,
|
||||||
|
total: meta.total ?? fonts.length,
|
||||||
|
limit: meta.limit ?? 10,
|
||||||
|
offset: meta.offset ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeStore(params = {}) {
|
||||||
|
return new FontStore({ limit: 10, ...params });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
|
||||||
|
fetch.mockResolvedValue(makeResponse(fonts, meta));
|
||||||
|
const store = makeStore(params);
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FontStore', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('construction', () => {
|
||||||
|
it('stores initial params', () => {
|
||||||
|
const store = makeStore({ limit: 20 });
|
||||||
|
expect(store.params.limit).toBe(20);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults limit to 50 when not provided', () => {
|
||||||
|
const store = new FontStore();
|
||||||
|
expect(store.params.limit).toBe(50);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with empty fonts', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.fonts).toEqual([]);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with isEmpty false — initial fetch is in progress', () => {
|
||||||
|
// The observer starts fetching immediately on construction.
|
||||||
|
// isEmpty must be false so the UI shows a loader, not "no results".
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.isEmpty).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('state after fetch', () => {
|
||||||
|
it('exposes loaded fonts', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(7));
|
||||||
|
expect(store.fonts).toHaveLength(7);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isEmpty is false when fonts are present', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isEmpty).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isLoading is false after fetch', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isLoading).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isFetching is false after fetch', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isFetching).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isError is false on success', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error is null on success', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.error).toBeNull();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('error states', () => {
|
||||||
|
it('isError is false before any fetch', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps network failures in FontNetworkError', async () => {
|
||||||
|
fetch.mockRejectedValue(new Error('network down'));
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||||
|
expect(store.isError).toBe(true);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes FontResponseError for falsy response', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(null);
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).field).toBe('response');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes FontResponseError for missing fonts field', async () => {
|
||||||
|
fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 });
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).field).toBe('response.fonts');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes FontResponseError for non-array fonts', async () => {
|
||||||
|
fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 });
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).received).toBe('bad');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('font accumulation', () => {
|
||||||
|
it('replaces fonts when refetching the first page', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const second = generateMockFonts(2);
|
||||||
|
fetch.mockResolvedValue(makeResponse(second));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
// refetch at offset=0 re-fetches all pages; only one page loaded → new data replaces old
|
||||||
|
expect(store.fonts).toHaveLength(2);
|
||||||
|
expect(store.fonts[0].id).toBe(second[0].id);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends fonts after nextPage', async () => {
|
||||||
|
const page1 = generateMockFonts(3);
|
||||||
|
const store = await fetchedStore({ limit: 3 }, page1, { total: 6, limit: 3, offset: 0 });
|
||||||
|
const page2 = generateMockFonts(3).map((f, i) => ({ ...f, id: `p2-${i}` }));
|
||||||
|
fetch.mockResolvedValue(makeResponse(page2, { total: 6, limit: 3, offset: 3 }));
|
||||||
|
await store.nextPage();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(6);
|
||||||
|
expect(store.fonts.slice(0, 3).map(f => f.id)).toEqual(page1.map(f => f.id));
|
||||||
|
expect(store.fonts.slice(3).map(f => f.id)).toEqual(page2.map(f => f.id));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('pagination state', () => {
|
||||||
|
it('returns zero-value defaults before any fetch', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.pagination).toMatchObject({ total: 0, hasMore: false, page: 1, totalPages: 0 });
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects response metadata after fetch', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
|
||||||
|
expect(store.pagination.total).toBe(30);
|
||||||
|
expect(store.pagination.hasMore).toBe(true);
|
||||||
|
expect(store.pagination.page).toBe(1);
|
||||||
|
expect(store.pagination.totalPages).toBe(3);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasMore is false on the last page', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(10), { total: 10, limit: 10, offset: 0 });
|
||||||
|
expect(store.pagination.hasMore).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page count increments after nextPage', async () => {
|
||||||
|
const store = await fetchedStore({ limit: 10 }, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
|
||||||
|
expect(store.pagination.page).toBe(1);
|
||||||
|
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
||||||
|
await store.nextPage();
|
||||||
|
flushSync();
|
||||||
|
expect(store.pagination.page).toBe(2);
|
||||||
|
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('setParams', () => {
|
||||||
|
it('merges updates into existing params', () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setParams({ limit: 20 });
|
||||||
|
expect(store.params.limit).toBe(20);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains unmodified params', () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
store.setParams({ limit: 25 });
|
||||||
|
expect(store.params.categories).toEqual(['serif']);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('filter change resets', () => {
|
||||||
|
it('clears accumulated fonts when a filter changes', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.setSearch('roboto');
|
||||||
|
flushSync();
|
||||||
|
// TQ switches to a new queryKey → data.pages reset → fonts = []
|
||||||
|
expect(store.fonts).toHaveLength(0);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isEmpty is false immediately after filter change — fetch is in progress', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
// Hang the next fetch so we can observe the transitioning state
|
||||||
|
fetch.mockReturnValue(new Promise(() => {}));
|
||||||
|
store.setSearch('roboto');
|
||||||
|
flushSync();
|
||||||
|
// fonts = [] AND isFetching = true → isEmpty must be false (no "no results" flash)
|
||||||
|
expect(store.isEmpty).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT reset fonts when the same filter value is set again', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
flushSync();
|
||||||
|
// First change: clears fonts (expected)
|
||||||
|
store.setCategories(['serif']); // same value — same queryKey — TQ keeps data.pages
|
||||||
|
flushSync();
|
||||||
|
// Because queryKey hasn't changed, TQ returns cached data — fonts restored from cache
|
||||||
|
// (actual font count depends on cache; key assertion is no extra reset)
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('staleTime in buildOptions', () => {
|
||||||
|
it('is 5 minutes with no active filters', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect((store as any).buildOptions().staleTime).toBe(5 * 60 * 1000);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 when a search query is active', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setSearch('roboto');
|
||||||
|
expect((store as any).buildOptions().staleTime).toBe(0);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 when a category filter is active', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
expect((store as any).buildOptions().staleTime).toBe(0);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gcTime is 10 minutes always', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect((store as any).buildOptions().gcTime).toBe(10 * 60 * 1000);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('buildQueryKey', () => {
|
||||||
|
it('omits empty-string params', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setSearch('');
|
||||||
|
const [root, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(root).toBe('fonts');
|
||||||
|
expect(normalized).not.toHaveProperty('q');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits empty-array params', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setProviders([]);
|
||||||
|
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(normalized).not.toHaveProperty('providers');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes non-empty filter values', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(normalized).toHaveProperty('categories', ['serif']);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include offset (offset is the TQ page param, not a query key component)', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(normalized).not.toHaveProperty('offset');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('does not throw', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(() => store.destroy()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.destroy();
|
||||||
|
expect(() => store.destroy()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('refetch', () => {
|
||||||
|
it('triggers a fetch', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
|
||||||
|
await store.refetch();
|
||||||
|
expect(fetch).toHaveBeenCalled();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses params current at call time', async () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setParams({ limit: 20 });
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(20)));
|
||||||
|
await store.refetch();
|
||||||
|
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('nextPage', () => {
|
||||||
|
let store: FontStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
||||||
|
store = new FontStore({ limit: 10 });
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the next page and appends fonts', async () => {
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
||||||
|
await store.nextPage();
|
||||||
|
flushSync();
|
||||||
|
expect(store.fonts).toHaveLength(20);
|
||||||
|
expect(store.pagination.offset).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when hasMore is false', async () => {
|
||||||
|
// Set up a store where all fonts fit in one page (hasMore = false)
|
||||||
|
queryClient.clear();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
|
||||||
|
store = new FontStore({ limit: 10 });
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.pagination.hasMore).toBe(false);
|
||||||
|
await store.nextPage(); // should not trigger another fetch
|
||||||
|
expect(store.fonts).toHaveLength(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('prevPage and goToPage', () => {
|
||||||
|
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.prevPage();
|
||||||
|
expect(store.fonts).toHaveLength(5); // unchanged
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goToPage is a no-op — infinite scroll does not support arbitrary page jumps', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.goToPage(3);
|
||||||
|
expect(store.fonts).toHaveLength(5); // unchanged
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('prefetch', () => {
|
||||||
|
it('triggers a fetch for the provided params', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(5)));
|
||||||
|
await store.prefetch({ limit: 5 });
|
||||||
|
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 5, offset: 0 }));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('getCachedData / setQueryData', () => {
|
||||||
|
it('getCachedData returns undefined before any fetch', () => {
|
||||||
|
queryClient.clear();
|
||||||
|
const store = new FontStore({ limit: 10 });
|
||||||
|
expect(store.getCachedData()).toBeUndefined();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCachedData returns flattened fonts after fetch', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.getCachedData()).toHaveLength(5);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setQueryData writes to cache', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
const font = generateMockFonts(1)[0];
|
||||||
|
store.setQueryData(() => [font]);
|
||||||
|
expect(store.getCachedData()).toHaveLength(1);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setQueryData updater receives existing flattened fonts', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
const updater = vi.fn((old: UnifiedFont[] | undefined) => old ?? []);
|
||||||
|
store.setQueryData(updater);
|
||||||
|
expect(updater).toHaveBeenCalledWith(expect.any(Array));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('invalidate', () => {
|
||||||
|
it('calls invalidateQueries', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
const spy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
store.invalidate();
|
||||||
|
expect(spy).toHaveBeenCalledOnce();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('setLimit', () => {
|
||||||
|
it('updates the limit param', () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setLimit(25);
|
||||||
|
expect(store.params.limit).toBe(25);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('filter shortcut methods', () => {
|
||||||
|
let store: FontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = makeStore();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setProviders updates providers param', () => {
|
||||||
|
store.setProviders(['google']);
|
||||||
|
expect(store.params.providers).toEqual(['google']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setCategories updates categories param', () => {
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
expect(store.params.categories).toEqual(['serif']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSubsets updates subsets param', () => {
|
||||||
|
store.setSubsets(['cyrillic']);
|
||||||
|
expect(store.params.subsets).toEqual(['cyrillic']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSearch sets q param', () => {
|
||||||
|
store.setSearch('roboto');
|
||||||
|
expect(store.params.q).toBe('roboto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSearch with empty string clears q', () => {
|
||||||
|
store.setSearch('roboto');
|
||||||
|
store.setSearch('');
|
||||||
|
expect(store.params.q).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSort updates sort param', () => {
|
||||||
|
store.setSort('popularity');
|
||||||
|
expect(store.params.sort).toBe('popularity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('category getters', () => {
|
||||||
|
it('each getter returns only fonts of that category', async () => {
|
||||||
|
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
|
||||||
|
fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
||||||
|
const store = makeStore({ limit: 50 });
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true);
|
||||||
|
expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true);
|
||||||
|
expect(store.displayFonts.every(f => f.category === 'display')).toBe(true);
|
||||||
|
expect(store.handwritingFonts.every(f => f.category === 'handwriting')).toBe(true);
|
||||||
|
expect(store.monospaceFonts.every(f => f.category === 'monospace')).toBe(true);
|
||||||
|
expect(store.sansSerifFonts).toHaveLength(2);
|
||||||
|
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
283
src/entities/Font/model/store/fontStore/fontStore.svelte.ts
Normal file
283
src/entities/Font/model/store/fontStore/fontStore.svelte.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import {
|
||||||
|
type InfiniteData,
|
||||||
|
InfiniteQueryObserver,
|
||||||
|
type InfiniteQueryObserverResult,
|
||||||
|
type QueryFunctionContext,
|
||||||
|
} from '@tanstack/query-core';
|
||||||
|
import {
|
||||||
|
type ProxyFontsParams,
|
||||||
|
type ProxyFontsResponse,
|
||||||
|
fetchProxyFonts,
|
||||||
|
} from '../../../api';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '../../../lib/errors/errors';
|
||||||
|
import type { UnifiedFont } from '../../types';
|
||||||
|
|
||||||
|
type PageParam = { offset: number };
|
||||||
|
|
||||||
|
/** Filter params + limit — offset is managed by TQ as a page param, not a user param. */
|
||||||
|
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
|
||||||
|
|
||||||
|
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
|
||||||
|
|
||||||
|
export class FontStore {
|
||||||
|
#params = $state<FontStoreParams>({ limit: 50 });
|
||||||
|
#result = $state<FontStoreResult>({} as FontStoreResult);
|
||||||
|
#observer: InfiniteQueryObserver<
|
||||||
|
ProxyFontsResponse,
|
||||||
|
Error,
|
||||||
|
InfiniteData<ProxyFontsResponse, PageParam>,
|
||||||
|
readonly unknown[],
|
||||||
|
PageParam
|
||||||
|
>;
|
||||||
|
#qc = queryClient;
|
||||||
|
#unsubscribe: () => void;
|
||||||
|
|
||||||
|
constructor(params: FontStoreParams = {}) {
|
||||||
|
this.#params = { limit: 50, ...params };
|
||||||
|
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions());
|
||||||
|
this.#unsubscribe = this.#observer.subscribe(r => {
|
||||||
|
this.#result = r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Public state --
|
||||||
|
|
||||||
|
get params(): FontStoreParams {
|
||||||
|
return this.#params;
|
||||||
|
}
|
||||||
|
get fonts(): UnifiedFont[] {
|
||||||
|
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
|
||||||
|
}
|
||||||
|
get isLoading(): boolean {
|
||||||
|
return this.#result.isLoading;
|
||||||
|
}
|
||||||
|
get isFetching(): boolean {
|
||||||
|
return this.#result.isFetching;
|
||||||
|
}
|
||||||
|
get isError(): boolean {
|
||||||
|
return this.#result.isError;
|
||||||
|
}
|
||||||
|
|
||||||
|
get error(): Error | null {
|
||||||
|
return this.#result.error ?? null;
|
||||||
|
}
|
||||||
|
// isEmpty is false during loading/fetching so the UI never flashes "no results"
|
||||||
|
// while a fetch is in progress. The !isFetching guard is specifically for the filter-change
|
||||||
|
// transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false.
|
||||||
|
get isEmpty(): boolean {
|
||||||
|
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get pagination() {
|
||||||
|
const pages = this.#result.data?.pages;
|
||||||
|
const last = pages?.at(-1);
|
||||||
|
if (!last) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
limit: this.#params.limit ?? 50,
|
||||||
|
offset: 0,
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: last.total,
|
||||||
|
limit: last.limit,
|
||||||
|
offset: last.offset,
|
||||||
|
hasMore: this.#result.hasNextPage,
|
||||||
|
page: pages!.length,
|
||||||
|
totalPages: Math.ceil(last.total / last.limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Lifecycle --
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.#unsubscribe();
|
||||||
|
this.#observer.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Param management --
|
||||||
|
|
||||||
|
setParams(updates: Partial<FontStoreParams>) {
|
||||||
|
this.#params = { ...this.#params, ...updates };
|
||||||
|
this.#observer.setOptions(this.buildOptions());
|
||||||
|
}
|
||||||
|
invalidate() {
|
||||||
|
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Async operations --
|
||||||
|
|
||||||
|
async refetch() {
|
||||||
|
await this.#observer.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async prefetch(params: FontStoreParams) {
|
||||||
|
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
|
||||||
|
}
|
||||||
|
|
||||||
|
getCachedData(): UnifiedFont[] | undefined {
|
||||||
|
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
||||||
|
this.buildQueryKey(this.#params),
|
||||||
|
);
|
||||||
|
if (!data) return undefined;
|
||||||
|
return data.pages.flatMap(p => p.fonts);
|
||||||
|
}
|
||||||
|
|
||||||
|
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
||||||
|
const key = this.buildQueryKey(this.#params);
|
||||||
|
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
||||||
|
key,
|
||||||
|
old => {
|
||||||
|
const flatFonts = old?.pages.flatMap(p => p.fonts);
|
||||||
|
const newFonts = updater(flatFonts);
|
||||||
|
// Re-distribute the updated fonts back into the existing page structure
|
||||||
|
// Define the first page. If old data exists, we merge into the first page template.
|
||||||
|
const limit = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
|
||||||
|
const template = old?.pages[0] ?? {
|
||||||
|
total: newFonts.length,
|
||||||
|
limit,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedPage: ProxyFontsResponse = {
|
||||||
|
...template,
|
||||||
|
fonts: newFonts,
|
||||||
|
total: newFonts.length, // Synchronize total with the new font count
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pages: [updatedPage],
|
||||||
|
pageParams: [{ offset: 0 }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Filter shortcuts --
|
||||||
|
|
||||||
|
setProviders(v: ProxyFontsParams['providers']) {
|
||||||
|
this.setParams({ providers: v });
|
||||||
|
}
|
||||||
|
setCategories(v: ProxyFontsParams['categories']) {
|
||||||
|
this.setParams({ categories: v });
|
||||||
|
}
|
||||||
|
setSubsets(v: ProxyFontsParams['subsets']) {
|
||||||
|
this.setParams({ subsets: v });
|
||||||
|
}
|
||||||
|
setSearch(v: string) {
|
||||||
|
this.setParams({ q: v || undefined });
|
||||||
|
}
|
||||||
|
setSort(v: ProxyFontsParams['sort']) {
|
||||||
|
this.setParams({ sort: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Pagination navigation --
|
||||||
|
|
||||||
|
async nextPage(): Promise<void> {
|
||||||
|
await this.#observer.fetchNextPage();
|
||||||
|
}
|
||||||
|
prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility
|
||||||
|
goToPage(_page: number): void {} // no-op
|
||||||
|
|
||||||
|
setLimit(limit: number) {
|
||||||
|
this.setParams({ limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Category views --
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Private helpers (TypeScript-private so tests can spy via `as any`) --
|
||||||
|
|
||||||
|
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
|
||||||
|
const filtered: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
// Ensure we DO NOT 'continue' or skip the limit key here.
|
||||||
|
// The limit is a fundamental part of the data identity.
|
||||||
|
if (
|
||||||
|
value !== undefined
|
||||||
|
&& value !== null
|
||||||
|
&& value !== ''
|
||||||
|
&& !(Array.isArray(value) && value.length === 0)
|
||||||
|
) {
|
||||||
|
filtered[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['fonts', filtered];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildOptions(params = this.#params) {
|
||||||
|
const activeParams = { ...params };
|
||||||
|
const hasFilters = !!(
|
||||||
|
activeParams.q
|
||||||
|
|| (Array.isArray(activeParams.providers) && activeParams.providers.length > 0)
|
||||||
|
|| (Array.isArray(activeParams.categories) && activeParams.categories.length > 0)
|
||||||
|
|| (Array.isArray(activeParams.subsets) && activeParams.subsets.length > 0)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
queryKey: this.buildQueryKey(activeParams),
|
||||||
|
queryFn: ({ pageParam }: QueryFunctionContext<readonly unknown[], PageParam>) =>
|
||||||
|
this.fetchPage({ ...activeParams, ...pageParam }),
|
||||||
|
initialPageParam: { offset: 0 } as PageParam,
|
||||||
|
getNextPageParam: (lastPage: ProxyFontsResponse): PageParam | undefined => {
|
||||||
|
const next = lastPage.offset + lastPage.limit;
|
||||||
|
return next < lastPage.total ? { offset: next } : undefined;
|
||||||
|
},
|
||||||
|
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchPage(params: ProxyFontsParams): Promise<ProxyFontsResponse> {
|
||||||
|
let response: ProxyFontsResponse;
|
||||||
|
try {
|
||||||
|
response = await fetchProxyFonts(params);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new FontNetworkError(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) throw new FontResponseError('response', response);
|
||||||
|
if (!response.fonts) throw new FontResponseError('response.fonts', response.fonts);
|
||||||
|
if (!Array.isArray(response.fonts)) throw new FontResponseError('response.fonts', response.fonts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fonts: response.fonts,
|
||||||
|
total: response.total ?? 0,
|
||||||
|
limit: response.limit ?? params.limit ?? 50,
|
||||||
|
offset: response.offset ?? params.offset ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFontStore(params: FontStoreParams = {}): FontStore {
|
||||||
|
return new FontStore(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fontStore = new FontStore({ limit: 50 });
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
/**
|
// Applied fonts manager
|
||||||
* ============================================================================
|
|
||||||
* UNIFIED FONT STORE EXPORTS
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Single export point for the unified font store infrastructure.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Primary store (unified)
|
|
||||||
export {
|
|
||||||
createUnifiedFontStore,
|
|
||||||
type UnifiedFontStore,
|
|
||||||
unifiedFontStore,
|
|
||||||
} from './unifiedFontStore/unifiedFontStore.svelte';
|
|
||||||
|
|
||||||
// Applied fonts manager (CSS loading - unchanged)
|
|
||||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
||||||
|
|
||||||
|
// Single FontStore
|
||||||
|
export {
|
||||||
|
createFontStore,
|
||||||
|
FontStore,
|
||||||
|
fontStore,
|
||||||
|
} from './fontStore/fontStore.svelte';
|
||||||
|
|||||||
@@ -1,474 +0,0 @@
|
|||||||
import { QueryClient } from '@tanstack/query-core';
|
|
||||||
import { tick } from 'svelte';
|
|
||||||
import {
|
|
||||||
afterEach,
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import {
|
|
||||||
FontNetworkError,
|
|
||||||
FontResponseError,
|
|
||||||
} from '../../../lib/errors/errors';
|
|
||||||
|
|
||||||
vi.mock('$shared/api/queryClient', () => ({
|
|
||||||
queryClient: new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: 0,
|
|
||||||
gcTime: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../../api', () => ({
|
|
||||||
fetchProxyFonts: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
|
||||||
import { flushSync } from 'svelte';
|
|
||||||
import { fetchProxyFonts } from '../../../api';
|
|
||||||
import {
|
|
||||||
generateMixedCategoryFonts,
|
|
||||||
generateMockFonts,
|
|
||||||
} from '../../../lib/mocks/fonts.mock';
|
|
||||||
import type { UnifiedFont } from '../../types';
|
|
||||||
import { UnifiedFontStore } from './unifiedFontStore.svelte';
|
|
||||||
|
|
||||||
const mockedFetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
const makeResponse = (
|
|
||||||
fonts: UnifiedFont[],
|
|
||||||
meta: { total?: number; limit?: number; offset?: number } = {},
|
|
||||||
) => ({
|
|
||||||
fonts,
|
|
||||||
total: meta.total ?? fonts.length,
|
|
||||||
limit: meta.limit ?? 10,
|
|
||||||
offset: meta.offset ?? 0,
|
|
||||||
});
|
|
||||||
describe('unifiedFontStore', () => {
|
|
||||||
describe('fetchFn — error paths', () => {
|
|
||||||
let store: UnifiedFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new UnifiedFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets isError and error getter when fetchProxyFonts throws', async () => {
|
|
||||||
mockedFetch.mockRejectedValue(new Error('network down'));
|
|
||||||
await store.refetch().catch((e: unknown) => e);
|
|
||||||
|
|
||||||
expect(store.error).toBeInstanceOf(FontNetworkError);
|
|
||||||
expect((store.error as FontNetworkError).cause).toBeInstanceOf(Error);
|
|
||||||
expect(store.isError).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws FontResponseError when response is falsy', async () => {
|
|
||||||
mockedFetch.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await store.refetch().catch((e: unknown) => e);
|
|
||||||
|
|
||||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
|
||||||
expect((store.error as FontResponseError).field).toBe('response');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws FontResponseError when response.fonts is missing', async () => {
|
|
||||||
mockedFetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 });
|
|
||||||
|
|
||||||
await store.refetch().catch((e: unknown) => e);
|
|
||||||
|
|
||||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
|
||||||
expect((store.error as FontResponseError).field).toBe('response.fonts');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws FontResponseError when response.fonts is not an array', async () => {
|
|
||||||
mockedFetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 });
|
|
||||||
|
|
||||||
await store.refetch().catch((e: unknown) => e);
|
|
||||||
|
|
||||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
|
||||||
expect((store.error as FontResponseError).field).toBe('response.fonts');
|
|
||||||
expect((store.error as FontResponseError).received).toBe('bad');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchFn — success path', () => {
|
|
||||||
let store: UnifiedFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new UnifiedFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('populates fonts after a successful fetch', async () => {
|
|
||||||
const fonts = generateMockFonts(3);
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(fonts));
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
expect(store.fonts).toHaveLength(3);
|
|
||||||
expect(store.fonts[0].id).toBe(fonts[0].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores pagination metadata from response', async () => {
|
|
||||||
const fonts = generateMockFonts(3);
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: 30, limit: 10, offset: 0 }));
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
expect(store.pagination.total).toBe(30);
|
|
||||||
expect(store.pagination.limit).toBe(10);
|
|
||||||
expect(store.pagination.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaces accumulated fonts on offset-0 fetch', async () => {
|
|
||||||
const first = generateMockFonts(3);
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(first));
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
const second = generateMockFonts(2);
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(second));
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.fonts).toHaveLength(2);
|
|
||||||
expect(store.fonts[0].id).toBe(second[0].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('appends fonts when fetching at offset > 0', async () => {
|
|
||||||
const firstPage = generateMockFonts(3);
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(firstPage, { total: 6, limit: 3, offset: 0 }));
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
const secondPage = generateMockFonts(3).map((f, i) => ({
|
|
||||||
...f,
|
|
||||||
id: `page2-font-${i + 1}`,
|
|
||||||
}));
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(secondPage, { total: 6, limit: 3, offset: 3 }));
|
|
||||||
store.setParams({ offset: 3 });
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
expect(store.fonts).toHaveLength(6);
|
|
||||||
expect(store.fonts.slice(0, 3).map(f => f.id)).toEqual(firstPage.map(f => f.id));
|
|
||||||
expect(store.fonts.slice(3).map(f => f.id)).toEqual(secondPage.map(f => f.id));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pagination state', () => {
|
|
||||||
let store: UnifiedFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new UnifiedFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns default pagination before any fetch', () => {
|
|
||||||
expect(store.pagination.total).toBe(0);
|
|
||||||
expect(store.pagination.hasMore).toBe(false);
|
|
||||||
expect(store.pagination.page).toBe(1);
|
|
||||||
expect(store.pagination.totalPages).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computes hasMore as true when more pages remain', async () => {
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
expect(store.pagination.hasMore).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computes hasMore as false on last page', async () => {
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 20, limit: 10, offset: 10 }));
|
|
||||||
store.setParams({ offset: 10 });
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
expect(store.pagination.hasMore).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computes page and totalPages from response metadata', async () => {
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
|
||||||
store.setParams({ offset: 10 });
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
expect(store.pagination.page).toBe(2);
|
|
||||||
expect(store.pagination.totalPages).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pagination navigation', () => {
|
|
||||||
let store: UnifiedFontStore;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
|
||||||
store = new UnifiedFontStore({ limit: 10 });
|
|
||||||
await tick();
|
|
||||||
await store.refetch();
|
|
||||||
await tick();
|
|
||||||
flushSync();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('nextPage() advances offset by limit when hasMore', () => {
|
|
||||||
store.nextPage();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.offset).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('nextPage() does nothing when hasMore is false', async () => {
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 20, limit: 10, offset: 10 }));
|
|
||||||
store.setParams({ offset: 10 });
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
store.nextPage();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.offset).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prevPage() decrements offset by limit when on page > 1', async () => {
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
|
||||||
store.setParams({ offset: 10 });
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
store.prevPage();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prevPage() does nothing on the first page', () => {
|
|
||||||
store.prevPage();
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('goToPage() sets the correct offset', () => {
|
|
||||||
store.goToPage(2);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.offset).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('goToPage() does nothing for page 0', () => {
|
|
||||||
store.goToPage(0);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('goToPage() does nothing for page beyond totalPages', () => {
|
|
||||||
store.goToPage(99);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setLimit() updates the limit param', () => {
|
|
||||||
store.setLimit(25);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.limit).toBe(25);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('filter setters', () => {
|
|
||||||
let store: UnifiedFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new UnifiedFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setProviders() updates the providers param', () => {
|
|
||||||
store.setProviders(['google']);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.providers).toEqual(['google']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setCategories() updates the categories param', () => {
|
|
||||||
store.setCategories(['serif']);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.categories).toEqual(['serif']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setSubsets() updates the subsets param', () => {
|
|
||||||
store.setSubsets(['cyrillic']);
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.subsets).toEqual(['cyrillic']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setSearch() sets the q param', () => {
|
|
||||||
store.setSearch('roboto');
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.q).toBe('roboto');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setSearch() with empty string sets q to undefined', () => {
|
|
||||||
store.setSearch('roboto');
|
|
||||||
store.setSearch('');
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.q).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setSort() updates the sort param', () => {
|
|
||||||
store.setSort('popularity');
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.sort).toBe('popularity');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('filter change resets pagination', () => {
|
|
||||||
let store: UnifiedFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new UnifiedFontStore({ limit: 10 });
|
|
||||||
flushSync();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets offset to 0 when a filter changes', () => {
|
|
||||||
store.setParams({ offset: 20 });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
store.setParams({ q: 'roboto' });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.params.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears accumulated fonts when a filter changes', async () => {
|
|
||||||
const fonts = generateMockFonts(3);
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(fonts));
|
|
||||||
await store.refetch();
|
|
||||||
flushSync();
|
|
||||||
expect(store.fonts).toHaveLength(3);
|
|
||||||
|
|
||||||
store.setParams({ q: 'roboto' });
|
|
||||||
flushSync();
|
|
||||||
|
|
||||||
expect(store.fonts).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('category getters', () => {
|
|
||||||
let store: UnifiedFontStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new UnifiedFontStore({ limit: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.destroy();
|
|
||||||
queryClient.clear();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sansSerifFonts returns only sans-serif fonts', async () => {
|
|
||||||
const fonts = generateMixedCategoryFonts(2);
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
expect(store.fonts).toHaveLength(10);
|
|
||||||
expect(store.sansSerifFonts).toHaveLength(2);
|
|
||||||
expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serifFonts returns only serif fonts', async () => {
|
|
||||||
const fonts = generateMixedCategoryFonts(2);
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
expect(store.serifFonts).toHaveLength(2);
|
|
||||||
expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displayFonts returns only display fonts', async () => {
|
|
||||||
const fonts = generateMixedCategoryFonts(2);
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
expect(store.displayFonts).toHaveLength(2);
|
|
||||||
expect(store.displayFonts.every(f => f.category === 'display')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handwritingFonts returns only handwriting fonts', async () => {
|
|
||||||
const fonts = generateMixedCategoryFonts(2);
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
expect(store.handwritingFonts).toHaveLength(2);
|
|
||||||
expect(store.handwritingFonts.every(f => f.category === 'handwriting')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('monospaceFonts returns only monospace fonts', async () => {
|
|
||||||
const fonts = generateMixedCategoryFonts(2);
|
|
||||||
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
|
||||||
await store.refetch();
|
|
||||||
|
|
||||||
expect(store.monospaceFonts).toHaveLength(2);
|
|
||||||
expect(store.monospaceFonts.every(f => f.category === 'monospace')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('destroy', () => {
|
|
||||||
it('calls parent destroy and filterCleanup', () => {
|
|
||||||
const store = new UnifiedFontStore({ limit: 10 });
|
|
||||||
const parentDestroySpy = vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(store)), 'destroy');
|
|
||||||
|
|
||||||
store.destroy();
|
|
||||||
|
|
||||||
expect(parentDestroySpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can be called multiple times without throwing', () => {
|
|
||||||
const store = new UnifiedFontStore({ limit: 10 });
|
|
||||||
store.destroy();
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
store.destroy();
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { QueryObserverOptions } from '@tanstack/query-core';
|
|
||||||
import type { ProxyFontsParams } from '../../../api';
|
|
||||||
import { fetchProxyFonts } from '../../../api';
|
|
||||||
import {
|
|
||||||
FontNetworkError,
|
|
||||||
FontResponseError,
|
|
||||||
} from '../../../lib/errors/errors';
|
|
||||||
import type { UnifiedFont } from '../../types';
|
|
||||||
import { BaseFontStore } from '../baseFontStore/baseFontStore.svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified font store wrapping TanStack Query with Svelte 5 runes
|
|
||||||
*
|
|
||||||
* Extends BaseFontStore to provide:
|
|
||||||
* - Reactive state management
|
|
||||||
* - TanStack Query integration for caching
|
|
||||||
* - Filter change tracking with pagination reset
|
|
||||||
* - 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.setCategories(['serif']);
|
|
||||||
* store.nextPage();
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accumulated fonts from all pages (for infinite scroll)
|
|
||||||
*/
|
|
||||||
#accumulatedFonts = $state<UnifiedFont[]>([]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track previous filter params to detect changes and reset pagination
|
|
||||||
*/
|
|
||||||
#previousFilterParams = $state<string | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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({
|
|
||||||
providers: this.params.providers,
|
|
||||||
categories: this.params.categories,
|
|
||||||
subsets: this.params.subsets,
|
|
||||||
q: this.params.q,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If filters changed, reset offset and invalidate cache
|
|
||||||
if (filterParams !== this.#previousFilterParams) {
|
|
||||||
if (this.#previousFilterParams) {
|
|
||||||
if (this.params.offset !== 0) {
|
|
||||||
this.setParams({ offset: 0 });
|
|
||||||
}
|
|
||||||
this.#accumulatedFonts = [];
|
|
||||||
this.invalidate();
|
|
||||||
}
|
|
||||||
this.#previousFilterParams = filterParams;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Effect: Sync state from Query result (Handles Cache Hits)
|
|
||||||
$effect(() => {
|
|
||||||
const data = this.result.data;
|
|
||||||
const offset = this.params.offset ?? 0;
|
|
||||||
|
|
||||||
// When we have data and we are at the start (offset 0),
|
|
||||||
// we must ensure accumulatedFonts matches the fresh (or cached) data.
|
|
||||||
// This fixes the issue where cache hits skip fetchFn side-effects.
|
|
||||||
// Only sync at offset 0 to avoid clearing fonts during cache hits at other offsets.
|
|
||||||
if (offset === 0 && data && data.length > 0) {
|
|
||||||
this.#accumulatedFonts = data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
return { ...acc, [key]: value };
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Return a consistent key
|
|
||||||
return ['unifiedFonts', normalized] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
|
||||||
const hasFilters = !!(params.q || params.providers || params.categories || params.subsets);
|
|
||||||
return {
|
|
||||||
queryKey: this.getQueryKey(params),
|
|
||||||
queryFn: () => this.fetchFn(params),
|
|
||||||
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch function that calls the proxy API
|
|
||||||
* Returns the full response including pagination metadata
|
|
||||||
*/
|
|
||||||
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
|
|
||||||
let response: Awaited<ReturnType<typeof fetchProxyFonts>>;
|
|
||||||
try {
|
|
||||||
response = await fetchProxyFonts(params);
|
|
||||||
} catch (cause) {
|
|
||||||
throw new FontNetworkError(cause);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw new FontResponseError('response', response);
|
|
||||||
}
|
|
||||||
if (!response.fonts) {
|
|
||||||
throw new FontResponseError('response.fonts', response.fonts);
|
|
||||||
}
|
|
||||||
if (!Array.isArray(response.fonts)) {
|
|
||||||
throw new FontResponseError('response.fonts', response.fonts);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#paginationMetadata = {
|
|
||||||
total: response.total ?? 0,
|
|
||||||
limit: response.limit ?? this.params.limit ?? 50,
|
|
||||||
offset: response.offset ?? this.params.offset ?? 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const offset = params.offset ?? 0;
|
|
||||||
if (offset === 0) {
|
|
||||||
// Replace accumulated fonts on offset-0 fetch
|
|
||||||
this.#accumulatedFonts = response.fonts;
|
|
||||||
} else {
|
|
||||||
// Append fonts when fetching at offset > 0
|
|
||||||
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.fonts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if filter params changed and reset if needed
|
|
||||||
* Manually called in setParams to handle test contexts where $effect doesn't run
|
|
||||||
*/
|
|
||||||
#checkAndResetFilters(newParams: Partial<ProxyFontsParams>) {
|
|
||||||
// Only check filter-related params (not offset/limit/page)
|
|
||||||
const isFilterChange = 'q' in newParams || 'providers' in newParams || 'categories' in newParams
|
|
||||||
|| 'subsets' in newParams;
|
|
||||||
|
|
||||||
if (!isFilterChange) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterParams = JSON.stringify({
|
|
||||||
providers: this.params.providers,
|
|
||||||
categories: this.params.categories,
|
|
||||||
subsets: this.params.subsets,
|
|
||||||
q: this.params.q,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filterParams !== this.#previousFilterParams) {
|
|
||||||
// Reset offset if filter params changed
|
|
||||||
if (this.params.offset !== 0) {
|
|
||||||
// Update internal params directly to avoid recursion
|
|
||||||
this.updateInternalParams({ offset: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear fonts if there are accumulated fonts
|
|
||||||
// (to avoid clearing on initial setup when no fonts exist)
|
|
||||||
if (this.#accumulatedFonts.length > 0) {
|
|
||||||
this.#accumulatedFonts = [];
|
|
||||||
// Clear the result to prevent effect from using stale cached data
|
|
||||||
this.result.data = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.invalidate();
|
|
||||||
this.#previousFilterParams = filterParams;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Override setParams to check for filter changes
|
|
||||||
* @param newParams - Partial params to merge with existing
|
|
||||||
*/
|
|
||||||
setParams(newParams: Partial<ProxyFontsParams>) {
|
|
||||||
// First update params normally
|
|
||||||
super.setParams(newParams);
|
|
||||||
// Then check if filters changed (for test contexts)
|
|
||||||
this.#checkAndResetFilters(newParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set providers filter
|
|
||||||
*/
|
|
||||||
setProviders(providers: ProxyFontsParams['providers']) {
|
|
||||||
this.setParams({ providers });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set categories filter
|
|
||||||
*/
|
|
||||||
setCategories(categories: ProxyFontsParams['categories']) {
|
|
||||||
this.setParams({ categories });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set subsets filter
|
|
||||||
*/
|
|
||||||
setSubsets(subsets: ProxyFontsParams['subsets']) {
|
|
||||||
this.setParams({ subsets });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set search query
|
|
||||||
*/
|
|
||||||
setSearch(search: string) {
|
|
||||||
this.setParams({ q: search || undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set sort order
|
|
||||||
*/
|
|
||||||
setSort(sort: ProxyFontsParams['sort']) {
|
|
||||||
this.setParams({ sort });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* Common font domain types
|
|
||||||
*
|
|
||||||
* Shared types for font entities across providers (Google, Fontshare).
|
|
||||||
* Includes categories, subsets, weights, and filter types.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { FontCategory as FontshareFontCategory } from './fontshare';
|
|
||||||
import type { FontCategory as GoogleFontCategory } from './google';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified font category across all providers
|
|
||||||
*/
|
|
||||||
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font provider identifier
|
|
||||||
*/
|
|
||||||
export type FontProvider = 'google' | 'fontshare';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Character subset support
|
|
||||||
*/
|
|
||||||
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combined filter state for font queries
|
|
||||||
*/
|
|
||||||
export interface FontFilters {
|
|
||||||
/** Selected font providers */
|
|
||||||
providers: FontProvider[];
|
|
||||||
/** Selected font categories */
|
|
||||||
categories: FontCategory[];
|
|
||||||
/** Selected character subsets */
|
|
||||||
subsets: FontSubset[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Filter group identifier */
|
|
||||||
export type FilterGroup = 'providers' | 'categories' | 'subsets';
|
|
||||||
|
|
||||||
/** Filter type including search query */
|
|
||||||
export type FilterType = FilterGroup | 'searchQuery';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Numeric font weights (100-900)
|
|
||||||
*/
|
|
||||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Italic variant with weight: "100italic", "400italic", "700italic", etc.
|
|
||||||
*/
|
|
||||||
export type FontWeightItalic = `${FontWeight}italic`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All possible font variant identifiers
|
|
||||||
*
|
|
||||||
* Includes:
|
|
||||||
* - Numeric weights: "400", "700", etc.
|
|
||||||
* - Italic variants: "400italic", "700italic", etc.
|
|
||||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
|
||||||
*/
|
|
||||||
export type FontVariant =
|
|
||||||
| FontWeight
|
|
||||||
| FontWeightItalic
|
|
||||||
| 'regular'
|
|
||||||
| 'italic'
|
|
||||||
| 'bold'
|
|
||||||
| 'bolditalic';
|
|
||||||
@@ -1,25 +1,85 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* Font domain types
|
||||||
* NORMALIZATION TYPES
|
*
|
||||||
* ============================================================================
|
* Shared types for font entities across providers (Google, Fontshare).
|
||||||
|
* Includes categories, subsets, weights, and the unified font model.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
/**
|
||||||
FontCategory,
|
* Unified font category across all providers
|
||||||
FontProvider,
|
*/
|
||||||
FontSubset,
|
export type FontCategory =
|
||||||
FontVariant,
|
| 'sans-serif'
|
||||||
} from './common';
|
| 'serif'
|
||||||
|
| 'display'
|
||||||
|
| 'handwriting'
|
||||||
|
| 'monospace'
|
||||||
|
| 'slab'
|
||||||
|
| 'script';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font variant types (standardized)
|
* Font provider identifier
|
||||||
|
*/
|
||||||
|
export type FontProvider = 'google' | 'fontshare';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character subset support
|
||||||
|
*/
|
||||||
|
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined filter state for font queries
|
||||||
|
*/
|
||||||
|
export interface FontFilters {
|
||||||
|
/** Selected font providers */
|
||||||
|
providers: FontProvider[];
|
||||||
|
/** Selected font categories */
|
||||||
|
categories: FontCategory[];
|
||||||
|
/** Selected character subsets */
|
||||||
|
subsets: FontSubset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter group identifier */
|
||||||
|
export type FilterGroup = 'providers' | 'categories' | 'subsets';
|
||||||
|
|
||||||
|
/** Filter type including search query */
|
||||||
|
export type FilterType = FilterGroup | 'searchQuery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numeric font weights (100-900)
|
||||||
|
*/
|
||||||
|
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Italic variant with weight: "100italic", "400italic", "700italic", etc.
|
||||||
|
*/
|
||||||
|
export type FontWeightItalic = `${FontWeight}italic`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All possible font variant identifiers
|
||||||
|
*
|
||||||
|
* Includes:
|
||||||
|
* - Numeric weights: "400", "700", etc.
|
||||||
|
* - Italic variants: "400italic", "700italic", etc.
|
||||||
|
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
||||||
|
*/
|
||||||
|
export type FontVariant =
|
||||||
|
| FontWeight
|
||||||
|
| FontWeightItalic
|
||||||
|
| 'regular'
|
||||||
|
| 'italic'
|
||||||
|
| 'bold'
|
||||||
|
| 'bolditalic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized font variant alias
|
||||||
*/
|
*/
|
||||||
export type UnifiedFontVariant = FontVariant;
|
export type UnifiedFontVariant = FontVariant;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font style URLs
|
* Font style URLs
|
||||||
*/
|
*/
|
||||||
export interface LegacyFontStyleUrls {
|
export interface FontStyleUrls {
|
||||||
/** Regular weight URL */
|
/** Regular weight URL */
|
||||||
regular?: string;
|
regular?: string;
|
||||||
/** Italic URL */
|
/** Italic URL */
|
||||||
@@ -28,9 +88,7 @@ export interface LegacyFontStyleUrls {
|
|||||||
bold?: string;
|
bold?: string;
|
||||||
/** Bold italic URL */
|
/** Bold italic URL */
|
||||||
boldItalic?: string;
|
boldItalic?: string;
|
||||||
}
|
/** Additional variant mapping */
|
||||||
|
|
||||||
export interface FontStyleUrls extends LegacyFontStyleUrls {
|
|
||||||
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* FONTHARE API TYPES
|
|
||||||
* ============================================================================
|
|
||||||
*/
|
|
||||||
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2/fonts' as const;
|
|
||||||
|
|
||||||
export type FontCategory = 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Model of Fontshare API response
|
|
||||||
* @see https://fontshare.com
|
|
||||||
*
|
|
||||||
* Fontshare API uses 'fonts' key instead of 'items' for the array
|
|
||||||
*/
|
|
||||||
export interface FontshareApiModel {
|
|
||||||
/**
|
|
||||||
* Number of items returned in current page/response
|
|
||||||
*/
|
|
||||||
count: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Total number of items available across all pages
|
|
||||||
*/
|
|
||||||
count_total: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicates if there are more items available beyond this page
|
|
||||||
*/
|
|
||||||
has_more: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Array of fonts (Fontshare uses 'fonts' key, not 'items')
|
|
||||||
*/
|
|
||||||
fonts: FontshareFont[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Individual font metadata from Fontshare API
|
|
||||||
*/
|
|
||||||
export interface FontshareFont {
|
|
||||||
/**
|
|
||||||
* Unique identifier for the font
|
|
||||||
* UUID v4 format (e.g., "20e9fcdc-1e41-4559-a43d-1ede0adc8896")
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display name of the font family
|
|
||||||
* Examples: "Satoshi", "General Sans", "Clash Display"
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native/localized name of the font (if available)
|
|
||||||
* Often null for Latin-script fonts
|
|
||||||
*/
|
|
||||||
native_name: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL-friendly identifier for the font
|
|
||||||
* Used in URLs: e.g., "satoshi", "general-sans", "clash-display"
|
|
||||||
*/
|
|
||||||
slug: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font category classification
|
|
||||||
* Examples: "Sans", "Serif", "Display", "Script"
|
|
||||||
*/
|
|
||||||
category: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Script/writing system supported by the font
|
|
||||||
* Examples: "latin", "arabic", "devanagari"
|
|
||||||
*/
|
|
||||||
script: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font publisher/foundry information
|
|
||||||
*/
|
|
||||||
publisher: FontsharePublisher;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Array of designers who created this font
|
|
||||||
* Multiple designers may have collaborated on a single font
|
|
||||||
*/
|
|
||||||
designers: FontshareDesigner[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Related font families (if any)
|
|
||||||
* Often null, as fonts are typically independent
|
|
||||||
*/
|
|
||||||
related_families: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to display publisher as the designer instead of individual designers
|
|
||||||
*/
|
|
||||||
display_publisher_as_designer: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether trial downloads are enabled for this font
|
|
||||||
*/
|
|
||||||
trials_enabled: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to show Latin-specific metrics
|
|
||||||
*/
|
|
||||||
show_latin_metrics: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type of license for this font
|
|
||||||
* Examples: "itf_ffl" (ITF Free Font License)
|
|
||||||
*/
|
|
||||||
license_type: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Comma-separated list of languages supported by this font
|
|
||||||
* Example: "Afar, Afrikaans, Albanian, Aranese, Aromanian, Aymara, ..."
|
|
||||||
*/
|
|
||||||
languages: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ISO 8601 timestamp when the font was added to Fontshare
|
|
||||||
* Format: "2021-03-12T20:49:05Z"
|
|
||||||
*/
|
|
||||||
inserted_at: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTML-formatted story/description about the font
|
|
||||||
* Contains marketing text, design philosophy, and usage recommendations
|
|
||||||
*/
|
|
||||||
story: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Version of the font family
|
|
||||||
* Format: "1.0", "1.2", etc.
|
|
||||||
*/
|
|
||||||
version: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Total number of times this font has been viewed
|
|
||||||
*/
|
|
||||||
views: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Number of views in the recent time period
|
|
||||||
*/
|
|
||||||
views_recent: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this font is marked as "hot"/trending
|
|
||||||
*/
|
|
||||||
is_hot: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this font is marked as new
|
|
||||||
*/
|
|
||||||
is_new: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this font is in the shortlisted collection
|
|
||||||
*/
|
|
||||||
is_shortlisted: boolean | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this font is marked as top/popular
|
|
||||||
*/
|
|
||||||
is_top: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Variable font axes (for variable fonts)
|
|
||||||
* Empty array [] for static fonts
|
|
||||||
*/
|
|
||||||
axes: FontshareAxis[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tags/categories for this font
|
|
||||||
* Examples: ["Magazines", "Branding", "Logos", "Posters"]
|
|
||||||
*/
|
|
||||||
font_tags: FontshareTag[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenType features available in this font
|
|
||||||
*/
|
|
||||||
features: FontshareFeature[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Array of available font styles/variants
|
|
||||||
* Each style represents a different font file (weight, italic, variable)
|
|
||||||
*/
|
|
||||||
styles: FontshareStyle[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publisher/foundry information
|
|
||||||
*/
|
|
||||||
export interface FontsharePublisher {
|
|
||||||
/**
|
|
||||||
* Description/bio of the publisher
|
|
||||||
* Example: "Indian Type Foundry (ITF) creates retail and custom multilingual fonts..."
|
|
||||||
*/
|
|
||||||
bio: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publisher email (if available)
|
|
||||||
*/
|
|
||||||
email: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unique publisher identifier
|
|
||||||
* UUID format
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publisher links (social media, website, etc.)
|
|
||||||
*/
|
|
||||||
links: FontshareLink[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publisher name
|
|
||||||
* Example: "Indian Type Foundry"
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Designer information
|
|
||||||
*/
|
|
||||||
export interface FontshareDesigner {
|
|
||||||
/**
|
|
||||||
* Designer bio/description
|
|
||||||
*/
|
|
||||||
bio: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Designer links (Twitter, website, etc.)
|
|
||||||
*/
|
|
||||||
links: FontshareLink[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Designer name
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Link information
|
|
||||||
*/
|
|
||||||
export interface FontshareLink {
|
|
||||||
/**
|
|
||||||
* Name of the link platform/site
|
|
||||||
* Examples: "Twitter", "GitHub", "Website"
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL of the link (may be null)
|
|
||||||
*/
|
|
||||||
url: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font tag/category
|
|
||||||
*/
|
|
||||||
export interface FontshareTag {
|
|
||||||
/**
|
|
||||||
* Tag name
|
|
||||||
* Examples: "Magazines", "Branding", "Logos", "Posters"
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenType feature
|
|
||||||
*/
|
|
||||||
export interface FontshareFeature {
|
|
||||||
/**
|
|
||||||
* Feature name (descriptive name or null)
|
|
||||||
* Examples: "Alternate t", "All Alternates", or null
|
|
||||||
*/
|
|
||||||
name: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this feature is on by default
|
|
||||||
*/
|
|
||||||
on_by_default: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenType feature tag (4-character code)
|
|
||||||
* Examples: "ss01", "frac", "liga", "aalt", "case"
|
|
||||||
*/
|
|
||||||
tag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Variable font axis (for variable fonts)
|
|
||||||
* Defines the range and properties of a variable font axis (e.g., weight)
|
|
||||||
*/
|
|
||||||
export interface FontshareAxis {
|
|
||||||
/**
|
|
||||||
* Name of the axis
|
|
||||||
* Example: "wght" (weight axis)
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSS property name for the axis
|
|
||||||
* Example: "wght"
|
|
||||||
*/
|
|
||||||
property: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default value for the axis
|
|
||||||
* Example: 420.0, 650.0, 700.0
|
|
||||||
*/
|
|
||||||
range_default: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum value for the axis
|
|
||||||
* Example: 300.0, 100.0, 200.0
|
|
||||||
*/
|
|
||||||
range_left: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum value for the axis
|
|
||||||
* Example: 900.0, 700.0, 800.0
|
|
||||||
*/
|
|
||||||
range_right: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Individual font style/variant
|
|
||||||
* Each style represents a single downloadable font file
|
|
||||||
*/
|
|
||||||
export interface FontshareStyle {
|
|
||||||
/**
|
|
||||||
* Unique identifier for this style
|
|
||||||
* UUID format
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this is the default style for the font family
|
|
||||||
* Typically, one style per font is marked as default
|
|
||||||
*/
|
|
||||||
default: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CDN URL to the font file
|
|
||||||
* Protocol-relative URL: "//cdn.fontshare.com/wf/..."
|
|
||||||
* Note: URL starts with "//" (protocol-relative), may need protocol prepended
|
|
||||||
*/
|
|
||||||
file: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this style is italic
|
|
||||||
* false for upright, true for italic styles
|
|
||||||
*/
|
|
||||||
is_italic: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this is a variable font
|
|
||||||
* Variable fonts have adjustable axes (weight, slant, etc.)
|
|
||||||
*/
|
|
||||||
is_variable: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typography properties for this style
|
|
||||||
* Contains measurements like cap height, x-height, ascenders/descenders
|
|
||||||
* May be empty object {} for some styles
|
|
||||||
*/
|
|
||||||
properties: FontshareStyleProperties | Record<string, never>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Weight information for this style
|
|
||||||
*/
|
|
||||||
weight: FontshareWeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typography/measurement properties for a font style
|
|
||||||
*/
|
|
||||||
export interface FontshareStyleProperties {
|
|
||||||
/**
|
|
||||||
* Distance from baseline to the top of ascenders
|
|
||||||
* Example: 1010, 990, 1000
|
|
||||||
*/
|
|
||||||
ascending_leading: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Height of uppercase letters (cap height)
|
|
||||||
* Example: 710, 680, 750
|
|
||||||
*/
|
|
||||||
cap_height: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Distance from baseline to the bottom of descenders (negative value)
|
|
||||||
* Example: -203, -186, -220
|
|
||||||
*/
|
|
||||||
descending_leading: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Body height of the font
|
|
||||||
* Often null in Fontshare data
|
|
||||||
*/
|
|
||||||
body_height: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum character width in the font
|
|
||||||
* Example: 1739, 1739, 1739
|
|
||||||
*/
|
|
||||||
max_char_width: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Height of lowercase x-height
|
|
||||||
* Example: 480, 494, 523
|
|
||||||
*/
|
|
||||||
x_height: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum Y coordinate (top of ascenders)
|
|
||||||
* Example: 1010, 990, 1026
|
|
||||||
*/
|
|
||||||
y_max: number | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum Y coordinate (bottom of descenders)
|
|
||||||
* Example: -240, -250, -280
|
|
||||||
*/
|
|
||||||
y_min: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Weight information for a font style
|
|
||||||
*/
|
|
||||||
export interface FontshareWeight {
|
|
||||||
/**
|
|
||||||
* Display label for the weight
|
|
||||||
* Examples: "Light", "Regular", "Bold", "Variable", "Variable Italic"
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal name for the weight
|
|
||||||
* Examples: "Light", "Regular", "Bold", "Variable", "VariableItalic"
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native/localized name for the weight (if available)
|
|
||||||
* Often null for Latin-script fonts
|
|
||||||
*/
|
|
||||||
native_name: string | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Numeric weight value
|
|
||||||
* Examples: 300, 400, 700, 0 (for variable fonts), 1, 2
|
|
||||||
* Note: This matches the `weight` property
|
|
||||||
*/
|
|
||||||
number: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Numeric weight value (duplicate of `number`)
|
|
||||||
* Appears to be redundant with `number` field
|
|
||||||
*/
|
|
||||||
weight: number;
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* GOOGLE FONTS API TYPES
|
|
||||||
* ============================================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { FontVariant } from './common';
|
|
||||||
|
|
||||||
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Model of google fonts api response
|
|
||||||
*/
|
|
||||||
export interface GoogleFontsApiModel {
|
|
||||||
/**
|
|
||||||
* Array of font items returned by the Google Fonts API
|
|
||||||
* Contains all font families matching the requested query parameters
|
|
||||||
*/
|
|
||||||
items: FontItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Individual font from Google Fonts API
|
|
||||||
*/
|
|
||||||
export interface FontItem {
|
|
||||||
/**
|
|
||||||
* Font family name (e.g., "Roboto", "Open Sans", "Lato")
|
|
||||||
* This is the name used in CSS font-family declarations
|
|
||||||
*/
|
|
||||||
family: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace")
|
|
||||||
* Useful for grouping and filtering fonts by style
|
|
||||||
*/
|
|
||||||
category: FontCategory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Available font variants for this font family
|
|
||||||
* Array of strings representing available weights and styles
|
|
||||||
* Examples: ["regular", "italic", "100", "200", "300", "400", "500", "600", "700", "800", "900", "100italic", "900italic"]
|
|
||||||
* The keys in the `files` object correspond to these variant values
|
|
||||||
*/
|
|
||||||
variants: FontVariant[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported character subsets for this font
|
|
||||||
* Examples: ["latin", "latin-ext", "cyrillic", "greek", "arabic", "devanagari", "vietnamese", "hebrew", "thai", etc.]
|
|
||||||
* Determines which character sets are included in the font files
|
|
||||||
*/
|
|
||||||
subsets: string[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font version identifier
|
|
||||||
* Format: "v" followed by version number (e.g., "v31", "v20", "v1")
|
|
||||||
* Used to track font updates and cache busting
|
|
||||||
*/
|
|
||||||
version: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last modification date of the font
|
|
||||||
* Format: ISO 8601 date string (e.g., "2024-01-15", "2023-12-01")
|
|
||||||
* Indicates when the font was last updated by the font foundry
|
|
||||||
*/
|
|
||||||
lastModified: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mapping of font variants to their downloadable URLs
|
|
||||||
* Keys correspond to values in the `variants` array
|
|
||||||
* Examples:
|
|
||||||
* - "regular" → "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me4W..."
|
|
||||||
* - "700" → "https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlf..."
|
|
||||||
* - "700italic" → "https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzA..."
|
|
||||||
*/
|
|
||||||
files: FontFiles;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL to the font menu preview image
|
|
||||||
* Typically a PNG showing the font family name in the font
|
|
||||||
* Example: "https://fonts.gstatic.com/l/font?kit=KFOmCnqEu92Fr1Me4W...&s=i2"
|
|
||||||
*/
|
|
||||||
menu: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type alias for backward compatibility
|
|
||||||
* Google Fonts API font item
|
|
||||||
*/
|
|
||||||
export type GoogleFontItem = FontItem;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Google Fonts API file mapping
|
|
||||||
* Dynamic keys that match the variants array
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* - { "regular": "...", "italic": "...", "700": "...", "700italic": "..." }
|
|
||||||
* - { "400": "...", "400italic": "...", "900": "..." }
|
|
||||||
*/
|
|
||||||
export type FontFiles = Partial<Record<FontVariant, string>>;
|
|
||||||
@@ -7,48 +7,23 @@
|
|||||||
* All imports should use: `import { X } from '$entities/Font/model/types'`
|
* All imports should use: `import { X } from '$entities/Font/model/types'`
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Domain types
|
// Font domain and model types
|
||||||
export type {
|
export type {
|
||||||
|
FilterGroup,
|
||||||
|
FilterType,
|
||||||
FontCategory,
|
FontCategory,
|
||||||
|
FontFeatures,
|
||||||
|
FontFilters,
|
||||||
|
FontMetadata,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
|
FontStyleUrls,
|
||||||
FontSubset,
|
FontSubset,
|
||||||
FontVariant,
|
FontVariant,
|
||||||
FontWeight,
|
FontWeight,
|
||||||
FontWeightItalic,
|
FontWeightItalic,
|
||||||
} from './common';
|
|
||||||
|
|
||||||
// Google Fonts API types
|
|
||||||
export type {
|
|
||||||
FontFiles,
|
|
||||||
FontItem,
|
|
||||||
GoogleFontItem,
|
|
||||||
GoogleFontsApiModel,
|
|
||||||
} from './google';
|
|
||||||
|
|
||||||
// Fontshare API types
|
|
||||||
export type {
|
|
||||||
FontshareApiModel,
|
|
||||||
FontshareAxis,
|
|
||||||
FontshareDesigner,
|
|
||||||
FontshareFeature,
|
|
||||||
FontshareFont,
|
|
||||||
FontshareLink,
|
|
||||||
FontsharePublisher,
|
|
||||||
FontshareStyle,
|
|
||||||
FontshareStyleProperties,
|
|
||||||
FontshareTag,
|
|
||||||
FontshareWeight,
|
|
||||||
} from './fontshare';
|
|
||||||
export { FONTSHARE_API_URL } from './fontshare';
|
|
||||||
|
|
||||||
// Normalization types
|
|
||||||
export type {
|
|
||||||
FontFeatures,
|
|
||||||
FontMetadata,
|
|
||||||
FontStyleUrls,
|
|
||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
UnifiedFontVariant,
|
UnifiedFontVariant,
|
||||||
} from './normalize';
|
} from './font';
|
||||||
|
|
||||||
// Store types
|
// Store types
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import type {
|
|||||||
FontCategory,
|
FontCategory,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
FontSubset,
|
FontSubset,
|
||||||
} from './common';
|
UnifiedFont,
|
||||||
import type { UnifiedFont } from './normalize';
|
} from './font';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font collection state
|
* Font collection state
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
unifiedFontStore,
|
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
import { fontStore } from '../../model/store';
|
||||||
|
|
||||||
interface Props extends
|
interface Props extends
|
||||||
Omit<
|
Omit<
|
||||||
@@ -50,7 +50,7 @@ let {
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const isLoading = $derived(
|
const isLoading = $derived(
|
||||||
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
fontStore.isFetching || fontStore.isLoading,
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||||
@@ -82,12 +82,12 @@ function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
|||||||
*/
|
*/
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
if (
|
if (
|
||||||
!unifiedFontStore.pagination.hasMore
|
!fontStore.pagination.hasMore
|
||||||
|| unifiedFontStore.isFetching
|
|| fontStore.isFetching
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unifiedFontStore.nextPage();
|
fontStore.nextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,17 +97,17 @@ function loadMore() {
|
|||||||
* of the loaded items. Only fetches if there are more pages available.
|
* of the loaded items. Only fetches if there are more pages available.
|
||||||
*/
|
*/
|
||||||
function handleNearBottom(_lastVisibleIndex: number) {
|
function handleNearBottom(_lastVisibleIndex: number) {
|
||||||
const { hasMore } = unifiedFontStore.pagination;
|
const { hasMore } = fontStore.pagination;
|
||||||
|
|
||||||
// VirtualList already checks if we're near the bottom of loaded items
|
// VirtualList already checks if we're near the bottom of loaded items
|
||||||
if (hasMore && !unifiedFontStore.isFetching) {
|
if (hasMore && !fontStore.isFetching) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative w-full h-full">
|
<div class="relative w-full h-full">
|
||||||
{#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
|
{#if skeleton && isLoading && fontStore.fonts.length === 0}
|
||||||
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
||||||
<div transition:fade={{ duration: 300 }}>
|
<div transition:fade={{ duration: 300 }}>
|
||||||
{@render skeleton()}
|
{@render skeleton()}
|
||||||
@@ -115,8 +115,8 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={unifiedFontStore.fonts}
|
items={fontStore.fonts}
|
||||||
total={unifiedFontStore.pagination.total}
|
total={fontStore.pagination.total}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
onNearBottom={handleNearBottom}
|
onNearBottom={handleNearBottom}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
Sits below the filter list, separated by a top border.
|
Sits below the filter list, separated by a top border.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { unifiedFontStore } from '$entities/Font';
|
import { fontStore } from '$entities/Font';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import { Button } from '$shared/ui';
|
import { Button } from '$shared/ui';
|
||||||
@@ -33,7 +33,7 @@ const {
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const apiSort = sortStore.apiValue;
|
const apiSort = sortStore.apiValue;
|
||||||
untrack(() => unifiedFontStore.setSort(apiSort));
|
untrack(() => fontStore.setSort(apiSort));
|
||||||
});
|
});
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import {
|
||||||
|
type PreparedTextWithSegments,
|
||||||
|
layoutWithLines,
|
||||||
|
prepareWithSegments,
|
||||||
|
} from '@chenglou/pretext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single laid-out line produced by dual-font comparison layout.
|
||||||
|
*
|
||||||
|
* Line breaking is determined by the unified worst-case widths, so both fonts
|
||||||
|
* always break at identical positions. Per-character `xA`/`xB` offsets reflect
|
||||||
|
* each font's actual advance widths independently.
|
||||||
|
*/
|
||||||
|
export interface ComparisonLine {
|
||||||
|
/** Full text of this line as returned by pretext. */
|
||||||
|
text: string;
|
||||||
|
/** Rendered width of this line in pixels — maximum across font A and font B. */
|
||||||
|
width: number;
|
||||||
|
chars: Array<{
|
||||||
|
/** The grapheme cluster string (may be >1 code unit for emoji, etc.). */
|
||||||
|
char: string;
|
||||||
|
/** X offset from the start of the line in font A, in pixels. */
|
||||||
|
xA: number;
|
||||||
|
/** Advance width of this grapheme in font A, in pixels. */
|
||||||
|
widthA: number;
|
||||||
|
/** X offset from the start of the line in font B, in pixels. */
|
||||||
|
xB: number;
|
||||||
|
/** Advance width of this grapheme in font B, in pixels. */
|
||||||
|
widthB: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated output of a dual-font layout pass.
|
||||||
|
*/
|
||||||
|
export interface ComparisonResult {
|
||||||
|
/** Per-line grapheme data for both fonts. Empty when input text is empty. */
|
||||||
|
lines: ComparisonLine[];
|
||||||
|
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
|
||||||
|
totalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual-font text layout engine backed by `@chenglou/pretext`.
|
||||||
|
*
|
||||||
|
* Computes identical line breaks for two fonts simultaneously by constructing a
|
||||||
|
* "unified" prepared-text object whose per-glyph widths are the worst-case maximum
|
||||||
|
* of font A and font B. This guarantees that both fonts wrap at exactly the same
|
||||||
|
* positions, making side-by-side or slider comparison visually coherent.
|
||||||
|
*
|
||||||
|
* **Two-level caching strategy**
|
||||||
|
* 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only
|
||||||
|
* when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive
|
||||||
|
* (canvas measurement), so this avoids re-measuring during slider interaction.
|
||||||
|
* 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but
|
||||||
|
* the fonts have not changed. Line-breaking is cheap relative to measurement, but
|
||||||
|
* still worth skipping on every render tick.
|
||||||
|
*
|
||||||
|
* **`as any` casts:** `PreparedTextWithSegments` exposes only the `segments` field in
|
||||||
|
* its public TypeScript type. The numeric arrays (`widths`, `breakableFitAdvances`,
|
||||||
|
* `lineEndFitAdvances`, `lineEndPaintAdvances`) are internal implementation details of
|
||||||
|
* pretext that are not part of the published type signature. The casts are required to
|
||||||
|
* access these fields; they are verified against the pretext source at
|
||||||
|
* `node_modules/@chenglou/pretext/src/layout.ts`.
|
||||||
|
*/
|
||||||
|
export class CharacterComparisonEngine {
|
||||||
|
#segmenter: Intl.Segmenter;
|
||||||
|
|
||||||
|
// Cached prepared data
|
||||||
|
#preparedA: PreparedTextWithSegments | null = null;
|
||||||
|
#preparedB: PreparedTextWithSegments | null = null;
|
||||||
|
#unifiedPrepared: PreparedTextWithSegments | null = null;
|
||||||
|
|
||||||
|
#lastText = '';
|
||||||
|
#lastFontA = '';
|
||||||
|
#lastFontB = '';
|
||||||
|
|
||||||
|
// Cached layout results
|
||||||
|
#lastWidth = -1;
|
||||||
|
#lastLineHeight = -1;
|
||||||
|
#lastResult: ComparisonResult | null = null;
|
||||||
|
|
||||||
|
constructor(locale?: string) {
|
||||||
|
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lay out `text` using both fonts within `width` pixels.
|
||||||
|
*
|
||||||
|
* Line breaks are determined by the worst-case (maximum) glyph widths across
|
||||||
|
* both fonts, so both fonts always wrap at identical positions.
|
||||||
|
*
|
||||||
|
* @param text Raw text to lay out.
|
||||||
|
* @param fontA CSS font string for the first font: `"weight sizepx \"family\""`.
|
||||||
|
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
|
||||||
|
* @param width Available line width in pixels.
|
||||||
|
* @param lineHeight Line height in pixels (passed directly to pretext).
|
||||||
|
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
|
||||||
|
*/
|
||||||
|
layout(
|
||||||
|
text: string,
|
||||||
|
fontA: string,
|
||||||
|
fontB: string,
|
||||||
|
width: number,
|
||||||
|
lineHeight: number,
|
||||||
|
): ComparisonResult {
|
||||||
|
if (!text) {
|
||||||
|
return { lines: [], totalHeight: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFontChange = text !== this.#lastText || fontA !== this.#lastFontA || fontB !== this.#lastFontB;
|
||||||
|
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
|
||||||
|
|
||||||
|
if (!isFontChange && !isLayoutChange && this.#lastResult) {
|
||||||
|
return this.#lastResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Prepare (or use cache)
|
||||||
|
if (isFontChange) {
|
||||||
|
this.#preparedA = prepareWithSegments(text, fontA);
|
||||||
|
this.#preparedB = prepareWithSegments(text, fontB);
|
||||||
|
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB);
|
||||||
|
|
||||||
|
this.#lastText = text;
|
||||||
|
this.#lastFontA = fontA;
|
||||||
|
this.#lastFontB = fontB;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
|
||||||
|
return { lines: [], totalHeight: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Layout using the unified widths.
|
||||||
|
// `PreparedTextWithSegments` only exposes `segments` in its public type; cast to `any`
|
||||||
|
// so pretext's layoutWithLines can read the internal numeric arrays at runtime.
|
||||||
|
const { lines, height } = layoutWithLines(this.#unifiedPrepared as any, width, lineHeight);
|
||||||
|
|
||||||
|
// 3. Map results back to both fonts
|
||||||
|
const resultLines: ComparisonLine[] = lines.map(line => {
|
||||||
|
const chars: ComparisonLine['chars'] = [];
|
||||||
|
let currentXA = 0;
|
||||||
|
let currentXB = 0;
|
||||||
|
|
||||||
|
const start = line.start;
|
||||||
|
const end = line.end;
|
||||||
|
|
||||||
|
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
|
||||||
|
const intA = this.#preparedA as any;
|
||||||
|
const intB = this.#preparedB as any;
|
||||||
|
|
||||||
|
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
||||||
|
const segmentText = this.#preparedA!.segments[sIdx];
|
||||||
|
if (segmentText === undefined) continue;
|
||||||
|
|
||||||
|
// PERFORMANCE: Reuse segmenter results if possible, but for now just optimize the loop
|
||||||
|
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
||||||
|
|
||||||
|
const advA = intA.breakableFitAdvances[sIdx];
|
||||||
|
const advB = intB.breakableFitAdvances[sIdx];
|
||||||
|
|
||||||
|
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
|
||||||
|
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
|
||||||
|
|
||||||
|
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
||||||
|
const char = graphemes[gIdx];
|
||||||
|
const wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
|
||||||
|
const wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
|
||||||
|
|
||||||
|
chars.push({
|
||||||
|
char,
|
||||||
|
xA: currentXA,
|
||||||
|
widthA: wA,
|
||||||
|
xB: currentXB,
|
||||||
|
widthB: wB,
|
||||||
|
});
|
||||||
|
currentXA += wA;
|
||||||
|
currentXB += wB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: line.text,
|
||||||
|
width: line.width,
|
||||||
|
chars,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#lastWidth = width;
|
||||||
|
this.#lastLineHeight = lineHeight;
|
||||||
|
this.#lastResult = {
|
||||||
|
lines: resultLines,
|
||||||
|
totalHeight: height,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.#lastResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates character proximity and direction relative to a slider position.
|
||||||
|
*
|
||||||
|
* Uses the most recent `layout()` result — must be called after `layout()`.
|
||||||
|
* No DOM calls are made; all geometry is derived from cached layout data.
|
||||||
|
*
|
||||||
|
* @param lineIndex Zero-based index of the line within the last layout result.
|
||||||
|
* @param charIndex Zero-based index of the character within that line's `chars` array.
|
||||||
|
* @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`.
|
||||||
|
* @param containerWidth Total container width in pixels, used to convert pixel offsets to %.
|
||||||
|
* @returns `proximity` in [0, 1] (1 = slider exactly over char center) and
|
||||||
|
* `isPast` (true when the slider has already passed the char center).
|
||||||
|
*/
|
||||||
|
getCharState(
|
||||||
|
lineIndex: number,
|
||||||
|
charIndex: number,
|
||||||
|
sliderPos: number,
|
||||||
|
containerWidth: number,
|
||||||
|
): { proximity: number; isPast: boolean } {
|
||||||
|
if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) {
|
||||||
|
return { proximity: 0, isPast: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = this.#lastResult.lines[lineIndex];
|
||||||
|
const char = line.chars[charIndex];
|
||||||
|
|
||||||
|
if (!char) return { proximity: 0, isPast: false };
|
||||||
|
|
||||||
|
// Center the comparison on the unified width
|
||||||
|
// In the UI, lines are centered. So we need to calculate the global X.
|
||||||
|
const lineXOffset = (containerWidth - line.width) / 2;
|
||||||
|
const charCenterX = lineXOffset + char.xA + (char.widthA / 2);
|
||||||
|
|
||||||
|
const charGlobalPercent = (charCenterX / 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helper to merge two prepared texts into a "worst-case" unified version
|
||||||
|
*/
|
||||||
|
#createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments {
|
||||||
|
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
|
||||||
|
const intA = a as any;
|
||||||
|
const intB = b as any;
|
||||||
|
|
||||||
|
const unified = { ...intA };
|
||||||
|
|
||||||
|
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]));
|
||||||
|
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
|
||||||
|
Math.max(w, intB.lineEndFitAdvances[i])
|
||||||
|
);
|
||||||
|
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
|
||||||
|
Math.max(w, intB.lineEndPaintAdvances[i])
|
||||||
|
);
|
||||||
|
|
||||||
|
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
|
||||||
|
const advB = intB.breakableFitAdvances[i];
|
||||||
|
if (!advA && !advB) return null;
|
||||||
|
if (!advA) return advB;
|
||||||
|
if (!advB) return advA;
|
||||||
|
|
||||||
|
return advA.map((w: number, j: number) => Math.max(w, advB[j]));
|
||||||
|
});
|
||||||
|
|
||||||
|
return unified;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { clearCache } from '@chenglou/pretext';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import { installCanvasMock } from '../__mocks__/canvas';
|
||||||
|
import { CharacterComparisonEngine } from './CharacterComparisonEngine.svelte';
|
||||||
|
|
||||||
|
// FontA: 10px per character. FontB: 15px per character.
|
||||||
|
// The mock dispatches on whether the font string contains 'FontA' or 'FontB'.
|
||||||
|
const FONT_A_WIDTH = 10;
|
||||||
|
const FONT_B_WIDTH = 15;
|
||||||
|
|
||||||
|
function fontWidthFactory(font: string, text: string): number {
|
||||||
|
const perChar = font.includes('FontA') ? FONT_A_WIDTH : FONT_B_WIDTH;
|
||||||
|
return text.length * perChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CharacterComparisonEngine', () => {
|
||||||
|
let engine: CharacterComparisonEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
installCanvasMock(fontWidthFactory);
|
||||||
|
clearCache();
|
||||||
|
engine = new CharacterComparisonEngine();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- layout() ---
|
||||||
|
|
||||||
|
it('returns empty result for empty string', () => {
|
||||||
|
const result = engine.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
expect(result.lines).toHaveLength(0);
|
||||||
|
expect(result.totalHeight).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses worst-case width across both fonts to determine line breaks', () => {
|
||||||
|
// 'AB CD' — two 2-char words separated by a space.
|
||||||
|
// FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total.
|
||||||
|
// FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '.
|
||||||
|
// Unified must use FontB widths — so it must wrap at the same place FontB wraps.
|
||||||
|
const result = engine.layout('AB CD', '400 16px "FontA"', '400 16px "FontB"', 35, 20);
|
||||||
|
expect(result.lines.length).toBeGreaterThan(1);
|
||||||
|
// First line text must not include both words.
|
||||||
|
expect(result.lines[0].text).not.toContain('CD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides xA and xB offsets for both fonts on a single line', () => {
|
||||||
|
// 'ABC' fits in 500px for both fonts.
|
||||||
|
// FontA: A@0(w=10), B@10(w=10), C@20(w=10)
|
||||||
|
// FontB: A@0(w=15), B@15(w=15), C@30(w=15)
|
||||||
|
const result = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
|
||||||
|
expect(chars).toHaveLength(3);
|
||||||
|
|
||||||
|
expect(chars[0].xA).toBe(0);
|
||||||
|
expect(chars[0].widthA).toBe(FONT_A_WIDTH);
|
||||||
|
expect(chars[0].xB).toBe(0);
|
||||||
|
expect(chars[0].widthB).toBe(FONT_B_WIDTH);
|
||||||
|
|
||||||
|
expect(chars[1].xA).toBe(FONT_A_WIDTH); // 10
|
||||||
|
expect(chars[1].widthA).toBe(FONT_A_WIDTH);
|
||||||
|
expect(chars[1].xB).toBe(FONT_B_WIDTH); // 15
|
||||||
|
expect(chars[1].widthB).toBe(FONT_B_WIDTH);
|
||||||
|
|
||||||
|
expect(chars[2].xA).toBe(FONT_A_WIDTH * 2); // 20
|
||||||
|
expect(chars[2].xB).toBe(FONT_B_WIDTH * 2); // 30
|
||||||
|
});
|
||||||
|
|
||||||
|
it('xA positions are monotonically increasing', () => {
|
||||||
|
const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
for (let i = 1; i < chars.length; i++) {
|
||||||
|
expect(chars[i].xA).toBeGreaterThan(chars[i - 1].xA);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('xB positions are monotonically increasing', () => {
|
||||||
|
const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
for (let i = 1; i < chars.length; i++) {
|
||||||
|
expect(chars[i].xB).toBeGreaterThan(chars[i - 1].xB);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached result when called again with same arguments', () => {
|
||||||
|
const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const r2 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
expect(r2).toBe(r1); // strict reference equality — same object
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-computes when text changes', () => {
|
||||||
|
const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const r2 = engine.layout('DEF', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
expect(r2).not.toBe(r1);
|
||||||
|
expect(r2.lines[0].text).not.toBe(r1.lines[0].text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-computes when width changes', () => {
|
||||||
|
const r1 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const r2 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20);
|
||||||
|
expect(r2).not.toBe(r1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-computes when fontA changes', () => {
|
||||||
|
const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const r2 = engine.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
expect(r2).not.toBe(r1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- getCharState() ---
|
||||||
|
|
||||||
|
it('getCharState returns proximity 1 when slider is exactly over char center', () => {
|
||||||
|
// 'A' only: FontA width=10. Container=500px. Line centered.
|
||||||
|
// lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider).
|
||||||
|
// charCenterX = lineXOffset + xA + widthA/2.
|
||||||
|
// Using xA=0, widthA=10: charCenterX = (500-15)/2 + 0 + 5 = 247.5 + 5 = 252.5
|
||||||
|
// charGlobalPercent = (252.5 / 500) * 100 = 50.5
|
||||||
|
// distance = |50.5 - 50.5| = 0 => proximity = 1
|
||||||
|
const containerWidth = 500;
|
||||||
|
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
|
||||||
|
// Recalculate expected percent manually:
|
||||||
|
const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case)
|
||||||
|
const lineXOffset = (containerWidth - lineWidth) / 2;
|
||||||
|
const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2;
|
||||||
|
const charPercent = (charCenterX / containerWidth) * 100;
|
||||||
|
|
||||||
|
const state = engine.getCharState(0, 0, charPercent, containerWidth);
|
||||||
|
expect(state.proximity).toBe(1);
|
||||||
|
expect(state.isPast).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCharState returns proximity 0 when slider is far from char', () => {
|
||||||
|
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
// Slider at 0%, char is near 50% — distance > 5 range => proximity = 0
|
||||||
|
const state = engine.getCharState(0, 0, 0, 500);
|
||||||
|
expect(state.proximity).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCharState isPast is true when slider has passed char center', () => {
|
||||||
|
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const state = engine.getCharState(0, 0, 100, 500);
|
||||||
|
expect(state.isPast).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCharState returns safe default for out-of-range lineIndex', () => {
|
||||||
|
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const state = engine.getCharState(99, 0, 50, 500);
|
||||||
|
expect(state.proximity).toBe(0);
|
||||||
|
expect(state.isPast).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCharState returns safe default for out-of-range charIndex', () => {
|
||||||
|
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
|
const state = engine.getCharState(0, 99, 50, 500);
|
||||||
|
expect(state.proximity).toBe(0);
|
||||||
|
expect(state.isPast).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCharState returns safe default before layout() has been called', () => {
|
||||||
|
const state = engine.getCharState(0, 0, 50, 500);
|
||||||
|
expect(state.proximity).toBe(0);
|
||||||
|
expect(state.isPast).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import {
|
||||||
|
layoutWithLines,
|
||||||
|
prepareWithSegments,
|
||||||
|
} from '@chenglou/pretext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single laid-out line of text, with per-grapheme x offsets and widths.
|
||||||
|
*
|
||||||
|
* `chars` is indexed by grapheme cluster (not UTF-16 code unit), so emoji
|
||||||
|
* sequences and combining characters each produce exactly one entry.
|
||||||
|
*/
|
||||||
|
export interface LayoutLine {
|
||||||
|
/** Full text of this line as returned by pretext. */
|
||||||
|
text: string;
|
||||||
|
/** Rendered width of this line in pixels. */
|
||||||
|
width: number;
|
||||||
|
chars: Array<{
|
||||||
|
/** The grapheme cluster string (may be >1 code unit for emoji, etc.). */
|
||||||
|
char: string;
|
||||||
|
/** X offset from the start of the line, in pixels. */
|
||||||
|
x: number;
|
||||||
|
/** Advance width of this grapheme, in pixels. */
|
||||||
|
width: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated output of a single-font layout pass.
|
||||||
|
*/
|
||||||
|
export interface LayoutResult {
|
||||||
|
/** Per-line grapheme data. Empty when input text is empty. */
|
||||||
|
lines: LayoutLine[];
|
||||||
|
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
|
||||||
|
totalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-font text layout engine backed by `@chenglou/pretext`.
|
||||||
|
*
|
||||||
|
* Replaces the canvas-DOM hybrid `createCharacterComparison` for cases where
|
||||||
|
* only one font is needed. For dual-font comparison use `CharacterComparisonEngine`.
|
||||||
|
*
|
||||||
|
* **Usage**
|
||||||
|
* ```ts
|
||||||
|
* const engine = new TextLayoutEngine();
|
||||||
|
* const result = engine.layout('Hello World', '400 16px "Inter"', 320, 24);
|
||||||
|
* // result.lines[0].chars → [{ char: 'H', x: 0, width: 9 }, ...]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Font string format:** `"${weight} ${size}px \"${family}\""` — e.g. `'400 16px "Inter"'`.
|
||||||
|
* This matches what SliderArea constructs from `typography.weight` and `typography.renderedSize`.
|
||||||
|
*
|
||||||
|
* **Canvas requirement:** pretext calls `document.createElement('canvas').getContext('2d')` on
|
||||||
|
* first use and caches the context for the process lifetime. Tests must install a canvas mock
|
||||||
|
* (see `__mocks__/canvas.ts`) before the first `layout()` call.
|
||||||
|
*/
|
||||||
|
export class TextLayoutEngine {
|
||||||
|
/**
|
||||||
|
* Grapheme segmenter used to split segment text into individual clusters.
|
||||||
|
*
|
||||||
|
* Pretext maintains its own internal segmenter for line-breaking decisions.
|
||||||
|
* We keep a separate one here so we can iterate graphemes in `layout()`
|
||||||
|
* without depending on pretext internals — the two segmenters produce
|
||||||
|
* identical boundaries because both use `{ granularity: 'grapheme' }`.
|
||||||
|
*/
|
||||||
|
#segmenter: Intl.Segmenter;
|
||||||
|
|
||||||
|
/** @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale. */
|
||||||
|
constructor(locale?: string) {
|
||||||
|
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lay out `text` in the given `font` within `width` pixels.
|
||||||
|
*
|
||||||
|
* @param text Raw text to lay out.
|
||||||
|
* @param font CSS font string: `"weight sizepx \"family\""`.
|
||||||
|
* @param width Available line width in pixels.
|
||||||
|
* @param lineHeight Line height in pixels (passed directly to pretext).
|
||||||
|
* @returns Per-line grapheme data. Empty `lines` when `text` is empty.
|
||||||
|
*/
|
||||||
|
layout(text: string, font: string, width: number, lineHeight: number): LayoutResult {
|
||||||
|
if (!text) {
|
||||||
|
return { lines: [], totalHeight: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareWithSegments measures the text and builds the segment data structure
|
||||||
|
// (widths, breakableFitAdvances, etc.) that the line-walker consumes.
|
||||||
|
const prepared = prepareWithSegments(text, font);
|
||||||
|
const { lines, height } = layoutWithLines(prepared, width, lineHeight);
|
||||||
|
|
||||||
|
// `PreparedTextWithSegments` has these fields in its public type definition
|
||||||
|
// but the TypeScript signature only exposes `segments`. We cast to `any` to
|
||||||
|
// access the parallel numeric arrays — they are documented in the plan and
|
||||||
|
// verified against the pretext source at node_modules/@chenglou/pretext/src/layout.ts.
|
||||||
|
const internal = prepared as any;
|
||||||
|
const breakableFitAdvances = internal.breakableFitAdvances as (number[] | null)[];
|
||||||
|
const widths = internal.widths as number[];
|
||||||
|
|
||||||
|
const resultLines: LayoutLine[] = lines.map(line => {
|
||||||
|
const chars: LayoutLine['chars'] = [];
|
||||||
|
let currentX = 0;
|
||||||
|
|
||||||
|
const start = line.start;
|
||||||
|
const end = line.end;
|
||||||
|
|
||||||
|
// Walk every segment that falls within this line's [start, end] cursors.
|
||||||
|
// Both cursors are grapheme-level: start is inclusive, end is exclusive.
|
||||||
|
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
||||||
|
const segmentText = prepared.segments[sIdx];
|
||||||
|
if (segmentText === undefined) continue;
|
||||||
|
|
||||||
|
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
||||||
|
const advances = breakableFitAdvances[sIdx];
|
||||||
|
|
||||||
|
// For the first and last segments of the line the cursor may point
|
||||||
|
// into the middle of the segment — respect those boundaries.
|
||||||
|
// All intermediate segments are walked in full (gStart=0, gEnd=length).
|
||||||
|
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
|
||||||
|
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
|
||||||
|
|
||||||
|
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
||||||
|
const char = graphemes[gIdx];
|
||||||
|
|
||||||
|
// `breakableFitAdvances[sIdx]` is an array of per-grapheme advance
|
||||||
|
// widths when the segment has >1 grapheme (multi-character words).
|
||||||
|
// It is `null` for single-grapheme segments (spaces, punctuation,
|
||||||
|
// emoji, etc.) — in that case the entire segment width is attributed
|
||||||
|
// to this single grapheme.
|
||||||
|
const charWidth = advances != null ? advances[gIdx]! : widths[sIdx]!;
|
||||||
|
|
||||||
|
chars.push({
|
||||||
|
char,
|
||||||
|
x: currentX,
|
||||||
|
width: charWidth,
|
||||||
|
});
|
||||||
|
currentX += charWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: line.text,
|
||||||
|
width: line.width,
|
||||||
|
chars,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
lines: resultLines,
|
||||||
|
// pretext guarantees height === lineCount * lineHeight (see layout.ts source).
|
||||||
|
totalHeight: height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { clearCache } from '@chenglou/pretext';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import { installCanvasMock } from '../__mocks__/canvas';
|
||||||
|
import { TextLayoutEngine } from './TextLayoutEngine.svelte';
|
||||||
|
|
||||||
|
// Fixed-width mock: every segment is measured as (text.length * 10) px.
|
||||||
|
// This is font-independent so we can reason about wrapping precisely.
|
||||||
|
const CHAR_WIDTH = 10;
|
||||||
|
|
||||||
|
describe('TextLayoutEngine', () => {
|
||||||
|
let engine: TextLayoutEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Install mock BEFORE any prepareWithSegments call.
|
||||||
|
// clearMeasurementCaches resets pretext's cached canvas context
|
||||||
|
// and segment metric caches so each test gets a clean slate.
|
||||||
|
installCanvasMock((_font, text) => text.length * CHAR_WIDTH);
|
||||||
|
clearCache();
|
||||||
|
engine = new TextLayoutEngine();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty result for empty string', () => {
|
||||||
|
const result = engine.layout('', '400 16px "Inter"', 500, 20);
|
||||||
|
expect(result.lines).toHaveLength(0);
|
||||||
|
expect(result.totalHeight).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a single line when text fits within width', () => {
|
||||||
|
// 'ABC' = 3 chars × 10px = 30px, fits in 500px
|
||||||
|
const result = engine.layout('ABC', '400 16px "Inter"', 500, 20);
|
||||||
|
expect(result.lines).toHaveLength(1);
|
||||||
|
expect(result.lines[0].text).toBe('ABC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('breaks text into multiple lines when it exceeds width', () => {
|
||||||
|
// 'Hello World' — pretext will split at the space.
|
||||||
|
// 'Hello' = 50px, ' ' hangs, 'World' = 50px. Width = 60px forces wrap after 'Hello '.
|
||||||
|
const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20);
|
||||||
|
expect(result.lines.length).toBeGreaterThan(1);
|
||||||
|
// First line must not exceed the container width.
|
||||||
|
expect(result.lines[0].width).toBeLessThanOrEqual(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns correct x positions to characters on a single line', () => {
|
||||||
|
// 'ABC': A=10px, B=10px, C=10px; all on one line in 500px container.
|
||||||
|
const result = engine.layout('ABC', '400 16px "Inter"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
|
||||||
|
expect(chars).toHaveLength(3);
|
||||||
|
expect(chars[0].char).toBe('A');
|
||||||
|
expect(chars[0].x).toBe(0);
|
||||||
|
expect(chars[0].width).toBe(CHAR_WIDTH);
|
||||||
|
|
||||||
|
expect(chars[1].char).toBe('B');
|
||||||
|
expect(chars[1].x).toBe(CHAR_WIDTH);
|
||||||
|
expect(chars[1].width).toBe(CHAR_WIDTH);
|
||||||
|
|
||||||
|
expect(chars[2].char).toBe('C');
|
||||||
|
expect(chars[2].x).toBe(CHAR_WIDTH * 2);
|
||||||
|
expect(chars[2].width).toBe(CHAR_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('x positions are monotonically increasing across a line', () => {
|
||||||
|
const result = engine.layout('ABCDE', '400 16px "Inter"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
for (let i = 1; i < chars.length; i++) {
|
||||||
|
expect(chars[i].x).toBeGreaterThan(chars[i - 1].x);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each line has at least one char', () => {
|
||||||
|
const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20);
|
||||||
|
for (const line of result.lines) {
|
||||||
|
expect(line.chars.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('totalHeight equals lineCount * lineHeight', () => {
|
||||||
|
const lineHeight = 24;
|
||||||
|
const result = engine.layout('Hello World', '400 16px "Inter"', 60, lineHeight);
|
||||||
|
expect(result.totalHeight).toBe(result.lines.length * lineHeight);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/shared/lib/helpers/__mocks__/canvas.ts
Normal file
29
src/shared/lib/helpers/__mocks__/canvas.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// src/shared/lib/helpers/__mocks__/canvas.ts
|
||||||
|
//
|
||||||
|
// Call installCanvasMock(fn) before any pretext import to control measureText.
|
||||||
|
// The factory receives the current ctx.font string and the text to measure.
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
export type MeasureFactory = (font: string, text: string) => number;
|
||||||
|
|
||||||
|
export function installCanvasMock(factory: MeasureFactory): void {
|
||||||
|
let currentFont = '';
|
||||||
|
|
||||||
|
const mockCtx = {
|
||||||
|
get font() {
|
||||||
|
return currentFont;
|
||||||
|
},
|
||||||
|
set font(f: string) {
|
||||||
|
currentFont = f;
|
||||||
|
},
|
||||||
|
measureText: vi.fn((text: string) => ({ width: factory(currentFont, text) })),
|
||||||
|
};
|
||||||
|
|
||||||
|
// HTMLCanvasElement.prototype.getContext is the entry point pretext uses in DOM environments.
|
||||||
|
// OffscreenCanvas takes priority in pretext; jsdom does not define it so DOM path is used.
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn(() => mockCtx),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
/**
|
|
||||||
* Character-by-character font comparison helper
|
|
||||||
*
|
|
||||||
* Creates utilities for comparing two fonts character by character.
|
|
||||||
* Used by the ComparisonView widget to render morphing text effects
|
|
||||||
* where characters transition between font A and font B based on
|
|
||||||
* slider position.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Responsive text measurement using canvas
|
|
||||||
* - Binary search for optimal line breaking
|
|
||||||
* - Character proximity calculation for morphing effects
|
|
||||||
* - Handles CSS transforms correctly (uses offsetWidth)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```svelte
|
|
||||||
* <script lang="ts">
|
|
||||||
* import { createCharacterComparison } from '$shared/lib/helpers';
|
|
||||||
*
|
|
||||||
* const comparison = createCharacterComparison(
|
|
||||||
* () => text,
|
|
||||||
* () => fontA,
|
|
||||||
* () => fontB,
|
|
||||||
* () => weight,
|
|
||||||
* () => size
|
|
||||||
* );
|
|
||||||
*
|
|
||||||
* $: lines = comparison.lines;
|
|
||||||
* </script>
|
|
||||||
*
|
|
||||||
* <canvas bind:this={measureCanvas} hidden></canvas>
|
|
||||||
* <div bind:this={container}>
|
|
||||||
* {#each lines as line}
|
|
||||||
* <span>{line.text}</span>
|
|
||||||
* {/each}
|
|
||||||
* </div>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a single line of text with its measured width
|
|
||||||
*/
|
|
||||||
export interface LineData {
|
|
||||||
/** The text content of the line */
|
|
||||||
text: string;
|
|
||||||
/** Maximum width between both fonts in pixels */
|
|
||||||
width: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a character comparison helper for morphing text effects
|
|
||||||
*
|
|
||||||
* Measures text in both fonts to determine line breaks and calculates
|
|
||||||
* character-level proximity for morphing animations.
|
|
||||||
*
|
|
||||||
* @param text - Getter for the text to compare
|
|
||||||
* @param fontA - Getter for the first font (left/top side)
|
|
||||||
* @param fontB - Getter for the second font (right/bottom side)
|
|
||||||
* @param weight - Getter for the current font weight
|
|
||||||
* @param size - Getter for the controlled font size
|
|
||||||
* @returns Character comparison instance with lines and proximity calculations
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const comparison = createCharacterComparison(
|
|
||||||
* () => $sampleText,
|
|
||||||
* () => $selectedFontA,
|
|
||||||
* () => $selectedFontB,
|
|
||||||
* () => $fontWeight,
|
|
||||||
* () => $fontSize
|
|
||||||
* );
|
|
||||||
*
|
|
||||||
* // Call when DOM is ready
|
|
||||||
* comparison.breakIntoLines(container, canvas);
|
|
||||||
*
|
|
||||||
* // Get character state for morphing
|
|
||||||
* const state = comparison.getCharState(5, sliderPosition, lineEl, container);
|
|
||||||
* // state.proximity: 0-1 value for opacity/interpolation
|
|
||||||
* // state.isPast: true if slider is past this character
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function createCharacterComparison<
|
|
||||||
T extends { name: string; id: string } | undefined = undefined,
|
|
||||||
>(
|
|
||||||
text: () => string,
|
|
||||||
fontA: () => T,
|
|
||||||
fontB: () => T,
|
|
||||||
weight: () => number,
|
|
||||||
size: () => number,
|
|
||||||
) {
|
|
||||||
let lines = $state<LineData[]>([]);
|
|
||||||
let containerWidth = $state(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type guard to check if a font is defined
|
|
||||||
*/
|
|
||||||
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
|
|
||||||
return font !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measures text width using canvas 2D context
|
|
||||||
*
|
|
||||||
* @param ctx - Canvas rendering context
|
|
||||||
* @param text - Text string to measure
|
|
||||||
* @param fontSize - Font size in pixels
|
|
||||||
* @param fontWeight - Font weight (100-900)
|
|
||||||
* @param fontFamily - Font family name (optional, returns 0 if missing)
|
|
||||||
* @returns Width of text in pixels
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets responsive font size based on viewport width
|
|
||||||
*
|
|
||||||
* Matches Tailwind breakpoints used in the component:
|
|
||||||
* - < 640px: 64px
|
|
||||||
* - 640-767px: 80px
|
|
||||||
* - 768-1023px: 96px
|
|
||||||
* - >= 1024px: 112px
|
|
||||||
*/
|
|
||||||
function getFontSize() {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return 64;
|
|
||||||
}
|
|
||||||
return window.innerWidth >= 1024
|
|
||||||
? 112
|
|
||||||
: window.innerWidth >= 768
|
|
||||||
? 96
|
|
||||||
: window.innerWidth >= 640
|
|
||||||
? 80
|
|
||||||
: 64;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Breaks text into lines based on container width
|
|
||||||
*
|
|
||||||
* Measures text in BOTH fonts and uses the wider width to prevent
|
|
||||||
* layout shifts. Uses binary search for efficient word breaking.
|
|
||||||
*
|
|
||||||
* @param container - Container element to measure width from
|
|
||||||
* @param measureCanvas - Hidden canvas element for text measurement
|
|
||||||
*/
|
|
||||||
function breakIntoLines(
|
|
||||||
container: HTMLElement | undefined,
|
|
||||||
measureCanvas: HTMLCanvasElement | undefined,
|
|
||||||
) {
|
|
||||||
if (!container || !measureCanvas || !fontA() || !fontB()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use offsetWidth to avoid CSS transform scaling issues
|
|
||||||
// getBoundingClientRect() includes transform scale which breaks calculations
|
|
||||||
const width = container.offsetWidth;
|
|
||||||
containerWidth = width;
|
|
||||||
|
|
||||||
const padding = window.innerWidth < 640 ? 48 : 96;
|
|
||||||
const availableWidth = width - padding;
|
|
||||||
const ctx = measureCanvas.getContext('2d');
|
|
||||||
if (!ctx) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controlledFontSize = size();
|
|
||||||
const fontSize = getFontSize();
|
|
||||||
const currentWeight = weight();
|
|
||||||
const words = text().split(' ');
|
|
||||||
const newLines: LineData[] = [];
|
|
||||||
let currentLineWords: string[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a line to the output using the wider font's width
|
|
||||||
*/
|
|
||||||
function pushLine(words: string[]) {
|
|
||||||
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lineText = words.join(' ');
|
|
||||||
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 - use wider to prevent 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 = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if word alone fits
|
|
||||||
const wordWidthA = measureText(
|
|
||||||
ctx,
|
|
||||||
word,
|
|
||||||
Math.min(fontSize, controlledFontSize),
|
|
||||||
currentWeight,
|
|
||||||
fontA()?.name,
|
|
||||||
);
|
|
||||||
const wordWidthB = measureText(
|
|
||||||
ctx,
|
|
||||||
word,
|
|
||||||
Math.min(fontSize, controlledFontSize),
|
|
||||||
currentWeight,
|
|
||||||
fontB()?.name,
|
|
||||||
);
|
|
||||||
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
|
|
||||||
|
|
||||||
if (wordAloneWidth <= availableWidth) {
|
|
||||||
currentLineWords = [word];
|
|
||||||
} else {
|
|
||||||
// Word doesn't fit - binary search to find break point
|
|
||||||
let remainingWord = word;
|
|
||||||
while (remainingWord.length > 0) {
|
|
||||||
let low = 1;
|
|
||||||
let high = remainingWord.length;
|
|
||||||
let bestBreak = 1;
|
|
||||||
|
|
||||||
// Binary search for 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates character proximity to slider position
|
|
||||||
*
|
|
||||||
* Used for morphing effects - returns how close a character is to
|
|
||||||
* the slider and whether it's on the "past" side.
|
|
||||||
*
|
|
||||||
* @param charIndex - Index of character within its line
|
|
||||||
* @param sliderPos - Slider position (0-100, percent across container)
|
|
||||||
* @param lineElement - The line element containing the character
|
|
||||||
* @param container - The container element for position calculations
|
|
||||||
* @returns Proximity (0-1, 1 = at slider) and isPast (true = right of slider)
|
|
||||||
*/
|
|
||||||
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 character bounding box relative to container
|
|
||||||
const charRect = charElement.getBoundingClientRect();
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Calculate character center as percentage of container width
|
|
||||||
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
|
|
||||||
const charGlobalPercent = (charCenter / containerWidth) * 100;
|
|
||||||
|
|
||||||
// Calculate proximity (1.0 = at slider, 0.0 = 5% away)
|
|
||||||
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 {
|
|
||||||
/** Reactive array of broken lines */
|
|
||||||
get lines() {
|
|
||||||
return lines;
|
|
||||||
},
|
|
||||||
/** Container width in pixels */
|
|
||||||
get containerWidth() {
|
|
||||||
return containerWidth;
|
|
||||||
},
|
|
||||||
/** Break text into lines based on current container and fonts */
|
|
||||||
breakIntoLines,
|
|
||||||
/** Get character state for morphing calculations */
|
|
||||||
getCharState,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type representing a character comparison instance
|
|
||||||
*/
|
|
||||||
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
import {
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import { createCharacterComparison } from './createCharacterComparison.svelte';
|
|
||||||
|
|
||||||
type Font = { name: string; id: string };
|
|
||||||
|
|
||||||
const fontA: Font = { name: 'Roboto', id: 'roboto' };
|
|
||||||
const fontB: Font = { name: 'Open Sans', id: 'open-sans' };
|
|
||||||
|
|
||||||
function createMockCanvas(charWidth = 10): HTMLCanvasElement {
|
|
||||||
return {
|
|
||||||
getContext: () => ({
|
|
||||||
font: '',
|
|
||||||
measureText: (text: string) => ({ width: text.length * charWidth }),
|
|
||||||
}),
|
|
||||||
} as unknown as HTMLCanvasElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockContainer(offsetWidth = 500): HTMLElement {
|
|
||||||
return {
|
|
||||||
offsetWidth,
|
|
||||||
getBoundingClientRect: () => ({
|
|
||||||
left: 0,
|
|
||||||
width: offsetWidth,
|
|
||||||
top: 0,
|
|
||||||
right: offsetWidth,
|
|
||||||
bottom: 0,
|
|
||||||
height: 0,
|
|
||||||
}),
|
|
||||||
} as unknown as HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createCharacterComparison', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Mock window.innerWidth for getFontSize and padding calculations
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
value: { innerWidth: 1024 },
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Initial State', () => {
|
|
||||||
it('should initialize with empty lines and zero container width', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(comparison.lines).toEqual([]);
|
|
||||||
expect(comparison.containerWidth).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('breakIntoLines', () => {
|
|
||||||
it('should not break lines when container or canvas is undefined', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hello world',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(undefined, undefined);
|
|
||||||
expect(comparison.lines).toEqual([]);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(), undefined);
|
|
||||||
expect(comparison.lines).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not break lines when fonts are undefined', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hello world',
|
|
||||||
() => undefined,
|
|
||||||
() => undefined,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(), createMockCanvas());
|
|
||||||
expect(comparison.lines).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should produce a single line when text fits within container', () => {
|
|
||||||
// charWidth=10, padding=96 (innerWidth>=640), availableWidth=500-96=404
|
|
||||||
// "Hello" = 5 chars * 10 = 50px, fits easily
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hello',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.lines).toHaveLength(1);
|
|
||||||
expect(comparison.lines[0].text).toBe('Hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should break text into multiple lines when it overflows', () => {
|
|
||||||
// charWidth=10, container=200, padding=96, availableWidth=104
|
|
||||||
// "Hello world test" => "Hello" (50px), "Hello world" (110px > 104)
|
|
||||||
// So "Hello" goes on line 1, "world" (50px) fits, "world test" (100px) fits
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hello world test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(200), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.lines.length).toBeGreaterThan(1);
|
|
||||||
// All original text should be preserved across lines
|
|
||||||
const reconstructed = comparison.lines.map(l => l.text).join(' ');
|
|
||||||
expect(reconstructed).toBe('Hello world test');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update containerWidth after breaking lines', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hi',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(750), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.containerWidth).toBe(750);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use smaller padding on narrow viewports', () => {
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
value: { innerWidth: 500 },
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// container=150, padding=48 (innerWidth<640), availableWidth=102
|
|
||||||
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, fits in 102
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'ABCDEFGHIJ',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.lines).toHaveLength(1);
|
|
||||||
expect(comparison.lines[0].text).toBe('ABCDEFGHIJ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should break a single long word using binary search', () => {
|
|
||||||
// container=150, padding=96, availableWidth=54
|
|
||||||
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, doesn't fit as single word
|
|
||||||
// Binary search should split it
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'ABCDEFGHIJ',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.lines.length).toBeGreaterThan(1);
|
|
||||||
const reconstructed = comparison.lines.map(l => l.text).join('');
|
|
||||||
expect(reconstructed).toBe('ABCDEFGHIJ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store max width between both fonts for each line', () => {
|
|
||||||
// Use a canvas where measureText returns text.length * charWidth
|
|
||||||
// Both fonts measure the same, so width = text.length * charWidth
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hi',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.lines[0].width).toBe(20); // "Hi" = 2 chars * 10
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getCharState', () => {
|
|
||||||
it('should return zero proximity and isPast=false when containerWidth is 0', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
const state = comparison.getCharState(0, 50, undefined, undefined);
|
|
||||||
|
|
||||||
expect(state.proximity).toBe(0);
|
|
||||||
expect(state.isPast).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return zero proximity when charElement is not found', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
// First break lines to set containerWidth
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
const lineEl = { children: [] } as unknown as HTMLElement;
|
|
||||||
const container = createMockContainer(500);
|
|
||||||
const state = comparison.getCharState(0, 50, lineEl, container);
|
|
||||||
|
|
||||||
expect(state.proximity).toBe(0);
|
|
||||||
expect(state.isPast).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate proximity based on distance from slider', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
// Character centered at 250px in a 500px container = 50%
|
|
||||||
const charEl = {
|
|
||||||
getBoundingClientRect: () => ({ left: 240, width: 20 }),
|
|
||||||
};
|
|
||||||
const lineEl = { children: [charEl] } as unknown as HTMLElement;
|
|
||||||
const container = createMockContainer(500);
|
|
||||||
|
|
||||||
// Slider at 50% => charCenter at 250px => charGlobalPercent = 50%
|
|
||||||
// distance = |50 - 50| = 0, proximity = max(0, 1 - 0/5) = 1
|
|
||||||
const state = comparison.getCharState(0, 50, lineEl, container);
|
|
||||||
|
|
||||||
expect(state.proximity).toBe(1);
|
|
||||||
expect(state.isPast).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return isPast=true when slider is past the character', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
// Character centered at 100px => 20% of 500px
|
|
||||||
const charEl = {
|
|
||||||
getBoundingClientRect: () => ({ left: 90, width: 20 }),
|
|
||||||
};
|
|
||||||
const lineEl = { children: [charEl] } as unknown as HTMLElement;
|
|
||||||
const container = createMockContainer(500);
|
|
||||||
|
|
||||||
// Slider at 80% => past the character at 20%
|
|
||||||
const state = comparison.getCharState(0, 80, lineEl, container);
|
|
||||||
|
|
||||||
expect(state.isPast).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return zero proximity when character is far from slider', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
// Character at 10% of container, slider at 90% => distance = 80%, range = 5%
|
|
||||||
const charEl = {
|
|
||||||
getBoundingClientRect: () => ({ left: 45, width: 10 }),
|
|
||||||
};
|
|
||||||
const lineEl = { children: [charEl] } as unknown as HTMLElement;
|
|
||||||
const container = createMockContainer(500);
|
|
||||||
|
|
||||||
const state = comparison.getCharState(0, 90, lineEl, container);
|
|
||||||
|
|
||||||
expect(state.proximity).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -50,6 +50,14 @@ export interface VirtualizerOptions {
|
|||||||
/**
|
/**
|
||||||
* Function to estimate the size of an item at a given index.
|
* Function to estimate the size of an item at a given index.
|
||||||
* Used for initial layout before actual measurements are available.
|
* Used for initial layout before actual measurements are available.
|
||||||
|
*
|
||||||
|
* Called inside a `$derived.by` block. Any `$state` or `$derived` value
|
||||||
|
* read within this function is automatically tracked as a dependency —
|
||||||
|
* when those values change, `offsets` and `totalSize` recompute instantly.
|
||||||
|
*
|
||||||
|
* For font preview rows, pass a closure that reads
|
||||||
|
* `appliedFontsManager.statuses` so the virtualizer recalculates heights
|
||||||
|
* as fonts finish loading, eliminating the DOM-measurement snap on load.
|
||||||
*/
|
*/
|
||||||
estimateSize: (index: number) => number;
|
estimateSize: (index: number) => number;
|
||||||
/** Number of extra items to render outside viewport for smoother scrolling (default: 5) */
|
/** Number of extra items to render outside viewport for smoother scrolling (default: 5) */
|
||||||
@@ -71,6 +79,18 @@ export interface VirtualizerOptions {
|
|||||||
useWindowScroll?: boolean;
|
useWindowScroll?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A height resolver for a single virtual-list row.
|
||||||
|
*
|
||||||
|
* When this function reads reactive state (e.g. `SvelteMap.get()`), calling
|
||||||
|
* it inside a `$derived.by` block automatically subscribes to that state.
|
||||||
|
* Return `fallbackHeight` whenever the true height is not yet known.
|
||||||
|
*
|
||||||
|
* @param rowIndex Zero-based row index within the data array.
|
||||||
|
* @returns Row height in pixels, excluding the list gap.
|
||||||
|
*/
|
||||||
|
export type ItemSizeResolver = (rowIndex: number) => number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items.
|
* Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -52,10 +52,16 @@ export {
|
|||||||
} from './createEntityStore/createEntityStore.svelte';
|
} from './createEntityStore/createEntityStore.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type CharacterComparison,
|
CharacterComparisonEngine,
|
||||||
createCharacterComparison,
|
type ComparisonLine,
|
||||||
type LineData,
|
type ComparisonResult,
|
||||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
} from './CharacterComparisonEngine/CharacterComparisonEngine.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
type LayoutLine as TextLayoutLine,
|
||||||
|
type LayoutResult as TextLayoutResult,
|
||||||
|
TextLayoutEngine,
|
||||||
|
} from './TextLayoutEngine/TextLayoutEngine.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createPersistentStore,
|
createPersistentStore,
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type CharacterComparison,
|
CharacterComparisonEngine,
|
||||||
|
type ComparisonLine,
|
||||||
|
type ComparisonResult,
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
createCharacterComparison,
|
|
||||||
createDebouncedState,
|
createDebouncedState,
|
||||||
createEntityStore,
|
createEntityStore,
|
||||||
createFilter,
|
createFilter,
|
||||||
@@ -21,12 +22,14 @@ export {
|
|||||||
type EntityStore,
|
type EntityStore,
|
||||||
type Filter,
|
type Filter,
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
type LineData,
|
|
||||||
type PersistentStore,
|
type PersistentStore,
|
||||||
type PerspectiveManager,
|
type PerspectiveManager,
|
||||||
type Property,
|
type Property,
|
||||||
type ResponsiveManager,
|
type ResponsiveManager,
|
||||||
responsiveManager,
|
responsiveManager,
|
||||||
|
TextLayoutEngine,
|
||||||
|
type TextLayoutLine,
|
||||||
|
type TextLayoutResult,
|
||||||
type TypographyControl,
|
type TypographyControl,
|
||||||
type VirtualItem,
|
type VirtualItem,
|
||||||
type Virtualizer,
|
type Virtualizer,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import {
|
import {
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
unifiedFontStore,
|
fontStore,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
@@ -85,7 +85,7 @@ export class ComparisonStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if fonts are available to set as defaults
|
// Check if fonts are available to set as defaults
|
||||||
const fonts = unifiedFontStore.fonts;
|
const fonts = fontStore.fonts;
|
||||||
if (fonts.length >= 2) {
|
if (fonts.length >= 2) {
|
||||||
// Only set if we really have nothing (fallback)
|
// Only set if we really have nothing (fallback)
|
||||||
if (!this.#fontA) this.#fontA = fonts[0];
|
if (!this.#fontA) this.#fontA = fonts[0];
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Tests the font comparison store functionality including:
|
* Tests the font comparison store functionality including:
|
||||||
* - Font loading via CSS Font Loading API
|
* - Font loading via CSS Font Loading API
|
||||||
* - Storage synchronization when fonts change
|
* - Storage synchronization when fonts change
|
||||||
* - Default values from unifiedFontStore
|
* - Default values from fontStore
|
||||||
* - Reset functionality
|
* - Reset functionality
|
||||||
* - isReady computed state
|
* - isReady computed state
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
// Mock all dependencies
|
// Mock all dependencies
|
||||||
vi.mock('$entities/Font', () => ({
|
vi.mock('$entities/Font', () => ({
|
||||||
fetchFontsByIds: vi.fn(),
|
fetchFontsByIds: vi.fn(),
|
||||||
unifiedFontStore: { fonts: [] },
|
fontStore: { fonts: [] },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$features/SetupFont', () => ({
|
vi.mock('$features/SetupFont', () => ({
|
||||||
@@ -119,7 +119,7 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
|
|||||||
// Import after mocks
|
// Import after mocks
|
||||||
import {
|
import {
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
unifiedFontStore,
|
fontStore,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { createTypographyControlManager } from '$features/SetupFont';
|
import { createTypographyControlManager } from '$features/SetupFont';
|
||||||
import { ComparisonStore } from './comparisonStore.svelte';
|
import { ComparisonStore } from './comparisonStore.svelte';
|
||||||
@@ -150,8 +150,8 @@ describe('ComparisonStore', () => {
|
|||||||
};
|
};
|
||||||
mockStorage._clear.mockClear();
|
mockStorage._clear.mockClear();
|
||||||
|
|
||||||
// Setup mock unifiedFontStore
|
// Setup mock fontStore
|
||||||
(unifiedFontStore as any).fonts = [];
|
(fontStore as any).fonts = [];
|
||||||
|
|
||||||
// Setup mock fetchFontsByIds
|
// Setup mock fetchFontsByIds
|
||||||
vi.mocked(fetchFontsByIds).mockResolvedValue([]);
|
vi.mocked(fetchFontsByIds).mockResolvedValue([]);
|
||||||
@@ -301,8 +301,8 @@ describe('ComparisonStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle partial restoration when only one font is found', async () => {
|
it('should handle partial restoration when only one font is found', async () => {
|
||||||
// Ensure unifiedFontStore is empty so $effect doesn't interfere
|
// Ensure fontStore is empty so $effect doesn't interfere
|
||||||
(unifiedFontStore as any).fonts = [];
|
(fontStore as any).fonts = [];
|
||||||
|
|
||||||
mockStorage._value.fontAId = mockFontA.id;
|
mockStorage._value.fontAId = mockFontA.id;
|
||||||
mockStorage._value.fontBId = mockFontB.id;
|
mockStorage._value.fontBId = mockFontB.id;
|
||||||
@@ -330,7 +330,7 @@ describe('ComparisonStore', () => {
|
|||||||
describe('Font Loading with CSS Font Loading API', () => {
|
describe('Font Loading with CSS Font Loading API', () => {
|
||||||
it('should construct correct font strings for checking', async () => {
|
it('should construct correct font strings for checking', async () => {
|
||||||
mockFontFaceSet.check.mockReturnValue(false);
|
mockFontFaceSet.check.mockReturnValue(false);
|
||||||
(unifiedFontStore as any).fonts = [mockFontA, mockFontB];
|
(fontStore as any).fonts = [mockFontA, mockFontB];
|
||||||
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
|
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
|
||||||
|
|
||||||
const store = new ComparisonStore();
|
const store = new ComparisonStore();
|
||||||
@@ -367,7 +367,7 @@ describe('ComparisonStore', () => {
|
|||||||
// Mock load to fail
|
// Mock load to fail
|
||||||
mockFontFaceSet.load.mockRejectedValue(new Error('Font load failed'));
|
mockFontFaceSet.load.mockRejectedValue(new Error('Font load failed'));
|
||||||
|
|
||||||
(unifiedFontStore as any).fonts = [mockFontA, mockFontB];
|
(fontStore as any).fonts = [mockFontA, mockFontB];
|
||||||
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
|
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
@@ -384,11 +384,11 @@ describe('ComparisonStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Default Values from unifiedFontStore', () => {
|
describe('Default Values from fontStore', () => {
|
||||||
it('should set default fonts from unifiedFontStore when available', () => {
|
it('should set default fonts from fontStore when available', () => {
|
||||||
// Note: This test relies on Svelte 5's $effect which may not work
|
// Note: This test relies on Svelte 5's $effect which may not work
|
||||||
// reliably in the test environment. We test the logic path instead.
|
// reliably in the test environment. We test the logic path instead.
|
||||||
(unifiedFontStore as any).fonts = [mockFontA, mockFontB];
|
(fontStore as any).fonts = [mockFontA, mockFontB];
|
||||||
|
|
||||||
const store = new ComparisonStore();
|
const store = new ComparisonStore();
|
||||||
|
|
||||||
@@ -402,9 +402,9 @@ describe('ComparisonStore', () => {
|
|||||||
expect(store.fontB).toBeDefined();
|
expect(store.fontB).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use first and last font from unifiedFontStore as defaults', () => {
|
it('should use first and last font from fontStore as defaults', () => {
|
||||||
const mockFontC = UNIFIED_FONTS.lato;
|
const mockFontC = UNIFIED_FONTS.lato;
|
||||||
(unifiedFontStore as any).fonts = [mockFontA, mockFontC, mockFontB];
|
(fontStore as any).fonts = [mockFontA, mockFontC, mockFontB];
|
||||||
|
|
||||||
const store = new ComparisonStore();
|
const store = new ComparisonStore();
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,21 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { comparisonStore } from '../../model';
|
import { comparisonStore } from '../../model';
|
||||||
|
|
||||||
|
interface LineChar {
|
||||||
|
char: string;
|
||||||
|
xA: number;
|
||||||
|
widthA: number;
|
||||||
|
xB: number;
|
||||||
|
widthB: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Line text
|
* Pre-computed grapheme array from CharacterComparisonEngine.
|
||||||
|
* Using the engine's chars array (rather than splitting line.text) ensures
|
||||||
|
* correct grapheme-cluster boundaries for emoji and multi-codepoint characters.
|
||||||
*/
|
*/
|
||||||
text: string;
|
chars: LineChar[];
|
||||||
/**
|
|
||||||
* DOM element reference
|
|
||||||
*/
|
|
||||||
element?: HTMLElement;
|
|
||||||
/**
|
/**
|
||||||
* Character render snippet
|
* Character render snippet
|
||||||
*/
|
*/
|
||||||
@@ -22,18 +28,15 @@ interface Props {
|
|||||||
}
|
}
|
||||||
const typography = $derived(comparisonStore.typography);
|
const typography = $derived(comparisonStore.typography);
|
||||||
|
|
||||||
let { text, element = $bindable<HTMLElement>(), character }: Props = $props();
|
let { chars, character }: Props = $props();
|
||||||
|
|
||||||
const characters = $derived(text.split(''));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
|
||||||
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
||||||
style:height="{typography.height}em"
|
style:height="{typography.height}em"
|
||||||
style:line-height="{typography.height}em"
|
style:line-height="{typography.height}em"
|
||||||
>
|
>
|
||||||
{#each characters as char, index}
|
{#each chars as c, index}
|
||||||
{@render character?.({ char, index })}
|
{@render character?.({ char: c.char, index })}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
type CharacterComparison,
|
|
||||||
type ResponsiveManager,
|
type ResponsiveManager,
|
||||||
createCharacterComparison,
|
|
||||||
debounce,
|
debounce,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
|
import {
|
||||||
|
CharacterComparisonEngine,
|
||||||
|
} from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import { Loader } from '$shared/ui';
|
import { Loader } from '$shared/ui';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
@@ -44,22 +45,16 @@ const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady
|
|||||||
const typography = $derived(comparisonStore.typography);
|
const typography = $derived(comparisonStore.typography);
|
||||||
|
|
||||||
let container = $state<HTMLElement>();
|
let container = $state<HTMLElement>();
|
||||||
let measureCanvas = $state<HTMLCanvasElement>();
|
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
const isMobile = $derived(responsive?.isMobile ?? false);
|
const isMobile = $derived(responsive?.isMobile ?? false);
|
||||||
|
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
|
|
||||||
const charComparison: CharacterComparison = createCharacterComparison(
|
// New high-performance layout engine
|
||||||
() => comparisonStore.text,
|
const comparisonEngine = new CharacterComparisonEngine();
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => typography.weight,
|
|
||||||
() => typography.renderedSize,
|
|
||||||
);
|
|
||||||
|
|
||||||
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ lines: [], totalHeight: 0 });
|
||||||
|
|
||||||
const sliderSpring = new Spring(50, {
|
const sliderSpring = new Spring(50, {
|
||||||
stiffness: 0.2,
|
stiffness: 0.2,
|
||||||
@@ -123,18 +118,41 @@ $effect(() => {
|
|||||||
const _weight = typography.weight;
|
const _weight = typography.weight;
|
||||||
const _size = typography.renderedSize;
|
const _size = typography.renderedSize;
|
||||||
const _height = typography.height;
|
const _height = typography.height;
|
||||||
if (container && measureCanvas && fontA && fontB) {
|
|
||||||
requestAnimationFrame(() => {
|
if (container && fontA && fontB) {
|
||||||
charComparison.breakIntoLines(container, measureCanvas);
|
// PRETEXT API strings: "weight sizepx family"
|
||||||
});
|
const fontAStr = `${_weight} ${_size}px "${fontA.name}"`;
|
||||||
|
const fontBStr = `${_weight} ${_size}px "${fontB.name}"`;
|
||||||
|
|
||||||
|
// Use offsetWidth to avoid transform scaling issues
|
||||||
|
const width = container.offsetWidth;
|
||||||
|
const padding = isMobile ? 48 : 96;
|
||||||
|
const availableWidth = width - padding;
|
||||||
|
const lineHeight = _size * 1.2; // Approximate
|
||||||
|
|
||||||
|
layoutResult = comparisonEngine.layout(
|
||||||
|
_text,
|
||||||
|
fontAStr,
|
||||||
|
fontBStr,
|
||||||
|
availableWidth,
|
||||||
|
lineHeight,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (container && measureCanvas) {
|
if (container && fontA && fontB) {
|
||||||
charComparison.breakIntoLines(container, measureCanvas);
|
const width = container.offsetWidth;
|
||||||
|
const padding = isMobile ? 48 : 96;
|
||||||
|
layoutResult = comparisonEngine.layout(
|
||||||
|
comparisonStore.text,
|
||||||
|
`${typography.weight} ${typography.renderedSize}px "${fontA.name}"`,
|
||||||
|
`${typography.weight} ${typography.renderedSize}px "${fontB.name}"`,
|
||||||
|
width - padding,
|
||||||
|
typography.renderedSize * 1.2,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
@@ -156,9 +174,6 @@ const scaleClass = $derived(
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Hidden measurement canvas -->
|
|
||||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Outer flex container — fills parent.
|
Outer flex container — fills parent.
|
||||||
The paper div inside scales down when the sidebar opens on desktop.
|
The paper div inside scales down when the sidebar opens on desktop.
|
||||||
@@ -218,10 +233,10 @@ const scaleClass = $derived(
|
|||||||
my-auto
|
my-auto
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{#each charComparison.lines as line, lineIndex}
|
{#each layoutResult.lines as line, lineIndex}
|
||||||
<Line bind:element={lineElements[lineIndex]} text={line.text}>
|
<Line chars={line.chars}>
|
||||||
{#snippet character({ char, index })}
|
{#snippet character({ char, index })}
|
||||||
{@const { proximity, isPast } = charComparison.getCharState(index, sliderPos, lineElements[lineIndex], container)}
|
{@const { proximity, isPast } = comparisonEngine.getCharState(lineIndex, index, sliderPos, container?.offsetWidth ?? 0)}
|
||||||
<Character {char} {proximity} {isPast} />
|
<Character {char} {proximity} {isPast} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Line>
|
</Line>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Provides a search input and filtration for fonts
|
Provides a search input and filtration for fonts
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { unifiedFontStore } from '$entities/Font';
|
import { fontStore } from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
@@ -36,7 +36,7 @@ let { showFilters = $bindable(true) }: Props = $props();
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const params = mapManagerToParams(filterManager);
|
const params = mapManagerToParams(filterManager);
|
||||||
untrack(() => unifiedFontStore.setParams(params));
|
untrack(() => fontStore.setParams(params));
|
||||||
});
|
});
|
||||||
|
|
||||||
const transform = new Tween(
|
const transform = new Tween(
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
- Provides a typography menu for font setup.
|
- Provides a typography menu for font setup.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FontVirtualList } from '$entities/Font';
|
import {
|
||||||
|
FontVirtualList,
|
||||||
|
appliedFontsManager,
|
||||||
|
createFontRowSizeResolver,
|
||||||
|
fontStore,
|
||||||
|
} from '$entities/Font';
|
||||||
import { FontSampler } from '$features/DisplayFont';
|
import { FontSampler } from '$features/DisplayFont';
|
||||||
import {
|
import {
|
||||||
TypographyMenu,
|
TypographyMenu,
|
||||||
@@ -15,12 +20,30 @@ import { throttle } from '$shared/lib/utils';
|
|||||||
import { Skeleton } from '$shared/ui';
|
import { Skeleton } from '$shared/ui';
|
||||||
import { layoutManager } from '../../model';
|
import { layoutManager } from '../../model';
|
||||||
|
|
||||||
|
// FontSampler chrome heights — derived from Tailwind classes in FontSampler.svelte.
|
||||||
|
// Header: py-3 (12+12px padding) + ~32px content row ≈ 56px.
|
||||||
|
// Only the header is counted; the mobile footer (md:hidden) is excluded because
|
||||||
|
// on desktop, where container widths are wide and estimates matter most, it is invisible.
|
||||||
|
// Over-estimating chrome is safe (row is slightly taller than text needs, never cut off).
|
||||||
|
const SAMPLER_CHROME_HEIGHT = 56;
|
||||||
|
|
||||||
|
// p-4 = 16px per side = 32px total horizontal padding in FontSampler's content area.
|
||||||
|
// Using the smallest breakpoint (mobile) ensures contentWidth is never over-estimated:
|
||||||
|
// wider actual padding → more text wrapping → pretext height ≥ rendered height → safe.
|
||||||
|
const SAMPLER_CONTENT_PADDING_X = 32;
|
||||||
|
|
||||||
|
// Fallback row height used when the font has not loaded yet.
|
||||||
|
// Matches the previous hardcoded itemHeight={220} value to avoid regressions.
|
||||||
|
const SAMPLER_FALLBACK_HEIGHT = 220;
|
||||||
|
|
||||||
let text = $state('The quick brown fox jumps over the lazy dog...');
|
let text = $state('The quick brown fox jumps over the lazy dog...');
|
||||||
let wrapper = $state<HTMLDivElement | null>(null);
|
let wrapper = $state<HTMLDivElement | null>(null);
|
||||||
// Binds to the actual window height
|
// Binds to the actual window height
|
||||||
let innerHeight = $state(0);
|
let innerHeight = $state(0);
|
||||||
// Is the component above the middle of the viewport?
|
// Is the component above the middle of the viewport?
|
||||||
let isAboveMiddle = $state(false);
|
let isAboveMiddle = $state(false);
|
||||||
|
// Inner width of the wrapper div — updated by bind:clientWidth on mount and resize.
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
|
||||||
const checkPosition = throttle(() => {
|
const checkPosition = throttle(() => {
|
||||||
if (!wrapper) return;
|
if (!wrapper) return;
|
||||||
@@ -30,6 +53,24 @@ const checkPosition = throttle(() => {
|
|||||||
|
|
||||||
isAboveMiddle = rect.top < viewportMiddle;
|
isAboveMiddle = rect.top < viewportMiddle;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
// Resolver recreated when typography values change. The returned closure reads
|
||||||
|
// appliedFontsManager.statuses (a SvelteMap) on every call, so any font status
|
||||||
|
// change triggers a full offsets recompute in createVirtualizer — no DOM snap.
|
||||||
|
const fontRowHeight = $derived.by(() =>
|
||||||
|
createFontRowSizeResolver({
|
||||||
|
getFonts: () => fontStore.fonts,
|
||||||
|
getWeight: () => controlManager.weight,
|
||||||
|
getPreviewText: () => text,
|
||||||
|
getContainerWidth: () => containerWidth,
|
||||||
|
getFontSizePx: () => controlManager.renderedSize,
|
||||||
|
getLineHeightPx: () => controlManager.height * controlManager.renderedSize,
|
||||||
|
getStatus: key => appliedFontsManager.statuses.get(key),
|
||||||
|
contentHorizontalPadding: SAMPLER_CONTENT_PADDING_X,
|
||||||
|
chromeHeight: SAMPLER_CHROME_HEIGHT,
|
||||||
|
fallbackHeight: SAMPLER_FALLBACK_HEIGHT,
|
||||||
|
})
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet skeleton()}
|
{#snippet skeleton()}
|
||||||
@@ -52,9 +93,9 @@ const checkPosition = throttle(() => {
|
|||||||
onresize={checkPosition}
|
onresize={checkPosition}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div bind:this={wrapper}>
|
<div bind:this={wrapper} bind:clientWidth={containerWidth}>
|
||||||
<FontVirtualList
|
<FontVirtualList
|
||||||
itemHeight={220}
|
itemHeight={fontRowHeight}
|
||||||
useWindowScroll={true}
|
useWindowScroll={true}
|
||||||
weight={controlManager.weight}
|
weight={controlManager.weight}
|
||||||
columns={layoutManager.columns}
|
columns={layoutManager.columns}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { NavigationWrapper } from '$entities/Breadcrumb';
|
import { NavigationWrapper } from '$entities/Breadcrumb';
|
||||||
import { unifiedFontStore } from '$entities/Font';
|
import { fontStore } from '$entities/Font';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +36,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
|
|||||||
id="sample_set"
|
id="sample_set"
|
||||||
title="Sample Set"
|
title="Sample Set"
|
||||||
headerTitle="visual_output"
|
headerTitle="visual_output"
|
||||||
headerSubtitle="items_total: {unifiedFontStore.pagination.total ?? 0}"
|
headerSubtitle="items_total: {fontStore.pagination.total ?? 0}"
|
||||||
headerAction={registerAction}
|
headerAction={registerAction}
|
||||||
>
|
>
|
||||||
{#snippet headerContent()}
|
{#snippet headerContent()}
|
||||||
|
|||||||
@@ -122,6 +122,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@chenglou/pretext@npm:^0.0.5":
|
||||||
|
version: 0.0.5
|
||||||
|
resolution: "@chenglou/pretext@npm:0.0.5"
|
||||||
|
checksum: 10c0/5139b39a166fbe7d1e0cf31c95f83125cc0658d8951b19dff3ac14b94d08c2bb53e954801c0325dac79c5b2b21157fa7763e0c561d46773baa37253f1a526242
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@chromatic-com/storybook@npm:^4.1.3":
|
"@chromatic-com/storybook@npm:^4.1.3":
|
||||||
version: 4.1.3
|
version: 4.1.3
|
||||||
resolution: "@chromatic-com/storybook@npm:4.1.3"
|
resolution: "@chromatic-com/storybook@npm:4.1.3"
|
||||||
@@ -2436,6 +2443,7 @@ __metadata:
|
|||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "glyphdiff@workspace:."
|
resolution: "glyphdiff@workspace:."
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@chenglou/pretext": "npm:^0.0.5"
|
||||||
"@chromatic-com/storybook": "npm:^4.1.3"
|
"@chromatic-com/storybook": "npm:^4.1.3"
|
||||||
"@internationalized/date": "npm:^3.10.0"
|
"@internationalized/date": "npm:^3.10.0"
|
||||||
"@lucide/svelte": "npm:^0.561.0"
|
"@lucide/svelte": "npm:^0.561.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user