TelegramPWA/src/components/common/AnimatedCounter.tsx
2025-06-04 20:41:58 +02:00

108 lines
3.0 KiB
TypeScript

import type { ElementRef, FC } from '../../lib/teact/teact';
import type React from '../../lib/teact/teact';
import { memo, useEffect, useMemo } from '../../lib/teact/teact';
import { getGlobal } from '../../global';
import { selectCanAnimateInterface } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { throttleWithTickEnd } from '../../util/schedulers';
import useForceUpdate from '../../hooks/useForceUpdate';
import useLang from '../../hooks/useLang';
import usePreviousDeprecated from '../../hooks/usePreviousDeprecated';
import styles from './AnimatedCounter.module.scss';
type OwnProps = {
text: string;
className?: string;
isDisabled?: boolean;
ref?: ElementRef<HTMLSpanElement>;
};
const ANIMATION_TIME = 200;
const MAX_SIMULTANEOUS_ANIMATIONS = 10;
let scheduledAnimationsCounter = 0;
const resetCounterOnTickEnd = throttleWithTickEnd(() => {
scheduledAnimationsCounter = 0;
});
const AnimatedCounter: FC<OwnProps> = ({
text,
className,
isDisabled,
ref,
}) => {
const { isRtl } = useLang();
const prevText = usePreviousDeprecated(text);
const forceUpdate = useForceUpdate();
const shouldAnimate = scheduleAnimation(
!isDisabled && selectCanAnimateInterface(getGlobal()) && prevText !== undefined && prevText !== text,
);
const characters = useMemo(() => {
return shouldAnimate ? renderAnimatedCharacters(text, prevText) : text;
}, [shouldAnimate, prevText, text]);
useEffect(() => {
if (!shouldAnimate) return undefined;
const timeoutId = window.setTimeout(() => {
forceUpdate();
}, ANIMATION_TIME);
return () => {
window.clearTimeout(timeoutId);
};
}, [shouldAnimate, text]);
return (
<span ref={ref} className={buildClassName(className, !isDisabled && styles.root)} dir={isRtl ? 'rtl' : undefined}>
{characters}
</span>
);
};
export default memo(AnimatedCounter);
function scheduleAnimation(condition: boolean) {
if (!condition || scheduledAnimationsCounter >= MAX_SIMULTANEOUS_ANIMATIONS) return false;
if (scheduledAnimationsCounter === 0) {
resetCounterOnTickEnd();
}
scheduledAnimationsCounter++;
return true;
}
function renderAnimatedCharacters(text: string, prevText?: string) {
const elements: React.ReactNode[] = [];
const textLength = text.length;
const prevTextLength = prevText?.length ?? 0;
for (let i = 0; i <= textLength; i++) {
const charIndex = textLength - i;
const prevTextCharIndex = prevTextLength - i;
if (prevText && prevTextCharIndex >= 0 && text[charIndex] !== prevText[prevTextCharIndex]) {
elements.unshift(
<div className={styles.characterContainer}>
<div className={styles.character}>{text[charIndex] ?? ''}</div>
<div className={styles.characterOld}>{prevText[prevTextCharIndex]}</div>
<div className={styles.characterNew}>{text[charIndex] ?? ''}</div>
</div>,
);
} else {
elements.unshift(<span>{text[charIndex] ?? ''}</span>);
}
}
return elements;
}