224 lines
6.1 KiB
TypeScript
224 lines
6.1 KiB
TypeScript
import type { TeactNode } from '../../lib/teact/teact';
|
|
import {
|
|
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, useUnmountCleanup,
|
|
} from '../../lib/teact/teact';
|
|
|
|
import type { ApiFormattedText } from '../../api/types';
|
|
|
|
import { requestMutation } from '../../lib/fasterdom/fasterdom';
|
|
import { LOCAL_TGS_URLS } from './helpers/animatedAssets';
|
|
import { REM } from './helpers/mediaDimensions';
|
|
|
|
import useLastCallback from '../../hooks/useLastCallback';
|
|
|
|
import AnimatedIconWithPreview from './AnimatedIconWithPreview';
|
|
|
|
import styles from './TypingWrapper.module.scss';
|
|
|
|
type OwnProps = {
|
|
formattedText: ApiFormattedText;
|
|
shouldAnimateMask?: boolean;
|
|
renderText: (text: ApiFormattedText) => TeactNode;
|
|
};
|
|
|
|
const CHUNK_SIZE = 67;
|
|
const CHUNK_SPREAD_DURATION = 500;
|
|
const HEADWAY_DURATION = 750;
|
|
const PLACEHOLDER_SIZE = 1.25 * REM;
|
|
const SPREAD_CHARS = 20;
|
|
const PROGRESS_CSS_PROPERTY = '--typing-draft-progress';
|
|
const SPREAD_CSS_PROPERTY = '--typing-draft-spread';
|
|
|
|
try {
|
|
window.CSS.registerProperty({
|
|
name: PROGRESS_CSS_PROPERTY,
|
|
syntax: '<percentage>',
|
|
inherits: false,
|
|
initialValue: '0%',
|
|
});
|
|
} catch (_) {
|
|
// Ignore duplicate registrations
|
|
}
|
|
|
|
function getRunningProgress(animation: Animation | undefined, baseProgress: number) {
|
|
const timing = animation?.effect?.getComputedTiming().progress;
|
|
if (typeof timing !== 'number') return baseProgress;
|
|
return baseProgress + (100 - baseProgress) * timing;
|
|
}
|
|
|
|
const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProps) => {
|
|
const ref = useRef<HTMLSpanElement>();
|
|
const animationRef = useRef<Animation>();
|
|
const progressRef = useRef(0);
|
|
const prevRevealedRef = useRef(0);
|
|
|
|
const [revealedLength, setRevealedLength] = useState(0);
|
|
const revealedLengthRef = useRef(0);
|
|
const chunkTimerRef = useRef<number>();
|
|
const prevFullTextRef = useRef('');
|
|
|
|
const fullText = formattedText.text;
|
|
|
|
const stopAnimation = useLastCallback(() => {
|
|
animationRef.current?.cancel();
|
|
animationRef.current = undefined;
|
|
});
|
|
|
|
const scheduleChunks = useLastCallback((from: number, to: number) => {
|
|
window.clearTimeout(chunkTimerRef.current);
|
|
|
|
const delta = to - from;
|
|
if (delta <= 0) return;
|
|
|
|
const numChunks = Math.ceil(delta / CHUNK_SIZE);
|
|
const chunkInterval = numChunks > 1 ? CHUNK_SPREAD_DURATION / (numChunks - 1) : 0;
|
|
|
|
let position = from;
|
|
|
|
const addChunk = () => {
|
|
position = Math.min(position + CHUNK_SIZE, to);
|
|
revealedLengthRef.current = position;
|
|
setRevealedLength(position);
|
|
|
|
if (position < to) {
|
|
chunkTimerRef.current = window.setTimeout(addChunk, chunkInterval);
|
|
} else {
|
|
chunkTimerRef.current = undefined;
|
|
}
|
|
};
|
|
|
|
addChunk();
|
|
});
|
|
|
|
const resetChunking = useLastCallback(() => {
|
|
window.clearTimeout(chunkTimerRef.current);
|
|
chunkTimerRef.current = undefined;
|
|
revealedLengthRef.current = 0;
|
|
prevRevealedRef.current = 0;
|
|
progressRef.current = 0;
|
|
stopAnimation();
|
|
setRevealedLength(0);
|
|
});
|
|
|
|
// --- Chunking: spread incoming text over time ---
|
|
useEffect(() => {
|
|
if (fullText === prevFullTextRef.current) return;
|
|
prevFullTextRef.current = fullText;
|
|
|
|
const fullLen = fullText.length;
|
|
const revealed = revealedLengthRef.current;
|
|
|
|
if (fullLen < revealed) {
|
|
resetChunking();
|
|
scheduleChunks(0, fullLen);
|
|
return;
|
|
}
|
|
|
|
scheduleChunks(revealed, fullLen);
|
|
});
|
|
|
|
// --- Mask animation: smooth reveal of rendered content (layout effect to prevent flash) ---
|
|
useLayoutEffect(() => {
|
|
const element = ref.current;
|
|
if (!element) return;
|
|
|
|
const revealed = revealedLength;
|
|
const prevRevealed = prevRevealedRef.current;
|
|
if (revealed === prevRevealed) return;
|
|
|
|
prevRevealedRef.current = revealed;
|
|
|
|
if (!shouldAnimateMask) {
|
|
stopAnimation();
|
|
progressRef.current = 100;
|
|
element.style.setProperty(PROGRESS_CSS_PROPERTY, '100%');
|
|
return;
|
|
}
|
|
|
|
let progress = animationRef.current
|
|
? getRunningProgress(animationRef.current, progressRef.current)
|
|
: progressRef.current;
|
|
|
|
stopAnimation();
|
|
|
|
if (revealed < prevRevealed) {
|
|
progress = 0;
|
|
} else if (prevRevealed && revealed) {
|
|
progress = Math.min((prevRevealed * progress) / revealed, 100);
|
|
} else if (!prevRevealed) {
|
|
progress = 0;
|
|
}
|
|
|
|
if (!revealed) {
|
|
progress = 100;
|
|
}
|
|
|
|
progressRef.current = progress;
|
|
const remaining = 100 - progress;
|
|
const spread = revealed ? (SPREAD_CHARS / revealed) * 100 : 0;
|
|
|
|
if (!revealed || remaining <= 0) {
|
|
progressRef.current = 100;
|
|
element.style.setProperty(PROGRESS_CSS_PROPERTY, '100%');
|
|
return;
|
|
}
|
|
|
|
element.style.setProperty(SPREAD_CSS_PROPERTY, `${spread}%`);
|
|
element.style.setProperty(PROGRESS_CSS_PROPERTY, `${progress}%`);
|
|
|
|
const animation = element.animate([
|
|
{ [PROGRESS_CSS_PROPERTY]: `${progress}%` },
|
|
{ [PROGRESS_CSS_PROPERTY]: '100%' },
|
|
] as Keyframe[], {
|
|
duration: HEADWAY_DURATION,
|
|
easing: 'linear',
|
|
fill: 'forwards',
|
|
});
|
|
|
|
animationRef.current = animation;
|
|
|
|
animation.onfinish = () => {
|
|
if (animationRef.current !== animation) return;
|
|
|
|
progressRef.current = 100;
|
|
animationRef.current = undefined;
|
|
|
|
requestMutation(() => {
|
|
element.style.setProperty(PROGRESS_CSS_PROPERTY, '100%');
|
|
});
|
|
};
|
|
|
|
animation.oncancel = () => {
|
|
if (animationRef.current !== animation) return;
|
|
animationRef.current = undefined;
|
|
};
|
|
});
|
|
|
|
useUnmountCleanup(() => {
|
|
window.clearTimeout(chunkTimerRef.current);
|
|
stopAnimation();
|
|
});
|
|
|
|
const truncatedText = useMemo(() => ({
|
|
text: fullText.slice(0, revealedLength),
|
|
entities: formattedText.entities,
|
|
}), [fullText, formattedText.entities, revealedLength]);
|
|
|
|
return (
|
|
<span ref={ref} className={styles.root}>
|
|
{renderText(truncatedText)}
|
|
<span key="typing-placeholder" className={styles.placeholder}>
|
|
<AnimatedIconWithPreview
|
|
tgsUrl={LOCAL_TGS_URLS.Typing}
|
|
size={PLACEHOLDER_SIZE}
|
|
play
|
|
noLoop={false}
|
|
shouldUseTextColor
|
|
/>
|
|
</span>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
export default memo(TypingWrapper);
|