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
9f6573c0
Commit
9f6573c0
authored
Jun 30, 2026
by
Kantz
Browse files
error with parial loss of message fixed
parent
3f8118b0
Changes
4
Hide whitespace changes
Inline
Side-by-side
math-tutor/frontend/src/components/Chat/ChatWindow.tsx
View file @
9f6573c0
...
...
@@ -8,7 +8,7 @@ type ChatWindowProps = {
messages
:
ChatMessage
[];
draft
:
string
;
onDraftChange
:
(
value
:
string
)
=>
void
;
onSend
:
()
=>
void
;
onSend
:
(
value
:
string
)
=>
void
;
onTip
?:
()
=>
void
;
onRevealSolution
?:
()
=>
void
;
isSending
?:
boolean
;
...
...
math-tutor/frontend/src/components/Chat/MessageInput.tsx
View file @
9f6573c0
import
{
useEffect
,
useRef
,
useState
}
from
"
react
"
;
import
{
useEffect
,
useLayoutEffect
,
useRef
,
useState
}
from
"
react
"
;
import
{
EditPencil
,
Send
,
Upload
}
from
"
iconoir-react
"
;
import
{
t
}
from
"
../../i18n
"
;
import
"
mathquill/build/mathquill.css
"
;
...
...
@@ -6,7 +6,7 @@ import "mathquill/build/mathquill.css";
type
MessageInputProps
=
{
value
:
string
;
onChange
:
(
value
:
string
)
=>
void
;
onSend
:
()
=>
void
;
onSend
:
(
value
:
string
)
=>
void
;
onTip
?:
()
=>
void
;
onRevealSolution
?:
()
=>
void
;
isSending
?:
boolean
;
...
...
@@ -42,6 +42,23 @@ type MathQuillGlobal = {
getInterface
:
(
version
:
number
)
=>
MathQuillInterface
;
};
type
TextPart
=
{
type
:
"
text
"
;
value
:
string
;
};
type
MathPart
=
{
type
:
"
math
"
;
latex
:
string
;
};
type
Part
=
TextPart
|
MathPart
;
type
CaretPosition
=
{
index
:
number
;
offset
:
number
;
};
declare
global
{
interface
Window
{
$
?:
unknown
;
...
...
@@ -50,13 +67,11 @@ declare global {
}
}
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
emptyParts
=
():
Part
[]
=>
[{
type
:
"
text
"
,
value
:
""
}]
;
const
loadMathQuill
=
async
():
Promise
<
MathQuillInterface
>
=>
{
if
(
!
mathQuillLoader
)
{
...
...
@@ -79,161 +94,155 @@ const loadMathQuill = async (): Promise<MathQuillInterface> => {
return
mathQuillLoader
;
};
const
isSelectionInside
=
(
container
:
HTMLElement
,
selection
:
Selection
|
null
)
=>
{
if
(
!
selection
||
selection
.
rangeCount
===
0
)
{
return
false
;
}
return
container
.
contains
(
selection
.
anchorNode
);
};
const
stringifyParts
=
(
parts
:
Part
[])
=>
parts
.
map
((
part
)
=>
(
part
.
type
===
"
math
"
?
`$
${
part
.
latex
}
$`
:
part
.
value
))
.
join
(
""
);
const
placeCaretAfter
=
(
node
:
Node
)
=>
{
const
selection
=
window
.
getSelection
();
if
(
!
selection
)
{
return
;
const
normalizeParts
=
(
parts
:
Part
[]):
Part
[]
=>
{
if
(
!
parts
.
length
)
{
return
emptyParts
();
}
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
normalized
:
Part
[]
=
[];
const
insertLineBreakAtCaret
=
(
editor
:
HTMLElement
)
=>
{
const
selection
=
window
.
getSelection
()
;
if
(
!
selection
)
{
return
;
}
if
(
!
isSelectionInside
(
editor
,
selection
))
{
editor
.
focus
(
);
}
const
pushText
=
(
value
:
string
)
=>
{
const
last
=
normalized
[
normalized
.
length
-
1
]
;
if
(
last
?.
type
===
"
text
"
)
{
last
.
value
+=
value
;
return
;
}
normalized
.
push
({
type
:
"
text
"
,
value
}
);
}
;
const
activeSelection
=
window
.
getSelection
();
if
(
!
activeSelection
||
activeSelection
.
rangeCount
===
0
)
{
return
;
}
parts
.
forEach
((
part
,
partIndex
)
=>
{
if
(
part
.
type
===
"
text
"
)
{
pushText
(
part
.
value
);
return
;
}
const
range
=
activeSelection
.
getRangeAt
(
0
);
range
.
deleteContents
();
const
previous
=
normalized
[
normalized
.
length
-
1
];
if
(
!
previous
||
previous
.
type
!==
"
text
"
)
{
normalized
.
push
({
type
:
"
text
"
,
value
:
""
});
}
const
br
=
document
.
createElement
(
"
br
"
);
const
spacer
=
document
.
createTextNode
(
ZERO_WIDTH_SPACE
);
range
.
insertNode
(
spacer
);
range
.
insertNode
(
br
);
normalized
.
push
(
part
);
const
nextRange
=
document
.
createRange
();
nextRange
.
setStart
(
spacer
,
spacer
.
textContent
?.
length
??
0
);
nextRange
.
collapse
(
true
);
activeSelection
.
removeAllRanges
();
activeSelection
.
addRange
(
nextRange
);
};
const
nextInput
=
parts
[
partIndex
+
1
];
if
(
!
nextInput
||
nextInput
.
type
!==
"
text
"
)
{
normalized
.
push
({
type
:
"
text
"
,
value
:
""
});
}
});
const
isCaretAtStart
=
(
editor
:
HTMLElement
)
=>
{
const
selection
=
window
.
getSelection
();
if
(
!
selection
||
!
selection
.
isCollapsed
||
!
isSelectionInside
(
editor
,
selection
))
{
return
false
;
if
(
!
normalized
.
length
)
{
return
emptyParts
();
}
const
range
=
selection
.
getRangeAt
(
0
).
cloneRange
();
const
prefixRange
=
document
.
createRange
();
prefixRange
.
selectNodeContents
(
editor
);
prefixRange
.
setEnd
(
range
.
startContainer
,
range
.
startOffset
);
if
(
normalized
[
0
].
type
!==
"
text
"
)
{
normalized
.
unshift
({
type
:
"
text
"
,
value
:
""
});
}
if
(
normalized
[
normalized
.
length
-
1
].
type
!==
"
text
"
)
{
normalized
.
push
({
type
:
"
text
"
,
value
:
""
});
}
const
fragment
=
prefixRange
.
cloneContents
();
if
(
fragment
.
querySelector
(
INLINE_MATH_SELECTOR
))
{
return
false
;
return
normalized
;
};
const
partsFromValue
=
(
value
:
string
):
Part
[]
=>
{
const
parts
:
Part
[]
=
[];
let
lastIndex
=
0
;
for
(
const
match
of
value
.
matchAll
(
MATH_SEGMENT_PATTERN
))
{
const
fullMatch
=
match
[
0
];
const
index
=
match
.
index
??
0
;
parts
.
push
({
type
:
"
text
"
,
value
:
value
.
slice
(
lastIndex
,
index
)
});
parts
.
push
({
type
:
"
math
"
,
latex
:
fullMatch
.
slice
(
1
,
-
1
)
});
lastIndex
=
index
+
fullMatch
.
length
;
}
return
prefixRange
.
toString
().
replaceAll
(
ZERO_WIDTH_SPACE
,
""
).
length
===
0
;
parts
.
push
({
type
:
"
text
"
,
value
:
value
.
slice
(
lastIndex
)
});
return
normalizeParts
(
parts
);
};
const
serializeEditor
=
(
root
:
HTMLElement
)
=>
{
const
parts
:
string
[]
=
[];
const
getSelectionOffsets
=
(
element
:
HTMLElement
)
=>
{
const
selection
=
window
.
getSelection
();
if
(
!
selection
||
selection
.
rangeCount
===
0
||
!
element
.
contains
(
selection
.
anchorNode
))
{
const
length
=
element
.
textContent
?.
length
??
0
;
return
{
start
:
length
,
end
:
length
};
}
const
walk
=
(
node
:
Node
)
=>
{
if
(
node
.
nodeType
===
Node
.
TEXT_NODE
)
{
parts
.
push
((
node
.
textContent
||
""
).
replaceAll
(
ZERO_WIDTH_SPACE
,
""
));
return
;
}
const
range
=
selection
.
getRangeAt
(
0
);
const
startRange
=
range
.
cloneRange
();
startRange
.
selectNodeContents
(
element
);
startRange
.
setEnd
(
range
.
startContainer
,
range
.
startOffset
);
if
(
!
(
node
instanceof
HTMLElement
))
{
return
;
}
const
endRange
=
range
.
cloneRange
();
endRange
.
selectNodeContents
(
element
)
;
endRange
.
setEnd
(
range
.
endContainer
,
range
.
endOffset
);
if
(
node
.
matches
(
"
.inline-math-render
"
))
{
parts
.
push
(
`$
${
node
.
dataset
.
latex
||
""
}
$`
);
return
;
}
return
{
start
:
startRange
.
toString
().
length
,
end
:
endRange
.
toString
().
length
,
};
};
if
(
node
.
matches
(
"
.inline-math
"
))
{
const
field
=
activeMathFields
.
get
(
node
);
const
latex
=
field
?.
latex
()
||
node
.
dataset
.
latex
||
""
;
parts
.
push
(
`$
${
latex
}
$`
);
return
;
}
const
setCaretOffset
=
(
element
:
HTMLElement
,
offset
:
number
)
=>
{
const
selection
=
window
.
getSelection
();
if
(
!
selection
)
{
return
;
}
if
(
node
.
tagName
===
"
BR
"
)
{
parts
.
push
(
"
\n
"
);
const
range
=
document
.
createRange
();
const
walker
=
document
.
createTreeWalker
(
element
,
NodeFilter
.
SHOW_TEXT
);
let
remaining
=
offset
;
let
currentNode
=
walker
.
nextNode
();
while
(
currentNode
)
{
const
textLength
=
currentNode
.
textContent
?.
length
??
0
;
if
(
remaining
<=
textLength
)
{
range
.
setStart
(
currentNode
,
remaining
);
range
.
collapse
(
true
);
selection
.
removeAllRanges
();
selection
.
addRange
(
range
);
return
;
}
remaining
-=
textLength
;
currentNode
=
walker
.
nextNode
();
}
Array
.
from
(
node
.
childNodes
).
forEach
(
walk
);
};
Array
.
from
(
root
.
childNodes
).
forEach
(
walk
);
return
parts
.
join
(
""
);
range
.
selectNodeContents
(
element
);
range
.
collapse
(
false
);
selection
.
removeAllRanges
();
selection
.
addRange
(
range
);
};
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
findNearestTextIndex
=
(
parts
:
Part
[],
start
:
number
,
direction
:
-
1
|
1
)
=>
{
let
index
=
start
;
while
(
index
>=
0
&&
index
<
parts
.
length
)
{
if
(
parts
[
index
]?.
type
===
"
text
"
)
{
return
index
;
}
index
+=
direction
;
}
return
direction
>
0
?
parts
.
length
-
1
:
0
;
};
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
"
));
}
});
const
clampCaret
=
(
parts
:
Part
[],
caret
:
CaretPosition
):
CaretPosition
=>
{
const
index
=
findNearestTextIndex
(
parts
,
caret
.
index
,
caret
.
index
>=
parts
.
length
?
-
1
:
1
);
const
part
=
parts
[
index
];
const
maxOffset
=
part
?.
type
===
"
text
"
?
part
.
value
.
length
:
0
;
return
{
index
,
offset
:
Math
.
max
(
0
,
Math
.
min
(
caret
.
offset
,
maxOffset
)),
};
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
);
const
isCaretAtStart
=
(
parts
:
Part
[],
index
:
number
,
element
:
HTMLElement
)
=>
{
const
{
start
,
end
}
=
getSelectionOffsets
(
element
);
if
(
start
!==
0
||
end
!==
0
)
{
return
false
;
}
return
stringifyParts
(
parts
.
slice
(
0
,
index
)).
length
===
0
;
};
export
default
function
MessageInput
({
...
...
@@ -247,194 +256,233 @@ export default function MessageInput({
onToggleCanvas
,
onUploadSolution
,
}:
MessageInputProps
)
{
const
canSend
=
value
.
trim
().
length
>
0
;
const
uploadInputRef
=
useRef
<
HTMLInputElement
|
null
>
(
null
);
const
editorRef
=
useRef
<
HTMLDivElement
|
null
>
(
null
);
const
textPartRefs
=
useRef
(
new
Map
<
number
,
HTMLSpanElement
>
());
const
staticMathRefs
=
useRef
(
new
Map
<
number
,
HTMLSpanElement
>
());
const
activeMathHostRef
=
useRef
<
HTMLSpanElement
|
null
>
(
null
);
const
activeFieldRef
=
useRef
<
MathFieldLike
|
null
>
(
null
);
const
mqRef
=
useRef
<
MathQuillInterface
|
null
>
(
null
);
const
wasSendingRef
=
useRef
(
isSending
);
const
shouldRestoreFocusRef
=
useRef
(
false
);
const
sendButtonStartedFromEditorRef
=
useRef
(
false
);
const
currentValueRef
=
useRef
(
value
);
const
mqRef
=
useRef
<
MathQuillInterface
|
null
>
(
null
);
const
[
isMathBoxActive
,
setIsMathBoxActive
]
=
useState
(
false
);
const
pendingCaretRef
=
useRef
<
CaretPosition
|
null
>
(
null
);
const
selectionRef
=
useRef
<
CaretPosition
>
({
index
:
0
,
offset
:
0
});
const
activeMathIndexRef
=
useRef
<
number
|
null
>
(
null
);
const
partsRef
=
useRef
<
Part
[]
>
(
partsFromValue
(
value
));
const
[
parts
,
setParts
]
=
useState
<
Part
[]
>
(
partsRef
.
current
);
const
[
activeMathIndex
,
setActiveMathIndex
]
=
useState
<
number
|
null
>
(
null
);
const
message
=
stringifyParts
(
parts
);
const
canSend
=
message
.
trim
().
length
>
0
;
const
commitParts
=
(
nextPartsInput
:
Part
[],
options
:
{
emit
?:
boolean
;
caret
?:
CaretPosition
|
null
;
activeMath
?:
number
|
null
;
}
=
{}
)
=>
{
const
previousMessage
=
stringifyParts
(
partsRef
.
current
);
const
nextParts
=
normalizeParts
(
nextPartsInput
);
const
nextMessage
=
stringifyParts
(
nextParts
);
partsRef
.
current
=
nextParts
;
setParts
(
nextParts
);
if
(
options
.
caret
)
{
pendingCaretRef
.
current
=
clampCaret
(
nextParts
,
options
.
caret
);
}
const
syncEditorToValue
=
async
(
nextValue
:
string
)
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
{
if
(
options
.
activeMath
!==
undefined
)
{
activeMathIndexRef
.
current
=
options
.
activeMath
;
setActiveMathIndex
(
options
.
activeMath
);
}
if
(
options
.
emit
!==
false
&&
nextMessage
!==
previousMessage
)
{
onChange
(
nextMessage
);
}
};
const
finalizeActiveMathField
=
()
=>
{
const
field
=
activeFieldRef
.
current
;
const
index
=
activeMathIndexRef
.
current
;
if
(
!
field
||
index
===
null
)
{
return
;
}
const
MQ
=
mqRef
.
current
??
(
await
loadMathQuill
());
mqRef
.
current
=
MQ
;
renderValueIntoEditor
(
editor
,
MQ
,
nextValue
);
const
latex
=
field
.
latex
();
const
nextParts
=
[...
partsRef
.
current
];
nextParts
[
index
]
=
latex
?
{
type
:
"
math
"
,
latex
}
:
{
type
:
"
text
"
,
value
:
""
};
const
caret
=
latex
?
{
index
:
index
+
1
,
offset
:
0
}
:
{
index
,
offset
:
0
};
activeFieldRef
.
current
=
null
;
commitParts
(
nextParts
,
{
caret
,
activeMath
:
null
});
};
const
commitEditorValue
=
()
=>
{
useEffect
(()
=>
{
activeMathIndexRef
.
current
=
activeMathIndex
;
},
[
activeMathIndex
]);
useEffect
(()
=>
{
partsRef
.
current
=
parts
;
},
[
parts
]);
useEffect
(()
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
{
return
;
}
const
nextValue
=
serializeEditor
(
editor
);
if
(
nextV
alue
===
currentValue
Ref
.
current
)
{
if
(
v
alue
===
stringifyParts
(
parts
Ref
.
current
)
)
{
return
;
}
currentValueRef
.
current
=
nextValue
;
onChange
(
nextValue
);
};
const
wireMathField
=
async
(
element
:
HTMLElement
,
latex
:
string
)
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
{
return
null
;
if
(
activeMathIndexRef
.
current
!==
null
||
editor
.
contains
(
document
.
activeElement
))
{
return
;
}
const
MQ
=
mqRef
.
current
??
(
await
loadMathQuill
());
mqRef
.
current
=
MQ
;
const
nextParts
=
partsFromValue
(
value
);
partsRef
.
current
=
nextParts
;
setParts
(
nextParts
);
},
[
value
]);
let
field
:
MathFieldLike
|
null
=
null
;
const
finalizeMathField
=
()
=>
{
if
(
!
field
)
{
return
null
;
}
const
fieldElement
=
field
.
el
();
if
(
!
fieldElement
.
isConnected
)
{
return
null
;
useEffect
(()
=>
{
let
cancelled
=
false
;
const
renderStaticMath
=
async
()
=>
{
const
MQ
=
mqRef
.
current
??
(
await
loadMathQuill
());
if
(
cancelled
)
{
return
;
}
const
rendered
=
createRenderedMathNode
(
MQ
,
field
.
latex
());
rendered
.
addEventListener
(
"
click
"
,
()
=>
{
void
activateRenderedMath
(
rendered
);
});
rendered
.
addEventListener
(
"
keydown
"
,
(
event
)
=>
{
if
(
event
.
key
!==
"
Enter
"
&&
event
.
key
!==
"
"
)
{
mqRef
.
current
=
MQ
;
parts
.
forEach
((
part
,
index
)
=>
{
if
(
part
.
type
!==
"
math
"
||
index
===
activeMathIndex
)
{
return
;
}
event
.
preventDefault
();
void
activateRenderedMath
(
rendered
);
const
node
=
staticMathRefs
.
current
.
get
(
index
);
if
(
!
node
||
node
.
dataset
.
renderedLatex
===
part
.
latex
)
{
return
;
}
node
.
dataset
.
latex
=
part
.
latex
;
node
.
dataset
.
renderedLatex
=
part
.
latex
;
node
.
textContent
=
part
.
latex
||
"
\\
square
"
;
MQ
.
StaticMath
(
node
);
});
fieldElement
.
replaceWith
(
rendered
);
activeMathFields
.
delete
(
fieldElement
);
field
.
revert
();
setIsMathBoxActive
(
false
);
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
);
}
},
},
});
void
renderStaticMath
();
activeMathFields
.
set
(
element
,
field
);
field
.
latex
(
latex
)
;
element
.
dataset
.
latex
=
latex
;
element
.
dataset
.
empty
=
latex
?
"
false
"
:
"
true
"
;
return
()
=>
{
cancelled
=
true
;
}
;
},
[
parts
,
activeMathIndex
])
;
const
root
=
field
.
el
();
root
.
addEventListener
(
"
focusin
"
,
()
=>
{
setIsMathBoxActive
(
true
);
});
root
.
addEventListener
(
"
keydown
"
,
(
event
)
=>
{
if
(
event
.
key
!==
"
Enter
"
)
{
return
;
}
event
.
preventDefault
();
const
rendered
=
finalizeMathField
();
if
(
rendered
)
{
exitMathField
(
editor
,
rendered
);
}
});
root
.
addEventListener
(
"
focusout
"
,
()
=>
{
useEffect
(()
=>
{
if
(
activeMathIndex
===
null
)
{
return
;
}
const
host
=
activeMathHostRef
.
current
;
if
(
!
host
)
{
return
;
}
let
disposed
=
false
;
let
field
:
MathFieldLike
|
null
=
null
;
const
handleFocusOut
=
()
=>
{
requestAnimationFrame
(()
=>
{
if
(
roo
t
.
contains
(
document
.
activeElement
))
{
if
(
hos
t
.
contains
(
document
.
activeElement
))
{
return
;
}
setIsMathBoxActive
(
false
);
finalizeMathField
();
finalizeActiveMathField
();
});
});
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
;
}
const
handleKeyDown
=
(
event
:
KeyboardEvent
)
=>
{
if
(
event
.
key
!==
"
Enter
"
&&
event
.
key
!==
"
$
"
)
{
return
;
}
event
.
preventDefault
();
event
.
stopPropagation
();
finalizeActiveMathField
();
};
if
(
!
isSelectionInside
(
editor
,
selection
))
{
editor
.
focus
();
placeCaretAtStart
(
editor
);
}
void
(
async
()
=>
{
const
MQ
=
mqRef
.
current
??
(
await
loadMathQuill
());
if
(
disposed
)
{
return
;
}
const
activeSelection
=
window
.
getSelection
();
if
(
!
activeSelection
||
activeSelection
.
rangeCount
===
0
)
{
return
null
;
}
mqRef
.
current
=
MQ
;
field
=
MQ
.
MathField
(
host
,
{
handlers
:
{
edit
:
(
mathField
)
=>
{
const
nextLatex
=
mathField
.
latex
();
mathField
.
el
().
dataset
.
empty
=
nextLatex
?
"
false
"
:
"
true
"
;
mathField
.
el
().
dataset
.
latex
=
nextLatex
;
},
enter
:
()
=>
{
finalizeActiveMathField
();
},
},
});
const
range
=
activeSelection
.
getRangeAt
(
0
);
range
.
deleteContents
();
activeFieldRef
.
current
=
field
;
const
mathfield
=
document
.
createElement
(
"
span
"
);
mathfield
.
className
=
"
inline-math
"
;
const
spacer
=
document
.
createTextNode
(
"
\
u00a0
"
);
range
.
insertNode
(
spacer
);
range
.
insertNode
(
mathfield
);
const
part
=
partsRef
.
current
[
activeMathIndex
];
const
latex
=
part
?.
type
===
"
math
"
?
part
.
latex
:
""
;
field
.
latex
(
latex
);
host
.
dataset
.
empty
=
latex
?
"
false
"
:
"
true
"
;
host
.
dataset
.
latex
=
latex
;
host
.
addEventListener
(
"
focusout
"
,
handleFocusOut
);
host
.
addEventListener
(
"
keydown
"
,
handleKeyDown
);
requestAnimationFrame
(()
=>
field
?.
focus
());
})();
const
field
=
await
wireMathField
(
mathfield
,
""
);
commitEditorValue
();
requestAnimationFrame
(()
=>
field
?.
focus
());
return
field
;
};
return
()
=>
{
disposed
=
true
;
activeFieldRef
.
current
=
null
;
if
(
field
)
{
host
.
removeEventListener
(
"
focusout
"
,
handleFocusOut
);
host
.
removeEventListener
(
"
keydown
"
,
handleKeyDown
);
field
.
revert
();
}
};
},
[
activeMathIndex
]);
useEffect
(()
=>
{
currentValueRef
.
current
=
value
;
},
[
value
]);
useLayoutEffect
(()
=>
{
if
(
activeMathIndex
!==
null
)
{
return
;
}
useEffect
(()
=>
{
const
editor
=
editorRef
.
current
;
if
(
!
editor
)
{
const
pendingCaret
=
pendingCaretRef
.
current
;
if
(
!
pendingCaret
)
{
return
;
}
const
currentSerialized
=
serializeEditor
(
editor
);
if
(
currentSerialized
===
value
)
{
const
target
=
textPartRefs
.
current
.
get
(
pendingCaret
.
index
);
if
(
!
target
)
{
return
;
}
void
syncEditorToValue
(
value
);
},
[
value
]);
target
.
focus
();
setCaretOffset
(
target
,
pendingCaret
.
offset
);
selectionRef
.
current
=
pendingCaret
;
pendingCaretRef
.
current
=
null
;
},
[
parts
,
activeMathIndex
]);
useEffect
(()
=>
{
if
(
wasSendingRef
.
current
&&
!
isSending
)
{
if
(
shouldRestoreFocusRef
.
current
)
{
editorRef
.
current
?.
focus
({
preventScroll
:
true
});
const
target
=
textPartRefs
.
current
.
get
(
selectionRef
.
current
.
index
);
target
?.
focus
({
preventScroll
:
true
});
}
shouldRestoreFocusRef
.
current
=
false
;
}
...
...
@@ -455,9 +503,82 @@ export default function MessageInput({
await
onUploadSolution
?.(
file
);
};
const
handleSend
=
(
restoreFocus
=
document
.
activeElement
===
editorRef
.
current
)
=>
{
shouldRestoreFocusRef
.
current
=
restoreFocus
;
onSend
();
const
updateSelection
=
(
index
:
number
,
element
:
HTMLElement
)
=>
{
const
{
end
}
=
getSelectionOffsets
(
element
);
selectionRef
.
current
=
{
index
,
offset
:
end
};
};
const
handleTextInput
=
(
index
:
number
,
element
:
HTMLElement
)
=>
{
const
nextParts
=
[...
partsRef
.
current
];
const
current
=
nextParts
[
index
];
if
(
!
current
||
current
.
type
!==
"
text
"
)
{
return
;
}
const
{
end
}
=
getSelectionOffsets
(
element
);
current
.
value
=
element
.
textContent
??
""
;
commitParts
(
nextParts
,
{
caret
:
{
index
,
offset
:
end
}
});
};
const
replaceTextSelection
=
(
index
:
number
,
text
:
string
)
=>
{
const
element
=
textPartRefs
.
current
.
get
(
index
);
const
current
=
partsRef
.
current
[
index
];
if
(
!
element
||
!
current
||
current
.
type
!==
"
text
"
)
{
return
;
}
const
{
start
,
end
}
=
getSelectionOffsets
(
element
);
const
nextValue
=
`
${
current
.
value
.
slice
(
0
,
start
)}${
text
}${
current
.
value
.
slice
(
end
)}
`
;
const
nextParts
=
[...
partsRef
.
current
];
nextParts
[
index
]
=
{
type
:
"
text
"
,
value
:
nextValue
};
commitParts
(
nextParts
,
{
caret
:
{
index
,
offset
:
start
+
text
.
length
}
});
};
const
insertMathAtSelection
=
(
index
?:
number
)
=>
{
const
fallbackIndex
=
findNearestTextIndex
(
partsRef
.
current
,
partsRef
.
current
.
length
-
1
,
-
1
);
const
targetIndex
=
index
??
selectionRef
.
current
.
index
??
fallbackIndex
;
const
element
=
textPartRefs
.
current
.
get
(
targetIndex
);
const
current
=
partsRef
.
current
[
targetIndex
];
if
(
!
current
||
current
.
type
!==
"
text
"
)
{
return
;
}
const
selection
=
element
?
getSelectionOffsets
(
element
)
:
{
start
:
current
.
value
.
length
,
end
:
current
.
value
.
length
};
const
before
=
current
.
value
.
slice
(
0
,
selection
.
start
);
const
after
=
current
.
value
.
slice
(
selection
.
end
);
const
nextParts
=
[
...
partsRef
.
current
.
slice
(
0
,
targetIndex
),
{
type
:
"
text
"
,
value
:
before
}
as
TextPart
,
{
type
:
"
math
"
,
latex
:
""
}
as
MathPart
,
{
type
:
"
text
"
,
value
:
after
}
as
TextPart
,
...
partsRef
.
current
.
slice
(
targetIndex
+
1
),
];
commitParts
(
nextParts
,
{
activeMath
:
targetIndex
+
1
,
emit
:
false
});
};
const
activateMathPart
=
(
index
:
number
)
=>
{
if
(
isSending
)
{
return
;
}
commitParts
(
partsRef
.
current
,
{
activeMath
:
index
,
emit
:
false
});
};
const
handleSend
=
()
=>
{
finalizeActiveMathField
();
const
nextMessage
=
stringifyParts
(
partsRef
.
current
);
if
(
!
nextMessage
.
trim
())
{
return
;
}
shouldRestoreFocusRef
.
current
=
editorRef
.
current
?.
contains
(
document
.
activeElement
)
??
false
;
onSend
(
nextMessage
);
commitParts
(
emptyParts
(),
{
caret
:
{
index
:
0
,
offset
:
0
},
emit
:
true
,
activeMath
:
null
,
});
};
return
(
...
...
@@ -514,12 +635,12 @@ export default function MessageInput({
{
t
(
"
revealSolution
"
)
}
</
button
>
)
:
null
}
{
!
isMathBoxActive
?
(
{
activeMathIndex
===
null
?
(
<
button
className
=
"btn btn-formula"
type
=
"button"
onClick
=
{
()
=>
{
void
insertMath
FieldAtCaret
();
insertMath
AtSelection
();
}
}
disabled
=
{
isSending
}
aria-label
=
{
t
(
"
formula
"
)
}
...
...
@@ -532,10 +653,10 @@ export default function MessageInput({
className
=
"btn primary"
type
=
"button"
onPointerDown
=
{
()
=>
{
sendButtonStartedFromEditorRef
.
current
=
document
.
activeElement
===
editorRef
.
current
;
sendButtonStartedFromEditorRef
.
current
=
editorRef
.
current
?.
contains
(
document
.
activeElement
)
??
false
;
}
}
onClick
=
{
()
=>
{
handleSend
(
sendButtonStartedFromEditorRef
.
current
);
handleSend
();
sendButtonStartedFromEditorRef
.
current
=
false
;
}
}
disabled
=
{
!
canSend
||
isSending
}
...
...
@@ -556,54 +677,125 @@ export default function MessageInput({
<
div
ref
=
{
editorRef
}
className
=
{
`composer-input composer-rich-input
${
isSending
?
"
composer-input-disabled
"
:
""
}
`
}
contentEditable
=
{
!
isSending
}
suppressContentEditableWarning
data-placeholder
=
{
t
(
"
typeQuestionOrLatex
"
)
}
data-empty
=
{
message
.
length
===
0
?
"
true
"
:
"
false
"
}
aria-label
=
{
t
(
"
typeQuestionOrLatex
"
)
}
role
=
"textbox"
aria-multiline
=
"true"
onInput
=
{
()
=>
{
commitEditorValue
();
}
}
onKeyDown
=
{
(
event
)
=>
{
if
(
isSending
)
{
event
.
preventDefault
();
return
;
}
if
(
event
.
key
===
"
$
"
)
{
event
.
preventDefault
();
void
insertMathFieldAtCaret
();
return
;
>
{
parts
.
map
((
part
,
index
)
=>
{
if
(
part
.
type
===
"
text
"
)
{
return
(
<
span
key
=
{
`text-
${
index
}
`
}
ref
=
{
(
node
)
=>
{
if
(
node
)
{
textPartRefs
.
current
.
set
(
index
,
node
);
return
;
}
textPartRefs
.
current
.
delete
(
index
);
}
}
className
=
"composer-text-part"
contentEditable
=
{
!
isSending
&&
activeMathIndex
===
null
}
suppressContentEditableWarning
data-part-index
=
{
index
}
spellCheck
=
{
false
}
onFocus
=
{
(
event
)
=>
{
updateSelection
(
index
,
event
.
currentTarget
);
}
}
onMouseUp
=
{
(
event
)
=>
{
updateSelection
(
index
,
event
.
currentTarget
);
}
}
onKeyUp
=
{
(
event
)
=>
{
updateSelection
(
index
,
event
.
currentTarget
);
}
}
onInput
=
{
(
event
)
=>
{
handleTextInput
(
index
,
event
.
currentTarget
);
}
}
onKeyDown
=
{
(
event
)
=>
{
if
(
isSending
)
{
event
.
preventDefault
();
return
;
}
if
(
event
.
key
===
"
$
"
)
{
event
.
preventDefault
();
insertMathAtSelection
(
index
);
return
;
}
if
(
event
.
key
===
"
ArrowUp
"
&&
isCaretAtStart
(
partsRef
.
current
,
index
,
event
.
currentTarget
))
{
if
(
onHistoryNavigate
?.(
"
older
"
))
{
event
.
preventDefault
();
return
;
}
}
if
(
event
.
key
===
"
ArrowDown
"
&&
isCaretAtStart
(
partsRef
.
current
,
index
,
event
.
currentTarget
))
{
if
(
onHistoryNavigate
?.(
"
newer
"
))
{
event
.
preventDefault
();
return
;
}
}
if
(
event
.
key
===
"
Enter
"
&&
event
.
shiftKey
)
{
event
.
preventDefault
();
replaceTextSelection
(
index
,
"
\n
"
);
return
;
}
if
(
event
.
key
===
"
Enter
"
)
{
event
.
preventDefault
();
handleSend
();
}
}
}
>
{
part
.
value
}
</
span
>
);
}
if
(
event
.
key
===
"
ArrowUp
"
&&
isCaretAtStart
(
event
.
currentTarget
))
{
if
(
onHistoryNavigate
?.(
"
older
"
))
{
event
.
preventDefault
();
return
;
}
if
(
index
===
activeMathIndex
)
{
return
(
<
span
key
=
{
`math-active-
${
index
}
`
}
ref
=
{
activeMathHostRef
}
className
=
"inline-math"
data-empty
=
{
part
.
latex
?
"
false
"
:
"
true
"
}
data-latex
=
{
part
.
latex
}
contentEditable
=
{
false
}
/>
);
}
if
(
event
.
key
===
"
ArrowDown
"
&&
isCaretAtStart
(
event
.
currentTarget
))
{
if
(
onHistoryNavigate
?.(
"
newer
"
))
{
event
.
preventDefault
();
return
;
}
}
if
(
event
.
key
===
"
Enter
"
&&
event
.
shiftKey
)
{
event
.
preventDefault
();
insertLineBreakAtCaret
(
event
.
currentTarget
);
commitEditorValue
();
return
;
}
if
(
event
.
key
===
"
Enter
"
)
{
event
.
preventDefault
();
handleSend
(
true
);
}
}
}
/>
return
(
<
span
key
=
{
`math-
${
index
}
`
}
ref
=
{
(
node
)
=>
{
if
(
node
)
{
staticMathRefs
.
current
.
set
(
index
,
node
);
return
;
}
staticMathRefs
.
current
.
delete
(
index
);
}
}
className
=
"inline-math-render"
data-latex
=
{
part
.
latex
}
contentEditable
=
{
false
}
tabIndex
=
{
isSending
?
-
1
:
0
}
onClick
=
{
()
=>
{
activateMathPart
(
index
);
}
}
onKeyDown
=
{
(
event
)
=>
{
if
(
event
.
key
!==
"
Enter
"
&&
event
.
key
!==
"
"
)
{
return
;
}
event
.
preventDefault
();
activateMathPart
(
index
);
}
}
/>
);
})
}
</
div
>
</
div
>
);
}
math-tutor/frontend/src/pages/ChatPage.tsx
View file @
9f6573c0
...
...
@@ -835,9 +835,9 @@ export default function ChatPage() {
setRetrievalLoading
(
false
);
};
const
handleSend
=
async
()
=>
{
const
handleSend
=
async
(
value
:
string
)
=>
{
await
sendMessage
({
text
:
draft
,
text
:
value
,
clearDraft
:
true
,
});
};
...
...
math-tutor/frontend/src/styles/theme.css
View file @
9f6573c0
...
...
@@ -586,6 +586,7 @@ body {
}
.composer-rich-input
{
position
:
relative
;
min-height
:
92px
;
line-height
:
1.6
;
white-space
:
pre-wrap
;
...
...
@@ -594,9 +595,20 @@ body {
cursor
:
text
;
}
.composer-rich-input
[
data-empty
=
"true"
]
::before
,
.composer-rich-input
:empty::before
{
content
:
attr
(
data-placeholder
);
color
:
#8b857b
;
pointer-events
:
none
;
}
.composer-text-part
{
display
:
inline
;
outline
:
none
;
}
.composer-text-part
:empty::before
{
content
:
"\200b"
;
}
.composer-rich-input
:focus
{
...
...
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