fix(FontList): address the bug with selected font transition animations
This commit is contained in:
@@ -86,3 +86,9 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
|||||||
export const MULTIPLIER_S = 0.5;
|
export const MULTIPLIER_S = 0.5;
|
||||||
export const MULTIPLIER_M = 0.75;
|
export const MULTIPLIER_M = 0.75;
|
||||||
export const MULTIPLIER_L = 1;
|
export const MULTIPLIER_L = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index value for items not yet loaded in a virtualized list.
|
||||||
|
* Treated as being at the very bottom of the infinite scroll.
|
||||||
|
*/
|
||||||
|
export const VIRTUAL_INDEX_NOT_LOADED = Infinity;
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './utils/dotTransition';
|
||||||
export * from './utils/getPretextFontString';
|
export * from './utils/getPretextFontString';
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { VIRTUAL_INDEX_NOT_LOADED } from '$entities/Font';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
import {
|
||||||
|
type CrossfadeParams,
|
||||||
|
type TransitionConfig,
|
||||||
|
crossfade,
|
||||||
|
} from 'svelte/transition';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom parameters for dot transitions in virtualized lists.
|
||||||
|
*/
|
||||||
|
export interface DotTransitionParams extends CrossfadeParams {
|
||||||
|
/**
|
||||||
|
* Unique key for crossfade pairing
|
||||||
|
*/
|
||||||
|
key: any;
|
||||||
|
/**
|
||||||
|
* Current index of the item in the list
|
||||||
|
*/
|
||||||
|
index: number;
|
||||||
|
/**
|
||||||
|
* Target index to move towards (e.g. counterpart side index)
|
||||||
|
*/
|
||||||
|
otherIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe helper to create dot transition parameters.
|
||||||
|
*/
|
||||||
|
export function getDotTransitionParams(
|
||||||
|
key: 'active-dot' | 'inactive-dot',
|
||||||
|
index: number,
|
||||||
|
otherIndex: number,
|
||||||
|
): DotTransitionParams {
|
||||||
|
return { key, index, otherIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for DotTransitionParams.
|
||||||
|
*/
|
||||||
|
function isDotTransitionParams(p: CrossfadeParams): p is DotTransitionParams {
|
||||||
|
return (
|
||||||
|
p !== null
|
||||||
|
&& typeof p === 'object'
|
||||||
|
&& 'index' in p
|
||||||
|
&& 'otherIndex' in p
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a crossfade transition pair optimized for virtualized font lists.
|
||||||
|
*
|
||||||
|
* It uses the 'index' and 'otherIndex' params to calculate the direction
|
||||||
|
* of the slide animation when a matching pair cannot be found in the DOM
|
||||||
|
* (e.g. because it was virtualized out).
|
||||||
|
*/
|
||||||
|
export function createDotCrossfade() {
|
||||||
|
return crossfade({
|
||||||
|
duration: 300,
|
||||||
|
easing: cubicOut,
|
||||||
|
fallback(node: Element, params: CrossfadeParams, _intro: boolean): TransitionConfig {
|
||||||
|
if (!isDotTransitionParams(params)) {
|
||||||
|
return {
|
||||||
|
duration: 300,
|
||||||
|
easing: cubicOut,
|
||||||
|
css: t => `opacity: ${t};`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { index, otherIndex } = params;
|
||||||
|
|
||||||
|
// If the other target is unknown, just fade in place
|
||||||
|
if (otherIndex === undefined || otherIndex === -1) {
|
||||||
|
return {
|
||||||
|
duration: 300,
|
||||||
|
easing: cubicOut,
|
||||||
|
css: t => `opacity: ${t};`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = otherIndex - index;
|
||||||
|
const sign = diff > 0 ? 1 : (diff < 0 ? -1 : 0);
|
||||||
|
|
||||||
|
// Use container height for a full-height slide
|
||||||
|
const listEl = node.closest('[data-font-list]');
|
||||||
|
const h = listEl?.clientHeight ?? 300;
|
||||||
|
const fromY = sign * h;
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration: 300,
|
||||||
|
easing: cubicOut,
|
||||||
|
css: (t, u) => `
|
||||||
|
transform: translateY(${fromY * u}px);
|
||||||
|
opacity: ${t};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,65 +8,67 @@ import {
|
|||||||
FontApplicator,
|
FontApplicator,
|
||||||
FontVirtualList,
|
FontVirtualList,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
|
VIRTUAL_INDEX_NOT_LOADED,
|
||||||
|
fontStore,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Label,
|
Label,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import DotIcon from '@lucide/svelte/icons/dot';
|
import DotIcon from '@lucide/svelte/icons/dot';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import {
|
||||||
import { crossfade } from 'svelte/transition';
|
createDotCrossfade,
|
||||||
import { comparisonStore } from '../../model';
|
getDotTransitionParams,
|
||||||
|
} from '../../lib';
|
||||||
|
import {
|
||||||
|
type Side,
|
||||||
|
comparisonStore,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
const side = $derived(comparisonStore.side);
|
const side = $derived(comparisonStore.side);
|
||||||
let prevIndexA: number | null = null;
|
|
||||||
let prevIndexB: number | null = null;
|
|
||||||
let selectedIndexA: number | null = null;
|
|
||||||
let selectedIndexB: number | null = null;
|
|
||||||
let pendingDirection: 1 | -1 = 1;
|
|
||||||
|
|
||||||
const [send, receive] = crossfade({
|
// Treat -1 (not loaded) as being at the very bottom of the infinite list
|
||||||
duration: 300,
|
function getVirtualIndex(fontId: string | undefined): number {
|
||||||
easing: cubicOut,
|
if (!fontId) {
|
||||||
fallback(node) {
|
return -1;
|
||||||
// Read pendingDirection synchronously — no reactive timing issues
|
}
|
||||||
const fromY = pendingDirection * (node.closest('[data-font-list]')?.clientHeight ?? 300);
|
const idx = fontStore.fonts.findIndex(f => f.id === fontId);
|
||||||
return {
|
if (idx === -1) {
|
||||||
duration: 300,
|
return VIRTUAL_INDEX_NOT_LOADED;
|
||||||
easing: cubicOut,
|
}
|
||||||
css: t => `transform: translateY(${fromY * (1 - t)}px);`,
|
return idx;
|
||||||
};
|
}
|
||||||
},
|
|
||||||
|
// Reactive indices of the currently selected fonts in the full list
|
||||||
|
const indexA = $derived(getVirtualIndex(comparisonStore.fontA?.id));
|
||||||
|
const indexB = $derived(getVirtualIndex(comparisonStore.fontB?.id));
|
||||||
|
|
||||||
|
// Track previous state for directional fallback transitions.
|
||||||
|
// We use plain variables here. In Svelte 5, updates to these in an $effect
|
||||||
|
// happen AFTER the render/DOM update, so transitions starting as a result
|
||||||
|
// of that update will see the "old" values of these variables.
|
||||||
|
let prevIndexA = indexA;
|
||||||
|
let prevIndexB = indexB;
|
||||||
|
let prevSide: Side = side;
|
||||||
|
|
||||||
|
const [send, receive] = createDotCrossfade();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// This effect runs after every change to indexA, indexB, or side.
|
||||||
|
// It captures the "current" values which will serve as "previous" values
|
||||||
|
// for the NEXT transition.
|
||||||
|
prevIndexA = indexA;
|
||||||
|
prevIndexB = indexB;
|
||||||
|
prevSide = side;
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSelect(font: UnifiedFont, index: number) {
|
function handleSelect(font: UnifiedFont) {
|
||||||
if (side === 'A') {
|
if (side === 'A') {
|
||||||
if (prevIndexA !== null) {
|
|
||||||
pendingDirection = index > prevIndexA ? -1 : 1;
|
|
||||||
}
|
|
||||||
prevIndexA = index;
|
|
||||||
selectedIndexA = index;
|
|
||||||
comparisonStore.fontA = font;
|
comparisonStore.fontA = font;
|
||||||
} else if (side === 'B') {
|
} else {
|
||||||
if (prevIndexB !== null) {
|
|
||||||
pendingDirection = index > prevIndexB ? -1 : 1;
|
|
||||||
}
|
|
||||||
prevIndexB = index;
|
|
||||||
selectedIndexB = index;
|
|
||||||
comparisonStore.fontB = font;
|
comparisonStore.fontB = font;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When side switches, compute direction from relative positions of A vs B
|
|
||||||
$effect(() => {
|
|
||||||
const _ = side; // track side
|
|
||||||
if (selectedIndexA !== null && selectedIndexB !== null) {
|
|
||||||
// Switching TO B means dot moves toward B's position relative to A
|
|
||||||
pendingDirection = side === 'B'
|
|
||||||
? (selectedIndexB > selectedIndexA ? 1 : -1)
|
|
||||||
: (selectedIndexA > selectedIndexB ? 1 : -1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 h-full">
|
<div class="flex-1 min-h-0 h-full">
|
||||||
@@ -90,7 +92,7 @@ $effect(() => {
|
|||||||
<Button
|
<Button
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
{active}
|
{active}
|
||||||
onclick={() => handleSelect(font, index)}
|
onclick={() => handleSelect(font)}
|
||||||
class="w-full px-3 md:px-4 py-2.5 md:py-3 flex !justify-between text-left text-sm"
|
class="w-full px-3 md:px-4 py-2.5 md:py-3 flex !justify-between text-left text-sm"
|
||||||
iconPosition="right"
|
iconPosition="right"
|
||||||
>
|
>
|
||||||
@@ -99,15 +101,32 @@ $effect(() => {
|
|||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
{#if active}
|
{#if active}
|
||||||
<div
|
<div
|
||||||
in:receive={{ key: 'active-dot' }}
|
in:receive={getDotTransitionParams(
|
||||||
out:send={{ key: 'active-dot' }}
|
'active-dot',
|
||||||
|
index,
|
||||||
|
prevSide === 'A' ? prevIndexA : prevIndexB,
|
||||||
|
)}
|
||||||
|
out:send={getDotTransitionParams(
|
||||||
|
'active-dot',
|
||||||
|
index,
|
||||||
|
side === 'A' ? indexA : indexB,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<DotIcon class="size-8 stroke-brand" />
|
<DotIcon class="size-8 stroke-brand" />
|
||||||
</div>
|
</div>
|
||||||
{:else if isSelectedA || isSelectedB}
|
{:else if isSelectedA || isSelectedB}
|
||||||
|
{@const isA = isSelectedA}
|
||||||
<div
|
<div
|
||||||
in:receive={{ key: 'inactive-dot' }}
|
in:receive={getDotTransitionParams(
|
||||||
out:send={{ key: 'inactive-dot' }}
|
'inactive-dot',
|
||||||
|
index,
|
||||||
|
isA ? prevIndexB : prevIndexA,
|
||||||
|
)}
|
||||||
|
out:send={getDotTransitionParams(
|
||||||
|
'inactive-dot',
|
||||||
|
index,
|
||||||
|
isA ? indexB : indexA,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<DotIcon class="size-8 stroke-neutral-300 dark:stroke-neutral-600" />
|
<DotIcon class="size-8 stroke-neutral-300 dark:stroke-neutral-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user