feature/comparison-slider #19

Merged
ilia merged 129 commits from feature/comparison-slider into main 2026-02-02 09:23:46 +00:00
Showing only changes of commit 961475dea0 - Show all commits

View File

@@ -3,33 +3,47 @@ import { SvelteMap } from 'svelte/reactivity';
export type FontStatus = 'loading' | 'loaded' | 'error';
export interface FontConfigRequest {
slug: string;
/**
* Font id
*/
id: string;
/**
* Real font name (e.g. "Lato")
*/
name: string;
/**
* The .ttf URL
*/
url: string;
/**
* Font weight
*/
weight: number;
/**
* Flag of the variable weight
*/
isVariable?: boolean;
}
/**
* Manager that handles loading of fonts from Fontshare.
* Manager that handles loading of fonts.
* Logic:
* - Variable fonts: Loaded once per slug (covers all weights).
* - Static fonts: Loaded per slug + weight combination.
* - Variable fonts: Loaded once per id (covers all weights).
* - Static fonts: Loaded per id + weight combination.
*/
class AppliedFontsManager {
// Tracking usage: Map<key, timestamp> where key is "slug" or "slug@weight"
#usageTracker = new Map<string, number>();
// Map: key -> batchId
#slugToBatch = new Map<string, string>();
// Map: batchId -> HTMLLinkElement
#batchElements = new Map<string, HTMLLinkElement>();
#idToBatch = new Map<string, string>();
// Changed to HTMLStyleElement
#batchElements = new Map<string, HTMLStyleElement>();
#queue = new Set<string>();
#queue = new Map<string, FontConfigRequest>(); // Track config in queue
#timeoutId: ReturnType<typeof setTimeout> | null = null;
#PURGE_INTERVAL = 60000;
#TTL = 5 * 60 * 1000;
#CHUNK_SIZE = 3;
#CHUNK_SIZE = 5; // Can be larger since we're just injecting strings
// Reactive status map for UI feedback
statuses = new SvelteMap<string, FontStatus>();
constructor() {
@@ -38,139 +52,119 @@ class AppliedFontsManager {
}
}
/**
* Resolves a unique key for the font asset.
*/
#getFontKey(slug: string, weight: number, isVariable: boolean): string {
const s = slug.toLowerCase();
// Variable fonts only need one entry regardless of weight
return isVariable ? s : `${s}@${weight}`;
#getFontKey(id: string, weight: number): string {
return `${id.toLowerCase()}@${weight}`;
}
/**
* Call this when a font is rendered on screen.
*/
touch(configs: FontConfigRequest[]) {
const now = Date.now();
const toRegister: string[] = [];
configs.forEach(({ slug, weight, isVariable = false }) => {
const key = this.#getFontKey(slug, weight, isVariable);
configs.forEach(config => {
const key = this.#getFontKey(config.id, config.weight);
this.#usageTracker.set(key, now);
if (!this.#slugToBatch.has(key)) {
toRegister.push(key);
}
});
if (toRegister.length > 0) this.registerFonts(toRegister);
}
registerFonts(keys: string[]) {
const newKeys = keys.filter(k => !this.#slugToBatch.has(k) && !this.#queue.has(k));
if (newKeys.length === 0) return;
newKeys.forEach(k => this.#queue.add(k));
if (!this.#idToBatch.has(key) && !this.#queue.has(key)) {
this.#queue.set(key, config);
if (this.#timeoutId) clearTimeout(this.#timeoutId);
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
}
});
}
getFontStatus(slug: string, weight: number, isVariable: boolean) {
return this.statuses.get(this.#getFontKey(slug, weight, isVariable));
getFontStatus(id: string, weight: number) {
return this.statuses.get(this.#getFontKey(id, weight));
}
#processQueue() {
const fullQueue = Array.from(this.#queue);
if (fullQueue.length === 0) return;
const entries = Array.from(this.#queue.entries());
if (entries.length === 0) return;
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) {
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE));
for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE));
}
this.#queue.clear();
this.#timeoutId = null;
}
#createBatch(keys: string[]) {
#createBatch(batchEntries: [string, FontConfigRequest][]) {
if (typeof document === 'undefined') return;
const batchId = crypto.randomUUID();
let cssRules = '';
/**
* Fontshare API Logic:
* - If key contains '@', it's static (e.g., satoshi@700)
* - If it's a plain slug, it's variable. We append '@1,2' for variable assets.
*/
const query = keys.map(k => {
return k.includes('@') ? `f[]=${k}` : `f[]=${k}@1,2`;
}).join('&');
batchEntries.forEach(([key, config]) => {
this.statuses.set(key, 'loading');
this.#idToBatch.set(key, batchId);
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
keys.forEach(key => this.statuses.set(key, '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);
keys.forEach(key => {
this.#slugToBatch.set(key, batchId);
// Determine what to check in the Font Loading API
const isVariable = !key.includes('@');
const [family, staticWeight] = key.split('@');
// For variable fonts, we check a standard weight;
// for static, we check the specific numeric weight requested.
const weightToCheck = isVariable ? '400' : staticWeight;
document.fonts.load(`${weightToCheck} 1em "${family}"`)
.then(loadedFonts => {
this.statuses.set(key, loadedFonts.length > 0 ? 'loaded' : 'error');
})
.catch(() => {
this.statuses.set(key, 'error');
// Construct the @font-face rule
// Using format('truetype') for .ttf
cssRules += `
@font-face {
font-family: '${config.name}';
src: url('${config.url}') format('truetype');
font-weight: ${config.weight};
font-style: normal;
font-display: swap;
}
`;
});
// Create and inject the style tag
const style = document.createElement('style');
style.dataset.batchId = batchId;
style.innerHTML = cssRules;
document.head.appendChild(style);
this.#batchElements.set(batchId, style);
// Verify loading via Font Loading API
batchEntries.forEach(([key, config]) => {
document.fonts.load(`${config.weight} 1em "${config.name}"`)
.then(loaded => {
this.statuses.set(key, loaded.length > 0 ? 'loaded' : 'error');
})
.catch(() => this.statuses.set(key, 'error'));
});
}
#purgeUnused() {
const now = Date.now();
const batchesToPotentialDelete = new Set<string>();
const keysToDelete: string[] = [];
const batchesToRemove = new Set<string>();
const keysToRemove: string[] = [];
for (const [key, lastUsed] of this.#usageTracker.entries()) {
if (now - lastUsed > this.#TTL) {
const batchId = this.#slugToBatch.get(key);
if (batchId) batchesToPotentialDelete.add(batchId);
keysToDelete.push(key);
}
}
batchesToPotentialDelete.forEach(batchId => {
const batchKeys = Array.from(this.#slugToBatch.entries())
const batchId = this.#idToBatch.get(key);
if (batchId) {
// Check if EVERY font in this batch is expired
const batchKeys = Array.from(this.#idToBatch.entries())
.filter(([_, bId]) => bId === batchId)
.map(([key]) => key);
.map(([k]) => k);
const allExpired = batchKeys.every(k => keysToDelete.includes(k));
const canDeleteBatch = batchKeys.every(k => {
const lastK = this.#usageTracker.get(k);
return lastK && (now - lastK > this.#TTL);
});
if (allExpired) {
this.#batchElements.get(batchId)?.remove();
this.#batchElements.delete(batchId);
batchKeys.forEach(k => {
this.#slugToBatch.delete(k);
if (canDeleteBatch) {
batchesToRemove.add(batchId);
keysToRemove.push(...batchKeys);
}
}
}
}
batchesToRemove.forEach(id => {
this.#batchElements.get(id)?.remove();
this.#batchElements.delete(id);
});
keysToRemove.forEach(k => {
this.#idToBatch.delete(k);
this.#usageTracker.delete(k);
this.statuses.delete(k);
});
}
});
}
}
export const appliedFontsManager = new AppliedFontsManager();