Compare commits
23 Commits
32da012b26
...
20f6e193f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20f6e193f2 | ||
|
|
c04518300b | ||
|
|
ee074036f6 | ||
|
|
ba883ef9a8 | ||
|
|
28a71452d1 | ||
|
|
b7ce100407 | ||
|
|
96b26fb055 | ||
|
|
5ef8d609ab | ||
|
|
f457e5116f | ||
|
|
e0e0d929bb | ||
|
|
37ab7f795e | ||
|
|
af2ef77c30 | ||
|
|
ad18a19c4b | ||
|
|
ef259c6fce | ||
|
|
5d23a2af55 | ||
|
|
df8eca6ef2 | ||
|
|
7e62acce49 | ||
|
|
86e7b2c1ec | ||
|
|
da0612942c | ||
|
|
0444f8c114 | ||
|
|
6b4e0dbbd0 | ||
|
|
7389ec779d | ||
|
|
4d04761d88 |
@@ -24,6 +24,8 @@ let { children } = $props();
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
|
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||||
|
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div id="app-root">
|
<div id="app-root">
|
||||||
|
|||||||
@@ -60,9 +60,11 @@ export type {
|
|||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
appliedFontsManager,
|
||||||
createFontshareStore,
|
createFontshareStore,
|
||||||
fetchFontshareFontsQuery,
|
fetchFontshareFontsQuery,
|
||||||
fontshareStore,
|
fontshareStore,
|
||||||
|
selectedFontsStore,
|
||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
// Stores
|
// Stores
|
||||||
@@ -72,4 +74,8 @@ export {
|
|||||||
} from './model/services/fetchGoogleFonts.svelte';
|
} from './model/services/fetchGoogleFonts.svelte';
|
||||||
|
|
||||||
// UI elements
|
// UI elements
|
||||||
export { FontList } from './ui';
|
export {
|
||||||
|
FontApplicator,
|
||||||
|
FontListItem,
|
||||||
|
FontVirtualList,
|
||||||
|
} from './ui';
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export type {
|
|||||||
export { fetchFontshareFontsQuery } from './services';
|
export { fetchFontshareFontsQuery } from './services';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
appliedFontsManager,
|
||||||
createFontshareStore,
|
createFontshareStore,
|
||||||
type FontshareStore,
|
type FontshareStore,
|
||||||
fontshareStore,
|
fontshareStore,
|
||||||
|
selectedFontsStore,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
|
class AppliedFontsManager {
|
||||||
|
// Stores: slug -> timestamp of last visibility
|
||||||
|
#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>();
|
||||||
|
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
#PURGE_INTERVAL = 60000; // Check every minute
|
||||||
|
#TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
#CHUNK_SIZE = 3;
|
||||||
|
|
||||||
|
statuses = new SvelteMap<string, FontStatus>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Start the "Janitor" loop
|
||||||
|
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the 'last seen' timestamp for fonts.
|
||||||
|
* Prevents them from being purged while they are on screen.
|
||||||
|
*/
|
||||||
|
touch(slugs: string[]) {
|
||||||
|
const now = Date.now();
|
||||||
|
const toRegister: string[] = [];
|
||||||
|
|
||||||
|
slugs.forEach(slug => {
|
||||||
|
this.#usageTracker.set(slug, now);
|
||||||
|
if (!this.#slugToBatch.has(slug)) {
|
||||||
|
toRegister.push(slug);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toRegister.length > 0) this.registerFonts(toRegister);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerFonts(slugs: string[]) {
|
||||||
|
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s));
|
||||||
|
if (newSlugs.length === 0) return;
|
||||||
|
|
||||||
|
newSlugs.forEach(s => this.#queue.add(s));
|
||||||
|
|
||||||
|
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
||||||
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFontStatus(slug: string) {
|
||||||
|
return this.statuses.get(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
#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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#purgeUnused() {
|
||||||
|
const now = Date.now();
|
||||||
|
const batchesToPotentialDelete = new Set<string>();
|
||||||
|
const slugsToDelete: string[] = [];
|
||||||
|
|
||||||
|
// Identify expired slugs
|
||||||
|
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
|
||||||
|
if (now - lastUsed > this.#TTL) {
|
||||||
|
const batchId = this.#slugToBatch.get(slug);
|
||||||
|
if (batchId) batchesToPotentialDelete.add(batchId);
|
||||||
|
slugsToDelete.push(slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only remove a batch if ALL fonts in that batch are expired
|
||||||
|
batchesToPotentialDelete.forEach(batchId => {
|
||||||
|
const batchSlugs = Array.from(this.#slugToBatch.entries())
|
||||||
|
.filter(([_, bId]) => bId === batchId)
|
||||||
|
.map(([slug]) => slug);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appliedFontsManager = new AppliedFontsManager();
|
||||||
@@ -17,3 +17,7 @@ export {
|
|||||||
type FontshareStore,
|
type FontshareStore,
|
||||||
fontshareStore,
|
fontshareStore,
|
||||||
} from './fontshareStore.svelte';
|
} from './fontshareStore.svelte';
|
||||||
|
|
||||||
|
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
||||||
|
|
||||||
|
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createEntityStore } from '$shared/lib';
|
||||||
|
import type { UnifiedFont } from '../../types';
|
||||||
|
|
||||||
|
export const selectedFontsStore = createEntityStore<UnifiedFont>([]);
|
||||||
46
src/entities/Font/ui/FontApplicator/FontApplicator.svelte
Normal file
46
src/entities/Font/ui/FontApplicator/FontApplicator.svelte
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!--
|
||||||
|
Component: FontApplicator
|
||||||
|
|
||||||
|
Loads fonts from fontshare with link tag
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
appliedFontsManager,
|
||||||
|
} from '$entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
className?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, id, className, children }: Props = $props();
|
||||||
|
let element: Element;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
appliedFontsManager.touch([id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = $derived(appliedFontsManager.getFontStatus(id) === 'loading');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={element}
|
||||||
|
style:font-family={name}
|
||||||
|
class={cn(
|
||||||
|
'transition-all duration-200 ease-out',
|
||||||
|
isLoading ? 'opacity-0 translate-y-1' : 'opacity-100 translate-y-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: FontList
|
|
||||||
|
|
||||||
- Displays a virtualized list of fonts with loading, empty, and error states.
|
|
||||||
- Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import FontView from '$features/ShowFont/ui/FontView.svelte';
|
|
||||||
import {
|
|
||||||
Content as ItemContent,
|
|
||||||
Root as ItemRoot,
|
|
||||||
Title as ItemTitle,
|
|
||||||
} from '$shared/shadcn/ui/item';
|
|
||||||
import { VirtualList } from '$shared/ui';
|
|
||||||
import { fontshareStore } from '../../model';
|
|
||||||
|
|
||||||
$inspect(fontshareStore.fonts);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<VirtualList items={fontshareStore.fonts}>
|
|
||||||
{#snippet children({ item: font })}
|
|
||||||
<ItemRoot>
|
|
||||||
<ItemContent>
|
|
||||||
<!-- <ItemTitle></ItemTitle> -->
|
|
||||||
<span class="text-xs text-muted-foreground">
|
|
||||||
{font.provider} • {font.category}
|
|
||||||
</span>
|
|
||||||
<FontView id={font.id} slug={font.id} name={font.name}>{font.name}</FontView>
|
|
||||||
</ItemContent>
|
|
||||||
</ItemRoot>
|
|
||||||
{/snippet}
|
|
||||||
</VirtualList>
|
|
||||||
81
src/entities/Font/ui/FontListItem/FontListItem.svelte
Normal file
81
src/entities/Font/ui/FontListItem/FontListItem.svelte
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<!--
|
||||||
|
Component: FontListItem
|
||||||
|
Displays a font item with a checkbox and its characteristics in badges.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { Badge } from '$shared/shadcn/ui/badge';
|
||||||
|
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||||
|
import { Label } from '$shared/shadcn/ui/label';
|
||||||
|
import {
|
||||||
|
type UnifiedFont,
|
||||||
|
selectedFontsStore,
|
||||||
|
} from '../../model';
|
||||||
|
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
font: UnifiedFont;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { font }: Props = $props();
|
||||||
|
|
||||||
|
const handleChange = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
selectedFontsStore.addOne(font);
|
||||||
|
} else {
|
||||||
|
selectedFontsStore.removeOne(font.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selected = $derived(selectedFontsStore.has(font.id));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pb-1">
|
||||||
|
<Label
|
||||||
|
for={font.id}
|
||||||
|
class="
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
30
src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
Normal file
30
src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts" generics="T extends { id: string }">
|
||||||
|
import { VirtualList } from '$shared/ui';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
import { appliedFontsManager } from '../../model/store/appliedFontsStore/appliedFontsStore.svelte';
|
||||||
|
|
||||||
|
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
|
||||||
|
onVisibleItemsChange?: (items: T[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { items, children, onVisibleItemsChange, ...rest }: Props = $props();
|
||||||
|
|
||||||
|
function handleInternalVisibleChange(visibleItems: T[]) {
|
||||||
|
// Auto-register fonts with the manager
|
||||||
|
const slugs = visibleItems.map(item => item.id);
|
||||||
|
appliedFontsManager.registerFonts(slugs);
|
||||||
|
|
||||||
|
// Forward the call to any external listener
|
||||||
|
onVisibleItemsChange?.(visibleItems);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<VirtualList
|
||||||
|
{items}
|
||||||
|
{...rest}
|
||||||
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
|
>
|
||||||
|
{#snippet children(scope)}
|
||||||
|
{@render children(scope)}
|
||||||
|
{/snippet}
|
||||||
|
</VirtualList>
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
import FontList from './FontList/FontList.svelte';
|
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||||
|
import FontListItem from './FontListItem/FontListItem.svelte';
|
||||||
|
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||||
|
|
||||||
export { FontList };
|
export {
|
||||||
|
FontApplicator,
|
||||||
|
FontListItem,
|
||||||
|
FontVirtualList,
|
||||||
|
};
|
||||||
|
|||||||
0
src/features/DisplayFont/index.ts
Normal file
0
src/features/DisplayFont/index.ts
Normal file
1
src/features/DisplayFont/model/index.ts
Normal file
1
src/features/DisplayFont/model/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { displayedFontsStore } from './store';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { selectedFontsStore } from '$entities/Font';
|
||||||
|
|
||||||
|
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
src/features/DisplayFont/model/store/index.ts
Normal file
1
src/features/DisplayFont/model/store/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { displayedFontsStore } from './displayedFontsStore.svelte';
|
||||||
14
src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte
Normal file
14
src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!--
|
||||||
|
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>
|
||||||
37
src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
Normal file
37
src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<!--
|
||||||
|
Component: FontSampler
|
||||||
|
Displays a sample text with a given font in a contenteditable element.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
FontApplicator,
|
||||||
|
type UnifiedFont,
|
||||||
|
} from '$entities/Font';
|
||||||
|
import { ContentEditable } from '$shared/ui';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
font: UnifiedFont;
|
||||||
|
text: string;
|
||||||
|
fontSize?: number;
|
||||||
|
lineHeight?: number;
|
||||||
|
letterSpacing?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
font,
|
||||||
|
text = $bindable(),
|
||||||
|
...restProps
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
w-full rounded-xl
|
||||||
|
bg-white p-6 border border-slate-200
|
||||||
|
shadow-sm dark:border-slate-800 dark:bg-slate-950
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<FontApplicator id={font.id} name={font.name}>
|
||||||
|
<ContentEditable bind:text={text} {...restProps} />
|
||||||
|
</FontApplicator>
|
||||||
|
</div>
|
||||||
@@ -4,14 +4,12 @@
|
|||||||
Combines search input with font list display
|
Combines search input with font list display
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { fontshareStore } from '$entities/Font';
|
||||||
FontList,
|
|
||||||
fontshareStore,
|
|
||||||
} from '$entities/Font';
|
|
||||||
import { SearchBar } from '$shared/ui';
|
import { SearchBar } from '$shared/ui';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { mapManagerToParams } from '../../lib';
|
import { mapManagerToParams } from '../../lib';
|
||||||
import { filterManager } from '../../model';
|
import { filterManager } from '../../model';
|
||||||
|
import SuggestedFonts from '../SuggestedFonts/SuggestedFonts.svelte';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
/**
|
/**
|
||||||
@@ -31,5 +29,5 @@ onMount(() => {
|
|||||||
placeholder="Search fonts by name..."
|
placeholder="Search fonts by name..."
|
||||||
bind:value={filterManager.queryValue}
|
bind:value={filterManager.queryValue}
|
||||||
>
|
>
|
||||||
<FontList />
|
<SuggestedFonts />
|
||||||
</SearchBar>
|
</SearchBar>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
FontListItem,
|
||||||
|
FontVirtualList,
|
||||||
|
fontshareStore,
|
||||||
|
} from '$entities/Font';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FontVirtualList items={fontshareStore.fonts}>
|
||||||
|
{#snippet children({ item: font })}
|
||||||
|
<FontListItem {font} />
|
||||||
|
{/snippet}
|
||||||
|
</FontVirtualList>
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: FontView
|
|
||||||
|
|
||||||
Loads fonts from fontshare with link tag
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
id: string;
|
|
||||||
children?: import('svelte').Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { name, slug, id, children }: Props = $props();
|
|
||||||
let isLoaded = $state(false);
|
|
||||||
|
|
||||||
// Construct the Fontshare API CSS URL
|
|
||||||
// We specify the weight (400) or 'all'
|
|
||||||
const cssUrl = $derived(`https://api.fontshare.com/v2/css?f[]=${id}&display=swap`);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// Even though we use a link tag, we can still "watch"
|
|
||||||
// for the font to be ready for a smooth fade-in
|
|
||||||
document.fonts.load(`1em "${name}"`).then(() => {
|
|
||||||
isLoaded = true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<link rel="stylesheet" href={cssUrl} />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style:--f={name}
|
|
||||||
style:font-family={name ? `'${name}', sans-serif` : 'inherit'}
|
|
||||||
class="transition-opacity duration-500 {isLoaded ? 'font-[var(--f)] opacity-100' : 'font-sans opacity-0'}"
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page Component
|
* Page Component
|
||||||
*/
|
*/
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Font List -->
|
<!-- Font List -->
|
||||||
<div />
|
<div class="p-2">
|
||||||
|
<FontDisplay />
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ const isBrowser = typeof window !== 'undefined';
|
|||||||
class MotionPreference {
|
class MotionPreference {
|
||||||
// Reactive state
|
// Reactive state
|
||||||
#reduced = $state(false);
|
#reduced = $state(false);
|
||||||
#mediaQuery: MediaQueryList = new MediaQueryList();
|
|
||||||
|
|
||||||
private handleChange = (e: MediaQueryListEvent) => {
|
|
||||||
this.#reduced = e.matches;
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (isBrowser) {
|
if (isBrowser) {
|
||||||
@@ -17,9 +12,12 @@ class MotionPreference {
|
|||||||
// Set initial value immediately
|
// Set initial value immediately
|
||||||
this.#reduced = mediaQuery.matches;
|
this.#reduced = mediaQuery.matches;
|
||||||
|
|
||||||
mediaQuery.addEventListener('change', this.handleChange);
|
// Simple listener that updates the reactive state
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
this.#reduced = e.matches;
|
||||||
|
};
|
||||||
|
|
||||||
this.#mediaQuery = mediaQuery;
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,10 +25,6 @@ class MotionPreference {
|
|||||||
get reduced() {
|
get reduced() {
|
||||||
return this.#reduced;
|
return this.#reduced;
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.#mediaQuery.removeEventListener('change', this.handleChange);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export a single instance to be used everywhere
|
// Export a single instance to be used everywhere
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
export interface Entity {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Svelte 5 Entity Store
|
||||||
|
* Uses SvelteMap for O(1) lookups and granular reactivity.
|
||||||
|
*/
|
||||||
|
export class EntityStore<T extends Entity> {
|
||||||
|
// SvelteMap is a reactive version of the native Map
|
||||||
|
#entities = new SvelteMap<string, T>();
|
||||||
|
|
||||||
|
constructor(initialEntities: T[] = []) {
|
||||||
|
this.setAll(initialEntities);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Selectors (Equivalent to Selectors) ---
|
||||||
|
|
||||||
|
/** Get all entities as an array */
|
||||||
|
get all() {
|
||||||
|
return Array.from(this.#entities.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select a single entity by ID */
|
||||||
|
getById(id: string) {
|
||||||
|
return this.#entities.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select multiple entities by IDs */
|
||||||
|
getByIds(ids: string[]) {
|
||||||
|
return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Actions (CRUD) ---
|
||||||
|
|
||||||
|
addOne(entity: T) {
|
||||||
|
this.#entities.set(entity.id, entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMany(entities: T[]) {
|
||||||
|
entities.forEach(e => this.addOne(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOne(id: string, changes: Partial<T>) {
|
||||||
|
const entity = this.#entities.get(id);
|
||||||
|
if (entity) {
|
||||||
|
// In Svelte 5, updating the object property directly is reactive
|
||||||
|
// if the object itself was made reactive, but here we replace
|
||||||
|
// the reference to ensure top-level map triggers.
|
||||||
|
this.#entities.set(id, { ...entity, ...changes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOne(id: string) {
|
||||||
|
this.#entities.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMany(ids: string[]) {
|
||||||
|
ids.forEach(id => this.#entities.delete(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
setAll(entities: T[]) {
|
||||||
|
this.#entities.clear();
|
||||||
|
this.addMany(entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id: string) {
|
||||||
|
return this.#entities.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.#entities.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEntityStore<T extends Entity>(initialEntities: T[] = []) {
|
||||||
|
return new EntityStore<T>(initialEntities);
|
||||||
|
}
|
||||||
@@ -79,7 +79,11 @@ export interface VirtualizerOptions {
|
|||||||
* </div>
|
* </div>
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & { data: T[] }) {
|
export function createVirtualizer<T>(
|
||||||
|
optionsGetter: () => VirtualizerOptions & {
|
||||||
|
data: T[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
let scrollOffset = $state(0);
|
let scrollOffset = $state(0);
|
||||||
let containerHeight = $state(0);
|
let containerHeight = $state(0);
|
||||||
let measuredSizes = $state<Record<number, number>>({});
|
let measuredSizes = $state<Record<number, number>>({});
|
||||||
@@ -149,6 +153,7 @@ export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & {
|
|||||||
key: options.getItemKey?.(i) ?? i,
|
key: options.getItemKey?.(i) ?? i,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
// Svelte Actions (The DOM Interface)
|
// Svelte Actions (The DOM Interface)
|
||||||
|
|||||||
@@ -20,3 +20,9 @@ export {
|
|||||||
} from './createVirtualizer/createVirtualizer.svelte';
|
} from './createVirtualizer/createVirtualizer.svelte';
|
||||||
|
|
||||||
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
|
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createEntityStore,
|
||||||
|
type Entity,
|
||||||
|
type EntityStore,
|
||||||
|
} from './createEntityStore/createEntityStore.svelte';
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ export {
|
|||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
createDebouncedState,
|
createDebouncedState,
|
||||||
|
createEntityStore,
|
||||||
createFilter,
|
createFilter,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
createVirtualizer,
|
createVirtualizer,
|
||||||
|
type Entity,
|
||||||
|
type EntityStore,
|
||||||
type Filter,
|
type Filter,
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
type Property,
|
type Property,
|
||||||
@@ -15,3 +18,4 @@ export {
|
|||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
|
||||||
export { motion } from './accessibility/motion.svelte';
|
export { motion } from './accessibility/motion.svelte';
|
||||||
|
export { splitArray } from './utils';
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ 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 { splitArray } from './splitArray/splitArray';
|
||||||
|
|||||||
8
src/shared/lib/utils/splitArray/splitArray.ts
Normal file
8
src/shared/lib/utils/splitArray/splitArray.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
|
||||||
|
return array.reduce<[T[], T[]]>(
|
||||||
|
([pass, fail], item) => (
|
||||||
|
callback(item) ? pass.push(item) : fail.push(item), [pass, fail]
|
||||||
|
),
|
||||||
|
[[], []],
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/shared/ui/ContentEditable/ContentEditable.svelte
Normal file
62
src/shared/ui/ContentEditable/ContentEditable.svelte
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<!--
|
||||||
|
Component: ContentEditable
|
||||||
|
Provides a contenteditable div with custom font and text properties.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Visible text
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* Font settings
|
||||||
|
*/
|
||||||
|
fontSize?: number;
|
||||||
|
lineHeight?: number;
|
||||||
|
letterSpacing?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
text = $bindable('The quick brown fox jumps over the lazy dog.'),
|
||||||
|
fontSize = 48,
|
||||||
|
lineHeight = 1.2,
|
||||||
|
letterSpacing = 0,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let element: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
// Initial Sync: Set the text ONLY ONCE when the element is created.
|
||||||
|
// This prevents Svelte from "owning" the innerHTML/innerText.
|
||||||
|
$effect(() => {
|
||||||
|
if (element && element.innerText !== text) {
|
||||||
|
element.innerText = text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle changes: Update the outer state without re-rendering the div.
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const target = e.target as HTMLDivElement;
|
||||||
|
// Update the bindable prop directly
|
||||||
|
text = target.innerText;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={element}
|
||||||
|
contenteditable="plaintext-only"
|
||||||
|
spellcheck="false"
|
||||||
|
role="textbox"
|
||||||
|
tabindex="0"
|
||||||
|
data-placeholder="Type something to test..."
|
||||||
|
oninput={handleInput}
|
||||||
|
class="
|
||||||
|
w-full min-h-[1.2em] outline-none transition-all duration-200
|
||||||
|
empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400
|
||||||
|
selection:bg-indigo-100 selection:text-indigo-900
|
||||||
|
caret-indigo-500
|
||||||
|
"
|
||||||
|
style:font-size="{fontSize}px"
|
||||||
|
style:line-height={lineHeight}
|
||||||
|
style:letter-spacing="{letterSpacing}em"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
@@ -35,6 +35,11 @@ interface Props {
|
|||||||
* (follows shadcn convention for className prop)
|
* (follows shadcn convention for className prop)
|
||||||
*/
|
*/
|
||||||
class?: string;
|
class?: string;
|
||||||
|
/**
|
||||||
|
* An optional callback that will be called for each new set of loaded items
|
||||||
|
* @param items - Loaded items
|
||||||
|
*/
|
||||||
|
onVisibleItemsChange?: (items: T[]) => void;
|
||||||
/**
|
/**
|
||||||
* Snippet for rendering individual list items.
|
* Snippet for rendering individual list items.
|
||||||
*
|
*
|
||||||
@@ -50,7 +55,8 @@ interface Props {
|
|||||||
children: Snippet<[{ item: T; index: number }]>;
|
children: Snippet<[{ item: T; index: number }]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props();
|
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }:
|
||||||
|
Props = $props();
|
||||||
|
|
||||||
const virtualizer = createVirtualizer(() => ({
|
const virtualizer = createVirtualizer(() => ({
|
||||||
count: items.length,
|
count: items.length,
|
||||||
@@ -58,12 +64,17 @@ const virtualizer = createVirtualizer(() => ({
|
|||||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||||
overscan,
|
overscan,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const visibleItems = virtualizer.items.map(item => items[item.index]);
|
||||||
|
onVisibleItemsChange?.(visibleItems);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
use:virtualizer.container
|
use:virtualizer.container
|
||||||
class={cn(
|
class={cn(
|
||||||
'relative overflow-auto border rounded-md bg-background',
|
'relative overflow-auto rounded-md bg-background',
|
||||||
'h-150 w-full',
|
'h-150 w-full',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
|
|
||||||
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
|
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
|
||||||
import ComboControl from './ComboControl/ComboControl.svelte';
|
import ComboControl from './ComboControl/ComboControl.svelte';
|
||||||
|
import ContentEditable from './ContentEditable/ContentEditable.svelte';
|
||||||
import SearchBar from './SearchBar/SearchBar.svelte';
|
import SearchBar from './SearchBar/SearchBar.svelte';
|
||||||
import VirtualList from './VirtualList/VirtualList.svelte';
|
import VirtualList from './VirtualList/VirtualList.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CheckboxFilter,
|
CheckboxFilter,
|
||||||
ComboControl,
|
ComboControl,
|
||||||
|
ContentEditable,
|
||||||
SearchBar,
|
SearchBar,
|
||||||
VirtualList,
|
VirtualList,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user