fix: correct advances null-check in CharacterComparisonEngine and remove unused TextLayoutEngine dep
This commit is contained in:
@@ -0,0 +1,208 @@
|
|||||||
|
import {
|
||||||
|
type PreparedTextWithSegments,
|
||||||
|
layoutWithLines,
|
||||||
|
prepareWithSegments,
|
||||||
|
} from '@chenglou/pretext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of dual-font comparison layout
|
||||||
|
*/
|
||||||
|
export interface ComparisonLine {
|
||||||
|
text: string;
|
||||||
|
width: number; // Max width between font A and B
|
||||||
|
chars: Array<{
|
||||||
|
char: string;
|
||||||
|
xA: number; // X offset in font A
|
||||||
|
widthA: number;
|
||||||
|
xB: number; // X offset in font B
|
||||||
|
widthB: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparisonResult {
|
||||||
|
lines: ComparisonLine[];
|
||||||
|
totalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CharacterComparisonEngine {
|
||||||
|
#segmenter: Intl.Segmenter;
|
||||||
|
|
||||||
|
// Cached prepared data
|
||||||
|
#preparedA: PreparedTextWithSegments | null = null;
|
||||||
|
#preparedB: PreparedTextWithSegments | null = null;
|
||||||
|
#unifiedPrepared: PreparedTextWithSegments | null = null;
|
||||||
|
|
||||||
|
#lastText = '';
|
||||||
|
#lastFontA = '';
|
||||||
|
#lastFontB = '';
|
||||||
|
|
||||||
|
// Cached layout results
|
||||||
|
#lastWidth = -1;
|
||||||
|
#lastLineHeight = -1;
|
||||||
|
#lastResult: ComparisonResult | null = null;
|
||||||
|
|
||||||
|
constructor(locale?: string) {
|
||||||
|
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified layout for two fonts
|
||||||
|
* Ensures consistent line breaks by taking the worst-case width
|
||||||
|
*/
|
||||||
|
layout(
|
||||||
|
text: string,
|
||||||
|
fontA: string,
|
||||||
|
fontB: string,
|
||||||
|
width: number,
|
||||||
|
lineHeight: number,
|
||||||
|
): ComparisonResult {
|
||||||
|
if (!text) {
|
||||||
|
return { lines: [], totalHeight: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFontChange = text !== this.#lastText || fontA !== this.#lastFontA || fontB !== this.#lastFontB;
|
||||||
|
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
|
||||||
|
|
||||||
|
if (!isFontChange && !isLayoutChange && this.#lastResult) {
|
||||||
|
return this.#lastResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Prepare (or use cache)
|
||||||
|
if (isFontChange) {
|
||||||
|
this.#preparedA = prepareWithSegments(text, fontA);
|
||||||
|
this.#preparedB = prepareWithSegments(text, fontB);
|
||||||
|
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB);
|
||||||
|
|
||||||
|
this.#lastText = text;
|
||||||
|
this.#lastFontA = fontA;
|
||||||
|
this.#lastFontB = fontB;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
|
||||||
|
return { lines: [], totalHeight: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Layout using the unified widths
|
||||||
|
const { lines, height } = layoutWithLines(this.#unifiedPrepared as any, width, lineHeight);
|
||||||
|
|
||||||
|
// 3. Map results back to both fonts
|
||||||
|
const resultLines: ComparisonLine[] = lines.map(line => {
|
||||||
|
const chars: ComparisonLine['chars'] = [];
|
||||||
|
let currentXA = 0;
|
||||||
|
let currentXB = 0;
|
||||||
|
|
||||||
|
const start = line.start;
|
||||||
|
const end = line.end;
|
||||||
|
|
||||||
|
const intA = this.#preparedA as any;
|
||||||
|
const intB = this.#preparedB as any;
|
||||||
|
|
||||||
|
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
||||||
|
const segmentText = this.#preparedA!.segments[sIdx];
|
||||||
|
if (segmentText === undefined) continue;
|
||||||
|
|
||||||
|
// PERFORMANCE: Reuse segmenter results if possible, but for now just optimize the loop
|
||||||
|
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
||||||
|
|
||||||
|
const advA = intA.breakableFitAdvances[sIdx];
|
||||||
|
const advB = intB.breakableFitAdvances[sIdx];
|
||||||
|
|
||||||
|
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
|
||||||
|
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
|
||||||
|
|
||||||
|
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
||||||
|
const char = graphemes[gIdx];
|
||||||
|
const wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
|
||||||
|
const wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
|
||||||
|
|
||||||
|
chars.push({
|
||||||
|
char,
|
||||||
|
xA: currentXA,
|
||||||
|
widthA: wA,
|
||||||
|
xB: currentXB,
|
||||||
|
widthB: wB,
|
||||||
|
});
|
||||||
|
currentXA += wA;
|
||||||
|
currentXB += wB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: line.text,
|
||||||
|
width: line.width,
|
||||||
|
chars,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#lastWidth = width;
|
||||||
|
this.#lastLineHeight = lineHeight;
|
||||||
|
this.#lastResult = {
|
||||||
|
lines: resultLines,
|
||||||
|
totalHeight: height,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.#lastResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates character state without any DOM calls
|
||||||
|
*/
|
||||||
|
getCharState(
|
||||||
|
lineIndex: number,
|
||||||
|
charIndex: number,
|
||||||
|
sliderPos: number, // 0-100 percentage
|
||||||
|
containerWidth: number,
|
||||||
|
) {
|
||||||
|
if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) {
|
||||||
|
return { proximity: 0, isPast: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = this.#lastResult.lines[lineIndex];
|
||||||
|
const char = line.chars[charIndex];
|
||||||
|
|
||||||
|
if (!char) return { proximity: 0, isPast: false };
|
||||||
|
|
||||||
|
// Center the comparison on the unified width
|
||||||
|
// In the UI, lines are centered. So we need to calculate the global X.
|
||||||
|
const lineXOffset = (containerWidth - line.width) / 2;
|
||||||
|
const charCenterX = lineXOffset + char.xA + (char.widthA / 2);
|
||||||
|
|
||||||
|
const charGlobalPercent = (charCenterX / containerWidth) * 100;
|
||||||
|
|
||||||
|
const distance = Math.abs(sliderPos - charGlobalPercent);
|
||||||
|
const range = 5;
|
||||||
|
const proximity = Math.max(0, 1 - distance / range);
|
||||||
|
const isPast = sliderPos > charGlobalPercent;
|
||||||
|
|
||||||
|
return { proximity, isPast };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helper to merge two prepared texts into a "worst-case" unified version
|
||||||
|
*/
|
||||||
|
#createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments {
|
||||||
|
const intA = a as any;
|
||||||
|
const intB = b as any;
|
||||||
|
|
||||||
|
const unified = { ...intA };
|
||||||
|
|
||||||
|
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]));
|
||||||
|
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
|
||||||
|
Math.max(w, intB.lineEndFitAdvances[i])
|
||||||
|
);
|
||||||
|
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
|
||||||
|
Math.max(w, intB.lineEndPaintAdvances[i])
|
||||||
|
);
|
||||||
|
|
||||||
|
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
|
||||||
|
const advB = intB.breakableFitAdvances[i];
|
||||||
|
if (!advA && !advB) return null;
|
||||||
|
if (!advA) return advB;
|
||||||
|
if (!advB) return advA;
|
||||||
|
|
||||||
|
return advA.map((w: number, j: number) => Math.max(w, advB[j]));
|
||||||
|
});
|
||||||
|
|
||||||
|
return unified;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user