Commit 0b77973f authored by Kantz's avatar Kantz
Browse files

base Mathquill implementation

parent 590d71a3
...@@ -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",
......
...@@ -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
......
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 textareaRef = useRef<HTMLTextAreaElement | null>(null); const editorRef = useRef<HTMLDivElement | 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) {
textareaRef.current?.focus({ preventScroll: true }); editorRef.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 === textareaRef.current) => { const handleSend = (restoreFocus = document.activeElement === editorRef.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(sendButtonStartedFromTextareaRef.current); handleSend(sendButtonStartedFromEditorRef.current);
sendButtonStartedFromTextareaRef.current = false; sendButtonStartedFromEditorRef.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);
} }
......
...@@ -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",
......
...@@ -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;
......
declare module "jquery";
declare module "mathquill/build/mathquill.js";
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