{ setLatex(value); setError(null); }}
+ onLoadError={() => { setInputMode("source"); setError(t("mathFormulaDialog.loadError")); }}
+ />
+ ) : null) : (
+ )}
+ {inputMode === "source" && incomplete && {t("mathFormulaDialog.incomplete")}
}
@@ -189,7 +203,7 @@ export const MathFormulaDialog = ({
-
diff --git a/apps/web/src/components/editor/MathVisualInput.tsx b/apps/web/src/components/editor/MathVisualInput.tsx
new file mode 100644
index 00000000..38749ded
--- /dev/null
+++ b/apps/web/src/components/editor/MathVisualInput.tsx
@@ -0,0 +1,121 @@
+import { useEffect, useRef, useState } from "react";
+import type { MathfieldElement } from "mathlive";
+import "./math-visual-input.css";
+import placeholderStyles from "./mathlive-placeholder.css?inline";
+
+export const MathVisualInput = ({
+ id, value, label, loadingLabel, language, hint, onChange, onLoadError,
+}: {
+ id: string;
+ value: string;
+ label: string;
+ loadingLabel: string;
+ language: string;
+ hint?: string;
+ onChange: (value: string) => void;
+ onLoadError: () => void;
+}) => {
+ const hostRef = useRef(null);
+ const keyboardHostRef = useRef(null);
+ const fieldRef = useRef(null);
+ const latestRef = useRef({ value, onChange, onLoadError });
+ latestRef.current = { value, onChange, onLoadError };
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ let dispose: (() => void) | undefined;
+ void import("./mathlive-loader").then(({ MathfieldElement, convertLatexToMarkup }) => {
+ const host = hostRef.current;
+ const keyboardHost = keyboardHostRef.current;
+ if (cancelled || !host || !keyboardHost) return;
+ MathfieldElement.locale = language.toLowerCase();
+ const field = new MathfieldElement();
+ field.id = id;
+ field.setAttribute("aria-label", label);
+ field.mathVirtualKeyboardPolicy = "manual";
+ field.placeholderSymbol = "\u25a2";
+ field.setValue(latestRef.current.value, { silenceNotifications: true });
+ // Expand MathLive-specific macros (e.g. imaginaryI) into portable LaTeX
+ // before passing it to KaTeX or persisting it in the document.
+ const handleInput = () => latestRef.current.onChange(field.getValue("latex-expanded"));
+ field.addEventListener("input", handleInput);
+ host.appendChild(field);
+ const placeholderStyle = document.createElement("style");
+ placeholderStyle.textContent = placeholderStyles;
+ field.shadowRoot?.appendChild(placeholderStyle);
+ // The pinned MathLive version emits slots as ordinary glyph spans rather
+ // than exposing a CSS part. Mark only its dedicated placeholder symbol.
+ const markInputSlots = () => {
+ field.shadowRoot?.querySelectorAll("span[data-atom-id]").forEach((element) => {
+ if (element.children.length === 0 && element.textContent === "\u25a2") {
+ element.classList.add("edgeever-math-input-slot");
+ }
+ });
+ };
+ const slotObserver = new MutationObserver(markInputSlots);
+ if (field.shadowRoot) slotObserver.observe(field.shadowRoot, { childList: true, subtree: true });
+ markInputSlots();
+ field.menuItems = [];
+ fieldRef.current = field;
+ if (field.getValue("latex-expanded") !== latestRef.current.value) handleInput();
+
+ // Keep the keyboard inside Radix's modal focus/pointer boundary.
+ const keyboard = window.mathVirtualKeyboard;
+ const originalLayouts = keyboard.layouts;
+ const keyLabels: Record = {
+ "#@^2}": "\\square^2",
+ "#@^{#0}}": "\\square^{\\square}",
+ "\\sqrt{#0}": "\\sqrt{\\square}",
+ };
+ keyboard.layouts = keyboard.normalizedLayouts.map((layout) => ({
+ ...layout,
+ layers: layout.layers.map((layer) => ({
+ ...layer,
+ rows: layer.rows?.map((row) => row.map((key) => {
+ const label = key.latex && keyLabels[key.latex];
+ return label ? { ...key, label: convertLatexToMarkup(label) } : key;
+ })),
+ })),
+ }));
+ keyboard.container = keyboardHost;
+ const resizeKeyboard = () => {
+ keyboardHost.style.height = `${keyboard.boundingRect.height}px`;
+ };
+ keyboard.addEventListener("geometrychange", resizeKeyboard);
+ dispose = () => {
+ slotObserver.disconnect();
+ keyboard.removeEventListener("geometrychange", resizeKeyboard);
+ keyboard.hide();
+ keyboard.layouts = originalLayouts;
+ keyboard.container = null;
+ field.removeEventListener("input", handleInput);
+ field.remove();
+ fieldRef.current = null;
+ };
+ setReady(true);
+ field.focus();
+ keyboard.show();
+ resizeKeyboard();
+ }).catch((error: unknown) => {
+ dispose?.();
+ console.error("Failed to load the formula editor", error);
+ if (!cancelled) latestRef.current.onLoadError();
+ });
+ return () => { cancelled = true; dispose?.(); };
+ }, [id, label, language]);
+
+ useEffect(() => {
+ const field = fieldRef.current;
+ if (field && field.getValue("latex-expanded") !== value) field.setValue(value, { silenceNotifications: true });
+ }, [value, ready]);
+
+ return (
+
+ {!ready &&
{loadingLabel}
}
+
+ {hint &&
{hint}
}
+
+
+ );
+};
diff --git a/apps/web/src/components/editor/math-formula-preview.test.mjs b/apps/web/src/components/editor/math-formula-preview.test.mjs
new file mode 100644
index 00000000..36144ccc
--- /dev/null
+++ b/apps/web/src/components/editor/math-formula-preview.test.mjs
@@ -0,0 +1,15 @@
+import { expect, test } from "bun:test";
+import { hasMathPlaceholders, renderMathPreview } from "./math-formula-preview.ts";
+
+test("MathLive's unfinished square and root render without becoming valid saved formulas", () => {
+ for (const latex of ["e=\\placeholder{}^2", "x^{\\placeholder{}}", "\\sqrt{\\placeholder{}}"] ) {
+ expect(hasMathPlaceholders(latex)).toBe(true);
+ expect(renderMathPreview(latex, "inline")).toContain('class="katex"');
+ }
+});
+
+test("completed slots render normally and unknown commands still fail", () => {
+ expect(hasMathPlaceholders("e=x^2")).toBe(false);
+ expect(renderMathPreview("e=x^2", "inline")).toContain('class="katex"');
+ expect(renderMathPreview("\\notARealCommand{}", "inline")).toBe("");
+});
diff --git a/apps/web/src/components/editor/math-formula-preview.ts b/apps/web/src/components/editor/math-formula-preview.ts
new file mode 100644
index 00000000..1d95567e
--- /dev/null
+++ b/apps/web/src/components/editor/math-formula-preview.ts
@@ -0,0 +1,21 @@
+import katex from "katex";
+import type { MathFormulaKind } from "./math-formula";
+
+export const hasMathPlaceholders = (latex: string) => /\\placeholder\b/.test(latex);
+
+export const renderMathPreview = (latex: string, kind: MathFormulaKind) => {
+ if (!latex.trim()) return "";
+ try {
+ return katex.renderToString(latex.trim(), {
+ displayMode: kind === "block",
+ throwOnError: true,
+ strict: "warn",
+ trust: false,
+ // MathLive emits this command for slots still being edited. It is only
+ // supported in the preview; unfinished formulas must not be saved.
+ macros: { "\\placeholder": "\\boxed{\\phantom{x#1}}" },
+ });
+ } catch {
+ return "";
+ }
+};
diff --git a/apps/web/src/components/editor/math-visual-input.css b/apps/web/src/components/editor/math-visual-input.css
new file mode 100644
index 00000000..333659cd
--- /dev/null
+++ b/apps/web/src/components/editor/math-visual-input.css
@@ -0,0 +1,72 @@
+.edgeever-math-visual-input {
+ --keyboard-accent-color: rgb(var(--slate-900-rgb));
+ --keyboard-background: hsl(var(--card));
+ --keycap-height: 36px;
+ --keycap-font-size: 18px;
+ --keycap-gap: 2px;
+ --keyboard-padding-top: 0px;
+ --keyboard-padding-bottom: 0px;
+ --keyboard-toolbar-background: hsl(var(--card));
+ --keyboard-toolbar-text: rgb(var(--slate-600-rgb));
+ --keycap-background: rgb(var(--slate-100-rgb));
+ --keycap-background-hover: rgb(var(--slate-200-rgb));
+ --keycap-background-active: rgb(var(--slate-200-rgb));
+ --keycap-background-pressed: rgb(var(--slate-200-rgb));
+ --keycap-text: rgb(var(--slate-900-rgb));
+ --keycap-text-active: rgb(var(--slate-900-rgb));
+ --keycap-text-pressed: rgb(var(--slate-900-rgb));
+ --keycap-border: rgb(var(--slate-200-rgb));
+ --keycap-border-bottom: rgb(var(--slate-200-rgb));
+ --keycap-primary-background: rgb(var(--slate-100-rgb));
+ --keycap-primary-background-hover: rgb(var(--slate-200-rgb));
+ --keycap-primary-text: rgb(var(--slate-900-rgb));
+ --keycap-secondary-background: rgb(var(--slate-100-rgb));
+ --keycap-secondary-background-hover: rgb(var(--slate-200-rgb));
+ --keycap-secondary-text: rgb(var(--slate-900-rgb));
+ --keycap-secondary-border: rgb(var(--slate-200-rgb));
+ --keycap-secondary-border-bottom: rgb(var(--slate-200-rgb));
+ --box-placeholder-color: rgb(var(--slate-500-rgb));
+ --variant-panel-background: hsl(var(--card));
+ --variant-keycap-background-active: rgb(var(--slate-200-rgb));
+}
+
+/* MathLive's default modifier padding assumes taller keys. Keep icons centered
+ in our compact keys, including Shift and Backspace's edge-aligned defaults. */
+.edgeever-math-visual-input .ML__keyboard .MLK__rows .MLK__row > .action,
+.edgeever-math-visual-input .ML__keyboard .MLK__rows .MLK__row > .shift {
+ padding: 0;
+ align-items: center;
+ justify-content: center;
+}
+
+.edgeever-math-visual-input .ML__keyboard .MLK__rows .MLK__row > .action > svg,
+.edgeever-math-visual-input .ML__keyboard .MLK__rows .MLK__row > .shift > svg {
+ margin: 0;
+}
+
+.edgeever-math-visual-input math-field {
+ display: block;
+ width: 100%;
+ min-height: 56px;
+ border: 1px solid rgb(var(--slate-200-rgb));
+ border-radius: 6px;
+ padding: 8px 12px;
+ background: hsl(var(--card));
+ color: rgb(var(--slate-900-rgb));
+ font-size: 22px;
+ --caret-color: rgb(var(--slate-900-rgb));
+ --selection-background-color: rgb(var(--slate-200-rgb));
+}
+
+.edgeever-math-visual-input math-field:focus-within {
+ border-color: rgb(var(--slate-500-rgb));
+ outline: 2px solid rgb(var(--slate-900-rgb) / 10%);
+}
+
+.edgeever-math-visual-input math-field::part(virtual-keyboard-toggle) {
+ color: rgb(var(--slate-500-rgb));
+}
+
+.edgeever-math-visual-input math-field::part(menu-toggle) {
+ display: none;
+}
diff --git a/apps/web/src/components/editor/mathlive-loader.ts b/apps/web/src/components/editor/mathlive-loader.ts
new file mode 100644
index 00000000..adc9e9fe
--- /dev/null
+++ b/apps/web/src/components/editor/mathlive-loader.ts
@@ -0,0 +1,9 @@
+import { MathfieldElement } from "mathlive";
+import "mathlive/fonts.css";
+
+// Vite packages the fonts locally. Disable MathLive's automatic directory/CDN
+// loading and optional key sounds so the editor also works in desktop/offline use.
+MathfieldElement.fontsDirectory = null;
+MathfieldElement.soundsDirectory = null;
+
+export { MathfieldElement, convertLatexToMarkup } from "mathlive";
diff --git a/apps/web/src/components/editor/mathlive-placeholder.css b/apps/web/src/components/editor/mathlive-placeholder.css
new file mode 100644
index 00000000..db771c37
--- /dev/null
+++ b/apps/web/src/components/editor/mathlive-placeholder.css
@@ -0,0 +1,31 @@
+/* MathLive does not expose expression placeholders as a CSS part. This sheet
+ lives inside this math-field's shadow root and only styles empty input slots. */
+.edgeever-math-input-slot {
+ position: relative;
+ padding: 0;
+ outline: 1px dashed rgb(var(--slate-500-rgb));
+ border-radius: 3px;
+ background: rgb(var(--slate-50-rgb));
+ /* MathLive uses !important for selected glyph colors. Hide the square glyph
+ while preserving its layout and the editor's selection/hit testing. */
+ color: transparent !important;
+ opacity: 1;
+ cursor: text;
+}
+
+.edgeever-math-input-slot.ML__selected,
+.ML__placeholder-selected {
+ outline-color: rgb(var(--slate-700-rgb));
+ background: rgb(var(--slate-100-rgb));
+}
+
+.ML__focused .edgeever-math-input-slot.ML__selected::after,
+.ML__focused .ML__placeholder-selected::after {
+ content: "";
+ position: absolute;
+ top: 15%;
+ bottom: 15%;
+ left: 50%;
+ border-left: 1px solid rgb(var(--slate-900-rgb));
+ pointer-events: none;
+}
diff --git a/bun.lock b/bun.lock
index 2fbd23fd..b619bf69 100644
--- a/bun.lock
+++ b/bun.lock
@@ -250,6 +250,7 @@
"html-to-image": "^1.11.13",
"lowlight": "^3.3.0",
"lucide-react": "1.21.0",
+ "mathlive": "0.111.1",
"mediabunny": "^1.61.3",
"mermaid": "^11.16.0",
"motion": "^12.43.0",
@@ -3072,6 +3073,8 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+ "mathlive": ["mathlive@0.111.1", "", {}, "sha512-RVaC7NO02Lmx3ZNUxOHR2xCRpcvTfBb2BtdgV60cTanAMufaUkWcPPB2VxChcsNeL+0rTQKqOPDYxXLPOXJI+A=="],
+
"md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="],
"mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="],
diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts
index c88f491e..1c816d4a 100644
--- a/packages/shared/src/i18n/en-US.ts
+++ b/packages/shared/src/i18n/en-US.ts
@@ -2456,17 +2456,23 @@ export const enUS = {
mathFormulaDialog: {
title: "Insert formula",
editTitle: "Edit formula",
- description: "Write the formula in LaTeX. Inline math sits in a sentence; a display formula gets its own line.",
+ description: "Build formulas visually or edit LaTeX. Inline formulas stay in the sentence; display formulas occupy their own line.",
inline: "Inline",
block: "Display",
latexLabel: "LaTeX",
latexPlaceholder: "E = mc^2",
preview: "Preview",
- previewEmpty: "Preview appears after you enter LaTeX",
+ previewEmpty: "Enter a formula to preview it",
previewError: "Could not render this formula",
apply: "Apply",
remove: "Delete formula",
- errorEmpty: "Enter LaTeX.",
+ errorEmpty: "Please enter a formula.",
+ visualLabel: "Formula",
+ sourceMode: "LaTeX source",
+ visualMode: "Visual editor",
+ loading: "Loading formula editor…",
+ loadError: "The visual editor could not load. You can continue with LaTeX.",
+ incomplete: "Type in an empty slot; press Tab for the next one",
},
noteLinkPicker: {
title: "Link a note",
diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts
index 925a249a..bb2e900c 100644
--- a/packages/shared/src/i18n/ja.ts
+++ b/packages/shared/src/i18n/ja.ts
@@ -2456,17 +2456,23 @@ export const ja = {
mathFormulaDialog: {
title: "数式を挿入",
editTitle: "数式を編集",
- description: "LaTeX で数式を書きます。インラインは文中に入り、ブロックは独立した行になります。",
+ description: "記号キーボードで数式を作成するか、LaTeX を編集できます。インライン数式は文中に、ブロック数式は独立した行に表示されます。",
inline: "インライン",
block: "ブロック",
latexLabel: "LaTeX",
latexPlaceholder: "E = mc^2",
preview: "プレビュー",
- previewEmpty: "LaTeX を入力するとプレビューされます",
+ previewEmpty: "数式を入力するとプレビューされます",
previewError: "この数式を表示できません",
apply: "適用",
remove: "数式を削除",
- errorEmpty: "LaTeX を入力してください。",
+ errorEmpty: "数式を入力してください。",
+ visualLabel: "数式",
+ sourceMode: "LaTeX ソース",
+ visualMode: "ビジュアル編集",
+ loading: "数式エディターを読み込み中…",
+ loadError: "ビジュアルエディターを読み込めませんでした。LaTeX で編集できます。",
+ incomplete: "空欄に入力し、Tab キーで次の空欄へ移動",
},
noteLinkPicker: {
title: "ノートをリンク",
diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts
index 8949e3ec..fa813bc6 100644
--- a/packages/shared/src/i18n/pl.ts
+++ b/packages/shared/src/i18n/pl.ts
@@ -2456,17 +2456,23 @@ export const pl = {
mathFormulaDialog: {
title: "Wstaw wzór",
editTitle: "Edytuj wzór",
- description: "Zapisz wzór w LaTeX. Wzór w tekście jest częścią zdania, a wzór wyróżniony zajmuje osobny wiersz.",
+ description: "Twórz wzory za pomocą klawiatury symboli lub edytuj LaTeX. Wzory liniowe pozostają w zdaniu, a blokowe zajmują osobny wiersz.",
inline: "W tekście",
block: "Wyróżniony",
latexLabel: "LaTeX",
latexPlaceholder: "E = mc^2",
preview: "Podgląd",
- previewEmpty: "Podgląd pojawi się po wpisaniu kodu LaTeX",
+ previewEmpty: "Wpisz wzór, aby zobaczyć podgląd",
previewError: "Nie udało się wyrenderować tego wzoru",
apply: "Zastosuj",
remove: "Usuń wzór",
- errorEmpty: "Wpisz kod LaTeX.",
+ errorEmpty: "Wpisz wzór.",
+ visualLabel: "Wzór",
+ sourceMode: "Kod LaTeX",
+ visualMode: "Edytor wizualny",
+ loading: "Ładowanie edytora wzorów…",
+ loadError: "Nie udało się załadować edytora wizualnego. Możesz użyć LaTeX.",
+ incomplete: "Wpisz w pustym polu; Tab przechodzi do następnego",
},
noteLinkPicker: {
title: "Połącz z notatką",
diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts
index 20fd0145..6581593e 100644
--- a/packages/shared/src/i18n/zh-CN.ts
+++ b/packages/shared/src/i18n/zh-CN.ts
@@ -2454,17 +2454,23 @@ export const zhCN = {
mathFormulaDialog: {
title: "插入公式",
editTitle: "编辑公式",
- description: "用 LaTeX 编写公式。行内公式会出现在句子中,块级公式单独占一行。",
+ description: "使用符号键盘直观编写公式,也可编辑 LaTeX。行内公式出现在句子中,块级公式单独占一行。",
inline: "行内",
block: "块级",
latexLabel: "LaTeX",
latexPlaceholder: "E = mc^2",
preview: "预览",
- previewEmpty: "输入 LaTeX 后显示预览",
+ previewEmpty: "输入公式后显示预览",
previewError: "无法渲染该公式",
apply: "应用",
remove: "删除公式",
- errorEmpty: "请输入 LaTeX。",
+ errorEmpty: "请输入公式。",
+ visualLabel: "公式",
+ sourceMode: "LaTeX 源码",
+ visualMode: "可视化编辑",
+ loading: "正在加载公式编辑器…",
+ loadError: "可视化编辑器暂时无法加载,可继续使用 LaTeX。",
+ incomplete: "在空框中输入,按 Tab 切换到下一处",
},
noteLinkPicker: {
title: "引用笔记",