feature/comparison-slider #19
@@ -3,33 +3,47 @@ import { SvelteMap } from 'svelte/reactivity';
|
|||||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
export interface FontConfigRequest {
|
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;
|
weight: number;
|
||||||
|
/**
|
||||||
|
* Flag of the variable weight
|
||||||
|
*/
|
||||||
isVariable?: boolean;
|
isVariable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manager that handles loading of fonts from Fontshare.
|
* Manager that handles loading of fonts.
|
||||||
* Logic:
|
* Logic:
|
||||||
* - Variable fonts: Loaded once per slug (covers all weights).
|
* - Variable fonts: Loaded once per id (covers all weights).
|
||||||
* - Static fonts: Loaded per slug + weight combination.
|
* - Static fonts: Loaded per id + weight combination.
|
||||||
*/
|
*/
|
||||||
class AppliedFontsManager {
|
class AppliedFontsManager {
|
||||||
// Tracking usage: Map<key, timestamp> where key is "slug" or "slug@weight"
|
|
||||||
#usageTracker = new Map<string, number>();
|
#usageTracker = new Map<string, number>();
|
||||||
// Map: key -> batchId
|
#idToBatch = new Map<string, string>();
|
||||||
#slugToBatch = new Map<string, string>();
|
// Changed to HTMLStyleElement
|
||||||
// Map: batchId -> HTMLLinkElement
|
#batchElements = new Map<string, HTMLStyleElement>();
|
||||||
#batchElements = new Map<string, HTMLLinkElement>();
|
|
||||||
|
|
||||||
#queue = new Set<string>();
|
#queue = new Map<string, FontConfigRequest>(); // Track config in queue
|
||||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
#PURGE_INTERVAL = 60000;
|
#PURGE_INTERVAL = 60000;
|
||||||
#TTL = 5 * 60 * 1000;
|
#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>();
|
statuses = new SvelteMap<string, FontStatus>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -38,139 +52,119 @@ class AppliedFontsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
#getFontKey(id: string, weight: number): string {
|
||||||
* Resolves a unique key for the font asset.
|
return `${id.toLowerCase()}@${weight}`;
|
||||||
*/
|
|
||||||
#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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this when a font is rendered on screen.
|
|
||||||
*/
|
|
||||||
touch(configs: FontConfigRequest[]) {
|
touch(configs: FontConfigRequest[]) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const toRegister: string[] = [];
|
configs.forEach(config => {
|
||||||
|
const key = this.#getFontKey(config.id, config.weight);
|
||||||
configs.forEach(({ slug, weight, isVariable = false }) => {
|
|
||||||
const key = this.#getFontKey(slug, weight, isVariable);
|
|
||||||
|
|
||||||
this.#usageTracker.set(key, now);
|
this.#usageTracker.set(key, now);
|
||||||
|
|
||||||
if (!this.#slugToBatch.has(key)) {
|
if (!this.#idToBatch.has(key) && !this.#queue.has(key)) {
|
||||||
toRegister.push(key);
|
this.#queue.set(key, config);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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.#timeoutId) clearTimeout(this.#timeoutId);
|
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getFontStatus(slug: string, weight: number, isVariable: boolean) {
|
getFontStatus(id: string, weight: number) {
|
||||||
return this.statuses.get(this.#getFontKey(slug, weight, isVariable));
|
return this.statuses.get(this.#getFontKey(id, weight));
|
||||||
}
|
}
|
||||||
|
|
||||||
#processQueue() {
|
#processQueue() {
|
||||||
const fullQueue = Array.from(this.#queue);
|
const entries = Array.from(this.#queue.entries());
|
||||||
if (fullQueue.length === 0) return;
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) {
|
for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
|
||||||
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE));
|
this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#queue.clear();
|
this.#queue.clear();
|
||||||
this.#timeoutId = null;
|
this.#timeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
#createBatch(keys: string[]) {
|
#createBatch(batchEntries: [string, FontConfigRequest][]) {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
const batchId = crypto.randomUUID();
|
const batchId = crypto.randomUUID();
|
||||||
|
let cssRules = '';
|
||||||
|
|
||||||
/**
|
batchEntries.forEach(([key, config]) => {
|
||||||
* Fontshare API Logic:
|
this.statuses.set(key, 'loading');
|
||||||
* - If key contains '@', it's static (e.g., satoshi@700)
|
this.#idToBatch.set(key, batchId);
|
||||||
* - 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('&');
|
|
||||||
|
|
||||||
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
|
// Construct the @font-face rule
|
||||||
|
// Using format('truetype') for .ttf
|
||||||
keys.forEach(key => this.statuses.set(key, 'loading'));
|
cssRules += `
|
||||||
|
@font-face {
|
||||||
const link = document.createElement('link');
|
font-family: '${config.name}';
|
||||||
link.rel = 'stylesheet';
|
src: url('${config.url}') format('truetype');
|
||||||
link.href = url;
|
font-weight: ${config.weight};
|
||||||
link.dataset.batchId = batchId;
|
font-style: normal;
|
||||||
document.head.appendChild(link);
|
font-display: swap;
|
||||||
|
}
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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() {
|
#purgeUnused() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const batchesToPotentialDelete = new Set<string>();
|
const batchesToRemove = new Set<string>();
|
||||||
const keysToDelete: string[] = [];
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
for (const [key, lastUsed] of this.#usageTracker.entries()) {
|
for (const [key, lastUsed] of this.#usageTracker.entries()) {
|
||||||
if (now - lastUsed > this.#TTL) {
|
if (now - lastUsed > this.#TTL) {
|
||||||
const batchId = this.#slugToBatch.get(key);
|
const batchId = this.#idToBatch.get(key);
|
||||||
if (batchId) batchesToPotentialDelete.add(batchId);
|
if (batchId) {
|
||||||
keysToDelete.push(key);
|
// Check if EVERY font in this batch is expired
|
||||||
}
|
const batchKeys = Array.from(this.#idToBatch.entries())
|
||||||
}
|
|
||||||
|
|
||||||
batchesToPotentialDelete.forEach(batchId => {
|
|
||||||
const batchKeys = Array.from(this.#slugToBatch.entries())
|
|
||||||
.filter(([_, bId]) => bId === batchId)
|
.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) {
|
if (canDeleteBatch) {
|
||||||
this.#batchElements.get(batchId)?.remove();
|
batchesToRemove.add(batchId);
|
||||||
this.#batchElements.delete(batchId);
|
keysToRemove.push(...batchKeys);
|
||||||
batchKeys.forEach(k => {
|
}
|
||||||
this.#slugToBatch.delete(k);
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
batchesToRemove.forEach(id => {
|
||||||
|
this.#batchElements.get(id)?.remove();
|
||||||
|
this.#batchElements.delete(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
keysToRemove.forEach(k => {
|
||||||
|
this.#idToBatch.delete(k);
|
||||||
this.#usageTracker.delete(k);
|
this.#usageTracker.delete(k);
|
||||||
this.statuses.delete(k);
|
this.statuses.delete(k);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appliedFontsManager = new AppliedFontsManager();
|
export const appliedFontsManager = new AppliedFontsManager();
|
||||||
|
|||||||
Reference in New Issue
Block a user