Merge pull request 'Refactor/reacrhitecture to fsd+' (#49) from refactor/reacrhitecture-to-fsd+ into main
Reviewed-on: #49
This commit was merged in pull request #49.
This commit is contained in:
+195
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["import"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn",
|
||||
"perf": "warn",
|
||||
// style/restriction off: opt-in, contradictory grab-bags. Wanted rules enabled individually below.
|
||||
"style": "off",
|
||||
"restriction": "off"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
".svelte-kit",
|
||||
".vercel",
|
||||
"*.config.js",
|
||||
"*.config.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "warn",
|
||||
"no-debugger": "error",
|
||||
"no-alert": "warn",
|
||||
|
||||
// no-cycle resolves $-aliases via tsconfig auto-discovery (no resolver config in oxlint)
|
||||
"import/no-cycle": "error",
|
||||
"import/no-duplicates": "warn",
|
||||
"import/no-unassigned-import": "off", // CSS/side-effect imports are intentional
|
||||
|
||||
"no-sequences": "error",
|
||||
"no-underscore-dangle": "off",
|
||||
"no-shadow": "warn",
|
||||
"no-implicit-coercion": "warn",
|
||||
"no-await-in-loop": "warn",
|
||||
"no-return-assign": "warn",
|
||||
"no-new": "warn",
|
||||
"no-unneeded-ternary": "warn"
|
||||
},
|
||||
// FSD boundaries. oxlint has no zone rule, so layer/segment direction is enforced
|
||||
// with no-restricted-imports patterns scoped per glob. Layer order (high->low):
|
||||
// app(exempt top shell) > routes > widgets > features > entities > shared.
|
||||
// A layer bans imports from itself (cross-slice via alias) and every layer above.
|
||||
// Overrides are LAST-WINS, not merged: a file matching two overrides keeps only the
|
||||
// last rule config. So the domain override (below) is a self-contained superset, and
|
||||
// the test/story override (last) fully disables boundary checks for those files.
|
||||
"overrides": [
|
||||
// shared = lowest layer: imports nothing above it
|
||||
{
|
||||
"files": ["src/shared/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"$app",
|
||||
"$app/*",
|
||||
"$routes",
|
||||
"$routes/*",
|
||||
"$widgets",
|
||||
"$widgets/*",
|
||||
"$features",
|
||||
"$features/*",
|
||||
"$entities",
|
||||
"$entities/*"
|
||||
],
|
||||
"message": "FSD layer violation: `shared` is the lowest layer and may not import from any layer above it."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// entities: import shared only; no other entity via alias; interior ui<-only-ui
|
||||
{
|
||||
"files": ["src/entities/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*", "$features", "$features/*"],
|
||||
"message": "FSD layer violation: `entities` may only import from `shared`."
|
||||
},
|
||||
{
|
||||
"group": ["$entities", "$entities/*"],
|
||||
"message": "FSD cross-slice violation: do not import another entity via its alias. Use relative imports inside your own slice; invert the dependency through a higher layer for cross-slice needs."
|
||||
},
|
||||
{
|
||||
"group": ["../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// features: import entities/shared only; no other feature via alias
|
||||
{
|
||||
"files": ["src/features/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*"],
|
||||
"message": "FSD layer violation: `features` may only import from `entities` and `shared`."
|
||||
},
|
||||
{
|
||||
"group": ["$features", "$features/*"],
|
||||
"message": "FSD cross-slice violation: do not import another feature via its alias. Invert the dependency through a higher layer (widget/route)."
|
||||
},
|
||||
{
|
||||
"group": ["../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// widgets: import features/entities/shared only; no other widget via alias
|
||||
{
|
||||
"files": ["src/widgets/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["$app", "$app/*", "$routes", "$routes/*"],
|
||||
"message": "FSD layer violation: `widgets` may only import from `features`, `entities`, and `shared`."
|
||||
},
|
||||
{
|
||||
"group": ["$widgets", "$widgets/*"],
|
||||
"message": "FSD cross-slice violation: do not import another widget via its alias. Invert the dependency through the route layer."
|
||||
},
|
||||
{
|
||||
"group": ["../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// routes: top of the FSD list, imports any layer below; only app is above it
|
||||
{
|
||||
"files": ["src/routes/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{ "group": ["$app", "$app/*"], "message": "FSD layer violation: `routes` may not import from `app`." }
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// domain (FSD+): pure logic. Imports NO layer (not even shared) and no sibling
|
||||
// model/ui segment. Superset: wins over the layer override above for these files.
|
||||
{
|
||||
"files": ["src/**/domain/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"$app",
|
||||
"$app/*",
|
||||
"$routes",
|
||||
"$routes/*",
|
||||
"$widgets",
|
||||
"$widgets/*",
|
||||
"$features",
|
||||
"$features/*",
|
||||
"$entities",
|
||||
"$entities/*",
|
||||
"$shared",
|
||||
"$shared/*"
|
||||
],
|
||||
"message": "FSD+ domain isolation: `domain` is pure business logic and may not import any layer (including `shared`). Allowed: relative imports within `domain` and framework-agnostic npm packages."
|
||||
},
|
||||
{
|
||||
"group": ["../model", "../model/*", "../../model/*", "../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD+ domain isolation: `domain` may not import sibling `model` or `ui` segments. Dependency flows ui -> model -> domain, never back."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// tests/stories/fixtures legitimately cross-import (e.g. $entities/Font/testing).
|
||||
// Must be LAST so last-wins disables boundary checks for them.
|
||||
{
|
||||
"files": ["**/*.test.ts", "**/*.spec.ts", "**/*.stories.svelte", "src/**/testing/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,9 +8,9 @@ test.describe('preview text', () => {
|
||||
await comparison.pickPair('Inter', 'Roboto');
|
||||
await comparison.setPreviewText('Sphinx');
|
||||
|
||||
// Each grapheme renders as a `.char-wrap` cell in the slider once
|
||||
// both fonts are loaded. Six glyphs → six cells.
|
||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6);
|
||||
// Window chars render as `.char-wrap` cells for crossfade.
|
||||
// With WINDOW_SIZE=5, "Sphinx" (6 chars) fits 5 in the window.
|
||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(5);
|
||||
});
|
||||
|
||||
test('preserves the typed value in the input', async ({ comparison }) => {
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn",
|
||||
"perf": "warn",
|
||||
"style": "warn",
|
||||
"restriction": "error"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
".svelte-kit",
|
||||
".vercel",
|
||||
"*.config.js",
|
||||
"*.config.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"no-debugger": "error",
|
||||
"no-alert": "warn"
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -4,6 +4,10 @@
|
||||
"version": "0.0.1",
|
||||
"packageManager": "yarn@4.11.0",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"*.css",
|
||||
"**/router.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -65,6 +69,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "0.0.6",
|
||||
"@tanstack/svelte-query": "6.1.28"
|
||||
"@tanstack/svelte-query": "6.1.28",
|
||||
"sv-router": "^0.16.3"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-7
@@ -6,21 +6,27 @@
|
||||
/**
|
||||
* App Component
|
||||
*
|
||||
* Application entry point component. Wraps the main page route within the shared
|
||||
* Application entry point component. Wraps the active route within the shared
|
||||
* layout shell. This is the root component mounted by the application.
|
||||
*
|
||||
* Structure:
|
||||
* - QueryProvider provides TanStack Query client for data fetching
|
||||
* - Layout provides sidebar, header/footer, and page container
|
||||
* - Page renders the current route content
|
||||
* - Router renders the matched route component
|
||||
*/
|
||||
import Page from '$routes/Page.svelte';
|
||||
import { QueryProvider } from './providers';
|
||||
import '$routes/router';
|
||||
import { Router } from 'sv-router';
|
||||
import {
|
||||
AppBindingsProvider,
|
||||
QueryProvider,
|
||||
} from './providers';
|
||||
import Layout from './ui/Layout.svelte';
|
||||
</script>
|
||||
|
||||
<QueryProvider>
|
||||
<Layout>
|
||||
<Page />
|
||||
</Layout>
|
||||
<AppBindingsProvider>
|
||||
<Layout>
|
||||
<Router />
|
||||
</Layout>
|
||||
</AppBindingsProvider>
|
||||
</QueryProvider>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
Component: AppBindings
|
||||
Provider that starts app-wide store bindings (filters → sort → font catalog)
|
||||
for its subtree. Mount-scoped so the bindings' lifetime tracks the app tree.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { startFilterBindings } from '$features/FilterAndSortFonts';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// startFilterBindings returns its $effect.root cleanup; onMount runs it on unmount.
|
||||
onMount(() => startFilterBindings());
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@@ -6,7 +6,7 @@
|
||||
descendants of this provider.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
@@ -18,6 +18,9 @@ interface Props {
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// First call to the lazy singleton — constructs the shared client for the app.
|
||||
const queryClient = getQueryClient();
|
||||
</script>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as AppBindingsProvider } from './AppBindings.svelte';
|
||||
export { default as QueryProvider } from './QueryProvider.svelte';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Application shell with providers and page wrapper
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { themeManager } from '$features/ChangeAppTheme';
|
||||
import { getThemeManager } from '$features/ChangeAppTheme';
|
||||
import G from '$shared/assets/G.svg';
|
||||
import { ResponsiveProvider } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
@@ -32,6 +32,8 @@ interface Props {
|
||||
|
||||
let { children }: Props = $props();
|
||||
let fontsReady = $state(true);
|
||||
|
||||
const themeManager = getThemeManager();
|
||||
const theme = $derived(themeManager.value);
|
||||
|
||||
onMount(() => themeManager.init());
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
||||
export * from './types/types.ts';
|
||||
@@ -19,7 +19,9 @@ vi.mock('$shared/api/api', () => ({
|
||||
}));
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { FontResponseError } from '../../lib/errors/errors';
|
||||
import {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
@@ -26,7 +26,7 @@ import type { UnifiedFont } from '../../model/types';
|
||||
*/
|
||||
export function seedFontCache(fonts: UnifiedFont[]): void {
|
||||
fonts.forEach(font => {
|
||||
queryClient.setQueryData(fontKeys.detail(font.id), font);
|
||||
getQueryClient().setQueryData(fontKeys.detail(font.id), font);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
||||
import { clearCache } from '@chenglou/pretext';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { DualFontLayout } from './DualFontLayout';
|
||||
|
||||
// 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('DualFontLayout', () => {
|
||||
let layout: DualFontLayout;
|
||||
|
||||
beforeEach(() => {
|
||||
installCanvasMock(fontWidthFactory);
|
||||
clearCache();
|
||||
layout = new DualFontLayout();
|
||||
});
|
||||
|
||||
it('returns empty result for empty string', () => {
|
||||
const result = layout.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 = layout.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 = layout.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('returns cached result when called again with same arguments', () => {
|
||||
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const r2 = layout.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 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const r2 = layout.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 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const r2 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20);
|
||||
expect(r2).not.toBe(r1);
|
||||
});
|
||||
|
||||
it('re-computes when fontA changes', () => {
|
||||
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const r2 = layout.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
expect(r2).not.toBe(r1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
type PreparedTextWithSegments,
|
||||
layoutWithLines,
|
||||
prepareWithSegments,
|
||||
} from '@chenglou/pretext';
|
||||
|
||||
/**
|
||||
* Default render size in px when callers omit the `size` arg on `layout()`.
|
||||
*/
|
||||
const DEFAULT_RENDER_SIZE_PX = 16;
|
||||
|
||||
/**
|
||||
* Per-grapheme data computed during dual-font layout. Internal to the engine;
|
||||
* consumed by computeLineRenderModel to derive the per-frame render model.
|
||||
*/
|
||||
export interface ComparisonChar {
|
||||
/**
|
||||
* Grapheme cluster (may be >1 code unit for emoji, combining marks).
|
||||
*/
|
||||
char: string;
|
||||
/**
|
||||
* X offset from line start in fontA, pixels.
|
||||
*/
|
||||
xA: number;
|
||||
/**
|
||||
* Advance width of this grapheme in fontA, pixels.
|
||||
*/
|
||||
widthA: number;
|
||||
/**
|
||||
* X offset from line start in fontB, pixels.
|
||||
*/
|
||||
xB: number;
|
||||
/**
|
||||
* Advance width of this grapheme in fontB, pixels.
|
||||
*/
|
||||
widthB: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single laid-out line. `chars` carries the per-grapheme data needed by
|
||||
* computeLineRenderModel. Consumers should not iterate it directly.
|
||||
*/
|
||||
export interface ComparisonLine {
|
||||
/**
|
||||
* Full text of this line as returned by pretext.
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Rendered width in pixels — maximum across fontA and fontB.
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Per-grapheme metadata for both fonts.
|
||||
*/
|
||||
chars: ComparisonChar[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated output of a dual-font layout pass.
|
||||
*/
|
||||
export interface ComparisonResult {
|
||||
/**
|
||||
* Per-line grapheme data. Empty when input text is empty.
|
||||
*/
|
||||
lines: ComparisonLine[];
|
||||
/**
|
||||
* Total height in pixels.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* Relies on pretext's published structural fields on `PreparedTextWithSegments`
|
||||
* (`widths`, `breakableFitAdvances`, `lineEndFitAdvances`, `lineEndPaintAdvances`)
|
||||
* which are exposed via the `PreparedCore` intersection in `@chenglou/pretext@0.0.6`.
|
||||
*
|
||||
* **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.
|
||||
*
|
||||
* Per-frame slider state derivation lives in `computeLineRenderModel`, not on the
|
||||
* class. This class is pure layout + caching; it holds no reactive state.
|
||||
*/
|
||||
export class DualFontLayout {
|
||||
#segmenter: Intl.Segmenter;
|
||||
|
||||
// Cached prepared data
|
||||
#preparedA: PreparedTextWithSegments | null = null;
|
||||
#preparedB: PreparedTextWithSegments | null = null;
|
||||
#unifiedPrepared: PreparedTextWithSegments | null = null;
|
||||
|
||||
#lastText = '';
|
||||
#lastFontA = '';
|
||||
#lastFontB = '';
|
||||
#lastSpacing = 0;
|
||||
#lastSize = 0;
|
||||
|
||||
// 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).
|
||||
* @param spacing Letter spacing in em (from typography settings).
|
||||
* @param size Current font size in pixels (used to convert spacing em to px).
|
||||
* @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,
|
||||
spacing: number = 0,
|
||||
size: number = DEFAULT_RENDER_SIZE_PX,
|
||||
): ComparisonResult {
|
||||
if (!text) {
|
||||
return { lines: [], totalHeight: 0 };
|
||||
}
|
||||
|
||||
const spacingPx = spacing * size;
|
||||
|
||||
const isFontChange = text !== this.#lastText
|
||||
|| fontA !== this.#lastFontA
|
||||
|| fontB !== this.#lastFontB
|
||||
|| spacing !== this.#lastSpacing
|
||||
|| size !== this.#lastSize;
|
||||
|
||||
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, spacingPx);
|
||||
|
||||
this.#lastText = text;
|
||||
this.#lastFontA = fontA;
|
||||
this.#lastFontB = fontB;
|
||||
this.#lastSpacing = spacing;
|
||||
this.#lastSize = size;
|
||||
}
|
||||
|
||||
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
|
||||
return { lines: [], totalHeight: 0 };
|
||||
}
|
||||
|
||||
const { lines, height } = layoutWithLines(this.#unifiedPrepared, width, lineHeight);
|
||||
|
||||
// 3. Map results back to both fonts
|
||||
const preparedA = this.#preparedA;
|
||||
const preparedB = this.#preparedB;
|
||||
const resultLines: ComparisonLine[] = lines.map(line => {
|
||||
const chars: ComparisonChar[] = [];
|
||||
let currentXA = 0;
|
||||
let currentXB = 0;
|
||||
|
||||
const start = line.start;
|
||||
const end = line.end;
|
||||
|
||||
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
||||
const segmentText = preparedA.segments[sIdx];
|
||||
if (segmentText === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
||||
|
||||
const advA = preparedA.breakableFitAdvances[sIdx];
|
||||
const advB = preparedB.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];
|
||||
let wA = advA != null ? advA[gIdx]! : preparedA.widths[sIdx]!;
|
||||
let wB = advB != null ? advB[gIdx]! : preparedB.widths[sIdx]!;
|
||||
|
||||
// Apply letter spacing (tracking) to the width of each character
|
||||
wA += spacingPx;
|
||||
wB += spacingPx;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two prepared texts into a worst-case unified version so both fonts
|
||||
* wrap at identical positions. Per-segment widths are the elementwise max
|
||||
* across both fonts, with `spacingPx` added to model letter-spacing.
|
||||
*/
|
||||
#createUnifiedPrepared(
|
||||
a: PreparedTextWithSegments,
|
||||
b: PreparedTextWithSegments,
|
||||
spacingPx: number = 0,
|
||||
): PreparedTextWithSegments {
|
||||
const unified: PreparedTextWithSegments = { ...a };
|
||||
|
||||
unified.widths = a.widths.map((w, i) => Math.max(w, b.widths[i]) + spacingPx);
|
||||
unified.lineEndFitAdvances = a.lineEndFitAdvances.map((w, i) =>
|
||||
Math.max(w, b.lineEndFitAdvances[i]) + spacingPx
|
||||
);
|
||||
unified.lineEndPaintAdvances = a.lineEndPaintAdvances.map((w, i) =>
|
||||
Math.max(w, b.lineEndPaintAdvances[i]) + spacingPx
|
||||
);
|
||||
|
||||
unified.breakableFitAdvances = a.breakableFitAdvances.map((advA, i) => {
|
||||
const advB = b.breakableFitAdvances[i];
|
||||
if (!advA && !advB) {
|
||||
return null;
|
||||
}
|
||||
if (!advA) {
|
||||
return advB!.map(w => w + spacingPx);
|
||||
}
|
||||
if (!advB) {
|
||||
return advA.map(w => w + spacingPx);
|
||||
}
|
||||
return advA.map((w, j) => Math.max(w, advB[j]) + spacingPx);
|
||||
});
|
||||
|
||||
return unified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
|
||||
import {
|
||||
type LineRenderModel,
|
||||
computeLineRenderModel,
|
||||
findSplitIndex,
|
||||
} from './computeLineRenderModel';
|
||||
|
||||
/**
|
||||
* Build a ComparisonLine fixture with given per-char widths. xA/xB are
|
||||
* cumulative prefix sums of widthA/widthB respectively.
|
||||
*/
|
||||
function makeLine(
|
||||
chars: { char: string; widthA: number; widthB: number }[],
|
||||
): ComparisonLine {
|
||||
let xA = 0;
|
||||
let xB = 0;
|
||||
const out: ComparisonLine = {
|
||||
text: chars.map(c => c.char).join(''),
|
||||
width: chars.reduce((s, c) => s + Math.max(c.widthA, c.widthB), 0),
|
||||
chars: chars.map(c => {
|
||||
const entry = {
|
||||
char: c.char,
|
||||
xA,
|
||||
xB,
|
||||
widthA: c.widthA,
|
||||
widthB: c.widthB,
|
||||
};
|
||||
xA += c.widthA;
|
||||
xB += c.widthB;
|
||||
return entry;
|
||||
}),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test helper: compute split + render model in one step, matching the
|
||||
* SliderArea call site shape.
|
||||
*/
|
||||
function compute(
|
||||
line: ComparisonLine,
|
||||
sliderPos: number,
|
||||
containerWidth: number,
|
||||
windowSize: number,
|
||||
): LineRenderModel {
|
||||
const split = findSplitIndex(line, sliderPos, containerWidth);
|
||||
return computeLineRenderModel(line, split, windowSize);
|
||||
}
|
||||
|
||||
describe('computeLineRenderModel', () => {
|
||||
it('returns empty model for an empty line', () => {
|
||||
const line = makeLine([]);
|
||||
const model = compute(line, 50, 500, 5);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars).toEqual([]);
|
||||
expect(model.rightText).toBe('');
|
||||
});
|
||||
|
||||
it('places entire line in rightText when slider is at 0', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = compute(line, 0, 500, 0);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars).toEqual([]);
|
||||
expect(model.rightText).toBe('ABC');
|
||||
});
|
||||
|
||||
it('places entire line in leftText when slider is at 100', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = compute(line, 100, 500, 0);
|
||||
expect(model.leftText).toBe('ABC');
|
||||
expect(model.windowChars).toEqual([]);
|
||||
expect(model.rightText).toBe('');
|
||||
});
|
||||
|
||||
it('splits line correctly with slider mid-line (window=0)', () => {
|
||||
// Equal widths → line is centered. Container=300, total=30 → xOffset=135.
|
||||
// Char thresholds (per the threshold formula in the design):
|
||||
// threshold[i] = xOffset + prefA[i] + widthA[i]/2
|
||||
// i=0: 135 + 0 + 5 = 140 → 140/300 = 46.67%
|
||||
// i=1: 135 + 10 + 5 = 150 → 150/300 = 50.00%
|
||||
// i=2: 135 + 20 + 5 = 160 → 160/300 = 53.33%
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
// Slider just past B's threshold (50%) but not C's (53.33%).
|
||||
const model = compute(line, 51, 300, 0);
|
||||
expect(model.leftText).toBe('AB');
|
||||
expect(model.rightText).toBe('C');
|
||||
});
|
||||
|
||||
it('centers window of size 3 on the split index', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
// Slider past A and B (~thresholds 43.33%, 46.67%); not past C (50%).
|
||||
// split = 2 → halfWindow = 1 → windowStart = 1, windowEnd = 4
|
||||
const model = compute(line, 48, 300, 3);
|
||||
expect(model.leftText).toBe('A');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['B', 'C', 'D']);
|
||||
expect(model.rightText).toBe('E');
|
||||
});
|
||||
|
||||
it('clamps window at line start when slider is near 0', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = compute(line, 0, 300, 3);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B', 'C']);
|
||||
expect(model.rightText).toBe('DE');
|
||||
});
|
||||
|
||||
it('clamps window at line end when slider is near 100', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = compute(line, 100, 300, 3);
|
||||
expect(model.leftText).toBe('AB');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['C', 'D', 'E']);
|
||||
expect(model.rightText).toBe('');
|
||||
});
|
||||
|
||||
it('treats whole line as window when line is shorter than windowSize', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = compute(line, 50, 300, 5);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B']);
|
||||
expect(model.rightText).toBe('');
|
||||
});
|
||||
|
||||
it('produces stable keys across slider movement within the same line', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const a = compute(line, 40, 300, 3);
|
||||
const b = compute(line, 60, 300, 3);
|
||||
// Chars that appear in both windows must carry identical keys.
|
||||
for (const charA of a.windowChars) {
|
||||
const charB = b.windowChars.find(w => w.char === charA.char);
|
||||
if (charB !== undefined) {
|
||||
expect(charB.key).toBe(charA.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('marks isPast=true for chars before the split and false for chars after', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
// split = 2 → A,B past; C,D,E not
|
||||
const model = compute(line, 48, 300, 5);
|
||||
const expected = new Map([['A', true], ['B', true], ['C', false], ['D', false], ['E', false]]);
|
||||
for (const wc of model.windowChars) {
|
||||
expect(wc.isPast).toBe(expected.get(wc.char));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSplitIndex', () => {
|
||||
it('returns 0 for empty line', () => {
|
||||
const line = makeLine([]);
|
||||
expect(findSplitIndex(line, 50, 500)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when slider is before all char thresholds', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
expect(findSplitIndex(line, 0, 300)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns chars.length when slider is past all char thresholds', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
expect(findSplitIndex(line, 100, 300)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
|
||||
|
||||
/**
|
||||
* Per-line render slice consumed by Line.svelte. The window is centered on the
|
||||
* slider's split index and clamps at line boundaries.
|
||||
*/
|
||||
export interface LineRenderModel {
|
||||
/**
|
||||
* Chars before the window joined into a single string, rendered as one fontA text run.
|
||||
*/
|
||||
leftText: string;
|
||||
/**
|
||||
* Window chars — each rendered as its own Character element with crossfade slots.
|
||||
*/
|
||||
windowChars: Array<{
|
||||
/**
|
||||
* Stable key for Svelte keyed each — survives slider movement within the same line.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Grapheme cluster to render.
|
||||
*/
|
||||
char: string;
|
||||
/**
|
||||
* True once the slider has crossed this char's threshold.
|
||||
*/
|
||||
isPast: boolean;
|
||||
}>;
|
||||
/**
|
||||
* Chars after the window joined into a single string, rendered as one fontB text run.
|
||||
*/
|
||||
rightText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of chars whose flip threshold the slider has crossed.
|
||||
*
|
||||
* Exposed as a separate step so consumers can pass the resulting primitive
|
||||
* `split` across component boundaries: when split is unchanged tick-to-tick,
|
||||
* downstream `$derived` reads of `computeLineRenderModel(line, split, ...)`
|
||||
* short-circuit on value equality and skip re-rendering.
|
||||
*
|
||||
* For each candidate split `i`, the line's hypothetical width at that moment is
|
||||
* `prefA[i] + widthA[i] + sufB[i+1]` (past chars in fontA, char `i` flipping, future
|
||||
* chars in fontB). The threshold is the x of char `i`'s center in the centered line.
|
||||
* Thresholds are monotonically non-decreasing in `i`, so the scan short-circuits on
|
||||
* the first miss.
|
||||
*/
|
||||
export function findSplitIndex(
|
||||
line: ComparisonLine,
|
||||
sliderPos: number,
|
||||
containerWidth: number,
|
||||
): number {
|
||||
const chars = line.chars;
|
||||
const n = chars.length;
|
||||
if (n === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sliderX = (sliderPos / 100) * containerWidth;
|
||||
|
||||
const prefA = new Float64Array(n + 1);
|
||||
const sufB = new Float64Array(n + 1);
|
||||
for (let i = 0, j = n - 1; i < n; i++, j--) {
|
||||
prefA[i + 1] = prefA[i] + chars[i].widthA;
|
||||
sufB[j] = sufB[j + 1] + chars[j].widthB;
|
||||
}
|
||||
|
||||
let split = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
|
||||
const xOffset = (containerWidth - totalWidth) / 2;
|
||||
const threshold = xOffset + prefA[i] + chars[i].widthA / 2;
|
||||
if (sliderX > threshold) {
|
||||
split = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slices a laid-out line into three regions around a precomputed split index:
|
||||
* a fontA bulk run, an N-char crossfade window, and a fontB bulk run.
|
||||
*
|
||||
* Pure and allocation-bounded: two strings plus a `windowSize`-length array per call.
|
||||
* Takes `split` as a primitive so callers can feed it into a `$derived` and
|
||||
* skip re-evaluation on ticks where the split index is unchanged.
|
||||
*
|
||||
* @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model.
|
||||
* @param split Count of chars the slider has passed, in `[0, line.chars.length]`.
|
||||
* @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`.
|
||||
* At line edges the window is shifted (not shrunk) to keep its size.
|
||||
*/
|
||||
export function computeLineRenderModel(
|
||||
line: ComparisonLine,
|
||||
split: number,
|
||||
windowSize: number,
|
||||
): LineRenderModel {
|
||||
const chars = line.chars;
|
||||
const n = chars.length;
|
||||
if (n === 0) {
|
||||
return { leftText: '', windowChars: [], rightText: '' };
|
||||
}
|
||||
|
||||
const halfWindow = Math.floor(Math.max(0, windowSize) / 2);
|
||||
let windowStart = clamp(split - halfWindow, 0, n);
|
||||
let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n);
|
||||
windowStart = Math.max(0, windowEnd - Math.max(0, windowSize));
|
||||
|
||||
const leftText = chars.slice(0, windowStart).map(c => c.char).join('');
|
||||
const rightText = chars.slice(windowEnd).map(c => c.char).join('');
|
||||
const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({
|
||||
key: `${windowStart + idx}-${c.char}`,
|
||||
char: c.char,
|
||||
isPast: (windowStart + idx) < split,
|
||||
}));
|
||||
|
||||
return { leftText, windowChars, rightText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps `value` into the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
|
||||
*/
|
||||
function clamp(value: number, lo: number, hi: number): number {
|
||||
if (value < lo) {
|
||||
return lo;
|
||||
}
|
||||
if (value > hi) {
|
||||
return hi;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
type ComparisonLine,
|
||||
type ComparisonResult,
|
||||
DualFontLayout,
|
||||
} from './DualFontLayout/DualFontLayout';
|
||||
export {
|
||||
computeLineRenderModel,
|
||||
findSplitIndex,
|
||||
type LineRenderModel,
|
||||
} from './computeLineRenderModel/computeLineRenderModel';
|
||||
@@ -1,4 +1,92 @@
|
||||
export * from './api';
|
||||
export * from './lib';
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
export {
|
||||
computeLineRenderModel,
|
||||
DualFontLayout,
|
||||
findSplitIndex,
|
||||
} from './domain';
|
||||
export type {
|
||||
ComparisonLine,
|
||||
ComparisonResult,
|
||||
LineRenderModel,
|
||||
} from './domain';
|
||||
|
||||
export {
|
||||
createFontRowSizeResolver,
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
getFontUrl,
|
||||
} from './lib';
|
||||
export type { FontRowSizeResolverOptions } from './lib';
|
||||
|
||||
export {
|
||||
FontApplicator,
|
||||
FontSampler,
|
||||
FontVirtualList,
|
||||
} from './ui';
|
||||
|
||||
// Pure model surface (types + constants).
|
||||
export {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
VIRTUAL_INDEX_NOT_LOADED,
|
||||
} from './model/const/const';
|
||||
export type {
|
||||
FilterGroup,
|
||||
FilterType,
|
||||
FontCategory,
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
FontFeatures,
|
||||
FontFilters,
|
||||
FontLoadRequestConfig,
|
||||
FontLoadStatus,
|
||||
FontMetadata,
|
||||
FontProvider,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './model/types';
|
||||
|
||||
/*
|
||||
* Stores are exposed as lazy accessors / classes (not eager singletons): the
|
||||
* entity's public API is complete, so consumers go through this barrel instead
|
||||
* of deep-importing `./model` (FSD public-API boundary). Construction happens on
|
||||
* first call, so this is inert at import. The slice root already transitively
|
||||
* loads `@tanstack/query-core` via `./ui` (FontVirtualList), so surfacing the
|
||||
* stores here adds no new eager cost.
|
||||
*/
|
||||
export {
|
||||
FontLifecycleManager,
|
||||
FontsByIdsStore,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from './model';
|
||||
export type { FontCatalogStore } from './model';
|
||||
|
||||
/*
|
||||
* `./api` (proxy clients: `fetchProxyFonts`, `seedFontCache`, …) is intentionally
|
||||
* NOT re-exported here — those are not part of the entity's consumed surface and
|
||||
* importing them eagerly constructs the TanStack `queryClient`. Import via the
|
||||
* segment: `import { fetchProxyFonts } from '$entities/Font/api'`.
|
||||
*/
|
||||
|
||||
// `./testing` is intentionally not re-exported: fixtures must not leak into the
|
||||
// production public API. Import them via `$entities/Font/testing`.
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
import { createFontLoadRequestContfig } from './createFontLoadRequestContfig';
|
||||
|
||||
/**
|
||||
* Minimal UnifiedFont mock — override only the fields a case exercises.
|
||||
*/
|
||||
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
|
||||
const baseFont: UnifiedFont = {
|
||||
id: 'test-font',
|
||||
name: 'Test Font',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
variants: [],
|
||||
styles: {},
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
features: {
|
||||
isVariable: false,
|
||||
tags: [],
|
||||
},
|
||||
};
|
||||
|
||||
return { ...baseFont, ...overrides };
|
||||
}
|
||||
|
||||
describe('createFontLoadRequestContfig', () => {
|
||||
it('builds a single-element config when a URL resolves', () => {
|
||||
const font = createMockFont({
|
||||
id: 'roboto',
|
||||
name: 'Roboto',
|
||||
styles: { variants: { '400': 'https://example.com/roboto-400.woff2' } },
|
||||
});
|
||||
|
||||
const result = createFontLoadRequestContfig(font, 400);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'roboto',
|
||||
name: 'Roboto',
|
||||
weight: 400,
|
||||
url: 'https://example.com/roboto-400.woff2',
|
||||
isVariable: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when no URL resolves (flatMap drops the font)', () => {
|
||||
const font = createMockFont({ styles: {} });
|
||||
|
||||
expect(createFontLoadRequestContfig(font, 400)).toEqual([]);
|
||||
});
|
||||
|
||||
it('forwards isVariable from font features', () => {
|
||||
const font = createMockFont({
|
||||
features: { isVariable: true, tags: [] },
|
||||
styles: { variants: { '700': 'https://example.com/inter-vf.woff2' } },
|
||||
});
|
||||
|
||||
const [config] = createFontLoadRequestContfig(font, 700);
|
||||
|
||||
expect(config.isVariable).toBe(true);
|
||||
});
|
||||
|
||||
it('sets isVariable to undefined when features is absent', () => {
|
||||
// features is non-optional on UnifiedFont, but upstream data can be partial —
|
||||
// the optional chain must not throw, and isVariable stays undefined.
|
||||
const font = createMockFont({
|
||||
styles: { variants: { '400': 'https://example.com/font.woff2' } },
|
||||
});
|
||||
// @ts-expect-error — deliberately drop the guaranteed field to exercise the optional chain
|
||||
font.features = undefined;
|
||||
|
||||
const [config] = createFontLoadRequestContfig(font, 400);
|
||||
|
||||
expect(config.isVariable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the resolved fallback URL, not just exact matches', () => {
|
||||
// getFontUrl falls back to styles.regular when the exact weight is missing;
|
||||
// the config must carry whatever URL actually resolved.
|
||||
const font = createMockFont({
|
||||
styles: { regular: 'https://example.com/font-regular.woff2' },
|
||||
});
|
||||
|
||||
const [config] = createFontLoadRequestContfig(font, 900);
|
||||
|
||||
expect(config.url).toBe('https://example.com/font-regular.woff2');
|
||||
expect(config.weight).toBe(900);
|
||||
});
|
||||
|
||||
it('carries the requested weight even when the URL is a shared fallback', () => {
|
||||
const font = createMockFont({
|
||||
styles: { variants: { '400': 'https://example.com/shared.woff2' } },
|
||||
});
|
||||
|
||||
expect(createFontLoadRequestContfig(font, 700)[0].weight).toBe(700);
|
||||
});
|
||||
|
||||
it('propagates the invalid-weight error from getFontUrl', () => {
|
||||
const font = createMockFont();
|
||||
|
||||
expect(() => createFontLoadRequestContfig(font, 450)).toThrow('Invalid weight: 450');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
FontLoadRequestConfig,
|
||||
UnifiedFont,
|
||||
} from '../../model';
|
||||
import { getFontUrl } from '../getFontUrl/getFontUrl';
|
||||
|
||||
/**
|
||||
* Build the font-lifecycle load request for a single font at a given weight.
|
||||
*
|
||||
* Returns a 0-or-1 element array rather than `FontLoadRequestConfig | undefined`
|
||||
* so call sites can `flatMap` over a font list — resolve the URL and drop fonts
|
||||
* that have none in a single pass, with no separate filter step. An empty array
|
||||
* means the font has no loadable asset for this weight (or its fallbacks) and is
|
||||
* silently skipped.
|
||||
*
|
||||
* `isVariable` is forwarded from the font's features so the lifecycle manager can
|
||||
* dedupe variable fonts per ID (they load once regardless of weight) while still
|
||||
* loading static fonts per weight.
|
||||
*
|
||||
* @param font - Unified font to load
|
||||
* @param weight - Numeric weight (100-900)
|
||||
* @returns Single-element config array, or `[]` when no URL resolves
|
||||
* @throws Error when weight is outside the valid 100-900 range (propagated from `getFontUrl`)
|
||||
*/
|
||||
export function createFontLoadRequestContfig(font: UnifiedFont, weight: number): FontLoadRequestConfig[] {
|
||||
const url = getFontUrl(font, weight);
|
||||
|
||||
if (!url) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable }];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NonRetryableError } from '$shared/api/queryClient';
|
||||
import { NonRetryableError } from '$shared/api/nonRetryableError';
|
||||
|
||||
/**
|
||||
* Thrown when the network request to the proxy API fails.
|
||||
|
||||
@@ -1,49 +1,5 @@
|
||||
export { getFontUrl } from './getFontUrl/getFontUrl';
|
||||
|
||||
// Mock data helpers for Storybook and testing
|
||||
export {
|
||||
createCategoriesFilter,
|
||||
createErrorState,
|
||||
createGenericFilter,
|
||||
createLoadingState,
|
||||
createMockComparisonStore,
|
||||
// Filter mocks
|
||||
createMockFilter,
|
||||
createMockFontApiResponse,
|
||||
createMockFontStoreState,
|
||||
// Store mocks
|
||||
createMockQueryState,
|
||||
createMockReactiveState,
|
||||
createMockStore,
|
||||
createProvidersFilter,
|
||||
createSubsetsFilter,
|
||||
createSuccessState,
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
generatePaginatedFonts,
|
||||
generateSequentialFilter,
|
||||
GENERIC_FILTERS,
|
||||
getAllMockFonts,
|
||||
getFontsByCategory,
|
||||
getFontsByProvider,
|
||||
MOCK_FILTERS,
|
||||
MOCK_FILTERS_ALL_SELECTED,
|
||||
MOCK_FILTERS_EMPTY,
|
||||
MOCK_FILTERS_SELECTED,
|
||||
MOCK_FONT_STORE_STATES,
|
||||
MOCK_STORES,
|
||||
type MockFilterOptions,
|
||||
type MockFilters,
|
||||
type MockFontStoreState,
|
||||
// Font mocks
|
||||
// Types
|
||||
type MockQueryObserverResult,
|
||||
type MockQueryState,
|
||||
mockUnifiedFont,
|
||||
type MockUnifiedFontOptions,
|
||||
UNIFIED_FONTS,
|
||||
} from './mocks';
|
||||
|
||||
export {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('@chenglou/pretext', async () => {
|
||||
layout: vi.fn(actual.layout),
|
||||
};
|
||||
});
|
||||
import { mockUnifiedFont } from '$entities/Font/testing';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { ControlModel } from '$shared/lib';
|
||||
import type { ControlId } from '../types/typography';
|
||||
|
||||
/**
|
||||
* Font size constants
|
||||
*/
|
||||
@@ -33,60 +30,6 @@ export const MIN_LETTER_SPACING = -0.1;
|
||||
export const MAX_LETTER_SPACING = 0.5;
|
||||
export const LETTER_SPACING_STEP = 0.01;
|
||||
|
||||
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
{
|
||||
id: 'font_size',
|
||||
value: DEFAULT_FONT_SIZE,
|
||||
max: MAX_FONT_SIZE,
|
||||
min: MIN_FONT_SIZE,
|
||||
step: FONT_SIZE_STEP,
|
||||
|
||||
increaseLabel: 'Increase Font Size',
|
||||
decreaseLabel: 'Decrease Font Size',
|
||||
controlLabel: 'Size',
|
||||
},
|
||||
{
|
||||
id: 'font_weight',
|
||||
value: DEFAULT_FONT_WEIGHT,
|
||||
max: MAX_FONT_WEIGHT,
|
||||
min: MIN_FONT_WEIGHT,
|
||||
step: FONT_WEIGHT_STEP,
|
||||
|
||||
increaseLabel: 'Increase Font Weight',
|
||||
decreaseLabel: 'Decrease Font Weight',
|
||||
controlLabel: 'Weight',
|
||||
},
|
||||
{
|
||||
id: 'line_height',
|
||||
value: DEFAULT_LINE_HEIGHT,
|
||||
max: MAX_LINE_HEIGHT,
|
||||
min: MIN_LINE_HEIGHT,
|
||||
step: LINE_HEIGHT_STEP,
|
||||
|
||||
increaseLabel: 'Increase Line Height',
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Leading',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
value: DEFAULT_LETTER_SPACING,
|
||||
max: MAX_LETTER_SPACING,
|
||||
min: MIN_LETTER_SPACING,
|
||||
step: LETTER_SPACING_STEP,
|
||||
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Tracking',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Font size multipliers
|
||||
*/
|
||||
export const MULTIPLIER_S = 0.5;
|
||||
export const MULTIPLIER_M = 0.75;
|
||||
export const MULTIPLIER_L = 1;
|
||||
|
||||
/**
|
||||
* Index value for items not yet loaded in a virtualized list.
|
||||
* Treated as being at the very bottom of the infinite scroll.
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
export * from './const/const';
|
||||
export * from './store';
|
||||
export * from './types';
|
||||
export {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
VIRTUAL_INDEX_NOT_LOADED,
|
||||
} from './const/const';
|
||||
|
||||
// Stores (lazy accessors + classes)
|
||||
export {
|
||||
__resetFontLifecycleManager,
|
||||
FontLifecycleManager,
|
||||
FontsByIdsStore,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from './store';
|
||||
export type { FontCatalogStore } from './store';
|
||||
|
||||
export type {
|
||||
FilterGroup,
|
||||
FilterType,
|
||||
FontCategory,
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
FontFeatures,
|
||||
FontFilters,
|
||||
FontLoadRequestConfig,
|
||||
FontLoadStatus,
|
||||
FontMetadata,
|
||||
FontProvider,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './types';
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { QueryClient } from '@tanstack/query-core';
|
||||
import {
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
} from '$entities/Font/testing';
|
||||
import { flushSync } from 'svelte';
|
||||
import {
|
||||
afterEach,
|
||||
@@ -12,27 +15,33 @@ import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../../lib/errors/errors';
|
||||
import {
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
} from '../../../lib/mocks/fonts.mock';
|
||||
import type { UnifiedFont } from '../../types';
|
||||
import { FontCatalogStore } from './fontCatalogStore.svelte';
|
||||
|
||||
vi.mock('$shared/api/queryClient', async importOriginal => {
|
||||
/**
|
||||
* Import QueryClient inside the factory rather than referencing the top-level binding.
|
||||
* A hoisted vi.mock factory that touches a module-level import can hit that import
|
||||
* before it is initialized (ReferenceError) when the import sits in a circular/eager
|
||||
* barrel chain — which it now does via $shared/lib → BaseQueryStore → query-core.
|
||||
*/
|
||||
const { QueryClient } = await import('@tanstack/query-core');
|
||||
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
||||
const mockClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
queryClient: new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||
}),
|
||||
getQueryClient: () => mockClient,
|
||||
};
|
||||
});
|
||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { fetchProxyFonts } from '../../../api';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||
|
||||
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
DEFAULT_QUERY_GC_TIME_MS,
|
||||
DEFAULT_QUERY_STALE_TIME_MS,
|
||||
queryClient,
|
||||
getQueryClient,
|
||||
} from '$shared/api/queryClient';
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import {
|
||||
type InfiniteData,
|
||||
InfiniteQueryObserver,
|
||||
@@ -46,7 +47,7 @@ export class FontCatalogStore {
|
||||
readonly unknown[],
|
||||
PageParam
|
||||
>;
|
||||
#qc = queryClient;
|
||||
#qc = getQueryClient();
|
||||
#unsubscribe: () => void;
|
||||
|
||||
constructor(params: FontStoreParams = {}) {
|
||||
@@ -483,8 +484,12 @@ export class FontCatalogStore {
|
||||
}
|
||||
}
|
||||
|
||||
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
|
||||
return new FontCatalogStore(params);
|
||||
}
|
||||
const catalog = createSingleton(
|
||||
() => new FontCatalogStore({ limit: 50 }),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
|
||||
export const getFontCatalog = catalog.get;
|
||||
|
||||
// test-only reset, so specs don't share a live observer
|
||||
export const __resetFontCatalog = catalog.reset;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
type FontLoadRequestConfig,
|
||||
@@ -420,6 +421,15 @@ export class FontLifecycleManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance — use throughout the application for unified font loading state.
|
||||
* App-wide font lifecycle manager, created on first access. Lazy so its
|
||||
* AbortController / FontFace bookkeeping isn't set up at module load.
|
||||
*/
|
||||
export const fontLifecycleManager = new FontLifecycleManager();
|
||||
const fontLifecycleManager = createSingleton(
|
||||
() => new FontLifecycleManager(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getFontLifecycleManager = fontLifecycleManager.get;
|
||||
|
||||
// test-only reset, so specs don't share loaded-font/eviction state
|
||||
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('loadFont', () => {
|
||||
it('throws FontParseError when font.load() rejects', async () => {
|
||||
const loadError = new Error('parse failed');
|
||||
const MockFontFace = vi.fn(
|
||||
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
|
||||
function(this: any, _name: string, _buffer: BufferSource, _options: FontFaceDescriptors) {
|
||||
this.load = vi.fn().mockRejectedValue(loadError);
|
||||
},
|
||||
);
|
||||
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore/BaseQueryStore.svelte';
|
||||
import {
|
||||
fetchFontsByIds,
|
||||
seedFontCache,
|
||||
} from '$entities/Font/api/proxy/proxyFonts';
|
||||
} from '../../../api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '$entities/Font/lib/errors/errors';
|
||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
||||
} from '../../../lib/errors/errors';
|
||||
import type { UnifiedFont } from '../../types';
|
||||
|
||||
/**
|
||||
* Internal fetcher that seeds the cache and handles error wrapping.
|
||||
+8
-6
@@ -1,9 +1,6 @@
|
||||
import * as api from '$entities/Font/api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '$entities/Font/lib/errors/errors';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import {
|
||||
beforeEach,
|
||||
@@ -12,6 +9,11 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import * as api from '../../../api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../../lib/errors/errors';
|
||||
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
|
||||
|
||||
describe('FontsByIdsStore', () => {
|
||||
@@ -1,9 +1,13 @@
|
||||
// Font lifecycle manager (browser-side load + cache + eviction)
|
||||
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||
export {
|
||||
__resetFontLifecycleManager,
|
||||
FontLifecycleManager,
|
||||
getFontLifecycleManager,
|
||||
} from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||
|
||||
// Paginated catalog
|
||||
export {
|
||||
createFontCatalogStore,
|
||||
FontCatalogStore,
|
||||
fontCatalogStore,
|
||||
} from './fontCatalogStore/fontCatalogStore.svelte';
|
||||
export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||
export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||
|
||||
// Batch fetch by IDs (detail-cache seeding)
|
||||
export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';
|
||||
|
||||
@@ -23,5 +23,7 @@ export type {
|
||||
FontCollectionState,
|
||||
} from './store';
|
||||
|
||||
export * from './store/fontLifecycle';
|
||||
export * from './typography';
|
||||
export type {
|
||||
FontLoadRequestConfig,
|
||||
FontLoadStatus,
|
||||
} from './store/fontLifecycle';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK FONT DATA
|
||||
* ============================================================================
|
||||
*
|
||||
* Factory functions and preset mock data for fonts.
|
||||
* Mock font data: factory functions and preset fixtures.
|
||||
* Used in Storybook stories, tests, and development.
|
||||
*
|
||||
* ## Usage
|
||||
@@ -16,7 +12,7 @@
|
||||
* GOOGLE_FONTS,
|
||||
* FONTHARE_FONTS,
|
||||
* UNIFIED_FONTS,
|
||||
* } from '$entities/Font/lib/mocks';
|
||||
* } from '$entities/Font/testing';
|
||||
*
|
||||
* // Create a mock Google Font
|
||||
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
|
||||
@@ -28,7 +24,7 @@
|
||||
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
|
||||
*
|
||||
* // Use preset fonts
|
||||
* import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
|
||||
* import { UNIFIED_FONTS } from '$entities/Font/testing';
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK DATA HELPERS - MAIN EXPORT
|
||||
* ============================================================================
|
||||
*
|
||||
* Mock data helpers (main export).
|
||||
* Comprehensive mock data for Storybook stories, tests, and development.
|
||||
*
|
||||
* ## Quick Start
|
||||
@@ -13,7 +10,7 @@
|
||||
* UNIFIED_FONTS,
|
||||
* MOCK_FILTERS,
|
||||
* createMockFontStoreState,
|
||||
* } from '$entities/Font/lib/mocks';
|
||||
* } from '$entities/Font/testing';
|
||||
*
|
||||
* // Use in stories
|
||||
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
|
||||
+2
-6
@@ -8,7 +8,7 @@
|
||||
* import {
|
||||
* createMockQueryState,
|
||||
* MOCK_STORES,
|
||||
* } from '$entities/Font/lib/mocks';
|
||||
* } from '$entities/Font/testing';
|
||||
*
|
||||
* // Create a mock query state
|
||||
* const loadingState = createMockQueryState({ status: 'pending' });
|
||||
@@ -21,11 +21,7 @@
|
||||
*/
|
||||
|
||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||
import type {
|
||||
QueryKey,
|
||||
QueryObserverResult,
|
||||
QueryStatus,
|
||||
} from '@tanstack/svelte-query';
|
||||
import type { QueryStatus } from '@tanstack/svelte-query';
|
||||
import {
|
||||
UNIFIED_FONTS,
|
||||
generateMockFonts,
|
||||
@@ -10,20 +10,20 @@ const { Story } = defineMeta({
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Loads a font and applies it to children. Shows blur/scale loading state until font is ready, then reveals with a smooth transition.',
|
||||
'Applies a font to its children based on the supplied load `status`. Renders the skeleton (or system font) until status is `loaded`/`error`, then reveals the font. The status is provided by the composing widget — the component does not read the lifecycle store itself.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
argTypes: {
|
||||
weight: { control: 'number' },
|
||||
status: { control: 'select', options: ['loading', 'loaded', 'error'] },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { mockUnifiedFont } from '$entities/Font/lib/mocks';
|
||||
import { mockUnifiedFont } from '$entities/Font/testing';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
const fontUnknown = mockUnifiedFont({ id: 'nonexistent-font-xk92z', name: 'Nonexistent Font Xk92z' });
|
||||
@@ -39,11 +39,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
|
||||
'Status is `loading`: the font file has not resolved yet, so children render in the skeleton (or system font) fallback rather than the target font.',
|
||||
},
|
||||
},
|
||||
}}
|
||||
args={{ font: fontUnknown, weight: 400 }}
|
||||
args={{ font: fontUnknown, status: 'loading' }}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||
<FontApplicator {...args}>
|
||||
@@ -58,11 +58,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Uses Arial, a system font available in all browsers. Because fontLifecycleManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
|
||||
'Status is `loaded`: the component reveals the font, applying it to its children (Arial here, available in all browsers).',
|
||||
},
|
||||
},
|
||||
}}
|
||||
args={{ font: fontArial, weight: 400 }}
|
||||
args={{ font: fontArial, status: 'loaded' }}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||
<FontApplicator {...args}>
|
||||
@@ -72,16 +72,16 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Custom Weight"
|
||||
name="Error State"
|
||||
parameters={{
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Demonstrates passing a custom weight (700). The weight is forwarded to fontLifecycleManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
|
||||
'Status is `error`: the font failed to load. The component still reveals (it treats `error` like `loaded` for reveal purposes) so children are not stuck behind the skeleton — they fall back to the system font.',
|
||||
},
|
||||
},
|
||||
}}
|
||||
args={{ font: fontArialBold, weight: 700 }}
|
||||
args={{ font: fontArialBold, status: 'error' }}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||
<FontApplicator {...args}>
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
type UnifiedFont,
|
||||
fontLifecycleManager,
|
||||
} from '../../model';
|
||||
import type {
|
||||
FontLoadStatus,
|
||||
UnifiedFont,
|
||||
} from '../../model/types';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -18,10 +17,13 @@ interface Props {
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Font weight
|
||||
* @default 400
|
||||
* Current load status for this font, supplied by the composing layer.
|
||||
* Kept out of the component so it does not depend on (and import) the
|
||||
* lifecycle store — the owning widget reads the manager and passes the
|
||||
* resolved status down. `undefined` means the font is not tracked yet and
|
||||
* is treated as not-yet-revealed (skeleton / system-font fallback).
|
||||
*/
|
||||
weight?: number;
|
||||
status: FontLoadStatus | undefined;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
@@ -39,20 +41,12 @@ interface Props {
|
||||
|
||||
let {
|
||||
font,
|
||||
weight = DEFAULT_FONT_WEIGHT,
|
||||
status,
|
||||
className,
|
||||
children,
|
||||
skeleton,
|
||||
}: Props = $props();
|
||||
|
||||
const status = $derived(
|
||||
fontLifecycleManager.getFontStatus(
|
||||
font.id,
|
||||
weight,
|
||||
font.features?.isVariable,
|
||||
),
|
||||
);
|
||||
|
||||
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||
</script>
|
||||
|
||||
|
||||
+19
-2
@@ -4,7 +4,7 @@ import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import FontSampler from './FontSampler.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Features/FontSampler',
|
||||
title: 'Entities/Font/FontSampler',
|
||||
component: FontSampler,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
@@ -21,6 +21,11 @@ const { Story } = defineMeta({
|
||||
control: 'object',
|
||||
description: 'Font information object',
|
||||
},
|
||||
status: {
|
||||
control: 'select',
|
||||
options: ['loading', 'loaded', 'error'],
|
||||
description: 'Font-load status, supplied by the composing widget and forwarded to FontApplicator',
|
||||
},
|
||||
text: {
|
||||
control: 'text',
|
||||
description: 'Editable sample text (two-way bindable)',
|
||||
@@ -34,8 +39,8 @@ const { Story } = defineMeta({
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
|
||||
// Mock fonts for testing
|
||||
const mockArial: UnifiedFont = {
|
||||
@@ -79,14 +84,24 @@ const mockGeorgia: UnifiedFont = {
|
||||
isVariable: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Stand-in for the AdjustTypography store the composing widget injects.
|
||||
const mockTypography = {
|
||||
renderedSize: 48,
|
||||
weight: 400,
|
||||
height: 1.5,
|
||||
spacing: 0,
|
||||
};
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{
|
||||
font: mockArial,
|
||||
status: 'loaded',
|
||||
text: 'The quick brown fox jumps over the lazy dog',
|
||||
index: 0,
|
||||
typography: mockTypography,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||
@@ -101,9 +116,11 @@ const mockGeorgia: UnifiedFont = {
|
||||
name="Long Text"
|
||||
args={{
|
||||
font: mockGeorgia,
|
||||
status: 'loaded',
|
||||
text:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
|
||||
index: 1,
|
||||
typography: mockTypography,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||
+54
-24
@@ -4,11 +4,6 @@
|
||||
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||
import {
|
||||
Badge,
|
||||
ContentEditable,
|
||||
@@ -17,12 +12,47 @@ import {
|
||||
Stat,
|
||||
} from '$shared/ui';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type {
|
||||
FontLoadStatus,
|
||||
UnifiedFont,
|
||||
} from '../../model/types';
|
||||
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
||||
|
||||
/**
|
||||
* Minimal typography contract this view renders with. The AdjustTypography
|
||||
* store satisfies it structurally; defining it here keeps the entity decoupled
|
||||
* from that feature (no entity -> feature import).
|
||||
*/
|
||||
interface FontSampleTypography {
|
||||
/**
|
||||
* Rendered font size in px
|
||||
*/
|
||||
renderedSize: number;
|
||||
/**
|
||||
* Numeric font weight
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Line-height multiplier
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* Letter spacing
|
||||
*/
|
||||
spacing: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Font info
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Current font-load status, supplied by the composing widget so this
|
||||
* component (and FontApplicator) stay decoupled from the lifecycle store.
|
||||
* `undefined` means not tracked yet (treated as not-yet-revealed).
|
||||
*/
|
||||
status: FontLoadStatus | undefined;
|
||||
/**
|
||||
* Sample text
|
||||
*/
|
||||
@@ -32,12 +62,15 @@ interface Props {
|
||||
* @default 0
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* Typography settings to render the sample with. Injected by the composing
|
||||
* widget (which owns the AdjustTypography store) so this entity view stays
|
||||
* decoupled from that feature — the same inversion as `status`.
|
||||
*/
|
||||
typography: FontSampleTypography;
|
||||
}
|
||||
|
||||
let { font, text = $bindable(), index = 0 }: Props = $props();
|
||||
|
||||
// Adjust the property name to match your UnifiedFont type
|
||||
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
||||
let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
|
||||
|
||||
// Extract provider badge with fallback
|
||||
const providerBadge = $derived(
|
||||
@@ -46,10 +79,10 @@ const providerBadge = $derived(
|
||||
);
|
||||
|
||||
const stats = $derived([
|
||||
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
|
||||
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
|
||||
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
|
||||
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
|
||||
{ label: 'SZ', value: `${typography.renderedSize}PX` },
|
||||
{ label: 'WGT', value: `${typography.weight}` },
|
||||
{ label: 'LH', value: typography.height.toFixed(2) },
|
||||
{ label: 'LTR', value: `${typography.spacing}` },
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -67,9 +100,8 @@ const stats = $derived([
|
||||
min-h-60
|
||||
rounded-none
|
||||
"
|
||||
style:font-weight={typographySettingsStore.weight}
|
||||
style:font-weight={typography.weight}
|
||||
>
|
||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
flex items-center justify-between
|
||||
@@ -91,9 +123,9 @@ const stats = $derived([
|
||||
{font.name}
|
||||
</span>
|
||||
|
||||
{#if fontType}
|
||||
{#if font?.category}
|
||||
<Badge size="xs" variant="default" nowrap>
|
||||
{fontType}
|
||||
{font?.category}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
@@ -130,19 +162,18 @@ const stats = $derived([
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 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={typographySettingsStore.weight}>
|
||||
<FontApplicator {font} {status}>
|
||||
<ContentEditable
|
||||
bind:text
|
||||
fontSize={typographySettingsStore.renderedSize}
|
||||
lineHeight={typographySettingsStore.height}
|
||||
letterSpacing={typographySettingsStore.spacing}
|
||||
fontSize={typography.renderedSize}
|
||||
lineHeight={typography.height}
|
||||
letterSpacing={typography.spacing}
|
||||
/>
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
||||
<!-- Mobile stats footer; md:hidden because the header stats take over above -->
|
||||
<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-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||
@@ -154,7 +185,6 @@ const stats = $derived([
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ── Red hover line ─────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
absolute bottom-0 left-0 right-0
|
||||
@@ -5,21 +5,18 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { debounce } from '$shared/lib/utils';
|
||||
import {
|
||||
Skeleton,
|
||||
VirtualList,
|
||||
} from '$shared/ui';
|
||||
import { VirtualList } from '$shared/ui';
|
||||
import type {
|
||||
ComponentProps,
|
||||
Snippet,
|
||||
} from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getFontUrl } from '../../lib';
|
||||
import { createFontLoadRequestContfig } from '../../lib/createFontLoadRequestContfig/createFontLoadRequestContfig';
|
||||
import {
|
||||
type FontLoadRequestConfig,
|
||||
type UnifiedFont,
|
||||
fontCatalogStore,
|
||||
fontLifecycleManager,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from '../../model';
|
||||
|
||||
interface Props extends
|
||||
@@ -55,17 +52,28 @@ let {
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const isLoading = $derived(
|
||||
fontCatalogStore.isFetching || fontCatalogStore.isLoading,
|
||||
);
|
||||
const fontCatalog = getFontCatalog();
|
||||
const fontLifecycleManager = getFontLifecycleManager();
|
||||
|
||||
const isLoading = $derived<boolean>(fontCatalog?.isLoading);
|
||||
const isFetching = $derived<boolean>(fontCatalog.isFetching);
|
||||
const hasMore = $derived<boolean>(fontCatalog?.pagination?.hasMore);
|
||||
const fonts = $derived<UnifiedFont[]>(fontCatalog.fonts);
|
||||
const total = $derived<number>(fontCatalog?.pagination.total);
|
||||
|
||||
let visibleFonts = $state<UnifiedFont[]>([]);
|
||||
let isCatchingUp = $state(false);
|
||||
let isCatchingUp = $state<boolean>(false);
|
||||
|
||||
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
|
||||
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
|
||||
const showInitialSkeleton = $derived.by(() => (
|
||||
!!skeleton && (isLoading || isFetching) && fontCatalog.fonts.length === 0
|
||||
));
|
||||
const showCatchupSkeleton = $derived.by(() => (
|
||||
!!skeleton && isCatchingUp
|
||||
));
|
||||
// Settled query with no matches — empty state replaces the (otherwise blank) list.
|
||||
const showEmpty = $derived(!!empty && !isLoading && !isCatchingUp && fontCatalogStore.fonts.length === 0);
|
||||
const showEmpty = $derived.by(() => (
|
||||
!!empty && !(isLoading || isFetching) && !isCatchingUp && fontCatalog.fonts.length === 0
|
||||
));
|
||||
|
||||
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||
visibleFonts = items;
|
||||
@@ -79,12 +87,12 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||
* font files for thousands of intermediate fonts.
|
||||
*/
|
||||
async function handleJump(targetIndex: number) {
|
||||
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
|
||||
if (isCatchingUp || !hasMore) {
|
||||
return;
|
||||
}
|
||||
isCatchingUp = true;
|
||||
try {
|
||||
await fontCatalogStore.fetchAllPagesTo(targetIndex);
|
||||
await fontCatalog.fetchAllPagesTo(targetIndex);
|
||||
} finally {
|
||||
isCatchingUp = false;
|
||||
}
|
||||
@@ -105,13 +113,7 @@ $effect(() => {
|
||||
if (isCatchingUp) {
|
||||
return;
|
||||
}
|
||||
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 }];
|
||||
});
|
||||
const configs = visibleFonts.flatMap(item => createFontLoadRequestContfig(item, weight));
|
||||
if (configs.length > 0) {
|
||||
debouncedTouch(configs);
|
||||
}
|
||||
@@ -137,13 +139,11 @@ $effect(() => {
|
||||
* Load more fonts by moving to the next page
|
||||
*/
|
||||
function loadMore() {
|
||||
if (
|
||||
!fontCatalogStore.pagination.hasMore
|
||||
|| fontCatalogStore.isFetching
|
||||
) {
|
||||
if (!hasMore || isFetching) {
|
||||
return;
|
||||
}
|
||||
fontCatalogStore.nextPage();
|
||||
|
||||
fontCatalog.nextPage();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,12 +153,10 @@ function loadMore() {
|
||||
* of the loaded items. Only fetches if there are more pages available.
|
||||
*/
|
||||
function handleNearBottom(_lastVisibleIndex: number) {
|
||||
const { hasMore } = fontCatalogStore.pagination;
|
||||
|
||||
// VirtualList already checks if we're near the bottom of loaded items.
|
||||
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
||||
// during batch catch-up, which would otherwise let nextPage() race with it.
|
||||
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
|
||||
if (hasMore && !isFetching && !isCatchingUp) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
@@ -177,9 +175,9 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
||||
{:else}
|
||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||
<VirtualList
|
||||
items={fontCatalogStore.fonts}
|
||||
total={fontCatalogStore.pagination.total}
|
||||
isLoading={isLoading || isCatchingUp}
|
||||
items={fonts}
|
||||
{total}
|
||||
isLoading={isLoading || isFetching || isCatchingUp}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
onNearBottom={handleNearBottom}
|
||||
onJump={handleJump}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||
|
||||
export {
|
||||
FontApplicator,
|
||||
FontSampler,
|
||||
FontVirtualList,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export {
|
||||
createTypographySettingsStore,
|
||||
getTypographySettingsStore,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
type TypographySettingsStore,
|
||||
typographySettingsStore,
|
||||
} from './model';
|
||||
export { TypographyMenu } from './ui';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
} from '$entities/Font';
|
||||
import type {
|
||||
ControlId,
|
||||
ControlModel,
|
||||
} from '../types/typography';
|
||||
|
||||
/**
|
||||
* Responsive font-size scaling factors applied by typographySettingsStore.
|
||||
*/
|
||||
export const MULTIPLIER_S = 0.5;
|
||||
export const MULTIPLIER_M = 0.75;
|
||||
export const MULTIPLIER_L = 1;
|
||||
|
||||
/**
|
||||
* Default control definitions seeding the typography settings store.
|
||||
* Composed from the font-render ranges/defaults owned by the Font entity.
|
||||
*/
|
||||
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
{
|
||||
id: 'font_size',
|
||||
value: DEFAULT_FONT_SIZE,
|
||||
max: MAX_FONT_SIZE,
|
||||
min: MIN_FONT_SIZE,
|
||||
step: FONT_SIZE_STEP,
|
||||
increaseLabel: 'Increase Font Size',
|
||||
decreaseLabel: 'Decrease Font Size',
|
||||
controlLabel: 'Size',
|
||||
},
|
||||
{
|
||||
id: 'font_weight',
|
||||
value: DEFAULT_FONT_WEIGHT,
|
||||
max: MAX_FONT_WEIGHT,
|
||||
min: MIN_FONT_WEIGHT,
|
||||
step: FONT_WEIGHT_STEP,
|
||||
increaseLabel: 'Increase Font Weight',
|
||||
decreaseLabel: 'Decrease Font Weight',
|
||||
controlLabel: 'Weight',
|
||||
},
|
||||
{
|
||||
id: 'line_height',
|
||||
value: DEFAULT_LINE_HEIGHT,
|
||||
max: MAX_LINE_HEIGHT,
|
||||
min: MIN_LINE_HEIGHT,
|
||||
step: LINE_HEIGHT_STEP,
|
||||
increaseLabel: 'Increase Line Height',
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Leading',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
value: DEFAULT_LETTER_SPACING,
|
||||
max: MAX_LETTER_SPACING,
|
||||
min: MIN_LETTER_SPACING,
|
||||
step: LETTER_SPACING_STEP,
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Tracking',
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,10 @@
|
||||
export {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from './const/const';
|
||||
export {
|
||||
createTypographySettingsStore,
|
||||
getTypographySettingsStore,
|
||||
type TypographySettingsStore,
|
||||
typographySettingsStore,
|
||||
} from './store/typographySettingsStore/typographySettingsStore.svelte';
|
||||
|
||||
+42
-17
@@ -11,22 +11,27 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
type ControlId,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
} from '$entities/Font';
|
||||
// Deep path (not the root barrel) on purpose: pulls only these pure
|
||||
// constants, not the entity's UI/store graph (+ @tanstack) — keeps this
|
||||
// feature store and its spec light at import. See audit D-1.
|
||||
} from '$entities/Font/model/const/const';
|
||||
import {
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
type PersistentStore,
|
||||
type TypographyControl,
|
||||
createPersistentStore,
|
||||
createTypographyControl,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
import type { NumericControl } from '$shared/ui';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../../const/const';
|
||||
import type {
|
||||
ControlId,
|
||||
ControlModel,
|
||||
} from '../../types/typography';
|
||||
import { createTypographyControl } from '../../typographyControl/createTypographyControl.svelte';
|
||||
|
||||
/**
|
||||
* Epsilon for detecting "significant" base-size changes when reconciling
|
||||
@@ -36,7 +41,7 @@ import { SvelteMap } from 'svelte/reactivity';
|
||||
*/
|
||||
const BASE_SIZE_EPSILON = 0.01;
|
||||
|
||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, 'value' | 'min' | 'max' | 'step'>;
|
||||
|
||||
/**
|
||||
* A control with its associated instance
|
||||
@@ -45,7 +50,7 @@ export interface Control extends ControlOnlyFields<ControlId> {
|
||||
/**
|
||||
* The reactive typography control instance
|
||||
*/
|
||||
instance: TypographyControl;
|
||||
instance: NumericControl;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +98,12 @@ export class TypographySettingsStore {
|
||||
* The underlying font size before responsive scaling is applied
|
||||
*/
|
||||
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||
/**
|
||||
* Disposes the $effect.root that backs the storage-sync effects.
|
||||
* $effect.root lives outside component lifecycle, so callers must invoke
|
||||
* destroy() to avoid leaking the subscriptions.
|
||||
*/
|
||||
#disposeEffects: () => void;
|
||||
|
||||
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||
this.#storage = storage;
|
||||
@@ -116,7 +127,7 @@ export class TypographySettingsStore {
|
||||
|
||||
// The Sync Effect (UI -> Storage)
|
||||
// We access .value explicitly to ensure Svelte 5 tracks the dependency
|
||||
$effect.root(() => {
|
||||
this.#disposeEffects = $effect.root(() => {
|
||||
$effect(() => {
|
||||
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
|
||||
const fontSize = this.#baseSize;
|
||||
@@ -154,6 +165,14 @@ export class TypographySettingsStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down the storage-sync effects. Call on unmount / store disposal.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.#disposeEffects();
|
||||
this.#storage.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets initial value for a control from storage or defaults
|
||||
*/
|
||||
@@ -288,9 +307,6 @@ export class TypographySettingsStore {
|
||||
if (c.id === 'font_size') {
|
||||
c.instance.value = defaults.fontSize * this.#multiplier;
|
||||
} else {
|
||||
// Map storage key to control id
|
||||
const key = c.id.replace('_', '') as keyof TypographySettings;
|
||||
// Simplified for brevity, you'd map these properly:
|
||||
if (c.id === 'font_weight') {
|
||||
c.instance.value = defaults.fontWeight;
|
||||
}
|
||||
@@ -335,10 +351,19 @@ export function createTypographySettingsStore(
|
||||
return new TypographySettingsStore(configs, storage);
|
||||
}
|
||||
|
||||
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
|
||||
|
||||
/**
|
||||
* App-wide typography settings singleton, keyed for the comparison view.
|
||||
* App-wide typography settings store, keyed for the comparison view.
|
||||
* Created on first access so its persistent-store sync effects aren't set up
|
||||
* at module load.
|
||||
*/
|
||||
export const typographySettingsStore = createTypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
COMPARISON_STORAGE_KEY,
|
||||
const typographySettingsStore = createSingleton(
|
||||
() => createTypographySettingsStore(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, COMPARISON_STORAGE_KEY),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getTypographySettingsStore = typographySettingsStore.get;
|
||||
|
||||
// test-only reset, so specs don't share persisted typography state or leak effects
|
||||
export const __resetTypographySettingsStore = typographySettingsStore.reset;
|
||||
|
||||
+5
-2
@@ -6,8 +6,7 @@ import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
} from '$entities/Font';
|
||||
} from '$entities/Font/model/const/const';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../../const/const';
|
||||
import {
|
||||
type TypographySettings,
|
||||
TypographySettingsStore,
|
||||
@@ -51,6 +51,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
let mockPersistentStore: {
|
||||
value: TypographySettings;
|
||||
clear: () => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
||||
@@ -70,6 +71,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||
};
|
||||
},
|
||||
destroy() {},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -535,6 +537,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
mockStorage = v;
|
||||
},
|
||||
clear: clearSpy,
|
||||
destroy() {},
|
||||
};
|
||||
|
||||
const manager = new TypographySettingsStore(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
ControlLabels,
|
||||
NumericControl,
|
||||
} from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Identifiers for the adjustable typography axes
|
||||
*/
|
||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||
|
||||
/**
|
||||
* Static configuration for one typography control.
|
||||
*
|
||||
* Derived from the SSOT contract types — declares no fields of its own beyond
|
||||
* the domain `id`. Bounds come from NumericControl, labels from ControlLabels.
|
||||
*
|
||||
* @template T - Control identifier type
|
||||
*/
|
||||
export type ControlModel<T extends string = string> =
|
||||
& Pick<NumericControl, 'value' | 'min' | 'max' | 'step'>
|
||||
& ControlLabels
|
||||
& {
|
||||
/**
|
||||
* Unique identifier for the control
|
||||
*/
|
||||
id: T;
|
||||
};
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Bounded numeric control for typography settings.
|
||||
*
|
||||
* Produces a reactive control that clamps to [min, max] and rounds to step.
|
||||
* Implements the NumericControl contract that ComboControl renders.
|
||||
*/
|
||||
import {
|
||||
clampNumber,
|
||||
roundToStepPrecision,
|
||||
} from '$shared/lib/utils';
|
||||
import type { NumericControl } from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Bounds + initial value seed for a control
|
||||
*/
|
||||
type ControlSeed = Pick<NumericControl, 'value' | 'min' | 'max' | 'step'>;
|
||||
|
||||
/**
|
||||
* Create a reactive bounded numeric control.
|
||||
*
|
||||
* @param initialState - Initial value and bounds
|
||||
* @returns A NumericControl whose value is always clamped and step-rounded
|
||||
*/
|
||||
export function createTypographyControl(initialState: ControlSeed): NumericControl {
|
||||
let value = $state(initialState.value);
|
||||
let max = $state(initialState.max);
|
||||
let min = $state(initialState.min);
|
||||
let step = $state(initialState.step);
|
||||
|
||||
const { isAtMax, isAtMin } = $derived({
|
||||
isAtMax: value >= max,
|
||||
isAtMin: value <= min,
|
||||
});
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(newValue) {
|
||||
const rounded = roundToStepPrecision(clampNumber(newValue, min, max), step);
|
||||
if (value !== rounded) {
|
||||
value = rounded;
|
||||
}
|
||||
},
|
||||
get max() {
|
||||
return max;
|
||||
},
|
||||
get min() {
|
||||
return min;
|
||||
},
|
||||
get step() {
|
||||
return step;
|
||||
},
|
||||
get isAtMax() {
|
||||
return isAtMax;
|
||||
},
|
||||
get isAtMin() {
|
||||
return isAtMin;
|
||||
},
|
||||
increase() {
|
||||
value = roundToStepPrecision(clampNumber(value + step, min, max), step);
|
||||
},
|
||||
decrease() {
|
||||
value = roundToStepPrecision(clampNumber(value - step, min, max), step);
|
||||
},
|
||||
};
|
||||
}
|
||||
+3
-5
@@ -1,12 +1,10 @@
|
||||
import {
|
||||
type TypographyControl,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import type { NumericControl } from '$shared/ui';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { createTypographyControl } from './createTypographyControl.svelte';
|
||||
|
||||
/**
|
||||
* Test Strategy for createTypographyControl Helper
|
||||
@@ -34,7 +32,7 @@ describe('createTypographyControl - Unit Tests', () => {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}): TypographyControl {
|
||||
}): NumericControl {
|
||||
return createTypographyControl({
|
||||
value: initialValue,
|
||||
min: options?.min ?? 0,
|
||||
@@ -5,11 +5,6 @@
|
||||
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/lib';
|
||||
import {
|
||||
@@ -24,7 +19,12 @@ import XIcon from '@lucide/svelte/icons/x';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { typographySettingsStore } from '../../model';
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
getTypographySettingsStore,
|
||||
} from '../../model';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -46,6 +46,7 @@ interface Props {
|
||||
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const typographySettingsStore = getTypographySettingsStore();
|
||||
|
||||
/**
|
||||
* Sets the common font size multiplier based on the current responsive state.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
|
||||
* import { onMount } from 'svelte';
|
||||
*
|
||||
* onMount(() => {
|
||||
@@ -26,8 +26,8 @@
|
||||
*/
|
||||
|
||||
export {
|
||||
getScrollBreadcrumbsStore,
|
||||
type NavigationAction,
|
||||
scrollBreadcrumbsStore,
|
||||
} from './model';
|
||||
export {
|
||||
BreadcrumbHeader,
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
__resetScrollBreadcrumbsStore,
|
||||
createScrollBreadcrumbsStore,
|
||||
getScrollBreadcrumbsStore,
|
||||
} from './store/scrollBreadcrumbsStore.svelte';
|
||||
export type { BreadcrumbItem } from './store/scrollBreadcrumbsStore.svelte';
|
||||
export type { NavigationAction } from './types/types.ts';
|
||||
+20
-3
@@ -1,3 +1,5 @@
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
|
||||
/**
|
||||
* Scroll-based breadcrumb tracking store
|
||||
*
|
||||
@@ -15,7 +17,7 @@
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
|
||||
*
|
||||
* onMount(() => {
|
||||
* scrollBreadcrumbsStore.add({
|
||||
@@ -167,6 +169,13 @@ class ScrollBreadcrumbsStore {
|
||||
this.#detachScrollListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down the observer and scroll listener. Call on store disposal.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.#disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* All tracked items sorted by index
|
||||
*/
|
||||
@@ -273,6 +282,14 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton scroll breadcrumbs store instance
|
||||
* App-wide scroll breadcrumbs store, created on first access.
|
||||
*/
|
||||
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
||||
const scrollBreadcrumbsStore = createSingleton(
|
||||
() => createScrollBreadcrumbsStore(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getScrollBreadcrumbsStore = scrollBreadcrumbsStore.get;
|
||||
|
||||
// test-only reset, so specs don't share observer/scroll state
|
||||
export const __resetScrollBreadcrumbsStore = scrollBreadcrumbsStore.reset;
|
||||
+2
-3
@@ -70,7 +70,6 @@ class MockIntersectionObserver implements IntersectionObserver {
|
||||
describe('ScrollBreadcrumbsStore', () => {
|
||||
let scrollListeners: Array<() => void> = [];
|
||||
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
let scrollToSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
// Helper to create mock elements
|
||||
@@ -111,7 +110,7 @@ describe('ScrollBreadcrumbsStore', () => {
|
||||
|
||||
// Track scroll event listeners
|
||||
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
|
||||
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
|
||||
(event: string, listener: EventListenerOrEventListenerObject, _options?: any) => {
|
||||
if (event === 'scroll') {
|
||||
scrollListeners.push(listener as () => void);
|
||||
}
|
||||
@@ -119,7 +118,7 @@ describe('ScrollBreadcrumbsStore', () => {
|
||||
},
|
||||
);
|
||||
|
||||
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(
|
||||
vi.spyOn(window, 'removeEventListener').mockImplementation(
|
||||
(event: string, listener: EventListenerOrEventListenerObject) => {
|
||||
if (event === 'scroll') {
|
||||
const index = scrollListeners.indexOf(listener as () => void);
|
||||
+2
-1
@@ -14,9 +14,10 @@ import { cubicOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
import {
|
||||
type BreadcrumbItem,
|
||||
scrollBreadcrumbsStore,
|
||||
getScrollBreadcrumbsStore,
|
||||
} from '../../model';
|
||||
|
||||
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
+9
-3
@@ -1,18 +1,24 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { scrollBreadcrumbsStore } from '../../model';
|
||||
import { getScrollBreadcrumbsStore } from '../../model';
|
||||
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
|
||||
|
||||
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||
|
||||
const sections = [
|
||||
{ index: 100, title: 'Introduction' },
|
||||
{ index: 101, title: 'Typography' },
|
||||
{ index: 102, title: 'Spacing' },
|
||||
];
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let container;
|
||||
/** @type {HTMLDivElement | undefined} */
|
||||
let container = $state();
|
||||
|
||||
onMount(() => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const section of sections) {
|
||||
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
|
||||
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
|
||||
+3
-1
@@ -6,9 +6,11 @@
|
||||
import { type Snippet } from 'svelte';
|
||||
import {
|
||||
type NavigationAction,
|
||||
scrollBreadcrumbsStore,
|
||||
getScrollBreadcrumbsStore,
|
||||
} from '../../model';
|
||||
|
||||
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Navigation index
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
export { getThemeManager } from './model';
|
||||
export { ThemeSwitch } from './ui';
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { themeManager } from './store/ThemeManager/ThemeManager.svelte';
|
||||
export { getThemeManager } from './store/ThemeManager/ThemeManager.svelte';
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
import {
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
|
||||
export const STORAGE_KEY = 'glyphdiff:theme';
|
||||
|
||||
@@ -125,6 +128,7 @@ class ThemeManager {
|
||||
destroy(): void {
|
||||
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
||||
this.#mediaQuery = null;
|
||||
this.#store.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,14 +199,20 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton theme manager instance
|
||||
* App-wide theme manager, created on first access.
|
||||
*
|
||||
* Use throughout the app for consistent theme state.
|
||||
* Lazy so its persistent-store subscription isn't set up at module load.
|
||||
* Call init() on mount and destroy() on unmount (see Layout).
|
||||
*/
|
||||
export const themeManager = new ThemeManager();
|
||||
const themeManager = createSingleton(() => new ThemeManager(), instance => instance.destroy());
|
||||
|
||||
export const getThemeManager = themeManager.get;
|
||||
|
||||
// test-only reset, so specs don't share persisted theme state
|
||||
export const __resetThemeManager = themeManager.reset;
|
||||
|
||||
/**
|
||||
* ThemeManager class exported for testing purposes
|
||||
* Use the singleton `themeManager` in application code.
|
||||
* Use the `getThemeManager()` accessor in application code.
|
||||
*/
|
||||
export { ThemeManager };
|
||||
|
||||
@@ -22,8 +22,9 @@ const { Story } = defineMeta({
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { themeManager } from '$features/ChangeAppTheme';
|
||||
import { getThemeManager } from '$features/ChangeAppTheme';
|
||||
|
||||
const themeManager = getThemeManager();
|
||||
// Current theme state for display
|
||||
const currentTheme = $derived(themeManager.value);
|
||||
const themeSource = $derived(themeManager.source);
|
||||
|
||||
@@ -8,10 +8,11 @@ import { IconButton } from '$shared/ui';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import { getContext } from 'svelte';
|
||||
import { themeManager } from '../../model';
|
||||
import { getThemeManager } from '../../model';
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
const themeManager = getThemeManager();
|
||||
const theme = $derived(themeManager.value);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,16 +3,25 @@ import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/svelte';
|
||||
import { themeManager } from '../../model';
|
||||
import { afterEach } from 'vitest';
|
||||
import { getThemeManager } from '../../model';
|
||||
import { __resetThemeManager } from '../../model/store/ThemeManager/ThemeManager.svelte';
|
||||
import ThemeSwitch from './ThemeSwitch.svelte';
|
||||
|
||||
const context = new Map([['responsive', { isMobile: false }]]);
|
||||
|
||||
describe('ThemeSwitch', () => {
|
||||
let themeManager: ReturnType<typeof getThemeManager>;
|
||||
|
||||
beforeEach(() => {
|
||||
themeManager = getThemeManager();
|
||||
themeManager.setTheme('light');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetThemeManager();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders an icon button', () => {
|
||||
render(ThemeSwitch, { context });
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { FontSampler } from './ui';
|
||||
@@ -1,3 +0,0 @@
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
|
||||
export { FontSampler };
|
||||
@@ -1 +0,0 @@
|
||||
export { FontsByIdsStore } from './model';
|
||||
@@ -1 +0,0 @@
|
||||
export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte';
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
||||
import { NonRetryableError } from '$shared/api/queryClient';
|
||||
import { NonRetryableError } from '$shared/api/nonRetryableError';
|
||||
|
||||
const PROXY_API_URL = API_ENDPOINTS.filters;
|
||||
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export * from './filters/filters';
|
||||
export { fetchProxyFilters } from './filters/filters';
|
||||
export type {
|
||||
FilterMetadata,
|
||||
FilterOption,
|
||||
ProxyFiltersResponse,
|
||||
} from './filters/filters';
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
export { mapAppliedFiltersToParams } from './lib';
|
||||
|
||||
export {
|
||||
type AppliedFilterStore,
|
||||
appliedFilterStore,
|
||||
/**
|
||||
* Filter Store
|
||||
*/
|
||||
availableFilterStore,
|
||||
/**
|
||||
* Filter Manager
|
||||
*/
|
||||
createAppliedFilterStore,
|
||||
/**
|
||||
* Lazy store accessors
|
||||
*/
|
||||
getAppliedFilterStore,
|
||||
getAvailableFilterStore,
|
||||
getSortStore,
|
||||
/**
|
||||
* Sort Store
|
||||
*/
|
||||
SORT_MAP,
|
||||
SORT_OPTIONS,
|
||||
type SortApiValue,
|
||||
type SortOption,
|
||||
sortStore,
|
||||
startFilterBindings,
|
||||
} from './model';
|
||||
|
||||
export type {
|
||||
AppliedFilterStore,
|
||||
SortApiValue,
|
||||
SortOption,
|
||||
} from './model';
|
||||
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type {
|
||||
FilterMetadata,
|
||||
FilterOption,
|
||||
} from '../../api/filters/filters';
|
||||
import { mapFilterMetadataToGroups } from './mapFilterMetadataToGroups';
|
||||
|
||||
/**
|
||||
* Build a FilterOption with a known value and count.
|
||||
*/
|
||||
function option(value: string, count: number): FilterOption {
|
||||
return { id: value, name: value, value, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build filter metadata for one group from (value, count) entries.
|
||||
*/
|
||||
function metadata(id: string, options: Array<[string, number]>): FilterMetadata {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: '',
|
||||
type: 'array',
|
||||
options: options.map(([value, count]) => option(value, count)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('mapFilterMetadataToGroups', () => {
|
||||
it('maps id and name onto group id and label', () => {
|
||||
const [group] = mapFilterMetadataToGroups([metadata('categories', [['serif', 1]])]);
|
||||
|
||||
expect(group.id).toBe('categories');
|
||||
expect(group.label).toBe('categories');
|
||||
});
|
||||
|
||||
it('projects each option to a property with selected: false', () => {
|
||||
const [group] = mapFilterMetadataToGroups([metadata('providers', [['google', 5]])]);
|
||||
|
||||
expect(group.properties).toEqual([
|
||||
{ id: 'google', name: 'google', value: 'google', selected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders properties by descending count', () => {
|
||||
const [group] = mapFilterMetadataToGroups([
|
||||
metadata('subsets', [['latin', 2], ['cyrillic', 9], ['greek', 5]]),
|
||||
]);
|
||||
|
||||
expect(group.properties.map(p => p.value)).toEqual(['cyrillic', 'greek', 'latin']);
|
||||
});
|
||||
|
||||
it('does not mutate the source options array (TanStack cache safety)', () => {
|
||||
const source = metadata('subsets', [['latin', 2], ['cyrillic', 9]]);
|
||||
const originalOrder = source.options.map(o => o.value);
|
||||
|
||||
mapFilterMetadataToGroups([source]);
|
||||
|
||||
expect(source.options.map(o => o.value)).toEqual(originalOrder);
|
||||
});
|
||||
|
||||
it('maps every group, preserving group order', () => {
|
||||
const groups = mapFilterMetadataToGroups([
|
||||
metadata('providers', [['google', 1]]),
|
||||
metadata('categories', [['serif', 1]]),
|
||||
]);
|
||||
|
||||
expect(groups.map(g => g.id)).toEqual(['providers', 'categories']);
|
||||
});
|
||||
|
||||
it('returns an empty group list for empty metadata', () => {
|
||||
expect(mapFilterMetadataToGroups([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('yields an empty properties list when a group has no options', () => {
|
||||
const [group] = mapFilterMetadataToGroups([metadata('providers', [])]);
|
||||
|
||||
expect(group.properties).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FilterMetadata } from '../../api/filters/filters';
|
||||
import type { FilterGroupConfig } from '../../model';
|
||||
|
||||
/**
|
||||
* Map backend filter metadata into the group configs `appliedFilterStore.setGroups`
|
||||
* consumes.
|
||||
*
|
||||
* Inverse direction of `mapAppliedFiltersToParams`: that maps applied selections out
|
||||
* to API params; this maps the API's available-filter catalog in to the UI model.
|
||||
*
|
||||
* Options are ordered by descending font count so the most populated values surface
|
||||
* first. The source array is copied before sorting — `metadata` is TanStack-cached
|
||||
* query data, and `.sort()` mutates in place; sorting the live cache both corrupts it
|
||||
* and, when called from a reactive effect, writes into that effect's own read
|
||||
* dependency (triggering an update loop).
|
||||
*
|
||||
* Every property starts unselected; selection state is owned by the store, not the
|
||||
* backend catalog.
|
||||
*
|
||||
* @param metadata - Available-filter catalog from the filters endpoint
|
||||
* @returns Group configs ready for `setGroups`
|
||||
*/
|
||||
export function mapFilterMetadataToGroups(metadata: FilterMetadata[]): FilterGroupConfig<string>[] {
|
||||
return metadata.map(filter => ({
|
||||
id: filter.id,
|
||||
label: filter.name,
|
||||
properties: [...filter.options]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map(opt => ({
|
||||
id: opt.id,
|
||||
name: opt.name,
|
||||
value: opt.value,
|
||||
selected: false,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -14,9 +14,9 @@ export type {
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Low-level property selection store
|
||||
* Lazy accessor for the app-wide filter-metadata store
|
||||
*/
|
||||
availableFilterStore,
|
||||
getAvailableFilterStore,
|
||||
} from './store/availableFilterStore/availableFilterStore.svelte';
|
||||
|
||||
/**
|
||||
@@ -27,26 +27,30 @@ export {
|
||||
* Reactive interface returned by `createAppliedFilterStore`
|
||||
*/
|
||||
type AppliedFilterStore,
|
||||
/**
|
||||
* High-level manager for syncing search and filters
|
||||
*/
|
||||
appliedFilterStore,
|
||||
/**
|
||||
* Factory for constructing a filter manager instance
|
||||
*/
|
||||
createAppliedFilterStore,
|
||||
/**
|
||||
* Lazy accessor for the app-wide filter manager
|
||||
*/
|
||||
getAppliedFilterStore,
|
||||
} from './store/appliedFilterStore/appliedFilterStore.svelte';
|
||||
|
||||
/**
|
||||
* Side-effect import: installs the global appliedFilterStore+sortStore → fontCatalogStore
|
||||
* bridge on first import of this feature barrel. No exports.
|
||||
*/
|
||||
import './store/bindings.svelte';
|
||||
export { startFilterBindings } from './store/bindings.svelte';
|
||||
|
||||
/**
|
||||
* Sorting logic
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Lazy accessor for the app-wide sort store
|
||||
*/
|
||||
getSortStore,
|
||||
/**
|
||||
* Map of human-readable labels to API sort keys
|
||||
*/
|
||||
@@ -63,8 +67,4 @@ export {
|
||||
* UI model for a single sort option
|
||||
*/
|
||||
type SortOption,
|
||||
/**
|
||||
* Reactive store for the current sort selection
|
||||
*/
|
||||
sortStore,
|
||||
} from './store/sortStore/sortStore.svelte';
|
||||
|
||||
+28
-16
@@ -23,7 +23,10 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createFilter } from '$shared/lib';
|
||||
import {
|
||||
createFilter,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
import { createDebouncedState } from '$shared/lib/helpers';
|
||||
import type {
|
||||
FilterConfig,
|
||||
@@ -42,8 +45,13 @@ import type {
|
||||
export function createAppliedFilterStore<TValue extends string>(config: FilterConfig<TValue>) {
|
||||
const search = createDebouncedState(config.queryValue ?? '');
|
||||
|
||||
// Create filter instances upfront
|
||||
const groups = $state(
|
||||
// Create filter instances upfront.
|
||||
// `let` (not `const`) so setGroups can REASSIGN the whole array. In-place
|
||||
// `groups.length = 0; groups.push(...)` is forbidden here: push reads the
|
||||
// array's length signal, so a $effect that calls setGroups would both read
|
||||
// and write `groups.length` in one run and re-trigger itself forever
|
||||
// (effect_update_depth_exceeded).
|
||||
let groups = $state(
|
||||
config.groups.map(config => ({
|
||||
id: config.id,
|
||||
label: config.label,
|
||||
@@ -62,14 +70,11 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
|
||||
* Used when dynamic filter data loads from backend
|
||||
*/
|
||||
setGroups(newGroups: FilterGroupConfig<TValue>[]) {
|
||||
groups.length = 0;
|
||||
groups.push(
|
||||
...newGroups.map(g => ({
|
||||
id: g.id,
|
||||
label: g.label,
|
||||
instance: createFilter({ properties: g.properties }),
|
||||
})),
|
||||
);
|
||||
groups = newGroups.map(g => ({
|
||||
id: g.id,
|
||||
label: g.label,
|
||||
instance: createFilter({ properties: g.properties }),
|
||||
}));
|
||||
},
|
||||
/**
|
||||
* Current search query value (immediate, for UI binding)
|
||||
@@ -128,13 +133,20 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
|
||||
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
||||
|
||||
/**
|
||||
* App-wide filter manager singleton.
|
||||
* App-wide filter manager, created on first access.
|
||||
*
|
||||
* Constructed with empty groups; the availableFilterStore → appliedFilterStore wiring
|
||||
* lives in `./bindings.svelte` and populates groups once backend filter
|
||||
* metadata arrives.
|
||||
*/
|
||||
export const appliedFilterStore = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: [],
|
||||
});
|
||||
const appliedFilterStore = createSingleton(() =>
|
||||
createAppliedFilterStore<string>({
|
||||
queryValue: '',
|
||||
groups: [],
|
||||
})
|
||||
);
|
||||
|
||||
export const getAppliedFilterStore = appliedFilterStore.get;
|
||||
|
||||
// test-only reset, so specs don't share filter/selection state
|
||||
export const __resetAppliedFilterStore = appliedFilterStore.reset;
|
||||
|
||||
-6
@@ -29,12 +29,6 @@ import { createAppliedFilterStore } from './appliedFilterStore.svelte';
|
||||
* testing Svelte 5 reactive code in Node.js.
|
||||
*/
|
||||
|
||||
// Helper to flush Svelte effects (they run in microtasks)
|
||||
async function flushEffects() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// Helper to create test properties
|
||||
function createTestProperties(count: number, selectedIndices: number[] = []): Property<string>[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
|
||||
+14
-4
@@ -20,8 +20,9 @@ import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/fi
|
||||
import {
|
||||
DEFAULT_QUERY_GC_TIME_MS,
|
||||
DEFAULT_QUERY_STALE_TIME_MS,
|
||||
queryClient,
|
||||
getQueryClient,
|
||||
} from '$shared/api/queryClient';
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import {
|
||||
type QueryKey,
|
||||
QueryObserver,
|
||||
@@ -49,7 +50,7 @@ export class AvailableFilterStore {
|
||||
/**
|
||||
* Shared query client
|
||||
*/
|
||||
protected qc = queryClient;
|
||||
protected qc = getQueryClient();
|
||||
|
||||
/**
|
||||
* Creates a new filters store
|
||||
@@ -127,6 +128,15 @@ export class AvailableFilterStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance
|
||||
* App-wide filter-metadata store, created on first access. Lazy so the
|
||||
* QueryObserver isn't constructed at module load.
|
||||
*/
|
||||
export const availableFilterStore = new AvailableFilterStore();
|
||||
const availableFilterStore = createSingleton(
|
||||
() => new AvailableFilterStore(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getAvailableFilterStore = availableFilterStore.get;
|
||||
|
||||
// test-only reset, so specs don't share a live observer
|
||||
export const __resetAvailableFilterStore = availableFilterStore.reset;
|
||||
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
|
||||
@@ -9,52 +9,34 @@
|
||||
* observer, so it lives at module scope, not in any individual widget.
|
||||
*/
|
||||
|
||||
import { fontCatalogStore } from '$entities/Font';
|
||||
import { getFontCatalog } from '$entities/Font';
|
||||
import { untrack } from 'svelte';
|
||||
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
|
||||
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
|
||||
import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
|
||||
import { sortStore } from './sortStore/sortStore.svelte';
|
||||
import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups';
|
||||
import { getAppliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
|
||||
import { getAvailableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
|
||||
import { getSortStore } from './sortStore/sortStore.svelte';
|
||||
|
||||
$effect.root(() => {
|
||||
/**
|
||||
* Populate appliedFilterStore groups when backend filter metadata resolves.
|
||||
* availableFilterStore is async; until it loads, appliedFilterStore has empty groups
|
||||
* and the UI renders nothing for them.
|
||||
*/
|
||||
$effect(() => {
|
||||
const dynamicFilters = availableFilterStore.filters;
|
||||
export function startFilterBindings(): () => void {
|
||||
const appliedFilterStore = getAppliedFilterStore();
|
||||
const availableFilterStore = getAvailableFilterStore();
|
||||
const sortStore = getSortStore();
|
||||
|
||||
if (dynamicFilters.length > 0) {
|
||||
appliedFilterStore.setGroups(
|
||||
dynamicFilters.map(filter => ({
|
||||
id: filter.id,
|
||||
label: filter.name,
|
||||
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
|
||||
id: opt.id,
|
||||
name: opt.name,
|
||||
value: opt.value,
|
||||
selected: false,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
const stop = $effect.root(() => {
|
||||
$effect(() => {
|
||||
const dynamicFilters = availableFilterStore.filters;
|
||||
if (dynamicFilters.length > 0) {
|
||||
appliedFilterStore.setGroups(mapFilterMetadataToGroups(dynamicFilters));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const params = mapAppliedFiltersToParams(appliedFilterStore);
|
||||
const sort = sortStore.apiValue;
|
||||
const catalog = getFontCatalog();
|
||||
untrack(() => catalog.setParams({ ...params, sort }));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mirror filter selections + debounced search query + sort into fontCatalogStore params.
|
||||
*
|
||||
* Filters and sort are merged into one setParams call to avoid a startup race:
|
||||
* two separate effects each issued setOptions with a different queryKey on the
|
||||
* first flush, producing an orphaned `?limit=50&offset=0` fetch immediately
|
||||
* followed by the real `?limit=50&sort=popularity&offset=0` fetch.
|
||||
*
|
||||
* untrack the write so fontCatalogStore's internal $state reads don't feed back
|
||||
* into this effect's dependency graph.
|
||||
*/
|
||||
$effect(() => {
|
||||
const params = mapAppliedFiltersToParams(appliedFilterStore);
|
||||
const sort = sortStore.apiValue;
|
||||
untrack(() => fontCatalogStore.setParams({ ...params, sort }));
|
||||
});
|
||||
});
|
||||
return stop; // hand the caller the cleanup
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
|
||||
/**
|
||||
* Sort store — manages the current sort option for font listings.
|
||||
*
|
||||
@@ -44,4 +46,14 @@ export function createSortStore(initial: SortOption = 'Popularity') {
|
||||
};
|
||||
}
|
||||
|
||||
export const sortStore = createSortStore();
|
||||
export type SortStore = ReturnType<typeof createSortStore>;
|
||||
|
||||
/**
|
||||
* App-wide sort store, created on first access.
|
||||
*/
|
||||
const sortStore = createSingleton(() => createSortStore());
|
||||
|
||||
export const getSortStore = sortStore.get;
|
||||
|
||||
// test-only reset, so specs don't share selection state
|
||||
export const __resetSortStore = sortStore.reset;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
@@ -7,8 +8,9 @@ import {
|
||||
SORT_MAP,
|
||||
SORT_OPTIONS,
|
||||
type SortOption,
|
||||
__resetSortStore,
|
||||
createSortStore,
|
||||
sortStore,
|
||||
getSortStore,
|
||||
} from './sortStore.svelte';
|
||||
|
||||
describe('createSortStore', () => {
|
||||
@@ -51,14 +53,24 @@ describe('createSortStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortStore singleton', () => {
|
||||
describe('getSortStore singleton', () => {
|
||||
afterEach(() => {
|
||||
__resetSortStore();
|
||||
});
|
||||
|
||||
it('returns the same instance across calls', () => {
|
||||
expect(getSortStore()).toBe(getSortStore());
|
||||
});
|
||||
|
||||
it('exposes the same shape as a factory instance', () => {
|
||||
const sortStore = getSortStore();
|
||||
expect(typeof sortStore.value).toBe('string');
|
||||
expect(typeof sortStore.apiValue).toBe('string');
|
||||
expect(typeof sortStore.set).toBe('function');
|
||||
});
|
||||
|
||||
it('accepts all SORT_OPTIONS as valid set() inputs', () => {
|
||||
const sortStore = getSortStore();
|
||||
for (const option of SORT_OPTIONS) {
|
||||
sortStore.set(option);
|
||||
expect(sortStore.value).toBe(option);
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { FilterGroup } from '$shared/ui';
|
||||
import { appliedFilterStore } from '../../model';
|
||||
import { getAppliedFilterStore } from '../../model';
|
||||
|
||||
const appliedFilterStore = getAppliedFilterStore();
|
||||
const groups = $derived(appliedFilterStore.groups);
|
||||
</script>
|
||||
|
||||
{#each appliedFilterStore.groups as group (group.id)}
|
||||
{#each groups as group (group.id)}
|
||||
<FilterGroup
|
||||
displayedLabel={group.label}
|
||||
filter={group.instance}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
appliedFilterStore,
|
||||
availableFilterStore,
|
||||
getAppliedFilterStore,
|
||||
getAvailableFilterStore,
|
||||
} from '$features/FilterAndSortFonts';
|
||||
import {
|
||||
render,
|
||||
@@ -12,8 +12,8 @@ import Filters from './Filters.svelte';
|
||||
describe('Filters', () => {
|
||||
beforeEach(() => {
|
||||
// Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us
|
||||
appliedFilterStore.setGroups([]);
|
||||
vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]);
|
||||
getAppliedFilterStore().setGroups([]);
|
||||
vi.spyOn(getAvailableFilterStore(), 'filters', 'get').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -28,7 +28,7 @@ describe('Filters', () => {
|
||||
});
|
||||
|
||||
it('renders a label for each filter group', () => {
|
||||
appliedFilterStore.setGroups([
|
||||
getAppliedFilterStore().setGroups([
|
||||
{ id: 'cat', label: 'Categories', properties: [] },
|
||||
{ id: 'prov', label: 'Font Providers', properties: [] },
|
||||
]);
|
||||
@@ -38,7 +38,7 @@ describe('Filters', () => {
|
||||
});
|
||||
|
||||
it('renders filter properties within groups', () => {
|
||||
appliedFilterStore.setGroups([
|
||||
getAppliedFilterStore().setGroups([
|
||||
{
|
||||
id: 'cat',
|
||||
label: 'Category',
|
||||
@@ -54,7 +54,7 @@ describe('Filters', () => {
|
||||
});
|
||||
|
||||
it('renders multiple groups with their properties', () => {
|
||||
appliedFilterStore.setGroups([
|
||||
getAppliedFilterStore().setGroups([
|
||||
{
|
||||
id: 'cat',
|
||||
label: 'Category',
|
||||
|
||||
@@ -12,8 +12,8 @@ import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import { getContext } from 'svelte';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
appliedFilterStore,
|
||||
sortStore,
|
||||
getAppliedFilterStore,
|
||||
getSortStore,
|
||||
} from '../../model';
|
||||
|
||||
interface Props {
|
||||
@@ -30,6 +30,10 @@ const {
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
||||
|
||||
const appliedFilterStore = getAppliedFilterStore();
|
||||
const sortStore = getSortStore();
|
||||
const sortValue = $derived(sortStore.value);
|
||||
|
||||
function handleReset() {
|
||||
appliedFilterStore.deselectAllGlobal();
|
||||
}
|
||||
@@ -53,7 +57,7 @@ function handleReset() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
||||
active={sortStore.value === option}
|
||||
active={sortValue === option}
|
||||
onclick={() => sortStore.set(option)}
|
||||
class="tracking-wide px-0"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!--
|
||||
Component: Page
|
||||
Description: The main page component of the application.
|
||||
Component: Home
|
||||
Root route — comparison workspace.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ComparisonView } from '$widgets/ComparisonView';
|
||||
@@ -0,0 +1,10 @@
|
||||
<!--
|
||||
Component: Redirect
|
||||
Mounts only when an unmatched route is hit; immediately replaces the URL
|
||||
with the home route. Kept until additional routes exist.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { navigate } from './router';
|
||||
|
||||
navigate('/', { replace: true });
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
isActive,
|
||||
navigate,
|
||||
p,
|
||||
preload,
|
||||
route,
|
||||
} from './router';
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRouter } from 'sv-router';
|
||||
import Home from './Home.svelte';
|
||||
|
||||
/**
|
||||
* Single-page router for glyphdiff.
|
||||
*
|
||||
* Currently exposes one route; structure exists so additional routes can be
|
||||
* added without touching the app shell.
|
||||
*/
|
||||
export const {
|
||||
isActive,
|
||||
navigate,
|
||||
p,
|
||||
preload,
|
||||
route,
|
||||
} = createRouter({
|
||||
'/': Home,
|
||||
/**
|
||||
* Any unmatched path redirects to home until additional routes exist.
|
||||
* Lazy-loaded so `router` doesn't statically import `Redirect`, which
|
||||
* imports `navigate` from here — breaks the import cycle.
|
||||
*/
|
||||
'*notfound': () => import('./Redirect.svelte'),
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Marker base class for errors that retrying will never fix — schema-validation
|
||||
* failures, unauthorized responses, contract violations, etc.
|
||||
*
|
||||
* The queryClient retry handler short-circuits when it sees this; without it,
|
||||
* a non-transient backend bug pins the UI through the full retry budget
|
||||
* (default 3× exponential backoff ≈ 7s).
|
||||
*
|
||||
* Lives in its own module — free of `@tanstack/query-core` — so error types that
|
||||
* extend it (e.g. `FontResponseError`) can be imported without dragging the
|
||||
* TanStack client and its eager `new QueryClient()` instantiation into the
|
||||
* importer's module graph. See the barrel-coupling notes in the FSD audit.
|
||||
*/
|
||||
export class NonRetryableError extends Error {}
|
||||
@@ -1,14 +1,5 @@
|
||||
import { QueryClient } from '@tanstack/query-core';
|
||||
|
||||
/**
|
||||
* Marker base class for errors that retrying will never fix — schema-validation
|
||||
* failures, unauthorized responses, contract violations, etc.
|
||||
*
|
||||
* The queryClient retry handler short-circuits when it sees this; without it,
|
||||
* a non-transient backend bug pins the UI through the full retry budget
|
||||
* (default 3× exponential backoff ≈ 7s).
|
||||
*/
|
||||
export class NonRetryableError extends Error {}
|
||||
import { NonRetryableError } from './nonRetryableError';
|
||||
|
||||
/**
|
||||
* Data remains fresh for this long after fetch. Stores that override
|
||||
@@ -36,11 +27,16 @@ export const QUERY_RETRY_BASE_DELAY_MS = 1000;
|
||||
*/
|
||||
export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
||||
|
||||
let queryClientInstance: QueryClient | undefined;
|
||||
|
||||
/**
|
||||
* TanStack Query client instance
|
||||
* Shared TanStack Query client (lazy singleton).
|
||||
*
|
||||
* Configured for optimal caching and refetching behavior.
|
||||
* Used by all font stores for data fetching and caching.
|
||||
* Construction is deferred to the first call so importing this module is inert:
|
||||
* module eval runs no `new QueryClient()`, so the module is genuinely
|
||||
* side-effect-free and needs no `sideEffects` allowlist exception. The
|
||||
* app-layer `QueryProvider` is the first caller; every store reuses the same
|
||||
* instance. Matches the lazy-accessor pattern used by the font stores.
|
||||
*
|
||||
* Cache behavior:
|
||||
* - Data stays fresh for 5 minutes (staleTime)
|
||||
@@ -48,30 +44,32 @@ export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
||||
* - No refetch on window focus (reduces unnecessary network requests)
|
||||
* - 3 retries with exponential backoff on failure
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||
/**
|
||||
* Don't refetch when window regains focus
|
||||
*/
|
||||
refetchOnWindowFocus: false,
|
||||
/**
|
||||
* Refetch on mount if data is stale
|
||||
*/
|
||||
refetchOnMount: true,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof NonRetryableError) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < QUERY_RETRY_COUNT;
|
||||
export function getQueryClient(): QueryClient {
|
||||
return (queryClientInstance ??= new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||
/**
|
||||
* Don't refetch when window regains focus
|
||||
*/
|
||||
refetchOnWindowFocus: false,
|
||||
/**
|
||||
* Refetch on mount if data is stale
|
||||
*/
|
||||
refetchOnMount: true,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof NonRetryableError) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < QUERY_RETRY_COUNT;
|
||||
},
|
||||
/**
|
||||
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
|
||||
*/
|
||||
retryDelay: attemptIndex =>
|
||||
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
|
||||
},
|
||||
/**
|
||||
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
|
||||
*/
|
||||
retryDelay: attemptIndex =>
|
||||
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
QueryObserver,
|
||||
type QueryObserverOptions,
|
||||
@@ -20,7 +20,7 @@ export abstract class BaseQueryStore<TData, TError = Error> {
|
||||
#unsubscribe: () => void;
|
||||
|
||||
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
|
||||
this.#observer = new QueryObserver(queryClient, options);
|
||||
this.#observer = new QueryObserver(getQueryClient(), options);
|
||||
this.#unsubscribe = this.#observer.subscribe(result => {
|
||||
this.#result = result;
|
||||
});
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -1,360 +0,0 @@
|
||||
import {
|
||||
type PreparedTextWithSegments,
|
||||
layoutWithLines,
|
||||
prepareWithSegments,
|
||||
} from '@chenglou/pretext';
|
||||
|
||||
/**
|
||||
* Width of the character morph "halo" around the slider thumb, in percent
|
||||
* of container width. Characters within this window get partial blending
|
||||
* instead of a hard A→B flip.
|
||||
*/
|
||||
const CHAR_PROXIMITY_RANGE_PCT = 5;
|
||||
|
||||
/**
|
||||
* Default render size in px when callers omit the `size` arg on `layout()`.
|
||||
* Kept as a local constant to avoid pulling `$entities/Font` into
|
||||
* `$shared/lib` (would create an FSD-illegal upward import cycle).
|
||||
*/
|
||||
const DEFAULT_RENDER_SIZE_PX = 16;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Individual character metadata for both fonts in this line
|
||||
*/
|
||||
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 = '';
|
||||
#lastSpacing = 0;
|
||||
#lastSize = 0;
|
||||
|
||||
// Cached layout results
|
||||
#lastWidth = -1;
|
||||
#lastLineHeight = -1;
|
||||
#lastResult = $state<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).
|
||||
* @param spacing Letter spacing in em (from typography settings).
|
||||
* @param size Current font size in pixels (used to convert spacing em to px).
|
||||
* @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,
|
||||
spacing: number = 0,
|
||||
size: number = DEFAULT_RENDER_SIZE_PX,
|
||||
): ComparisonResult {
|
||||
if (!text) {
|
||||
return { lines: [], totalHeight: 0 };
|
||||
}
|
||||
|
||||
const spacingPx = spacing * size;
|
||||
|
||||
const isFontChange = text !== this.#lastText
|
||||
|| fontA !== this.#lastFontA
|
||||
|| fontB !== this.#lastFontB
|
||||
|| spacing !== this.#lastSpacing
|
||||
|| size !== this.#lastSize;
|
||||
|
||||
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, spacingPx);
|
||||
|
||||
this.#lastText = text;
|
||||
this.#lastFontA = fontA;
|
||||
this.#lastFontB = fontB;
|
||||
this.#lastSpacing = spacing;
|
||||
this.#lastSize = size;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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];
|
||||
let wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
|
||||
let wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
|
||||
|
||||
// Apply letter spacing (tracking) to the width of each character
|
||||
wA += spacingPx;
|
||||
wB += spacingPx;
|
||||
|
||||
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 states for an entire line in a single sequential pass.
|
||||
*
|
||||
* Walks characters left-to-right, accumulating the running x position using
|
||||
* each character's actual rendered width: `widthB` for already-morphed characters
|
||||
* (isPast=true) and `widthA` for upcoming ones. This ensures thresholds stay
|
||||
* aligned with the visual DOM layout even when the two fonts have different widths.
|
||||
*
|
||||
* @param line A single laid-out line from the last layout result.
|
||||
* @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`.
|
||||
* @param containerWidth Total container width in pixels.
|
||||
* @returns Per-character `proximity` and `isPast` in the same order as `line.chars`.
|
||||
*/
|
||||
getLineCharStates(
|
||||
line: ComparisonLine,
|
||||
sliderPos: number,
|
||||
containerWidth: number,
|
||||
): Array<{ proximity: number; isPast: boolean }> {
|
||||
if (!line) {
|
||||
return [];
|
||||
}
|
||||
const chars = line.chars;
|
||||
const n = chars.length;
|
||||
const sliderX = (sliderPos / 100) * containerWidth;
|
||||
const range = CHAR_PROXIMITY_RANGE_PCT;
|
||||
// Prefix sums of widthA (left chars will be past → use widthA).
|
||||
// Suffix sums of widthB (right chars will not be past → use widthB).
|
||||
// This lets us compute, for each char i, what the total line width and
|
||||
// char center would be at the exact moment the slider crosses that char:
|
||||
// left side (0..i-1) already past → font A widths
|
||||
// right side (i+1..n-1) not yet past → font B widths
|
||||
const prefA = new Float64Array(n + 1);
|
||||
const sufB = new Float64Array(n + 1);
|
||||
for (let i = 0; i < n; i++) {
|
||||
prefA[i + 1] = prefA[i] + chars[i].widthA;
|
||||
}
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
sufB[i] = sufB[i + 1] + chars[i].widthB;
|
||||
}
|
||||
// Per-char threshold: slider x at which this char should toggle isPast.
|
||||
const thresholds = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
|
||||
const xOffset = (containerWidth - totalWidth) / 2;
|
||||
thresholds[i] = xOffset + prefA[i] + chars[i].widthA / 2;
|
||||
}
|
||||
// Determine isPast for each char at the current slider position.
|
||||
const isPastArr = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
isPastArr[i] = sliderX > thresholds[i] ? 1 : 0;
|
||||
}
|
||||
// Compute visual positions based on actual rendered widths (font A if past, B if not).
|
||||
const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
|
||||
const xOffset = (containerWidth - totalRendered) / 2;
|
||||
let currentX = xOffset;
|
||||
|
||||
return chars.map((char, i) => {
|
||||
const isPast = isPastArr[i] === 1;
|
||||
const charWidth = isPast ? char.widthA : char.widthB;
|
||||
const visualCenter = currentX + charWidth / 2;
|
||||
const charGlobalPercent = (visualCenter / containerWidth) * 100;
|
||||
const distance = Math.abs(sliderPos - charGlobalPercent);
|
||||
const proximity = Math.max(0, 1 - distance / range);
|
||||
currentX += charWidth;
|
||||
return { proximity, isPast };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to merge two prepared texts into a "worst-case" unified version
|
||||
*/
|
||||
#createUnifiedPrepared(
|
||||
a: PreparedTextWithSegments,
|
||||
b: PreparedTextWithSegments,
|
||||
spacingPx: number = 0,
|
||||
): 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]) + spacingPx);
|
||||
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
|
||||
Math.max(w, intB.lineEndFitAdvances[i]) + spacingPx
|
||||
);
|
||||
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
|
||||
Math.max(w, intB.lineEndPaintAdvances[i]) + spacingPx
|
||||
);
|
||||
|
||||
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
|
||||
const advB = intB.breakableFitAdvances[i];
|
||||
if (!advA && !advB) {
|
||||
return null;
|
||||
}
|
||||
if (!advA) {
|
||||
return advB.map((w: number) => w + spacingPx);
|
||||
}
|
||||
if (!advB) {
|
||||
return advA.map((w: number) => w + spacingPx);
|
||||
}
|
||||
|
||||
return advA.map((w: number, j: number) => Math.max(w, advB[j]) + spacingPx);
|
||||
});
|
||||
|
||||
return unified;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user