/ src / features / tutorial / ManimText.tsx
ManimText.tsx
  1  import React, { useEffect, useState } from 'react';
  2  import { motion } from 'framer-motion';
  3  import * as opentype from 'opentype.js';
  4  
  5  // Vite handles ?url imports for asset URLs
  6  // @ts-expect-error - Vite asset import with ?url suffix
  7  import TexGyreTermesFont from './fonts/texgyretermes-regular.otf?url';
  8  
  9  interface ManimTextProps {
 10    text: string;
 11    strokeDuration?: number;
 12    fillDelay?: number;
 13    fadeStroke?: boolean;
 14    fontSize?: number;
 15    onComplete?: () => void;
 16  }
 17  
 18  interface CharPath {
 19    pathData: string;
 20    length: number;
 21    x: number;
 22  }
 23  
 24  /**
 25   * ManimText - 3Blue1Brown style text animation
 26   *
 27   * Three-phase animation:
 28   * 1. Stroke drawing (cascading per character)
 29   * 2. Fill reveal (after stroke completes)
 30   * 3. Stroke fade (creates crisp final text)
 31   *
 32   * Uses SVG stroke-dasharray/dashoffset technique from Manim
 33   */
 34  export const ManimText: React.FC<ManimTextProps> = ({
 35    text,
 36    strokeDuration = 2,
 37    fillDelay = 0.3,
 38    fadeStroke = true,
 39    fontSize = 48,
 40    onComplete
 41  }) => {
 42    const [charPaths, setCharPaths] = useState<CharPath[]>([]);
 43    const [isLoaded, setIsLoaded] = useState(false);
 44  
 45    useEffect(() => {
 46      // Reset state when text changes
 47      setCharPaths([]);
 48      setIsLoaded(false);
 49  
 50      // Load bundled font and convert text to SVG paths
 51      const loadFont = async () => {
 52        try {
 53          console.log('🎨 Loading bundled TeX Gyre Termes font for Manim animation...');
 54          const font = await opentype.load(TexGyreTermesFont);
 55  
 56          if (!font) {
 57            console.error('Font loading returned null');
 58            const paths = createFallbackPaths(text, fontSize);
 59            setCharPaths(paths);
 60            setIsLoaded(true);
 61            return;
 62          }
 63  
 64          console.log('✓ Font loaded successfully:', font.names.fontFamily.en);
 65  
 66          // Convert each character to SVG path
 67          const paths: CharPath[] = [];
 68          let xOffset = 0;
 69  
 70          for (const char of text) {
 71            const glyph = font.charToGlyph(char);
 72            const path = glyph.getPath(xOffset, fontSize, fontSize);
 73            const pathData = path.toSVG(2); // Get SVG path data
 74  
 75            // Extract just the 'd' attribute from the path
 76            const dMatch = pathData.match(/d="([^"]+)"/);
 77            if (dMatch) {
 78              // Create temporary path to measure length
 79              const tempSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
 80              const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
 81              tempPath.setAttribute('d', dMatch[1]);
 82              tempSvg.appendChild(tempPath);
 83              document.body.appendChild(tempSvg);
 84  
 85              const length = tempPath.getTotalLength();
 86  
 87              document.body.removeChild(tempSvg);
 88  
 89              paths.push({
 90                pathData: dMatch[1],
 91                length,
 92                x: xOffset
 93              });
 94            }
 95  
 96            xOffset += (glyph.advanceWidth ?? fontSize * 0.6) * fontSize / font.unitsPerEm;
 97          }
 98  
 99          setCharPaths(paths);
100          setIsLoaded(true);
101        } catch (error) {
102          console.error('Font loading error:', error);
103          // Use fallback
104          const paths = createFallbackPaths(text, fontSize);
105          setCharPaths(paths);
106          setIsLoaded(true);
107        }
108      };
109  
110      loadFont();
111    }, [text, fontSize]);
112  
113    // Fallback: create simple rectangular paths for each character
114    const createFallbackPaths = (text: string, size: number): CharPath[] => {
115      // Estimate character width (rough approximation)
116      const charWidth = size * 0.6;
117  
118      return text.split('').map((char, i) => ({
119        // Create a simple rectangular path as placeholder
120        pathData: `M ${i * charWidth} 0 L ${i * charWidth} ${size} L ${(i + 1) * charWidth} ${size} L ${(i + 1) * charWidth} 0 Z`,
121        length: size * 2 + charWidth * 2,
122        x: i * charWidth
123      }));
124    };
125  
126    const totalDuration = strokeDuration + fillDelay + (fadeStroke ? 0.5 : 0);
127    const cascadeDelay = 0.1; // Delay between each character starting
128  
129    useEffect(() => {
130      if (isLoaded && onComplete) {
131        const timer = setTimeout(() => {
132          onComplete();
133        }, (totalDuration + text.length * cascadeDelay) * 1000);
134  
135        return () => globalThis.clearTimeout(timer);
136      }
137      return undefined;
138    }, [isLoaded, onComplete, totalDuration, text.length]);
139  
140    if (!isLoaded || charPaths.length === 0) {
141      return null; // Loading state
142    }
143  
144    // Calculate viewBox to fit all characters
145    const viewBoxWidth = charPaths[charPaths.length - 1]?.x + fontSize || fontSize;
146    const viewBoxHeight = fontSize * 1.2;
147  
148    return (
149      <div className="manim-text-container" style={{ width: '100%', height: '100%' }}>
150        <svg
151          viewBox={`0 ${-viewBoxHeight * 0.8} ${viewBoxWidth} ${viewBoxHeight}`}
152          style={{ width: '100%', height: '100%', overflow: 'visible' }}
153        >
154          {charPaths.map((charPath, i) => {
155            const charDelay = i * cascadeDelay;
156  
157            return (
158              <g key={i}>
159                {/* Stroke layer - draws the outline */}
160                <motion.path
161                  d={charPath.pathData}
162                  stroke="white"
163                  strokeWidth={2}
164                  fill="transparent"
165                  initial={{
166                    strokeDasharray: charPath.length,
167                    strokeDashoffset: charPath.length,
168                    opacity: 1
169                  }}
170                  animate={{
171                    strokeDashoffset: 0,
172                    opacity: fadeStroke ? 0 : 1
173                  }}
174                  transition={{
175                    strokeDashoffset: {
176                      duration: strokeDuration,
177                      delay: charDelay,
178                      ease: "easeInOut"
179                    },
180                    opacity: {
181                      duration: 0.5,
182                      delay: charDelay + strokeDuration + fillDelay,
183                      ease: "easeOut"
184                    }
185                  }}
186                />
187  
188                {/* Fill layer - reveals after stroke */}
189                <motion.path
190                  d={charPath.pathData}
191                  fill="white"
192                  initial={{ opacity: 0 }}
193                  animate={{ opacity: 1 }}
194                  transition={{
195                    duration: 0.5,
196                    delay: charDelay + strokeDuration,
197                    ease: "easeIn"
198                  }}
199                />
200              </g>
201            );
202          })}
203        </svg>
204      </div>
205    );
206  };
207  
208  /**
209   * Helper component for positioning ManimText in 3D space
210   */
211  export const ManimText3D: React.FC<ManimTextProps & { position?: [number, number, number] }> = ({
212    position = [0, 0, 0],
213    ..._props
214  }) => {
215    return (
216      <mesh position={position}>
217        <planeGeometry args={[4, 1]} />
218        <meshBasicMaterial transparent opacity={0} />
219        {/* Html component will be added by parent */}
220      </mesh>
221    );
222  };