fix: iterate pre-computed chars array in Line.svelte to fix unicode grapheme splitting bug
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user