File size: 3,447 Bytes
f56a29b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import { useState, useEffect, useCallback, useRef } from 'react';

export interface StreamingTextOptions {
  text: string;
  speed?: number; // characters/second, default 30
  onComplete?: () => void;
  enabled?: boolean; // whether to enable streaming, default true
}

export interface StreamingTextResult {
  displayedText: string;
  isStreaming: boolean;
  skip: () => void;
  reset: () => void;
}

/**
 * Streaming Text Hook
 *
 * Implements a character-by-character text display effect
 *
 * @param options - Configuration options
 * @returns Streaming text state and control functions
 */
export function useStreamingText(options: StreamingTextOptions): StreamingTextResult {
  const { text, speed = 30, onComplete, enabled = true } = options;

  const [displayedText, setDisplayedText] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);

  const frameRef = useRef<number | null>(null);
  const startTimeRef = useRef<number | null>(null);
  const lastIndexRef = useRef(0);

  /**
   * Skip streaming animation and display all text immediately
   */
  const skip = useCallback(() => {
    if (frameRef.current) {
      cancelAnimationFrame(frameRef.current);
      frameRef.current = null;
    }
    setDisplayedText(text);
    setIsStreaming(false);
    startTimeRef.current = null;
    lastIndexRef.current = text.length;
    onComplete?.();
  }, [text, onComplete]);

  /**
   * Reset streaming state
   */
  const reset = useCallback(() => {
    if (frameRef.current) {
      cancelAnimationFrame(frameRef.current);
      frameRef.current = null;
    }
    setDisplayedText('');
    setIsStreaming(false);
    startTimeRef.current = null;
    lastIndexRef.current = 0;
  }, []);

  useEffect(() => {
    /* eslint-disable react-hooks/set-state-in-effect -- Animation driver: synchronous state transitions are intentional for streaming text display */
    // If streaming is disabled or text is empty, display all text immediately
    if (!enabled || !text) {
      setDisplayedText((prev) => (prev !== text ? text : prev));
      setIsStreaming((prev) => (prev ? false : prev));
      return;
    }

    // Limit max text length (disable streaming for text over 500 characters)
    if (text.length > 500) {
      setDisplayedText(text);
      setIsStreaming(false);
      onComplete?.();
      return;
    }

    // Start streaming display
    setIsStreaming(true);
    setDisplayedText('');
    /* eslint-enable react-hooks/set-state-in-effect */
    lastIndexRef.current = 0;

    const animate = (timestamp: number) => {
      if (!startTimeRef.current) {
        startTimeRef.current = timestamp;
      }

      const elapsed = timestamp - startTimeRef.current;
      const targetIndex = Math.min(Math.floor((elapsed / 1000) * speed), text.length);

      if (targetIndex > lastIndexRef.current) {
        lastIndexRef.current = targetIndex;
        setDisplayedText(text.slice(0, targetIndex));
      }

      if (targetIndex < text.length) {
        frameRef.current = requestAnimationFrame(animate);
      } else {
        setIsStreaming(false);
        startTimeRef.current = null;
        onComplete?.();
      }
    };

    frameRef.current = requestAnimationFrame(animate);

    return () => {
      if (frameRef.current) {
        cancelAnimationFrame(frameRef.current);
      }
    };
  }, [text, speed, enabled, onComplete]);

  return {
    displayedText,
    isStreaming,
    skip,
    reset,
  };
}