Spaces:
Running
Running
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(); |