import { TextLayoutEngine } from '$shared/lib'; import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey'; import type { FontLoadStatus, UnifiedFont, } from '../../model/types'; /** * Options for {@link createFontRowSizeResolver}. * * All getter functions are called on every resolver invocation. When called * inside a Svelte `$derived.by` block, any reactive state read within them * (e.g. `SvelteMap.get()`) is automatically tracked as a dependency. */ export interface FontRowSizeResolverOptions { /** * Returns the current fonts array. Index `i` corresponds to row `i`. */ getFonts: () => UnifiedFont[]; /** * Returns the active font weight (e.g. 400). */ getWeight: () => number; /** * Returns the preview text string. */ getPreviewText: () => string; /** * Returns the scroll container's inner width in pixels. Returns 0 before mount. */ getContainerWidth: () => number; /** * Returns the font size in pixels (e.g. `controlManager.renderedSize`). */ getFontSizePx: () => number; /** * Returns the computed line height in pixels. * Typically `controlManager.height * controlManager.renderedSize`. */ getLineHeightPx: () => number; /** * Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`). * * In production: `(key) => appliedFontsManager.statuses.get(key)`. * Injected for testability — avoids a module-level singleton dependency in tests. * The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context * for reactivity to work. This is satisfied when `itemHeight` is called by * `createVirtualizer`'s `estimateSize`. */ getStatus: (fontKey: string) => FontLoadStatus | undefined; /** * Total horizontal padding of the text content area in pixels. * Use the smallest breakpoint value (mobile `p-4` = 32px) to guarantee * the content width is never over-estimated, keeping the height estimate safe. */ contentHorizontalPadding: number; /** * Fixed height in pixels of chrome that is not text content (header bar, etc.). */ chromeHeight: number; /** * Height in pixels to return when the font is not loaded or container width is 0. */ fallbackHeight: number; } /** * Creates a row-height resolver for `FontSampler` rows in `VirtualList`. * * The returned function is suitable as the `itemHeight` prop of `VirtualList`. * Pass it from the widget layer (`SampleList`) so that typography values from * `controlManager` are injected as getter functions rather than imported directly, * keeping `$entities/Font` free of `$features` dependencies. * * **Reactivity:** When the returned function reads `getStatus()` inside a * `$derived.by` block (as `estimateSize` does in `createVirtualizer`), any * `SvelteMap.get()` call within `getStatus` registers a Svelte 5 dependency. * When a font's status changes to `'loaded'`, `offsets` recomputes automatically — * no DOM snap occurs. * * **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx` * prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated * naturally because a change in any input produces a different cache key. * * @param options - Configuration and getter functions (all injected for testability). * @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`. */ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number { const engine = new TextLayoutEngine(); // Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}` const cache = new Map(); return function resolveRowHeight(rowIndex: number): number { const fonts = options.getFonts(); const font = fonts[rowIndex]; if (!font) { return options.fallbackHeight; } const containerWidth = options.getContainerWidth(); const previewText = options.getPreviewText(); if (containerWidth <= 0 || !previewText) { return options.fallbackHeight; } const weight = options.getWeight(); // generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts. const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable }); // Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(), // which creates a Svelte 5 reactive dependency when called inside $derived.by. const status = options.getStatus(fontKey); if (status !== 'loaded') { return options.fallbackHeight; } const fontSizePx = options.getFontSizePx(); const lineHeightPx = options.getLineHeightPx(); const contentWidth = containerWidth - options.contentHorizontalPadding; const fontCssString = `${weight} ${fontSizePx}px "${font.name}"`; const cacheKey = `${fontCssString}|${previewText}|${contentWidth}|${lineHeightPx}`; const cached = cache.get(cacheKey); if (cached !== undefined) { return cached; } const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx); const result = totalHeight + options.chromeHeight; cache.set(cacheKey, result); return result; }; }