Compare commits

...

8 Commits

14 changed files with 271 additions and 153 deletions

View File

@@ -75,7 +75,6 @@ export type {
export { export {
appliedFontsManager, appliedFontsManager,
createUnifiedFontStore, createUnifiedFontStore,
selectedFontsStore,
unifiedFontStore, unifiedFontStore,
} from './model'; } from './model';

View File

@@ -38,7 +38,6 @@ export {
appliedFontsManager, appliedFontsManager,
createUnifiedFontStore, createUnifiedFontStore,
type FontConfigRequest, type FontConfigRequest,
selectedFontsStore,
type UnifiedFontStore, type UnifiedFontStore,
unifiedFontStore, unifiedFontStore,
} from './store'; } from './store';

View File

@@ -0,0 +1,115 @@
/** @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;
beforeEach(() => {
vi.useFakeTimers();
mockFontFaceSet = {
add: vi.fn(),
delete: vi.fn(),
};
// 1. Properly mock FontFace as a constructor function
const MockFontFace = vi.fn(function(this: any, name: string, url: string) {
this.name = name;
this.url = url;
this.load = vi.fn().mockImplementation(() => {
if (url.includes('fail')) return Promise.reject(new Error('Load failed'));
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,
});
manager = new AppliedFontsManager();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it('should batch multiple font requests into a single process', async () => {
const configs = [
{ id: 'lato-400', name: 'Lato', url: 'lato.ttf', weight: 400 },
{ id: 'lato-700', name: 'Lato', url: '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 config = { id: 'broken', name: 'Broken', url: 'fail.ttf', 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: '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: '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');
});
});

View File

@@ -31,155 +31,142 @@ export interface FontConfigRequest {
* - Variable fonts: Loaded once per id (covers all weights). * - Variable fonts: Loaded once per id (covers all weights).
* - Static fonts: Loaded per id + weight combination. * - Static fonts: Loaded per id + weight combination.
*/ */
class AppliedFontsManager { export class AppliedFontsManager {
#usageTracker = new Map<string, number>(); // Stores the actual FontFace objects for cleanup
#idToBatch = new Map<string, string>(); #loadedFonts = new Map<string, FontFace>();
// Changed to HTMLStyleElement // Optimization: Map<batchId, Set<fontKeys>> to avoid O(N^2) scans
#batchElements = new Map<string, HTMLStyleElement>(); #batchToKeys = new Map<string, Set<string>>();
// Optimization: Map<fontKey, batchId> for reverse lookup
#keyToBatch = new Map<string, string>();
#queue = new Map<string, FontConfigRequest>(); // Track config in queue #usageTracker = new Map<string, number>();
#queue = new Map<string, FontConfigRequest>();
#timeoutId: ReturnType<typeof setTimeout> | null = null; #timeoutId: ReturnType<typeof setTimeout> | null = null;
#PURGE_INTERVAL = 60000; readonly #PURGE_INTERVAL = 60000;
#TTL = 5 * 60 * 1000; readonly #TTL = 5 * 60 * 1000;
#CHUNK_SIZE = 5; // Can be larger since we're just injecting strings readonly #CHUNK_SIZE = 5;
statuses = new SvelteMap<string, FontStatus>(); statuses = new SvelteMap<string, FontStatus>();
constructor() { constructor() {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Using a weak reference style approach isn't possible for DOM,
// so we stick to the interval but make it highly efficient.
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL); setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
} }
} }
#getFontKey(config: FontConfigRequest): string { #getFontKey(id: string, weight: number, isVariable: boolean): string {
if (config.isVariable) { return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`;
// For variable fonts, the ID is unique enough.
// Loading "Roboto" once covers "Roboto 400" and "Roboto 700"
return `${config.id.toLowerCase()}@vf`;
}
// For static fonts, we still need weight separation
return `${config.id.toLowerCase()}@${config.weight}`;
} }
touch(configs: FontConfigRequest[]) { touch(configs: FontConfigRequest[]) {
const now = Date.now(); const now = Date.now();
configs.forEach(config => { let hasNewItems = false;
// Pass the whole config to get key
const key = this.#getFontKey(config);
for (const config of configs) {
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
this.#usageTracker.set(key, now); this.#usageTracker.set(key, now);
// If it's already loaded, we don't need to do anything if (this.statuses.get(key) === 'loaded' || this.statuses.get(key) === 'loading' || this.#queue.has(key)) {
if (this.statuses.get(key) === 'loaded') return; continue;
}
if (!this.#idToBatch.has(key) && !this.#queue.has(key)) {
this.#queue.set(key, config); this.#queue.set(key, config);
hasNewItems = true;
if (this.#timeoutId) clearTimeout(this.#timeoutId);
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
}
});
} }
getFontStatus(id: string, weight: number, isVariable: boolean = false) { // IMPROVEMENT: Only trigger timer if not already pending
// Construct a temp config to generate key if (hasNewItems && !this.#timeoutId) {
const key = this.#getFontKey({ id, weight, name: '', url: '', isVariable }); this.#timeoutId = setTimeout(() => this.#processQueue(), 16); // ~1 frame delay
return this.statuses.get(key); }
} }
#processQueue() { #processQueue() {
this.#timeoutId = null;
const entries = Array.from(this.#queue.entries()); const entries = Array.from(this.#queue.entries());
if (entries.length === 0) return; if (entries.length === 0) return;
for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE));
}
this.#queue.clear(); this.#queue.clear();
this.#timeoutId = null;
// Process in chunks to keep the UI responsive
for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
this.#applyBatch(entries.slice(i, i + this.#CHUNK_SIZE));
}
} }
#createBatch(batchEntries: [string, FontConfigRequest][]) { async #applyBatch(batchEntries: [string, FontConfigRequest][]) {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const batchId = crypto.randomUUID(); const batchId = crypto.randomUUID();
let cssRules = ''; const keysInBatch = new Set<string>();
batchEntries.forEach(([key, config]) => { const loadPromises = batchEntries.map(([key, config]) => {
this.statuses.set(key, 'loading'); this.statuses.set(key, 'loading');
this.#idToBatch.set(key, batchId); this.#keyToBatch.set(key, batchId);
keysInBatch.add(key);
// If variable, allow the full weight range. // Use a unique internal family name to prevent collisions
// If static, lock it to the specific weight. // while keeping the "real" name for the browser to resolve weight/style.
const weightRule = config.isVariable const internalName = `f_${config.id}`;
? '100 900' // Variable range (standard coverage) const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
: config.weight;
const fontFormat = config.isVariable ? 'truetype-variations' : 'truetype';
cssRules += ` const font = new FontFace(config.name, `url(${config.url})`, {
@font-face { weight: weightRange,
font-family: '${config.name}'; style: 'normal',
src: url('${config.url}') format('${fontFormat}'); display: 'swap',
font-weight: ${weightRule};
font-style: normal;
font-display: swap;
}
`;
}); });
const style = document.createElement('style'); this.#loadedFonts.set(key, font);
style.dataset.batchId = batchId;
style.innerHTML = cssRules;
document.head.appendChild(style);
this.#batchElements.set(batchId, style);
// Use the requested weight for verification, even if the rule covers a range return font.load()
batchEntries.forEach(([key, config]) => { .then(loadedFace => {
document.fonts.load(`${config.weight} 1em "${config.name}"`) document.fonts.add(loadedFace);
.then(loaded => { this.statuses.set(key, 'loaded');
this.statuses.set(key, loaded.length > 0 ? 'loaded' : 'error');
}) })
.catch(() => this.statuses.set(key, 'error')); .catch(e => {
console.error(`Font load failed: ${config.name}`, e);
this.statuses.set(key, 'error');
}); });
});
this.#batchToKeys.set(batchId, keysInBatch);
await Promise.allSettled(loadPromises);
} }
#purgeUnused() { #purgeUnused() {
const now = Date.now(); const now = Date.now();
const batchesToRemove = new Set<string>();
const keysToRemove: string[] = [];
for (const [key, lastUsed] of this.#usageTracker.entries()) { // We iterate over batches, not individual fonts, to reduce loops
if (now - lastUsed > this.#TTL) { for (const [batchId, keys] of this.#batchToKeys.entries()) {
const batchId = this.#idToBatch.get(key); let canPurgeBatch = true;
if (batchId) {
// Check if EVERY font in this batch is expired
const batchKeys = Array.from(this.#idToBatch.entries())
.filter(([_, bId]) => bId === batchId)
.map(([k]) => k);
const canDeleteBatch = batchKeys.every(k => { for (const key of keys) {
const lastK = this.#usageTracker.get(k); const lastUsed = this.#usageTracker.get(key) || 0;
return lastK && (now - lastK > this.#TTL); if (now - lastUsed < this.#TTL) {
canPurgeBatch = false;
break;
}
}
if (canPurgeBatch) {
keys.forEach(key => {
const font = this.#loadedFonts.get(key);
if (font) document.fonts.delete(font);
this.#loadedFonts.delete(key);
this.#keyToBatch.delete(key);
this.#usageTracker.delete(key);
this.statuses.delete(key);
}); });
this.#batchToKeys.delete(batchId);
if (canDeleteBatch) {
batchesToRemove.add(batchId);
keysToRemove.push(...batchKeys);
}
} }
} }
} }
batchesToRemove.forEach(id => { getFontStatus(id: string, weight: number, isVariable = false) {
this.#batchElements.get(id)?.remove(); return this.statuses.get(this.#getFontKey(id, weight, isVariable));
this.#batchElements.delete(id);
});
keysToRemove.forEach(k => {
this.#idToBatch.delete(k);
this.#usageTracker.delete(k);
this.statuses.delete(k);
});
} }
} }

View File

@@ -18,6 +18,3 @@ export {
appliedFontsManager, appliedFontsManager,
type FontConfigRequest, type FontConfigRequest,
} from './appliedFontsStore/appliedFontsStore.svelte'; } from './appliedFontsStore/appliedFontsStore.svelte';
// Selected fonts store (user selection - unchanged)
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';

View File

@@ -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>([]);

View File

@@ -20,13 +20,15 @@ interface Props {
* Font id to load * Font id to load
*/ */
id: string; id: string;
/** */
url: string; url: string;
/** /**
* Font weight * Font weight
*/ */
weight?: number; weight?: number;
/**
* Variable font flag
*/
isVariable?: boolean; isVariable?: boolean;
/** /**
* Additional classes * Additional classes
@@ -75,25 +77,25 @@ $effect(() => {
}); });
// The "Show" condition: Element is in view AND (Font is ready OR it errored out) // The "Show" condition: Element is in view AND (Font is ready OR it errored out)
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error')); const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded'));
const transitionClasses = $derived( const transitionClasses = $derived(
prefersReducedMotion.current 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} bind:this={element}
style:font-family={shouldReveal ? `'${name}'` : 'sans-serif'} style:font-family={shouldReveal ? `'${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 && !prefersReducedMotion.current !shouldReveal && !prefersReducedMotion.current
&& 'opacity-0 translate-y-8 scale-[0.98] blur-sm', && 'opacity-50 scale-[0.95] blur-sm',
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement !shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
shouldReveal && 'opacity-100 translate-y-0 scale-100 blur-0', shouldReveal && 'opacity-100 scale-100 blur-0',
className, className,
)} )}
> >

