Compare commits
8 Commits
cb740df1b2
...
1fc9572f3d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc9572f3d | ||
|
|
d006c662a9 | ||
|
|
422363d329 | ||
|
|
61c67acfb8 | ||
|
|
6945169279 | ||
|
|
055b02f720 | ||
|
|
7018b6a836 | ||
|
|
5d8869b3f2 |
@@ -44,17 +44,17 @@ jobs:
|
|||||||
run: yarn check:shadcn-excluded
|
run: yarn check:shadcn-excluded
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: build # Only runs if tests/lint pass
|
needs: build # Only runs if tests/lint pass
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.ref == 'refs/heads/main' # Only deploy from main branch
|
if: github.ref == 'refs/heads/main' # Only deploy from main branch
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
run: echo "${{ secrets.CI_DEPLOY_TOKEN }}" | docker login git.allmy.work -u ${{ gitea.repository_owner }} --password-stdin
|
run: echo "${{ secrets.CI_DEPLOY_TOKEN }}" | docker login git.allmy.work -u ${{ gitea.repository_owner }} --password-stdin
|
||||||
|
|
||||||
- name: Build and Push Docker Image
|
- name: Build and Push Docker Image
|
||||||
run: |
|
run: |
|
||||||
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
|
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
|
||||||
docker push git.allmy.work/${{ gitea.repository }}:latest
|
docker push git.allmy.work/${{ gitea.repository }}:latest
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export type {
|
|||||||
export {
|
export {
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
createUnifiedFontStore,
|
createUnifiedFontStore,
|
||||||
selectedFontsStore,
|
|
||||||
unifiedFontStore,
|
unifiedFontStore,
|
||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export {
|
|||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
createUnifiedFontStore,
|
createUnifiedFontStore,
|
||||||
type FontConfigRequest,
|
type FontConfigRequest,
|
||||||
selectedFontsStore,
|
|
||||||
type UnifiedFontStore,
|
type UnifiedFontStore,
|
||||||
unifiedFontStore,
|
unifiedFontStore,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,155 +31,142 @@ export interface FontConfigRequest {
|
|||||||
* - Variable fonts: Loaded once per id (covers all weights).
|
* - Variable fonts: Loaded once per id (covers all weights).
|
||||||
* - Static fonts: Loaded per id + weight combination.
|
* - Static fonts: Loaded per id + weight combination.
|
||||||
*/
|
*/
|
||||||
class AppliedFontsManager {
|
export class AppliedFontsManager {
|
||||||
#usageTracker = new Map<string, number>();
|
// Stores the actual FontFace objects for cleanup
|
||||||
#idToBatch = new Map<string, string>();
|
#loadedFonts = new Map<string, FontFace>();
|
||||||
// Changed to HTMLStyleElement
|
// Optimization: Map<batchId, Set<fontKeys>> to avoid O(N^2) scans
|
||||||
#batchElements = new Map<string, HTMLStyleElement>();
|
#batchToKeys = new Map<string, Set<string>>();
|
||||||
|
// Optimization: Map<fontKey, batchId> for reverse lookup
|
||||||
|
#keyToBatch = new Map<string, string>();
|
||||||
|
|
||||||
#queue = new Map<string, FontConfigRequest>(); // Track config in queue
|
#usageTracker = new Map<string, number>();
|
||||||
|
#queue = new Map<string, FontConfigRequest>();
|
||||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
#PURGE_INTERVAL = 60000;
|
readonly #PURGE_INTERVAL = 60000;
|
||||||
#TTL = 5 * 60 * 1000;
|
readonly #TTL = 5 * 60 * 1000;
|
||||||
#CHUNK_SIZE = 5; // Can be larger since we're just injecting strings
|
readonly #CHUNK_SIZE = 5;
|
||||||
|
|
||||||
statuses = new SvelteMap<string, FontStatus>();
|
statuses = new SvelteMap<string, FontStatus>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof window !== 'undefined') {
|
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);
|
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#getFontKey(config: FontConfigRequest): string {
|
#getFontKey(id: string, weight: number, isVariable: boolean): string {
|
||||||
if (config.isVariable) {
|
return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`;
|
||||||
// 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 => {
|
let hasNewItems = false;
|
||||||
// Pass the whole config to get key
|
|
||||||
const key = this.#getFontKey(config);
|
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
|
||||||
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' || this.statuses.get(key) === 'loading' || this.#queue.has(key)) {
|
||||||
if (this.statuses.get(key) === 'loaded') return;
|
continue;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getFontStatus(id: string, weight: number, isVariable: boolean = false) {
|
this.#queue.set(key, config);
|
||||||
// Construct a temp config to generate key
|
hasNewItems = true;
|
||||||
const key = this.#getFontKey({ id, weight, name: '', url: '', isVariable });
|
}
|
||||||
return this.statuses.get(key);
|
|
||||||
|
// IMPROVEMENT: Only trigger timer if not already pending
|
||||||
|
if (hasNewItems && !this.#timeoutId) {
|
||||||
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 16); // ~1 frame delay
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#processQueue() {
|
#processQueue() {
|
||||||
|
this.#timeoutId = null;
|
||||||
const entries = Array.from(this.#queue.entries());
|
const entries = Array.from(this.#queue.entries());
|
||||||
if (entries.length === 0) return;
|
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.#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;
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
const batchId = crypto.randomUUID();
|
const batchId = crypto.randomUUID();
|
||||||
let cssRules = '';
|
const keysInBatch = new Set<string>();
|
||||||
|
|
||||||
batchEntries.forEach(([key, config]) => {
|
const loadPromises = batchEntries.map(([key, config]) => {
|
||||||
this.statuses.set(key, 'loading');
|
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.
|
// Use a unique internal family name to prevent collisions
|
||||||
// If static, lock it to the specific weight.
|
// while keeping the "real" name for the browser to resolve weight/style.
|
||||||
const weightRule = config.isVariable
|
const internalName = `f_${config.id}`;
|
||||||
? '100 900' // Variable range (standard coverage)
|
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
|
||||||
: config.weight;
|
|
||||||
const fontFormat = config.isVariable ? 'truetype-variations' : 'truetype';
|
|
||||||
|
|
||||||
cssRules += `
|
const font = new FontFace(config.name, `url(${config.url})`, {
|
||||||
@font-face {
|
weight: weightRange,
|
||||||
font-family: '${config.name}';
|
style: 'normal',
|
||||||
src: url('${config.url}') format('${fontFormat}');
|
display: 'swap',
|
||||||
font-weight: ${weightRule};
|
});
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = document.createElement('style');
|
this.#loadedFonts.set(key, font);
|
||||||
style.dataset.batchId = batchId;
|
|
||||||
style.innerHTML = cssRules;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
this.#batchElements.set(batchId, style);
|
|
||||||
|
|
||||||
// Use the requested weight for verification, even if the rule covers a range
|
return font.load()
|
||||||
batchEntries.forEach(([key, config]) => {
|
.then(loadedFace => {
|
||||||
document.fonts.load(`${config.weight} 1em "${config.name}"`)
|
document.fonts.add(loadedFace);
|
||||||
.then(loaded => {
|
this.statuses.set(key, 'loaded');
|
||||||
this.statuses.set(key, loaded.length > 0 ? 'loaded' : 'error');
|
|
||||||
})
|
})
|
||||||
.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() {
|
#purgeUnused() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const batchesToRemove = new Set<string>();
|
|
||||||
const keysToRemove: string[] = [];
|
|
||||||
|
|
||||||
for (const [key, lastUsed] of this.#usageTracker.entries()) {
|
// We iterate over batches, not individual fonts, to reduce loops
|
||||||
if (now - lastUsed > this.#TTL) {
|
for (const [batchId, keys] of this.#batchToKeys.entries()) {
|
||||||
const batchId = this.#idToBatch.get(key);
|
let canPurgeBatch = true;
|
||||||
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);
|
|
||||||
|
|
||||||
const canDeleteBatch = batchKeys.every(k => {
|
for (const key of keys) {
|
||||||
const lastK = this.#usageTracker.get(k);
|
const lastUsed = this.#usageTracker.get(key) || 0;
|
||||||
return lastK && (now - lastK > this.#TTL);
|
if (now - lastUsed < this.#TTL) {
|
||||||
});
|
canPurgeBatch = false;
|
||||||
|
break;
|
||||||
if (canDeleteBatch) {
|
|
||||||
batchesToRemove.add(batchId);
|
|
||||||
keysToRemove.push(...batchKeys);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 => {
|
getFontStatus(id: string, weight: number, isVariable = false) {
|
||||||
this.#batchElements.get(id)?.remove();
|
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
|
||||||
this.#batchElements.delete(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
keysToRemove.forEach(k => {
|
|
||||||
this.#idToBatch.delete(k);
|
|
||||||
this.#usageTracker.delete(k);
|
|
||||||
this.statuses.delete(k);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,3 @@ export {
|
|||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
type FontConfigRequest,
|
type FontConfigRequest,
|
||||||
} from './appliedFontsStore/appliedFontsStore.svelte';
|
} from './appliedFontsStore/appliedFontsStore.svelte';
|
||||||
|
|
||||||
// Selected fonts store (user selection - unchanged)
|
|
||||||
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';
|
|
||||||
|
|||||||
@@ -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<UnifiedFont>([]);
|
|
||||||
@@ -20,13 +20,15 @@ interface Props {
|
|||||||
* Font id to load
|
* Font id to load
|
||||||
*/
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
|
/** */
|
||||||
url: string;
|
url: string;
|
||||||
/**
|
/**
|
||||||
* Font weight
|
* Font weight
|
||||||
*/
|
*/
|
||||||
weight?: number;
|
weight?: number;
|
||||||
|
/**
|
||||||
|
* Variable font flag
|
||||||
|
*/
|
||||||
isVariable?: boolean;
|
isVariable?: boolean;
|
||||||
/**
|
/**
|
||||||
* Additional classes
|
* Additional classes
|
||||||
@@ -75,25 +77,25 @@ $effect(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 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'));
|
||||||
|
|
||||||
const transitionClasses = $derived(
|
const transitionClasses = $derived(
|
||||||
prefersReducedMotion.current
|
prefersReducedMotion.current
|
||||||
? 'transition-none' // Disable CSS transitions if motion is reduced
|
? '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)]',
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
style:font-family={shouldReveal ? `'${name}'` : 'sans-serif'}
|
style:font-family={shouldReveal ? `'${name}'` : 'system-ui, -apple-system, 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
|
||||||
!shouldReveal && !prefersReducedMotion.current
|
!shouldReveal && !prefersReducedMotion.current
|
||||||
&& 'opacity-0 translate-y-8 scale-[0.98] blur-sm',
|
&& 'opacity-50 scale-[0.95] blur-sm',
|
||||||
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
||||||
shouldReveal && 'opacity-100 translate-y-0 scale-100 blur-0',
|
shouldReveal && 'opacity-100 scale-100 blur-0',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,10 +6,7 @@
|
|||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { Spring } from 'svelte/motion';
|
import { Spring } from 'svelte/motion';
|
||||||
import {
|
import { type UnifiedFont } from '../../model';
|
||||||
type UnifiedFont,
|
|
||||||
selectedFontsStore,
|
|
||||||
} from '../../model';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +33,6 @@ interface Props {
|
|||||||
|
|
||||||
const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props();
|
const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props();
|
||||||
|
|
||||||
const selected = $derived(selectedFontsStore.has(font.id));
|
|
||||||
let timeoutId = $state<NodeJS.Timeout | null>(null);
|
let timeoutId = $state<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Create a spring for smooth scale animation
|
// Create a spring for smooth scale animation
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
import {
|
import {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
selectedFontsStore,
|
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { controlManager } from '$features/SetupFont';
|
import { controlManager } from '$features/SetupFont';
|
||||||
import {
|
import {
|
||||||
@@ -48,10 +47,6 @@ const fontWeight = $derived(controlManager.weight);
|
|||||||
const fontSize = $derived(controlManager.renderedSize);
|
const fontSize = $derived(controlManager.renderedSize);
|
||||||
const lineHeight = $derived(controlManager.height);
|
const lineHeight = $derived(controlManager.height);
|
||||||
const letterSpacing = $derived(controlManager.spacing);
|
const letterSpacing = $derived(controlManager.spacing);
|
||||||
|
|
||||||
function removeSample() {
|
|
||||||
selectedFontsStore.removeOne(font.id);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ import {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { class: className }: Props = $props();
|
const { class: className, hidden = false }: Props = $props();
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
const [send, receive] = crossfade({
|
const [send, receive] = crossfade({
|
||||||
@@ -71,7 +72,7 @@ $effect(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn('w-auto max-screen z-10 flex justify-center', className)}
|
class={cn('w-auto max-screen z-10 flex justify-center', hidden && 'hidden', className)}
|
||||||
in:receive={{ key: 'panel' }}
|
in:receive={{ key: 'panel' }}
|
||||||
out:send={{ key: 'panel' }}
|
out:send={{ key: 'panel' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,9 +11,35 @@ interface Props {
|
|||||||
|
|
||||||
const { class: className }: Props = $props();
|
const { class: className }: Props = $props();
|
||||||
|
|
||||||
const baseClasses =
|
const baseClasses = 'font-[Barlow] font-thin text-5xl sm:text-6xl md:text-7xl lg:text-8xl';
|
||||||
'font-[Barlow] font-thin text-5xl sm:text-6xl md:text-7xl lg:text-8xl text-justify [text-align-last:justify] [text-justify:inter-character]';
|
|
||||||
|
const title = 'GLYPHDIFF';
|
||||||
</script>
|
</script>
|
||||||
<h2 class={cn(baseClasses, className)}>
|
|
||||||
GLYPHDIFF
|
<!-- Firefox version (hidden in Chrome/Safari) -->
|
||||||
|
<h2
|
||||||
|
class={cn(
|
||||||
|
baseClasses,
|
||||||
|
'text-justify [text-align-last:justify] [text-justify:inter-character]',
|
||||||
|
// Hide in non-Firefox
|
||||||
|
'hidden [@supports(text-justify:inter-character)]:block',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Chrome/Safari version (hidden in Firefox) -->
|
||||||
|
<h2
|
||||||
|
class={cn(
|
||||||
|
'flex justify-between w-full',
|
||||||
|
baseClasses,
|
||||||
|
// Hide in Firefox
|
||||||
|
'[@supports(text-justify:inter-character)]:hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#each title.split('') as letter}
|
||||||
|
<span>{letter}</span>
|
||||||
|
{/each}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -18,8 +18,15 @@ interface Props {
|
|||||||
let { sliderPos, isDragging }: Props = $props();
|
let { sliderPos, isDragging }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-y-2 sm:inset-y-4 pointer-events-none -translate-x-1/2 z-50 transition-all duration-300 ease-out flex flex-col justify-center items-center"
|
class={cn(
|
||||||
|
'absolute inset-y-2 sm:inset-y-4 pointer-events-none -translate-x-1/2 z-50 flex flex-col justify-center items-center',
|
||||||
|
// Force GPU layer with translateZ
|
||||||
|
'translate-z-0',
|
||||||
|
// Only transition left when NOT dragging
|
||||||
|
isDragging ? '' : 'transition-[left] duration-300 ease-out',
|
||||||
|
)}
|
||||||
style:left="{sliderPos}%"
|
style:left="{sliderPos}%"
|
||||||
|
style:will-change={isDragging ? 'left' : 'auto'}
|
||||||
>
|
>
|
||||||
<!-- We use part of lucide cursor svg icon as a handle -->
|
<!-- We use part of lucide cursor svg icon as a handle -->
|
||||||
<svg
|
<svg
|
||||||
@@ -40,13 +47,13 @@ let { sliderPos, isDragging }: Props = $props();
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'relative h-full rounded-sm transition-all duration-500',
|
'relative h-full rounded-sm transition-all duration-300',
|
||||||
'bg-white/3 backdrop-blur-md',
|
'bg-white/3 ',
|
||||||
// These are the visible "edges" of the glass
|
// These are the visible "edges" of the glass
|
||||||
'shadow-[0_0_40px_rgba(0,0,0,0.1)_inset_0_0_20px_rgba(255,255,255,0.1)]',
|
'shadow-[0_0_40px_rgba(0,0,0,0.1)_inset_0_0_20px_rgba(255,255,255,0.1)]',
|
||||||
'shadow-[0_10px_30px_-10px_rgba(0,0,0,0.2),inset_0_1px_1px_rgba(255,255,255,0.4)]',
|
'shadow-[0_10px_30px_-10px_rgba(0,0,0,0.2),inset_0_1px_1px_rgba(255,255,255,0.4)]',
|
||||||
'rounded-full',
|
'rounded-full',
|
||||||
isDragging ? 'w-16 sm:w-32' : 'w-12 sm:w-16',
|
isDragging ? 'w-16 sm:w-24' : 'w-12 sm:w-16',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ function checkPosition() {
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</FontVirtualList>
|
</FontVirtualList>
|
||||||
|
|
||||||
{#if isAboveMiddle}
|
<TypographyMenu
|
||||||
<TypographyMenu class="fixed bottom-4 sm:bottom-5 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2" />
|
class="fixed bottom-4 sm:bottom-5 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2"
|
||||||
{/if}
|
hidden={!isAboveMiddle}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user