135 lines
5.4 KiB
TypeScript
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;
|
|
};
|
|
}
|