diff --git a/.gitea/workflows/workflow.yml b/.gitea/workflows/workflow.yml index 9deb1ee..a9b8fb8 100644 --- a/.gitea/workflows/workflow.yml +++ b/.gitea/workflows/workflow.yml @@ -44,17 +44,17 @@ jobs: run: yarn check:shadcn-excluded publish: - needs: build # Only runs if tests/lint pass - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' # Only deploy from main branch - steps: - - name: Checkout - uses: actions/checkout@v4 + needs: build # Only runs if tests/lint pass + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' # Only deploy from main branch + steps: + - name: Checkout + uses: actions/checkout@v4 - - name: Login to Gitea Registry - run: echo "${{ secrets.CI_DEPLOY_TOKEN }}" | docker login git.allmy.work -u ${{ gitea.repository_owner }} --password-stdin + - name: Login to Gitea Registry + run: echo "${{ secrets.CI_DEPLOY_TOKEN }}" | docker login git.allmy.work -u ${{ gitea.repository_owner }} --password-stdin - - name: Build and Push Docker Image - run: | - docker build -t git.allmy.work/${{ gitea.repository }}:latest . - docker push git.allmy.work/${{ gitea.repository }}:latest + - name: Build and Push Docker Image + run: | + docker build -t git.allmy.work/${{ gitea.repository }}:latest . + docker push git.allmy.work/${{ gitea.repository }}:latest diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index f9ed924..f0f07a5 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -75,7 +75,6 @@ export type { export { appliedFontsManager, createUnifiedFontStore, - selectedFontsStore, unifiedFontStore, } from './model'; diff --git a/src/entities/Font/model/index.ts b/src/entities/Font/model/index.ts index 47daa00..8ec6e69 100644 --- a/src/entities/Font/model/index.ts +++ b/src/entities/Font/model/index.ts @@ -38,7 +38,6 @@ export { appliedFontsManager, createUnifiedFontStore, type FontConfigRequest, - selectedFontsStore, type UnifiedFontStore, unifiedFontStore, } from './store'; diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts new file mode 100644 index 0000000..f2a116e --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts @@ -0,0 +1,115 @@ +/** @vitest-environment jsdom */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { AppliedFontsManager } from './appliedFontsStore.svelte'; + +describe('AppliedFontsManager', () => { + let manager: AppliedFontsManager; + let mockFontFaceSet: any; + + beforeEach(() => { + vi.useFakeTimers(); + + mockFontFaceSet = { + add: vi.fn(), + delete: vi.fn(), + }; + + // 1. Properly mock FontFace as a constructor function + const MockFontFace = vi.fn(function(this: any, name: string, url: string) { + this.name = name; + this.url = url; + this.load = vi.fn().mockImplementation(() => { + if (url.includes('fail')) return Promise.reject(new Error('Load failed')); + return Promise.resolve(this); + }); + }); + + vi.stubGlobal('FontFace', MockFontFace); + + // 2. Mock document.fonts safely + Object.defineProperty(document, 'fonts', { + value: mockFontFaceSet, + configurable: true, + writable: true, + }); + + vi.stubGlobal('crypto', { + randomUUID: () => '11111111-1111-1111-1111-111111111111' as any, + }); + + manager = new AppliedFontsManager(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('should batch multiple font requests into a single process', async () => { + const configs = [ + { id: 'lato-400', name: 'Lato', url: 'lato.ttf', weight: 400 }, + { id: 'lato-700', name: 'Lato', url: 'lato-bold.ttf', weight: 700 }, + ]; + + manager.touch(configs); + + // Advance to trigger the 16ms debounced #processQueue + await vi.advanceTimersByTimeAsync(50); + + expect(manager.getFontStatus('lato-400', 400)).toBe('loaded'); + expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2); + }); + + it('should handle font loading errors gracefully', async () => { + // Suppress expected console error for clean test logs + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const config = { id: 'broken', name: 'Broken', url: 'fail.ttf', weight: 400 }; + + manager.touch([config]); + await vi.advanceTimersByTimeAsync(50); + + expect(manager.getFontStatus('broken', 400)).toBe('error'); + spy.mockRestore(); + }); + + it('should purge fonts after TTL expires', async () => { + const config = { id: 'ephemeral', name: 'Temp', url: 'temp.ttf', weight: 400 }; + + manager.touch([config]); + await vi.advanceTimersByTimeAsync(50); + expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded'); + + // Move clock forward past TTL (5m) and Purge Interval (1m) + // advanceTimersByTimeAsync is key here; it handles the promises inside the interval + await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + + expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined(); + expect(mockFontFaceSet.delete).toHaveBeenCalled(); + }); + + it('should NOT purge fonts that are still being "touched"', async () => { + const config = { id: 'active', name: 'Active', url: 'active.ttf', weight: 400 }; + + manager.touch([config]); + await vi.advanceTimersByTimeAsync(50); + + // Advance 4 minutes + await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + + // Refresh touch + manager.touch([config]); + + // Advance another 2 minutes (Total 6 since start) + await vi.advanceTimersByTimeAsync(2 * 60 * 1000); + + expect(manager.getFontStatus('active', 400)).toBe('loaded'); + }); +}); diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 4629d06..9cb92b7 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -31,155 +31,142 @@ export interface FontConfigRequest { * - Variable fonts: Loaded once per id (covers all weights). * - Static fonts: Loaded per id + weight combination. */ -class AppliedFontsManager { - #usageTracker = new Map(); - #idToBatch = new Map(); - // Changed to HTMLStyleElement - #batchElements = new Map(); +export class AppliedFontsManager { + // Stores the actual FontFace objects for cleanup + #loadedFonts = new Map(); + // Optimization: Map> to avoid O(N^2) scans + #batchToKeys = new Map>(); + // Optimization: Map for reverse lookup + #keyToBatch = new Map(); - #queue = new Map(); // Track config in queue + #usageTracker = new Map(); + #queue = new Map(); #timeoutId: ReturnType | null = null; - #PURGE_INTERVAL = 60000; - #TTL = 5 * 60 * 1000; - #CHUNK_SIZE = 5; // Can be larger since we're just injecting strings + readonly #PURGE_INTERVAL = 60000; + readonly #TTL = 5 * 60 * 1000; + readonly #CHUNK_SIZE = 5; statuses = new SvelteMap(); constructor() { if (typeof window !== 'undefined') { + // Using a weak reference style approach isn't possible for DOM, + // so we stick to the interval but make it highly efficient. setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL); } } - #getFontKey(config: FontConfigRequest): string { - 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}`; + #getFontKey(id: string, weight: number, isVariable: boolean): string { + return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`; } touch(configs: FontConfigRequest[]) { const now = Date.now(); - configs.forEach(config => { - // Pass the whole config to get key - const key = this.#getFontKey(config); + let hasNewItems = false; + for (const config of configs) { + const key = this.#getFontKey(config.id, config.weight, !!config.isVariable); 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)) { - this.#queue.set(key, config); - - if (this.#timeoutId) clearTimeout(this.#timeoutId); - this.#timeoutId = setTimeout(() => this.#processQueue(), 50); + if (this.statuses.get(key) === 'loaded' || this.statuses.get(key) === 'loading' || this.#queue.has(key)) { + continue; } - }); - } - getFontStatus(id: string, weight: number, isVariable: boolean = false) { - // Construct a temp config to generate key - const key = this.#getFontKey({ id, weight, name: '', url: '', isVariable }); - return this.statuses.get(key); + this.#queue.set(key, config); + hasNewItems = true; + } + + // IMPROVEMENT: Only trigger timer if not already pending + if (hasNewItems && !this.#timeoutId) { + this.#timeoutId = setTimeout(() => this.#processQueue(), 16); // ~1 frame delay + } } #processQueue() { + this.#timeoutId = null; const entries = Array.from(this.#queue.entries()); if (entries.length === 0) return; - 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; + + // Process in chunks to keep the UI responsive + for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) { + this.#applyBatch(entries.slice(i, i + this.#CHUNK_SIZE)); + } } - #createBatch(batchEntries: [string, FontConfigRequest][]) { + async #applyBatch(batchEntries: [string, FontConfigRequest][]) { if (typeof document === 'undefined') return; const batchId = crypto.randomUUID(); - let cssRules = ''; + const keysInBatch = new Set(); - batchEntries.forEach(([key, config]) => { + const loadPromises = batchEntries.map(([key, config]) => { this.statuses.set(key, 'loading'); - this.#idToBatch.set(key, batchId); + this.#keyToBatch.set(key, batchId); + keysInBatch.add(key); - // If variable, allow the full weight range. - // 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'; + // Use a unique internal family name to prevent collisions + // while keeping the "real" name for the browser to resolve weight/style. + const internalName = `f_${config.id}`; + const weightRange = config.isVariable ? '100 900' : `${config.weight}`; - cssRules += ` - @font-face { - font-family: '${config.name}'; - src: url('${config.url}') format('${fontFormat}'); - font-weight: ${weightRule}; - font-style: normal; - font-display: swap; - } - `; - }); + const font = new FontFace(config.name, `url(${config.url})`, { + weight: weightRange, + style: 'normal', + display: 'swap', + }); - const style = document.createElement('style'); - style.dataset.batchId = batchId; - style.innerHTML = cssRules; - document.head.appendChild(style); - this.#batchElements.set(batchId, style); + this.#loadedFonts.set(key, font); - // Use the requested weight for verification, even if the rule covers a range - batchEntries.forEach(([key, config]) => { - document.fonts.load(`${config.weight} 1em "${config.name}"`) - .then(loaded => { - this.statuses.set(key, loaded.length > 0 ? 'loaded' : 'error'); + return font.load() + .then(loadedFace => { + document.fonts.add(loadedFace); + this.statuses.set(key, 'loaded'); }) - .catch(() => this.statuses.set(key, 'error')); + .catch(e => { + console.error(`Font load failed: ${config.name}`, e); + this.statuses.set(key, 'error'); + }); }); + + this.#batchToKeys.set(batchId, keysInBatch); + await Promise.allSettled(loadPromises); } + #purgeUnused() { const now = Date.now(); - const batchesToRemove = new Set(); - const keysToRemove: string[] = []; - for (const [key, lastUsed] of this.#usageTracker.entries()) { - if (now - lastUsed > this.#TTL) { - 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(([k]) => k); + // We iterate over batches, not individual fonts, to reduce loops + for (const [batchId, keys] of this.#batchToKeys.entries()) { + let canPurgeBatch = true; - const canDeleteBatch = batchKeys.every(k => { - const lastK = this.#usageTracker.get(k); - return lastK && (now - lastK > this.#TTL); - }); - - if (canDeleteBatch) { - batchesToRemove.add(batchId); - keysToRemove.push(...batchKeys); - } + for (const key of keys) { + const lastUsed = this.#usageTracker.get(key) || 0; + if (now - lastUsed < this.#TTL) { + canPurgeBatch = false; + break; } } + + if (canPurgeBatch) { + keys.forEach(key => { + const font = this.#loadedFonts.get(key); + if (font) document.fonts.delete(font); + + this.#loadedFonts.delete(key); + this.#keyToBatch.delete(key); + this.#usageTracker.delete(key); + this.statuses.delete(key); + }); + this.#batchToKeys.delete(batchId); + } } + } - 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); - }); + getFontStatus(id: string, weight: number, isVariable = false) { + return this.statuses.get(this.#getFontKey(id, weight, isVariable)); } } diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index eacd64e..c110ee4 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -18,6 +18,3 @@ export { appliedFontsManager, type FontConfigRequest, } from './appliedFontsStore/appliedFontsStore.svelte'; - -// Selected fonts store (user selection - unchanged) -export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte'; diff --git a/src/entities/Font/model/store/selectedFontsStore/selectedFontsStore.svelte.ts b/src/entities/Font/model/store/selectedFontsStore/selectedFontsStore.svelte.ts deleted file mode 100644 index de3c3f8..0000000 --- a/src/entities/Font/model/store/selectedFontsStore/selectedFontsStore.svelte.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createEntityStore } from '$shared/lib'; -import type { UnifiedFont } from '../../types'; - -/** - * Store that handles collection of selected fonts - */ -export const selectedFontsStore = createEntityStore([]); diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte index ebfd362..1ec6114 100644 --- a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte +++ b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte @@ -20,13 +20,15 @@ interface Props { * Font id to load */ id: string; - + /** */ url: string; /** * Font weight */ weight?: number; - + /** + * Variable font flag + */ isVariable?: boolean; /** * Additional classes @@ -75,25 +77,25 @@ $effect(() => { }); // 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')); const transitionClasses = $derived( prefersReducedMotion.current ? 'transition-none' // Disable CSS transitions if motion is reduced - : 'transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]', + : 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]', );
diff --git a/src/entities/Font/ui/FontListItem/FontListItem.svelte b/src/entities/Font/ui/FontListItem/FontListItem.svelte index af0e7fc..b958d69 100644 --- a/src/entities/Font/ui/FontListItem/FontListItem.svelte +++ b/src/entities/Font/ui/FontListItem/FontListItem.svelte @@ -6,10 +6,7 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils'; import type { Snippet } from 'svelte'; import { Spring } from 'svelte/motion'; -import { - type UnifiedFont, - selectedFontsStore, -} from '../../model'; +import { type UnifiedFont } from '../../model'; interface Props { /** @@ -36,7 +33,6 @@ interface Props { const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props(); -const selected = $derived(selectedFontsStore.has(font.id)); let timeoutId = $state(null); // Create a spring for smooth scale animation diff --git a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte index 64274e5..e7157cf 100644 --- a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte +++ b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte @@ -6,7 +6,6 @@ import { FontApplicator, type UnifiedFont, - selectedFontsStore, } from '$entities/Font'; import { controlManager } from '$features/SetupFont'; import { @@ -48,10 +47,6 @@ const fontWeight = $derived(controlManager.weight); const fontSize = $derived(controlManager.renderedSize); const lineHeight = $derived(controlManager.height); const letterSpacing = $derived(controlManager.spacing); - -function removeSample() { - selectedFontsStore.removeOne(font.id); -}