2026-03-02 22:18:05 +03:00
|
|
|
<!--
|
|
|
|
|
Component: Line
|
2026-05-31 13:24:14 +03:00
|
|
|
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.
|
2026-03-02 22:18:05 +03:00
|
|
|
-->
|
|
|
|
|
<script lang="ts">
|
2026-05-31 13:24:14 +03:00
|
|
|
import {
|
|
|
|
|
type ComparisonLine,
|
|
|
|
|
computeLineRenderModel,
|
2026-06-03 16:03:51 +03:00
|
|
|
windowSizeForLine,
|
2026-05-31 13:24:14 +03:00
|
|
|
} from '$entities/Font';
|
2026-06-01 18:44:17 +03:00
|
|
|
import { getTypographySettingsStore } from '$features/AdjustTypography';
|
2026-06-01 17:25:05 +03:00
|
|
|
import { getComparisonStore } from '../../model';
|
2026-05-30 22:29:43 +03:00
|
|
|
import Character from '../Character/Character.svelte';
|
2026-04-11 16:26:41 +03:00
|
|
|
|
2026-03-02 22:18:05 +03:00
|
|
|
interface Props {
|
|
|
|
|
/**
|
2026-05-31 13:24:14 +03:00
|
|
|
* Laid-out line from `DualFontLayout.layout()`. Stable across slider movement.
|
2026-03-02 22:18:05 +03:00
|
|
|
*/
|
2026-05-31 13:24:14 +03:00
|
|
|
line: ComparisonLine;
|
|
|
|
|
/**
|
|
|
|
|
* Count of chars the slider has passed, from `findSplitIndex`.
|
|
|
|
|
*/
|
|
|
|
|
split: number;
|
2026-03-02 22:18:05 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-03 16:03:51 +03:00
|
|
|
let { line, split }: Props = $props();
|
2026-05-31 13:24:14 +03:00
|
|
|
|
2026-06-01 17:25:05 +03:00
|
|
|
const comparisonStore = getComparisonStore();
|
|
|
|
|
|
2026-06-03 16:03:51 +03:00
|
|
|
const windowSize = $derived(windowSizeForLine(line.chars.length));
|
|
|
|
|
|
2026-05-31 13:24:14 +03:00
|
|
|
const model = $derived(computeLineRenderModel(line, split, windowSize));
|
2026-05-30 22:29:43 +03:00
|
|
|
|
2026-06-01 18:44:17 +03:00
|
|
|
const typography = getTypographySettingsStore();
|
2026-05-30 22:29:43 +03:00
|
|
|
const fontA = $derived(comparisonStore.fontA);
|
|
|
|
|
const fontB = $derived(comparisonStore.fontB);
|
2026-05-31 13:24:14 +03:00
|
|
|
|
|
|
|
|
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`,
|
|
|
|
|
);
|
2026-03-02 22:18:05 +03:00
|
|
|
</script>
|
|
|
|
|
|
2026-05-31 13:24:14 +03:00
|
|
|
<!--
|
|
|
|
|
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.
|
|
|
|
|
-->
|
2026-06-01 17:25:05 +03:00
|
|
|
{#if fontA && fontB}
|
|
|
|
|
<div
|
|
|
|
|
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 class={BULK_LEFT_CLASS} style={leftStyle}>{model.leftText}</span>
|
|
|
|
|
{/if}
|
|
|
|
|
{#each model.windowChars as wc (wc.key)}
|
|
|
|
|
<Character char={wc.char} {fontA} {fontB} isPast={wc.isPast} fontSize={fontSizePx} />
|
|
|
|
|
{/each}
|
|
|
|
|
{#if model.rightText}
|
|
|
|
|
<span class={BULK_RIGHT_CLASS} style={rightStyle}>{model.rightText}</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|