fix: add missing JSDoc, return types, and as-any comments to layout engines

This commit is contained in:
Ilia Mashkov
2026-04-12 09:51:36 +03:00
parent 49822f8af7
commit 4b017a83bb
3 changed files with 78 additions and 11 deletions

View File

@@ -5,25 +5,64 @@ import {
} from '@chenglou/pretext';
/**
* Result of dual-font comparison layout
* A single laid-out line produced by dual-font comparison layout.
*
* Line breaking is determined by the unified worst-case widths, so both fonts
* always break at identical positions. Per-character `xA`/`xB` offsets reflect
* each font's actual advance widths independently.
*/
export interface ComparisonLine {
/** Full text of this line as returned by pretext. */
text: string;
width: number; // Max width between font A and B
/** Rendered width of this line in pixels — maximum across font A and font B. */
width: number;
chars: Array<{
/** The grapheme cluster string (may be >1 code unit for emoji, etc.). */
char: string;
xA: number; // X offset in font A
/** X offset from the start of the line in font A, in pixels. */
xA: number;
/** Advance width of this grapheme in font A, in pixels. */
widthA: number;
xB: number; // X offset in font B
/** X offset from the start of the line in font B, in pixels. */
xB: number;
/** Advance width of this grapheme in font B, in pixels. */
widthB: number;
}>;
}
/**
* Aggregated output of a dual-font layout pass.
*/
export interface ComparisonResult {
/** Per-line grapheme data for both fonts. Empty when input text is empty. */
lines: ComparisonLine[];
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
totalHeight: number;
}
/**
* Dual-font text layout engine backed by `@chenglou/pretext`.
*
* Computes identical line breaks for two fonts simultaneously by constructing a
* "unified" prepared-text object whose per-glyph widths are the worst-case maximum
* of font A and font B. This guarantees that both fonts wrap at exactly the same
* positions, making side-by-side or slider comparison visually coherent.
*
* **Two-level caching strategy**
* 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only
* when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive
* (canvas measurement), so this avoids re-measuring during slider interaction.
* 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but
* the fonts have not changed. Line-breaking is cheap relative to measurement, but
* still worth skipping on every render tick.
*
* **`as any` casts:** `PreparedTextWithSegments` exposes only the `segments` field in
* its public TypeScript type. The numeric arrays (`widths`, `breakableFitAdvances`,
* `lineEndFitAdvances`, `lineEndPaintAdvances`) are internal implementation details of
* pretext that are not part of the published type signature. The casts are required to
* access these fields; they are verified against the pretext source at
* `node_modules/@chenglou/pretext/src/layout.ts`.
*/
export class CharacterComparisonEngine {
#segmenter: Intl.Segmenter;
@@ -46,8 +85,17 @@ export class CharacterComparisonEngine {
}
/**
* Unified layout for two fonts
* Ensures consistent line breaks by taking the worst-case width
* Lay out `text` using both fonts within `width` pixels.
*
* Line breaks are determined by the worst-case (maximum) glyph widths across
* both fonts, so both fonts always wrap at identical positions.
*
* @param text Raw text to lay out.
* @param fontA CSS font string for the first font: `"weight sizepx \"family\""`.
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
* @param width Available line width in pixels.
* @param lineHeight Line height in pixels (passed directly to pretext).
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
*/
layout(
text: string,
@@ -82,7 +130,9 @@ export class CharacterComparisonEngine {
return { lines: [], totalHeight: 0 };
}
// 2. Layout using the unified widths
// 2. Layout using the unified widths.
// `PreparedTextWithSegments` only exposes `segments` in its public type; cast to `any`
// so pretext's layoutWithLines can read the internal numeric arrays at runtime.
const { lines, height } = layoutWithLines(this.#unifiedPrepared as any, width, lineHeight);
// 3. Map results back to both fonts
@@ -94,6 +144,7 @@ export class CharacterComparisonEngine {
const start = line.start;
const end = line.end;
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
const intA = this.#preparedA as any;
const intB = this.#preparedB as any;
@@ -145,14 +196,24 @@ export class CharacterComparisonEngine {
}
/**
* Calculates character state without any DOM calls
* Calculates character proximity and direction relative to a slider position.
*
* Uses the most recent `layout()` result — must be called after `layout()`.
* No DOM calls are made; all geometry is derived from cached layout data.
*
* @param lineIndex Zero-based index of the line within the last layout result.
* @param charIndex Zero-based index of the character within that line's `chars` array.
* @param sliderPos Current slider position as a percentage (0100) of `containerWidth`.
* @param containerWidth Total container width in pixels, used to convert pixel offsets to %.
* @returns `proximity` in [0, 1] (1 = slider exactly over char center) and
* `isPast` (true when the slider has already passed the char center).
*/
getCharState(
lineIndex: number,
charIndex: number,
sliderPos: number, // 0-100 percentage
sliderPos: number,
containerWidth: number,
) {
): { proximity: number; isPast: boolean } {
if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) {
return { proximity: 0, isPast: false };
}
@@ -181,6 +242,7 @@ export class CharacterComparisonEngine {
* Internal helper to merge two prepared texts into a "worst-case" unified version
*/
#createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments {
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
const intA = a as any;
const intB = b as any;

View File

@@ -36,7 +36,7 @@ describe('CharacterComparisonEngine', () => {
expect(result.totalHeight).toBe(0);
});
it('uses worst-case (FontB) width to determine line breaks', () => {
it('uses worst-case width across both fonts to determine line breaks', () => {
// 'AB CD' — two 2-char words separated by a space.
// FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total.
// FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '.

View File

@@ -24,7 +24,11 @@ export interface LayoutLine {
}>;
}
/**
* Aggregated output of a single-font layout pass.
*/
export interface LayoutResult {
/** Per-line grapheme data. Empty when input text is empty. */
lines: LayoutLine[];
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
totalHeight: number;
@@ -61,6 +65,7 @@ export class TextLayoutEngine {
*/
#segmenter: Intl.Segmenter;
/** @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale. */
constructor(locale?: string) {
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
}