Compare commits
336 Commits
993c63a39d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 30bbfa7e11 | |||
|
|
eff3979372 | ||
|
|
da79dd2e35 | ||
|
|
9d1f59d819 | ||
|
|
935b065843 | ||
|
|
d15b2ffe3f | ||
|
|
51ea8a9902 | ||
|
|
e81cadb32a | ||
|
|
1c3908f89e | ||
|
|
206e609a2d | ||
|
|
ff71d1c8c9 | ||
|
|
24ca2f6c41 | ||
|
|
3abe5723c7 | ||
| 4f181d1d92 | |||
|
|
aa4796079a | ||
|
|
f18454f9b3 | ||
|
|
e3924d43d8 | ||
|
|
0f6a4d6587 | ||
|
|
8f4faa3328 | ||
|
|
5867028be6 | ||
|
|
b8d019b824 | ||
|
|
45ed0d5601 | ||
|
|
9f91fed692 | ||
|
|
201280093f | ||
|
|
55b27973a2 | ||
|
|
5fa79e06e9 | ||
|
|
ee0749e828 | ||
|
|
5dae5fb7ea | ||
|
|
20f65ee396 | ||
|
|
010b8ad04b | ||
|
|
ce1dcd92ab | ||
|
|
ce609728c3 | ||
|
|
147df04c22 | ||
|
|
f356851d97 | ||
|
|
411dbfefcb | ||
|
|
a65d692139 | ||
|
|
3330f13228 | ||
|
|
ad6e1da292 | ||
|
|
ac8f0456b0 | ||
|
|
77668f507c | ||
|
|
23831efbe6 | ||
|
|
42854b4950 | ||
|
|
c45429f38d | ||
|
|
4d57f2084c | ||
|
|
bee529dff8 | ||
|
|
1f793278d1 | ||
|
|
4f76a03e33 | ||
|
|
940e20515b | ||
|
|
f15114a78b | ||
|
|
6ba37c9e4a | ||
|
|
858daff860 | ||
|
|
b7f54b503c | ||
|
|
17de544bdb | ||
|
|
a0ac52a348 | ||
|
|
99966d2de9 | ||
|
|
72334a3d05 | ||
|
|
8780b6932c | ||
|
|
5d2c05e192 | ||
|
|
1031b96ec5 | ||
|
|
4fdc99a15a | ||
|
|
9e74a2c2c6 | ||
|
|
aa3f467821 | ||
|
|
6001f50cf5 | ||
|
|
c2d0992015 | ||
|
|
bc56265717 | ||
|
|
2f45dc3620 | ||
|
|
d282448c53 | ||
|
|
f2e8de1d1d | ||
|
|
cee2a80c41 | ||
|
|
8b02333c01 | ||
|
|
0e85851cfd | ||
|
|
7dce7911c0 | ||
|
|
5e3929575d | ||
|
|
d3297d519f | ||
|
|
21d8273967 | ||
|
|
cdb2c355c0 | ||
|
|
3423eebf77 | ||
|
|
08d474289b | ||
|
|
2e6fc0e858 | ||
|
|
173816b5c0 | ||
|
|
d749f86edc | ||
|
|
8aad8942fc | ||
|
|
0eebe03bf8 | ||
|
|
2508168a3e | ||
|
|
a557e15759 | ||
|
|
a5b9238306 | ||
|
|
f01299f3d1 | ||
| 223dff2cda | |||
|
|
945132b6f5 | ||
|
|
e1117667d2 | ||
|
|
1c2fca784f | ||
|
|
3f0761aca7 | ||
|
|
0db13404e2 | ||
|
|
e39ed86a04 | ||
|
|
b43aa99f3e | ||
|
|
0a52bd6f6b | ||
|
|
4734b1120a | ||
|
|
7aa9fbd394 | ||
| 1eef9eff07 | |||
|
|
aefe03d811 | ||
|
|
e90b2bede5 | ||
|
|
bb8d2d685c | ||
|
|
c8d249d6ce | ||
| e3050097c6 | |||
|
|
faf9b8570b | ||
|
|
1fc9572f3d | ||
|
|
d006c662a9 | ||
|
|
422363d329 | ||
|
|
61c67acfb8 | ||
|
|
6945169279 | ||
|
|
055b02f720 | ||
|
|
7018b6a836 | ||
|
|
5d8869b3f2 | ||
|
|
cb740df1b2 | ||
|
|
d40170cfad | ||
|
|
3787ae260f | ||
|
|
a8858f6199 | ||
|
|
b1de03106f | ||
|
|
f3e9777267 | ||
|
|
c4abe84b0a | ||
|
|
1bd996659e | ||
|
|
e810135fc5 | ||
|
|
fc5a5c44e7 | ||
| d64de6f06b | |||
|
|
10788cf754 | ||
|
|
8eca240982 | ||
|
|
6f840fbad8 | ||
|
|
a7d08a9329 | ||
|
|
df2d6bae3b | ||
|
|
ce9665a842 | ||
|
|
b4e97da3a0 | ||
|
|
b3c0898735 | ||
|
|
f4875d7324 | ||
|
|
b16928ac80 | ||
|
|
7f01a9cc85 | ||
|
|
a1bc359c7f | ||
|
|
662d4ac626 | ||
|
|
4d7ae6c1c6 | ||
|
|
cb0e89b257 | ||
|
|
204aa75959 | ||
|
|
b72ec8afdf | ||
|
|
fa08986d60 | ||
|
|
359617212d | ||
|
|
beff194e5b | ||
|
|
f24c93c105 | ||
|
|
c16ef4acbf | ||
|
|
c91ced3617 | ||
|
|
a48c9bce0c | ||
|
|
152be85e34 | ||
|
|
b09b89f4fc | ||
|
|
1a23ec2f28 | ||
|
|
86ea9cd887 | ||
|
|
10919a9881 | ||
|
|
180abd150d | ||
|
|
c4bfb1db56 | ||
|
|
98a94e91ed | ||
|
|
a1b7f78fc4 | ||
|
|
41c5ceb848 | ||
|
|
780d76dced | ||
|
|
49f5564cc9 | ||
|
|
0ff8aec8f9 | ||
|
|
597ff7ec90 | ||
|
|
46a3c3e8fc | ||
|
|
4891cd3bbd | ||
|
|
70f2f82df0 | ||
|
|
0d572708c0 | ||
|
|
492c3573d0 | ||
|
|
a1080d3b34 | ||
|
|
fedf3f88e7 | ||
|
|
a26bcbecff | ||
|
|
352f30a558 | ||
|
|
8580884896 | ||
|
|
84417e440f | ||
| 8fda47ed57 | |||
|
|
1b9fe14f01 | ||
|
|
3537f6f62c | ||
|
|
88f4cd97f9 | ||
|
|
9167629616 | ||
|
|
b304e841de | ||
|
|
3ed63562b7 | ||
|
|
4b440496ba | ||
|
|
e4aacf609e | ||
|
|
51c2b6b5da | ||
|
|
195ae09fa2 | ||
|
|
b9eccbf627 | ||
|
|
63888e510c | ||
| cf8d3dffb9 | |||
|
|
1e2daa410c | ||
|
|
adf6dc93ea | ||
|
|
596a023d24 | ||
|
|
8195e9baa8 | ||
|
|
0554fcada7 | ||
|
|
9a794b626b | ||
|
|
40346aa9aa | ||
|
|
2b7f21711b | ||
|
|
69ae955131 | ||
|
|
12844432ac | ||
| a9aba10f09 | |||
|
|
778839d35e | ||
|
|
92fb314615 | ||
|
|
6f0b69ff45 | ||
|
|
2cd38797b9 | ||
|
|
6f231999e0 | ||
|
|
31a72d90ea | ||
|
|
072690270f | ||
|
|
eaf9d069c5 | ||
|
|
4a94f7bd09 | ||
|
|
918e792e41 | ||
|
|
c9c8b9abfc | ||
|
|
a392b575cc | ||
|
|
961475dea0 | ||
|
|
5496fd2680 | ||
|
|
f90f1e39e0 | ||
|
|
ca161dfbd4 | ||
|
|
ac2d0c32a4 | ||
|
|
54d22d650d | ||
|
|
a9c63f2544 | ||
|
|
70f57283a8 | ||
|
|
d43c873dc9 | ||
|
|
9501dbf281 | ||
|
|
0ac6acd174 | ||
|
|
5bb41c7e4c | ||
|
|
eed3339b0d | ||
|
|
d94e3cefb2 | ||
|
|
cfb586f539 | ||
|
|
6e975e5f8e | ||
|
|
142e4f0a19 | ||
|
|
59b85eead0 | ||
|
|
010643e398 | ||
|
|
27f637531b | ||
|
|
91fa08074b | ||
|
|
c246f70fe9 | ||
|
|
b1ce734f19 | ||
|
|
3add50a190 | ||
|
|
ef48d9815c | ||
|
|
818dfdb55e | ||
|
|
42e1271647 | ||
|
|
8ef9226dd2 | ||
|
|
f0c0a9de45 | ||
|
|
730eba138d | ||
|
|
18f265974e | ||
|
|
705723b009 | ||
|
|
75ea5ab382 | ||
|
|
f07b699926 | ||
|
|
b031e560af | ||
|
|
fbaf596fef | ||
|
|
1a2c44fb97 | ||
|
|
04602f0372 | ||
|
|
433fd2f7e6 | ||
|
|
87c4e04458 | ||
|
|
fb843c87af | ||
|
|
b2af3683bc | ||
|
|
90f11d8d16 | ||
|
|
a3f9bc12a0 | ||
|
|
6634f6df1e | ||
|
|
3f7ce63736 | ||
|
|
c665a579be | ||
|
|
ac7f094d13 | ||
|
|
c06aad1a8a | ||
|
|
471e186e70 | ||
|
|
dc72b9e048 | ||
|
|
07a37af71a | ||
|
|
d6607e5705 | ||
|
|
10801a641a | ||
|
|
98eab35615 | ||
|
|
7fbeef68e2 | ||
|
|
7078cb6f8c | ||
|
|
0b0489fa26 | ||
|
|
2022213921 | ||
|
|
6725a3b391 | ||
|
|
2eddb656a9 | ||
|
|
5973d241aa | ||
|
|
75a9c16070 | ||
|
|
31e4c64193 | ||
|
|
48e25fffa7 | ||
|
|
407c741349 | ||
|
|
13e114fafe | ||
|
|
1484ea024e | ||
|
|
67db6e22a7 | ||
|
|
192ce2d34a | ||
|
|
2b820230bc | ||
|
|
9b8ebed1c3 | ||
|
|
3d11f7317d | ||
|
|
c07800cc96 | ||
|
|
b49bf0d397 | ||
|
|
ed4ee8bb44 | ||
|
|
8a2059ac4a | ||
|
|
7ffc5d6a34 | ||
|
|
08cccc5ede | ||
|
|
71266f8b22 | ||
|
|
d5221ad449 | ||
|
|
873b697e8c | ||
|
|
3dce409034 | ||
|
|
cf08f7adfa | ||
|
|
4b01b1592d | ||
|
|
ecb4bea642 | ||
|
|
e89c6369cb | ||
|
|
18a311c6b1 | ||
|
|
732f77f504 | ||
|
|
b7992ca138 | ||
|
|
32b1367877 | ||
|
|
59b0d9c620 | ||
|
|
be13a5c8a0 | ||
|
|
80efa49ad0 | ||
|
|
7e9675be80 | ||
|
|
ac979c816c | ||
|
|
272c2c2d22 | ||
|
|
a9e2898945 | ||
|
|
1712134f64 | ||
|
|
52111ee941 | ||
|
|
e4970e43ba | ||
|
|
b41c48da67 | ||
|
|
1d0ca31262 | ||
|
|
a5380333eb | ||
|
|
46de3c6e87 | ||
|
|
91300bdc25 | ||
|
|
2ee66316f7 | ||
|
|
c6d20aae3d | ||
|
|
a0f184665d | ||
|
|
d4d2d68d9a | ||
|
|
55a560b785 | ||
|
|
c2542026a4 | ||
|
|
3f8fd357d8 | ||
|
|
1bd2a4f2f8 | ||
|
|
746a377038 | ||
|
|
1b76284237 | ||
|
|
b5ad3249ae | ||
| fb190f82b9 | |||
|
|
c0eed67618 | ||
|
|
e7f4304391 | ||
|
|
488857e0ec | ||
|
|
cca69a73ce | ||
|
|
2444e05bb7 | ||
|
|
72cc441c6f | ||
|
|
06cb155b47 | ||
|
|
50c7511698 |
@@ -42,3 +42,19 @@ jobs:
|
|||||||
|
|
||||||
- name: Type Check
|
- name: Type Check
|
||||||
run: yarn check:shadcn-excluded
|
run: yarn check:shadcn-excluded
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: build # Only runs if tests/lint pass
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main' # Only deploy from main branch
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
run: echo "${{ secrets.CI_DEPLOY_TOKEN }}" | docker login git.allmy.work -u ${{ gitea.repository_owner }} --password-stdin
|
||||||
|
|
||||||
|
- name: Build and Push Docker Image
|
||||||
|
run: |
|
||||||
|
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
|
||||||
|
docker push git.allmy.work/${{ gitea.repository }}:latest
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
/docs
|
/docs
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|||||||
29
.storybook/Decorator.svelte
Normal file
29
.storybook/Decorator.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!--
|
||||||
|
Component: Decorator
|
||||||
|
Global Storybook decorator that wraps all stories with necessary providers.
|
||||||
|
|
||||||
|
This provides:
|
||||||
|
- ResponsiveManager context for breakpoint tracking
|
||||||
|
- TooltipProvider for shadcn Tooltip components
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createResponsiveManager } from '$shared/lib';
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: import('svelte').Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
// Create and provide responsive context
|
||||||
|
const responsiveManager = createResponsiveManager();
|
||||||
|
$effect(() => responsiveManager.init());
|
||||||
|
setContext<ResponsiveManager>('responsive', responsiveManager);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
|
||||||
|
{@render children()}
|
||||||
|
</TooltipProvider>
|
||||||
16
.storybook/StoryStage.svelte
Normal file
16
.storybook/StoryStage.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
children: import('svelte').Snippet;
|
||||||
|
width?: string; // Optional width override
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, width = 'max-w-3xl' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
|
||||||
|
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {width}">
|
||||||
|
<div class="relative flex justify-center items-center text-foreground">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,16 @@
|
|||||||
import type { StorybookConfig } from '@storybook/svelte-vite';
|
import type { StorybookConfig } from '@storybook/svelte-vite';
|
||||||
|
import {
|
||||||
|
dirname,
|
||||||
|
resolve,
|
||||||
|
} from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import {
|
||||||
|
loadConfigFromFile,
|
||||||
|
mergeConfig,
|
||||||
|
} from 'vite';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
'stories': [
|
'stories': [
|
||||||
@@ -9,7 +21,8 @@ const config: StorybookConfig = {
|
|||||||
{
|
{
|
||||||
name: '@storybook/addon-svelte-csf',
|
name: '@storybook/addon-svelte-csf',
|
||||||
options: {
|
options: {
|
||||||
legacyTemplate: true, // Enables the legacy template syntax
|
// Use modern template syntax for better performance
|
||||||
|
legacyTemplate: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'@chromatic-com/storybook',
|
'@chromatic-com/storybook',
|
||||||
@@ -18,5 +31,17 @@ const config: StorybookConfig = {
|
|||||||
'@storybook/addon-docs',
|
'@storybook/addon-docs',
|
||||||
],
|
],
|
||||||
'framework': '@storybook/svelte-vite',
|
'framework': '@storybook/svelte-vite',
|
||||||
|
async viteFinal(config) {
|
||||||
|
// This attempts to find your actual vite.config.ts
|
||||||
|
const { config: userConfig } = await loadConfigFromFile(
|
||||||
|
{ command: 'serve', mode: 'development' },
|
||||||
|
resolve(__dirname, '../vite.config.ts'),
|
||||||
|
) || {};
|
||||||
|
|
||||||
|
return mergeConfig(config, {
|
||||||
|
// Merge only the resolve/alias parts if you want to be safe
|
||||||
|
resolve: userConfig?.resolve || {},
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
13
.storybook/preview-head.html
Normal file
13
.storybook/preview-head.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||||
|
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Preview } from '@storybook/svelte-vite';
|
import type { Preview } from '@storybook/svelte-vite';
|
||||||
|
import Decorator from './Decorator.svelte';
|
||||||
|
import StoryStage from './StoryStage.svelte';
|
||||||
import '../src/app/styles/app.css';
|
import '../src/app/styles/app.css';
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
parameters: {
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
@@ -17,7 +19,47 @@ const preview: Preview = {
|
|||||||
// 'off' - skip a11y checks entirely
|
// 'off' - skip a11y checks entirely
|
||||||
test: 'todo',
|
test: 'todo',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
docs: {
|
||||||
|
story: {
|
||||||
|
// This sets the default height for the iframe in Autodocs
|
||||||
|
iframeHeight: '400px',
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
head: `
|
||||||
|
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||||
|
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
decorators: [
|
||||||
|
// Wrap with providers (TooltipProvider, ResponsiveManager)
|
||||||
|
story => ({
|
||||||
|
Component: Decorator,
|
||||||
|
props: {
|
||||||
|
children: story(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Wrap with StoryStage for presentation styling
|
||||||
|
story => ({
|
||||||
|
Component: StoryStage,
|
||||||
|
props: {
|
||||||
|
children: story(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
|
|||||||
5
Caddyfile
Normal file
5
Caddyfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
:3000 {
|
||||||
|
root * /usr/share/caddy
|
||||||
|
file_server
|
||||||
|
try_files {path} /index.html
|
||||||
|
}
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
# Enable Corepack so we can use Yarn v4
|
||||||
|
RUN corepack enable && corepack prepare yarn@stable --activate
|
||||||
|
# Force Yarn to use node_modules instead of PnP
|
||||||
|
ENV YARN_NODE_LINKER=node-modules
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install --immutable
|
||||||
|
COPY . .
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# Production stage - Caddy
|
||||||
|
FROM caddy:2-alpine
|
||||||
|
|
||||||
|
WORKDIR /usr/share/caddy
|
||||||
|
|
||||||
|
# Copy built static files from the builder stage
|
||||||
|
COPY --from=builder /app/dist .
|
||||||
|
|
||||||
|
# Copy our local Caddyfile config
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start caddy using the config file
|
||||||
|
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
112
README.md
112
README.md
@@ -1,37 +1,38 @@
|
|||||||
# GlyphDiff
|
# GlyphDiff
|
||||||
|
|
||||||
A modern, high-performance font exploration tool for browsing and comparing fonts from Google Fonts and Fontshare.
|
A modern font exploration and comparison tool for browsing fonts from Google Fonts and Fontshare with real-time visual comparisons, advanced filtering, and customizable typography.
|
||||||
|
|
||||||
## ✨ Features
|
## Features
|
||||||
|
|
||||||
- **Multi-Provider Support**: Access fonts from Google Fonts and Fontshare in one place
|
- **Multi-Provider Catalog**: Browse fonts from Google Fonts and Fontshare in one place
|
||||||
- **Fast Virtual Scrolling**: Handles thousands of fonts smoothly with custom virtualization
|
- **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings
|
||||||
- **Advanced Filtering**: Filter by category, provider, and character subsets
|
- **Advanced Filtering**: Filter by category, provider, character subsets, and weight
|
||||||
- **Responsive Design**: Beautiful UI built with shadcn components and Tailwind CSS
|
- **Virtual Scrolling**: Fast, smooth browsing of thousands of fonts
|
||||||
|
- **Responsive UI**: Beautiful interface built with shadcn components and Tailwind CSS
|
||||||
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
|
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend**: Svelte 5 with runes (reactive primitives)
|
- **Framework**: Svelte 5 with reactive primitives (runes)
|
||||||
- **Styling**: Tailwind CSS v4 + shadcn-svelte components
|
- **Styling**: Tailwind CSS v4
|
||||||
- **Data Fetching**: TanStack Query for caching and state management
|
- **Components**: shadcn-svelte (via bits-ui)
|
||||||
- **Architecture**: Feature-Sliced Design (FSD) methodology
|
- **State Management**: TanStack Query for async data
|
||||||
- **Testing**: Playwright (E2E), Vitest (unit), Storybook (components)
|
- **Architecture**: Feature-Sliced Design (FSD)
|
||||||
- **Quality**: Oxlint (linting), Dprint (formatting), Lefthook (git hooks)
|
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)
|
||||||
|
|
||||||
## 📁 Architecture
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/ # App shell, layout, providers
|
├── app/ # App shell, layout, providers
|
||||||
├── widgets/ # Composed UI blocks
|
├── widgets/ # Composed UI blocks (ComparisonSlider, SampleList, FontSearch)
|
||||||
├── features/ # Business features (filters, search)
|
├── features/ # Business features (filters, search, display)
|
||||||
├── entities/ # Domain entities (Font models, stores)
|
├── entities/ # Domain models and stores (Font, Breadcrumb)
|
||||||
├── shared/ # Reusable utilities, UI components, helpers
|
├── shared/ # Reusable utilities, UI components, helpers
|
||||||
└── routes/ # Page-level components
|
└── routes/ # Page-level components
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
@@ -40,81 +41,38 @@ yarn install
|
|||||||
# Start development server
|
# Start development server
|
||||||
yarn dev
|
yarn dev
|
||||||
|
|
||||||
# Open in browser
|
# Build for production
|
||||||
yarn dev -- --open
|
yarn build
|
||||||
|
|
||||||
|
# Preview production build
|
||||||
|
yarn preview
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📦 Available Scripts
|
## Available Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ---------------- | -------------------------- |
|
| ------------------- | -------------------------- |
|
||||||
| `yarn dev` | Start development server |
|
| `yarn dev` | Start development server |
|
||||||
| `yarn build` | Build for production |
|
| `yarn build` | Build for production |
|
||||||
| `yarn preview` | Preview production build |
|
| `yarn preview` | Preview production build |
|
||||||
| `yarn check` | Run Svelte type checking |
|
| `yarn check` | Run Svelte type checking |
|
||||||
| `yarn lint` | Run oxlint |
|
| `yarn lint` | Run oxlint |
|
||||||
| `yarn format` | Format with dprint |
|
| `yarn format` | Format code with dprint |
|
||||||
| `yarn test` | Run all tests (E2E + unit) |
|
| `yarn test:unit` | Run unit tests |
|
||||||
| `yarn test:e2e` | Run Playwright E2E tests |
|
| `yarn test:unit:ui` | Run Vitest UI |
|
||||||
| `yarn test:unit` | Run Vitest unit tests |
|
|
||||||
| `yarn storybook` | Start Storybook dev server |
|
| `yarn storybook` | Start Storybook dev server |
|
||||||
|
|
||||||
## 🧪 Development
|
## Code Style
|
||||||
|
|
||||||
### Type Checking
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn check # Single run
|
|
||||||
yarn check:watch # Watch mode
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn test:unit # Unit tests
|
|
||||||
yarn test:unit:watch # Watch mode
|
|
||||||
yarn test:unit:ui # Vitest UI
|
|
||||||
yarn test:e2e # E2E tests with Playwright
|
|
||||||
yarn test:e2e --ui # Interactive test runner
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn lint # Lint code
|
|
||||||
yarn format # Format code
|
|
||||||
yarn format:check # Check formatting
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Key Components
|
|
||||||
|
|
||||||
- **VirtualList**: Custom high-performance list virtualization using Svelte 5 runes
|
|
||||||
- **FontList**: Displays fonts with loading, empty, and error states
|
|
||||||
- **FilterControls**: Multi-filter system for category, provider, and subsets
|
|
||||||
- **TypographyControl**: Dynamic typography adjustment controls
|
|
||||||
|
|
||||||
## 📝 Code Style
|
|
||||||
|
|
||||||
- **Path Aliases**: Use `$app/`, `$shared/`, `$features/`, `$entities/`, `$widgets/`, `$routes/`
|
- **Path Aliases**: Use `$app/`, `$shared/`, `$features/`, `$entities/`, `$widgets/`, `$routes/`
|
||||||
- **Components**: PascalCase (e.g., `CheckboxFilter.svelte`)
|
- **Components**: PascalCase (e.g., `ComparisonSlider.svelte`)
|
||||||
- **Formatting**: 100 char line width, 4-space indent, single quotes
|
- **Formatting**: 100 char line width, 4-space indent, single quotes
|
||||||
- **Imports**: Auto-sorted by dprint, separated by blank line
|
- **Type Safety**: Strict TypeScript with JSDoc comments for public APIs
|
||||||
- **Type Safety**: Strict TypeScript, JSDoc comments for public APIs
|
|
||||||
|
|
||||||
## 🏗️ Building for Production
|
## Architecture Notes
|
||||||
|
|
||||||
```bash
|
This project follows the Feature-Sliced Design (FSD) methodology for clean separation of concerns. The application uses Svelte 5's new runes system (`$state`, `$derived`, `$effect`) for reactive state management.
|
||||||
yarn build
|
|
||||||
yarn preview
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 Learn More
|
## License
|
||||||
|
|
||||||
- [Svelte 5 Documentation](https://svelte-5-preview.vercel.app/docs)
|
|
||||||
- [Feature-Sliced Design](https://feature-sliced.design)
|
|
||||||
- [Tailwind CSS v4](https://tailwindcss.com/blog/tailwindcss-v4-alpha)
|
|
||||||
- [shadcn-svelte](https://www.shadcn-svelte.com)
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
||||||
],
|
],
|
||||||
"typescript": {
|
"typescript": {
|
||||||
"lineWidth": 100,
|
"lineWidth": 120,
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"semiColons": "prefer",
|
"semiColons": "prefer",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"lineWidth": 100
|
"lineWidth": 100
|
||||||
},
|
},
|
||||||
"markup": {
|
"markup": {
|
||||||
"printWidth": 100,
|
"printWidth": 120,
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"quotes": "double",
|
"quotes": "double",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"vaul-svelte": "^1.0.0-next.7",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16",
|
||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
|
<!--
|
||||||
|
Component: QueryProvider
|
||||||
|
Provides a QueryClientProvider for child components.
|
||||||
|
|
||||||
|
All components that use useQueryClient() or createQuery() must be
|
||||||
|
descendants of this provider.
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
|
||||||
* Query Provider Component
|
|
||||||
*
|
|
||||||
* All components that use useQueryClient() or createQuery() must be
|
|
||||||
* descendants of this provider.
|
|
||||||
*/
|
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
/** Slot content for child components */
|
interface Props {
|
||||||
let { children } = $props();
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -37,6 +37,28 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
|
|
||||||
|
--background-20: oklch(1 0 0 / 20%);
|
||||||
|
--background-40: oklch(1 0 0 / 40%);
|
||||||
|
--background-60: oklch(1 0 0 / 60%);
|
||||||
|
--background-80: oklch(1 0 0 / 80%);
|
||||||
|
--background-95: oklch(1 0 0 / 95%);
|
||||||
|
--background-subtle: oklch(0.98 0 0);
|
||||||
|
--background-muted: oklch(0.97 0.002 286.375);
|
||||||
|
|
||||||
|
--text-muted: oklch(0.552 0.016 285.938);
|
||||||
|
--text-subtle: oklch(0.705 0.015 286.067);
|
||||||
|
--text-soft: oklch(0.5 0.01 286);
|
||||||
|
|
||||||
|
--border-subtle: oklch(0.95 0.003 286.32);
|
||||||
|
--border-muted: oklch(0.92 0.004 286.32);
|
||||||
|
--border-soft: oklch(0.88 0.005 286.32);
|
||||||
|
|
||||||
|
--gradient-from: oklch(0.98 0.002 286.32);
|
||||||
|
--gradient-via: oklch(1 0 0);
|
||||||
|
--gradient-to: oklch(0.98 0.002 286.32);
|
||||||
|
|
||||||
|
--font-mono: 'Major Mono Display';
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -71,6 +93,26 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
|
|
||||||
|
--background-20: oklch(0.21 0.006 285.885 / 20%);
|
||||||
|
--background-40: oklch(0.21 0.006 285.885 / 40%);
|
||||||
|
--background-60: oklch(0.21 0.006 285.885 / 60%);
|
||||||
|
--background-80: oklch(0.21 0.006 285.885 / 80%);
|
||||||
|
--background-95: oklch(0.21 0.006 285.885 / 95%);
|
||||||
|
--background-subtle: oklch(0.18 0.005 285.823);
|
||||||
|
--background-muted: oklch(0.274 0.006 286.033);
|
||||||
|
|
||||||
|
--text-muted: oklch(0.705 0.015 286.067);
|
||||||
|
--text-subtle: oklch(0.552 0.016 285.938);
|
||||||
|
--text-soft: oklch(0.8 0.01 286);
|
||||||
|
|
||||||
|
--border-subtle: oklch(1 0 0 / 8%);
|
||||||
|
--border-muted: oklch(1 0 0 / 10%);
|
||||||
|
--border-soft: oklch(1 0 0 / 15%);
|
||||||
|
|
||||||
|
--gradient-from: oklch(0.25 0.005 285.885);
|
||||||
|
--gradient-via: oklch(0.21 0.006 285.885);
|
||||||
|
--gradient-to: oklch(0.25 0.005 285.885);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -109,6 +151,24 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-background-20: var(--background-20);
|
||||||
|
--color-background-40: var(--background-40);
|
||||||
|
--color-background-60: var(--background-60);
|
||||||
|
--color-background-80: var(--background-80);
|
||||||
|
--color-background-95: var(--background-95);
|
||||||
|
--color-background-subtle: var(--background-subtle);
|
||||||
|
--color-background-muted: var(--background-muted);
|
||||||
|
--color-text-muted: var(--text-muted);
|
||||||
|
--color-text-subtle: var(--text-subtle);
|
||||||
|
--color-text-soft: var(--text-soft);
|
||||||
|
--color-border-subtle: var(--border-subtle);
|
||||||
|
--color-border-muted: var(--border-muted);
|
||||||
|
--color-border-soft: var(--border-soft);
|
||||||
|
--color-gradient-from: var(--gradient-from);
|
||||||
|
--color-gradient-via: var(--gradient-via);
|
||||||
|
--color-gradient-to: var(--gradient-to);
|
||||||
|
--font-mono: 'Major Mono Display', monospace;
|
||||||
|
--font-sans: 'Karla', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -117,6 +177,8 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||||
|
font-optical-sizing: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,3 +200,108 @@
|
|||||||
.peer:focus-visible ~ * {
|
.peer:focus-visible ~ * {
|
||||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes nudge {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0) scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
2% {
|
||||||
|
transform: translateY(-2px) scale(1.1) rotate(-1deg);
|
||||||
|
}
|
||||||
|
4% {
|
||||||
|
transform: translateY(0) scale(1) rotate(1deg);
|
||||||
|
}
|
||||||
|
6% {
|
||||||
|
transform: translateY(-2px) scale(1.1) rotate(0deg);
|
||||||
|
}
|
||||||
|
8% {
|
||||||
|
transform: translateY(0) scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-nudge {
|
||||||
|
animation: nudge 10s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barlow {
|
||||||
|
font-family: "Barlow", system-ui, Inter, Roboto, "Segoe UI", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(0 0% 70% / 0.4) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark * {
|
||||||
|
scrollbar-color: hsl(0 0% 40% / 0.5) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Webkit / Blink ---- */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(0 0% 70% / 0);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show thumb when container is hovered or actively scrolling */
|
||||||
|
:hover > ::-webkit-scrollbar-thumb,
|
||||||
|
::-webkit-scrollbar-thumb:hover,
|
||||||
|
*:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(0 0% 70% / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(0 0% 50% / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:active {
|
||||||
|
background: hsl(0 0% 40% / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(0 0% 40% / 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark :hover > ::-webkit-scrollbar-thumb,
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover,
|
||||||
|
.dark *:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(0 0% 40% / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(0 0% 55% / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:active {
|
||||||
|
background: hsl(0 0% 65% / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Behavior ---- */
|
||||||
|
* {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|||||||
13
src/app/types/ambient.d.ts
vendored
13
src/app/types/ambient.d.ts
vendored
@@ -35,3 +35,16 @@ declare module '*.jpg' {
|
|||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly DEV: boolean;
|
||||||
|
readonly PROD: boolean;
|
||||||
|
readonly MODE: string;
|
||||||
|
// Add other env variables you use
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,47 +3,105 @@
|
|||||||
* Layout Component
|
* Layout Component
|
||||||
*
|
*
|
||||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
||||||
* sidebar provider initialization, and renders child routes with consistent structure.
|
* toolbar provider initialization, and renders child routes with consistent structure.
|
||||||
*
|
*
|
||||||
* Layout structure:
|
* Layout structure:
|
||||||
* - Header area (currently empty, reserved for future use)
|
* - Header area (currently empty, reserved for future use)
|
||||||
* - Collapsible sidebar with main content area
|
|
||||||
* - Footer area (currently empty, reserved for future use)
|
|
||||||
*
|
*
|
||||||
* Uses Sidebar.Provider to enable mobile-responsive collapsible sidebar behavior
|
* - Footer area (currently empty, reserved for future use)
|
||||||
* throughout the application.
|
|
||||||
*/
|
*/
|
||||||
import favicon from '$shared/assets/favicon.svg';
|
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
||||||
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
|
import GD from '$shared/assets/GD.svg';
|
||||||
import { FiltersSidebar } from '$widgets/FiltersSidebar';
|
import { ResponsiveProvider } from '$shared/lib';
|
||||||
import TypographyMenu from '$widgets/TypographySettings/ui/TypographyMenu.svelte';
|
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||||
|
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||||
|
import {
|
||||||
|
type Snippet,
|
||||||
|
onMount,
|
||||||
|
} from 'svelte';
|
||||||
|
|
||||||
/** Slot content for route pages to render */
|
interface Props {
|
||||||
let { children } = $props();
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
let fontsReady = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets fontsReady flag to true when font for the page logo is loaded.
|
||||||
|
*/
|
||||||
|
onMount(async () => {
|
||||||
|
if (!('fonts' in document)) {
|
||||||
|
fontsReady = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = ['100'];
|
||||||
|
|
||||||
|
const missing = required.filter(
|
||||||
|
w => !document.fonts.check(`${w} 1em Barlow`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
missing.map(w => document.fonts.load(`${w} 1em Barlow`)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fontsReady = true;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={GD} />
|
||||||
|
|
||||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||||
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href="https://cdn.fontshare.com"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href="https://fonts.gstatic.com"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
as="style"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||||
|
media="print"
|
||||||
|
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
|
||||||
|
/>
|
||||||
|
<noscript>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||||
|
/>
|
||||||
|
</noscript>
|
||||||
|
<title>Compare Typography & Typefaces | GlyphDiff</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div id="app-root">
|
<ResponsiveProvider>
|
||||||
<header></header>
|
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
||||||
|
<header>
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
</header>
|
||||||
|
|
||||||
<Sidebar.Provider>
|
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||||
<FiltersSidebar />
|
<main class="flex-1 w-full mx-auto px-4 pt-0 pb-10 sm:px-6 sm:pt-8 sm:pb-12 md:px-8 md:pt-10 md:pb-16 lg:px-10 lg:pt-12 lg:pb-20 xl:px-16 relative">
|
||||||
<main class="w-dvw">
|
<TooltipProvider>
|
||||||
<TypographyMenu />
|
{#if fontsReady}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
{/if}
|
||||||
|
</TooltipProvider>
|
||||||
</main>
|
</main>
|
||||||
</Sidebar.Provider>
|
<!-- </ScrollArea> -->
|
||||||
<footer></footer>
|
<footer></footer>
|
||||||
</div>
|
</div>
|
||||||
|
</ResponsiveProvider>
|
||||||
<style>
|
|
||||||
#app-root {
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
2
src/entities/Breadcrumb/index.ts
Normal file
2
src/entities/Breadcrumb/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { scrollBreadcrumbsStore } from './model';
|
||||||
|
export { BreadcrumbHeader } from './ui';
|
||||||
1
src/entities/Breadcrumb/model/index.ts
Normal file
1
src/entities/Breadcrumb/model/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './store/scrollBreadcrumbsStore.svelte';
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
/**
|
||||||
|
* Index of the item to display
|
||||||
|
*/
|
||||||
|
index: number;
|
||||||
|
/**
|
||||||
|
* ID of the item to navigate to
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
|
/**
|
||||||
|
* Title snippet to render
|
||||||
|
*/
|
||||||
|
title: Snippet<[{ className?: string }]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollBreadcrumbsStore {
|
||||||
|
#items = $state<BreadcrumbItem[]>([]);
|
||||||
|
|
||||||
|
get items() {
|
||||||
|
// Keep them sorted by index for Swiss orderliness
|
||||||
|
return this.#items.sort((a, b) => a.index - b.index);
|
||||||
|
}
|
||||||
|
add(item: BreadcrumbItem) {
|
||||||
|
if (!this.#items.find(i => i.index === item.index)) {
|
||||||
|
this.#items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remove(index: number) {
|
||||||
|
this.#items = this.#items.filter(i => i.index !== index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScrollBreadcrumbsStore() {
|
||||||
|
return new ScrollBreadcrumbsStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!--
|
||||||
|
Component: BreadcrumbHeader
|
||||||
|
Fixed header for breadcrumbs navigation for sections in the page
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { smoothScroll } from '$shared/lib';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import {
|
||||||
|
fly,
|
||||||
|
slide,
|
||||||
|
} from 'svelte/transition';
|
||||||
|
import { scrollBreadcrumbsStore } from '../../model';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if scrollBreadcrumbsStore.items.length > 0}
|
||||||
|
<div
|
||||||
|
transition:slide={{ duration: 200 }}
|
||||||
|
class="
|
||||||
|
fixed top-0 left-0 right-0 z-100
|
||||||
|
backdrop-blur-lg bg-background-20
|
||||||
|
border-b border-border-muted
|
||||||
|
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||||
|
h-10 sm:h-12
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="max-w-8xl mx-auto px-4 sm:px-6 h-full flex items-center gap-2 sm:gap-4">
|
||||||
|
<h1 class={cn('barlow font-extralight text-sm sm:text-base')}>
|
||||||
|
GLYPHDIFF
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="h-3.5 sm:h-4 w-px bg-border-subtle hidden sm:block"></div>
|
||||||
|
|
||||||
|
<nav class="flex items-center gap-2 sm:gap-3 overflow-x-auto scrollbar-hide flex-1">
|
||||||
|
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
|
||||||
|
<div
|
||||||
|
in:fly={{ duration: 300, y: -10, x: 100, opacity: 0 }}
|
||||||
|
out:fly={{ duration: 300, y: 10, x: 100, opacity: 0 }}
|
||||||
|
class="flex items-center gap-2 sm:gap-3 whitespace-nowrap shrink-0"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-[8px] sm:text-[9px] text-text-muted tracking-wider">
|
||||||
|
{String(item.index).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<a href={`#${item.id}`} use:smoothScroll>
|
||||||
|
{@render item.title({
|
||||||
|
className: 'text-[9px] sm:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-foreground',
|
||||||
|
})}</a>
|
||||||
|
|
||||||
|
{#if idx < scrollBreadcrumbsStore.items.length - 1}
|
||||||
|
<div class="flex items-center gap-0.5 opacity-40">
|
||||||
|
<div class="w-1 h-px bg-text-muted"></div>
|
||||||
|
<div class="w-1 h-px bg-text-muted"></div>
|
||||||
|
<div class="w-1 h-px bg-text-muted"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1.5 sm:gap-2 opacity-50 ml-auto">
|
||||||
|
<div class="w-px h-2 sm:h-2.5 bg-border-subtle hidden sm:block"></div>
|
||||||
|
<span class="font-mono text-[7px] sm:text-[8px] text-text-muted tracking-wider">
|
||||||
|
[{scrollBreadcrumbsStore.items.length}]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
src/entities/Breadcrumb/ui/index.ts
Normal file
3
src/entities/Breadcrumb/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte';
|
||||||
|
|
||||||
|
export { BreadcrumbHeader };
|
||||||
@@ -4,6 +4,18 @@
|
|||||||
* Exports API clients and normalization utilities
|
* Exports API clients and normalization utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Proxy API (PRIMARY - NEW)
|
||||||
|
export {
|
||||||
|
fetchFontsByIds,
|
||||||
|
fetchProxyFontById,
|
||||||
|
fetchProxyFonts,
|
||||||
|
} from './proxy/proxyFonts';
|
||||||
|
export type {
|
||||||
|
ProxyFontsParams,
|
||||||
|
ProxyFontsResponse,
|
||||||
|
} from './proxy/proxyFonts';
|
||||||
|
|
||||||
|
// Google Fonts API (DEPRECATED - kept for backward compatibility)
|
||||||
export {
|
export {
|
||||||
fetchGoogleFontFamily,
|
fetchGoogleFontFamily,
|
||||||
fetchGoogleFonts,
|
fetchGoogleFonts,
|
||||||
@@ -14,6 +26,7 @@ export type {
|
|||||||
GoogleFontsResponse,
|
GoogleFontsResponse,
|
||||||
} from './google/googleFonts';
|
} from './google/googleFonts';
|
||||||
|
|
||||||
|
// Fontshare API (DEPRECATED - kept for backward compatibility)
|
||||||
export {
|
export {
|
||||||
fetchAllFontshareFonts,
|
fetchAllFontshareFonts,
|
||||||
fetchFontshareFontBySlug,
|
fetchFontshareFontBySlug,
|
||||||
|
|||||||
279
src/entities/Font/api/proxy/proxyFonts.ts
Normal file
279
src/entities/Font/api/proxy/proxyFonts.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* Proxy API client
|
||||||
|
*
|
||||||
|
* Handles API requests to GlyphDiff proxy API for fetching font metadata.
|
||||||
|
* Provides error handling, pagination support, and type-safe responses.
|
||||||
|
*
|
||||||
|
* Proxy API normalizes font data from Google Fonts and Fontshare into a single
|
||||||
|
* unified format, eliminating the need for client-side normalization.
|
||||||
|
*
|
||||||
|
* Fallback: If proxy API fails, falls back to Fontshare API for development.
|
||||||
|
*
|
||||||
|
* @see https://api.glyphdiff.com/api/v1/fonts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '$shared/api/api';
|
||||||
|
import { buildQueryString } from '$shared/lib/utils';
|
||||||
|
import type { QueryParams } from '$shared/lib/utils';
|
||||||
|
import type { UnifiedFont } from '../../model/types';
|
||||||
|
import type {
|
||||||
|
FontCategory,
|
||||||
|
FontSubset,
|
||||||
|
} from '../../model/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy API base URL
|
||||||
|
*/
|
||||||
|
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to use proxy API (true) or fallback (false)
|
||||||
|
*
|
||||||
|
* Set to true when your proxy API is ready:
|
||||||
|
* const USE_PROXY_API = true;
|
||||||
|
*
|
||||||
|
* Set to false to use Fontshare API as fallback during development:
|
||||||
|
* const USE_PROXY_API = false;
|
||||||
|
*
|
||||||
|
* The app will automatically fall back to Fontshare API if the proxy fails.
|
||||||
|
*/
|
||||||
|
const USE_PROXY_API = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy API parameters
|
||||||
|
*
|
||||||
|
* Maps directly to the proxy API query parameters
|
||||||
|
*/
|
||||||
|
export interface ProxyFontsParams extends QueryParams {
|
||||||
|
/**
|
||||||
|
* Font provider filter ("google" or "fontshare")
|
||||||
|
* Omit to fetch from both providers
|
||||||
|
*/
|
||||||
|
provider?: 'google' | 'fontshare';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font category filter
|
||||||
|
*/
|
||||||
|
category?: FontCategory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character subset filter
|
||||||
|
*/
|
||||||
|
subset?: FontSubset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search query (e.g., "roboto", "satoshi")
|
||||||
|
*/
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort order for results
|
||||||
|
* "name" - Alphabetical by font name
|
||||||
|
* "popularity" - Most popular first
|
||||||
|
* "lastModified" - Recently updated first
|
||||||
|
*/
|
||||||
|
sort?: 'name' | 'popularity' | 'lastModified';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of items to return (pagination)
|
||||||
|
*/
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of items to skip (pagination)
|
||||||
|
* Use for pagination: offset = (page - 1) * limit
|
||||||
|
*/
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy API response
|
||||||
|
*
|
||||||
|
* Includes pagination metadata alongside font data
|
||||||
|
*/
|
||||||
|
export interface ProxyFontsResponse {
|
||||||
|
/** Array of unified font objects */
|
||||||
|
fonts: UnifiedFont[];
|
||||||
|
|
||||||
|
/** Total number of fonts matching the query */
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
/** Limit used for this request */
|
||||||
|
limit: number;
|
||||||
|
|
||||||
|
/** Offset used for this request */
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch fonts from proxy API
|
||||||
|
*
|
||||||
|
* If proxy API fails or is unavailable, falls back to Fontshare API for development.
|
||||||
|
*
|
||||||
|
* @param params - Query parameters for filtering and pagination
|
||||||
|
* @returns Promise resolving to proxy API response
|
||||||
|
* @throws ApiError when request fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Fetch all sans-serif fonts from Google
|
||||||
|
* const response = await fetchProxyFonts({
|
||||||
|
* provider: 'google',
|
||||||
|
* category: 'sans-serif',
|
||||||
|
* limit: 50,
|
||||||
|
* offset: 0
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Search fonts across all providers
|
||||||
|
* const searchResponse = await fetchProxyFonts({
|
||||||
|
* q: 'roboto',
|
||||||
|
* limit: 20
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Fetch fonts with pagination
|
||||||
|
* const page1 = await fetchProxyFonts({ limit: 50, offset: 0 });
|
||||||
|
* const page2 = await fetchProxyFonts({ limit: 50, offset: 50 });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function fetchProxyFonts(
|
||||||
|
params: ProxyFontsParams = {},
|
||||||
|
): Promise<ProxyFontsResponse> {
|
||||||
|
// Try proxy API first if enabled
|
||||||
|
if (USE_PROXY_API) {
|
||||||
|
try {
|
||||||
|
const queryString = buildQueryString(params);
|
||||||
|
const url = `${PROXY_API_URL}${queryString}`;
|
||||||
|
|
||||||
|
console.log('[fetchProxyFonts] Fetching from proxy API', { params, url });
|
||||||
|
|
||||||
|
const response = await api.get<ProxyFontsResponse>(url);
|
||||||
|
|
||||||
|
// Validate response has fonts array
|
||||||
|
if (!response.data || !Array.isArray(response.data.fonts)) {
|
||||||
|
console.error('[fetchProxyFonts] Invalid response from proxy API', response.data);
|
||||||
|
throw new Error('Proxy API returned invalid response');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[fetchProxyFonts] Proxy API success', {
|
||||||
|
count: response.data.fonts.length,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[fetchProxyFonts] Proxy API failed, using fallback', error);
|
||||||
|
|
||||||
|
// Check if it's a network error or proxy not available
|
||||||
|
const isNetworkError = error instanceof Error
|
||||||
|
&& (error.message.includes('Failed to fetch')
|
||||||
|
|| error.message.includes('Network')
|
||||||
|
|| error.message.includes('404')
|
||||||
|
|| error.message.includes('500'));
|
||||||
|
|
||||||
|
if (isNetworkError) {
|
||||||
|
// Fall back to Fontshare API
|
||||||
|
console.log('[fetchProxyFonts] Using Fontshare API as fallback');
|
||||||
|
return await fetchFontshareFallback(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw other errors
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch fonts from proxy API: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Fontshare API directly
|
||||||
|
console.log('[fetchProxyFonts] Using Fontshare API (proxy disabled)');
|
||||||
|
return await fetchFontshareFallback(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback to Fontshare API when proxy is unavailable
|
||||||
|
*
|
||||||
|
* Maps proxy API params to Fontshare API params and normalizes response
|
||||||
|
*/
|
||||||
|
async function fetchFontshareFallback(
|
||||||
|
params: ProxyFontsParams,
|
||||||
|
): Promise<ProxyFontsResponse> {
|
||||||
|
// Import dynamically to avoid circular dependency
|
||||||
|
const { fetchFontshareFonts } = await import('$entities/Font/api/fontshare/fontshare');
|
||||||
|
const { normalizeFontshareFonts } = await import('$entities/Font/lib/normalize/normalize');
|
||||||
|
|
||||||
|
// Map proxy params to Fontshare params
|
||||||
|
const fontshareParams = {
|
||||||
|
q: params.q,
|
||||||
|
categories: params.category ? [params.category] : undefined,
|
||||||
|
page: params.offset ? Math.floor(params.offset / (params.limit || 50)) + 1 : undefined,
|
||||||
|
limit: params.limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetchFontshareFonts(fontshareParams);
|
||||||
|
const normalizedFonts = normalizeFontshareFonts(response.fonts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fonts: normalizedFonts,
|
||||||
|
total: response.count_total,
|
||||||
|
limit: params.limit || response.count,
|
||||||
|
offset: params.offset || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch font by ID
|
||||||
|
*
|
||||||
|
* Convenience function for fetching a single font by ID
|
||||||
|
* Note: This fetches a page and filters client-side, which is not ideal
|
||||||
|
* For production, consider adding a dedicated endpoint to the proxy API
|
||||||
|
*
|
||||||
|
* @param id - Font ID (family name for Google, slug for Fontshare)
|
||||||
|
* @returns Promise resolving to font or undefined
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const roboto = await fetchProxyFontById('Roboto');
|
||||||
|
* const satoshi = await fetchProxyFontById('satoshi');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function fetchProxyFontById(
|
||||||
|
id: string,
|
||||||
|
): Promise<UnifiedFont | undefined> {
|
||||||
|
const response = await fetchProxyFonts({ limit: 1000, q: id });
|
||||||
|
|
||||||
|
if (!response || !response.fonts) {
|
||||||
|
console.error('[fetchProxyFontById] No fonts in response', { response });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.fonts.find(font => font.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch multiple fonts by their IDs
|
||||||
|
*
|
||||||
|
* @param ids - Array of font IDs to fetch
|
||||||
|
* @returns Promise resolving to an array of fonts
|
||||||
|
*/
|
||||||
|
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
// Use proxy API if enabled
|
||||||
|
if (USE_PROXY_API) {
|
||||||
|
const queryString = ids.join(',');
|
||||||
|
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get<UnifiedFont[]>(url);
|
||||||
|
return response.data ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[fetchFontsByIds] Proxy API batch fetch failed, falling back', error);
|
||||||
|
// Fallthrough to fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Fetch individually (not efficient but functional for fallback)
|
||||||
|
const results = await Promise.all(
|
||||||
|
ids.map(id => fetchProxyFontById(id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.filter((f): f is UnifiedFont => !!f);
|
||||||
|
}
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
|
// Proxy API (PRIMARY)
|
||||||
|
export {
|
||||||
|
fetchFontsByIds,
|
||||||
|
fetchProxyFontById,
|
||||||
|
fetchProxyFonts,
|
||||||
|
} from './api/proxy/proxyFonts';
|
||||||
|
export type {
|
||||||
|
ProxyFontsParams,
|
||||||
|
ProxyFontsResponse,
|
||||||
|
} from './api/proxy/proxyFonts';
|
||||||
|
|
||||||
|
// Fontshare API (DEPRECATED)
|
||||||
export {
|
export {
|
||||||
fetchAllFontshareFonts,
|
fetchAllFontshareFonts,
|
||||||
fetchFontshareFontBySlug,
|
fetchFontshareFontBySlug,
|
||||||
@@ -7,6 +19,8 @@ export type {
|
|||||||
FontshareParams,
|
FontshareParams,
|
||||||
FontshareResponse,
|
FontshareResponse,
|
||||||
} from './api/fontshare/fontshare';
|
} from './api/fontshare/fontshare';
|
||||||
|
|
||||||
|
// Google Fonts API (DEPRECATED)
|
||||||
export {
|
export {
|
||||||
fetchGoogleFontFamily,
|
fetchGoogleFontFamily,
|
||||||
fetchGoogleFonts,
|
fetchGoogleFonts,
|
||||||
@@ -42,7 +56,6 @@ export type {
|
|||||||
FontshareFont,
|
FontshareFont,
|
||||||
FontshareLink,
|
FontshareLink,
|
||||||
FontsharePublisher,
|
FontsharePublisher,
|
||||||
FontshareStore,
|
|
||||||
FontshareStyle,
|
FontshareStyle,
|
||||||
FontshareStyleProperties,
|
FontshareStyleProperties,
|
||||||
FontshareTag,
|
FontshareTag,
|
||||||
@@ -61,17 +74,59 @@ export type {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
createFontshareStore,
|
createUnifiedFontStore,
|
||||||
fetchFontshareFontsQuery,
|
unifiedFontStore,
|
||||||
fontshareStore,
|
|
||||||
selectedFontsStore,
|
|
||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
// Stores
|
// Mock data helpers for Storybook and testing
|
||||||
export {
|
export {
|
||||||
createGoogleFontsStore,
|
createCategoriesFilter,
|
||||||
GoogleFontsStore,
|
createErrorState,
|
||||||
} from './model/services/fetchGoogleFonts.svelte';
|
createGenericFilter,
|
||||||
|
createLoadingState,
|
||||||
|
createMockComparisonStore,
|
||||||
|
// Filter mocks
|
||||||
|
createMockFilter,
|
||||||
|
createMockFontApiResponse,
|
||||||
|
createMockFontStoreState,
|
||||||
|
// Store mocks
|
||||||
|
createMockQueryState,
|
||||||
|
createMockReactiveState,
|
||||||
|
createMockStore,
|
||||||
|
createProvidersFilter,
|
||||||
|
createSubsetsFilter,
|
||||||
|
createSuccessState,
|
||||||
|
FONTHARE_FONTS,
|
||||||
|
generateMixedCategoryFonts,
|
||||||
|
generateMockFonts,
|
||||||
|
generatePaginatedFonts,
|
||||||
|
generateSequentialFilter,
|
||||||
|
GENERIC_FILTERS,
|
||||||
|
getAllMockFonts,
|
||||||
|
getFontsByCategory,
|
||||||
|
getFontsByProvider,
|
||||||
|
GOOGLE_FONTS,
|
||||||
|
MOCK_FILTERS,
|
||||||
|
MOCK_FILTERS_ALL_SELECTED,
|
||||||
|
MOCK_FILTERS_EMPTY,
|
||||||
|
MOCK_FILTERS_SELECTED,
|
||||||
|
MOCK_FONT_STORE_STATES,
|
||||||
|
MOCK_STORES,
|
||||||
|
type MockFilterOptions,
|
||||||
|
type MockFilters,
|
||||||
|
mockFontshareFont,
|
||||||
|
type MockFontshareFontOptions,
|
||||||
|
type MockFontStoreState,
|
||||||
|
// Font mocks
|
||||||
|
mockGoogleFont,
|
||||||
|
// Types
|
||||||
|
type MockGoogleFontOptions,
|
||||||
|
type MockQueryObserverResult,
|
||||||
|
type MockQueryState,
|
||||||
|
mockUnifiedFont,
|
||||||
|
type MockUnifiedFontOptions,
|
||||||
|
UNIFIED_FONTS,
|
||||||
|
} from './lib/mocks';
|
||||||
|
|
||||||
// UI elements
|
// UI elements
|
||||||
export {
|
export {
|
||||||
|
|||||||
592
src/entities/Font/lib/getFontUrl/getFontUrl.test.ts
Normal file
592
src/entities/Font/lib/getFontUrl/getFontUrl.test.ts
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import type { UnifiedFont } from '../../model/types';
|
||||||
|
import { getFontUrl } from './getFontUrl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a minimal UnifiedFont mock for testing
|
||||||
|
*/
|
||||||
|
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('getFontUrl', () => {
|
||||||
|
describe('basic logic', () => {
|
||||||
|
it('returns URL for exact weight match in variants', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
'700': 'https://example.com/font-700.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-400.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns URL for weight 700', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'700': 'https://example.com/font-700.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-700.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns URL for weight 100 (lightest)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'100': 'https://example.com/font-100.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 100);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-100.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns URL for weight 900 (boldest)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'900': 'https://example.com/font-900.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 900);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-900.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns URL for variable font (backend maps weight to VF URL)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-variable.woff2',
|
||||||
|
'700': 'https://example.com/font-variable.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result400 = getFontUrl(font, 400);
|
||||||
|
const result700 = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result400).toBe('https://example.com/font-variable.woff2');
|
||||||
|
expect(result700).toBe('https://example.com/font-variable.woff2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fallback logic', () => {
|
||||||
|
it('falls back to regular when exact weight not found', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
regular: 'https://example.com/font-regular.woff2',
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to variant 400 when exact weight and regular not found', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-400.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to variant regular when exact weight, regular, and 400 not found', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'700': 'https://example.com/font-700.woff2',
|
||||||
|
'regular': 'https://example.com/font-regular.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers regular over variants.400 for fallback', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
regular: 'https://example.com/font-regular.woff2',
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when no fallback options available', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'700': 'https://example.com/font-700.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for font with empty styles', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for font with undefined styles (invalid font data)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: undefined as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 400)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('handles font with only regular URL (legacy format)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
regular: 'https://example.com/font-regular.woff2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles font with only variants object', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
'700': 'https://example.com/font-700.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result400 = getFontUrl(font, 400);
|
||||||
|
const result700 = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result400).toBe('https://example.com/font-400.woff2');
|
||||||
|
expect(result700).toBe('https://example.com/font-700.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles font with variants but no requested weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-400.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Google Fonts style with legacy URLs', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||||
|
bold: 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Fontshare fonts with multiple weights', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'100': 'https://cdn.fontshare.com/wf/font-100.woff2',
|
||||||
|
'200': 'https://cdn.fontshare.com/wf/font-200.woff2',
|
||||||
|
'300': 'https://cdn.fontshare.com/wf/font-300.woff2',
|
||||||
|
'400': 'https://cdn.fontshare.com/wf/font-400.woff2',
|
||||||
|
'500': 'https://cdn.fontshare.com/wf/font-500.woff2',
|
||||||
|
'600': 'https://cdn.fontshare.com/wf/font-600.woff2',
|
||||||
|
'700': 'https://cdn.fontshare.com/wf/font-700.woff2',
|
||||||
|
'800': 'https://cdn.fontshare.com/wf/font-800.woff2',
|
||||||
|
'900': 'https://cdn.fontshare.com/wf/font-900.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test all valid weights
|
||||||
|
for (const weight of [100, 200, 300, 400, 500, 600, 700, 800, 900]) {
|
||||||
|
const result = getFontUrl(font, weight);
|
||||||
|
expect(result).toBe(`https://cdn.fontshare.com/wf/font-${weight}.woff2`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles font with partial weight coverage', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-regular.woff2',
|
||||||
|
'700': 'https://example.com/font-bold.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result400 = getFontUrl(font, 400);
|
||||||
|
const result700 = getFontUrl(font, 700);
|
||||||
|
const result500 = getFontUrl(font, 500);
|
||||||
|
|
||||||
|
expect(result400).toBe('https://example.com/font-regular.woff2');
|
||||||
|
expect(result700).toBe('https://example.com/font-bold.woff2');
|
||||||
|
expect(result500).toBe('https://example.com/font-regular.woff2'); // Fallback
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles font with variants.regular as fallback', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'700': 'https://example.com/font-bold.woff2',
|
||||||
|
'regular': 'https://example.com/font-regular.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty variants object', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when variant URL is null and no fallback available', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': null as any,
|
||||||
|
'700': 'https://example.com/font-bold.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
// null is falsy, so it falls back to regular, 400, and then regular variant
|
||||||
|
// All are undefined, so returns undefined
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('boundary tests', () => {
|
||||||
|
it('handles lowest valid weight (100)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'100': 'https://example.com/font-100.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 100);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-100.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles highest valid weight (900)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'900': 'https://example.com/font-900.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 900);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-900.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles middle weight (500)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'500': 'https://example.com/font-500.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 500);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-500.woff2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalid weights', () => {
|
||||||
|
it('throws error for weight below 100', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 99)).toThrow('Invalid weight: 99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for weight above 900', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 901)).toThrow('Invalid weight: 901');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for weight 0', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 0)).toThrow('Invalid weight: 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for negative weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, -100)).toThrow('Invalid weight: -100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for non-numeric weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore - Testing invalid input type
|
||||||
|
expect(() => getFontUrl(font, '400' as any)).toThrow('Invalid weight: 400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for decimal weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 450.5)).toThrow('Invalid weight: 450.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for weight with step of 50 (not supported)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 450)).toThrow('Invalid weight: 450');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for weight with step of 10 (not supported)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 410)).toThrow('Invalid weight: 410');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for NaN weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, NaN)).toThrow('Invalid weight: NaN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for Infinity weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, Infinity)).toThrow('Invalid weight: Infinity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws descriptive error message', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
getFontUrl(font, 999);
|
||||||
|
expect.fail('Expected function to throw');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toBe('Invalid weight: 999');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('provider-specific tests', () => {
|
||||||
|
it('handles Google Fonts with variable fonts', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
provider: 'google',
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||||
|
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result400 = getFontUrl(font, 400);
|
||||||
|
const result700 = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
// Variable fonts return the same URL for all weights
|
||||||
|
expect(result400).toBe(result700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Fontshare fonts with static weights', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
provider: 'fontshare',
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://cdn.fontshare.com/wf/satoshi-regular.woff2',
|
||||||
|
'700': 'https://cdn.fontshare.com/wf/satoshi-bold.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result400 = getFontUrl(font, 400);
|
||||||
|
const result700 = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result400).toBe('https://cdn.fontshare.com/wf/satoshi-regular.woff2');
|
||||||
|
expect(result700).toBe('https://cdn.fontshare.com/wf/satoshi-bold.woff2');
|
||||||
|
expect(result400).not.toBe(result700);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('all valid weights test', () => {
|
||||||
|
it('handles all valid weight values', () => {
|
||||||
|
const validWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||||
|
|
||||||
|
validWeights.forEach(weight => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
[weight.toString()]: `https://example.com/font-${weight}.woff2`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, weight);
|
||||||
|
expect(result).toBe(`https://example.com/font-${weight}.woff2`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/entities/Font/lib/getFontUrl/getFontUrl.ts
Normal file
29
src/entities/Font/lib/getFontUrl/getFontUrl.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type {
|
||||||
|
FontWeight,
|
||||||
|
UnifiedFont,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
|
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a URL for a font based on the provided font and weight.
|
||||||
|
* @param font - The font object.
|
||||||
|
* @param weight - The weight of the font.
|
||||||
|
* @returns The URL for the font.
|
||||||
|
*/
|
||||||
|
export function getFontUrl(font: UnifiedFont, weight: number): string | undefined {
|
||||||
|
if (!SIZES.includes(weight)) {
|
||||||
|
throw new Error(`Invalid weight: ${weight}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightKey = weight.toString() as FontWeight;
|
||||||
|
|
||||||
|
// 1. Try exact match (Backend now maps "100".."900" to VF URL if variable)
|
||||||
|
if (font.styles.variants?.[weightKey]) {
|
||||||
|
return font.styles.variants[weightKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallbacks for Static Fonts (if exact weight missing)
|
||||||
|
// Try 'regular' or '400' as safe defaults
|
||||||
|
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
|
||||||
|
}
|
||||||
@@ -4,3 +4,55 @@ export {
|
|||||||
normalizeGoogleFont,
|
normalizeGoogleFont,
|
||||||
normalizeGoogleFonts,
|
normalizeGoogleFonts,
|
||||||
} from './normalize/normalize';
|
} from './normalize/normalize';
|
||||||
|
|
||||||
|
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,
|
||||||
|
FONTHARE_FONTS,
|
||||||
|
generateMixedCategoryFonts,
|
||||||
|
generateMockFonts,
|
||||||
|
generatePaginatedFonts,
|
||||||
|
generateSequentialFilter,
|
||||||
|
GENERIC_FILTERS,
|
||||||
|
getAllMockFonts,
|
||||||
|
getFontsByCategory,
|
||||||
|
getFontsByProvider,
|
||||||
|
GOOGLE_FONTS,
|
||||||
|
MOCK_FILTERS,
|
||||||
|
MOCK_FILTERS_ALL_SELECTED,
|
||||||
|
MOCK_FILTERS_EMPTY,
|
||||||
|
MOCK_FILTERS_SELECTED,
|
||||||
|
MOCK_FONT_STORE_STATES,
|
||||||
|
MOCK_STORES,
|
||||||
|
type MockFilterOptions,
|
||||||
|
type MockFilters,
|
||||||
|
mockFontshareFont,
|
||||||
|
type MockFontshareFontOptions,
|
||||||
|
type MockFontStoreState,
|
||||||
|
// Font mocks
|
||||||
|
mockGoogleFont,
|
||||||
|
// Types
|
||||||
|
type MockGoogleFontOptions,
|
||||||
|
type MockQueryObserverResult,
|
||||||
|
type MockQueryState,
|
||||||
|
mockUnifiedFont,
|
||||||
|
type MockUnifiedFontOptions,
|
||||||
|
UNIFIED_FONTS,
|
||||||
|
} from './mocks';
|
||||||
|
|||||||
348
src/entities/Font/lib/mocks/filters.mock.ts
Normal file
348
src/entities/Font/lib/mocks/filters.mock.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* MOCK FONT FILTER DATA
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Factory functions and preset mock data for font-related filters.
|
||||||
|
* Used in Storybook stories for font filtering components.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import {
|
||||||
|
* createMockFilter,
|
||||||
|
* MOCK_FILTERS,
|
||||||
|
* } from '$entities/Font/lib/mocks';
|
||||||
|
*
|
||||||
|
* // Create a custom filter
|
||||||
|
* const customFilter = createMockFilter({
|
||||||
|
* properties: [
|
||||||
|
* { id: 'option1', name: 'Option 1', value: 'option1' },
|
||||||
|
* { id: 'option2', name: 'Option 2', value: 'option2', selected: true },
|
||||||
|
* ],
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Use preset filters
|
||||||
|
* const categoriesFilter = MOCK_FILTERS.categories;
|
||||||
|
* const subsetsFilter = MOCK_FILTERS.subsets;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FontCategory,
|
||||||
|
FontProvider,
|
||||||
|
FontSubset,
|
||||||
|
} from '$entities/Font/model/types';
|
||||||
|
import type { Property } from '$shared/lib';
|
||||||
|
import { createFilter } from '$shared/lib';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPE DEFINITIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a mock filter
|
||||||
|
*/
|
||||||
|
export interface MockFilterOptions {
|
||||||
|
/** Filter properties */
|
||||||
|
properties: Property<string>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset mock filters for font filtering
|
||||||
|
*/
|
||||||
|
export interface MockFilters {
|
||||||
|
/** Provider filter (Google, Fontshare) */
|
||||||
|
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
|
||||||
|
/** Category filter (sans-serif, serif, display, etc.) */
|
||||||
|
categories: ReturnType<typeof createFilter<FontCategory>>;
|
||||||
|
/** Subset filter (latin, latin-ext, cyrillic, etc.) */
|
||||||
|
subsets: ReturnType<typeof createFilter<FontSubset>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FONT CATEGORIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Fonts categories
|
||||||
|
*/
|
||||||
|
export const GOOGLE_CATEGORIES: Property<'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'>[] = [
|
||||||
|
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
|
||||||
|
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||||
|
{ id: 'display', name: 'Display', value: 'display' },
|
||||||
|
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
||||||
|
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fontshare categories (mapped to common naming)
|
||||||
|
*/
|
||||||
|
export const FONTHARE_CATEGORIES: Property<'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script'>[] = [
|
||||||
|
{ id: 'sans', name: 'Sans', value: 'sans' },
|
||||||
|
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||||
|
{ id: 'slab', name: 'Slab', value: 'slab' },
|
||||||
|
{ id: 'display', name: 'Display', value: 'display' },
|
||||||
|
{ id: 'handwritten', name: 'Handwritten', value: 'handwritten' },
|
||||||
|
{ id: 'script', name: 'Script', value: 'script' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified categories (combines both providers)
|
||||||
|
*/
|
||||||
|
export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
|
||||||
|
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
|
||||||
|
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||||
|
{ id: 'display', name: 'Display', value: 'display' },
|
||||||
|
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
||||||
|
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FONT SUBSETS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common font subsets
|
||||||
|
*/
|
||||||
|
export const FONT_SUBSETS: Property<FontSubset>[] = [
|
||||||
|
{ id: 'latin', name: 'Latin', value: 'latin' },
|
||||||
|
{ id: 'latin-ext', name: 'Latin Extended', value: 'latin-ext' },
|
||||||
|
{ id: 'cyrillic', name: 'Cyrillic', value: 'cyrillic' },
|
||||||
|
{ id: 'greek', name: 'Greek', value: 'greek' },
|
||||||
|
{ id: 'arabic', name: 'Arabic', value: 'arabic' },
|
||||||
|
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FONT PROVIDERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font providers
|
||||||
|
*/
|
||||||
|
export const FONT_PROVIDERS: Property<FontProvider>[] = [
|
||||||
|
{ id: 'google', name: 'Google Fonts', value: 'google' },
|
||||||
|
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FILTER FACTORIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock filter from properties
|
||||||
|
*/
|
||||||
|
export function createMockFilter<TValue extends string>(
|
||||||
|
options: MockFilterOptions & { properties: Property<TValue>[] },
|
||||||
|
) {
|
||||||
|
return createFilter<TValue>(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock filter for categories
|
||||||
|
*/
|
||||||
|
export function createCategoriesFilter(options?: { selected?: FontCategory[] }) {
|
||||||
|
const properties = UNIFIED_CATEGORIES.map(cat => ({
|
||||||
|
...cat,
|
||||||
|
selected: options?.selected?.includes(cat.value) ?? false,
|
||||||
|
}));
|
||||||
|
return createFilter<FontCategory>({ properties });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock filter for subsets
|
||||||
|
*/
|
||||||
|
export function createSubsetsFilter(options?: { selected?: FontSubset[] }) {
|
||||||
|
const properties = FONT_SUBSETS.map(subset => ({
|
||||||
|
...subset,
|
||||||
|
selected: options?.selected?.includes(subset.value) ?? false,
|
||||||
|
}));
|
||||||
|
return createFilter<FontSubset>({ properties });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock filter for providers
|
||||||
|
*/
|
||||||
|
export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
|
||||||
|
const properties = FONT_PROVIDERS.map(provider => ({
|
||||||
|
...provider,
|
||||||
|
selected: options?.selected?.includes(provider.value) ?? false,
|
||||||
|
}));
|
||||||
|
return createFilter<FontProvider>({ properties });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PRESET FILTERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset mock filters - use these directly in stories
|
||||||
|
*/
|
||||||
|
export const MOCK_FILTERS: MockFilters = {
|
||||||
|
providers: createFilter({
|
||||||
|
properties: FONT_PROVIDERS,
|
||||||
|
}),
|
||||||
|
categories: createFilter({
|
||||||
|
properties: UNIFIED_CATEGORIES,
|
||||||
|
}),
|
||||||
|
subsets: createFilter({
|
||||||
|
properties: FONT_SUBSETS,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset filters with some items selected
|
||||||
|
*/
|
||||||
|
export const MOCK_FILTERS_SELECTED: MockFilters = {
|
||||||
|
providers: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ ...FONT_PROVIDERS[0], selected: true },
|
||||||
|
{ ...FONT_PROVIDERS[1] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
categories: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ ...UNIFIED_CATEGORIES[0], selected: true },
|
||||||
|
{ ...UNIFIED_CATEGORIES[1], selected: true },
|
||||||
|
{ ...UNIFIED_CATEGORIES[2] },
|
||||||
|
{ ...UNIFIED_CATEGORIES[3] },
|
||||||
|
{ ...UNIFIED_CATEGORIES[4] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
subsets: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ ...FONT_SUBSETS[0], selected: true },
|
||||||
|
{ ...FONT_SUBSETS[1] },
|
||||||
|
{ ...FONT_SUBSETS[2] },
|
||||||
|
{ ...FONT_SUBSETS[3] },
|
||||||
|
{ ...FONT_SUBSETS[4] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty filters (all properties, none selected)
|
||||||
|
*/
|
||||||
|
export const MOCK_FILTERS_EMPTY: MockFilters = {
|
||||||
|
providers: createFilter({
|
||||||
|
properties: FONT_PROVIDERS.map(p => ({ ...p, selected: false })),
|
||||||
|
}),
|
||||||
|
categories: createFilter({
|
||||||
|
properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: false })),
|
||||||
|
}),
|
||||||
|
subsets: createFilter({
|
||||||
|
properties: FONT_SUBSETS.map(s => ({ ...s, selected: false })),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All selected filters
|
||||||
|
*/
|
||||||
|
export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
|
||||||
|
providers: createFilter({
|
||||||
|
properties: FONT_PROVIDERS.map(p => ({ ...p, selected: true })),
|
||||||
|
}),
|
||||||
|
categories: createFilter({
|
||||||
|
properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: true })),
|
||||||
|
}),
|
||||||
|
subsets: createFilter({
|
||||||
|
properties: FONT_SUBSETS.map(s => ({ ...s, selected: true })),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GENERIC FILTER MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock filter with generic string properties
|
||||||
|
* Useful for testing generic filter components
|
||||||
|
*/
|
||||||
|
export function createGenericFilter(
|
||||||
|
items: Array<{ id: string; name: string; selected?: boolean }>,
|
||||||
|
options?: { selected?: string[] },
|
||||||
|
) {
|
||||||
|
const properties = items.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
value: item.id,
|
||||||
|
selected: options?.selected?.includes(item.id) ?? item.selected ?? false,
|
||||||
|
}));
|
||||||
|
return createFilter({ properties });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset generic filters for testing
|
||||||
|
*/
|
||||||
|
export const GENERIC_FILTERS = {
|
||||||
|
/** Small filter with 3 items */
|
||||||
|
small: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ id: 'option-1', name: 'Option 1', value: 'option-1' },
|
||||||
|
{ id: 'option-2', name: 'Option 2', value: 'option-2' },
|
||||||
|
{ id: 'option-3', name: 'Option 3', value: 'option-3' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/** Medium filter with 6 items */
|
||||||
|
medium: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ id: 'alpha', name: 'Alpha', value: 'alpha' },
|
||||||
|
{ id: 'beta', name: 'Beta', value: 'beta' },
|
||||||
|
{ id: 'gamma', name: 'Gamma', value: 'gamma' },
|
||||||
|
{ id: 'delta', name: 'Delta', value: 'delta' },
|
||||||
|
{ id: 'epsilon', name: 'Epsilon', value: 'epsilon' },
|
||||||
|
{ id: 'zeta', name: 'Zeta', value: 'zeta' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/** Large filter with 12 items */
|
||||||
|
large: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ id: 'jan', name: 'January', value: 'jan' },
|
||||||
|
{ id: 'feb', name: 'February', value: 'feb' },
|
||||||
|
{ id: 'mar', name: 'March', value: 'mar' },
|
||||||
|
{ id: 'apr', name: 'April', value: 'apr' },
|
||||||
|
{ id: 'may', name: 'May', value: 'may' },
|
||||||
|
{ id: 'jun', name: 'June', value: 'jun' },
|
||||||
|
{ id: 'jul', name: 'July', value: 'jul' },
|
||||||
|
{ id: 'aug', name: 'August', value: 'aug' },
|
||||||
|
{ id: 'sep', name: 'September', value: 'sep' },
|
||||||
|
{ id: 'oct', name: 'October', value: 'oct' },
|
||||||
|
{ id: 'nov', name: 'November', value: 'nov' },
|
||||||
|
{ id: 'dec', name: 'December', value: 'dec' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/** Filter with some pre-selected items */
|
||||||
|
partial: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ id: 'red', name: 'Red', value: 'red', selected: true },
|
||||||
|
{ id: 'blue', name: 'Blue', value: 'blue', selected: false },
|
||||||
|
{ id: 'green', name: 'Green', value: 'green', selected: true },
|
||||||
|
{ id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/** Filter with all items selected */
|
||||||
|
allSelected: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ id: 'cat', name: 'Cat', value: 'cat', selected: true },
|
||||||
|
{ id: 'dog', name: 'Dog', value: 'dog', selected: true },
|
||||||
|
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/** Empty filter (no items) */
|
||||||
|
empty: createFilter({
|
||||||
|
properties: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a filter with sequential items
|
||||||
|
*/
|
||||||
|
export function generateSequentialFilter(count: number, prefix = 'Item ') {
|
||||||
|
const properties = Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: `item-${i + 1}`,
|
||||||
|
name: `${prefix}${i + 1}`,
|
||||||
|
value: `item-${i + 1}`,
|
||||||
|
}));
|
||||||
|
return createFilter({ properties });
|
||||||
|
}
|
||||||
630
src/entities/Font/lib/mocks/fonts.mock.ts
Normal file
630
src/entities/Font/lib/mocks/fonts.mock.ts
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* MOCK FONT DATA
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Factory functions and preset mock data for fonts.
|
||||||
|
* Used in Storybook stories, tests, and development.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import {
|
||||||
|
* mockGoogleFont,
|
||||||
|
* mockFontshareFont,
|
||||||
|
* mockUnifiedFont,
|
||||||
|
* GOOGLE_FONTS,
|
||||||
|
* FONTHARE_FONTS,
|
||||||
|
* UNIFIED_FONTS,
|
||||||
|
* } from '$entities/Font/lib/mocks';
|
||||||
|
*
|
||||||
|
* // Create a mock Google Font
|
||||||
|
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
|
||||||
|
*
|
||||||
|
* // Create a mock Fontshare font
|
||||||
|
* const satoshi = mockFontshareFont({ name: 'Satoshi', slug: 'satoshi' });
|
||||||
|
*
|
||||||
|
* // Create a mock UnifiedFont
|
||||||
|
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
|
||||||
|
*
|
||||||
|
* // Use preset fonts
|
||||||
|
* import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FontCategory,
|
||||||
|
FontProvider,
|
||||||
|
FontSubset,
|
||||||
|
FontVariant,
|
||||||
|
} from '$entities/Font/model/types';
|
||||||
|
import type {
|
||||||
|
FontItem,
|
||||||
|
FontshareFont,
|
||||||
|
GoogleFontItem,
|
||||||
|
} from '$entities/Font/model/types';
|
||||||
|
import type {
|
||||||
|
FontFeatures,
|
||||||
|
FontMetadata,
|
||||||
|
FontStyleUrls,
|
||||||
|
UnifiedFont,
|
||||||
|
} from '$entities/Font/model/types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GOOGLE FONTS MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a mock Google Font
|
||||||
|
*/
|
||||||
|
export interface MockGoogleFontOptions {
|
||||||
|
/** Font family name (default: 'Mock Font') */
|
||||||
|
family?: string;
|
||||||
|
/** Font category (default: 'sans-serif') */
|
||||||
|
category?: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
||||||
|
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
||||||
|
variants?: FontVariant[];
|
||||||
|
/** Font subsets (default: ['latin']) */
|
||||||
|
subsets?: string[];
|
||||||
|
/** Font version (default: 'v30') */
|
||||||
|
version?: string;
|
||||||
|
/** Last modified date (default: current ISO date) */
|
||||||
|
lastModified?: string;
|
||||||
|
/** Custom file URLs (if not provided, mock URLs are generated) */
|
||||||
|
files?: Partial<Record<FontVariant, string>>;
|
||||||
|
/** Popularity rank (1 = most popular) */
|
||||||
|
popularity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default mock Google Font
|
||||||
|
*/
|
||||||
|
export function mockGoogleFont(options: MockGoogleFontOptions = {}): GoogleFontItem {
|
||||||
|
const {
|
||||||
|
family = 'Mock Font',
|
||||||
|
category = 'sans-serif',
|
||||||
|
variants = ['regular', '700', 'italic', '700italic'],
|
||||||
|
subsets = ['latin'],
|
||||||
|
version = 'v30',
|
||||||
|
lastModified = new Date().toISOString().split('T')[0],
|
||||||
|
files,
|
||||||
|
popularity = 1,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const baseUrl = `https://fonts.gstatic.com/s/${family.toLowerCase().replace(/\s+/g, '')}/${version}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
family,
|
||||||
|
category,
|
||||||
|
variants: variants as FontVariant[],
|
||||||
|
subsets,
|
||||||
|
version,
|
||||||
|
lastModified,
|
||||||
|
files: files ?? {
|
||||||
|
regular: `${baseUrl}/KFOmCnqEu92Fr1Me4W.woff2`,
|
||||||
|
'700': `${baseUrl}/KFOlCnqEu92Fr1MmWUlfBBc9.woff2`,
|
||||||
|
italic: `${baseUrl}/KFOkCnqEu92Fr1Mu51xIIzI.woff2`,
|
||||||
|
'700italic': `${baseUrl}/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2`,
|
||||||
|
},
|
||||||
|
menu: `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset Google Font mocks
|
||||||
|
*/
|
||||||
|
export const GOOGLE_FONTS: Record<string, GoogleFontItem> = {
|
||||||
|
roboto: mockGoogleFont({
|
||||||
|
family: 'Roboto',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['100', '300', '400', '500', '700', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
||||||
|
popularity: 1,
|
||||||
|
}),
|
||||||
|
openSans: mockGoogleFont({
|
||||||
|
family: 'Open Sans',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['300', '400', '500', '600', '700', '800', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
||||||
|
popularity: 2,
|
||||||
|
}),
|
||||||
|
lato: mockGoogleFont({
|
||||||
|
family: 'Lato',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['100', '300', '400', '700', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext'],
|
||||||
|
popularity: 3,
|
||||||
|
}),
|
||||||
|
playfairDisplay: mockGoogleFont({
|
||||||
|
family: 'Playfair Display',
|
||||||
|
category: 'serif',
|
||||||
|
variants: ['400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic'],
|
||||||
|
popularity: 10,
|
||||||
|
}),
|
||||||
|
montserrat: mockGoogleFont({
|
||||||
|
family: 'Montserrat',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||||
|
popularity: 4,
|
||||||
|
}),
|
||||||
|
sourceSansPro: mockGoogleFont({
|
||||||
|
family: 'Source Sans Pro',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['200', '300', '400', '600', '700', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
||||||
|
popularity: 5,
|
||||||
|
}),
|
||||||
|
merriweather: mockGoogleFont({
|
||||||
|
family: 'Merriweather',
|
||||||
|
category: 'serif',
|
||||||
|
variants: ['300', '400', '700', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||||
|
popularity: 15,
|
||||||
|
}),
|
||||||
|
robotoSlab: mockGoogleFont({
|
||||||
|
family: 'Roboto Slab',
|
||||||
|
category: 'serif',
|
||||||
|
variants: ['100', '300', '400', '500', '700', '900'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
||||||
|
popularity: 8,
|
||||||
|
}),
|
||||||
|
oswald: mockGoogleFont({
|
||||||
|
family: 'Oswald',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['200', '300', '400', '500', '600', '700'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'vietnamese'],
|
||||||
|
popularity: 6,
|
||||||
|
}),
|
||||||
|
raleway: mockGoogleFont({
|
||||||
|
family: 'Raleway',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||||
|
popularity: 7,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FONTHARE MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a mock Fontshare font
|
||||||
|
*/
|
||||||
|
export interface MockFontshareFontOptions {
|
||||||
|
/** Font name (default: 'Mock Font') */
|
||||||
|
name?: string;
|
||||||
|
/** URL-friendly slug (default: derived from name) */
|
||||||
|
slug?: string;
|
||||||
|
/** Font category (default: 'sans') */
|
||||||
|
category?: 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script' | 'mono';
|
||||||
|
/** Script (default: 'latin') */
|
||||||
|
script?: string;
|
||||||
|
/** Whether this is a variable font (default: false) */
|
||||||
|
isVariable?: boolean;
|
||||||
|
/** Font version (default: '1.0') */
|
||||||
|
version?: string;
|
||||||
|
/** Popularity/views count (default: 1000) */
|
||||||
|
views?: number;
|
||||||
|
/** Usage tags */
|
||||||
|
tags?: string[];
|
||||||
|
/** Font weights available */
|
||||||
|
weights?: number[];
|
||||||
|
/** Publisher name */
|
||||||
|
publisher?: string;
|
||||||
|
/** Designer name */
|
||||||
|
designer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock Fontshare style
|
||||||
|
*/
|
||||||
|
function mockFontshareStyle(
|
||||||
|
weight: number,
|
||||||
|
isItalic: boolean,
|
||||||
|
isVariable: boolean,
|
||||||
|
slug: string,
|
||||||
|
): FontshareFont['styles'][number] {
|
||||||
|
const weightLabel = weight === 400 ? 'Regular' : weight === 700 ? 'Bold' : weight.toString();
|
||||||
|
const suffix = isItalic ? 'italic' : '';
|
||||||
|
const variablePrefix = isVariable ? 'variable-' : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `style-${weight}${isItalic ? '-italic' : ''}`,
|
||||||
|
default: weight === 400 && !isItalic,
|
||||||
|
file: `//cdn.fontshare.com/wf/${slug}-${variablePrefix}${weight}${suffix}.woff2`,
|
||||||
|
is_italic: isItalic,
|
||||||
|
is_variable: isVariable,
|
||||||
|
properties: {},
|
||||||
|
weight: {
|
||||||
|
label: isVariable ? 'Variable' + (isItalic ? ' Italic' : '') : weightLabel,
|
||||||
|
name: isVariable ? 'Variable' + (isItalic ? 'Italic' : '') : weightLabel,
|
||||||
|
native_name: null,
|
||||||
|
number: isVariable ? 0 : weight,
|
||||||
|
weight: isVariable ? 0 : weight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default mock Fontshare font
|
||||||
|
*/
|
||||||
|
export function mockFontshareFont(options: MockFontshareFontOptions = {}): FontshareFont {
|
||||||
|
const {
|
||||||
|
name = 'Mock Font',
|
||||||
|
slug = name.toLowerCase().replace(/\s+/g, '-'),
|
||||||
|
category = 'sans',
|
||||||
|
script = 'latin',
|
||||||
|
isVariable = false,
|
||||||
|
version = '1.0',
|
||||||
|
views = 1000,
|
||||||
|
tags = [],
|
||||||
|
weights = [400, 700],
|
||||||
|
publisher = 'Mock Foundry',
|
||||||
|
designer = 'Mock Designer',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Generate styles based on weights and variable setting
|
||||||
|
const styles: FontshareFont['styles'] = isVariable
|
||||||
|
? [
|
||||||
|
mockFontshareStyle(0, false, true, slug),
|
||||||
|
mockFontshareStyle(0, true, true, slug),
|
||||||
|
]
|
||||||
|
: weights.flatMap(weight => [
|
||||||
|
mockFontshareStyle(weight, false, false, slug),
|
||||||
|
mockFontshareStyle(weight, true, false, slug),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `mock-${slug}`,
|
||||||
|
name,
|
||||||
|
native_name: null,
|
||||||
|
slug,
|
||||||
|
category,
|
||||||
|
script,
|
||||||
|
publisher: {
|
||||||
|
bio: `Mock publisher bio for ${publisher}`,
|
||||||
|
email: null,
|
||||||
|
id: `pub-${slug}`,
|
||||||
|
links: [],
|
||||||
|
name: publisher,
|
||||||
|
},
|
||||||
|
designers: [
|
||||||
|
{
|
||||||
|
bio: `Mock designer bio for ${designer}`,
|
||||||
|
links: [],
|
||||||
|
name: designer,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
related_families: null,
|
||||||
|
display_publisher_as_designer: false,
|
||||||
|
trials_enabled: true,
|
||||||
|
show_latin_metrics: false,
|
||||||
|
license_type: 'ofl',
|
||||||
|
languages: 'English, Spanish, French, German',
|
||||||
|
inserted_at: '2021-03-12T20:49:05Z',
|
||||||
|
story: `<p>A mock font story for ${name}.</p>`,
|
||||||
|
version,
|
||||||
|
views,
|
||||||
|
views_recent: Math.floor(views * 0.1),
|
||||||
|
is_hot: views > 5000,
|
||||||
|
is_new: views < 500,
|
||||||
|
is_shortlisted: null,
|
||||||
|
is_top: views > 10000,
|
||||||
|
axes: isVariable
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'Weight',
|
||||||
|
property: 'wght',
|
||||||
|
range_default: 400,
|
||||||
|
range_left: 300,
|
||||||
|
range_right: 700,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
font_tags: tags.map(name => ({ name })),
|
||||||
|
features: [],
|
||||||
|
styles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset Fontshare font mocks
|
||||||
|
*/
|
||||||
|
export const FONTHARE_FONTS: Record<string, FontshareFont> = {
|
||||||
|
satoshi: mockFontshareFont({
|
||||||
|
name: 'Satoshi',
|
||||||
|
slug: 'satoshi',
|
||||||
|
category: 'sans',
|
||||||
|
isVariable: true,
|
||||||
|
views: 15000,
|
||||||
|
tags: ['Branding', 'Logos', 'Editorial'],
|
||||||
|
publisher: 'Indian Type Foundry',
|
||||||
|
designer: 'Denis Shelabovets',
|
||||||
|
}),
|
||||||
|
generalSans: mockFontshareFont({
|
||||||
|
name: 'General Sans',
|
||||||
|
slug: 'general-sans',
|
||||||
|
category: 'sans',
|
||||||
|
isVariable: true,
|
||||||
|
views: 12000,
|
||||||
|
tags: ['UI', 'Branding', 'Display'],
|
||||||
|
publisher: 'Indestructible Type',
|
||||||
|
designer: 'Eugene Tantsur',
|
||||||
|
}),
|
||||||
|
clashDisplay: mockFontshareFont({
|
||||||
|
name: 'Clash Display',
|
||||||
|
slug: 'clash-display',
|
||||||
|
category: 'display',
|
||||||
|
isVariable: false,
|
||||||
|
views: 8000,
|
||||||
|
tags: ['Headlines', 'Posters', 'Branding'],
|
||||||
|
weights: [400, 500, 600, 700],
|
||||||
|
publisher: 'Letterogika',
|
||||||
|
designer: 'Matěj Trnka',
|
||||||
|
}),
|
||||||
|
fonta: mockFontshareFont({
|
||||||
|
name: 'Fonta',
|
||||||
|
slug: 'fonta',
|
||||||
|
category: 'serif',
|
||||||
|
isVariable: false,
|
||||||
|
views: 5000,
|
||||||
|
tags: ['Editorial', 'Books', 'Magazines'],
|
||||||
|
weights: [300, 400, 500, 600, 700],
|
||||||
|
publisher: 'Fonta',
|
||||||
|
designer: 'Alexei Vanyashin',
|
||||||
|
}),
|
||||||
|
aileron: mockFontshareFont({
|
||||||
|
name: 'Aileron',
|
||||||
|
slug: 'aileron',
|
||||||
|
category: 'sans',
|
||||||
|
isVariable: false,
|
||||||
|
views: 3000,
|
||||||
|
tags: ['Display', 'Headlines'],
|
||||||
|
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||||
|
publisher: 'Sorkin Type',
|
||||||
|
designer: 'Sorkin Type',
|
||||||
|
}),
|
||||||
|
beVietnamPro: mockFontshareFont({
|
||||||
|
name: 'Be Vietnam Pro',
|
||||||
|
slug: 'be-vietnam-pro',
|
||||||
|
category: 'sans',
|
||||||
|
isVariable: true,
|
||||||
|
views: 20000,
|
||||||
|
tags: ['UI', 'App', 'Web'],
|
||||||
|
publisher: 'ildefox',
|
||||||
|
designer: 'Manh Nguyen',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UNIFIED FONT MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a mock UnifiedFont
|
||||||
|
*/
|
||||||
|
export interface MockUnifiedFontOptions {
|
||||||
|
/** Unique identifier (default: derived from name) */
|
||||||
|
id?: string;
|
||||||
|
/** Font display name (default: 'Mock Font') */
|
||||||
|
name?: string;
|
||||||
|
/** Font provider (default: 'google') */
|
||||||
|
provider?: FontProvider;
|
||||||
|
/** Font category (default: 'sans-serif') */
|
||||||
|
category?: FontCategory;
|
||||||
|
/** Font subsets (default: ['latin']) */
|
||||||
|
subsets?: FontSubset[];
|
||||||
|
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
||||||
|
variants?: FontVariant[];
|
||||||
|
/** Style URLs (if not provided, mock URLs are generated) */
|
||||||
|
styles?: FontStyleUrls;
|
||||||
|
/** Metadata overrides */
|
||||||
|
metadata?: Partial<FontMetadata>;
|
||||||
|
/** Features overrides */
|
||||||
|
features?: Partial<FontFeatures>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default mock UnifiedFont
|
||||||
|
*/
|
||||||
|
export function mockUnifiedFont(options: MockUnifiedFontOptions = {}): UnifiedFont {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
name = 'Mock Font',
|
||||||
|
provider = 'google',
|
||||||
|
category = 'sans-serif',
|
||||||
|
subsets = ['latin'],
|
||||||
|
variants = ['regular', '700', 'italic', '700italic'],
|
||||||
|
styles,
|
||||||
|
metadata,
|
||||||
|
features,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const fontId = id ?? name.toLowerCase().replace(/\s+/g, '');
|
||||||
|
const baseUrl = provider === 'google'
|
||||||
|
? `https://fonts.gstatic.com/s/${fontId}/v30`
|
||||||
|
: `//cdn.fontshare.com/wf/${fontId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fontId,
|
||||||
|
name,
|
||||||
|
provider,
|
||||||
|
category,
|
||||||
|
subsets,
|
||||||
|
variants: variants as FontVariant[],
|
||||||
|
styles: styles ?? {
|
||||||
|
regular: `${baseUrl}/regular.woff2`,
|
||||||
|
bold: `${baseUrl}/bold.woff2`,
|
||||||
|
italic: `${baseUrl}/italic.woff2`,
|
||||||
|
boldItalic: `${baseUrl}/bolditalic.woff2`,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
version: '1.0',
|
||||||
|
lastModified: new Date().toISOString().split('T')[0],
|
||||||
|
popularity: 1,
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
isVariable: false,
|
||||||
|
...features,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset UnifiedFont mocks
|
||||||
|
*/
|
||||||
|
export const UNIFIED_FONTS: Record<string, UnifiedFont> = {
|
||||||
|
roboto: mockUnifiedFont({
|
||||||
|
id: 'roboto',
|
||||||
|
name: 'Roboto',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin', 'latin-ext'],
|
||||||
|
variants: ['100', '300', '400', '500', '700', '900'],
|
||||||
|
metadata: { popularity: 1 },
|
||||||
|
}),
|
||||||
|
openSans: mockUnifiedFont({
|
||||||
|
id: 'open-sans',
|
||||||
|
name: 'Open Sans',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin', 'latin-ext'],
|
||||||
|
variants: ['300', '400', '500', '600', '700', '800'],
|
||||||
|
metadata: { popularity: 2 },
|
||||||
|
}),
|
||||||
|
lato: mockUnifiedFont({
|
||||||
|
id: 'lato',
|
||||||
|
name: 'Lato',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin', 'latin-ext'],
|
||||||
|
variants: ['100', '300', '400', '700', '900'],
|
||||||
|
metadata: { popularity: 3 },
|
||||||
|
}),
|
||||||
|
playfairDisplay: mockUnifiedFont({
|
||||||
|
id: 'playfair-display',
|
||||||
|
name: 'Playfair Display',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['400', '700', '900'],
|
||||||
|
metadata: { popularity: 10 },
|
||||||
|
}),
|
||||||
|
montserrat: mockUnifiedFont({
|
||||||
|
id: 'montserrat',
|
||||||
|
name: 'Montserrat',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin', 'latin-ext'],
|
||||||
|
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||||
|
metadata: { popularity: 4 },
|
||||||
|
}),
|
||||||
|
satoshi: mockUnifiedFont({
|
||||||
|
id: 'satoshi',
|
||||||
|
name: 'Satoshi',
|
||||||
|
provider: 'fontshare',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
|
||||||
|
features: { isVariable: true, axes: [{ name: 'wght', property: 'wght', default: 400, min: 300, max: 700 }] },
|
||||||
|
metadata: { popularity: 15000 },
|
||||||
|
}),
|
||||||
|
generalSans: mockUnifiedFont({
|
||||||
|
id: 'general-sans',
|
||||||
|
name: 'General Sans',
|
||||||
|
provider: 'fontshare',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
|
||||||
|
features: { isVariable: true },
|
||||||
|
metadata: { popularity: 12000 },
|
||||||
|
}),
|
||||||
|
clashDisplay: mockUnifiedFont({
|
||||||
|
id: 'clash-display',
|
||||||
|
name: 'Clash Display',
|
||||||
|
provider: 'fontshare',
|
||||||
|
category: 'display',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['regular', '500', '600', 'bold'] as FontVariant[],
|
||||||
|
features: { tags: ['Headlines', 'Posters', 'Branding'] },
|
||||||
|
metadata: { popularity: 8000 },
|
||||||
|
}),
|
||||||
|
oswald: mockUnifiedFont({
|
||||||
|
id: 'oswald',
|
||||||
|
name: 'Oswald',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['200', '300', '400', '500', '600', '700'],
|
||||||
|
metadata: { popularity: 6 },
|
||||||
|
}),
|
||||||
|
raleway: mockUnifiedFont({
|
||||||
|
id: 'raleway',
|
||||||
|
name: 'Raleway',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||||
|
metadata: { popularity: 7 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of all preset UnifiedFonts
|
||||||
|
*/
|
||||||
|
export function getAllMockFonts(): UnifiedFont[] {
|
||||||
|
return Object.values(UNIFIED_FONTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fonts by provider
|
||||||
|
*/
|
||||||
|
export function getFontsByProvider(provider: FontProvider): UnifiedFont[] {
|
||||||
|
return getAllMockFonts().filter(font => font.provider === provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fonts by category
|
||||||
|
*/
|
||||||
|
export function getFontsByCategory(category: FontCategory): UnifiedFont[] {
|
||||||
|
return getAllMockFonts().filter(font => font.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an array of mock fonts with sequential naming
|
||||||
|
*/
|
||||||
|
export function generateMockFonts(count: number, options?: Omit<MockUnifiedFontOptions, 'id' | 'name'>): UnifiedFont[] {
|
||||||
|
return Array.from({ length: count }, (_, i) =>
|
||||||
|
mockUnifiedFont({
|
||||||
|
...options,
|
||||||
|
id: `mock-font-${i + 1}`,
|
||||||
|
name: `Mock Font ${i + 1}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an array of mock fonts with different categories
|
||||||
|
*/
|
||||||
|
export function generateMixedCategoryFonts(countPerCategory: number = 2): UnifiedFont[] {
|
||||||
|
const categories: FontCategory[] = ['sans-serif', 'serif', 'display', 'handwriting', 'monospace'];
|
||||||
|
const fonts: UnifiedFont[] = [];
|
||||||
|
|
||||||
|
categories.forEach(category => {
|
||||||
|
for (let i = 0; i < countPerCategory; i++) {
|
||||||
|
fonts.push(
|
||||||
|
mockUnifiedFont({
|
||||||
|
id: `${category}-${i + 1}`,
|
||||||
|
name: `${category.replace('-', ' ')} ${i + 1}`,
|
||||||
|
category,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fonts;
|
||||||
|
}
|
||||||
84
src/entities/Font/lib/mocks/index.ts
Normal file
84
src/entities/Font/lib/mocks/index.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* MOCK DATA HELPERS - MAIN EXPORT
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Comprehensive mock data for Storybook stories, tests, and development.
|
||||||
|
*
|
||||||
|
* ## Quick Start
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import {
|
||||||
|
* mockUnifiedFont,
|
||||||
|
* UNIFIED_FONTS,
|
||||||
|
* MOCK_FILTERS,
|
||||||
|
* createMockFontStoreState,
|
||||||
|
* } from '$entities/Font/lib/mocks';
|
||||||
|
*
|
||||||
|
* // Use in stories
|
||||||
|
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
|
||||||
|
* const presets = UNIFIED_FONTS;
|
||||||
|
* const filter = MOCK_FILTERS.categories;
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Font mocks
|
||||||
|
export {
|
||||||
|
FONTHARE_FONTS,
|
||||||
|
generateMixedCategoryFonts,
|
||||||
|
generateMockFonts,
|
||||||
|
getAllMockFonts,
|
||||||
|
getFontsByCategory,
|
||||||
|
getFontsByProvider,
|
||||||
|
GOOGLE_FONTS,
|
||||||
|
mockFontshareFont,
|
||||||
|
type MockFontshareFontOptions,
|
||||||
|
mockGoogleFont,
|
||||||
|
type MockGoogleFontOptions,
|
||||||
|
mockUnifiedFont,
|
||||||
|
type MockUnifiedFontOptions,
|
||||||
|
UNIFIED_FONTS,
|
||||||
|
} from './fonts.mock';
|
||||||
|
|
||||||
|
// Filter mocks
|
||||||
|
export {
|
||||||
|
createCategoriesFilter,
|
||||||
|
createGenericFilter,
|
||||||
|
createMockFilter,
|
||||||
|
createProvidersFilter,
|
||||||
|
createSubsetsFilter,
|
||||||
|
FONT_PROVIDERS,
|
||||||
|
FONT_SUBSETS,
|
||||||
|
FONTHARE_CATEGORIES,
|
||||||
|
generateSequentialFilter,
|
||||||
|
GENERIC_FILTERS,
|
||||||
|
GOOGLE_CATEGORIES,
|
||||||
|
MOCK_FILTERS,
|
||||||
|
MOCK_FILTERS_ALL_SELECTED,
|
||||||
|
MOCK_FILTERS_EMPTY,
|
||||||
|
MOCK_FILTERS_SELECTED,
|
||||||
|
type MockFilterOptions,
|
||||||
|
type MockFilters,
|
||||||
|
UNIFIED_CATEGORIES,
|
||||||
|
} from './filters.mock';
|
||||||
|
|
||||||
|
// Store mocks
|
||||||
|
export {
|
||||||
|
createErrorState,
|
||||||
|
createLoadingState,
|
||||||
|
createMockComparisonStore,
|
||||||
|
createMockFontApiResponse,
|
||||||
|
createMockFontStoreState,
|
||||||
|
createMockQueryState,
|
||||||
|
createMockReactiveState,
|
||||||
|
createMockStore,
|
||||||
|
createSuccessState,
|
||||||
|
generatePaginatedFonts,
|
||||||
|
MOCK_FONT_STORE_STATES,
|
||||||
|
MOCK_STORES,
|
||||||
|
type MockFontStoreState,
|
||||||
|
type MockQueryObserverResult,
|
||||||
|
type MockQueryState,
|
||||||
|
} from './stores.mock';
|
||||||
590
src/entities/Font/lib/mocks/stores.mock.ts
Normal file
590
src/entities/Font/lib/mocks/stores.mock.ts
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* MOCK FONT STORE HELPERS
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Factory functions and preset mock data for TanStack Query stores and state management.
|
||||||
|
* Used in Storybook stories for components that use reactive stores.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import {
|
||||||
|
* createMockQueryState,
|
||||||
|
* MOCK_STORES,
|
||||||
|
* } from '$entities/Font/lib/mocks';
|
||||||
|
*
|
||||||
|
* // Create a mock query state
|
||||||
|
* const loadingState = createMockQueryState({ status: 'pending' });
|
||||||
|
* const errorState = createMockQueryState({ status: 'error', error: 'Failed to load' });
|
||||||
|
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
|
||||||
|
*
|
||||||
|
* // Use preset stores
|
||||||
|
* const mockFontStore = MOCK_STORES.unifiedFontStore();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||||
|
import type {
|
||||||
|
QueryKey,
|
||||||
|
QueryObserverResult,
|
||||||
|
QueryStatus,
|
||||||
|
} from '@tanstack/svelte-query';
|
||||||
|
import {
|
||||||
|
UNIFIED_FONTS,
|
||||||
|
generateMockFonts,
|
||||||
|
} from './fonts.mock';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TANSTACK QUERY MOCK TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock TanStack Query state
|
||||||
|
*/
|
||||||
|
export interface MockQueryState<TData = unknown, TError = Error> {
|
||||||
|
status: QueryStatus;
|
||||||
|
data?: TData;
|
||||||
|
error?: TError;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
|
isSuccess?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
isPending?: boolean;
|
||||||
|
dataUpdatedAt?: number;
|
||||||
|
errorUpdatedAt?: number;
|
||||||
|
failureCount?: number;
|
||||||
|
failureReason?: TError;
|
||||||
|
errorUpdateCount?: number;
|
||||||
|
isRefetching?: boolean;
|
||||||
|
isRefetchError?: boolean;
|
||||||
|
isPaused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock TanStack Query observer result
|
||||||
|
*/
|
||||||
|
export interface MockQueryObserverResult<TData = unknown, TError = Error> {
|
||||||
|
status?: QueryStatus;
|
||||||
|
data?: TData;
|
||||||
|
error?: TError;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
|
isSuccess?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
isPending?: boolean;
|
||||||
|
dataUpdatedAt?: number;
|
||||||
|
errorUpdatedAt?: number;
|
||||||
|
failureCount?: number;
|
||||||
|
failureReason?: TError;
|
||||||
|
errorUpdateCount?: number;
|
||||||
|
isRefetching?: boolean;
|
||||||
|
isRefetchError?: boolean;
|
||||||
|
isPaused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TANSTACK QUERY MOCK FACTORIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock query state for TanStack Query
|
||||||
|
*/
|
||||||
|
export function createMockQueryState<TData = unknown, TError = Error>(
|
||||||
|
options: MockQueryState<TData, TError>,
|
||||||
|
): MockQueryObserverResult<TData, TError> {
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: status ?? 'success',
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
isLoading: status === 'pending' ? true : false,
|
||||||
|
isFetching: status === 'pending' ? true : false,
|
||||||
|
isSuccess: status === 'success',
|
||||||
|
isError: status === 'error',
|
||||||
|
isPending: status === 'pending',
|
||||||
|
dataUpdatedAt: status === 'success' ? Date.now() : undefined,
|
||||||
|
errorUpdatedAt: status === 'error' ? Date.now() : undefined,
|
||||||
|
failureCount: status === 'error' ? 1 : 0,
|
||||||
|
failureReason: status === 'error' ? error : undefined,
|
||||||
|
errorUpdateCount: status === 'error' ? 1 : 0,
|
||||||
|
isRefetching: false,
|
||||||
|
isRefetchError: false,
|
||||||
|
isPaused: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a loading query state
|
||||||
|
*/
|
||||||
|
export function createLoadingState<TData = unknown>(): MockQueryObserverResult<TData> {
|
||||||
|
return createMockQueryState<TData>({ status: 'pending', data: undefined, error: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error query state
|
||||||
|
*/
|
||||||
|
export function createErrorState<TError = Error>(
|
||||||
|
error: TError,
|
||||||
|
): MockQueryObserverResult<unknown, TError> {
|
||||||
|
return createMockQueryState<unknown, TError>({ status: 'error', data: undefined, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a success query state
|
||||||
|
*/
|
||||||
|
export function createSuccessState<TData>(data: TData): MockQueryObserverResult<TData> {
|
||||||
|
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FONT STORE MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock UnifiedFontStore state
|
||||||
|
*/
|
||||||
|
export interface MockFontStoreState {
|
||||||
|
/** All cached fonts */
|
||||||
|
fonts: Record<string, UnifiedFont>;
|
||||||
|
/** Current page */
|
||||||
|
page: number;
|
||||||
|
/** Total pages available */
|
||||||
|
totalPages: number;
|
||||||
|
/** Items per page */
|
||||||
|
limit: number;
|
||||||
|
/** Total font count */
|
||||||
|
total: number;
|
||||||
|
/** Loading state */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Error state */
|
||||||
|
error: Error | null;
|
||||||
|
/** Search query */
|
||||||
|
searchQuery: string;
|
||||||
|
/** Selected provider */
|
||||||
|
provider: 'google' | 'fontshare' | 'all';
|
||||||
|
/** Selected category */
|
||||||
|
category: string | null;
|
||||||
|
/** Selected subset */
|
||||||
|
subset: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock font store state
|
||||||
|
*/
|
||||||
|
export function createMockFontStoreState(
|
||||||
|
options: Partial<MockFontStoreState> = {},
|
||||||
|
): MockFontStoreState {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 24,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
searchQuery = '',
|
||||||
|
provider = 'all',
|
||||||
|
category = null,
|
||||||
|
subset = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Generate mock fonts if not provided
|
||||||
|
const mockFonts = options.fonts ?? Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS).map(font => [font.id, font]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fontArray = Object.values(mockFonts);
|
||||||
|
const total = options.total ?? fontArray.length;
|
||||||
|
const totalPages = options.totalPages ?? Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fonts: mockFonts,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
searchQuery,
|
||||||
|
provider,
|
||||||
|
category,
|
||||||
|
subset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset font store states
|
||||||
|
*/
|
||||||
|
export const MOCK_FONT_STORE_STATES = {
|
||||||
|
/** Initial loading state */
|
||||||
|
loading: createMockFontStoreState({
|
||||||
|
isLoading: true,
|
||||||
|
fonts: {},
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Empty state (no fonts found) */
|
||||||
|
empty: createMockFontStoreState({
|
||||||
|
fonts: {},
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** First page with fonts */
|
||||||
|
firstPage: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 50,
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
totalPages: 5,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Second page with fonts */
|
||||||
|
secondPage: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 50,
|
||||||
|
page: 2,
|
||||||
|
limit: 10,
|
||||||
|
totalPages: 5,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Last page with fonts */
|
||||||
|
lastPage: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 25,
|
||||||
|
page: 3,
|
||||||
|
limit: 10,
|
||||||
|
totalPages: 3,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Error state */
|
||||||
|
error: createMockFontStoreState({
|
||||||
|
fonts: {},
|
||||||
|
error: new Error('Failed to load fonts'),
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** With search query */
|
||||||
|
withSearch: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 3,
|
||||||
|
page: 1,
|
||||||
|
isLoading: false,
|
||||||
|
searchQuery: 'Roboto',
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Filtered by category */
|
||||||
|
filteredByCategory: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS)
|
||||||
|
.filter(f => f.category === 'serif')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 5,
|
||||||
|
page: 1,
|
||||||
|
isLoading: false,
|
||||||
|
category: 'serif',
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Filtered by provider */
|
||||||
|
filteredByProvider: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS)
|
||||||
|
.filter(f => f.provider === 'google')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 5,
|
||||||
|
page: 1,
|
||||||
|
isLoading: false,
|
||||||
|
provider: 'google',
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Large dataset */
|
||||||
|
largeDataset: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
generateMockFonts(50).map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 500,
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
totalPages: 10,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MOCK STORE OBJECT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock store object that mimics TanStack Query behavior
|
||||||
|
* Useful for components that subscribe to store properties
|
||||||
|
*/
|
||||||
|
export function createMockStore<T>(config: {
|
||||||
|
data?: T;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
error?: Error;
|
||||||
|
isFetching?: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading = false,
|
||||||
|
isError = false,
|
||||||
|
error,
|
||||||
|
isFetching = false,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get data() {
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
get isLoading() {
|
||||||
|
return isLoading;
|
||||||
|
},
|
||||||
|
get isError() {
|
||||||
|
return isError;
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
get isFetching() {
|
||||||
|
return isFetching;
|
||||||
|
},
|
||||||
|
get isSuccess() {
|
||||||
|
return !isLoading && !isError && data !== undefined;
|
||||||
|
},
|
||||||
|
get status() {
|
||||||
|
if (isLoading) return 'pending';
|
||||||
|
if (isError) return 'error';
|
||||||
|
return 'success';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset mock stores
|
||||||
|
*/
|
||||||
|
export const MOCK_STORES = {
|
||||||
|
/** Font store in loading state */
|
||||||
|
loadingFontStore: createMockStore<UnifiedFont[]>({
|
||||||
|
isLoading: true,
|
||||||
|
data: undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Font store with fonts loaded */
|
||||||
|
successFontStore: createMockStore<UnifiedFont[]>({
|
||||||
|
data: Object.values(UNIFIED_FONTS),
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Font store with error */
|
||||||
|
errorFontStore: createMockStore<UnifiedFont[]>({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isError: true,
|
||||||
|
error: new Error('Failed to load fonts'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Font store with empty results */
|
||||||
|
emptyFontStore: createMockStore<UnifiedFont[]>({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock UnifiedFontStore-like object
|
||||||
|
* Note: This is a simplified mock for Storybook use
|
||||||
|
*/
|
||||||
|
unifiedFontStore: (state: Partial<MockFontStoreState> = {}) => {
|
||||||
|
const mockState = createMockFontStoreState(state);
|
||||||
|
return {
|
||||||
|
// State properties
|
||||||
|
get fonts() {
|
||||||
|
return mockState.fonts;
|
||||||
|
},
|
||||||
|
get page() {
|
||||||
|
return mockState.page;
|
||||||
|
},
|
||||||
|
get totalPages() {
|
||||||
|
return mockState.totalPages;
|
||||||
|
},
|
||||||
|
get limit() {
|
||||||
|
return mockState.limit;
|
||||||
|
},
|
||||||
|
get total() {
|
||||||
|
return mockState.total;
|
||||||
|
},
|
||||||
|
get isLoading() {
|
||||||
|
return mockState.isLoading;
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return mockState.error;
|
||||||
|
},
|
||||||
|
get searchQuery() {
|
||||||
|
return mockState.searchQuery;
|
||||||
|
},
|
||||||
|
get provider() {
|
||||||
|
return mockState.provider;
|
||||||
|
},
|
||||||
|
get category() {
|
||||||
|
return mockState.category;
|
||||||
|
},
|
||||||
|
get subset() {
|
||||||
|
return mockState.subset;
|
||||||
|
},
|
||||||
|
// Methods (no-op for Storybook)
|
||||||
|
nextPage: () => {},
|
||||||
|
prevPage: () => {},
|
||||||
|
goToPage: (_page: number) => {},
|
||||||
|
setLimit: (_limit: number) => {},
|
||||||
|
setProvider: (_provider: typeof mockState.provider) => {},
|
||||||
|
setCategory: (_category: string | null) => {},
|
||||||
|
setSubset: (_subset: string | null) => {},
|
||||||
|
setSearch: (_query: string) => {},
|
||||||
|
resetFilters: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// REACTIVE STATE MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a reactive state object using Svelte 5 runes pattern
|
||||||
|
* Useful for stories that need reactive state
|
||||||
|
*
|
||||||
|
* Note: This uses plain JavaScript objects since Svelte runes
|
||||||
|
* only work in .svelte files. For Storybook, this provides
|
||||||
|
* a similar API for testing.
|
||||||
|
*/
|
||||||
|
export function createMockReactiveState<T>(initialValue: T) {
|
||||||
|
let value = initialValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(newValue: T) {
|
||||||
|
value = newValue;
|
||||||
|
},
|
||||||
|
update(fn: (current: T) => T) {
|
||||||
|
value = fn(value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock comparison store for ComparisonSlider component
|
||||||
|
*/
|
||||||
|
export function createMockComparisonStore(config: {
|
||||||
|
fontA?: UnifiedFont;
|
||||||
|
fontB?: UnifiedFont;
|
||||||
|
text?: string;
|
||||||
|
} = {}) {
|
||||||
|
const { fontA, fontB, text = 'The quick brown fox jumps over the lazy dog.' } = config;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get fontA() {
|
||||||
|
return fontA ?? UNIFIED_FONTS.roboto;
|
||||||
|
},
|
||||||
|
get fontB() {
|
||||||
|
return fontB ?? UNIFIED_FONTS.openSans;
|
||||||
|
},
|
||||||
|
get text() {
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
// Methods (no-op for Storybook)
|
||||||
|
setFontA: (_font: UnifiedFont | undefined) => {},
|
||||||
|
setFontB: (_font: UnifiedFont | undefined) => {},
|
||||||
|
setText: (_text: string) => {},
|
||||||
|
swapFonts: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MOCK DATA GENERATORS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate paginated font data
|
||||||
|
*/
|
||||||
|
export function generatePaginatedFonts(
|
||||||
|
totalCount: number,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
): {
|
||||||
|
fonts: UnifiedFont[];
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
total: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
} {
|
||||||
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
|
const startIndex = (page - 1) * limit;
|
||||||
|
const endIndex = Math.min(startIndex + limit, totalCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fonts: generateMockFonts(endIndex - startIndex).map((font, i) => ({
|
||||||
|
...font,
|
||||||
|
id: `font-${startIndex + i + 1}`,
|
||||||
|
name: `Font ${startIndex + i + 1}`,
|
||||||
|
})),
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total: totalCount,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPrevPage: page > 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create mock API response for fonts
|
||||||
|
*/
|
||||||
|
export function createMockFontApiResponse(config: {
|
||||||
|
fonts?: UnifiedFont[];
|
||||||
|
total?: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
} = {}) {
|
||||||
|
const fonts = config.fonts ?? Object.values(UNIFIED_FONTS);
|
||||||
|
const total = config.total ?? fonts.length;
|
||||||
|
const page = config.page ?? 1;
|
||||||
|
const limit = config.limit ?? fonts.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: fonts,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
hasNextPage: page < Math.ceil(total / limit),
|
||||||
|
hasPrevPage: page > 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,11 +24,9 @@ describe('Font Normalization', () => {
|
|||||||
subsets: ['latin', 'latin-ext'],
|
subsets: ['latin', 'latin-ext'],
|
||||||
files: {
|
files: {
|
||||||
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||||
'700':
|
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||||
'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
|
||||||
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
||||||
'700italic':
|
'700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
||||||
'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
|
||||||
},
|
},
|
||||||
version: 'v30',
|
version: 'v30',
|
||||||
lastModified: '2022-01-01',
|
lastModified: '2022-01-01',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
FontshareFont,
|
FontshareFont,
|
||||||
GoogleFontItem,
|
GoogleFontItem,
|
||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
|
UnifiedFontVariant,
|
||||||
} from '../../model/types';
|
} from '../../model/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,7 +187,7 @@ export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
|
|||||||
const variants = apiFont.styles.map(style => {
|
const variants = apiFont.styles.map(style => {
|
||||||
const weightLabel = style.weight.label;
|
const weightLabel = style.weight.label;
|
||||||
const isItalic = style.is_italic;
|
const isItalic = style.is_italic;
|
||||||
return isItalic ? `${weightLabel}italic` : weightLabel;
|
return (isItalic ? `${weightLabel}italic` : weightLabel) as UnifiedFontVariant;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map styles to URLs
|
// Map styles to URLs
|
||||||
|
|||||||
@@ -34,12 +34,10 @@ export type {
|
|||||||
UnifiedFontVariant,
|
UnifiedFontVariant,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export { fetchFontshareFontsQuery } from './services';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
createFontshareStore,
|
createUnifiedFontStore,
|
||||||
type FontshareStore,
|
type FontConfigRequest,
|
||||||
fontshareStore,
|
type UnifiedFontStore,
|
||||||
selectedFontsStore,
|
unifiedFontStore,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import {
|
|
||||||
type FontshareParams,
|
|
||||||
fetchFontshareFonts,
|
|
||||||
} from '../../api';
|
|
||||||
import { normalizeFontshareFonts } from '../../lib';
|
|
||||||
import type { UnifiedFont } from '../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query function for fetching fonts from Fontshare.
|
|
||||||
*
|
|
||||||
* @param params - The parameters for fetching fonts from Fontshare (E.g. search query, page number, etc.).
|
|
||||||
* @returns A promise that resolves with an array of UnifiedFont objects representing the fonts found in Fontshare.
|
|
||||||
*/
|
|
||||||
export async function fetchFontshareFontsQuery(params: FontshareParams): Promise<UnifiedFont[]> {
|
|
||||||
try {
|
|
||||||
const response = await fetchFontshareFonts(params);
|
|
||||||
return normalizeFontshareFonts(response.fonts);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('Failed to fetch')) {
|
|
||||||
throw new Error(
|
|
||||||
'Unable to connect to Fontshare. Please check your internet connection.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error.message.includes('404')) {
|
|
||||||
throw new Error('Font not found in Fontshare catalog.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('Failed to load fonts from Fontshare.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
/**
|
|
||||||
* Service for fetching Google Fonts with Svelte 5 runes + TanStack Query
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
type CreateQueryResult,
|
|
||||||
createQuery,
|
|
||||||
useQueryClient,
|
|
||||||
} from '@tanstack/svelte-query';
|
|
||||||
import {
|
|
||||||
type GoogleFontsParams,
|
|
||||||
fetchGoogleFonts,
|
|
||||||
} from '../../api';
|
|
||||||
import { normalizeGoogleFonts } from '../../lib';
|
|
||||||
import type {
|
|
||||||
FontCategory,
|
|
||||||
FontSubset,
|
|
||||||
} from '../types';
|
|
||||||
import type { UnifiedFont } from '../types/normalize';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query key factory
|
|
||||||
*/
|
|
||||||
function getGoogleFontsQueryKey(params: GoogleFontsParams) {
|
|
||||||
return ['googleFonts', params] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query function
|
|
||||||
*/
|
|
||||||
export async function fetchGoogleFontsQuery(params: GoogleFontsParams): Promise<UnifiedFont[]> {
|
|
||||||
try {
|
|
||||||
const response = await fetchGoogleFonts({
|
|
||||||
category: params.category,
|
|
||||||
subset: params.subset,
|
|
||||||
sort: params.sort,
|
|
||||||
});
|
|
||||||
return normalizeGoogleFonts(response.items);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('Failed to fetch')) {
|
|
||||||
throw new Error(
|
|
||||||
'Unable to connect to Google Fonts. Please check your internet connection.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error.message.includes('404')) {
|
|
||||||
throw new Error('Font not found in Google Fonts catalog.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('Failed to load fonts from Google Fonts.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Google Fonts store wrapping TanStack Query with runes
|
|
||||||
*/
|
|
||||||
export class GoogleFontsStore {
|
|
||||||
params = $state<GoogleFontsParams>({});
|
|
||||||
private query: CreateQueryResult<UnifiedFont[], Error>;
|
|
||||||
private queryClient = useQueryClient();
|
|
||||||
|
|
||||||
constructor(initialParams: GoogleFontsParams = {}) {
|
|
||||||
this.params = initialParams;
|
|
||||||
|
|
||||||
// Create the query - automatically reactive
|
|
||||||
this.query = createQuery(() => ({
|
|
||||||
queryKey: getGoogleFontsQueryKey(this.params),
|
|
||||||
queryFn: () => fetchGoogleFontsQuery(this.params),
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy TanStack Query's reactive state
|
|
||||||
get fonts() {
|
|
||||||
return this.query.data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
get isLoading() {
|
|
||||||
return this.query.isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFetching() {
|
|
||||||
return this.query.isFetching;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isRefetching() {
|
|
||||||
return this.query.isRefetching;
|
|
||||||
}
|
|
||||||
|
|
||||||
get error() {
|
|
||||||
return this.query.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isError() {
|
|
||||||
return this.query.isError;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isSuccess() {
|
|
||||||
return this.query.isSuccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
get status() {
|
|
||||||
return this.query.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derived helpers
|
|
||||||
get hasData() {
|
|
||||||
return this.fonts.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isEmpty() {
|
|
||||||
return !this.isLoading && this.fonts.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get fontCount() {
|
|
||||||
return this.fonts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtered fonts by category (if you need additional client-side filtering)
|
|
||||||
get sansSerifFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'sans-serif');
|
|
||||||
}
|
|
||||||
|
|
||||||
get serifFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'serif');
|
|
||||||
}
|
|
||||||
|
|
||||||
get displayFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'display');
|
|
||||||
}
|
|
||||||
|
|
||||||
get handwritingFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'handwriting');
|
|
||||||
}
|
|
||||||
|
|
||||||
get monospaceFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'monospace');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update parameters - TanStack Query will automatically refetch
|
|
||||||
*/
|
|
||||||
setParams(newParams: Partial<GoogleFontsParams>) {
|
|
||||||
this.params = { ...this.params, ...newParams };
|
|
||||||
}
|
|
||||||
|
|
||||||
setCategory(category: FontCategory | undefined) {
|
|
||||||
this.setParams({ category });
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubset(subset: FontSubset | undefined) {
|
|
||||||
this.setParams({ subset });
|
|
||||||
}
|
|
||||||
|
|
||||||
setSort(sort: 'popularity' | 'alpha' | 'date' | undefined) {
|
|
||||||
this.setParams({ sort });
|
|
||||||
}
|
|
||||||
|
|
||||||
setSearch(search: string) {
|
|
||||||
this.setParams({ search });
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSearch() {
|
|
||||||
this.setParams({ search: undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFilters() {
|
|
||||||
this.params = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually refetch
|
|
||||||
*/
|
|
||||||
async refetch() {
|
|
||||||
await this.query.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate cache and refetch
|
|
||||||
*/
|
|
||||||
invalidate() {
|
|
||||||
this.queryClient.invalidateQueries({
|
|
||||||
queryKey: getGoogleFontsQueryKey(this.params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate all Google Fonts queries
|
|
||||||
*/
|
|
||||||
invalidateAll() {
|
|
||||||
this.queryClient.invalidateQueries({
|
|
||||||
queryKey: ['googleFonts'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefetch with different params (for hover states, pagination, etc.)
|
|
||||||
*/
|
|
||||||
async prefetch(params: GoogleFontsParams) {
|
|
||||||
await this.queryClient.prefetchQuery({
|
|
||||||
queryKey: getGoogleFontsQueryKey(params),
|
|
||||||
queryFn: () => fetchGoogleFontsQuery(params),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefetch next category (useful for tab switching)
|
|
||||||
*/
|
|
||||||
async prefetchCategory(category: FontCategory) {
|
|
||||||
await this.prefetch({ ...this.params, category });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel ongoing queries
|
|
||||||
*/
|
|
||||||
cancel() {
|
|
||||||
this.queryClient.cancelQueries({
|
|
||||||
queryKey: getGoogleFontsQueryKey(this.params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear cache for current params
|
|
||||||
*/
|
|
||||||
clearCache() {
|
|
||||||
this.queryClient.removeQueries({
|
|
||||||
queryKey: getGoogleFontsQueryKey(this.params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached data without triggering fetch
|
|
||||||
*/
|
|
||||||
getCachedData() {
|
|
||||||
return this.queryClient.getQueryData<UnifiedFont[]>(
|
|
||||||
getGoogleFontsQueryKey(this.params),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if data exists in cache
|
|
||||||
*/
|
|
||||||
hasCache(params?: GoogleFontsParams) {
|
|
||||||
const key = params ? getGoogleFontsQueryKey(params) : getGoogleFontsQueryKey(this.params);
|
|
||||||
return this.queryClient.getQueryData(key) !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set data manually (optimistic updates)
|
|
||||||
*/
|
|
||||||
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
|
||||||
this.queryClient.setQueryData(
|
|
||||||
getGoogleFontsQueryKey(this.params),
|
|
||||||
updater,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get query state for debugging
|
|
||||||
*/
|
|
||||||
getQueryState() {
|
|
||||||
return this.queryClient.getQueryState(
|
|
||||||
getGoogleFontsQueryKey(this.params),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create Google Fonts store
|
|
||||||
*/
|
|
||||||
export function createGoogleFontsStore(params: GoogleFontsParams = {}) {
|
|
||||||
return new GoogleFontsStore(params);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { fetchFontshareFontsQuery } from './fetchFontshareFonts.svelte';
|
|
||||||
export { fetchGoogleFontsQuery } from './fetchGoogleFonts.svelte';
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
||||||
|
|
||||||
|
describe('AppliedFontsManager', () => {
|
||||||
|
let manager: AppliedFontsManager;
|
||||||
|
let mockFontFaceSet: any;
|
||||||
|
let mockFetch: any;
|
||||||
|
let failUrls: Set<string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
failUrls = new Set();
|
||||||
|
|
||||||
|
mockFontFaceSet = {
|
||||||
|
add: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Properly mock FontFace as a constructor function
|
||||||
|
// The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string
|
||||||
|
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
|
||||||
|
this.name = name;
|
||||||
|
this.bufferOrUrl = bufferOrUrl;
|
||||||
|
this.load = vi.fn().mockImplementation(() => {
|
||||||
|
// For error tests, we track which URLs should fail via failUrls
|
||||||
|
// The fetch mock will have already rejected for those URLs
|
||||||
|
return Promise.resolve(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('FontFace', MockFontFace);
|
||||||
|
|
||||||
|
// 2. Mock document.fonts safely
|
||||||
|
Object.defineProperty(document, 'fonts', {
|
||||||
|
value: mockFontFaceSet,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('crypto', {
|
||||||
|
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Mock fetch to return fake ArrayBuffer data
|
||||||
|
mockFetch = vi.fn((url: string) => {
|
||||||
|
if (failUrls.has(url)) {
|
||||||
|
return Promise.reject(new Error('Network error'));
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||||
|
clone: () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
manager = new AppliedFontsManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should batch multiple font requests into a single process', async () => {
|
||||||
|
const configs = [
|
||||||
|
{ id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
|
||||||
|
{ id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
|
||||||
|
];
|
||||||
|
|
||||||
|
manager.touch(configs);
|
||||||
|
|
||||||
|
// Advance to trigger the 16ms debounced #processQueue
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('lato-400', 400)).toBe('loaded');
|
||||||
|
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle font loading errors gracefully', async () => {
|
||||||
|
// Suppress expected console error for clean test logs
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const failUrl = 'https://example.com/fail.ttf';
|
||||||
|
failUrls.add(failUrl);
|
||||||
|
|
||||||
|
const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 };
|
||||||
|
|
||||||
|
manager.touch([config]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('broken', 400)).toBe('error');
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should purge fonts after TTL expires', async () => {
|
||||||
|
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
|
||||||
|
|
||||||
|
manager.touch([config]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
|
||||||
|
|
||||||
|
// Move clock forward past TTL (5m) and Purge Interval (1m)
|
||||||
|
// advanceTimersByTimeAsync is key here; it handles the promises inside the interval
|
||||||
|
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
||||||
|
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT purge fonts that are still being "touched"', async () => {
|
||||||
|
const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
|
||||||
|
|
||||||
|
manager.touch([config]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
// Advance 4 minutes
|
||||||
|
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
|
||||||
|
|
||||||
|
// Refresh touch
|
||||||
|
manager.touch([config]);
|
||||||
|
|
||||||
|
// Advance another 2 minutes (Total 6 since start)
|
||||||
|
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,150 +1,354 @@
|
|||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
/** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */
|
||||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
|
/** Configuration for a font load request. */
|
||||||
|
export interface FontConfigRequest {
|
||||||
/**
|
/**
|
||||||
* Manager that handles loading of the fonts
|
* Unique identifier for the font (e.g., "lato", "roboto").
|
||||||
* Adds <link /> tags to <head />
|
|
||||||
* - Uses batch loading to reduce the number of requests
|
|
||||||
* - Uses a queue to prevent too many requests at once
|
|
||||||
* - Purges unused fonts after a certain time
|
|
||||||
*/
|
*/
|
||||||
class AppliedFontsManager {
|
id: string;
|
||||||
// Stores: slug -> timestamp of last visibility
|
/**
|
||||||
|
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* URL pointing to the font file (typically .ttf or .woff2).
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
|
||||||
|
*/
|
||||||
|
weight: number;
|
||||||
|
/**
|
||||||
|
* Variable fonts load once per ID; static fonts load per weight.
|
||||||
|
*/
|
||||||
|
isVariable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
|
||||||
|
*
|
||||||
|
* **Two-Phase Loading Strategy:**
|
||||||
|
* 1. *Concurrent Fetching*: Font files fetched in parallel (network I/O is non-blocking)
|
||||||
|
* 2. *Sequential Parsing*: Buffers parsed into FontFace objects one at a time with periodic yields
|
||||||
|
*
|
||||||
|
* **Yielding Strategy:**
|
||||||
|
* - Chromium: Yields only when user input is pending (via `scheduler.yield()` + `isInputPending()`)
|
||||||
|
* - Others: Time-based fallback, yields every 8ms
|
||||||
|
*
|
||||||
|
* **Network Adaptation:**
|
||||||
|
* - 2G: 1 concurrent request, 3G: 2, 4G+: 4 (via Network Information API)
|
||||||
|
* - Respects `saveData` mode to defer non-critical weights
|
||||||
|
*
|
||||||
|
* **Cache Integration:** Cache API with best-effort fallback (handles private browsing, quota limits)
|
||||||
|
*
|
||||||
|
* **Cleanup:** LRU-style eviction after 5 minutes of inactivity; cleanup runs every 60 seconds
|
||||||
|
*
|
||||||
|
* **Font Identity:** Variable fonts use `{id}@vf`, static fonts use `{id}@{weight}`
|
||||||
|
*
|
||||||
|
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
||||||
|
*/
|
||||||
|
export class AppliedFontsManager {
|
||||||
|
// Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf`
|
||||||
|
#loadedFonts = new Map<string, FontFace>();
|
||||||
|
|
||||||
|
// Last-used timestamps for LRU cleanup. Key: `{id}@{weight}` or `{id}@vf`, Value: unix timestamp (ms)
|
||||||
#usageTracker = new Map<string, number>();
|
#usageTracker = new Map<string, number>();
|
||||||
// Stores: slug -> batchId
|
|
||||||
#slugToBatch = new Map<string, string>();
|
|
||||||
// Stores: batchId -> HTMLLinkElement (for physical cleanup)
|
|
||||||
#batchElements = new Map<string, HTMLLinkElement>();
|
|
||||||
|
|
||||||
#queue = new Set<string>();
|
// Fonts queued for loading by `touch()`, processed by `#processQueue()`
|
||||||
|
#queue = new Map<string, FontConfigRequest>();
|
||||||
|
|
||||||
|
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
|
||||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
#PURGE_INTERVAL = 60000; // Check every minute
|
|
||||||
#TTL = 5 * 60 * 1000; // 5 minutes
|
|
||||||
#CHUNK_SIZE = 3;
|
|
||||||
|
|
||||||
|
// Interval handle for periodic cleanup (runs every PURGE_INTERVAL)
|
||||||
|
#intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// AbortController for canceling in-flight fetches on destroy
|
||||||
|
#abortController = new AbortController();
|
||||||
|
|
||||||
|
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
||||||
|
#pendingType: 'idle' | 'timeout' | null = null;
|
||||||
|
|
||||||
|
// Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped
|
||||||
|
#retryCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
readonly #MAX_RETRIES = 3;
|
||||||
|
readonly #PURGE_INTERVAL = 60000; // 60 seconds
|
||||||
|
readonly #TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
readonly #CACHE_NAME = 'font-cache-v1'; // Versioned for future invalidation
|
||||||
|
|
||||||
|
// Reactive status map for Svelte components to track font states
|
||||||
statuses = new SvelteMap<string, FontStatus>();
|
statuses = new SvelteMap<string, FontStatus>();
|
||||||
|
|
||||||
|
// Starts periodic cleanup timer (browser-only).
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Start the "Janitor" loop
|
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||||
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generates font key: `{id}@vf` for variable, `{id}@{weight}` for static.
|
||||||
|
#getFontKey(id: string, weight: number, isVariable: boolean): string {
|
||||||
|
return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests fonts to be loaded. Updates usage tracking and queues new fonts.
|
||||||
|
*
|
||||||
|
* Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES.
|
||||||
|
* Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms).
|
||||||
|
*/
|
||||||
|
touch(configs: FontConfigRequest[]) {
|
||||||
|
if (this.#abortController.signal.aborted) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let hasNewItems = false;
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
|
||||||
|
this.#usageTracker.set(key, now);
|
||||||
|
|
||||||
|
const status = this.statuses.get(key);
|
||||||
|
if (status === 'loaded' || status === 'loading' || this.#queue.has(key)) continue;
|
||||||
|
if (status === 'error' && (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES) continue;
|
||||||
|
|
||||||
|
this.#queue.set(key, config);
|
||||||
|
hasNewItems = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNewItems && !this.#timeoutId) {
|
||||||
|
if (typeof requestIdleCallback !== 'undefined') {
|
||||||
|
this.#timeoutId = requestIdleCallback(
|
||||||
|
() => this.#processQueue(),
|
||||||
|
{ timeout: 150 },
|
||||||
|
) as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
this.#pendingType = 'idle';
|
||||||
|
} else {
|
||||||
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
||||||
|
this.#pendingType = 'timeout';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() (Chrome/Edge) or MessageChannel fallback. */
|
||||||
|
async #yieldToMain(): Promise<void> {
|
||||||
|
// @ts-expect-error - scheduler not in TypeScript lib yet
|
||||||
|
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
||||||
|
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
|
||||||
|
await scheduler.yield();
|
||||||
|
} else {
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
const ch = new MessageChannel();
|
||||||
|
ch.port1.onmessage = () => resolve();
|
||||||
|
ch.port2.postMessage(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */
|
||||||
|
#getEffectiveConcurrency(): number {
|
||||||
|
const nav = navigator as any;
|
||||||
|
const conn = nav.connection;
|
||||||
|
if (!conn) return 4;
|
||||||
|
|
||||||
|
switch (conn.effectiveType) {
|
||||||
|
case 'slow-2g':
|
||||||
|
case '2g':
|
||||||
|
return 1;
|
||||||
|
case '3g':
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
|
||||||
|
#shouldDeferNonCritical(): boolean {
|
||||||
|
const nav = navigator as any;
|
||||||
|
return nav.connection?.saveData === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes queued fonts in two phases:
|
||||||
|
* 1. Concurrent fetching (network I/O, non-blocking)
|
||||||
|
* 2. Sequential parsing with periodic yields (CPU-intensive, can block UI)
|
||||||
|
*
|
||||||
|
* Yielding: Chromium uses `isInputPending()` for optimal responsiveness; others yield every 8ms.
|
||||||
|
*/
|
||||||
|
async #processQueue() {
|
||||||
|
this.#timeoutId = null;
|
||||||
|
this.#pendingType = null;
|
||||||
|
|
||||||
|
let entries = Array.from(this.#queue.entries());
|
||||||
|
if (!entries.length) return;
|
||||||
|
this.#queue.clear();
|
||||||
|
|
||||||
|
if (this.#shouldDeferNonCritical()) {
|
||||||
|
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Concurrent fetching (I/O bound, non-blocking)
|
||||||
|
const concurrency = this.#getEffectiveConcurrency();
|
||||||
|
const buffers = new Map<string, ArrayBuffer>();
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i += concurrency) {
|
||||||
|
const chunk = entries.slice(i, i + concurrency);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
chunk.map(async ([key, config]) => {
|
||||||
|
this.statuses.set(key, 'loading');
|
||||||
|
const buffer = await this.#fetchFontBuffer(
|
||||||
|
config.url,
|
||||||
|
this.#abortController.signal,
|
||||||
|
);
|
||||||
|
buffers.set(key, buffer);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let j = 0; j < results.length; j++) {
|
||||||
|
if (results[j].status === 'rejected') {
|
||||||
|
const [key, config] = chunk[j];
|
||||||
|
console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason);
|
||||||
|
this.statuses.set(key, 'error');
|
||||||
|
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Sequential parsing (CPU-intensive, yields periodically)
|
||||||
|
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||||
|
let lastYield = performance.now();
|
||||||
|
const YIELD_INTERVAL = 8; // ms
|
||||||
|
|
||||||
|
for (const [key, config] of entries) {
|
||||||
|
const buffer = buffers.get(key);
|
||||||
|
if (!buffer) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
|
||||||
|
const font = new FontFace(config.name, buffer, {
|
||||||
|
weight: weightRange,
|
||||||
|
style: 'normal',
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
await font.load();
|
||||||
|
document.fonts.add(font);
|
||||||
|
this.#loadedFonts.set(key, font);
|
||||||
|
this.statuses.set(key, 'loaded');
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') continue;
|
||||||
|
console.error(`Font parse failed: ${config.name}`, e);
|
||||||
|
this.statuses.set(key, 'error');
|
||||||
|
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldYield = hasInputPending
|
||||||
|
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
||||||
|
: (performance.now() - lastYield > YIELD_INTERVAL);
|
||||||
|
|
||||||
|
if (shouldYield) {
|
||||||
|
await this.#yieldToMain();
|
||||||
|
lastYield = performance.now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the 'last seen' timestamp for fonts.
|
* Fetches font with cache-aside pattern: checks Cache API first, falls back to network.
|
||||||
* Prevents them from being purged while they are on screen.
|
* Cache failures (private browsing, quota limits) are silently ignored.
|
||||||
*/
|
*/
|
||||||
touch(slugs: string[]) {
|
async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
||||||
const now = Date.now();
|
try {
|
||||||
const toRegister: string[] = [];
|
if (typeof caches !== 'undefined') {
|
||||||
|
const cache = await caches.open(this.#CACHE_NAME);
|
||||||
slugs.forEach(slug => {
|
const cached = await cache.match(url);
|
||||||
this.#usageTracker.set(slug, now);
|
if (cached) return cached.arrayBuffer();
|
||||||
if (!this.#slugToBatch.has(slug)) {
|
|
||||||
toRegister.push(slug);
|
|
||||||
}
|
}
|
||||||
});
|
} catch {
|
||||||
|
// Cache unavailable (private browsing, security restrictions) — fall through to network
|
||||||
if (toRegister.length > 0) this.registerFonts(toRegister);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
registerFonts(slugs: string[]) {
|
const response = await fetch(url, { signal });
|
||||||
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s));
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
if (newSlugs.length === 0) return;
|
|
||||||
|
|
||||||
newSlugs.forEach(s => this.#queue.add(s));
|
try {
|
||||||
|
if (typeof caches !== 'undefined') {
|
||||||
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
const cache = await caches.open(this.#CACHE_NAME);
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
await cache.put(url, response.clone());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cache write failed (quota, storage pressure) — return font anyway
|
||||||
}
|
}
|
||||||
|
|
||||||
getFontStatus(slug: string) {
|
return response.arrayBuffer();
|
||||||
return this.statuses.get(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
#processQueue() {
|
|
||||||
const fullQueue = Array.from(this.#queue);
|
|
||||||
if (fullQueue.length === 0) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) {
|
|
||||||
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#queue.clear();
|
|
||||||
this.#timeoutId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
#createBatch(slugs: string[]) {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
|
|
||||||
const batchId = crypto.randomUUID();
|
|
||||||
// font-display=swap included for better UX
|
|
||||||
const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&');
|
|
||||||
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
|
|
||||||
|
|
||||||
// Mark all as loading immediately
|
|
||||||
slugs.forEach(slug => this.statuses.set(slug, 'loading'));
|
|
||||||
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = url;
|
|
||||||
link.dataset.batchId = batchId;
|
|
||||||
document.head.appendChild(link);
|
|
||||||
|
|
||||||
this.#batchElements.set(batchId, link);
|
|
||||||
slugs.forEach(slug => {
|
|
||||||
this.#slugToBatch.set(slug, batchId);
|
|
||||||
|
|
||||||
// Use the Native Font Loading API
|
|
||||||
// format: "font-size font-family"
|
|
||||||
document.fonts.load(`1em "${slug}"`)
|
|
||||||
.then(loadedFonts => {
|
|
||||||
if (loadedFonts.length > 0) {
|
|
||||||
this.statuses.set(slug, 'loaded');
|
|
||||||
} else {
|
|
||||||
this.statuses.set(slug, 'error');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
this.statuses.set(slug, 'error');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. */
|
||||||
#purgeUnused() {
|
#purgeUnused() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const batchesToPotentialDelete = new Set<string>();
|
for (const [key, lastUsed] of this.#usageTracker) {
|
||||||
const slugsToDelete: string[] = [];
|
if (now - lastUsed < this.#TTL) continue;
|
||||||
|
|
||||||
// Identify expired slugs
|
const font = this.#loadedFonts.get(key);
|
||||||
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
|
if (font) document.fonts.delete(font);
|
||||||
if (now - lastUsed > this.#TTL) {
|
|
||||||
const batchId = this.#slugToBatch.get(slug);
|
this.#loadedFonts.delete(key);
|
||||||
if (batchId) batchesToPotentialDelete.add(batchId);
|
this.#usageTracker.delete(key);
|
||||||
slugsToDelete.push(slug);
|
this.statuses.delete(key);
|
||||||
|
this.#retryCounts.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only remove a batch if ALL fonts in that batch are expired
|
/** Returns current loading status for a font, or undefined if never requested. */
|
||||||
batchesToPotentialDelete.forEach(batchId => {
|
getFontStatus(id: string, weight: number, isVariable = false) {
|
||||||
const batchSlugs = Array.from(this.#slugToBatch.entries())
|
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
|
||||||
.filter(([_, bId]) => bId === batchId)
|
|
||||||
.map(([slug]) => slug);
|
|
||||||
|
|
||||||
const allExpired = batchSlugs.every(s => slugsToDelete.includes(s));
|
|
||||||
|
|
||||||
if (allExpired) {
|
|
||||||
this.#batchElements.get(batchId)?.remove();
|
|
||||||
this.#batchElements.delete(batchId);
|
|
||||||
batchSlugs.forEach(s => {
|
|
||||||
this.#slugToBatch.delete(s);
|
|
||||||
this.#usageTracker.delete(s);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
/** Waits for all fonts to finish loading using document.fonts.ready. */
|
||||||
|
async ready(): Promise<void> {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
try {
|
||||||
|
await document.fonts.ready;
|
||||||
|
} catch {
|
||||||
|
// document.fonts.ready can reject in some edge cases
|
||||||
|
// (e.g., document unloaded). Silently resolve.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
|
||||||
|
destroy() {
|
||||||
|
this.#abortController.abort();
|
||||||
|
|
||||||
|
if (this.#timeoutId !== null) {
|
||||||
|
if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') {
|
||||||
|
cancelIdleCallback(this.#timeoutId as unknown as number);
|
||||||
|
} else {
|
||||||
|
clearTimeout(this.#timeoutId);
|
||||||
|
}
|
||||||
|
this.#timeoutId = null;
|
||||||
|
this.#pendingType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#intervalId) {
|
||||||
|
clearInterval(this.#intervalId);
|
||||||
|
this.#intervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
for (const font of this.#loadedFonts.values()) {
|
||||||
|
document.fonts.delete(font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#loadedFonts.clear();
|
||||||
|
this.#usageTracker.clear();
|
||||||
|
this.#retryCounts.clear();
|
||||||
|
this.statuses.clear();
|
||||||
|
this.#queue.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton instance — use throughout the application for unified font loading state. */
|
||||||
export const appliedFontsManager = new AppliedFontsManager();
|
export const appliedFontsManager = new AppliedFontsManager();
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type { UnifiedFont } from '../types';
|
|||||||
|
|
||||||
/** */
|
/** */
|
||||||
export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
||||||
// params = $state<TParams>({} as TParams);
|
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
|
|
||||||
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
||||||
@@ -18,9 +17,11 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
params = $derived.by(() => {
|
params = $derived.by(() => {
|
||||||
let merged = { ...this.#internalParams };
|
let merged = { ...this.#internalParams };
|
||||||
|
|
||||||
|
// Loop through every "Cable" plugged into the store
|
||||||
// Loop through every "Cable" plugged into the store
|
// Loop through every "Cable" plugged into the store
|
||||||
for (const getter of this.#bindings) {
|
for (const getter of this.#bindings) {
|
||||||
merged = { ...merged, ...getter() };
|
const bindingResult = getter();
|
||||||
|
merged = { ...merged, ...bindingResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged as TParams;
|
return merged as TParams;
|
||||||
@@ -54,11 +55,12 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
protected abstract getQueryKey(params: TParams): QueryKey;
|
protected abstract getQueryKey(params: TParams): QueryKey;
|
||||||
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
|
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
|
||||||
|
|
||||||
private getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||||
return {
|
return {
|
||||||
queryKey: this.getQueryKey(params),
|
queryKey: this.getQueryKey(params),
|
||||||
queryFn: () => this.fetchFn(params),
|
queryFn: () => this.fetchFn(params),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { FontshareParams } from '../../api';
|
|
||||||
import { fetchFontshareFontsQuery } from '../services';
|
|
||||||
import type { UnifiedFont } from '../types';
|
|
||||||
import { BaseFontStore } from './baseFontStore.svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fontshare store wrapping TanStack Query with runes
|
|
||||||
*/
|
|
||||||
export class FontshareStore extends BaseFontStore<FontshareParams> {
|
|
||||||
constructor(initialParams: FontshareParams = {}) {
|
|
||||||
super(initialParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getQueryKey(params: FontshareParams) {
|
|
||||||
return ['fontshare', params] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async fetchFn(params: FontshareParams): Promise<UnifiedFont[]> {
|
|
||||||
return fetchFontshareFontsQuery(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider-specific methods (shortcuts)
|
|
||||||
setSearch(search: string) {
|
|
||||||
this.setParams({ q: search } as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFontshareStore(params: FontshareParams = {}) {
|
|
||||||
return new FontshareStore(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fontshareStore = new FontshareStore();
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { GoogleFontsParams } from '../../api';
|
|
||||||
import { fetchGoogleFontsQuery } from '../services';
|
|
||||||
import type { UnifiedFont } from '../types';
|
|
||||||
import { BaseFontStore } from './baseFontStore.svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Google Fonts store wrapping TanStack Query with runes
|
|
||||||
*/
|
|
||||||
export class GoogleFontsStore extends BaseFontStore<GoogleFontsParams> {
|
|
||||||
constructor(initialParams: GoogleFontsParams = {}) {
|
|
||||||
super(initialParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getQueryKey(params: GoogleFontsParams) {
|
|
||||||
return ['googleFonts', params] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async fetchFn(params: GoogleFontsParams): Promise<UnifiedFont[]> {
|
|
||||||
return fetchGoogleFontsQuery(params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFontshareStore(params: GoogleFontsParams = {}) {
|
|
||||||
return new GoogleFontsStore(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const googleFontsStore = new GoogleFontsStore();
|
|
||||||
@@ -6,18 +6,15 @@
|
|||||||
* Single export point for the unified font store infrastructure.
|
* Single export point for the unified font store infrastructure.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// export {
|
// Primary store (unified)
|
||||||
// createUnifiedFontStore,
|
|
||||||
// UNIFIED_FONT_STORE_KEY,
|
|
||||||
// type UnifiedFontStore,
|
|
||||||
// } from './unifiedFontStore.svelte';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createFontshareStore,
|
createUnifiedFontStore,
|
||||||
type FontshareStore,
|
type UnifiedFontStore,
|
||||||
fontshareStore,
|
unifiedFontStore,
|
||||||
} from './fontshareStore.svelte';
|
} from './unifiedFontStore.svelte';
|
||||||
|
|
||||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
// Applied fonts manager (CSS loading - unchanged)
|
||||||
|
export {
|
||||||
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';
|
appliedFontsManager,
|
||||||
|
type FontConfigRequest,
|
||||||
|
} from './appliedFontsStore/appliedFontsStore.svelte';
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { createEntityStore } from '$shared/lib';
|
|
||||||
import type { UnifiedFont } from '../../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store that handles collection of selected fonts
|
|
||||||
*/
|
|
||||||
export const selectedFontsStore = createEntityStore<UnifiedFont>([]);
|
|
||||||
@@ -1,25 +1,377 @@
|
|||||||
import { type Filter } from '$shared/lib';
|
/**
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
* Unified font store
|
||||||
import type { FontProvider } from '../types';
|
*
|
||||||
import type { CheckboxFilter } from '../types/common';
|
* Single source of truth for font data, powered by the proxy API.
|
||||||
import type { BaseFontStore } from './baseFontStore.svelte';
|
* Extends BaseFontStore for TanStack Query integration and reactivity.
|
||||||
import { createFontshareStore } from './fontshareStore.svelte';
|
*
|
||||||
import type { ProviderParams } from './types';
|
* Key features:
|
||||||
|
* - Provider-agnostic (proxy API handles provider logic)
|
||||||
|
* - Reactive to filter changes
|
||||||
|
* - Optimistic updates via TanStack Query
|
||||||
|
* - Pagination support
|
||||||
|
* - Provider-specific shortcuts for common operations
|
||||||
|
*/
|
||||||
|
|
||||||
export class UnitedFontStore {
|
import type { QueryObserverOptions } from '@tanstack/query-core';
|
||||||
private sources: Partial<Record<FontProvider, BaseFontStore<ProviderParams>>>;
|
import type { ProxyFontsParams } from '../../api';
|
||||||
|
import { fetchProxyFonts } from '../../api';
|
||||||
|
import type { UnifiedFont } from '../types';
|
||||||
|
import { BaseFontStore } from './baseFontStore.svelte';
|
||||||
|
|
||||||
filters: SvelteMap<CheckboxFilter, Filter>;
|
/**
|
||||||
queryValue = $state('');
|
* Unified font store wrapping TanStack Query with Svelte 5 runes
|
||||||
|
*
|
||||||
|
* Extends BaseFontStore to provide:
|
||||||
|
* - Reactive state management
|
||||||
|
* - TanStack Query integration for caching
|
||||||
|
* - Dynamic parameter binding for filters
|
||||||
|
* - Pagination support
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const store = new UnifiedFontStore({
|
||||||
|
* provider: 'google',
|
||||||
|
* category: 'sans-serif',
|
||||||
|
* limit: 50
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Access reactive state
|
||||||
|
* $effect(() => {
|
||||||
|
* console.log(store.fonts);
|
||||||
|
* console.log(store.isLoading);
|
||||||
|
* console.log(store.pagination);
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Update parameters
|
||||||
|
* store.setCategory('serif');
|
||||||
|
* store.nextPage();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||||
|
/**
|
||||||
|
* Store pagination metadata separately from fonts
|
||||||
|
* This is a workaround for TanStack Query's type system
|
||||||
|
*/
|
||||||
|
#paginationMetadata = $state<
|
||||||
|
{
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
} | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
constructor(initialConfig: Partial<Record<FontProvider, ProviderParams>> = {}) {
|
/**
|
||||||
this.sources = {
|
* Accumulated fonts from all pages (for infinite scroll)
|
||||||
fontshare: createFontshareStore(initialConfig?.fontshare),
|
*/
|
||||||
|
#accumulatedFonts = $state<UnifiedFont[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination metadata (derived from proxy API response)
|
||||||
|
*/
|
||||||
|
readonly pagination = $derived.by(() => {
|
||||||
|
if (this.#paginationMetadata) {
|
||||||
|
const { total, limit, offset } = this.#paginationMetadata;
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: offset + limit < total,
|
||||||
|
page: Math.floor(offset / limit) + 1,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
};
|
};
|
||||||
this.filters = new SvelteMap();
|
}
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
limit: this.params.limit || 50,
|
||||||
|
offset: this.params.offset || 0,
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track previous filter params to detect changes and reset pagination
|
||||||
|
*/
|
||||||
|
#previousFilterParams = $state<string>('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup function for the filter tracking effect
|
||||||
|
*/
|
||||||
|
#filterCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(initialParams: ProxyFontsParams = {}) {
|
||||||
|
super(initialParams);
|
||||||
|
|
||||||
|
// Track filter params (excluding pagination params)
|
||||||
|
// Wrapped in $effect.root() to prevent effect_orphan error
|
||||||
|
this.#filterCleanup = $effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
const filterParams = JSON.stringify({
|
||||||
|
provider: this.params.provider,
|
||||||
|
category: this.params.category,
|
||||||
|
subset: this.params.subset,
|
||||||
|
q: this.params.q,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If filters changed, reset offset to 0
|
||||||
|
if (filterParams !== this.#previousFilterParams) {
|
||||||
|
if (this.#previousFilterParams && this.params.offset !== 0) {
|
||||||
|
this.setParams({ offset: 0 });
|
||||||
|
}
|
||||||
|
this.#previousFilterParams = filterParams;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Effect: Sync state from Query result (Handles Cache Hits)
|
||||||
|
$effect(() => {
|
||||||
|
const data = this.result.data;
|
||||||
|
const offset = this.params.offset || 0;
|
||||||
|
|
||||||
|
// When we have data and we are at the start (offset 0),
|
||||||
|
// we must ensure accumulatedFonts matches the fresh (or cached) data.
|
||||||
|
// This fixes the issue where cache hits skip fetchFn side-effects.
|
||||||
|
if (offset === 0 && data && data.length > 0) {
|
||||||
|
this.#accumulatedFonts = data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get fonts() {
|
/**
|
||||||
return Object.values(this.sources).map(store => store.fonts).flat();
|
* Clean up both parent and child effects
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
// Call parent cleanup (TanStack observer effect)
|
||||||
|
super.destroy();
|
||||||
|
|
||||||
|
// Call filter tracking effect cleanup
|
||||||
|
if (this.#filterCleanup) {
|
||||||
|
this.#filterCleanup();
|
||||||
|
this.#filterCleanup = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query key for TanStack Query caching
|
||||||
|
* Normalizes params to treat empty arrays/strings as undefined
|
||||||
|
*/
|
||||||
|
protected getQueryKey(params: ProxyFontsParams) {
|
||||||
|
// Normalize params to treat empty arrays/strings as undefined
|
||||||
|
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
|
||||||
|
if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
return { ...acc, [key]: value };
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Return a consistent key
|
||||||
|
return ['unifiedFonts', normalized] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||||
|
const hasFilters = !!(params.q || params.provider || params.category || params.subset);
|
||||||
|
return {
|
||||||
|
queryKey: this.getQueryKey(params),
|
||||||
|
queryFn: () => this.fetchFn(params),
|
||||||
|
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch function that calls the proxy API
|
||||||
|
* Returns the full response including pagination metadata
|
||||||
|
*/
|
||||||
|
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
|
||||||
|
const response = await fetchProxyFonts(params);
|
||||||
|
|
||||||
|
// Validate response structure
|
||||||
|
if (!response) {
|
||||||
|
console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params });
|
||||||
|
throw new Error('Proxy API returned undefined response');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.fonts) {
|
||||||
|
console.error('[UnifiedFontStore] response.fonts is undefined', { response });
|
||||||
|
throw new Error('Proxy API response missing fonts array');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(response.fonts)) {
|
||||||
|
console.error('[UnifiedFontStore] response.fonts is not an array', {
|
||||||
|
fonts: response.fonts,
|
||||||
|
});
|
||||||
|
throw new Error('Proxy API fonts is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store pagination metadata separately for derived values
|
||||||
|
this.#paginationMetadata = {
|
||||||
|
total: response.total ?? 0,
|
||||||
|
limit: response.limit ?? this.params.limit ?? 50,
|
||||||
|
offset: response.offset ?? this.params.offset ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accumulate fonts for infinite scroll
|
||||||
|
// Note: For offset === 0, we rely on the $effect above to handle the reset/init
|
||||||
|
// This prevents race conditions and double-setting.
|
||||||
|
if (params.offset !== 0) {
|
||||||
|
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.fonts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Getters (proxied from BaseFontStore) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all accumulated fonts (for infinite scroll)
|
||||||
|
*/
|
||||||
|
get fonts(): UnifiedFont[] {
|
||||||
|
return this.#accumulatedFonts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if loading initial data
|
||||||
|
*/
|
||||||
|
get isLoading(): boolean {
|
||||||
|
return this.result.isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if fetching (including background refetches)
|
||||||
|
*/
|
||||||
|
get isFetching(): boolean {
|
||||||
|
return this.result.isFetching;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error occurred
|
||||||
|
*/
|
||||||
|
get isError(): boolean {
|
||||||
|
return this.result.isError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if result is empty (not loading and no fonts)
|
||||||
|
*/
|
||||||
|
get isEmpty(): boolean {
|
||||||
|
return !this.isLoading && this.fonts.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Provider-specific shortcuts ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set provider filter
|
||||||
|
*/
|
||||||
|
setProvider(provider: 'google' | 'fontshare' | undefined) {
|
||||||
|
this.setParams({ provider });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set category filter
|
||||||
|
*/
|
||||||
|
setCategory(category: ProxyFontsParams['category']) {
|
||||||
|
this.setParams({ category });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set subset filter
|
||||||
|
*/
|
||||||
|
setSubset(subset: ProxyFontsParams['subset']) {
|
||||||
|
this.setParams({ subset });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set search query
|
||||||
|
*/
|
||||||
|
setSearch(search: string) {
|
||||||
|
this.setParams({ q: search || undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set sort order
|
||||||
|
*/
|
||||||
|
setSort(sort: ProxyFontsParams['sort']) {
|
||||||
|
this.setParams({ sort });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pagination methods ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to next page
|
||||||
|
*/
|
||||||
|
nextPage() {
|
||||||
|
if (this.pagination.hasMore) {
|
||||||
|
this.setParams({
|
||||||
|
offset: this.pagination.offset + this.pagination.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to previous page
|
||||||
|
*/
|
||||||
|
prevPage() {
|
||||||
|
if (this.pagination.page > 1) {
|
||||||
|
this.setParams({
|
||||||
|
offset: this.pagination.offset - this.pagination.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to specific page
|
||||||
|
*/
|
||||||
|
goToPage(page: number) {
|
||||||
|
if (page >= 1 && page <= this.pagination.totalPages) {
|
||||||
|
this.setParams({
|
||||||
|
offset: (page - 1) * this.pagination.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set limit (items per page)
|
||||||
|
*/
|
||||||
|
setLimit(limit: number) {
|
||||||
|
this.setParams({ limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Category shortcuts (for convenience) ---
|
||||||
|
|
||||||
|
get sansSerifFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'sans-serif');
|
||||||
|
}
|
||||||
|
|
||||||
|
get serifFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'serif');
|
||||||
|
}
|
||||||
|
|
||||||
|
get displayFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'display');
|
||||||
|
}
|
||||||
|
|
||||||
|
get handwritingFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'handwriting');
|
||||||
|
}
|
||||||
|
|
||||||
|
get monospaceFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'monospace');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create unified font store
|
||||||
|
*/
|
||||||
|
export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
|
||||||
|
return new UnifiedFontStore(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance for global use
|
||||||
|
* Initialized with a default limit to prevent fetching all fonts at once
|
||||||
|
*/
|
||||||
|
export const unifiedFontStore = new UnifiedFontStore({
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,3 +32,27 @@ export interface FontFilters {
|
|||||||
|
|
||||||
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
|
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
|
||||||
export type FilterType = CheckboxFilter | 'searchQuery';
|
export type FilterType = CheckboxFilter | 'searchQuery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard font weights
|
||||||
|
*/
|
||||||
|
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Italic variant format: e.g., "100italic", "400italic", "700italic"
|
||||||
|
*/
|
||||||
|
export type FontWeightItalic = `${FontWeight}italic`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All possible font variants
|
||||||
|
* - Numeric weights: "400", "700", etc.
|
||||||
|
* - Italic variants: "400italic", "700italic", etc.
|
||||||
|
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
||||||
|
*/
|
||||||
|
export type FontVariant =
|
||||||
|
| FontWeight
|
||||||
|
| FontWeightItalic
|
||||||
|
| 'regular'
|
||||||
|
| 'italic'
|
||||||
|
| 'bold'
|
||||||
|
| 'bolditalic';
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* ============================================================================
|
* ============================================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { FontVariant } from './common';
|
||||||
|
|
||||||
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,30 +88,6 @@ export interface FontItem {
|
|||||||
*/
|
*/
|
||||||
export type GoogleFontItem = FontItem;
|
export type GoogleFontItem = FontItem;
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard font weights that can appear in Google Fonts API
|
|
||||||
*/
|
|
||||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Italic variant format: e.g., "100italic", "400italic", "700italic"
|
|
||||||
*/
|
|
||||||
export type FontWeightItalic = `${FontWeight}italic`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All possible font variants in Google Fonts API
|
|
||||||
* - Numeric weights: "400", "700", etc.
|
|
||||||
* - Italic variants: "400italic", "700italic", etc.
|
|
||||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
|
||||||
*/
|
|
||||||
export type FontVariant =
|
|
||||||
| FontWeight
|
|
||||||
| FontWeightItalic
|
|
||||||
| 'regular'
|
|
||||||
| 'italic'
|
|
||||||
| 'bold'
|
|
||||||
| 'bolditalic';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google Fonts API file mapping
|
* Google Fonts API file mapping
|
||||||
* Dynamic keys that match the variants array
|
* Dynamic keys that match the variants array
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ export type {
|
|||||||
FontCategory,
|
FontCategory,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
FontSubset,
|
FontSubset,
|
||||||
|
FontVariant,
|
||||||
|
FontWeight,
|
||||||
|
FontWeightItalic,
|
||||||
} from './common';
|
} from './common';
|
||||||
|
|
||||||
// Google Fonts API types
|
// Google Fonts API types
|
||||||
export type {
|
export type {
|
||||||
FontFiles,
|
FontFiles,
|
||||||
FontItem,
|
FontItem,
|
||||||
FontVariant,
|
|
||||||
FontWeight,
|
|
||||||
FontWeightItalic,
|
|
||||||
GoogleFontItem,
|
GoogleFontItem,
|
||||||
GoogleFontsApiModel,
|
GoogleFontsApiModel,
|
||||||
} from './google';
|
} from './google';
|
||||||
|
|||||||
@@ -8,17 +8,18 @@ import type {
|
|||||||
FontCategory,
|
FontCategory,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
FontSubset,
|
FontSubset,
|
||||||
|
FontVariant,
|
||||||
} from './common';
|
} from './common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font variant types (standardized)
|
* Font variant types (standardized)
|
||||||
*/
|
*/
|
||||||
export type UnifiedFontVariant = string;
|
export type UnifiedFontVariant = FontVariant;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font style URLs
|
* Font style URLs
|
||||||
*/
|
*/
|
||||||
export interface FontStyleUrls {
|
export interface LegacyFontStyleUrls {
|
||||||
/** Regular weight URL */
|
/** Regular weight URL */
|
||||||
regular?: string;
|
regular?: string;
|
||||||
/** Italic URL */
|
/** Italic URL */
|
||||||
@@ -29,6 +30,10 @@ export interface FontStyleUrls {
|
|||||||
boldItalic?: string;
|
boldItalic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FontStyleUrls extends LegacyFontStyleUrls {
|
||||||
|
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font metadata
|
* Font metadata
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,24 +2,27 @@
|
|||||||
Component: FontApplicator
|
Component: FontApplicator
|
||||||
Loads fonts from fontshare with link tag
|
Loads fonts from fontshare with link tag
|
||||||
- Loads font only if it's not already applied
|
- Loads font only if it's not already applied
|
||||||
- Uses IntersectionObserver to detect when font is visible
|
- Reacts to font load status to show/hide content
|
||||||
- Adds smooth transition when font appears
|
- Adds smooth transition when font appears
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { motion } from '$shared/lib';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { appliedFontsManager } from '../../model';
|
import { prefersReducedMotion } from 'svelte/motion';
|
||||||
|
import {
|
||||||
|
type UnifiedFont,
|
||||||
|
appliedFontsManager,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Font name to set
|
* Applied font
|
||||||
*/
|
*/
|
||||||
name: string;
|
font: UnifiedFont;
|
||||||
/**
|
/**
|
||||||
* Font id to load
|
* Font weight
|
||||||
*/
|
*/
|
||||||
id: string;
|
weight?: number;
|
||||||
/**
|
/**
|
||||||
* Additional classes
|
* Additional classes
|
||||||
*/
|
*/
|
||||||
@@ -30,46 +33,43 @@ interface Props {
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { name, id, className, children }: Props = $props();
|
let {
|
||||||
let element: Element;
|
font,
|
||||||
|
weight = 400,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
// Track if the user has actually scrolled this into view
|
const status = $derived(
|
||||||
let hasEnteredViewport = $state(false);
|
appliedFontsManager.getFontStatus(
|
||||||
|
font.id,
|
||||||
|
weight,
|
||||||
|
font.features.isVariable,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
|
||||||
const observer = new IntersectionObserver(entries => {
|
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||||
if (entries[0].isIntersecting) {
|
|
||||||
hasEnteredViewport = true;
|
|
||||||
appliedFontsManager.touch([id]);
|
|
||||||
|
|
||||||
// Once it has entered, we can stop observing to save CPU
|
|
||||||
observer.unobserve(element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
observer.observe(element);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = $derived(appliedFontsManager.getFontStatus(id));
|
|
||||||
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
|
|
||||||
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
|
|
||||||
|
|
||||||
const transitionClasses = $derived(
|
const transitionClasses = $derived(
|
||||||
motion.reduced
|
prefersReducedMotion.current
|
||||||
? 'transition-none' // Disable CSS transitions if motion is reduced
|
? 'transition-none' // Disable CSS transitions if motion is reduced
|
||||||
: 'transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
style:font-family={shouldReveal
|
||||||
style:font-family={name}
|
? `'${font.name}'`
|
||||||
|
: 'system-ui, -apple-system, sans-serif'}
|
||||||
class={cn(
|
class={cn(
|
||||||
transitionClasses,
|
transitionClasses,
|
||||||
// If reduced motion is on, we skip the transform/blur entirely
|
// If reduced motion is on, we skip the transform/blur entirely
|
||||||
!shouldReveal && !motion.reduced && 'opacity-0 translate-y-8 scale-[0.98] blur-sm',
|
!shouldReveal
|
||||||
!shouldReveal && motion.reduced && 'opacity-0', // Still hide until font is ready, but no movement
|
&& !prefersReducedMotion.current
|
||||||
shouldReveal && 'opacity-100 translate-y-0 scale-100 blur-0',
|
&& 'opacity-50 scale-[0.95] blur-sm',
|
||||||
|
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
||||||
|
shouldReveal && 'opacity-100 scale-100 blur-0',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,84 +1,39 @@
|
|||||||
<!--
|
|
||||||
Component: FontListItem
|
|
||||||
Displays a font item with a checkbox and its characteristics in badges.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Badge } from '$shared/shadcn/ui/badge';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
import type { Snippet } from 'svelte';
|
||||||
import { Label } from '$shared/shadcn/ui/label';
|
import { type UnifiedFont } from '../../model';
|
||||||
import {
|
|
||||||
type UnifiedFont,
|
|
||||||
selectedFontsStore,
|
|
||||||
} from '../../model';
|
|
||||||
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Object with information about font
|
* Object with information about font
|
||||||
*/
|
*/
|
||||||
font: UnifiedFont;
|
font: UnifiedFont;
|
||||||
|
/**
|
||||||
|
* Is element fully visible
|
||||||
|
*/
|
||||||
|
isFullyVisible: boolean;
|
||||||
|
/**
|
||||||
|
* Is element partially visible
|
||||||
|
*/
|
||||||
|
isPartiallyVisible: boolean;
|
||||||
|
/**
|
||||||
|
* From 0 to 1
|
||||||
|
*/
|
||||||
|
proximity: number;
|
||||||
|
/**
|
||||||
|
* Children snippet
|
||||||
|
*/
|
||||||
|
children: Snippet<[font: UnifiedFont]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { font }: Props = $props();
|
const { font, children }: Props = $props();
|
||||||
|
|
||||||
const handleChange = (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
selectedFontsStore.addOne(font);
|
|
||||||
} else {
|
|
||||||
selectedFontsStore.removeOne(font.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selected = $derived(selectedFontsStore.has(font.id));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="pb-1">
|
<div
|
||||||
<Label
|
class={cn(
|
||||||
for={font.id}
|
'pb-1 will-change-transform transition-transform duration-200 ease-out',
|
||||||
class="
|
'hover:scale-[0.98]', // Simple CSS hover effect
|
||||||
w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3
|
)}
|
||||||
active:scale-[0.98] active:transition-transform active:duration-75
|
|
||||||
has-aria-checked:border-blue-600
|
|
||||||
has-aria-checked:bg-blue-50
|
|
||||||
dark:has-aria-checked:border-blue-900
|
|
||||||
dark:has-aria-checked:bg-blue-950
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div class="w-full">
|
{@render children?.(font)}
|
||||||
<div class="flex flex-row gap-1 w-full items-center justify-between">
|
|
||||||
<div class="flex flex-col gap-1 transition-all duration-150 ease-out">
|
|
||||||
<div class="flex flex-row gap-1">
|
|
||||||
<Badge variant="outline" class="text-[0.5rem]">
|
|
||||||
{font.provider}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" class="text-[0.5rem]">
|
|
||||||
{font.category}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<FontApplicator
|
|
||||||
id={font.id}
|
|
||||||
className="text-2xl"
|
|
||||||
name={font.name}
|
|
||||||
>
|
|
||||||
{font.name}
|
|
||||||
</FontApplicator>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
id={font.id}
|
|
||||||
checked={selected}
|
|
||||||
onCheckedChange={handleChange}
|
|
||||||
class="
|
|
||||||
transition-all duration-150 ease-out
|
|
||||||
data-[state=checked]:scale-100
|
|
||||||
data-[state=checked]:border-blue-600
|
|
||||||
data-[state=checked]:bg-blue-600
|
|
||||||
data-[state=checked]:text-white
|
|
||||||
dark:data-[state=checked]:border-blue-700
|
|
||||||
dark:data-[state=checked]:bg-blue-700
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,33 +3,127 @@
|
|||||||
- Renders a virtualized list of fonts
|
- Renders a virtualized list of fonts
|
||||||
- Handles font registration with the manager
|
- Handles font registration with the manager
|
||||||
-->
|
-->
|
||||||
<script lang="ts" generics="T extends { id: string }">
|
<script lang="ts">
|
||||||
import { VirtualList } from '$shared/ui';
|
import {
|
||||||
import type { ComponentProps } from 'svelte';
|
Skeleton,
|
||||||
import { appliedFontsManager } from '../../model';
|
VirtualList,
|
||||||
|
} from '$shared/ui';
|
||||||
|
import type {
|
||||||
|
ComponentProps,
|
||||||
|
Snippet,
|
||||||
|
} from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { getFontUrl } from '../../lib';
|
||||||
|
import {
|
||||||
|
type FontConfigRequest,
|
||||||
|
type UnifiedFont,
|
||||||
|
appliedFontsManager,
|
||||||
|
unifiedFontStore,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
|
interface Props extends
|
||||||
onVisibleItemsChange?: (items: T[]) => void;
|
Omit<
|
||||||
|
ComponentProps<typeof VirtualList<UnifiedFont>>,
|
||||||
|
'items' | 'total' | 'isLoading' | 'onVisibleItemsChange' | 'onNearBottom'
|
||||||
|
>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Callback for when visible items change
|
||||||
|
*/
|
||||||
|
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
||||||
|
/**
|
||||||
|
* Weight of the font
|
||||||
|
*/
|
||||||
|
weight: number;
|
||||||
|
/**
|
||||||
|
* Skeleton snippet
|
||||||
|
*/
|
||||||
|
skeleton?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { items, children, onVisibleItemsChange, ...rest }: Props = $props();
|
let {
|
||||||
|
children,
|
||||||
|
onVisibleItemsChange,
|
||||||
|
weight,
|
||||||
|
skeleton,
|
||||||
|
...rest
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
function handleInternalVisibleChange(visibleItems: T[]) {
|
const isLoading = $derived(
|
||||||
|
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||||
|
const configs: FontConfigRequest[] = [];
|
||||||
|
|
||||||
|
visibleItems.forEach(item => {
|
||||||
|
const url = getFontUrl(item, weight);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
configs.push({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
weight,
|
||||||
|
url,
|
||||||
|
isVariable: item.features?.isVariable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
// Auto-register fonts with the manager
|
// Auto-register fonts with the manager
|
||||||
const slugs = visibleItems.map(item => item.id);
|
appliedFontsManager.touch(configs);
|
||||||
appliedFontsManager.registerFonts(slugs);
|
|
||||||
|
|
||||||
// Forward the call to any external listener
|
// Forward the call to any external listener
|
||||||
onVisibleItemsChange?.(visibleItems);
|
// onVisibleItemsChange?.(visibleItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load more fonts by moving to the next page
|
||||||
|
*/
|
||||||
|
function loadMore() {
|
||||||
|
if (
|
||||||
|
!unifiedFontStore.pagination.hasMore
|
||||||
|
|| unifiedFontStore.isFetching
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unifiedFontStore.nextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle scroll near bottom - auto-load next page
|
||||||
|
*
|
||||||
|
* Triggered by VirtualList when the user scrolls within 5 items of the end
|
||||||
|
* of the loaded items. Only fetches if there are more pages available.
|
||||||
|
*/
|
||||||
|
function handleNearBottom(_lastVisibleIndex: number) {
|
||||||
|
const { hasMore } = unifiedFontStore.pagination;
|
||||||
|
|
||||||
|
// VirtualList already checks if we're near the bottom of loaded items
|
||||||
|
if (hasMore && !unifiedFontStore.isFetching) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="relative w-full h-full">
|
||||||
|
{#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
|
||||||
|
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
||||||
|
<div transition:fade={{ duration: 300 }}>
|
||||||
|
{@render skeleton()}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
<VirtualList
|
<VirtualList
|
||||||
{items}
|
items={unifiedFontStore.fonts}
|
||||||
{...rest}
|
total={unifiedFontStore.pagination.total}
|
||||||
|
isLoading={isLoading}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
|
onNearBottom={handleNearBottom}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
{#snippet children(scope)}
|
{#snippet children(scope)}
|
||||||
{@render children(scope)}
|
{@render children(scope)}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</VirtualList>
|
</VirtualList>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { FontDisplay } from './ui';
|
export { FontSampler } from './ui';
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { displayedFontsStore } from './store';
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { selectedFontsStore } from '$entities/Font';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store for displayed font samples
|
|
||||||
* - Handles shown text
|
|
||||||
* - Stores selected fonts for display
|
|
||||||
*/
|
|
||||||
export class DisplayedFontsStore {
|
|
||||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
|
||||||
|
|
||||||
#displayedFonts = $derived.by(() => {
|
|
||||||
return selectedFontsStore.all;
|
|
||||||
});
|
|
||||||
|
|
||||||
get fonts() {
|
|
||||||
return this.#displayedFonts;
|
|
||||||
}
|
|
||||||
|
|
||||||
get text() {
|
|
||||||
return this.#sampleText;
|
|
||||||
}
|
|
||||||
|
|
||||||
set text(text: string) {
|
|
||||||
this.#sampleText = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const displayedFontsStore = new DisplayedFontsStore();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { displayedFontsStore } from './displayedFontsStore.svelte';
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: FontDisplay
|
|
||||||
Displays a grid of FontSampler components for each displayed font.
|
|
||||||
-->
|
|
||||||
<script>
|
|
||||||
import { displayedFontsStore } from '../../model';
|
|
||||||
import FontSampler from '../FontSampler/FontSampler.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
|
|
||||||
{#each displayedFontsStore.fonts as font (font.id)}
|
|
||||||
<FontSampler font={font} bind:text={displayedFontsStore.text} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
FontApplicator,
|
FontApplicator,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { ContentEditable } from '$shared/ui';
|
import { controlManager } from '$features/SetupFont';
|
||||||
|
import {
|
||||||
|
ContentEditable,
|
||||||
|
Footnote,
|
||||||
|
// IconButton,
|
||||||
|
} from '$shared/ui';
|
||||||
|
// import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +24,10 @@ interface Props {
|
|||||||
* Text to display
|
* Text to display
|
||||||
*/
|
*/
|
||||||
text: string;
|
text: string;
|
||||||
|
/**
|
||||||
|
* Index of the font sampler
|
||||||
|
*/
|
||||||
|
index?: number;
|
||||||
/**
|
/**
|
||||||
* Font settings
|
* Font settings
|
||||||
*/
|
*/
|
||||||
@@ -26,21 +36,75 @@ interface Props {
|
|||||||
letterSpacing?: number;
|
letterSpacing?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { font, text = $bindable(), index = 0, ...restProps }: Props = $props();
|
||||||
font,
|
|
||||||
text = $bindable(),
|
const fontWeight = $derived(controlManager.weight);
|
||||||
...restProps
|
const fontSize = $derived(controlManager.renderedSize);
|
||||||
}: Props = $props();
|
const lineHeight = $derived(controlManager.height);
|
||||||
|
const letterSpacing = $derived(controlManager.spacing);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
w-full rounded-xl
|
w-full h-full rounded-xl sm:rounded-2xl
|
||||||
bg-white p-6 border border-slate-200
|
flex flex-col
|
||||||
shadow-sm dark:border-slate-800 dark:bg-slate-950
|
bg-background-80
|
||||||
|
border border-border-muted
|
||||||
|
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||||
|
relative overflow-hidden
|
||||||
"
|
"
|
||||||
|
style:font-weight={fontWeight}
|
||||||
>
|
>
|
||||||
<FontApplicator id={font.id} name={font.name}>
|
<div class="px-4 sm:px-5 md:px-6 py-2.5 sm:py-3 border-b border-border-subtle flex items-center justify-between">
|
||||||
<ContentEditable bind:text={text} {...restProps} />
|
<div class="flex items-center gap-2 sm:gap-2.5">
|
||||||
|
<Footnote>
|
||||||
|
typeface_{String(index).padStart(3, '0')}
|
||||||
|
</Footnote>
|
||||||
|
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
|
||||||
|
<div class="font-bold text-foreground">
|
||||||
|
{font.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
<IconButton
|
||||||
|
onclick={removeSample}
|
||||||
|
class="w-5 h-5 rounded-full hover:bg-transparent flex items-center justify-center transition-colors group translate-x-1/2 cursor-pointer"
|
||||||
|
>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<XIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 sm:p-5 md:p-8 relative z-10">
|
||||||
|
<FontApplicator {font} weight={fontWeight}>
|
||||||
|
<ContentEditable
|
||||||
|
bind:text
|
||||||
|
{...restProps}
|
||||||
|
{fontSize}
|
||||||
|
{lineHeight}
|
||||||
|
{letterSpacing}
|
||||||
|
/>
|
||||||
</FontApplicator>
|
</FontApplicator>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 sm:px-5 md:px-6 py-1.5 sm:py-2 border-t border-border-subtle w-full flex flex-row gap-2 sm:gap-4 bg-background mt-auto">
|
||||||
|
<Footnote class="text-[7px] sm:text-[8px] tracking-wider ml-auto">
|
||||||
|
SZ:{fontSize}PX
|
||||||
|
</Footnote>
|
||||||
|
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||||
|
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||||
|
WGT:{fontWeight}
|
||||||
|
</Footnote>
|
||||||
|
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||||
|
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||||
|
LH:{lineHeight?.toFixed(2)}
|
||||||
|
</Footnote>
|
||||||
|
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||||
|
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider">
|
||||||
|
LTR:{letterSpacing}
|
||||||
|
</Footnote>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import FontDisplay from './FontDisplay/FontDisplay.svelte';
|
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||||
|
|
||||||
export { FontDisplay };
|
export { FontSampler };
|
||||||
|
|||||||
@@ -15,5 +15,4 @@ export { filterManager } from './model/state/manager.svelte';
|
|||||||
export {
|
export {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
FontSearch,
|
|
||||||
} from './ui';
|
} from './ui';
|
||||||
|
|||||||
@@ -1,18 +1,54 @@
|
|||||||
import type { FontshareParams } from '$entities/Font';
|
import type { ProxyFontsParams } from '$entities/Font/api';
|
||||||
import type { FilterManager } from '../filterManager/filterManager.svelte';
|
import type { FilterManager } from '../filterManager/filterManager.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps filter manager to fontshare params.
|
* Maps filter manager to proxy API parameters.
|
||||||
*
|
*
|
||||||
* @param manager - Filter manager instance.
|
* Transforms UI filter state into proxy API query parameters.
|
||||||
* @returns - Partial fontshare params.
|
* Handles conversion from filter groups to API-specific parameters.
|
||||||
|
*
|
||||||
|
* @param manager - Filter manager instance with reactive state
|
||||||
|
* @returns - Partial proxy API parameters ready for API call
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Example filter manager state:
|
||||||
|
* // {
|
||||||
|
* // queryValue: 'roboto',
|
||||||
|
* // providers: ['google'],
|
||||||
|
* // categories: ['sans-serif'],
|
||||||
|
* // subsets: ['latin']
|
||||||
|
* // }
|
||||||
|
*
|
||||||
|
* const params = mapManagerToParams(manager);
|
||||||
|
* // Returns: { provider: 'google', category: 'sans-serif', subset: 'latin', q: 'roboto' }
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function mapManagerToParams(manager: FilterManager): Partial<FontshareParams> {
|
export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
|
||||||
|
const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value);
|
||||||
|
const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value);
|
||||||
|
const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
q: manager.debouncedQueryValue,
|
// Search query (debounced)
|
||||||
// Map groups to specific API keys
|
q: manager.debouncedQueryValue || undefined,
|
||||||
categories: manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value)
|
|
||||||
?? [],
|
// Provider filter (single value - proxy API doesn't support array)
|
||||||
tags: manager.getGroup('tags')?.instance.selectedProperties.map(p => p.value) ?? [],
|
// Use first provider if multiple selected, or undefined if none/all selected
|
||||||
|
provider: providers && providers.length === 1
|
||||||
|
? (providers[0] as 'google' | 'fontshare')
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// Category filter (single value - proxy API doesn't support array)
|
||||||
|
// Use first category if multiple selected, or undefined if none/all selected
|
||||||
|
category: categories && categories.length === 1
|
||||||
|
? (categories[0] as ProxyFontsParams['category'])
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// Subset filter (single value - proxy API doesn't support array)
|
||||||
|
// Use first subset if multiple selected, or undefined if none/all selected
|
||||||
|
subset: subsets && subsets.length === 1
|
||||||
|
? (subsets[0] as ProxyFontsParams['subset'])
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,42 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$shared/shadcn/ui/button';
|
import { Button } from '$shared/shadcn/ui/button';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import Rotate from '@lucide/svelte/icons/rotate-ccw';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
import { Tween } from 'svelte/motion';
|
||||||
import { filterManager } from '../../model';
|
import { filterManager } from '../../model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className }: Props = $props();
|
||||||
|
|
||||||
|
const transform = new Tween(
|
||||||
|
{ scale: 1, rotate: 0 },
|
||||||
|
{ duration: 150, easing: cubicOut },
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
filterManager.deselectAllGlobal();
|
||||||
|
|
||||||
|
transform.set({ scale: 0.98, rotate: 1 }).then(() => {
|
||||||
|
transform.set({ scale: 1, rotate: 0 });
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-row gap-2">
|
<div
|
||||||
<Button
|
class={cn('flex flex-row gap-2', className)}
|
||||||
variant="outline"
|
style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
|
||||||
class="flex-1 cursor-pointer"
|
|
||||||
onclick={filterManager.deselectAllGlobal}
|
|
||||||
>
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="group flex flex-1 cursor-pointer gap-1"
|
||||||
|
onclick={handleClick}
|
||||||
|
>
|
||||||
|
<Rotate class="size-4 group-hover:-rotate-180 transition-transform duration-300" />
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: FontSearch
|
|
||||||
|
|
||||||
Combines search input with font list display
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { fontshareStore } from '$entities/Font';
|
|
||||||
import { SearchBar } from '$shared/ui';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { mapManagerToParams } from '../../lib';
|
|
||||||
import { filterManager } from '../../model';
|
|
||||||
import SuggestedFonts from '../SuggestedFonts/SuggestedFonts.svelte';
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
/**
|
|
||||||
* The Pairing:
|
|
||||||
* We "plug" this manager into the global store.
|
|
||||||
* addBinding returns a function that removes this binding when the component unmounts.
|
|
||||||
*/
|
|
||||||
const unbind = fontshareStore.addBinding(() => mapManagerToParams(filterManager));
|
|
||||||
|
|
||||||
return unbind;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<SearchBar
|
|
||||||
id="font-search"
|
|
||||||
class="w-full"
|
|
||||||
placeholder="Search fonts by name..."
|
|
||||||
bind:value={filterManager.queryValue}
|
|
||||||
>
|
|
||||||
<SuggestedFonts />
|
|
||||||
</SearchBar>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: SuggestedFonts
|
|
||||||
Renders a list of suggested fonts in a virtualized list to improve performance.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
FontListItem,
|
|
||||||
FontVirtualList,
|
|
||||||
fontshareStore,
|
|
||||||
} from '$entities/Font';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FontVirtualList items={fontshareStore.fonts}>
|
|
||||||
{#snippet children({ item: font })}
|
|
||||||
<FontListItem {font} />
|
|
||||||
{/snippet}
|
|
||||||
</FontVirtualList>
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import Filters from './Filters/Filters.svelte';
|
import Filters from './Filters/Filters.svelte';
|
||||||
import FilterControls from './FiltersControl/FilterControls.svelte';
|
import FilterControls from './FiltersControl/FilterControls.svelte';
|
||||||
import FontSearch from './FontSearch/FontSearch.svelte';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
FontSearch,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import SetupFontMenu from './ui/SetupFontMenu.svelte';
|
export { TypographyMenu } from './ui';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
type ControlId,
|
||||||
controlManager,
|
controlManager,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
FONT_SIZE_STEP,
|
FONT_SIZE_STEP,
|
||||||
FONT_WEIGHT_STEP,
|
FONT_WEIGHT_STEP,
|
||||||
LINE_HEIGHT_STEP,
|
LINE_HEIGHT_STEP,
|
||||||
@@ -14,5 +17,12 @@ export {
|
|||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MIN_FONT_WEIGHT,
|
MIN_FONT_WEIGHT,
|
||||||
MIN_LINE_HEIGHT,
|
MIN_LINE_HEIGHT,
|
||||||
|
MULTIPLIER_L,
|
||||||
|
MULTIPLIER_M,
|
||||||
|
MULTIPLIER_S,
|
||||||
} from './model';
|
} from './model';
|
||||||
export { SetupFontMenu };
|
|
||||||
|
export {
|
||||||
|
createTypographyControlManager,
|
||||||
|
type TypographyControlManager,
|
||||||
|
} from './lib';
|
||||||
|
|||||||
@@ -1,28 +1,215 @@
|
|||||||
import {
|
import {
|
||||||
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
|
type PersistentStore,
|
||||||
|
type TypographyControl,
|
||||||
|
createPersistentStore,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import {
|
||||||
|
type ControlId,
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
|
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||||
|
|
||||||
|
export interface Control extends ControlOnlyFields<ControlId> {
|
||||||
|
instance: TypographyControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TypographyControlManager {
|
||||||
|
#controls = new SvelteMap<string, Control>();
|
||||||
|
#multiplier = $state(1);
|
||||||
|
#storage: PersistentStore<TypographySettings>;
|
||||||
|
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||||
|
|
||||||
|
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||||
|
this.#storage = storage;
|
||||||
|
|
||||||
|
// Initial Load
|
||||||
|
const saved = storage.value;
|
||||||
|
this.#baseSize = saved.fontSize;
|
||||||
|
|
||||||
|
// Setup Controls
|
||||||
|
configs.forEach(config => {
|
||||||
|
const initialValue = this.#getInitialValue(config.id, saved);
|
||||||
|
|
||||||
|
this.#controls.set(config.id, {
|
||||||
|
...config,
|
||||||
|
instance: createTypographyControl({
|
||||||
|
...config,
|
||||||
|
value: initialValue,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Sync Effect (UI -> Storage)
|
||||||
|
// We access .value explicitly to ensure Svelte 5 tracks the dependency
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
|
||||||
|
const fontSize = this.#baseSize;
|
||||||
|
const fontWeight = this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
||||||
|
const lineHeight = this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
||||||
|
const letterSpacing = this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||||
|
|
||||||
|
// Syncing back to storage
|
||||||
|
this.#storage.value = {
|
||||||
|
fontSize,
|
||||||
|
fontWeight,
|
||||||
|
lineHeight,
|
||||||
|
letterSpacing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Font Size Proxy Effect
|
||||||
|
// This handles the "Multiplier" logic specifically for the Font Size Control
|
||||||
|
$effect(() => {
|
||||||
|
const ctrl = this.#controls.get('font_size')?.instance;
|
||||||
|
if (!ctrl) return;
|
||||||
|
|
||||||
|
// If the user moves the slider/clicks buttons in the UI:
|
||||||
|
// We update the baseSize (User Intent)
|
||||||
|
const currentDisplayValue = ctrl.value;
|
||||||
|
const calculatedBase = currentDisplayValue / this.#multiplier;
|
||||||
|
|
||||||
|
// Only update if the difference is significant (prevents rounding jitter)
|
||||||
|
if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
|
||||||
|
this.#baseSize = calculatedBase;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#getInitialValue(id: string, saved: TypographySettings): number {
|
||||||
|
if (id === 'font_size') return saved.fontSize * this.#multiplier;
|
||||||
|
if (id === 'font_weight') return saved.fontWeight;
|
||||||
|
if (id === 'line_height') return saved.lineHeight;
|
||||||
|
if (id === 'letter_spacing') return saved.letterSpacing;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Getters / Setters ---
|
||||||
|
|
||||||
|
get multiplier() {
|
||||||
|
return this.#multiplier;
|
||||||
|
}
|
||||||
|
set multiplier(value: number) {
|
||||||
|
if (this.#multiplier === value) return;
|
||||||
|
this.#multiplier = value;
|
||||||
|
|
||||||
|
// When multiplier changes, we must update the Font Size Control's display value
|
||||||
|
const ctrl = this.#controls.get('font_size')?.instance;
|
||||||
|
if (ctrl) {
|
||||||
|
ctrl.value = this.#baseSize * this.#multiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The scaled size for CSS usage */
|
||||||
|
get renderedSize() {
|
||||||
|
return this.#baseSize * this.#multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The base size (User Preference) */
|
||||||
|
get baseSize() {
|
||||||
|
return this.#baseSize;
|
||||||
|
}
|
||||||
|
set baseSize(val: number) {
|
||||||
|
this.#baseSize = val;
|
||||||
|
const ctrl = this.#controls.get('font_size')?.instance;
|
||||||
|
if (ctrl) ctrl.value = val * this.#multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getters for controls
|
||||||
|
*/
|
||||||
|
get controls() {
|
||||||
|
return Array.from(this.#controls.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
get weightControl() {
|
||||||
|
return this.#controls.get('font_weight')?.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
get sizeControl() {
|
||||||
|
return this.#controls.get('font_size')?.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
get heightControl() {
|
||||||
|
return this.#controls.get('line_height')?.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
get spacingControl() {
|
||||||
|
return this.#controls.get('letter_spacing')?.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getters for values (besides font-size)
|
||||||
|
*/
|
||||||
|
get weight() {
|
||||||
|
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
get height() {
|
||||||
|
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
get spacing() {
|
||||||
|
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.#storage.clear();
|
||||||
|
const defaults = this.#storage.value;
|
||||||
|
|
||||||
|
this.#baseSize = defaults.fontSize;
|
||||||
|
|
||||||
|
// Reset all control instances
|
||||||
|
this.#controls.forEach(c => {
|
||||||
|
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;
|
||||||
|
if (c.id === 'line_height') c.instance.value = defaults.lineHeight;
|
||||||
|
if (c.id === 'letter_spacing') c.instance.value = defaults.letterSpacing;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage schema for typography settings
|
||||||
|
*/
|
||||||
|
export interface TypographySettings {
|
||||||
|
fontSize: number;
|
||||||
|
fontWeight: number;
|
||||||
|
lineHeight: number;
|
||||||
|
letterSpacing: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a typography control manager that handles a collection of typography controls.
|
* Creates a typography control manager that handles a collection of typography controls.
|
||||||
*
|
*
|
||||||
* @param configs - Array of control configurations.
|
* @param configs - Array of control configurations.
|
||||||
|
* @param storageId - Persistent storage identifier.
|
||||||
* @returns - Typography control manager instance.
|
* @returns - Typography control manager instance.
|
||||||
*/
|
*/
|
||||||
export function createTypographyControlManager(configs: ControlModel[]) {
|
export function createTypographyControlManager(
|
||||||
const controls = $state(
|
configs: ControlModel<ControlId>[],
|
||||||
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
|
storageId: string = 'glyphdiff:typography',
|
||||||
id,
|
) {
|
||||||
increaseLabel,
|
const storage = createPersistentStore<TypographySettings>(storageId, {
|
||||||
decreaseLabel,
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
controlLabel,
|
fontWeight: DEFAULT_FONT_WEIGHT,
|
||||||
instance: createTypographyControl(config),
|
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||||
})),
|
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||||
);
|
});
|
||||||
|
return new TypographyControlManager(configs, storage);
|
||||||
return {
|
|
||||||
get controls() {
|
|
||||||
return controls;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
export { createTypographyControlManager } from './controlManager/controlManager.svelte';
|
export {
|
||||||
|
createTypographyControlManager,
|
||||||
|
type TypographyControlManager,
|
||||||
|
} from './controlManager/controlManager.svelte';
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import type { ControlModel } from '$shared/lib';
|
||||||
|
import type { ControlId } from '..';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font size constants
|
* Font size constants
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_FONT_SIZE = 16;
|
export const DEFAULT_FONT_SIZE = 48;
|
||||||
export const MIN_FONT_SIZE = 8;
|
export const MIN_FONT_SIZE = 8;
|
||||||
export const MAX_FONT_SIZE = 100;
|
export const MAX_FONT_SIZE = 100;
|
||||||
export const FONT_SIZE_STEP = 1;
|
export const FONT_SIZE_STEP = 1;
|
||||||
@@ -21,3 +24,65 @@ export const DEFAULT_LINE_HEIGHT = 1.5;
|
|||||||
export const MIN_LINE_HEIGHT = 1;
|
export const MIN_LINE_HEIGHT = 1;
|
||||||
export const MAX_LINE_HEIGHT = 2;
|
export const MAX_LINE_HEIGHT = 2;
|
||||||
export const LINE_HEIGHT_STEP = 0.05;
|
export const LINE_HEIGHT_STEP = 0.05;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Letter spacing constants
|
||||||
|
*/
|
||||||
|
export const DEFAULT_LETTER_SPACING = 0;
|
||||||
|
export const MIN_LETTER_SPACING = -0.1;
|
||||||
|
export const MAX_LETTER_SPACING = 0.5;
|
||||||
|
export const LETTER_SPACING_STEP = 0.01;
|
||||||
|
|
||||||
|
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: 'Font 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: 'Font 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: 'Line Height',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'letter_spacing',
|
||||||
|
value: DEFAULT_LETTER_SPACING,
|
||||||
|
max: MAX_LETTER_SPACING,
|
||||||
|
min: MIN_LETTER_SPACING,
|
||||||
|
step: LETTER_SPACING_STEP,
|
||||||
|
|
||||||
|
increaseLabel: 'Increase Letter Spacing',
|
||||||
|
decreaseLabel: 'Decrease Letter Spacing',
|
||||||
|
controlLabel: 'Letter Spacing',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font size multipliers
|
||||||
|
*/
|
||||||
|
export const MULTIPLIER_S = 0.5;
|
||||||
|
export const MULTIPLIER_M = 0.75;
|
||||||
|
export const MULTIPLIER_L = 1;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
export {
|
export {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
FONT_SIZE_STEP,
|
FONT_SIZE_STEP,
|
||||||
FONT_WEIGHT_STEP,
|
FONT_WEIGHT_STEP,
|
||||||
LINE_HEIGHT_STEP,
|
LINE_HEIGHT_STEP,
|
||||||
@@ -11,6 +13,12 @@ export {
|
|||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MIN_FONT_WEIGHT,
|
MIN_FONT_WEIGHT,
|
||||||
MIN_LINE_HEIGHT,
|
MIN_LINE_HEIGHT,
|
||||||
|
MULTIPLIER_L,
|
||||||
|
MULTIPLIER_M,
|
||||||
|
MULTIPLIER_S,
|
||||||
} from './const/const';
|
} from './const/const';
|
||||||
|
|
||||||
export { controlManager } from './state/manager.svelte';
|
export {
|
||||||
|
type ControlId,
|
||||||
|
controlManager,
|
||||||
|
} from './state/manager.svelte';
|
||||||
|
|||||||
@@ -1,54 +1,6 @@
|
|||||||
import type { ControlModel } from '$shared/lib';
|
|
||||||
import { createTypographyControlManager } from '../../lib';
|
import { createTypographyControlManager } from '../../lib';
|
||||||
import {
|
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
DEFAULT_FONT_WEIGHT,
|
|
||||||
DEFAULT_LINE_HEIGHT,
|
|
||||||
FONT_SIZE_STEP,
|
|
||||||
FONT_WEIGHT_STEP,
|
|
||||||
LINE_HEIGHT_STEP,
|
|
||||||
MAX_FONT_SIZE,
|
|
||||||
MAX_FONT_WEIGHT,
|
|
||||||
MAX_LINE_HEIGHT,
|
|
||||||
MIN_FONT_SIZE,
|
|
||||||
MIN_FONT_WEIGHT,
|
|
||||||
MIN_LINE_HEIGHT,
|
|
||||||
} from '../const/const';
|
|
||||||
|
|
||||||
const controlData: ControlModel[] = [
|
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||||
{
|
|
||||||
id: 'font_size',
|
|
||||||
value: DEFAULT_FONT_SIZE,
|
|
||||||
max: MAX_FONT_SIZE,
|
|
||||||
min: MIN_FONT_SIZE,
|
|
||||||
step: FONT_SIZE_STEP,
|
|
||||||
|
|
||||||
increaseLabel: 'Increase Font Size',
|
export const controlManager = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA);
|
||||||
decreaseLabel: 'Decrease Font Size',
|
|
||||||
controlLabel: 'Font 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: 'Font 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: 'Line Height',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const controlManager = createTypographyControlManager(controlData);
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: SetupFontMenu
|
|
||||||
Contains controls for setting up font properties.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { Separator } from '$shared/shadcn/ui/separator/index';
|
|
||||||
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
|
|
||||||
import { ComboControl } from '$shared/ui';
|
|
||||||
import { controlManager } from '../model';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="p-2 flex flex-row items-center gap-2">
|
|
||||||
<SidebarTrigger />
|
|
||||||
<Separator orientation="vertical" class="h-full" />
|
|
||||||
<div class="flex flex-row gap-2">
|
|
||||||
{#each controlManager.controls as control (control.id)}
|
|
||||||
<ComboControl control={control.instance} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
134
src/features/SetupFont/ui/TypographyMenu.svelte
Normal file
134
src/features/SetupFont/ui/TypographyMenu.svelte
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<!--
|
||||||
|
Component: TypographyMenu
|
||||||
|
Provides a menu for selecting and configuring typography settings
|
||||||
|
- On mobile the menu is displayed as a drawer
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import {
|
||||||
|
Content as ItemContent,
|
||||||
|
Root as ItemRoot,
|
||||||
|
} from '$shared/shadcn/ui/item';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import {
|
||||||
|
ComboControlV2,
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
|
} from '$shared/ui';
|
||||||
|
import { Label } from '$shared/ui';
|
||||||
|
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
import { crossfade } from 'svelte/transition';
|
||||||
|
import {
|
||||||
|
MULTIPLIER_L,
|
||||||
|
MULTIPLIER_M,
|
||||||
|
MULTIPLIER_S,
|
||||||
|
controlManager,
|
||||||
|
} from '../model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className, hidden = false }: Props = $props();
|
||||||
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
|
const [send, receive] = crossfade({
|
||||||
|
duration: 300,
|
||||||
|
easing: cubicOut,
|
||||||
|
fallback(node, params) {
|
||||||
|
// If it can't find a pair, it falls back to a simple fade/slide
|
||||||
|
return {
|
||||||
|
duration: 300,
|
||||||
|
css: t => `opacity: ${t}; transform: translateY(${(1 - t) * 10}px);`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the common font size multiplier based on the current responsive state.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
if (!responsive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (true) {
|
||||||
|
case responsive.isMobile:
|
||||||
|
controlManager.multiplier = MULTIPLIER_S;
|
||||||
|
break;
|
||||||
|
case responsive.isTablet:
|
||||||
|
controlManager.multiplier = MULTIPLIER_M;
|
||||||
|
break;
|
||||||
|
case responsive.isDesktop:
|
||||||
|
controlManager.multiplier = MULTIPLIER_L;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
controlManager.multiplier = MULTIPLIER_L;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'w-auto max-screen z-10 flex justify-center',
|
||||||
|
hidden && 'hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
in:receive={{ key: 'panel' }}
|
||||||
|
out:send={{ key: 'panel' }}
|
||||||
|
>
|
||||||
|
{#if responsive.isMobile}
|
||||||
|
<Drawer>
|
||||||
|
{#snippet trigger({ onClick })}
|
||||||
|
<IconButton onclick={onClick}>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<SlidersIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content({ className })}
|
||||||
|
<Label
|
||||||
|
class="mt-6 mb-12 px-2"
|
||||||
|
text="Typography Controls"
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<div class={cn(className, 'flex flex-col gap-8')}>
|
||||||
|
{#each controlManager.controls as control (control.id)}
|
||||||
|
<ComboControlV2
|
||||||
|
control={control.instance}
|
||||||
|
orientation="horizontal"
|
||||||
|
label={control.controlLabel}
|
||||||
|
reduced
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Drawer>
|
||||||
|
{:else}
|
||||||
|
<ItemRoot
|
||||||
|
variant="outline"
|
||||||
|
class="w-full sm:w-auto max-w-full sm:max-w-max p-2 sm:p-2.5 rounded-xl sm:rounded-2xl backdrop-blur-lg"
|
||||||
|
>
|
||||||
|
<ItemContent class="flex flex-row justify-center items-center max-w-full sm:max-w-max">
|
||||||
|
<div class="sm:py-2 sm:px-10 flex flex-row items-center gap-2">
|
||||||
|
<div class="flex flex-row gap-3">
|
||||||
|
{#each controlManager.controls as control (control.id)}
|
||||||
|
<ComboControlV2
|
||||||
|
control={control.instance}
|
||||||
|
increaseLabel={control.increaseLabel}
|
||||||
|
decreaseLabel={control.decreaseLabel}
|
||||||
|
controlLabel={control.controlLabel}
|
||||||
|
orientation="vertical"
|
||||||
|
showScale={false}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
</ItemRoot>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
1
src/features/SetupFont/ui/index.ts
Normal file
1
src/features/SetupFont/ui/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as TypographyMenu } from './TypographyMenu.svelte';
|
||||||
@@ -1,12 +1,152 @@
|
|||||||
|
<!--
|
||||||
|
Component: Page
|
||||||
|
Description: The main page component of the application.
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
|
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import {
|
||||||
|
Logo,
|
||||||
|
Section,
|
||||||
|
} from '$shared/ui';
|
||||||
|
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
||||||
|
import { FontSearch } from '$widgets/FontSearch';
|
||||||
|
import { SampleList } from '$widgets/SampleList';
|
||||||
|
import CodeIcon from '@lucide/svelte/icons/code';
|
||||||
|
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||||
|
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
|
||||||
|
import ScanSearchIcon from '@lucide/svelte/icons/search';
|
||||||
|
import {
|
||||||
|
type Snippet,
|
||||||
|
getContext,
|
||||||
|
} from 'svelte';
|
||||||
|
import { cubicIn } from 'svelte/easing';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
/**
|
let searchContainer: HTMLElement;
|
||||||
* Page Component
|
|
||||||
*/
|
let isExpanded = $state(true);
|
||||||
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
|
function handleTitleStatusChanged(
|
||||||
|
index: number,
|
||||||
|
isPast: boolean,
|
||||||
|
title?: Snippet<[{ className?: string }]>,
|
||||||
|
id?: string,
|
||||||
|
) {
|
||||||
|
if (isPast && title) {
|
||||||
|
scrollBreadcrumbsStore.add({ index, title, id });
|
||||||
|
} else {
|
||||||
|
scrollBreadcrumbsStore.remove(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollBreadcrumbsStore.remove(index);
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Font List -->
|
<!-- Font List -->
|
||||||
<div class="p-2">
|
<div
|
||||||
<FontDisplay />
|
class="p-2 sm:p-3 md:p-4 h-full grid gap-3 sm:gap-4 grid-cols-[max-content_1fr]"
|
||||||
|
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
|
||||||
|
>
|
||||||
|
<Section
|
||||||
|
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-8"
|
||||||
|
onTitleStatusChange={handleTitleStatusChanged}
|
||||||
|
>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<CodeIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet description({ className })}
|
||||||
|
<span class={className}> Project_Codename </span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content({ className })}
|
||||||
|
<div class={cn(className, 'col-start-0 col-span-2')}>
|
||||||
|
<Logo />
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||||
|
index={1}
|
||||||
|
id="optical_comparator"
|
||||||
|
onTitleStatusChange={handleTitleStatusChanged}
|
||||||
|
stickyTitle={responsive.isDesktopLarge}
|
||||||
|
stickyOffset="4rem"
|
||||||
|
>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<EyeIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet title({ className })}
|
||||||
|
<h1 class={className}>
|
||||||
|
Optical<br />Comparator
|
||||||
|
</h1>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content({ className })}
|
||||||
|
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||||
|
<ComparisonSlider />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||||
|
index={2}
|
||||||
|
id="query_module"
|
||||||
|
onTitleStatusChange={handleTitleStatusChanged}
|
||||||
|
stickyTitle={responsive.isDesktopLarge}
|
||||||
|
stickyOffset="4rem"
|
||||||
|
>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<ScanSearchIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet title({ className })}
|
||||||
|
<h2 class={className}>
|
||||||
|
Query<br />Module
|
||||||
|
</h2>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content({ className })}
|
||||||
|
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||||
|
<FontSearch bind:showFilters={isExpanded} />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||||
|
index={3}
|
||||||
|
id="sample_set"
|
||||||
|
onTitleStatusChange={handleTitleStatusChanged}
|
||||||
|
stickyTitle={responsive.isDesktopLarge}
|
||||||
|
stickyOffset="4rem"
|
||||||
|
>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<LineSquiggleIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet title({ className })}
|
||||||
|
<h2 class={className}>
|
||||||
|
Sample<br />Set
|
||||||
|
</h2>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content({ className })}
|
||||||
|
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||||
|
<SampleList />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.content {
|
||||||
|
/* Tells the browser to skip rendering off-screen content */
|
||||||
|
content-visibility: auto;
|
||||||
|
/* Helps the browser reserve space without calculating everything */
|
||||||
|
contain-intrinsic-size: 1px 1000px;
|
||||||
|
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -56,6 +56,5 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: <T>(url: string, options?: RequestInit) =>
|
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
||||||
request<T>(url, { ...options, method: 'DELETE' }),
|
|
||||||
};
|
};
|
||||||
|
|||||||
4
src/shared/assets/GD.svg
Normal file
4
src/shared/assets/GD.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="52" height="35" viewBox="0 0 52 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10.608 34.368C8.496 34.368 6.64 33.968 5.04 33.168C3.44 32.336 2.192 31.184 1.296 29.712C0.432 28.208 0 26.48 0 24.528V9.84C0 7.888 0.432 6.176 1.296 4.704C2.192 3.2 3.44 2.048 5.04 1.248C6.64 0.415999 8.496 0 10.608 0C12.688 0 14.528 0.415999 16.128 1.248C17.728 2.048 18.96 3.2 19.824 4.704C20.688 6.176 21.12 7.872 21.12 9.792V10.512C21.12 10.832 20.96 10.992 20.64 10.992H20.16C19.84 10.992 19.68 10.832 19.68 10.512V9.744C19.68 7.216 18.848 5.184 17.184 3.648C15.52 2.112 13.328 1.344 10.608 1.344C7.856 1.344 5.632 2.128 3.936 3.696C2.272 5.232 1.44 7.264 1.44 9.792V24.576C1.44 27.104 2.272 29.152 3.936 30.72C5.632 32.256 7.856 33.024 10.608 33.024C13.328 33.024 15.52 32.272 17.184 30.768C18.848 29.232 19.68 27.2 19.68 24.672V19.152C19.68 19.024 19.616 18.96 19.488 18.96H11.472C11.152 18.96 10.992 18.8 10.992 18.48V18.144C10.992 17.824 11.152 17.664 11.472 17.664H20.64C20.96 17.664 21.12 17.824 21.12 18.144V24.48C21.12 26.464 20.688 28.208 19.824 29.712C18.96 31.184 17.728 32.336 16.128 33.168C14.528 33.968 12.688 34.368 10.608 34.368Z" fill="white"/>
|
||||||
|
<path d="M31.2124 33.984C30.8924 33.984 30.7324 33.824 30.7324 33.504V0.863997C30.7324 0.543998 30.8924 0.383998 31.2124 0.383998H42.1084C45.0204 0.383998 47.3084 1.168 48.9724 2.736C50.6684 4.272 51.5164 6.4 51.5164 9.12V25.248C51.5164 27.968 50.6684 30.112 48.9724 31.68C47.3084 33.216 45.0204 33.984 42.1084 33.984H31.2124ZM32.1724 32.448C32.1724 32.576 32.2364 32.64 32.3644 32.64H42.2044C44.6364 32.64 46.5564 31.984 47.9644 30.672C49.3724 29.328 50.0764 27.504 50.0764 25.2V9.216C50.0764 6.88 49.3724 5.056 47.9644 3.744C46.5564 2.4 44.6364 1.728 42.2044 1.728H32.3644C32.2364 1.728 32.1724 1.792 32.1724 1.92V32.448Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,32 +0,0 @@
|
|||||||
// Check if we are in a browser environment
|
|
||||||
const isBrowser = typeof window !== 'undefined';
|
|
||||||
|
|
||||||
// A class to manage motion preference and provide a single instance for use everywhere
|
|
||||||
class MotionPreference {
|
|
||||||
// Reactive state
|
|
||||||
#reduced = $state(false);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (isBrowser) {
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
|
|
||||||
// Set initial value immediately
|
|
||||||
this.#reduced = mediaQuery.matches;
|
|
||||||
|
|
||||||
// Simple listener that updates the reactive state
|
|
||||||
const handleChange = (e: MediaQueryListEvent) => {
|
|
||||||
this.#reduced = e.matches;
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getter allows us to use 'motion.reduced' reactively in components
|
|
||||||
get reduced() {
|
|
||||||
return this.#reduced;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export a single instance to be used everywhere
|
|
||||||
export const motion = new MotionPreference();
|
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* Interface representing a line of text with its measured width.
|
||||||
|
*/
|
||||||
|
export interface LineData {
|
||||||
|
/**
|
||||||
|
* Line's text
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* It's width
|
||||||
|
*/
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a helper for splitting text into lines and calculating character proximity.
|
||||||
|
* This is used by the ComparisonSlider (TestTen) to render morphing text.
|
||||||
|
*
|
||||||
|
* @param text - The text to split and measure
|
||||||
|
* @param fontA - The first font definition
|
||||||
|
* @param fontB - The second font definition
|
||||||
|
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
|
||||||
|
*/
|
||||||
|
export function createCharacterComparison<
|
||||||
|
T extends { name: string; id: string } | undefined = undefined,
|
||||||
|
>(
|
||||||
|
text: () => string,
|
||||||
|
fontA: () => T,
|
||||||
|
fontB: () => T,
|
||||||
|
weight: () => number,
|
||||||
|
size: () => number,
|
||||||
|
) {
|
||||||
|
let lines = $state<LineData[]>([]);
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
|
||||||
|
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
|
||||||
|
return font !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures text width using a canvas context.
|
||||||
|
* @param ctx - Canvas rendering context
|
||||||
|
* @param text - Text string to measure
|
||||||
|
* @param fontFamily - Font family name
|
||||||
|
* @param fontSize - Font size in pixels
|
||||||
|
* @param fontWeight - Font weight
|
||||||
|
*/
|
||||||
|
function measureText(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
text: string,
|
||||||
|
fontSize: number,
|
||||||
|
fontWeight: number,
|
||||||
|
fontFamily?: string,
|
||||||
|
): number {
|
||||||
|
if (!fontFamily) return 0;
|
||||||
|
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
||||||
|
return ctx.measureText(text).width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the appropriate font size based on window width.
|
||||||
|
* Matches the Tailwind breakpoints used in the component.
|
||||||
|
*/
|
||||||
|
function getFontSize() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 64;
|
||||||
|
}
|
||||||
|
return window.innerWidth >= 1024
|
||||||
|
? 112
|
||||||
|
: window.innerWidth >= 768
|
||||||
|
? 96
|
||||||
|
: window.innerWidth >= 640
|
||||||
|
? 80
|
||||||
|
: 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breaks the text into lines based on the container width and measure canvas.
|
||||||
|
* Populates the `lines` state.
|
||||||
|
*
|
||||||
|
* @param container - The container element to measure width from
|
||||||
|
* @param measureCanvas - The canvas element used for text measurement
|
||||||
|
*/
|
||||||
|
|
||||||
|
function breakIntoLines(
|
||||||
|
container: HTMLElement | undefined,
|
||||||
|
measureCanvas: HTMLCanvasElement | undefined,
|
||||||
|
) {
|
||||||
|
if (!container || !measureCanvas || !fontA() || !fontB()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues
|
||||||
|
// getBoundingClientRect() returns transformed dimensions, which causes incorrect line breaking
|
||||||
|
// when PerspectivePlan applies scale() transforms (e.g., scale(0.5) in settings mode)
|
||||||
|
const width = container.offsetWidth;
|
||||||
|
containerWidth = width;
|
||||||
|
|
||||||
|
// Padding considerations - matches the container padding
|
||||||
|
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||||
|
const availableWidth = width - padding;
|
||||||
|
const ctx = measureCanvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlledFontSize = size();
|
||||||
|
const fontSize = getFontSize();
|
||||||
|
const currentWeight = weight(); // Get current weight
|
||||||
|
const words = text().split(' ');
|
||||||
|
const newLines: LineData[] = [];
|
||||||
|
let currentLineWords: string[] = [];
|
||||||
|
|
||||||
|
function pushLine(words: string[]) {
|
||||||
|
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lineText = words.join(' ');
|
||||||
|
// Measure both fonts at the CURRENT weight
|
||||||
|
const widthA = measureText(
|
||||||
|
ctx!,
|
||||||
|
lineText,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontA()?.name,
|
||||||
|
);
|
||||||
|
const widthB = measureText(
|
||||||
|
ctx!,
|
||||||
|
lineText,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontB()?.name,
|
||||||
|
);
|
||||||
|
const maxWidth = Math.max(widthA, widthB);
|
||||||
|
newLines.push({ text: lineText, width: maxWidth });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
const testLine = currentLineWords.length > 0
|
||||||
|
? currentLineWords.join(' ') + ' ' + word
|
||||||
|
: word;
|
||||||
|
// Measure with both fonts and use the wider one to prevent layout shifts
|
||||||
|
const widthA = measureText(
|
||||||
|
ctx,
|
||||||
|
testLine,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontA()?.name,
|
||||||
|
);
|
||||||
|
const widthB = measureText(
|
||||||
|
ctx,
|
||||||
|
testLine,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontB()?.name,
|
||||||
|
);
|
||||||
|
const maxWidth = Math.max(widthA, widthB);
|
||||||
|
const isContainerOverflown = maxWidth > availableWidth;
|
||||||
|
|
||||||
|
if (isContainerOverflown) {
|
||||||
|
if (currentLineWords.length > 0) {
|
||||||
|
pushLine(currentLineWords);
|
||||||
|
currentLineWords = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordWidthA = measureText(
|
||||||
|
ctx,
|
||||||
|
word,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontA()?.name,
|
||||||
|
);
|
||||||
|
const wordWidthB = measureText(
|
||||||
|
ctx,
|
||||||
|
word,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontB()?.name,
|
||||||
|
);
|
||||||
|
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
|
||||||
|
|
||||||
|
if (wordAloneWidth <= availableWidth) {
|
||||||
|
// If word fits start new line with it
|
||||||
|
currentLineWords = [word];
|
||||||
|
} else {
|
||||||
|
let remainingWord = word;
|
||||||
|
while (remainingWord.length > 0) {
|
||||||
|
let low = 1;
|
||||||
|
let high = remainingWord.length;
|
||||||
|
let bestBreak = 1;
|
||||||
|
|
||||||
|
// Binary Search to find the maximum characters that fit
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = Math.floor((low + high) / 2);
|
||||||
|
const testFragment = remainingWord.slice(0, mid);
|
||||||
|
|
||||||
|
const wA = measureText(
|
||||||
|
ctx,
|
||||||
|
testFragment,
|
||||||
|
fontSize,
|
||||||
|
currentWeight,
|
||||||
|
fontA()?.name,
|
||||||
|
);
|
||||||
|
const wB = measureText(
|
||||||
|
ctx,
|
||||||
|
testFragment,
|
||||||
|
fontSize,
|
||||||
|
currentWeight,
|
||||||
|
fontB()?.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Math.max(wA, wB) <= availableWidth) {
|
||||||
|
bestBreak = mid;
|
||||||
|
low = mid + 1;
|
||||||
|
} else {
|
||||||
|
high = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushLine([remainingWord.slice(0, bestBreak)]);
|
||||||
|
remainingWord = remainingWord.slice(bestBreak);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (maxWidth > availableWidth && currentLineWords.length > 0) {
|
||||||
|
pushLine(currentLineWords);
|
||||||
|
currentLineWords = [word];
|
||||||
|
} else {
|
||||||
|
currentLineWords.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLineWords.length > 0) {
|
||||||
|
pushLine(currentLineWords);
|
||||||
|
}
|
||||||
|
lines = newLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* precise calculation of character state based on global slider position.
|
||||||
|
*
|
||||||
|
* @param charIndex - Index of the character in the line
|
||||||
|
* @param sliderPos - Current slider position (0-100)
|
||||||
|
* @param lineElement - The line element
|
||||||
|
* @param container - The container element
|
||||||
|
* @returns Object containing proximity (0-1) and isPast (boolean)
|
||||||
|
*/
|
||||||
|
function getCharState(
|
||||||
|
charIndex: number,
|
||||||
|
sliderPos: number,
|
||||||
|
lineElement?: HTMLElement,
|
||||||
|
container?: HTMLElement,
|
||||||
|
) {
|
||||||
|
if (!containerWidth || !container) {
|
||||||
|
return {
|
||||||
|
proximity: 0,
|
||||||
|
isPast: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const charElement = lineElement?.children[charIndex] as HTMLElement;
|
||||||
|
|
||||||
|
if (!charElement) {
|
||||||
|
return { proximity: 0, isPast: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the actual bounding box of the character
|
||||||
|
const charRect = charElement.getBoundingClientRect();
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Calculate character center relative to container
|
||||||
|
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
|
||||||
|
const charGlobalPercent = (charCenter / containerWidth) * 100;
|
||||||
|
|
||||||
|
const distance = Math.abs(sliderPos - charGlobalPercent);
|
||||||
|
const range = 5;
|
||||||
|
const proximity = Math.max(0, 1 - distance / range);
|
||||||
|
const isPast = sliderPos > charGlobalPercent;
|
||||||
|
|
||||||
|
return { proximity, isPast };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get lines() {
|
||||||
|
return lines;
|
||||||
|
},
|
||||||
|
get containerWidth() {
|
||||||
|
return containerWidth;
|
||||||
|
},
|
||||||
|
breakIntoLines,
|
||||||
|
getCharState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;
|
||||||
@@ -46,9 +46,6 @@ export class EntityStore<T extends Entity> {
|
|||||||
updateOne(id: string, changes: Partial<T>) {
|
updateOne(id: string, changes: Partial<T>) {
|
||||||
const entity = this.#entities.get(id);
|
const entity = this.#entities.get(id);
|
||||||
if (entity) {
|
if (entity) {
|
||||||
// In Svelte 5, updating the object property directly is reactive
|
|
||||||
// if the object itself was made reactive, but here we replace
|
|
||||||
// the reference to ensure top-level map triggers.
|
|
||||||
this.#entities.set(id, { ...entity, ...changes });
|
this.#entities.set(id, { ...entity, ...changes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
type Entity,
|
||||||
|
EntityStore,
|
||||||
|
createEntityStore,
|
||||||
|
} from './createEntityStore.svelte';
|
||||||
|
|
||||||
|
interface TestEntity {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createEntityStore', () => {
|
||||||
|
describe('Construction and Initialization', () => {
|
||||||
|
it('should create an empty store when no initial entities are provided', () => {
|
||||||
|
const store = createEntityStore<TestEntity>();
|
||||||
|
|
||||||
|
expect(store.all).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a store with initial entities', () => {
|
||||||
|
const initialEntities: TestEntity[] = [
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
];
|
||||||
|
const store = createEntityStore(initialEntities);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(2);
|
||||||
|
expect(store.all).toEqual(initialEntities);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create EntityStore instance', () => {
|
||||||
|
const store = createEntityStore<TestEntity>();
|
||||||
|
|
||||||
|
expect(store).toBeInstanceOf(EntityStore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Selectors', () => {
|
||||||
|
let store: EntityStore<TestEntity>;
|
||||||
|
let entities: TestEntity[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
entities = [
|
||||||
|
{ id: '1', name: 'First', value: 10 },
|
||||||
|
{ id: '2', name: 'Second', value: 20 },
|
||||||
|
{ id: '3', name: 'Third', value: 30 },
|
||||||
|
];
|
||||||
|
store = createEntityStore(entities);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all entities as an array', () => {
|
||||||
|
const all = store.all;
|
||||||
|
|
||||||
|
expect(all).toEqual(entities);
|
||||||
|
expect(all).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get a single entity by ID', () => {
|
||||||
|
const entity = store.getById('2');
|
||||||
|
|
||||||
|
expect(entity).toEqual({ id: '2', name: 'Second', value: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for non-existent ID', () => {
|
||||||
|
const entity = store.getById('999');
|
||||||
|
|
||||||
|
expect(entity).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get multiple entities by IDs', () => {
|
||||||
|
const entities = store.getByIds(['1', '3']);
|
||||||
|
|
||||||
|
expect(entities).toEqual([
|
||||||
|
{ id: '1', name: 'First', value: 10 },
|
||||||
|
{ id: '3', name: 'Third', value: 30 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out undefined results when getting by IDs', () => {
|
||||||
|
const entities = store.getByIds(['1', '999', '3']);
|
||||||
|
|
||||||
|
expect(entities).toEqual([
|
||||||
|
{ id: '1', name: 'First', value: 10 },
|
||||||
|
{ id: '3', name: 'Third', value: 30 },
|
||||||
|
]);
|
||||||
|
expect(entities).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no IDs match', () => {
|
||||||
|
const entities = store.getByIds(['999', '888']);
|
||||||
|
|
||||||
|
expect(entities).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check if entity exists by ID', () => {
|
||||||
|
expect(store.has('1')).toBe(true);
|
||||||
|
expect(store.has('999')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CRUD Operations - Create', () => {
|
||||||
|
it('should add a single entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>();
|
||||||
|
|
||||||
|
store.addOne({ id: '1', name: 'First', value: 1 });
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add multiple entities at once', () => {
|
||||||
|
const store = createEntityStore<TestEntity>();
|
||||||
|
|
||||||
|
store.addMany([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
{ id: '3', name: 'Third', value: 3 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace entity when adding with existing ID', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
|
||||||
|
|
||||||
|
store.addOne({ id: '1', name: 'Updated', value: 2 });
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CRUD Operations - Update', () => {
|
||||||
|
it('should update an existing entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
|
||||||
|
|
||||||
|
store.updateOne('1', { name: 'Updated' });
|
||||||
|
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update multiple properties at once', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
|
||||||
|
|
||||||
|
store.updateOne('1', { name: 'Updated', value: 2 });
|
||||||
|
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when updating non-existent entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
|
||||||
|
|
||||||
|
store.updateOne('999', { name: 'Updated' });
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve entity when no changes are provided', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
|
||||||
|
|
||||||
|
store.updateOne('1', {});
|
||||||
|
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CRUD Operations - Delete', () => {
|
||||||
|
it('should remove a single entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.removeOne('1');
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toBeUndefined();
|
||||||
|
expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove multiple entities', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
{ id: '3', name: 'Third', value: 3 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.removeMany(['1', '3']);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when removing non-existent entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
|
||||||
|
|
||||||
|
store.removeOne('999');
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array when removing many', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
|
||||||
|
|
||||||
|
store.removeMany([]);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bulk Operations', () => {
|
||||||
|
it('should set all entities, replacing existing', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.setAll([{ id: '3', name: 'Third', value: 3 }]);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toBeUndefined();
|
||||||
|
expect(store.getById('3')).toEqual({ id: '3', name: 'Third', value: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear all entities', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.clear();
|
||||||
|
|
||||||
|
expect(store.all).toEqual([]);
|
||||||
|
expect(store.all).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reactivity with SvelteMap', () => {
|
||||||
|
it('should return reactive arrays', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
|
||||||
|
|
||||||
|
// The all getter should return a fresh array (or reactive state)
|
||||||
|
const first = store.all;
|
||||||
|
const second = store.all;
|
||||||
|
|
||||||
|
// Both should have the same content
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reflect changes in subsequent calls', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
|
||||||
|
store.addOne({ id: '2', name: 'Second', value: 2 });
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle empty initial array', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([]);
|
||||||
|
|
||||||
|
expect(store.all).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle entities with complex objects', () => {
|
||||||
|
interface ComplexEntity extends Entity {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
nested: {
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity: ComplexEntity = {
|
||||||
|
id: '1',
|
||||||
|
data: { nested: { value: 'test' } },
|
||||||
|
tags: ['a', 'b', 'c'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = createEntityStore<ComplexEntity>([entity]);
|
||||||
|
|
||||||
|
expect(store.getById('1')).toEqual(entity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle numeric string IDs', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '123', name: 'First', value: 1 },
|
||||||
|
{ id: '456', name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(store.getById('123')).toEqual({ id: '123', name: 'First', value: 1 });
|
||||||
|
expect(store.getById('456')).toEqual({ id: '456', name: 'Second', value: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle UUID-like IDs', () => {
|
||||||
|
const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
const uuid2 = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: uuid1, name: 'First', value: 1 },
|
||||||
|
{ id: uuid2, name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(store.getById(uuid1)).toEqual({ id: uuid1, name: 'First', value: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Type Safety', () => {
|
||||||
|
it('should enforce Entity type with id property', () => {
|
||||||
|
// This test verifies type checking at compile time
|
||||||
|
const validEntity: TestEntity = { id: '1', name: 'Test', value: 1 };
|
||||||
|
|
||||||
|
const store = createEntityStore<TestEntity>([validEntity]);
|
||||||
|
|
||||||
|
expect(store.getById('1')).toEqual(validEntity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with different entity types', () => {
|
||||||
|
interface User extends Entity {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Product extends Entity {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userStore = createEntityStore<User>([
|
||||||
|
{ id: 'u1', name: 'John', email: 'john@example.com' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const productStore = createEntityStore<Product>([
|
||||||
|
{ id: 'p1', title: 'Widget', price: 9.99 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(userStore.getById('u1')?.email).toBe('john@example.com');
|
||||||
|
expect(productStore.getById('p1')?.price).toBe(9.99);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Large Datasets', () => {
|
||||||
|
it('should handle large number of entities efficiently', () => {
|
||||||
|
const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({
|
||||||
|
id: `id-${i}`,
|
||||||
|
name: `Entity ${i}`,
|
||||||
|
value: i,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const store = createEntityStore(entities);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1000);
|
||||||
|
expect(store.getById('id-500')).toEqual({
|
||||||
|
id: 'id-500',
|
||||||
|
name: 'Entity 500',
|
||||||
|
value: 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should efficiently check existence in large dataset', () => {
|
||||||
|
const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({
|
||||||
|
id: `id-${i}`,
|
||||||
|
name: `Entity ${i}`,
|
||||||
|
value: i,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const store = createEntityStore(entities);
|
||||||
|
|
||||||
|
expect(store.has('id-999')).toBe(true);
|
||||||
|
expect(store.has('id-1000')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Method Chaining', () => {
|
||||||
|
it('should support chaining add operations', () => {
|
||||||
|
const store = createEntityStore<TestEntity>();
|
||||||
|
|
||||||
|
store.addOne({ id: '1', name: 'First', value: 1 });
|
||||||
|
store.addOne({ id: '2', name: 'Second', value: 2 });
|
||||||
|
store.addOne({ id: '3', name: 'Third', value: 3 });
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support chaining update operations', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.updateOne('1', { value: 10 });
|
||||||
|
store.updateOne('2', { value: 20 });
|
||||||
|
|
||||||
|
expect(store.getById('1')?.value).toBe(10);
|
||||||
|
expect(store.getById('2')?.value).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Reusable persistent storage utility using Svelte 5 runes
|
||||||
|
*
|
||||||
|
* Automatically syncs state with localStorage.
|
||||||
|
*/
|
||||||
|
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||||
|
// Initialize from storage or default
|
||||||
|
const loadFromStorage = (): T => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : defaultValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[createPersistentStore] Error loading ${key}:`, error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = $state<T>(loadFromStorage());
|
||||||
|
|
||||||
|
// Sync to storage whenever value changes
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[createPersistentStore] Error saving ${key}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(v: T) {
|
||||||
|
value = v;
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
value = defaultValue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>;
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { createPersistentStore } from './createPersistentStore.svelte';
|
||||||
|
|
||||||
|
describe('createPersistentStore', () => {
|
||||||
|
let mockLocalStorage: Storage;
|
||||||
|
const testKey = 'test-store-key';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock localStorage
|
||||||
|
const storeMap = new Map<string, string>();
|
||||||
|
|
||||||
|
mockLocalStorage = {
|
||||||
|
get length() {
|
||||||
|
return storeMap.size;
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
storeMap.clear();
|
||||||
|
},
|
||||||
|
getItem(key: string) {
|
||||||
|
return storeMap.get(key) ?? null;
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
storeMap.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
|
storeMap.delete(key);
|
||||||
|
},
|
||||||
|
key(index: number) {
|
||||||
|
return Array.from(storeMap.keys())[index] ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', mockLocalStorage);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initialization', () => {
|
||||||
|
it('should create store with default value when localStorage is empty', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
expect(store.value).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create store with value from localStorage', () => {
|
||||||
|
mockLocalStorage.setItem(testKey, JSON.stringify('stored value'));
|
||||||
|
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
expect(store.value).toBe('stored value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse JSON from localStorage', () => {
|
||||||
|
const storedValue = { name: 'Test', count: 42 };
|
||||||
|
mockLocalStorage.setItem(testKey, JSON.stringify(storedValue));
|
||||||
|
|
||||||
|
const store = createPersistentStore(testKey, { name: 'Default', count: 0 });
|
||||||
|
|
||||||
|
expect(store.value).toEqual(storedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default value when localStorage has invalid JSON', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
mockLocalStorage.setItem(testKey, 'invalid json{');
|
||||||
|
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
expect(store.value).toBe('default');
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reading Values', () => {
|
||||||
|
it('should return current value via getter', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
expect(store.value).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return updated value after setter', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
store.value = 'updated';
|
||||||
|
|
||||||
|
expect(store.value).toBe('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve type information', () => {
|
||||||
|
interface TestObject {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
const defaultValue: TestObject = { name: 'Test', count: 0 };
|
||||||
|
const store = createPersistentStore<TestObject>(testKey, defaultValue);
|
||||||
|
|
||||||
|
expect(store.value.name).toBe('Test');
|
||||||
|
expect(store.value.count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Writing Values', () => {
|
||||||
|
it('should update value when set via setter', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
store.value = 'new value';
|
||||||
|
|
||||||
|
expect(store.value).toBe('new value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serialize objects to JSON', () => {
|
||||||
|
const store = createPersistentStore(testKey, { name: 'Default', count: 0 });
|
||||||
|
|
||||||
|
store.value = { name: 'Updated', count: 42 };
|
||||||
|
|
||||||
|
// The value is updated in the store
|
||||||
|
expect(store.value).toEqual({ name: 'Updated', count: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle arrays', () => {
|
||||||
|
const store = createPersistentStore<number[]>(testKey, []);
|
||||||
|
|
||||||
|
store.value = [1, 2, 3];
|
||||||
|
|
||||||
|
expect(store.value).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle booleans', () => {
|
||||||
|
const store = createPersistentStore<boolean>(testKey, false);
|
||||||
|
|
||||||
|
store.value = true;
|
||||||
|
|
||||||
|
expect(store.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null values', () => {
|
||||||
|
const store = createPersistentStore<string | null>(testKey, null);
|
||||||
|
|
||||||
|
store.value = 'not null';
|
||||||
|
|
||||||
|
expect(store.value).toBe('not null');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Clear Function', () => {
|
||||||
|
it('should reset value to default when clear is called', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
store.value = 'modified';
|
||||||
|
store.clear();
|
||||||
|
|
||||||
|
expect(store.value).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with object defaults', () => {
|
||||||
|
const defaultValue = { name: 'Default', count: 0 };
|
||||||
|
const store = createPersistentStore(testKey, defaultValue);
|
||||||
|
|
||||||
|
store.value = { name: 'Modified', count: 42 };
|
||||||
|
store.clear();
|
||||||
|
|
||||||
|
expect(store.value).toEqual(defaultValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with array defaults', () => {
|
||||||
|
const defaultValue = [1, 2, 3];
|
||||||
|
const store = createPersistentStore<number[]>(testKey, defaultValue);
|
||||||
|
|
||||||
|
store.value = [4, 5, 6];
|
||||||
|
store.clear();
|
||||||
|
|
||||||
|
expect(store.value).toEqual(defaultValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Type Support', () => {
|
||||||
|
it('should work with string type', () => {
|
||||||
|
const store = createPersistentStore<string>(testKey, 'default');
|
||||||
|
|
||||||
|
store.value = 'test string';
|
||||||
|
|
||||||
|
expect(store.value).toBe('test string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with number type', () => {
|
||||||
|
const store = createPersistentStore<number>(testKey, 0);
|
||||||
|
|
||||||
|
store.value = 42;
|
||||||
|
|
||||||
|
expect(store.value).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with boolean type', () => {
|
||||||
|
const store = createPersistentStore<boolean>(testKey, false);
|
||||||
|
|
||||||
|
store.value = true;
|
||||||
|
|
||||||
|
expect(store.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with object type', () => {
|
||||||
|
interface TestObject {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
const defaultValue: TestObject = { name: 'Test', value: 0 };
|
||||||
|
const store = createPersistentStore<TestObject>(testKey, defaultValue);
|
||||||
|
|
||||||
|
store.value = { name: 'Updated', value: 42 };
|
||||||
|
|
||||||
|
expect(store.value.name).toBe('Updated');
|
||||||
|
expect(store.value.value).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with array type', () => {
|
||||||
|
const store = createPersistentStore<string[]>(testKey, []);
|
||||||
|
|
||||||
|
store.value = ['a', 'b', 'c'];
|
||||||
|
|
||||||
|
expect(store.value).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with null type', () => {
|
||||||
|
const store = createPersistentStore<string | null>(testKey, null);
|
||||||
|
|
||||||
|
expect(store.value).toBeNull();
|
||||||
|
|
||||||
|
store.value = 'not null';
|
||||||
|
|
||||||
|
expect(store.value).toBe('not null');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
store.value = '';
|
||||||
|
|
||||||
|
expect(store.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero number', () => {
|
||||||
|
const store = createPersistentStore<number>(testKey, 100);
|
||||||
|
|
||||||
|
store.value = 0;
|
||||||
|
|
||||||
|
expect(store.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle false boolean', () => {
|
||||||
|
const store = createPersistentStore<boolean>(testKey, true);
|
||||||
|
|
||||||
|
store.value = false;
|
||||||
|
|
||||||
|
expect(store.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
const store = createPersistentStore<number[]>(testKey, [1, 2, 3]);
|
||||||
|
|
||||||
|
store.value = [];
|
||||||
|
|
||||||
|
expect(store.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty object', () => {
|
||||||
|
const store = createPersistentStore<Record<string, unknown>>(testKey, { a: 1 });
|
||||||
|
|
||||||
|
store.value = {};
|
||||||
|
|
||||||
|
expect(store.value).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in string', () => {
|
||||||
|
const store = createPersistentStore(testKey, '');
|
||||||
|
|
||||||
|
const specialString = 'Hello "world"\nNew line\tTab';
|
||||||
|
store.value = specialString;
|
||||||
|
|
||||||
|
expect(store.value).toBe(specialString);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unicode characters', () => {
|
||||||
|
const store = createPersistentStore(testKey, '');
|
||||||
|
|
||||||
|
store.value = 'Hello 世界 🌍';
|
||||||
|
|
||||||
|
expect(store.value).toBe('Hello 世界 🌍');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multiple Instances', () => {
|
||||||
|
it('should handle multiple stores with different keys', () => {
|
||||||
|
const store1 = createPersistentStore('key1', 'value1');
|
||||||
|
const store2 = createPersistentStore('key2', 'value2');
|
||||||
|
|
||||||
|
store1.value = 'updated1';
|
||||||
|
store2.value = 'updated2';
|
||||||
|
|
||||||
|
expect(store1.value).toBe('updated1');
|
||||||
|
expect(store2.value).toBe('updated2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep stores independent', () => {
|
||||||
|
const store1 = createPersistentStore('key1', 'default1');
|
||||||
|
const store2 = createPersistentStore('key2', 'default2');
|
||||||
|
|
||||||
|
store1.clear();
|
||||||
|
|
||||||
|
expect(store1.value).toBe('default1');
|
||||||
|
expect(store2.value).toBe('default2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complex Scenarios', () => {
|
||||||
|
it('should handle nested objects', () => {
|
||||||
|
interface NestedObject {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
settings: {
|
||||||
|
theme: string;
|
||||||
|
notifications: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const defaultValue: NestedObject = {
|
||||||
|
user: {
|
||||||
|
name: 'Test',
|
||||||
|
settings: { theme: 'light', notifications: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = createPersistentStore<NestedObject>(testKey, defaultValue);
|
||||||
|
|
||||||
|
store.value = {
|
||||||
|
user: {
|
||||||
|
name: 'Updated',
|
||||||
|
settings: { theme: 'dark', notifications: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(store.value).toEqual({
|
||||||
|
user: {
|
||||||
|
name: 'Updated',
|
||||||
|
settings: { theme: 'dark', notifications: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle arrays of objects', () => {
|
||||||
|
interface Item {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
const store = createPersistentStore<Item[]>(testKey, []);
|
||||||
|
|
||||||
|
store.value = [
|
||||||
|
{ id: 1, name: 'First' },
|
||||||
|
{ id: 2, name: 'Second' },
|
||||||
|
{ id: 3, name: 'Third' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(store.value).toHaveLength(3);
|
||||||
|
expect(store.value[0].name).toBe('First');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Spring } from 'svelte/motion';
|
||||||
|
|
||||||
|
export interface PerspectiveConfig {
|
||||||
|
/**
|
||||||
|
* How many px to move back per level
|
||||||
|
*/
|
||||||
|
depthStep?: number;
|
||||||
|
/**
|
||||||
|
* Scale reduction per level
|
||||||
|
*/
|
||||||
|
scaleStep?: number;
|
||||||
|
/**
|
||||||
|
* Blur amount per level
|
||||||
|
*/
|
||||||
|
blurStep?: number;
|
||||||
|
/**
|
||||||
|
* Opacity reduction per level
|
||||||
|
*/
|
||||||
|
opacityStep?: number;
|
||||||
|
/**
|
||||||
|
* Parallax intensity per level
|
||||||
|
*/
|
||||||
|
parallaxIntensity?: number;
|
||||||
|
/**
|
||||||
|
* Horizontal offset for each plan (x-axis positioning)
|
||||||
|
* Positive = right, Negative = left
|
||||||
|
*/
|
||||||
|
horizontalOffset?: number;
|
||||||
|
/**
|
||||||
|
* Layout mode: 'center' (default) or 'split' for Swiss-style side-by-side
|
||||||
|
*/
|
||||||
|
layoutMode?: 'center' | 'split';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages perspective state with a simple boolean flag.
|
||||||
|
*
|
||||||
|
* Drastically simplified from the complex camera/index system.
|
||||||
|
* Just manages whether content is in "back" or "front" state.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const perspective = createPerspectiveManager({
|
||||||
|
* depthStep: 100,
|
||||||
|
* scaleStep: 0.5,
|
||||||
|
* blurStep: 4,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Toggle back/front
|
||||||
|
* perspective.toggle();
|
||||||
|
*
|
||||||
|
* // Check state
|
||||||
|
* const isBack = perspective.isBack; // reactive boolean
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class PerspectiveManager {
|
||||||
|
/**
|
||||||
|
* Spring for smooth back/front transitions
|
||||||
|
*/
|
||||||
|
spring = new Spring(0, {
|
||||||
|
stiffness: 0.2,
|
||||||
|
damping: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive boolean: true when in back position (blurred, scaled down)
|
||||||
|
*/
|
||||||
|
isBack = $derived(this.spring.current > 0.5);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive boolean: true when in front position (fully visible, interactive)
|
||||||
|
*/
|
||||||
|
isFront = $derived(this.spring.current < 0.5);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration values for style computation
|
||||||
|
*/
|
||||||
|
private config: Required<PerspectiveConfig>;
|
||||||
|
|
||||||
|
constructor(config: PerspectiveConfig = {}) {
|
||||||
|
this.config = {
|
||||||
|
depthStep: config.depthStep ?? 100,
|
||||||
|
scaleStep: config.scaleStep ?? 0.5,
|
||||||
|
blurStep: config.blurStep ?? 4,
|
||||||
|
opacityStep: config.opacityStep ?? 0.5,
|
||||||
|
parallaxIntensity: config.parallaxIntensity ?? 0,
|
||||||
|
horizontalOffset: config.horizontalOffset ?? 0,
|
||||||
|
layoutMode: config.layoutMode ?? 'center',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle between front (0) and back (1) positions.
|
||||||
|
* Smooth spring animation handles the transition.
|
||||||
|
*/
|
||||||
|
toggle = () => {
|
||||||
|
const target = this.spring.current < 0.5 ? 1 : 0;
|
||||||
|
this.spring.target = target;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force to back position
|
||||||
|
*/
|
||||||
|
setBack = () => {
|
||||||
|
this.spring.target = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force to front position
|
||||||
|
*/
|
||||||
|
setFront = () => {
|
||||||
|
this.spring.target = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configuration for style computation
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
getConfig = () => this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a PerspectiveManager instance.
|
||||||
|
*
|
||||||
|
* @param config - Configuration options
|
||||||
|
* @returns Configured PerspectiveManager instance
|
||||||
|
*/
|
||||||
|
export function createPerspectiveManager(config: PerspectiveConfig = {}) {
|
||||||
|
return new PerspectiveManager(config);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// $shared/lib/createResponsiveManager.svelte.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breakpoint definitions following common device sizes
|
||||||
|
* Customize these values to match your design system
|
||||||
|
*/
|
||||||
|
export interface Breakpoints {
|
||||||
|
/** Mobile devices (portrait phones) */
|
||||||
|
mobile: number;
|
||||||
|
/** Tablet portrait */
|
||||||
|
tabletPortrait: number;
|
||||||
|
/** Tablet landscape */
|
||||||
|
tablet: number;
|
||||||
|
/** Desktop */
|
||||||
|
desktop: number;
|
||||||
|
/** Large desktop */
|
||||||
|
desktopLarge: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default breakpoints (matches common Tailwind-like breakpoints)
|
||||||
|
*/
|
||||||
|
const DEFAULT_BREAKPOINTS: Breakpoints = {
|
||||||
|
mobile: 640, // sm
|
||||||
|
tabletPortrait: 768, // md
|
||||||
|
tablet: 1024, // lg
|
||||||
|
desktop: 1280, // xl
|
||||||
|
desktopLarge: 1536, // 2xl
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orientation type
|
||||||
|
*/
|
||||||
|
export type Orientation = 'portrait' | 'landscape';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reactive responsive manager that tracks viewport size and breakpoints.
|
||||||
|
*
|
||||||
|
* Provides reactive getters for:
|
||||||
|
* - Current breakpoint detection (isMobile, isTablet, etc.)
|
||||||
|
* - Viewport dimensions (width, height)
|
||||||
|
* - Device orientation (portrait/landscape)
|
||||||
|
* - Custom breakpoint matching
|
||||||
|
*
|
||||||
|
* @param customBreakpoints - Optional custom breakpoint values
|
||||||
|
* @returns Responsive manager instance with reactive properties
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <script lang="ts">
|
||||||
|
* const responsive = createResponsiveManager();
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* {#if responsive.isMobile}
|
||||||
|
* <MobileNav />
|
||||||
|
* {:else if responsive.isTablet}
|
||||||
|
* <TabletNav />
|
||||||
|
* {:else}
|
||||||
|
* <DesktopNav />
|
||||||
|
* {/if}
|
||||||
|
*
|
||||||
|
* <p>Width: {responsive.width}px</p>
|
||||||
|
* <p>Orientation: {responsive.orientation}</p>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
|
||||||
|
const breakpoints: Breakpoints = {
|
||||||
|
...DEFAULT_BREAKPOINTS,
|
||||||
|
...customBreakpoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
|
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||||
|
let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0);
|
||||||
|
|
||||||
|
// Derived breakpoint states
|
||||||
|
const isMobile = $derived(width < breakpoints.mobile);
|
||||||
|
const isTabletPortrait = $derived(
|
||||||
|
width >= breakpoints.mobile && width < breakpoints.tabletPortrait,
|
||||||
|
);
|
||||||
|
const isTablet = $derived(
|
||||||
|
width >= breakpoints.tabletPortrait && width < breakpoints.desktop,
|
||||||
|
);
|
||||||
|
const isDesktop = $derived(
|
||||||
|
width >= breakpoints.desktop && width < breakpoints.desktopLarge,
|
||||||
|
);
|
||||||
|
const isDesktopLarge = $derived(width >= breakpoints.desktopLarge);
|
||||||
|
|
||||||
|
// Convenience groupings
|
||||||
|
const isMobileOrTablet = $derived(width < breakpoints.desktop);
|
||||||
|
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
|
||||||
|
|
||||||
|
// Orientation
|
||||||
|
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
|
||||||
|
const isPortrait = $derived(orientation === 'portrait');
|
||||||
|
const isLandscape = $derived(orientation === 'landscape');
|
||||||
|
|
||||||
|
// Touch device detection (best effort)
|
||||||
|
const isTouchDevice = $derived(
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize responsive tracking
|
||||||
|
* Call this in an $effect or component mount
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
width = window.innerWidth;
|
||||||
|
height = window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use ResizeObserver for more accurate tracking
|
||||||
|
const resizeObserver = new ResizeObserver(handleResize);
|
||||||
|
resizeObserver.observe(document.documentElement);
|
||||||
|
|
||||||
|
// Fallback to window resize event
|
||||||
|
window.addEventListener('resize', handleResize, { passive: true });
|
||||||
|
|
||||||
|
// Initial measurement
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current width matches a custom breakpoint
|
||||||
|
* @param min - Minimum width (inclusive)
|
||||||
|
* @param max - Maximum width (exclusive)
|
||||||
|
*/
|
||||||
|
function matches(min: number, max?: number): boolean {
|
||||||
|
if (max !== undefined) {
|
||||||
|
return width >= min && width < max;
|
||||||
|
}
|
||||||
|
return width >= min;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current breakpoint name
|
||||||
|
*/
|
||||||
|
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
|
||||||
|
(() => {
|
||||||
|
if (isMobile) return 'mobile';
|
||||||
|
if (isTabletPortrait) return 'tabletPortrait';
|
||||||
|
if (isTablet) return 'tablet';
|
||||||
|
if (isDesktop) return 'desktop';
|
||||||
|
if (isDesktopLarge) return 'desktopLarge';
|
||||||
|
return 'xs'; // Fallback for very small screens
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Dimensions
|
||||||
|
get width() {
|
||||||
|
return width;
|
||||||
|
},
|
||||||
|
get height() {
|
||||||
|
return height;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Standard breakpoints
|
||||||
|
get isMobile() {
|
||||||
|
return isMobile;
|
||||||
|
},
|
||||||
|
get isTabletPortrait() {
|
||||||
|
return isTabletPortrait;
|
||||||
|
},
|
||||||
|
get isTablet() {
|
||||||
|
return isTablet;
|
||||||
|
},
|
||||||
|
get isDesktop() {
|
||||||
|
return isDesktop;
|
||||||
|
},
|
||||||
|
get isDesktopLarge() {
|
||||||
|
return isDesktopLarge;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Convenience groupings
|
||||||
|
get isMobileOrTablet() {
|
||||||
|
return isMobileOrTablet;
|
||||||
|
},
|
||||||
|
get isTabletOrDesktop() {
|
||||||
|
return isTabletOrDesktop;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Orientation
|
||||||
|
get orientation() {
|
||||||
|
return orientation;
|
||||||
|
},
|
||||||
|
get isPortrait() {
|
||||||
|
return isPortrait;
|
||||||
|
},
|
||||||
|
get isLandscape() {
|
||||||
|
return isLandscape;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Device capabilities
|
||||||
|
get isTouchDevice() {
|
||||||
|
return isTouchDevice;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Current breakpoint
|
||||||
|
get currentBreakpoint() {
|
||||||
|
return currentBreakpoint;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
init,
|
||||||
|
matches,
|
||||||
|
|
||||||
|
// Breakpoint values (for custom logic)
|
||||||
|
breakpoints,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const responsiveManager = createResponsiveManager();
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
responsiveManager.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for the responsive manager instance
|
||||||
|
*/
|
||||||
|
export type ResponsiveManager = ReturnType<typeof createResponsiveManager>;
|
||||||
@@ -22,23 +22,23 @@ export interface ControlDataModel {
|
|||||||
step: number;
|
step: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControlModel extends ControlDataModel {
|
export interface ControlModel<T extends string = string> extends ControlDataModel {
|
||||||
/**
|
/**
|
||||||
* Control identifier
|
* Control identifier
|
||||||
*/
|
*/
|
||||||
id: string;
|
id: T;
|
||||||
/**
|
/**
|
||||||
* Area label for increase button
|
* Area label for increase button
|
||||||
*/
|
*/
|
||||||
increaseLabel: string;
|
increaseLabel?: string;
|
||||||
/**
|
/**
|
||||||
* Area label for decrease button
|
* Area label for decrease button
|
||||||
*/
|
*/
|
||||||
decreaseLabel: string;
|
decreaseLabel?: string;
|
||||||
/**
|
/**
|
||||||
* Control area label
|
* Control area label
|
||||||
*/
|
*/
|
||||||
controlLabel: string;
|
controlLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTypographyControl<T extends ControlDataModel>(
|
export function createTypographyControl<T extends ControlDataModel>(
|
||||||
@@ -59,10 +59,10 @@ export function createTypographyControl<T extends ControlDataModel>(
|
|||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
set value(newValue) {
|
set value(newValue) {
|
||||||
value = roundToStepPrecision(
|
const rounded = roundToStepPrecision(clampNumber(newValue, min, max), step);
|
||||||
clampNumber(newValue, min, max),
|
if (value !== rounded) {
|
||||||
step,
|
value = rounded;
|
||||||
);
|
}
|
||||||
},
|
},
|
||||||
get max() {
|
get max() {
|
||||||
return max;
|
return max;
|
||||||
|
|||||||
@@ -3,17 +3,40 @@
|
|||||||
*
|
*
|
||||||
* Used to render visible items with absolute positioning based on computed offsets.
|
* Used to render visible items with absolute positioning based on computed offsets.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface VirtualItem {
|
export interface VirtualItem {
|
||||||
/** Index of the item in the data array */
|
/**
|
||||||
|
* Index of the item in the data array
|
||||||
|
*/
|
||||||
index: number;
|
index: number;
|
||||||
/** Offset from the top of the list in pixels */
|
/**
|
||||||
|
* Offset from the top of the list in pixels
|
||||||
|
*/
|
||||||
start: number;
|
start: number;
|
||||||
/** Height/size of the item in pixels */
|
/**
|
||||||
|
* Height/size of the item in pixels
|
||||||
|
*/
|
||||||
size: number;
|
size: number;
|
||||||
/** End position in pixels (start + size) */
|
/**
|
||||||
|
* End position in pixels (start + size)
|
||||||
|
*/
|
||||||
end: number;
|
end: number;
|
||||||
/** Unique key for the item (for Svelte's {#each} keying) */
|
/**
|
||||||
|
* Unique key for the item (for Svelte's {#each} keying)
|
||||||
|
*/
|
||||||
key: string | number;
|
key: string | number;
|
||||||
|
/**
|
||||||
|
* Whether the item is currently fully visible in the viewport
|
||||||
|
*/
|
||||||
|
isFullyVisible: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the item is currently partially visible in the viewport
|
||||||
|
*/
|
||||||
|
isPartiallyVisible: boolean;
|
||||||
|
/**
|
||||||
|
* Proximity of the item to the center of the viewport
|
||||||
|
*/
|
||||||
|
proximity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +64,11 @@ export interface VirtualizerOptions {
|
|||||||
* Can be useful for handling sticky headers or other UI elements.
|
* Can be useful for handling sticky headers or other UI elements.
|
||||||
*/
|
*/
|
||||||
scrollMargin?: number;
|
scrollMargin?: number;
|
||||||
|
/**
|
||||||
|
* Whether to use the window as the scroll container.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
useWindowScroll?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,13 +116,16 @@ export function createVirtualizer<T>(
|
|||||||
let containerHeight = $state(0);
|
let containerHeight = $state(0);
|
||||||
let measuredSizes = $state<Record<number, number>>({});
|
let measuredSizes = $state<Record<number, number>>({});
|
||||||
let elementRef: HTMLElement | null = null;
|
let elementRef: HTMLElement | null = null;
|
||||||
|
let elementOffsetTop = 0;
|
||||||
|
|
||||||
// By wrapping the getter in $derived, we track everything inside it
|
// By wrapping the getter in $derived, we track everything inside it
|
||||||
const options = $derived(optionsGetter());
|
const options = $derived(optionsGetter());
|
||||||
|
|
||||||
// This derivation now tracks: count, measuredSizes, AND the data array itself
|
// This derivation now tracks: count, _version (for measuredSizes updates), AND the data array itself
|
||||||
const offsets = $derived.by(() => {
|
const offsets = $derived.by(() => {
|
||||||
const count = options.count;
|
const count = options.count;
|
||||||
|
// Implicit dependency on version signal
|
||||||
|
const v = _version;
|
||||||
const result = new Float64Array(count);
|
const result = new Float64Array(count);
|
||||||
let accumulated = 0;
|
let accumulated = 0;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -102,6 +133,7 @@ export function createVirtualizer<T>(
|
|||||||
// Accessing measuredSizes here creates the subscription
|
// Accessing measuredSizes here creates the subscription
|
||||||
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,6 +148,8 @@ export function createVirtualizer<T>(
|
|||||||
// We MUST read options.data here so Svelte knows to re-run
|
// We MUST read options.data here so Svelte knows to re-run
|
||||||
// this derivation when the items array is replaced!
|
// this derivation when the items array is replaced!
|
||||||
const { count, data } = options;
|
const { count, data } = options;
|
||||||
|
// Implicit dependency
|
||||||
|
const v = _version;
|
||||||
if (count === 0 || containerHeight === 0 || !data) return [];
|
if (count === 0 || containerHeight === 0 || !data) return [];
|
||||||
|
|
||||||
const overscan = options.overscan ?? 5;
|
const overscan = options.overscan ?? 5;
|
||||||
@@ -136,6 +170,8 @@ export function createVirtualizer<T>(
|
|||||||
|
|
||||||
let endIdx = startIdx;
|
let endIdx = startIdx;
|
||||||
const viewportEnd = scrollOffset + containerHeight;
|
const viewportEnd = scrollOffset + containerHeight;
|
||||||
|
const viewportCenter = scrollOffset + (containerHeight / 2);
|
||||||
|
|
||||||
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
||||||
endIdx++;
|
endIdx++;
|
||||||
}
|
}
|
||||||
@@ -144,13 +180,34 @@ export function createVirtualizer<T>(
|
|||||||
const end = Math.min(count, endIdx + overscan);
|
const end = Math.min(count, endIdx + overscan);
|
||||||
|
|
||||||
const result: VirtualItem[] = [];
|
const result: VirtualItem[] = [];
|
||||||
|
|
||||||
for (let i = start; i < end; i++) {
|
for (let i = start; i < end; i++) {
|
||||||
|
const itemStart = offsets[i];
|
||||||
|
const itemSize = measuredSizes[i] ?? options.estimateSize(i);
|
||||||
|
const itemEnd = itemStart + itemSize;
|
||||||
|
|
||||||
|
// Visibility check: Does the item overlap the viewport?
|
||||||
|
const isPartiallyVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
|
||||||
|
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
||||||
|
|
||||||
|
// Proximity calculation: 1.0 at center, 0.0 at edges
|
||||||
|
// Guard against division by zero (containerHeight can be 0 on initial render)
|
||||||
|
const itemCenter = itemStart + (itemSize / 2);
|
||||||
|
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
|
||||||
|
const maxDistance = containerHeight / 2;
|
||||||
|
const proximity = maxDistance > 0
|
||||||
|
? Math.max(0, 1 - (distanceToCenter / maxDistance))
|
||||||
|
: 0;
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
index: i,
|
index: i,
|
||||||
start: offsets[i],
|
start: itemStart,
|
||||||
size: measuredSizes[i] ?? options.estimateSize(i),
|
size: itemSize,
|
||||||
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
|
end: itemEnd,
|
||||||
key: options.getItemKey?.(i) ?? i,
|
key: options.getItemKey?.(i) ?? i,
|
||||||
|
isPartiallyVisible,
|
||||||
|
isFullyVisible,
|
||||||
|
proximity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +225,72 @@ export function createVirtualizer<T>(
|
|||||||
*/
|
*/
|
||||||
function container(node: HTMLElement) {
|
function container(node: HTMLElement) {
|
||||||
elementRef = node;
|
elementRef = node;
|
||||||
|
const { useWindowScroll } = optionsGetter();
|
||||||
|
|
||||||
|
if (useWindowScroll) {
|
||||||
|
// Calculate initial offset ONCE
|
||||||
|
const getElementOffset = () => {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
return rect.top + window.scrollY;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedOffsetTop = 0;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
containerHeight = window.innerHeight;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (rafId !== null) return;
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
// Get current position of element relative to viewport
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
// Calculate how much of the element has scrolled past the top of viewport
|
||||||
|
// When element.top is 0, element is at top of viewport
|
||||||
|
// When element.top is -100, element has scrolled up 100px past viewport top
|
||||||
|
const scrolledPastTop = Math.max(0, -rect.top);
|
||||||
|
scrollOffset = scrolledPastTop;
|
||||||
|
rafId = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
containerHeight = window.innerHeight;
|
||||||
|
elementOffsetTop = getElementOffset();
|
||||||
|
cachedOffsetTop = elementOffsetTop;
|
||||||
|
handleScroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
elementOffsetTop = getElementOffset();
|
||||||
|
cachedOffsetTop = elementOffsetTop;
|
||||||
|
handleScroll();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
if (frameId !== null) {
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
frameId = null;
|
||||||
|
}
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
// Disconnect shared ResizeObserver
|
||||||
|
if (sharedResizeObserver) {
|
||||||
|
sharedResizeObserver.disconnect();
|
||||||
|
sharedResizeObserver = null;
|
||||||
|
}
|
||||||
|
elementRef = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
containerHeight = node.offsetHeight;
|
containerHeight = node.offsetHeight;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
@@ -185,49 +308,80 @@ export function createVirtualizer<T>(
|
|||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener('scroll', handleScroll);
|
node.removeEventListener('scroll', handleScroll);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
// Disconnect shared ResizeObserver
|
||||||
|
if (sharedResizeObserver) {
|
||||||
|
sharedResizeObserver.disconnect();
|
||||||
|
sharedResizeObserver = null;
|
||||||
|
}
|
||||||
elementRef = null;
|
elementRef = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let measurementBuffer: Record<number, number> = {};
|
let measurementBuffer: Record<number, number> = {};
|
||||||
let frameId: number | null = null;
|
let frameId: number | null = null;
|
||||||
|
// Signal to trigger updates when mutating measuredSizes in place
|
||||||
|
let _version = $state(0);
|
||||||
|
|
||||||
|
// Single shared ResizeObserver for all items (performance optimization)
|
||||||
|
let sharedResizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Svelte action to measure individual item elements for dynamic height support.
|
* Svelte action to measure individual item elements for dynamic height support.
|
||||||
*
|
*
|
||||||
* Attaches a ResizeObserver to track actual element height and updates
|
* Uses a single shared ResizeObserver for all items to track actual element heights.
|
||||||
* measured sizes when dimensions change. Requires `data-index` attribute on the element.
|
* Requires `data-index` attribute on the element.
|
||||||
*
|
*
|
||||||
* @param node - The DOM element to measure (should have `data-index` attribute)
|
* @param node - The DOM element to measure (should have `data-index` attribute)
|
||||||
* @returns Object with destroy method for cleanup
|
* @returns Object with destroy method for cleanup
|
||||||
*/
|
*/
|
||||||
function measureElement(node: HTMLElement) {
|
function measureElement(node: HTMLElement) {
|
||||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
// Initialize shared observer on first use
|
||||||
if (!entry) return;
|
if (!sharedResizeObserver) {
|
||||||
const index = parseInt(node.dataset.index || '', 10);
|
sharedResizeObserver = new ResizeObserver(entries => {
|
||||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
// Process all entries in a single batch
|
||||||
|
for (const entry of entries) {
|
||||||
|
const target = entry.target as HTMLElement;
|
||||||
|
const index = parseInt(target.dataset.index || '', 10);
|
||||||
|
const height = entry.borderBoxSize[0]?.blockSize ?? target.offsetHeight;
|
||||||
|
|
||||||
if (!isNaN(index) && measuredSizes[index] !== height) {
|
if (!isNaN(index)) {
|
||||||
// 1. Stuff the measurement into a temporary buffer
|
const oldHeight = measuredSizes[index];
|
||||||
|
|
||||||
|
// Only update if the height difference is significant (> 0.5px)
|
||||||
|
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
||||||
measurementBuffer[index] = height;
|
measurementBuffer[index] = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Schedule a single update for the next animation frame
|
// Schedule a single update for the next animation frame
|
||||||
if (frameId === null) {
|
if (frameId === null && Object.keys(measurementBuffer).length > 0) {
|
||||||
frameId = requestAnimationFrame(() => {
|
frameId = requestAnimationFrame(() => {
|
||||||
// 3. Update the state once for all collected measurements
|
// Mutation in place for performance
|
||||||
// We use spread to trigger a single fine-grained update
|
Object.assign(measuredSizes, measurementBuffer);
|
||||||
measuredSizes = { ...measuredSizes, ...measurementBuffer };
|
|
||||||
|
|
||||||
// 4. Reset the buffer
|
// Trigger reactivity
|
||||||
|
_version += 1;
|
||||||
|
|
||||||
|
// Reset buffer
|
||||||
measurementBuffer = {};
|
measurementBuffer = {};
|
||||||
frameId = null;
|
frameId = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
resizeObserver.observe(node);
|
// Observe this element with the shared observer
|
||||||
return { destroy: () => resizeObserver.disconnect() };
|
sharedResizeObserver.observe(node);
|
||||||
|
|
||||||
|
// Return cleanup that only unobserves this specific element
|
||||||
|
return {
|
||||||
|
destroy: () => {
|
||||||
|
sharedResizeObserver?.unobserve(node);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Programmatic Scroll
|
// Programmatic Scroll
|
||||||
@@ -249,14 +403,53 @@ export function createVirtualizer<T>(
|
|||||||
const itemStart = offsets[index];
|
const itemStart = offsets[index];
|
||||||
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
|
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
|
||||||
let target = itemStart;
|
let target = itemStart;
|
||||||
|
const { useWindowScroll } = optionsGetter();
|
||||||
|
|
||||||
|
if (useWindowScroll) {
|
||||||
|
if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2;
|
||||||
|
if (align === 'end') target = itemStart - window.innerHeight + itemSize;
|
||||||
|
|
||||||
|
// Add container offset to target to get absolute document position
|
||||||
|
const absoluteTarget = target + elementOffsetTop;
|
||||||
|
|
||||||
|
window.scrollTo({ top: absoluteTarget, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
|
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
|
||||||
if (align === 'end') target = itemStart - containerHeight + itemSize;
|
if (align === 'end') target = itemStart - containerHeight + itemSize;
|
||||||
|
|
||||||
elementRef.scrollTo({ top: target, behavior: 'smooth' });
|
elementRef.scrollTo({ top: target, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the container to a specific pixel offset.
|
||||||
|
* Used for preserving scroll position during data updates.
|
||||||
|
*
|
||||||
|
* @param offset - The scroll offset in pixels
|
||||||
|
* @param behavior - Scroll behavior: 'auto' for instant, 'smooth' for animated
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* virtualizer.scrollToOffset(1000, 'auto'); // Instant scroll to 1000px
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function scrollToOffset(offset: number, behavior: ScrollBehavior = 'auto') {
|
||||||
|
const { useWindowScroll } = optionsGetter();
|
||||||
|
|
||||||
|
if (useWindowScroll) {
|
||||||
|
window.scrollTo({ top: offset + elementOffsetTop, behavior });
|
||||||
|
} else if (elementRef) {
|
||||||
|
elementRef.scrollTo({ top: offset, behavior });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
get scrollOffset() {
|
||||||
|
return scrollOffset;
|
||||||
|
},
|
||||||
|
get containerHeight() {
|
||||||
|
return containerHeight;
|
||||||
|
},
|
||||||
/** Computed array of visible items to render (reactive) */
|
/** Computed array of visible items to render (reactive) */
|
||||||
get items() {
|
get items() {
|
||||||
return items;
|
return items;
|
||||||
@@ -271,6 +464,8 @@ export function createVirtualizer<T>(
|
|||||||
measureElement,
|
measureElement,
|
||||||
/** Programmatic scroll method to scroll to a specific item */
|
/** Programmatic scroll method to scroll to a specific item */
|
||||||
scrollToIndex,
|
scrollToIndex,
|
||||||
|
/** Programmatic scroll method to scroll to a specific pixel offset */
|
||||||
|
scrollToOffset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { createVirtualizer } from './createVirtualizer.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOTE: Svelte 5 Runes Testing Limitations
|
||||||
|
*
|
||||||
|
* The createVirtualizer helper uses Svelte 5 runes ($state, $derived, $derived.by)
|
||||||
|
* which require a full Svelte runtime environment to work correctly. In unit tests
|
||||||
|
* with jsdom, these runes are stubbed and don't provide actual reactivity.
|
||||||
|
*
|
||||||
|
* These tests focus on:
|
||||||
|
* 1. API surface verification (methods, getters exist)
|
||||||
|
* 2. Initial state calculation
|
||||||
|
* 3. DOM integration (event listeners are attached)
|
||||||
|
* 4. Edge case handling
|
||||||
|
*
|
||||||
|
* For full reactivity testing, use browser-based tests with @vitest/browser-playwright
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock ResizeObserver globally since it's not available in jsdom
|
||||||
|
class MockResizeObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = MockResizeObserver as any;
|
||||||
|
|
||||||
|
// Mock requestAnimationFrame
|
||||||
|
globalThis.requestAnimationFrame =
|
||||||
|
((cb: FrameRequestCallback) =>
|
||||||
|
setTimeout(() => cb(performance.now()), 16) as unknown) as typeof requestAnimationFrame;
|
||||||
|
globalThis.cancelAnimationFrame = vi.fn();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create test data array
|
||||||
|
*/
|
||||||
|
function createTestData(count: number): string[] {
|
||||||
|
return Array.from({ length: count }, (_, i) => `Item ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a mock scrollable container element
|
||||||
|
*/
|
||||||
|
function createMockContainer(height = 500, scrollTop = 0): any {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
Object.defineProperty(container, 'offsetHeight', {
|
||||||
|
value: height,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(container, 'scrollTop', {
|
||||||
|
value: scrollTop,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
// Add scrollTo method for testing
|
||||||
|
container.scrollTo = vi.fn();
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createVirtualizer - Basic API and State', () => {
|
||||||
|
describe('Basic Initialization and API Surface', () => {
|
||||||
|
it('should initialize and return expected API surface', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 0,
|
||||||
|
data: [],
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Verify API surface exists
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
expect(virtualizer).toHaveProperty('totalSize');
|
||||||
|
expect(virtualizer).toHaveProperty('scrollOffset');
|
||||||
|
expect(virtualizer).toHaveProperty('containerHeight');
|
||||||
|
expect(virtualizer).toHaveProperty('container');
|
||||||
|
expect(virtualizer).toHaveProperty('measureElement');
|
||||||
|
expect(virtualizer).toHaveProperty('scrollToIndex');
|
||||||
|
expect(virtualizer).toHaveProperty('scrollToOffset');
|
||||||
|
|
||||||
|
// Verify initial values
|
||||||
|
expect(virtualizer.items).toEqual([]);
|
||||||
|
expect(virtualizer.totalSize).toBe(0);
|
||||||
|
expect(virtualizer.scrollOffset).toBe(0);
|
||||||
|
expect(virtualizer.containerHeight).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate correct totalSize for uniform item sizes', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 10 items * 50px each = 500px total
|
||||||
|
expect(virtualizer.totalSize).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate correct totalSize for varying item sizes', () => {
|
||||||
|
const sizes = [50, 100, 150, 75, 125]; // Sum = 500
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 5,
|
||||||
|
data: createTestData(5),
|
||||||
|
estimateSize: (i: number) => sizes[i],
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer.totalSize).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty list (count = 0)', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 0,
|
||||||
|
data: [],
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer.totalSize).toBe(0);
|
||||||
|
expect(virtualizer.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large lists', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100000,
|
||||||
|
data: createTestData(100000),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer.totalSize).toBe(5000000); // 100000 * 50
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero estimated size', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer.totalSize).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Container Action', () => {
|
||||||
|
let cleanupHandlers: (() => void)[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupHandlers.forEach(cleanup => cleanup());
|
||||||
|
cleanupHandlers = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should attach container action and set up listeners', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const addEventListenerSpy = vi.spyOn(container, 'addEventListener');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
// Verify scroll listener was attached
|
||||||
|
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||||
|
'scroll',
|
||||||
|
expect.any(Function),
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update containerHeight when container is attached', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
expect(virtualizer.containerHeight).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up listeners on destroy', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const removeEventListenerSpy = vi.spyOn(container, 'removeEventListener');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanup?.destroy?.();
|
||||||
|
|
||||||
|
expect(removeEventListenerSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support window scrolling mode', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
useWindowScroll: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const windowAddSpy = vi.spyOn(window, 'addEventListener');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
// Should attach to window scroll
|
||||||
|
expect(windowAddSpy).toHaveBeenCalledWith('scroll', expect.any(Function), expect.any(Object));
|
||||||
|
expect(windowAddSpy).toHaveBeenCalledWith('resize', expect.any(Function));
|
||||||
|
|
||||||
|
windowAddSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scrollToIndex Method', () => {
|
||||||
|
let cleanupHandlers: (() => void)[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupHandlers.forEach(cleanup => cleanup());
|
||||||
|
cleanupHandlers = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have scrollToIndex method that does not throw without container', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Should not throw when container is not attached
|
||||||
|
expect(() => virtualizer.scrollToIndex(50)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll to specific index with container attached', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
virtualizer.scrollToIndex(10);
|
||||||
|
|
||||||
|
expect(scrollToSpy).toHaveBeenCalledWith({
|
||||||
|
top: expect.any(Number),
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle center alignment', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
virtualizer.scrollToIndex(10, 'center');
|
||||||
|
|
||||||
|
expect(scrollToSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle end alignment', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
virtualizer.scrollToIndex(10, 'end');
|
||||||
|
|
||||||
|
expect(scrollToSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not scroll for out of bounds indices', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
// Negative index
|
||||||
|
virtualizer.scrollToIndex(-1);
|
||||||
|
|
||||||
|
// Index >= count
|
||||||
|
virtualizer.scrollToIndex(100);
|
||||||
|
|
||||||
|
// Should not have been called
|
||||||
|
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scrollToOffset Method', () => {
|
||||||
|
let cleanupHandlers: (() => void)[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupHandlers.forEach(cleanup => cleanup());
|
||||||
|
cleanupHandlers = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll to specific pixel offset', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
virtualizer.scrollToOffset(1000);
|
||||||
|
|
||||||
|
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'auto' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support smooth behavior', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
virtualizer.scrollToOffset(1000, 'smooth');
|
||||||
|
|
||||||
|
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('measureElement Action', () => {
|
||||||
|
it('should attach measureElement action to DOM element', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.dataset.index = '0';
|
||||||
|
|
||||||
|
// Should not throw when attaching measureElement
|
||||||
|
expect(() => {
|
||||||
|
const cleanup = virtualizer.measureElement(element);
|
||||||
|
cleanup?.destroy?.();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up observer on destroy', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.dataset.index = '0';
|
||||||
|
|
||||||
|
const cleanup = virtualizer.measureElement(element);
|
||||||
|
|
||||||
|
// Should not throw when destroying
|
||||||
|
expect(() => cleanup?.destroy?.()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple elements being measured', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const elements = Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.dataset.index = String(i);
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanups = elements.map(el => virtualizer.measureElement(el));
|
||||||
|
|
||||||
|
// Should not throw when measuring multiple elements
|
||||||
|
expect(() => {
|
||||||
|
cleanups.forEach(cleanup => cleanup?.destroy?.());
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Options Handling', () => {
|
||||||
|
it('should use default overscan of 5', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Options with default overscan should work
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom overscan value', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
overscan: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use index as default key when getItemKey is not provided', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom getItemKey when provided', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
getItemKey: (i: number) => `custom-key-${i}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom scrollMargin when provided', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
scrollMargin: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle single item list', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 1,
|
||||||
|
data: ['Item 0'],
|
||||||
|
estimateSize: () => 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer.totalSize).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle items larger than viewport', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 5,
|
||||||
|
data: createTestData(5),
|
||||||
|
estimateSize: () => 200, // Each item is 200px
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Total size should still be calculated correctly
|
||||||
|
expect(virtualizer.totalSize).toBe(1000); // 5 * 200
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle overscan larger than viewport', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
overscan: 100, // Very large overscan
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative estimated size (graceful degradation)', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => -10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Should calculate total size (may be negative, but shouldn't crash)
|
||||||
|
expect(virtualizer.totalSize).toBeLessThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Virtual Item Structure', () => {
|
||||||
|
it('should return items with correct structure when container is attached', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
|
||||||
|
// Items may be empty in test environment due to reactivity limitations
|
||||||
|
// but we verify the structure exists
|
||||||
|
expect(Array.isArray(virtualizer.items)).toBe(true);
|
||||||
|
|
||||||
|
cleanup?.destroy?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,3 +26,25 @@ export {
|
|||||||
type Entity,
|
type Entity,
|
||||||
type EntityStore,
|
type EntityStore,
|
||||||
} from './createEntityStore/createEntityStore.svelte';
|
} from './createEntityStore/createEntityStore.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CharacterComparison,
|
||||||
|
createCharacterComparison,
|
||||||
|
type LineData,
|
||||||
|
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createPersistentStore,
|
||||||
|
type PersistentStore,
|
||||||
|
} from './createPersistentStore/createPersistentStore.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createResponsiveManager,
|
||||||
|
type ResponsiveManager,
|
||||||
|
responsiveManager,
|
||||||
|
} from './createResponsiveManager/createResponsiveManager.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createPerspectiveManager,
|
||||||
|
type PerspectiveManager,
|
||||||
|
} from './createPerspectiveManager/createPerspectiveManager.svelte';
|
||||||
|
|||||||
@@ -1,21 +1,43 @@
|
|||||||
export {
|
export {
|
||||||
|
type CharacterComparison,
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
|
createCharacterComparison,
|
||||||
createDebouncedState,
|
createDebouncedState,
|
||||||
createEntityStore,
|
createEntityStore,
|
||||||
createFilter,
|
createFilter,
|
||||||
|
createPersistentStore,
|
||||||
|
createPerspectiveManager,
|
||||||
|
createResponsiveManager,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
createVirtualizer,
|
createVirtualizer,
|
||||||
type Entity,
|
type Entity,
|
||||||
type EntityStore,
|
type EntityStore,
|
||||||
type Filter,
|
type Filter,
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
|
type LineData,
|
||||||
|
type PersistentStore,
|
||||||
|
type PerspectiveManager,
|
||||||
type Property,
|
type Property,
|
||||||
|
type ResponsiveManager,
|
||||||
|
responsiveManager,
|
||||||
type TypographyControl,
|
type TypographyControl,
|
||||||
type VirtualItem,
|
type VirtualItem,
|
||||||
type Virtualizer,
|
type Virtualizer,
|
||||||
type VirtualizerOptions,
|
type VirtualizerOptions,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
|
||||||
export { motion } from './accessibility/motion.svelte';
|
export {
|
||||||
export { splitArray } from './utils';
|
buildQueryString,
|
||||||
|
clampNumber,
|
||||||
|
debounce,
|
||||||
|
getDecimalPlaces,
|
||||||
|
roundToStepPrecision,
|
||||||
|
smoothScroll,
|
||||||
|
splitArray,
|
||||||
|
throttle,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
export { springySlideFade } from './transitions';
|
||||||
|
|
||||||
|
export { ResponsiveProvider } from './providers';
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!--
|
||||||
|
Component: ResponsiveProvider
|
||||||
|
Provides a responsive manager to all children
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
type ResponsiveManager,
|
||||||
|
createResponsiveManager,
|
||||||
|
} from '$shared/lib/helpers';
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
const responsive = createResponsiveManager();
|
||||||
|
|
||||||
|
// Initialize and cleanup
|
||||||
|
$effect(() => {
|
||||||
|
return responsive.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide to all children
|
||||||
|
setContext('responsive', responsive);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
1
src/shared/lib/providers/index.ts
Normal file
1
src/shared/lib/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as ResponsiveProvider } from './ResponsiveProvider/ResponsiveProvider.svelte';
|
||||||
41
src/shared/lib/storybook/MockIcon.svelte
Normal file
41
src/shared/lib/storybook/MockIcon.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!--
|
||||||
|
Component: MockIcon
|
||||||
|
Wrapper component for Lucide icons to properly handle className in Storybook.
|
||||||
|
|
||||||
|
Lucide Svelte icons from @lucide/svelte/icons/* don't properly handle
|
||||||
|
the className prop directly. This wrapper ensures the class is applied
|
||||||
|
correctly via the HTML element's class attribute.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import type {
|
||||||
|
Component,
|
||||||
|
Snippet,
|
||||||
|
} from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* The Lucide icon component
|
||||||
|
*/
|
||||||
|
icon: Component;
|
||||||
|
/**
|
||||||
|
* CSS classes to apply to the icon
|
||||||
|
*/
|
||||||
|
class?: string;
|
||||||
|
/**
|
||||||
|
* Additional icon-specific attributes
|
||||||
|
*/
|
||||||
|
attrs?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { icon: Icon, class: className, attrs = {} }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if Icon}
|
||||||
|
{@const __iconClass__ = cn('size-4', className)}
|
||||||
|
<!-- Render icon component dynamically with class prop -->
|
||||||
|
<Icon
|
||||||
|
class={__iconClass__}
|
||||||
|
{...attrs}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
64
src/shared/lib/storybook/Providers.svelte
Normal file
64
src/shared/lib/storybook/Providers.svelte
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!--
|
||||||
|
Component: Providers
|
||||||
|
Storybook wrapper that provides required contexts for components.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- responsive: ResponsiveManager context for breakpoint tracking
|
||||||
|
- tooltip: Tooltip.Provider context for shadcn Tooltip components
|
||||||
|
- Additional Radix UI providers can be added here as needed
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createResponsiveManager } from '$shared/lib';
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
/**
|
||||||
|
* Initial viewport width for the responsive context (default: 1280)
|
||||||
|
*/
|
||||||
|
initialWidth?: number;
|
||||||
|
/**
|
||||||
|
* Initial viewport height for the responsive context (default: 720)
|
||||||
|
*/
|
||||||
|
initialHeight?: number;
|
||||||
|
/**
|
||||||
|
* Tooltip provider options
|
||||||
|
*/
|
||||||
|
tooltipDelayDuration?: number;
|
||||||
|
/**
|
||||||
|
* Tooltip skip delay duration
|
||||||
|
*/
|
||||||
|
tooltipSkipDelayDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
initialWidth = 1280,
|
||||||
|
initialHeight = 720,
|
||||||
|
tooltipDelayDuration = 200,
|
||||||
|
tooltipSkipDelayDuration = 300,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Create a responsive manager with default breakpoints
|
||||||
|
const responsiveManager = createResponsiveManager();
|
||||||
|
|
||||||
|
// Initialize the responsive manager to set up resize listeners
|
||||||
|
$effect(() => {
|
||||||
|
return responsiveManager.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide the responsive context
|
||||||
|
setContext<ResponsiveManager>('responsive', responsiveManager);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="storybook-providers" style:width="100%" style:height="100%">
|
||||||
|
<TooltipProvider
|
||||||
|
delayDuration={tooltipDelayDuration}
|
||||||
|
skipDelayDuration={tooltipSkipDelayDuration}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
24
src/shared/lib/storybook/index.ts
Normal file
24
src/shared/lib/storybook/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* STORYBOOK HELPERS
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Helper components and utilities for Storybook stories.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```svelte
|
||||||
|
* <script lang="ts">
|
||||||
|
* import { Providers, MockIcon } from '$shared/lib/storybook';
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* <Providers>
|
||||||
|
* <YourComponent />
|
||||||
|
* </Providers>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as MockIcon } from './MockIcon.svelte';
|
||||||
|
export { default as Providers } from './Providers.svelte';
|
||||||
1
src/shared/lib/transitions/index.ts
Normal file
1
src/shared/lib/transitions/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { springySlideFade } from './springySlideFade/springySlideFade';
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type {
|
||||||
|
SlideParams,
|
||||||
|
TransitionConfig,
|
||||||
|
} from 'svelte/transition';
|
||||||
|
|
||||||
|
function elasticOut(t: number) {
|
||||||
|
return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gentleSpring(t: number) {
|
||||||
|
return 1 - Math.pow(1 - t, 3) * Math.cos(t * Math.PI * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Svelte slide transition function for custom slide+fade
|
||||||
|
* @param node - The element to apply the transition to
|
||||||
|
* @param params - Transition parameters
|
||||||
|
* @returns Transition configuration
|
||||||
|
*/
|
||||||
|
export function springySlideFade(
|
||||||
|
node: HTMLElement,
|
||||||
|
params: SlideParams = {},
|
||||||
|
): TransitionConfig {
|
||||||
|
const { duration = 400 } = params;
|
||||||
|
const height = node.scrollHeight;
|
||||||
|
|
||||||
|
// Check if the browser is Firefox to work around specific rendering issues
|
||||||
|
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration,
|
||||||
|
// We use 'tick' for the most precise control over the
|
||||||
|
// coordination with the elements below.
|
||||||
|
css: t => {
|
||||||
|
// Use elastic easing
|
||||||
|
const eased = gentleSpring(t);
|
||||||
|
|
||||||
|
return `
|
||||||
|
height: ${eased * height}px;
|
||||||
|
opacity: ${t};
|
||||||
|
transform: translateY(${(1 - t) * -10}px);
|
||||||
|
transform-origin: top;
|
||||||
|
overflow: hidden;
|
||||||
|
contain: size layout style;
|
||||||
|
will-change: max-height, opacity, transform;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
${
|
||||||
|
isFirefox
|
||||||
|
? `
|
||||||
|
perspective: 1000px;
|
||||||
|
isolation: isolate;
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,4 +11,6 @@ export { clampNumber } from './clampNumber/clampNumber';
|
|||||||
export { debounce } from './debounce/debounce';
|
export { debounce } from './debounce/debounce';
|
||||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||||
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
||||||
|
export { smoothScroll } from './smoothScroll/smoothScroll';
|
||||||
export { splitArray } from './splitArray/splitArray';
|
export { splitArray } from './splitArray/splitArray';
|
||||||
|
export { throttle } from './throttle/throttle';
|
||||||
|
|||||||
368
src/shared/lib/utils/smoothScroll/smoothScroll.test.ts
Normal file
368
src/shared/lib/utils/smoothScroll/smoothScroll.test.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { smoothScroll } from './smoothScroll';
|
||||||
|
|
||||||
|
describe('smoothScroll', () => {
|
||||||
|
let mockAnchor: HTMLAnchorElement;
|
||||||
|
let mockTarget: HTMLElement;
|
||||||
|
let mockScrollIntoView: ReturnType<typeof vi.fn>;
|
||||||
|
let mockPushState: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock scrollIntoView
|
||||||
|
mockScrollIntoView = vi.fn();
|
||||||
|
HTMLElement.prototype.scrollIntoView = mockScrollIntoView as (arg?: boolean | ScrollIntoViewOptions) => void;
|
||||||
|
|
||||||
|
// Mock history.pushState
|
||||||
|
mockPushState = vi.fn();
|
||||||
|
vi.stubGlobal('history', {
|
||||||
|
pushState: mockPushState,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mock elements
|
||||||
|
mockAnchor = document.createElement('a');
|
||||||
|
mockAnchor.setAttribute('href', '#section-1');
|
||||||
|
|
||||||
|
mockTarget = document.createElement('div');
|
||||||
|
mockTarget.id = 'section-1';
|
||||||
|
document.body.appendChild(mockTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Basic Functionality', () => {
|
||||||
|
it('should be a function that returns an object with destroy method', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
expect(typeof action).toBe('object');
|
||||||
|
expect(typeof action.destroy).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add click event listener to the anchor element', () => {
|
||||||
|
const addEventListenerSpy = vi.spyOn(mockAnchor, 'addEventListener');
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
|
||||||
|
addEventListenerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove click event listener when destroy is called', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
const removeEventListenerSpy = vi.spyOn(mockAnchor, 'removeEventListener');
|
||||||
|
|
||||||
|
action.destroy();
|
||||||
|
|
||||||
|
expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
|
||||||
|
removeEventListenerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Click Handling', () => {
|
||||||
|
it('should prevent default behavior on click', () => {
|
||||||
|
const mockEvent = new MouseEvent('click', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
const preventDefaultSpy = vi.spyOn(mockEvent, 'preventDefault');
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||||
|
preventDefaultSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll to target element when clicked', () => {
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledWith({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update URL hash without jumping when clicked', () => {
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should do nothing when href attribute is missing', () => {
|
||||||
|
mockAnchor.removeAttribute('href');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(mockPushState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when href is just "#"', () => {
|
||||||
|
mockAnchor.setAttribute('href', '#');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(mockPushState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when target element does not exist', () => {
|
||||||
|
mockAnchor.setAttribute('href', '#non-existent');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(mockPushState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty href attribute', () => {
|
||||||
|
mockAnchor.setAttribute('href', '');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multiple Anchors', () => {
|
||||||
|
it('should work correctly with multiple anchor elements', () => {
|
||||||
|
const anchor1 = document.createElement('a');
|
||||||
|
anchor1.setAttribute('href', '#section-1');
|
||||||
|
const target1 = document.createElement('div');
|
||||||
|
target1.id = 'section-1';
|
||||||
|
document.body.appendChild(target1);
|
||||||
|
|
||||||
|
const anchor2 = document.createElement('a');
|
||||||
|
anchor2.setAttribute('href', '#section-2');
|
||||||
|
const target2 = document.createElement('div');
|
||||||
|
target2.id = 'section-2';
|
||||||
|
document.body.appendChild(target2);
|
||||||
|
|
||||||
|
const action1 = smoothScroll(anchor1);
|
||||||
|
const action2 = smoothScroll(anchor2);
|
||||||
|
|
||||||
|
const event1 = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
anchor1.dispatchEvent(event1);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const event2 = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
anchor2.dispatchEvent(event2);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-2');
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
action1.destroy();
|
||||||
|
action2.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cleanup', () => {
|
||||||
|
it('should not trigger clicks after destroy is called', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
action.destroy();
|
||||||
|
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(mockPushState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow multiple destroy calls without errors', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
action.destroy();
|
||||||
|
action.destroy();
|
||||||
|
action.destroy();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Scroll Options', () => {
|
||||||
|
it('should always use smooth behavior', () => {
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
behavior: 'smooth',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always use block: start', () => {
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
block: 'start',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Different Hash Formats', () => {
|
||||||
|
it('should handle simple hash like "#section"', () => {
|
||||||
|
const target = document.createElement('div');
|
||||||
|
target.id = 'section';
|
||||||
|
document.body.appendChild(target);
|
||||||
|
|
||||||
|
mockAnchor.setAttribute('href', '#section');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalled();
|
||||||
|
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle hash with multiple words like "#my-section"', () => {
|
||||||
|
const target = document.createElement('div');
|
||||||
|
target.id = 'my-section';
|
||||||
|
document.body.appendChild(target);
|
||||||
|
|
||||||
|
mockAnchor.setAttribute('href', '#my-section');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalled();
|
||||||
|
expect(mockPushState).toHaveBeenCalledWith(null, '', '#my-section');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle hash with numbers like "#section-1-2"', () => {
|
||||||
|
const target = document.createElement('div');
|
||||||
|
target.id = 'section-1-2';
|
||||||
|
document.body.appendChild(target);
|
||||||
|
|
||||||
|
mockAnchor.setAttribute('href', '#section-1-2');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalled();
|
||||||
|
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Special Cases', () => {
|
||||||
|
it('should gracefully handle missing history.pushState', () => {
|
||||||
|
// Create a fresh test environment
|
||||||
|
const testAnchor = document.createElement('a');
|
||||||
|
testAnchor.href = '#test';
|
||||||
|
const testTarget = document.createElement('div');
|
||||||
|
testTarget.id = 'test';
|
||||||
|
document.body.appendChild(testTarget);
|
||||||
|
|
||||||
|
// Don't stub history - the action should still work without it
|
||||||
|
const action = smoothScroll(testAnchor);
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
// Should not throw even if history.pushState might not exist
|
||||||
|
expect(() => testAnchor.dispatchEvent(mockEvent)).not.toThrow();
|
||||||
|
|
||||||
|
action.destroy();
|
||||||
|
testTarget.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Return Value', () => {
|
||||||
|
it('should return an action object compatible with Svelte use directive', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
expect(action).toHaveProperty('destroy');
|
||||||
|
expect(typeof action.destroy).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow chaining destroy calls', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
const result = action.destroy();
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Real-World Scenarios', () => {
|
||||||
|
it('should handle table of contents navigation', () => {
|
||||||
|
const sections = ['intro', 'features', 'pricing', 'contact'];
|
||||||
|
sections.forEach(id => {
|
||||||
|
const section = document.createElement('section');
|
||||||
|
section.id = id;
|
||||||
|
document.body.appendChild(section);
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = `#${id}`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
|
||||||
|
const action = smoothScroll(link);
|
||||||
|
|
||||||
|
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
link.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalled();
|
||||||
|
|
||||||
|
action.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledTimes(sections.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with back-to-top button', () => {
|
||||||
|
const topAnchor = document.createElement('a');
|
||||||
|
topAnchor.href = '#top';
|
||||||
|
document.body.appendChild(topAnchor);
|
||||||
|
|
||||||
|
const topElement = document.createElement('div');
|
||||||
|
topElement.id = 'top';
|
||||||
|
document.body.prepend(topElement);
|
||||||
|
|
||||||
|
const action = smoothScroll(topAnchor);
|
||||||
|
|
||||||
|
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
topAnchor.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalled();
|
||||||
|
|
||||||
|
action.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
32
src/shared/lib/utils/smoothScroll/smoothScroll.ts
Normal file
32
src/shared/lib/utils/smoothScroll/smoothScroll.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Smoothly scrolls to the target element when an anchor element is clicked.
|
||||||
|
* @param node - The anchor element to listen for clicks on.
|
||||||
|
*/
|
||||||
|
export function smoothScroll(node: HTMLAnchorElement) {
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const hash = node.getAttribute('href');
|
||||||
|
if (!hash || hash === '#') return;
|
||||||
|
|
||||||
|
const targetElement = document.querySelector(hash);
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
targetElement.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update URL hash without jumping
|
||||||
|
history.pushState(null, '', hash);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
node.addEventListener('click', handleClick);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('click', handleClick);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user