Skip to content

Instantly share code, notes, and snippets.

@lucashmorais
Last active November 25, 2025 23:01
Show Gist options
  • Select an option

  • Save lucashmorais/b73434e22d8489e0ed0b6ac6b5e0e9dd to your computer and use it in GitHub Desktop.

Select an option

Save lucashmorais/b73434e22d8489e0ed0b6ac6b5e0e9dd to your computer and use it in GitHub Desktop.
<!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