fix/filtration #20

Merged
ilia merged 7 commits from fix/filtration into main 2026-02-05 08:51:46 +00:00
3 changed files with 75 additions and 32 deletions
Showing only changes of commit adf6dc93ea - Show all commits

View File

@@ -52,16 +52,27 @@ class AppliedFontsManager {
} }
} }
#getFontKey(id: string, weight: number): string { #getFontKey(config: FontConfigRequest): string {
return `${id.toLowerCase()}@${weight}`; if (config.isVariable) {
// 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 => { configs.forEach(config => {
const key = this.#getFontKey(config.id, config.weight); // Pass the whole config to get key
const key = this.#getFontKey(config);
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') return;
if (!this.#idToBatch.has(key) && !this.#queue.has(key)) { if (!this.#idToBatch.has(key) && !this.#queue.has(key)) {
this.#queue.set(key, config); this.#queue.set(key, config);
@@ -71,8 +82,10 @@ class AppliedFontsManager {
}); });
} }
getFontStatus(id: string, weight: number) { getFontStatus(id: string, weight: number, isVariable: boolean = false) {
return this.statuses.get(this.#getFontKey(id, weight)); // Construct a temp config to generate key
const key = this.#getFontKey({ id, weight, name: '', url: '', isVariable });
return this.statuses.get(key);
} }
#processQueue() { #processQueue() {
@@ -97,27 +110,31 @@ class AppliedFontsManager {
this.statuses.set(key, 'loading'); this.statuses.set(key, 'loading');
this.#idToBatch.set(key, batchId); this.#idToBatch.set(key, batchId);
// Construct the @font-face rule // If variable, allow the full weight range.
// Using format('truetype') for .ttf // If static, lock it to the specific weight.
const weightRule = config.isVariable
? '100 900' // Variable range (standard coverage)
: config.weight;
const fontFormat = config.isVariable ? 'truetype-variations' : 'truetype';
cssRules += ` cssRules += `
@font-face { @font-face {
font-family: '${config.name}'; font-family: '${config.name}';
src: url('${config.url}') format('truetype'); src: url('${config.url}') format('${fontFormat}');
font-weight: ${config.weight}; font-weight: ${weightRule};
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
`; `;
}); });
// Create and inject the style tag
const style = document.createElement('style'); const style = document.createElement('style');
style.dataset.batchId = batchId; style.dataset.batchId = batchId;
style.innerHTML = cssRules; style.innerHTML = cssRules;
document.head.appendChild(style); document.head.appendChild(style);
this.#batchElements.set(batchId, style); this.#batchElements.set(batchId, style);
// Verify loading via Font Loading API // Use the requested weight for verification, even if the rule covers a range
batchEntries.forEach(([key, config]) => { batchEntries.forEach(([key, config]) => {
document.fonts.load(`${config.weight} 1em "${config.name}"`) document.fonts.load(`${config.weight} 1em "${config.name}"`)
.then(loaded => { .then(loaded => {
@@ -126,7 +143,6 @@ class AppliedFontsManager {
.catch(() => this.statuses.set(key, 'error')); .catch(() => this.statuses.set(key, 'error'));
}); });
} }
#purgeUnused() { #purgeUnused() {
const now = Date.now(); const now = Date.now();
const batchesToRemove = new Set<string>(); const batchesToRemove = new Set<string>();

View File

@@ -26,6 +26,8 @@ interface Props {
* Font weight * Font weight
*/ */
weight?: number; weight?: number;
isVariable?: boolean;
/** /**
* Additional classes * Additional classes
*/ */
@@ -36,27 +38,42 @@ interface Props {
children?: Snippet; children?: Snippet;
} }
let { name, id, url, weight = 400, className, children }: Props = $props(); let { name, id, url, weight = 400, isVariable = false, className, children }: Props = $props();
let element: Element; let element: Element;
// Track if the user has actually scrolled this into view // Track if the user has actually scrolled this into view
let hasEnteredViewport = $state(false); let hasEnteredViewport = $state(false);
const status = $derived(appliedFontsManager.getFontStatus(id, weight, isVariable));
$effect(() => { $effect(() => {
if (status === 'loaded' || status === 'error') {
hasEnteredViewport = true;
return;
}
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) { if (entries[0].isIntersecting) {
hasEnteredViewport = true; hasEnteredViewport = true;
appliedFontsManager.touch([{ id, weight, name, url }]);
// Once it has entered, we can stop observing to save CPU // Touch ensures it's in the queue.
// It's safe to call this even if VirtualList called it
// (Manager dedupes based on key)
appliedFontsManager.touch([{
id,
weight,
name,
url,
isVariable,
}]);
observer.unobserve(element); observer.unobserve(element);
} }
}); });
observer.observe(element);
if (element) observer.observe(element);
return () => observer.disconnect(); return () => observer.disconnect();
}); });
const status = $derived(appliedFontsManager.getFontStatus(id, weight));
// 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' || status === 'error'));
@@ -69,7 +86,7 @@ const transitionClasses = $derived(
<div <div
bind:this={element} bind:this={element}
style:font-family={name} style:font-family={shouldReveal ? `'${name}'` : '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

View File

@@ -4,9 +4,10 @@
- Handles font registration with the manager - Handles font registration with the manager
--> -->
<script lang="ts" generics="T extends UnifiedFont"> <script lang="ts" generics="T extends UnifiedFont">
import type { FontConfigRequest } from '$entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte';
import { VirtualList } from '$shared/ui'; import { VirtualList } from '$shared/ui';
import type { ComponentProps } from 'svelte'; import type { ComponentProps } from 'svelte';
import { getFontUrl } from '../../lib';
import type { FontConfigRequest } from '../../model';
import { import {
type UnifiedFont, type UnifiedFont,
appliedFontsManager, appliedFontsManager,
@@ -21,16 +22,25 @@ interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleIt
let { items, children, onVisibleItemsChange, onNearBottom, weight, ...rest }: Props = $props(); let { items, children, onVisibleItemsChange, onNearBottom, weight, ...rest }: Props = $props();
function handleInternalVisibleChange(visibleItems: T[]) { function handleInternalVisibleChange(visibleItems: T[]) {
const configs: FontConfigRequest[] = [];
visibleItems.forEach(item => {
const url = getFontUrl(item, weight);
if (url) {
configs.push({
id: item.id,
name: item.name,
weight,
url,
isVariable: item.features?.isVariable,
});
}
});
// Auto-register fonts with the manager // Auto-register fonts with the manager
const configs = visibleItems.map<FontConfigRequest>(item => ({
id: item.id,
name: item.name,
weight,
url: item.styles.regular!,
}));
appliedFontsManager.touch(configs); appliedFontsManager.touch(configs);
// // Forward the call to any external listener // Forward the call to any external listener
// onVisibleItemsChange?.(visibleItems); // onVisibleItemsChange?.(visibleItems);
} }