Commit a94d3cb3 authored by Kantz's avatar Kantz
Browse files

conversion from mathjax to mathlive

parent 4e0eb686
...@@ -5,22 +5,6 @@ ...@@ -5,22 +5,6 @@
<link rel="icon" type="image/png" href="/SuMINT-Logo.png" /> <link rel="icon" type="image/png" href="/SuMINT-Logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Math Tutor</title> <title>Math Tutor</title>
<script>
window.MathJax = {
tex: {
inlineMath: [["$", "$"], ["\\(", "\\)"]],
displayMath: [["$$", "$$"], ["\\[", "\\]"]],
},
options: {
skipHtmlTags: ["script", "noscript", "style", "textarea", "pre", "code"],
},
};
</script>
<script
id="mathjax-script"
async
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"
></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"iconoir-react": "^7.11.0", "iconoir-react": "^7.11.0",
"mathlive": "^0.110.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
......
...@@ -11,6 +11,9 @@ importers: ...@@ -11,6 +11,9 @@ importers:
iconoir-react: iconoir-react:
specifier: ^7.11.0 specifier: ^7.11.0
version: 7.11.0(react@19.2.3) version: 7.11.0(react@19.2.3)
mathlive:
specifier: ^0.110.0
version: 0.110.0
react: react:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.3 version: 19.2.3
...@@ -63,6 +66,9 @@ importers: ...@@ -63,6 +66,9 @@ importers:
packages: packages:
'@arnog/colors@0.5.0':
resolution: {integrity: sha512-NB7yqrO7qvInsqJdqz6C6mDU+2oiKmbBbk3czMS0E7KkGxLRy538tEHCsB5TKqmaxxXpi6/U120TnGl8NsDePg==}
'@babel/code-frame@7.28.6': '@babel/code-frame@7.28.6':
resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
...@@ -146,6 +152,10 @@ packages: ...@@ -146,6 +152,10 @@ packages:
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@cortex-js/compute-engine@0.58.0':
resolution: {integrity: sha512-N0+vXjVZfKwJS7ZIGvq41mojI55m5TrdDXveAXzNjzjZ9iNiNrdEzXL2EmrcP+9GCWCwTonrEeZWAIG78f3INg==}
engines: {node: '>=21.7.3', npm: '>=10.5.0'}
'@emnapi/core@1.8.1': '@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
...@@ -270,24 +280,28 @@ packages: ...@@ -270,24 +280,28 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.50': '@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.50': '@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==}
...@@ -522,6 +536,10 @@ packages: ...@@ -522,6 +536,10 @@ packages:
comma-separated-tokens@2.0.3: comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
complex-esm@2.1.1-esm1:
resolution: {integrity: sha512-IShBEWHILB9s7MnfyevqNGxV0A1cfcSnewL/4uPFiSxkcQL4Mm3FxJ0pXMtCXuWLjYz3lRRyk6OfkeDZcjD6nw==}
engines: {node: '>=16.14.2', npm: '>=8.5.0'}
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
...@@ -548,6 +566,9 @@ packages: ...@@ -548,6 +566,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decode-named-character-reference@1.3.0: decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
...@@ -827,24 +848,28 @@ packages: ...@@ -827,24 +848,28 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2: lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2: lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2: lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2: lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
...@@ -875,6 +900,9 @@ packages: ...@@ -875,6 +900,9 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
mathlive@0.110.0:
resolution: {integrity: sha512-UOpJsQ6h1eeN0xZULGTl1MwUB/lZLCDuzKeHvKEh7Zra8U/rtgDNrssj5PZdJtBTIV48AfEhtv6rv47AeMOxJA==}
mdast-util-from-markdown@2.0.2: mdast-util-from-markdown@2.0.2:
resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
...@@ -1271,6 +1299,8 @@ packages: ...@@ -1271,6 +1299,8 @@ packages:
snapshots: snapshots:
'@arnog/colors@0.5.0': {}
'@babel/code-frame@7.28.6': '@babel/code-frame@7.28.6':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
...@@ -1383,6 +1413,12 @@ snapshots: ...@@ -1383,6 +1413,12 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@cortex-js/compute-engine@0.58.0':
dependencies:
'@arnog/colors': 0.5.0
complex-esm: 2.1.1-esm1
decimal.js: 10.6.0
'@emnapi/core@1.8.1': '@emnapi/core@1.8.1':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.1.0 '@emnapi/wasi-threads': 1.1.0
...@@ -1772,6 +1808,8 @@ snapshots: ...@@ -1772,6 +1808,8 @@ snapshots:
comma-separated-tokens@2.0.3: {} comma-separated-tokens@2.0.3: {}
complex-esm@2.1.1-esm1: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
...@@ -1790,6 +1828,8 @@ snapshots: ...@@ -1790,6 +1828,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js@10.6.0: {}
decode-named-character-reference@1.3.0: decode-named-character-reference@1.3.0:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
...@@ -2092,6 +2132,10 @@ snapshots: ...@@ -2092,6 +2132,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
mathlive@0.110.0:
dependencies:
'@cortex-js/compute-engine': 0.58.0
mdast-util-from-markdown@2.0.2: mdast-util-from-markdown@2.0.2:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
......
...@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; ...@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import type { RetrievedDoc } from "../Retrieval/DocPanel"; import type { RetrievedDoc } from "../Retrieval/DocPanel";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { escapeAsterisksInsideMath } from "../../utils/mathMarkdown"; import { escapeMarkdownInMath } from "../../utils/mathText";
import { renderMath } from "../../utils/mathRender";
type MessageBubbleProps = { type MessageBubbleProps = {
role: "user" | "assistant"; role: "user" | "assistant";
...@@ -12,14 +13,6 @@ type MessageBubbleProps = { ...@@ -12,14 +13,6 @@ type MessageBubbleProps = {
docSlugIndex?: Record<string, RetrievedDoc>; docSlugIndex?: Record<string, RetrievedDoc>;
}; };
declare global {
interface Window {
MathJax?: {
typesetPromise?: (elements?: Element[]) => Promise<void>;
};
}
}
export default function MessageBubble({ export default function MessageBubble({
role, role,
text, text,
...@@ -36,15 +29,15 @@ export default function MessageBubble({ ...@@ -36,15 +29,15 @@ export default function MessageBubble({
if (role !== "assistant" || parts.length < 2) { if (role !== "assistant" || parts.length < 2) {
return { return {
thinkingText: "", thinkingText: "",
answerText: escapeAsterisksInsideMath(text), answerText: escapeMarkdownInMath(text),
}; };
} }
const rawThinking = parts.slice(0, -1).join("</think>").trim(); const rawThinking = parts.slice(0, -1).join("</think>").trim();
const rawAnswer = parts[parts.length - 1].trim(); const rawAnswer = parts[parts.length - 1].trim();
return { return {
thinkingText: escapeAsterisksInsideMath(rawThinking), thinkingText: escapeMarkdownInMath(rawThinking),
answerText: escapeAsterisksInsideMath(rawAnswer), answerText: escapeMarkdownInMath(rawAnswer),
}; };
}, [role, text]); }, [role, text]);
...@@ -129,22 +122,7 @@ export default function MessageBubble({ ...@@ -129,22 +122,7 @@ export default function MessageBubble({
return; return;
} }
const typeset = () => { renderMath(bubbleRef.current);
if (window.MathJax?.typesetPromise && bubbleRef.current) {
window.MathJax.typesetPromise([bubbleRef.current]).catch(() => undefined);
}
};
if (window.MathJax?.typesetPromise) {
typeset();
return;
}
const script = document.getElementById("mathjax-script");
if (script) {
script.addEventListener("load", typeset);
return () => script.removeEventListener("load", typeset);
}
}, [answerText, isRawView, isThinkingExpanded, thinkingText]); }, [answerText, isRawView, isThinkingExpanded, thinkingText]);
return ( return (
......
import { useEffect, useRef } from "react"; import "mathlive";
import { useEffect, useRef, useState } from "react";
import { EditPencil, Send, Upload } from "iconoir-react"; import { EditPencil, Send, Upload } from "iconoir-react";
import type { MathfieldElement } from "mathlive";
import { t } from "../../i18n"; import { t } from "../../i18n";
type MessageInputProps = { type MessageInputProps = {
...@@ -28,9 +30,12 @@ export default function MessageInput({ ...@@ -28,9 +30,12 @@ export default function MessageInput({
const canSend = value.trim().length > 0; const canSend = value.trim().length > 0;
const uploadInputRef = useRef<HTMLInputElement | null>(null); const uploadInputRef = useRef<HTMLInputElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const mathfieldRef = useRef<MathfieldElement | null>(null);
const wasSendingRef = useRef(isSending); const wasSendingRef = useRef(isSending);
const shouldRestoreFocusRef = useRef(false); const shouldRestoreFocusRef = useRef(false);
const sendButtonStartedFromTextareaRef = useRef(false); const sendButtonStartedFromTextareaRef = useRef(false);
const [isMathEditorOpen, setIsMathEditorOpen] = useState(false);
const [mathValue, setMathValue] = useState("");
useEffect(() => { useEffect(() => {
if (wasSendingRef.current && !isSending) { if (wasSendingRef.current && !isSending) {
...@@ -43,6 +48,23 @@ export default function MessageInput({ ...@@ -43,6 +48,23 @@ export default function MessageInput({
wasSendingRef.current = isSending; wasSendingRef.current = isSending;
}, [isSending]); }, [isSending]);
useEffect(() => {
const mathfield = mathfieldRef.current;
if (!mathfield) {
return;
}
mathfield.smartFence = true;
mathfield.mathVirtualKeyboardPolicy = "manual";
const handleInput = () => setMathValue(mathfield.value);
mathfield.addEventListener("input", handleInput);
return () => {
mathfield.removeEventListener("input", handleInput);
};
}, [isMathEditorOpen]);
const handleUploadClick = () => { const handleUploadClick = () => {
uploadInputRef.current?.click(); uploadInputRef.current?.click();
}; };
...@@ -63,6 +85,38 @@ export default function MessageInput({ ...@@ -63,6 +85,38 @@ export default function MessageInput({
onSend(); onSend();
}; };
const insertAtCursor = (snippet: string) => {
const textarea = textareaRef.current;
if (!textarea) {
onChange(value ? `${value} ${snippet}` : snippet);
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const nextValue = value.slice(0, start) + snippet + value.slice(end);
onChange(nextValue);
requestAnimationFrame(() => {
const nextCursor = start + snippet.length;
textarea.focus({ preventScroll: true });
textarea.setSelectionRange(nextCursor, nextCursor);
});
};
const handleInsertMath = () => {
const latex = mathValue.trim();
if (!latex) {
return;
}
insertAtCursor(`$${latex}$`);
setMathValue("");
if (mathfieldRef.current) {
mathfieldRef.current.value = "";
}
};
return ( return (
<div className="composer"> <div className="composer">
<div className="composer-row"> <div className="composer-row">
...@@ -93,6 +147,16 @@ export default function MessageInput({ ...@@ -93,6 +147,16 @@ export default function MessageInput({
className="canvas-upload-input" className="canvas-upload-input"
onChange={handleUploadChange} onChange={handleUploadChange}
/> />
<button
className="btn"
type="button"
onClick={() => setIsMathEditorOpen((prev) => !prev)}
disabled={isSending}
aria-pressed={isMathEditorOpen}
title="Formula editor"
>
f (x)
</button>
{onTip ? ( {onTip ? (
<button <button
className="btn" className="btn"
...@@ -176,6 +240,27 @@ export default function MessageInput({ ...@@ -176,6 +240,27 @@ export default function MessageInput({
} }
}} }}
/> />
{isMathEditorOpen ? (
<div className="composer-math">
<math-field
ref={mathfieldRef}
className="composer-mathfield"
onKeyDown={(event) => {
if (isSending) {
event.preventDefault();
return;
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleInsertMath();
}
}}
>
{mathValue}
</math-field>
</div>
) : null}
</div> </div>
); );
} }
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { escapeAsterisksInsideMath } from "../../utils/mathMarkdown"; import { escapeMarkdownInMath } from "../../utils/mathText";
import { renderMath } from "../../utils/mathRender";
declare global {
interface Window {
MathJax?: {
typesetPromise?: (elements?: Element[]) => Promise<void>;
};
}
}
type DocCardProps = { type DocCardProps = {
title: string; title: string;
...@@ -32,27 +25,12 @@ export default function DocCard({ ...@@ -32,27 +25,12 @@ export default function DocCard({
}: DocCardProps) { }: DocCardProps) {
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const renderedSnippet = useMemo( const renderedSnippet = useMemo(
() => escapeAsterisksInsideMath(snippetMarkdown), () => escapeMarkdownInMath(snippetMarkdown),
[snippetMarkdown] [snippetMarkdown]
); );
useEffect(() => { useEffect(() => {
const typeset = () => { renderMath(contentRef.current);
if (window.MathJax?.typesetPromise && contentRef.current) {
window.MathJax.typesetPromise([contentRef.current]).catch(() => undefined);
}
};
if (window.MathJax?.typesetPromise) {
typeset();
return;
}
const script = document.getElementById("mathjax-script");
if (script) {
script.addEventListener("load", typeset);
return () => script.removeEventListener("load", typeset);
}
}, [snippetMarkdown]); }, [snippetMarkdown]);
return ( return (
......
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { escapeAsterisksInsideMath } from "../../utils/mathMarkdown"; import { escapeMarkdownInMath } from "../../utils/mathText";
import { renderMath } from "../../utils/mathRender";
type SocraticPanelProps = { type SocraticPanelProps = {
selectedTopicLabel?: string; selectedTopicLabel?: string;
...@@ -17,7 +18,7 @@ export default function SocraticPanel({ ...@@ -17,7 +18,7 @@ export default function SocraticPanel({
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const summaryMarkdown = selectedTopicSummary?.trim() || ""; const summaryMarkdown = selectedTopicSummary?.trim() || "";
const renderedSummary = useMemo( const renderedSummary = useMemo(
() => escapeAsterisksInsideMath(summaryMarkdown), () => escapeMarkdownInMath(summaryMarkdown),
[summaryMarkdown] [summaryMarkdown]
); );
...@@ -25,11 +26,7 @@ export default function SocraticPanel({ ...@@ -25,11 +26,7 @@ export default function SocraticPanel({
if (!contentRef.current) { if (!contentRef.current) {
return; return;
} }
const mathjax = window.MathJax; renderMath(contentRef.current);
if (!mathjax?.typesetPromise) {
return;
}
mathjax.typesetPromise([contentRef.current]).catch(() => undefined);
}, [selectedTopicSummary]); }, [selectedTopicSummary]);
return ( return (
......
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import type { TaskImage } from "../../api/taskApi"; import type { TaskImage } from "../../api/taskApi";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { renderMath } from "../../utils/mathRender";
import TaskContent from "./TaskContent"; import TaskContent from "./TaskContent";
type SelectOption = { type SelectOption = {
...@@ -51,11 +52,7 @@ export default function TaskPanel({ ...@@ -51,11 +52,7 @@ export default function TaskPanel({
if (!selectedTaskText || !taskDisplayRef.current) { if (!selectedTaskText || !taskDisplayRef.current) {
return; return;
} }
const mathjax = window.MathJax; renderMath(taskDisplayRef.current);
if (!mathjax?.typesetPromise) {
return;
}
mathjax.typesetPromise([taskDisplayRef.current]).catch(() => undefined);
}, [selectedTaskText]); }, [selectedTaskText]);
return ( return (
......
...@@ -1257,19 +1257,17 @@ export default function ChatPage() { ...@@ -1257,19 +1257,17 @@ export default function ChatPage() {
document.getElementById("meta").textContent = path; document.getElementById("meta").textContent = path;
document.getElementById("content").innerHTML = marked.parse(markdown); document.getElementById("content").innerHTML = marked.parse(markdown);
</script> </script>
<script> <script type="module">
window.MathJax = { import { renderMathInElement } from "https://esm.run/mathlive";
tex: { renderMathInElement(document.getElementById("content"), {
inlineMath: [["$", "$"], ["\\\\(", "\\\\)"]], TeX: {
displayMath: [["$$", "$$"], ["\\\\[", "\\\\]"]], delimiters: {
} inline: [["$", "$"], ["\\\\(", "\\\\)"]],
}; display: [["$$", "$$"], ["\\\\[", "\\\\]"]],
},
},
});
</script> </script>
<script
id="mathjax-script"
async
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"
></script>
</body> </body>
</html>`; </html>`;
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
getSelectionRouteForOrchestrator, getSelectionRouteForOrchestrator,
type OrchestratorName, type OrchestratorName,
} from "../utils/orchestrator"; } from "../utils/orchestrator";
import { renderMath } from "../utils/mathRender";
import "../styles/theme.css"; import "../styles/theme.css";
import sumintLogo from "../../SuMINT-Logo.png"; import sumintLogo from "../../SuMINT-Logo.png";
...@@ -91,11 +92,7 @@ export default function SelectionPage({ mode }: SelectionPageProps) { ...@@ -91,11 +92,7 @@ export default function SelectionPage({ mode }: SelectionPageProps) {
if (!isTaskMode || !selectedTask?.fullText || !taskDisplayRef.current) { if (!isTaskMode || !selectedTask?.fullText || !taskDisplayRef.current) {
return; return;
} }
const mathjax = window.MathJax; renderMath(taskDisplayRef.current);
if (!mathjax?.typesetPromise) {
return;
}
mathjax.typesetPromise([taskDisplayRef.current]).catch(() => undefined);
}, [isTaskMode, selectedTask?.fullText]); }, [isTaskMode, selectedTask?.fullText]);
const handleStart = async () => { const handleStart = async () => {
......
...@@ -585,6 +585,30 @@ body { ...@@ -585,6 +585,30 @@ body {
cursor: not-allowed; cursor: not-allowed;
} }
.composer-math {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid #d8d1c4;
border-radius: 12px;
background: #fffaf0;
}
.composer-mathfield {
width: 100%;
min-height: 54px;
border: 1px solid #d8d1c4;
border-radius: 10px;
background: #fff;
padding: 10px 12px;
--hue: 32;
}
.composer-mathfield::part(virtual-keyboard-toggle) {
display: none;
}
.btn { .btn {
border: 1px solid #b9b2a4; border: 1px solid #b9b2a4;
background: #ffffff; background: #ffffff;
......
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import type { MathfieldElement } from "mathlive";
export {};
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"math-field": DetailedHTMLProps<
HTMLAttributes<MathfieldElement>,
MathfieldElement
>;
}
}
}
const MATH_SEGMENT_PATTERN =
/(\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$(?:\\.|[^$\\\n])+\$)/g;
// wandlet * in \* um sodass * in der MArkdown darstellung nich zum zeichen
// für kursiv oder fett gedruckt wird.
const escapeAsterisks = (value: string) => value.replace(/\*/g, "\\*");
// Wandelt einfache Bruchschreibweisen in LaTeX-Brüche um.
// Beispiele:
// - (x+1)/(x-1) -> \frac{x+1}{x-1}
// - a/b -> \frac{a}{b}
//
// Diese Ersetzung läuft nur innerhalb erkannter Math-Segmente, damit
// normale Markdown-Links, URLs oder Fließtext unangetastet bleiben.
const convertSimpleFractions = (value: string) => {
let output = value;
// Vorrang für explizit geklammerte Ausdrücke:
// (x+1)/(x-1) => \frac{x+1}{x-1}
output = output.replace(
/\(([^()]+)\)\s*\/\s*\(([^()]+)\)/g,
(_match, numerator: string, denominator: string) =>
`\\frac{${numerator}}{${denominator}}`
);
// Einseitig geklammerte Ausdrücke:
// (a+b)/c => \frac{a+b}{c}
// a/(b+c) => \frac{a}{b+c}
// Diese Regel kommt vor der ganz einfachen a/b-Regel, damit die Klammern
// erhalten bleiben und nicht versehentlich nur Teilstücke ersetzt werden.
output = output.replace(
/\(([^()]+)\)\s*\/\s*([A-Za-z0-9]+)(?![\w{])/g,
(_match, numerator: string, denominator: string) =>
`\\frac{${numerator}}{${denominator}}`
);
output = output.replace(
/([A-Za-z0-9]+)\s*\/\s*\(([^()]+)\)(?![\w{])/g,
(_match, numerator: string, denominator: string) =>
`\\frac{${numerator}}{${denominator}}`
);
// Danach sehr einfache Kurzschreibweise:
// a/b => \frac{a}{b}
// Hinweis: Das ist bewusst konservativ gehalten, damit keine komplexen
// Ausdrücke oder schon vorhandene LaTeX-Befehle umgeschrieben werden.
output = output.replace(
/(^|[^\\\w}])([A-Za-z0-9]+)\s*\/\s*([A-Za-z0-9]+)(?![\w{])/g,
(_match, prefix: string, numerator: string, denominator: string) =>
`${prefix}\\frac{${numerator}}{${denominator}}`
);
return output;
};
// Formatiert Potenzen so, dass zusammengesetzte Exponenten in Klammern
// vollständig in LaTeX-Klammern gesetzt werden.
// Beispiel:
// - x^(a+b) -> x^{a+b}
const normalizePowers = (value: string) =>
value.replace(
/([A-Za-z0-9\\]+)\s*\^\s*\(([^()]+)\)/g,
(_match, base: string, exponent: string) => `${base}^{${exponent}}`
);
// Wandelt sqrt(...) in LaTeX-Wurzelnotation um.
// Beispiel:
// - sqrt(x) -> \sqrt{x}
const normalizeSquareRoots = (value: string) =>
value.replace(/\bsqrt\s*\(([^()]+)\)/g, (_match, inner: string) => `\\sqrt{${inner}}`);
// Wandelt exp(x) in e^x um.
// Beispiel:
// - exp(x) -> e^x
const normalizeExp = (value: string) =>
value.replace(/\bexp\s*\(([^()]+)\)/g, (_match, inner: string) => `e^${inner}`);
// Wandelt log(x) in eine lesbare Logarithmus-Notation um.
// Beispiel:
// - log(x) -> \log x
const normalizeLog = (value: string) =>
value.replace(/\blog\s*\(([^()]+)\)/g, (_match, inner: string) => `\\log ${inner}`);
// Wandelt pi in das griechische LaTeX-Symbol um.
// Beispiel:
// - pi -> \pi
const normalizePi = (value: string) =>
value.replace(/\bpi\b/g, "\\pi");
// Wandelt sin/cos/tan-Aufrufe in LaTeX-Makros um.
// Beispiele:
// - sin(a) -> \sin(a)
// - cos(a) -> \cos(a)
// - tan(a) -> \tan(a)
const normalizeTrigFunctions = (value: string) =>
value.replace(/\b(sin|cos|tan)\s*\(([^()]+)\)/g, (_match, fn: string, inner: string) => `\\${fn}(${inner})`);
// Wandelt inf/minf in Infinities um.
// Beispiele:
// - inf -> \infty
// - minf -> - \infty
const normalizeInfinityTokens = (value: string) =>
value
.replace(/\bminf\b/g, "- \\infty")
.replace(/\binf\b/g, "\\infty");
// Macht bereits geschriebene Mengenklammern in Math-Segmenten sichtbar.
// Beispiele:
// - \{1, 2, 3\} -> \\{1, 2, 3\\}
// - \{x \in \mathbb{R} \mid x > 0\} -> \\{x \in \mathbb{R} \mid x > 0\\}
//
// Wichtig:
// - Rohe {a,b,c} werden absichtlich NICHT umgeschrieben, damit z. B. x_{a,b}
// nicht fälschlich als Menge interpretiert wird.
// - Nur bereits escapte Klammern werden angehoben, damit MathJax sie sichtbar
// rendert statt sie als Gruppierungszeichen zu schlucken.
const normalizeSetBraces = (value: string) =>
value
.replace(/\\\{/g, "\\\\{")
.replace(/\\\}/g, "\\\\}");
export const escapeAsterisksInsideMath = (input: string): string => {
return input.replace(MATH_SEGMENT_PATTERN, (segment) => {
if (segment.startsWith("$$") && segment.endsWith("$$")) {
const inner = segment.slice(2, -2);
return `$$${normalizeSetBraces(normalizeInfinityTokens(normalizeTrigFunctions(normalizePi(normalizeLog(normalizeExp(normalizeSquareRoots(normalizePowers(convertSimpleFractions(escapeAsterisks(inner))))))))))}$$`;
}
if (segment.startsWith("\\[") && segment.endsWith("\\]")) {
const inner = segment.slice(2, -2);
return `\\[${normalizeSetBraces(normalizeInfinityTokens(normalizeTrigFunctions(normalizePi(normalizeLog(normalizeExp(normalizeSquareRoots(normalizePowers(convertSimpleFractions(escapeAsterisks(inner))))))))))}\\]`;
}
if (segment.startsWith("\\(") && segment.endsWith("\\)")) {
const inner = segment.slice(2, -2);
return `\\(${normalizeSetBraces(normalizeInfinityTokens(normalizeTrigFunctions(normalizePi(normalizeLog(normalizeExp(normalizeSquareRoots(normalizePowers(convertSimpleFractions(escapeAsterisks(inner))))))))))}\\)`;
}
if (segment.startsWith("$") && segment.endsWith("$")) {
const inner = segment.slice(1, -1);
return `$${normalizeSetBraces(normalizeInfinityTokens(normalizeTrigFunctions(normalizePi(normalizeLog(normalizeExp(normalizeSquareRoots(normalizePowers(convertSimpleFractions(escapeAsterisks(inner))))))))))}$`;
}
return segment;
});
};
import { renderMathInElement, type StaticRenderOptions } from "mathlive";
const mathRenderOptions: StaticRenderOptions = {
TeX: {
delimiters: {
inline: [
["$", "$"],
["\\(", "\\)"],
],
display: [
["$$", "$$"],
["\\[", "\\]"],
],
},
},
};
export const renderMath = (element: HTMLElement | null) => {
if (!element) {
return;
}
renderMathInElement(element, mathRenderOptions);
};
const MATH_SEGMENT_PATTERN =
/(\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$(?:\\.|[^$\\\n])+\$)/g;
const escapeAsterisks = (value: string) => value.replace(/\*/g, "\\*");
export const escapeMarkdownInMath = (input: string): string =>
input.replace(MATH_SEGMENT_PATTERN, (segment) => {
if (segment.startsWith("$$") && segment.endsWith("$$")) {
return `$$${escapeAsterisks(segment.slice(2, -2))}$$`;
}
if (segment.startsWith("\\[") && segment.endsWith("\\]")) {
return `\\[${escapeAsterisks(segment.slice(2, -2))}\\]`;
}
if (segment.startsWith("\\(") && segment.endsWith("\\)")) {
return `\\(${escapeAsterisks(segment.slice(2, -2))}\\)`;
}
if (segment.startsWith("$") && segment.endsWith("$")) {
return `$${escapeAsterisks(segment.slice(1, -1))}$`;
}
return segment;
});
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment