diff --git a/src/entities/Font/model/const/const.ts b/src/entities/Font/model/const/const.ts index 472a309..971dca4 100644 --- a/src/entities/Font/model/const/const.ts +++ b/src/entities/Font/model/const/const.ts @@ -86,3 +86,9 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel[] = [ export const MULTIPLIER_S = 0.5; export const MULTIPLIER_M = 0.75; 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; diff --git a/src/widgets/ComparisonView/lib/index.ts b/src/widgets/ComparisonView/lib/index.ts index cbfcf32..1d97db6 100644 --- a/src/widgets/ComparisonView/lib/index.ts +++ b/src/widgets/ComparisonView/lib/index.ts @@ -1 +1,2 @@ +export * from './utils/dotTransition'; export * from './utils/getPretextFontString'; diff --git a/src/widgets/ComparisonView/lib/utils/dotTransition.ts b/src/widgets/ComparisonView/lib/utils/dotTransition.ts new file mode 100644 index 0000000..78fb4c3 --- /dev/null +++ b/src/widgets/ComparisonView/lib/utils/dotTransition.ts @@ -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}; + `, + }; + }, + }); +} diff --git a/src/widgets/ComparisonView/ui/FontList/FontList.svelte b/src/widgets/ComparisonView/ui/FontList/FontList.svelte index 747739a..3fbe65a 100644 --- a/src/widgets/ComparisonView/ui/FontList/FontList.svelte +++ b/src/widgets/ComparisonView/ui/FontList/FontList.svelte @@ -8,65 +8,67 @@ import { FontApplicator, FontVirtualList, type UnifiedFont, + VIRTUAL_INDEX_NOT_LOADED, + fontStore, } from '$entities/Font'; import { Button, Label, } from '$shared/ui'; import DotIcon from '@lucide/svelte/icons/dot'; -import { cubicOut } from 'svelte/easing'; -import { crossfade } from 'svelte/transition'; -import { comparisonStore } from '../../model'; +import { + createDotCrossfade, + getDotTransitionParams, +} from '../../lib'; +import { + type Side, + comparisonStore, +} from '../../model'; 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({ - duration: 300, - easing: cubicOut, - fallback(node) { - // Read pendingDirection synchronously — no reactive timing issues - const fromY = pendingDirection * (node.closest('[data-font-list]')?.clientHeight ?? 300); - return { - duration: 300, - easing: cubicOut, - css: t => `transform: translateY(${fromY * (1 - t)}px);`, - }; - }, +// Treat -1 (not loaded) as being at the very bottom of the infinite list +function getVirtualIndex(fontId: string | undefined): number { + if (!fontId) { + return -1; + } + const idx = fontStore.fonts.findIndex(f => f.id === fontId); + if (idx === -1) { + return VIRTUAL_INDEX_NOT_LOADED; + } + 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 (prevIndexA !== null) { - pendingDirection = index > prevIndexA ? -1 : 1; - } - prevIndexA = index; - selectedIndexA = index; comparisonStore.fontA = font; - } else if (side === 'B') { - if (prevIndexB !== null) { - pendingDirection = index > prevIndexB ? -1 : 1; - } - prevIndexB = index; - selectedIndexB = index; + } else { 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); - } -});
@@ -90,7 +92,7 @@ $effect(() => {