@mathvoice/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,777 @@
1
+ // src/StudioEditor.tsx
2
+ import { useCallback as useCallback2, useEffect, useRef, useState as useState2 } from "react";
3
+ import katex from "katex";
4
+
5
+ // src/lib/mathSpeak.ts
6
+ function extractBraceGroup(s, pos) {
7
+ if (s[pos] !== "{") return null;
8
+ let depth = 1;
9
+ let i = pos + 1;
10
+ while (i < s.length && depth > 0) {
11
+ if (s[i] === "{") depth++;
12
+ else if (s[i] === "}") depth--;
13
+ i++;
14
+ }
15
+ if (depth !== 0) return null;
16
+ return [s.slice(pos + 1, i - 1), i];
17
+ }
18
+ function extractFrac(s) {
19
+ if (!s.startsWith("\\frac{")) return null;
20
+ const r1 = extractBraceGroup(s, 5);
21
+ if (!r1) return null;
22
+ const [num, p2] = r1;
23
+ if (s[p2] !== "{") return null;
24
+ const r2 = extractBraceGroup(s, p2);
25
+ if (!r2) return null;
26
+ return [num, r2[0], s.slice(r2[1])];
27
+ }
28
+ function extractSqrt(s) {
29
+ if (!s.startsWith("\\sqrt")) return null;
30
+ let pos = 5;
31
+ let index = null;
32
+ if (s[pos] === "[") {
33
+ const end = s.indexOf("]", pos + 1);
34
+ if (end === -1) return null;
35
+ index = s.slice(pos + 1, end);
36
+ pos = end + 1;
37
+ }
38
+ if (s[pos] !== "{") return null;
39
+ const r = extractBraceGroup(s, pos);
40
+ if (!r) return null;
41
+ return [index, r[0], s.slice(r[1])];
42
+ }
43
+ function processMathSpeakFrac(s) {
44
+ let result = "";
45
+ let i = 0;
46
+ while (i < s.length) {
47
+ if (s.startsWith("\\frac{", i)) {
48
+ const frac = extractFrac(s.slice(i));
49
+ if (frac) {
50
+ const [num, den, rest] = frac;
51
+ result += `start fraction ${latexToMathSpeak(num)} over ${latexToMathSpeak(den)} end fraction`;
52
+ i += s.slice(i).length - rest.length;
53
+ continue;
54
+ }
55
+ }
56
+ result += s[i];
57
+ i++;
58
+ }
59
+ return result;
60
+ }
61
+ function processMathSpeakSqrt(s) {
62
+ let result = "";
63
+ let i = 0;
64
+ while (i < s.length) {
65
+ if (s.startsWith("\\sqrt", i)) {
66
+ const sq = extractSqrt(s.slice(i));
67
+ if (sq) {
68
+ const [index, radicand, rest] = sq;
69
+ const spoken = index ? `${index} root of ${latexToMathSpeak(radicand)} end root` : `square root of ${latexToMathSpeak(radicand)} end root`;
70
+ result += spoken;
71
+ i += s.slice(i).length - rest.length;
72
+ continue;
73
+ }
74
+ }
75
+ result += s[i];
76
+ i++;
77
+ }
78
+ return result;
79
+ }
80
+ function latexToMathSpeak(latex) {
81
+ let s = latex;
82
+ s = processMathSpeakFrac(s);
83
+ s = processMathSpeakSqrt(s);
84
+ s = s.replace(
85
+ /\{([^{}]*)\}\^\{([^{}]*)\}/g,
86
+ (_m, b, e) => `${latexToMathSpeak(b)} to the power ${latexToMathSpeak(e)}`
87
+ );
88
+ s = s.replace(/([a-zA-Z0-9])\^\{2\}/g, "$1 squared");
89
+ s = s.replace(/([a-zA-Z0-9])\^\{3\}/g, "$1 cubed");
90
+ s = s.replace(/([a-zA-Z0-9])\^\{([^{}]*)\}/g, "$1 to the $2");
91
+ s = s.replace(/\\pm/g, " plus or minus ");
92
+ s = s.replace(/\\cdot|\\times/g, " times ");
93
+ s = s.replace(/\\infty/g, " infinity ");
94
+ s = s.replace(/\\pi/g, " pi ");
95
+ s = s.replace(/\\left\(|\\right\)/g, "");
96
+ s = s.replace(/\\[a-zA-Z]+/g, (m) => m.slice(1));
97
+ s = s.replace(/[{}]/g, " ");
98
+ s = s.replace(/\s+/g, " ").trim();
99
+ return s;
100
+ }
101
+ function parseLatexStructure(latex) {
102
+ const roles = {};
103
+ const frac = extractFrac(latex);
104
+ if (frac) {
105
+ const [num, den, rest] = frac;
106
+ roles.numerator = num;
107
+ roles.denominator = den;
108
+ return { type: "FRACTION", roles, rest };
109
+ }
110
+ const eqSplit = splitAtTopLevelEquals(latex);
111
+ if (eqSplit) {
112
+ roles.lhs = eqSplit[0].trim();
113
+ roles.rhs = eqSplit[1].trim();
114
+ return { type: "EQUALITY", roles };
115
+ }
116
+ const powM = latex.match(/^(\{[^{}]*\}|[a-zA-Z0-9\\]+)\^\{([^{}]*)\}(.*)$/);
117
+ if (powM) {
118
+ roles.base = powM[1].replace(/^\{|\}$/g, "");
119
+ roles.exponent = powM[2];
120
+ return { type: "POWER", roles, rest: powM[3] };
121
+ }
122
+ const sq = extractSqrt(latex);
123
+ if (sq) {
124
+ const [index, radicand, rest] = sq;
125
+ roles.index = index;
126
+ roles.radicand = radicand;
127
+ return { type: "SQRT", roles, rest };
128
+ }
129
+ return { type: "OPAQUE", roles };
130
+ }
131
+ function splitAtTopLevelEquals(latex) {
132
+ let depth = 0;
133
+ for (let i = 0; i < latex.length; i++) {
134
+ if (latex[i] === "{") depth++;
135
+ else if (latex[i] === "}") depth--;
136
+ else if (latex[i] === "=" && depth === 0) {
137
+ return [latex.slice(0, i), latex.slice(i + 1)];
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ // src/lib/mutateLatex.ts
144
+ function splitAtTopLevelEquals2(latex) {
145
+ let depth = 0;
146
+ for (let i = 0; i < latex.length; i++) {
147
+ if (latex[i] === "{") depth++;
148
+ else if (latex[i] === "}") depth--;
149
+ else if (latex[i] === "=" && depth === 0) {
150
+ return [latex.slice(0, i), latex.slice(i + 1)];
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+ function setRole(latex, role, newVal) {
156
+ var _a, _b, _c, _d, _e, _f, _g, _h;
157
+ const struct = parseLatexStructure(latex);
158
+ const rest = (_a = struct.rest) != null ? _a : "";
159
+ if (struct.type === "FRACTION" && (role === "numerator" || role === "denominator")) {
160
+ const n = role === "numerator" ? newVal : (_b = struct.roles.numerator) != null ? _b : "";
161
+ const d = role === "denominator" ? newVal : (_c = struct.roles.denominator) != null ? _c : "";
162
+ return `\\frac{${n}}{${d}}${rest}`;
163
+ }
164
+ if (struct.type === "EQUALITY" && (role === "lhs" || role === "rhs")) {
165
+ const l = role === "lhs" ? newVal : (_d = struct.roles.lhs) != null ? _d : "";
166
+ const r = role === "rhs" ? newVal : (_e = struct.roles.rhs) != null ? _e : "";
167
+ return `${l} = ${r}`;
168
+ }
169
+ if (struct.type === "POWER" && (role === "base" || role === "exponent")) {
170
+ const b = role === "base" ? newVal : (_f = struct.roles.base) != null ? _f : "";
171
+ const e = role === "exponent" ? newVal : (_g = struct.roles.exponent) != null ? _g : "";
172
+ return `{${b}}^{${e}}${rest}`;
173
+ }
174
+ if (struct.type === "SQRT" && (role === "radicand" || role === "index")) {
175
+ const idx = role === "index" ? newVal : struct.roles.index;
176
+ const rad = role === "radicand" ? newVal : (_h = struct.roles.radicand) != null ? _h : "";
177
+ return idx ? `\\sqrt[${idx}]{${rad}}${rest}` : `\\sqrt{${rad}}${rest}`;
178
+ }
179
+ const frac = extractFrac(latex);
180
+ if (frac && (role === "numerator" || role === "denominator")) {
181
+ const [num, den, rest2] = frac;
182
+ const n = role === "numerator" ? newVal : num;
183
+ const d = role === "denominator" ? newVal : den;
184
+ return `\\frac{${n}}{${d}}${rest2}`;
185
+ }
186
+ throw new Error(`Cannot set role "${role}" in formula: ${latex}`);
187
+ }
188
+ function escapeRegex(s) {
189
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
190
+ }
191
+ function removeTermFromSum(sum, term) {
192
+ const s = sum.replace(/\s+/g, " ").trim();
193
+ const t = term.replace(/\s+/g, " ").trim();
194
+ if (s === t) return "0";
195
+ const patterns = [
196
+ new RegExp(`^${escapeRegex(t)}\\s*\\+\\s*(.+)$`),
197
+ new RegExp(`^${escapeRegex(t)}\\s*-\\s*(.+)$`),
198
+ new RegExp(`^(.+)\\s*\\+\\s*${escapeRegex(t)}$`),
199
+ new RegExp(`^(.+)\\s*-\\s*${escapeRegex(t)}$`)
200
+ ];
201
+ for (const p of patterns) {
202
+ const m = s.match(p);
203
+ if (m) return m[1].trim() || "0";
204
+ }
205
+ return null;
206
+ }
207
+ function applyReplace(latex, intent) {
208
+ var _a, _b, _c, _d;
209
+ const role = (_a = intent.target) == null ? void 0 : _a.role;
210
+ const newVal = (_b = intent.value) == null ? void 0 : _b.raw;
211
+ if (!role || newVal === void 0) {
212
+ throw new Error("REPLACE_VALUE needs target.role and value.raw");
213
+ }
214
+ const struct = parseLatexStructure(latex);
215
+ const oldVal = (_d = (_c = struct.roles) == null ? void 0 : _c[role]) != null ? _d : "";
216
+ const after = setRole(latex, role, newVal);
217
+ return { after, diff: { op: "REPLACE_VALUE", role, before: oldVal, after: newVal } };
218
+ }
219
+ function applyDelete(latex, intent) {
220
+ var _a, _b, _c;
221
+ const role = (_a = intent.target) == null ? void 0 : _a.role;
222
+ if (!role) throw new Error("DELETE_NODE needs target.role");
223
+ const struct = parseLatexStructure(latex);
224
+ const oldVal = (_c = (_b = struct.roles) == null ? void 0 : _b[role]) != null ? _c : "?";
225
+ const after = setRole(latex, role, "");
226
+ return { after, diff: { op: "DELETE_NODE", role, before: oldVal != null ? oldVal : void 0 } };
227
+ }
228
+ function applyReparent(latex, intent) {
229
+ var _a, _b, _c, _d;
230
+ const srcRole = (_a = intent.source) == null ? void 0 : _a.role;
231
+ const tgtRole = (_b = intent.target) == null ? void 0 : _b.role;
232
+ if (!srcRole || !tgtRole) throw new Error("REPARENT_NODE needs source.role and target.role");
233
+ const struct = parseLatexStructure(latex);
234
+ const srcVal = (_c = struct.roles) == null ? void 0 : _c[srcRole];
235
+ const tgtVal = (_d = struct.roles) == null ? void 0 : _d[tgtRole];
236
+ if (!srcVal || !tgtVal) throw new Error(`Cannot find roles ${srcRole}, ${tgtRole}`);
237
+ let after = setRole(latex, srcRole, tgtVal);
238
+ after = setRole(after, tgtRole, srcVal);
239
+ return { after, diff: { op: "REPARENT_NODE", srcRole, tgtRole, srcVal, tgtVal } };
240
+ }
241
+ function applyNegate(latex, intent) {
242
+ var _a, _b;
243
+ const role = (_a = intent.target) == null ? void 0 : _a.role;
244
+ if (!role) throw new Error("NEGATE_NODE needs target.role");
245
+ const struct = parseLatexStructure(latex);
246
+ const val = (_b = struct.roles) == null ? void 0 : _b[role];
247
+ if (val === null || val === void 0) throw new Error(`Cannot find role ${role}`);
248
+ const negated = val.startsWith("-") ? val.slice(1) : `-${val}`;
249
+ const after = setRole(latex, role, negated);
250
+ return { after, diff: { op: "NEGATE_NODE", role, before: val, after: negated } };
251
+ }
252
+ function applyWrap(latex, intent) {
253
+ var _a, _b;
254
+ const role = (_a = intent.target) == null ? void 0 : _a.role;
255
+ if (!role) throw new Error("WRAP_NODE needs target.role");
256
+ const struct = parseLatexStructure(latex);
257
+ const val = (_b = struct.roles) == null ? void 0 : _b[role];
258
+ if (!val) throw new Error(`Cannot find role ${role}`);
259
+ let wrapped;
260
+ if (intent.wrapper === "SQRT") wrapped = `\\sqrt{${val}}`;
261
+ else if (intent.wrapper === "FRACTION") wrapped = `\\frac{${val}}{1}`;
262
+ else throw new Error("Unknown wrapper");
263
+ const after = setRole(latex, role, wrapped);
264
+ return { after, diff: { op: "WRAP_NODE", role, wrapper: intent.wrapper } };
265
+ }
266
+ function applyInverse(latex, intent) {
267
+ var _a, _b;
268
+ const eqSplit = splitAtTopLevelEquals2(latex);
269
+ if (!eqSplit) throw new Error("APPLY_INVERSE: no equation found (missing =)");
270
+ const [lhs, rhs] = eqSplit;
271
+ const val = (_b = (_a = intent.value) == null ? void 0 : _a.raw) != null ? _b : "1";
272
+ const op = intent.operation;
273
+ const applyOp = (side) => {
274
+ switch (op) {
275
+ case "ADD":
276
+ return `${side} + ${val}`;
277
+ case "SUBTRACT":
278
+ return `${side} - ${val}`;
279
+ case "MULTIPLY":
280
+ return `\\left(${side}\\right) \\cdot ${val}`;
281
+ case "DIVIDE":
282
+ return `\\frac{${side}}{${val}}`;
283
+ default:
284
+ throw new Error(`Unknown operation: ${op}`);
285
+ }
286
+ };
287
+ const newLhs = applyOp(lhs.trim());
288
+ const newRhs = applyOp(rhs.trim());
289
+ const after = `${newLhs} = ${newRhs}`;
290
+ return {
291
+ after,
292
+ diff: {
293
+ op: "APPLY_INVERSE",
294
+ operation: op,
295
+ value: val,
296
+ lhsBefore: lhs.trim(),
297
+ lhsAfter: newLhs,
298
+ rhsBefore: rhs.trim(),
299
+ rhsAfter: newRhs
300
+ }
301
+ };
302
+ }
303
+ function transposeTerm(latex, intent) {
304
+ var _a;
305
+ const eqSplit = splitAtTopLevelEquals2(latex);
306
+ if (!eqSplit) throw new Error("TRANSPOSE_TERM: no equation found");
307
+ const [lhs, rhs] = eqSplit.map((s) => s.trim());
308
+ const term = (_a = intent.term) == null ? void 0 : _a.raw;
309
+ if (!term) throw new Error("TRANSPOSE_TERM: term is required");
310
+ const removedFromLhs = removeTermFromSum(lhs, term);
311
+ if (removedFromLhs !== null) {
312
+ const negated = term.startsWith("-") ? term.slice(1).trim() : `-${term}`;
313
+ const newRhs = `${rhs} ${negated}`;
314
+ return {
315
+ after: `${removedFromLhs} = ${newRhs}`,
316
+ diff: { op: "TRANSPOSE_TERM", term, from: "lhs", to: "rhs", note: `Transposed: ${term} \u2192 ${negated}` }
317
+ };
318
+ }
319
+ const removedFromRhs = removeTermFromSum(rhs, term);
320
+ if (removedFromRhs !== null) {
321
+ const negated = term.startsWith("-") ? term.slice(1).trim() : `-${term}`;
322
+ const newLhs = `${lhs} ${negated}`;
323
+ return {
324
+ after: `${newLhs} = ${removedFromRhs}`,
325
+ diff: { op: "TRANSPOSE_TERM", term, from: "rhs", to: "lhs", note: `Transposed: ${term} \u2192 ${negated}` }
326
+ };
327
+ }
328
+ throw new Error(`Term "${term}" not found in either side of the equation`);
329
+ }
330
+ function mutateLatex(latex, intent, editMode) {
331
+ const before = latex;
332
+ try {
333
+ switch (intent.type) {
334
+ case "REPLACE_VALUE": {
335
+ const { after, diff } = applyReplace(latex, intent);
336
+ return { success: after !== before, before, after, diff };
337
+ }
338
+ case "DELETE_NODE": {
339
+ const { after, diff } = applyDelete(latex, intent);
340
+ return { success: true, before, after, diff };
341
+ }
342
+ case "REPARENT_NODE": {
343
+ const { after, diff } = applyReparent(latex, intent);
344
+ return { success: after !== before, before, after, diff };
345
+ }
346
+ case "NEGATE_NODE": {
347
+ const { after, diff } = applyNegate(latex, intent);
348
+ return { success: true, before, after, diff };
349
+ }
350
+ case "WRAP_NODE": {
351
+ const { after, diff } = applyWrap(latex, intent);
352
+ return { success: true, before, after, diff };
353
+ }
354
+ case "INSERT_NODE": {
355
+ const { after, diff } = applyReplace(latex, intent);
356
+ return { success: after !== before, before, after, diff };
357
+ }
358
+ case "APPLY_INVERSE": {
359
+ if (editMode !== "ALGEBRA") throw new Error("APPLY_INVERSE requires ALGEBRA mode");
360
+ const { after, diff } = applyInverse(latex, intent);
361
+ return { success: true, before, after, diff };
362
+ }
363
+ case "TRANSPOSE_TERM": {
364
+ if (editMode !== "ALGEBRA") throw new Error("TRANSPOSE_TERM requires ALGEBRA mode");
365
+ const { after, diff } = transposeTerm(latex, intent);
366
+ return { success: true, before, after, diff };
367
+ }
368
+ default:
369
+ throw new Error(`Unknown intent: ${intent.type}`);
370
+ }
371
+ } catch (e) {
372
+ const msg = e instanceof Error ? e.message : String(e);
373
+ return { success: false, before, after: before, diff: null, error: msg };
374
+ }
375
+ }
376
+ function formatDiff(d) {
377
+ var _a;
378
+ switch (d.op) {
379
+ case "REPLACE_VALUE":
380
+ return `${d.role}: ${d.before || "\u2205"} \u2192 ${d.after}`;
381
+ case "DELETE_NODE":
382
+ return `${d.role} deleted (was: ${d.before})`;
383
+ case "REPARENT_NODE":
384
+ return `Swapped ${d.srcRole} \u2194 ${d.tgtRole}`;
385
+ case "APPLY_INVERSE":
386
+ return `${d.operation} ${d.value} on both sides`;
387
+ case "TRANSPOSE_TERM":
388
+ return (_a = d.note) != null ? _a : "";
389
+ case "NEGATE_NODE":
390
+ return `Negated ${d.role}: ${d.before} \u2192 ${d.after}`;
391
+ case "WRAP_NODE":
392
+ return `Wrapped ${d.role} in ${d.wrapper}`;
393
+ default:
394
+ return JSON.stringify(d);
395
+ }
396
+ }
397
+
398
+ // src/lib/intent.ts
399
+ var PATTERNS = [
400
+ {
401
+ p: /REPLACE\s+(?:the\s+)?(\w+)\s+(?:TO|with)\s+(.+)/i,
402
+ fn: (m) => ({ type: "REPLACE_VALUE", target: { role: m[1].toLowerCase() }, value: { raw: m[2].trim() } })
403
+ },
404
+ {
405
+ p: /(?:change|set|make|update|let)\s+(?:the\s+)?(\w+)\s+(?:to|be|equal(?:\s+to)?)\s+(.+)/i,
406
+ fn: (m) => ({ type: "REPLACE_VALUE", target: { role: m[1].toLowerCase() }, value: { raw: m[2].trim() } })
407
+ },
408
+ {
409
+ p: /(?:DELETE|remove|drop|erase)\s+(?:the\s+)?(\w+)/i,
410
+ fn: (m) => ({ type: "DELETE_NODE", target: { role: m[1].toLowerCase() } })
411
+ },
412
+ {
413
+ p: /(?:MOVE|move)\s+(.+?)\s+to\s+(?:the\s+)?OTHER_SIDE/i,
414
+ fn: (m) => ({ type: "TRANSPOSE_TERM", term: { raw: m[1].trim() } })
415
+ },
416
+ {
417
+ p: /(?:MOVE|move)\s+(?:the\s+)?(\w+)\s+TO\s+(?:the\s+)?(\w+)/i,
418
+ fn: (m) => ({ type: "REPARENT_NODE", source: { role: m[1].toLowerCase() }, target: { role: m[2].toLowerCase() } })
419
+ },
420
+ {
421
+ p: /negate\s+(?:the\s+)?(\w+)/i,
422
+ fn: (m) => ({ type: "NEGATE_NODE", target: { role: m[1].toLowerCase() } })
423
+ },
424
+ {
425
+ p: /wrap\s+(?:the\s+)?(\w+)\s+in\s+(?:a\s+)?(?:square\s+)?root/i,
426
+ fn: (m) => ({ type: "WRAP_NODE", target: { role: m[1].toLowerCase() }, wrapper: "SQRT" })
427
+ },
428
+ {
429
+ p: /SUBTRACT\s+(.+?)\s+from\s+BOTH_SIDES/i,
430
+ fn: (m) => ({ type: "APPLY_INVERSE", operation: "SUBTRACT", value: { raw: m[1].trim() } })
431
+ },
432
+ {
433
+ p: /DIVIDE\s+BOTH_SIDES\s+BY\s+(.+)/i,
434
+ fn: (m) => ({ type: "APPLY_INVERSE", operation: "DIVIDE", value: { raw: m[1].trim() } })
435
+ },
436
+ {
437
+ p: /MULTIPLY\s+BOTH_SIDES\s+BY\s+(.+)/i,
438
+ fn: (m) => ({ type: "APPLY_INVERSE", operation: "MULTIPLY", value: { raw: m[1].trim() } })
439
+ },
440
+ {
441
+ p: /ADD_BOTH\s+(.+)/i,
442
+ fn: (m) => ({ type: "APPLY_INVERSE", operation: "ADD", value: { raw: m[1].trim() } })
443
+ }
444
+ ];
445
+ function parseIntentRegex(normalized) {
446
+ for (const { p, fn } of PATTERNS) {
447
+ const m = normalized.match(p);
448
+ if (m) {
449
+ return { intent: { ...fn(m), source_tier: "regex" }, confidence: 0.9 };
450
+ }
451
+ }
452
+ return { intent: null, confidence: 0 };
453
+ }
454
+
455
+ // src/lib/normalizeSpeech.ts
456
+ var SUBS = [
457
+ [/\b(the\s+)?(top|upper)\b/g, "numerator"],
458
+ [/\b(the\s+)?(bottom|lower)\b/g, "denominator"],
459
+ [/\b(the\s+)?power\b/g, "exponent"],
460
+ [/\braised\s+to(\s+the)?\b/g, "exponent"],
461
+ [/\b(the\s+)?index\b/g, "exponent"],
462
+ [/\bradicand\b/g, "radicand"],
463
+ [/\bunder\s+the\s+root\b/g, "radicand"],
464
+ [/\b(the\s+)?left\s+side\b/g, "lhs"],
465
+ [/\b(the\s+)?right\s+side\b/g, "rhs"],
466
+ [/\b(the\s+)?base\b/g, "base"],
467
+ [/\b(set|change|replace|make|update|let)\b/g, "REPLACE"],
468
+ [/\b(delete|remove|drop|erase|take\s+out)\b/g, "DELETE"],
469
+ [/\b(add|insert|put|place)\b/g, "INSERT"],
470
+ [/\b(move|shift|transfer)\b/g, "MOVE"],
471
+ [/\bboth\s+sides?\b/g, "BOTH_SIDES"],
472
+ [/\b(subtract|minus)\b/g, "SUBTRACT"],
473
+ [/\bdivide(\s+both\s+sides?\s+by)?\b/g, "DIVIDE"],
474
+ [/\bmultiply(\s+both\s+sides?\s+by)?\b/g, "MULTIPLY"],
475
+ [/\badd\s+to\s+both\s+sides?\b/g, "ADD_BOTH"],
476
+ [/\b(\w+)\s+over\s+(\w+)\b/g, (_m, a, b) => `\\frac{${a}}{${b}}`],
477
+ [/\b(\w+)\s+squared\b/g, (_m, a) => `${a}^{2}`],
478
+ [/\b(\w+)\s+cubed\b/g, (_m, a) => `${a}^{3}`],
479
+ [/\bsquare\s+root\s+of\b/g, "\\sqrt"],
480
+ [/\bcube\s+root\s+of\b/g, "\\sqrt[3]"],
481
+ [/\bpi\b/g, "\\pi"],
482
+ [/\binfinity\b/g, "\\infty"],
483
+ [/\btheta\b/g, "\\theta"],
484
+ [/\balpha\b/g, "\\alpha"],
485
+ [/\bbeta\b/g, "\\beta"],
486
+ [/\blambda\b/g, "\\lambda"],
487
+ [/\bplus\s+or\s+minus\b/g, "\\pm"],
488
+ [/\bplus\b/g, "+"],
489
+ [/\bminus\b/g, "-"],
490
+ [/\btimes\b/g, "\\cdot"],
491
+ [/\bdivided\s+by\b/g, "/"],
492
+ [/\bequals?\b/g, "="],
493
+ [/\bto\b/g, "TO"],
494
+ [/\bwith\b/g, "WITH"],
495
+ [/\bby\b/g, "BY"],
496
+ [/\b(other|opposite)\s+side\b/g, "OTHER_SIDE"]
497
+ ];
498
+ var KNOWN_TOKENS = /* @__PURE__ */ new Set([
499
+ "REPLACE",
500
+ "DELETE",
501
+ "INSERT",
502
+ "MOVE",
503
+ "SUBTRACT",
504
+ "DIVIDE",
505
+ "MULTIPLY",
506
+ "ADD_BOTH",
507
+ "BOTH_SIDES",
508
+ "numerator",
509
+ "denominator",
510
+ "exponent",
511
+ "radicand",
512
+ "lhs",
513
+ "rhs",
514
+ "base",
515
+ "TO"
516
+ ]);
517
+ function normalizeSpeech(raw) {
518
+ let t = raw.toLowerCase().trim();
519
+ for (const [pattern, replacement] of SUBS) {
520
+ t = t.replace(pattern, replacement);
521
+ }
522
+ const tokens = t.split(/\s+/).filter((x) => x.length > 0);
523
+ const matched = tokens.filter((x) => KNOWN_TOKENS.has(x)).length;
524
+ const confidence = Math.min(0.95, 0.3 + matched / Math.max(tokens.length, 1) * 0.7);
525
+ return { normalized: t, tokens, confidence, raw };
526
+ }
527
+
528
+ // src/lib/intentProxy.ts
529
+ async function callIntentProxy(opts) {
530
+ const { rawText, normalizedText, formulaContext, editMode, apiEndpoint } = opts;
531
+ const res = await fetch(apiEndpoint, {
532
+ method: "POST",
533
+ headers: { "Content-Type": "application/json" },
534
+ body: JSON.stringify({ rawText, normalizedText, formulaContext, editMode })
535
+ });
536
+ if (!res.ok) throw new Error(`Intent API error: ${res.status}`);
537
+ return res.json();
538
+ }
539
+
540
+ // src/hooks/useUndoStack.ts
541
+ import { useCallback, useState } from "react";
542
+ var MAX = 50;
543
+ function useUndoStack(initial) {
544
+ const [value, setValueState] = useState(initial);
545
+ const [undoStack, setUndo] = useState([]);
546
+ const [redoStack, setRedo] = useState([]);
547
+ const push = useCallback((next) => {
548
+ setUndo((prev) => {
549
+ const stack = [...prev, value];
550
+ return stack.length > MAX ? stack.slice(-MAX) : stack;
551
+ });
552
+ setRedo([]);
553
+ setValueState(next);
554
+ }, [value]);
555
+ const set = useCallback((next) => setValueState(next), []);
556
+ const undo = useCallback(() => {
557
+ setUndo((prev) => {
558
+ if (prev.length === 0) return prev;
559
+ const last = prev[prev.length - 1];
560
+ setRedo((r) => [...r, value]);
561
+ setValueState(last);
562
+ return prev.slice(0, -1);
563
+ });
564
+ }, [value]);
565
+ const redo = useCallback(() => {
566
+ setRedo((prev) => {
567
+ if (prev.length === 0) return prev;
568
+ const last = prev[prev.length - 1];
569
+ setUndo((u) => [...u, value]);
570
+ setValueState(last);
571
+ return prev.slice(0, -1);
572
+ });
573
+ }, [value]);
574
+ return {
575
+ value,
576
+ set,
577
+ push,
578
+ undo,
579
+ redo,
580
+ canUndo: undoStack.length > 0,
581
+ canRedo: redoStack.length > 0
582
+ };
583
+ }
584
+
585
+ // src/StudioEditor.tsx
586
+ import { jsx, jsxs } from "react/jsx-runtime";
587
+ function StudioEditor({
588
+ initialLatex = "\\frac{-b}{2a}",
589
+ latex: controlledLatex,
590
+ onMutation,
591
+ editMode = "CORRECT",
592
+ voiceEnabled = true,
593
+ onModeChange,
594
+ apiEndpoint = "/api/intent",
595
+ className,
596
+ style
597
+ }) {
598
+ const isControlled = controlledLatex !== void 0;
599
+ const undoStack = useUndoStack(initialLatex);
600
+ const internalLatex = undoStack.value;
601
+ const latex = isControlled ? controlledLatex : internalLatex;
602
+ const [internalMode, setInternalMode] = useState2(editMode);
603
+ const activeMode = onModeChange ? editMode : internalMode;
604
+ const formulaRef = useRef(null);
605
+ const [command, setCommand] = useState2("");
606
+ const [status, setStatus] = useState2("");
607
+ const [processing, setProcessing] = useState2(false);
608
+ const [srAnnounce, setSrAnnounce] = useState2("");
609
+ useEffect(() => {
610
+ if (!formulaRef.current) return;
611
+ try {
612
+ katex.render(latex, formulaRef.current, {
613
+ throwOnError: false,
614
+ displayMode: true,
615
+ output: "htmlAndMathml"
616
+ });
617
+ } catch {
618
+ formulaRef.current.textContent = latex;
619
+ }
620
+ }, [latex]);
621
+ const runPipeline = useCallback2(async (raw) => {
622
+ var _a;
623
+ const cmd = raw.trim();
624
+ if (!cmd || processing) return;
625
+ setProcessing(true);
626
+ setStatus("Parsing\u2026");
627
+ try {
628
+ const norm = normalizeSpeech(cmd);
629
+ let intent = null;
630
+ const regexResult = parseIntentRegex(norm.normalized);
631
+ if (regexResult.intent && regexResult.confidence >= 0.85) {
632
+ intent = regexResult.intent;
633
+ } else {
634
+ try {
635
+ intent = await callIntentProxy({
636
+ rawText: cmd,
637
+ normalizedText: norm.normalized,
638
+ formulaContext: { latex },
639
+ editMode: activeMode,
640
+ apiEndpoint
641
+ });
642
+ } catch {
643
+ setStatus("Network error \u2014 command not sent to server");
644
+ return;
645
+ }
646
+ }
647
+ if (!intent || intent.type === "UNKNOWN") {
648
+ setStatus(`Not understood: "${cmd}"`);
649
+ return;
650
+ }
651
+ if ((intent.type === "APPLY_INVERSE" || intent.type === "TRANSPOSE_TERM") && activeMode !== "ALGEBRA") {
652
+ setStatus("Switch to ALGEBRA mode for this operation");
653
+ return;
654
+ }
655
+ const result = mutateLatex(latex, intent, activeMode);
656
+ if (result.success) {
657
+ if (!isControlled) undoStack.push(result.after);
658
+ const detail = result.diff ? formatDiff(result.diff) : result.after;
659
+ setStatus(`\u2713 ${detail}`);
660
+ setSrAnnounce(`Formula updated: ${latexToMathSpeak(result.after)}`);
661
+ onMutation == null ? void 0 : onMutation(result);
662
+ } else {
663
+ setStatus((_a = result.error) != null ? _a : "Mutation failed");
664
+ }
665
+ } finally {
666
+ setProcessing(false);
667
+ }
668
+ }, [activeMode, apiEndpoint, isControlled, latex, onMutation, processing, undoStack]);
669
+ const handleSubmit = useCallback2((e) => {
670
+ e.preventDefault();
671
+ runPipeline(command);
672
+ setCommand("");
673
+ }, [command, runPipeline]);
674
+ const handleModeChange = useCallback2((m) => {
675
+ if (onModeChange) {
676
+ onModeChange(m);
677
+ } else {
678
+ setInternalMode(m);
679
+ }
680
+ }, [onModeChange]);
681
+ const ariaLabel = latexToMathSpeak(latex);
682
+ return /* @__PURE__ */ jsxs(
683
+ "div",
684
+ {
685
+ className,
686
+ style,
687
+ "data-mathvoice-editor": true,
688
+ children: [
689
+ /* @__PURE__ */ jsx(
690
+ "span",
691
+ {
692
+ role: "status",
693
+ "aria-live": "polite",
694
+ "aria-atomic": "true",
695
+ style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0,0,0,0)" },
696
+ children: srAnnounce
697
+ }
698
+ ),
699
+ /* @__PURE__ */ jsx(
700
+ "div",
701
+ {
702
+ ref: formulaRef,
703
+ role: "img",
704
+ tabIndex: 0,
705
+ "aria-label": ariaLabel,
706
+ "data-mathvoice-formula": true
707
+ }
708
+ ),
709
+ !onModeChange && /* @__PURE__ */ jsx("nav", { "aria-label": "Edit mode", "data-mathvoice-modes": true, children: ["CORRECT", "ALGEBRA", "ASK"].map((m) => /* @__PURE__ */ jsx(
710
+ "button",
711
+ {
712
+ type: "button",
713
+ "aria-pressed": activeMode === m,
714
+ onClick: () => handleModeChange(m),
715
+ "data-mode": m,
716
+ children: m
717
+ },
718
+ m
719
+ )) }),
720
+ /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, "data-mathvoice-command": true, children: [
721
+ /* @__PURE__ */ jsx("label", { htmlFor: "mathvoice-cmd", style: { position: "absolute", width: 1, height: 1, overflow: "hidden" }, children: "Voice command" }),
722
+ /* @__PURE__ */ jsx(
723
+ "input",
724
+ {
725
+ id: "mathvoice-cmd",
726
+ type: "text",
727
+ value: command,
728
+ onChange: (e) => setCommand(e.target.value),
729
+ placeholder: 'Try: "change the denominator to n"',
730
+ "aria-label": "Type a voice command",
731
+ autoComplete: "off",
732
+ disabled: processing
733
+ }
734
+ ),
735
+ /* @__PURE__ */ jsx("button", { type: "submit", disabled: processing, "aria-label": "Run command", children: "Run" }),
736
+ voiceEnabled && /* @__PURE__ */ jsx(
737
+ "button",
738
+ {
739
+ type: "button",
740
+ "aria-label": "Voice input (connect ASR in host app)",
741
+ "aria-pressed": false,
742
+ title: "Implement onStartListening prop to connect your ASR provider",
743
+ disabled: true,
744
+ children: "\u{1F3A4}"
745
+ }
746
+ )
747
+ ] }),
748
+ /* @__PURE__ */ jsx("div", { role: "status", "aria-live": "polite", "data-mathvoice-status": true, children: status }),
749
+ !isControlled && /* @__PURE__ */ jsxs("div", { "data-mathvoice-history": true, children: [
750
+ /* @__PURE__ */ jsx(
751
+ "button",
752
+ {
753
+ type: "button",
754
+ onClick: () => undoStack.undo(),
755
+ disabled: !undoStack.canUndo,
756
+ "aria-label": "Undo last edit",
757
+ children: "\u21A9 Undo"
758
+ }
759
+ ),
760
+ /* @__PURE__ */ jsx(
761
+ "button",
762
+ {
763
+ type: "button",
764
+ onClick: () => undoStack.redo(),
765
+ disabled: !undoStack.canRedo,
766
+ "aria-label": "Redo",
767
+ children: "\u21AA Redo"
768
+ }
769
+ )
770
+ ] })
771
+ ]
772
+ }
773
+ );
774
+ }
775
+ export {
776
+ StudioEditor
777
+ };