/** * Typography control manager * * Manages a collection of typography controls (font size, weight, line height, * letter spacing) with persistent storage. Supports responsive scaling * through a multiplier system. * * The font size control uses a multiplier system to allow responsive scaling * while preserving the user's base size preference. The multiplier is applied * when displaying/editing, but the base size is what's stored. */ import { type ControlId, DEFAULT_FONT_SIZE, DEFAULT_FONT_WEIGHT, DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, } from '$entities/Font'; import { type ControlDataModel, type ControlModel, type PersistentStore, type TypographyControl, createPersistentStore, createTypographyControl, } from '$shared/lib'; import { SvelteMap } from 'svelte/reactivity'; type ControlOnlyFields = Omit, keyof ControlDataModel>; /** * A control with its associated instance */ export interface Control extends ControlOnlyFields { /** * The reactive typography control instance */ instance: TypographyControl; } /** * Storage schema for typography settings */ export interface TypographySettings { /** * Base font size (User preference, unscaled) */ fontSize: number; /** * Numeric font weight (100-900) */ fontWeight: number; /** * Line height multiplier (e.g. 1.5) */ lineHeight: number; /** * Letter spacing in em/px */ letterSpacing: number; } /** * Typography control manager class * * Manages multiple typography controls with persistent storage and * responsive scaling support for font size. */ export class TypographySettingsManager { /** * Internal map of reactive controls keyed by their identifier */ #controls = new SvelteMap(); /** * Global multiplier for responsive font size scaling */ #multiplier = $state(1); /** * LocalStorage-backed storage for persistence */ #storage: PersistentStore; /** * The underlying font size before responsive scaling is applied */ #baseSize = $state(DEFAULT_FONT_SIZE); constructor(configs: ControlModel[], storage: PersistentStore) { this.#storage = storage; // Initial Load const saved = storage.value; this.#baseSize = saved.fontSize; // Setup Controls configs.forEach(config => { const initialValue = this.#getInitialValue(config.id, saved); this.#controls.set(config.id, { ...config, instance: createTypographyControl({ ...config, value: initialValue, }), }); }); // The Sync Effect (UI -> Storage) // We access .value explicitly to ensure Svelte 5 tracks the dependency $effect.root(() => { $effect(() => { // EXPLICIT DEPENDENCIES: Accessing these triggers the effect const fontSize = this.#baseSize; const fontWeight = this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT; const lineHeight = this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT; const letterSpacing = this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING; // Syncing back to storage this.#storage.value = { fontSize, fontWeight, lineHeight, letterSpacing, }; }); // The Font Size Proxy Effect // This handles the "Multiplier" logic specifically for the Font Size Control $effect(() => { const ctrl = this.#controls.get('font_size')?.instance; if (!ctrl) { return; } // If the user moves the slider/clicks buttons in the UI: // We update the baseSize (User Intent) const currentDisplayValue = ctrl.value; const calculatedBase = currentDisplayValue / this.#multiplier; // Only update if the difference is significant (prevents rounding jitter) if (Math.abs(this.#baseSize - calculatedBase) > 0.01) { this.#baseSize = calculatedBase; } }); }); } /** * Gets initial value for a control from storage or defaults */ #getInitialValue(id: string, saved: TypographySettings): number { if (id === 'font_size') { return saved.fontSize * this.#multiplier; } if (id === 'font_weight') { return saved.fontWeight; } if (id === 'line_height') { return saved.lineHeight; } if (id === 'letter_spacing') { return saved.letterSpacing; } return 0; } /** * Active scaling factor for the rendered font size */ get multiplier() { return this.#multiplier; } /** * Updates the multiplier and recalculates dependent control values */ set multiplier(value: number) { if (this.#multiplier === value) { return; } this.#multiplier = value; // When multiplier changes, we must update the Font Size Control's display value const ctrl = this.#controls.get('font_size')?.instance; if (ctrl) { ctrl.value = this.#baseSize * this.#multiplier; } } /** * The actual pixel value for CSS font-size (baseSize * multiplier) */ get renderedSize() { return this.#baseSize * this.#multiplier; } /** * The raw font size preference before scaling */ get baseSize() { return this.#baseSize; } set baseSize(val: number) { this.#baseSize = val; const ctrl = this.#controls.get('font_size')?.instance; if (ctrl) { ctrl.value = val * this.#multiplier; } } /** * List of all managed typography controls */ get controls() { return Array.from(this.#controls.values()); } /** * Reactive instance for weight manipulation */ get weightControl() { return this.#controls.get('font_weight')?.instance; } /** * Reactive instance for size manipulation */ get sizeControl() { return this.#controls.get('font_size')?.instance; } /** * Reactive instance for line-height manipulation */ get heightControl() { return this.#controls.get('line_height')?.instance; } /** * Reactive instance for letter-spacing manipulation */ get spacingControl() { return this.#controls.get('letter_spacing')?.instance; } /** * Current numeric font weight (reactive) */ get weight() { return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT; } /** * Current numeric line height (reactive) */ get height() { return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT; } /** * Current numeric letter spacing (reactive) */ get spacing() { return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING; } /** * Reset all controls to project-defined defaults */ reset() { this.#storage.clear(); const defaults = this.#storage.value; this.#baseSize = defaults.fontSize; // Reset all control instances this.#controls.forEach(c => { if (c.id === 'font_size') { c.instance.value = defaults.fontSize * this.#multiplier; } else { // Map storage key to control id const key = c.id.replace('_', '') as keyof TypographySettings; // Simplified for brevity, you'd map these properly: if (c.id === 'font_weight') { c.instance.value = defaults.fontWeight; } if (c.id === 'line_height') { c.instance.value = defaults.lineHeight; } if (c.id === 'letter_spacing') { c.instance.value = defaults.letterSpacing; } } }); } } /** * Creates a typography control manager * * @param configs - Array of control configurations * @param storageId - Persistent storage identifier * @returns Typography control manager instance */ export function createTypographySettingsManager( configs: ControlModel[], storageId: string = 'glyphdiff:typography', ) { const storage = createPersistentStore(storageId, { fontSize: DEFAULT_FONT_SIZE, fontWeight: DEFAULT_FONT_WEIGHT, lineHeight: DEFAULT_LINE_HEIGHT, letterSpacing: DEFAULT_LETTER_SPACING, }); return new TypographySettingsManager(configs, storage); }