refactor(comparison): switch to 3-section render model via DualFontLayout

Rewrite Line.svelte to render leftText / windowChars / rightText regions
from a LineRenderModel. Bulk regions render as native shaped text runs so
the browser applies kerning and ligatures; per-char DOM is reserved for
the N-char crossfade window straddling the slider.

Slim Character.svelte: drop the unused proximity prop and the redundant
font-size/font-weight/letter-spacing styles now inherited from the line
container.

Switch SliderArea.svelte to instantiate DualFontLayout and derive each
line's render model via computeLineRenderModel(line, sliderPos,
containerWidth, WINDOW_SIZE).
This commit is contained in:
Ilia Mashkov
2026-05-30 22:29:43 +03:00
parent 3e568685b3
commit 6153769317
3 changed files with 46 additions and 57 deletions
@@ -3,7 +3,6 @@
Renders a single character with morphing animation Renders a single character with morphing animation
--> -->
<script lang="ts"> <script lang="ts">
import { typographySettingsStore } from '$features/AdjustTypography';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
import { comparisonStore } from '../../model'; import { comparisonStore } from '../../model';
@@ -12,26 +11,21 @@ interface Props {
* Character * Character
*/ */
char: string; char: string;
/**
* Proximity value
*/
proximity: number;
/** /**
* Past state * Past state
*/ */
isPast: boolean; isPast: boolean;
} }
let { char, proximity, isPast }: Props = $props(); let { char, isPast }: Props = $props();
const fontA = $derived(comparisonStore.fontA); const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB); const fontB = $derived(comparisonStore.fontB);
const typography = $derived(typographySettingsStore);
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 === ' ' ? '\u00A0' : char); const displayChar = $derived(char === ' ' ? ' ' : char);
const targetFont = $derived(isPast ? fontA?.name ?? '' : fontB?.name ?? ''); const targetFont = $derived(isPast ? fontA?.name ?? '' : fontB?.name ?? '');
$effect(() => { $effect(() => {
@@ -45,12 +39,7 @@ $effect(() => {
</script> </script>
{#if fontA && fontB} {#if fontA && fontB}
<span <span class="char-wrap">
class="char-wrap"
style:font-size="{typography.renderedSize}px"
style:margin-right="{typography.spacing}em"
style:will-change={proximity > 0 ? 'transform' : 'auto'}
>
{#each [0, 1] as s (s)} {#each [0, 1] as s (s)}
<span <span
class={cn( class={cn(
@@ -61,7 +50,6 @@ $effect(() => {
: 'text-neutral-950 dark:text-white', : 'text-neutral-950 dark:text-white',
)} )}
style:font-family={slotFonts[s]} style:font-family={slotFonts[s]}
style:font-weight={typography.weight}
style:opacity={slot === s ? '1' : '0'} style:opacity={slot === s ? '1' : '0'}
style:position={slot === s ? 'relative' : 'absolute'} style:position={slot === s ? 'relative' : 'absolute'}
aria-hidden={slot !== s ? true : undefined} aria-hidden={slot !== s ? true : undefined}
@@ -89,7 +77,6 @@ $effect(() => {
font-optical-sizing: auto; font-optical-sizing: auto;
transition: transition:
opacity 0.1s ease-out, opacity 0.1s ease-out,
color 0.2s ease-out, color 0.2s ease-out;
transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
</style> </style>
+27 -23
View File
@@ -1,42 +1,46 @@
<!-- <!--
Component: Line Component: Line
Renders a line of text in the SliderArea Renders one laid-out line of comparison text as three regions:
a fontA bulk run (already past the slider), an N-char window of crossfade
slots straddling the slider, and a fontB bulk run (not yet past).
Bulk text is rendered as native shaped runs so the browser applies
kerning and ligatures; per-char DOM is reserved for the window only.
--> -->
<script lang="ts"> <script lang="ts">
import type { LineRenderModel } from '$entities/Font';
import { typographySettingsStore } from '$features/AdjustTypography'; import { typographySettingsStore } from '$features/AdjustTypography';
import type { Snippet } from 'svelte'; import { comparisonStore } from '../../model';
import Character from '../Character/Character.svelte';
interface LineChar {
char: string;
xA: number;
widthA: number;
xB: number;
widthB: number;
}
interface Props { interface Props {
/** /**
* Pre-computed grapheme array from CharacterComparisonEngine. * Per-line render slice from computeLineRenderModel.
* Using the engine's chars array (rather than splitting line.text) ensures
* correct grapheme-cluster boundaries for emoji and multi-codepoint characters.
*/ */
chars: LineChar[]; model: LineRenderModel;
/**
* Character render snippet
*/
character: Snippet<[{ char: string; index: number }]>;
} }
const typography = $derived(typographySettingsStore);
let { chars, character }: Props = $props(); let { model }: Props = $props();
const typography = $derived(typographySettingsStore);
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
</script> </script>
<div <div
class="relative flex w-full justify-center items-center whitespace-nowrap" class="relative flex w-full justify-center items-center whitespace-pre"
style:height="{typography.height * typography.renderedSize}px" style:height="{typography.height * typography.renderedSize}px"
style:line-height="{typography.height * typography.renderedSize}px" style:line-height="{typography.height * typography.renderedSize}px"
style:font-size="{typography.renderedSize}px"
style:letter-spacing="{typography.spacing}em"
style:font-weight={typography.weight}
> >
{#each chars as c, index} {#if model.leftText}
{@render character?.({ char: c.char, index })} <span style:font-family={fontA?.name}>{model.leftText}</span>
{/if}
{#each model.windowChars as wc (wc.key)}
<Character char={wc.char} isPast={wc.isPast} />
{/each} {/each}
{#if model.rightText}
<span style:font-family={fontB?.name}>{model.rightText}</span>
{/if}
</div> </div>
@@ -9,10 +9,12 @@
--> -->
<script lang="ts"> <script lang="ts">
import { import {
CharacterComparisonEngine, type ComparisonResult,
DualFontLayout,
MULTIPLIER_L, MULTIPLIER_L,
MULTIPLIER_M, MULTIPLIER_M,
MULTIPLIER_S, MULTIPLIER_S,
computeLineRenderModel,
} 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';
@@ -30,7 +32,6 @@ import {
getPretextFontString, getPretextFontString,
} from '../../lib'; } from '../../lib';
import { comparisonStore } from '../../model'; import { comparisonStore } from '../../model';
import Character from '../Character/Character.svelte';
import Line from '../Line/Line.svelte'; import Line from '../Line/Line.svelte';
import Thumb from '../Thumb/Thumb.svelte'; import Thumb from '../Thumb/Thumb.svelte';
@@ -95,10 +96,15 @@ let isDragging = $state(false);
let isTypographyMenuOpen = $state(false); let isTypographyMenuOpen = $state(false);
let containerWidth = $state(0); let containerWidth = $state(0);
// New high-performance layout engine const layout = new DualFontLayout();
const comparisonEngine = new CharacterComparisonEngine();
let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ lines: [], totalHeight: 0 }); let layoutResult = $state<ComparisonResult>({ lines: [], totalHeight: 0 });
/**
* N-window size for the per-char crossfade zone around the slider split.
* Tuned so chars complete their 100ms opacity crossfade before exiting the window.
*/
const WINDOW_SIZE = 5;
// Track container width changes (window resize, sidebar toggle, etc.) // Track container width changes (window resize, sidebar toggle, etc.)
$effect(() => { $effect(() => {
@@ -249,7 +255,7 @@ $effect(() => {
if (cancelled) { if (cancelled) {
return; return;
} }
layoutResult = comparisonEngine.layout( layoutResult = layout.layout(
_text, _text,
fontAStr, fontAStr,
fontBStr, fontBStr,
@@ -328,17 +334,9 @@ $effect(() => {
my-auto my-auto
" "
> >
{#each layoutResult.lines as line} {#each layoutResult.lines as line, lineIdx (lineIdx)}
{@const lineStates = comparisonEngine.getLineCharStates(line, sliderPos, containerWidth)} {@const model = computeLineRenderModel(line, sliderPos, containerWidth, WINDOW_SIZE)}
<Line chars={line.chars}> <Line {model} />
{#snippet character({ char, index })}
<Character
{char}
proximity={lineStates[index]?.proximity ?? 0}
isPast={lineStates[index]?.isPast ?? false}
/>
{/snippet}
</Line>
{/each} {/each}
</div> </div>