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