fix: iterate pre-computed chars array in Line.svelte to fix unicode grapheme splitting bug

This commit is contained in:
Ilia Mashkov
2026-04-11 16:26:41 +03:00
parent 5977e0a0dc
commit 99f662e2d5
2 changed files with 53 additions and 35 deletions

View File

@@ -6,15 +6,21 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { comparisonStore } from '../../model'; import { comparisonStore } from '../../model';
interface LineChar {
char: string;
xA: number;
widthA: number;
xB: number;
widthB: number;
}
interface Props { interface Props {
/** /**
* Line text * Pre-computed grapheme array from CharacterComparisonEngine.
* Using the engine's chars array (rather than splitting line.text) ensures
* correct grapheme-cluster boundaries for emoji and multi-codepoint characters.
*/ */
text: string; chars: LineChar[];
/**
* DOM element reference
*/
element?: HTMLElement;
/** /**
* Character render snippet * Character render snippet
*/ */
@@ -22,18 +28,15 @@ interface Props {
} }
const typography = $derived(comparisonStore.typography); const typography = $derived(comparisonStore.typography);
let { text, element = $bindable<HTMLElement>(), character }: Props = $props(); let { chars, character }: Props = $props();
const characters = $derived(text.split(''));
</script> </script>
<div <div
bind:this={element}
class="relative flex w-full justify-center items-center whitespace-nowrap" class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height="{typography.height}em" style:height="{typography.height}em"
style:line-height="{typography.height}em" style:line-height="{typography.height}em"
> >
{#each characters as char, index} {#each chars as c, index}
{@render character?.({ char, index })} {@render character?.({ char: c.char, index })}
{/each} {/each}
</div> </div>

View File

@@ -9,11 +9,12 @@
--> -->
<script lang="ts"> <script lang="ts">
import { import {
type CharacterComparison,
type ResponsiveManager, type ResponsiveManager,
createCharacterComparison,
debounce, debounce,
} from '$shared/lib'; } from '$shared/lib';
import {
CharacterComparisonEngine,
} from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Loader } from '$shared/ui'; import { Loader } from '$shared/ui';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
@@ -44,22 +45,16 @@ const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady
const typography = $derived(comparisonStore.typography); const typography = $derived(comparisonStore.typography);
let container = $state<HTMLElement>(); let container = $state<HTMLElement>();
let measureCanvas = $state<HTMLCanvasElement>();
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
const isMobile = $derived(responsive?.isMobile ?? false); const isMobile = $derived(responsive?.isMobile ?? false);
let isDragging = $state(false); let isDragging = $state(false);
const charComparison: CharacterComparison = createCharacterComparison( // New high-performance layout engine
() => comparisonStore.text, const comparisonEngine = new CharacterComparisonEngine();
() => fontA,
() => fontB,
() => typography.weight,
() => typography.renderedSize,
);
let lineElements = $state<(HTMLElement | undefined)[]>([]); let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ lines: [], totalHeight: 0 });
const sliderSpring = new Spring(50, { const sliderSpring = new Spring(50, {
stiffness: 0.2, stiffness: 0.2,
@@ -123,18 +118,41 @@ $effect(() => {
const _weight = typography.weight; const _weight = typography.weight;
const _size = typography.renderedSize; const _size = typography.renderedSize;
const _height = typography.height; const _height = typography.height;
if (container && measureCanvas && fontA && fontB) {
requestAnimationFrame(() => { if (container && fontA && fontB) {
charComparison.breakIntoLines(container, measureCanvas); // PRETEXT API strings: "weight sizepx family"
}); const fontAStr = `${_weight} ${_size}px "${fontA.name}"`;
const fontBStr = `${_weight} ${_size}px "${fontB.name}"`;
// Use offsetWidth to avoid transform scaling issues
const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
const availableWidth = width - padding;
const lineHeight = _size * 1.2; // Approximate
layoutResult = comparisonEngine.layout(
_text,
fontAStr,
fontBStr,
availableWidth,
lineHeight,
);
} }
}); });
$effect(() => { $effect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const handleResize = () => { const handleResize = () => {
if (container && measureCanvas) { if (container && fontA && fontB) {
charComparison.breakIntoLines(container, measureCanvas); const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
layoutResult = comparisonEngine.layout(
comparisonStore.text,
`${typography.weight} ${typography.renderedSize}px "${fontA.name}"`,
`${typography.weight} ${typography.renderedSize}px "${fontB.name}"`,
width - padding,
typography.renderedSize * 1.2,
);
} }
}; };
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
@@ -156,9 +174,6 @@ const scaleClass = $derived(
); );
</script> </script>
<!-- Hidden measurement canvas -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<!-- <!--
Outer flex container — fills parent. Outer flex container — fills parent.
The paper div inside scales down when the sidebar opens on desktop. The paper div inside scales down when the sidebar opens on desktop.
@@ -218,10 +233,10 @@ const scaleClass = $derived(
my-auto my-auto
" "
> >
{#each charComparison.lines as line, lineIndex} {#each layoutResult.lines as line, lineIndex}
<Line bind:element={lineElements[lineIndex]} text={line.text}> <Line chars={line.chars}>
{#snippet character({ char, index })} {#snippet character({ char, index })}
{@const { proximity, isPast } = charComparison.getCharState(index, sliderPos, lineElements[lineIndex], container)} {@const { proximity, isPast } = comparisonEngine.getCharState(lineIndex, index, sliderPos, container?.offsetWidth ?? 0)}
<Character {char} {proximity} {isPast} /> <Character {char} {proximity} {isPast} />
{/snippet} {/snippet}
</Line> </Line>