fix/filtration #20
@@ -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>();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user