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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user