fix: add missing JSDoc, return types, and as-any comments to layout engines
This commit is contained in:
@@ -5,25 +5,64 @@ import {
|
|||||||
} from '@chenglou/pretext';
|
} 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 {
|
export interface ComparisonLine {
|
||||||
|
/** Full text of this line as returned by pretext. */
|
||||||
text: string;
|
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<{
|
chars: Array<{
|
||||||
|
/** The grapheme cluster string (may be >1 code unit for emoji, etc.). */
|
||||||
char: string;
|
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;
|
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;
|
widthB: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated output of a dual-font layout pass.
|
||||||
|
*/
|
||||||
export interface ComparisonResult {
|
export interface ComparisonResult {
|
||||||
|
/** Per-line grapheme data for both fonts. Empty when input text is empty. */
|
||||||
lines: ComparisonLine[];
|
lines: ComparisonLine[];
|
||||||
|
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
|
||||||
totalHeight: number;
|
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 {
|
export class CharacterComparisonEngine {
|
||||||
#segmenter: Intl.Segmenter;
|
#segmenter: Intl.Segmenter;
|
||||||
|
|
||||||
@@ -46,8 +85,17 @@ export class CharacterComparisonEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified layout for two fonts
|
* Lay out `text` using both fonts within `width` pixels.
|
||||||
* Ensures consistent line breaks by taking the worst-case width
|
*
|
||||||
|
* 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(
|
layout(
|
||||||
text: string,
|
text: string,
|
||||||
@@ -82,7 +130,9 @@ export class CharacterComparisonEngine {
|
|||||||
return { lines: [], totalHeight: 0 };
|
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);
|
const { lines, height } = layoutWithLines(this.#unifiedPrepared as any, width, lineHeight);
|
||||||
|
|
||||||
// 3. Map results back to both fonts
|
// 3. Map results back to both fonts
|
||||||
@@ -94,6 +144,7 @@ export class CharacterComparisonEngine {
|
|||||||
const start = line.start;
|
const start = line.start;
|
||||||
const end = line.end;
|
const end = line.end;
|
||||||
|
|
||||||
|
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
|
||||||
const intA = this.#preparedA as any;
|
const intA = this.#preparedA as any;
|
||||||
const intB = this.#preparedB 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 (0–100) 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(
|
getCharState(
|
||||||
lineIndex: number,
|
lineIndex: number,
|
||||||
charIndex: number,
|
charIndex: number,
|
||||||
sliderPos: number, // 0-100 percentage
|
sliderPos: number,
|
||||||
containerWidth: number,
|
containerWidth: number,
|
||||||
) {
|
): { proximity: number; isPast: boolean } {
|
||||||
if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) {
|
if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) {
|
||||||
return { proximity: 0, isPast: false };
|
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
|
* Internal helper to merge two prepared texts into a "worst-case" unified version
|
||||||
*/
|
*/
|
||||||
#createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments {
|
#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 intA = a as any;
|
||||||
const intB = b as any;
|
const intB = b as any;
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe('CharacterComparisonEngine', () => {
|
|||||||
expect(result.totalHeight).toBe(0);
|
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.
|
// 'AB CD' — two 2-char words separated by a space.
|
||||||
// FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total.
|
// 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 '.
|
// FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '.
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ export interface LayoutLine {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated output of a single-font layout pass.
|
||||||
|
*/
|
||||||
export interface LayoutResult {
|
export interface LayoutResult {
|
||||||
|
/** Per-line grapheme data. Empty when input text is empty. */
|
||||||
lines: LayoutLine[];
|
lines: LayoutLine[];
|
||||||
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
|
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
|
||||||
totalHeight: number;
|
totalHeight: number;
|
||||||
@@ -61,6 +65,7 @@ export class TextLayoutEngine {
|
|||||||
*/
|
*/
|
||||||
#segmenter: Intl.Segmenter;
|
#segmenter: Intl.Segmenter;
|
||||||
|
|
||||||
|
/** @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale. */
|
||||||
constructor(locale?: string) {
|
constructor(locale?: string) {
|
||||||
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user