feat: implement createFontRowSizeResolver with canvas-measured heights and reactive status check
This commit is contained in:
112
src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts
Normal file
112
src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user