View File

@@ -6,10 +6,7 @@
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 { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
import { import { type UnifiedFont } from '../../model';
type UnifiedFont,
selectedFontsStore,
} from '../../model';
interface Props { interface Props {
/** /**
@@ -36,7 +33,6 @@ interface Props {
const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props(); const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props();
const selected = $derived(selectedFontsStore.has(font.id));
let timeoutId = $state<NodeJS.Timeout | null>(null); let timeoutId = $state<NodeJS.Timeout | null>(null);
// Create a spring for smooth scale animation // Create a spring for smooth scale animation

View File

@@ -6,7 +6,6 @@
import { import {
FontApplicator, FontApplicator,
type UnifiedFont, type UnifiedFont,
selectedFontsStore,
} from '$entities/Font'; } from '$entities/Font';
import { controlManager } from '$features/SetupFont'; import { controlManager } from '$features/SetupFont';
import { import {
@@ -48,10 +47,6 @@ const fontWeight = $derived(controlManager.weight);
const fontSize = $derived(controlManager.renderedSize); const fontSize = $derived(controlManager.renderedSize);
const lineHeight = $derived(controlManager.height); const lineHeight = $derived(controlManager.height);
const letterSpacing = $derived(controlManager.spacing); const letterSpacing = $derived(controlManager.spacing);
function removeSample() {
selectedFontsStore.removeOne(font.id);
}
</script> </script>
<div <div

View File

@@ -28,9 +28,10 @@ import {
interface Props { interface Props {
class?: string; class?: string;
hidden?: boolean;
} }
const { class: className }: Props = $props(); const { class: className, hidden = false }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
const [send, receive] = crossfade({ const [send, receive] = crossfade({
@@ -71,7 +72,7 @@ $effect(() => {
</script> </script>
<div <div
class={cn('w-auto max-screen z-10 flex justify-center', className)} class={cn('w-auto max-screen z-10 flex justify-center', hidden && 'hidden', className)}
in:receive={{ key: 'panel' }} in:receive={{ key: 'panel' }}
out:send={{ key: 'panel' }} out:send={{ key: 'panel' }}
> >

View File

@@ -11,9 +11,35 @@ interface Props {
const { class: className }: Props = $props(); const { class: className }: Props = $props();
const baseClasses = const baseClasses = 'font-[Barlow] font-thin text-5xl sm:text-6xl md:text-7xl lg:text-8xl';
'font-[Barlow] font-thin text-5xl sm:text-6xl md:text-7xl lg:text-8xl text-justify [text-align-last:justify] [text-justify:inter-character]';
const title = 'GLYPHDIFF';
</script> </script>
<h2 class={cn(baseClasses, className)}>
GLYPHDIFF <!-- Firefox version (hidden in Chrome/Safari) -->
<h2
class={cn(
baseClasses,
'text-justify [text-align-last:justify] [text-justify:inter-character]',
// Hide in non-Firefox
'hidden [@supports(text-justify:inter-character)]:block',
className,
)}
>
{title}
</h2>
<!-- Chrome/Safari version (hidden in Firefox) -->
<h2
class={cn(
'flex justify-between w-full',
baseClasses,
// Hide in Firefox
'[@supports(text-justify:inter-character)]:hidden',
className,
)}
>
{#each title.split('') as letter}
<span>{letter}</span>
{/each}
</h2> </h2>

View File

@@ -18,8 +18,15 @@ interface Props {
let { sliderPos, isDragging }: Props = $props(); let { sliderPos, isDragging }: Props = $props();
</script> </script>
<div <div
class="absolute inset-y-2 sm:inset-y-4 pointer-events-none -translate-x-1/2 z-50 transition-all duration-300 ease-out flex flex-col justify-center items-center" class={cn(
'absolute inset-y-2 sm:inset-y-4 pointer-events-none -translate-x-1/2 z-50 flex flex-col justify-center items-center',
// Force GPU layer with translateZ
'translate-z-0',
// Only transition left when NOT dragging
isDragging ? '' : 'transition-[left] duration-300 ease-out',
)}
style:left="{sliderPos}%" style:left="{sliderPos}%"
style:will-change={isDragging ? 'left' : 'auto'}
> >
<!-- We use part of lucide cursor svg icon as a handle --> <!-- We use part of lucide cursor svg icon as a handle -->
<svg <svg
@@ -40,13 +47,13 @@ let { sliderPos, isDragging }: Props = $props();
<div <div
class={cn( class={cn(
'relative h-full rounded-sm transition-all duration-500', 'relative h-full rounded-sm transition-all duration-300',
'bg-white/3 backdrop-blur-md', 'bg-white/3 ',
// These are the visible "edges" of the glass // These are the visible "edges" of the glass
'shadow-[0_0_40px_rgba(0,0,0,0.1)_inset_0_0_20px_rgba(255,255,255,0.1)]', 'shadow-[0_0_40px_rgba(0,0,0,0.1)_inset_0_0_20px_rgba(255,255,255,0.1)]',
'shadow-[0_10px_30px_-10px_rgba(0,0,0,0.2),inset_0_1px_1px_rgba(255,255,255,0.4)]', 'shadow-[0_10px_30px_-10px_rgba(0,0,0,0.2),inset_0_1px_1px_rgba(255,255,255,0.4)]',
'rounded-full', 'rounded-full',
isDragging ? 'w-16 sm:w-32' : 'w-12 sm:w-16', isDragging ? 'w-16 sm:w-24' : 'w-12 sm:w-16',
)} )}
> >
</div> </div>

View File

@@ -101,7 +101,8 @@ function checkPosition() {
{/snippet} {/snippet}
</FontVirtualList> </FontVirtualList>
{#if isAboveMiddle} <TypographyMenu
<TypographyMenu class="fixed bottom-4 sm:bottom-5 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2" /> class="fixed bottom-4 sm:bottom-5 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2"
{/if} hidden={!isAboveMiddle}
/>
</div> </div>