Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in
Toggle navigation
Menu
Open sidebar
math_tutor_dev
public_math_tutor
Commits
0b77973f
Commit
0b77973f
authored
Jun 29, 2026
by
Kantz
Browse files
base Mathquill implementation
parent
590d71a3
Changes
6
Hide whitespace changes
Inline
Side-by-side
math-tutor/frontend/package.json
View file @
0b77973f
...
@@ -12,6 +12,8 @@
...
@@ -12,6 +12,8 @@
},
},
"dependencies"
:
{
"dependencies"
:
{
"iconoir-react"
:
"^7.11.0"
,
"iconoir-react"
:
"^7.11.0"
,
"jquery"
:
"1.12.4"
,
"mathquill"
:
"0.10.1-a"
,
"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"
,
...
...
math-tutor/frontend/pnpm-lock.yaml
View file @
0b77973f
...
@@ -11,6 +11,12 @@ importers:
...
@@ -11,6 +11,12 @@ 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)
jquery
:
specifier
:
1.12.4
version
:
1.12.4
mathquill
:
specifier
:
0.10.1-a
version
:
0.10.1-a
react
:
react
:
specifier
:
^19.2.0
specifier
:
^19.2.0
version
:
19.2.3
version
:
19.2.3
...
@@ -270,24 +276,28 @@ packages:
...
@@ -270,24 +276,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==
}
...
@@ -759,6 +769,10 @@ packages:
...
@@ -759,6 +769,10 @@ packages:
isexe@2.0.0
:
isexe@2.0.0
:
resolution
:
{
integrity
:
sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
}
resolution
:
{
integrity
:
sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
}
jquery@1.12.4
:
resolution
:
{
integrity
:
sha512-UEVp7PPK9xXYSk8xqXCJrkXnKZtlgWkd2GsAQbMRFK6S/ePU2JN5G2Zum8hIVjzR3CpdfSqdqAzId/xd4TJHeg==
}
deprecated
:
This version is deprecated. Please upgrade to the latest version or find support at https://www.herodevs.com/support/jquery-nes.
js-tokens@4.0.0
:
js-tokens@4.0.0
:
resolution
:
{
integrity
:
sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
}
resolution
:
{
integrity
:
sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
}
...
@@ -827,24 +841,28 @@ packages:
...
@@ -827,24 +841,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 +893,9 @@ packages:
...
@@ -875,6 +893,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==
}
mathquill@0.10.1-a
:
resolution
:
{
integrity
:
sha512-snSAEwAtwdwBFSor+nVBnWWQtTw67kgAgKMyAIxuz4ZPboy0qkWZmd7BL3lfOXp/INihhRlU1PcfaAtDaRhmzA==
}
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==
}
...
@@ -2006,6 +2027,8 @@ snapshots:
...
@@ -2006,6 +2027,8 @@ snapshots:
isexe@2.0.0
:
{}
isexe@2.0.0
:
{}
jquery@1.12.4
:
{}
js-tokens@4.0.0
:
{}
js-tokens@4.0.0
:
{}
js-yaml@4.1.1
:
js-yaml@4.1.1
:
...
@@ -2092,6 +2115,10 @@ snapshots:
...
@@ -2092,6 +2115,10 @@ snapshots:
dependencies
:
dependencies
:
yallist
:
3.1.1
yallist
:
3.1.1
mathquill@0.10.1-a
:
dependencies
:
jquery
:
1.12.4
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
...
...
math-tutor/frontend/src/components/Chat/MessageInput.tsx
View file @
0b77973f
import
{
useEffect
,
useRef
}
from
"
react
"
;
import
{
useEffect
,
useRef
}
from
"
react
"
;
import
{
EditPencil
,
Send
,
Upload
}
from
"
iconoir-react
"
;
import
{
EditPencil
,
Send
,
Upload
}
from
"
iconoir-react
"
;
import
{
t
}
from
"
../../i18n
"
;
import
{
t
}
from
"
../../i18n
"
;
import
"
mathquill/build/mathquill.css
"
;
type
MessageInputProps
=
{
type
MessageInputProps
=
{
value
:
string
;
value
:
string
;
...
@@ -14,6 +15,227 @@ type MessageInputProps = {
...
@@ -14,6 +15,227 @@ type MessageInputProps = {
onUploadSolution
?:
(
file
:
File
)
=>
void
|
Promise
<
void
>
;
onUploadSolution
?:
(
file
:
File
)
=>
void
|
Promise
<
void
>
;
};
};
type
MathFieldLike
=
{
el
:
()
=>
HTMLElement
;
latex
:
{
():
string
;
(
value
:
string
):
void
;
};
focus
:
()
=>
void
;
revert
:
()
=>
void
;
};
type
MathQuillInterface
=
{
MathField
:
(
element
:
HTMLElement
,
config
:
{
handlers
?:
{
edit
?:
(
mathField
:
MathFieldLike
)
=>
void
;
enter
?:
(
mathField
:
MathFieldLike
)
=>
void
;
};
}
)
=>
MathFieldLike
;
StaticMath
:
(
element
:
HTMLElement
)
=>
unknown
;
};
type
MathQuillGlobal
=
{
getInterface
:
(
version
:
number
)
=>
MathQuillInterface
;
};
declare
global
{
interface
Window
{
$
?:
unknown
;
jQuery
?:
unknown
;
MathQuill
?:
MathQuillGlobal
;
}
}
const
INLINE_MATH_SELECTOR
=
"
.inline-math, .inline-math-render
"
;
const
ZERO_WIDTH_SPACE
=
"
\
u200b
"
;
const
MATH_SEGMENT_PATTERN
=
/
(\$[^
$
\n]
+
\$)
/g
;
let
mathQuillLoader
:
Promise
<
MathQuillInterface
>
|
null
=
null
;
const
activeMathFields
=
new
WeakMap
<
HTMLElement
,
MathFieldLike
>
();
const
loadMathQuill
=
async
():
Promise
<
MathQuillInterface
>
=>
{
if
(
!
mathQuillLoader
)
{
mathQuillLoader
=
(
async
()
=>
{
const
jqueryModule
=
await
import
(
"
jquery
"
);
const
jquery
=
jqueryModule
.
default
??
jqueryModule
;
window
.
$
=
jquery
;
window
.
jQuery
=
jquery
;
await
import
(
"
mathquill/build/mathquill.js
"
);
const
mathQuill
=
window
.
MathQuill
;
if
(
!
mathQuill
)
{
throw
new
Error
(
"
MathQuill failed to load
"
);
}
return
mathQuill
.
getInterface
(
2
);
})();
}
return
mathQuillLoader
;
};
const
isSelectionInside
=
(
container
:
HTMLElement
,
selection
:
Selection
|
null
)
=>
{
if
(
!
selection
||
selection
.
rangeCount
===
0
)
{
return
false
;
}
return
container
.
contains
(
selection
.
anchorNode
);
};
const
placeCaretAfter
=
(
node
:
Node
)
=>
{
const
selection
=
window
.
getSelection
();
if
(
!
selection
)
{
return
;
}
const
range
=
document
.
createRange
();
range
.
setStartAfter
(
node
);
range
.
collapse
(
true
);
selection
.
removeAllRanges
();
selection
.
addRange
(
range
);
};
const
placeCaretAtStart
=
(
container
:
HTMLElement
)
=>
{
const
selection
=
window
.
getSelection
();
if
(
!
selection
)
{
return
;
}
const
range
=
document
.
createRange
();
range
.
selectNodeContents
(
container
);
range
.
collapse
(
true
);
selection
.
removeAllRanges
();
selection
.
addRange
(
range
);
};
const
insertLineBreakAtCaret
=
(
editor
:
HTMLElement
)
=>
{
const
selection
=
window
.
getSelection
();
if
(
!
selection
)
{
return
;
}
if
(
!
isSelectionInside
(
editor
,
selection
))
{
editor
.
focus
();
}
const
activeSelection
=
window
.
getSelection
();
if
(
!
activeSelection
||
activeSelection
.
rangeCount
===
0
)
{
return
;
}
const
range
=
activeSelection
.
getRangeAt
(
0
);
range
.
deleteContents
();
const
br
=
document
.
createElement
(
"
br
"
);
const
spacer
=
document
.
createTextNode
(
ZERO_WIDTH_SPACE
);
range
.
insertNode
(
spacer
);
range
.
insertNode
(
br
);
const
nextRange
=
document
.
createRange
();
nextRange
.
setStart
(
spacer
,
spacer
.
textContent
?.
length
??
0
);
nextRange
.
collapse
(
true
);
activeSelection
.
removeAllRanges
();
activeSelection
.
addRange
(
nextRange
);
};
const
isCaretAtStart
=
(
editor
:
HTMLElement
)
=>
{
const
selection
=
window
.
getSelection
();
if
(
!
selection
||
!
selection
.
isCollapsed
||
!
isSelectionInside
(
editor
,
selection
))
{
return
false
;
}
const
range
=
selection
.
getRangeAt
(
0
).
cloneRange
();
const
prefixRange
=
document
.
createRange
();
prefixRange
.
selectNodeContents
(
editor
);
prefixRange
.
setEnd
(
range
.
startContainer
,
range
.
startOffset
);
const
fragment
=
prefixRange
.
cloneContents
();
if
(
fragment
.
querySelector
(
INLINE_MATH_SELECTOR
))
{
return
false
;
}
return
prefixRange
.
toString
().
replaceAll
(
ZERO_WIDTH_SPACE
,
""
).
length
===
0
;
};
const
serializeEditor
=
(
root
:
HTMLElement
)
=>
{
const
parts
:
string
[]
=
[];
const
walk
=
(
node
:
Node
)
=>
{
if
(
node
.
nodeType
===
Node
.
TEXT_NODE
)
{
parts
.
push
((
node
.
textContent
||
""
).
replaceAll
(
ZERO_WIDTH_SPACE
,
""
));
return
;
}
if
(
!
(
node
instanceof
HTMLElement
))
{
return
;
}
if
(
node
.
matches
(
"
.inline-math-render
"
))
{
parts
.
push
(
`$
${
node
.
dataset
.
latex
||
""
}
$`
);
return
;
}
if
(
node
.
matches
(
"
.inline-math
"
))
{
const
field
=
activeMathFields
.
get
(
node
);
const
latex
=
field
?.
latex
()
||
node
.
dataset
.
latex
||
""
;
parts
.
push
(
`$
${
latex
}
$`
);
return
;
}
if
(
node
.
tagName
===
"
BR
"
)
{
parts
.
push
(
"
\n
"
);
return
;
}
Array
.
from
(
node
.
childNodes
).
forEach
(
walk
);
};
Array
.
from
(
root
.
childNodes
).
forEach
(
walk
);
return
parts
.
join
(
""
);
};
const
createRenderedMathNode
=
(
MQ
:
MathQuillInterface
,
latex
:
string
)
=>
{
const
rendered
=
document
.
createElement
(
"
span
"
);
rendered
.
className
=
"
inline-math-render
"
;
rendered
.
dataset
.
latex
=
latex
;
rendered
.
tabIndex
=
0
;
rendered
.
textContent
=
latex
||
"
\\
square
"
;
MQ
.
StaticMath
(
rendered
);
return
rendered
;
};
const
renderValueIntoEditor
=
(
editor
:
HTMLElement
,
MQ
:
MathQuillInterface
,
value
:
string
)
=>
{
editor
.
innerHTML
=
""
;
const
appendText
=
(
text
:
string
)
=>
{
const
lines
=
text
.
split
(
"
\n
"
);
lines
.
forEach
((
line
,
lineIndex
)
=>
{
if
(
line
)
{
editor
.
appendChild
(
document
.
createTextNode
(
line
));
}
if
(
lineIndex
<
lines
.
length
-
1
)
{
editor
.
appendChild
(
document
.
createElement
(
"
br
"
));
}
});
};
let
lastIndex
=
0
;
for
(
const
match
of
value
.
matchAll
(
MATH_SEGMENT_PATTERN
))
{
const
fullMatch
=
match
[
0
];
const
index
=
match
.
index
??
0
;
appendText
(
value
.
slice
(
lastIndex
,
index
));
editor
.
appendChild
(
createRenderedMathNode
(
MQ
,
fullMatch
.
slice
(
1
,
-
1
)));
lastIndex
=
index
+
fullMatch
.
length
;
}
appendText
(
value
.
slice
(
lastIndex
));
};
const
exitMathField
=
(
editor
:
HTMLElement
,
node
:
Node
)
=>
{
editor
.
focus
();
placeCaretAfter
(
node
);
};
export
default
function
MessageInput
({
export
default
function
MessageInput
({
value
,
value
,
onChange
,
onChange
,
...
@@ -27,15 +249,186 @@ export default function MessageInput({
...
@@ -27,15 +249,186 @@ export default function MessageInput({
}:
MessageInputProps
)
{
}:
MessageInputProps
)
{
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
textarea
Ref
=
useRef
<
HTML
TextArea
Element
|
null
>
(
null
);
const
editor
Ref
=
useRef
<
HTML
Div
Element
|
null
>
(
null
);
const
wasSendingRef
=
useRef
(
isSending
);
const
wasSendingRef
=
useRef
(
isSending
);
const
shouldRestoreFocusRef
=
useRef
(
false
);
const
shouldRestoreFocusRef
=
useRef
(
false
);
const
sendButtonStartedFromTextareaRef
=
useRef
(
false
);
const
sendButtonStartedFromEditorRef
=
useRef
(
false
);
const
currentValueRef
=
useRef
(
value
);
const
mqRef
=
useRef
<
MathQuillInterface
|
null
>
(
null
);
const
syncEditorToValue
=
async
(
nextValue
:
string
)
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
{
return
;
}
const
MQ
=
mqRef
.
current
??
(
await
loadMathQuill
());
mqRef
.
current
=
MQ
;
renderValueIntoEditor
(
editor
,
MQ
,
nextValue
);
};
const
commitEditorValue
=
()
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
{
return
;
}
const
nextValue
=
serializeEditor
(
editor
);
if
(
nextValue
===
currentValueRef
.
current
)
{
return
;
}
currentValueRef
.
current
=
nextValue
;
onChange
(
nextValue
);
};
const
wireMathField
=
async
(
element
:
HTMLElement
,
latex
:
string
)
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
{
return
null
;
}
const
MQ
=
mqRef
.
current
??
(
await
loadMathQuill
());
mqRef
.
current
=
MQ
;
let
field
:
MathFieldLike
|
null
=
null
;
const
finalizeMathField
=
()
=>
{
if
(
!
field
)
{
return
null
;
}
const
fieldElement
=
field
.
el
();
if
(
!
fieldElement
.
isConnected
)
{
return
null
;
}
const
rendered
=
createRenderedMathNode
(
MQ
,
field
.
latex
());
rendered
.
addEventListener
(
"
click
"
,
()
=>
{
void
activateRenderedMath
(
rendered
);
});
rendered
.
addEventListener
(
"
keydown
"
,
(
event
)
=>
{
if
(
event
.
key
!==
"
Enter
"
&&
event
.
key
!==
"
"
)
{
return
;
}
event
.
preventDefault
();
void
activateRenderedMath
(
rendered
);
});
fieldElement
.
replaceWith
(
rendered
);
activeMathFields
.
delete
(
fieldElement
);
field
.
revert
();
commitEditorValue
();
return
rendered
;
};
field
=
MQ
.
MathField
(
element
,
{
handlers
:
{
edit
:
(
mathField
)
=>
{
mathField
.
el
().
dataset
.
empty
=
mathField
.
latex
()
?
"
false
"
:
"
true
"
;
commitEditorValue
();
},
enter
:
()
=>
{
const
rendered
=
finalizeMathField
();
if
(
rendered
)
{
exitMathField
(
editor
,
rendered
);
}
},
},
});
activeMathFields
.
set
(
element
,
field
);
field
.
latex
(
latex
);
element
.
dataset
.
latex
=
latex
;
element
.
dataset
.
empty
=
latex
?
"
false
"
:
"
true
"
;
const
root
=
field
.
el
();
root
.
addEventListener
(
"
keydown
"
,
(
event
)
=>
{
if
(
event
.
key
!==
"
Enter
"
)
{
return
;
}
event
.
preventDefault
();
const
rendered
=
finalizeMathField
();
if
(
rendered
)
{
exitMathField
(
editor
,
rendered
);
}
});
root
.
addEventListener
(
"
focusout
"
,
()
=>
{
requestAnimationFrame
(()
=>
{
if
(
root
.
contains
(
document
.
activeElement
))
{
return
;
}
finalizeMathField
();
});
});
return
field
;
};
const
activateRenderedMath
=
async
(
rendered
:
HTMLElement
)
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
{
return
null
;
}
const
mathfield
=
document
.
createElement
(
"
span
"
);
mathfield
.
className
=
"
inline-math
"
;
mathfield
.
dataset
.
latex
=
rendered
.
dataset
.
latex
||
""
;
rendered
.
replaceWith
(
mathfield
);
const
field
=
await
wireMathField
(
mathfield
,
rendered
.
dataset
.
latex
||
""
);
requestAnimationFrame
(()
=>
field
?.
focus
());
return
field
;
};
const
insertMathFieldAtCaret
=
async
()
=>
{
const
editor
=
editorRef
.
current
;
const
selection
=
window
.
getSelection
();
if
(
!
editor
||
!
selection
)
{
return
null
;
}
if
(
!
isSelectionInside
(
editor
,
selection
))
{
editor
.
focus
();
placeCaretAtStart
(
editor
);
}
const
activeSelection
=
window
.
getSelection
();
if
(
!
activeSelection
||
activeSelection
.
rangeCount
===
0
)
{
return
null
;
}
const
range
=
activeSelection
.
getRangeAt
(
0
);
range
.
deleteContents
();
const
mathfield
=
document
.
createElement
(
"
span
"
);
mathfield
.
className
=
"
inline-math
"
;
const
spacer
=
document
.
createTextNode
(
"
\
u00a0
"
);
range
.
insertNode
(
spacer
);
range
.
insertNode
(
mathfield
);
const
field
=
await
wireMathField
(
mathfield
,
""
);
commitEditorValue
();
requestAnimationFrame
(()
=>
field
?.
focus
());
return
field
;
};
useEffect
(()
=>
{
currentValueRef
.
current
=
value
;
},
[
value
]);
useEffect
(()
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
{
return
;
}
const
currentSerialized
=
serializeEditor
(
editor
);
if
(
currentSerialized
===
value
)
{
return
;
}
void
syncEditorToValue
(
value
);
},
[
value
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
wasSendingRef
.
current
&&
!
isSending
)
{
if
(
wasSendingRef
.
current
&&
!
isSending
)
{
if
(
shouldRestoreFocusRef
.
current
)
{
if
(
shouldRestoreFocusRef
.
current
)
{
textarea
Ref
.
current
?.
focus
({
preventScroll
:
true
});
editor
Ref
.
current
?.
focus
({
preventScroll
:
true
});
}
}
shouldRestoreFocusRef
.
current
=
false
;
shouldRestoreFocusRef
.
current
=
false
;
}
}
...
@@ -47,9 +440,7 @@ export default function MessageInput({
...
@@ -47,9 +440,7 @@ export default function MessageInput({
uploadInputRef
.
current
?.
click
();
uploadInputRef
.
current
?.
click
();
};
};
const
handleUploadChange
=
async
(
const
handleUploadChange
=
async
(
event
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
event
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
file
=
event
.
target
.
files
?.[
0
];
const
file
=
event
.
target
.
files
?.[
0
];
event
.
target
.
value
=
""
;
event
.
target
.
value
=
""
;
if
(
!
file
)
{
if
(
!
file
)
{
...
@@ -58,7 +449,7 @@ export default function MessageInput({
...
@@ -58,7 +449,7 @@ export default function MessageInput({
await
onUploadSolution
?.(
file
);
await
onUploadSolution
?.(
file
);
};
};
const
handleSend
=
(
restoreFocus
=
document
.
activeElement
===
textarea
Ref
.
current
)
=>
{
const
handleSend
=
(
restoreFocus
=
document
.
activeElement
===
editor
Ref
.
current
)
=>
{
shouldRestoreFocusRef
.
current
=
restoreFocus
;
shouldRestoreFocusRef
.
current
=
restoreFocus
;
onSend
();
onSend
();
};
};
...
@@ -117,16 +508,27 @@ export default function MessageInput({
...
@@ -117,16 +508,27 @@ export default function MessageInput({
{
t
(
"
revealSolution
"
)
}
{
t
(
"
revealSolution
"
)
}
</
button
>
</
button
>
)
:
null
}
)
:
null
}
<
button
className
=
"btn btn-formula"
type
=
"button"
onClick
=
{
()
=>
{
void
insertMathFieldAtCaret
();
}
}
disabled
=
{
isSending
}
aria-label
=
{
t
(
"
formula
"
)
}
title
=
{
t
(
"
formula
"
)
}
>
f (x)
</
button
>
<
button
<
button
className
=
"btn primary"
className
=
"btn primary"
type
=
"button"
type
=
"button"
onPointerDown
=
{
()
=>
{
onPointerDown
=
{
()
=>
{
sendButtonStartedFromTextareaRef
.
current
=
sendButtonStartedFromEditorRef
.
current
=
document
.
activeElement
===
editorRef
.
current
;
document
.
activeElement
===
textareaRef
.
current
;
}
}
}
}
onClick
=
{
()
=>
{
onClick
=
{
()
=>
{
handleSend
(
sendButtonStartedFrom
Textarea
Ref
.
current
);
handleSend
(
sendButtonStartedFrom
Editor
Ref
.
current
);
sendButtonStartedFrom
Textarea
Ref
.
current
=
false
;
sendButtonStartedFrom
Editor
Ref
.
current
=
false
;
}
}
}
}
disabled
=
{
!
canSend
||
isSending
}
disabled
=
{
!
canSend
||
isSending
}
aria-label
=
{
t
(
"
send
"
)
}
aria-label
=
{
t
(
"
send
"
)
}
...
@@ -143,34 +545,52 @@ export default function MessageInput({
...
@@ -143,34 +545,52 @@ export default function MessageInput({
)
}
)
}
</
button
>
</
button
>
</
div
>
</
div
>
<
textarea
<
div
ref
=
{
textareaRef
}
ref
=
{
editorRef
}
className
=
"composer-input"
className
=
{
`composer-input composer-rich-input
${
isSending
?
"
composer-input-disabled
"
:
""
}
`
}
placeholder
=
{
t
(
"
typeQuestionOrLatex
"
)
}
contentEditable
=
{
!
isSending
}
rows
=
{
3
}
suppressContentEditableWarning
value
=
{
value
}
data-placeholder
=
{
t
(
"
typeQuestionOrLatex
"
)
}
disabled
=
{
isSending
}
aria-label
=
{
t
(
"
typeQuestionOrLatex
"
)
}
onChange
=
{
(
event
)
=>
onChange
(
event
.
target
.
value
)
}
role
=
"textbox"
aria-multiline
=
"true"
onInput
=
{
()
=>
{
commitEditorValue
();
}
}
onKeyDown
=
{
(
event
)
=>
{
onKeyDown
=
{
(
event
)
=>
{
if
(
isSending
)
{
if
(
isSending
)
{
event
.
preventDefault
();
event
.
preventDefault
();
return
;
return
;
}
}
const
isCursorAtStart
=
if
(
event
.
key
===
"
$
"
)
{
event
.
currentTarget
.
selectionStart
===
0
&&
event
.
currentTarget
.
selectionEnd
===
0
;
if
(
event
.
key
===
"
ArrowUp
"
&&
isCursorAtStart
&&
onHistoryNavigate
?.(
"
older
"
))
{
event
.
preventDefault
();
event
.
preventDefault
();
void
insertMathFieldAtCaret
();
return
;
return
;
}
}
if
(
event
.
key
===
"
ArrowDown
"
&&
isCursorAtStart
&&
onHistoryNavigate
?.(
"
newer
"
))
{
if
(
event
.
key
===
"
ArrowUp
"
&&
isCaretAtStart
(
event
.
currentTarget
))
{
if
(
onHistoryNavigate
?.(
"
older
"
))
{
event
.
preventDefault
();
return
;
}
}
if
(
event
.
key
===
"
ArrowDown
"
&&
isCaretAtStart
(
event
.
currentTarget
))
{
if
(
onHistoryNavigate
?.(
"
newer
"
))
{
event
.
preventDefault
();
return
;
}
}
if
(
event
.
key
===
"
Enter
"
&&
event
.
shiftKey
)
{
event
.
preventDefault
();
event
.
preventDefault
();
insertLineBreakAtCaret
(
event
.
currentTarget
);
commitEditorValue
();
return
;
return
;
}
}
if
(
event
.
key
===
"
Enter
"
&&
!
event
.
shiftKey
)
{
if
(
event
.
key
===
"
Enter
"
)
{
event
.
preventDefault
();
event
.
preventDefault
();
handleSend
(
true
);
handleSend
(
true
);
}
}
...
...
math-tutor/frontend/src/i18n.ts
View file @
0b77973f
...
@@ -38,6 +38,7 @@
...
@@ -38,6 +38,7 @@
inspect
:
"
Inspect
"
,
inspect
:
"
Inspect
"
,
cite
:
"
Cite
"
,
cite
:
"
Cite
"
,
draw
:
"
Draw
"
,
draw
:
"
Draw
"
,
formula
:
"
Insert formula
"
,
tip
:
"
Tip
"
,
tip
:
"
Tip
"
,
revealSolution
:
"
Solution
"
,
revealSolution
:
"
Solution
"
,
send
:
"
Send
"
,
send
:
"
Send
"
,
...
@@ -137,6 +138,7 @@
...
@@ -137,6 +138,7 @@
inspect
:
"
Ansehen
"
,
inspect
:
"
Ansehen
"
,
cite
:
"
Zitieren
"
,
cite
:
"
Zitieren
"
,
draw
:
"
Zeichnen
"
,
draw
:
"
Zeichnen
"
,
formula
:
"
Formel einfuegen
"
,
tip
:
"
Tip
"
,
tip
:
"
Tip
"
,
revealSolution
:
"
Lösung
"
,
revealSolution
:
"
Lösung
"
,
send
:
"
Senden
"
,
send
:
"
Senden
"
,
...
...
math-tutor/frontend/src/styles/theme.css
View file @
0b77973f
...
@@ -572,11 +572,11 @@ body {
...
@@ -572,11 +572,11 @@ body {
.composer-input
{
.composer-input
{
width
:
100%
;
width
:
100%
;
resize
:
vertical
;
border-radius
:
12px
;
border-radius
:
12px
;
padding
:
10px
12px
;
padding
:
10px
12px
;
border
:
1px
solid
#d8d1c4
;
border
:
1px
solid
#d8d1c4
;
font-family
:
inherit
;
font-family
:
inherit
;
background
:
#fff
;
}
}
.composer-input
:disabled
{
.composer-input
:disabled
{
...
@@ -585,6 +585,94 @@ body {
...
@@ -585,6 +585,94 @@ body {
cursor
:
not-allowed
;
cursor
:
not-allowed
;
}
}
.composer-rich-input
{
min-height
:
92px
;
line-height
:
1.6
;
white-space
:
pre-wrap
;
overflow-wrap
:
anywhere
;
outline
:
none
;
cursor
:
text
;
}
.composer-rich-input
:empty::before
{
content
:
attr
(
data-placeholder
);
color
:
#8b857b
;
}
.composer-rich-input
:focus
{
border-color
:
#1d1b16
;
box-shadow
:
0
0
0
2px
rgba
(
29
,
27
,
22
,
0.08
);
}
.composer-input-disabled
{
background
:
#f7f3ec
;
color
:
#8b857b
;
cursor
:
not-allowed
;
}
.btn-formula
{
font-weight
:
600
;
}
.inline-math
{
display
:
inline-block
;
min-width
:
1.6em
;
margin
:
0
0.18em
;
padding
:
4px
8px
;
vertical-align
:
middle
;
border
:
1px
solid
#d8d1c4
;
border-radius
:
10px
;
background
:
#fffaf0
;
font-size
:
1rem
;
}
.inline-math
.mq-root-block
{
min-width
:
1em
;
}
.inline-math
[
data-empty
=
"true"
]
.mq-root-block
::before
{
content
:
"x"
;
opacity
:
0.35
;
}
.inline-math
:focus-within
,
.inline-math.mq-focused
{
border-color
:
#1d1b16
;
box-shadow
:
0
0
0
2px
rgba
(
29
,
27
,
22
,
0.08
);
}
.inline-math-render
{
display
:
inline-block
;
min-width
:
1em
;
margin
:
0
0.18em
;
padding
:
2px
4px
;
border-radius
:
8px
;
vertical-align
:
middle
;
cursor
:
text
;
}
.inline-math-render
.mq-root-block
{
display
:
inline-block
;
}
.inline-math-render
:focus
{
outline
:
2px
solid
rgba
(
29
,
27
,
22
,
0.18
);
outline-offset
:
2px
;
}
/* ponytail: MathQuill's bundled Symbola font is malformed in current browsers.
Override its font stacks so the browser never tries to load that asset. */
.mq-math-mode
var
,
.mq-math-mode
.mq-text-mode
,
.mq-math-mode
.mq-nonSymbola
,
.mq-math-mode
.mq-font
,
.mq-math-mode
.mq-sans-serif
,
.mq-math-mode
.mq-monospace
,
.mq-math-mode
var
.mq-operator-name
,
.mq-math-mode
.mq-binary-operator
{
font-family
:
"Times New Roman"
,
serif
!important
;
}
.btn
{
.btn
{
border
:
1px
solid
#b9b2a4
;
border
:
1px
solid
#b9b2a4
;
background
:
#ffffff
;
background
:
#ffffff
;
...
...
math-tutor/frontend/src/types/mathquill.d.ts
0 → 100644
View file @
0b77973f
declare
module
"
jquery
"
;
declare
module
"
mathquill/build/mathquill.js
"
;
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment