fix(comparison): stabilize line rendering, cut per-tick re-renders
Extract findSplitIndex; computeLineRenderModel now takes the split index as a primitive. Line derives its model from `split`, so the $derived short-circuits on value equality and skips recomputation on spring ticks that don't move the split (previously every tick rebuilt the model and re-rendered the line). Lay the three regions out as inline boxes on a shared baseline. fontA and fontB now align on the typographic baseline despite differing metrics, and an always-present overflow:hidden strut pins the line-box baseline so the line no longer jumps when a bulk run mounts/unmounts or the last window char morphs to a font of different ascent.
This commit is contained in:
+55
-12
@@ -4,7 +4,11 @@ import {
|
|||||||
it,
|
it,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
|
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
|
||||||
import { computeLineRenderModel } from './computeLineRenderModel';
|
import {
|
||||||
|
type LineRenderModel,
|
||||||
|
computeLineRenderModel,
|
||||||
|
findSplitIndex,
|
||||||
|
} from './computeLineRenderModel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a ComparisonLine fixture with given per-char widths. xA/xB are
|
* Build a ComparisonLine fixture with given per-char widths. xA/xB are
|
||||||
@@ -34,10 +38,24 @@ function makeLine(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test helper: compute split + render model in one step, matching the
|
||||||
|
* SliderArea call site shape.
|
||||||
|
*/
|
||||||
|
function compute(
|
||||||
|
line: ComparisonLine,
|
||||||
|
sliderPos: number,
|
||||||
|
containerWidth: number,
|
||||||
|
windowSize: number,
|
||||||
|
): LineRenderModel {
|
||||||
|
const split = findSplitIndex(line, sliderPos, containerWidth);
|
||||||
|
return computeLineRenderModel(line, split, windowSize);
|
||||||
|
}
|
||||||
|
|
||||||
describe('computeLineRenderModel', () => {
|
describe('computeLineRenderModel', () => {
|
||||||
it('returns empty model for an empty line', () => {
|
it('returns empty model for an empty line', () => {
|
||||||
const line = makeLine([]);
|
const line = makeLine([]);
|
||||||
const model = computeLineRenderModel(line, 50, 500, 5);
|
const model = compute(line, 50, 500, 5);
|
||||||
expect(model.leftText).toBe('');
|
expect(model.leftText).toBe('');
|
||||||
expect(model.windowChars).toEqual([]);
|
expect(model.windowChars).toEqual([]);
|
||||||
expect(model.rightText).toBe('');
|
expect(model.rightText).toBe('');
|
||||||
@@ -49,7 +67,7 @@ describe('computeLineRenderModel', () => {
|
|||||||
{ char: 'B', widthA: 10, widthB: 10 },
|
{ char: 'B', widthA: 10, widthB: 10 },
|
||||||
{ char: 'C', widthA: 10, widthB: 10 },
|
{ char: 'C', widthA: 10, widthB: 10 },
|
||||||
]);
|
]);
|
||||||
const model = computeLineRenderModel(line, 0, 500, 0);
|
const model = compute(line, 0, 500, 0);
|
||||||
expect(model.leftText).toBe('');
|
expect(model.leftText).toBe('');
|
||||||
expect(model.windowChars).toEqual([]);
|
expect(model.windowChars).toEqual([]);
|
||||||
expect(model.rightText).toBe('ABC');
|
expect(model.rightText).toBe('ABC');
|
||||||
@@ -61,7 +79,7 @@ describe('computeLineRenderModel', () => {
|
|||||||
{ char: 'B', widthA: 10, widthB: 10 },
|
{ char: 'B', widthA: 10, widthB: 10 },
|
||||||
{ char: 'C', widthA: 10, widthB: 10 },
|
{ char: 'C', widthA: 10, widthB: 10 },
|
||||||
]);
|
]);
|
||||||
const model = computeLineRenderModel(line, 100, 500, 0);
|
const model = compute(line, 100, 500, 0);
|
||||||
expect(model.leftText).toBe('ABC');
|
expect(model.leftText).toBe('ABC');
|
||||||
expect(model.windowChars).toEqual([]);
|
expect(model.windowChars).toEqual([]);
|
||||||
expect(model.rightText).toBe('');
|
expect(model.rightText).toBe('');
|
||||||
@@ -80,7 +98,7 @@ describe('computeLineRenderModel', () => {
|
|||||||
{ char: 'C', widthA: 10, widthB: 10 },
|
{ char: 'C', widthA: 10, widthB: 10 },
|
||||||
]);
|
]);
|
||||||
// Slider just past B's threshold (50%) but not C's (53.33%).
|
// Slider just past B's threshold (50%) but not C's (53.33%).
|
||||||
const model = computeLineRenderModel(line, 51, 300, 0);
|
const model = compute(line, 51, 300, 0);
|
||||||
expect(model.leftText).toBe('AB');
|
expect(model.leftText).toBe('AB');
|
||||||
expect(model.rightText).toBe('C');
|
expect(model.rightText).toBe('C');
|
||||||
});
|
});
|
||||||
@@ -95,7 +113,7 @@ describe('computeLineRenderModel', () => {
|
|||||||
]);
|
]);
|
||||||
// Slider past A and B (~thresholds 43.33%, 46.67%); not past C (50%).
|
// Slider past A and B (~thresholds 43.33%, 46.67%); not past C (50%).
|
||||||
// split = 2 → halfWindow = 1 → windowStart = 1, windowEnd = 4
|
// split = 2 → halfWindow = 1 → windowStart = 1, windowEnd = 4
|
||||||
const model = computeLineRenderModel(line, 48, 300, 3);
|
const model = compute(line, 48, 300, 3);
|
||||||
expect(model.leftText).toBe('A');
|
expect(model.leftText).toBe('A');
|
||||||
expect(model.windowChars.map(w => w.char)).toEqual(['B', 'C', 'D']);
|
expect(model.windowChars.map(w => w.char)).toEqual(['B', 'C', 'D']);
|
||||||
expect(model.rightText).toBe('E');
|
expect(model.rightText).toBe('E');
|
||||||
@@ -109,7 +127,7 @@ describe('computeLineRenderModel', () => {
|
|||||||
{ char: 'D', widthA: 10, widthB: 10 },
|
{ char: 'D', widthA: 10, widthB: 10 },
|
||||||
{ char: 'E', widthA: 10, widthB: 10 },
|
{ char: 'E', widthA: 10, widthB: 10 },
|
||||||
]);
|
]);
|
||||||
const model = computeLineRenderModel(line, 0, 300, 3);
|
const model = compute(line, 0, 300, 3);
|
||||||
expect(model.leftText).toBe('');
|
expect(model.leftText).toBe('');
|
||||||
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B', 'C']);
|
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B', 'C']);
|
||||||
expect(model.rightText).toBe('DE');
|
expect(model.rightText).toBe('DE');
|
||||||
@@ -123,7 +141,7 @@ describe('computeLineRenderModel', () => {
|
|||||||
{ char: 'D', widthA: 10, widthB: 10 },
|
{ char: 'D', widthA: 10, widthB: 10 },
|
||||||
{ char: 'E', widthA: 10, widthB: 10 },
|
{ char: 'E', widthA: 10, widthB: 10 },
|
||||||
]);
|
]);
|
||||||
const model = computeLineRenderModel(line, 100, 300, 3);
|
const model = compute(line, 100, 300, 3);
|
||||||
expect(model.leftText).toBe('AB');
|
expect(model.leftText).toBe('AB');
|
||||||
expect(model.windowChars.map(w => w.char)).toEqual(['C', 'D', 'E']);
|
expect(model.windowChars.map(w => w.char)).toEqual(['C', 'D', 'E']);
|
||||||
expect(model.rightText).toBe('');
|
expect(model.rightText).toBe('');
|
||||||
@@ -134,7 +152,7 @@ describe('computeLineRenderModel', () => {
|
|||||||
{ char: 'A', widthA: 10, widthB: 10 },
|
{ char: 'A', widthA: 10, widthB: 10 },
|
||||||
{ char: 'B', widthA: 10, widthB: 10 },
|
{ char: 'B', widthA: 10, widthB: 10 },
|
||||||
]);
|
]);
|
||||||
const model = computeLineRenderModel(line, 50, 300, 5);
|
const model = compute(line, 50, 300, 5);
|
||||||
expect(model.leftText).toBe('');
|
expect(model.leftText).toBe('');
|
||||||
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B']);
|
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B']);
|
||||||
expect(model.rightText).toBe('');
|
expect(model.rightText).toBe('');
|
||||||
@@ -148,8 +166,8 @@ describe('computeLineRenderModel', () => {
|
|||||||
{ char: 'D', widthA: 10, widthB: 10 },
|
{ char: 'D', widthA: 10, widthB: 10 },
|
||||||
{ char: 'E', widthA: 10, widthB: 10 },
|
{ char: 'E', widthA: 10, widthB: 10 },
|
||||||
]);
|
]);
|
||||||
const a = computeLineRenderModel(line, 40, 300, 3);
|
const a = compute(line, 40, 300, 3);
|
||||||
const b = computeLineRenderModel(line, 60, 300, 3);
|
const b = compute(line, 60, 300, 3);
|
||||||
// Chars that appear in both windows must carry identical keys.
|
// Chars that appear in both windows must carry identical keys.
|
||||||
for (const charA of a.windowChars) {
|
for (const charA of a.windowChars) {
|
||||||
const charB = b.windowChars.find(w => w.char === charA.char);
|
const charB = b.windowChars.find(w => w.char === charA.char);
|
||||||
@@ -168,10 +186,35 @@ describe('computeLineRenderModel', () => {
|
|||||||
{ char: 'E', widthA: 10, widthB: 10 },
|
{ char: 'E', widthA: 10, widthB: 10 },
|
||||||
]);
|
]);
|
||||||
// split = 2 → A,B past; C,D,E not
|
// split = 2 → A,B past; C,D,E not
|
||||||
const model = computeLineRenderModel(line, 48, 300, 5);
|
const model = compute(line, 48, 300, 5);
|
||||||
const expected = new Map([['A', true], ['B', true], ['C', false], ['D', false], ['E', false]]);
|
const expected = new Map([['A', true], ['B', true], ['C', false], ['D', false], ['E', false]]);
|
||||||
for (const wc of model.windowChars) {
|
for (const wc of model.windowChars) {
|
||||||
expect(wc.isPast).toBe(expected.get(wc.char));
|
expect(wc.isPast).toBe(expected.get(wc.char));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('findSplitIndex', () => {
|
||||||
|
it('returns 0 for empty line', () => {
|
||||||
|
const line = makeLine([]);
|
||||||
|
expect(findSplitIndex(line, 50, 500)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when slider is before all char thresholds', () => {
|
||||||
|
const line = makeLine([
|
||||||
|
{ char: 'A', widthA: 10, widthB: 10 },
|
||||||
|
{ char: 'B', widthA: 10, widthB: 10 },
|
||||||
|
{ char: 'C', widthA: 10, widthB: 10 },
|
||||||
|
]);
|
||||||
|
expect(findSplitIndex(line, 0, 300)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns chars.length when slider is past all char thresholds', () => {
|
||||||
|
const line = makeLine([
|
||||||
|
{ char: 'A', widthA: 10, widthB: 10 },
|
||||||
|
{ char: 'B', widthA: 10, widthB: 10 },
|
||||||
|
{ char: 'C', widthA: 10, widthB: 10 },
|
||||||
|
]);
|
||||||
|
expect(findSplitIndex(line, 100, 300)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+52
-49
@@ -1,7 +1,4 @@
|
|||||||
import type {
|
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
|
||||||
ComparisonChar,
|
|
||||||
ComparisonLine,
|
|
||||||
} from '../DualFontLayout/DualFontLayout';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-line render slice consumed by Line.svelte. The window is centered on the
|
* Per-line render slice consumed by Line.svelte. The window is centered on the
|
||||||
@@ -35,64 +32,30 @@ export interface LineRenderModel {
|
|||||||
rightText: string;
|
rightText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Slices a laid-out line into three regions around the slider's split index:
|
|
||||||
* a fontA bulk run, an N-char crossfade window, and a fontB bulk run.
|
|
||||||
*
|
|
||||||
* Pure and allocation-bounded: two strings plus a `windowSize`-length array per call.
|
|
||||||
* Safe to invoke per slider frame; `line.chars` is treated as read-only.
|
|
||||||
*
|
|
||||||
* @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model.
|
|
||||||
* @param sliderPos Slider position in percent of `containerWidth`, range 0..100.
|
|
||||||
* @param containerWidth Container width in pixels, used to translate `sliderPos` to a threshold x.
|
|
||||||
* @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`.
|
|
||||||
* At line edges the window is shifted (not shrunk) to keep its size.
|
|
||||||
*/
|
|
||||||
export function computeLineRenderModel(
|
|
||||||
line: ComparisonLine,
|
|
||||||
sliderPos: number,
|
|
||||||
containerWidth: number,
|
|
||||||
windowSize: number,
|
|
||||||
): LineRenderModel {
|
|
||||||
const chars = line.chars;
|
|
||||||
const n = chars.length;
|
|
||||||
if (n === 0) {
|
|
||||||
return { leftText: '', windowChars: [], rightText: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const split = findSplitIndex(chars, sliderPos, containerWidth);
|
|
||||||
|
|
||||||
const halfWindow = Math.floor(Math.max(0, windowSize) / 2);
|
|
||||||
let windowStart = clamp(split - halfWindow, 0, n);
|
|
||||||
let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n);
|
|
||||||
windowStart = Math.max(0, windowEnd - Math.max(0, windowSize));
|
|
||||||
|
|
||||||
const leftText = chars.slice(0, windowStart).map(c => c.char).join('');
|
|
||||||
const rightText = chars.slice(windowEnd).map(c => c.char).join('');
|
|
||||||
const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({
|
|
||||||
key: `${windowStart + idx}-${c.char}`,
|
|
||||||
char: c.char,
|
|
||||||
isPast: (windowStart + idx) < split,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { leftText, windowChars, rightText };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the count of chars whose flip threshold the slider has crossed.
|
* Returns the count of chars whose flip threshold the slider has crossed.
|
||||||
*
|
*
|
||||||
|
* Exposed as a separate step so consumers can pass the resulting primitive
|
||||||
|
* `split` across component boundaries: when split is unchanged tick-to-tick,
|
||||||
|
* downstream `$derived` reads of `computeLineRenderModel(line, split, ...)`
|
||||||
|
* short-circuit on value equality and skip re-rendering.
|
||||||
|
*
|
||||||
* For each candidate split `i`, the line's hypothetical width at that moment is
|
* For each candidate split `i`, the line's hypothetical width at that moment is
|
||||||
* `prefA[i] + widthA[i] + sufB[i+1]` (past chars in fontA, char `i` flipping, future
|
* `prefA[i] + widthA[i] + sufB[i+1]` (past chars in fontA, char `i` flipping, future
|
||||||
* chars in fontB). The threshold is the x of char `i`'s center in the centered line.
|
* chars in fontB). The threshold is the x of char `i`'s center in the centered line.
|
||||||
* Thresholds are monotonically non-decreasing in `i`, so the scan short-circuits on
|
* Thresholds are monotonically non-decreasing in `i`, so the scan short-circuits on
|
||||||
* the first miss.
|
* the first miss.
|
||||||
*/
|
*/
|
||||||
function findSplitIndex(
|
export function findSplitIndex(
|
||||||
chars: ComparisonChar[],
|
line: ComparisonLine,
|
||||||
sliderPos: number,
|
sliderPos: number,
|
||||||
containerWidth: number,
|
containerWidth: number,
|
||||||
): number {
|
): number {
|
||||||
|
const chars = line.chars;
|
||||||
const n = chars.length;
|
const n = chars.length;
|
||||||
|
if (n === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const sliderX = (sliderPos / 100) * containerWidth;
|
const sliderX = (sliderPos / 100) * containerWidth;
|
||||||
|
|
||||||
const prefA = new Float64Array(n + 1);
|
const prefA = new Float64Array(n + 1);
|
||||||
@@ -116,6 +79,46 @@ function findSplitIndex(
|
|||||||
return split;
|
return split;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slices a laid-out line into three regions around a precomputed split index:
|
||||||
|
* a fontA bulk run, an N-char crossfade window, and a fontB bulk run.
|
||||||
|
*
|
||||||
|
* Pure and allocation-bounded: two strings plus a `windowSize`-length array per call.
|
||||||
|
* Takes `split` as a primitive so callers can feed it into a `$derived` and
|
||||||
|
* skip re-evaluation on ticks where the split index is unchanged.
|
||||||
|
*
|
||||||
|
* @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model.
|
||||||
|
* @param split Count of chars the slider has passed, in `[0, line.chars.length]`.
|
||||||
|
* @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`.
|
||||||
|
* At line edges the window is shifted (not shrunk) to keep its size.
|
||||||
|
*/
|
||||||
|
export function computeLineRenderModel(
|
||||||
|
line: ComparisonLine,
|
||||||
|
split: number,
|
||||||
|
windowSize: number,
|
||||||
|
): LineRenderModel {
|
||||||
|
const chars = line.chars;
|
||||||
|
const n = chars.length;
|
||||||
|
if (n === 0) {
|
||||||
|
return { leftText: '', windowChars: [], rightText: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const halfWindow = Math.floor(Math.max(0, windowSize) / 2);
|
||||||
|
let windowStart = clamp(split - halfWindow, 0, n);
|
||||||
|
let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n);
|
||||||
|
windowStart = Math.max(0, windowEnd - Math.max(0, windowSize));
|
||||||
|
|
||||||
|
const leftText = chars.slice(0, windowStart).map(c => c.char).join('');
|
||||||
|
const rightText = chars.slice(windowEnd).map(c => c.char).join('');
|
||||||
|
const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({
|
||||||
|
key: `${windowStart + idx}-${c.char}`,
|
||||||
|
char: c.char,
|
||||||
|
isPast: (windowStart + idx) < split,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { leftText, windowChars, rightText };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clamps `value` into the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
|
* Clamps `value` into the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export {
|
|||||||
} from './DualFontLayout/DualFontLayout';
|
} from './DualFontLayout/DualFontLayout';
|
||||||
export {
|
export {
|
||||||
computeLineRenderModel,
|
computeLineRenderModel,
|
||||||
|
findSplitIndex,
|
||||||
type LineRenderModel,
|
type LineRenderModel,
|
||||||
} from './computeLineRenderModel/computeLineRenderModel';
|
} from './computeLineRenderModel/computeLineRenderModel';
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: Character
|
Component: Character
|
||||||
Renders a single character with morphing animation
|
Renders a single character with morphing animation.
|
||||||
|
|
||||||
|
Sits inline on the parent line's baseline (`vertical-align: baseline`) so it
|
||||||
|
aligns with the bulk text runs in Line.svelte. Sets its own font size since
|
||||||
|
the Line container zeroes font-size to collapse inter-element whitespace.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
@@ -15,9 +19,14 @@ interface Props {
|
|||||||
* Past state
|
* Past state
|
||||||
*/
|
*/
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
|
/**
|
||||||
|
* Font size in px. Set explicitly because the Line container uses
|
||||||
|
* `font-size: 0` to collapse inter-element whitespace.
|
||||||
|
*/
|
||||||
|
fontSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { char, isPast }: Props = $props();
|
let { char, isPast, fontSize }: Props = $props();
|
||||||
|
|
||||||
const fontA = $derived(comparisonStore.fontA);
|
const fontA = $derived(comparisonStore.fontA);
|
||||||
const fontB = $derived(comparisonStore.fontB);
|
const fontB = $derived(comparisonStore.fontB);
|
||||||
@@ -25,7 +34,7 @@ const fontB = $derived(comparisonStore.fontB);
|
|||||||
let slot = $state<0 | 1>(0);
|
let slot = $state<0 | 1>(0);
|
||||||
let slotFonts = $state<[string, string]>(['', '']);
|
let slotFonts = $state<[string, string]>(['', '']);
|
||||||
|
|
||||||
const displayChar = $derived(char === ' ' ? ' ' : char);
|
const displayChar = $derived(char === ' ' ? ' ' : char);
|
||||||
const targetFont = $derived(isPast ? fontA?.name ?? '' : fontB?.name ?? '');
|
const targetFont = $derived(isPast ? fontA?.name ?? '' : fontB?.name ?? '');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -39,7 +48,7 @@ $effect(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if fontA && fontB}
|
{#if fontA && fontB}
|
||||||
<span class="char-wrap">
|
<span class="char-wrap" style:font-size="{fontSize}px">
|
||||||
{#each [0, 1] as s (s)}
|
{#each [0, 1] as s (s)}
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -62,9 +71,10 @@ $effect(() => {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.char-wrap {
|
.char-wrap {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
vertical-align: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.char-inner {
|
.char-inner {
|
||||||
|
|||||||
@@ -1,46 +1,101 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: Line
|
Component: Line
|
||||||
Renders one laid-out line of comparison text as three regions:
|
Renders one laid-out line as three regions: a fontA bulk run (past the
|
||||||
a fontA bulk run (already past the slider), an N-char window of crossfade
|
slider), an N-char crossfade window straddling it, and a fontB bulk run (not
|
||||||
slots straddling the slider, and a fontB bulk run (not yet past).
|
yet past). Bulk runs are native shaped text (kerning, ligatures); only the
|
||||||
Bulk text is rendered as native shaped runs so the browser applies
|
window uses per-char DOM. `split` is a primitive so the render-model
|
||||||
kerning and ligatures; per-char DOM is reserved for the window only.
|
`$derived` skips recomputation on ticks that leave it unchanged.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { LineRenderModel } from '$entities/Font';
|
import {
|
||||||
|
type ComparisonLine,
|
||||||
|
computeLineRenderModel,
|
||||||
|
} from '$entities/Font';
|
||||||
import { typographySettingsStore } from '$features/AdjustTypography';
|
import { typographySettingsStore } from '$features/AdjustTypography';
|
||||||
import { comparisonStore } from '../../model';
|
import { comparisonStore } from '../../model';
|
||||||
import Character from '../Character/Character.svelte';
|
import Character from '../Character/Character.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Per-line render slice from computeLineRenderModel.
|
* Laid-out line from `DualFontLayout.layout()`. Stable across slider movement.
|
||||||
*/
|
*/
|
||||||
model: LineRenderModel;
|
line: ComparisonLine;
|
||||||
|
/**
|
||||||
|
* Count of chars the slider has passed, from `findSplitIndex`.
|
||||||
|
*/
|
||||||
|
split: number;
|
||||||
|
/**
|
||||||
|
* Number of chars in the crossfade window around the split.
|
||||||
|
*/
|
||||||
|
windowSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { model }: Props = $props();
|
let { line, split, windowSize }: Props = $props();
|
||||||
|
|
||||||
|
const model = $derived(computeLineRenderModel(line, split, windowSize));
|
||||||
|
|
||||||
const typography = $derived(typographySettingsStore);
|
const typography = $derived(typographySettingsStore);
|
||||||
const fontA = $derived(comparisonStore.fontA);
|
const fontA = $derived(comparisonStore.fontA);
|
||||||
const fontB = $derived(comparisonStore.fontB);
|
const fontB = $derived(comparisonStore.fontB);
|
||||||
|
|
||||||
|
const fontSizePx = $derived(typography.renderedSize);
|
||||||
|
const lineHeightPx = $derived(typography.height * typography.renderedSize);
|
||||||
|
const letterSpacingPx = $derived(typography.spacing * typography.renderedSize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class and style are single short bindings so the formatter keeps
|
||||||
|
* `<span ...>{text}</span>` on one line. A wrapped text expression would leak
|
||||||
|
* its indentation into the span content under `white-space: pre`.
|
||||||
|
*/
|
||||||
|
const BULK_LEFT_CLASS =
|
||||||
|
'inline-block align-baseline leading-none text-swiss-black/75 dark:text-brand/75 transition-colors duration-300';
|
||||||
|
const BULK_RIGHT_CLASS =
|
||||||
|
'inline-block align-baseline leading-none text-neutral-950 dark:text-white transition-colors duration-300';
|
||||||
|
|
||||||
|
const leftStyle = $derived(`font-family:${fontA?.name ?? ''};font-size:${fontSizePx}px`);
|
||||||
|
const rightStyle = $derived(`font-family:${fontB?.name ?? ''};font-size:${fontSizePx}px`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the whole line from jumping up or down as the slider moves. The browser
|
||||||
|
* pins a line box's baseline to its tallest inline box, so without a fixed
|
||||||
|
* reference the baseline (and every glyph) shifts the moment a bulk run appears
|
||||||
|
* or disappears, or the last window char morphs to a font with a taller ascent.
|
||||||
|
* This invisible strut is always the tallest box — `overflow: hidden` puts its
|
||||||
|
* baseline at its bottom edge — so it owns the line baseline and holds it still.
|
||||||
|
* Its height also sets the text's vertical position (the container is block, so
|
||||||
|
* nothing else centers it).
|
||||||
|
*
|
||||||
|
* Height factors are empirical: the first term centers the text, the `* 1.1`
|
||||||
|
* floor keeps the strut above the fonts' ascent at tight line-heights.
|
||||||
|
*/
|
||||||
|
const strutHeightPx = $derived(Math.max(lineHeightPx / 2 + fontSizePx * 0.34, fontSizePx * 1.1));
|
||||||
|
const strutStyle = $derived(
|
||||||
|
`display:inline-block;width:0;overflow:hidden;vertical-align:baseline;height:${strutHeightPx}px`,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Children align on the baseline (`align-baseline`) so fontA/fontB share it
|
||||||
|
despite differing metrics. `font-size: 0` drops inter-element whitespace that
|
||||||
|
would show as gaps under `white-space: pre`; children restore their size.
|
||||||
|
Letter-spacing is px because em would resolve against that zero.
|
||||||
|
-->
|
||||||
<div
|
<div
|
||||||
class="relative flex w-full justify-center items-center whitespace-pre"
|
class="relative block w-full text-center whitespace-pre"
|
||||||
style:height="{typography.height * typography.renderedSize}px"
|
style:height="{lineHeightPx}px"
|
||||||
style:line-height="{typography.height * typography.renderedSize}px"
|
style:line-height="{lineHeightPx}px"
|
||||||
style:font-size="{typography.renderedSize}px"
|
style:font-size="0"
|
||||||
style:letter-spacing="{typography.spacing}em"
|
style:letter-spacing="{letterSpacingPx}px"
|
||||||
style:font-weight={typography.weight}
|
style:font-weight={typography.weight}
|
||||||
>
|
>
|
||||||
|
<span style={strutStyle} aria-hidden="true"></span>
|
||||||
{#if model.leftText}
|
{#if model.leftText}
|
||||||
<span style:font-family={fontA?.name}>{model.leftText}</span>
|
<span class={BULK_LEFT_CLASS} style={leftStyle}>{model.leftText}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#each model.windowChars as wc (wc.key)}
|
{#each model.windowChars as wc (wc.key)}
|
||||||
<Character char={wc.char} isPast={wc.isPast} />
|
<Character char={wc.char} isPast={wc.isPast} fontSize={fontSizePx} />
|
||||||
{/each}
|
{/each}
|
||||||
{#if model.rightText}
|
{#if model.rightText}
|
||||||
<span style:font-family={fontB?.name}>{model.rightText}</span>
|
<span class={BULK_RIGHT_CLASS} style={rightStyle}>{model.rightText}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
MULTIPLIER_L,
|
MULTIPLIER_L,
|
||||||
MULTIPLIER_M,
|
MULTIPLIER_M,
|
||||||
MULTIPLIER_S,
|
MULTIPLIER_S,
|
||||||
computeLineRenderModel,
|
findSplitIndex,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { TypographyMenu } from '$features/AdjustTypography';
|
import { TypographyMenu } from '$features/AdjustTypography';
|
||||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||||
@@ -335,8 +335,8 @@ $effect(() => {
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
{#each layoutResult.lines as line, lineIdx (lineIdx)}
|
{#each layoutResult.lines as line, lineIdx (lineIdx)}
|
||||||
{@const model = computeLineRenderModel(line, sliderPos, containerWidth, WINDOW_SIZE)}
|
{@const split = findSplitIndex(line, sliderPos, containerWidth)}
|
||||||
<Line {model} />
|
<Line {line} {split} windowSize={WINDOW_SIZE} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user