Compare commits
39 Commits
26737f2f11
...
feature/un
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816d4b89ce | ||
|
|
aa1379c15b | ||
|
|
33e589f041 | ||
|
|
b12dc6257d | ||
|
|
35e0f06a77 | ||
|
|
dde187e0b2 | ||
|
|
5a7c61ade7 | ||
|
|
d2bce85f9c | ||
|
|
e509463911 | ||
|
|
db08f523f6 | ||
|
|
c5fa159c14 | ||
|
|
8645c7dcc8 | ||
|
|
fbeb84270b | ||
|
|
c1ac9b5bc4 | ||
| 46d0d887b1 | |||
|
|
0a489a8adc | ||
|
|
cd349aec92 | ||
|
|
adaa6d7648 | ||
|
|
10f4781a67 | ||
|
|
f4a568832a | ||
|
|
4e9670118a | ||
|
|
8e88d1b7cf | ||
|
|
1cbc262af7 | ||
| f072c5b270 | |||
|
|
bfa99cde20 | ||
|
|
75b62265be | ||
| 5b81be6614 | |||
|
|
a74abbb0b3 | ||
|
|
20accb9c93 | ||
|
|
46b9db1db3 | ||
|
|
4b017a83bb | ||
|
|
49822f8af7 | ||
|
|
338ca9b4fd | ||
|
|
99f662e2d5 | ||
|
|
5977e0a0dc | ||
|
|
2b0d8470e5 | ||
|
|
351ee9fd52 | ||
|
|
a526a51af8 | ||
|
|
fcde78abad |
@@ -66,6 +66,7 @@
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"@tanstack/svelte-query": "^6.0.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +265,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* 21× border-black/5 dark:border-white/10 → single token */
|
||||
.border-subtle {
|
||||
@apply border-black/5 dark:border-white/10;
|
||||
}
|
||||
/* Secondary text pair */
|
||||
.text-secondary {
|
||||
@apply text-neutral-500 dark:text-neutral-400;
|
||||
}
|
||||
/* Standard focus ring */
|
||||
.focus-ring {
|
||||
@apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global utility - useful across your app */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
|
||||
@@ -85,19 +85,11 @@ onDestroy(() => themeManager.destroy());
|
||||
theme === 'dark' ? 'dark' : '',
|
||||
)}
|
||||
>
|
||||
<header>
|
||||
<BreadcrumbHeader />
|
||||
</header>
|
||||
|
||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||
<!-- <main class="flex-1 w-full mx-auto relative"> -->
|
||||
<TooltipProvider>
|
||||
{#if fontsReady}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</TooltipProvider>
|
||||
<!-- </main> -->
|
||||
<!-- </ScrollArea> -->
|
||||
<footer></footer>
|
||||
</div>
|
||||
</ResponsiveProvider>
|
||||
|
||||
@@ -44,7 +44,7 @@ function createButtonText(item: BreadcrumbItem) {
|
||||
flex items-center justify-between
|
||||
z-40
|
||||
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
|
||||
border-b border-black/5 dark:border-white/10
|
||||
border-b border-subtle
|
||||
"
|
||||
>
|
||||
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
|
||||
|
||||
@@ -19,10 +19,13 @@ vi.mock('$shared/api/api', () => ({
|
||||
}));
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
fetchProxyFonts,
|
||||
seedFontCache,
|
||||
} from './proxyFonts';
|
||||
|
||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
|
||||
@@ -46,6 +49,7 @@ function mockApiGet<T>(data: T) {
|
||||
describe('proxyFonts', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.get).mockReset();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
describe('fetchProxyFonts', () => {
|
||||
@@ -168,4 +172,33 @@ describe('proxyFonts', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedFontCache', () => {
|
||||
test('should populate cache with multiple fonts', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', name: 'A' }),
|
||||
createMockFont({ id: '2', name: 'B' }),
|
||||
];
|
||||
seedFontCache(fonts);
|
||||
expect(queryClient.getQueryData(fontKeys.detail('1'))).toEqual(fonts[0]);
|
||||
expect(queryClient.getQueryData(fontKeys.detail('2'))).toEqual(fonts[1]);
|
||||
});
|
||||
|
||||
test('should update existing cached fonts with new data', () => {
|
||||
const id = 'update-me';
|
||||
queryClient.setQueryData(fontKeys.detail(id), createMockFont({ id, name: 'Old' }));
|
||||
|
||||
const updated = createMockFont({ id, name: 'New' });
|
||||
seedFontCache([updated]);
|
||||
|
||||
expect(queryClient.getQueryData(fontKeys.detail(id))).toEqual(updated);
|
||||
});
|
||||
|
||||
test('should handle empty input arrays gracefully', () => {
|
||||
const spy = vi.spyOn(queryClient, 'setQueryData');
|
||||
seedFontCache([]);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,13 +11,23 @@
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
import type {
|
||||
FontCategory,
|
||||
FontSubset,
|
||||
} from '../../model/types';
|
||||
|
||||
/**
|
||||
* Normalizes cache by seeding individual font entries from collection responses.
|
||||
* This ensures that a font fetched in a list or batch is available via its detail key.
|
||||
*
|
||||
* @param fonts - Array of fonts to cache
|
||||
*/
|
||||
export function seedFontCache(fonts: UnifiedFont[]): void {
|
||||
fonts.forEach(font => {
|
||||
queryClient.setQueryData(fontKeys.detail(font.id), font);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy API base URL
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './api';
|
||||
export * from './lib';
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
|
||||
@@ -48,3 +48,6 @@ export {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from './errors/errors';
|
||||
|
||||
export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver';
|
||||
export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver';
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import type { ControlModel } from '$shared/lib';
|
||||
import type { ControlId } from '..';
|
||||
import type { ControlId } from '../types/typography';
|
||||
|
||||
/**
|
||||
* Font size constants
|
||||
@@ -1,7 +1,3 @@
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createFontStore,
|
||||
FontStore,
|
||||
fontStore,
|
||||
} from './store';
|
||||
export * from './const/const';
|
||||
export * from './store';
|
||||
export * from './types';
|
||||
|
||||
91
src/entities/Font/model/store/batchFontStore.svelte.ts
Normal file
91
src/entities/Font/model/store/batchFontStore.svelte.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
||||
import {
|
||||
fetchFontsByIds,
|
||||
seedFontCache,
|
||||
} from '../../api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../lib/errors/errors';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
|
||||
/**
|
||||
* Internal fetcher that seeds the cache and handles error wrapping.
|
||||
* Standalone function to avoid 'this' issues during construction.
|
||||
*/
|
||||
async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
let response: UnifiedFont[];
|
||||
try {
|
||||
response = await fetchFontsByIds(ids);
|
||||
} catch (cause) {
|
||||
throw new FontNetworkError(cause);
|
||||
}
|
||||
|
||||
if (!response || !Array.isArray(response)) {
|
||||
throw new FontResponseError('batchResponse', response);
|
||||
}
|
||||
|
||||
seedFontCache(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive store for fetching and caching batches of fonts by ID.
|
||||
* Integrates with TanStack Query via BaseQueryStore and handles
|
||||
* normalized cache seeding.
|
||||
*/
|
||||
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
|
||||
constructor(initialIds: string[] = []) {
|
||||
super({
|
||||
queryKey: fontKeys.batch(initialIds),
|
||||
queryFn: () => fetchAndSeed(initialIds),
|
||||
enabled: initialIds.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the IDs to fetch. Triggers a new query.
|
||||
*
|
||||
* @param ids - Array of font IDs
|
||||
*/
|
||||
setIds(ids: string[]): void {
|
||||
this.updateOptions({
|
||||
queryKey: fontKeys.batch(ids),
|
||||
queryFn: () => fetchAndSeed(ids),
|
||||
enabled: ids.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Array of fetched fonts
|
||||
*/
|
||||
get fonts(): UnifiedFont[] {
|
||||
return this.result.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the query is currently loading
|
||||
*/
|
||||
get isLoading(): boolean {
|
||||
return this.result.isLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the query encountered an error
|
||||
*/
|
||||
get isError(): boolean {
|
||||
return this.result.isError;
|
||||
}
|
||||
|
||||
/**
|
||||
* The error object if the query failed
|
||||
*/
|
||||
get error(): Error | null {
|
||||
return (this.result.error as Error) ?? null;
|
||||
}
|
||||
}
|
||||
107
src/entities/Font/model/store/batchFontStore.test.ts
Normal file
107
src/entities/Font/model/store/batchFontStore.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import * as api from '../../api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../lib/errors/errors';
|
||||
import { BatchFontStore } from './batchFontStore.svelte';
|
||||
|
||||
describe('BatchFontStore', () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Fetch Behavior', () => {
|
||||
it('should skip fetch when initialized with empty IDs', async () => {
|
||||
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
||||
const store = new BatchFontStore([]);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(store.fonts).toEqual([]);
|
||||
});
|
||||
|
||||
it('should fetch and seed cache for valid IDs', async () => {
|
||||
const fonts = [{ id: 'a', name: 'A' }] as any[];
|
||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||
const store = new BatchFontStore(['a']);
|
||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should transition through loading state', async () => {
|
||||
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
||||
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
||||
);
|
||||
const store = new BatchFontStore(['a']);
|
||||
expect(store.isLoading).toBe(true);
|
||||
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should wrap network failures in FontNetworkError', async () => {
|
||||
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
||||
const store = new BatchFontStore(['a']);
|
||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||
});
|
||||
|
||||
it('should handle malformed API responses with FontResponseError', async () => {
|
||||
// Mocking a malformed response that the store should validate
|
||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
|
||||
const store = new BatchFontStore(['a']);
|
||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||
});
|
||||
|
||||
it('should have null error in success state', async () => {
|
||||
const fonts = [{ id: 'a' }] as any[];
|
||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||
const store = new BatchFontStore(['a']);
|
||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||
expect(store.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disable Behavior', () => {
|
||||
it('should return empty fonts and not fetch when setIds is called with empty array', async () => {
|
||||
const fonts1 = [{ id: 'a' }] as any[];
|
||||
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
||||
|
||||
const store = new BatchFontStore(['a']);
|
||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||
|
||||
spy.mockClear();
|
||||
store.setIds([]);
|
||||
|
||||
await vi.waitFor(() => expect(store.fonts).toEqual([]), { timeout: 1000 });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reactivity', () => {
|
||||
it('should refetch when setIds is called', async () => {
|
||||
const fonts1 = [{ id: 'a' }] as any[];
|
||||
const fonts2 = [{ id: 'b' }] as any[];
|
||||
vi.spyOn(api, 'fetchFontsByIds')
|
||||
.mockResolvedValueOnce(fonts1)
|
||||
.mockResolvedValueOnce(fonts2);
|
||||
|
||||
const store = new BatchFontStore(['a']);
|
||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||
|
||||
store.setIds(['b']);
|
||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts2), { timeout: 1000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
// Applied fonts manager
|
||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
export * from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
|
||||
// Batch font store
|
||||
export { BatchFontStore } from './batchFontStore.svelte';
|
||||
|
||||
// Single FontStore
|
||||
export {
|
||||
|
||||
@@ -33,3 +33,4 @@ export type {
|
||||
} from './store';
|
||||
|
||||
export * from './store/appliedFonts';
|
||||
export * from './typography';
|
||||
|
||||
1
src/entities/Font/model/types/typography.ts
Normal file
1
src/entities/Font/model/types/typography.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||
@@ -10,6 +10,7 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
} from '../../model';
|
||||
@@ -36,7 +37,7 @@ interface Props {
|
||||
|
||||
let {
|
||||
font,
|
||||
weight = 400,
|
||||
weight = DEFAULT_FONT_WEIGHT,
|
||||
className,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
type FontLoadRequestConfig,
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
fontStore,
|
||||
} from '../../model';
|
||||
import { fontStore } from '../../model/store';
|
||||
|
||||
interface Props extends
|
||||
Omit<
|
||||
@@ -53,30 +53,42 @@ const isLoading = $derived(
|
||||
fontStore.isFetching || fontStore.isLoading,
|
||||
);
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||
const configs: FontLoadRequestConfig[] = [];
|
||||
|
||||
visibleItems.forEach(item => {
|
||||
const url = getFontUrl(item, weight);
|
||||
|
||||
if (url) {
|
||||
configs.push({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
weight,
|
||||
url,
|
||||
isVariable: item.features?.isVariable,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-register fonts with the manager
|
||||
appliedFontsManager.touch(configs);
|
||||
let visibleFonts = $state<UnifiedFont[]>([]);
|
||||
|
||||
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||
visibleFonts = items;
|
||||
// Forward the call to any external listener
|
||||
// onVisibleItemsChange?.(visibleItems);
|
||||
onVisibleItemsChange?.(items);
|
||||
}
|
||||
|
||||
// Re-touch whenever visible set or weight changes — fixes weight-change gap
|
||||
$effect(() => {
|
||||
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
|
||||
const url = getFontUrl(item, weight);
|
||||
if (!url) return [];
|
||||
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
|
||||
});
|
||||
if (configs.length > 0) {
|
||||
appliedFontsManager.touch(configs);
|
||||
}
|
||||
});
|
||||
|
||||
// Pin visible fonts so the eviction policy never removes on-screen entries.
|
||||
// Cleanup captures the snapshot values, so a weight change unpins the old
|
||||
// weight before pinning the new one.
|
||||
$effect(() => {
|
||||
const w = weight;
|
||||
const fonts = visibleFonts;
|
||||
for (const f of fonts) {
|
||||
appliedFontsManager.pin(f.id, w, f.features?.isVariable);
|
||||
}
|
||||
return () => {
|
||||
for (const f of fonts) {
|
||||
appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Load more fonts by moving to the next page
|
||||
*/
|
||||
|
||||
@@ -35,7 +35,6 @@ const { Story } = defineMeta({
|
||||
|
||||
<script lang="ts">
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
|
||||
// Mock fonts for testing
|
||||
const mockArial: UnifiedFont = {
|
||||
|
||||
@@ -8,14 +8,13 @@ import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
||||
import {
|
||||
Badge,
|
||||
ContentEditable,
|
||||
Divider,
|
||||
Footnote,
|
||||
Stat,
|
||||
StatGroup,
|
||||
} from '$shared/ui';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
@@ -37,11 +36,6 @@ interface Props {
|
||||
|
||||
let { font, text = $bindable(), index = 0 }: Props = $props();
|
||||
|
||||
const fontWeight = $derived(controlManager.weight);
|
||||
const fontSize = $derived(controlManager.renderedSize);
|
||||
const lineHeight = $derived(controlManager.height);
|
||||
const letterSpacing = $derived(controlManager.spacing);
|
||||
|
||||
// Adjust the property name to match your UnifiedFont type
|
||||
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
||||
|
||||
@@ -52,10 +46,10 @@ const providerBadge = $derived(
|
||||
);
|
||||
|
||||
const stats = $derived([
|
||||
{ label: 'SZ', value: `${fontSize}PX` },
|
||||
{ label: 'WGT', value: `${fontWeight}` },
|
||||
{ label: 'LH', value: lineHeight?.toFixed(2) },
|
||||
{ label: 'LTR', value: `${letterSpacing}` },
|
||||
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
|
||||
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
|
||||
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
|
||||
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -65,7 +59,7 @@ const stats = $derived([
|
||||
group relative
|
||||
w-full h-full
|
||||
bg-paper dark:bg-dark-card
|
||||
border border-black/5 dark:border-white/10
|
||||
border border-subtle
|
||||
hover:border-brand dark:hover:border-brand
|
||||
hover:shadow-brand/10
|
||||
hover:shadow-[5px_5px_0px_0px]
|
||||
@@ -75,14 +69,14 @@ const stats = $derived([
|
||||
min-h-60
|
||||
rounded-none
|
||||
"
|
||||
style:font-weight={fontWeight}
|
||||
style:font-weight={typographySettingsStore.weight}
|
||||
>
|
||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
flex items-center justify-between
|
||||
px-4 sm:px-5 md:px-6 py-3 sm:py-4
|
||||
border-b border-black/5 dark:border-white/10
|
||||
border-b border-subtle
|
||||
bg-paper dark:bg-dark-card
|
||||
"
|
||||
>
|
||||
@@ -140,18 +134,18 @@ const stats = $derived([
|
||||
|
||||
<!-- ── Main content area ──────────────────────────────────────────── -->
|
||||
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
|
||||
<FontApplicator {font} weight={fontWeight}>
|
||||
<FontApplicator {font} weight={typographySettingsStore.weight}>
|
||||
<ContentEditable
|
||||
bind:text
|
||||
{fontSize}
|
||||
{lineHeight}
|
||||
{letterSpacing}
|
||||
fontSize={typographySettingsStore.renderedSize}
|
||||
lineHeight={typographySettingsStore.height}
|
||||
letterSpacing={typographySettingsStore.spacing}
|
||||
/>
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
||||
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-black/5 dark:border-white/10 flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
||||
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
||||
{#each stats as stat, i}
|
||||
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||
{stat.label}:{stat.value}
|
||||
|
||||
@@ -1,28 +1,6 @@
|
||||
export { TypographyMenu } from './ui';
|
||||
|
||||
export {
|
||||
type ControlId,
|
||||
controlManager,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LINE_HEIGHT,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from './model';
|
||||
|
||||
export {
|
||||
createTypographyControlManager,
|
||||
type TypographyControlManager,
|
||||
createTypographySettingsManager,
|
||||
type TypographySettingsManager,
|
||||
} from './lib';
|
||||
export { typographySettingsStore } from './model';
|
||||
export { TypographyMenu } from './ui';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export {
|
||||
createTypographyControlManager,
|
||||
type TypographyControlManager,
|
||||
} from './controlManager/controlManager.svelte';
|
||||
createTypographySettingsManager,
|
||||
type TypographySettingsManager,
|
||||
} from './settingsManager/settingsManager.svelte';
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
* when displaying/editing, but the base size is what's stored.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ControlId,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
@@ -19,13 +26,6 @@ import {
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
type ControlId,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
} from '../../model';
|
||||
|
||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface TypographySettings {
|
||||
* Manages multiple typography controls with persistent storage and
|
||||
* responsive scaling support for font size.
|
||||
*/
|
||||
export class TypographyControlManager {
|
||||
export class TypographySettingsManager {
|
||||
/** Map of controls keyed by ID */
|
||||
#controls = new SvelteMap<string, Control>();
|
||||
/** Responsive multiplier for font size display */
|
||||
@@ -242,7 +242,7 @@ export class TypographyControlManager {
|
||||
* @param storageId - Persistent storage identifier
|
||||
* @returns Typography control manager instance
|
||||
*/
|
||||
export function createTypographyControlManager(
|
||||
export function createTypographySettingsManager(
|
||||
configs: ControlModel<ControlId>[],
|
||||
storageId: string = 'glyphdiff:typography',
|
||||
) {
|
||||
@@ -252,5 +252,5 @@ export function createTypographyControlManager(
|
||||
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||
});
|
||||
return new TypographyControlManager(configs, storage);
|
||||
return new TypographySettingsManager(configs, storage);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import {
|
||||
afterEach,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
@@ -8,21 +14,14 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
} from '../../model';
|
||||
import {
|
||||
TypographyControlManager,
|
||||
type TypographySettings,
|
||||
} from './controlManager.svelte';
|
||||
TypographySettingsManager,
|
||||
} from './settingsManager.svelte';
|
||||
|
||||
/**
|
||||
* Test Strategy for TypographyControlManager
|
||||
* Test Strategy for TypographySettingsManager
|
||||
*
|
||||
* This test suite validates the TypographyControlManager state management logic.
|
||||
* This test suite validates the TypographySettingsManager state management logic.
|
||||
* These are unit tests for the manager logic, separate from component rendering.
|
||||
*
|
||||
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
|
||||
@@ -45,7 +44,7 @@ async function flushEffects() {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('TypographyControlManager - Unit Tests', () => {
|
||||
describe('TypographySettingsManager - Unit Tests', () => {
|
||||
let mockStorage: TypographySettings;
|
||||
let mockPersistentStore: {
|
||||
value: TypographySettings;
|
||||
@@ -85,7 +84,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('creates manager with default values from storage', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -105,7 +104,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -117,7 +116,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -126,7 +125,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns all controls via controls getter', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -142,7 +141,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns individual controls via specific getters', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -160,7 +159,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('control instances have expected interface', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -179,7 +178,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Multiplier System', () => {
|
||||
it('has default multiplier of 1', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -188,7 +187,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates multiplier when set', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -201,7 +200,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('does not update multiplier if set to same value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -217,7 +216,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -241,7 +240,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates font size control display value when multiplier increases', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -262,7 +261,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Base Size Setter', () => {
|
||||
it('updates baseSize when set directly', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -273,7 +272,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates size control value when baseSize is set', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -284,7 +283,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('applies multiplier to size control when baseSize is set', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -298,7 +297,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Rendered Size Calculation', () => {
|
||||
it('calculates renderedSize as baseSize * multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -307,7 +306,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates renderedSize when multiplier changes', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -320,7 +319,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates renderedSize when baseSize changes', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -340,7 +339,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
// proxy effect behavior should be tested in E2E tests.
|
||||
|
||||
it('does NOT immediately update baseSize from control change (effect is async)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -355,7 +354,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('updates baseSize via direct setter (synchronous)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -380,7 +379,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -393,7 +392,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -409,7 +408,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -422,7 +421,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs height control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -434,7 +433,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('syncs spacing control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -448,7 +447,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Control Value Getters', () => {
|
||||
it('returns current weight value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -460,7 +459,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns current height value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -472,7 +471,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('returns current spacing value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -485,7 +484,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
it('returns default value when control is not found', () => {
|
||||
// Create a manager with empty configs (no controls)
|
||||
const manager = new TypographyControlManager([], mockPersistentStore);
|
||||
const manager = new TypographySettingsManager([], mockPersistentStore);
|
||||
|
||||
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||
@@ -503,7 +502,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -536,7 +535,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
clear: clearSpy,
|
||||
};
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -547,7 +546,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('respects multiplier when resetting font size control', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -565,7 +564,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
|
||||
describe('Complex Scenarios', () => {
|
||||
it('handles changing multiplier then modifying baseSize', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -586,7 +585,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('maintains correct renderedSize throughout changes', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -608,7 +607,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles multiple control changes in sequence', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -633,7 +632,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -645,7 +644,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles very small multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -658,7 +657,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles large base size with multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -671,7 +670,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles floating point precision in multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -690,7 +689,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles control methods (increase/decrease)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -704,7 +703,7 @@ describe('TypographyControlManager - Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('handles control boundary conditions', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
const manager = new TypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
@@ -1,24 +1 @@
|
||||
export {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LINE_HEIGHT,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from './const/const';
|
||||
|
||||
export {
|
||||
type ControlId,
|
||||
controlManager,
|
||||
} from './state/manager.svelte';
|
||||
export { typographySettingsStore } from './state/typographySettingsStore';
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createTypographyControlManager } from '../../lib';
|
||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
|
||||
|
||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||
|
||||
export const controlManager = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '$entities/Font';
|
||||
import { createTypographySettingsManager } from '../../lib';
|
||||
|
||||
export const typographySettingsStore = createTypographySettingsManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
'glyphdiff:comparison:typography',
|
||||
);
|
||||
@@ -6,10 +6,14 @@
|
||||
Desktop: inline bar with combo controls.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
Button,
|
||||
ComboControl,
|
||||
ControlGroup,
|
||||
Slider,
|
||||
@@ -20,12 +24,7 @@ import { Popover } from 'bits-ui';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
controlManager,
|
||||
} from '../../model';
|
||||
import { typographySettingsStore } from '../../model';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -52,16 +51,16 @@ $effect(() => {
|
||||
if (!responsive) return;
|
||||
switch (true) {
|
||||
case responsive.isMobile:
|
||||
controlManager.multiplier = MULTIPLIER_S;
|
||||
typographySettingsStore.multiplier = MULTIPLIER_S;
|
||||
break;
|
||||
case responsive.isTablet:
|
||||
controlManager.multiplier = MULTIPLIER_M;
|
||||
typographySettingsStore.multiplier = MULTIPLIER_M;
|
||||
break;
|
||||
case responsive.isDesktop:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
typographySettingsStore.multiplier = MULTIPLIER_L;
|
||||
break;
|
||||
default:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
typographySettingsStore.multiplier = MULTIPLIER_L;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -80,7 +79,7 @@ $effect(() => {
|
||||
'transition-colors duration-150',
|
||||
'hover:bg-white/50 dark:hover:bg-white/5',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||
isOpen && 'bg-paper dark:bg-dark-card border-black/5 dark:border-white/10 shadow-sm',
|
||||
isOpen && 'bg-paper dark:bg-dark-card border-subtle shadow-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -97,7 +96,7 @@ $effect(() => {
|
||||
class={cn(
|
||||
'z-50 w-72',
|
||||
'bg-surface dark:bg-dark-card',
|
||||
'border border-black/5 dark:border-white/10',
|
||||
'border border-subtle',
|
||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
|
||||
'rounded-none p-4',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
@@ -110,7 +109,7 @@ $effect(() => {
|
||||
escapeKeydownBehavior="close"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-black/5 dark:border-white/10">
|
||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Settings2Icon size={12} class="text-swiss-red" />
|
||||
<span
|
||||
@@ -133,7 +132,7 @@ $effect(() => {
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
{#each typographySettingsStore.controls as control (control.id)}
|
||||
<ControlGroup label={control.controlLabel ?? ''}>
|
||||
<Slider
|
||||
bind:value={control.instance.value}
|
||||
@@ -155,13 +154,13 @@ $effect(() => {
|
||||
class={cn(
|
||||
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
||||
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
|
||||
'border border-black/5 dark:border-white/10',
|
||||
'border border-subtle',
|
||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
|
||||
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
|
||||
)}
|
||||
>
|
||||
<!-- Header: icon + label -->
|
||||
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-black/5 dark:border-white/10 mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
|
||||
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-subtle mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
|
||||
<Settings2Icon
|
||||
size={14}
|
||||
class="text-swiss-red"
|
||||
@@ -174,7 +173,7 @@ $effect(() => {
|
||||
</div>
|
||||
|
||||
<!-- Controls with dividers between each -->
|
||||
{#each controlManager.controls as control, i (control.id)}
|
||||
{#each typographySettingsStore.controls as control, i (control.id)}
|
||||
{#if i > 0}
|
||||
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
Description: The main page component of the application.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
import { ComparisonView } from '$widgets/ComparisonView';
|
||||
import { FontSearchSection } from '$widgets/FontSearch';
|
||||
import { SampleListSection } from '$widgets/SampleList';
|
||||
import { cubicIn } from 'svelte/easing';
|
||||
import { fade } from 'svelte/transition';
|
||||
</script>
|
||||
@@ -18,8 +15,4 @@ import { fade } from 'svelte/transition';
|
||||
<section class="w-auto">
|
||||
<ComparisonView />
|
||||
</section>
|
||||
<main class="w-full pt-0 pb-10 sm:px-6 sm:pt-16 sm:pb-12 md:px-8 md:pt-32 md:pb-16 lg:px-10 lg:pt-48 lg:pb-20 xl:px-16">
|
||||
<FontSearchSection />
|
||||
<SampleListSection index={1} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
73
src/shared/api/queryKeys.test.ts
Normal file
73
src/shared/api/queryKeys.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { fontKeys } from './queryKeys';
|
||||
|
||||
describe('fontKeys', () => {
|
||||
describe('Hierarchy', () => {
|
||||
it('should generate base keys', () => {
|
||||
expect(fontKeys.all).toEqual(['fonts']);
|
||||
expect(fontKeys.lists()).toEqual(['fonts', 'list']);
|
||||
expect(fontKeys.batches()).toEqual(['fonts', 'batch']);
|
||||
expect(fontKeys.details()).toEqual(['fonts', 'detail']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Keys (Stability & Sorting)', () => {
|
||||
it('should sort IDs for stable serialization', () => {
|
||||
const key1 = fontKeys.batch(['b', 'a', 'c']);
|
||||
const key2 = fontKeys.batch(['c', 'b', 'a']);
|
||||
const expected = ['fonts', 'batch', ['a', 'b', 'c']];
|
||||
expect(key1).toEqual(expected);
|
||||
expect(key2).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle empty ID arrays', () => {
|
||||
expect(fontKeys.batch([])).toEqual(['fonts', 'batch', []]);
|
||||
});
|
||||
|
||||
it('should not mutate the input array when sorting', () => {
|
||||
const ids = ['c', 'b', 'a'];
|
||||
fontKeys.batch(ids);
|
||||
expect(ids).toEqual(['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
it('batch key should be rooted in batches() base', () => {
|
||||
const key = fontKeys.batch(['a']);
|
||||
expect(key.slice(0, 2)).toEqual(fontKeys.batches());
|
||||
});
|
||||
});
|
||||
|
||||
describe('List Keys (Parameters)', () => {
|
||||
it('should include parameters in list keys', () => {
|
||||
const params = { provider: 'google' };
|
||||
expect(fontKeys.list(params)).toEqual(['fonts', 'list', params]);
|
||||
});
|
||||
|
||||
it('should handle empty parameters', () => {
|
||||
expect(fontKeys.list({})).toEqual(['fonts', 'list', {}]);
|
||||
});
|
||||
|
||||
it('list key should be rooted in lists() base', () => {
|
||||
const key = fontKeys.list({ provider: 'google' });
|
||||
expect(key.slice(0, 2)).toEqual(fontKeys.lists());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detail Keys', () => {
|
||||
it('should generate unique detail keys per ID', () => {
|
||||
expect(fontKeys.detail('roboto')).toEqual(['fonts', 'detail', 'roboto']);
|
||||
});
|
||||
|
||||
it('should generate different keys for different IDs', () => {
|
||||
expect(fontKeys.detail('roboto')).not.toEqual(fontKeys.detail('open-sans'));
|
||||
});
|
||||
|
||||
it('detail key should be rooted in details() base', () => {
|
||||
const key = fontKeys.detail('roboto');
|
||||
expect(key.slice(0, 2)).toEqual(fontKeys.details());
|
||||
});
|
||||
});
|
||||
});
|
||||
23
src/shared/api/queryKeys.ts
Normal file
23
src/shared/api/queryKeys.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Stable query key factory for font-related queries.
|
||||
* Ensures consistent serialization for batch requests by sorting IDs.
|
||||
*/
|
||||
export const fontKeys = {
|
||||
/** Base key for all font queries */
|
||||
all: ['fonts'] as const,
|
||||
|
||||
/** Keys for font list queries */
|
||||
lists: () => [...fontKeys.all, 'list'] as const,
|
||||
/** Specific font list key with filter parameters */
|
||||
list: (params: object) => [...fontKeys.lists(), params] as const,
|
||||
|
||||
/** Keys for font batch queries */
|
||||
batches: () => [...fontKeys.all, 'batch'] as const,
|
||||
/** Specific batch key, sorted for stability */
|
||||
batch: (ids: string[]) => [...fontKeys.batches(), [...ids].sort()] as const,
|
||||
|
||||
/** Keys for font detail queries */
|
||||
details: () => [...fontKeys.all, 'detail'] as const,
|
||||
/** Specific font detail key by ID */
|
||||
detail: (id: string) => [...fontKeys.details(), id] as const,
|
||||
} as const;
|
||||
51
src/shared/lib/helpers/BaseQueryStore.svelte.ts
Normal file
51
src/shared/lib/helpers/BaseQueryStore.svelte.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
QueryObserver,
|
||||
type QueryObserverOptions,
|
||||
type QueryObserverResult,
|
||||
} from '@tanstack/query-core';
|
||||
|
||||
/**
|
||||
* Abstract base class for reactive Svelte 5 stores backed by TanStack Query.
|
||||
*
|
||||
* Provides a unified way to use TanStack Query observers within Svelte 5 classes
|
||||
* using runes for reactivity. Handles subscription lifecycle automatically.
|
||||
*
|
||||
* @template TData - The type of data returned by the query.
|
||||
* @template TError - The type of error that can be thrown.
|
||||
*/
|
||||
export abstract class BaseQueryStore<TData, TError = Error> {
|
||||
#result = $state<QueryObserverResult<TData, TError>>({} as QueryObserverResult<TData, TError>);
|
||||
#observer: QueryObserver<TData, TError>;
|
||||
#unsubscribe: () => void;
|
||||
|
||||
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
|
||||
this.#observer = new QueryObserver(queryClient, options);
|
||||
this.#unsubscribe = this.#observer.subscribe(result => {
|
||||
this.#result = result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Current query result (reactive)
|
||||
*/
|
||||
protected get result(): QueryObserverResult<TData, TError> {
|
||||
return this.#result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates observer options dynamically.
|
||||
* Use this when query parameters or dependencies change.
|
||||
*/
|
||||
protected updateOptions(options: QueryObserverOptions<TData, TError, TData, any, any>): void {
|
||||
this.#observer.setOptions(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the observer subscription.
|
||||
* Should be called when the store is no longer needed.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.#unsubscribe();
|
||||
}
|
||||
}
|
||||
91
src/shared/lib/helpers/BaseQueryStore.test.ts
Normal file
91
src/shared/lib/helpers/BaseQueryStore.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { BaseQueryStore } from './BaseQueryStore.svelte';
|
||||
|
||||
class TestStore extends BaseQueryStore<string> {
|
||||
constructor(key = ['test'], fn = () => Promise.resolve('ok')) {
|
||||
super({
|
||||
queryKey: key,
|
||||
queryFn: fn,
|
||||
retry: false, // Disable retries for faster error testing
|
||||
});
|
||||
}
|
||||
get data() {
|
||||
return this.result.data;
|
||||
}
|
||||
get isLoading() {
|
||||
return this.result.isLoading;
|
||||
}
|
||||
get isError() {
|
||||
return this.result.isError;
|
||||
}
|
||||
|
||||
update(newKey: string[], newFn?: () => Promise<string>) {
|
||||
this.updateOptions({
|
||||
queryKey: newKey,
|
||||
queryFn: newFn ?? (() => Promise.resolve('ok')),
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
import * as tq from '@tanstack/query-core';
|
||||
|
||||
// ... (TestStore remains same)
|
||||
|
||||
describe('BaseQueryStore', () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
describe('Lifecycle & Fetching', () => {
|
||||
it('should transition from loading to success', async () => {
|
||||
const store = new TestStore();
|
||||
expect(store.isLoading).toBe(true);
|
||||
await vi.waitFor(() => expect(store.data).toBe('ok'), { timeout: 1000 });
|
||||
expect(store.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should have undefined data and no error in initial loading state', () => {
|
||||
const store = new TestStore(['initial-state'], () => new Promise(r => setTimeout(() => r('late'), 500)));
|
||||
expect(store.data).toBeUndefined();
|
||||
expect(store.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle query failures', async () => {
|
||||
const store = new TestStore(['fail'], () => Promise.reject(new Error('fail')));
|
||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reactivity', () => {
|
||||
it('should refetch and update data when options change', async () => {
|
||||
const store = new TestStore(['key1'], () => Promise.resolve('val1'));
|
||||
await vi.waitFor(() => expect(store.data).toBe('val1'), { timeout: 1000 });
|
||||
|
||||
store.update(['key2'], () => Promise.resolve('val2'));
|
||||
await vi.waitFor(() => expect(store.data).toBe('val2'), { timeout: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should unsubscribe observer on destroy', () => {
|
||||
const unsubscribe = vi.fn();
|
||||
const subscribeSpy = vi.spyOn(tq.QueryObserver.prototype, 'subscribe').mockReturnValue(unsubscribe);
|
||||
|
||||
const store = new TestStore();
|
||||
store.destroy();
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalled();
|
||||
subscribeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
* 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;
|
||||
/** Number of extra items to render outside viewport for smoother scrolling (default: 5) */
|
||||
@@ -71,6 +79,18 @@ export interface VirtualizerOptions {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -52,10 +52,16 @@ export {
|
||||
} from './createEntityStore/createEntityStore.svelte';
|
||||
|
||||
export {
|
||||
type CharacterComparison,
|
||||
createCharacterComparison,
|
||||
type LineData,
|
||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||
CharacterComparisonEngine,
|
||||
type ComparisonLine,
|
||||
type ComparisonResult,
|
||||
} from './CharacterComparisonEngine/CharacterComparisonEngine.svelte';
|
||||
|
||||
export {
|
||||
type LayoutLine as TextLayoutLine,
|
||||
type LayoutResult as TextLayoutResult,
|
||||
TextLayoutEngine,
|
||||
} from './TextLayoutEngine/TextLayoutEngine.svelte';
|
||||
|
||||
export {
|
||||
createPersistentStore,
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
export {
|
||||
type CharacterComparison,
|
||||
CharacterComparisonEngine,
|
||||
type ComparisonLine,
|
||||
type ComparisonResult,
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
createCharacterComparison,
|
||||
createDebouncedState,
|
||||
createEntityStore,
|
||||
createFilter,
|
||||
@@ -21,12 +22,14 @@ export {
|
||||
type EntityStore,
|
||||
type Filter,
|
||||
type FilterModel,
|
||||
type LineData,
|
||||
type PersistentStore,
|
||||
type PerspectiveManager,
|
||||
type Property,
|
||||
type ResponsiveManager,
|
||||
responsiveManager,
|
||||
TextLayoutEngine,
|
||||
type TextLayoutLine,
|
||||
type TextLayoutResult,
|
||||
type TypographyControl,
|
||||
type VirtualItem,
|
||||
type Virtualizer,
|
||||
|
||||
@@ -111,7 +111,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
),
|
||||
ghost: cn(
|
||||
'bg-transparent',
|
||||
'text-neutral-500 dark:text-neutral-400',
|
||||
'text-secondary',
|
||||
'border border-transparent',
|
||||
'hover:bg-transparent dark:hover:bg-transparent',
|
||||
'hover:text-brand dark:hover:text-brand',
|
||||
@@ -121,7 +121,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
),
|
||||
icon: cn(
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'text-neutral-500 dark:text-neutral-400',
|
||||
'text-secondary',
|
||||
'border border-transparent',
|
||||
'hover:bg-paper dark:hover:bg-paper',
|
||||
'hover:text-brand',
|
||||
@@ -172,7 +172,7 @@ const activeStyles: Partial<Record<ButtonVariant, string>> = {
|
||||
'bg-paper dark:bg-dark-card border-black/10 dark:border-white/10 shadow-sm text-neutral-900 dark:text-neutral-100',
|
||||
ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand',
|
||||
outline: 'bg-surface dark:bg-paper border-brand',
|
||||
icon: 'bg-paper dark:bg-paper text-brand border-black/5 dark:border-white/10',
|
||||
icon: 'bg-paper dark:bg-paper text-brand border-subtle',
|
||||
};
|
||||
|
||||
const classes = $derived(cn(
|
||||
@@ -184,7 +184,7 @@ const classes = $derived(cn(
|
||||
'select-none',
|
||||
'outline-none',
|
||||
'cursor-pointer',
|
||||
'focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
|
||||
'focus-ring',
|
||||
'focus-visible:ring-offset-surface dark:focus-visible:ring-offset-dark-bg',
|
||||
'disabled:cursor-not-allowed disabled:pointer-events-none',
|
||||
// Variant
|
||||
|
||||
@@ -26,7 +26,7 @@ let { children, class: className, ...rest }: Props = $props();
|
||||
class={cn(
|
||||
'flex items-center gap-1 p-1',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'border border-black/5 dark:border-white/10',
|
||||
'border border-subtle',
|
||||
'rounded-none',
|
||||
'transition-colors duration-500',
|
||||
className,
|
||||
|
||||
@@ -93,9 +93,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
step={control.step}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<span
|
||||
class="font-mono text-[0.6875rem] text-neutral-500 dark:text-neutral-400 tabular-nums w-10 text-right shrink-0"
|
||||
>
|
||||
<span class="font-mono text-[0.6875rem] text-secondary tabular-nums w-10 text-right shrink-0">
|
||||
{formattedValue()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -129,7 +127,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
'border border-transparent',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||
open
|
||||
? 'bg-paper dark:bg-dark-card shadow-sm border-black/5 dark:border-white/10'
|
||||
? 'bg-paper dark:bg-dark-card shadow-sm border-subtle'
|
||||
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
||||
)}
|
||||
aria-label={controlLabel}
|
||||
@@ -157,7 +155,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
|
||||
<!-- Vertical slider popover -->
|
||||
<PopoverContent
|
||||
class="w-auto py-4 px-3 h-64 flex items-center justify-center rounded-none border border-black/5 dark:border-white/10 shadow-sm bg-paper dark:bg-dark-card"
|
||||
class="w-auto py-4 px-3 h-64 flex items-center justify-center rounded-none border border-subtle shadow-sm bg-paper dark:bg-dark-card"
|
||||
align="center"
|
||||
side="top"
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ interface Props {
|
||||
const { label, children, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col gap-3 py-6 border-b border-black/5 dark:border-white/10 last:border-0', className)}>
|
||||
<div class={cn('flex flex-col gap-3 py-6 border-b border-subtle last:border-0', className)}>
|
||||
<div class="flex justify-between items-center text-[0.6875rem] font-primary font-bold tracking-tight text-neutral-900 dark:text-neutral-100 uppercase leading-none">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,10 @@ import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
import { scale } from 'svelte/transition';
|
||||
import {
|
||||
inputSizeConfig,
|
||||
inputVariantConfig,
|
||||
} from './config';
|
||||
import type {
|
||||
InputSize,
|
||||
InputVariant,
|
||||
@@ -80,36 +84,11 @@ let {
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const sizeConfig: Record<InputSize, { input: string; text: string; height: string; clearIcon: number }> = {
|
||||
sm: { input: 'px-3 py-1.5', text: 'text-sm', height: 'h-8', clearIcon: 12 },
|
||||
md: { input: 'px-4 py-2', text: 'text-base', height: 'h-10', clearIcon: 14 },
|
||||
lg: { input: 'px-4 py-3', text: 'text-lg md:text-xl', height: 'h-12', clearIcon: 16 },
|
||||
xl: { input: 'px-4 py-3', text: 'text-xl md:text-2xl', height: 'h-14', clearIcon: 18 },
|
||||
};
|
||||
|
||||
const variantConfig: Record<InputVariant, { base: string; focus: string; error: string }> = {
|
||||
default: {
|
||||
base: 'bg-paper dark:bg-paper border border-black/5 dark:border-white/10',
|
||||
focus: 'focus:border-brand focus:ring-1 focus:ring-brand/20',
|
||||
error: 'border-brand ring-1 ring-brand/20',
|
||||
},
|
||||
underline: {
|
||||
base: 'bg-transparent border-0 border-b border-neutral-300 dark:border-neutral-700',
|
||||
focus: 'focus:border-brand',
|
||||
error: 'border-brand',
|
||||
},
|
||||
filled: {
|
||||
base: 'bg-surface dark:bg-paper border border-transparent',
|
||||
focus: 'focus:border-brand focus:ring-1 focus:ring-brand/20',
|
||||
error: 'border-brand ring-1 ring-brand/20',
|
||||
},
|
||||
};
|
||||
|
||||
const hasValue = $derived(value !== undefined && value !== '');
|
||||
const showClear = $derived(showClearButton && hasValue && !!onclear);
|
||||
const hasRightSlot = $derived(!!rightIcon || showClearButton);
|
||||
const cfg = $derived(sizeConfig[size]);
|
||||
const styles = $derived(variantConfig[variant]);
|
||||
const cfg = $derived(inputSizeConfig[size]);
|
||||
const styles = $derived(inputVariantConfig[variant]);
|
||||
|
||||
const inputClasses = $derived(cn(
|
||||
'font-primary rounded-none outline-none transition-all duration-200',
|
||||
@@ -170,7 +149,7 @@ const inputClasses = $derived(cn(
|
||||
<span
|
||||
class={cn(
|
||||
'text-[0.625rem] font-mono tracking-wide px-1',
|
||||
error ? 'text-brand ' : 'text-neutral-500 dark:text-neutral-400',
|
||||
error ? 'text-brand ' : 'text-secondary',
|
||||
)}
|
||||
>
|
||||
{helperText}
|
||||
|
||||
31
src/shared/ui/Input/config.ts
Normal file
31
src/shared/ui/Input/config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
InputSize,
|
||||
InputVariant,
|
||||
} from './types';
|
||||
|
||||
/** Size-specific layout classes: padding, text size, height, and clear-icon pixel size. */
|
||||
export const inputSizeConfig: Record<InputSize, { input: string; text: string; height: string; clearIcon: number }> = {
|
||||
sm: { input: 'px-3 py-1.5', text: 'text-sm', height: 'h-8', clearIcon: 12 },
|
||||
md: { input: 'px-4 py-2', text: 'text-base', height: 'h-10', clearIcon: 14 },
|
||||
lg: { input: 'px-4 py-3', text: 'text-lg md:text-xl', height: 'h-12', clearIcon: 16 },
|
||||
xl: { input: 'px-4 py-3', text: 'text-xl md:text-2xl', height: 'h-14', clearIcon: 18 },
|
||||
};
|
||||
|
||||
/** Variant-specific classes: base background/border, focus ring, and error state. */
|
||||
export const inputVariantConfig: Record<InputVariant, { base: string; focus: string; error: string }> = {
|
||||
default: {
|
||||
base: 'bg-paper dark:bg-paper border border-subtle',
|
||||
focus: 'focus:border-brand focus:ring-1 focus:ring-brand/20',
|
||||
error: 'border-brand ring-1 ring-brand/20',
|
||||
},
|
||||
underline: {
|
||||
base: 'bg-transparent border-0 border-b border-neutral-300 dark:border-neutral-700',
|
||||
focus: 'focus:border-brand',
|
||||
error: 'border-brand',
|
||||
},
|
||||
filled: {
|
||||
base: 'bg-surface dark:bg-paper border border-transparent',
|
||||
focus: 'focus:border-brand focus:ring-1 focus:ring-brand/20',
|
||||
error: 'border-brand ring-1 ring-brand/20',
|
||||
},
|
||||
};
|
||||
@@ -20,7 +20,7 @@ let {
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Input bind:value variant="underline" {...rest}>
|
||||
<Input bind:value variant="default" {...rest}>
|
||||
{#snippet rightIcon(size)}
|
||||
<SearchIcon size={inputIconSize[size]} />
|
||||
{/snippet}
|
||||
|
||||
@@ -84,7 +84,7 @@ function close() {
|
||||
'overflow-hidden',
|
||||
'will-change-[width]',
|
||||
'transition-[width] duration-300 ease-out',
|
||||
'border-r border-black/5 dark:border-white/10',
|
||||
'border-r border-subtle',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
isOpen ? 'w-80 opacity-100' : 'w-0 opacity-0',
|
||||
'transition-[width,opacity] duration-300 ease-out',
|
||||
|
||||
@@ -70,7 +70,7 @@ let {
|
||||
const isVertical = $derived(orientation === 'vertical');
|
||||
|
||||
const labelClasses = `font-mono text-[0.625rem] tabular-nums shrink-0
|
||||
text-neutral-500 dark:text-neutral-400
|
||||
text-secondary
|
||||
group-hover:text-neutral-700 dark:group-hover:text-neutral-300
|
||||
transition-colors`;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Font comparison store for side-by-side font comparison
|
||||
* Font comparison store — TanStack Query refactor
|
||||
*
|
||||
* Manages the state for comparing two fonts character by character.
|
||||
* Persists font selection to localStorage and handles font loading
|
||||
@@ -7,22 +7,23 @@
|
||||
*
|
||||
* Features:
|
||||
* - Persistent font selection (survives page refresh)
|
||||
* - Font loading state tracking
|
||||
* - Font loading state tracking via BatchFontStore + TanStack Query
|
||||
* - Sample text management
|
||||
* - Typography controls (size, weight, line height, spacing)
|
||||
* - Slider position for character-by-character morphing
|
||||
*/
|
||||
|
||||
import {
|
||||
BatchFontStore,
|
||||
type FontLoadRequestConfig,
|
||||
type UnifiedFont,
|
||||
fetchFontsByIds,
|
||||
appliedFontsManager,
|
||||
fontStore,
|
||||
getFontUrl,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
createTypographyControlManager,
|
||||
} from '$features/SetupFont';
|
||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
/**
|
||||
* Storage schema for comparison state
|
||||
@@ -43,11 +44,13 @@ const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
|
||||
});
|
||||
|
||||
/**
|
||||
* Store for managing font comparison state
|
||||
* Store for managing font comparison state.
|
||||
*
|
||||
* Handles font selection persistence, fetching, and loading state tracking.
|
||||
* Uses the CSS Font Loading API to ensure fonts are loaded before
|
||||
* showing the comparison interface.
|
||||
* Uses BatchFontStore (TanStack Query) to fetch fonts by ID, replacing
|
||||
* the previous hand-rolled async fetch approach. Three reactive effects
|
||||
* handle: (1) syncing batch results into fontA/fontB, (2) triggering the
|
||||
* CSS Font Loading API, and (3) falling back to default fonts when
|
||||
* storage is empty.
|
||||
*/
|
||||
export class ComparisonStore {
|
||||
/** Font for side A */
|
||||
@@ -56,50 +59,99 @@ export class ComparisonStore {
|
||||
#fontB = $state<UnifiedFont | undefined>();
|
||||
/** Sample text to display */
|
||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
||||
/** Whether currently restoring from storage */
|
||||
#isRestoring = $state(true);
|
||||
/** Whether fonts are loaded and ready to display */
|
||||
#fontsReady = $state(false);
|
||||
/** Active side for single-font operations */
|
||||
#side = $state<Side>('A');
|
||||
/** Slider position for character morphing (0-100) */
|
||||
#sliderPosition = $state(50);
|
||||
/** Typography controls for this comparison */
|
||||
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
|
||||
// /** Typography controls for this comparison */
|
||||
// #typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
|
||||
/** TanStack Query-backed batch font fetcher */
|
||||
#batchStore: BatchFontStore;
|
||||
|
||||
constructor() {
|
||||
this.restoreFromStorage();
|
||||
// Synchronously seed the batch store with any IDs already in storage
|
||||
const { fontAId, fontBId } = storage.value;
|
||||
this.#batchStore = new BatchFontStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
||||
|
||||
// Reactively set defaults if we aren't restoring and have no selection
|
||||
$effect.root(() => {
|
||||
// Effect 1: Sync batch results → fontA / fontB
|
||||
$effect(() => {
|
||||
// Wait until we are done checking storage
|
||||
if (this.#isRestoring) {
|
||||
return;
|
||||
}
|
||||
const fonts = this.#batchStore.fonts;
|
||||
if (fonts.length === 0) return;
|
||||
|
||||
// If we already have a selection, do nothing
|
||||
if (this.#fontA && this.#fontB) {
|
||||
const { fontAId: aId, fontBId: bId } = storage.value;
|
||||
if (aId) {
|
||||
const fa = fonts.find(f => f.id === aId);
|
||||
if (fa) this.#fontA = fa;
|
||||
}
|
||||
if (bId) {
|
||||
const fb = fonts.find(f => f.id === bId);
|
||||
if (fb) this.#fontB = fb;
|
||||
}
|
||||
});
|
||||
|
||||
// Effect 2: Trigger font loading whenever selection or weight changes
|
||||
$effect(() => {
|
||||
const fa = this.#fontA;
|
||||
const fb = this.#fontB;
|
||||
const weight = typographySettingsStore.weight;
|
||||
|
||||
if (!fa || !fb) return;
|
||||
|
||||
const configs: FontLoadRequestConfig[] = [];
|
||||
[fa, fb].forEach(f => {
|
||||
const url = getFontUrl(f, weight);
|
||||
if (url) {
|
||||
configs.push({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
weight,
|
||||
url,
|
||||
isVariable: f.features?.isVariable,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (configs.length > 0) {
|
||||
appliedFontsManager.touch(configs);
|
||||
this.#checkFontsLoaded();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Effect 3: Set default fonts when storage is empty
|
||||
$effect(() => {
|
||||
if (this.#fontA && this.#fontB) return;
|
||||
|
||||
// Check if fonts are available to set as defaults
|
||||
const fonts = fontStore.fonts;
|
||||
if (fonts.length >= 2) {
|
||||
// Only set if we really have nothing (fallback)
|
||||
if (!this.#fontA) this.#fontA = fonts[0];
|
||||
if (!this.#fontB) this.#fontB = fonts[fonts.length - 1];
|
||||
|
||||
// Sync defaults to storage so they persist if the user leaves
|
||||
this.updateStorage();
|
||||
untrack(() => {
|
||||
const id1 = fonts[0].id;
|
||||
const id2 = fonts[fonts.length - 1].id;
|
||||
storage.value = { fontAId: id1, fontBId: id2 };
|
||||
this.#batchStore.setIds([id1, id2]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Effect 4: Pin fontA/fontB so eviction never removes on-screen fonts
|
||||
$effect(() => {
|
||||
const fa = this.#fontA;
|
||||
const fb = this.#fontB;
|
||||
const w = typographySettingsStore.weight;
|
||||
if (fa) appliedFontsManager.pin(fa.id, w, fa.features?.isVariable);
|
||||
if (fb) appliedFontsManager.pin(fb.id, w, fb.features?.isVariable);
|
||||
return () => {
|
||||
if (fa) appliedFontsManager.unpin(fa.id, w, fa.features?.isVariable);
|
||||
if (fb) appliedFontsManager.unpin(fb.id, w, fb.features?.isVariable);
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if fonts are actually loaded in the browser at current weight
|
||||
* Checks if fonts are actually loaded in the browser at current weight.
|
||||
*
|
||||
* Uses CSS Font Loading API to prevent FOUT. Waits for fonts to load
|
||||
* and forces a layout/paint cycle before marking as ready.
|
||||
@@ -110,8 +162,8 @@ export class ComparisonStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const weight = this.#typography.weight;
|
||||
const size = this.#typography.renderedSize;
|
||||
const weight = typographySettingsStore.weight;
|
||||
const size = typographySettingsStore.renderedSize;
|
||||
const fontAName = this.#fontA?.name;
|
||||
const fontBName = this.#fontB?.name;
|
||||
|
||||
@@ -132,75 +184,39 @@ export class ComparisonStore {
|
||||
this.#fontsReady = false;
|
||||
|
||||
try {
|
||||
// Step 1: Load fonts into memory
|
||||
await Promise.all([
|
||||
document.fonts.load(fontAString),
|
||||
document.fonts.load(fontBString),
|
||||
]);
|
||||
|
||||
// Step 2: Wait for browser to be ready to render
|
||||
await document.fonts.ready;
|
||||
|
||||
// Step 3: Force a layout/paint cycle (critical!)
|
||||
await new Promise(resolve => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(resolve); // Double rAF ensures paint completes
|
||||
requestAnimationFrame(resolve);
|
||||
});
|
||||
});
|
||||
|
||||
this.#fontsReady = true;
|
||||
} catch (error) {
|
||||
console.warn('[ComparisonStore] Font loading failed:', error);
|
||||
setTimeout(() => this.#fontsReady = true, 1000);
|
||||
setTimeout(() => (this.#fontsReady = true), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore state from persistent storage
|
||||
*
|
||||
* Fetches saved fonts from the API and restores them to the store.
|
||||
*/
|
||||
async restoreFromStorage() {
|
||||
this.#isRestoring = true;
|
||||
const { fontAId, fontBId } = storage.value;
|
||||
|
||||
if (fontAId && fontBId) {
|
||||
try {
|
||||
// Batch fetch the saved fonts
|
||||
const fonts = await fetchFontsByIds([fontAId, fontBId]);
|
||||
const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId);
|
||||
const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId);
|
||||
|
||||
if (loadedFontA && loadedFontB) {
|
||||
this.#fontA = loadedFontA;
|
||||
this.#fontB = loadedFontB;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ComparisonStore] Failed to restore fonts:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark restoration as complete (whether success or fail)
|
||||
this.#isRestoring = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update storage with current state
|
||||
* Updates persistent storage with the current font selection.
|
||||
*/
|
||||
private updateStorage() {
|
||||
// Don't save if we are currently restoring (avoid race)
|
||||
if (this.#isRestoring) return;
|
||||
|
||||
storage.value = {
|
||||
fontAId: this.#fontA?.id ?? null,
|
||||
fontBId: this.#fontB?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Typography control manager */
|
||||
get typography() {
|
||||
return this.#typography;
|
||||
}
|
||||
// // ── Getters / Setters ─────────────────────────────────────────────────────
|
||||
|
||||
// /** Typography control manager */
|
||||
// get typography() {
|
||||
// return typographySettingsStore;
|
||||
// }
|
||||
|
||||
/** Font for side A */
|
||||
get fontA() {
|
||||
@@ -249,35 +265,25 @@ export class ComparisonStore {
|
||||
this.#sliderPosition = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if both fonts are selected and loaded
|
||||
*/
|
||||
/** Whether both fonts are selected and loaded */
|
||||
get isReady() {
|
||||
return !!this.#fontA && !!this.#fontB && this.#fontsReady;
|
||||
}
|
||||
|
||||
/** Whether currently loading or restoring */
|
||||
/** Whether currently loading (batch fetch in flight or fonts not yet painted) */
|
||||
get isLoading() {
|
||||
return this.#isRestoring || !this.#fontsReady;
|
||||
return this.#batchStore.isLoading || !this.#fontsReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public initializer (optional, as constructor starts it)
|
||||
*/
|
||||
initialize() {
|
||||
if (!this.#isRestoring && !this.#fontA && !this.#fontB) {
|
||||
this.restoreFromStorage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all state and clear storage
|
||||
* Resets all state, clears storage, and disables the batch query.
|
||||
*/
|
||||
resetAll() {
|
||||
this.#fontA = undefined;
|
||||
this.#fontB = undefined;
|
||||
this.#batchStore.setIds([]);
|
||||
storage.clear();
|
||||
this.#typography.reset();
|
||||
typographySettingsStore.reset();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
/**
|
||||
* Unit tests for ComparisonStore
|
||||
* Unit tests for ComparisonStore (TanStack Query refactor)
|
||||
*
|
||||
* Tests the font comparison store functionality including:
|
||||
* - Font loading via CSS Font Loading API
|
||||
* - Storage synchronization when fonts change
|
||||
* - Default values from fontStore
|
||||
* - Reset functionality
|
||||
* - isReady computed state
|
||||
* Uses the real BatchFontStore so Svelte $state reactivity works correctly.
|
||||
* Controls network behaviour via vi.spyOn on the proxyFonts API layer.
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
@@ -22,74 +18,13 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
// Mock all dependencies
|
||||
vi.mock('$entities/Font', () => ({
|
||||
fetchFontsByIds: vi.fn(),
|
||||
fontStore: { fonts: [] },
|
||||
}));
|
||||
// ── Persistent-store mock ─────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('$features/SetupFont', () => ({
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [
|
||||
{
|
||||
id: 'font_size',
|
||||
value: 48,
|
||||
min: 8,
|
||||
max: 100,
|
||||
step: 1,
|
||||
increaseLabel: 'Increase Font Size',
|
||||
decreaseLabel: 'Decrease Font Size',
|
||||
controlLabel: 'Size',
|
||||
},
|
||||
{
|
||||
id: 'font_weight',
|
||||
value: 400,
|
||||
min: 100,
|
||||
max: 900,
|
||||
step: 100,
|
||||
increaseLabel: 'Increase Font Weight',
|
||||
decreaseLabel: 'Decrease Font Weight',
|
||||
controlLabel: 'Weight',
|
||||
},
|
||||
{
|
||||
id: 'line_height',
|
||||
value: 1.5,
|
||||
min: 1,
|
||||
max: 2,
|
||||
step: 0.05,
|
||||
increaseLabel: 'Increase Line Height',
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Leading',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
value: 0,
|
||||
min: -0.1,
|
||||
max: 0.5,
|
||||
step: 0.01,
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Tracking',
|
||||
},
|
||||
],
|
||||
createTypographyControlManager: vi.fn(() => ({
|
||||
weight: 400,
|
||||
renderedSize: 48,
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Create mock storage accessible from both vi.mock factory and tests
|
||||
const mockStorage = vi.hoisted(() => {
|
||||
const storage: any = {};
|
||||
storage._value = {
|
||||
fontAId: null as string | null,
|
||||
fontBId: null as string | null,
|
||||
};
|
||||
storage._value = { fontAId: null, fontBId: null };
|
||||
storage._clear = vi.fn(() => {
|
||||
storage._value = {
|
||||
fontAId: null,
|
||||
fontBId: null,
|
||||
};
|
||||
storage._value = { fontAId: null, fontBId: null };
|
||||
});
|
||||
|
||||
Object.defineProperty(storage, 'value', {
|
||||
@@ -116,471 +51,228 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
|
||||
createPersistentStore: vi.fn(() => mockStorage),
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
// ── $entities/Font mock — keep real BatchFontStore, stub singletons ───────────
|
||||
|
||||
vi.mock('$entities/Font', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('$entities/Font')>();
|
||||
const { BatchFontStore } = await import(
|
||||
'$entities/Font/model/store/batchFontStore.svelte'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
BatchFontStore,
|
||||
fontStore: { fonts: [] },
|
||||
appliedFontsManager: {
|
||||
touch: vi.fn(),
|
||||
pin: vi.fn(),
|
||||
unpin: vi.fn(),
|
||||
getFontStatus: vi.fn(),
|
||||
ready: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
getFontUrl: vi.fn(() => 'https://example.com/font.woff2'),
|
||||
};
|
||||
});
|
||||
|
||||
// ── $features/SetupFont mock ──────────────────────────────────────────────────
|
||||
|
||||
vi.mock('$features/SetupFont', () => ({
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
|
||||
createTypographyControlManager: vi.fn(() => ({
|
||||
weight: 400,
|
||||
renderedSize: 48,
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('$features/SetupFont/model', () => ({
|
||||
typographySettingsStore: {
|
||||
weight: 400,
|
||||
renderedSize: 48,
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Imports (after mocks) ─────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
fetchFontsByIds,
|
||||
appliedFontsManager,
|
||||
fontStore,
|
||||
} from '$entities/Font';
|
||||
import { createTypographyControlManager } from '$features/SetupFont';
|
||||
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
|
||||
import { ComparisonStore } from './comparisonStore.svelte';
|
||||
|
||||
describe('ComparisonStore', () => {
|
||||
// Mock fonts
|
||||
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto;
|
||||
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans;
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Mock document.fonts
|
||||
let mockFontFaceSet: {
|
||||
check: ReturnType<typeof vi.fn>;
|
||||
load: ReturnType<typeof vi.fn>;
|
||||
ready: Promise<FontFaceSet>;
|
||||
};
|
||||
describe('ComparisonStore', () => {
|
||||
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto'
|
||||
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans'
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks
|
||||
queryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.clear();
|
||||
|
||||
// Reset mock storage value via the helper
|
||||
mockStorage._value = {
|
||||
fontAId: null,
|
||||
fontBId: null,
|
||||
};
|
||||
mockStorage._value = { fontAId: null, fontBId: null };
|
||||
mockStorage._clear.mockClear();
|
||||
|
||||
// Setup mock fontStore
|
||||
(fontStore as any).fonts = [];
|
||||
|
||||
// Setup mock fetchFontsByIds
|
||||
vi.mocked(fetchFontsByIds).mockResolvedValue([]);
|
||||
|
||||
// Setup mock createTypographyControlManager
|
||||
vi.mocked(createTypographyControlManager).mockReturnValue({
|
||||
weight: 400,
|
||||
renderedSize: 48,
|
||||
reset: vi.fn(),
|
||||
} as any);
|
||||
|
||||
// Setup mock document.fonts
|
||||
mockFontFaceSet = {
|
||||
check: vi.fn(() => true),
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
ready: Promise.resolve({} as FontFaceSet),
|
||||
};
|
||||
// Default: fetchFontsByIds returns empty so tests that don't care don't hang
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]);
|
||||
|
||||
// document.fonts: check returns true so #checkFontsLoaded resolves immediately
|
||||
Object.defineProperty(document, 'fonts', {
|
||||
value: mockFontFaceSet,
|
||||
value: {
|
||||
check: vi.fn(() => true),
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
ready: Promise.resolve({} as FontFaceSet),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure document.fonts is always reset to a valid mock
|
||||
// This prevents issues when tests delete or undefined document.fonts
|
||||
if (!document.fonts || typeof document.fonts.check !== 'function') {
|
||||
Object.defineProperty(document, 'fonts', {
|
||||
value: {
|
||||
check: vi.fn(() => true),
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
ready: Promise.resolve({} as FontFaceSet),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
// ── Initialization ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create store with initial empty state', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
expect(store.fontA).toBeUndefined();
|
||||
expect(store.fontB).toBeUndefined();
|
||||
expect(store.text).toBe('The quick brown fox jumps over the lazy dog');
|
||||
expect(store.side).toBe('A');
|
||||
expect(store.sliderPosition).toBe(50);
|
||||
});
|
||||
|
||||
it('should initialize with default sample text', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
expect(store.text).toBe('The quick brown fox jumps over the lazy dog');
|
||||
});
|
||||
|
||||
it('should have typography manager attached', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
expect(store.typography).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage Synchronization', () => {
|
||||
it('should update storage when fontA is set', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
store.fontA = mockFontA;
|
||||
|
||||
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
|
||||
});
|
||||
|
||||
it('should update storage when fontB is set', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
store.fontB = mockFontB;
|
||||
|
||||
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
|
||||
});
|
||||
|
||||
it('should update storage when both fonts are set', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
store.fontA = mockFontA;
|
||||
store.fontB = mockFontB;
|
||||
|
||||
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
|
||||
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
|
||||
});
|
||||
|
||||
it('should set storage to null when font is set to undefined', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
store.fontA = mockFontA;
|
||||
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
|
||||
|
||||
store.fontA = undefined;
|
||||
expect(mockStorage._value.fontAId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Restore from Storage', () => {
|
||||
it('should restore fonts from storage when both IDs exist', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
mockStorage._value.fontBId = mockFontB.id;
|
||||
|
||||
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
|
||||
|
||||
const store = new ComparisonStore();
|
||||
await store.restoreFromStorage();
|
||||
|
||||
expect(fetchFontsByIds).toHaveBeenCalledWith([mockFontA.id, mockFontB.id]);
|
||||
expect(store.fontA).toEqual(mockFontA);
|
||||
expect(store.fontB).toEqual(mockFontB);
|
||||
});
|
||||
|
||||
it('should not restore when storage has null IDs', async () => {
|
||||
mockStorage._value.fontAId = null;
|
||||
mockStorage._value.fontBId = null;
|
||||
|
||||
const store = new ComparisonStore();
|
||||
await store.restoreFromStorage();
|
||||
|
||||
expect(fetchFontsByIds).not.toHaveBeenCalled();
|
||||
expect(store.fontA).toBeUndefined();
|
||||
expect(store.fontB).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle fetch errors gracefully when restoring', async () => {
|
||||
// ── Restoration from Storage ──────────────────────────────────────────────
|
||||
|
||||
describe('Restoration from Storage (via BatchFontStore)', () => {
|
||||
it('should restore fontA and fontB from stored IDs', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
mockStorage._value.fontBId = mockFontB.id;
|
||||
|
||||
vi.mocked(fetchFontsByIds).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
||||
|
||||
const store = new ComparisonStore();
|
||||
await store.restoreFromStorage();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
await vi.waitFor(() => {
|
||||
expect(store.fontA?.id).toBe(mockFontA.id);
|
||||
expect(store.fontB?.id).toBe(mockFontB.id);
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
|
||||
it('should handle fetch errors during restoration gracefully', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
mockStorage._value.fontBId = mockFontB.id;
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
||||
|
||||
const store = new ComparisonStore();
|
||||
|
||||
// Store stays in valid state — no throw, fonts remain undefined
|
||||
await vi.waitFor(() => expect(store.isLoading).toBe(true)); // stuck loading since no fonts
|
||||
expect(store.fontA).toBeUndefined();
|
||||
expect(store.fontB).toBeUndefined();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial restoration when only one font is found', async () => {
|
||||
// Ensure fontStore is empty so $effect doesn't interfere
|
||||
(fontStore as any).fonts = [];
|
||||
// ── Default Fallbacks ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Default Fallbacks', () => {
|
||||
it('should update storage with default IDs when storage is empty', async () => {
|
||||
(fontStore as any).fonts = [mockFontA, mockFontB];
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
||||
|
||||
new ComparisonStore();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
|
||||
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Loading State ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Aggregate Loading State', () => {
|
||||
it('should be loading initially when storage has IDs', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
mockStorage._value.fontBId = mockFontB.id;
|
||||
|
||||
// Only return fontA (simulating partial data from API)
|
||||
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA]);
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation(
|
||||
() => new Promise(r => setTimeout(() => r([mockFontA, mockFontB]), 50)),
|
||||
);
|
||||
|
||||
const store = new ComparisonStore();
|
||||
// Wait for async restoration from constructor
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(store.isLoading).toBe(true);
|
||||
|
||||
// The store should call fetchFontsByIds with both IDs
|
||||
expect(fetchFontsByIds).toHaveBeenCalledWith([mockFontA.id, mockFontB.id]);
|
||||
|
||||
// When only one font is found, the store handles it gracefully
|
||||
// (both fonts need to be found for restoration to set them)
|
||||
// The key behavior tested here is that partial fetch doesn't crash
|
||||
// and the store remains functional
|
||||
|
||||
// Store should not have crashed and should be in a valid state
|
||||
expect(store).toBeDefined();
|
||||
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 2000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Font Loading with CSS Font Loading API', () => {
|
||||
it('should construct correct font strings for checking', async () => {
|
||||
mockFontFaceSet.check.mockReturnValue(false);
|
||||
(fontStore as any).fonts = [mockFontA, mockFontB];
|
||||
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
|
||||
|
||||
const store = new ComparisonStore();
|
||||
store.fontA = mockFontA;
|
||||
store.fontB = mockFontB;
|
||||
|
||||
// Wait for async operations
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// Check that font strings are constructed correctly
|
||||
const expectedFontAString = '400 48px "Roboto"';
|
||||
const expectedFontBString = '400 48px "Open Sans"';
|
||||
|
||||
expect(mockFontFaceSet.load).toHaveBeenCalledWith(expectedFontAString);
|
||||
expect(mockFontFaceSet.load).toHaveBeenCalledWith(expectedFontBString);
|
||||
});
|
||||
|
||||
it('should handle missing document.fonts API gracefully', () => {
|
||||
// Delete the fonts property entirely to simulate missing API
|
||||
delete (document as any).fonts;
|
||||
|
||||
const store = new ComparisonStore();
|
||||
store.fontA = mockFontA;
|
||||
store.fontB = mockFontB;
|
||||
|
||||
// Should not throw and should still work
|
||||
expect(store.fontA).toStrictEqual(mockFontA);
|
||||
expect(store.fontB).toStrictEqual(mockFontB);
|
||||
});
|
||||
|
||||
it('should handle font loading errors gracefully', async () => {
|
||||
// Mock check to return false (fonts not loaded)
|
||||
mockFontFaceSet.check.mockReturnValue(false);
|
||||
// Mock load to fail
|
||||
mockFontFaceSet.load.mockRejectedValue(new Error('Font load failed'));
|
||||
|
||||
(fontStore as any).fonts = [mockFontA, mockFontB];
|
||||
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const store = new ComparisonStore();
|
||||
store.fontA = mockFontA;
|
||||
store.fontB = mockFontB;
|
||||
|
||||
// Wait for async operations and timeout fallback
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default Values from fontStore', () => {
|
||||
it('should set default fonts from fontStore when available', () => {
|
||||
// Note: This test relies on Svelte 5's $effect which may not work
|
||||
// reliably in the test environment. We test the logic path instead.
|
||||
(fontStore as any).fonts = [mockFontA, mockFontB];
|
||||
|
||||
const store = new ComparisonStore();
|
||||
|
||||
// The default fonts should be set when storage is empty
|
||||
// In the actual app, this happens via $effect in the constructor
|
||||
// In tests, we verify the store can have fonts set manually
|
||||
store.fontA = mockFontA;
|
||||
store.fontB = mockFontB;
|
||||
|
||||
expect(store.fontA).toBeDefined();
|
||||
expect(store.fontB).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use first and last font from fontStore as defaults', () => {
|
||||
const mockFontC = UNIFIED_FONTS.lato;
|
||||
(fontStore as any).fonts = [mockFontA, mockFontC, mockFontB];
|
||||
|
||||
const store = new ComparisonStore();
|
||||
|
||||
// Manually set the first font to test the logic
|
||||
store.fontA = mockFontA;
|
||||
|
||||
expect(store.fontA?.id).toBe(mockFontA.id);
|
||||
});
|
||||
});
|
||||
// ── Reset ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Reset Functionality', () => {
|
||||
it('should reset all state and clear storage', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
// Set some values
|
||||
store.fontA = mockFontA;
|
||||
store.fontB = mockFontB;
|
||||
store.text = 'Custom text';
|
||||
store.side = 'B';
|
||||
store.sliderPosition = 75;
|
||||
|
||||
// Reset
|
||||
store.resetAll();
|
||||
|
||||
// Check all state is cleared
|
||||
expect(store.fontA).toBeUndefined();
|
||||
expect(store.fontB).toBeUndefined();
|
||||
expect(mockStorage._clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset typography controls when resetAll is called', () => {
|
||||
const mockReset = vi.fn();
|
||||
vi.mocked(createTypographyControlManager).mockReturnValue({
|
||||
weight: 400,
|
||||
renderedSize: 48,
|
||||
reset: mockReset,
|
||||
} as any);
|
||||
|
||||
const store = new ComparisonStore();
|
||||
store.resetAll();
|
||||
|
||||
expect(mockReset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not affect text property on reset', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
store.text = 'Custom text';
|
||||
store.resetAll();
|
||||
|
||||
// Text is not reset by resetAll
|
||||
expect(store.text).toBe('Custom text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReady Computed State', () => {
|
||||
it('should return false when fonts are not set', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
expect(store.isReady).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when only fontA is set', () => {
|
||||
const store = new ComparisonStore();
|
||||
store.fontA = mockFontA;
|
||||
|
||||
expect(store.isReady).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when only fontB is set', () => {
|
||||
const store = new ComparisonStore();
|
||||
store.fontB = mockFontB;
|
||||
|
||||
expect(store.isReady).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when both fonts are set', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
// Manually set fonts
|
||||
store.fontA = mockFontA;
|
||||
store.fontB = mockFontB;
|
||||
|
||||
// After setting both fonts, isReady should eventually be true
|
||||
// Note: In actual testing with Svelte 5 runes, the reactivity
|
||||
// may not work in Node.js environment, so this tests the logic path
|
||||
expect(store.fontA).toBeDefined();
|
||||
expect(store.fontB).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLoading State', () => {
|
||||
it('should return true when restoring from storage', async () => {
|
||||
it('should clear fontA and fontB on reset', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
mockStorage._value.fontBId = mockFontB.id;
|
||||
|
||||
// Make fetch take some time
|
||||
vi.mocked(fetchFontsByIds).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve([mockFontA, mockFontB]), 10)),
|
||||
);
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
||||
|
||||
const store = new ComparisonStore();
|
||||
const restorePromise = store.restoreFromStorage();
|
||||
await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 });
|
||||
|
||||
// While restoring, isLoading should be true
|
||||
expect(store.isLoading).toBe(true);
|
||||
|
||||
await restorePromise;
|
||||
|
||||
// After restoration, isLoading should be false
|
||||
expect(store.isLoading).toBe(false);
|
||||
store.resetAll();
|
||||
expect(store.fontA).toBeUndefined();
|
||||
expect(store.fontB).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Getters and Setters', () => {
|
||||
it('should allow getting and setting sample text', () => {
|
||||
const store = new ComparisonStore();
|
||||
// ── Pin / Unpin ───────────────────────────────────────────────────────────
|
||||
|
||||
store.text = 'Hello World';
|
||||
expect(store.text).toBe('Hello World');
|
||||
describe('Pin / Unpin (eviction guard)', () => {
|
||||
it('pins fontA and fontB when they are loaded', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
mockStorage._value.fontBId = mockFontB.id;
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
||||
|
||||
new ComparisonStore();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
|
||||
mockFontA.id,
|
||||
400,
|
||||
mockFontA.features?.isVariable,
|
||||
);
|
||||
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
|
||||
mockFontB.id,
|
||||
400,
|
||||
mockFontB.features?.isVariable,
|
||||
);
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
|
||||
it('should allow getting and setting side', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
expect(store.side).toBe('A');
|
||||
|
||||
store.side = 'B';
|
||||
expect(store.side).toBe('B');
|
||||
});
|
||||
|
||||
it('should allow getting and setting slider position', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
store.sliderPosition = 75;
|
||||
expect(store.sliderPosition).toBe(75);
|
||||
});
|
||||
|
||||
it('should allow getting typography manager', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
expect(store.typography).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty font names gracefully', () => {
|
||||
const emptyFont = { ...mockFontA, name: '' };
|
||||
it('unpins the old font when fontA is replaced', async () => {
|
||||
mockStorage._value.fontAId = mockFontA.id;
|
||||
mockStorage._value.fontBId = mockFontB.id;
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
||||
|
||||
const store = new ComparisonStore();
|
||||
await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 });
|
||||
|
||||
store.fontA = emptyFont;
|
||||
store.fontB = mockFontB;
|
||||
const mockFontC: typeof mockFontA = { ...mockFontA, id: 'playfair', name: 'Playfair Display' };
|
||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontC, mockFontB]);
|
||||
store.fontA = mockFontC;
|
||||
|
||||
// Should not throw
|
||||
expect(store.fontA).toEqual(emptyFont);
|
||||
});
|
||||
|
||||
it('should handle fontA with undefined name', () => {
|
||||
const noNameFont = { ...mockFontA, name: undefined as any };
|
||||
|
||||
const store = new ComparisonStore();
|
||||
|
||||
store.fontA = noNameFont;
|
||||
|
||||
expect(store.fontA).toEqual(noNameFont);
|
||||
});
|
||||
|
||||
it('should handle setSide with both valid values', () => {
|
||||
const store = new ComparisonStore();
|
||||
|
||||
store.side = 'A';
|
||||
expect(store.side).toBe('A');
|
||||
|
||||
store.side = 'B';
|
||||
expect(store.side).toBe('B');
|
||||
await vi.waitFor(() => {
|
||||
expect(appliedFontsManager.unpin).toHaveBeenCalledWith(
|
||||
mockFontA.id,
|
||||
400,
|
||||
mockFontA.features?.isVariable,
|
||||
);
|
||||
expect(appliedFontsManager.pin).toHaveBeenCalledWith(
|
||||
mockFontC.id,
|
||||
400,
|
||||
mockFontC.features?.isVariable,
|
||||
);
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Renders a single character with morphing animation
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { typographySettingsStore } from '$features/SetupFont';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { comparisonStore } from '../../model';
|
||||
|
||||
@@ -25,7 +26,7 @@ let { char, proximity, isPast }: Props = $props();
|
||||
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
const typography = $derived(typographySettingsStore);
|
||||
|
||||
let slot = $state<0 | 1>(0);
|
||||
let slotFonts = $state<[string, string]>(['', '']);
|
||||
@@ -51,6 +52,7 @@ $effect(() => {
|
||||
<span
|
||||
class={cn(
|
||||
'char-inner',
|
||||
'transition-colors duration-300',
|
||||
isPast
|
||||
? 'text-swiss-black/75 dark:text-brand/75'
|
||||
: 'text-neutral-950 dark:text-white',
|
||||
|
||||
@@ -6,23 +6,18 @@
|
||||
<script lang="ts">
|
||||
import { NavigationWrapper } from '$entities/Breadcrumb';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import {
|
||||
ControlGroup,
|
||||
SidebarContainer,
|
||||
Slider,
|
||||
} from '$shared/ui';
|
||||
import { SidebarContainer } from '$shared/ui';
|
||||
import {
|
||||
getContext,
|
||||
untrack,
|
||||
} from 'svelte';
|
||||
import { comparisonStore } from '../../model';
|
||||
import FontList from '../FontList/FontList.svelte';
|
||||
import Header from '../Header/Header.svelte';
|
||||
import Search from '../Search/Search.svelte';
|
||||
import Sidebar from '../Sidebar/Sidebar.svelte';
|
||||
import SliderArea from '../SliderArea/SliderArea.svelte';
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
||||
let isSidebarOpen = $state(!isMobileOrTabletPortrait);
|
||||
|
||||
@@ -43,52 +38,9 @@ $effect(() => {
|
||||
{#snippet sidebar()}
|
||||
<Sidebar class="w-full h-full border-none">
|
||||
{#snippet main()}
|
||||
<Search />
|
||||
<FontList />
|
||||
{/snippet}
|
||||
|
||||
{#snippet controls()}
|
||||
{#if typography.sizeControl && typography.weightControl && typography.heightControl && typography.spacingControl}
|
||||
<ControlGroup label="Size">
|
||||
<Slider
|
||||
bind:value={typography.sizeControl.value}
|
||||
min={typography.sizeControl.min}
|
||||
max={typography.sizeControl.max}
|
||||
step={typography.sizeControl.step}
|
||||
/>
|
||||
</ControlGroup>
|
||||
|
||||
<ControlGroup label="Weight">
|
||||
<Slider
|
||||
bind:value={typography.weightControl.value}
|
||||
min={typography.weightControl.min}
|
||||
max={typography.weightControl.max}
|
||||
step={typography.weightControl.step}
|
||||
/>
|
||||
</ControlGroup>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 mt-4">
|
||||
<ControlGroup label="Leading" class="border-0 py-0">
|
||||
<Slider
|
||||
bind:value={typography.heightControl.value}
|
||||
min={typography.heightControl.min}
|
||||
max={typography.heightControl.max}
|
||||
step={typography.heightControl.step}
|
||||
format={(v => v.toFixed(1))}
|
||||
/>
|
||||
</ControlGroup>
|
||||
|
||||
<ControlGroup label="Tracking" class="border-0 py-0">
|
||||
<Slider
|
||||
bind:value={typography.spacingControl.value}
|
||||
min={typography.spacingControl.min}
|
||||
max={typography.spacingControl.max}
|
||||
step={typography.spacingControl.step}
|
||||
format={(v => v.toFixed(2))}
|
||||
/>
|
||||
</ControlGroup>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Sidebar>
|
||||
{/snippet}
|
||||
</SidebarContainer>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
FontApplicator,
|
||||
FontVirtualList,
|
||||
type UnifiedFont,
|
||||
@@ -18,8 +19,6 @@ import { crossfade } from 'svelte/transition';
|
||||
import { comparisonStore } from '../../model';
|
||||
|
||||
const side = $derived(comparisonStore.side);
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
|
||||
let prevIndexA: number | null = null;
|
||||
let prevIndexB: number | null = null;
|
||||
let selectedIndexA: number | null = null;
|
||||
@@ -71,17 +70,17 @@ $effect(() => {
|
||||
</script>
|
||||
|
||||
<div class="flex-1 min-h-0 h-full">
|
||||
<div class="py-2 pl-4 relative flex flex-col min-h-0 h-full">
|
||||
<div class="px-2 py-4 mr-4 sticky border-b border-black/5 dark:border-white/10 mb-2">
|
||||
<div class="py-2 relative flex flex-col min-h-0 h-full">
|
||||
<div class="py-2 mx-6 sticky border-b border-subtle">
|
||||
<Label class="font-primary text-neutral-400" bold variant="default" size="sm" uppercase>
|
||||
Typeface Selection
|
||||
</Label>
|
||||
</div>
|
||||
<FontVirtualList
|
||||
data-font-list
|
||||
weight={typography.weight}
|
||||
weight={DEFAULT_FONT_WEIGHT}
|
||||
itemHeight={45}
|
||||
class="bg-transparent min-h-0 h-full scroll-stable pr-4"
|
||||
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
|
||||
>
|
||||
{#snippet children({ item: font, index })}
|
||||
{@const isSelectedA = font.id === comparisonStore.fontA?.id}
|
||||
@@ -95,7 +94,7 @@ $effect(() => {
|
||||
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
|
||||
iconPosition="right"
|
||||
>
|
||||
<FontApplicator {font} weight={typography.weight}>{font.name}</FontApplicator>
|
||||
<FontApplicator {font}>{font.name}</FontApplicator>
|
||||
|
||||
{#snippet icon()}
|
||||
{#if active}
|
||||
|
||||
@@ -53,7 +53,7 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
|
||||
'flex items-center justify-between',
|
||||
'px-4 md:px-8 py-4 md:py-6',
|
||||
'h-16 md:h-20 z-20',
|
||||
'border-b border-black/5 dark:border-white/10',
|
||||
'border-b border-subtle',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -3,37 +3,40 @@
|
||||
Renders a line of text in the SliderArea
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { typographySettingsStore } from '$features/SetupFont';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { comparisonStore } from '../../model';
|
||||
|
||||
interface LineChar {
|
||||
char: string;
|
||||
xA: number;
|
||||
widthA: number;
|
||||
xB: number;
|
||||
widthB: number;
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* DOM element reference
|
||||
*/
|
||||
element?: HTMLElement;
|
||||
chars: LineChar[];
|
||||
/**
|
||||
* Character render snippet
|
||||
*/
|
||||
character: Snippet<[{ char: string; index: number }]>;
|
||||
}
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
const typography = $derived(typographySettingsStore);
|
||||
|
||||
let { text, element = $bindable<HTMLElement>(), character }: Props = $props();
|
||||
|
||||
const characters = $derived(text.split(''));
|
||||
let { chars, character }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
||||
style:height="{typography.height}em"
|
||||
style:line-height="{typography.height}em"
|
||||
>
|
||||
{#each characters as char, index}
|
||||
{@render character?.({ char, index })}
|
||||
{#each chars as c, index}
|
||||
{@render character?.({ char: c.char, index })}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
14
src/widgets/ComparisonView/ui/Search/Search.svelte
Normal file
14
src/widgets/ComparisonView/ui/Search/Search.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { filterManager } from '$features/GetFonts';
|
||||
import { SearchBar } from '$shared/ui';
|
||||
</script>
|
||||
|
||||
<div class="p-6 border-b border-black/5">
|
||||
<SearchBar
|
||||
id="font-search"
|
||||
class="w-full"
|
||||
placeholder="Typeface Search"
|
||||
bind:value={filterManager.queryValue}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@ let {
|
||||
'flex flex-col h-full',
|
||||
'w-80',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'border-r border-black/5 dark:border-white/10',
|
||||
'border-r border-subtle',
|
||||
'transition-colors duration-500',
|
||||
className,
|
||||
)}
|
||||
@@ -53,7 +53,7 @@ let {
|
||||
<div
|
||||
class="
|
||||
p-6 shrink-0
|
||||
border-b border-black/5 dark:border-white/10
|
||||
border-b border-subtle
|
||||
bg-surface dark:bg-dark-bg
|
||||
"
|
||||
>
|
||||
@@ -100,7 +100,7 @@ let {
|
||||
class="
|
||||
shrink-0 p-6
|
||||
bg-surface dark:bg-dark-bg
|
||||
border-t border-black/5 dark:border-white/10
|
||||
border-t border-subtle
|
||||
z-10
|
||||
"
|
||||
>
|
||||
|
||||
@@ -8,12 +8,15 @@
|
||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { TypographyMenu } from '$features/SetupFont';
|
||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
||||
import {
|
||||
type CharacterComparison,
|
||||
type ResponsiveManager,
|
||||
createCharacterComparison,
|
||||
debounce,
|
||||
} from '$shared/lib';
|
||||
import {
|
||||
CharacterComparisonEngine,
|
||||
} from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Loader } from '$shared/ui';
|
||||
import { getContext } from 'svelte';
|
||||
@@ -41,25 +44,19 @@ let { isSidebarOpen = false, class: className }: Props = $props();
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
const typography = $derived(typographySettingsStore);
|
||||
|
||||
let container = $state<HTMLElement>();
|
||||
let measureCanvas = $state<HTMLCanvasElement>();
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const isMobile = $derived(responsive?.isMobile ?? false);
|
||||
|
||||
let isDragging = $state(false);
|
||||
|
||||
const charComparison: CharacterComparison = createCharacterComparison(
|
||||
() => comparisonStore.text,
|
||||
() => fontA,
|
||||
() => fontB,
|
||||
() => typography.weight,
|
||||
() => typography.renderedSize,
|
||||
);
|
||||
// New high-performance layout engine
|
||||
const comparisonEngine = new CharacterComparisonEngine();
|
||||
|
||||
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
||||
let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ lines: [], totalHeight: 0 });
|
||||
|
||||
const sliderSpring = new Spring(50, {
|
||||
stiffness: 0.2,
|
||||
@@ -123,18 +120,41 @@ $effect(() => {
|
||||
const _weight = typography.weight;
|
||||
const _size = typography.renderedSize;
|
||||
const _height = typography.height;
|
||||
if (container && measureCanvas && fontA && fontB) {
|
||||
requestAnimationFrame(() => {
|
||||
charComparison.breakIntoLines(container, measureCanvas);
|
||||
});
|
||||
|
||||
if (container && fontA && fontB) {
|
||||
// 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(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handleResize = () => {
|
||||
if (container && measureCanvas) {
|
||||
charComparison.breakIntoLines(container, measureCanvas);
|
||||
if (container && fontA && fontB) {
|
||||
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);
|
||||
@@ -156,20 +176,12 @@ const scaleClass = $derived(
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- Hidden measurement canvas -->
|
||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
||||
|
||||
<!--
|
||||
Outer flex container — fills parent.
|
||||
The paper div inside scales down when the sidebar opens on desktop.
|
||||
-->
|
||||
<div class={cn('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
|
||||
<!--
|
||||
Paper surface.
|
||||
Replaces the old glassmorphism card with a clean white/dark sheet.
|
||||
Scale transition replaces motion.div spring — CSS transition-transform
|
||||
is smooth enough here; a JS spring would add ~4kb for minimal gain.
|
||||
-->
|
||||
<!-- Paper surface -->
|
||||
<div
|
||||
class={cn(
|
||||
'w-full h-full flex flex-col items-center justify-center relative',
|
||||
@@ -218,10 +230,10 @@ const scaleClass = $derived(
|
||||
my-auto
|
||||
"
|
||||
>
|
||||
{#each charComparison.lines as line, lineIndex}
|
||||
<Line bind:element={lineElements[lineIndex]} text={line.text}>
|
||||
{#each layoutResult.lines as line, lineIndex}
|
||||
<Line chars={line.chars}>
|
||||
{#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} />
|
||||
{/snippet}
|
||||
</Line>
|
||||
@@ -233,4 +245,10 @@ const scaleClass = $derived(
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TypographyMenu
|
||||
class={cn(
|
||||
'absolute bottom-4 sm:bottom-5 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2 z-50',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,22 +5,45 @@
|
||||
- Provides a typography menu for font setup.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { FontVirtualList } from '$entities/Font';
|
||||
import {
|
||||
FontVirtualList,
|
||||
appliedFontsManager,
|
||||
createFontRowSizeResolver,
|
||||
fontStore,
|
||||
} from '$entities/Font';
|
||||
import { FontSampler } from '$features/DisplayFont';
|
||||
import {
|
||||
TypographyMenu,
|
||||
controlManager,
|
||||
typographySettingsStore,
|
||||
} from '$features/SetupFont';
|
||||
import { throttle } from '$shared/lib/utils';
|
||||
import { Skeleton } from '$shared/ui';
|
||||
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 wrapper = $state<HTMLDivElement | null>(null);
|
||||
// Binds to the actual window height
|
||||
let innerHeight = $state(0);
|
||||
// Is the component above the middle of the viewport?
|
||||
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(() => {
|
||||
if (!wrapper) return;
|
||||
@@ -30,6 +53,24 @@ const checkPosition = throttle(() => {
|
||||
|
||||
isAboveMiddle = rect.top < viewportMiddle;
|
||||
}, 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: () => typographySettingsStore.weight,
|
||||
getPreviewText: () => text,
|
||||
getContainerWidth: () => containerWidth,
|
||||
getFontSizePx: () => typographySettingsStore.renderedSize,
|
||||
getLineHeightPx: () => typographySettingsStore.height * typographySettingsStore.renderedSize,
|
||||
getStatus: key => appliedFontsManager.statuses.get(key),
|
||||
contentHorizontalPadding: SAMPLER_CONTENT_PADDING_X,
|
||||
chromeHeight: SAMPLER_CHROME_HEIGHT,
|
||||
fallbackHeight: SAMPLER_FALLBACK_HEIGHT,
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet skeleton()}
|
||||
@@ -52,11 +93,11 @@ const checkPosition = throttle(() => {
|
||||
onresize={checkPosition}
|
||||
/>
|
||||
|
||||
<div bind:this={wrapper}>
|
||||
<div bind:this={wrapper} bind:clientWidth={containerWidth}>
|
||||
<FontVirtualList
|
||||
itemHeight={220}
|
||||
itemHeight={fontRowHeight}
|
||||
useWindowScroll={true}
|
||||
weight={controlManager.weight}
|
||||
weight={typographySettingsStore.weight}
|
||||
columns={layoutManager.columns}
|
||||
gap={layoutManager.gap}
|
||||
{skeleton}
|
||||
|
||||
@@ -122,6 +122,13 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 4.1.3
|
||||
resolution: "@chromatic-com/storybook@npm:4.1.3"
|
||||
@@ -2436,6 +2443,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "glyphdiff@workspace:."
|
||||
dependencies:
|
||||
"@chenglou/pretext": "npm:^0.0.5"
|
||||
"@chromatic-com/storybook": "npm:^4.1.3"
|
||||
"@internationalized/date": "npm:^3.10.0"
|
||||
"@lucide/svelte": "npm:^0.561.0"
|
||||
|
||||
Reference in New Issue
Block a user