From 20accb9c939d925af3a17f37e9c0faceca1d69af Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 13 Apr 2026 08:54:19 +0300 Subject: [PATCH] feat: implement createFontRowSizeResolver with canvas-measured heights and reactive status check --- src/entities/Font/lib/index.ts | 3 + .../createFontRowSizeResolver.test.ts | 168 ++++++++++++++++++ .../sizeResolver/createFontRowSizeResolver.ts | 112 ++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts create mode 100644 src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts diff --git a/src/entities/Font/lib/index.ts b/src/entities/Font/lib/index.ts index 07d5f5f..161c396 100644 --- a/src/entities/Font/lib/index.ts +++ b/src/entities/Font/lib/index.ts @@ -48,3 +48,6 @@ export { FontNetworkError, FontResponseError, } from './errors/errors'; + +export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver'; +export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver'; diff --git a/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts new file mode 100644 index 0000000..33525d6 --- /dev/null +++ b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import { TextLayoutEngine } from '$shared/lib'; +import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas'; +import { clearCache } from '@chenglou/pretext'; +import { + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { FontLoadStatus } from '../../model/types'; +import { mockUnifiedFont } from '../mocks'; +import { createFontRowSizeResolver } from './createFontRowSizeResolver'; + +// Fixed-width canvas mock: every character is 10px wide regardless of font. +// This makes wrapping math predictable: N chars × 10px = N×10 total width. +const CHAR_WIDTH = 10; +const LINE_HEIGHT = 20; +const CONTAINER_WIDTH = 200; +const CONTENT_PADDING_X = 32; // p-4 × 2 sides = 32px +const CHROME_HEIGHT = 56; +const FALLBACK_HEIGHT = 220; +const FONT_SIZE_PX = 16; + +describe('createFontRowSizeResolver', () => { + let statusMap: Map; + let getStatus: (key: string) => FontLoadStatus | undefined; + + beforeEach(() => { + installCanvasMock((_font, text) => text.length * CHAR_WIDTH); + clearCache(); + statusMap = new Map(); + getStatus = key => statusMap.get(key); + }); + + function makeResolver(overrides?: Partial[0]>) { + const font = mockUnifiedFont({ id: 'inter', name: 'Inter' }); + return { + font, + resolver: createFontRowSizeResolver({ + getFonts: () => [font], + getWeight: () => 400, + getPreviewText: () => 'Hello', + getContainerWidth: () => CONTAINER_WIDTH, + getFontSizePx: () => FONT_SIZE_PX, + getLineHeightPx: () => LINE_HEIGHT, + getStatus, + contentHorizontalPadding: CONTENT_PADDING_X, + chromeHeight: CHROME_HEIGHT, + fallbackHeight: FALLBACK_HEIGHT, + ...overrides, + }), + }; + } + + it('returns fallbackHeight when font status is undefined', () => { + const { resolver } = makeResolver(); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); + + it('returns fallbackHeight when font status is "loading"', () => { + const { resolver } = makeResolver(); + statusMap.set('inter@400', 'loading'); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); + + it('returns fallbackHeight when font status is "error"', () => { + const { resolver } = makeResolver(); + statusMap.set('inter@400', 'error'); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); + + it('returns fallbackHeight when containerWidth is 0', () => { + const { resolver } = makeResolver({ getContainerWidth: () => 0 }); + statusMap.set('inter@400', 'loaded'); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); + + it('returns fallbackHeight when previewText is empty', () => { + const { resolver } = makeResolver({ getPreviewText: () => '' }); + statusMap.set('inter@400', 'loaded'); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); + + it('returns fallbackHeight for out-of-bounds rowIndex', () => { + const { resolver } = makeResolver(); + statusMap.set('inter@400', 'loaded'); + expect(resolver(99)).toBe(FALLBACK_HEIGHT); + }); + + it('returns computed height (totalHeight + chromeHeight) when font is loaded', () => { + const { resolver } = makeResolver(); + statusMap.set('inter@400', 'loaded'); + + // 'Hello' = 5 chars × 10px = 50px. contentWidth = 200 - 32 = 168px. Fits on one line. + // totalHeight = 1 × LINE_HEIGHT = 20. result = 20 + CHROME_HEIGHT = 76. + const result = resolver(0); + expect(result).toBe(LINE_HEIGHT + CHROME_HEIGHT); + }); + + it('returns increased height when text wraps due to narrow container', () => { + // contentWidth = 40 - 32 = 8px — 'Hello' (50px) forces wrapping onto many lines + const { resolver } = makeResolver({ getContainerWidth: () => 40 }); + statusMap.set('inter@400', 'loaded'); + + const result = resolver(0); + expect(result).toBeGreaterThan(LINE_HEIGHT + CHROME_HEIGHT); + }); + + it('does not call layout() again on second call with same arguments', () => { + const { resolver } = makeResolver(); + statusMap.set('inter@400', 'loaded'); + + const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout'); + + resolver(0); + resolver(0); + + expect(layoutSpy).toHaveBeenCalledTimes(1); + layoutSpy.mockRestore(); + }); + + it('calls layout() again when containerWidth changes (cache miss)', () => { + let width = CONTAINER_WIDTH; + const { resolver } = makeResolver({ getContainerWidth: () => width }); + statusMap.set('inter@400', 'loaded'); + + const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout'); + + resolver(0); + width = 100; + resolver(0); + + expect(layoutSpy).toHaveBeenCalledTimes(2); + layoutSpy.mockRestore(); + }); + + it('returns greater height when container narrows (more wrapping)', () => { + let width = CONTAINER_WIDTH; + const { resolver } = makeResolver({ getContainerWidth: () => width }); + statusMap.set('inter@400', 'loaded'); + + const h1 = resolver(0); + width = 100; // narrower → more wrapping + const h2 = resolver(0); + + expect(h2).toBeGreaterThanOrEqual(h1); + }); + + it('uses variable font key for variable fonts', () => { + const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } }); + const { resolver } = makeResolver({ getFonts: () => [vfFont] }); + // Variable fonts use '{id}@vf' key, not '{id}@{weight}' + statusMap.set('roboto@vf', 'loaded'); + const result = resolver(0); + expect(result).not.toBe(FALLBACK_HEIGHT); + expect(result).toBeGreaterThan(0); + }); + + it('returns fallbackHeight for variable font when static key is set instead', () => { + const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } }); + const { resolver } = makeResolver({ getFonts: () => [vfFont] }); + // Setting the static key should NOT unlock computed height for variable fonts + statusMap.set('roboto@400', 'loaded'); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); +}); diff --git a/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts new file mode 100644 index 0000000..fe053c3 --- /dev/null +++ b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts @@ -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(); + + 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; + }; +}