File size: 9,180 Bytes
03662fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
// 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();