Files
frontend-svelte/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts

135 lines
5.4 KiB
TypeScript

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<string, number>();
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;
};
}