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:
@@ -1,6 +1,10 @@
|
||||
<!--
|
||||
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">
|
||||
import { cn } from '$shared/lib';
|
||||
@@ -15,9 +19,14 @@ interface Props {
|
||||
* Past state
|
||||
*/
|
||||
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 fontB = $derived(comparisonStore.fontB);
|
||||
@@ -25,7 +34,7 @@ const fontB = $derived(comparisonStore.fontB);
|
||||
let slot = $state<0 | 1>(0);
|
||||
let slotFonts = $state<[string, string]>(['', '']);
|
||||
|
||||
const displayChar = $derived(char === ' ' ? ' ' : char);
|
||||
const displayChar = $derived(char === ' ' ? ' ' : char);
|
||||
const targetFont = $derived(isPast ? fontA?.name ?? '' : fontB?.name ?? '');
|
||||
|
||||
$effect(() => {
|
||||
@@ -39,7 +48,7 @@ $effect(() => {
|
||||
</script>
|
||||
|
||||
{#if fontA && fontB}
|
||||
<span class="char-wrap">
|
||||
<span class="char-wrap" style:font-size="{fontSize}px">
|
||||
{#each [0, 1] as s (s)}
|
||||
<span
|
||||
class={cn(
|
||||
@@ -62,9 +71,10 @@ $effect(() => {
|
||||
|
||||
<style>
|
||||
.char-wrap {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
line-height: 1;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.char-inner {
|
||||
|
||||
@@ -1,46 +1,101 @@
|
||||
<!--
|
||||
Component: Line
|
||||
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.
|
||||
Renders one laid-out line as three regions: a fontA bulk run (past the
|
||||
slider), an N-char crossfade window straddling it, and a fontB bulk run (not
|
||||
yet past). Bulk runs are native shaped text (kerning, ligatures); only the
|
||||
window uses per-char DOM. `split` is a primitive so the render-model
|
||||
`$derived` skips recomputation on ticks that leave it unchanged.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { LineRenderModel } from '$entities/Font';
|
||||
import {
|
||||
type ComparisonLine,
|
||||
computeLineRenderModel,
|
||||
} from '$entities/Font';
|
||||
import { typographySettingsStore } from '$features/AdjustTypography';
|
||||
import { comparisonStore } from '../../model';
|
||||
import Character from '../Character/Character.svelte';
|
||||
|
||||
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 fontA = $derived(comparisonStore.fontA);
|
||||
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>
|
||||
|
||||
<!--
|
||||
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
|
||||
class="relative flex w-full justify-center items-center whitespace-pre"
|
||||
style: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"
|
||||
class="relative block w-full text-center whitespace-pre"
|
||||
style:height="{lineHeightPx}px"
|
||||
style:line-height="{lineHeightPx}px"
|
||||
style:font-size="0"
|
||||
style:letter-spacing="{letterSpacingPx}px"
|
||||
style:font-weight={typography.weight}
|
||||
>
|
||||
<span style={strutStyle} aria-hidden="true"></span>
|
||||
{#if model.leftText}
|
||||
<span style:font-family={fontA?.name}>{model.leftText}</span>
|
||||
<span class={BULK_LEFT_CLASS} style={leftStyle}>{model.leftText}</span>
|
||||
{/if}
|
||||
{#each model.windowChars as wc (wc.key)}
|
||||
<Character char={wc.char} isPast={wc.isPast} />
|
||||
<Character char={wc.char} isPast={wc.isPast} fontSize={fontSizePx} />
|
||||
{/each}
|
||||
{#if model.rightText}
|
||||
<span style:font-family={fontB?.name}>{model.rightText}</span>
|
||||
<span class={BULK_RIGHT_CLASS} style={rightStyle}>{model.rightText}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
computeLineRenderModel,
|
||||
findSplitIndex,
|
||||
} from '$entities/Font';
|
||||
import { TypographyMenu } from '$features/AdjustTypography';
|
||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||
@@ -335,8 +335,8 @@ $effect(() => {
|
||||
"
|
||||
>
|
||||
{#each layoutResult.lines as line, lineIdx (lineIdx)}
|
||||
{@const model = computeLineRenderModel(line, sliderPos, containerWidth, WINDOW_SIZE)}
|
||||
<Line {model} />
|
||||
{@const split = findSplitIndex(line, sliderPos, containerWidth)}
|
||||
<Line {line} {split} windowSize={WINDOW_SIZE} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user