Last active
November 25, 2025 23:01
-
-
Save lucashmorais/b73434e22d8489e0ed0b6ac6b5e0e9dd to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>LaTeX Studio</title> | |
| <!-- Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- React & ReactDOM --> | |
| <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script> | |
| <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script> | |
| <!-- Babel for JSX compilation --> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| <!-- Preload KaTeX CSS (Optional, but helps with initial load speed) --> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css"> | |
| <style> | |
| body { font-family: 'Inter', sans-serif; } | |
| /* Hide scrollbar for cleaner look in some areas */ | |
| .no-scrollbar::-webkit-scrollbar { display: none; } | |
| .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } | |
| </style> | |
| </head> | |
| <body class="bg-slate-50 text-slate-800"> | |
| <div id="root"></div> | |
| <script type="text/babel"> | |
| // ---------------------------------------------------- | |
| // 1. Icon Components (Replacements for lucide-react) | |
| // ---------------------------------------------------- | |
| const IconBase = ({ children, size = 24, className = "" }) => ( | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| width={size} | |
| height={size} | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| className={className} | |
| > | |
| {children} | |
| </svg> | |
| ); | |
| const Settings = (props) => ( | |
| <IconBase {...props}> | |
| <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.1a2 2 0 0 1-1-1.72v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /> | |
| <circle cx="12" cy="12" r="3" /> | |
| </IconBase> | |
| ); | |
| const ZoomIn = (props) => ( | |
| <IconBase {...props}> | |
| <circle cx="11" cy="11" r="8" /> | |
| <line x1="21" x2="16.65" y1="21" y2="16.65" /> | |
| <line x1="11" x2="11" y1="8" y2="14" /> | |
| <line x1="8" x2="14" y1="11" y2="11" /> | |
| </IconBase> | |
| ); | |
| const ZoomOut = (props) => ( | |
| <IconBase {...props}> | |
| <circle cx="11" cy="11" r="8" /> | |
| <line x1="21" x2="16.65" y1="21" y2="16.65" /> | |
| <line x1="8" x2="14" y1="11" y2="11" /> | |
| </IconBase> | |
| ); | |
| const Type = (props) => ( | |
| <IconBase {...props}> | |
| <polyline points="4 7 4 4 20 4 20 7" /> | |
| <line x1="9" x2="15" y1="20" y2="20" /> | |
| <line x1="12" x2="12" y1="4" y2="20" /> | |
| </IconBase> | |
| ); | |
| const Palette = (props) => ( | |
| <IconBase {...props}> | |
| <circle cx="13.5" cy="6.5" r=".5" fill="currentColor" /> | |
| <circle cx="17.5" cy="10.5" r=".5" fill="currentColor" /> | |
| <circle cx="8.5" cy="7.5" r=".5" fill="currentColor" /> | |
| <circle cx="6.5" cy="12.5" r=".5" fill="currentColor" /> | |
| <path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" /> | |
| </IconBase> | |
| ); | |
| const AlertCircle = (props) => ( | |
| <IconBase {...props}> | |
| <circle cx="12" cy="12" r="10" /> | |
| <line x1="12" x2="12" y1="8" y2="12" /> | |
| <line x1="12" x2="12.01" y1="16" y2="16" /> | |
| </IconBase> | |
| ); | |
| const Check = (props) => ( | |
| <IconBase {...props}> | |
| <polyline points="20 6 9 17 4 12" /> | |
| </IconBase> | |
| ); | |
| const Copy = (props) => ( | |
| <IconBase {...props}> | |
| <rect width="14" height="14" x="8" y="8" rx="2" ry="2" /> | |
| <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /> | |
| </IconBase> | |
| ); | |
| // ---------------------------------------------------- | |
| // 2. Main Application Logic | |
| // ---------------------------------------------------- | |
| const { useState, useEffect, useRef } = React; | |
| function App() { | |
| const [equation, setEquation] = useState('\\int_{-\\infty}^\\infty e^{-x^2} dx = \\sqrt{\\pi}'); | |
| const [bgColor, setBgColor] = useState({ r: 255, g: 255, b: 255 }); | |
| const [textColor, setTextColor] = useState('#000000'); | |
| const [zoom, setZoom] = useState(2.5); // 1 = 1em, 2 = 2em, etc. | |
| const [isKatexLoaded, setIsKatexLoaded] = useState(false); | |
| const [error, setError] = useState(null); | |
| const previewRef = useRef(null); | |
| // Load KaTeX resources dynamically | |
| useEffect(() => { | |
| // 1. Load CSS | |
| if (!document.querySelector('link[href*="katex.min.css"]')) { | |
| const link = document.createElement('link'); | |
| link.rel = 'stylesheet'; | |
| link.href = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css'; | |
| link.integrity = 'sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV'; | |
| link.crossOrigin = 'anonymous'; | |
| document.head.appendChild(link); | |
| } | |
| // 2. Load JS | |
| if (!window.katex) { | |
| const script = document.createElement('script'); | |
| script.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js'; | |
| script.integrity = 'sha384-XjKyOOlGwcjNTAIQHIpgOno0Hl1YQqzUOEleOLALmuqehneUG+vnGctmUb0ZY0l8'; | |
| script.crossOrigin = 'anonymous'; | |
| script.onload = () => setIsKatexLoaded(true); | |
| document.body.appendChild(script); | |
| } else { | |
| setIsKatexLoaded(true); | |
| } | |
| }, []); | |
| // Render logic | |
| useEffect(() => { | |
| if (isKatexLoaded && previewRef.current) { | |
| try { | |
| window.katex.render(equation, previewRef.current, { | |
| throwOnError: false, // Let KaTeX handle simple errors with red text | |
| displayMode: true, | |
| output: 'html', | |
| }); | |
| setError(null); | |
| } catch (err) { | |
| setError(err.message); | |
| previewRef.current.innerHTML = `<span style="color:red; font-family:monospace;">Error rendering equation</span>`; | |
| } | |
| } | |
| }, [equation, isKatexLoaded]); | |
| // Helper to convert RGB obj to hex for the color input | |
| const rgbToHex = (r, g, b) => { | |
| const toHex = (c) => { | |
| const hex = Math.max(0, Math.min(255, Number(c))).toString(16); | |
| return hex.length === 1 ? '0' + hex : hex; | |
| }; | |
| return `#${toHex(r)}${toHex(g)}${toHex(b)}`; | |
| }; | |
| // Helper to convert Hex to RGB obj | |
| const hexToRgb = (hex) => { | |
| const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
| return result ? { | |
| r: parseInt(result[1], 16), | |
| g: parseInt(result[2], 16), | |
| b: parseInt(result[3], 16) | |
| } : { r: 255, g: 255, b: 255 }; | |
| }; | |
| const handleRgbChange = (key, value) => { | |
| setBgColor(prev => ({ ...prev, [key]: parseInt(value) || 0 })); | |
| }; | |
| const copyToClipboard = () => { | |
| if (equation) { | |
| // Create a temporary textarea to copy text | |
| const el = document.createElement('textarea'); | |
| el.value = equation; | |
| document.body.appendChild(el); | |
| el.select(); | |
| document.execCommand('copy'); | |
| document.body.removeChild(el); | |
| } | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-slate-50 flex flex-col font-sans text-slate-800"> | |
| {/* Header */} | |
| <header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between shadow-sm"> | |
| <div className="flex items-center gap-2"> | |
| <div className="bg-indigo-600 text-white p-2 rounded-lg"> | |
| <Type size={20} /> | |
| </div> | |
| <h1 className="text-xl font-bold text-slate-900 tracking-tight">LaTeX Studio</h1> | |
| </div> | |
| <div className="text-sm text-slate-500 hidden sm:block"> | |
| Interactive Rendering Engine | |
| </div> | |
| </header> | |
| <main className="flex-1 max-w-7xl w-full mx-auto p-4 md:p-6 grid grid-cols-1 lg:grid-cols-12 gap-6"> | |
| {/* Left Column: Controls & Input */} | |
| <div className="lg:col-span-4 space-y-6"> | |
| {/* Input Section */} | |
| <div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <label className="text-sm font-semibold text-slate-700 flex items-center gap-2"> | |
| <Settings size={16} /> Equation Input | |
| </label> | |
| <button | |
| onClick={copyToClipboard} | |
| className="text-xs flex items-center gap-1 text-indigo-600 hover:text-indigo-700 font-medium transition-colors" | |
| > | |
| <Copy size={12} /> Copy Source | |
| </button> | |
| </div> | |
| <textarea | |
| value={equation} | |
| onChange={(e) => setEquation(e.target.value)} | |
| className="w-full h-32 p-3 font-mono text-sm bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all outline-none resize-none" | |
| placeholder="Enter LaTeX here..." | |
| spellCheck="false" | |
| /> | |
| <div className="mt-3 flex gap-2 overflow-x-auto pb-2 no-scrollbar"> | |
| {[ | |
| { label: 'Integral', code: '\\int_{a}^{b} x^2 dx' }, | |
| { label: 'Matrix', code: '\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}' }, | |
| { label: 'Fraction', code: '\\frac{x+1}{x-1}' }, | |
| { label: 'Sum', code: '\\sum_{i=1}^{n} i^2' } | |
| ].map((item) => ( | |
| <button | |
| key={item.label} | |
| onClick={() => setEquation(item.code)} | |
| className="px-3 py-1.5 text-xs font-medium bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-full transition-colors whitespace-nowrap" | |
| > | |
| {item.label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Appearance Settings */} | |
| <div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5 space-y-6"> | |
| <h3 className="text-sm font-semibold text-slate-700 flex items-center gap-2"> | |
| <Palette size={16} /> Appearance | |
| </h3> | |
| {/* Background Color Control */} | |
| <div className="space-y-3"> | |
| <label className="text-xs font-medium text-slate-500 uppercase tracking-wider">Background (RGB)</label> | |
| <div className="flex gap-3 items-center"> | |
| {/* Color Picker Visual */} | |
| <div className="relative"> | |
| <input | |
| type="color" | |
| value={rgbToHex(bgColor.r, bgColor.g, bgColor.b)} | |
| onChange={(e) => setBgColor(hexToRgb(e.target.value))} | |
| className="w-10 h-10 rounded cursor-pointer border-0 p-0 overflow-hidden" | |
| /> | |
| <div | |
| className="absolute inset-0 rounded pointer-events-none ring-1 ring-inset ring-slate-200" | |
| style={{ backgroundColor: rgbToHex(bgColor.r, bgColor.g, bgColor.b) }} | |
| ></div> | |
| </div> | |
| {/* RGB Numeric Inputs */} | |
| <div className="grid grid-cols-3 gap-2 flex-1"> | |
| {['r', 'g', 'b'].map((channel) => ( | |
| <div key={channel} className="relative"> | |
| <span className="absolute left-2 top-1/2 -translate-y-1/2 text-[10px] font-bold text-slate-400 uppercase">{channel}</span> | |
| <input | |
| type="number" | |
| min="0" | |
| max="255" | |
| value={bgColor[channel]} | |
| onChange={(e) => handleRgbChange(channel, e.target.value)} | |
| className="w-full pl-6 pr-1 py-1.5 text-sm border border-slate-200 rounded-md focus:ring-2 focus:ring-indigo-500 outline-none" | |
| /> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Text Color Control */} | |
| <div className="space-y-3"> | |
| <label className="text-xs font-medium text-slate-500 uppercase tracking-wider">Text Color</label> | |
| <div className="flex items-center gap-3"> | |
| <input | |
| type="color" | |
| value={textColor} | |
| onChange={(e) => setTextColor(e.target.value)} | |
| className="h-8 w-12 bg-transparent cursor-pointer" | |
| /> | |
| <span className="text-sm font-mono text-slate-600">{textColor.toUpperCase()}</span> | |
| </div> | |
| </div> | |
| {/* Zoom Control */} | |
| <div className="space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <label className="text-xs font-medium text-slate-500 uppercase tracking-wider">Zoom Level</label> | |
| <span className="text-xs font-medium text-slate-700 bg-slate-100 px-2 py-0.5 rounded">{(zoom * 100).toFixed(0)}%</span> | |
| </div> | |
| <div className="flex items-center gap-3 text-slate-400"> | |
| <ZoomOut size={16} /> | |
| <input | |
| type="range" | |
| min="0.5" | |
| max="6" | |
| step="0.1" | |
| value={zoom} | |
| onChange={(e) => setZoom(parseFloat(e.target.value))} | |
| className="flex-1 h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600" | |
| /> | |
| <ZoomIn size={16} /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Right Column: Preview */} | |
| <div className="lg:col-span-8 flex flex-col h-full min-h-[500px]"> | |
| <div className="bg-white rounded-xl shadow-sm border border-slate-200 flex-1 flex flex-col overflow-hidden relative"> | |
| {/* Toolbar */} | |
| <div className="h-12 border-b border-slate-100 flex items-center justify-between px-4 bg-white z-10"> | |
| <span className="text-sm font-medium text-slate-500">Preview Canvas</span> | |
| {!isKatexLoaded && ( | |
| <span className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded-full"> | |
| <div className="animate-spin h-3 w-3 border-2 border-amber-600 border-t-transparent rounded-full" /> | |
| Loading Engine... | |
| </span> | |
| )} | |
| {isKatexLoaded && !error && ( | |
| <span className="flex items-center gap-1.5 text-xs text-emerald-600 bg-emerald-50 px-2 py-1 rounded-full"> | |
| <Check size={12} /> Ready | |
| </span> | |
| )} | |
| </div> | |
| {/* Canvas Area */} | |
| <div | |
| className="flex-1 overflow-auto flex items-center justify-center p-8 transition-colors duration-200" | |
| style={{ | |
| backgroundColor: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`, | |
| }} | |
| > | |
| {equation.trim() ? ( | |
| <div | |
| ref={previewRef} | |
| style={{ | |
| fontSize: `${zoom}em`, | |
| color: textColor | |
| }} | |
| className="transition-all duration-100 origin-center" | |
| /> | |
| ) : ( | |
| <div className="text-center opacity-40 select-none" style={{ color: textColor }}> | |
| <div className="mx-auto mb-2 flex justify-center"> | |
| <Type size={48} /> | |
| </div> | |
| <p className="text-sm font-medium">Enter an equation to start</p> | |
| </div> | |
| )} | |
| </div> | |
| {/* Hint overlay if empty */} | |
| {equation.trim() === '' && ( | |
| <div className="absolute inset-0 pointer-events-none flex items-center justify-center"> | |
| {/* Empty state handled above, keeping structure clean */} | |
| </div> | |
| )} | |
| </div> | |
| <div className="mt-4 flex gap-4 text-xs text-slate-400 justify-center"> | |
| <span>Powered by KaTeX</span> | |
| <span>•</span> | |
| <span>Vector Quality Rendering</span> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| ); | |
| } | |
| const root = ReactDOM.createRoot(document.getElementById('root')); | |
| root.render(<App />); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment