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 };