feat(appliedFontsStore): implement the logic to update font link when font weight changes
This commit is contained in:
@@ -2,66 +2,83 @@ import { SvelteMap } from 'svelte/reactivity';
|
|||||||
|
|
||||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
|
export interface FontConfigRequest {
|
||||||
|
slug: string;
|
||||||
|
weight: number;
|
||||||
|
isVariable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manager that handles loading of the fonts
|
* Manager that handles loading of fonts from Fontshare.
|
||||||
* Adds <link /> tags to <head />
|
* Logic:
|
||||||
* - Uses batch loading to reduce the number of requests
|
* - Variable fonts: Loaded once per slug (covers all weights).
|
||||||
* - Uses a queue to prevent too many requests at once
|
* - Static fonts: Loaded per slug + weight combination.
|
||||||
* - Purges unused fonts after a certain time
|
|
||||||
*/
|
*/
|
||||||
class AppliedFontsManager {
|
class AppliedFontsManager {
|
||||||
// Stores: slug -> timestamp of last visibility
|
// Tracking usage: Map<key, timestamp> where key is "slug" or "slug@weight"
|
||||||
#usageTracker = new Map<string, number>();
|
#usageTracker = new Map<string, number>();
|
||||||
// Stores: slug -> batchId
|
// Map: key -> batchId
|
||||||
#slugToBatch = new Map<string, string>();
|
#slugToBatch = new Map<string, string>();
|
||||||
// Stores: batchId -> HTMLLinkElement (for physical cleanup)
|
// Map: batchId -> HTMLLinkElement
|
||||||
#batchElements = new Map<string, HTMLLinkElement>();
|
#batchElements = new Map<string, HTMLLinkElement>();
|
||||||
|
|
||||||
#queue = new Set<string>();
|
#queue = new Set<string>();
|
||||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
#PURGE_INTERVAL = 60000; // Check every minute
|
|
||||||
#TTL = 5 * 60 * 1000; // 5 minutes
|
#PURGE_INTERVAL = 60000;
|
||||||
|
#TTL = 5 * 60 * 1000;
|
||||||
#CHUNK_SIZE = 3;
|
#CHUNK_SIZE = 3;
|
||||||
|
|
||||||
|
// Reactive status map for UI feedback
|
||||||
statuses = new SvelteMap<string, FontStatus>();
|
statuses = new SvelteMap<string, FontStatus>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Start the "Janitor" loop
|
|
||||||
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the 'last seen' timestamp for fonts.
|
* Resolves a unique key for the font asset.
|
||||||
* Prevents them from being purged while they are on screen.
|
|
||||||
*/
|
*/
|
||||||
touch(slugs: string[]) {
|
#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[]) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const toRegister: string[] = [];
|
const toRegister: string[] = [];
|
||||||
|
|
||||||
slugs.forEach(slug => {
|
configs.forEach(({ slug, weight, isVariable = false }) => {
|
||||||
this.#usageTracker.set(slug, now);
|
const key = this.#getFontKey(slug, weight, isVariable);
|
||||||
if (!this.#slugToBatch.has(slug)) {
|
|
||||||
toRegister.push(slug);
|
this.#usageTracker.set(key, now);
|
||||||
|
|
||||||
|
if (!this.#slugToBatch.has(key)) {
|
||||||
|
toRegister.push(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (toRegister.length > 0) this.registerFonts(toRegister);
|
if (toRegister.length > 0) this.registerFonts(toRegister);
|
||||||
}
|
}
|
||||||
|
|
||||||
registerFonts(slugs: string[]) {
|
registerFonts(keys: string[]) {
|
||||||
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s));
|
const newKeys = keys.filter(k => !this.#slugToBatch.has(k) && !this.#queue.has(k));
|
||||||
if (newSlugs.length === 0) return;
|
if (newKeys.length === 0) return;
|
||||||
|
|
||||||
newSlugs.forEach(s => this.#queue.add(s));
|
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) {
|
getFontStatus(slug: string, weight: number, isVariable: boolean) {
|
||||||
return this.statuses.get(slug);
|
return this.statuses.get(this.#getFontKey(slug, weight, isVariable));
|
||||||
}
|
}
|
||||||
|
|
||||||
#processQueue() {
|
#processQueue() {
|
||||||
@@ -76,16 +93,23 @@ class AppliedFontsManager {
|
|||||||
this.#timeoutId = null;
|
this.#timeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
#createBatch(slugs: string[]) {
|
#createBatch(keys: string[]) {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
const batchId = crypto.randomUUID();
|
const batchId = crypto.randomUUID();
|
||||||
// font-display=swap included for better UX
|
|
||||||
const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&');
|
/**
|
||||||
|
* 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('&');
|
||||||
|
|
||||||
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
|
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
|
||||||
|
|
||||||
// Mark all as loading immediately
|
keys.forEach(key => this.statuses.set(key, 'loading'));
|
||||||
slugs.forEach(slug => this.statuses.set(slug, 'loading'));
|
|
||||||
|
|
||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
@@ -94,21 +118,24 @@ class AppliedFontsManager {
|
|||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
|
|
||||||
this.#batchElements.set(batchId, link);
|
this.#batchElements.set(batchId, link);
|
||||||
slugs.forEach(slug => {
|
|
||||||
this.#slugToBatch.set(slug, batchId);
|
|
||||||
|
|
||||||
// Use the Native Font Loading API
|
keys.forEach(key => {
|
||||||
// format: "font-size font-family"
|
this.#slugToBatch.set(key, batchId);
|
||||||
document.fonts.load(`1em "${slug}"`)
|
|
||||||
|
// 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 => {
|
.then(loadedFonts => {
|
||||||
if (loadedFonts.length > 0) {
|
this.statuses.set(key, loadedFonts.length > 0 ? 'loaded' : 'error');
|
||||||
this.statuses.set(slug, 'loaded');
|
|
||||||
} else {
|
|
||||||
this.statuses.set(slug, 'error');
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
this.statuses.set(slug, 'error');
|
this.statuses.set(key, 'error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -116,31 +143,30 @@ class AppliedFontsManager {
|
|||||||
#purgeUnused() {
|
#purgeUnused() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const batchesToPotentialDelete = new Set<string>();
|
const batchesToPotentialDelete = new Set<string>();
|
||||||
const slugsToDelete: string[] = [];
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
// Identify expired slugs
|
for (const [key, lastUsed] of this.#usageTracker.entries()) {
|
||||||
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
|
|
||||||
if (now - lastUsed > this.#TTL) {
|
if (now - lastUsed > this.#TTL) {
|
||||||
const batchId = this.#slugToBatch.get(slug);
|
const batchId = this.#slugToBatch.get(key);
|
||||||
if (batchId) batchesToPotentialDelete.add(batchId);
|
if (batchId) batchesToPotentialDelete.add(batchId);
|
||||||
slugsToDelete.push(slug);
|
keysToDelete.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only remove a batch if ALL fonts in that batch are expired
|
|
||||||
batchesToPotentialDelete.forEach(batchId => {
|
batchesToPotentialDelete.forEach(batchId => {
|
||||||
const batchSlugs = Array.from(this.#slugToBatch.entries())
|
const batchKeys = Array.from(this.#slugToBatch.entries())
|
||||||
.filter(([_, bId]) => bId === batchId)
|
.filter(([_, bId]) => bId === batchId)
|
||||||
.map(([slug]) => slug);
|
.map(([key]) => key);
|
||||||
|
|
||||||
const allExpired = batchSlugs.every(s => slugsToDelete.includes(s));
|
const allExpired = batchKeys.every(k => keysToDelete.includes(k));
|
||||||
|
|
||||||
if (allExpired) {
|
if (allExpired) {
|
||||||
this.#batchElements.get(batchId)?.remove();
|
this.#batchElements.get(batchId)?.remove();
|
||||||
this.#batchElements.delete(batchId);
|
this.#batchElements.delete(batchId);
|
||||||
batchSlugs.forEach(s => {
|
batchKeys.forEach(k => {
|
||||||
this.#slugToBatch.delete(s);
|
this.#slugToBatch.delete(k);
|
||||||
this.#usageTracker.delete(s);
|
this.#usageTracker.delete(k);
|
||||||
|
this.statuses.delete(k);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,55 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sliderPos: number;
|
sliderPos: number;
|
||||||
|
isDragging: boolean;
|
||||||
}
|
}
|
||||||
let { sliderPos }: Props = $props();
|
let { sliderPos, isDragging }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Vertical Divider & Knobs -->
|
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 bottom-0 z-30 pointer-events-none"
|
class="absolute inset-y-0 pointer-events-none -translate-x-1/2 z-30"
|
||||||
style:left="{sliderPos}%"
|
style:left="{sliderPos}%"
|
||||||
>
|
>
|
||||||
<!-- Vertical Line -->
|
<!-- Subtle wave glow zone -->
|
||||||
<div class="absolute inset-y-0 -left-px w-0.5 bg-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]">
|
<div
|
||||||
|
class={cn(
|
||||||
|
'absolute inset-y-0 w-24 -left-12 bg-linear-to-r from-transparent via-indigo-500/8 to-transparent transition-all duration-300',
|
||||||
|
isDragging ? 'via-indigo-500/12' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Top Knob -->
|
<!-- Vertical divider line -->
|
||||||
<div class="absolute top-6 left-0 -translate-x-1/2">
|
<div
|
||||||
<div class="w-2.5 h-2.5 bg-indigo-500 rounded-full shadow ring-2 ring-white"></div>
|
class="absolute inset-y-0 w-0.5 bg-linear-to-b from-indigo-400/30 via-indigo-500 to-indigo-400/30 shadow-[0_0_12px_rgba(99,102,241,0.5)] transition-shadow duration-200"
|
||||||
|
class:shadow-[0_0_20px_rgba(99,102,241,0.7)]={isDragging}
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Knob -->
|
<!-- Top knob -->
|
||||||
<div class="absolute bottom-6 left-0 -translate-x-1/2">
|
<div
|
||||||
<div class="w-2.5 h-2.5 bg-indigo-500 rounded-full shadow ring-2 ring-white"></div>
|
class="absolute top-6 left-0 -translate-x-1/2 transition-transform duration-200"
|
||||||
|
class:scale-125={isDragging}
|
||||||
|
>
|
||||||
|
<div class="w-3 h-3 bg-indigo-500 rounded-full shadow-lg ring-2 ring-white"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom knob -->
|
||||||
|
<div
|
||||||
|
class="absolute bottom-6 left-0 -translate-x-1/2 transition-transform duration-200"
|
||||||
|
class:scale-125={isDragging}
|
||||||
|
>
|
||||||
|
<div class="w-3 h-3 bg-indigo-500 rounded-full shadow-lg ring-2 ring-white"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
<style>
|
||||||
|
|
||||||
|
div {
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
-->
|
||||||
|
|||||||
Reference in New Issue
Block a user