feat(editor): add visual formula editing with MathLive

Add an on-demand formula keyboard, editable placeholder styling, and LaTeX source switching. Expand MathLive macros for KaTeX compatibility and prevent applying incomplete formulas.

Refs #514
This commit is contained in:
tianma-if committed 2026-10-10 19:39:49 +08:00
1 parent e13691749f
commit d42fbec67e
14 files changed
+365 -54

No files matched your search

+1
View File
@@ -32,6 +32,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",
+10 -10
View File
@@ -401,6 +401,16 @@ export const EditorToolbar = ({
<MemoEditorToolbarDivider className="hidden sm:block" />
</>
)}
{showFormattingTools && onPickMathFormula && (
<EditorToolbarButton
title={t("editorToolbar.math")}
active={isActive("inlineMath") || isActive("blockMath")}
disabled={disabled}
onClick={onPickMathFormula}
>
<Sigma className="h-4 w-4" />
</EditorToolbarButton>
)}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
@@ -575,16 +585,6 @@ export const EditorToolbar = ({
>
<ChartNoAxesCombined className="h-4 w-4" />
</EditorToolbarButton>
{onPickMathFormula && (
<EditorToolbarButton
title={t("editorToolbar.math")}
active={isActive("inlineMath") || isActive("blockMath")}
disabled={disabled}
onClick={onPickMathFormula}
>
<Sigma className="h-4 w-4" />
</EditorToolbarButton>
)}
<EditorToolbarButton
title={t("editorToolbar.horizontalRule")}
disabled={disabled}
@@ -6,27 +6,13 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { MathFormulaDraft, MathFormulaKind } from "@/components/editor/math-formula";
const renderPreviewHtml = (latex: string, kind: MathFormulaKind) => {
const trimmed = latex.trim();
if (!trimmed) return "";
try {
return katex.renderToString(trimmed, {
displayMode: kind === "block",
throwOnError: false,
strict: "warn",
trust: false,
});
} catch {
return "";
}
};
import { MathVisualInput } from "@/components/editor/MathVisualInput";
import { hasMathPlaceholders, renderMathPreview } from "@/components/editor/math-formula-preview";
export const MathFormulaDialog = ({
open,
@@ -41,13 +27,14 @@ export const MathFormulaDialog = ({
onApply: (draft: MathFormulaDraft) => void;
onRemove?: () => void;
}) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const latexId = useId();
const previewId = useId();
const latexInputRef = useRef<HTMLTextAreaElement>(null);
const [kind, setKind] = useState<MathFormulaKind>(draft?.kind ?? "inline");
const [latex, setLatex] = useState(draft?.latex ?? "");
const [error, setError] = useState<string | null>(null);
const [inputMode, setInputMode] = useState<"visual" | "source">("visual");
const editing = typeof draft?.pos === "number";
useEffect(() => {
@@ -55,20 +42,28 @@ export const MathFormulaDialog = ({
setKind(draft?.kind ?? "inline");
setLatex(draft?.latex ?? "");
setError(null);
const timer = window.setTimeout(() => {
latexInputRef.current?.focus();
latexInputRef.current?.select();
}, 0);
return () => window.clearTimeout(timer);
setInputMode("visual");
}, [draft, open]);
const previewHtml = useMemo(() => renderPreviewHtml(latex, kind), [kind, latex]);
useEffect(() => {
if (open && inputMode === "source") latexInputRef.current?.focus();
}, [inputMode, open]);
const previewHtml = useMemo(() => renderMathPreview(latex, kind), [kind, latex]);
const incomplete = hasMathPlaceholders(latex);
const submit = () => {
if (incomplete) return;
if (!latex.trim()) {
setError(t("mathFormulaDialog.errorEmpty"));
return;
}
try {
katex.renderToString(latex.trim(), { throwOnError: true, trust: false, strict: "warn" });
} catch {
setError(t("mathFormulaDialog.previewError"));
return;
}
onApply({
kind,
latex,
@@ -81,15 +76,12 @@ export const MathFormulaDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
<DialogContent aria-describedby={undefined} className="max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-2xl gap-0 overflow-y-auto p-0">
<DialogHeader className="border-b border-slate-200 px-5 py-5 pr-12 text-left">
<DialogTitle className="flex items-center gap-2 text-sm leading-6">
<Sigma className="h-5 w-5 text-slate-700" />
{editing ? t("mathFormulaDialog.editTitle") : t("mathFormulaDialog.title")}
</DialogTitle>
<DialogDescription className="pt-1 leading-5">
{t("mathFormulaDialog.description")}
</DialogDescription>
</DialogHeader>
<form
@@ -107,7 +99,7 @@ export const MathFormulaDialog = ({
type="button"
className={
kind === value
? "flex-1 rounded-sm bg-workspace-selection px-3 py-1.5 text-xs font-semibold text-slate-950"
? "flex-1 rounded-sm bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-950"
: "flex-1 rounded-sm px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
}
aria-pressed={kind === value}
@@ -120,9 +112,29 @@ export const MathFormulaDialog = ({
)}
<div className="space-y-1.5">
<label htmlFor={latexId} className="text-xs font-medium text-slate-600">
{t("mathFormulaDialog.latexLabel")}
</label>
<div className="flex items-center justify-between gap-2">
<label htmlFor={latexId} className="text-xs font-medium text-slate-600">
{t(inputMode === "visual" ? "mathFormulaDialog.visualLabel" : "mathFormulaDialog.latexLabel")}
</label>
<Button type="button" variant="ghost" size="sm" onClick={() => {
setInputMode(inputMode === "visual" ? "source" : "visual");
setError(null);
}}>
{t(inputMode === "visual" ? "mathFormulaDialog.sourceMode" : "mathFormulaDialog.visualMode")}
</Button>
</div>
{inputMode === "visual" ? (open ? (
<MathVisualInput
id={latexId}
value={latex}
label={t("mathFormulaDialog.visualLabel")}
loadingLabel={t("mathFormulaDialog.loading")}
language={i18n.resolvedLanguage ?? i18n.language}
hint={incomplete ? t("mathFormulaDialog.incomplete") : undefined}
onChange={(value) => { setLatex(value); setError(null); }}
onLoadError={() => { setInputMode("source"); setError(t("mathFormulaDialog.loadError")); }}
/>
) : null) : (
<textarea
ref={latexInputRef}
id={latexId}
@@ -137,6 +149,8 @@ export const MathFormulaDialog = ({
if (error) setError(null);
}}
/>
)}
{inputMode === "source" && incomplete && <p className="text-xs leading-5 text-slate-500" role="status">{t("mathFormulaDialog.incomplete")}</p>}
</div>
<div className="space-y-1.5">
@@ -189,7 +203,7 @@ export const MathFormulaDialog = ({
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" variant="solid">
<Button type="submit" variant="solid" disabled={incomplete}>
{t("mathFormulaDialog.apply")}
</Button>
</div>
@@ -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<HTMLDivElement>(null);
const keyboardHostRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<MathfieldElement | null>(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<string, string> = {
"#@^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 (
<div className="edgeever-math-visual-input space-y-2">
{!ready && <p role="status" className="text-xs text-slate-500">{loadingLabel}</p>}
<div ref={hostRef} />
{hint && <p role="status" className="text-xs leading-5 text-slate-500">{hint}</p>}
<div ref={keyboardHostRef} className="relative w-full overflow-hidden rounded-md" />
</div>
);
};
@@ -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("");
});
@@ -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 "";
}
};
@@ -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;
}
@@ -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";
@@ -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;
}
+3
View File
@@ -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=="],
+9 -3
View File
@@ -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",
+9 -3
View File
@@ -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: "ノートをリンク",
+9 -3
View File
@@ -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ą",
+9 -3
View File
@@ -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: "引用笔记",