latex-math-renderer / script.js
namelessai's picture
Create a Latex math renderer for Latex. It should have a command board with common functions such as fractions, square roots, etc, and an automatic syntax fixer for common mistakes. Use Katex for the rendering.
03662fa verified
// Theme handling
const themeToggle = document.getElementById('themeToggle');
const themeIcon = document.getElementById('themeIcon');
function applyStoredTheme() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const useDark = stored ? stored === 'dark' : prefersDark;
document.documentElement.classList.toggle('dark', useDark);
themeIcon.textContent = useDark ? 'β˜€οΈ' : 'πŸŒ™';
}
applyStoredTheme();
themeToggle.addEventListener('click', () => {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
themeIcon.textContent = isDark ? 'β˜€οΈ' : 'πŸŒ™';
});
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
const renderBtn = document.getElementById('renderBtn');
const copyTexBtn = document.getElementById('copyTexBtn');
const clearBtn = document.getElementById('clearBtn');
const renderError = document.getElementById('renderError');
const autofixStatus = document.getElementById('autofixStatus');
function renderLatex(value) {
renderError.classList.add('hidden');
renderError.textContent = '';
preview.innerHTML = '';
try {
// Reset content before rendering to ensure size recalculations
katex.render(value || '', preview, {
throwOnError: true,
displayMode: true,
trust: false,
strict: 'warn',
output: 'htmlAndMathml',
macros: {
'\\f': '#1f(#2)',
},
});
} catch (err) {
renderError.classList.remove('hidden');
renderError.textContent = 'KaTeX error: ' + (err?.message || String(err));
// Render with throwOnError=false to still show a best-effort preview
try {
katex.render(value || '', preview, {
throwOnError: false,
displayMode: true,
trust: false,
strict: 'warn',
output: 'htmlAndMathml',
});
} catch (e2) {
// ignore
}
}
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Simple tokenizer to avoid replacing inside macro or environment names
function findLikelyCommandRanges(text) {
// Return ranges [start,end) of sequences likely to be command names
// (alphabetic sequences after a backslash)
const ranges = [];
const re = /\\([A-Za-z]+)/g;
let m;
while ((m = re.exec(text)) !== null) {
ranges.push([m.index, m.index + m[0].length]);
}
return ranges;
}
function isIndexInRanges(index, ranges) {
for (const [s, e] of ranges) {
if (index >= s && index < e) return true;
}
return false;
}
// Auto syntax fixer
function autoFixLatex(input) {
if (!input) return input;
let text = input;
// Normalize whitespace and dashes
text = text
.replace(/\r\n?/g, '\n')
.replace(/[ \t]+/g, ' ')
.replace(/–|β€”/g, '-')
.replace(/βˆ’/g, '-');
// Convert common ASCII relations to LaTeX
const replacements = [
{ from: '>=', to: '\\geq' },
{ from: '<=', to: '\\leq' },
{ from: '!=', to: '\\neq' },
{ from: '~=', to: '\\approx' },
{ from: '+=+', to: '\\pm' }, // handle user typed "+=+"
{ from: '+/-', to: '\\pm' },
];
for (const r of replacements) {
const re = new RegExp(escapeRegExp(r.from), 'g');
text = text.replace(re, r.to);
}
// Convert common words to functions
const funcs = ['sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'log', 'ln', 'exp', 'max', 'min', 'argmax', 'argmin'];
const ranges = findLikelyCommandRanges(text);
for (const fn of funcs) {
const re = new RegExp(`\\b${escapeRegExp(fn)}\\b`, 'gi');
text = text.replace(re, (m) => {
const idx = m.index ?? 0;
// If already preceded by backslash, leave it
if (idx > 0 && text[idx - 1] === '\\') return m;
// If inside an already detected command, leave it
if (isIndexInRanges(idx, ranges)) return m;
return `\\${m.toLowerCase()}`;
});
}
// sqrt shortcuts: "sqrt(...)" or user typed "^/" to suggest root
text = text.replace(/\bsqrt\s*\(\s*([^)]+?)\s*\)/gi, (_, inside) => `\\sqrt{${inside}}`);
// If user typed "n√" like n√x, convert to \sqrt[n]{x} when braces after caret were intended
text = text.replace(/(\d+)\s*\^\s*\/\s*([A-Za-z0-9\\]+)/g, (_, n, expr) => `\\sqrt[${n}]{${expr}}`);
// Replace slash "a/b" with fraction if likely math context (alphanumeric on both sides)
text = text.replace(/([A-Za-z0-9}\)])\s*\/\s*([A-Za-z0-9{($]|\^|_|\\\\)/g, (m, a, b) => {
// Avoid http:// and similar
if (/https?:\/\//.test(m)) return m;
return `\\frac{${a}}{${b}}`;
});
// Ensure some common operators are LaTeX
text = text.replace(/\[/g, '\\left[').replace(/\]/g, '\\right]');
text = text.replace(/\(/g, '\\left(').replace(/\)/g, '\\right)');
text = text.replace(/\|/g, '\\left|\\,\\right|'); // moderate auto-grouping
// Normalize percentages and degrees
text = text.replace(/([A-Za-z0-9\)])\s*%\s*/g, '$1\\%');
// Fix double backslashes and missing backslash in common commands
text = text.replace(/\\+/g, (m) => (m.length % 2 === 0 ? '\\' : m)); // keep single backslash
// Replace plain asterisks with \cdot in likely math contexts
text = text.replace(/([A-Za-z0-9])\s*\*\s*([A-Za-z0-9])/g, '$1\\cdot$2');
return text;
}
// Insert snippet helpers
function insertSnippet(value) {
const start = editor.selectionStart;
const end = editor.selectionEnd;
const before = editor.value.slice(0, start);
const after = editor.value.slice(end);
// If snippet contains β– , select the first placeholder region (including braces) so user can type over it
let newValue = before + value + after;
let cursorPos = newValue.length;
const placeholderIdx = value.indexOf('β– ');
if (placeholderIdx !== -1) {
// Replace only the first β–  with empty string; we'll select a region around it to ease typing
const cleaned = value.replace('β– ', '');
const insertPos = before.length + placeholderIdx;
// Try to find the nearest pair of braces after the placeholder to select content inside
let selectStart = insertPos;
let selectEnd = insertPos;
// Naive: select a short word after placeholder if exists; else just place cursor
const rest = value.slice(placeholderIdx + 1);
const matchWord = rest.match(/^[A-Za-z0-9\\^_]+/);
if (matchWord) {
selectEnd = insertPos + matchWord[0].length;
} else {
selectStart = insertPos;
selectEnd = insertPos;
}
newValue = before + cleaned + after;
editor.value = newValue;
// Put cursor or select content
requestAnimationFrame(() => {
editor.focus();
if (selectEnd > selectStart) {
editor.setSelectionRange(selectStart, selectEnd);
} else {
editor.setSelectionRange(insertPos, insertPos);
}
// Trigger rendering and fixing
triggerUpdate();
});
return;
}
// No placeholder: just insert
editor.value = newValue;
requestAnimationFrame(() => {
const caret = before.length + value.length;
editor.setSelectionRange(caret, caret);
editor.focus();
triggerUpdate();
});
}
// Command board click handling
document.querySelectorAll('.cmd').forEach(btn => {
btn.addEventListener('click', () => {
const snippet = btn.getAttribute('data-insert') || '';
insertSnippet(snippet);
});
});
// Keyboard navigation within command board (Tab/Shift+Tab)
document.addEventListener('keydown', (e) => {
const isCommandRegion = e.target.closest('.cmd') !== null;
if (!isCommandRegion) return;
const cmds = Array.from(document.querySelectorAll('.cmd'));
const idx = cmds.indexOf(e.target);
if (e.key === 'Tab') {
e.preventDefault();
const dir = e.shiftKey ? -1 : 1;
const next = (idx + dir + cmds.length) % cmds.length;
cmds[next].focus();
}
});
// Update pipeline
let lastRendered = '';
let lastFixed = '';
let lastUserInput = '';
function triggerUpdate() {
const current = editor.value;
if (current === lastUserInput) {
// If nothing changed (e.g., selection change only), still try to render
renderLatex(lastFixed || current);
return;
}
const fixed = autoFixLatex(current);
lastUserInput = current;
lastFixed = fixed;
// Only render if changed to reduce churn
if (fixed !== lastRendered) {
lastRendered = fixed;
renderLatex(fixed);
} else {
renderLatex(fixed);
}
}
editor.addEventListener('input', triggerUpdate);
renderBtn.addEventListener('click', triggerUpdate);
copyTexBtn.addEventListener('click', async () => {
try {
const text = lastFixed || editor.value || '';
await navigator.clipboard.writeText(text);
copyTexBtn.textContent = 'Copied!';
setTimeout(() => (copyTexBtn.textContent = 'Copy LaTeX'), 1200);
} catch {
alert('Copy failed. Please select and copy manually.');
}
});
clearBtn.addEventListener('click', () => {
editor.value = '';
lastUserInput = '';
lastFixed = '';
lastRendered = '';
renderLatex('');
editor.focus();
});
// Initial render with sample
editor.value = String.raw`\int_{0}^{\infty} e^{-x^2} \, dx = \frac{\sqrt{\pi}}{2}`;
triggerUpdate();