use-streaming-text.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. import { useState, useEffect, useCallback, useRef } from 'react';
  2. export interface StreamingTextOptions {
  3. text: string;
  4. speed?: number; // characters/second, default 30
  5. onComplete?: () => void;
  6. enabled?: boolean; // whether to enable streaming, default true
  7. }
  8. export interface StreamingTextResult {
  9. displayedText: string;
  10. isStreaming: boolean;
  11. skip: () => void;
  12. reset: () => void;
  13. }
  14. /**
  15. * Streaming Text Hook
  16. *
  17. * Implements a character-by-character text display effect
  18. *
  19. * @param options - Configuration options
  20. * @returns Streaming text state and control functions
  21. */
  22. export function useStreamingText(options: StreamingTextOptions): StreamingTextResult {
  23. const { text, speed = 30, onComplete, enabled = true } = options;
  24. const [displayedText, setDisplayedText] = useState('');
  25. const [isStreaming, setIsStreaming] = useState(false);
  26. const frameRef = useRef<number | null>(null);
  27. const startTimeRef = useRef<number | null>(null);
  28. const lastIndexRef = useRef(0);
  29. /**
  30. * Skip streaming animation and display all text immediately
  31. */
  32. const skip = useCallback(() => {
  33. if (frameRef.current) {
  34. cancelAnimationFrame(frameRef.current);
  35. frameRef.current = null;
  36. }
  37. setDisplayedText(text);
  38. setIsStreaming(false);
  39. startTimeRef.current = null;
  40. lastIndexRef.current = text.length;
  41. onComplete?.();
  42. }, [text, onComplete]);
  43. /**
  44. * Reset streaming state
  45. */
  46. const reset = useCallback(() => {
  47. if (frameRef.current) {
  48. cancelAnimationFrame(frameRef.current);
  49. frameRef.current = null;
  50. }
  51. setDisplayedText('');
  52. setIsStreaming(false);
  53. startTimeRef.current = null;
  54. lastIndexRef.current = 0;
  55. }, []);
  56. useEffect(() => {
  57. /* eslint-disable react-hooks/set-state-in-effect -- Animation driver: synchronous state transitions are intentional for streaming text display */
  58. // If streaming is disabled or text is empty, display all text immediately
  59. if (!enabled || !text) {
  60. setDisplayedText((prev) => (prev !== text ? text : prev));
  61. setIsStreaming((prev) => (prev ? false : prev));
  62. return;
  63. }
  64. // Limit max text length (disable streaming for text over 500 characters)
  65. if (text.length > 500) {
  66. setDisplayedText(text);
  67. setIsStreaming(false);
  68. onComplete?.();
  69. return;
  70. }
  71. // Start streaming display
  72. setIsStreaming(true);
  73. setDisplayedText('');
  74. /* eslint-enable react-hooks/set-state-in-effect */
  75. lastIndexRef.current = 0;
  76. const animate = (timestamp: number) => {
  77. if (!startTimeRef.current) {
  78. startTimeRef.current = timestamp;
  79. }
  80. const elapsed = timestamp - startTimeRef.current;
  81. const targetIndex = Math.min(Math.floor((elapsed / 1000) * speed), text.length);
  82. if (targetIndex > lastIndexRef.current) {
  83. lastIndexRef.current = targetIndex;
  84. setDisplayedText(text.slice(0, targetIndex));
  85. }
  86. if (targetIndex < text.length) {
  87. frameRef.current = requestAnimationFrame(animate);
  88. } else {
  89. setIsStreaming(false);
  90. startTimeRef.current = null;
  91. onComplete?.();
  92. }
  93. };
  94. frameRef.current = requestAnimationFrame(animate);
  95. return () => {
  96. if (frameRef.current) {
  97. cancelAnimationFrame(frameRef.current);
  98. }
  99. };
  100. }, [text, speed, enabled, onComplete]);
  101. return {
  102. displayedText,
  103. isStreaming,
  104. skip,
  105. reset,
  106. };
  107. }